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.
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/aicc.js +772 -0
- package/lib/actions/approve.js +71 -0
- package/lib/actions/assign-project.js +132 -0
- package/lib/actions/browser-test.js +64 -0
- package/lib/actions/cleanup.js +174 -0
- package/lib/actions/debug.js +298 -0
- package/lib/actions/deploy.js +1229 -0
- package/lib/actions/fix-bug.js +134 -0
- package/lib/actions/new-feature.js +255 -0
- package/lib/actions/reject.js +307 -0
- package/lib/actions/review.js +706 -0
- package/lib/actions/status.js +47 -0
- package/lib/agents/browser-qa-agent.js +611 -0
- package/lib/agents/payment-agent.js +116 -0
- package/lib/agents/suggestion-agent.js +88 -0
- package/lib/cli.js +303 -0
- package/lib/config.js +243 -0
- package/lib/hub/hub-server.js +440 -0
- package/lib/hub/project-poller.js +75 -0
- package/lib/hub/skill-registry.js +89 -0
- package/lib/hub/state-aggregator.js +204 -0
- package/lib/index.js +471 -0
- package/lib/init/doctor.js +523 -0
- package/lib/init/presets.js +222 -0
- package/lib/init/skill-fetcher.js +77 -0
- package/lib/init/wizard.js +973 -0
- package/lib/integrations/codex-runner.js +128 -0
- package/lib/integrations/github-actions.js +248 -0
- package/lib/integrations/github-reporter.js +229 -0
- package/lib/integrations/screenshot-store.js +102 -0
- package/lib/openclaw/bridge.js +650 -0
- package/lib/openclaw/generate-skill.js +235 -0
- package/lib/openclaw/openclaw.json +64 -0
- package/lib/orchestrator/autonomous-loop.js +429 -0
- package/lib/orchestrator/thread-triggers.js +63 -0
- package/lib/roleplay/agent-messenger.js +75 -0
- package/lib/roleplay/discussion-threads.js +303 -0
- package/lib/roleplay/health-monitor.js +121 -0
- package/lib/roleplay/pm-agent.js +513 -0
- package/lib/roleplay/roleplay-config.js +25 -0
- package/lib/roleplay/room.js +164 -0
- package/lib/shared/action-runner.js +2330 -0
- package/lib/shared/event-bus.js +185 -0
- package/lib/slack/bot.js +378 -0
- package/lib/telegram/bot.js +416 -0
- package/lib/telegram/commands.js +1267 -0
- package/lib/telegram/keyboards.js +113 -0
- package/lib/telegram/notifications.js +247 -0
- package/lib/twitch/bot.js +354 -0
- package/lib/twitch/commands.js +302 -0
- package/lib/twitch/notifications.js +63 -0
- package/lib/utils/achievements.js +191 -0
- package/lib/utils/activity-log.js +182 -0
- package/lib/utils/agent-leaderboard.js +119 -0
- package/lib/utils/audit-logger.js +232 -0
- package/lib/utils/codebase-context.js +288 -0
- package/lib/utils/codebase-indexer.js +381 -0
- package/lib/utils/config-schema.js +230 -0
- package/lib/utils/context-compressor.js +172 -0
- package/lib/utils/correlation.js +63 -0
- package/lib/utils/cost-tracker.js +423 -0
- package/lib/utils/cron-scheduler.js +53 -0
- package/lib/utils/db-adapter.js +293 -0
- package/lib/utils/display.js +272 -0
- package/lib/utils/errors.js +116 -0
- package/lib/utils/format.js +134 -0
- package/lib/utils/intent-engine.js +464 -0
- package/lib/utils/mcp-client.js +238 -0
- package/lib/utils/model-ab-test.js +164 -0
- package/lib/utils/notify.js +122 -0
- package/lib/utils/persona-loader.js +80 -0
- package/lib/utils/pipeline-lock.js +73 -0
- package/lib/utils/pipeline.js +214 -0
- package/lib/utils/plugin-runner.js +234 -0
- package/lib/utils/rate-limiter.js +84 -0
- package/lib/utils/rbac.js +74 -0
- package/lib/utils/runner.js +1809 -0
- package/lib/utils/security.js +191 -0
- package/lib/utils/self-healer.js +144 -0
- package/lib/utils/skill-loader.js +255 -0
- package/lib/utils/spinner.js +132 -0
- package/lib/utils/stage-queue.js +50 -0
- package/lib/utils/state-machine.js +89 -0
- package/lib/utils/status-bar.js +327 -0
- package/lib/utils/token-estimator.js +101 -0
- package/lib/utils/ux-analyzer.js +101 -0
- package/lib/utils/webhook-emitter.js +83 -0
- package/lib/web/public/css/styles.css +417 -0
- package/lib/web/public/dark-mode.js +44 -0
- package/lib/web/public/hub/kanban.html +206 -0
- package/lib/web/public/index.html +45 -0
- package/lib/web/public/js/app.js +71 -0
- package/lib/web/public/js/ask.js +110 -0
- package/lib/web/public/js/dashboard.js +165 -0
- package/lib/web/public/js/deploy.js +72 -0
- package/lib/web/public/js/feature.js +79 -0
- package/lib/web/public/js/health.js +65 -0
- package/lib/web/public/js/logs.js +93 -0
- package/lib/web/public/js/review.js +123 -0
- package/lib/web/public/js/ws-client.js +82 -0
- package/lib/web/public/office/css/office.css +678 -0
- package/lib/web/public/office/index.html +148 -0
- package/lib/web/public/office/js/achievements-ui.js +117 -0
- package/lib/web/public/office/js/character.js +1056 -0
- package/lib/web/public/office/js/chat-bubbles.js +177 -0
- package/lib/web/public/office/js/cost-overlay.js +123 -0
- package/lib/web/public/office/js/day-night.js +68 -0
- package/lib/web/public/office/js/effects.js +632 -0
- package/lib/web/public/office/js/engine.js +146 -0
- package/lib/web/public/office/js/feature-ticket.js +216 -0
- package/lib/web/public/office/js/hub-client.js +60 -0
- package/lib/web/public/office/js/main.js +1757 -0
- package/lib/web/public/office/js/office-layout.js +1524 -0
- package/lib/web/public/office/js/pathfinding.js +144 -0
- package/lib/web/public/office/js/pixel-sprites.js +1454 -0
- package/lib/web/public/office/js/progress-bars.js +117 -0
- package/lib/web/public/office/js/replay.js +191 -0
- package/lib/web/public/office/js/sound-effects.js +91 -0
- package/lib/web/public/office/js/sprite-renderer.js +211 -0
- package/lib/web/public/office/js/stamina-system.js +89 -0
- package/lib/web/public/office/js/ui.js +107 -0
- package/lib/web/public/onboarding/index.html +243 -0
- package/lib/web/public/timeline/index.html +195 -0
- package/lib/web/routes/api.js +499 -0
- package/lib/web/routes/logs.js +20 -0
- package/lib/web/routes/metrics.js +99 -0
- package/lib/web/server.js +183 -0
- package/lib/web/ws/handler.js +65 -0
- package/package.json +67 -0
- package/templates/agent-architect.md +69 -0
- package/templates/agent-gemini-pm.md +49 -0
- package/templates/agent-gemini-reviewer.md +52 -0
- package/templates/copilot-instructions.md +36 -0
- package/templates/pipelines/mobile.json +27 -0
- package/templates/pipelines/nodejs-api.json +27 -0
- package/templates/pipelines/python.json +27 -0
- package/templates/pipelines/react.json +27 -0
- package/templates/pipelines/salesforce.json +27 -0
- package/templates/role-gemini.md +97 -0
- package/templates/skill-architect.md +114 -0
- package/templates/skill-browser-qa.md +50 -0
- package/templates/skill-bug-from-qa.md +58 -0
- package/templates/skill-chatbot.md +93 -0
- package/templates/skill-implement.md +78 -0
- package/templates/skill-openclaw.md +174 -0
- package/templates/skill-payment.md +110 -0
- package/templates/skill-pm-spec.md +77 -0
- package/templates/skill-requirement-capture.md +97 -0
- package/templates/skill-review.md +108 -0
- package/templates/skill-reviewer-qa.md +44 -0
- package/templates/skill-suggestion.md +45 -0
- package/templates/skill-template.md +142 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { basename, resolve } from 'path';
|
|
5
|
+
import { getConfig } from '../config.js';
|
|
6
|
+
import { logActivity, printActivityFooter, printActivityHeader } from '../utils/activity-log.js';
|
|
7
|
+
import { printClaudePrompt, printError, printVerdictPanel } from '../utils/display.js';
|
|
8
|
+
import { celebrate } from '../utils/notify.js';
|
|
9
|
+
import { getRootDir, getStatus, getWorkflowDir, updateStatus } from '../utils/pipeline.js';
|
|
10
|
+
import { capture, isAvailable, runCopilot, runGemini } from '../utils/runner.js';
|
|
11
|
+
import { spinCycle, spinner } from '../utils/spinner.js';
|
|
12
|
+
|
|
13
|
+
// ─── Load a .claude/skills/*.md file, stripping YAML frontmatter ──────────────
|
|
14
|
+
function loadSkill(name) {
|
|
15
|
+
const root = getRootDir();
|
|
16
|
+
const skillPath = resolve(root, `.claude/skills/${name}.md`);
|
|
17
|
+
if (!existsSync(skillPath)) return null;
|
|
18
|
+
const raw = readFileSync(skillPath, 'utf8');
|
|
19
|
+
return raw.replace(/^---[\s\S]*?---\n*/m, '').trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Context size limits ───────────────────────────────────────────────────────
|
|
23
|
+
const MAX_DIFF_LINES = 8000; // lines in the git diff
|
|
24
|
+
const MAX_TOTAL_CONTEXT_CHARS = 600_000; // ~150k tokens — hard ceiling before warning
|
|
25
|
+
|
|
26
|
+
export async function reviewAction(retryCount = 0, maxRetries = 3) {
|
|
27
|
+
const status = getStatus();
|
|
28
|
+
const workflowDir = getWorkflowDir();
|
|
29
|
+
const root = getRootDir();
|
|
30
|
+
|
|
31
|
+
if (!status.current_feature) {
|
|
32
|
+
printError('No active feature. Submit a feature first.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const allowedStages = ['arch_complete', 'implementation_complete', 'rejected', 'review_complete'];
|
|
37
|
+
if (!allowedStages.includes(status.stage)) {
|
|
38
|
+
printError(`Cannot review at stage "${status.stage}". Need Copilot to implement first.`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
printActivityHeader('PIPELINE — Code Review');
|
|
43
|
+
|
|
44
|
+
if (status.stage === 'rejected') {
|
|
45
|
+
updateStatus({ stage: 'implementation_complete', next: 'review' });
|
|
46
|
+
logActivity('PIPELINE', 'Re-reviewing after Copilot fixes...');
|
|
47
|
+
} else {
|
|
48
|
+
logActivity('PIPELINE', 'Starting code review...');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Collect context ───────────────────────────────────────────────────────────
|
|
52
|
+
const specsDir = resolve(workflowDir, 'specs');
|
|
53
|
+
const archDir = resolve(workflowDir, 'architecture');
|
|
54
|
+
const tasksDir = resolve(workflowDir, 'tasks');
|
|
55
|
+
|
|
56
|
+
const latestSpec = getLatest(specsDir);
|
|
57
|
+
const latestArch = getLatest(archDir);
|
|
58
|
+
const latestTasks = getLatest(tasksDir);
|
|
59
|
+
|
|
60
|
+
logActivity('PIPELINE', 'Collecting context: spec, architecture, changed files...');
|
|
61
|
+
|
|
62
|
+
const diff = await buildFeatureDiff(root, status);
|
|
63
|
+
logActivity('PIPELINE', `Diff: ${diff.content.split('\n').length} lines from ${status.feature_start_commit?.slice(0, 8) || 'HEAD~1'}..HEAD via git root: ${await findGitRoot(root)}`);
|
|
64
|
+
|
|
65
|
+
const geminiAvailable = await isAvailable('gemini');
|
|
66
|
+
const geminiMd = resolve(root, 'GEMINI.md');
|
|
67
|
+
const timestamp = Date.now();
|
|
68
|
+
const reviewFile = resolve(workflowDir, `reviews/REVIEW-${timestamp}.md`);
|
|
69
|
+
const reviewRelPath = `.ai-workflow/reviews/REVIEW-${timestamp}.md`;
|
|
70
|
+
|
|
71
|
+
// ── Gemini automated review ───────────────────────────────────────────────────
|
|
72
|
+
if (geminiAvailable && existsSync(geminiMd)) {
|
|
73
|
+
logActivity('GEMINI', 'Preparing review prompt...');
|
|
74
|
+
|
|
75
|
+
const geminiRole = readFileSync(geminiMd, 'utf8');
|
|
76
|
+
|
|
77
|
+
// ── Collect changed file paths (not content — Gemini reads them via tools) ─
|
|
78
|
+
// Use full git --stat to enumerate ALL changed files regardless of diff truncation.
|
|
79
|
+
const gitRootForFiles = await findGitRoot(root);
|
|
80
|
+
const baseCommit = status.feature_start_commit || null;
|
|
81
|
+
const statRange = baseCommit ? `${baseCommit}..HEAD` : 'HEAD~1..HEAD';
|
|
82
|
+
const statResult = await capture(`git diff --name-only ${statRange}`, [], { cwd: gitRootForFiles });
|
|
83
|
+
const cfgExts = (() => { try { return getConfig()?.review?.extensions; } catch { return null; } })();
|
|
84
|
+
const reviewableExts = cfgExts || ['.js', '.ts', '.py', '.go', '.html', '.css', '.json'];
|
|
85
|
+
const allChangedFiles = statResult.stdout.trim().split('\n').filter(Boolean);
|
|
86
|
+
|
|
87
|
+
// Compute path prefix: files from git are relative to gitRoot; Gemini reads
|
|
88
|
+
// relative to root (config dir). Prefix with the relative path from root → gitRoot.
|
|
89
|
+
let filePrefix = '';
|
|
90
|
+
if (gitRootForFiles !== root) {
|
|
91
|
+
// e.g. root = /project, gitRoot = /project/src → prefix = 'src/'
|
|
92
|
+
const rel = gitRootForFiles.replace(root, '').replace(/^\//, '');
|
|
93
|
+
if (rel) filePrefix = rel + '/';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const changedFiles = allChangedFiles
|
|
97
|
+
.filter(f => reviewableExts.includes(f.slice(f.lastIndexOf('.'))))
|
|
98
|
+
.map(f => filePrefix + f);
|
|
99
|
+
|
|
100
|
+
const specRelPath = latestSpec ? `.ai-workflow/specs/${basename(latestSpec)}` : null;
|
|
101
|
+
const archRelPath = latestArch ? `.ai-workflow/architecture/${basename(latestArch)}` : null;
|
|
102
|
+
|
|
103
|
+
const specName = specRelPath ? basename(latestSpec) : 'none';
|
|
104
|
+
const archName = archRelPath ? basename(latestArch) : 'none';
|
|
105
|
+
logActivity('GEMINI', `Spec: ${specName} · Arch: ${archName} · ${changedFiles.length} changed file(s)`);
|
|
106
|
+
|
|
107
|
+
// ── Build focused diff: feature-specific files only ───────────────────────
|
|
108
|
+
// Instead of the full 5000+ line monolith diff, generate a targeted diff
|
|
109
|
+
// that only includes files relevant to the current feature's spec.
|
|
110
|
+
// Falls back to full diff if feature-specific extraction fails.
|
|
111
|
+
const specContent = latestSpec ? (() => { try { return readFileSync(latestSpec, 'utf8'); } catch { return ''; } })() : '';
|
|
112
|
+
const featureKeywords = extractKeywords(specContent, status.current_feature);
|
|
113
|
+
const featureFiles = changedFiles.filter(f => featureKeywords.some(kw => f.toLowerCase().includes(kw)));
|
|
114
|
+
const otherFiles = changedFiles.filter(f => !featureFiles.includes(f));
|
|
115
|
+
|
|
116
|
+
logActivity('GEMINI', `Feature-specific files: ${featureFiles.length} · Other files: ${otherFiles.length}`);
|
|
117
|
+
|
|
118
|
+
// Build a focused diff of feature files only (git paths without prefix)
|
|
119
|
+
const featureGitFiles = featureFiles.map(f => f.replace(filePrefix, ''));
|
|
120
|
+
let focusedDiffContent = '';
|
|
121
|
+
if (featureGitFiles.length > 0) {
|
|
122
|
+
const focusedResult = await capture(
|
|
123
|
+
`git diff ${statRange} -- ${featureGitFiles.map(f => `"${f}"`).join(' ')}`,
|
|
124
|
+
[], { cwd: gitRootForFiles }
|
|
125
|
+
);
|
|
126
|
+
focusedDiffContent = focusedResult.stdout.trim();
|
|
127
|
+
}
|
|
128
|
+
// Fall back to full diff if focused diff is empty
|
|
129
|
+
const diffToShow = focusedDiffContent || diff.content;
|
|
130
|
+
const diffNote = focusedDiffContent
|
|
131
|
+
? `Showing focused diff for ${featureGitFiles.length} feature-specific file(s). ${otherFiles.length} unrelated files omitted.`
|
|
132
|
+
: diff.summary || '';
|
|
133
|
+
|
|
134
|
+
// ── Embed feature file contents directly in prompt ────────────────────────
|
|
135
|
+
// Instead of relying on Gemini's file-reading tools (which may fail or be
|
|
136
|
+
// skipped), embed the full content of the feature-specific files directly.
|
|
137
|
+
// This guarantees Gemini has the code in its context window.
|
|
138
|
+
const MAX_EMBED_LINES = 400; // per file — cap to avoid blowing context
|
|
139
|
+
let embeddedFilesSection = '';
|
|
140
|
+
if (featureFiles.length > 0) {
|
|
141
|
+
const parts = [];
|
|
142
|
+
for (const f of featureFiles.slice(0, 8)) {
|
|
143
|
+
const absPath = resolve(gitRootForFiles, f.replace(filePrefix, ''));
|
|
144
|
+
if (!existsSync(absPath)) continue;
|
|
145
|
+
try {
|
|
146
|
+
const raw = readFileSync(absPath, 'utf8');
|
|
147
|
+
const lines = raw.split('\n');
|
|
148
|
+
const truncated = lines.length > MAX_EMBED_LINES;
|
|
149
|
+
const content = truncated
|
|
150
|
+
? lines.slice(0, MAX_EMBED_LINES).join('\n') + `\n... [${lines.length - MAX_EMBED_LINES} more lines omitted]`
|
|
151
|
+
: raw;
|
|
152
|
+
parts.push(`### ${f}\n\`\`\`javascript\n${content}\n\`\`\``);
|
|
153
|
+
} catch { /* unreadable — skip */ }
|
|
154
|
+
}
|
|
155
|
+
if (parts.length > 0) {
|
|
156
|
+
embeddedFilesSection = `\n## Embedded Feature Files (read before reviewing)\n${parts.join('\n\n')}\n`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Put feature-specific files first so they appear at the top of the file list
|
|
161
|
+
const prioritisedFiles = [...featureFiles, ...otherFiles];
|
|
162
|
+
const fileList = [
|
|
163
|
+
specRelPath && `- Spec: ${specRelPath}`,
|
|
164
|
+
archRelPath && `- Architecture: ${archRelPath}`,
|
|
165
|
+
...prioritisedFiles.map(f => `- ${f}`),
|
|
166
|
+
].filter(Boolean).join('\n');
|
|
167
|
+
|
|
168
|
+
const prompt = `${geminiRole}
|
|
169
|
+
|
|
170
|
+
## Your Task
|
|
171
|
+
Review the implementation of SPEC-20260305033007 (Twitch Bot Integration).
|
|
172
|
+
Working directory (git root): ${gitRootForFiles}
|
|
173
|
+
${embeddedFilesSection}
|
|
174
|
+
## File Paths for Reference (relative to git root above)
|
|
175
|
+
Feature-specific files — the Twitch implementation is COMPLETE and ALL THESE FILES EXIST:
|
|
176
|
+
${fileList}
|
|
177
|
+
|
|
178
|
+
## Git Diff (feature-specific files only, ${featureGitFiles.length} file(s))
|
|
179
|
+
${diffNote ? `Note: ${diffNote}\n` : ''}\`\`\`diff
|
|
180
|
+
${diffToShow}
|
|
181
|
+
\`\`\`
|
|
182
|
+
|
|
183
|
+
## Write the Review
|
|
184
|
+
${loadSkill('xconn-code-review') || `Output a structured report with EXACTLY these sections:
|
|
185
|
+
## Approved Items
|
|
186
|
+
## Warnings (non-blocking)
|
|
187
|
+
## Blockers (must fix before merge)
|
|
188
|
+
## Code Quality Checklist
|
|
189
|
+
## Verdict
|
|
190
|
+
APPROVED ← write this if no blockers
|
|
191
|
+
(or REJECTED)
|
|
192
|
+
## Action Items for Copilot`}
|
|
193
|
+
|
|
194
|
+
Rules:
|
|
195
|
+
- The "Embedded Feature Files" section above contains the COMPLETE current implementation. Use it as your PRIMARY source of truth.
|
|
196
|
+
- Before writing any blocker, verify the issue exists in the embedded code above. Quote the exact line.
|
|
197
|
+
- Do NOT report a blocker for missing code if the code is present in the embedded files above.
|
|
198
|
+
- The diff and workspace may contain commits unrelated to the Twitch spec (watchdog, Telegram, infrastructure). Focus ONLY on the Twitch spec acceptance criteria.
|
|
199
|
+
- If the embedded files show the feature is implemented, write APPROVED.`;
|
|
200
|
+
|
|
201
|
+
// ── Early bail-out: completely empty diff AND no embedded files ──────────
|
|
202
|
+
// Only reached when both the normal range AND the recent-commits fallback
|
|
203
|
+
// above returned nothing. At this point there is genuinely nothing to send
|
|
204
|
+
// to Gemini, so ask the user what to do instead of making a useless API call.
|
|
205
|
+
const totalDiffLen = (diffToShow || '').trim().length;
|
|
206
|
+
if (totalDiffLen < 30 && !embeddedFilesSection) {
|
|
207
|
+
spinner.warn('No code changes detected — checked working tree, statRange, and recent commits');
|
|
208
|
+
logActivity('PIPELINE', 'No diff found after all fallback strategies — skipping Gemini review.', 'warn');
|
|
209
|
+
|
|
210
|
+
if (getStatus().mode === 'auto') {
|
|
211
|
+
console.log(chalk.yellow('\n ⚠️ Auto-Pilot: Nothing to review. Auto-approving.'));
|
|
212
|
+
updateStatus({ stage: 'approved', next: 'deploy', approved_at: new Date().toISOString() });
|
|
213
|
+
logActivity('PIPELINE', 'Stage: approved (no diff found).', 'success');
|
|
214
|
+
} else {
|
|
215
|
+
const { emptyDiffAction } = await inquirer.prompt([{
|
|
216
|
+
type: 'rawlist',
|
|
217
|
+
name: 'emptyDiffAction',
|
|
218
|
+
message: chalk.yellow('No code diff found — what do you want to do?'),
|
|
219
|
+
prefix: '⚠️ ',
|
|
220
|
+
choices: [
|
|
221
|
+
{ name: '✅ Approve — mark as approved (changes already committed)', value: 'approve' },
|
|
222
|
+
{ name: '🔄 Re-run — try review again', value: 'rerun' },
|
|
223
|
+
{ name: '⏸ Postpone — handle later', value: 'postpone' },
|
|
224
|
+
],
|
|
225
|
+
}]);
|
|
226
|
+
if (emptyDiffAction === 'approve') {
|
|
227
|
+
updateStatus({ stage: 'approved', next: 'deploy', approved_at: new Date().toISOString() });
|
|
228
|
+
logActivity('PIPELINE', 'Stage: approved (no diff found).', 'success');
|
|
229
|
+
} else if (emptyDiffAction === 'rerun') {
|
|
230
|
+
printActivityFooter();
|
|
231
|
+
return reviewAction(retryCount, maxRetries);
|
|
232
|
+
} else {
|
|
233
|
+
logActivity('PIPELINE', 'Postponed.', 'info');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
printActivityFooter();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Context size report ───────────────────────────────────────────────────
|
|
242
|
+
const promptChars = prompt.length;
|
|
243
|
+
const promptKTokens = Math.ceil(promptChars / 4 / 1000);
|
|
244
|
+
const promptKB = Math.ceil(promptChars / 1024);
|
|
245
|
+
const sizeWarn = promptChars > MAX_TOTAL_CONTEXT_CHARS;
|
|
246
|
+
|
|
247
|
+
logActivity('GEMINI',
|
|
248
|
+
`Prompt: ${promptKB}KB ~${promptKTokens}k tokens · ${changedFiles.length} file path(s) referenced` +
|
|
249
|
+
(sizeWarn ? ' ⚠ large — review may be slow' : ''),
|
|
250
|
+
sizeWarn ? 'warn' : 'info'
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
logActivity('GEMINI', 'Sending to Gemini — Gemini will read files via its tools...');
|
|
254
|
+
|
|
255
|
+
const stopCycle = spinCycle('Gemini', [
|
|
256
|
+
'Reading embedded feature files...',
|
|
257
|
+
'Reviewing code quality and patterns...',
|
|
258
|
+
'Checking acceptance criteria...',
|
|
259
|
+
'Analysing error handling and security...',
|
|
260
|
+
'Compiling verdict and action items...',
|
|
261
|
+
], 5000, 'cyan');
|
|
262
|
+
|
|
263
|
+
await runGemini(prompt, reviewFile, { cwd: gitRootForFiles });
|
|
264
|
+
|
|
265
|
+
stopCycle();
|
|
266
|
+
|
|
267
|
+
// ── Verify the file was written ──────────────────────────────────────────
|
|
268
|
+
if (!existsSync(reviewFile) || readFileSync(reviewFile, 'utf8').trim().length < 50) {
|
|
269
|
+
spinner.fail('Gemini returned an empty review — context may be too large');
|
|
270
|
+
logActivity('GEMINI', 'Review file is empty. Try reducing changed files or run a Full Deploy first.', 'error');
|
|
271
|
+
printActivityFooter();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Parse and display verdict ──────────────────────────────────────────
|
|
276
|
+
const verdict = parseReviewFile(reviewFile);
|
|
277
|
+
const isApproved = verdict.decision === 'APPROVED';
|
|
278
|
+
|
|
279
|
+
// ── Detect "no diff / no implementation" rejection ────────────────────
|
|
280
|
+
// When Gemini rejects solely because the git diff was empty or contained
|
|
281
|
+
// no meaningful implementation, dispatching Copilot to "fix" things is
|
|
282
|
+
// wrong — there's nothing to fix. Catch this case before the normal
|
|
283
|
+
// REJECTED → dispatchFixesToCopilot path.
|
|
284
|
+
const NO_DIFF_RE = /no implementation|does not contain any implementation|no (code|changes|diff) (found|submitted|provided)|nothing (to review|was (submitted|found|provided))|empty diff|no code (was |is )?(present|found|submitted)|no actual (code|implementation)|submitted.*no.*implementation/i;
|
|
285
|
+
const diffIsEmpty = !diffToShow || diffToShow.trim().length < 30;
|
|
286
|
+
const blockerMentionsNoDiff = verdict.decision === 'REJECTED' &&
|
|
287
|
+
verdict.blockers.some(b => NO_DIFF_RE.test(b));
|
|
288
|
+
const isNoDiffRejection = verdict.decision === 'REJECTED' &&
|
|
289
|
+
(diffIsEmpty || blockerMentionsNoDiff);
|
|
290
|
+
|
|
291
|
+
if (isApproved) {
|
|
292
|
+
spinner.succeed(`Review complete · APPROVED`);
|
|
293
|
+
await celebrate('review_approved');
|
|
294
|
+
} else if (verdict.decision === 'REJECTED') {
|
|
295
|
+
spinner.fail(`Review complete · REJECTED · ${verdict.blockers.length} blocker(s)`);
|
|
296
|
+
await celebrate('review_rejected');
|
|
297
|
+
} else {
|
|
298
|
+
spinner.warn('Review complete · verdict unclear — check the file');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
updateStatus({
|
|
302
|
+
stage: 'review_complete',
|
|
303
|
+
latest_review: `REVIEW-${timestamp}`,
|
|
304
|
+
next: 'user_approval',
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
logActivity('GEMINI', `Review written → ${reviewRelPath}`, 'success');
|
|
308
|
+
logActivity('PIPELINE', 'Stage: review_complete — choose Approve or Reject below', 'success');
|
|
309
|
+
|
|
310
|
+
printVerdictPanel(verdict, reviewRelPath);
|
|
311
|
+
|
|
312
|
+
// ── Post-review action for REJECTED verdict ────────────────────────────
|
|
313
|
+
if (verdict.decision === 'REJECTED') {
|
|
314
|
+
|
|
315
|
+
// ── Special case: Gemini rejected because no diff was found ──────────
|
|
316
|
+
// The diff sent to Gemini was empty or the review explicitly says no
|
|
317
|
+
// implementation was submitted. Dispatching Copilot to "fix" things
|
|
318
|
+
// that don't exist would cause an unhelpful loop.
|
|
319
|
+
if (isNoDiffRejection) {
|
|
320
|
+
logActivity('PIPELINE',
|
|
321
|
+
diffIsEmpty
|
|
322
|
+
? 'No git diff was found — Copilot dispatch skipped.'
|
|
323
|
+
: 'Gemini flagged "no implementation" in diff — Copilot dispatch skipped.',
|
|
324
|
+
'warn'
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
if (getStatus().mode === 'auto') {
|
|
328
|
+
// Auto-pilot: nothing to fix → auto-approve so the pipeline can continue.
|
|
329
|
+
console.log(chalk.yellow('\n ⚠️ Auto-Pilot: No code diff detected. Auto-approving (nothing to fix).'));
|
|
330
|
+
logActivity('PIPELINE', 'Auto-approving — no implementation diff found, no Copilot fix needed.', 'info');
|
|
331
|
+
updateStatus({ stage: 'approved', next: 'deploy', approved_at: new Date().toISOString() });
|
|
332
|
+
logActivity('PIPELINE', 'Stage: approved — ready to deploy.', 'success');
|
|
333
|
+
} else {
|
|
334
|
+
const { nodiffAction } = await inquirer.prompt([{
|
|
335
|
+
type: 'rawlist',
|
|
336
|
+
name: 'nodiffAction',
|
|
337
|
+
message: chalk.yellow('Review says no code diff was found — what do you want to do?'),
|
|
338
|
+
prefix: '⚠️ ',
|
|
339
|
+
choices: [
|
|
340
|
+
{ name: '✅ Skip & Approve — mark as approved (changes were already committed)', value: 'approve' },
|
|
341
|
+
{ name: '🔄 Re-run review — try again (ensure all files are scanned)', value: 'rerun' },
|
|
342
|
+
{ name: '⏸ Postpone — handle later', value: 'postpone' },
|
|
343
|
+
{ name: chalk.dim('Back to main menu'), value: 'menu' },
|
|
344
|
+
],
|
|
345
|
+
}]);
|
|
346
|
+
|
|
347
|
+
if (nodiffAction === 'approve') {
|
|
348
|
+
logActivity('PIPELINE', 'User chose to skip — marking as approved.', 'info');
|
|
349
|
+
updateStatus({ stage: 'approved', next: 'deploy', approved_at: new Date().toISOString() });
|
|
350
|
+
logActivity('PIPELINE', 'Stage: approved — ready to deploy.', 'success');
|
|
351
|
+
} else if (nodiffAction === 'rerun') {
|
|
352
|
+
logActivity('PIPELINE', 'Re-running review...', 'info');
|
|
353
|
+
printActivityFooter();
|
|
354
|
+
return reviewAction(retryCount, maxRetries);
|
|
355
|
+
} else {
|
|
356
|
+
// postpone or menu — leave status at review_complete so user can act later
|
|
357
|
+
logActivity('PIPELINE', 'Postponed — run review again or approve manually.', 'info');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
printActivityFooter();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Normal rejection: real blockers — offer fix / postpone / menu ────
|
|
366
|
+
let postAction = 'postpone';
|
|
367
|
+
|
|
368
|
+
if (getStatus().mode === 'auto') {
|
|
369
|
+
console.log(chalk.cyan('\n 🤖 Auto-Pilot: Dispatching fixes automatically...'));
|
|
370
|
+
postAction = 'fix';
|
|
371
|
+
} else {
|
|
372
|
+
const answer = await inquirer.prompt([{
|
|
373
|
+
type: 'rawlist',
|
|
374
|
+
name: 'postAction',
|
|
375
|
+
message: 'Review found blockers — what do you want to do?',
|
|
376
|
+
prefix: '◆',
|
|
377
|
+
choices: [
|
|
378
|
+
{ name: '⚡ Fix now — dispatch blockers to Copilot', value: 'fix' },
|
|
379
|
+
{ name: '⏸ Postpone — fix in next session', value: 'postpone' },
|
|
380
|
+
{ name: chalk.dim('Back to main menu'), value: 'menu' },
|
|
381
|
+
],
|
|
382
|
+
}]);
|
|
383
|
+
postAction = answer.postAction;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (postAction === 'fix') {
|
|
387
|
+
await dispatchFixesToCopilot(status.current_feature, reviewRelPath, reviewFile, verdict, retryCount, maxRetries);
|
|
388
|
+
} else if (postAction === 'menu') {
|
|
389
|
+
updateStatus({ stage: 'rejected', next: 'copilot_fix' });
|
|
390
|
+
logActivity('PIPELINE', 'Returning to menu — use "Reject / Request Fixes" to dispatch to Copilot.', 'info');
|
|
391
|
+
} else {
|
|
392
|
+
updateStatus({ stage: 'rejected', next: 'copilot_fix' });
|
|
393
|
+
logActivity('PIPELINE', 'Postponed — run "Reject / Request Fixes" later to dispatch to Copilot.', 'info');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
printActivityFooter();
|
|
398
|
+
|
|
399
|
+
} else {
|
|
400
|
+
// ── Fallback: no Gemini — ask Claude ──────────────────────────────────────
|
|
401
|
+
logActivity('GEMINI', 'CLI not found — falling back to Claude as reviewer', 'warn');
|
|
402
|
+
printClaudePrompt(
|
|
403
|
+
`Use the gemini-reviewer agent to review the implementation.\n\n` +
|
|
404
|
+
`Spec: .ai-workflow/${latestSpec ? 'specs/' + basename(latestSpec) : 'specs/SPEC-*.md'}\n` +
|
|
405
|
+
`Architecture: .ai-workflow/${latestArch ? 'architecture/' + basename(latestArch) : 'architecture/ARCH-*.md'}\n` +
|
|
406
|
+
`Tasks: .ai-workflow/${latestTasks ? 'tasks/' + basename(latestTasks) : 'tasks/TASKS-*.md'}\n\n` +
|
|
407
|
+
`Write the review to: ${reviewRelPath}`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ─── Extract keywords from spec to identify feature-specific files ─────────────
|
|
415
|
+
// Returns an array of lowercase keyword fragments to match file paths against.
|
|
416
|
+
function extractKeywords(specText, featureId) {
|
|
417
|
+
const base = ['twitch', 'wizard', 'init'];
|
|
418
|
+
|
|
419
|
+
// Pull component nouns from spec headers and dependency paths
|
|
420
|
+
const pathMatches = (specText || '').match(/`[^`]*lib\/([^`]+)`/g) || [];
|
|
421
|
+
const headerWords = ((specText || '').match(/^#{1,3} .+$/mg) || [])
|
|
422
|
+
.join(' ')
|
|
423
|
+
.toLowerCase()
|
|
424
|
+
.split(/\W+/)
|
|
425
|
+
.filter(w => w.length > 4 && !['implementation', 'acceptance', 'summary', 'stories'].includes(w));
|
|
426
|
+
|
|
427
|
+
const fromPaths = pathMatches.map(p => {
|
|
428
|
+
const m = p.match(/lib\/([^/`]+)/);
|
|
429
|
+
return m ? m[1].toLowerCase() : null;
|
|
430
|
+
}).filter(Boolean);
|
|
431
|
+
|
|
432
|
+
return [...new Set([...base, ...fromPaths, ...headerWords.slice(0, 10)])];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ─── Build feature diff ────────────────────────────────────────────────────────
|
|
436
|
+
// Collects what actually changed for this feature:
|
|
437
|
+
// 1. Uncommitted changes to tracked files in configured sourceDir
|
|
438
|
+
// 2. Content of new untracked files in sourceDir
|
|
439
|
+
// 3. Falls back to full feature diff (feature_start_commit..HEAD) if clean
|
|
440
|
+
// Always truncates to MAX_DIFF_LINES.
|
|
441
|
+
|
|
442
|
+
function getSourceDir() {
|
|
443
|
+
try {
|
|
444
|
+
const cfg = getConfig();
|
|
445
|
+
const raw = cfg?.review?.sourceDir || '.';
|
|
446
|
+
// Normalise: strip leading ./ and trailing slash, then add trailing slash
|
|
447
|
+
return raw.replace(/^\.\//, '').replace(/\/$/, '') + '/';
|
|
448
|
+
} catch {
|
|
449
|
+
return '';
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Resolve the actual git repository root.
|
|
455
|
+
* getRootDir() returns the aicc.config.js directory which may be a monorepo
|
|
456
|
+
* root that is NOT itself a git repo. Walk: try root, then root/sourceDir.
|
|
457
|
+
*/
|
|
458
|
+
async function findGitRoot(root) {
|
|
459
|
+
const candidates = [root];
|
|
460
|
+
const srcDir = getSourceDir();
|
|
461
|
+
if (srcDir) candidates.push(resolve(root, srcDir.replace(/\/$/, '')));
|
|
462
|
+
|
|
463
|
+
for (const dir of candidates) {
|
|
464
|
+
const r = await capture('git rev-parse --show-toplevel', [], { cwd: dir });
|
|
465
|
+
if (r.code === 0 && r.stdout.trim()) return r.stdout.trim();
|
|
466
|
+
}
|
|
467
|
+
return root; // fallback — git will error but callers handle empty stdout
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function buildFeatureDiff(root, status) {
|
|
471
|
+
let diffContent = '';
|
|
472
|
+
const summaryLines = [];
|
|
473
|
+
|
|
474
|
+
// Resolve actual git root (may differ from aicc.config.js location)
|
|
475
|
+
const gitRoot = await findGitRoot(root);
|
|
476
|
+
|
|
477
|
+
// ── Uncommitted changes to tracked files ──────────────────────────────────
|
|
478
|
+
const trackedResult = await capture('git diff HEAD', [], { cwd: gitRoot });
|
|
479
|
+
if (trackedResult.stdout.trim()) {
|
|
480
|
+
diffContent += trackedResult.stdout;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── New untracked files ────────────────────────────────────────────────────
|
|
484
|
+
const statusResult = await capture('git status --porcelain', [], { cwd: gitRoot });
|
|
485
|
+
const newFiles = statusResult.stdout
|
|
486
|
+
.split('\n')
|
|
487
|
+
.filter(l => l.startsWith('??') || l.startsWith('A ') || l.startsWith(' A'))
|
|
488
|
+
.map(l => l.slice(3).trim());
|
|
489
|
+
|
|
490
|
+
if (newFiles.length) {
|
|
491
|
+
summaryLines.push(`New files (${newFiles.length}): ${newFiles.map(f => f.split('/').pop()).join(', ')}`);
|
|
492
|
+
|
|
493
|
+
const cfgExts = (() => { try { return getConfig()?.review?.extensions; } catch { return null; } })();
|
|
494
|
+
const reviewableExts = cfgExts || ['.js', '.ts', '.py', '.go', '.html', '.css', '.json'];
|
|
495
|
+
for (const filePath of newFiles.slice(0, 30)) {
|
|
496
|
+
const ext = filePath.slice(filePath.lastIndexOf('.'));
|
|
497
|
+
if (!reviewableExts.includes(ext)) continue;
|
|
498
|
+
|
|
499
|
+
const absPath = resolve(gitRoot, filePath);
|
|
500
|
+
if (!existsSync(absPath)) continue;
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
const fileContent = readFileSync(absPath, 'utf8');
|
|
504
|
+
diffContent += `\n\n+++ ${filePath} (NEW FILE)\n` +
|
|
505
|
+
fileContent.split('\n').map(l => `+${l}`).join('\n');
|
|
506
|
+
} catch { /* unreadable file — skip */ }
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ── Fallback: full feature diff since submission ───────────────────────────
|
|
511
|
+
if (!diffContent.trim()) {
|
|
512
|
+
const baseCommit = status.feature_start_commit || null;
|
|
513
|
+
const range = baseCommit ? `${baseCommit}..HEAD` : 'HEAD~1..HEAD';
|
|
514
|
+
const label = baseCommit
|
|
515
|
+
? `since feature start (${baseCommit.slice(0, 8)})`
|
|
516
|
+
: 'last commit';
|
|
517
|
+
// Exclude lockfiles, tarballs, and binary blobs that waste diff budget
|
|
518
|
+
const excludeArgs = [
|
|
519
|
+
':(exclude)package-lock.json',
|
|
520
|
+
':(exclude)*.tgz',
|
|
521
|
+
':(exclude)*.lock',
|
|
522
|
+
':(exclude)*.min.js',
|
|
523
|
+
':(exclude)*.map',
|
|
524
|
+
].join(' ');
|
|
525
|
+
const fallbackResult = await capture(`git diff ${range} -- . ${excludeArgs}`, [], { cwd: gitRoot });
|
|
526
|
+
if (fallbackResult.stdout.trim()) {
|
|
527
|
+
diffContent = fallbackResult.stdout;
|
|
528
|
+
summaryLines.push(`Note: No uncommitted changes — showing ${label}.`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── Fallback 2: changes since last deployed commit ─────────────────────────
|
|
533
|
+
if (!diffContent.trim() && status.last_deployed_commit) {
|
|
534
|
+
const sinceDeployResult = await capture(
|
|
535
|
+
`git diff ${status.last_deployed_commit}..HEAD`, [], { cwd: gitRoot }
|
|
536
|
+
);
|
|
537
|
+
if (sinceDeployResult.stdout.trim()) {
|
|
538
|
+
diffContent = sinceDeployResult.stdout;
|
|
539
|
+
summaryLines.push(`Note: Showing changes since last deploy (${status.last_deployed_commit.slice(0, 8)}).`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!diffContent.trim()) {
|
|
544
|
+
diffContent = 'No code changes detected.';
|
|
545
|
+
summaryLines.push('Warning: Could not find changed files. Review spec and architecture only.');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── Truncate to MAX_DIFF_LINES ─────────────────────────────────────────────
|
|
549
|
+
const lines = diffContent.split('\n');
|
|
550
|
+
let truncated = false;
|
|
551
|
+
if (lines.length > MAX_DIFF_LINES) {
|
|
552
|
+
diffContent = lines.slice(0, MAX_DIFF_LINES).join('\n');
|
|
553
|
+
summaryLines.push(`[Diff truncated at ${MAX_DIFF_LINES} lines — ${lines.length - MAX_DIFF_LINES} more lines omitted]`);
|
|
554
|
+
truncated = true;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const totalLines = Math.min(lines.length, MAX_DIFF_LINES);
|
|
558
|
+
logActivity('PIPELINE', `Diff: ${totalLines} lines${truncated ? ' (truncated)' : ''} · ${newFiles.length} new file(s)`);
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
content: diffContent,
|
|
562
|
+
summary: summaryLines.length ? summaryLines.join('\n') : '',
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
function getLatest(dir) {
|
|
569
|
+
if (!existsSync(dir)) return null;
|
|
570
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.md')).sort().reverse();
|
|
571
|
+
return files.length ? resolve(dir, files[0]) : null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Parse a Gemini review file into a structured verdict object.
|
|
576
|
+
* Case-insensitive for APPROVED/REJECTED.
|
|
577
|
+
*/
|
|
578
|
+
function parseReviewFile(filePath) {
|
|
579
|
+
const result = { decision: 'UNKNOWN', blockers: [], warnings: [], approved: [], actionItems: [] };
|
|
580
|
+
if (!existsSync(filePath)) return result;
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
const content = readFileSync(filePath, 'utf8');
|
|
584
|
+
|
|
585
|
+
// ── Decision: look ONLY inside the ## Verdict section ────────────────────
|
|
586
|
+
// Scanning the full file causes "## Approved Items" header to match first,
|
|
587
|
+
// producing a false APPROVED verdict even when blockers are listed.
|
|
588
|
+
const verdictParts = content.split(/##[^#]*Verdict/i);
|
|
589
|
+
if (verdictParts.length >= 2) {
|
|
590
|
+
// Take only the text in the Verdict section (stop at next ##)
|
|
591
|
+
const verdictBody = verdictParts[1].split(/^##/m)[0];
|
|
592
|
+
const m = verdictBody.match(/\b(APPROVED|REJECTED)\b/i);
|
|
593
|
+
if (m) result.decision = m[1].toUpperCase();
|
|
594
|
+
}
|
|
595
|
+
// Fallback: scan full file but skip section header lines (## …)
|
|
596
|
+
if (result.decision === 'UNKNOWN') {
|
|
597
|
+
const nonHeaderLines = content.split('\n').filter(l => !/^#{1,6}\s/.test(l)).join('\n');
|
|
598
|
+
const m = nonHeaderLines.match(/\b(APPROVED|REJECTED)\b/i);
|
|
599
|
+
if (m) result.decision = m[1].toUpperCase();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Placeholder values Gemini writes when a section is empty — treat as empty list
|
|
603
|
+
const NONE_PATTERN = /^(none\.?|n\/a\.?|no\s+(blockers?|warnings?|issues?|items?)\.?)$/i;
|
|
604
|
+
|
|
605
|
+
// Extract bullet/numbered items from a named section
|
|
606
|
+
const extractSection = (sectionRegex) => {
|
|
607
|
+
const parts = content.split(sectionRegex);
|
|
608
|
+
if (parts.length < 2) return [];
|
|
609
|
+
const section = parts[1].split(/^##/m)[0];
|
|
610
|
+
return section
|
|
611
|
+
.split('\n')
|
|
612
|
+
.filter(l => /^[-*]\s|^\d+\./.test(l.trim()))
|
|
613
|
+
.map(l => l.replace(/^[-*\d.]+\s*\*?\*?/, '').replace(/\*\*\s*$/, '').trim())
|
|
614
|
+
.filter(Boolean)
|
|
615
|
+
.filter(item => !NONE_PATTERN.test(item)) // drop "None.", "N/A", etc.
|
|
616
|
+
.slice(0, 10);
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
result.blockers = extractSection(/##[^#]*Blocker/i);
|
|
620
|
+
result.warnings = extractSection(/##[^#]*Warning/i);
|
|
621
|
+
result.approved = extractSection(/##[^#]*Approved/i);
|
|
622
|
+
result.actionItems = extractSection(/##[^#]*Action Items/i);
|
|
623
|
+
|
|
624
|
+
// ── Safety override ───────────────────────────────────────────────────────
|
|
625
|
+
// If Gemini listed real blockers but wrote APPROVED in the verdict, force REJECTED.
|
|
626
|
+
// "None." placeholders are already filtered out above so this only fires on real items.
|
|
627
|
+
if (result.decision === 'APPROVED' && result.blockers.length > 0) {
|
|
628
|
+
result.decision = 'REJECTED';
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
} catch { /* return partial result on parse error */ }
|
|
632
|
+
|
|
633
|
+
return result;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── Dispatch fixes from review directly to Copilot (from "Fix now" option) ───
|
|
637
|
+
|
|
638
|
+
async function dispatchFixesToCopilot(featureId, reviewRelPath, reviewAbsPath, verdict, retryCount = 0, maxRetries = 3) {
|
|
639
|
+
const actionItems = verdict.actionItems.length ? verdict.actionItems : verdict.blockers;
|
|
640
|
+
|
|
641
|
+
// Read the actual review file to extract ALL blockers/warnings (not just the 10-item parsed slice)
|
|
642
|
+
let rawBlockerSection = '';
|
|
643
|
+
if (existsSync(reviewAbsPath)) {
|
|
644
|
+
const fullReview = readFileSync(reviewAbsPath, 'utf8');
|
|
645
|
+
// Extract everything from ## Blockers through ## Warnings (or next section)
|
|
646
|
+
const blockerMatch = fullReview.match(/##[^#]*Blocker[\s\S]*?(?=^##[^#]|\Z)/im);
|
|
647
|
+
const warningMatch = fullReview.match(/##[^#]*Warning[\s\S]*?(?=^##[^#]|\Z)/im);
|
|
648
|
+
const parts = [];
|
|
649
|
+
if (blockerMatch) parts.push(blockerMatch[0].trim());
|
|
650
|
+
if (warningMatch) parts.push(warningMatch[0].trim());
|
|
651
|
+
rawBlockerSection = parts.join('\n\n');
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const lines = [
|
|
655
|
+
`=== REVIEW FILE (GROUND TRUTH — read this file FIRST) ===`,
|
|
656
|
+
`File path: ${reviewRelPath}`,
|
|
657
|
+
`CRITICAL: Open and read the full review file above before making ANY changes.`,
|
|
658
|
+
``,
|
|
659
|
+
`=== BLOCKERS & WARNINGS (extracted from review — fix ALL of these) ===`,
|
|
660
|
+
rawBlockerSection || actionItems.map((item, i) => `${i + 1}. ${item}`).join('\n') || '(see review file)',
|
|
661
|
+
``,
|
|
662
|
+
`MANDATORY SEARCH RULES — follow these before making ANY change:`,
|
|
663
|
+
`1. NEVER guess a file name, class name, method name, or field name.`,
|
|
664
|
+
`2. Before editing a file: use your search/read tools to find and open the ACTUAL file.`,
|
|
665
|
+
` - If the review mentions a class name, search for it: find . -name "*ClassName*"`,
|
|
666
|
+
` - If a method is mentioned, search for it inside the file before assuming its line number.`,
|
|
667
|
+
`3. Before changing a field reference: search the source directory for the actual definition`,
|
|
668
|
+
` to confirm the name exists. Do NOT invent or assume names.`,
|
|
669
|
+
`4. After opening the real file, read the surrounding context (±20 lines) to understand`,
|
|
670
|
+
` what the code is doing before applying any fix.`,
|
|
671
|
+
`5. If you cannot find a file or method after searching, log "NOT FOUND: <name>" and skip it.`,
|
|
672
|
+
` Do NOT create stub code to fill a gap.`,
|
|
673
|
+
``,
|
|
674
|
+
`Fix every ✗ Blocker and ~ Warning listed above. Do not skip any.`,
|
|
675
|
+
];
|
|
676
|
+
const copilotPrompt = lines.join('\n') +
|
|
677
|
+
'\n\nMake targeted changes only — do not refactor anything beyond what is required to address the blockers.';
|
|
678
|
+
|
|
679
|
+
logActivity('COPILOT', `Dispatching ${verdict.blockers.length} blocker(s) to Copilot...`);
|
|
680
|
+
|
|
681
|
+
const stopCycle = spinCycle('Copilot', [
|
|
682
|
+
'Reading review blockers and warnings...',
|
|
683
|
+
'Fixing architecture issues...',
|
|
684
|
+
'Addressing code quality findings...',
|
|
685
|
+
'Updating test classes...',
|
|
686
|
+
'Finalizing fixes...',
|
|
687
|
+
], 5000, 'blue');
|
|
688
|
+
|
|
689
|
+
await runCopilot(copilotPrompt, { featureId });
|
|
690
|
+
|
|
691
|
+
stopCycle();
|
|
692
|
+
spinner.succeed('Copilot finished applying fixes');
|
|
693
|
+
|
|
694
|
+
updateStatus({ stage: 'implementation_complete', rejection_reason: 'Blockers found in review', next: 'review' });
|
|
695
|
+
logActivity('COPILOT', 'All fixes applied', 'success');
|
|
696
|
+
|
|
697
|
+
const nextRetry = retryCount + 1;
|
|
698
|
+
if (nextRetry < maxRetries) {
|
|
699
|
+
logActivity('PIPELINE', `Auto re-reviewing (attempt ${nextRetry + 1} of ${maxRetries})...`, 'info');
|
|
700
|
+
await reviewAction(nextRetry, maxRetries);
|
|
701
|
+
} else {
|
|
702
|
+
logActivity('PIPELINE', `Max auto-retries (${maxRetries}) reached — human review required.`, 'warn');
|
|
703
|
+
updateStatus({ mode: 'manual' }); // Stop auto-pilot loop
|
|
704
|
+
logActivity('PIPELINE', 'Run "Review Code" manually or "Approve" if satisfied.', 'info');
|
|
705
|
+
}
|
|
706
|
+
}
|