@walwal-harness/cli 2.0.1 → 2.3.1

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.
Files changed (30) hide show
  1. package/README.md +296 -89
  2. package/assets/templates/config.json +41 -1
  3. package/bin/init.js +65 -3
  4. package/package.json +5 -2
  5. package/scripts/harness-next.sh +36 -1
  6. package/scripts/harness-user-prompt-submit.sh +106 -0
  7. package/scripts/lib/harness-render-progress.sh +15 -1
  8. package/scripts/scan-project.sh +24 -2
  9. package/skills/brainstorming/SKILL.md +200 -0
  10. package/skills/brainstorming/references/attribution.md +109 -0
  11. package/skills/brainstorming/references/spec-document-reviewer-prompt.md +49 -0
  12. package/skills/brainstorming/references/visual-companion.md +287 -0
  13. package/skills/brainstorming/scripts/frame-template.html +214 -0
  14. package/skills/brainstorming/scripts/helper.js +88 -0
  15. package/skills/brainstorming/scripts/server.cjs +354 -0
  16. package/skills/brainstorming/scripts/start-server.sh +148 -0
  17. package/skills/brainstorming/scripts/stop-server.sh +56 -0
  18. package/skills/dispatcher/SKILL.md +114 -2
  19. package/skills/dispatcher/references/pipeline-definitions.md +53 -5
  20. package/skills/evaluator-functional-flutter/SKILL.md +198 -0
  21. package/skills/evaluator-functional-flutter/references/ia-compliance.md +77 -0
  22. package/skills/evaluator-functional-flutter/references/scoring-rubric.md +132 -0
  23. package/skills/evaluator-functional-flutter/references/static-check-rules.md +99 -0
  24. package/skills/generator-frontend-flutter/SKILL.md +138 -0
  25. package/skills/generator-frontend-flutter/references/anti-patterns.md +288 -0
  26. package/skills/generator-frontend-flutter/references/api-layer-pattern.md +233 -0
  27. package/skills/generator-frontend-flutter/references/i18n-pattern.md +102 -0
  28. package/skills/generator-frontend-flutter/references/riverpod-pattern.md +199 -0
  29. package/skills/planner/SKILL.md +23 -1
  30. package/skills/planner/references/fe-stack-detection.md +131 -0
@@ -0,0 +1,354 @@
1
+ const crypto = require('crypto');
2
+ const http = require('http');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // ========== WebSocket Protocol (RFC 6455) ==========
7
+
8
+ const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
9
+ const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
10
+
11
+ function computeAcceptKey(clientKey) {
12
+ return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
13
+ }
14
+
15
+ function encodeFrame(opcode, payload) {
16
+ const fin = 0x80;
17
+ const len = payload.length;
18
+ let header;
19
+
20
+ if (len < 126) {
21
+ header = Buffer.alloc(2);
22
+ header[0] = fin | opcode;
23
+ header[1] = len;
24
+ } else if (len < 65536) {
25
+ header = Buffer.alloc(4);
26
+ header[0] = fin | opcode;
27
+ header[1] = 126;
28
+ header.writeUInt16BE(len, 2);
29
+ } else {
30
+ header = Buffer.alloc(10);
31
+ header[0] = fin | opcode;
32
+ header[1] = 127;
33
+ header.writeBigUInt64BE(BigInt(len), 2);
34
+ }
35
+
36
+ return Buffer.concat([header, payload]);
37
+ }
38
+
39
+ function decodeFrame(buffer) {
40
+ if (buffer.length < 2) return null;
41
+
42
+ const secondByte = buffer[1];
43
+ const opcode = buffer[0] & 0x0F;
44
+ const masked = (secondByte & 0x80) !== 0;
45
+ let payloadLen = secondByte & 0x7F;
46
+ let offset = 2;
47
+
48
+ if (!masked) throw new Error('Client frames must be masked');
49
+
50
+ if (payloadLen === 126) {
51
+ if (buffer.length < 4) return null;
52
+ payloadLen = buffer.readUInt16BE(2);
53
+ offset = 4;
54
+ } else if (payloadLen === 127) {
55
+ if (buffer.length < 10) return null;
56
+ payloadLen = Number(buffer.readBigUInt64BE(2));
57
+ offset = 10;
58
+ }
59
+
60
+ const maskOffset = offset;
61
+ const dataOffset = offset + 4;
62
+ const totalLen = dataOffset + payloadLen;
63
+ if (buffer.length < totalLen) return null;
64
+
65
+ const mask = buffer.slice(maskOffset, dataOffset);
66
+ const data = Buffer.alloc(payloadLen);
67
+ for (let i = 0; i < payloadLen; i++) {
68
+ data[i] = buffer[dataOffset + i] ^ mask[i % 4];
69
+ }
70
+
71
+ return { opcode, payload: data, bytesConsumed: totalLen };
72
+ }
73
+
74
+ // ========== Configuration ==========
75
+
76
+ const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
77
+ const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
78
+ const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
79
+ const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
80
+ const CONTENT_DIR = path.join(SESSION_DIR, 'content');
81
+ const STATE_DIR = path.join(SESSION_DIR, 'state');
82
+ let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
83
+
84
+ const MIME_TYPES = {
85
+ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
86
+ '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
87
+ '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml'
88
+ };
89
+
90
+ // ========== Templates and Constants ==========
91
+
92
+ const WAITING_PAGE = `<!DOCTYPE html>
93
+ <html>
94
+ <head><meta charset="utf-8"><title>Brainstorm Companion</title>
95
+ <style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
96
+ h1 { color: #333; } p { color: #666; }</style>
97
+ </head>
98
+ <body><h1>Brainstorm Companion</h1>
99
+ <p>Waiting for the agent to push a screen...</p></body></html>`;
100
+
101
+ const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
102
+ const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
103
+ const helperInjection = '<script>\n' + helperScript + '\n</script>';
104
+
105
+ // ========== Helper Functions ==========
106
+
107
+ function isFullDocument(html) {
108
+ const trimmed = html.trimStart().toLowerCase();
109
+ return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
110
+ }
111
+
112
+ function wrapInFrame(content) {
113
+ return frameTemplate.replace('<!-- CONTENT -->', content);
114
+ }
115
+
116
+ function getNewestScreen() {
117
+ const files = fs.readdirSync(CONTENT_DIR)
118
+ .filter(f => f.endsWith('.html'))
119
+ .map(f => {
120
+ const fp = path.join(CONTENT_DIR, f);
121
+ return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
122
+ })
123
+ .sort((a, b) => b.mtime - a.mtime);
124
+ return files.length > 0 ? files[0].path : null;
125
+ }
126
+
127
+ // ========== HTTP Request Handler ==========
128
+
129
+ function handleRequest(req, res) {
130
+ touchActivity();
131
+ if (req.method === 'GET' && req.url === '/') {
132
+ const screenFile = getNewestScreen();
133
+ let html = screenFile
134
+ ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
135
+ : WAITING_PAGE;
136
+
137
+ if (html.includes('</body>')) {
138
+ html = html.replace('</body>', helperInjection + '\n</body>');
139
+ } else {
140
+ html += helperInjection;
141
+ }
142
+
143
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
144
+ res.end(html);
145
+ } else if (req.method === 'GET' && req.url.startsWith('/files/')) {
146
+ const fileName = req.url.slice(7);
147
+ const filePath = path.join(CONTENT_DIR, path.basename(fileName));
148
+ if (!fs.existsSync(filePath)) {
149
+ res.writeHead(404);
150
+ res.end('Not found');
151
+ return;
152
+ }
153
+ const ext = path.extname(filePath).toLowerCase();
154
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
155
+ res.writeHead(200, { 'Content-Type': contentType });
156
+ res.end(fs.readFileSync(filePath));
157
+ } else {
158
+ res.writeHead(404);
159
+ res.end('Not found');
160
+ }
161
+ }
162
+
163
+ // ========== WebSocket Connection Handling ==========
164
+
165
+ const clients = new Set();
166
+
167
+ function handleUpgrade(req, socket) {
168
+ const key = req.headers['sec-websocket-key'];
169
+ if (!key) { socket.destroy(); return; }
170
+
171
+ const accept = computeAcceptKey(key);
172
+ socket.write(
173
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
174
+ 'Upgrade: websocket\r\n' +
175
+ 'Connection: Upgrade\r\n' +
176
+ 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
177
+ );
178
+
179
+ let buffer = Buffer.alloc(0);
180
+ clients.add(socket);
181
+
182
+ socket.on('data', (chunk) => {
183
+ buffer = Buffer.concat([buffer, chunk]);
184
+ while (buffer.length > 0) {
185
+ let result;
186
+ try {
187
+ result = decodeFrame(buffer);
188
+ } catch (e) {
189
+ socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
190
+ clients.delete(socket);
191
+ return;
192
+ }
193
+ if (!result) break;
194
+ buffer = buffer.slice(result.bytesConsumed);
195
+
196
+ switch (result.opcode) {
197
+ case OPCODES.TEXT:
198
+ handleMessage(result.payload.toString());
199
+ break;
200
+ case OPCODES.CLOSE:
201
+ socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
202
+ clients.delete(socket);
203
+ return;
204
+ case OPCODES.PING:
205
+ socket.write(encodeFrame(OPCODES.PONG, result.payload));
206
+ break;
207
+ case OPCODES.PONG:
208
+ break;
209
+ default: {
210
+ const closeBuf = Buffer.alloc(2);
211
+ closeBuf.writeUInt16BE(1003);
212
+ socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
213
+ clients.delete(socket);
214
+ return;
215
+ }
216
+ }
217
+ }
218
+ });
219
+
220
+ socket.on('close', () => clients.delete(socket));
221
+ socket.on('error', () => clients.delete(socket));
222
+ }
223
+
224
+ function handleMessage(text) {
225
+ let event;
226
+ try {
227
+ event = JSON.parse(text);
228
+ } catch (e) {
229
+ console.error('Failed to parse WebSocket message:', e.message);
230
+ return;
231
+ }
232
+ touchActivity();
233
+ console.log(JSON.stringify({ source: 'user-event', ...event }));
234
+ if (event.choice) {
235
+ const eventsFile = path.join(STATE_DIR, 'events');
236
+ fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
237
+ }
238
+ }
239
+
240
+ function broadcast(msg) {
241
+ const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg)));
242
+ for (const socket of clients) {
243
+ try { socket.write(frame); } catch (e) { clients.delete(socket); }
244
+ }
245
+ }
246
+
247
+ // ========== Activity Tracking ==========
248
+
249
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
250
+ let lastActivity = Date.now();
251
+
252
+ function touchActivity() {
253
+ lastActivity = Date.now();
254
+ }
255
+
256
+ // ========== File Watching ==========
257
+
258
+ const debounceTimers = new Map();
259
+
260
+ // ========== Server Startup ==========
261
+
262
+ function startServer() {
263
+ if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true });
264
+ if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
265
+
266
+ // Track known files to distinguish new screens from updates.
267
+ // macOS fs.watch reports 'rename' for both new files and overwrites,
268
+ // so we can't rely on eventType alone.
269
+ const knownFiles = new Set(
270
+ fs.readdirSync(CONTENT_DIR).filter(f => f.endsWith('.html'))
271
+ );
272
+
273
+ const server = http.createServer(handleRequest);
274
+ server.on('upgrade', handleUpgrade);
275
+
276
+ const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => {
277
+ if (!filename || !filename.endsWith('.html')) return;
278
+
279
+ if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
280
+ debounceTimers.set(filename, setTimeout(() => {
281
+ debounceTimers.delete(filename);
282
+ const filePath = path.join(CONTENT_DIR, filename);
283
+
284
+ if (!fs.existsSync(filePath)) return; // file was deleted
285
+ touchActivity();
286
+
287
+ if (!knownFiles.has(filename)) {
288
+ knownFiles.add(filename);
289
+ const eventsFile = path.join(STATE_DIR, 'events');
290
+ if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
291
+ console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
292
+ } else {
293
+ console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
294
+ }
295
+
296
+ broadcast({ type: 'reload' });
297
+ }, 100));
298
+ });
299
+ watcher.on('error', (err) => console.error('fs.watch error:', err.message));
300
+
301
+ function shutdown(reason) {
302
+ console.log(JSON.stringify({ type: 'server-stopped', reason }));
303
+ const infoFile = path.join(STATE_DIR, 'server-info');
304
+ if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
305
+ fs.writeFileSync(
306
+ path.join(STATE_DIR, 'server-stopped'),
307
+ JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
308
+ );
309
+ watcher.close();
310
+ clearInterval(lifecycleCheck);
311
+ server.close(() => process.exit(0));
312
+ }
313
+
314
+ function ownerAlive() {
315
+ if (!ownerPid) return true;
316
+ try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
317
+ }
318
+
319
+ // Check every 60s: exit if owner process died or idle for 30 minutes
320
+ const lifecycleCheck = setInterval(() => {
321
+ if (!ownerAlive()) shutdown('owner process exited');
322
+ else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout');
323
+ }, 60 * 1000);
324
+ lifecycleCheck.unref();
325
+
326
+ // Validate owner PID at startup. If it's already dead, the PID resolution
327
+ // was wrong (common on WSL, Tailscale SSH, and cross-user scenarios).
328
+ // Disable monitoring and rely on the idle timeout instead.
329
+ if (ownerPid) {
330
+ try { process.kill(ownerPid, 0); }
331
+ catch (e) {
332
+ if (e.code !== 'EPERM') {
333
+ console.log(JSON.stringify({ type: 'owner-pid-invalid', pid: ownerPid, reason: 'dead at startup' }));
334
+ ownerPid = null;
335
+ }
336
+ }
337
+ }
338
+
339
+ server.listen(PORT, HOST, () => {
340
+ const info = JSON.stringify({
341
+ type: 'server-started', port: Number(PORT), host: HOST,
342
+ url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,
343
+ screen_dir: CONTENT_DIR, state_dir: STATE_DIR
344
+ });
345
+ console.log(info);
346
+ fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n');
347
+ });
348
+ }
349
+
350
+ if (require.main === module) {
351
+ startServer();
352
+ }
353
+
354
+ module.exports = { computeAcceptKey, encodeFrame, decodeFrame, OPCODES };
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env bash
2
+ # Start the brainstorm server and output connection info
3
+ # Usage: start-server.sh [--project-dir <path>] [--host <bind-host>] [--url-host <display-host>] [--foreground] [--background]
4
+ #
5
+ # Starts server on a random high port, outputs JSON with URL.
6
+ # Each session gets its own directory to avoid conflicts.
7
+ #
8
+ # Options:
9
+ # --project-dir <path> Store session files under <path>/.superpowers/brainstorm/
10
+ # instead of /tmp. Files persist after server stops.
11
+ # --host <bind-host> Host/interface to bind (default: 127.0.0.1).
12
+ # Use 0.0.0.0 in remote/containerized environments.
13
+ # --url-host <host> Hostname shown in returned URL JSON.
14
+ # --foreground Run server in the current terminal (no backgrounding).
15
+ # --background Force background mode (overrides Codex auto-foreground).
16
+
17
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18
+
19
+ # Parse arguments
20
+ PROJECT_DIR=""
21
+ FOREGROUND="false"
22
+ FORCE_BACKGROUND="false"
23
+ BIND_HOST="127.0.0.1"
24
+ URL_HOST=""
25
+ while [[ $# -gt 0 ]]; do
26
+ case "$1" in
27
+ --project-dir)
28
+ PROJECT_DIR="$2"
29
+ shift 2
30
+ ;;
31
+ --host)
32
+ BIND_HOST="$2"
33
+ shift 2
34
+ ;;
35
+ --url-host)
36
+ URL_HOST="$2"
37
+ shift 2
38
+ ;;
39
+ --foreground|--no-daemon)
40
+ FOREGROUND="true"
41
+ shift
42
+ ;;
43
+ --background|--daemon)
44
+ FORCE_BACKGROUND="true"
45
+ shift
46
+ ;;
47
+ *)
48
+ echo "{\"error\": \"Unknown argument: $1\"}"
49
+ exit 1
50
+ ;;
51
+ esac
52
+ done
53
+
54
+ if [[ -z "$URL_HOST" ]]; then
55
+ if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then
56
+ URL_HOST="localhost"
57
+ else
58
+ URL_HOST="$BIND_HOST"
59
+ fi
60
+ fi
61
+
62
+ # Some environments reap detached/background processes. Auto-foreground when detected.
63
+ if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
64
+ FOREGROUND="true"
65
+ fi
66
+
67
+ # Windows/Git Bash reaps nohup background processes. Auto-foreground when detected.
68
+ if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
69
+ case "${OSTYPE:-}" in
70
+ msys*|cygwin*|mingw*) FOREGROUND="true" ;;
71
+ esac
72
+ if [[ -n "${MSYSTEM:-}" ]]; then
73
+ FOREGROUND="true"
74
+ fi
75
+ fi
76
+
77
+ # Generate unique session directory
78
+ SESSION_ID="$$-$(date +%s)"
79
+
80
+ if [[ -n "$PROJECT_DIR" ]]; then
81
+ SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
82
+ else
83
+ SESSION_DIR="/tmp/brainstorm-${SESSION_ID}"
84
+ fi
85
+
86
+ STATE_DIR="${SESSION_DIR}/state"
87
+ PID_FILE="${STATE_DIR}/server.pid"
88
+ LOG_FILE="${STATE_DIR}/server.log"
89
+
90
+ # Create fresh session directory with content and state peers
91
+ mkdir -p "${SESSION_DIR}/content" "$STATE_DIR"
92
+
93
+ # Kill any existing server
94
+ if [[ -f "$PID_FILE" ]]; then
95
+ old_pid=$(cat "$PID_FILE")
96
+ kill "$old_pid" 2>/dev/null
97
+ rm -f "$PID_FILE"
98
+ fi
99
+
100
+ cd "$SCRIPT_DIR"
101
+
102
+ # Resolve the harness PID (grandparent of this script).
103
+ # $PPID is the ephemeral shell the harness spawned to run us — it dies
104
+ # when this script exits. The harness itself is $PPID's parent.
105
+ OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')"
106
+ if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then
107
+ OWNER_PID="$PPID"
108
+ fi
109
+
110
+ # Foreground mode for environments that reap detached/background processes.
111
+ if [[ "$FOREGROUND" == "true" ]]; then
112
+ echo "$$" > "$PID_FILE"
113
+ env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs
114
+ exit $?
115
+ fi
116
+
117
+ # Start server, capturing output to log file
118
+ # Use nohup to survive shell exit; disown to remove from job table
119
+ nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs > "$LOG_FILE" 2>&1 &
120
+ SERVER_PID=$!
121
+ disown "$SERVER_PID" 2>/dev/null
122
+ echo "$SERVER_PID" > "$PID_FILE"
123
+
124
+ # Wait for server-started message (check log file)
125
+ for i in {1..50}; do
126
+ if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
127
+ # Verify server is still alive after a short window (catches process reapers)
128
+ alive="true"
129
+ for _ in {1..20}; do
130
+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
131
+ alive="false"
132
+ break
133
+ fi
134
+ sleep 0.1
135
+ done
136
+ if [[ "$alive" != "true" ]]; then
137
+ echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}"
138
+ exit 1
139
+ fi
140
+ grep "server-started" "$LOG_FILE" | head -1
141
+ exit 0
142
+ fi
143
+ sleep 0.1
144
+ done
145
+
146
+ # Timeout - server didn't start
147
+ echo '{"error": "Server failed to start within 5 seconds"}'
148
+ exit 1
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # Stop the brainstorm server and clean up
3
+ # Usage: stop-server.sh <session_dir>
4
+ #
5
+ # Kills the server process. Only deletes session directory if it's
6
+ # under /tmp (ephemeral). Persistent directories (.superpowers/) are
7
+ # kept so mockups can be reviewed later.
8
+
9
+ SESSION_DIR="$1"
10
+
11
+ if [[ -z "$SESSION_DIR" ]]; then
12
+ echo '{"error": "Usage: stop-server.sh <session_dir>"}'
13
+ exit 1
14
+ fi
15
+
16
+ STATE_DIR="${SESSION_DIR}/state"
17
+ PID_FILE="${STATE_DIR}/server.pid"
18
+
19
+ if [[ -f "$PID_FILE" ]]; then
20
+ pid=$(cat "$PID_FILE")
21
+
22
+ # Try to stop gracefully, fallback to force if still alive
23
+ kill "$pid" 2>/dev/null || true
24
+
25
+ # Wait for graceful shutdown (up to ~2s)
26
+ for i in {1..20}; do
27
+ if ! kill -0 "$pid" 2>/dev/null; then
28
+ break
29
+ fi
30
+ sleep 0.1
31
+ done
32
+
33
+ # If still running, escalate to SIGKILL
34
+ if kill -0 "$pid" 2>/dev/null; then
35
+ kill -9 "$pid" 2>/dev/null || true
36
+
37
+ # Give SIGKILL a moment to take effect
38
+ sleep 0.1
39
+ fi
40
+
41
+ if kill -0 "$pid" 2>/dev/null; then
42
+ echo '{"status": "failed", "error": "process still running"}'
43
+ exit 1
44
+ fi
45
+
46
+ rm -f "$PID_FILE" "${STATE_DIR}/server.log"
47
+
48
+ # Only delete ephemeral /tmp directories
49
+ if [[ "$SESSION_DIR" == /tmp/* ]]; then
50
+ rm -rf "$SESSION_DIR"
51
+ fi
52
+
53
+ echo '{"status": "stopped"}'
54
+ else
55
+ echo '{"status": "not_running"}'
56
+ fi
@@ -16,13 +16,31 @@ disable-model-invocation: false
16
16
  1. progress.json 업데이트:
17
17
  - `agent_status` → `"completed"`
18
18
  - `completed_agents`에 `"dispatcher"` 추가
19
- - `next_agent` → `"planner"`
19
+ - `next_agent` → **브레인스토밍 결정 트리에 따라 결정** ([섹션 6](#6-brainstormer-routing-decision) 참조)
20
+ - 신규/재플래닝 + 사용자가 브레인스토밍 선택 → `"brainstorming"`
21
+ - 신규/재플래닝 + 사용자가 건너뛰기 선택 → `"planner"`
22
+ - 특정 에이전트 직접 명령 → 해당 에이전트 (예: `"evaluator-functional"`)
23
+ - Gotcha 교정 후 재작업 → `failure.retry_target` (해당 에이전트)
20
24
  - `pipeline` → 선택된 파이프라인 (FULLSTACK/FE-ONLY/BE-ONLY)
21
- - `sprint.number` → `1`, `sprint.status` → `"in_progress"`
25
+ - `sprint.number` → `1`, `sprint.status` → `"in_progress"` (신규 파이프라인인 경우에만)
22
26
  2. `.harness/progress.log`에 요약 한 줄 추가
23
27
  3. **STOP. 다음 에이전트를 직접 호출하지 않는다.**
24
28
  4. 출력: `"✓ Dispatcher 완료. bash scripts/harness-next.sh 실행하여 다음 단계 확인."`
25
29
 
30
+ ## Auto-Routing (UserPromptSubmit Hook)
31
+
32
+ walwal-harness v2.2.0+ 부터 **UserPromptSubmit 훅** 이 모든 사용자 프롬프트 앞에
33
+ `[walwal-harness] Auto-routing is ACTIVE` 안내를 자동 주입한다. 이 훅이 켜져 있으면
34
+ Claude 는 기본적으로 Dispatcher 경유로 분류/라우팅해야 한다.
35
+
36
+ - **활성 조건**: `.harness/config.json` 의 `behavior.auto_route_dispatcher == true`
37
+ - **per-message opt-out**: 사용자가 `harness skip`, `harness 없이`, `without harness`,
38
+ `just answer` 등을 말하면 그 메시지 한정으로 훅이 pass-through
39
+ - **전역 비활성**: `behavior.auto_route_dispatcher = false`
40
+
41
+ 훅이 주입하는 컨텍스트에는 `pipeline`, `current_agent`, `next_agent`, `sprint`,
42
+ `fe_stack` 현재값이 포함되므로 Dispatcher 는 별도 상태 조회 없이 판단 가능.
43
+
26
44
  ## 1. Request Classification (최우선)
27
45
 
28
46
  사용자 입력을 먼저 분류합니다:
@@ -30,6 +48,7 @@ disable-model-invocation: false
30
48
  - **실수 지적** ("아니", "잘못", "그렇게 하면 안 돼", "X로 해야지") → **Gotcha Flow**
31
49
  - **기능 요청** ("만들어", "추가", "시작", PRD, OpenAPI) → **Pipeline Flow**
32
50
  - **혼합** → Gotcha 먼저 기록 → Pipeline 이어서
51
+ - **메타/인사/Claude 자체 질문** → Dispatcher skip, 짧은 일반 응답 허용
33
52
 
34
53
  ## 2. Gotcha Flow
35
54
 
@@ -68,3 +87,96 @@ AGENTS.md 비하네스 → 기존 백업 + 리빌드
68
87
  ## 5. Output
69
88
 
70
89
  `.harness/actions/pipeline.json` 생성 → 사용자 확인 → Session Boundary Protocol On Complete 실행
90
+
91
+ ### fe_stack 필드 (FE 파이프라인에서 필수)
92
+
93
+ FE-ONLY 또는 FULLSTACK 선택 시, `pipeline.json`에 **`fe_stack`** 필드를 포함해야 한다:
94
+
95
+ - `scan-result.json.tech_stack.fe_stack` 값을 기본으로 사용 (`react` | `flutter`)
96
+ - 값이 없거나 불명확하면 Planner가 확정하도록 위임 (Dispatcher는 `"unknown"` 기록 + `notes` 에 메모)
97
+ - Flutter 선택 시 `agents_active`/`agents_skipped`에 치환된 에이전트명을 기록
98
+ - active: `generator-frontend-flutter`, `evaluator-functional-flutter`
99
+ - skipped: `generator-frontend`, `evaluator-functional`, `evaluator-visual`
100
+
101
+ ## 6. Brainstormer Routing Decision
102
+
103
+ **원칙**: Brainstormer 는 파이프라인의 고정 스텝이 **아니다**. Dispatcher 가 조건부로 삽입한다.
104
+
105
+ ### 6.1 결정 트리
106
+
107
+ Dispatcher 는 사용자 요청을 분류한 뒤 아래 순서로 판단:
108
+
109
+ ```
110
+ 1. Gotcha / 실수 지적인가?
111
+ → YES: gotcha 기록 → next_agent = failure.retry_target (해당 에이전트)
112
+ (브레인스토밍 없음)
113
+
114
+ 2. 특정 에이전트 직접 명령인가?
115
+ ("evaluator 다시 돌려", "generator-frontend 재작업", "planner plan.md 고쳐" 등)
116
+ → YES: next_agent = <대상 에이전트> (브레인스토밍 없음)
117
+
118
+ 3. Planner 가 동작해야 하는 케이스인가?
119
+ (신규 파이프라인 / 신규 PRD / 기존 plan.md 대폭 수정 / 신규 feature 대규모 추가)
120
+ → YES: 사용자에게 확인 질문 → 6.2 "브레인스토밍 확인 플로우"
121
+ → NO: 다른 에이전트로 라우팅 (generator 이어서 등)
122
+
123
+ 4. 그 외 (메타/인사/Claude 자체 질문) → Dispatcher skip
124
+ ```
125
+
126
+ ### 6.2 브레인스토밍 확인 플로우
127
+
128
+ Planner 를 호출해야 한다고 판단되면, **사용자에게 단 하나의 질문을 출력한 뒤 대기한다:**
129
+
130
+ ```
131
+ 이 요청은 Planner 가 처리할 신규/재플래닝 건으로 보입니다.
132
+ 러프한 요구사항을 먼저 구체화하는 Brainstormer 과정을 거칠까요?
133
+
134
+ (Y) 예 — Brainstormer 와 대화하며 요구사항을 fit 하게 만든 뒤 Planner
135
+ (N) 아니오 — 이미 PRD/OpenAPI 가 명확하므로 바로 Planner
136
+
137
+ 답변: Y / N
138
+ ```
139
+
140
+ 사용자 응답 처리:
141
+ - **Y (긍정)** — "네", "y", "yes", "필요해", "해줘" 등
142
+ → `next_agent = "brainstorming"`
143
+ - **N (부정)** — "아니오", "n", "no", "필요없어", "바로", "skip" 등
144
+ → `next_agent = "planner"`
145
+ - **불명확 / 무응답** — 한 번 더 "Y 또는 N 으로 답해주세요" 요청
146
+
147
+ ### 6.3 Skip 케이스 정리
148
+
149
+ 브레인스토밍이 **실행되지 않는** 경우 (Dispatcher 가 직접 다른 에이전트로 라우팅):
150
+
151
+ | 상황 | next_agent |
152
+ |------|-----------|
153
+ | "Eval, X 다시 검증해" | `evaluator-functional` (또는 `evaluator-visual`) |
154
+ | "Generator-FE, Y 버그 고쳐" | `generator-frontend` (또는 Flutter 변형) |
155
+ | "Generator-BE, API 재생성해" | `generator-backend` |
156
+ | Eval FAIL → retry | `failure.retry_target` |
157
+ | Gotcha 수정 | `failure.retry_target` 또는 현재 에이전트 |
158
+ | 기존 plan.md 소폭 수정 (Dispatcher 판단) | `planner` (직접) |
159
+ | 사용자가 "Brainstormer 없이" / "skip brainstorming" 명시 | `planner` (직접) |
160
+
161
+ ### 6.4 강제 호출 케이스
162
+
163
+ 사용자가 명시적으로 원하면 브레인스토밍은 언제든 재호출 가능:
164
+
165
+ - "Brainstormer 다시 돌려줘"
166
+ - "요구사항 다시 잡자"
167
+ - "plan 처음부터"
168
+
169
+ 이 경우 기존 `.harness/actions/brainstorm-spec.md` 는 Brainstormer 의 On Start 에서
170
+ `.harness/archive/brainstorm-spec-<timestamp>.md` 로 백업된다.
171
+
172
+ ## 7. Handoff 라우팅 (fe_stack 반영)
173
+
174
+ Dispatcher가 `next_agent` 를 세팅할 때 pipeline.json.fe_stack 을 참조해 치환:
175
+
176
+ | 원본 next_agent | fe_stack=react | fe_stack=flutter |
177
+ |-----------------|----------------|------------------|
178
+ | generator-frontend | generator-frontend | generator-frontend-flutter |
179
+ | evaluator-functional (FE 단계) | evaluator-functional | evaluator-functional-flutter |
180
+ | evaluator-visual | evaluator-visual | (skip → 다음 단계로 이동) |
181
+
182
+ **Brainstormer 는 fe_stack 치환 대상이 아니다** — 언어/스택 무관 공통 에이전트.