erne-universal 0.2.0 → 0.3.0
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/README.md +92 -26
- package/agents/feature-builder.md +88 -0
- package/agents/senior-developer.md +77 -0
- package/bin/cli.js +4 -2
- package/dashboard/package.json +10 -0
- package/dashboard/public/agents.js +329 -0
- package/dashboard/public/canvas.js +275 -0
- package/dashboard/public/index.html +113 -0
- package/dashboard/public/sidebar.js +107 -0
- package/dashboard/public/ws-client.js +69 -0
- package/dashboard/server.js +191 -0
- package/docs/assets/dashboard-preview.png +0 -0
- package/docs/superpowers/plans/2026-03-11-agent-dashboard.md +1537 -0
- package/docs/superpowers/specs/2026-03-11-agent-dashboard-design.md +275 -0
- package/hooks/hooks.json +14 -0
- package/lib/dashboard.js +156 -0
- package/lib/init.js +294 -0
- package/lib/start.js +26 -0
- package/lib/update.js +60 -0
- package/package.json +3 -1
- package/scripts/daily-news/scan-ai-agents.js +222 -0
- package/scripts/daily-news/scan-rn-expo.js +233 -0
- package/scripts/hooks/dashboard-event.js +89 -0
- package/scripts/sync/issue-to-clickup.js +108 -0
- package/scripts/validate-all.js +1 -1
package/lib/init.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// lib/init.js — Interactive project initializer
|
|
2
|
+
// Implements the 4-step install flow from spec Section 6
|
|
3
|
+
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const readline = require('readline/promises');
|
|
9
|
+
const { stdin, stdout } = require('process');
|
|
10
|
+
|
|
11
|
+
module.exports = async function init() {
|
|
12
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
13
|
+
const cwd = process.cwd();
|
|
14
|
+
|
|
15
|
+
console.log('\n erne — Setting up AI agent harness for React Native & Expo\n');
|
|
16
|
+
|
|
17
|
+
// ─── Step 1: Detect project type ───
|
|
18
|
+
console.log(' Step 1: Scanning project...');
|
|
19
|
+
const detection = detectProject(cwd);
|
|
20
|
+
printDetection(detection);
|
|
21
|
+
|
|
22
|
+
if (!detection.isRNProject) {
|
|
23
|
+
console.log('\n ⚠ No React Native project detected in current directory.');
|
|
24
|
+
const proceed = await rl.question(' Continue anyway? (y/N) ');
|
|
25
|
+
if (proceed.toLowerCase() !== 'y') {
|
|
26
|
+
console.log(' Aborted.');
|
|
27
|
+
rl.close();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Step 2: Choose hook profile ───
|
|
33
|
+
console.log('\n Step 2: Select hook profile:\n');
|
|
34
|
+
console.log(' (a) minimal — fast iteration, minimal checks');
|
|
35
|
+
console.log(' (b) standard — balanced quality + speed [recommended]');
|
|
36
|
+
console.log(' (c) strict — production-grade enforcement');
|
|
37
|
+
console.log();
|
|
38
|
+
|
|
39
|
+
let profileChoice = await rl.question(' Profile (a/b/c) [b]: ');
|
|
40
|
+
profileChoice = profileChoice.toLowerCase() || 'b';
|
|
41
|
+
const profileMap = { a: 'minimal', b: 'standard', c: 'strict' };
|
|
42
|
+
const profile = profileMap[profileChoice] || 'standard';
|
|
43
|
+
|
|
44
|
+
// ─── Step 3: Select MCP integrations ───
|
|
45
|
+
console.log('\n Step 3: MCP server integrations:\n');
|
|
46
|
+
|
|
47
|
+
const mcpSelections = {};
|
|
48
|
+
|
|
49
|
+
// Recommended servers
|
|
50
|
+
console.log(' Recommended:');
|
|
51
|
+
const agentDevice = await rl.question(' [Y/n] agent-device — Control iOS Simulator & Android Emulator: ');
|
|
52
|
+
mcpSelections['agent-device'] = agentDevice.toLowerCase() !== 'n';
|
|
53
|
+
|
|
54
|
+
const github = await rl.question(' [Y/n] GitHub — PR management, issue tracking: ');
|
|
55
|
+
mcpSelections['github'] = github.toLowerCase() !== 'n';
|
|
56
|
+
|
|
57
|
+
// Optional servers
|
|
58
|
+
console.log('\n Optional (press Enter to skip):');
|
|
59
|
+
const optionalServers = [
|
|
60
|
+
{ key: 'supabase', label: 'Supabase — Database & auth' },
|
|
61
|
+
{ key: 'firebase', label: 'Firebase — Analytics & push' },
|
|
62
|
+
{ key: 'figma', label: 'Figma — Design token sync' },
|
|
63
|
+
{ key: 'sentry', label: 'Sentry — Error tracking' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
for (const server of optionalServers) {
|
|
67
|
+
const answer = await rl.question(` [y/N] ${server.label}: `);
|
|
68
|
+
mcpSelections[server.key] = answer.toLowerCase() === 'y';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
rl.close();
|
|
72
|
+
|
|
73
|
+
// ─── Step 4: Generate config ───
|
|
74
|
+
console.log('\n Step 4: Generating configuration...\n');
|
|
75
|
+
|
|
76
|
+
const erneRoot = path.resolve(__dirname, '..');
|
|
77
|
+
const claudeDir = path.join(cwd, '.claude');
|
|
78
|
+
|
|
79
|
+
// Ensure .claude/ exists
|
|
80
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
// Copy agents
|
|
83
|
+
copyDir(path.join(erneRoot, 'agents'), path.join(claudeDir, 'agents'));
|
|
84
|
+
console.log(' ✓ .claude/agents/ (8 agents)');
|
|
85
|
+
|
|
86
|
+
// Copy commands
|
|
87
|
+
copyDir(path.join(erneRoot, 'commands'), path.join(claudeDir, 'commands'));
|
|
88
|
+
console.log(' ✓ .claude/commands/ (16 commands)');
|
|
89
|
+
|
|
90
|
+
// Copy applicable rules
|
|
91
|
+
const ruleLayers = determineRuleLayers(detection);
|
|
92
|
+
const rulesTarget = path.join(claudeDir, 'rules');
|
|
93
|
+
fs.mkdirSync(rulesTarget, { recursive: true });
|
|
94
|
+
for (const layer of ruleLayers) {
|
|
95
|
+
copyDir(path.join(erneRoot, 'rules', layer), path.join(rulesTarget, layer));
|
|
96
|
+
}
|
|
97
|
+
console.log(` ✓ .claude/rules/ (layers: ${ruleLayers.join(', ')})`);
|
|
98
|
+
|
|
99
|
+
// Copy selected hook profile
|
|
100
|
+
const hooksSource = path.join(erneRoot, 'hooks');
|
|
101
|
+
const hooksTarget = path.join(claudeDir);
|
|
102
|
+
const profileSource = path.join(hooksSource, 'profiles', `${profile}.json`);
|
|
103
|
+
const masterHooks = JSON.parse(fs.readFileSync(path.join(hooksSource, 'hooks.json'), 'utf8'));
|
|
104
|
+
const profileHooks = JSON.parse(fs.readFileSync(profileSource, 'utf8'));
|
|
105
|
+
const mergedHooks = mergeHookProfile(masterHooks, profileHooks, profile);
|
|
106
|
+
fs.writeFileSync(path.join(hooksTarget, 'hooks.json'), JSON.stringify(mergedHooks, null, 2));
|
|
107
|
+
console.log(` ✓ .claude/hooks.json (${profile} profile)`);
|
|
108
|
+
|
|
109
|
+
// Copy hook scripts
|
|
110
|
+
const scriptsTarget = path.join(claudeDir, 'scripts', 'hooks');
|
|
111
|
+
copyDir(path.join(erneRoot, 'scripts', 'hooks'), scriptsTarget);
|
|
112
|
+
console.log(' ✓ .claude/scripts/hooks/ (hook implementations)');
|
|
113
|
+
|
|
114
|
+
// Copy contexts
|
|
115
|
+
copyDir(path.join(erneRoot, 'contexts'), path.join(claudeDir, 'contexts'));
|
|
116
|
+
console.log(' ✓ .claude/contexts/ (3 contexts)');
|
|
117
|
+
|
|
118
|
+
// Copy selected MCP configs
|
|
119
|
+
const mcpTarget = path.join(claudeDir, 'mcp-configs');
|
|
120
|
+
fs.mkdirSync(mcpTarget, { recursive: true });
|
|
121
|
+
let mcpCount = 0;
|
|
122
|
+
for (const [key, enabled] of Object.entries(mcpSelections)) {
|
|
123
|
+
if (enabled) {
|
|
124
|
+
const src = path.join(erneRoot, 'mcp-configs', `${key}.json`);
|
|
125
|
+
if (fs.existsSync(src)) {
|
|
126
|
+
fs.copyFileSync(src, path.join(mcpTarget, `${key}.json`));
|
|
127
|
+
mcpCount++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
console.log(` ✓ .claude/mcp-configs/ (${mcpCount} servers)`);
|
|
132
|
+
|
|
133
|
+
// Copy skills
|
|
134
|
+
copyDir(path.join(erneRoot, 'skills'), path.join(claudeDir, 'skills'));
|
|
135
|
+
console.log(' ✓ .claude/skills/ (8 skills)');
|
|
136
|
+
|
|
137
|
+
// Generate CLAUDE.md
|
|
138
|
+
const claudeMd = generateClaudeMd(detection, profile, ruleLayers);
|
|
139
|
+
fs.writeFileSync(path.join(cwd, 'CLAUDE.md'), claudeMd);
|
|
140
|
+
console.log(' ✓ CLAUDE.md (with correct rule imports)');
|
|
141
|
+
|
|
142
|
+
// Generate settings.json
|
|
143
|
+
const settings = {
|
|
144
|
+
hookProfile: profile,
|
|
145
|
+
erneVersion: require('../package.json').version,
|
|
146
|
+
detectedProject: detection.type,
|
|
147
|
+
installedAt: new Date().toISOString()
|
|
148
|
+
};
|
|
149
|
+
fs.writeFileSync(
|
|
150
|
+
path.join(claudeDir, 'settings.json'),
|
|
151
|
+
JSON.stringify(settings, null, 2)
|
|
152
|
+
);
|
|
153
|
+
console.log(' ✓ .claude/settings.json');
|
|
154
|
+
|
|
155
|
+
console.log('\n Done! Run /plan to start your first feature.\n');
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
// ─── Helper functions ───
|
|
160
|
+
|
|
161
|
+
function detectProject(cwd) {
|
|
162
|
+
const result = {
|
|
163
|
+
isRNProject: false,
|
|
164
|
+
type: 'unknown',
|
|
165
|
+
hasExpo: false,
|
|
166
|
+
hasBareRN: false,
|
|
167
|
+
hasIOS: false,
|
|
168
|
+
hasAndroid: false,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Check for app.json / app.config.js / app.config.ts (Expo)
|
|
172
|
+
const expoConfigs = ['app.json', 'app.config.js', 'app.config.ts'];
|
|
173
|
+
result.hasExpo = expoConfigs.some(f => fs.existsSync(path.join(cwd, f)));
|
|
174
|
+
|
|
175
|
+
// Check for ios/ directory with Swift files
|
|
176
|
+
const iosDir = path.join(cwd, 'ios');
|
|
177
|
+
if (fs.existsSync(iosDir) && fs.statSync(iosDir).isDirectory()) {
|
|
178
|
+
result.hasIOS = hasFilesWithExtension(iosDir, '.swift');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check for android/ directory with Kotlin files
|
|
182
|
+
const androidDir = path.join(cwd, 'android');
|
|
183
|
+
if (fs.existsSync(androidDir) && fs.statSync(androidDir).isDirectory()) {
|
|
184
|
+
result.hasAndroid = hasFilesWithExtension(androidDir, '.kt');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check for bare RN indicators
|
|
188
|
+
const packageJsonPath = path.join(cwd, 'package.json');
|
|
189
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
190
|
+
try {
|
|
191
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
192
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
193
|
+
if (deps['react-native']) {
|
|
194
|
+
result.isRNProject = true;
|
|
195
|
+
result.hasBareRN = !result.hasExpo;
|
|
196
|
+
}
|
|
197
|
+
if (deps['expo']) {
|
|
198
|
+
result.isRNProject = true;
|
|
199
|
+
result.hasExpo = true;
|
|
200
|
+
}
|
|
201
|
+
} catch { /* ignore parse errors */ }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Determine type
|
|
205
|
+
if (result.hasExpo) result.type = 'expo-managed';
|
|
206
|
+
else if (result.hasBareRN) result.type = 'bare-rn';
|
|
207
|
+
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasFilesWithExtension(dir, ext) {
|
|
212
|
+
try {
|
|
213
|
+
const entries = fs.readdirSync(dir, { recursive: true });
|
|
214
|
+
return entries.some(entry => entry.endsWith(ext));
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function printDetection(detection) {
|
|
221
|
+
const ok = (msg) => console.log(` ✓ ${msg}`);
|
|
222
|
+
const no = (msg) => console.log(` ✗ ${msg}`);
|
|
223
|
+
|
|
224
|
+
if (detection.hasExpo) ok('Expo config found → Expo managed workflow');
|
|
225
|
+
else no('No Expo config detected');
|
|
226
|
+
|
|
227
|
+
if (detection.hasBareRN) ok('Bare React Native project detected');
|
|
228
|
+
|
|
229
|
+
if (detection.hasIOS) ok('ios/ contains Swift files → iOS native rules enabled');
|
|
230
|
+
else no('No iOS native code found');
|
|
231
|
+
|
|
232
|
+
if (detection.hasAndroid) ok('android/ contains Kotlin files → Android native rules enabled');
|
|
233
|
+
else no('No Android native code found');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function determineRuleLayers(detection) {
|
|
237
|
+
const layers = ['common'];
|
|
238
|
+
if (detection.hasExpo) layers.push('expo');
|
|
239
|
+
if (detection.hasBareRN) layers.push('bare-rn');
|
|
240
|
+
if (detection.hasIOS) layers.push('native-ios');
|
|
241
|
+
if (detection.hasAndroid) layers.push('native-android');
|
|
242
|
+
return layers;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function mergeHookProfile(masterHooks, profileHooks, profileName) {
|
|
246
|
+
// Filter master hooks to only include those enabled in the profile
|
|
247
|
+
const enabledEvents = profileHooks.enabledEvents || [];
|
|
248
|
+
const result = {};
|
|
249
|
+
|
|
250
|
+
for (const [event, hooks] of Object.entries(masterHooks)) {
|
|
251
|
+
if (event === '_meta') {
|
|
252
|
+
result._meta = { ...masterHooks._meta, activeProfile: profileName };
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (Array.isArray(hooks)) {
|
|
257
|
+
result[event] = hooks.filter(hook => {
|
|
258
|
+
// Include hook if the profile enables its event
|
|
259
|
+
// or if the hook has no profile restriction
|
|
260
|
+
const hookProfiles = hook.profiles || ['minimal', 'standard', 'strict'];
|
|
261
|
+
return hookProfiles.includes(profileName);
|
|
262
|
+
});
|
|
263
|
+
// Remove empty arrays
|
|
264
|
+
if (result[event].length === 0) delete result[event];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function generateClaudeMd(detection, profile, ruleLayers) {
|
|
272
|
+
const lines = [
|
|
273
|
+
'# Project Configuration (ERNE)',
|
|
274
|
+
'',
|
|
275
|
+
`Hook profile: ${profile}`,
|
|
276
|
+
`Project type: ${detection.type}`,
|
|
277
|
+
'',
|
|
278
|
+
'## Rules',
|
|
279
|
+
'',
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
for (const layer of ruleLayers) {
|
|
283
|
+
lines.push(`@import .claude/rules/${layer}/`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
lines.push('', '## Skills', '', '@import .claude/skills/', '');
|
|
287
|
+
|
|
288
|
+
return lines.join('\n');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function copyDir(src, dest) {
|
|
292
|
+
if (!fs.existsSync(src)) return;
|
|
293
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
294
|
+
}
|
package/lib/start.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// lib/start.js — Initialize project and launch dashboard in background
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { fork } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const SERVER_PATH = path.resolve(__dirname, '..', 'dashboard', 'server.js');
|
|
8
|
+
const DEFAULT_PORT = 3333;
|
|
9
|
+
|
|
10
|
+
module.exports = async function start() {
|
|
11
|
+
const init = require('./init');
|
|
12
|
+
await init();
|
|
13
|
+
|
|
14
|
+
const port = DEFAULT_PORT;
|
|
15
|
+
|
|
16
|
+
const child = fork(SERVER_PATH, [], {
|
|
17
|
+
env: { ...process.env, ERNE_DASHBOARD_PORT: String(port) },
|
|
18
|
+
detached: true,
|
|
19
|
+
stdio: 'ignore',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
child.unref();
|
|
23
|
+
|
|
24
|
+
const url = `http://localhost:${port}`;
|
|
25
|
+
console.log(`\n ERNE Dashboard running at ${url}\n`);
|
|
26
|
+
};
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// lib/update.js — Update ERNE to latest version
|
|
2
|
+
// Usage: npx erne-universal update
|
|
3
|
+
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
module.exports = async function update() {
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
const claudeDir = path.join(cwd, '.claude');
|
|
13
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
14
|
+
|
|
15
|
+
console.log('\n erne — Checking for updates...\n');
|
|
16
|
+
|
|
17
|
+
// Check if ERNE is installed in this project
|
|
18
|
+
if (!fs.existsSync(settingsPath)) {
|
|
19
|
+
console.log(' ⚠ ERNE not found in this project.');
|
|
20
|
+
console.log(' Run "npx erne-universal init" to set up.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
25
|
+
console.log(` Current version: ${settings.erneVersion}`);
|
|
26
|
+
|
|
27
|
+
// Fetch latest version from npm
|
|
28
|
+
let latestVersion;
|
|
29
|
+
try {
|
|
30
|
+
latestVersion = execSync('npm view erne-universal version', { encoding: 'utf8' }).trim();
|
|
31
|
+
} catch {
|
|
32
|
+
console.log(' ⚠ Could not check npm for latest version.');
|
|
33
|
+
console.log(' Check https://erne.dev for updates.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(` Latest version: ${latestVersion}`);
|
|
38
|
+
|
|
39
|
+
if (settings.erneVersion === latestVersion) {
|
|
40
|
+
console.log('\n Already up to date!\n');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log(`\n Updating ${settings.erneVersion} → ${latestVersion}...`);
|
|
45
|
+
|
|
46
|
+
// Re-run init with preserved settings
|
|
47
|
+
// The init command detects existing settings and preserves user choices
|
|
48
|
+
console.log(' Running: npx erne-universal@latest init');
|
|
49
|
+
console.log(' Your profile and MCP selections will be preserved.\n');
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
execSync(`npx erne-universal@${latestVersion} init`, {
|
|
53
|
+
stdio: 'inherit',
|
|
54
|
+
cwd,
|
|
55
|
+
});
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(' Update failed:', err.message);
|
|
58
|
+
console.error(' Manual update: npm install -g erne-universal@latest && erne init');
|
|
59
|
+
}
|
|
60
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "erne-universal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Complete AI coding agent harness for React Native and Expo development",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"hooks/",
|
|
30
30
|
"contexts/",
|
|
31
31
|
"mcp-configs/",
|
|
32
|
+
"dashboard/",
|
|
33
|
+
"lib/",
|
|
32
34
|
"scripts/",
|
|
33
35
|
"examples/",
|
|
34
36
|
"schemas/",
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Daily AI Agent Ecosystem News Scanner
|
|
5
|
+
*
|
|
6
|
+
* Scans for AI coding agent updates relevant to ERNE:
|
|
7
|
+
* - Claude Code updates
|
|
8
|
+
* - Competitor agent tools (Codex, Cursor, Windsurf, Antigravity)
|
|
9
|
+
* - Skills marketplace changes
|
|
10
|
+
* - New agent skills in the ecosystem
|
|
11
|
+
*
|
|
12
|
+
* Uses keyword-based relevance filtering (no API credits needed).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const SOURCES = {
|
|
16
|
+
github_releases: [
|
|
17
|
+
'anthropics/claude-code',
|
|
18
|
+
'anthropics/skills',
|
|
19
|
+
'openai/codex',
|
|
20
|
+
'VoltAgent/awesome-agent-skills',
|
|
21
|
+
'callstackincubator/agent-skills',
|
|
22
|
+
'expo/skills',
|
|
23
|
+
'vercel-labs/agent-skills',
|
|
24
|
+
],
|
|
25
|
+
github_repos_activity: [
|
|
26
|
+
'anthropics/claude-code',
|
|
27
|
+
'google/anthropic-antigravity',
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const CLICKUP_API = 'https://api.clickup.com/api/v2';
|
|
32
|
+
|
|
33
|
+
// Keywords that indicate high relevance to ERNE
|
|
34
|
+
const HIGH_PRIORITY_KEYWORDS = [
|
|
35
|
+
'breaking', 'deprecat', 'removed', 'migration', 'upgrade',
|
|
36
|
+
'skill', 'plugin', 'marketplace', 'CLAUDE.md', 'AGENTS.md',
|
|
37
|
+
'react-native', 'expo', 'agent',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const NORMAL_KEYWORDS = [
|
|
41
|
+
'feature', 'new', 'add', 'support', 'improve', 'update',
|
|
42
|
+
'fix', 'bug', 'release', 'version',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
async function fetchGitHubReleases(repo) {
|
|
46
|
+
const res = await fetch(
|
|
47
|
+
`https://api.github.com/repos/${repo}/releases?per_page=3`,
|
|
48
|
+
{
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `token ${process.env.GITHUB_TOKEN}`,
|
|
51
|
+
Accept: 'application/vnd.github.v3+json',
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
if (!res.ok) return [];
|
|
56
|
+
const releases = await res.json();
|
|
57
|
+
|
|
58
|
+
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
|
59
|
+
return releases
|
|
60
|
+
.filter((r) => new Date(r.published_at).getTime() > oneDayAgo)
|
|
61
|
+
.map((r) => ({
|
|
62
|
+
type: 'release',
|
|
63
|
+
source: repo,
|
|
64
|
+
title: `${repo} ${r.tag_name}`,
|
|
65
|
+
body: r.body?.slice(0, 1000) || '',
|
|
66
|
+
url: r.html_url,
|
|
67
|
+
date: r.published_at,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function fetchRepoCommits(repo) {
|
|
72
|
+
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
73
|
+
const res = await fetch(
|
|
74
|
+
`https://api.github.com/repos/${repo}/commits?since=${since}&per_page=10`,
|
|
75
|
+
{
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: `token ${process.env.GITHUB_TOKEN}`,
|
|
78
|
+
Accept: 'application/vnd.github.v3+json',
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
if (!res.ok) return [];
|
|
83
|
+
const commits = await res.json();
|
|
84
|
+
|
|
85
|
+
if (commits.length === 0) return [];
|
|
86
|
+
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
type: 'commits',
|
|
90
|
+
source: repo,
|
|
91
|
+
title: `${repo}: ${commits.length} new commit(s)`,
|
|
92
|
+
body: commits
|
|
93
|
+
.slice(0, 5)
|
|
94
|
+
.map((c) => `- ${c.commit.message.split('\n')[0]}`)
|
|
95
|
+
.join('\n'),
|
|
96
|
+
url: `https://github.com/${repo}/commits`,
|
|
97
|
+
date: commits[0]?.commit.author.date,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function analyzeRelevance(items) {
|
|
103
|
+
if (items.length === 0) return [];
|
|
104
|
+
|
|
105
|
+
return items.map((item) => {
|
|
106
|
+
const text = `${item.title} ${item.body}`.toLowerCase();
|
|
107
|
+
|
|
108
|
+
const isHighPriority = HIGH_PRIORITY_KEYWORDS.some((kw) => text.includes(kw));
|
|
109
|
+
const isRelease = item.type === 'release';
|
|
110
|
+
|
|
111
|
+
// All releases are relevant, commits only if they match keywords
|
|
112
|
+
if (!isRelease && !isHighPriority && !NORMAL_KEYWORDS.some((kw) => text.includes(kw))) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const priority = isHighPriority ? 'high' : 'normal';
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
title: `[${item.type.toUpperCase()}] ${item.title}`,
|
|
120
|
+
description: item.body.slice(0, 300) || 'New activity detected',
|
|
121
|
+
action: isRelease
|
|
122
|
+
? 'Review release notes for ERNE compatibility'
|
|
123
|
+
: 'Review changes for potential impact',
|
|
124
|
+
priority,
|
|
125
|
+
source_url: item.url,
|
|
126
|
+
};
|
|
127
|
+
}).filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function createClickUpTask(task) {
|
|
131
|
+
const listId = process.env.CLICKUP_LIST_ID;
|
|
132
|
+
if (!listId) {
|
|
133
|
+
console.log('CLICKUP_LIST_ID not set, skipping task creation');
|
|
134
|
+
console.log('Task:', task.title);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const priorityMap = { urgent: 1, high: 2, normal: 3, low: 4 };
|
|
139
|
+
|
|
140
|
+
const res = await fetch(`${CLICKUP_API}/list/${listId}/task`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: {
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
Authorization: process.env.CLICKUP_API_TOKEN,
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
name: task.title,
|
|
148
|
+
markdown_description: [
|
|
149
|
+
`## Summary`,
|
|
150
|
+
task.description,
|
|
151
|
+
'',
|
|
152
|
+
`## Action Required`,
|
|
153
|
+
task.action,
|
|
154
|
+
'',
|
|
155
|
+
`**Source:** [${task.source_url}](${task.source_url})`,
|
|
156
|
+
'',
|
|
157
|
+
`---`,
|
|
158
|
+
`*Auto-generated by ERNE AI Agent Scanner*`,
|
|
159
|
+
].join('\n'),
|
|
160
|
+
priority: priorityMap[task.priority] || 3,
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (res.ok) {
|
|
165
|
+
const data = await res.json();
|
|
166
|
+
console.log(`Created task: ${task.title} (${data.id})`);
|
|
167
|
+
} else {
|
|
168
|
+
console.error(`Failed to create task: ${task.title}`, res.status);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function main() {
|
|
173
|
+
console.log('=== ERNE Daily AI Agent News Scanner ===');
|
|
174
|
+
console.log(`Date: ${new Date().toISOString()}\n`);
|
|
175
|
+
|
|
176
|
+
const allItems = [];
|
|
177
|
+
|
|
178
|
+
// GitHub releases
|
|
179
|
+
console.log('Fetching GitHub releases...');
|
|
180
|
+
for (const repo of SOURCES.github_releases) {
|
|
181
|
+
const releases = await fetchGitHubReleases(repo);
|
|
182
|
+
allItems.push(...releases);
|
|
183
|
+
if (releases.length > 0) {
|
|
184
|
+
console.log(` ${repo}: ${releases.length} new release(s)`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Repo activity
|
|
189
|
+
console.log('Fetching repo activity...');
|
|
190
|
+
for (const repo of SOURCES.github_repos_activity) {
|
|
191
|
+
const commits = await fetchRepoCommits(repo);
|
|
192
|
+
allItems.push(...commits);
|
|
193
|
+
if (commits.length > 0) {
|
|
194
|
+
console.log(` ${repo}: new activity`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.log(`\nTotal items found: ${allItems.length}`);
|
|
199
|
+
|
|
200
|
+
if (allItems.length === 0) {
|
|
201
|
+
console.log('No new items today. Exiting.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log('\nAnalyzing relevance...');
|
|
206
|
+
const relevant = analyzeRelevance(allItems);
|
|
207
|
+
console.log(`Relevant items: ${relevant.length}`);
|
|
208
|
+
|
|
209
|
+
if (relevant.length > 0) {
|
|
210
|
+
console.log('\nCreating ClickUp tasks...');
|
|
211
|
+
for (const task of relevant) {
|
|
212
|
+
await createClickUpTask(task);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log('\nDone!');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
main().catch((err) => {
|
|
220
|
+
console.error('Scanner failed:', err);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
});
|