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,132 @@
1
+ /**
2
+ * Global spinner singleton — single-width braille frames work everywhere.
3
+ * Usage:
4
+ * spinner.start('Gemini · Analyzing code...');
5
+ * spinner.update('Gemini · Writing review...');
6
+ * spinner.succeed('Review complete'); // stops + prints ✓
7
+ * spinner.fail('Review failed'); // stops + prints ✗
8
+ *
9
+ * Automatically pauses before console.log() calls (via pause/resume) so
10
+ * spinner line never mixes with log output.
11
+ */
12
+ import chalk from 'chalk';
13
+
14
+ // Braille spinner — each char is 1 terminal column wide (safe in all terminals)
15
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
16
+ const INTERVAL = 80;
17
+
18
+ let _timer = null;
19
+ let _label = '';
20
+ let _color = 'cyan';
21
+ let _frame = 0;
22
+ let _active = false;
23
+
24
+ function draw() {
25
+ if (!_active) return;
26
+ const f = chalk[_color](FRAMES[_frame % FRAMES.length]);
27
+ process.stdout.write(`\r ${f} ${_label} `); // trailing spaces overwrite old content
28
+ _frame++;
29
+ }
30
+
31
+ function clearLine() {
32
+ process.stdout.write('\r\x1b[K'); // carriage-return + erase to end-of-line
33
+ }
34
+
35
+ function stopTimer() {
36
+ if (_timer) { clearInterval(_timer); _timer = null; }
37
+ }
38
+
39
+ export const spinner = {
40
+
41
+ get active() { return _active; },
42
+
43
+ /** Start spinning with a label. */
44
+ start(label, color = 'cyan') {
45
+ _label = label;
46
+ _color = color;
47
+ _frame = 0;
48
+ _active = true;
49
+ stopTimer();
50
+ _timer = setInterval(draw, INTERVAL);
51
+ draw();
52
+ return this;
53
+ },
54
+
55
+ /** Change the label while spinning — takes effect on next frame. */
56
+ update(label) {
57
+ _label = label;
58
+ return this;
59
+ },
60
+
61
+ /**
62
+ * Pause the spinner and clear the line (before printing a log line).
63
+ * Call resume() after the line is printed.
64
+ */
65
+ pause() {
66
+ stopTimer();
67
+ clearLine();
68
+ },
69
+
70
+ /** Resume spinning after a log line was printed. */
71
+ resume() {
72
+ if (!_active) return;
73
+ if (_timer) return;
74
+ _timer = setInterval(draw, INTERVAL);
75
+ draw();
76
+ },
77
+
78
+ /** Stop spinning silently (no output). */
79
+ stop() {
80
+ stopTimer();
81
+ clearLine();
82
+ _active = false;
83
+ },
84
+
85
+ /** Stop and print a green success line. */
86
+ succeed(msg) {
87
+ stopTimer();
88
+ clearLine();
89
+ _active = false;
90
+ if (msg) process.stdout.write(chalk.green(` ✓ ${msg}\n`));
91
+ },
92
+
93
+ /** Stop and print a red failure line. */
94
+ fail(msg) {
95
+ stopTimer();
96
+ clearLine();
97
+ _active = false;
98
+ if (msg) process.stdout.write(chalk.red(` ✗ ${msg}\n`));
99
+ },
100
+
101
+ /** Stop and print a yellow warning line. */
102
+ warn(msg) {
103
+ stopTimer();
104
+ clearLine();
105
+ _active = false;
106
+ if (msg) process.stdout.write(chalk.yellow(` ~ ${msg}\n`));
107
+ },
108
+ };
109
+
110
+ /**
111
+ * Cycle through a list of progress messages on the spinner while an async
112
+ * task runs. Returns a cleanup function — call it after await completes.
113
+ *
114
+ * Example:
115
+ * const stopCycle = spinCycle('GEMINI', [
116
+ * 'Reading feature request...',
117
+ * 'Writing user stories...',
118
+ * 'Identifying risks...',
119
+ * ]);
120
+ * await runGemini(...);
121
+ * stopCycle();
122
+ * spinner.succeed('PM spec written');
123
+ */
124
+ export function spinCycle(aiLabel, messages, intervalMs = 4000, color = 'cyan') {
125
+ let i = 0;
126
+ spinner.start(`${aiLabel} · ${messages[0]}`, color);
127
+ const t = setInterval(() => {
128
+ i = (i + 1) % messages.length;
129
+ spinner.update(`${aiLabel} · ${messages[i]}`);
130
+ }, intervalMs);
131
+ return () => clearInterval(t);
132
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Stage Queue — buffers incoming requests when pipeline is busy.
3
+ *
4
+ * File: .ai-workflow/stage-queue.json
5
+ * Format: [{ type, data, queuedAt }]
6
+ * Max size: 10 (oldest dropped)
7
+ */
8
+
9
+ import { existsSync, readFileSync } from 'fs';
10
+ import { resolve } from 'path';
11
+ import { getWorkflowDir, atomicWriteSync } from './pipeline.js';
12
+
13
+ const QUEUE_FILE = () => resolve(getWorkflowDir(), 'stage-queue.json');
14
+ const MAX_QUEUE = 10;
15
+
16
+ export function enqueue(item) {
17
+ const file = QUEUE_FILE();
18
+ let queue = [];
19
+ if (existsSync(file)) {
20
+ try { queue = JSON.parse(readFileSync(file, 'utf8')); } catch { queue = []; }
21
+ }
22
+
23
+ queue.push({ ...item, queuedAt: new Date().toISOString() });
24
+
25
+ // Drop oldest if over limit
26
+ if (queue.length > MAX_QUEUE) {
27
+ queue = queue.slice(queue.length - MAX_QUEUE);
28
+ }
29
+
30
+ atomicWriteSync(file, JSON.stringify(queue, null, 2));
31
+ }
32
+
33
+ export function dequeue() {
34
+ const file = QUEUE_FILE();
35
+ if (!existsSync(file)) return null;
36
+
37
+ let queue = [];
38
+ try { queue = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
39
+ if (queue.length === 0) return null;
40
+
41
+ const item = queue.shift();
42
+ atomicWriteSync(file, JSON.stringify(queue, null, 2));
43
+ return item;
44
+ }
45
+
46
+ export function peekQueue() {
47
+ const file = QUEUE_FILE();
48
+ if (!existsSync(file)) return [];
49
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return []; }
50
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * State Machine — enforces valid pipeline transitions.
3
+ * Every updateStatus() call MUST go through validateTransition().
4
+ *
5
+ * This is the single source of truth for all pipeline state transitions.
6
+ * No ad-hoc states allowed.
7
+ */
8
+
9
+ import { getStatus, updateStatus } from './pipeline.js';
10
+ import { logActivity } from './activity-log.js';
11
+
12
+ export const VALID_TRANSITIONS = {
13
+ 'idle': ['assign', 'suggestion', 'browser-qa'],
14
+ 'assign': ['browser-qa', 'spec', 'arch'],
15
+ 'browser-qa': ['triage', 'spec', 'deploy-verify', 'idle'],
16
+ 'triage': ['bugfix', 'spec', 'escalate'],
17
+ 'bugfix': ['re-qa', 'escalate'],
18
+ 're-qa': ['spec', 'bugfix', 'escalate', 'idle', 'deploy-verify'],
19
+ 'spec': ['arch'],
20
+ 'arch': ['impl'],
21
+ 'impl': ['review', 'browser-qa'],
22
+ 'review': ['deploy', 'impl'], // approved → deploy, rejected → impl
23
+ 'deploy': ['deploy-verify'],
24
+ 'deploy-verify': ['idle', 'bugfix'], // post-deploy QA
25
+ 'escalate': ['manual_hold', 'bugfix', 'idle'],
26
+ 'manual_hold': ['bugfix', 'idle', 'spec'], // CEO decides
27
+ 'suggestion': ['idle', 'spec'], // CEO picks suggestion → spec
28
+ };
29
+
30
+ export const TERMINAL_STATES = ['idle', 'manual_hold'];
31
+
32
+ /**
33
+ * Every status.json MUST conform to this shape.
34
+ * Validate on every read/write.
35
+ */
36
+ export const STATUS_SCHEMA = {
37
+ stage: 'string', // one of VALID_TRANSITIONS keys
38
+ previousStage: 'string',
39
+ featureId: 'string|null',
40
+ assignedUrl: 'string|null', // target website URL (from /assign)
41
+ assignedGoal: 'string|null', // project goal text
42
+ transitionedAt: 'string', // ISO timestamp
43
+ pipelineStarted: 'string|null', // ISO timestamp of pipeline start
44
+ currentCycle: 'number', // bugfix cycle counter (0 = no bugfix)
45
+ maxCycles: 'number', // from config, default 3
46
+ qa_pass_rate: 'number|null', // last QA pass rate %
47
+ qa_last_run: 'string|null', // ISO timestamp
48
+ error: 'string|null', // last error message (cleared on success)
49
+ escalation: 'object|null', // { reason, failedStage, cycles, timestamp }
50
+ };
51
+
52
+ /**
53
+ * Validate a state transition. Throws if invalid.
54
+ */
55
+ export function validateTransition(currentState, nextState) {
56
+ const allowed = VALID_TRANSITIONS[currentState];
57
+ if (!allowed) {
58
+ throw new Error(`Unknown state: "${currentState}". Valid states: ${Object.keys(VALID_TRANSITIONS).join(', ')}`);
59
+ }
60
+ if (!allowed.includes(nextState)) {
61
+ throw new Error(
62
+ `Invalid transition: "${currentState}" → "${nextState}". ` +
63
+ `Allowed from "${currentState}": [${allowed.join(', ')}]`
64
+ );
65
+ }
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Wrap updateStatus to enforce state machine.
71
+ * Replace direct updateStatus() calls with this.
72
+ */
73
+ export function transitionTo(nextState, metadata = {}) {
74
+ const current = getStatus();
75
+ const currentStage = current.stage || 'idle';
76
+
77
+ validateTransition(currentStage, nextState);
78
+
79
+ updateStatus({
80
+ stage: nextState,
81
+ previousStage: currentStage,
82
+ transitionedAt: new Date().toISOString(),
83
+ ...metadata,
84
+ });
85
+
86
+ logActivity('PIPELINE', `${currentStage} → ${nextState}`, 'info');
87
+
88
+ return { from: currentStage, to: nextState };
89
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Persistent 2-line bottom status bar — inspired by Claude Code's UI.
3
+ *
4
+ * Row N-1 (agent row) — active AI agent · current task · elapsed time · tokens
5
+ * Row N (info row) — pipeline stage · feature · session log path · model
6
+ *
7
+ * Uses ANSI scroll-region to reserve the last 2 terminal rows so normal
8
+ * console.log output scrolls without overwriting the bar.
9
+ *
10
+ * Usage:
11
+ * statusBar.install(inquirer) ← once at startup; patches inquirer to auto-pause
12
+ * statusBar.reinstall() ← call after console.clear() in printHeader()
13
+ * statusBar.set('GEMINI', 'Analyzing errors...', estimatedTokens)
14
+ * statusBar.setLastEvent('COPILOT', 'All fixes applied')
15
+ * statusBar.clear() ← task finished
16
+ */
17
+ import chalk from 'chalk';
18
+ import { getStatus } from './pipeline.js';
19
+
20
+ // ─── Guard: no-op when stdout is not a real terminal ─────────────────────────
21
+
22
+ const IS_TTY = !!process.stdout.isTTY;
23
+
24
+ // ─── State ────────────────────────────────────────────────────────────────────
25
+
26
+ let _installed = false;
27
+ let _paused = false;
28
+ let _timer = null;
29
+ let _agent = null; // 'GEMINI' | 'CLAUDE' | 'COPILOT' | null
30
+ let _task = '';
31
+ let _startTime = null;
32
+ let _sessionTokens = 0; // accumulated estimated input tokens this session
33
+ let _lastEvent = ''; // last milestone activity line (shown in info row)
34
+ let _logFile = null; // current session log path (set from activity-log)
35
+ let _model = ''; // active model string (e.g. 'haiku-4.5')
36
+ let _prevRows = 0; // last known terminal height — used to erase old bar on resize
37
+
38
+ // ─── Terminal primitives ──────────────────────────────────────────────────────
39
+
40
+ const write = s => IS_TTY && process.stdout.write(s);
41
+ const rows = () => (IS_TTY && process.stdout.rows) || 24;
42
+ const cols = () => (IS_TTY && process.stdout.columns) || 80;
43
+ const save = () => write('\x1b7');
44
+ const restore = () => write('\x1b8');
45
+ const goTo = (r, c) => write(`\x1b[${r};${c}H`);
46
+ const clrLine = () => write('\x1b[2K');
47
+
48
+ const BAR_ROWS = 2; // number of rows reserved at the bottom
49
+
50
+ function setScrollRegion(top, bottom) {
51
+ write(`\x1b[${top};${bottom}r`);
52
+ }
53
+
54
+ // ─── Agent colour map ─────────────────────────────────────────────────────────
55
+
56
+ const AGENT_COLOR = { GEMINI: 'cyan', CLAUDE: 'magenta', COPILOT: 'blue', PIPELINE: 'yellow' };
57
+
58
+ // ─── Elapsed time formatter ───────────────────────────────────────────────────
59
+
60
+ function fmtElapsed(startMs) {
61
+ const s = Math.floor((Date.now() - startMs) / 1000);
62
+ const m = Math.floor(s / 60);
63
+ return m > 0 ? `${m}m ${(s % 60).toString().padStart(2, '0')}s` : `${s}s`;
64
+ }
65
+
66
+ // ─── Truncate to fit within N visible chars ───────────────────────────────────
67
+
68
+ function fit(str, maxLen) {
69
+ if (!str) return '';
70
+ return str.length > maxLen ? str.slice(0, maxLen - 1) + '…' : str;
71
+ }
72
+
73
+ // ─── Draw both rows ───────────────────────────────────────────────────────────
74
+
75
+ function draw() {
76
+ if (!IS_TTY || !_installed || _paused) return;
77
+
78
+ const r = rows();
79
+ const c = cols();
80
+
81
+ save();
82
+
83
+ // ── Erase old bar rows when terminal is resized ───────────────────────────
84
+ // If rows() changed (terminal grew or shrank), the old bar content is still
85
+ // painted at the old physical rows. Erase those rows before drawing the new
86
+ // bar so no duplicate/ghost lines are left behind.
87
+ if (_prevRows > 0 && _prevRows !== r) {
88
+ goTo(_prevRows - 1, 1); clrLine();
89
+ goTo(_prevRows, 1); clrLine();
90
+ }
91
+ _prevRows = r;
92
+
93
+ // ── Row N-1: Agent activity row ───────────────────────────────────────────
94
+ const agentRow = r - 1;
95
+ goTo(agentRow, 1);
96
+ clrLine();
97
+
98
+ let agentLine;
99
+ if (_agent) {
100
+ const aColor = AGENT_COLOR[_agent] || 'white';
101
+ const elapsed = _startTime ? fmtElapsed(_startTime) : '0s';
102
+
103
+ // Build right side first (fixed width) — elapsed + tokens + model
104
+ // Right side is always visible; task gets whatever space remains.
105
+ const tokPlain = _sessionTokens > 0
106
+ ? ` ~${(_sessionTokens / 1000).toFixed(1)}k tok`
107
+ : '';
108
+ const modelPlain = _model ? ` [${_model}]` : '';
109
+ const rightPlain = ` ${elapsed}${tokPlain}${modelPlain} `;
110
+ const rightLen = rightPlain.length;
111
+
112
+ // Left: " AGENT · " prefix (fixed) + task (fills remaining space)
113
+ const prefixPlain = ` ${_agent} · `;
114
+ const taskLen = Math.max(6, c - prefixPlain.length - rightLen);
115
+ const task = fit(_task, taskLen);
116
+
117
+ agentLine = chalk.bgBlack(
118
+ ` ${chalk[aColor].bold(_agent)}` +
119
+ chalk.dim(' · ') +
120
+ chalk.white(task) +
121
+ chalk.dim(` ${elapsed}`) +
122
+ chalk.dim(tokPlain) +
123
+ chalk.dim(modelPlain) +
124
+ ' '
125
+ );
126
+ } else {
127
+ // Idle agent row: show "ready" hint + session token total
128
+ const tokPlain = _sessionTokens > 0
129
+ ? ` session ~${(_sessionTokens / 1000).toFixed(1)}k tok`
130
+ : '';
131
+ const idlePlain = ` ready · select an action${tokPlain}`;
132
+ const pad = Math.max(0, c - idlePlain.length - 1);
133
+ agentLine = chalk.bgBlack(chalk.dim(idlePlain) + ' '.repeat(pad) + ' ');
134
+ }
135
+ process.stdout.write(agentLine);
136
+
137
+ // ── Row N: Info row — pipeline + feature + log file ───────────────────────
138
+ const infoRow = r;
139
+ goTo(infoRow, 1);
140
+ clrLine();
141
+
142
+ const st = getStatus();
143
+ const stage = st.stage || 'idle';
144
+ const feature = st.current_feature || '—';
145
+ const pipelineMode = st.pipeline_mode;
146
+
147
+ // Left: stage + feature + mode badge
148
+ const stageColour = {
149
+ idle: 'dim', approved: 'green', review_complete: 'green',
150
+ rejected: 'red', arch_complete: 'blue', implementation_complete: 'magenta',
151
+ }[stage] || 'yellow';
152
+
153
+ const modeStr = pipelineMode === 'auto' ? ' · auto' : '';
154
+ const modePlain = modeStr.length;
155
+
156
+ const leftParts = [
157
+ chalk[stageColour](` ${stage}`),
158
+ chalk.dim(' · '),
159
+ chalk.dim(fit(feature, 28)),
160
+ pipelineMode === 'auto'
161
+ ? chalk.dim(' · ') + chalk.cyan('auto')
162
+ : '',
163
+ ];
164
+ const leftStr = leftParts.join('');
165
+
166
+ // Right: last event + log file shortname
167
+ const logShort = _logFile
168
+ ? chalk.dim(' log:' + _logFile.split('/').pop())
169
+ : '';
170
+ const lastEvt = _lastEvent
171
+ ? chalk.dim(` ${fit(_lastEvent, 38)}`)
172
+ : '';
173
+
174
+ // Plain lengths for padding
175
+ const leftPlain = ` ${stage} · ${fit(feature, 28)}`.length + modePlain;
176
+ const rightPlain = (_lastEvent ? fit(_lastEvent, 38).length + 2 : 0) +
177
+ (_logFile ? _logFile.split('/').pop().length + 6 : 0);
178
+ const pad = Math.max(0, c - leftPlain - rightPlain - 1);
179
+
180
+ const infoLine = chalk.bgBlack(
181
+ leftStr +
182
+ ' '.repeat(pad) +
183
+ lastEvt +
184
+ logShort +
185
+ ' '
186
+ );
187
+ process.stdout.write(infoLine);
188
+
189
+ restore();
190
+ }
191
+
192
+ // ─── Public API ───────────────────────────────────────────────────────────────
193
+
194
+ export const statusBar = {
195
+
196
+ get installed() { return _installed; },
197
+ get sessionTokens() { return _sessionTokens; },
198
+
199
+ /**
200
+ * Call once at startup.
201
+ * Pass the inquirer instance to auto-pause during every prompt.
202
+ */
203
+ install(inquirerInstance) {
204
+ if (!IS_TTY || _installed) return;
205
+ _installed = true;
206
+
207
+ setScrollRegion(1, rows() - BAR_ROWS);
208
+
209
+ // Re-apply scroll region + redraw on terminal resize.
210
+ // draw() erases old bar rows first (via _prevRows check) so no ghost lines remain.
211
+ process.stdout.on('resize', () => {
212
+ setScrollRegion(1, rows() - BAR_ROWS);
213
+ draw();
214
+ });
215
+
216
+ // 1-second redraw drives the elapsed timer
217
+ _timer = setInterval(draw, 1000);
218
+ draw();
219
+
220
+ // Restore terminal on exit
221
+ const cleanup = () => {
222
+ if (_timer) clearInterval(_timer);
223
+ setScrollRegion(1, rows()); // restore full scroll region
224
+ // Clear both bar rows
225
+ save();
226
+ goTo(rows() - 1, 1); clrLine();
227
+ goTo(rows(), 1); clrLine();
228
+ restore();
229
+ };
230
+ process.on('exit', cleanup);
231
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
232
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
233
+
234
+ // Auto-pause during every inquirer.prompt() call
235
+ if (inquirerInstance) {
236
+ const _orig = inquirerInstance.prompt.bind(inquirerInstance);
237
+ inquirerInstance.prompt = async (...args) => {
238
+ statusBar.pause();
239
+ try {
240
+ return await _orig(...args);
241
+ } finally {
242
+ statusBar.resume();
243
+ }
244
+ };
245
+ }
246
+ },
247
+
248
+ /**
249
+ * Re-apply scroll region + redraw after console.clear() in printHeader().
250
+ */
251
+ reinstall() {
252
+ if (!IS_TTY || !_installed) return;
253
+ setScrollRegion(1, rows() - BAR_ROWS);
254
+ draw();
255
+ },
256
+
257
+ /**
258
+ * Set active AI agent.
259
+ * @param {string} agent 'GEMINI' | 'CLAUDE' | 'COPILOT'
260
+ * @param {string} task Short description shown in agent row
261
+ * @param {number} estimatedTokens Rough input token count (chars / 4)
262
+ */
263
+ set(agent, task, estimatedTokens = 0) {
264
+ _agent = agent;
265
+ _task = (task || '').replace(/\n/g, ' ').trim();
266
+ _startTime = Date.now();
267
+ _sessionTokens += estimatedTokens;
268
+ // Derive model hint — respect GEMINI_MODEL env var when set
269
+ const geminiModel = process.env.GEMINI_MODEL || 'gemini-2.5-pro';
270
+ _model = agent === 'COPILOT' ? 'copilot'
271
+ : agent === 'GEMINI' ? geminiModel
272
+ : agent === 'CLAUDE' ? 'claude-sonnet-4-6'
273
+ : '';
274
+ draw();
275
+ },
276
+
277
+ /** Mark current task as done. Agent row returns to idle. */
278
+ clear() {
279
+ _agent = null;
280
+ _task = '';
281
+ _startTime = null;
282
+ _model = '';
283
+ draw();
284
+ },
285
+
286
+ /**
287
+ * Update the last milestone event shown in the info row.
288
+ * Call from logActivity() so the bar always reflects the latest event.
289
+ */
290
+ setLastEvent(ai, message) {
291
+ const prefix = ai ? `${ai}: ` : '';
292
+ _lastEvent = prefix + message;
293
+ draw();
294
+ },
295
+
296
+ /** Set the session log file path for display in the info row. */
297
+ setLogFile(path) {
298
+ _logFile = path;
299
+ draw();
300
+ },
301
+
302
+ /** Reset the session token counter (e.g. start of a new feature). */
303
+ resetTokens() {
304
+ _sessionTokens = 0;
305
+ draw();
306
+ },
307
+
308
+ /**
309
+ * Pause the bar and clear both bar rows.
310
+ * Call before anything that writes to the terminal.
311
+ */
312
+ pause() {
313
+ if (!IS_TTY || !_installed) return;
314
+ _paused = true;
315
+ save();
316
+ goTo(rows() - 1, 1); clrLine();
317
+ goTo(rows(), 1); clrLine();
318
+ restore();
319
+ },
320
+
321
+ /** Resume drawing after a pause. */
322
+ resume() {
323
+ if (!IS_TTY || !_installed) return;
324
+ _paused = false;
325
+ draw();
326
+ },
327
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Pre-flight Token Estimation.
3
+ *
4
+ * Provides heuristic token counts, cost estimates, and budget checks
5
+ * before sending prompts to AI models.
6
+ */
7
+
8
+ import { bus } from '../shared/event-bus.js';
9
+
10
+ /** Per-1K-token pricing for supported models. */
11
+ const PRICING = {
12
+ 'gemini-2.5-pro': { input: 0.00125, output: 0.005 },
13
+ 'claude-sonnet-4.6': { input: 0.003, output: 0.015 },
14
+ 'claude-haiku-4.5': { input: 0.0008, output: 0.004 },
15
+ 'copilot': { input: 0, output: 0 },
16
+ };
17
+
18
+ /**
19
+ * Estimate token count from raw text.
20
+ *
21
+ * Heuristics:
22
+ * English prose ≈ 1 token per 0.75 words
23
+ * Code ≈ 1 token per 0.50 words
24
+ *
25
+ * We detect code-heavy text by the ratio of non-alpha characters.
26
+ */
27
+ export function estimateTokens(text) {
28
+ if (!text) return { tokens: 0, words: 0, chars: 0 };
29
+
30
+ const chars = text.length;
31
+ const words = text.split(/\s+/).filter(Boolean).length;
32
+
33
+ const nonAlpha = (text.match(/[^a-zA-Z\s]/g) || []).length;
34
+ const codeRatio = chars > 0 ? nonAlpha / chars : 0;
35
+ const wordsPerToken = codeRatio > 0.3 ? 0.5 : 0.75;
36
+
37
+ const tokens = Math.ceil(words / wordsPerToken);
38
+
39
+ return { tokens, words, chars };
40
+ }
41
+
42
+ /**
43
+ * Estimate cost for a given token count and model.
44
+ *
45
+ * @returns {{ inputCost: number, outputCost: number, totalCost: number, model: string }}
46
+ */
47
+ export function estimateCost(tokens, model) {
48
+ const pricing = PRICING[model] || PRICING['copilot'];
49
+ const inputCost = (tokens / 1000) * pricing.input;
50
+ const outputCost = (tokens / 1000) * pricing.output;
51
+
52
+ return {
53
+ inputCost: Math.round(inputCost * 1_000_000) / 1_000_000,
54
+ outputCost: Math.round(outputCost * 1_000_000) / 1_000_000,
55
+ totalCost: Math.round((inputCost + outputCost) * 1_000_000) / 1_000_000,
56
+ model,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Check whether estimated tokens exceed a budget threshold.
62
+ * Emits a 'budget:warning' event via the shared bus when usage > 80 %.
63
+ *
64
+ * @param {string} prompt – the text to estimate
65
+ * @param {string} stage – pipeline stage name
66
+ * @param {number} budget – maximum allowed tokens
67
+ * @returns {{ ok: boolean, estimated: number, budget: number, percentUsed: number, warning: string|null }}
68
+ */
69
+ export function checkBudget(prompt, stage, budget) {
70
+ const { tokens: estimated } = estimateTokens(prompt);
71
+ const percentUsed = budget > 0 ? Math.round((estimated / budget) * 100) : 0;
72
+ let warning = null;
73
+
74
+ if (percentUsed > 100) {
75
+ warning = `Token budget exceeded for "${stage}": ${estimated}/${budget} (${percentUsed}%)`;
76
+ } else if (percentUsed > 80) {
77
+ warning = `Approaching token budget for "${stage}": ${estimated}/${budget} (${percentUsed}%)`;
78
+ }
79
+
80
+ if (warning) {
81
+ bus.emit('budget:warning', { stage, estimated, budget, percentUsed, warning });
82
+ }
83
+
84
+ return {
85
+ ok: percentUsed <= 100,
86
+ estimated,
87
+ budget,
88
+ percentUsed,
89
+ warning,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Format a token estimate as a human-readable string.
95
+ */
96
+ export function formatEstimate(estimate) {
97
+ if (!estimate) return 'No estimate available';
98
+
99
+ const { tokens, words, chars } = estimate;
100
+ return `~${tokens.toLocaleString()} tokens (${words.toLocaleString()} words, ${chars.toLocaleString()} chars)`;
101
+ }