@juho0719/cckit 0.2.3 → 0.2.5
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/assets/hooks/agent-track.sh +53 -0
- package/assets/hooks/auto-commit-push.sh +123 -0
- package/assets/hooks/on-prompt-start.sh +6 -0
- package/assets/hooks/skill-track.sh +26 -0
- package/assets/hooks/subagent-notify.sh +20 -0
- package/assets/skills/plan-viewer/SKILL.md +162 -0
- package/assets/skills/plan-viewer/scripts/frame-template.html +266 -0
- package/assets/skills/plan-viewer/scripts/helper.js +84 -0
- package/assets/skills/plan-viewer/scripts/server.js +334 -0
- package/assets/skills/plan-viewer/scripts/start-server.sh +122 -0
- package/assets/skills/plan-viewer/scripts/stop-server.sh +27 -0
- package/assets/skills/plan-viewer/spec-reviewer-prompt.md +47 -0
- package/assets/skills/plan-viewer/visual-guide.md +234 -0
- package/assets/skills/scaffold/SKILL.md +119 -0
- package/assets/skills/scaffold/presets.md +94 -0
- package/assets/skills/scaffold/scripts/common.sh +73 -0
- package/assets/skills/scaffold/scripts/monorepo.sh +449 -0
- package/assets/skills/scaffold/scripts/nextjs-fullstack.sh +305 -0
- package/assets/statusline/statusline.sh +186 -0
- package/dist/{chunk-OLLOS3GG.js → chunk-E3INXQNO.js} +38 -8
- package/dist/{chunk-SLVASXTF.js → chunk-W7RWPDBH.js} +13 -3
- package/dist/{cli-6WQMAFNA.js → cli-KZYBSIXO.js} +43 -14
- package/dist/{hooks-S73JTX2I.js → hooks-A2WQ2LGG.js} +1 -1
- package/dist/index.js +10 -10
- package/dist/{registry-BU55RMHU.js → registry-KRLOB4TH.js} +1 -1
- package/dist/{uninstall-cli-7XGNDIUC.js → uninstall-cli-GLYJG5V2.js} +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const WS_URL = 'ws://' + window.location.host;
|
|
3
|
+
let ws = null;
|
|
4
|
+
let eventQueue = [];
|
|
5
|
+
|
|
6
|
+
function connect() {
|
|
7
|
+
ws = new WebSocket(WS_URL);
|
|
8
|
+
|
|
9
|
+
ws.onopen = () => {
|
|
10
|
+
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
|
|
11
|
+
eventQueue = [];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
ws.onmessage = (msg) => {
|
|
15
|
+
const data = JSON.parse(msg.data);
|
|
16
|
+
if (data.type === 'reload') {
|
|
17
|
+
window.location.reload();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
ws.onclose = () => {
|
|
22
|
+
setTimeout(connect, 1000);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sendEvent(event) {
|
|
27
|
+
event.timestamp = Date.now();
|
|
28
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
29
|
+
ws.send(JSON.stringify(event));
|
|
30
|
+
} else {
|
|
31
|
+
eventQueue.push(event);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
document.addEventListener('click', (e) => {
|
|
36
|
+
const target = e.target.closest('[data-choice]');
|
|
37
|
+
if (!target) return;
|
|
38
|
+
|
|
39
|
+
sendEvent({
|
|
40
|
+
type: 'click',
|
|
41
|
+
text: target.textContent.trim(),
|
|
42
|
+
choice: target.dataset.choice,
|
|
43
|
+
id: target.id || null
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
const indicator = document.getElementById('indicator-text');
|
|
48
|
+
if (!indicator) return;
|
|
49
|
+
const container = target.closest('.options') || target.closest('.cards');
|
|
50
|
+
const selected = container ? container.querySelectorAll('.selected') : [];
|
|
51
|
+
if (selected.length === 0) {
|
|
52
|
+
indicator.textContent = '옵션을 클릭한 후 터미널로 돌아가세요';
|
|
53
|
+
} else if (selected.length === 1) {
|
|
54
|
+
const label = selected[0].querySelector('h3, .content h3, .card-body h3')?.textContent?.trim() || selected[0].dataset.choice;
|
|
55
|
+
indicator.innerHTML = '<span class="selected-text">' + label + ' 선택됨</span> — 터미널로 돌아가서 계속하세요';
|
|
56
|
+
} else {
|
|
57
|
+
indicator.innerHTML = '<span class="selected-text">' + selected.length + '개 선택됨</span> — 터미널로 돌아가서 계속하세요';
|
|
58
|
+
}
|
|
59
|
+
}, 0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
window.selectedChoice = null;
|
|
63
|
+
|
|
64
|
+
window.toggleSelect = function(el) {
|
|
65
|
+
const container = el.closest('.options') || el.closest('.cards');
|
|
66
|
+
const multi = container && container.dataset.multiselect !== undefined;
|
|
67
|
+
if (container && !multi) {
|
|
68
|
+
container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
|
|
69
|
+
}
|
|
70
|
+
if (multi) {
|
|
71
|
+
el.classList.toggle('selected');
|
|
72
|
+
} else {
|
|
73
|
+
el.classList.add('selected');
|
|
74
|
+
}
|
|
75
|
+
window.selectedChoice = el.dataset.choice;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
window.planViewer = {
|
|
79
|
+
send: sendEvent,
|
|
80
|
+
choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata })
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
connect();
|
|
84
|
+
})();
|
|
@@ -0,0 +1,334 @@
|
|
|
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.PLAN_VIEWER_PORT || (49152 + Math.floor(Math.random() * 16383));
|
|
77
|
+
const HOST = process.env.PLAN_VIEWER_HOST || '127.0.0.1';
|
|
78
|
+
const URL_HOST = process.env.PLAN_VIEWER_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
|
|
79
|
+
const SCREEN_DIR = process.env.PLAN_VIEWER_DIR || '/tmp/plan-viewer';
|
|
80
|
+
const OWNER_PID = process.env.PLAN_VIEWER_OWNER_PID ? Number(process.env.PLAN_VIEWER_OWNER_PID) : null;
|
|
81
|
+
|
|
82
|
+
const MIME_TYPES = {
|
|
83
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
84
|
+
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
|
85
|
+
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml'
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ========== Templates and Constants ==========
|
|
89
|
+
|
|
90
|
+
const WAITING_PAGE = `<!DOCTYPE html>
|
|
91
|
+
<html>
|
|
92
|
+
<head><meta charset="utf-8"><title>Plan Viewer</title>
|
|
93
|
+
<style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
94
|
+
h1 { color: #333; } p { color: #666; }</style>
|
|
95
|
+
</head>
|
|
96
|
+
<body><h1>Plan Viewer</h1>
|
|
97
|
+
<p>Waiting for content...</p></body></html>`;
|
|
98
|
+
|
|
99
|
+
const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
|
|
100
|
+
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
|
|
101
|
+
const helperInjection = '<script>\n' + helperScript + '\n</script>';
|
|
102
|
+
|
|
103
|
+
// ========== Helper Functions ==========
|
|
104
|
+
|
|
105
|
+
function isFullDocument(html) {
|
|
106
|
+
const trimmed = html.trimStart().toLowerCase();
|
|
107
|
+
return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function wrapInFrame(content) {
|
|
111
|
+
return frameTemplate.replace('<!-- CONTENT -->', content);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getNewestScreen() {
|
|
115
|
+
const files = fs.readdirSync(SCREEN_DIR)
|
|
116
|
+
.filter(f => f.endsWith('.html'))
|
|
117
|
+
.map(f => {
|
|
118
|
+
const fp = path.join(SCREEN_DIR, f);
|
|
119
|
+
return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
|
|
120
|
+
})
|
|
121
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
122
|
+
return files.length > 0 ? files[0].path : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ========== HTTP Request Handler ==========
|
|
126
|
+
|
|
127
|
+
function handleRequest(req, res) {
|
|
128
|
+
touchActivity();
|
|
129
|
+
if (req.method === 'GET' && req.url === '/') {
|
|
130
|
+
const screenFile = getNewestScreen();
|
|
131
|
+
let html = screenFile
|
|
132
|
+
? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
|
|
133
|
+
: WAITING_PAGE;
|
|
134
|
+
|
|
135
|
+
if (html.includes('</body>')) {
|
|
136
|
+
html = html.replace('</body>', helperInjection + '\n</body>');
|
|
137
|
+
} else {
|
|
138
|
+
html += helperInjection;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
142
|
+
res.end(html);
|
|
143
|
+
} else if (req.method === 'GET' && req.url.startsWith('/files/')) {
|
|
144
|
+
const fileName = req.url.slice(7);
|
|
145
|
+
const filePath = path.join(SCREEN_DIR, path.basename(fileName));
|
|
146
|
+
if (!fs.existsSync(filePath)) {
|
|
147
|
+
res.writeHead(404);
|
|
148
|
+
res.end('Not found');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
152
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
153
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
154
|
+
res.end(fs.readFileSync(filePath));
|
|
155
|
+
} else {
|
|
156
|
+
res.writeHead(404);
|
|
157
|
+
res.end('Not found');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ========== WebSocket Connection Handling ==========
|
|
162
|
+
|
|
163
|
+
const clients = new Set();
|
|
164
|
+
|
|
165
|
+
function handleUpgrade(req, socket) {
|
|
166
|
+
const key = req.headers['sec-websocket-key'];
|
|
167
|
+
if (!key) { socket.destroy(); return; }
|
|
168
|
+
|
|
169
|
+
const accept = computeAcceptKey(key);
|
|
170
|
+
socket.write(
|
|
171
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
172
|
+
'Upgrade: websocket\r\n' +
|
|
173
|
+
'Connection: Upgrade\r\n' +
|
|
174
|
+
'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
let buffer = Buffer.alloc(0);
|
|
178
|
+
clients.add(socket);
|
|
179
|
+
|
|
180
|
+
socket.on('data', (chunk) => {
|
|
181
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
182
|
+
while (buffer.length > 0) {
|
|
183
|
+
let result;
|
|
184
|
+
try {
|
|
185
|
+
result = decodeFrame(buffer);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
|
|
188
|
+
clients.delete(socket);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (!result) break;
|
|
192
|
+
buffer = buffer.slice(result.bytesConsumed);
|
|
193
|
+
|
|
194
|
+
switch (result.opcode) {
|
|
195
|
+
case OPCODES.TEXT:
|
|
196
|
+
handleMessage(result.payload.toString());
|
|
197
|
+
break;
|
|
198
|
+
case OPCODES.CLOSE:
|
|
199
|
+
socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
|
|
200
|
+
clients.delete(socket);
|
|
201
|
+
return;
|
|
202
|
+
case OPCODES.PING:
|
|
203
|
+
socket.write(encodeFrame(OPCODES.PONG, result.payload));
|
|
204
|
+
break;
|
|
205
|
+
case OPCODES.PONG:
|
|
206
|
+
break;
|
|
207
|
+
default: {
|
|
208
|
+
const closeBuf = Buffer.alloc(2);
|
|
209
|
+
closeBuf.writeUInt16BE(1003);
|
|
210
|
+
socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
|
|
211
|
+
clients.delete(socket);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
socket.on('close', () => clients.delete(socket));
|
|
219
|
+
socket.on('error', () => clients.delete(socket));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function handleMessage(text) {
|
|
223
|
+
let event;
|
|
224
|
+
try {
|
|
225
|
+
event = JSON.parse(text);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
console.error('Failed to parse WebSocket message:', e.message);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
touchActivity();
|
|
231
|
+
console.log(JSON.stringify({ source: 'user-event', ...event }));
|
|
232
|
+
if (event.choice) {
|
|
233
|
+
const eventsFile = path.join(SCREEN_DIR, '.events');
|
|
234
|
+
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function broadcast(msg) {
|
|
239
|
+
const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg)));
|
|
240
|
+
for (const socket of clients) {
|
|
241
|
+
try { socket.write(frame); } catch (e) { clients.delete(socket); }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ========== Activity Tracking ==========
|
|
246
|
+
|
|
247
|
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
248
|
+
let lastActivity = Date.now();
|
|
249
|
+
|
|
250
|
+
function touchActivity() {
|
|
251
|
+
lastActivity = Date.now();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ========== File Watching ==========
|
|
255
|
+
|
|
256
|
+
const debounceTimers = new Map();
|
|
257
|
+
|
|
258
|
+
// ========== Server Startup ==========
|
|
259
|
+
|
|
260
|
+
function startServer() {
|
|
261
|
+
if (!fs.existsSync(SCREEN_DIR)) fs.mkdirSync(SCREEN_DIR, { recursive: true });
|
|
262
|
+
|
|
263
|
+
const knownFiles = new Set(
|
|
264
|
+
fs.readdirSync(SCREEN_DIR).filter(f => f.endsWith('.html'))
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const server = http.createServer(handleRequest);
|
|
268
|
+
server.on('upgrade', handleUpgrade);
|
|
269
|
+
|
|
270
|
+
const watcher = fs.watch(SCREEN_DIR, (eventType, filename) => {
|
|
271
|
+
if (!filename || !filename.endsWith('.html')) return;
|
|
272
|
+
|
|
273
|
+
if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
|
|
274
|
+
debounceTimers.set(filename, setTimeout(() => {
|
|
275
|
+
debounceTimers.delete(filename);
|
|
276
|
+
const filePath = path.join(SCREEN_DIR, filename);
|
|
277
|
+
|
|
278
|
+
if (!fs.existsSync(filePath)) return;
|
|
279
|
+
touchActivity();
|
|
280
|
+
|
|
281
|
+
if (!knownFiles.has(filename)) {
|
|
282
|
+
knownFiles.add(filename);
|
|
283
|
+
const eventsFile = path.join(SCREEN_DIR, '.events');
|
|
284
|
+
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
|
|
285
|
+
console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
|
|
286
|
+
} else {
|
|
287
|
+
console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
broadcast({ type: 'reload' });
|
|
291
|
+
}, 100));
|
|
292
|
+
});
|
|
293
|
+
watcher.on('error', (err) => console.error('fs.watch error:', err.message));
|
|
294
|
+
|
|
295
|
+
function shutdown(reason) {
|
|
296
|
+
console.log(JSON.stringify({ type: 'server-stopped', reason }));
|
|
297
|
+
const infoFile = path.join(SCREEN_DIR, '.server-info');
|
|
298
|
+
if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
|
|
299
|
+
fs.writeFileSync(
|
|
300
|
+
path.join(SCREEN_DIR, '.server-stopped'),
|
|
301
|
+
JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
|
|
302
|
+
);
|
|
303
|
+
watcher.close();
|
|
304
|
+
clearInterval(lifecycleCheck);
|
|
305
|
+
server.close(() => process.exit(0));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function ownerAlive() {
|
|
309
|
+
if (!OWNER_PID) return true;
|
|
310
|
+
try { process.kill(OWNER_PID, 0); return true; } catch (e) { return false; }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const lifecycleCheck = setInterval(() => {
|
|
314
|
+
if (!ownerAlive()) shutdown('owner process exited');
|
|
315
|
+
else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout');
|
|
316
|
+
}, 60 * 1000);
|
|
317
|
+
lifecycleCheck.unref();
|
|
318
|
+
|
|
319
|
+
server.listen(PORT, HOST, () => {
|
|
320
|
+
const info = JSON.stringify({
|
|
321
|
+
type: 'server-started', port: Number(PORT), host: HOST,
|
|
322
|
+
url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,
|
|
323
|
+
screen_dir: SCREEN_DIR
|
|
324
|
+
});
|
|
325
|
+
console.log(info);
|
|
326
|
+
fs.writeFileSync(path.join(SCREEN_DIR, '.server-info'), info + '\n');
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (require.main === module) {
|
|
331
|
+
startServer();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = { computeAcceptKey, encodeFrame, decodeFrame, OPCODES };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Start the plan-viewer server and output connection info
|
|
3
|
+
# Usage: start-server.sh [--project-dir <path>] [--host <bind-host>] [--url-host <display-host>] [--foreground]
|
|
4
|
+
#
|
|
5
|
+
# Starts server on a random high port, outputs JSON with URL.
|
|
6
|
+
# Each session gets its own directory under .plan-viewer/sessions/
|
|
7
|
+
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
+
|
|
10
|
+
# Parse arguments
|
|
11
|
+
PROJECT_DIR=""
|
|
12
|
+
FOREGROUND="false"
|
|
13
|
+
FORCE_BACKGROUND="false"
|
|
14
|
+
BIND_HOST="127.0.0.1"
|
|
15
|
+
URL_HOST=""
|
|
16
|
+
while [[ $# -gt 0 ]]; do
|
|
17
|
+
case "$1" in
|
|
18
|
+
--project-dir)
|
|
19
|
+
PROJECT_DIR="$2"
|
|
20
|
+
shift 2
|
|
21
|
+
;;
|
|
22
|
+
--host)
|
|
23
|
+
BIND_HOST="$2"
|
|
24
|
+
shift 2
|
|
25
|
+
;;
|
|
26
|
+
--url-host)
|
|
27
|
+
URL_HOST="$2"
|
|
28
|
+
shift 2
|
|
29
|
+
;;
|
|
30
|
+
--foreground|--no-daemon)
|
|
31
|
+
FOREGROUND="true"
|
|
32
|
+
shift
|
|
33
|
+
;;
|
|
34
|
+
--background|--daemon)
|
|
35
|
+
FORCE_BACKGROUND="true"
|
|
36
|
+
shift
|
|
37
|
+
;;
|
|
38
|
+
*)
|
|
39
|
+
echo "{\"error\": \"Unknown argument: $1\"}"
|
|
40
|
+
exit 1
|
|
41
|
+
;;
|
|
42
|
+
esac
|
|
43
|
+
done
|
|
44
|
+
|
|
45
|
+
if [[ -z "$URL_HOST" ]]; then
|
|
46
|
+
if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then
|
|
47
|
+
URL_HOST="localhost"
|
|
48
|
+
else
|
|
49
|
+
URL_HOST="$BIND_HOST"
|
|
50
|
+
fi
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
|
|
54
|
+
FOREGROUND="true"
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# Generate unique session directory
|
|
58
|
+
SESSION_ID="$$-$(date +%s)"
|
|
59
|
+
|
|
60
|
+
if [[ -n "$PROJECT_DIR" ]]; then
|
|
61
|
+
SCREEN_DIR="${PROJECT_DIR}/.plan-viewer/sessions/${SESSION_ID}"
|
|
62
|
+
else
|
|
63
|
+
SCREEN_DIR="/tmp/plan-viewer-${SESSION_ID}"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
PID_FILE="${SCREEN_DIR}/.server.pid"
|
|
67
|
+
LOG_FILE="${SCREEN_DIR}/.server.log"
|
|
68
|
+
|
|
69
|
+
# Create fresh session directory
|
|
70
|
+
mkdir -p "$SCREEN_DIR"
|
|
71
|
+
|
|
72
|
+
# Kill any existing server
|
|
73
|
+
if [[ -f "$PID_FILE" ]]; then
|
|
74
|
+
old_pid=$(cat "$PID_FILE")
|
|
75
|
+
kill "$old_pid" 2>/dev/null
|
|
76
|
+
rm -f "$PID_FILE"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
cd "$SCRIPT_DIR"
|
|
80
|
+
|
|
81
|
+
# Resolve the harness PID
|
|
82
|
+
OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')"
|
|
83
|
+
if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then
|
|
84
|
+
OWNER_PID="$PPID"
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# Foreground mode
|
|
88
|
+
if [[ "$FOREGROUND" == "true" ]]; then
|
|
89
|
+
echo "$$" > "$PID_FILE"
|
|
90
|
+
env PLAN_VIEWER_DIR="$SCREEN_DIR" PLAN_VIEWER_HOST="$BIND_HOST" PLAN_VIEWER_URL_HOST="$URL_HOST" PLAN_VIEWER_OWNER_PID="$OWNER_PID" node server.js
|
|
91
|
+
exit $?
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
# Background mode
|
|
95
|
+
nohup env PLAN_VIEWER_DIR="$SCREEN_DIR" PLAN_VIEWER_HOST="$BIND_HOST" PLAN_VIEWER_URL_HOST="$URL_HOST" PLAN_VIEWER_OWNER_PID="$OWNER_PID" node server.js > "$LOG_FILE" 2>&1 &
|
|
96
|
+
SERVER_PID=$!
|
|
97
|
+
disown "$SERVER_PID" 2>/dev/null
|
|
98
|
+
echo "$SERVER_PID" > "$PID_FILE"
|
|
99
|
+
|
|
100
|
+
# Wait for server-started message
|
|
101
|
+
for i in {1..50}; do
|
|
102
|
+
if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
|
|
103
|
+
alive="true"
|
|
104
|
+
for _ in {1..20}; do
|
|
105
|
+
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
|
106
|
+
alive="false"
|
|
107
|
+
break
|
|
108
|
+
fi
|
|
109
|
+
sleep 0.1
|
|
110
|
+
done
|
|
111
|
+
if [[ "$alive" != "true" ]]; then
|
|
112
|
+
echo "{\"error\": \"Server started but was killed. Retry with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}"
|
|
113
|
+
exit 1
|
|
114
|
+
fi
|
|
115
|
+
grep "server-started" "$LOG_FILE" | head -1
|
|
116
|
+
exit 0
|
|
117
|
+
fi
|
|
118
|
+
sleep 0.1
|
|
119
|
+
done
|
|
120
|
+
|
|
121
|
+
echo '{"error": "Server failed to start within 5 seconds"}'
|
|
122
|
+
exit 1
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Stop the plan-viewer server and clean up
|
|
3
|
+
# Usage: stop-server.sh <screen_dir>
|
|
4
|
+
|
|
5
|
+
SCREEN_DIR="$1"
|
|
6
|
+
|
|
7
|
+
if [[ -z "$SCREEN_DIR" ]]; then
|
|
8
|
+
echo '{"error": "Usage: stop-server.sh <screen_dir>"}'
|
|
9
|
+
exit 1
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
PID_FILE="${SCREEN_DIR}/.server.pid"
|
|
13
|
+
|
|
14
|
+
if [[ -f "$PID_FILE" ]]; then
|
|
15
|
+
pid=$(cat "$PID_FILE")
|
|
16
|
+
kill "$pid" 2>/dev/null
|
|
17
|
+
rm -f "$PID_FILE" "${SCREEN_DIR}/.server.log"
|
|
18
|
+
|
|
19
|
+
# Only delete ephemeral /tmp directories
|
|
20
|
+
if [[ "$SCREEN_DIR" == /tmp/* ]]; then
|
|
21
|
+
rm -rf "$SCREEN_DIR"
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
echo '{"status": "stopped"}'
|
|
25
|
+
else
|
|
26
|
+
echo '{"status": "not_running"}'
|
|
27
|
+
fi
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Spec Document Reviewer Prompt Template
|
|
2
|
+
|
|
3
|
+
스펙 문서 작성 후 검증을 위한 서브에이전트 프롬프트 템플릿.
|
|
4
|
+
|
|
5
|
+
**용도:** 스펙이 완전하고 일관적이며 구현 계획 작성 준비가 되었는지 검증.
|
|
6
|
+
|
|
7
|
+
**실행 시점:** 스펙 문서가 docs/specs/에 작성된 후.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Agent tool (general-purpose):
|
|
11
|
+
description: "Review spec document"
|
|
12
|
+
prompt: |
|
|
13
|
+
스펙 문서 리뷰어. 이 스펙이 완전하고 구현 계획 작성 준비가 되었는지 검증하라.
|
|
14
|
+
|
|
15
|
+
**검토할 스펙:** [SPEC_FILE_PATH]
|
|
16
|
+
|
|
17
|
+
## 검토 항목
|
|
18
|
+
|
|
19
|
+
| 카테고리 | 확인 사항 |
|
|
20
|
+
|----------|-----------|
|
|
21
|
+
| 완전성 | TODO, 플레이스홀더, "TBD", 미완성 섹션 |
|
|
22
|
+
| 커버리지 | 에러 처리, 엣지 케이스, 통합 포인트 누락 |
|
|
23
|
+
| 일관성 | 내부 모순, 충돌하는 요구사항 |
|
|
24
|
+
| 명확성 | 모호한 요구사항 |
|
|
25
|
+
| YAGNI | 요청하지 않은 기능, 과도한 설계 |
|
|
26
|
+
| 범위 | 단일 계획으로 충분한 범위인지 |
|
|
27
|
+
| 아키텍처 | 명확한 경계, 잘 정의된 인터페이스, 독립적으로 이해/테스트 가능 |
|
|
28
|
+
|
|
29
|
+
## 특히 주의
|
|
30
|
+
|
|
31
|
+
- TODO 마커 또는 플레이스홀더 텍스트
|
|
32
|
+
- "추후 정의" 또는 "X 완료 후 스펙 작성" 등의 문구
|
|
33
|
+
- 다른 섹션에 비해 눈에 띄게 상세도가 낮은 섹션
|
|
34
|
+
- 명확한 경계나 인터페이스가 없는 유닛
|
|
35
|
+
|
|
36
|
+
## 출력 형식
|
|
37
|
+
|
|
38
|
+
## Spec Review
|
|
39
|
+
|
|
40
|
+
**Status:** ✅ Approved | ❌ Issues Found
|
|
41
|
+
|
|
42
|
+
**Issues (있는 경우):**
|
|
43
|
+
- [섹션 X]: [구체적 문제] - [중요한 이유]
|
|
44
|
+
|
|
45
|
+
**Recommendations (권고):**
|
|
46
|
+
- [승인을 차단하지 않는 제안사항]
|
|
47
|
+
```
|