@bamptee/aia-code 2.0.12 → 2.0.14
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 +408 -34
- package/package.json +11 -2
- package/src/constants.js +24 -0
- package/src/providers/anthropic.js +6 -21
- package/src/providers/cli-runner.js +131 -7
- package/src/providers/gemini.js +4 -2
- package/src/providers/openai.js +3 -2
- package/src/services/agent-sessions.js +110 -0
- package/src/services/apps.js +132 -0
- package/src/services/config.js +41 -0
- package/src/services/feature.js +28 -7
- package/src/services/model-call.js +2 -2
- package/src/services/runner.js +23 -1
- package/src/services/status.js +69 -1
- package/src/services/test-quick.js +229 -0
- package/src/services/worktrunk.js +135 -21
- package/src/types/test-quick.js +88 -0
- package/src/ui/api/config.js +28 -1
- package/src/ui/api/features.js +160 -50
- package/src/ui/api/index.js +2 -0
- package/src/ui/api/test-quick.js +207 -0
- package/src/ui/api/worktrunk.js +63 -25
- package/src/ui/public/components/config-view.js +95 -0
- package/src/ui/public/components/dashboard.js +823 -163
- package/src/ui/public/components/feature-detail.js +517 -124
- package/src/ui/public/components/test-quick.js +276 -0
- package/src/ui/public/components/worktrunk-panel.js +187 -25
- package/src/ui/public/index.html +13 -0
- package/src/ui/public/main.js +5 -1
- package/src/ui/server.js +97 -67
|
@@ -1,24 +1,117 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
2
3
|
import chalk from 'chalk';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_IDLE_TIMEOUT_MS = 180_000;
|
|
5
|
-
const AGENT_IDLE_TIMEOUT_MS =
|
|
6
|
+
const AGENT_IDLE_TIMEOUT_MS = 1_800_000; // 30 minutes pour éviter les timeouts sur les tâches complexes
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Parse stream-json events from Claude CLI and extract human-readable output
|
|
10
|
+
*/
|
|
11
|
+
function parseStreamJsonEvent(line, onData, state = {}) {
|
|
12
|
+
try {
|
|
13
|
+
const event = JSON.parse(line);
|
|
14
|
+
|
|
15
|
+
// Extract readable content based on event type
|
|
16
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
17
|
+
const msg = `[init] Model: ${event.model}, Tools: ${event.tools?.length || 0}\n`;
|
|
18
|
+
if (onData) onData({ type: 'stdout', text: msg });
|
|
19
|
+
return { result: null, state };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Stream events (partial messages) - token by token streaming
|
|
23
|
+
if (event.type === 'stream_event' && event.event) {
|
|
24
|
+
const streamEvent = event.event;
|
|
25
|
+
|
|
26
|
+
// Content block delta - actual text tokens
|
|
27
|
+
if (streamEvent.type === 'content_block_delta' && streamEvent.delta) {
|
|
28
|
+
if (streamEvent.delta.type === 'text_delta' && streamEvent.delta.text) {
|
|
29
|
+
if (onData) onData({ type: 'stdout', text: streamEvent.delta.text });
|
|
30
|
+
}
|
|
31
|
+
// Tool use input delta
|
|
32
|
+
if (streamEvent.delta.type === 'input_json_delta' && streamEvent.delta.partial_json) {
|
|
33
|
+
// Tool input streaming - don't output raw JSON
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Content block start - new block starting
|
|
38
|
+
if (streamEvent.type === 'content_block_start' && streamEvent.content_block) {
|
|
39
|
+
if (streamEvent.content_block.type === 'tool_use') {
|
|
40
|
+
const toolMsg = `\n[tool] ${streamEvent.content_block.name}\n`;
|
|
41
|
+
if (onData) onData({ type: 'stdout', text: toolMsg });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Message start
|
|
46
|
+
if (streamEvent.type === 'message_start') {
|
|
47
|
+
// New message starting - could add indicator
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Message stop
|
|
51
|
+
if (streamEvent.type === 'message_stop') {
|
|
52
|
+
if (onData) onData({ type: 'stdout', text: '\n' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { result: null, state };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Complete assistant message (non-streaming)
|
|
59
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
60
|
+
for (const block of event.message.content) {
|
|
61
|
+
if (block.type === 'text' && block.text) {
|
|
62
|
+
if (onData) onData({ type: 'stdout', text: block.text });
|
|
63
|
+
} else if (block.type === 'tool_use') {
|
|
64
|
+
const toolMsg = `\n[tool] ${block.name}\n`;
|
|
65
|
+
if (onData) onData({ type: 'stdout', text: toolMsg });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { result: null, state };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// User message (tool results)
|
|
72
|
+
if (event.type === 'user' && event.message?.content) {
|
|
73
|
+
for (const block of event.message.content) {
|
|
74
|
+
if (block.type === 'tool_result') {
|
|
75
|
+
const status = block.is_error ? '✗' : '✓';
|
|
76
|
+
const resultMsg = `\n[${status}] Tool completed\n`;
|
|
77
|
+
if (onData) onData({ type: 'stdout', text: resultMsg });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { result: null, state };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Final result
|
|
84
|
+
if (event.type === 'result') {
|
|
85
|
+
return { result: event.result || '', state };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { result: null, state };
|
|
89
|
+
} catch {
|
|
90
|
+
// Not valid JSON, return as-is
|
|
91
|
+
return { result: null, state };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function runCli(command, args, { stdin: stdinData, verbose = false, apply = false, idleTimeoutMs, onData, cwd, streamJson = false } = {}) {
|
|
8
96
|
if (!idleTimeoutMs) {
|
|
9
97
|
idleTimeoutMs = apply ? AGENT_IDLE_TIMEOUT_MS : DEFAULT_IDLE_TIMEOUT_MS;
|
|
10
98
|
}
|
|
11
99
|
return new Promise((resolve, reject) => {
|
|
12
|
-
|
|
100
|
+
// Remove CLAUDECODE from env to avoid conflicts
|
|
101
|
+
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
|
13
102
|
const child = spawn(command, args, {
|
|
14
103
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
15
104
|
env: { ...cleanEnv, FORCE_COLOR: '0' },
|
|
105
|
+
cwd,
|
|
16
106
|
});
|
|
17
107
|
|
|
18
108
|
const chunks = [];
|
|
19
109
|
let stderr = '';
|
|
20
110
|
let settled = false;
|
|
21
111
|
let gotFirstOutput = false;
|
|
112
|
+
let jsonBuffer = ''; // Buffer for incomplete JSON lines
|
|
113
|
+
let finalResult = ''; // Store the final result from stream-json
|
|
114
|
+
let parserState = {}; // State for stream parser
|
|
22
115
|
|
|
23
116
|
function resetTimer() {
|
|
24
117
|
clearTimeout(timer);
|
|
@@ -45,10 +138,32 @@ export function runCli(command, args, { stdin: stdinData, verbose = false, apply
|
|
|
45
138
|
console.error(chalk.gray('[AI] First stdout received — agent is running'));
|
|
46
139
|
}
|
|
47
140
|
const text = data.toString();
|
|
48
|
-
process.stdout.write(text);
|
|
49
|
-
chunks.push(text);
|
|
50
|
-
if (onData) onData({ type: 'stdout', text });
|
|
51
141
|
resetTimer();
|
|
142
|
+
|
|
143
|
+
if (streamJson) {
|
|
144
|
+
// Parse stream-json format line by line
|
|
145
|
+
jsonBuffer += text;
|
|
146
|
+
const lines = jsonBuffer.split('\n');
|
|
147
|
+
jsonBuffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
148
|
+
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
if (!line.trim()) continue;
|
|
151
|
+
const { result, state } = parseStreamJsonEvent(line, onData, parserState);
|
|
152
|
+
parserState = state;
|
|
153
|
+
if (result !== null) {
|
|
154
|
+
finalResult = result;
|
|
155
|
+
}
|
|
156
|
+
if (verbose) {
|
|
157
|
+
// In verbose mode, also show raw JSON for debugging
|
|
158
|
+
process.stdout.write(chalk.gray(line + '\n'));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
// Original behavior for non-stream-json
|
|
163
|
+
if (verbose) process.stdout.write(text);
|
|
164
|
+
chunks.push(text);
|
|
165
|
+
if (onData) onData({ type: 'stdout', text });
|
|
166
|
+
}
|
|
52
167
|
});
|
|
53
168
|
|
|
54
169
|
child.stderr.on('data', (data) => {
|
|
@@ -74,10 +189,19 @@ export function runCli(command, args, { stdin: stdinData, verbose = false, apply
|
|
|
74
189
|
});
|
|
75
190
|
|
|
76
191
|
child.on('close', (code) => {
|
|
192
|
+
// Process any remaining data in the buffer
|
|
193
|
+
if (streamJson && jsonBuffer.trim()) {
|
|
194
|
+
const { result } = parseStreamJsonEvent(jsonBuffer, onData, parserState);
|
|
195
|
+
if (result !== null) {
|
|
196
|
+
finalResult = result;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
77
200
|
if (code !== 0) {
|
|
78
201
|
finish(new Error(`${command} exited with code ${code}:\n${stderr.trim()}`));
|
|
79
202
|
} else {
|
|
80
|
-
|
|
203
|
+
// For stream-json, return the extracted result; otherwise return chunks
|
|
204
|
+
finish(null, streamJson ? finalResult : chunks.join(''));
|
|
81
205
|
}
|
|
82
206
|
});
|
|
83
207
|
|
package/src/providers/gemini.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { runCli } from './cli-runner.js';
|
|
2
2
|
|
|
3
|
-
export async function generate(prompt, model, { verbose = false, apply = false, onData } = {}) {
|
|
3
|
+
export async function generate(prompt, model, { verbose = false, apply = false, onData, cwd } = {}) {
|
|
4
4
|
const args = [];
|
|
5
5
|
if (model) {
|
|
6
6
|
args.push('-m', model);
|
|
7
7
|
}
|
|
8
8
|
if (apply) {
|
|
9
9
|
args.push('--sandbox', 'false');
|
|
10
|
+
// Use stream-json for real-time output visibility
|
|
11
|
+
args.push('--output-format', 'stream-json');
|
|
10
12
|
}
|
|
11
13
|
args.push('-');
|
|
12
14
|
|
|
13
|
-
return runCli('gemini', args, { stdin: prompt, verbose: verbose || apply, apply, onData });
|
|
15
|
+
return runCli('gemini', args, { stdin: prompt, verbose: verbose || apply, apply, onData, cwd, streamJson: apply });
|
|
14
16
|
}
|
package/src/providers/openai.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { runCli } from './cli-runner.js';
|
|
2
2
|
|
|
3
|
-
export async function generate(prompt, model, { verbose = false, apply = false, onData } = {}) {
|
|
3
|
+
export async function generate(prompt, model, { verbose = false, apply = false, onData, cwd } = {}) {
|
|
4
4
|
const args = ['exec'];
|
|
5
5
|
if (model) {
|
|
6
6
|
args.push('-c', `model="${model}"`);
|
|
@@ -10,5 +10,6 @@ export async function generate(prompt, model, { verbose = false, apply = false,
|
|
|
10
10
|
}
|
|
11
11
|
args.push('-');
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Note: Codex CLI does not support stream-json output format
|
|
14
|
+
return runCli('codex', args, { stdin: prompt, verbose: verbose || apply, apply, onData, cwd });
|
|
14
15
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Agent session tracking - in-memory Map with log buffer
|
|
2
|
+
// Map<featureName, { step, startedAt, logs: Array<{text, type, ts}>, sseClients: Set<Response> }>
|
|
3
|
+
const sessions = new Map();
|
|
4
|
+
const MAX_LOGS = 500;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Start a new agent session for a feature
|
|
8
|
+
* @param {string} feature - Feature name
|
|
9
|
+
* @param {string} step - Step name
|
|
10
|
+
*/
|
|
11
|
+
export function startSession(feature, step) {
|
|
12
|
+
sessions.set(feature, {
|
|
13
|
+
step,
|
|
14
|
+
startedAt: Date.now(),
|
|
15
|
+
logs: [],
|
|
16
|
+
sseClients: new Set(),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* End an agent session and notify all SSE clients
|
|
22
|
+
* @param {string} feature - Feature name
|
|
23
|
+
*/
|
|
24
|
+
export function endSession(feature) {
|
|
25
|
+
const session = sessions.get(feature);
|
|
26
|
+
if (session) {
|
|
27
|
+
// Notify all SSE clients that session ended
|
|
28
|
+
for (const client of session.sseClients) {
|
|
29
|
+
try {
|
|
30
|
+
client.write(`event: done\ndata: {}\n\n`);
|
|
31
|
+
} catch {
|
|
32
|
+
// Client may have disconnected
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
sessions.delete(feature);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get an active session for a feature
|
|
41
|
+
* @param {string} feature - Feature name
|
|
42
|
+
* @returns {Object|null} Session object or null
|
|
43
|
+
*/
|
|
44
|
+
export function getSession(feature) {
|
|
45
|
+
return sessions.get(feature) || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Append a log entry to a session's buffer
|
|
50
|
+
* @param {string} feature - Feature name
|
|
51
|
+
* @param {string} text - Log text
|
|
52
|
+
* @param {string} type - Log type ('stdout' or 'stderr')
|
|
53
|
+
*/
|
|
54
|
+
export function appendLog(feature, text, type = 'stdout') {
|
|
55
|
+
const session = sessions.get(feature);
|
|
56
|
+
if (!session) return;
|
|
57
|
+
|
|
58
|
+
session.logs.push({ text, type, ts: Date.now() });
|
|
59
|
+
if (session.logs.length > MAX_LOGS) session.logs.shift();
|
|
60
|
+
|
|
61
|
+
// Broadcast to all SSE clients
|
|
62
|
+
for (const client of session.sseClients) {
|
|
63
|
+
try {
|
|
64
|
+
client.write(`event: log\ndata: ${JSON.stringify({ text, type })}\n\n`);
|
|
65
|
+
} catch {
|
|
66
|
+
// Client may have disconnected
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register an SSE client for a session
|
|
73
|
+
* @param {string} feature - Feature name
|
|
74
|
+
* @param {Response} res - HTTP response object
|
|
75
|
+
*/
|
|
76
|
+
export function addSseClient(feature, res) {
|
|
77
|
+
const session = sessions.get(feature);
|
|
78
|
+
if (session) session.sseClients.add(res);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Unregister an SSE client from a session
|
|
83
|
+
* @param {string} feature - Feature name
|
|
84
|
+
* @param {Response} res - HTTP response object
|
|
85
|
+
*/
|
|
86
|
+
export function removeSseClient(feature, res) {
|
|
87
|
+
const session = sessions.get(feature);
|
|
88
|
+
if (session) session.sseClients.delete(res);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if a feature has an active session
|
|
93
|
+
* @param {string} feature - Feature name
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
export function isRunning(feature) {
|
|
97
|
+
return sessions.has(feature);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get all running sessions (for dashboard)
|
|
102
|
+
* @returns {Object} Map of feature name to session summary
|
|
103
|
+
*/
|
|
104
|
+
export function getAllRunningSessions() {
|
|
105
|
+
const result = {};
|
|
106
|
+
for (const [feature, session] of sessions) {
|
|
107
|
+
result[feature] = { step: session.step, startedAt: session.startedAt };
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { APP_ICONS } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
const MANIFEST_FILES = {
|
|
6
|
+
'package.json': 'node',
|
|
7
|
+
'pom.xml': 'java',
|
|
8
|
+
'go.mod': 'go',
|
|
9
|
+
'Cargo.toml': 'rust',
|
|
10
|
+
'requirements.txt': 'python',
|
|
11
|
+
'pyproject.toml': 'python',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const IGNORE_DIRS = new Set([
|
|
15
|
+
'node_modules',
|
|
16
|
+
'.git',
|
|
17
|
+
'dist',
|
|
18
|
+
'build',
|
|
19
|
+
'vendor',
|
|
20
|
+
'.aia',
|
|
21
|
+
'coverage',
|
|
22
|
+
'__pycache__',
|
|
23
|
+
'.next',
|
|
24
|
+
'.nuxt',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect the icon for an app based on its manifest file
|
|
29
|
+
*/
|
|
30
|
+
async function detectIcon(appPath, manifestType) {
|
|
31
|
+
// For node projects, check for framework-specific indicators
|
|
32
|
+
if (manifestType === 'node') {
|
|
33
|
+
const pkgPath = path.join(appPath, 'package.json');
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'));
|
|
36
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
37
|
+
|
|
38
|
+
if (deps.react || deps['react-dom']) return 'react';
|
|
39
|
+
if (deps.vue) return 'vue';
|
|
40
|
+
if (deps['@angular/core']) return 'angular';
|
|
41
|
+
return 'node';
|
|
42
|
+
} catch {
|
|
43
|
+
return 'node';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return manifestType;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if a directory is a git submodule
|
|
52
|
+
*/
|
|
53
|
+
async function isGitSubmodule(dirPath) {
|
|
54
|
+
const gitPath = path.join(dirPath, '.git');
|
|
55
|
+
try {
|
|
56
|
+
const stat = await fs.stat(gitPath);
|
|
57
|
+
// Submodules have a .git file (not directory) pointing to the main repo
|
|
58
|
+
return stat.isFile();
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Scan a directory for apps/submodules
|
|
66
|
+
*/
|
|
67
|
+
export async function scanApps(root = process.cwd()) {
|
|
68
|
+
const apps = [];
|
|
69
|
+
const visited = new Set();
|
|
70
|
+
|
|
71
|
+
async function scan(dir, depth = 0) {
|
|
72
|
+
// Limit depth to avoid deep recursion
|
|
73
|
+
if (depth > 3) return;
|
|
74
|
+
|
|
75
|
+
const relativePath = path.relative(root, dir);
|
|
76
|
+
if (visited.has(relativePath)) return;
|
|
77
|
+
visited.add(relativePath);
|
|
78
|
+
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check for manifest files in current directory
|
|
87
|
+
let foundManifest = null;
|
|
88
|
+
for (const [manifest, type] of Object.entries(MANIFEST_FILES)) {
|
|
89
|
+
if (entries.some(e => e.isFile() && e.name === manifest)) {
|
|
90
|
+
foundManifest = { manifest, type };
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check if this is a git submodule
|
|
96
|
+
const isSubmodule = await isGitSubmodule(dir);
|
|
97
|
+
|
|
98
|
+
// If we found a manifest or submodule (not at root), add as app
|
|
99
|
+
if ((foundManifest || isSubmodule) && relativePath) {
|
|
100
|
+
const appName = path.basename(dir);
|
|
101
|
+
const iconType = foundManifest
|
|
102
|
+
? await detectIcon(dir, foundManifest.type)
|
|
103
|
+
: 'generic';
|
|
104
|
+
|
|
105
|
+
apps.push({
|
|
106
|
+
name: appName,
|
|
107
|
+
path: relativePath,
|
|
108
|
+
icon: APP_ICONS[iconType] || APP_ICONS.generic,
|
|
109
|
+
enabled: true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Don't scan deeper if we found an app
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Recurse into subdirectories
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!entry.isDirectory()) continue;
|
|
119
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
120
|
+
if (entry.name.startsWith('.')) continue;
|
|
121
|
+
|
|
122
|
+
await scan(path.join(dir, entry.name), depth + 1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await scan(root);
|
|
127
|
+
|
|
128
|
+
// Sort by name
|
|
129
|
+
apps.sort((a, b) => a.name.localeCompare(b.name));
|
|
130
|
+
|
|
131
|
+
return apps;
|
|
132
|
+
}
|
package/src/services/config.js
CHANGED
|
@@ -4,6 +4,11 @@ import fs from 'fs-extra';
|
|
|
4
4
|
import yaml from 'yaml';
|
|
5
5
|
import { AIA_DIR } from '../constants.js';
|
|
6
6
|
|
|
7
|
+
// Project config path helper
|
|
8
|
+
function projectConfigPath(root) {
|
|
9
|
+
return path.join(root, AIA_DIR, 'config.yaml');
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
// Global user config directory
|
|
8
13
|
const GLOBAL_AIA_DIR = path.join(os.homedir(), '.aia');
|
|
9
14
|
const GLOBAL_CONFIG_PATH = path.join(GLOBAL_AIA_DIR, 'config.yaml');
|
|
@@ -103,3 +108,39 @@ export async function updateGlobalConfig(updates) {
|
|
|
103
108
|
export function getGlobalConfigPath() {
|
|
104
109
|
return GLOBAL_CONFIG_PATH;
|
|
105
110
|
}
|
|
111
|
+
|
|
112
|
+
// Project config helpers for apps
|
|
113
|
+
export async function loadProjectConfig(root = process.cwd()) {
|
|
114
|
+
const configPath = projectConfigPath(root);
|
|
115
|
+
if (!(await fs.pathExists(configPath))) {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
119
|
+
return yaml.parse(raw) || {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function getApps(root = process.cwd()) {
|
|
123
|
+
const config = await loadProjectConfig(root);
|
|
124
|
+
return config.apps || [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function setApps(apps, root = process.cwd()) {
|
|
128
|
+
const configPath = projectConfigPath(root);
|
|
129
|
+
let config = {};
|
|
130
|
+
if (await fs.pathExists(configPath)) {
|
|
131
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
132
|
+
config = yaml.parse(raw) || {};
|
|
133
|
+
}
|
|
134
|
+
config.apps = apps;
|
|
135
|
+
await fs.writeFile(configPath, yaml.stringify(config), 'utf-8');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function toggleApp(appName, enabled, root = process.cwd()) {
|
|
139
|
+
const apps = await getApps(root);
|
|
140
|
+
const app = apps.find(a => a.name === appName);
|
|
141
|
+
if (!app) {
|
|
142
|
+
throw new Error(`App "${appName}" not found`);
|
|
143
|
+
}
|
|
144
|
+
app.enabled = Boolean(enabled);
|
|
145
|
+
await setApps(apps, root);
|
|
146
|
+
}
|
package/src/services/feature.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import yaml from 'yaml';
|
|
4
|
-
import { AIA_DIR, FEATURE_STEPS } from '../constants.js';
|
|
4
|
+
import { AIA_DIR, FEATURE_STEPS, DEFAULT_FEATURE_TYPE, FEATURE_TYPES } from '../constants.js';
|
|
5
5
|
|
|
6
6
|
const FEATURE_FILES = [
|
|
7
7
|
'status.yaml',
|
|
@@ -36,21 +36,42 @@ function buildInitMd(name) {
|
|
|
36
36
|
`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function buildStatusYaml(name) {
|
|
39
|
+
function buildStatusYaml(name, { type = DEFAULT_FEATURE_TYPE, apps = [] } = {}) {
|
|
40
|
+
// Validate type
|
|
41
|
+
const validType = FEATURE_TYPES.includes(type) ? type : DEFAULT_FEATURE_TYPE;
|
|
42
|
+
|
|
43
|
+
// Validate apps - ensure it's an array of strings
|
|
44
|
+
const validApps = Array.isArray(apps)
|
|
45
|
+
? apps.filter(a => typeof a === 'string' && a.trim()).map(a => a.trim())
|
|
46
|
+
: [];
|
|
47
|
+
|
|
40
48
|
const steps = {};
|
|
41
49
|
for (const step of FEATURE_STEPS) {
|
|
42
50
|
steps[step] = 'pending';
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
|
|
53
|
+
// Bug type defaults to quick flow, feature type defaults to full flow
|
|
54
|
+
const flow = validType === 'bug' ? 'quick' : 'full';
|
|
55
|
+
const currentStep = flow === 'quick' ? 'dev-plan' : 'brief';
|
|
56
|
+
|
|
57
|
+
const status = {
|
|
46
58
|
feature: name,
|
|
47
|
-
|
|
59
|
+
type: validType,
|
|
60
|
+
flow,
|
|
61
|
+
current_step: currentStep,
|
|
62
|
+
createdAt: new Date().toISOString(),
|
|
48
63
|
steps,
|
|
49
64
|
knowledge: ['backend'],
|
|
50
|
-
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (validApps.length > 0) {
|
|
68
|
+
status.apps = validApps;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return yaml.stringify(status);
|
|
51
72
|
}
|
|
52
73
|
|
|
53
|
-
export async function createFeature(name, root = process.cwd()) {
|
|
74
|
+
export async function createFeature(name, root = process.cwd(), options = {}) {
|
|
54
75
|
validateFeatureName(name);
|
|
55
76
|
|
|
56
77
|
const featureDir = path.join(root, AIA_DIR, 'features', name);
|
|
@@ -64,7 +85,7 @@ export async function createFeature(name, root = process.cwd()) {
|
|
|
64
85
|
for (const file of FEATURE_FILES) {
|
|
65
86
|
const filePath = path.join(featureDir, file);
|
|
66
87
|
let content = '';
|
|
67
|
-
if (file === 'status.yaml') content = buildStatusYaml(name);
|
|
88
|
+
if (file === 'status.yaml') content = buildStatusYaml(name, options);
|
|
68
89
|
else if (file === 'init.md') content = buildInitMd(name);
|
|
69
90
|
await fs.writeFile(filePath, content, 'utf-8');
|
|
70
91
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { resolveModelAlias } from '../providers/registry.js';
|
|
3
3
|
|
|
4
|
-
export async function callModel(model, prompt, { verbose = false, apply = false, onData } = {}) {
|
|
4
|
+
export async function callModel(model, prompt, { verbose = false, apply = false, onData, cwd } = {}) {
|
|
5
5
|
const resolved = resolveModelAlias(model);
|
|
6
6
|
const displayName = resolved.model ?? `${model} (CLI default)`;
|
|
7
7
|
const mode = apply ? 'agent' : 'print';
|
|
8
8
|
|
|
9
9
|
console.log(chalk.yellow(`[AI] Calling ${displayName} (${mode} mode)...`));
|
|
10
10
|
|
|
11
|
-
return resolved.provider.generate(prompt, resolved.model, { verbose, apply, onData });
|
|
11
|
+
return resolved.provider.generate(prompt, resolved.model, { verbose, apply, onData, cwd });
|
|
12
12
|
}
|
package/src/services/runner.js
CHANGED
|
@@ -7,6 +7,8 @@ import { buildPrompt } from '../prompt-builder.js';
|
|
|
7
7
|
import { callModel } from './model-call.js';
|
|
8
8
|
import { loadStatus, updateStepStatus } from './status.js';
|
|
9
9
|
import { logExecution } from '../logger.js';
|
|
10
|
+
import { startSession, endSession, appendLog } from './agent-sessions.js';
|
|
11
|
+
import { hasWorktree, getWorktreePath, getFeatureBranch } from './worktrunk.js';
|
|
10
12
|
|
|
11
13
|
export async function runStep(step, feature, { description, instructions, history, attachments, model: modelOverride, verbose = false, apply = false, root = process.cwd(), onData } = {}) {
|
|
12
14
|
if (!FEATURE_STEPS.includes(step)) {
|
|
@@ -25,12 +27,29 @@ export async function runStep(step, feature, { description, instructions, histor
|
|
|
25
27
|
|
|
26
28
|
const shouldApply = apply || APPLY_STEPS.has(step);
|
|
27
29
|
|
|
30
|
+
// Start agent session for tracking
|
|
31
|
+
startSession(feature, step);
|
|
32
|
+
|
|
33
|
+
// Note: Claude CLI has a bug where it hangs in git worktrees
|
|
34
|
+
// So we always use the main repo root as CWD for now
|
|
35
|
+
// TODO: Re-enable worktree CWD when Claude CLI fixes this
|
|
36
|
+
// const branch = getFeatureBranch(feature);
|
|
37
|
+
// const wtPath = hasWorktree(branch, root) ? getWorktreePath(branch, root) : null;
|
|
38
|
+
// const cwd = wtPath || root;
|
|
39
|
+
const cwd = root;
|
|
40
|
+
|
|
41
|
+
// Wrapper onData to buffer logs in session
|
|
42
|
+
const wrappedOnData = (data) => {
|
|
43
|
+
appendLog(feature, data.text, data.type);
|
|
44
|
+
if (onData) onData(data);
|
|
45
|
+
};
|
|
46
|
+
|
|
28
47
|
try {
|
|
29
48
|
const model = modelOverride || await resolveModel(step, root);
|
|
30
49
|
const prompt = await buildPrompt(feature, step, { description, instructions, history, attachments, root });
|
|
31
50
|
|
|
32
51
|
const start = performance.now();
|
|
33
|
-
const output = await callModel(model, prompt, { verbose, apply: shouldApply, onData });
|
|
52
|
+
const output = await callModel(model, prompt, { verbose, apply: shouldApply, onData: wrappedOnData, cwd });
|
|
34
53
|
const duration = performance.now() - start;
|
|
35
54
|
|
|
36
55
|
const outputPath = path.join(root, AIA_DIR, 'features', feature, `${step}.md`);
|
|
@@ -44,5 +63,8 @@ export async function runStep(step, feature, { description, instructions, histor
|
|
|
44
63
|
} catch (err) {
|
|
45
64
|
await updateStepStatus(feature, step, STEP_STATUS.ERROR, root);
|
|
46
65
|
throw err;
|
|
66
|
+
} finally {
|
|
67
|
+
// End session regardless of success or failure
|
|
68
|
+
endSession(feature);
|
|
47
69
|
}
|
|
48
70
|
}
|