agileflow 2.95.2 → 2.96.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/CHANGELOG.md +10 -0
- package/README.md +6 -6
- package/lib/api-routes.js +605 -0
- package/lib/api-server.js +260 -0
- package/lib/claude-cli-bridge.js +221 -0
- package/lib/dashboard-protocol.js +541 -0
- package/lib/dashboard-server.js +1601 -0
- package/lib/drivers/claude-driver.ts +310 -0
- package/lib/drivers/codex-driver.ts +454 -0
- package/lib/drivers/driver-manager.ts +158 -0
- package/lib/drivers/gemini-driver.ts +485 -0
- package/lib/drivers/index.ts +17 -0
- package/lib/flag-detection.js +350 -0
- package/lib/git-operations.js +267 -0
- package/lib/lock-file.js +144 -0
- package/lib/merge-operations.js +959 -0
- package/lib/protocol/driver.ts +360 -0
- package/lib/protocol/index.ts +12 -0
- package/lib/protocol/ir.ts +271 -0
- package/lib/session-display.js +330 -0
- package/lib/worktree-operations.js +221 -0
- package/package.json +2 -2
- package/scripts/agileflow-welcome.js +272 -24
- package/scripts/api-server-runner.js +177 -0
- package/scripts/archive-completed-stories.sh +22 -0
- package/scripts/automation-run-due.js +126 -0
- package/scripts/backfill-ideation-status.js +124 -0
- package/scripts/claude-tmux.sh +62 -1
- package/scripts/context-loader.js +292 -0
- package/scripts/dashboard-serve.js +323 -0
- package/scripts/lib/automation-registry.js +544 -0
- package/scripts/lib/automation-runner.js +476 -0
- package/scripts/lib/concurrency-limiter.js +513 -0
- package/scripts/lib/configure-features.js +46 -0
- package/scripts/lib/context-formatter.js +61 -0
- package/scripts/lib/damage-control-utils.js +29 -4
- package/scripts/lib/hook-metrics.js +324 -0
- package/scripts/lib/ideation-index.js +1196 -0
- package/scripts/lib/process-cleanup.js +359 -0
- package/scripts/lib/quality-gates.js +574 -0
- package/scripts/lib/status-task-bridge.js +522 -0
- package/scripts/lib/sync-ideation-status.js +292 -0
- package/scripts/lib/task-registry-cache.js +490 -0
- package/scripts/lib/task-registry.js +1181 -0
- package/scripts/migrate-ideation-index.js +515 -0
- package/scripts/precompact-context.sh +104 -0
- package/scripts/ralph-loop.js +2 -2
- package/scripts/session-manager.js +363 -2770
- package/scripts/spawn-parallel.js +45 -9
- package/src/core/agents/api-validator.md +180 -0
- package/src/core/agents/api.md +2 -0
- package/src/core/agents/code-reviewer.md +289 -0
- package/src/core/agents/configuration/damage-control.md +17 -0
- package/src/core/agents/database.md +2 -0
- package/src/core/agents/error-analyzer.md +203 -0
- package/src/core/agents/logic-analyzer-edge.md +171 -0
- package/src/core/agents/logic-analyzer-flow.md +254 -0
- package/src/core/agents/logic-analyzer-invariant.md +207 -0
- package/src/core/agents/logic-analyzer-race.md +267 -0
- package/src/core/agents/logic-analyzer-type.md +218 -0
- package/src/core/agents/logic-consensus.md +256 -0
- package/src/core/agents/orchestrator.md +89 -1
- package/src/core/agents/schema-validator.md +451 -0
- package/src/core/agents/team-coordinator.md +328 -0
- package/src/core/agents/ui-validator.md +328 -0
- package/src/core/agents/ui.md +2 -0
- package/src/core/commands/api.md +267 -0
- package/src/core/commands/automate.md +415 -0
- package/src/core/commands/babysit.md +290 -9
- package/src/core/commands/ideate/history.md +403 -0
- package/src/core/commands/{ideate.md → ideate/new.md} +244 -34
- package/src/core/commands/logic/audit.md +368 -0
- package/src/core/commands/roadmap/analyze.md +1 -1
- package/src/core/experts/documentation/expertise.yaml +29 -2
- package/src/core/templates/CONTEXT.md.example +49 -0
- package/src/core/templates/claude-settings.advanced.example.json +4 -0
- package/tools/cli/commands/serve.js +456 -0
- package/tools/cli/installers/core/installer.js +7 -2
- package/tools/cli/installers/ide/claude-code.js +85 -0
- package/tools/cli/lib/content-injector.js +27 -1
- package/tools/cli/lib/ui.js +26 -57
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgileFlow CLI - Serve Command
|
|
3
|
+
*
|
|
4
|
+
* Starts a WebSocket server for the AgileFlow Dashboard to connect to.
|
|
5
|
+
* Enables real-time communication between the dashboard and Claude Code.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const chalk = require('chalk');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { displayLogo, displaySection, success, warning, info } = require('../lib/ui');
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
name: 'serve',
|
|
14
|
+
description: 'Start WebSocket server for AgileFlow Dashboard',
|
|
15
|
+
options: [
|
|
16
|
+
['-p, --port <number>', 'Port to listen on (default: 8765)'],
|
|
17
|
+
['-H, --host <host>', 'Host to bind to (default: 0.0.0.0)'],
|
|
18
|
+
['-k, --api-key <key>', 'API key for authentication'],
|
|
19
|
+
['--require-auth', 'Require API key for connections'],
|
|
20
|
+
['--no-tunnel', 'Disable automatic tunnel (local only)'],
|
|
21
|
+
['--tunnel-provider <provider>', 'Tunnel provider: cloudflared (default) or ngrok'],
|
|
22
|
+
],
|
|
23
|
+
action: async options => {
|
|
24
|
+
try {
|
|
25
|
+
// Import server modules
|
|
26
|
+
const {
|
|
27
|
+
createDashboardServer,
|
|
28
|
+
startDashboardServer,
|
|
29
|
+
stopDashboardServer,
|
|
30
|
+
} = require('../../../lib/dashboard-server');
|
|
31
|
+
const {
|
|
32
|
+
createNotification,
|
|
33
|
+
createTextDelta,
|
|
34
|
+
createToolStart,
|
|
35
|
+
createToolResult,
|
|
36
|
+
} = require('../../../lib/dashboard-protocol');
|
|
37
|
+
const { execSync } = require('child_process');
|
|
38
|
+
const readline = require('readline');
|
|
39
|
+
|
|
40
|
+
let port = parseInt(options.port, 10) || 8765;
|
|
41
|
+
const host = options.host || '0.0.0.0';
|
|
42
|
+
|
|
43
|
+
// Check if port is in use and handle it
|
|
44
|
+
port = await handlePortConflict(port, readline);
|
|
45
|
+
|
|
46
|
+
const serverOptions = {
|
|
47
|
+
port,
|
|
48
|
+
host,
|
|
49
|
+
apiKey: options.apiKey || null,
|
|
50
|
+
requireAuth: options.requireAuth || !!options.apiKey,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Display banner
|
|
54
|
+
printBanner();
|
|
55
|
+
console.log('Starting server...\n');
|
|
56
|
+
|
|
57
|
+
// Create server
|
|
58
|
+
const server = createDashboardServer(serverOptions);
|
|
59
|
+
|
|
60
|
+
// Set up event handlers
|
|
61
|
+
setupEventHandlers(server, { createTextDelta, createToolStart, createToolResult });
|
|
62
|
+
|
|
63
|
+
// Start server
|
|
64
|
+
const { wsUrl } = await startDashboardServer(server);
|
|
65
|
+
|
|
66
|
+
// Start tunnel automatically (unless --no-tunnel)
|
|
67
|
+
let tunnelUrl = null;
|
|
68
|
+
if (options.tunnel !== false) {
|
|
69
|
+
const provider = options.tunnelProvider || 'cloudflared';
|
|
70
|
+
console.log(chalk.dim(` Starting ${provider} tunnel...`));
|
|
71
|
+
tunnelUrl = await startTunnel(serverOptions.port, provider);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log('─────────────────────────────────────────────────────────────');
|
|
75
|
+
console.log('');
|
|
76
|
+
if (tunnelUrl) {
|
|
77
|
+
console.log(chalk.green(' Ready!') + ' Connect your dashboard to:');
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log(chalk.cyan.bold(` ${tunnelUrl}`));
|
|
80
|
+
console.log('');
|
|
81
|
+
console.log(chalk.dim(` Dashboard: https://dashboard.agileflow.projectquestorg.com`));
|
|
82
|
+
console.log(chalk.dim(` Paste the URL above into the WebSocket URL field.`));
|
|
83
|
+
} else {
|
|
84
|
+
console.log(chalk.green(' Ready!') + ' Local connection:');
|
|
85
|
+
console.log(chalk.cyan(` ${wsUrl}`));
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(chalk.yellow(' ⚠️ For cloud dashboard, run with tunnel:'));
|
|
88
|
+
console.log(chalk.dim(' npx agileflow serve'));
|
|
89
|
+
console.log(chalk.dim(' (tunnels are enabled by default)'));
|
|
90
|
+
}
|
|
91
|
+
console.log('');
|
|
92
|
+
if (serverOptions.apiKey) {
|
|
93
|
+
console.log(chalk.dim(` API Key: ${serverOptions.apiKey.slice(0, 8)}...`));
|
|
94
|
+
console.log('');
|
|
95
|
+
}
|
|
96
|
+
console.log(chalk.dim(' Press Ctrl+C to stop.'));
|
|
97
|
+
console.log('');
|
|
98
|
+
console.log('─────────────────────────────────────────────────────────────');
|
|
99
|
+
console.log('');
|
|
100
|
+
|
|
101
|
+
// Handle shutdown
|
|
102
|
+
const shutdown = async () => {
|
|
103
|
+
console.log('\nShutting down...');
|
|
104
|
+
await stopDashboardServer(server);
|
|
105
|
+
process.exit(0);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
process.on('SIGINT', shutdown);
|
|
109
|
+
process.on('SIGTERM', shutdown);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error(chalk.red('Error:'), err.message);
|
|
112
|
+
if (process.env.DEBUG) {
|
|
113
|
+
console.error(err.stack);
|
|
114
|
+
}
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function printBanner() {
|
|
121
|
+
console.log(`
|
|
122
|
+
${chalk.hex('#e8683a')('╔═══════════════════════════════════════════════════════════╗')}
|
|
123
|
+
${chalk.hex('#e8683a')('║')} ${chalk.hex('#e8683a')('║')}
|
|
124
|
+
${chalk.hex('#e8683a')('║')} ${chalk.bold.hex('#e8683a')('█████╗ ██████╗ ██╗██╗ ███████╗███████╗██╗ ')}${chalk.hex('#e8683a')('║')}
|
|
125
|
+
${chalk.hex('#e8683a')('║')} ${chalk.bold.hex('#e8683a')('██╔══██╗██╔════╝ ██║██║ ██╔════╝██╔════╝██║ ')}${chalk.hex('#e8683a')('║')}
|
|
126
|
+
${chalk.hex('#e8683a')('║')} ${chalk.bold.hex('#e8683a')('███████║██║ ███╗██║██║ █████╗ █████╗ ██║ ')}${chalk.hex('#e8683a')('║')}
|
|
127
|
+
${chalk.hex('#e8683a')('║')} ${chalk.bold.hex('#e8683a')('██╔══██║██║ ██║██║██║ ██╔══╝ ██╔══╝ ██║ ')}${chalk.hex('#e8683a')('║')}
|
|
128
|
+
${chalk.hex('#e8683a')('║')} ${chalk.bold.hex('#e8683a')('██║ ██║╚██████╔╝██║███████╗███████╗██║ ███████╗ ')}${chalk.hex('#e8683a')('║')}
|
|
129
|
+
${chalk.hex('#e8683a')('║')} ${chalk.bold.hex('#e8683a')('╚═╝ ╚═╝ ╚═════╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚══════╝ ')}${chalk.hex('#e8683a')('║')}
|
|
130
|
+
${chalk.hex('#e8683a')('║')} ${chalk.hex('#e8683a')('║')}
|
|
131
|
+
${chalk.hex('#e8683a')('║')} ${chalk.white('Dashboard WebSocket Server')} ${chalk.hex('#e8683a')('║')}
|
|
132
|
+
${chalk.hex('#e8683a')('║')} ${chalk.hex('#e8683a')('║')}
|
|
133
|
+
${chalk.hex('#e8683a')('╚═══════════════════════════════════════════════════════════╝')}
|
|
134
|
+
`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function setupEventHandlers(server, protocol) {
|
|
138
|
+
const { createTextDelta, createToolStart, createToolResult } = protocol;
|
|
139
|
+
|
|
140
|
+
// Session events
|
|
141
|
+
server.on('session:connected', (sessionId, session) => {
|
|
142
|
+
console.log(chalk.dim(`[${new Date().toISOString()}]`) + ` Session connected: ${chalk.cyan(sessionId)}`);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
server.on('session:disconnected', sessionId => {
|
|
146
|
+
console.log(chalk.dim(`[${new Date().toISOString()}]`) + ` Session disconnected: ${chalk.yellow(sessionId)}`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// User message handler - uses real Claude CLI
|
|
150
|
+
server.on('user:message', async (session, content) => {
|
|
151
|
+
console.log(
|
|
152
|
+
chalk.dim(`[${new Date().toISOString()}]`) +
|
|
153
|
+
` Message from ${chalk.cyan(session.id)}: ${chalk.white(content.slice(0, 50))}...`
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Use real Claude CLI
|
|
157
|
+
await handleClaudeMessage(session, content, { createTextDelta, createToolStart, createToolResult });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Cancel handler
|
|
161
|
+
server.on('user:cancel', session => {
|
|
162
|
+
console.log(chalk.dim(`[${new Date().toISOString()}]`) + ` Cancel from ${chalk.yellow(session.id)}`);
|
|
163
|
+
// Note: To properly cancel, we'd need to track the bridge per session
|
|
164
|
+
// For now, this just logs the cancel request
|
|
165
|
+
session.setState('idle');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Refresh handlers
|
|
169
|
+
server.on('refresh:tasks', session => {
|
|
170
|
+
console.log(chalk.dim(`[${new Date().toISOString()}]`) + ` Task refresh for ${chalk.cyan(session.id)}`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
server.on('refresh:status', session => {
|
|
174
|
+
console.log(chalk.dim(`[${new Date().toISOString()}]`) + ` Status refresh for ${chalk.cyan(session.id)}`);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function handleClaudeMessage(session, content, protocol) {
|
|
179
|
+
const { createTextDelta, createToolStart, createToolResult } = protocol;
|
|
180
|
+
const { createClaudeBridge } = require('../../../lib/claude-cli-bridge');
|
|
181
|
+
|
|
182
|
+
// Set session state to thinking
|
|
183
|
+
session.setState('thinking');
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const bridge = createClaudeBridge({
|
|
187
|
+
cwd: process.cwd(),
|
|
188
|
+
|
|
189
|
+
onText: (text, done) => {
|
|
190
|
+
// Always send text deltas (even empty ones with done=true to signal completion)
|
|
191
|
+
if (text) {
|
|
192
|
+
console.log(chalk.dim(`[${new Date().toISOString()}]`) + ` Text: ${chalk.green(text.slice(0, 50))}${text.length > 50 ? '...' : ''}`);
|
|
193
|
+
}
|
|
194
|
+
session.send(createTextDelta(text || '', done));
|
|
195
|
+
if (done) {
|
|
196
|
+
console.log(chalk.dim(`[${new Date().toISOString()}]`) + chalk.green(' Response complete'));
|
|
197
|
+
session.setState('idle');
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
onToolStart: (id, name, input) => {
|
|
202
|
+
console.log(chalk.dim(`[${new Date().toISOString()}]`) + ` Tool: ${chalk.yellow(name)}`);
|
|
203
|
+
session.send(createToolStart(id, name, input));
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
onToolResult: (id, output, isError, toolName) => {
|
|
207
|
+
session.send(createToolResult(id, output, isError ? output : null));
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
onError: (error) => {
|
|
211
|
+
console.error(chalk.red(`[${new Date().toISOString()}]`) + ` Error: ${error}`);
|
|
212
|
+
session.send(createTextDelta(`\n\nError: ${error}`, true));
|
|
213
|
+
session.setState('error');
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
onComplete: (fullResponse) => {
|
|
217
|
+
session.addMessage('assistant', fullResponse);
|
|
218
|
+
session.setState('idle');
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Send the message to Claude
|
|
223
|
+
await bridge.sendMessage(content);
|
|
224
|
+
|
|
225
|
+
} catch (err) {
|
|
226
|
+
console.error(chalk.red('Claude error:'), err.message);
|
|
227
|
+
session.send(createTextDelta(`Error: ${err.message}`, true));
|
|
228
|
+
session.setState('error');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function startTunnel(port, provider = 'cloudflared') {
|
|
233
|
+
const { spawn, exec } = require('child_process');
|
|
234
|
+
|
|
235
|
+
// Try cloudflared first (free, no signup needed)
|
|
236
|
+
if (provider === 'cloudflared') {
|
|
237
|
+
const url = await startCloudflaredTunnel(port, spawn, exec);
|
|
238
|
+
if (url) return url;
|
|
239
|
+
// Fall back to ngrok if cloudflared fails
|
|
240
|
+
console.log(chalk.dim(' Trying ngrok as fallback...'));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Try ngrok
|
|
244
|
+
return startNgrokTunnel(port, exec);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function startCloudflaredTunnel(port, spawn, exec) {
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
// Check if cloudflared is installed
|
|
250
|
+
exec('which cloudflared', (error) => {
|
|
251
|
+
if (error) {
|
|
252
|
+
console.log(chalk.yellow(' cloudflared not found.'));
|
|
253
|
+
console.log(chalk.dim(' Install: brew install cloudflared (mac) or sudo apt install cloudflared (linux)'));
|
|
254
|
+
console.log(chalk.dim(' Or download from: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'));
|
|
255
|
+
resolve(null);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Start cloudflared tunnel (quick tunnel, no account needed)
|
|
260
|
+
const tunnel = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
261
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
let resolved = false;
|
|
265
|
+
|
|
266
|
+
const handleOutput = (data) => {
|
|
267
|
+
const output = data.toString();
|
|
268
|
+
// Look for the tunnel URL in output
|
|
269
|
+
const urlMatch = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
270
|
+
if (urlMatch && !resolved) {
|
|
271
|
+
resolved = true;
|
|
272
|
+
const httpsUrl = urlMatch[0];
|
|
273
|
+
const wssUrl = httpsUrl.replace('https://', 'wss://');
|
|
274
|
+
console.log(chalk.green(' ✓ Tunnel ready'));
|
|
275
|
+
resolve(wssUrl);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
tunnel.stdout.on('data', handleOutput);
|
|
280
|
+
tunnel.stderr.on('data', handleOutput);
|
|
281
|
+
|
|
282
|
+
tunnel.on('error', (err) => {
|
|
283
|
+
if (!resolved) {
|
|
284
|
+
console.log(chalk.yellow(` cloudflared error: ${err.message}`));
|
|
285
|
+
resolve(null);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Timeout after 15 seconds
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
if (!resolved) {
|
|
292
|
+
console.log(chalk.yellow(' cloudflared tunnel timeout'));
|
|
293
|
+
resolve(null);
|
|
294
|
+
}
|
|
295
|
+
}, 15000);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function startNgrokTunnel(port, exec) {
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
exec('which ngrok', (error) => {
|
|
303
|
+
if (error) {
|
|
304
|
+
console.log(chalk.yellow(' ngrok not found either.'));
|
|
305
|
+
console.log('');
|
|
306
|
+
console.log(chalk.yellow(' To enable cloud dashboard access, install a tunnel:'));
|
|
307
|
+
console.log(chalk.dim(' brew install cloudflared # Recommended (free, no signup)'));
|
|
308
|
+
console.log(chalk.dim(' npm install -g ngrok # Alternative (requires signup)'));
|
|
309
|
+
resolve(null);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const ngrok = exec(`ngrok http ${port} --log stdout`, { encoding: 'utf8' });
|
|
314
|
+
|
|
315
|
+
let resolved = false;
|
|
316
|
+
|
|
317
|
+
ngrok.stdout.on('data', data => {
|
|
318
|
+
const urlMatch = data.match(/url=(https?:\/\/[^\s]+)/);
|
|
319
|
+
if (urlMatch && !resolved) {
|
|
320
|
+
resolved = true;
|
|
321
|
+
const tunnelUrl = urlMatch[1].replace('https://', 'wss://').replace('http://', 'ws://');
|
|
322
|
+
console.log(chalk.green(' ✓ ngrok tunnel ready'));
|
|
323
|
+
resolve(tunnelUrl);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
ngrok.stderr.on('data', data => {
|
|
328
|
+
if (!resolved && data.includes('error')) {
|
|
329
|
+
console.error(chalk.red(' ngrok error:'), data);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
setTimeout(() => {
|
|
334
|
+
if (!resolved) {
|
|
335
|
+
console.log(chalk.yellow(' ngrok tunnel timeout'));
|
|
336
|
+
resolve(null);
|
|
337
|
+
}
|
|
338
|
+
}, 10000);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function sleep(ms) {
|
|
344
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Check if port is in use and handle conflict
|
|
349
|
+
*/
|
|
350
|
+
async function handlePortConflict(port, readline) {
|
|
351
|
+
const { execSync } = require('child_process');
|
|
352
|
+
const net = require('net');
|
|
353
|
+
|
|
354
|
+
// Check if port is in use
|
|
355
|
+
const isPortInUse = await new Promise((resolve) => {
|
|
356
|
+
const server = net.createServer();
|
|
357
|
+
server.once('error', (err) => {
|
|
358
|
+
if (err.code === 'EADDRINUSE') {
|
|
359
|
+
resolve(true);
|
|
360
|
+
} else {
|
|
361
|
+
resolve(false);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
server.once('listening', () => {
|
|
365
|
+
server.close();
|
|
366
|
+
resolve(false);
|
|
367
|
+
});
|
|
368
|
+
server.listen(port);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (!isPortInUse) {
|
|
372
|
+
return port;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Port is in use - find out what's using it
|
|
376
|
+
let processInfo = '';
|
|
377
|
+
try {
|
|
378
|
+
processInfo = execSync(`lsof -i :${port} -t 2>/dev/null`, { encoding: 'utf-8' }).trim();
|
|
379
|
+
} catch (e) {
|
|
380
|
+
// lsof might not be available or port check failed
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
console.log(chalk.yellow(`\n Port ${port} is already in use.`));
|
|
384
|
+
|
|
385
|
+
if (processInfo) {
|
|
386
|
+
console.log(chalk.dim(` Process ID: ${processInfo}`));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Ask user what to do
|
|
390
|
+
const rl = readline.createInterface({
|
|
391
|
+
input: process.stdin,
|
|
392
|
+
output: process.stdout
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
return new Promise((resolve) => {
|
|
396
|
+
console.log('');
|
|
397
|
+
console.log(' Options:');
|
|
398
|
+
console.log(chalk.cyan(' [k]') + ' Kill existing process and use this port');
|
|
399
|
+
console.log(chalk.cyan(' [n]') + ' Use next available port');
|
|
400
|
+
console.log(chalk.cyan(' [q]') + ' Quit');
|
|
401
|
+
console.log('');
|
|
402
|
+
|
|
403
|
+
rl.question(' Your choice (k/n/q): ', async (answer) => {
|
|
404
|
+
rl.close();
|
|
405
|
+
const choice = answer.toLowerCase().trim();
|
|
406
|
+
|
|
407
|
+
if (choice === 'k' && processInfo) {
|
|
408
|
+
// Kill the process
|
|
409
|
+
try {
|
|
410
|
+
execSync(`kill ${processInfo}`, { encoding: 'utf-8' });
|
|
411
|
+
console.log(chalk.green(` Killed process ${processInfo}`));
|
|
412
|
+
await sleep(500); // Give it a moment to release the port
|
|
413
|
+
resolve(port);
|
|
414
|
+
} catch (e) {
|
|
415
|
+
console.log(chalk.red(` Failed to kill process: ${e.message}`));
|
|
416
|
+
console.log(chalk.dim(' Trying next available port instead...'));
|
|
417
|
+
resolve(await findNextAvailablePort(port));
|
|
418
|
+
}
|
|
419
|
+
} else if (choice === 'n') {
|
|
420
|
+
const newPort = await findNextAvailablePort(port);
|
|
421
|
+
console.log(chalk.green(` Using port ${newPort}`));
|
|
422
|
+
resolve(newPort);
|
|
423
|
+
} else {
|
|
424
|
+
console.log(chalk.dim(' Exiting.'));
|
|
425
|
+
process.exit(0);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Find next available port starting from given port
|
|
433
|
+
*/
|
|
434
|
+
async function findNextAvailablePort(startPort) {
|
|
435
|
+
const net = require('net');
|
|
436
|
+
let port = startPort + 1;
|
|
437
|
+
|
|
438
|
+
while (port < startPort + 100) {
|
|
439
|
+
const available = await new Promise((resolve) => {
|
|
440
|
+
const server = net.createServer();
|
|
441
|
+
server.once('error', () => resolve(false));
|
|
442
|
+
server.once('listening', () => {
|
|
443
|
+
server.close();
|
|
444
|
+
resolve(true);
|
|
445
|
+
});
|
|
446
|
+
server.listen(port);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
if (available) {
|
|
450
|
+
return port;
|
|
451
|
+
}
|
|
452
|
+
port++;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
throw new Error('Could not find an available port');
|
|
456
|
+
}
|
|
@@ -139,6 +139,7 @@ class Installer {
|
|
|
139
139
|
fileOps,
|
|
140
140
|
force: effectiveForce,
|
|
141
141
|
timestamp,
|
|
142
|
+
docsFolder: docsFolder || 'docs',
|
|
142
143
|
});
|
|
143
144
|
} else {
|
|
144
145
|
// Fallback: copy from old structure (commands, agents, skills at root)
|
|
@@ -149,6 +150,7 @@ class Installer {
|
|
|
149
150
|
fileOps,
|
|
150
151
|
force: effectiveForce,
|
|
151
152
|
timestamp,
|
|
153
|
+
docsFolder: docsFolder || 'docs',
|
|
152
154
|
});
|
|
153
155
|
}
|
|
154
156
|
|
|
@@ -295,8 +297,9 @@ class Installer {
|
|
|
295
297
|
* @param {string} source - Source file path
|
|
296
298
|
* @param {string} dest - Destination file path
|
|
297
299
|
* @param {string} agileflowFolder - AgileFlow folder name
|
|
300
|
+
* @param {string} docsFolder - Docs folder name (default: 'docs')
|
|
298
301
|
*/
|
|
299
|
-
async copyFileWithReplacements(source, dest, agileflowFolder) {
|
|
302
|
+
async copyFileWithReplacements(source, dest, agileflowFolder, docsFolder = 'docs') {
|
|
300
303
|
const ext = path.extname(source).toLowerCase();
|
|
301
304
|
|
|
302
305
|
if (TEXT_EXTENSIONS.has(ext)) {
|
|
@@ -306,6 +309,7 @@ class Installer {
|
|
|
306
309
|
content = injectContent(content, {
|
|
307
310
|
coreDir: this.coreDir,
|
|
308
311
|
agileflowFolder,
|
|
312
|
+
docsFolder,
|
|
309
313
|
version: this.version,
|
|
310
314
|
});
|
|
311
315
|
|
|
@@ -326,7 +330,7 @@ class Installer {
|
|
|
326
330
|
* @param {Object} policy - Copy policy
|
|
327
331
|
*/
|
|
328
332
|
async copyFileWithPolicy(source, dest, agileflowFolder, policy) {
|
|
329
|
-
const { agileflowDir, cfgDir, fileIndex, fileOps, force, timestamp } = policy;
|
|
333
|
+
const { agileflowDir, cfgDir, fileIndex, fileOps, force, timestamp, docsFolder = 'docs' } = policy;
|
|
330
334
|
|
|
331
335
|
const relativePath = toPosixPath(path.relative(agileflowDir, dest));
|
|
332
336
|
const maybeRecord = fileIndex.files[relativePath];
|
|
@@ -342,6 +346,7 @@ class Installer {
|
|
|
342
346
|
content = injectContent(content, {
|
|
343
347
|
coreDir: this.coreDir,
|
|
344
348
|
agileflowFolder,
|
|
349
|
+
docsFolder,
|
|
345
350
|
version: this.version,
|
|
346
351
|
});
|
|
347
352
|
newContent = content;
|
|
@@ -56,6 +56,9 @@ class ClaudeCodeSetup extends BaseIdeSetup {
|
|
|
56
56
|
// Claude Code specific: Setup damage control hooks
|
|
57
57
|
await this.setupDamageControl(projectDir, agileflowDir, ideDir, options);
|
|
58
58
|
|
|
59
|
+
// Claude Code specific: Setup SessionStart hooks (welcome, archive, context-loader)
|
|
60
|
+
await this.setupSessionStartHooks(projectDir, agileflowDir, ideDir, options);
|
|
61
|
+
|
|
59
62
|
return result;
|
|
60
63
|
}
|
|
61
64
|
|
|
@@ -93,6 +96,14 @@ class ClaudeCodeSetup extends BaseIdeSetup {
|
|
|
93
96
|
}
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
// Copy lib/damage-control-utils.js (required by hook scripts via ../lib/damage-control-utils)
|
|
100
|
+
const libSource = path.join(agileflowDir, 'scripts', 'lib', 'damage-control-utils.js');
|
|
101
|
+
const libTarget = path.join(claudeDir, 'hooks', 'lib', 'damage-control-utils.js');
|
|
102
|
+
if (fs.existsSync(libSource)) {
|
|
103
|
+
await this.ensureDir(path.dirname(libTarget));
|
|
104
|
+
await fs.copy(libSource, libTarget);
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
// Copy patterns.yaml (preserve existing)
|
|
97
108
|
const patternsSource = path.join(damageControlSource, 'patterns.yaml');
|
|
98
109
|
const patternsTarget = path.join(damageControlTarget, 'patterns.yaml');
|
|
@@ -195,6 +206,80 @@ class ClaudeCodeSetup extends BaseIdeSetup {
|
|
|
195
206
|
// Write settings
|
|
196
207
|
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
|
197
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Setup SessionStart hooks (welcome, archive, context-loader)
|
|
212
|
+
* @param {string} projectDir - Project directory
|
|
213
|
+
* @param {string} agileflowDir - AgileFlow installation directory
|
|
214
|
+
* @param {string} claudeDir - .claude directory path
|
|
215
|
+
* @param {Object} options - Setup options
|
|
216
|
+
*/
|
|
217
|
+
async setupSessionStartHooks(projectDir, agileflowDir, claudeDir, options = {}) {
|
|
218
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
219
|
+
let settings = {};
|
|
220
|
+
|
|
221
|
+
// Load existing settings
|
|
222
|
+
if (fs.existsSync(settingsPath)) {
|
|
223
|
+
try {
|
|
224
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
225
|
+
} catch (e) {
|
|
226
|
+
settings = {};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Initialize hooks structure
|
|
231
|
+
if (!settings.hooks) settings.hooks = {};
|
|
232
|
+
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
|
233
|
+
|
|
234
|
+
// Define SessionStart hooks
|
|
235
|
+
const sessionStartHooks = [
|
|
236
|
+
{
|
|
237
|
+
type: 'command',
|
|
238
|
+
command: 'node $CLAUDE_PROJECT_DIR/.agileflow/scripts/agileflow-welcome.js 2>/dev/null || true',
|
|
239
|
+
timeout: 10000,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
type: 'command',
|
|
243
|
+
command:
|
|
244
|
+
'bash $CLAUDE_PROJECT_DIR/.agileflow/scripts/archive-completed-stories.sh --quiet 2>/dev/null || true',
|
|
245
|
+
timeout: 10000,
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
type: 'command',
|
|
249
|
+
command: 'node $CLAUDE_PROJECT_DIR/.agileflow/scripts/context-loader.js 2>/dev/null || true',
|
|
250
|
+
timeout: 5000,
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
// Check if SessionStart hooks already exist
|
|
255
|
+
const existingEntry = settings.hooks.SessionStart.find(
|
|
256
|
+
h => h.matcher === '' || h.matcher === undefined
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (existingEntry) {
|
|
260
|
+
// Merge hooks - add any missing
|
|
261
|
+
if (!existingEntry.hooks) existingEntry.hooks = [];
|
|
262
|
+
|
|
263
|
+
for (const newHook of sessionStartHooks) {
|
|
264
|
+
const alreadyExists = existingEntry.hooks.some(
|
|
265
|
+
h => h.command && h.command.includes(newHook.command.split('/').pop().split(' ')[0])
|
|
266
|
+
);
|
|
267
|
+
if (!alreadyExists) {
|
|
268
|
+
existingEntry.hooks.push(newHook);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
// Add new entry
|
|
273
|
+
settings.hooks.SessionStart.push({
|
|
274
|
+
matcher: '',
|
|
275
|
+
hooks: sessionStartHooks,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Write settings
|
|
280
|
+
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
|
281
|
+
console.log(chalk.dim(` - SessionStart hooks: welcome, archive, context-loader`));
|
|
282
|
+
}
|
|
198
283
|
}
|
|
199
284
|
|
|
200
285
|
module.exports = { ClaudeCodeSetup };
|
|
@@ -23,7 +23,12 @@
|
|
|
23
23
|
*
|
|
24
24
|
* FOLDER REFERENCES:
|
|
25
25
|
* {agileflow_folder} - Name of the agileflow folder (e.g., .agileflow)
|
|
26
|
+
* {docs_folder} - Name of the docs folder (e.g., docs, agileflow-docs)
|
|
26
27
|
* {project-root} - Project root reference
|
|
28
|
+
*
|
|
29
|
+
* PATH INJECTION:
|
|
30
|
+
* When docsFolder is not 'docs', all path references like `docs/` are replaced
|
|
31
|
+
* with the actual folder name (e.g., `agileflow-docs/`).
|
|
27
32
|
*/
|
|
28
33
|
|
|
29
34
|
const fs = require('fs');
|
|
@@ -473,11 +478,12 @@ function clearPreserveRulesCache() {
|
|
|
473
478
|
* @param {Object} context - Context for replacements
|
|
474
479
|
* @param {string} context.coreDir - Path to core directory (commands/, agents/, skills/)
|
|
475
480
|
* @param {string} context.agileflowFolder - AgileFlow folder name
|
|
481
|
+
* @param {string} context.docsFolder - Docs folder name (default: 'docs')
|
|
476
482
|
* @param {string} context.version - AgileFlow version
|
|
477
483
|
* @returns {string} Content with all placeholders replaced
|
|
478
484
|
*/
|
|
479
485
|
function injectContent(content, context = {}) {
|
|
480
|
-
const { coreDir, agileflowFolder = '.agileflow', version = 'unknown' } = context;
|
|
486
|
+
const { coreDir, agileflowFolder = '.agileflow', docsFolder = 'docs', version = 'unknown' } = context;
|
|
481
487
|
|
|
482
488
|
let result = content;
|
|
483
489
|
|
|
@@ -502,6 +508,10 @@ function injectContent(content, context = {}) {
|
|
|
502
508
|
'agileflow_folder',
|
|
503
509
|
agileflowFolder
|
|
504
510
|
).sanitized;
|
|
511
|
+
const safeDocsFolder = validatePlaceholderValue(
|
|
512
|
+
'docs_folder',
|
|
513
|
+
docsFolder
|
|
514
|
+
).sanitized;
|
|
505
515
|
|
|
506
516
|
// Replace count placeholders (both formats: {{X}} and <!-- {{X}} -->)
|
|
507
517
|
result = result.replace(/\{\{COMMAND_COUNT\}\}/g, String(safeCommandCount));
|
|
@@ -593,8 +603,22 @@ function injectContent(content, context = {}) {
|
|
|
593
603
|
|
|
594
604
|
// Replace folder placeholders with sanitized values
|
|
595
605
|
result = result.replace(/\{agileflow_folder\}/g, safeAgileflowFolder);
|
|
606
|
+
result = result.replace(/\{docs_folder\}/g, safeDocsFolder);
|
|
596
607
|
result = result.replace(/\{project-root\}/g, '{project-root}'); // Keep as-is for runtime
|
|
597
608
|
|
|
609
|
+
// Replace docs/ path references with actual folder if different from default
|
|
610
|
+
// This ensures all path references point to the correct folder
|
|
611
|
+
// Pattern matches: `docs/`, "docs/", 'docs/' but NOT word boundaries like "documents/"
|
|
612
|
+
if (safeDocsFolder !== 'docs') {
|
|
613
|
+
// Replace in code/path contexts: `docs/xxx`, "docs/xxx", 'docs/xxx'
|
|
614
|
+
result = result.replace(/`docs\//g, `\`${safeDocsFolder}/`);
|
|
615
|
+
result = result.replace(/"docs\//g, `"${safeDocsFolder}/`);
|
|
616
|
+
result = result.replace(/'docs\//g, `'${safeDocsFolder}/`);
|
|
617
|
+
// Replace standalone path references like: docs/00-meta, docs/09-agents
|
|
618
|
+
// Must be followed by a path component (letter, number, or dash)
|
|
619
|
+
result = result.replace(/\bdocs\/([0-9a-zA-Z_-])/g, `${safeDocsFolder}/$1`);
|
|
620
|
+
}
|
|
621
|
+
|
|
598
622
|
return result;
|
|
599
623
|
}
|
|
600
624
|
|
|
@@ -690,6 +714,7 @@ function hasPlaceholders(content) {
|
|
|
690
714
|
/\{\{QUALITY_GATE_PRIORITIES\}\}/,
|
|
691
715
|
/\{\{RULES:\w+\}\}/,
|
|
692
716
|
/\{agileflow_folder\}/,
|
|
717
|
+
/\{docs_folder\}/,
|
|
693
718
|
];
|
|
694
719
|
|
|
695
720
|
return patterns.some(pattern => pattern.test(content));
|
|
@@ -735,6 +760,7 @@ function getPlaceholderDocs() {
|
|
|
735
760
|
},
|
|
736
761
|
folders: {
|
|
737
762
|
'{agileflow_folder}': 'Name of the AgileFlow folder',
|
|
763
|
+
'{docs_folder}': 'Name of the docs folder (docs/ paths auto-replaced when different)',
|
|
738
764
|
'{project-root}': 'Project root reference (kept as-is)',
|
|
739
765
|
},
|
|
740
766
|
};
|