erne-universal 0.10.0 → 0.10.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/dashboard/lib/agents-config.js +3 -0
- package/dashboard/server.js +13 -1
- package/lib/doctor.js +19 -6
- package/lib/generate.js +33 -4
- package/lib/worker.js +5 -1
- package/package.json +3 -1
- package/scripts/hooks/audit-refresh.js +4 -1
- package/scripts/hooks/run-with-flags.js +1 -1
- package/worker/agent-router.js +183 -0
- package/worker/confidence-scorer.js +92 -0
- package/worker/config.js +98 -0
- package/worker/context-resolver.js +33 -0
- package/worker/dashboard-events.js +48 -0
- package/worker/executor.js +97 -0
- package/worker/health-delta.js +56 -0
- package/worker/interpolate.js +51 -0
- package/worker/logger.js +97 -0
- package/worker/pipeline.js +156 -0
- package/worker/poller.js +71 -0
- package/worker/prompt-builder.js +85 -0
- package/worker/providers/clickup.js +133 -0
- package/worker/providers/factory.js +34 -0
- package/worker/providers/github.js +175 -0
- package/worker/providers/gitlab.js +163 -0
- package/worker/providers/jira.js +177 -0
- package/worker/providers/linear.js +199 -0
- package/worker/providers/types.js +24 -0
- package/worker/scheduler.js +194 -0
- package/worker/self-reviewer.js +43 -0
- package/worker/test-verifier.js +35 -0
- package/worker/ticket-validator.js +49 -0
- package/worker/worktree.js +56 -0
- package/worker.example.json +28 -0
|
@@ -17,6 +17,8 @@ const AGENT_DEFINITIONS = [
|
|
|
17
17
|
{ name: 'senior-developer', room: 'development' },
|
|
18
18
|
{ name: 'feature-builder', room: 'development' },
|
|
19
19
|
{ name: 'pipeline-orchestrator', room: 'conference' },
|
|
20
|
+
{ name: 'visual-debugger', room: 'testing' },
|
|
21
|
+
{ name: 'documentation-generator', room: 'review' },
|
|
20
22
|
];
|
|
21
23
|
|
|
22
24
|
const AGENT_ORDER = [
|
|
@@ -25,6 +27,7 @@ const AGENT_ORDER = [
|
|
|
25
27
|
'code-reviewer', 'upgrade-assistant',
|
|
26
28
|
'tdd-guide', 'performance-profiler',
|
|
27
29
|
'pipeline-orchestrator',
|
|
30
|
+
'visual-debugger', 'documentation-generator',
|
|
28
31
|
];
|
|
29
32
|
|
|
30
33
|
module.exports = { AGENT_ORDER, AGENT_DEFINITIONS };
|
package/dashboard/server.js
CHANGED
|
@@ -214,6 +214,17 @@ const handleEvent = (event) => {
|
|
|
214
214
|
return { ok: true };
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// Visual debug events — log and store
|
|
218
|
+
if (type && type.startsWith('visual-debug:')) {
|
|
219
|
+
const now = new Date().toISOString();
|
|
220
|
+
if (agent && agentState[agent]) {
|
|
221
|
+
agentState[agent].lastEvent = now;
|
|
222
|
+
addHistoryEntry(agent, { type, task: task || null, timestamp: now });
|
|
223
|
+
persistHistory();
|
|
224
|
+
}
|
|
225
|
+
return { ok: true };
|
|
226
|
+
}
|
|
227
|
+
|
|
217
228
|
// Worker events — update worker state and broadcast
|
|
218
229
|
if (type && type.startsWith('worker:')) {
|
|
219
230
|
const now = new Date().toISOString();
|
|
@@ -739,7 +750,8 @@ wss.on('connection', (ws) => {
|
|
|
739
750
|
// Validate event shape before processing
|
|
740
751
|
if (!data || typeof data !== 'object' || typeof data.type !== 'string') return;
|
|
741
752
|
const VALID_TYPES = ['agent:start', 'agent:complete', 'planning:start', 'planning:end', 'audit:complete',
|
|
742
|
-
'worker:start', 'worker:poll', 'worker:task-start', 'worker:task-complete', 'worker:idle'
|
|
753
|
+
'worker:start', 'worker:poll', 'worker:task-start', 'worker:task-complete', 'worker:idle',
|
|
754
|
+
'visual-debug:screenshot', 'visual-debug:fix', 'visual-debug:compare'];
|
|
743
755
|
if (!VALID_TYPES.includes(data.type)) return;
|
|
744
756
|
|
|
745
757
|
const result = handleEvent(data);
|
package/lib/doctor.js
CHANGED
|
@@ -139,12 +139,25 @@ async function autoFix(cwd, findings) {
|
|
|
139
139
|
const tsFinding = findings.find(f => f.title && f.title.includes('strict mode'));
|
|
140
140
|
if (tsFinding && fs.existsSync(tsconfigPath)) {
|
|
141
141
|
try {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
142
|
+
const raw = fs.readFileSync(tsconfigPath, 'utf8');
|
|
143
|
+
let tsconfig;
|
|
144
|
+
try {
|
|
145
|
+
tsconfig = JSON.parse(raw);
|
|
146
|
+
} catch {
|
|
147
|
+
// tsconfig.json may contain comments (JSON5) — skip auto-fix
|
|
148
|
+
fixes.push('Skipped strict mode: tsconfig.json contains comments (edit manually)');
|
|
149
|
+
return fixes;
|
|
150
|
+
}
|
|
151
|
+
// If tsconfig extends a base config, the base likely already sets strict
|
|
152
|
+
if (tsconfig.extends) {
|
|
153
|
+
fixes.push('Skipped strict mode: tsconfig.json extends a base config that may already include strict');
|
|
154
|
+
} else {
|
|
155
|
+
if (!tsconfig.compilerOptions) tsconfig.compilerOptions = {};
|
|
156
|
+
tsconfig.compilerOptions.strict = true;
|
|
157
|
+
fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n');
|
|
158
|
+
fixes.push('Enabled TypeScript strict mode');
|
|
159
|
+
}
|
|
160
|
+
} catch { /* skip unreadable tsconfig */ }
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
return fixes;
|
package/lib/generate.js
CHANGED
|
@@ -161,9 +161,26 @@ function determineRuleLayers(detection, cwd) {
|
|
|
161
161
|
// ─── Helper: mergeHookProfile ──────────────────────────────────────────────────
|
|
162
162
|
|
|
163
163
|
function mergeHookProfile(masterHooks, profileHooks, profileName) {
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
// masterHooks can be either:
|
|
165
|
+
// - flat array format: { hooks: [...] }
|
|
166
|
+
// - event-keyed format: { PreToolUse: [...], PostToolUse: [...], _meta: {...} }
|
|
167
|
+
if (Array.isArray(masterHooks.hooks)) {
|
|
168
|
+
// Flat array format — filter by profile, then group by event
|
|
169
|
+
const filtered = masterHooks.hooks.filter(hook => {
|
|
170
|
+
const hookProfiles = hook.profiles || ['minimal', 'standard', 'strict'];
|
|
171
|
+
return hookProfiles.includes(profileName);
|
|
172
|
+
});
|
|
173
|
+
const result = { _meta: { activeProfile: profileName } };
|
|
174
|
+
for (const hook of filtered) {
|
|
175
|
+
const event = hook.event;
|
|
176
|
+
if (!result[event]) result[event] = [];
|
|
177
|
+
result[event].push(hook);
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
166
181
|
|
|
182
|
+
// Event-keyed format (legacy path)
|
|
183
|
+
const result = {};
|
|
167
184
|
for (const [event, hooks] of Object.entries(masterHooks)) {
|
|
168
185
|
if (event === '_meta') {
|
|
169
186
|
result._meta = { ...masterHooks._meta, activeProfile: profileName };
|
|
@@ -293,14 +310,26 @@ function generateConfig(erneRoot, targetDir, detection, profile, mcpSelections)
|
|
|
293
310
|
}
|
|
294
311
|
|
|
295
312
|
// 6. Apply hook profile
|
|
313
|
+
// Write to .claude/hooks.json (NOT .claude/hooks/hooks.json) — Claude Code reads from .claude/hooks.json
|
|
296
314
|
const masterHooksPath = path.join(erneRoot, 'hooks', 'hooks.json');
|
|
297
315
|
const profileHooksPath = path.join(erneRoot, 'hooks', 'profiles', `${profile}.json`);
|
|
298
316
|
if (fs.existsSync(masterHooksPath) && fs.existsSync(profileHooksPath)) {
|
|
299
317
|
const masterHooks = JSON.parse(fs.readFileSync(masterHooksPath, 'utf8'));
|
|
300
318
|
const profileHooks = JSON.parse(fs.readFileSync(profileHooksPath, 'utf8'));
|
|
301
319
|
const merged = mergeHookProfile(masterHooks, profileHooks, profile);
|
|
302
|
-
|
|
303
|
-
|
|
320
|
+
|
|
321
|
+
// Rewrite hook commands to use node_modules path so they resolve from user's project root
|
|
322
|
+
const pkgRelPrefix = 'node_modules/erne-universal/scripts/hooks/run-with-flags.js';
|
|
323
|
+
for (const [event, hooks] of Object.entries(merged)) {
|
|
324
|
+
if (event === '_meta' || !Array.isArray(hooks)) continue;
|
|
325
|
+
for (const hook of hooks) {
|
|
326
|
+
if (hook.command && hook.command.includes('scripts/hooks/run-with-flags.js')) {
|
|
327
|
+
hook.command = hook.command.replace('scripts/hooks/run-with-flags.js', pkgRelPrefix);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const destHooksPath = path.join(targetDir, 'hooks.json');
|
|
304
333
|
fs.writeFileSync(destHooksPath, JSON.stringify(merged, null, 2));
|
|
305
334
|
}
|
|
306
335
|
|
package/lib/worker.js
CHANGED
|
@@ -23,7 +23,11 @@ module.exports = async function worker() {
|
|
|
23
23
|
|
|
24
24
|
// 1. Load config
|
|
25
25
|
const fullPath = path.resolve(configPath);
|
|
26
|
-
const config = loadConfig(fullPath);
|
|
26
|
+
const { config, error: configError } = loadConfig(fullPath);
|
|
27
|
+
if (configError) {
|
|
28
|
+
console.error(' Config error: ' + configError);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
27
31
|
const validation = validateConfig(config);
|
|
28
32
|
if (!validation.valid) {
|
|
29
33
|
console.error(' Config errors:');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "erne-universal",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Complete AI coding agent harness for React Native and Expo development",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"scripts/",
|
|
39
39
|
"examples/",
|
|
40
40
|
"schemas/",
|
|
41
|
+
"worker/",
|
|
42
|
+
"worker.example.json",
|
|
41
43
|
"docs/getting-started.md",
|
|
42
44
|
"docs/agents.md",
|
|
43
45
|
"docs/commands.md",
|
|
@@ -23,7 +23,10 @@ try {
|
|
|
23
23
|
if (age < TWENTY_FOUR_HOURS) process.exit(0);
|
|
24
24
|
|
|
25
25
|
// Refresh scan (JSON only, skip dep health for speed)
|
|
26
|
-
|
|
26
|
+
let runScan;
|
|
27
|
+
try { runScan = require('erne-universal/lib/audit-scanner').runScan; } catch {
|
|
28
|
+
try { runScan = require('../../lib/audit-scanner').runScan; } catch { process.exit(0); }
|
|
29
|
+
}
|
|
27
30
|
const auditData = runScan(cwd, { skipDepHealth: true, maxFiles: 500 });
|
|
28
31
|
|
|
29
32
|
const docsDir = path.join(cwd, 'erne-docs');
|
|
@@ -109,7 +109,7 @@ if (process.env.ERNE_DASHBOARD_PORT || process.env.ERNE_HOOK_CHAIN) {
|
|
|
109
109
|
});
|
|
110
110
|
const req = http.request({
|
|
111
111
|
hostname: '127.0.0.1', port,
|
|
112
|
-
path: '/api/
|
|
112
|
+
path: '/api/events',
|
|
113
113
|
method: 'POST',
|
|
114
114
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
115
115
|
timeout: 300
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent definitions with priority and keyword triggers.
|
|
5
|
+
* Higher priority wins when multiple agents match.
|
|
6
|
+
*/
|
|
7
|
+
const AGENTS = [
|
|
8
|
+
{
|
|
9
|
+
name: 'performance-profiler',
|
|
10
|
+
priority: 10,
|
|
11
|
+
keywords: ['perf', 'slow', 'fps', 'jank', 'memory', 'lag', 'freeze', 'stutter', 'bundle size', 'memory leak', 'frame drop', 'battery drain', 'cpu usage', 'tti', 'startup time'],
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'expo-config-resolver',
|
|
15
|
+
priority: 10,
|
|
16
|
+
keywords: ['build', 'crash', 'metro', 'config', 'pod install', 'gradle', 'xcode', "won't start", 'build error', 'build failed', 'module not found', 'red screen', 'white screen', 'blank screen', 'eas build', 'prebuild', 'app.json', 'app.config'],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'native-bridge-builder',
|
|
20
|
+
priority: 10,
|
|
21
|
+
keywords: ['native', 'swift', 'kotlin', 'bridge', 'turbo module', 'jsi', 'fabric', 'native module', 'expo module', 'objective-c', 'codegen', 'nitro'],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'upgrade-assistant',
|
|
25
|
+
priority: 10,
|
|
26
|
+
keywords: ['upgrade', 'migration', 'breaking', 'expo sdk', 'react native version', 'deprecated', 'breaking change', 'peer dependency', 'outdated'],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'documentation-generator',
|
|
30
|
+
priority: 10,
|
|
31
|
+
keywords: ['docs', 'documentation', 'write docs', 'readme', 'jsdoc', 'api docs'],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'tdd-guide',
|
|
35
|
+
priority: 8,
|
|
36
|
+
keywords: ['test', 'tdd', 'coverage', 'jest', 'detox', 'failing test', 'unit test', 'snapshot test', 'integration test', 'e2e', 'mock', 'fixture'],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'code-reviewer',
|
|
40
|
+
priority: 8,
|
|
41
|
+
keywords: ['review', 'refactor', 'quality', 'tech debt', 'dead code', 'clean up', 'code smell', 'anti-pattern', 'readability', 'maintainability', 'dry', 'solid'],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'visual-debugger',
|
|
45
|
+
priority: 5,
|
|
46
|
+
keywords: ['visual', 'ui bug', 'layout', 'spacing', 'alignment', 'overflow', 'cut off', 'overlapping', 'not centered', 'wrong font', 'wrong colors', 'dark mode', 'safe area', 'image'],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'ui-designer',
|
|
50
|
+
priority: 5,
|
|
51
|
+
keywords: ['component', 'button', 'modal', 'screen', 'animation', 'design system', 'theme', 'icon', 'gesture', 'bottom sheet', 'drawer', 'header', 'card', 'skeleton', 'loading', 'toast', 'tab'],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'architect',
|
|
55
|
+
priority: 5,
|
|
56
|
+
keywords: ['architecture', 'design', 'plan', 'data flow', 'structure', 'system design', 'folder structure', 'state management', 'navigation structure', 'decompose', 'separation of concerns'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'senior-developer',
|
|
60
|
+
priority: 3,
|
|
61
|
+
keywords: [],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'feature-builder',
|
|
65
|
+
priority: 1,
|
|
66
|
+
keywords: [],
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const FALLBACK_AGENT = 'feature-builder';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Combine ticket title, description, and labels into a single searchable string.
|
|
74
|
+
*/
|
|
75
|
+
function buildSearchText(ticket) {
|
|
76
|
+
const parts = [];
|
|
77
|
+
if (ticket.title) parts.push(ticket.title);
|
|
78
|
+
if (ticket.description) parts.push(ticket.description);
|
|
79
|
+
if (Array.isArray(ticket.labels)) {
|
|
80
|
+
parts.push(ticket.labels.join(' '));
|
|
81
|
+
}
|
|
82
|
+
return parts.join(' ').toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Score a single agent against the search text.
|
|
87
|
+
* Returns the number of keyword matches multiplied by the agent's priority.
|
|
88
|
+
*/
|
|
89
|
+
function scoreAgent(agent, searchText) {
|
|
90
|
+
if (agent.keywords.length === 0) return 0;
|
|
91
|
+
|
|
92
|
+
let matches = 0;
|
|
93
|
+
for (const keyword of agent.keywords) {
|
|
94
|
+
if (searchText.includes(keyword)) {
|
|
95
|
+
matches++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return matches * agent.priority;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Apply bonus scoring from audit data.
|
|
104
|
+
* If the ticket mentions a file found in deadCode or high-severity findings,
|
|
105
|
+
* boost the code-reviewer score.
|
|
106
|
+
*/
|
|
107
|
+
function applyAuditBonus(scores, ticket, auditData) {
|
|
108
|
+
if (!auditData) return;
|
|
109
|
+
|
|
110
|
+
const searchText = buildSearchText(ticket);
|
|
111
|
+
|
|
112
|
+
// Check dead code references
|
|
113
|
+
if (Array.isArray(auditData.deadCode)) {
|
|
114
|
+
for (const entry of auditData.deadCode) {
|
|
115
|
+
const file = typeof entry === 'string' ? entry : (entry.file || '');
|
|
116
|
+
if (file && searchText.includes(file.toLowerCase())) {
|
|
117
|
+
scores['code-reviewer'] = (scores['code-reviewer'] || 0) + 10;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check high-severity findings
|
|
124
|
+
if (Array.isArray(auditData.findings)) {
|
|
125
|
+
for (const finding of auditData.findings) {
|
|
126
|
+
const severity = finding.severity || '';
|
|
127
|
+
const file = finding.file || '';
|
|
128
|
+
if (severity === 'high' && file && searchText.includes(file.toLowerCase())) {
|
|
129
|
+
scores['code-reviewer'] = (scores['code-reviewer'] || 0) + 10;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Route a ticket to the best-matching ERNE agent.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} ticket - { title, description, labels[], type }
|
|
140
|
+
* @param {object} [auditData] - Parsed audit-data.json (deadCode[], findings[])
|
|
141
|
+
* @param {object} [config] - Worker config (unused for now, reserved for overrides)
|
|
142
|
+
* @returns {string} The agent name to handle this ticket
|
|
143
|
+
*/
|
|
144
|
+
function routeTicketToAgent(ticket, auditData, config) {
|
|
145
|
+
if (!ticket || typeof ticket !== 'object') return FALLBACK_AGENT;
|
|
146
|
+
|
|
147
|
+
const searchText = buildSearchText(ticket);
|
|
148
|
+
|
|
149
|
+
// Senior-developer triggers on task type
|
|
150
|
+
const isSeniorType = ticket.type === 'advice' || ticket.type === 'opinion' || ticket.type === 'question';
|
|
151
|
+
|
|
152
|
+
// Score each agent
|
|
153
|
+
const scores = {};
|
|
154
|
+
for (const agent of AGENTS) {
|
|
155
|
+
const score = scoreAgent(agent, searchText);
|
|
156
|
+
if (score > 0) {
|
|
157
|
+
scores[agent.name] = score;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Senior-developer type bonus
|
|
162
|
+
if (isSeniorType) {
|
|
163
|
+
scores['senior-developer'] = (scores['senior-developer'] || 0) + 3;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Audit data bonuses
|
|
167
|
+
applyAuditBonus(scores, ticket, auditData);
|
|
168
|
+
|
|
169
|
+
// Find the winner
|
|
170
|
+
let bestAgent = FALLBACK_AGENT;
|
|
171
|
+
let bestScore = 0;
|
|
172
|
+
|
|
173
|
+
for (const [name, score] of Object.entries(scores)) {
|
|
174
|
+
if (score > bestScore) {
|
|
175
|
+
bestScore = score;
|
|
176
|
+
bestAgent = name;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return bestAgent;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { routeTicketToAgent };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const COMPLEX_LABELS = ['architecture', 'breaking', 'migration', 'refactor-large'];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Calculate a confidence score for how safely a ticket can be auto-resolved.
|
|
7
|
+
* @param {object} ticket
|
|
8
|
+
* @param {object} auditData
|
|
9
|
+
* @param {object} context - { affectedFiles: string[], knownFiles: string[] }
|
|
10
|
+
* @returns {{ score: number, level: string, factors: Array<{ factor: string, impact: number }> }}
|
|
11
|
+
*/
|
|
12
|
+
function calculateConfidence(ticket, auditData, context) {
|
|
13
|
+
const factors = [];
|
|
14
|
+
let score = 100;
|
|
15
|
+
|
|
16
|
+
// --- Description length ---
|
|
17
|
+
const desc = (ticket && ticket.description) || '';
|
|
18
|
+
if (desc.length < 50) {
|
|
19
|
+
factors.push({ factor: 'Short description (< 50 chars)', impact: -20 });
|
|
20
|
+
score -= 20;
|
|
21
|
+
}
|
|
22
|
+
if (desc.length > 200) {
|
|
23
|
+
factors.push({ factor: 'Detailed description (> 200 chars)', impact: 5 });
|
|
24
|
+
score += 5;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Affected files ---
|
|
28
|
+
const affected = (context && context.affectedFiles) || [];
|
|
29
|
+
const fileCount = affected.length;
|
|
30
|
+
|
|
31
|
+
if (fileCount === 0) {
|
|
32
|
+
factors.push({ factor: 'No affected files identified', impact: -15 });
|
|
33
|
+
score -= 15;
|
|
34
|
+
} else if (fileCount > 20) {
|
|
35
|
+
factors.push({ factor: `Very high file count (${fileCount} > 20)`, impact: -30 });
|
|
36
|
+
score -= 30;
|
|
37
|
+
} else if (fileCount > 10) {
|
|
38
|
+
factors.push({ factor: `High file count (${fileCount} > 10)`, impact: -20 });
|
|
39
|
+
score -= 20;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Ticket type ---
|
|
43
|
+
const type = (ticket && ticket.type || '').toLowerCase();
|
|
44
|
+
if (type === 'bug') {
|
|
45
|
+
factors.push({ factor: 'Bug ticket type', impact: -10 });
|
|
46
|
+
score -= 10;
|
|
47
|
+
} else if (type === 'story') {
|
|
48
|
+
factors.push({ factor: 'Story ticket type', impact: -15 });
|
|
49
|
+
score -= 15;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Known files ---
|
|
53
|
+
const knownFiles = (context && context.knownFiles) || [];
|
|
54
|
+
if (fileCount > 0 && knownFiles.length > 0) {
|
|
55
|
+
const allKnown = affected.every((f) => knownFiles.includes(f));
|
|
56
|
+
const noneKnown = affected.every((f) => !knownFiles.includes(f));
|
|
57
|
+
|
|
58
|
+
if (allKnown) {
|
|
59
|
+
factors.push({ factor: 'All affected files known in audit', impact: 10 });
|
|
60
|
+
score += 10;
|
|
61
|
+
} else if (noneKnown) {
|
|
62
|
+
factors.push({ factor: 'No affected files known in audit', impact: -20 });
|
|
63
|
+
score -= 20;
|
|
64
|
+
}
|
|
65
|
+
} else if (fileCount > 0 && knownFiles.length === 0) {
|
|
66
|
+
factors.push({ factor: 'No affected files known in audit', impact: -20 });
|
|
67
|
+
score -= 20;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Complex labels ---
|
|
71
|
+
const labels = (ticket && ticket.labels || []).map((l) => String(l).toLowerCase());
|
|
72
|
+
const hasComplex = labels.some((l) => COMPLEX_LABELS.includes(l));
|
|
73
|
+
if (hasComplex) {
|
|
74
|
+
const matched = labels.filter((l) => COMPLEX_LABELS.includes(l)).join(', ');
|
|
75
|
+
factors.push({ factor: `Complex label(s): ${matched}`, impact: -25 });
|
|
76
|
+
score -= 25;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Clamp ---
|
|
80
|
+
score = Math.max(0, Math.min(100, score));
|
|
81
|
+
|
|
82
|
+
// --- Level ---
|
|
83
|
+
let level;
|
|
84
|
+
if (score >= 80) level = 'high';
|
|
85
|
+
else if (score >= 50) level = 'medium';
|
|
86
|
+
else if (score >= 30) level = 'low';
|
|
87
|
+
else level = 'too-complex';
|
|
88
|
+
|
|
89
|
+
return { score, level, factors };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { calculateConfidence };
|
package/worker/config.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
executor: {
|
|
8
|
+
timeout_seconds: 600,
|
|
9
|
+
},
|
|
10
|
+
erne: {
|
|
11
|
+
hook_profile: 'standard',
|
|
12
|
+
},
|
|
13
|
+
provider: {
|
|
14
|
+
poll_interval_seconds: 60,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const PROVIDER_ENV_KEYS = {
|
|
19
|
+
linear: 'LINEAR_API_KEY',
|
|
20
|
+
jira: 'JIRA_API_TOKEN',
|
|
21
|
+
github: 'GITHUB_TOKEN',
|
|
22
|
+
clickup: 'CLICKUP_API_TOKEN',
|
|
23
|
+
gitlab: 'GITLAB_TOKEN',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load a JSON config file and merge with defaults.
|
|
28
|
+
*/
|
|
29
|
+
function loadConfig(configPath) {
|
|
30
|
+
if (!configPath || typeof configPath !== 'string') {
|
|
31
|
+
return { config: null, error: 'Config path is required' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const resolved = path.resolve(configPath);
|
|
35
|
+
if (!fs.existsSync(resolved)) {
|
|
36
|
+
return { config: null, error: `Config file not found: ${resolved}` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = fs.readFileSync(resolved, 'utf8');
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { config: null, error: `Failed to read config: ${err.message}` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(raw);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { config: null, error: `Invalid JSON in config: ${err.message}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Merge defaults
|
|
54
|
+
const config = Object.assign({}, parsed);
|
|
55
|
+
config.executor = Object.assign({}, DEFAULTS.executor, parsed.executor);
|
|
56
|
+
config.erne = Object.assign({}, DEFAULTS.erne, parsed.erne);
|
|
57
|
+
config.provider = Object.assign({}, DEFAULTS.provider, parsed.provider);
|
|
58
|
+
|
|
59
|
+
return { config, error: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate a loaded config object.
|
|
64
|
+
*/
|
|
65
|
+
function validateConfig(config) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
|
|
68
|
+
if (!config || typeof config !== 'object') {
|
|
69
|
+
return { valid: false, errors: ['Config must be an object'] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Required: provider.type
|
|
73
|
+
if (!config.provider || !config.provider.type) {
|
|
74
|
+
errors.push('provider.type is required (linear|jira|github|clickup|gitlab)');
|
|
75
|
+
} else {
|
|
76
|
+
const validTypes = ['linear', 'jira', 'github', 'clickup', 'gitlab'];
|
|
77
|
+
if (!validTypes.includes(config.provider.type)) {
|
|
78
|
+
errors.push(`provider.type must be one of: ${validTypes.join(', ')}`);
|
|
79
|
+
} else {
|
|
80
|
+
// Check env var for provider
|
|
81
|
+
const envKey = PROVIDER_ENV_KEYS[config.provider.type];
|
|
82
|
+
if (envKey && !process.env[envKey]) {
|
|
83
|
+
errors.push(`Environment variable ${envKey} is required for provider "${config.provider.type}"`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Required: repo.path
|
|
89
|
+
if (!config.repo || !config.repo.path) {
|
|
90
|
+
errors.push('repo.path is required');
|
|
91
|
+
} else if (!fs.existsSync(config.repo.path)) {
|
|
92
|
+
errors.push(`repo.path does not exist: ${config.repo.path}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { valid: errors.length === 0, errors };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { loadConfig, validateConfig };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve context for a ticket — affected files and known files from audit data.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} ticket
|
|
7
|
+
* @param {object} [auditData]
|
|
8
|
+
* @param {object} [stackInfo]
|
|
9
|
+
* @returns {{ affectedFiles: string[], knownFiles: string[] }}
|
|
10
|
+
*/
|
|
11
|
+
function resolveContext(ticket, auditData, stackInfo) {
|
|
12
|
+
const affectedFiles = [];
|
|
13
|
+
const knownFiles = [];
|
|
14
|
+
|
|
15
|
+
// Extract file references from description
|
|
16
|
+
const desc = (ticket && ticket.description) || '';
|
|
17
|
+
const fileRe = /(?:^|\s)([\w./-]+\.\w{1,5})(?:\s|$|,|:)/g;
|
|
18
|
+
let match;
|
|
19
|
+
while ((match = fileRe.exec(desc)) !== null) {
|
|
20
|
+
affectedFiles.push(match[1]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Gather known files from audit data
|
|
24
|
+
if (auditData && Array.isArray(auditData.files)) {
|
|
25
|
+
for (const f of auditData.files) {
|
|
26
|
+
knownFiles.push(typeof f === 'string' ? f : f.file || '');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { affectedFiles, knownFiles };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { resolveContext };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
|
|
5
|
+
const DASHBOARD_PORT = parseInt(process.env.ERNE_DASHBOARD_PORT || '3333', 10);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fire-and-forget POST to the ERNE dashboard events endpoint.
|
|
9
|
+
*
|
|
10
|
+
* Valid types:
|
|
11
|
+
* worker:start, worker:poll, worker:task-start, worker:task-complete,
|
|
12
|
+
* worker:idle, worker:ticket-rejected, worker:confidence-scored,
|
|
13
|
+
* worker:tests-run, worker:health-delta
|
|
14
|
+
*
|
|
15
|
+
* Catches ALL errors silently — never blocks the caller.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} type - Event type
|
|
18
|
+
* @param {object} [data] - Additional event data
|
|
19
|
+
*/
|
|
20
|
+
function publishDashboardEvent(type, data) {
|
|
21
|
+
try {
|
|
22
|
+
const payload = JSON.stringify({ type, ...(data || {}) });
|
|
23
|
+
|
|
24
|
+
const req = http.request(
|
|
25
|
+
{
|
|
26
|
+
hostname: '127.0.0.1',
|
|
27
|
+
port: DASHBOARD_PORT,
|
|
28
|
+
path: '/api/events',
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
33
|
+
},
|
|
34
|
+
timeout: 2000,
|
|
35
|
+
},
|
|
36
|
+
() => { /* response ignored */ }
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
req.on('error', () => { /* silent */ });
|
|
40
|
+
req.on('timeout', () => { req.destroy(); });
|
|
41
|
+
req.write(payload);
|
|
42
|
+
req.end();
|
|
43
|
+
} catch {
|
|
44
|
+
// Never throw — fire-and-forget
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { publishDashboardEvent };
|