@kybird/llm-wiki 0.3.0 → 0.4.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.
@@ -0,0 +1,856 @@
1
+ // llm-wiki monitor — 읽기 전용 실시간 보드 뷰어 (2026-09-16).
2
+ //
3
+ // "서버·DB 없음"(AGENTS.md)의 경계를 정확히 지킨다: 카드의 정본은 파일이고
4
+ // 쓰는 건 CLI뿐이다. 이 서버는 로컬 파일을 읽어 브라우저에 보여주기만 한다 —
5
+ // 상태를 남기지 않고, 쓰기 메서드는 전부 405로 거부한다. plan.md 1.4(읽기전용
6
+ // 색인이 확장 경로)과 6.6(칸반 웹 뷰어)이 예약해 둔 자리다. 드래그 이동 같은
7
+ // 쓰기 계층은 별도 카드(웹 뷰 — 드래그 이동)로 이 뷰어 위에 얹는다.
8
+ //
9
+ // 갱신은 폴링(2초)이다 — wait가 익힌 교훈대로 Windows의 fs.watch는 신뢰할 수
10
+ // 없고(junction), SSE·웹소켓은 두 번째 연결 프로토콜이라 배관이 먼저다(작업
11
+ // 원칙 1). localhost에서 2초 지연은 사람이 보기에 충분하다.
12
+ //
13
+ // stdout 계약: 첫 줄이 http://127.0.0.1:<port> 다 — 테스트가 이 줄을 파싱한다.
14
+ // 그래서 monitor는 bin의 auto-update 트리거 목록에 없다(wait와 같은 이유 —
15
+ // 배너가 첫 줄을 밀어내면 계약이 깨진다).
16
+ const http = require('http');
17
+ const fs = require('fs');
18
+ const kanban = require('./kanban');
19
+ const { recordProject, readProjects } = require('./auto-update');
20
+ const {
21
+ parseArgs, validateFlags, fail, USAGE, requireBoard, collectBoard, collectBoardAt,
22
+ } = require('./kanban-cmd');
23
+
24
+ const MONITOR_FLAGS = { port: 'string', all: 'flag' };
25
+ const DEFAULT_PORT = 4747;
26
+ const PORT_SPAN = 10;
27
+ // 테스트 노브(wait의 LLM_WIKI_WAIT_POLL_MS 전례) — 기본 포트 워크의 시작점을
28
+ // 바꾼다. 병렬 테스트가 4747을 두고 경합하지 않게.
29
+ const basePort = () => {
30
+ const env = Number(process.env.LLM_WIKI_MONITOR_BASE_PORT);
31
+ return Number.isInteger(env) && env > 0 ? env : DEFAULT_PORT;
32
+ };
33
+
34
+ // 이미 떠 있는 인스턴스를 본다 — /api/board가 kanban-board를 내면 monitor이고
35
+ // 보드 신원(repo.docRoot)까지 돌려준다. /api/fleet이 kanban-fleet을 내면 플릿
36
+ // (기계 단위 집계 뷰)이다. 루프가 매 시작마다 monitor를 띄우므로 재기동은
37
+ // 멱등이어야 한다(멱등 재시도≠실패 — agent-cli-contract). 단 **같은 보드만
38
+ // 재사용한다** — 다른 프로젝트의 모니터를 가로채던 결함(2026-09-17
39
+ // 접수)의 수습이다. 구버전(0.4.0) 응답은 신원이 없어 docRoot=null.
40
+ function probeExistingMonitor(port) {
41
+ const get = apiPath => new Promise(resolve => {
42
+ const req = http.get({ host: '127.0.0.1', port, path: apiPath, timeout: 1500 }, res => {
43
+ let body = '';
44
+ res.setEncoding('utf8');
45
+ res.on('data', d => { body += d; });
46
+ res.on('end', () => {
47
+ try { resolve(JSON.parse(body)); } catch { resolve(null); }
48
+ });
49
+ });
50
+ req.on('error', () => resolve(null));
51
+ req.on('timeout', () => { req.destroy(); resolve(null); });
52
+ });
53
+ return (async () => {
54
+ const board = await get('/api/board');
55
+ if (board && board.kind === 'kanban-board') {
56
+ return { state: 'monitor', docRoot: board.repo && board.repo.docRoot, name: board.repo && board.repo.name };
57
+ }
58
+ const fleet = await get('/api/fleet');
59
+ if (fleet && fleet.kind === 'kanban-fleet') return { state: 'fleet' };
60
+ // 응답은 있지만 우리 API가 아니다 — 남의 프로세스.
61
+ return { state: (board === null && fleet === null) ? 'none' : 'foreign' };
62
+ })();
63
+ }
64
+
65
+ function reusePrint(port) {
66
+ console.log(`http://127.0.0.1:${port}`);
67
+ console.log('이미 떠 있다 — 이 보드의 모니터를 재사용한다 (멱등 재시도).');
68
+ }
69
+
70
+ function startMonitorServer(ctx, port) {
71
+ const server = http.createServer((req, res) => {
72
+ try {
73
+ handle(req, res, ctx);
74
+ } catch (e) {
75
+ if (!res.headersSent) {
76
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
77
+ }
78
+ res.end(`보드를 읽다 실패했다: ${e.message}\n`);
79
+ }
80
+ });
81
+ server.on('error', err => {
82
+ if (err.code === 'EADDRINUSE') {
83
+ console.error(`✗ 포트 ${port}가 이미 쓰이고 있다 — --port <다른 번호> 또는 기본 실행(빈 포트 자동 탐색).`);
84
+ } else {
85
+ console.error(`✗ 서버 오류: ${err.message}`);
86
+ }
87
+ process.exit(1);
88
+ });
89
+ server.listen(port, '127.0.0.1', () => {
90
+ const actual = server.address().port;
91
+ console.log(`http://127.0.0.1:${actual}`);
92
+ console.log(`읽기 전용 모니터 — 정본은 ${ctx.paths.kanbanDir} 의 카드 파일, 쓰는 건 CLI뿐이다. 종료는 Ctrl+C.`);
93
+ });
94
+ return server;
95
+ }
96
+
97
+ // ── 플릿(monitor --all) — 기계 단위 집계 뷰 ────────────────────────────────
98
+ // 프로젝트 목록은 기계 등록부(auto-update의 projects.json)에서. 각 타일의 상태는
99
+ // 그 프로젝트의 doc/kanban 파일을 직접 읽어 유도한다 — 서버끼리 통신하는 배관
100
+ // 없음(파일이 정본). /p/<slug>/ 는 단일 보드 페이지·API를 그 프로젝트 경로로.
101
+ function startFleetServer(port) {
102
+ const server = http.createServer((req, res) => {
103
+ try {
104
+ handleFleet(req, res);
105
+ } catch (e) {
106
+ if (!res.headersSent) {
107
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
108
+ }
109
+ res.end(`플릿을 읽다 실패했다: ${e.message}\n`);
110
+ }
111
+ });
112
+ server.on('error', err => {
113
+ if (err.code === 'EADDRINUSE') {
114
+ console.error(`✗ 포트 ${port}가 이미 쓰이고 있다 — --port <다른 번호> 또는 기본 실행(빈 포트 자동 탐색).`);
115
+ } else {
116
+ console.error(`✗ 서버 오류: ${err.message}`);
117
+ }
118
+ process.exit(1);
119
+ });
120
+ server.listen(port, '127.0.0.1', () => {
121
+ const actual = server.address().port;
122
+ console.log(`http://127.0.0.1:${actual}`);
123
+ console.log('플릿 — 이 머신의 모든 llm-wiki 프로젝트를 한 페이지로. 종료는 Ctrl+C.');
124
+ });
125
+ return server;
126
+ }
127
+
128
+ // 등록부 + 고유 슬러그(동명 리포는 -2 접미).
129
+ function fleetProjects() {
130
+ const seen = new Set();
131
+ return readProjects().map(p => {
132
+ let slug = kanban.slugify(p.name || 'project');
133
+ if (seen.has(slug)) {
134
+ let i = 2;
135
+ while (seen.has(`${slug}-${i}`)) i += 1;
136
+ slug = `${slug}-${i}`;
137
+ }
138
+ seen.add(slug);
139
+ return { ...p, slug };
140
+ });
141
+ }
142
+
143
+ function fleetSummary() {
144
+ const projects = fleetProjects().map(p => {
145
+ try {
146
+ const { view, paths } = collectBoardAt(p.docRoot);
147
+ const activity = kanban.readActivity(paths);
148
+ const last = activity[activity.length - 1];
149
+ return {
150
+ // 이름은 등록부 우선(recordProject가 projectName|리포명으로 기록) — 보드
151
+ // 뷰의 재계산과 어긋나는 환경(오버라이드 루트)에서도 타일이 흔들리지 않게.
152
+ name: p.name || view.repo.name,
153
+ docRoot: p.docRoot,
154
+ slug: p.slug,
155
+ wip: view.wip,
156
+ counts: {
157
+ todo: view.columns.todo.length,
158
+ doing: view.columns.doing.length,
159
+ review: view.columns.review.length,
160
+ },
161
+ milestones: view.milestones.map(m => ({
162
+ title: m.title,
163
+ status: m.status,
164
+ finished: m.total - m.counts.active,
165
+ total: m.total,
166
+ })),
167
+ terminal: view.terminal,
168
+ lastActivity: last ? last.ts : null,
169
+ };
170
+ } catch {
171
+ return null; // 읽다 실패한 프로젝트는 타일에서만 빠진다 — 서버는 산다
172
+ }
173
+ }).filter(Boolean);
174
+ return { schemaVersion: 1, kind: 'kanban-fleet', generatedAt: new Date().toISOString(), projects };
175
+ }
176
+
177
+ function handleFleet(req, res) {
178
+ if (req.method !== 'GET') {
179
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET' });
180
+ res.end('카드 쓰기는 CLI뿐이다 — 이 서버는 읽기 전용이다.\n');
181
+ return;
182
+ }
183
+ const url = new URL(req.url, 'http://127.0.0.1');
184
+ const noStore = { 'cache-control': 'no-store' };
185
+ if (url.pathname === '/' || url.pathname === '') {
186
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...noStore });
187
+ res.end(renderFleetPage());
188
+ } else if (url.pathname === '/api/fleet') {
189
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', ...noStore });
190
+ res.end(JSON.stringify(fleetSummary()));
191
+ } else {
192
+ const m = url.pathname.match(/^\/p\/([^/]+)(\/.*)?$/);
193
+ if (m) {
194
+ const entry = fleetProjects().find(p => p.slug === decodeURIComponent(m[1]));
195
+ if (!entry) {
196
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
197
+ res.end('등록부에 없는 프로젝트다 — /api/fleet 목록을 보라.\n');
198
+ return;
199
+ }
200
+ const paths = kanban.kanbanPaths(entry.docRoot);
201
+ const rest = m[2] || '/';
202
+ // /p/<slug>/ → 보드 페이지, /p/<slug>/api/… → 그 보드의 API(같은 처리기).
203
+ serveBoard(req, res, { docRoot: entry.docRoot, paths }, new URL(rest + url.search, 'http://127.0.0.1'));
204
+ return;
205
+ }
206
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
207
+ res.end('없는 경로다 — / · /api/fleet · /p/<프로젝트>/.\n');
208
+ }
209
+ }
210
+
211
+ async function monitor(options = {}) {
212
+ const flags = { ...parseArgs(options.rest || []).flags };
213
+ if (options.json) flags.json = true;
214
+ validateFlags(flags, MONITOR_FLAGS);
215
+
216
+ const port = flags.port !== undefined ? Number(flags.port) : DEFAULT_PORT;
217
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
218
+ fail(`--port 값이 잘못됐다: ${flags.port} — 0~65535의 정수(0은 임시 포트 할당). ${USAGE.monitor}`);
219
+ }
220
+
221
+ // 시작 시 한 번 검증한다 — requireBoard의 fail은 process.exit(1)이라 요청
222
+ // 도중에 돌리면 서버가 그대로 죽는다. 이후 요청에서 보드가 사라지면 500.
223
+ const { docRoot, paths, config } = requireBoard();
224
+ const ctx = { docRoot, paths };
225
+ // 기계 등록부 — 이 프로젝트를 플릿(--all)이 발견하게 한다. 능동 명령이
226
+ // 기록하는 것과 같은 파이프(maybeAutoUpdate → recordProject).
227
+ recordProject(docRoot, config);
228
+
229
+ // --all — 기계 단위 집계 뷰(플릿). 재사용은 "다른 플릿"만: 어느 프로젝트가
230
+ // 띄웠든 플릿은 하나면 충분하다.
231
+ if (flags.all) {
232
+ if (port === 0) {
233
+ startFleetServer(0);
234
+ return;
235
+ }
236
+ if (flags.port !== undefined) {
237
+ const found = await probeExistingMonitor(port);
238
+ if (found.state === 'fleet') {
239
+ reusePrint(port);
240
+ return;
241
+ }
242
+ if (found.state === 'monitor') {
243
+ fail(`포트 ${port}는 보드 모니터가 쓰고 있다(${found.name || '신원 없음'}) — 플릿은 기본 실행(빈 포트 자동 탐색)이나 다른 포트로 띄워라.`);
244
+ }
245
+ startFleetServer(port);
246
+ return;
247
+ }
248
+ const base = basePort();
249
+ for (let p = base; p < base + PORT_SPAN; p++) {
250
+ const found = await probeExistingMonitor(p);
251
+ if (found.state === 'none') { startFleetServer(p); return; }
252
+ if (found.state === 'fleet') { reusePrint(p); return; }
253
+ }
254
+ fail(`${base}–${base + PORT_SPAN - 1}에 빈 포트가 없다 — --port <번호>로 직접 지정하라.`);
255
+ }
256
+
257
+ // 임시 포트(0)는 검사·재사용이 무의미하다 — 그냥 듣는다.
258
+ if (port === 0) {
259
+ startMonitorServer(ctx, 0);
260
+ return;
261
+ }
262
+
263
+ // --port 명시는 엄격하다: 같은 보드의 모니터면 재사용(exit 0), 다른 프로젝트
264
+ // 소유면 이름을 말하며 실패 — 조용히 남의 보드로 안내하는 게 이 결함의 본체였다.
265
+ if (flags.port !== undefined) {
266
+ const found = await probeExistingMonitor(port);
267
+ if (found.state === 'monitor') {
268
+ if (found.docRoot === docRoot) {
269
+ reusePrint(port);
270
+ return;
271
+ }
272
+ const who = found.name || found.docRoot || '신원 없음(구버전 monitor)';
273
+ fail(`포트 ${port}는 다른 프로젝트의 모니터가 쓰고 있다(${who}) — 같은 포트 재사용은 같은 보드만 된다. 기본 실행(빈 포트 자동 탐색)이나 --port <다른 번호>로 띄워라.`);
274
+ }
275
+ if (found.state === 'fleet') {
276
+ fail(`포트 ${port}는 플릿(monitor --all)이 쓰고 있다 — 보드 모니터는 기본 실행이나 다른 포트로 띄워라.`);
277
+ }
278
+ // foreign/none — bind 시도. foreign이면 EADDRINUSE 핸들러가 안내한다.
279
+ startMonitorServer(ctx, port);
280
+ return;
281
+ }
282
+
283
+ // 기본 실행 — 시작 포트부터 차례로: 같은 보드의 모니터면 재사용, 다른
284
+ // 프로젝트·플릿·타 프로세스면 건너뛴다. 멀티 프로젝트 밤 시나리오(각 루프가 monitor를
285
+ // 띄움)에서 각자의 보드가 각자의 포트에 뜬다.
286
+ const base = basePort();
287
+ for (let p = base; p < base + PORT_SPAN; p++) {
288
+ const found = await probeExistingMonitor(p);
289
+ if (found.state === 'none') {
290
+ startMonitorServer(ctx, p);
291
+ return;
292
+ }
293
+ if (found.state === 'monitor' && found.docRoot === docRoot) {
294
+ reusePrint(p);
295
+ return;
296
+ }
297
+ }
298
+ fail(`${base}–${base + PORT_SPAN - 1}에 빈 포트가 없다 — --port <번호>로 직접 지정하라.`);
299
+ }
300
+
301
+ function handle(req, res, ctx) {
302
+ serveBoard(req, res, ctx, new URL(req.url, 'http://127.0.0.1'));
303
+ }
304
+
305
+ // 단일 보드의 페이지+API — 플릿(/p/<slug>/…)도 프로젝트 경로와 (필요하면 다시
306
+ // 쓴 URL과 함께) 같은 처리기를 재사용한다. 상대 fetch 덕에 페이지 코드는 한 벌.
307
+ function serveBoard(req, res, ctx, url) {
308
+ const paths = ctx.paths;
309
+ if (req.method !== 'GET') {
310
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET' });
311
+ res.end('카드 쓰기는 CLI뿐이다 — 이 서버는 읽기 전용이다.\n');
312
+ return;
313
+ }
314
+ const noStore = { 'cache-control': 'no-store' };
315
+ if (url.pathname === '/' || url.pathname === '') {
316
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...noStore });
317
+ res.end(renderMonitorPage());
318
+ } else if (url.pathname === '/api/board') {
319
+ // 이 보드의 것만 — 플릿 경로로도 ctx.docRoot가 그 프로젝트를 가리킨다.
320
+ const { view } = collectBoardAt(ctx.docRoot);
321
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', ...noStore });
322
+ res.end(JSON.stringify(view));
323
+ } else if (url.pathname === '/api/activity') {
324
+ // 최근 50건만 — readActivity 자체가 ACTIVITY_CAP(10000줄)으로 봉인돼 있다.
325
+ const events = kanban.readActivity(paths).slice(-50);
326
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', ...noStore });
327
+ res.end(JSON.stringify({ schemaVersion: 1, kind: 'kanban-activity', events }));
328
+ } else if (url.pathname === '/api/card') {
329
+ // 카드 본문 — 제목은 파일명이 아니라 파싱된 meta.title과 비교한다
330
+ // (findCard). 경로로 쓰지 않으니 조회 키일 뿐이다. 종결 카드도 같이 찾는다.
331
+ const title = url.searchParams.get('title');
332
+ if (!title) {
333
+ res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
334
+ res.end('title 쿼리가 필요하다 — /api/card?title=<카드 제목>\n');
335
+ return;
336
+ }
337
+ const cardObj = kanban.findCard(paths, title);
338
+ if (!cardObj) {
339
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
340
+ res.end(`카드를 못 찾았다: ${title}\n`);
341
+ return;
342
+ }
343
+ // Goal·AC는 센티넬 블록으로 파싱돼 card.goal/card.ac에 있고 sections는
344
+ // 나머지(Plan/Notes/Handoff/Result)다 — 파일 순서(Goal → AC → 나머지)로 합친다.
345
+ const sections = {};
346
+ if (cardObj.card.goal && cardObj.card.goal.trim()) sections.Goal = cardObj.card.goal.trim();
347
+ if (cardObj.card.ac.length) {
348
+ sections['Acceptance Criteria'] = cardObj.card.ac
349
+ .map(a => (a.checked ? '- [x] ' : '- [ ] ') + '#' + a.idx + ' ' + a.text)
350
+ .join('\n');
351
+ }
352
+ for (const [name, bodyText] of cardObj.card.sections) {
353
+ if (bodyText && bodyText.trim()) sections[name] = bodyText;
354
+ }
355
+ const payload = {
356
+ schemaVersion: 1,
357
+ kind: 'kanban-card',
358
+ title: cardObj.meta.title,
359
+ meta: cardObj.meta,
360
+ sections,
361
+ };
362
+ // 마일스톤 상세는 멤버 목록까지 유도해 실는다 — 회고 브라우징의 뼈대(3.8):
363
+ // 대의(Goal)를 읽고 멤버를 따라 들어간다.
364
+ if (cardObj.meta.kind === 'milestone') {
365
+ payload.members = kanban.listAllCards(paths)
366
+ .filter(c => c.meta.milestone === cardObj.meta.title)
367
+ .map(c => ({ title: c.meta.title, status: c.meta.status, claimedBy: c.meta.claimed_by || null }));
368
+ }
369
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', ...noStore });
370
+ res.end(JSON.stringify(payload));
371
+ } else {
372
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
373
+ res.end('없는 경로다 — / · /api/board · /api/activity · /api/card?title=….\n');
374
+ }
375
+ }
376
+
377
+ // ── 페이지 — 정적 골격 + DOM API 데이터 바인딩 ─────────────────────────────
378
+ // 카드 제목·질문은 에이전트가 쓴 문자열이다 — innerHTML로 심지 않고
379
+ // textContent로만 넣는다(주입 차단). 골격 문자열에는 데이터가 없다.
380
+ function renderMonitorPage() {
381
+ return `<!doctype html>
382
+ <html lang="ko">
383
+ <head>
384
+ <meta charset="utf-8">
385
+ <title>llm-wiki monitor</title>
386
+ <style>
387
+ body { font-family: -apple-system, 'Segoe UI', 'Malgun Gothic', sans-serif; margin: 20px;
388
+ background: #0f141a; color: #d7dee6; }
389
+ header h1 { font-size: 18px; margin: 0 0 4px; }
390
+ header p { margin: 0 0 16px; color: #7f8b98; font-size: 13px; }
391
+ header p.off { color: #ff8f8f; }
392
+ main { display: grid; grid-template-columns: repeat(5, minmax(180px, 1fr)); gap: 12px; align-items: start; }
393
+ section { background: #161d26; border: 1px solid #232d3a; border-radius: 10px; padding: 10px; }
394
+ h2 { font-size: 12px; margin: 0 0 8px; text-transform: uppercase; letter-spacing: .05em;
395
+ color: #8a97a3; font-weight: 600; }
396
+ .count { color: #5b6a78; font-weight: 400; }
397
+ .card { background: #1c2531; border: 1px solid #2a3543; border-radius: 8px;
398
+ padding: 8px 10px; margin-bottom: 8px; }
399
+ .card[data-title] { cursor: pointer; }
400
+ .card[data-title]:hover { border-color: #48596d; }
401
+ .act[data-title] { cursor: pointer; }
402
+ .act[data-title]:hover { color: #d7dee6; }
403
+ #modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }
404
+ #modal[hidden] { display: none; }
405
+ #modal-backdrop { position: absolute; inset: 0; background: rgba(4, 8, 12, .72); }
406
+ #modal-panel { position: relative; background: #161d26; border: 1px solid #2a3543;
407
+ border-radius: 10px; width: min(720px, calc(100vw - 48px));
408
+ max-height: calc(100vh - 96px); overflow: auto; padding: 16px 20px; }
409
+ #modal-panel header { display: flex; align-items: baseline; gap: 12px; }
410
+ #modal-title { font-size: 16px; margin: 0; flex: 1; }
411
+ #modal-close { background: none; border: 1px solid #2a3543; color: #9fb0c0;
412
+ border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 12px; }
413
+ #modal-close:hover { color: #d7dee6; border-color: #48596d; }
414
+ #modal-meta { color: #8a97a3; font-size: 12px; margin: 6px 0 12px; }
415
+ #modal-body h4 { font-size: 11px; margin: 14px 0 4px; text-transform: uppercase;
416
+ letter-spacing: .05em; color: #8a97a3; }
417
+ #modal-body .sec { white-space: pre-wrap; font-size: 13px; line-height: 1.55;
418
+ color: #c6d0da; }
419
+ .card .t { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
420
+ .q { font-size: 12px; color: #9fb0c0; margin: 2px 0 4px; }
421
+ .badge { display: inline-block; font-size: 11px; background: #26313f; color: #a9b7c4;
422
+ border-radius: 999px; padding: 2px 8px; margin: 2px 4px 0 0; }
423
+ .badge.warn { background: #4a2f14; color: #ffb45e; }
424
+ .badge.gate { background: #16324f; color: #7cb6ff; }
425
+ .badge.t-done { background: #12331f; color: #6ee7a0; }
426
+ .badge.t-superseded { background: #2b1f4d; color: #c9a6ff; }
427
+ .badge.t-abandoned { background: #47201f; color: #ff8f8f; }
428
+ .act { font-size: 12px; padding: 4px 2px; border-bottom: 1px solid #1c2531; color: #9fb0c0; }
429
+ .who { color: #8a97a3; }
430
+ .act .a-done { color: #6ee7a0; } .act .a-abandoned { color: #ff8f8f; }
431
+ .act .a-claimed { color: #7cb6ff; } .act .a-handoff { color: #ffb45e; }
432
+ .act .a-milestone-done { color: #6ee7a0; font-weight: 600; }
433
+ .empty { color: #55616d; font-size: 12px; }
434
+ #milestones { background: #161d26; border: 1px solid #232d3a; border-radius: 10px;
435
+ padding: 10px; margin-bottom: 12px; }
436
+ #milestones[hidden] { display: none; }
437
+ .ms { border: 1px solid #2a3543; border-radius: 8px; padding: 8px 10px;
438
+ margin-bottom: 8px; background: #1c2531; }
439
+ .ms.done { opacity: .82; }
440
+ .ms-head { display: flex; align-items: center; gap: 10px; cursor: pointer; }
441
+ .ms-title { font-size: 13px; font-weight: 600; max-width: 34%; overflow: hidden;
442
+ text-overflow: ellipsis; white-space: nowrap; }
443
+ .ms-bar { flex: 1; height: 6px; background: #10161d; border-radius: 999px; overflow: hidden; }
444
+ .ms-bar-fill { height: 100%; background: linear-gradient(90deg, #2f6f4f, #6ee7a0); }
445
+ .ms-count { font-size: 12px; color: #8a97a3; white-space: nowrap; }
446
+ .ms-members { margin-top: 6px; }
447
+ .ms-member { font-size: 12px; padding: 3px 0; cursor: pointer; }
448
+ .ms-member:hover { color: #d7dee6; }
449
+ .badge.st-doing { background: #16324f; color: #7cb6ff; }
450
+ .badge.st-review { background: #4a2f14; color: #ffb45e; }
451
+ .badge.st-todo { background: #26313f; color: #a9b7c4; }
452
+ .badge.ms-tag { background: #233043; color: #9fb0c0; font-size: 10px; }
453
+ </style>
454
+ </head>
455
+ <body>
456
+ <header>
457
+ <h1>llm-wiki monitor <span id="project" class="who"></span></h1>
458
+ <p id="summary">불러오는 중…</p>
459
+ </header>
460
+ <section id="milestones" hidden>
461
+ <h2>마일스톤 <span class="count" id="n-ms"></span></h2>
462
+ <div id="list-milestones"></div>
463
+ </section>
464
+ <main>
465
+ <section><h2>Doing <span class="count" id="n-doing"></span></h2><div id="list-doing"></div></section>
466
+ <section><h2>Review — 사람 판정 대기 <span class="count" id="n-review"></span></h2><div id="list-review"></div></section>
467
+ <section><h2>Todo <span class="count" id="n-todo"></span></h2><div id="list-todo"></div></section>
468
+ <section><h2>종결 <span class="count" id="n-terminal"></span></h2><div id="list-terminal"></div></section>
469
+ <section><h2>활동 <span class="count" id="n-act"></span></h2><div id="list-activity"></div></section>
470
+ </main>
471
+ <div id="modal" hidden>
472
+ <div id="modal-backdrop"></div>
473
+ <div id="modal-panel" role="dialog" aria-modal="true">
474
+ <header><h3 id="modal-title"></h3><button id="modal-close" type="button">닫기 (Esc)</button></header>
475
+ <div id="modal-meta"></div>
476
+ <div id="modal-body"></div>
477
+ </div>
478
+ </div>
479
+ <script>
480
+ 'use strict';
481
+ const ACTION_LABEL = { created: '생성', claimed: '집김', done: '완료', handoff: '판정 대기',
482
+ abandoned: '폐기', superseded: '대체', reopened: '재검토', resumed: '복귀', edited: '편집',
483
+ reverted: '되돌림', 'milestone-done': '계획 완료', 'milestone-reverted': '계획 되돌림' };
484
+ const el = (tag, cls, text) => {
485
+ const n = document.createElement(tag);
486
+ if (cls) n.className = cls;
487
+ if (text !== undefined) n.textContent = text;
488
+ return n;
489
+ };
490
+ const fmtElapsed = ms => {
491
+ const m = Math.floor(ms / 60000);
492
+ return m < 60 ? m + '분' : Math.floor(m / 60) + '시간 ' + (m % 60) + '분';
493
+ };
494
+ const fmtClock = ts => new Date(ts).toLocaleTimeString('ko-KR', { hour12: false });
495
+ const fmtDayClock = ts => {
496
+ const d = new Date(ts);
497
+ const pad = n => String(n).padStart(2, '0');
498
+ return (d.getMonth() + 1) + '월 ' + d.getDate() + '일 ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
499
+ };
500
+ const TERMINAL_LABEL = { done: '완료', superseded: '대체', abandoned: '폐기' };
501
+ const STATUS_LABEL = { todo: '할 일', doing: '진행 중', review: '판정 대기', done: '완료', superseded: '대체', abandoned: '폐기' };
502
+ // 클릭 가능 카드 — data-title이 상세 보기 조회 키다(이벤트 위임 한 곳에서 처리).
503
+ const cardEl = title => {
504
+ const n = el('div', 'card');
505
+ n.dataset.title = title;
506
+ return n;
507
+ };
508
+
509
+ function renderBoard(view) {
510
+ renderMilestones(view.milestones || []);
511
+ const resolved = new Set(view.resolved);
512
+ const today = new Date().toISOString().slice(0, 10);
513
+
514
+ const doing = document.getElementById('list-doing');
515
+ doing.replaceChildren(
516
+ ...view.columns.doing.map(c => {
517
+ const card = cardEl(c.title);
518
+ card.append(el('div', 't', c.title));
519
+ const meta = el('div', 'meta');
520
+ if (c.claimExpired) {
521
+ meta.append(el('span', 'badge warn', '클레임 만료 — 재집기 가능'));
522
+ } else if (c.claimedBy) {
523
+ let label = c.claimedBy;
524
+ const ms = c.claimedAt ? Date.now() - Date.parse(c.claimedAt) : NaN;
525
+ if (Number.isFinite(ms) && ms >= 0) label += ' · ' + fmtElapsed(ms);
526
+ meta.append(el('span', 'badge', label));
527
+ }
528
+ card.append(meta);
529
+ return card;
530
+ })
531
+ );
532
+ if (!view.columns.doing.length) doing.append(el('div', 'empty', '비어 있음'));
533
+ document.getElementById('n-doing').textContent = view.wip.doing.used + '/' + view.wip.doing.limit;
534
+
535
+ const review = document.getElementById('list-review');
536
+ review.replaceChildren(
537
+ ...view.columns.review.flatMap(c => {
538
+ const card = cardEl(c.title);
539
+ card.append(el('div', 't', c.title));
540
+ if (c.question) card.append(el('div', 'q', '↳ ' + c.question));
541
+ return [card];
542
+ })
543
+ );
544
+ if (!view.columns.review.length) review.append(el('div', 'empty', '비어 있음'));
545
+ document.getElementById('n-review').textContent = view.columns.review.length;
546
+
547
+ const todo = document.getElementById('list-todo');
548
+ todo.replaceChildren(
549
+ ...view.columns.todo.map(c => {
550
+ const card = cardEl(c.title);
551
+ card.append(el('div', 't', c.title));
552
+ const meta = el('div', 'meta');
553
+ if (c.notBefore && c.notBefore > today) meta.append(el('span', 'badge gate', '시작 예정 ' + c.notBefore));
554
+ const unmet = c.dependsOn.filter(d => !resolved.has(d));
555
+ if (unmet.length) meta.append(el('span', 'badge gate', '의존 대기: ' + unmet.join(', ')));
556
+ card.append(meta);
557
+ return card;
558
+ })
559
+ );
560
+ if (!view.columns.todo.length) todo.append(el('div', 'empty', '비어 있음'));
561
+ document.getElementById('n-todo').textContent = view.columns.todo.length;
562
+
563
+ // 종결 적체 — 완료가 쌓이는 모습이 수렴의 증거다(3.2). 최근 8건, 새 것부터.
564
+ const terminal = document.getElementById('list-terminal');
565
+ terminal.replaceChildren(
566
+ ...(view.terminalRecent || []).map(t => {
567
+ const card = cardEl(t.title);
568
+ card.append(el('div', 't', t.title));
569
+ const meta = el('div', 'meta');
570
+ meta.append(el('span', 'badge t-' + t.kind, TERMINAL_LABEL[t.kind] || t.kind));
571
+ if (t.milestone) meta.append(el('span', 'badge ms-tag', t.milestone));
572
+ if (t.at) meta.append(el('span', 'who', ' ' + fmtDayClock(t.at)));
573
+ card.append(meta);
574
+ return card;
575
+ })
576
+ );
577
+ if (!(view.terminalRecent || []).length) terminal.append(el('div', 'empty', '비어 있음'));
578
+ document.getElementById('n-terminal').textContent = '최근 ' + (view.terminalRecent || []).length;
579
+ }
580
+
581
+ // ── 마일스톤 패널 — 목적 축(3.8). 진행 중은 펼쳐 멤버를 보이고(2026-09-16 확정),
582
+ // 완료는 접힌다. 제목·멤버 클릭은 상세 모달로(위임 핸들러가 잡는다).
583
+ const MEMBER_RANK = { doing: 0, review: 1, todo: 2, done: 3, superseded: 4, abandoned: 5 };
584
+ const memberBadgeClass = status =>
585
+ ['done', 'superseded', 'abandoned'].includes(status) ? 't-' + status : 'st-' + status;
586
+
587
+ function renderMilestones(list) {
588
+ const panel = document.getElementById('milestones');
589
+ const container = document.getElementById('list-milestones');
590
+ panel.hidden = !list.length;
591
+ document.getElementById('n-ms').textContent = list.length;
592
+ if (!list.length) { container.replaceChildren(); return; }
593
+ container.replaceChildren(...list.map(ms => {
594
+ const isActive = ['todo', 'doing', 'review'].includes(ms.status);
595
+ const finished = ms.total - (ms.counts.active || 0);
596
+ const box = el('div', 'ms' + (isActive ? '' : ' done'));
597
+ const head = el('div', 'ms-head');
598
+ head.dataset.title = ms.title;
599
+ head.append(el('span', 'ms-title', (isActive ? '' : '✓ ') + ms.title));
600
+ const barWrap = el('div', 'ms-bar');
601
+ const bar = el('div', 'ms-bar-fill');
602
+ bar.style.width = (ms.total ? Math.round(finished / ms.total * 100) : 0) + '%';
603
+ barWrap.append(bar);
604
+ head.append(barWrap, el('span', 'ms-count', finished + '/' + ms.total));
605
+ if (ms.completedAt) head.append(el('span', 'who', ' ' + fmtDayClock(ms.completedAt)));
606
+ box.append(head);
607
+ if (isActive) {
608
+ const members = el('div', 'ms-members');
609
+ const sorted = [...ms.members].sort((a, b) => (MEMBER_RANK[a.status] ?? 9) - (MEMBER_RANK[b.status] ?? 9));
610
+ for (const mem of sorted) {
611
+ const row = el('div', 'ms-member');
612
+ row.dataset.title = mem.title;
613
+ row.append(el('span', 'badge ' + memberBadgeClass(mem.status), STATUS_LABEL[mem.status] || mem.status));
614
+ row.append(document.createTextNode(' ' + mem.title));
615
+ members.append(row);
616
+ }
617
+ if (!sorted.length) members.append(el('div', 'empty', '멤버 없음 — card new --milestone로 붙여라'));
618
+ box.append(members);
619
+ }
620
+ return box;
621
+ }));
622
+ }
623
+
624
+ function renderActivity(payload) {
625
+ const list = document.getElementById('list-activity');
626
+ const newest = [...payload.events].reverse();
627
+ list.replaceChildren(
628
+ ...newest.map(ev => {
629
+ const row = el('div', 'act');
630
+ if (ev.title) row.dataset.title = ev.title;
631
+ const label = ACTION_LABEL[ev.action] || ev.action;
632
+ row.append(el('span', 'a-' + ev.action, label));
633
+ row.append(document.createTextNode(' ' + (ev.title || '')));
634
+ if (ev.actor) row.append(el('span', 'who', ' — ' + ev.actor));
635
+ row.append(el('div', 'who', fmtClock(ev.ts)));
636
+ return row;
637
+ })
638
+ );
639
+ if (!newest.length) list.append(el('div', 'empty', '활동 없음'));
640
+ document.getElementById('n-act').textContent = newest.length;
641
+ }
642
+
643
+ // 직전 응답과 같으면 DOM을 다시 짓지 않는다 — 2초마다 replaceChildren하면
644
+ // 클릭 순간 노드가 교체돼 클릭이 씹힌다(2026-09-16 실측: 자동 클릭이
645
+ // actionability 대기에서 타임아웃). 렌더는 변화에만 반응한다. generatedAt는
646
+ // 매 응답마다 바뀌므로 비교 서명에서 제외한다.
647
+ let lastBoardSig = '';
648
+ let lastActivitySig = '';
649
+ let lastBoard = null;
650
+
651
+ // 요약 줄은 매 틱 다시 쓴다 — '갱신' 시계가 폴링 생존 신호다(멈추면 죽은 것이다).
652
+ // 컬럼 DOM은 변화에만 다시 짓는다.
653
+ function updateSummary() {
654
+ const summary = document.getElementById('summary');
655
+ if (!lastBoard) return;
656
+ // 프로젝트 이름 — 여러 탭(멀티 프로젝트)을 구분하는 표지다.
657
+ const projName = lastBoard.repo && lastBoard.repo.name;
658
+ document.getElementById('project').textContent = projName ? '· ' + projName : '';
659
+ const t = lastBoard.terminal;
660
+ summary.className = '';
661
+ summary.textContent = 'WIP doing ' + lastBoard.wip.doing.used + '/' + lastBoard.wip.doing.limit +
662
+ ' · 종결: done ' + t.done + ' · superseded ' + t.superseded + ' · abandoned ' + t.abandoned +
663
+ ' · 갱신 ' + fmtClock(Date.now());
664
+ }
665
+
666
+ async function tick() {
667
+ const summary = document.getElementById('summary');
668
+ try {
669
+ const [b, a] = await Promise.all([fetch('api/board'), fetch('api/activity')]);
670
+ if (!b.ok || !a.ok) throw new Error('HTTP ' + b.status + '/' + a.status);
671
+ const board = await b.json();
672
+ const activity = await a.json();
673
+ const boardSig = JSON.stringify({ ...board, generatedAt: null });
674
+ const activitySig = JSON.stringify(activity);
675
+ if (boardSig !== lastBoardSig) {
676
+ lastBoardSig = boardSig;
677
+ lastBoard = board;
678
+ renderBoard(board);
679
+ }
680
+ if (activitySig !== lastActivitySig) {
681
+ lastActivitySig = activitySig;
682
+ renderActivity(activity);
683
+ }
684
+ updateSummary();
685
+ } catch (e) {
686
+ summary.className = 'off';
687
+ summary.textContent = '연결 끊김 — 서버가 죽었거나 보드를 읽을 수 없다 (' + e.message + ')';
688
+ }
689
+ }
690
+
691
+ // ── 카드 상세 보기 — 클릭된 제목의 카드 파일 본문을 읽어 온다 ────────────────
692
+ // 본문은 pre-wrap 텍스트로만 심는다(textContent) — 카드 내용은 에이전트가 쓴
693
+ // 문자열이라 마크다운 해석·innerHTML을 섞지 않는다. 조회 키(제목)도
694
+ // encodeURIComponent로 감싼다.
695
+ const modal = document.getElementById('modal');
696
+ function closeModal() { modal.hidden = true; }
697
+ document.getElementById('modal-close').addEventListener('click', closeModal);
698
+ document.getElementById('modal-backdrop').addEventListener('click', closeModal);
699
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
700
+
701
+ async function openCard(title) {
702
+ modal.hidden = false;
703
+ const t = document.getElementById('modal-title');
704
+ const metaEl = document.getElementById('modal-meta');
705
+ const body = document.getElementById('modal-body');
706
+ t.textContent = title;
707
+ metaEl.textContent = '';
708
+ body.replaceChildren(el('div', 'sec', '불러오는 중…'));
709
+ try {
710
+ const r = await fetch('api/card?title=' + encodeURIComponent(title));
711
+ if (!r.ok) throw new Error(r.status === 404 ? '카드를 못 찾았다 — 제목이 바뀌었거나 아직 안 만들어졌다' : 'HTTP ' + r.status);
712
+ const data = await r.json();
713
+ t.textContent = data.title;
714
+ const m = data.meta || {};
715
+ const bits = [STATUS_LABEL[m.status] || m.status];
716
+ if (m.claimed_by) bits.push('클레임 ' + m.claimed_by + (m.claimed_at ? ' (' + m.claimed_at + ')' : ''));
717
+ if (m.not_before) bits.push('시작 예정 ' + m.not_before);
718
+ if (Array.isArray(m.depends_on) && m.depends_on.length) bits.push('의존 ' + m.depends_on.join(', '));
719
+ if (m.created) bits.push('생성 ' + m.created);
720
+ metaEl.textContent = bits.join(' · ');
721
+ const nodes = Object.entries(data.sections || {})
722
+ .filter(([, text]) => text && text.trim())
723
+ .flatMap(([name, text]) => [el('h4', '', name), el('div', 'sec', text.trim())]);
724
+ // 마일스톤 상세 — 멤버 전원 목록(회고 브라우징). 행 클릭은 위임 핸들러가
725
+ // 다시 openCard로 보내 모달이 멤버 카드로 교체된다.
726
+ if (Array.isArray(data.members)) {
727
+ nodes.push(el('h4', '', '멤버 (' + data.members.length + ')'));
728
+ const memberList = el('div', 'ms-members');
729
+ const sorted = [...data.members].sort((a, b) => (MEMBER_RANK[a.status] ?? 9) - (MEMBER_RANK[b.status] ?? 9));
730
+ for (const mem of sorted) {
731
+ const row = el('div', 'ms-member');
732
+ row.dataset.title = mem.title;
733
+ row.append(el('span', 'badge ' + memberBadgeClass(mem.status), STATUS_LABEL[mem.status] || mem.status));
734
+ row.append(document.createTextNode(' ' + mem.title));
735
+ memberList.append(row);
736
+ }
737
+ if (!sorted.length) memberList.append(el('div', 'empty', '멤버 없음'));
738
+ nodes.push(memberList);
739
+ }
740
+ body.replaceChildren(...nodes);
741
+ if (!body.childElementCount) body.append(el('div', 'sec', '(비어 있는 섹션)'));
742
+ } catch (e) {
743
+ body.replaceChildren(el('div', 'sec', '카드를 못 읽었다: ' + e.message));
744
+ }
745
+ }
746
+
747
+ // 위임 한 곳 — 컬럼 카드·종결 카드·활동 행 어디를 눌러도 같은 규칙.
748
+ document.addEventListener('click', e => {
749
+ const hit = e.target.closest('[data-title]');
750
+ if (hit && hit.dataset.title) openCard(hit.dataset.title);
751
+ });
752
+
753
+ setInterval(tick, 2000);
754
+ tick();
755
+ </script>
756
+ </body>
757
+ </html>
758
+ `;
759
+ }
760
+
761
+ // ── 플릿 페이지 — 이 머신의 프로젝트 타일. 데이터는 textContent만. ───────────
762
+ function renderFleetPage() {
763
+ return `<!doctype html>
764
+ <html lang="ko">
765
+ <head>
766
+ <meta charset="utf-8">
767
+ <title>llm-wiki fleet</title>
768
+ <style>
769
+ body { font-family: -apple-system, 'Segoe UI', 'Malgun Gothic', sans-serif; margin: 20px;
770
+ background: #0f141a; color: #d7dee6; }
771
+ header h1 { font-size: 18px; margin: 0 0 4px; }
772
+ header p { margin: 0 0 16px; color: #7f8b98; font-size: 13px; }
773
+ main { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
774
+ a.tile { display: block; background: #161d26; border: 1px solid #232d3a; border-radius: 10px;
775
+ padding: 12px; text-decoration: none; color: #d7dee6; }
776
+ a.tile:hover { border-color: #48596d; }
777
+ .tile h2 { font-size: 15px; margin: 0 0 8px; }
778
+ .tile .line { font-size: 12px; color: #9fb0c0; margin: 3px 0; }
779
+ .fbar { height: 5px; background: #10161d; border-radius: 999px; overflow: hidden; margin: 4px 0; }
780
+ .fbar-fill { height: 100%; background: linear-gradient(90deg, #2f6f4f, #6ee7a0); }
781
+ .badge { display: inline-block; font-size: 11px; background: #26313f; color: #a9b7c4;
782
+ border-radius: 999px; padding: 2px 8px; margin: 2px 4px 0 0; }
783
+ .badge.warn { background: #4a2f14; color: #ffb45e; }
784
+ .who { color: #8a97a3; font-size: 11px; }
785
+ .empty { color: #55616d; font-size: 13px; }
786
+ </style>
787
+ </head>
788
+ <body>
789
+ <header>
790
+ <h1>llm-wiki fleet <span class="who">— 이 머신의 모든 프로젝트</span></h1>
791
+ <p id="summary">불러오는 중…</p>
792
+ </header>
793
+ <main id="tiles"></main>
794
+ <script>
795
+ 'use strict';
796
+ const el = (tag, cls, text) => {
797
+ const n = document.createElement(tag);
798
+ if (cls) n.className = cls;
799
+ if (text !== undefined) n.textContent = text;
800
+ return n;
801
+ };
802
+ const fmtDayClock = ts => {
803
+ const d = new Date(ts);
804
+ const pad = n => String(n).padStart(2, '0');
805
+ return (d.getMonth() + 1) + '월 ' + d.getDate() + '일 ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
806
+ };
807
+ let lastSig = '';
808
+ async function tick() {
809
+ const summary = document.getElementById('summary');
810
+ try {
811
+ const r = await fetch('api/fleet');
812
+ if (!r.ok) throw new Error('HTTP ' + r.status);
813
+ const fleet = await r.json();
814
+ const sig = JSON.stringify({ ...fleet, generatedAt: null });
815
+ summary.className = '';
816
+ summary.textContent = '프로젝트 ' + fleet.projects.length + ' · 갱신 ' + new Date().toLocaleTimeString('ko-KR', { hour12: false });
817
+ if (sig === lastSig) return; // 변화 없으면 DOM을 다시 짓지 않는다(클릭 보호)
818
+ lastSig = sig;
819
+ const tiles = document.getElementById('tiles');
820
+ tiles.replaceChildren(...fleet.projects.map(p => {
821
+ const a = el('a', 'tile');
822
+ a.href = 'p/' + encodeURIComponent(p.slug) + '/';
823
+ a.append(el('h2', '', p.name));
824
+ const status = el('div');
825
+ status.append(el('span', 'badge', 'doing ' + p.counts.doing + '/' + p.wip.doing.limit));
826
+ if (p.counts.review) status.append(el('span', 'badge warn', '판정 대기 ' + p.counts.review));
827
+ if (p.counts.todo) status.append(el('span', 'badge', 'todo ' + p.counts.todo));
828
+ a.append(status);
829
+ for (const m of (p.milestones || []).slice(0, 4)) {
830
+ const row = el('div', 'line', (['todo', 'doing', 'review'].includes(m.status) ? '• ' : '✓ ') + m.title + ' ' + m.finished + '/' + m.total);
831
+ const bar = el('div', 'fbar');
832
+ const fill = el('div', 'fbar-fill');
833
+ fill.style.width = (m.total ? Math.round(m.finished / m.total * 100) : 0) + '%';
834
+ bar.append(fill);
835
+ a.append(row, bar);
836
+ }
837
+ const t = p.terminal;
838
+ const foot = el('div', 'who', '종결 done ' + t.done + ' · superseded ' + t.superseded + ' · abandoned ' + t.abandoned +
839
+ (p.lastActivity ? ' · 마지막 활동 ' + fmtDayClock(p.lastActivity) : ''));
840
+ a.append(foot);
841
+ return a;
842
+ }));
843
+ if (!fleet.projects.length) tiles.append(el('div', 'empty', '등록된 프로젝트가 없다 — 각 프로젝트에서 llm-wiki 명령을 한 번씩 실행하면 등록부에 오른다.'));
844
+ } catch (e) {
845
+ summary.textContent = '연결 끊김 (' + e.message + ')';
846
+ }
847
+ }
848
+ setInterval(tick, 2000);
849
+ tick();
850
+ </script>
851
+ </body>
852
+ </html>
853
+ `;
854
+ }
855
+
856
+ module.exports = { monitor, renderMonitorPage, renderFleetPage };