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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A/B Testing System for comparing AI model outputs.
|
|
3
|
+
*
|
|
4
|
+
* Scores and compares pre-computed responses from two models,
|
|
5
|
+
* persists results, and provides aggregated statistics.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
|
|
9
|
+
import { resolve } from 'path';
|
|
10
|
+
import { getWorkflowDir } from './pipeline.js';
|
|
11
|
+
|
|
12
|
+
const AB_DIR = 'ab-tests';
|
|
13
|
+
|
|
14
|
+
function getABDir() {
|
|
15
|
+
const dir = resolve(getWorkflowDir(), AB_DIR);
|
|
16
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Score an AI response (0–100) based on heuristic quality signals.
|
|
22
|
+
*
|
|
23
|
+
* Breakdown:
|
|
24
|
+
* Length adequacy 10–30 pts
|
|
25
|
+
* Structure 10–30 pts (headings, code blocks)
|
|
26
|
+
* Keyword relevance 10–20 pts
|
|
27
|
+
* Completeness 10–20 pts (distinct sections)
|
|
28
|
+
*/
|
|
29
|
+
export function scoreResponse(response, stage) {
|
|
30
|
+
if (!response || typeof response !== 'string') return 0;
|
|
31
|
+
|
|
32
|
+
let score = 0;
|
|
33
|
+
|
|
34
|
+
// Length adequacy (10–30 pts)
|
|
35
|
+
const words = response.split(/\s+/).filter(Boolean).length;
|
|
36
|
+
if (words < 50) score += 10;
|
|
37
|
+
else if (words < 200) score += 20;
|
|
38
|
+
else score += 30;
|
|
39
|
+
|
|
40
|
+
// Structure / formatting (10–30 pts)
|
|
41
|
+
const headings = (response.match(/^#{1,6}\s/gm) || []).length;
|
|
42
|
+
const codeBlocks = (response.match(/```/g) || []).length / 2;
|
|
43
|
+
const structureScore = Math.min(30, 10 + headings * 4 + Math.floor(codeBlocks) * 5);
|
|
44
|
+
score += structureScore;
|
|
45
|
+
|
|
46
|
+
// Keyword relevance (10–20 pts)
|
|
47
|
+
const stageKeywords = {
|
|
48
|
+
plan: ['goal', 'step', 'requirement', 'scope', 'milestone'],
|
|
49
|
+
blueprint:['architecture', 'component', 'interface', 'design', 'module'],
|
|
50
|
+
implement:['function', 'class', 'import', 'export', 'return'],
|
|
51
|
+
review: ['issue', 'fix', 'improve', 'suggestion', 'change'],
|
|
52
|
+
};
|
|
53
|
+
const keywords = stageKeywords[stage] || [];
|
|
54
|
+
const lower = response.toLowerCase();
|
|
55
|
+
const hits = keywords.filter(kw => lower.includes(kw)).length;
|
|
56
|
+
score += Math.min(20, 10 + hits * 2);
|
|
57
|
+
|
|
58
|
+
// Completeness (10–20 pts)
|
|
59
|
+
const sections = new Set(
|
|
60
|
+
(response.match(/^#{1,3}\s.+/gm) || []).map(h => h.trim())
|
|
61
|
+
).size;
|
|
62
|
+
score += Math.min(20, 10 + sections * 2);
|
|
63
|
+
|
|
64
|
+
return Math.min(100, score);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run an A/B comparison between two pre-computed model responses.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} prompt – the original prompt
|
|
71
|
+
* @param {{ model: string, response: string }} modelA
|
|
72
|
+
* @param {{ model: string, response: string }} modelB
|
|
73
|
+
* @param {string} stage – pipeline stage (plan, blueprint, …)
|
|
74
|
+
* @returns {{ winner: string, modelA: object, modelB: object, reason: string }}
|
|
75
|
+
*/
|
|
76
|
+
export function runABTest(prompt, modelA, modelB, stage) {
|
|
77
|
+
const scoreA = scoreResponse(modelA.response, stage);
|
|
78
|
+
const scoreB = scoreResponse(modelB.response, stage);
|
|
79
|
+
|
|
80
|
+
const winner = scoreA >= scoreB ? modelA.model : modelB.model;
|
|
81
|
+
const diff = Math.abs(scoreA - scoreB);
|
|
82
|
+
const reason = diff < 5
|
|
83
|
+
? 'Near tie — scores within 5 points'
|
|
84
|
+
: `${winner} scored ${diff} points higher on ${stage} heuristics`;
|
|
85
|
+
|
|
86
|
+
const result = {
|
|
87
|
+
winner,
|
|
88
|
+
modelA: { model: modelA.model, score: scoreA },
|
|
89
|
+
modelB: { model: modelB.model, score: scoreB },
|
|
90
|
+
reason,
|
|
91
|
+
stage,
|
|
92
|
+
prompt: prompt.slice(0, 200),
|
|
93
|
+
timestamp: new Date().toISOString(),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
saveABResult(result);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Persist an A/B test result to disk.
|
|
102
|
+
*/
|
|
103
|
+
export function saveABResult(result) {
|
|
104
|
+
const dir = getABDir();
|
|
105
|
+
const file = resolve(dir, `ab-${Date.now()}.json`);
|
|
106
|
+
writeFileSync(file, JSON.stringify(result, null, 2));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Return the last N A/B test results (most recent first).
|
|
111
|
+
*/
|
|
112
|
+
export function getABHistory(limit = 20) {
|
|
113
|
+
const dir = getABDir();
|
|
114
|
+
const files = readdirSync(dir)
|
|
115
|
+
.filter(f => f.startsWith('ab-') && f.endsWith('.json'))
|
|
116
|
+
.sort()
|
|
117
|
+
.reverse()
|
|
118
|
+
.slice(0, limit);
|
|
119
|
+
|
|
120
|
+
return files.map(f => {
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(readFileSync(resolve(dir, f), 'utf-8'));
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}).filter(Boolean);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Aggregate win/loss statistics per model across all recorded tests.
|
|
131
|
+
*/
|
|
132
|
+
export function getABSummary() {
|
|
133
|
+
const history = getABHistory(1000);
|
|
134
|
+
const stats = {};
|
|
135
|
+
|
|
136
|
+
for (const result of history) {
|
|
137
|
+
const { winner, modelA, modelB } = result;
|
|
138
|
+
|
|
139
|
+
for (const entry of [modelA, modelB]) {
|
|
140
|
+
if (!stats[entry.model]) {
|
|
141
|
+
stats[entry.model] = { wins: 0, losses: 0, ties: 0, totalScore: 0, tests: 0 };
|
|
142
|
+
}
|
|
143
|
+
stats[entry.model].tests += 1;
|
|
144
|
+
stats[entry.model].totalScore += entry.score;
|
|
145
|
+
|
|
146
|
+
if (result.reason.startsWith('Near tie')) {
|
|
147
|
+
stats[entry.model].ties += 1;
|
|
148
|
+
} else if (entry.model === winner) {
|
|
149
|
+
stats[entry.model].wins += 1;
|
|
150
|
+
} else {
|
|
151
|
+
stats[entry.model].losses += 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Compute average scores
|
|
157
|
+
for (const model of Object.keys(stats)) {
|
|
158
|
+
stats[model].avgScore = stats[model].tests
|
|
159
|
+
? Math.round(stats[model].totalScore / stats[model].tests)
|
|
160
|
+
: 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return stats;
|
|
164
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal notifications: sounds + celebration animations.
|
|
3
|
+
*
|
|
4
|
+
* Sounds use macOS `afplay` (fire-and-forget, non-blocking).
|
|
5
|
+
* Animations use a progress-bar sweep → final message reveal (pure chalk, no deps).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { celebrate } from '../utils/notify.js';
|
|
9
|
+
* await celebrate('deploy_success'); // plays sound + shows banner
|
|
10
|
+
* await celebrate('review_rejected'); // plays sound only (red banner)
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'child_process';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
|
|
15
|
+
// ─── macOS system sounds ───────────────────────────────────────────────────────
|
|
16
|
+
const SOUNDS_DIR = '/System/Library/Sounds';
|
|
17
|
+
const SOUND_MAP = {
|
|
18
|
+
deploy_success: `${SOUNDS_DIR}/Hero.aiff`,
|
|
19
|
+
review_approved: `${SOUNDS_DIR}/Glass.aiff`,
|
|
20
|
+
approved: `${SOUNDS_DIR}/Glass.aiff`,
|
|
21
|
+
review_rejected: `${SOUNDS_DIR}/Blow.aiff`,
|
|
22
|
+
deploy_failed: `${SOUNDS_DIR}/Basso.aiff`,
|
|
23
|
+
task_done: `${SOUNDS_DIR}/Ping.aiff`,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Play a macOS system sound for the given event.
|
|
28
|
+
* Fire-and-forget — returns immediately, sound plays in background.
|
|
29
|
+
* Silent no-op on non-macOS systems.
|
|
30
|
+
*/
|
|
31
|
+
export function playSound(event) {
|
|
32
|
+
const file = SOUND_MAP[event];
|
|
33
|
+
if (!file || process.platform !== 'darwin') return;
|
|
34
|
+
try {
|
|
35
|
+
const proc = spawn('afplay', [file], { detached: true, stdio: 'ignore' });
|
|
36
|
+
proc.unref(); // don't block the Node.js process
|
|
37
|
+
} catch { /* non-fatal — sound is a nice-to-have */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Animation helpers ─────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Animate a progress bar sweep (░→█), then reveal the final message.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} color chalk color name ('green', 'cyan', 'red', 'yellow')
|
|
48
|
+
* @param {string} icon emoji or symbol shown before the message
|
|
49
|
+
* @param {string} message text after the icon
|
|
50
|
+
*/
|
|
51
|
+
async function sweepBanner(color, icon, message) {
|
|
52
|
+
const WIDTH = 42;
|
|
53
|
+
const FRAMES = 10;
|
|
54
|
+
const DELAY = 35; // ms per frame (~350ms total sweep)
|
|
55
|
+
|
|
56
|
+
// Phase 1: fill the bar left → right
|
|
57
|
+
for (let i = 0; i <= FRAMES; i++) {
|
|
58
|
+
const filled = Math.round((i / FRAMES) * WIDTH);
|
|
59
|
+
const bar = chalk[color]('█'.repeat(filled)) + chalk.dim('░'.repeat(WIDTH - filled));
|
|
60
|
+
process.stdout.write(`\r ${bar} `);
|
|
61
|
+
await sleep(DELAY);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Phase 2: pause on full bar for a beat
|
|
65
|
+
await sleep(120);
|
|
66
|
+
|
|
67
|
+
// Phase 3: clear bar, show final message
|
|
68
|
+
process.stdout.write('\r\x1b[K');
|
|
69
|
+
process.stdout.write(chalk[color].bold(` ${icon} ${message}\n`));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Event definitions ─────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const CELEBRATIONS = {
|
|
75
|
+
deploy_success: {
|
|
76
|
+
color: 'green',
|
|
77
|
+
icon: '🚀',
|
|
78
|
+
message: 'DEPLOYED SUCCESSFULLY',
|
|
79
|
+
},
|
|
80
|
+
review_approved: {
|
|
81
|
+
color: 'cyan',
|
|
82
|
+
icon: '✓',
|
|
83
|
+
message: 'REVIEW APPROVED · Ready to approve and ship!',
|
|
84
|
+
},
|
|
85
|
+
approved: {
|
|
86
|
+
color: 'green',
|
|
87
|
+
icon: '✓',
|
|
88
|
+
message: 'FEATURE APPROVED · Deploy when ready',
|
|
89
|
+
},
|
|
90
|
+
review_rejected: {
|
|
91
|
+
color: 'red',
|
|
92
|
+
icon: '✗',
|
|
93
|
+
message: 'REVIEW REJECTED · Blockers found — Copilot will fix',
|
|
94
|
+
},
|
|
95
|
+
deploy_failed: {
|
|
96
|
+
color: 'red',
|
|
97
|
+
icon: '✗',
|
|
98
|
+
message: 'DEPLOY FAILED · Sending errors to Copilot',
|
|
99
|
+
},
|
|
100
|
+
task_done: {
|
|
101
|
+
color: 'blue',
|
|
102
|
+
icon: '◆',
|
|
103
|
+
message: 'PIPELINE STEP COMPLETE',
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Play the sound for `event` and show the celebration animation.
|
|
109
|
+
* Always resolves — sound/animation failures are silently swallowed.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} event key from CELEBRATIONS / SOUND_MAP
|
|
112
|
+
*/
|
|
113
|
+
export async function celebrate(event) {
|
|
114
|
+
playSound(event);
|
|
115
|
+
|
|
116
|
+
const cfg = CELEBRATIONS[event];
|
|
117
|
+
if (!cfg) return;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await sweepBanner(cfg.color, cfg.icon, cfg.message);
|
|
121
|
+
} catch { /* non-fatal */ }
|
|
122
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { getWorkflowDir } from './pipeline.js';
|
|
4
|
+
|
|
5
|
+
const PERSONA_FILES = {
|
|
6
|
+
pm: 'pm.md',
|
|
7
|
+
architect: 'architect.md',
|
|
8
|
+
coder: 'coder.md',
|
|
9
|
+
reviewer: 'reviewer.md',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PERSONAS = {
|
|
13
|
+
pm: {
|
|
14
|
+
name: 'Product Manager',
|
|
15
|
+
content: 'You are a Product Manager. You write clear, structured feature specs with user stories, acceptance criteria, and technical risks.',
|
|
16
|
+
},
|
|
17
|
+
architect: {
|
|
18
|
+
name: 'System Architect',
|
|
19
|
+
content: 'You are a System Architect. You design scalable architectures with data flow diagrams, component breakdowns, and implementation task lists.',
|
|
20
|
+
},
|
|
21
|
+
coder: {
|
|
22
|
+
name: 'Senior Developer',
|
|
23
|
+
content: 'You are a Senior Developer. You write clean, tested, production-ready code following project conventions.',
|
|
24
|
+
},
|
|
25
|
+
reviewer: {
|
|
26
|
+
name: 'Code Reviewer',
|
|
27
|
+
content: 'You are a Code Reviewer. You review code for bugs, security issues, performance problems, and adherence to best practices.',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Return the persona directory path.
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function getPersonaDir() {
|
|
36
|
+
return join(getWorkflowDir(), 'personas');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load a persona for a given role.
|
|
41
|
+
* Falls back to default persona if no custom file exists.
|
|
42
|
+
* @param {string} role - One of: pm, architect, coder, reviewer
|
|
43
|
+
* @returns {{name: string, role: string, content: string, custom: boolean}}
|
|
44
|
+
*/
|
|
45
|
+
export function loadPersona(role) {
|
|
46
|
+
const def = DEFAULT_PERSONAS[role];
|
|
47
|
+
if (!def) throw new Error(`Unknown persona role: ${role}`);
|
|
48
|
+
|
|
49
|
+
const file = join(getPersonaDir(), PERSONA_FILES[role]);
|
|
50
|
+
if (existsSync(file)) {
|
|
51
|
+
const content = readFileSync(file, 'utf-8').trim();
|
|
52
|
+
const nameMatch = content.match(/^#\s+(.+)$/m);
|
|
53
|
+
const name = nameMatch ? nameMatch[1] : def.name;
|
|
54
|
+
return { name, role, content, custom: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { name: def.name, role, content: def.content, custom: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* List all available personas with role, name, and custom/default status.
|
|
62
|
+
* @returns {Array<{role: string, name: string, custom: boolean}>}
|
|
63
|
+
*/
|
|
64
|
+
export function listPersonas() {
|
|
65
|
+
return Object.keys(PERSONA_FILES).map(role => {
|
|
66
|
+
const persona = loadPersona(role);
|
|
67
|
+
return { role: persona.role, name: persona.name, custom: persona.custom };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Prepend persona identity to a prompt string.
|
|
73
|
+
* @param {string} prompt - The base prompt
|
|
74
|
+
* @param {string} role - Persona role to inject
|
|
75
|
+
* @returns {string} Prompt with persona prepended
|
|
76
|
+
*/
|
|
77
|
+
export function injectPersona(prompt, role) {
|
|
78
|
+
const persona = loadPersona(role);
|
|
79
|
+
return `${persona.content}\n\n${prompt}`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline Lock — prevents concurrent pipeline actions.
|
|
3
|
+
*
|
|
4
|
+
* Uses file-based locking since AICC is single-process but may have
|
|
5
|
+
* multiple triggers (web API, Telegram, cron) firing simultaneously.
|
|
6
|
+
*
|
|
7
|
+
* File: .ai-workflow/.pipeline-lock
|
|
8
|
+
* Format: { holder: 'string', acquiredAt: ISO, expiresAt: ISO }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
12
|
+
import { resolve } from 'path';
|
|
13
|
+
import { getWorkflowDir } from './pipeline.js';
|
|
14
|
+
|
|
15
|
+
const LOCK_FILE = () => resolve(getWorkflowDir(), '.pipeline-lock');
|
|
16
|
+
|
|
17
|
+
export function acquireLock(holder, ttlMs = 120_000) {
|
|
18
|
+
const lockPath = LOCK_FILE();
|
|
19
|
+
|
|
20
|
+
// Check existing lock
|
|
21
|
+
if (existsSync(lockPath)) {
|
|
22
|
+
try {
|
|
23
|
+
const existing = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
24
|
+
const expiresAt = new Date(existing.expiresAt).getTime();
|
|
25
|
+
|
|
26
|
+
if (Date.now() < expiresAt) {
|
|
27
|
+
// Lock is valid and held by someone else
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
// Lock expired — steal it
|
|
31
|
+
} catch {
|
|
32
|
+
// Corrupt lock file — remove and proceed
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Acquire lock
|
|
37
|
+
writeFileSync(lockPath, JSON.stringify({
|
|
38
|
+
holder,
|
|
39
|
+
acquiredAt: new Date().toISOString(),
|
|
40
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function releaseLock(holder) {
|
|
47
|
+
const lockPath = LOCK_FILE();
|
|
48
|
+
if (!existsSync(lockPath)) return;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const existing = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
52
|
+
if (existing.holder === holder) {
|
|
53
|
+
unlinkSync(lockPath);
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// If we can't read it, try to remove anyway
|
|
57
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isLocked() {
|
|
62
|
+
const lockPath = LOCK_FILE();
|
|
63
|
+
if (!existsSync(lockPath)) return { locked: false };
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const existing = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
67
|
+
const expiresAt = new Date(existing.expiresAt).getTime();
|
|
68
|
+
if (Date.now() >= expiresAt) return { locked: false, expired: true };
|
|
69
|
+
return { locked: true, holder: existing.holder, expiresAt: existing.expiresAt };
|
|
70
|
+
} catch {
|
|
71
|
+
return { locked: false };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'fs';
|
|
3
|
+
import { resolve, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { ErrorCodes } from './errors.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Find the project root by walking up from cwd to find aicc.config.js.
|
|
11
|
+
* Falls back to __dirname/../../.. for backwards compatibility.
|
|
12
|
+
*/
|
|
13
|
+
function findRoot() {
|
|
14
|
+
let dir = process.cwd();
|
|
15
|
+
while (true) {
|
|
16
|
+
if (existsSync(resolve(dir, 'aicc.config.js'))) return dir;
|
|
17
|
+
const parent = dirname(dir);
|
|
18
|
+
if (parent === dir) break;
|
|
19
|
+
dir = parent;
|
|
20
|
+
}
|
|
21
|
+
// Fallback: relative to package location (works when package IS the project)
|
|
22
|
+
return resolve(__dirname, '../../..');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ROOT = findRoot();
|
|
26
|
+
const STATUS_FILE = resolve(ROOT, '.ai-workflow/status.json');
|
|
27
|
+
const WORKFLOW_DIR = resolve(ROOT, '.ai-workflow');
|
|
28
|
+
|
|
29
|
+
// ─── Atomic Write ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Write data to a file atomically: write to .tmp, fsync, rename.
|
|
33
|
+
* Prevents corruption from concurrent readers or crashes mid-write.
|
|
34
|
+
*/
|
|
35
|
+
export function atomicWriteSync(filePath, data) {
|
|
36
|
+
const dir = dirname(filePath);
|
|
37
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
38
|
+
const tmpPath = `${filePath}.tmp`;
|
|
39
|
+
try {
|
|
40
|
+
const fd = openSync(tmpPath, 'w');
|
|
41
|
+
writeSync(fd, typeof data === 'string' ? data : JSON.stringify(data, null, 2));
|
|
42
|
+
fsyncSync(fd);
|
|
43
|
+
closeSync(fd);
|
|
44
|
+
renameSync(tmpPath, filePath);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* cleanup best-effort */ }
|
|
47
|
+
const wrapped = new Error(`[${ErrorCodes.STATE_WRITE_FAIL}] Failed to write ${filePath}: ${err.message}`);
|
|
48
|
+
wrapped.code = ErrorCodes.STATE_WRITE_FAIL;
|
|
49
|
+
throw wrapped;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getStatus() {
|
|
54
|
+
if (!existsSync(STATUS_FILE)) return { stage: 'idle', current_feature: null };
|
|
55
|
+
return JSON.parse(readFileSync(STATUS_FILE, 'utf8'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function updateStatus(updates) {
|
|
59
|
+
const current = getStatus();
|
|
60
|
+
const next = { ...current, ...updates };
|
|
61
|
+
atomicWriteSync(STATUS_FILE, JSON.stringify(next, null, 2));
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Checkpoint System ────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const CHECKPOINT_DIR = resolve(WORKFLOW_DIR, 'checkpoints');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Save a checkpoint after a successful sub-step within a pipeline stage.
|
|
71
|
+
*/
|
|
72
|
+
export function saveCheckpoint(featureId, stage, substep, data) {
|
|
73
|
+
if (!existsSync(CHECKPOINT_DIR)) mkdirSync(CHECKPOINT_DIR, { recursive: true });
|
|
74
|
+
const payload = {
|
|
75
|
+
featureId,
|
|
76
|
+
stage,
|
|
77
|
+
substep,
|
|
78
|
+
completedAt: new Date().toISOString(),
|
|
79
|
+
outputs: data,
|
|
80
|
+
};
|
|
81
|
+
const content = JSON.stringify(payload, null, 2);
|
|
82
|
+
payload.checksum = createHash('sha256').update(content).digest('hex');
|
|
83
|
+
const finalContent = JSON.stringify(payload, null, 2);
|
|
84
|
+
const filePath = resolve(CHECKPOINT_DIR, `${featureId}-${substep}.json`);
|
|
85
|
+
atomicWriteSync(filePath, finalContent);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Load a checkpoint. Validates SHA256 checksum; deletes corrupt files.
|
|
90
|
+
* Returns the checkpoint data or null if not found/invalid.
|
|
91
|
+
*/
|
|
92
|
+
export function loadCheckpoint(featureId, substep) {
|
|
93
|
+
const filePath = resolve(CHECKPOINT_DIR, `${featureId}-${substep}.json`);
|
|
94
|
+
if (!existsSync(filePath)) return null;
|
|
95
|
+
try {
|
|
96
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
97
|
+
const parsed = JSON.parse(raw);
|
|
98
|
+
const { checksum, ...rest } = parsed;
|
|
99
|
+
const verify = createHash('sha256').update(JSON.stringify(rest, null, 2)).digest('hex');
|
|
100
|
+
if (verify !== checksum) {
|
|
101
|
+
console.error(`[${ErrorCodes.CHECKPOINT_CORRUPT}] Checkpoint corrupt: ${filePath} — deleting`);
|
|
102
|
+
try { unlinkSync(filePath); } catch { /* */ }
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return parsed;
|
|
106
|
+
} catch {
|
|
107
|
+
console.error(`[${ErrorCodes.CHECKPOINT_CORRUPT}] Checkpoint unreadable: ${filePath} — deleting`);
|
|
108
|
+
try { unlinkSync(filePath); } catch { /* */ }
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Clear all checkpoints for a feature (e.g. on new pipeline run).
|
|
115
|
+
*/
|
|
116
|
+
export function clearCheckpoints(featureId) {
|
|
117
|
+
if (!existsSync(CHECKPOINT_DIR)) return;
|
|
118
|
+
try {
|
|
119
|
+
const files = readdirSync(CHECKPOINT_DIR).filter(f => f.startsWith(featureId));
|
|
120
|
+
for (const f of files) {
|
|
121
|
+
try { unlinkSync(resolve(CHECKPOINT_DIR, f)); } catch { /* */ }
|
|
122
|
+
}
|
|
123
|
+
} catch { /* non-fatal */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getLatestFilePath(subdir) {
|
|
127
|
+
const dir = resolve(WORKFLOW_DIR, subdir);
|
|
128
|
+
if (!existsSync(dir)) return null;
|
|
129
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.md')).sort().reverse();
|
|
130
|
+
return files.length ? resolve(dir, files[0]) : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function readLatestFile(subdir) {
|
|
134
|
+
const filePath = getLatestFilePath(subdir);
|
|
135
|
+
if (!filePath) return null;
|
|
136
|
+
return readFileSync(filePath, 'utf8');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getWorkflowDir() { return WORKFLOW_DIR; }
|
|
140
|
+
export function getRootDir() { return ROOT; }
|
|
141
|
+
|
|
142
|
+
// ─── Predictive Pipeline Duration ─────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export function calculateAverageStageDurations(costEntries) {
|
|
145
|
+
const stageDurations = {};
|
|
146
|
+
const stageCounts = {};
|
|
147
|
+
|
|
148
|
+
for (const entry of costEntries) {
|
|
149
|
+
const stage = entry.stage;
|
|
150
|
+
if (!stage || !entry.durationMs) continue;
|
|
151
|
+
if (!stageDurations[stage]) {
|
|
152
|
+
stageDurations[stage] = 0;
|
|
153
|
+
stageCounts[stage] = 0;
|
|
154
|
+
}
|
|
155
|
+
stageDurations[stage] += entry.durationMs;
|
|
156
|
+
stageCounts[stage]++;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const averages = {};
|
|
160
|
+
for (const stage of Object.keys(stageDurations)) {
|
|
161
|
+
averages[stage] = Math.round(stageDurations[stage] / stageCounts[stage]);
|
|
162
|
+
}
|
|
163
|
+
return averages;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getStagesAfter(currentStage) {
|
|
167
|
+
const stageOrder = ['spec', 'plan', 'pm', 'arch', 'architect', 'impl', 'implement', 'review', 'deploy'];
|
|
168
|
+
const idx = stageOrder.indexOf(currentStage);
|
|
169
|
+
if (idx === -1) return stageOrder;
|
|
170
|
+
return stageOrder.slice(idx + 1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function predictCompletion(currentStage, costEntries) {
|
|
174
|
+
const avgDurations = calculateAverageStageDurations(costEntries);
|
|
175
|
+
const remainingStages = getStagesAfter(currentStage);
|
|
176
|
+
|
|
177
|
+
const defaultDurations = {
|
|
178
|
+
spec: 60000, plan: 60000, pm: 60000,
|
|
179
|
+
arch: 120000, architect: 120000,
|
|
180
|
+
impl: 180000, implement: 180000,
|
|
181
|
+
review: 90000,
|
|
182
|
+
deploy: 60000,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
let remainingMs = 0;
|
|
186
|
+
|
|
187
|
+
for (const stage of remainingStages) {
|
|
188
|
+
if (avgDurations[stage]) {
|
|
189
|
+
remainingMs += avgDurations[stage];
|
|
190
|
+
} else {
|
|
191
|
+
remainingMs += defaultDurations[stage] || 120000;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const dataPoints = costEntries.length;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
estimatedMs: remainingMs,
|
|
199
|
+
estimatedMinutes: Math.ceil(remainingMs / 60000),
|
|
200
|
+
estimatedCompletion: new Date(Date.now() + remainingMs).toISOString(),
|
|
201
|
+
confidence: dataPoints > 10 ? 'high' : dataPoints > 5 ? 'medium' : 'low',
|
|
202
|
+
dataPoints,
|
|
203
|
+
remainingStages: remainingStages.length,
|
|
204
|
+
avgDurations,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function formatPrediction(prediction) {
|
|
209
|
+
if (!prediction) return '';
|
|
210
|
+
const confEmoji = { high: '🟢', medium: '🟡', low: '🔴' };
|
|
211
|
+
return `⏱️ ETA: ~${prediction.estimatedMinutes} min ` +
|
|
212
|
+
`${confEmoji[prediction.confidence] || '⚪'} ` +
|
|
213
|
+
`(${prediction.confidence} confidence, based on ${prediction.dataPoints} data points)`;
|
|
214
|
+
}
|