@bamptee/aia-code 2.0.0 → 2.0.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/README.md +55 -4
- package/package.json +7 -1
- package/src/commands/init.js +11 -1
- package/src/models.js +19 -2
- package/src/prompt-builder.js +33 -1
- package/src/providers/cli-runner.js +2 -1
- package/src/services/config.js +53 -2
- package/src/services/flow-analyzer.js +32 -0
- package/src/services/runner.js +2 -2
- package/src/services/status.js +14 -0
- package/src/services/suggestions.js +166 -0
- package/src/services/worktrunk.js +197 -0
- package/src/ui/api/config.js +55 -2
- package/src/ui/api/constants.js +9 -3
- package/src/ui/api/features.js +301 -6
- package/src/ui/api/index.js +2 -0
- package/src/ui/api/worktrunk.js +153 -0
- package/src/ui/public/components/config-view.js +196 -2
- package/src/ui/public/components/dashboard.js +64 -4
- package/src/ui/public/components/feature-detail.js +589 -104
- package/src/ui/public/components/terminal.js +197 -0
- package/src/ui/public/components/worktrunk-panel.js +205 -0
- package/src/ui/public/main.js +23 -1
- package/src/ui/router.js +1 -1
- package/src/ui/server.js +85 -0
- package/src/utils/prompt.js +38 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
|
|
6
|
+
// F1: Validate branch name to prevent command injection
|
|
7
|
+
function validateBranchName(branch) {
|
|
8
|
+
// Allow only alphanumeric, dash, underscore, slash
|
|
9
|
+
if (!/^[a-zA-Z0-9_\-/]+$/.test(branch)) {
|
|
10
|
+
throw new Error(`Invalid branch name: "${branch}". Only alphanumeric characters, dashes, underscores, and slashes are allowed.`);
|
|
11
|
+
}
|
|
12
|
+
return branch;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Find wt binary - check common locations
|
|
16
|
+
let wtBinary = null;
|
|
17
|
+
|
|
18
|
+
function findWtBinary() {
|
|
19
|
+
if (wtBinary !== null) return wtBinary;
|
|
20
|
+
|
|
21
|
+
const home = os.homedir();
|
|
22
|
+
const candidates = [
|
|
23
|
+
'wt', // in PATH
|
|
24
|
+
path.join(home, '.cargo', 'bin', 'wt'),
|
|
25
|
+
'/usr/local/bin/wt',
|
|
26
|
+
'/opt/homebrew/bin/wt',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const candidate of candidates) {
|
|
30
|
+
try {
|
|
31
|
+
execSync(`${candidate} --version`, { stdio: 'ignore' });
|
|
32
|
+
wtBinary = candidate;
|
|
33
|
+
return wtBinary;
|
|
34
|
+
} catch {
|
|
35
|
+
// Try next
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
wtBinary = false;
|
|
40
|
+
return wtBinary;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getWtCommand() {
|
|
44
|
+
const wt = findWtBinary();
|
|
45
|
+
if (!wt) throw new Error('Worktrunk (wt) CLI not found');
|
|
46
|
+
return wt;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if worktrunk (wt) CLI is installed
|
|
51
|
+
*/
|
|
52
|
+
export function isWtInstalled() {
|
|
53
|
+
return findWtBinary() !== false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* List all worktrees in a repository
|
|
58
|
+
* @param {string} cwd - Repository root directory
|
|
59
|
+
* @returns {Array} List of worktree objects
|
|
60
|
+
*/
|
|
61
|
+
export function listWorktrees(cwd) {
|
|
62
|
+
try {
|
|
63
|
+
const wt = getWtCommand();
|
|
64
|
+
const output = execSync(`${wt} list --format=json`, { cwd, encoding: 'utf-8' });
|
|
65
|
+
return JSON.parse(output);
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if a git branch exists
|
|
73
|
+
* @param {string} branch - Branch name
|
|
74
|
+
* @param {string} cwd - Repository root directory
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
function branchExists(branch, cwd) {
|
|
78
|
+
try {
|
|
79
|
+
execSync(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd, stdio: 'ignore' });
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a worktree for a branch
|
|
88
|
+
* @param {string} branch - Branch name (e.g., 'feature/my-feature')
|
|
89
|
+
* @param {string} cwd - Repository root directory
|
|
90
|
+
*/
|
|
91
|
+
export function createWorktree(branch, cwd) {
|
|
92
|
+
validateBranchName(branch);
|
|
93
|
+
const wt = getWtCommand();
|
|
94
|
+
|
|
95
|
+
// --no-cd: Don't try to change directory (we're in Node.js, not a shell)
|
|
96
|
+
// --yes: Skip approval prompts
|
|
97
|
+
// If branch already exists, switch to it (creates worktree)
|
|
98
|
+
// If branch doesn't exist, create it with -c
|
|
99
|
+
if (branchExists(branch, cwd)) {
|
|
100
|
+
execSync(`${wt} switch --no-cd --yes ${branch}`, { cwd, stdio: 'inherit' });
|
|
101
|
+
} else {
|
|
102
|
+
execSync(`${wt} switch --no-cd --yes -c ${branch}`, { cwd, stdio: 'inherit' });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get the path of a worktree for a branch
|
|
108
|
+
* @param {string} branch - Branch name
|
|
109
|
+
* @param {string} cwd - Repository root directory
|
|
110
|
+
* @returns {string|null} Worktree path or null if not found
|
|
111
|
+
*/
|
|
112
|
+
export function getWorktreePath(branch, cwd) {
|
|
113
|
+
const list = listWorktrees(cwd);
|
|
114
|
+
const wt = list.find(w => w.branch === branch || w.branch === `refs/heads/${branch}`);
|
|
115
|
+
return wt?.path || null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if a worktree exists for a branch
|
|
120
|
+
* @param {string} branch - Branch name
|
|
121
|
+
* @param {string} cwd - Repository root directory
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
export function hasWorktree(branch, cwd) {
|
|
125
|
+
return getWorktreePath(branch, cwd) !== null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remove a worktree
|
|
130
|
+
* @param {string} branch - Branch name
|
|
131
|
+
* @param {string} cwd - Repository root directory
|
|
132
|
+
*/
|
|
133
|
+
export function removeWorktree(branch, cwd) {
|
|
134
|
+
validateBranchName(branch);
|
|
135
|
+
const wt = getWtCommand();
|
|
136
|
+
execSync(`${wt} remove ${branch}`, { cwd, stdio: 'inherit' });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get the feature branch name from a feature name
|
|
141
|
+
* @param {string} featureName - Feature name
|
|
142
|
+
* @returns {string} Branch name (e.g., 'feature/my-feature')
|
|
143
|
+
*/
|
|
144
|
+
export function getFeatureBranch(featureName) {
|
|
145
|
+
return `feature/${featureName}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Start docker-compose services in a worktree
|
|
150
|
+
* @param {string} wtPath - Worktree directory path
|
|
151
|
+
*/
|
|
152
|
+
export function startServices(wtPath) {
|
|
153
|
+
const composePath = path.join(wtPath, 'docker-compose.wt.yml');
|
|
154
|
+
if (fs.existsSync(composePath)) {
|
|
155
|
+
execSync('docker-compose -f docker-compose.wt.yml up -d', { cwd: wtPath, stdio: 'inherit' });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Stop docker-compose services in a worktree
|
|
161
|
+
* @param {string} wtPath - Worktree directory path
|
|
162
|
+
*/
|
|
163
|
+
export function stopServices(wtPath) {
|
|
164
|
+
const composePath = path.join(wtPath, 'docker-compose.wt.yml');
|
|
165
|
+
if (fs.existsSync(composePath)) {
|
|
166
|
+
execSync('docker-compose -f docker-compose.wt.yml down', { cwd: wtPath, stdio: 'inherit' });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check if docker-compose.wt.yml exists in a worktree
|
|
172
|
+
* @param {string} wtPath - Worktree directory path
|
|
173
|
+
* @returns {boolean}
|
|
174
|
+
*/
|
|
175
|
+
export function hasDockerServices(wtPath) {
|
|
176
|
+
if (!wtPath) return false;
|
|
177
|
+
const composePath = path.join(wtPath, 'docker-compose.wt.yml');
|
|
178
|
+
return fs.existsSync(composePath);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get docker services status
|
|
183
|
+
* @param {string} wtPath - Worktree directory path
|
|
184
|
+
* @returns {Array} List of service objects with name and status
|
|
185
|
+
*/
|
|
186
|
+
export function getServicesStatus(wtPath) {
|
|
187
|
+
if (!hasDockerServices(wtPath)) return [];
|
|
188
|
+
try {
|
|
189
|
+
const output = execSync('docker-compose -f docker-compose.wt.yml ps --format json', {
|
|
190
|
+
cwd: wtPath,
|
|
191
|
+
encoding: 'utf-8',
|
|
192
|
+
});
|
|
193
|
+
return JSON.parse(output);
|
|
194
|
+
} catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/ui/api/config.js
CHANGED
|
@@ -2,10 +2,11 @@ import path from 'node:path';
|
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import yaml from 'yaml';
|
|
4
4
|
import { AIA_DIR } from '../../constants.js';
|
|
5
|
+
import { loadGlobalConfig, saveGlobalConfig, getGlobalConfigPath } from '../../services/config.js';
|
|
5
6
|
import { json, error } from '../router.js';
|
|
6
7
|
|
|
7
8
|
export function registerConfigRoutes(router) {
|
|
8
|
-
// Get config
|
|
9
|
+
// Get project config
|
|
9
10
|
router.get('/api/config', async (req, res, { root }) => {
|
|
10
11
|
const configPath = path.join(root, AIA_DIR, 'config.yaml');
|
|
11
12
|
if (!(await fs.pathExists(configPath))) {
|
|
@@ -15,7 +16,7 @@ export function registerConfigRoutes(router) {
|
|
|
15
16
|
json(res, { content, parsed: yaml.parse(content) });
|
|
16
17
|
});
|
|
17
18
|
|
|
18
|
-
// Save config
|
|
19
|
+
// Save project config
|
|
19
20
|
router.put('/api/config', async (req, res, { root, parseBody }) => {
|
|
20
21
|
const body = await parseBody();
|
|
21
22
|
const configPath = path.join(root, AIA_DIR, 'config.yaml');
|
|
@@ -23,6 +24,58 @@ export function registerConfigRoutes(router) {
|
|
|
23
24
|
json(res, { ok: true });
|
|
24
25
|
});
|
|
25
26
|
|
|
27
|
+
// Get global user config
|
|
28
|
+
router.get('/api/user-config', async (req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
const config = await loadGlobalConfig();
|
|
31
|
+
const configPath = getGlobalConfigPath();
|
|
32
|
+
json(res, { parsed: config, path: configPath });
|
|
33
|
+
} catch (e) {
|
|
34
|
+
error(res, e.message, 500);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Update global user preferences
|
|
39
|
+
router.patch('/api/user-config', async (req, res, { parseBody }) => {
|
|
40
|
+
const body = await parseBody();
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const config = await loadGlobalConfig();
|
|
44
|
+
|
|
45
|
+
// Update only user preference fields
|
|
46
|
+
if (body.user_name !== undefined) config.user_name = body.user_name;
|
|
47
|
+
if (body.communication_language !== undefined) config.communication_language = body.communication_language;
|
|
48
|
+
|
|
49
|
+
await saveGlobalConfig(config);
|
|
50
|
+
|
|
51
|
+
json(res, { ok: true, config });
|
|
52
|
+
} catch (e) {
|
|
53
|
+
error(res, e.message, 500);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Update project preferences (partial update)
|
|
58
|
+
router.patch('/api/config/project', async (req, res, { root, parseBody }) => {
|
|
59
|
+
const body = await parseBody();
|
|
60
|
+
const configPath = path.join(root, AIA_DIR, 'config.yaml');
|
|
61
|
+
|
|
62
|
+
if (!(await fs.pathExists(configPath))) {
|
|
63
|
+
return error(res, 'config.yaml not found', 404);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
67
|
+
const config = yaml.parse(content);
|
|
68
|
+
|
|
69
|
+
// Update project preference fields
|
|
70
|
+
if (body.projectName !== undefined) config.projectName = body.projectName;
|
|
71
|
+
if (body.document_output_language !== undefined) config.document_output_language = body.document_output_language;
|
|
72
|
+
|
|
73
|
+
const newContent = yaml.stringify(config);
|
|
74
|
+
await fs.writeFile(configPath, newContent, 'utf-8');
|
|
75
|
+
|
|
76
|
+
json(res, { ok: true, config });
|
|
77
|
+
});
|
|
78
|
+
|
|
26
79
|
// List context files
|
|
27
80
|
router.get('/api/context', async (req, res, { root }) => {
|
|
28
81
|
const dir = path.join(root, AIA_DIR, 'context');
|
package/src/ui/api/constants.js
CHANGED
|
@@ -7,13 +7,19 @@ export function registerConstantsRoutes(router) {
|
|
|
7
7
|
json(res, { FEATURE_STEPS, STEP_STATUS, QUICK_STEPS });
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
-
// List
|
|
10
|
+
// List all unique models from config
|
|
11
11
|
router.get('/api/models', async (req, res, { root }) => {
|
|
12
12
|
try {
|
|
13
13
|
const config = await loadConfig(root);
|
|
14
|
-
|
|
14
|
+
const all = new Set();
|
|
15
|
+
for (const models of Object.values(config.models || {})) {
|
|
16
|
+
for (const entry of models) {
|
|
17
|
+
all.add(entry.model);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
json(res, [...all]);
|
|
15
21
|
} catch (err) {
|
|
16
|
-
json(res,
|
|
22
|
+
json(res, []);
|
|
17
23
|
}
|
|
18
24
|
});
|
|
19
25
|
}
|
package/src/ui/api/features.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
|
+
import Busboy from 'busboy';
|
|
3
4
|
import { AIA_DIR } from '../../constants.js';
|
|
4
|
-
import { loadStatus, updateStepStatus, resetStep } from '../../services/status.js';
|
|
5
|
+
import { loadStatus, updateStepStatus, resetStep, updateFlowType } from '../../services/status.js';
|
|
5
6
|
import { createFeature, validateFeatureName } from '../../services/feature.js';
|
|
6
7
|
import { runStep } from '../../services/runner.js';
|
|
7
8
|
import { runQuick } from '../../services/quick.js';
|
|
9
|
+
import { suggestFlowType } from '../../services/flow-analyzer.js';
|
|
10
|
+
import { getGuidance } from '../../services/suggestions.js';
|
|
11
|
+
import { callModel } from '../../services/model-call.js';
|
|
12
|
+
import { loadConfig } from '../../models.js';
|
|
8
13
|
import { json, error } from '../router.js';
|
|
14
|
+
import { isWtInstalled, hasWorktree, getFeatureBranch } from '../../services/worktrunk.js';
|
|
15
|
+
|
|
16
|
+
const MAX_DESCRIPTION_LENGTH = 50000; // 50KB
|
|
9
17
|
|
|
10
18
|
function sseHeaders(res) {
|
|
11
19
|
res.writeHead(200, {
|
|
@@ -20,6 +28,27 @@ function sseSend(res, event, data) {
|
|
|
20
28
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
21
29
|
}
|
|
22
30
|
|
|
31
|
+
// F6: Extracted validation helpers to avoid code duplication
|
|
32
|
+
function validateHistory(rawHistory) {
|
|
33
|
+
return Array.isArray(rawHistory)
|
|
34
|
+
? rawHistory
|
|
35
|
+
.filter(msg => msg && typeof msg === 'object' && typeof msg.content === 'string')
|
|
36
|
+
.map(msg => ({
|
|
37
|
+
role: msg.role === 'agent' ? 'agent' : 'user',
|
|
38
|
+
content: String(msg.content).slice(0, 50000),
|
|
39
|
+
}))
|
|
40
|
+
.slice(-20)
|
|
41
|
+
: [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateAttachments(rawAttachments) {
|
|
45
|
+
return Array.isArray(rawAttachments)
|
|
46
|
+
? rawAttachments
|
|
47
|
+
.filter(a => a && typeof a === 'object' && typeof a.path === 'string')
|
|
48
|
+
.map(a => ({ filename: a.filename || 'unknown', path: a.path }))
|
|
49
|
+
: [];
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
export function registerFeatureRoutes(router) {
|
|
24
53
|
// List all features
|
|
25
54
|
router.get('/api/features', async (req, res, { root }) => {
|
|
@@ -29,13 +58,15 @@ export function registerFeatureRoutes(router) {
|
|
|
29
58
|
}
|
|
30
59
|
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
|
|
31
60
|
const features = [];
|
|
61
|
+
const wtInstalled = isWtInstalled();
|
|
32
62
|
for (const entry of entries) {
|
|
33
63
|
if (entry.isDirectory()) {
|
|
34
64
|
try {
|
|
35
65
|
const status = await loadStatus(entry.name, root);
|
|
36
|
-
|
|
66
|
+
const hasWt = wtInstalled && hasWorktree(getFeatureBranch(entry.name), root);
|
|
67
|
+
features.push({ name: entry.name, ...status, hasWorktree: hasWt });
|
|
37
68
|
} catch {
|
|
38
|
-
features.push({ name: entry.name, error: true });
|
|
69
|
+
features.push({ name: entry.name, error: true, hasWorktree: false });
|
|
39
70
|
}
|
|
40
71
|
}
|
|
41
72
|
}
|
|
@@ -95,10 +126,15 @@ export function registerFeatureRoutes(router) {
|
|
|
95
126
|
};
|
|
96
127
|
|
|
97
128
|
try {
|
|
129
|
+
const validatedHistory = validateHistory(body.history || []);
|
|
130
|
+
const validatedAttachments = validateAttachments(body.attachments || []);
|
|
131
|
+
|
|
98
132
|
const output = await runStep(params.step, params.name, {
|
|
99
133
|
description: body.description,
|
|
134
|
+
history: validatedHistory,
|
|
135
|
+
attachments: validatedAttachments,
|
|
100
136
|
model: body.model || undefined,
|
|
101
|
-
verbose: true,
|
|
137
|
+
verbose: body.verbose !== undefined ? body.verbose : true,
|
|
102
138
|
apply: body.apply || false,
|
|
103
139
|
root,
|
|
104
140
|
onData,
|
|
@@ -171,10 +207,15 @@ export function registerFeatureRoutes(router) {
|
|
|
171
207
|
try { sseSend(res, 'log', { type, text }); } catch {}
|
|
172
208
|
};
|
|
173
209
|
|
|
174
|
-
const
|
|
210
|
+
const validatedHistory = validateHistory(body.history || []);
|
|
211
|
+
const validatedAttachments = validateAttachments(body.attachments || []);
|
|
212
|
+
|
|
213
|
+
await runStep(params.step, params.name, {
|
|
175
214
|
instructions: body.instructions,
|
|
215
|
+
history: validatedHistory,
|
|
216
|
+
attachments: validatedAttachments,
|
|
176
217
|
model: body.model || undefined,
|
|
177
|
-
verbose: true,
|
|
218
|
+
verbose: body.verbose !== undefined ? body.verbose : true,
|
|
178
219
|
apply: body.apply || false,
|
|
179
220
|
root,
|
|
180
221
|
onData,
|
|
@@ -195,4 +236,258 @@ export function registerFeatureRoutes(router) {
|
|
|
195
236
|
error(res, err.message, 400);
|
|
196
237
|
}
|
|
197
238
|
});
|
|
239
|
+
|
|
240
|
+
// Initialize feature with description, enrich with agent, and get flow suggestion
|
|
241
|
+
router.post('/api/features/:name/init', async (req, res, { params, root, parseBody }) => {
|
|
242
|
+
const body = await parseBody();
|
|
243
|
+
const { description } = body;
|
|
244
|
+
|
|
245
|
+
if (!description) {
|
|
246
|
+
return error(res, 'Description is required', 400);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (description.length > MAX_DESCRIPTION_LENGTH) {
|
|
250
|
+
return error(res, `Description too long (${description.length} chars, max ${MAX_DESCRIPTION_LENGTH})`, 400);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
sseHeaders(res);
|
|
254
|
+
sseSend(res, 'status', { status: 'enriching', message: 'Structuring your description...' });
|
|
255
|
+
|
|
256
|
+
const onData = ({ type, text }) => {
|
|
257
|
+
try { sseSend(res, 'log', { type, text }); } catch {}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
// Load config to get user preferences
|
|
262
|
+
const config = await loadConfig(root);
|
|
263
|
+
|
|
264
|
+
// Build enrichment prompt
|
|
265
|
+
const enrichPrompt = `You are helping structure a feature description for a development project.
|
|
266
|
+
|
|
267
|
+
USER INPUT:
|
|
268
|
+
${description}
|
|
269
|
+
|
|
270
|
+
TASK:
|
|
271
|
+
Transform this input into a well-structured feature specification document in Markdown format.
|
|
272
|
+
|
|
273
|
+
OUTPUT FORMAT:
|
|
274
|
+
# ${params.name}
|
|
275
|
+
|
|
276
|
+
## Summary
|
|
277
|
+
(1-2 sentence summary of what this feature does)
|
|
278
|
+
|
|
279
|
+
## Problem Statement
|
|
280
|
+
(What problem does this solve? Why is it needed?)
|
|
281
|
+
|
|
282
|
+
## Requirements
|
|
283
|
+
(List the key functional requirements as bullet points)
|
|
284
|
+
|
|
285
|
+
## Constraints
|
|
286
|
+
(Any technical constraints, limitations, or considerations)
|
|
287
|
+
|
|
288
|
+
## Success Criteria
|
|
289
|
+
(How do we know this feature is complete and working?)
|
|
290
|
+
|
|
291
|
+
IMPORTANT:
|
|
292
|
+
- Keep it concise but complete
|
|
293
|
+
- Don't add requirements that weren't mentioned or implied
|
|
294
|
+
- If the input is vague, make reasonable assumptions and note them
|
|
295
|
+
- Output ONLY the markdown document, no explanations
|
|
296
|
+
- Write the ENTIRE document in ${config.document_output_language || 'English'} regardless of the input language`;
|
|
297
|
+
|
|
298
|
+
// Call model to enrich (use brief model config or default)
|
|
299
|
+
const model = config.models?.brief?.[0]?.model || 'claude-default';
|
|
300
|
+
|
|
301
|
+
sseSend(res, 'status', { status: 'generating', message: 'AI is structuring the feature...' });
|
|
302
|
+
|
|
303
|
+
const enrichedContent = await callModel(model, enrichPrompt, {
|
|
304
|
+
verbose: false,
|
|
305
|
+
apply: false,
|
|
306
|
+
onData
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Save enriched content to init.md
|
|
310
|
+
const initPath = path.join(root, AIA_DIR, 'features', params.name, 'init.md');
|
|
311
|
+
await fs.writeFile(initPath, enrichedContent.trim(), 'utf-8');
|
|
312
|
+
|
|
313
|
+
// Analyze and suggest flow type based on enriched content
|
|
314
|
+
const suggestion = suggestFlowType(enrichedContent);
|
|
315
|
+
|
|
316
|
+
sseSend(res, 'done', { ok: true, suggestion, content: enrichedContent.trim() });
|
|
317
|
+
} catch (err) {
|
|
318
|
+
sseSend(res, 'error', { message: err.message });
|
|
319
|
+
}
|
|
320
|
+
res.end();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Get guidance for a step
|
|
324
|
+
router.get('/api/features/:name/guidance/:step', async (req, res, { params, root }) => {
|
|
325
|
+
const config = await loadConfig(root);
|
|
326
|
+
const language = config.communication_language || 'English';
|
|
327
|
+
const guidance = getGuidance(params.step, language);
|
|
328
|
+
if (!guidance) {
|
|
329
|
+
return error(res, `No guidance for step "${params.step}"`, 404);
|
|
330
|
+
}
|
|
331
|
+
json(res, guidance);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Upload attachments (multipart/form-data)
|
|
335
|
+
router.post('/api/features/:name/attachments', async (req, res, { params, root }) => {
|
|
336
|
+
const attachDir = path.join(root, AIA_DIR, 'features', params.name, 'attachments');
|
|
337
|
+
await fs.ensureDir(attachDir);
|
|
338
|
+
|
|
339
|
+
const busboy = Busboy({
|
|
340
|
+
headers: req.headers,
|
|
341
|
+
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB per file
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const files = [];
|
|
345
|
+
const writtenFiles = []; // F12: Track all written files for cleanup
|
|
346
|
+
let totalSize = 0;
|
|
347
|
+
const maxTotalSize = 50 * 1024 * 1024; // 50MB total
|
|
348
|
+
let hasError = false;
|
|
349
|
+
|
|
350
|
+
// F12: Cleanup function to remove all written files on error
|
|
351
|
+
const cleanupFiles = async () => {
|
|
352
|
+
for (const filepath of writtenFiles) {
|
|
353
|
+
await fs.unlink(filepath).catch(() => {});
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
busboy.on('file', (fieldname, file, info) => {
|
|
358
|
+
const { filename, mimeType } = info;
|
|
359
|
+
|
|
360
|
+
// Validate mime type
|
|
361
|
+
const allowedTypes = ['image/', 'application/pdf', 'text/'];
|
|
362
|
+
const isAllowed = allowedTypes.some(t => mimeType.startsWith(t));
|
|
363
|
+
if (!isAllowed) {
|
|
364
|
+
file.resume(); // Drain the stream
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const safeFilename = `${Date.now()}-${filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
|
369
|
+
const filepath = path.join(attachDir, safeFilename);
|
|
370
|
+
const writeStream = fs.createWriteStream(filepath);
|
|
371
|
+
writtenFiles.push(filepath); // F12: Track for cleanup
|
|
372
|
+
|
|
373
|
+
// F7: Handle writeStream errors
|
|
374
|
+
writeStream.on('error', async (err) => {
|
|
375
|
+
hasError = true;
|
|
376
|
+
file.resume(); // Stop reading
|
|
377
|
+
await cleanupFiles();
|
|
378
|
+
if (!res.headersSent) {
|
|
379
|
+
error(res, `Write error: ${err.message}`, 500);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
let fileSize = 0;
|
|
384
|
+
file.on('data', (data) => {
|
|
385
|
+
fileSize += data.length;
|
|
386
|
+
totalSize += data.length;
|
|
387
|
+
|
|
388
|
+
// F3: Check total size during upload, not after
|
|
389
|
+
if (totalSize > maxTotalSize) {
|
|
390
|
+
hasError = true;
|
|
391
|
+
file.resume(); // Stop reading this file
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
file.pipe(writeStream);
|
|
396
|
+
|
|
397
|
+
file.on('end', () => {
|
|
398
|
+
if (!hasError && fileSize > 0) {
|
|
399
|
+
files.push({
|
|
400
|
+
filename: safeFilename,
|
|
401
|
+
originalName: filename,
|
|
402
|
+
path: filepath,
|
|
403
|
+
mimeType,
|
|
404
|
+
size: fileSize,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
file.on('limit', () => {
|
|
410
|
+
fs.unlink(filepath).catch(() => {});
|
|
411
|
+
// Remove from writtenFiles since we're handling it here
|
|
412
|
+
const idx = writtenFiles.indexOf(filepath);
|
|
413
|
+
if (idx > -1) writtenFiles.splice(idx, 1);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
busboy.on('finish', async () => {
|
|
418
|
+
if (hasError) {
|
|
419
|
+
// F12: Clean up all files if we hit an error
|
|
420
|
+
await cleanupFiles();
|
|
421
|
+
if (!res.headersSent) {
|
|
422
|
+
error(res, `Total size exceeds ${maxTotalSize / 1024 / 1024}MB limit`, 413);
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
json(res, { ok: true, files });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
busboy.on('error', async (err) => {
|
|
430
|
+
await cleanupFiles();
|
|
431
|
+
if (!res.headersSent) {
|
|
432
|
+
error(res, err.message, 500);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
req.pipe(busboy);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// List attachments
|
|
440
|
+
router.get('/api/features/:name/attachments', async (req, res, { params, root }) => {
|
|
441
|
+
const attachDir = path.join(root, AIA_DIR, 'features', params.name, 'attachments');
|
|
442
|
+
if (!(await fs.pathExists(attachDir))) {
|
|
443
|
+
return json(res, []);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const entries = await fs.readdir(attachDir);
|
|
447
|
+
const files = await Promise.all(
|
|
448
|
+
entries.map(async (filename) => {
|
|
449
|
+
const filepath = path.join(attachDir, filename);
|
|
450
|
+
const stat = await fs.stat(filepath);
|
|
451
|
+
return {
|
|
452
|
+
filename,
|
|
453
|
+
path: filepath,
|
|
454
|
+
size: stat.size,
|
|
455
|
+
};
|
|
456
|
+
})
|
|
457
|
+
);
|
|
458
|
+
json(res, files);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// Delete an attachment
|
|
462
|
+
router.delete('/api/features/:name/attachments/:filename', async (req, res, { params, root }) => {
|
|
463
|
+
// F1: Prevent path traversal attacks
|
|
464
|
+
const filename = path.basename(params.filename);
|
|
465
|
+
if (filename !== params.filename || filename.includes('..')) {
|
|
466
|
+
return error(res, 'Invalid filename', 400);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const filepath = path.join(root, AIA_DIR, 'features', params.name, 'attachments', filename);
|
|
470
|
+
if (!(await fs.pathExists(filepath))) {
|
|
471
|
+
return error(res, 'Attachment not found', 404);
|
|
472
|
+
}
|
|
473
|
+
await fs.remove(filepath);
|
|
474
|
+
json(res, { ok: true });
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Update flow type (quick/full)
|
|
478
|
+
router.patch('/api/features/:name/flow', async (req, res, { params, root, parseBody }) => {
|
|
479
|
+
const body = await parseBody();
|
|
480
|
+
const { flow } = body;
|
|
481
|
+
|
|
482
|
+
if (!flow || !['quick', 'full'].includes(flow)) {
|
|
483
|
+
return error(res, 'Invalid flow type. Must be "quick" or "full"', 400);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
await updateFlowType(params.name, flow, root);
|
|
488
|
+
json(res, { ok: true, flow });
|
|
489
|
+
} catch (err) {
|
|
490
|
+
error(res, err.message, 400);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
198
493
|
}
|
package/src/ui/api/index.js
CHANGED
|
@@ -2,10 +2,12 @@ import { registerFeatureRoutes } from './features.js';
|
|
|
2
2
|
import { registerConfigRoutes } from './config.js';
|
|
3
3
|
import { registerLogRoutes } from './logs.js';
|
|
4
4
|
import { registerConstantsRoutes } from './constants.js';
|
|
5
|
+
import { registerWorktrunkRoutes } from './worktrunk.js';
|
|
5
6
|
|
|
6
7
|
export function registerApiRoutes(router, root) {
|
|
7
8
|
registerFeatureRoutes(router);
|
|
8
9
|
registerConfigRoutes(router);
|
|
9
10
|
registerLogRoutes(router);
|
|
10
11
|
registerConstantsRoutes(router);
|
|
12
|
+
registerWorktrunkRoutes(router);
|
|
11
13
|
}
|