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,191 @@
1
+ /**
2
+ * Security Module — credential sanitization, cost guard, route exclusion,
3
+ * form safety, and agent authority enforcement.
4
+ */
5
+
6
+ import { readFileSync, existsSync, appendFileSync } from 'fs';
7
+ import { resolve } from 'path';
8
+ import { getWorkflowDir } from './pipeline.js';
9
+
10
+ // ─── Route Exclusion ────────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Check if a URL matches any exclusion pattern.
14
+ * Uses simple glob-style matching (supports * wildcard).
15
+ */
16
+ export function isExcluded(url, excludePatterns) {
17
+ if (!excludePatterns || excludePatterns.length === 0) return false;
18
+
19
+ let pathname;
20
+ try {
21
+ pathname = new URL(url).pathname;
22
+ } catch {
23
+ pathname = url;
24
+ }
25
+
26
+ return excludePatterns.some(pattern => {
27
+ // Convert glob pattern to regex
28
+ const escaped = pattern
29
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
30
+ .replace(/\*/g, '.*');
31
+ return new RegExp(`^${escaped}$`).test(pathname);
32
+ });
33
+ }
34
+
35
+ // ─── Form Safety ────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Check if a form is safe to submit.
39
+ * NEVER submit POST forms unless action is in allowedFormActions.
40
+ */
41
+ export function isFormSafe(action, method, allowedActions = []) {
42
+ // Never submit forms with dangerous keywords in action
43
+ if (/delete|remove|destroy|purge|deactivate/i.test(action || '')) {
44
+ return false;
45
+ }
46
+
47
+ // Never submit POST forms unless whitelisted
48
+ if ((method || 'GET').toUpperCase() === 'POST') {
49
+ if (!allowedActions.some(a => (action || '').includes(a))) {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ return true;
55
+ }
56
+
57
+ // ─── Credential Security ────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Credential resolver — loads QA credentials from multiple sources.
61
+ * Priority: env vars > config file > vault (future)
62
+ *
63
+ * NEVER log credentials. NEVER write credentials to reports.
64
+ * NEVER include credentials in AI prompts.
65
+ */
66
+ export function resolveCredentials(config) {
67
+ const envPrefix = config?.envPrefix || 'AICC';
68
+
69
+ return {
70
+ email: process.env[`${envPrefix}_QA_EMAIL`] || config?.browserQA?.credentials?.email,
71
+ password: process.env[`${envPrefix}_QA_PASSWORD`] || config?.browserQA?.credentials?.password,
72
+ loginUrl: config?.browserQA?.credentials?.loginUrl,
73
+ loginSelectors: config?.browserQA?.credentials?.loginSelectors,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Sanitize function — strip credentials from any string before logging/reporting.
79
+ */
80
+ export function sanitize(text, config) {
81
+ if (!text) return text;
82
+
83
+ const creds = resolveCredentials(config);
84
+ let cleaned = text;
85
+ if (creds.email) cleaned = cleaned.replaceAll(creds.email, '[REDACTED_EMAIL]');
86
+ if (creds.password) cleaned = cleaned.replaceAll(creds.password, '[REDACTED_PASSWORD]');
87
+
88
+ // Also strip common env var patterns (API keys, tokens)
89
+ cleaned = cleaned.replace(/(?:sk_|pk_|ghp_|whsec_|gho_|glpat-)[a-zA-Z0-9_-]+/g, '[REDACTED_TOKEN]');
90
+
91
+ return cleaned;
92
+ }
93
+
94
+ // ─── Cost Guard ─────────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Cost guard — tracks AI spend per pipeline run.
98
+ * Pauses pipeline and escalates if spend exceeds threshold.
99
+ */
100
+ export function getCurrentRunCost() {
101
+ const costsFile = resolve(getWorkflowDir(), 'costs.jsonl');
102
+ if (!existsSync(costsFile)) return 0;
103
+
104
+ let lines;
105
+ try {
106
+ lines = readFileSync(costsFile, 'utf8').trim().split('\n');
107
+ } catch {
108
+ return 0;
109
+ }
110
+
111
+ const currentRun = [];
112
+
113
+ // Read from bottom — find last "pipeline_start" marker
114
+ for (let i = lines.length - 1; i >= 0; i--) {
115
+ try {
116
+ const entry = JSON.parse(lines[i]);
117
+ currentRun.push(entry);
118
+ if (entry.event === 'pipeline_start') {
119
+ break;
120
+ }
121
+ } catch { continue; }
122
+ }
123
+
124
+ return currentRun.reduce((sum, e) => sum + (e.cost_usd || 0), 0);
125
+ }
126
+
127
+ export function checkCostGuard(config) {
128
+ const maxCost = config?.costGuard?.maxPerRun || 5.0; // $5 default
129
+ const current = getCurrentRunCost();
130
+
131
+ if (current >= maxCost) {
132
+ return {
133
+ exceeded: true,
134
+ current,
135
+ max: maxCost,
136
+ message: `AI spend $${current.toFixed(2)} exceeded limit $${maxCost.toFixed(2)} — escalating to CEO`,
137
+ };
138
+ }
139
+
140
+ return { exceeded: false, current, max: maxCost };
141
+ }
142
+
143
+ // ─── Agent Authority ────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Authority check — ensures agents only do what they're allowed to.
147
+ * See Decision Authority Matrix in V1 doc.
148
+ */
149
+ const AUTHORITY = {
150
+ PM: { canDeploy: false, canEscalate: true, canRetry: true, canCreateIssue: false, canSpendOver5: false },
151
+ CODER: { canDeploy: false, canEscalate: false, canRetry: false, canCreateIssue: false, canSpendOver5: false },
152
+ QA: { canDeploy: false, canEscalate: true, canRetry: true, canCreateIssue: true, canSpendOver5: false },
153
+ REVIEWER: { canDeploy: false, canEscalate: true, canRetry: false, canCreateIssue: false, canSpendOver5: false },
154
+ DEPLOYER: { canDeploy: true, canEscalate: true, canRetry: true, canCreateIssue: false, canSpendOver5: false },
155
+ };
156
+
157
+ export function checkAuthority(agent, action) {
158
+ const perms = AUTHORITY[agent];
159
+ if (!perms) throw new Error(`Unknown agent: ${agent}`);
160
+
161
+ const key = `can${action.charAt(0).toUpperCase() + action.slice(1)}`;
162
+ if (perms[key] === false) {
163
+ return { allowed: false, reason: `${agent} is not authorized to ${action}. Requires CEO approval.` };
164
+ }
165
+ return { allowed: true };
166
+ }
167
+
168
+ // ─── Pipeline History ───────────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Record a completed pipeline run.
172
+ */
173
+ export function recordPipelineRun(data) {
174
+ const historyFile = resolve(getWorkflowDir(), 'pipeline-history.jsonl');
175
+ const entry = JSON.stringify({
176
+ ...data,
177
+ completedAt: new Date().toISOString(),
178
+ });
179
+ try {
180
+ appendFileSync(historyFile, entry + '\n');
181
+ } catch { /* best effort */ }
182
+ }
183
+
184
+ // ─── Session Validity ───────────────────────────────────────────────────────
185
+
186
+ /**
187
+ * Check if browser session is still valid (not redirected to login page).
188
+ */
189
+ export function isSessionExpired(currentUrl) {
190
+ return /login|signin|auth\/(login|signin)|sign-in/i.test(currentUrl);
191
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Self-Healing Pipeline — automatically runs tests after implementation
3
+ * and sends failures back to Copilot for fixing (up to N retries).
4
+ */
5
+ import { execSync } from 'child_process';
6
+ import { logActivity } from './activity-log.js';
7
+ import { getRootDir } from './pipeline.js';
8
+
9
+ const DEFAULT_MAX_ATTEMPTS = 3;
10
+ const DEFAULT_TEST_TIMEOUT = 120_000;
11
+
12
+ /**
13
+ * Run tests after implementation and attempt self-healing on failure.
14
+ *
15
+ * @param {string} featureId
16
+ * @param {object} config - { selfHeal: { enabled, maxAttempts, testCommand } }
17
+ * @param {Function} runCopilotFn - async (prompt, opts) => string
18
+ * @returns {{ success: boolean, attempts: number, errors: string[] }}
19
+ */
20
+ export async function postImplValidation(featureId, config, runCopilotFn) {
21
+ const selfHeal = config?.selfHeal || {};
22
+ if (selfHeal.enabled === false) {
23
+ return { success: true, attempts: 0, errors: [], skipped: true };
24
+ }
25
+
26
+ const testCommand = selfHeal.testCommand || 'npm test';
27
+ const maxAttempts = selfHeal.maxAttempts || DEFAULT_MAX_ATTEMPTS;
28
+ const timeout = selfHeal.testTimeout || DEFAULT_TEST_TIMEOUT;
29
+ const root = getRootDir();
30
+ const errors = [];
31
+
32
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
33
+ logActivity('PIPELINE', `Self-heal: running tests (attempt ${attempt}/${maxAttempts})`, 'info');
34
+
35
+ try {
36
+ execSync(testCommand, {
37
+ cwd: root,
38
+ encoding: 'utf8',
39
+ timeout,
40
+ stdio: ['pipe', 'pipe', 'pipe'],
41
+ });
42
+
43
+ logActivity('PIPELINE', `Self-heal: all tests pass after ${attempt} attempt(s)`, 'success');
44
+ return { success: true, attempts: attempt, errors };
45
+ } catch (err) {
46
+ const stderr = err.stderr || '';
47
+ const stdout = err.stdout || '';
48
+ const errorOutput = (stderr + '\n' + stdout).trim().slice(0, 5000);
49
+ errors.push(errorOutput);
50
+
51
+ logActivity('PIPELINE', `Self-heal: tests failed (attempt ${attempt}/${maxAttempts}) — ${errorOutput.split('\n')[0]}`, 'warn');
52
+
53
+ if (attempt < maxAttempts && runCopilotFn) {
54
+ const healPrompt =
55
+ `The following test failures occurred after implementing feature ${featureId}.\n` +
56
+ `Fix ONLY the test failures. Do NOT break existing functionality.\n` +
57
+ `Do NOT rewrite tests to make them pass — fix the actual code.\n\n` +
58
+ `Test command: ${testCommand}\n\n` +
59
+ `Error output:\n\`\`\`\n${errorOutput}\n\`\`\``;
60
+
61
+ try {
62
+ await runCopilotFn(healPrompt, {
63
+ featureId,
64
+ stage: 'self-heal',
65
+ sessionResume: true,
66
+ });
67
+ } catch (healErr) {
68
+ logActivity('PIPELINE', `Self-heal: Copilot fix attempt failed — ${healErr.message}`, 'error');
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ logActivity('PIPELINE', `Self-heal: exhausted ${maxAttempts} attempts — proceeding with test failures`, 'error');
75
+ return { success: false, attempts: maxAttempts, errors };
76
+ }
77
+
78
+ /**
79
+ * Check whether the test command is available and runs without hanging.
80
+ */
81
+ export function isTestCommandAvailable(config) {
82
+ const testCommand = config?.selfHeal?.testCommand || 'npm test';
83
+ const root = getRootDir();
84
+ try {
85
+ const cmd = testCommand.split(' ')[0];
86
+ execSync(`which ${cmd} 2>/dev/null || where ${cmd} 2>/dev/null`, {
87
+ cwd: root,
88
+ encoding: 'utf8',
89
+ timeout: 5000,
90
+ stdio: 'pipe',
91
+ });
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Post-implementation browser QA validation.
100
+ * Runs browser tests after implementation and attempts self-healing on failure.
101
+ * Call this AFTER postImplValidation() if browserQA is enabled.
102
+ */
103
+ export async function postImplBrowserValidation(featureId, config) {
104
+ if (!config?.browserQA?.enabled) {
105
+ return { success: true, skipped: true, reason: 'browserQA disabled' };
106
+ }
107
+
108
+ const { browserTestAction } = await import('../actions/browser-test.js');
109
+ const maxAttempts = config.browserQA?.maxBugfixCycles || 3;
110
+ const errors = [];
111
+
112
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
113
+ logActivity('SELF-HEAL', `Browser QA validation attempt ${attempt}/${maxAttempts}`, 'info');
114
+
115
+ const result = await browserTestAction({ featureId, config });
116
+
117
+ if (result.success || result.skipped) {
118
+ logActivity('SELF-HEAL', `Browser QA passed on attempt ${attempt}`, 'success');
119
+ return { success: true, attempts: attempt, errors };
120
+ }
121
+
122
+ errors.push(`Attempt ${attempt}: ${result.failCount} page(s) failed`);
123
+
124
+ if (attempt < maxAttempts && result.report) {
125
+ const { runCopilot } = await import('./runner.js');
126
+ const fixPrompt = buildBrowserFixPrompt(result.report, featureId);
127
+ await runCopilot(fixPrompt, { featureId, stage: 'browser-heal' }).catch(() => {});
128
+ }
129
+ }
130
+
131
+ logActivity('SELF-HEAL', `Browser QA failed after ${maxAttempts} attempts`, 'error');
132
+ return { success: false, attempts: maxAttempts, errors };
133
+ }
134
+
135
+ function buildBrowserFixPrompt(qaReport, featureId) {
136
+ return (
137
+ `Browser QA tests failed after implementing feature ${featureId}.\n` +
138
+ `Fix the following browser errors without breaking other pages.\n\n` +
139
+ `Failed pages:\n` +
140
+ qaReport.failed.map(f =>
141
+ `- ${f.url}\n Errors: ${f.errors.join('; ')}`
142
+ ).join('\n')
143
+ );
144
+ }
@@ -0,0 +1,255 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, copyFileSync } from 'fs';
2
+ import { resolve, join, basename, dirname } from 'path';
3
+ import { homedir } from 'os';
4
+ import { fileURLToPath } from 'url';
5
+ import { getRootDir, getWorkflowDir } from './pipeline.js';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ const VALID_STAGES = ['capture', 'spec', 'arch', 'impl', 'review', 'all'];
10
+ let _skillsEnsured = false;
11
+
12
+ function getProjectSkillsDir() {
13
+ return join(getWorkflowDir(), 'skills');
14
+ }
15
+
16
+ /**
17
+ * Ensure pipeline skills exist in .ai-workflow/skills/.
18
+ * Auto-generates from templates on first call if directory is empty.
19
+ * This guarantees skills are available even if user never ran `aicc start` or `aicc openclaw`.
20
+ */
21
+ function ensureSkills() {
22
+ if (_skillsEnsured) return;
23
+ _skillsEnsured = true;
24
+
25
+ const dir = getProjectSkillsDir();
26
+ // If skills directory already has .md files, nothing to do
27
+ if (existsSync(dir)) {
28
+ try {
29
+ const files = readdirSync(dir).filter(f => f.endsWith('.md'));
30
+ if (files.length > 0) return;
31
+ } catch { /* fall through to generate */ }
32
+ }
33
+
34
+ // Auto-generate skills from templates
35
+ try {
36
+ // Locate templates relative to this file (lib/utils/ → ../../templates/)
37
+ const templateDir = resolve(__dirname, '../../templates');
38
+ if (!existsSync(templateDir)) return; // Not in package context
39
+
40
+ // Minimal template vars — read project name from aicc.config.js
41
+ const root = getRootDir();
42
+ const configPath = join(root, 'aicc.config.js');
43
+ let projectName = 'project';
44
+ let projectDesc = '';
45
+ if (existsSync(configPath)) {
46
+ const raw = readFileSync(configPath, 'utf-8');
47
+ const nameMatch = raw.match(/name\s*:\s*['"]([^'"]+)['"]/);
48
+ if (nameMatch) projectName = nameMatch[1];
49
+ const descMatch = raw.match(/description\s*:\s*['"]([^'"]+)['"]/);
50
+ if (descMatch) projectDesc = descMatch[1];
51
+ }
52
+
53
+ const vars = {
54
+ PROJECT_NAME: projectName,
55
+ DESCRIPTION: projectDesc || `AI Control Center for ${projectName}`,
56
+ PROJECT_ROOT: root,
57
+ PROJECT_DESCRIPTION: projectDesc || `AI Control Center for ${projectName}`,
58
+ STAGES: '',
59
+ QUERIES: '_No custom queries configured._',
60
+ WORKFLOW_DIR: getWorkflowDir(),
61
+ EXTRA_COMMANDS: '',
62
+ };
63
+
64
+ mkdirSync(dir, { recursive: true });
65
+
66
+ const STAGE_TEMPLATES = [
67
+ { template: 'skill-pm-spec.md', output: 'SKILL-pm-spec.md', stage: 'spec' },
68
+ { template: 'skill-architect.md', output: 'SKILL-architect.md', stage: 'arch' },
69
+ { template: 'skill-implement.md', output: 'SKILL-implement.md', stage: 'impl' },
70
+ { template: 'skill-review.md', output: 'SKILL-review.md', stage: 'review' },
71
+ { template: 'skill-requirement-capture.md', output: 'SKILL-requirement-capture.md', stage: 'capture' },
72
+ ];
73
+
74
+ for (const def of STAGE_TEMPLATES) {
75
+ const tplPath = resolve(templateDir, def.template);
76
+ if (!existsSync(tplPath)) continue;
77
+ let content = readFileSync(tplPath, 'utf-8');
78
+ for (const [key, value] of Object.entries(vars)) {
79
+ content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
80
+ }
81
+ writeFileSync(join(dir, def.output), content);
82
+ }
83
+ } catch {
84
+ // Non-fatal — pipeline works without skills
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get the OpenClaw skills directory for the current project.
90
+ * Reads project name from aicc.config.js at project root to find
91
+ * ~/.openclaw/skills/<project-slug>/
92
+ */
93
+ function getOpenClawSkillsDir() {
94
+ try {
95
+ // Read project name from aicc.config.js without importing config module
96
+ // to avoid circular dependencies. We just need the name field.
97
+ const root = getRootDir();
98
+ const configPath = join(root, 'aicc.config.js');
99
+ if (existsSync(configPath)) {
100
+ const raw = readFileSync(configPath, 'utf-8');
101
+ // Extract name from: name: 'ProjectName' or name: "ProjectName"
102
+ const nameMatch = raw.match(/name\s*:\s*['"]([^'"]+)['"]/);
103
+ if (nameMatch) {
104
+ const slug = nameMatch[1]
105
+ .toLowerCase()
106
+ .replace(/[^a-z0-9]+/g, '-')
107
+ .replace(/^-|-$/g, '');
108
+ const dir = join(homedir(), '.openclaw', 'skills', slug);
109
+ if (existsSync(dir)) return dir;
110
+ }
111
+ }
112
+ } catch {
113
+ // Not in a project, or config not parseable
114
+ }
115
+ return null;
116
+ }
117
+
118
+ function getGlobalSkillsDir() {
119
+ return join(homedir(), '.aicc', 'skills');
120
+ }
121
+
122
+ /**
123
+ * Parse frontmatter and body from a SKILL.md file.
124
+ * Expects --- delimited YAML-style frontmatter.
125
+ */
126
+ function parseSkillFile(filePath) {
127
+ const raw = readFileSync(filePath, 'utf-8');
128
+ const meta = { name: '', stage: 'all', description: '', tags: [] };
129
+ let body = raw;
130
+
131
+ const fmMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
132
+ if (fmMatch) {
133
+ const frontmatter = fmMatch[1];
134
+ body = fmMatch[2];
135
+
136
+ for (const line of frontmatter.split('\n')) {
137
+ const m = line.match(/^(\w+):\s*(.+)$/);
138
+ if (!m) continue;
139
+ const [, key, value] = m;
140
+ if (key === 'name') meta.name = value.trim();
141
+ else if (key === 'stage') meta.stage = value.trim();
142
+ else if (key === 'description') meta.description = value.trim();
143
+ else if (key === 'tags') meta.tags = value.split(',').map(t => t.trim()).filter(Boolean);
144
+ }
145
+ }
146
+
147
+ if (!meta.name) {
148
+ meta.name = basename(filePath, '.md');
149
+ }
150
+
151
+ return { ...meta, content: body.trim(), path: filePath };
152
+ }
153
+
154
+ /**
155
+ * Discover and parse all available skills from project and global directories.
156
+ * @returns {Array<{name: string, stage: string, description: string, tags: string[], content: string, path: string}>}
157
+ */
158
+ export function loadSkills() {
159
+ // Auto-generate skills from templates if .ai-workflow/skills/ is empty
160
+ ensureSkills();
161
+
162
+ const skills = [];
163
+ const seen = new Set();
164
+
165
+ // Priority order: project-local > OpenClaw project skills > global
166
+ const openclawDir = getOpenClawSkillsDir();
167
+ const dirs = [
168
+ getProjectSkillsDir(), // 1. .ai-workflow/skills/ (highest priority)
169
+ openclawDir, // 2. ~/.openclaw/skills/<project-slug>/
170
+ getGlobalSkillsDir(), // 3. ~/.aicc/skills/ (global fallback)
171
+ ];
172
+ for (const dir of dirs) {
173
+ if (!dir || !existsSync(dir)) continue;
174
+ const files = readdirSync(dir).filter(f => f.endsWith('.md'));
175
+ for (const file of files) {
176
+ try {
177
+ const skill = parseSkillFile(join(dir, file));
178
+ if (!seen.has(skill.name)) {
179
+ seen.add(skill.name);
180
+ skills.push(skill);
181
+ }
182
+ } catch {
183
+ // Skip unparseable skill files
184
+ }
185
+ }
186
+ }
187
+
188
+ return skills;
189
+ }
190
+
191
+ /**
192
+ * Return skills filtered for a specific pipeline stage.
193
+ * @param {string} stage - One of: spec, arch, impl, review
194
+ * @returns {Array} Skills matching the stage or scoped to 'all'
195
+ */
196
+ export function getSkillsForStage(stage) {
197
+ return loadSkills().filter(s => s.stage === stage || s.stage === 'all');
198
+ }
199
+
200
+ /**
201
+ * Append matching skill content to a prompt string.
202
+ * @param {string} prompt - The base prompt
203
+ * @param {string} stage - Pipeline stage to filter skills by
204
+ * @returns {string} Prompt with skill content appended
205
+ */
206
+ export function injectSkills(prompt, stage) {
207
+ const skills = getSkillsForStage(stage);
208
+ if (skills.length === 0) return prompt;
209
+
210
+ const sections = skills.map(s => `## Skill: ${s.name}\n${s.content}`);
211
+ return `${prompt}\n\n${sections.join('\n\n')}`;
212
+ }
213
+
214
+ /**
215
+ * Download/copy a skill from a URL or local path to the project skills directory.
216
+ * @param {string} source - URL (http/https) or local file path
217
+ * @returns {Promise<{name: string, path: string}>} Installed skill info
218
+ */
219
+ export async function installSkill(source) {
220
+ const dir = getProjectSkillsDir();
221
+ if (!existsSync(dir)) {
222
+ mkdirSync(dir, { recursive: true });
223
+ }
224
+
225
+ if (source.startsWith('http://') || source.startsWith('https://')) {
226
+ const res = await fetch(source);
227
+ if (!res.ok) throw new Error(`Failed to download skill: ${res.statusText}`);
228
+ const content = await res.text();
229
+ const filename = basename(new URL(source).pathname) || 'skill.md';
230
+ const dest = join(dir, filename);
231
+ writeFileSync(dest, content, 'utf-8');
232
+ const skill = parseSkillFile(dest);
233
+ return { name: skill.name, path: dest };
234
+ }
235
+
236
+ const srcPath = resolve(source);
237
+ if (!existsSync(srcPath)) throw new Error(`Skill file not found: ${srcPath}`);
238
+ const filename = basename(srcPath);
239
+ const dest = join(dir, filename);
240
+ copyFileSync(srcPath, dest);
241
+ const skill = parseSkillFile(dest);
242
+ return { name: skill.name, path: dest };
243
+ }
244
+
245
+ /**
246
+ * Return a formatted list of all available skills.
247
+ * @returns {Array<{name: string, stage: string, description: string}>}
248
+ */
249
+ export function listSkills() {
250
+ return loadSkills().map(s => ({
251
+ name: s.name,
252
+ stage: s.stage,
253
+ description: s.description,
254
+ }));
255
+ }