@kybird/llm-wiki 0.4.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.
package/README.md CHANGED
@@ -49,7 +49,7 @@ That's it. The agent does the rest.
49
49
  | `llm-wiki lint` | Broken links, **evidence back-matching** (hash refs & `### Error` quotes must exist verbatim in `doc/raw/`), uncompiled concepts, metadata, staleness |
50
50
  | `llm-wiki board` / `board report` | Derived kanban view (text only, no flags) / dashboard (done:abandoned ratio, trend, QA reverts, waiting queue) |
51
51
  | `llm-wiki board video` | Replay `activity.jsonl` into a board timelapse MP4 (requires the `video/` Remotion project; CPU render, no GPU) |
52
- | `llm-wiki monitor [--port <n>]` | Live read-only board view at `http://127.0.0.1:<n>` (default 4747) — who claimed what and for how long (`zcode · 14분`), claim expiry, REVIEW questions, gates/dependency waits, the terminal pile (종결 적체 — newest done/superseded/abandoned with timestamps), and an activity stream. A **milestone panel** (purpose axis, plan.md 3.8) shows in-progress milestones expanded with their member cards and completed ones collapsed with progress — click a milestone to read its Goal (the plan's 대의) and full member list. Click any card (columns, terminal pile, activity rows) to read the full card file. Polls every 2s (re-renders only on change); the page renders agent-authored text via `textContent` only. Starting one while another monitor already listens is **idempotent** — exit 0, same URL (a port held by anything else fails with guidance). **The CLI stays the only writer** — every non-GET method gets a 405; the server keeps no state |
52
+ | `llm-wiki monitor [--port <n>]` | Live read-only board view at `http://127.0.0.1:<n>` (default 4747) — who claimed what and for how long (`zcode · 14분`), claim expiry, REVIEW questions, gates/dependency waits, the terminal pile (종결 적체 — newest done/superseded/abandoned with timestamps), and an activity stream. A **milestone panel** (purpose axis, plan.md 3.8) shows in-progress milestones expanded with their member cards and completed ones collapsed with progress — click a milestone to read its Goal (the plan's 대의) and full member list. Click any card (columns, terminal pile, activity rows) to read the full card file. Polls every 2s (re-renders only on change); the page renders agent-authored text via `textContent` only. Starting one while **the same board's** monitor already listens is **idempotent** — exit 0, same URL. A port held by **another project's** monitor: default execution walks to the next free port (4747→4748→…, each board gets its own URL; the header shows the project name); an explicit `--port` fails with the owner's name. **`--all`** starts a **fleet view** — every llm-wiki project on this machine as tiles (WIP, review queue, milestone progress, last activity) with each project's full board one click away; projects register themselves in a machine-local registry (`~/.llm-wiki/projects.json`) as they run commands. **The CLI stays the only writer** — every non-GET method gets a 405; the server keeps no state |
53
53
  | `llm-wiki card new/show/edit` | Create and edit cards — the CLI is the only writer (sentinel-safe sections). `--kind milestone` creates a milestone card (the plan's 대의 lives in its Goal; not pickable, auto-completes when all members terminate); `--milestone "<title>"` attaches a card to one — membership is frontmatter, milestone progress is derived, never stored (plan.md 3.8) |
54
54
  | `llm-wiki pick --claim <name> [--card <title>]` | Atomically claim the next eligible card (lock, WIP limit, dependencies, claim expiry). `--card` claims a specific card by title — every gate still applies: a blocked pick prints why and leaves all files untouched, an unknown title fails (exit 1) |
55
55
  | `llm-wiki handoff <title> --question "…"` | Park a card for human judgment and release the claim |
package/bin/llm-wiki.js CHANGED
@@ -35,9 +35,12 @@ Kanban (cards are files; CLI is the only writer):
35
35
  llm-wiki board Derived board view (columns, WIP, queue) — text only, no flags
36
36
  llm-wiki board report Dashboard (done:abandoned ratio, trend, reverts)
37
37
  llm-wiki board video Timelapse of board activity → MP4 (needs video/ project)
38
- llm-wiki monitor [--port <n>] Live read-only board view at http://127.0.0.1:<n>
39
- (default 4747) claims, elapsed, gates, activity;
40
- the CLI stays the only writer (405 on writes)
38
+ llm-wiki monitor [--port <n>] [--all] Live read-only board view at http://127.0.0.1:<n>
39
+ (default 4747, walks to the next free port past other
40
+ projects' monitors) claims, elapsed, gates, activity;
41
+ --all = fleet view of EVERY llm-wiki project on this
42
+ machine (tiles + per-project boards); the CLI stays the
43
+ only writer (405 on any write method)
41
44
  llm-wiki card new "<title>" Create card (--goal, --ac, --depends, --not-before;
42
45
  --kind milestone = milestone card, --milestone "<t>"
43
46
  attaches to one — plan-level grouping, plan.md 3.8)
@@ -50,6 +50,10 @@ function maybeAutoUpdate() {
50
50
  if (cfg.autoUpdate === false) return; // 옵트아웃 — init 재실행으로만 갱신
51
51
  if (process.env.LLM_WIKI_NO_AUTO_UPDATE === '1') return; // 일회성 끄기
52
52
 
53
+ // 기계의 프로젝트 등록부 — monitor --all(플릿)이 이 머신의 보드들을 아는
54
+ // 길이다. 버전 동기화와 무관하게 매번 갱신한다(마지막 본 시각 포함).
55
+ recordProject(docRoot, cfg);
56
+
53
57
  const sp = stampPath(root);
54
58
  if (readStamp(sp) === PKG_VERSION) return; // 이 버전으로 이미 동기화됨
55
59
 
@@ -76,4 +80,38 @@ function maybeAutoUpdate() {
76
80
  }
77
81
  }
78
82
 
79
- module.exports = { maybeAutoUpdate, stampPath };
83
+ // ── 기계의 프로젝트 등록부 (monitor --all 플릿의 발견 소스) ─────────────────
84
+ // <stateDir>/projects.json — { "<리포 절대경로>": { name, docRoot, lastSeen } }.
85
+ // 능동 명령(maybeAutoUpdate)과 monitor 시작이 매번 갱신한다. 플릿은 파일이
86
+ // 정본인 보드들을 직접 읽으므로, 이 등록부는 "어떤 프로젝트가 이 머신에 사나"의
87
+ // 유일한 목록이다 — 스탬프 해시는 경로를 못 되돌려서 이 용도로 못 쓴다.
88
+ function registryPath() {
89
+ return path.join(stateDir(), 'projects.json');
90
+ }
91
+
92
+ function recordProject(docRoot, cfg) {
93
+ try {
94
+ const root = path.dirname(path.resolve(docRoot));
95
+ const name = (cfg && cfg.projectName) || path.basename(root);
96
+ let data = {};
97
+ try { data = JSON.parse(fs.readFileSync(registryPath(), 'utf8')); } catch { /* 없음 — 첫 등록 */ }
98
+ data[root] = { name, docRoot: path.resolve(docRoot), lastSeen: new Date().toISOString() };
99
+ fs.mkdirSync(stateDir(), { recursive: true });
100
+ fs.writeFileSync(registryPath(), JSON.stringify(data, null, 2) + '\n');
101
+ } catch {
102
+ // 등록부는 부가 기능이다 — 실패가 명령을 막지 않는다(maybeAutoUpdate와 같은 규칙).
103
+ }
104
+ }
105
+
106
+ function readProjects() {
107
+ try {
108
+ const data = JSON.parse(fs.readFileSync(registryPath(), 'utf8'));
109
+ return Object.values(data)
110
+ .filter(p => p && p.docRoot && fs.existsSync(path.join(p.docRoot, 'kanban')))
111
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)));
112
+ } catch {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ module.exports = { maybeAutoUpdate, stampPath, recordProject, readProjects };
package/lib/kanban-cmd.js CHANGED
@@ -116,8 +116,7 @@ function fail(message) {
116
116
 
117
117
  // 보드가 스캐폴드돼 있어야 한다 — 없으면 init으로 안내. 에러는 실제 탐색 루트와
118
118
  // LLM_WIKI_ROOT 값을 함께 보고한다 — 오버라이드가 다른 곳을 보고 있었다는 단서.
119
- function requireBoard() {
120
- const docRoot = findDocRoot();
119
+ function requireBoardAt(docRoot) {
121
120
  const paths = kanban.kanbanPaths(docRoot);
122
121
  if (!fs.existsSync(paths.kanbanDir)) {
123
122
  fail(`doc/kanban/ 이 없다 (${paths.kanbanDir}${process.env.LLM_WIKI_ROOT ? ` — LLM_WIKI_ROOT=${process.env.LLM_WIKI_ROOT}` : ''}). 먼저 \`llm-wiki init\`을 실행하라.`);
@@ -125,6 +124,10 @@ function requireBoard() {
125
124
  return { docRoot, paths, config: kanban.loadConfig(paths) };
126
125
  }
127
126
 
127
+ function requireBoard() {
128
+ return requireBoardAt(findDocRoot());
129
+ }
130
+
128
131
  function nowIso() { return localIso(); }
129
132
 
130
133
  // ── depends_on 검증 (card new/edit 공용) ──────────────────────────────────
@@ -183,7 +186,7 @@ const CARD_EDIT_FLAGS = {
183
186
  ac: 'string', 'add-ac': 'string', 'check-ac': 'string',
184
187
  note: 'string', 'renew-claim': 'flag',
185
188
  depends: 'string', 'add-depends': 'string', 'remove-depends': 'string',
186
- milestone: 'string',
189
+ milestone: 'string', 'remove-milestone': 'flag',
187
190
  };
188
191
 
189
192
  // 각 명령의 사용법 문자열 — fail(인자 오류)과 bin의 --help 가드가 같은 문자열을
@@ -192,7 +195,7 @@ const CARD_EDIT_FLAGS = {
192
195
  const USAGE = {
193
196
  'card new': '사용법: llm-wiki card new "<제목>" [--goal …] [--ac …] [--depends a,b] [--not-before YYYY-MM-DD] [--kind milestone] [--milestone "<마일스톤 제목>"]',
194
197
  'card show': '사용법: llm-wiki card show <제목>',
195
- 'card edit': '사용법: llm-wiki card edit <제목> [--goal …] [--plan …] [--ac …] [--add-ac …] [--check-ac 1,2] [--note …] [--renew-claim] [--depends a,b] [--add-depends a,b] [--remove-depends a,b] [--milestone "<마일스톤 제목>"]',
198
+ 'card edit': '사용법: llm-wiki card edit <제목> [--goal …] [--plan …] [--ac …] [--add-ac …] [--check-ac 1,2] [--note …] [--renew-claim] [--depends a,b] [--add-depends a,b] [--remove-depends a,b] [--milestone "<마일스톤 제목>"] [--remove-milestone]',
196
199
  card: '사용법: llm-wiki card <new|show|edit> …',
197
200
  handoff: '사용법: llm-wiki handoff <제목> --question "…"',
198
201
  done: '사용법: llm-wiki done <제목> --result "…" — Result 없는 완료는 거부한다.',
@@ -201,7 +204,7 @@ const USAGE = {
201
204
  reopen: '사용법: llm-wiki reopen <제목> --why "…" — 되돌림 사유가 기록되지 않으면 QA가 아니다.',
202
205
  resume: '사용법: llm-wiki resume <제목> [--note "…"]',
203
206
  board: '사용법: llm-wiki board — 텍스트 유도 뷰(플래그 없음). 하위: board report [--json] 대시보드 · board video 타임랩스',
204
- monitor: '사용법: llm-wiki monitor [--port <번호>] — 읽기 전용 실시간 보드 뷰어(127.0.0.1, 기본 4747)',
207
+ monitor: '사용법: llm-wiki monitor [--port <번호>] [--all] — 읽기 전용 실시간 보드 뷰어(127.0.0.1, 기본 4747부터 빈 포트 탐색). --all은 이 머신 전체 프로젝트 집계(플릿)',
205
208
  pick: '사용법: llm-wiki pick [--claim <이름>] [--card "<제목>"] — --card 없으면 ordinal 최저를 집는다',
206
209
  };
207
210
 
@@ -232,6 +235,11 @@ function validateMilestoneTarget(paths, milestoneTitle, selfKind) {
232
235
  if (selfKind === 'milestone') {
233
236
  fail(`마일스톤 카드는 다른 마일스톤에 속할 수 없다(평면 — plan.md 3.8): ${milestoneTitle}`);
234
237
  }
238
+ // 종결 마일스톤은 회고 이력이다 — 산 멤버를 붙이면 "종결된 계획 아래 진행 중
239
+ // 카드"라는 썩은 상태가 되고, 자동 종결도 이미 종결 상태를 못 되돌아본다.
240
+ if (kanban.TERMINAL_FOLDERS.includes(target.meta.status)) {
241
+ fail(`종결된 마일스톤(${target.meta.status})에는 소속을 걸 수 없다: ${milestoneTitle} — 새 마일스톤을 만들거나 reopen으로 되돌려라.`);
242
+ }
235
243
  }
236
244
 
237
245
  function cardNew(paths, config, { positional, flags }) {
@@ -417,6 +425,18 @@ function cardEdit(paths, { positional, flags }) {
417
425
  changes.push(`milestone=${milestoneTitle}`);
418
426
  }
419
427
  }
428
+ // --remove-milestone — 잘못 건 소속 해제. 없는 걸 지우면 오타일 확률이 높다
429
+ // (--remove-depends와 같은 규칙). 종결 카드의 소속은 회고 이력이라 해제도 불허.
430
+ if (flags['remove-milestone']) {
431
+ if (!kanban.ACTIVE_STATUSES.includes(cardObj.meta.status)) {
432
+ fail(`종결 카드(${cardObj.meta.status})의 마일스톤은 바꾸지 않는다 — 회고 뷰가 읽는 이력이다: ${cardObj.filePath}`);
433
+ }
434
+ if (!meta.milestone) {
435
+ fail(`제거할 마일스톤 소속이 이 카드에 없다: ${title}`);
436
+ }
437
+ delete meta.milestone;
438
+ changes.push('milestone -해제');
439
+ }
420
440
 
421
441
  // --renew-claim — 긴 카드는 타임아웃 전에 클레임을 갱신한다 (루프 스킬 규칙 6).
422
442
  // 클레임은 만료 있는 협동 락이므로, 살아있는 작업자는 주기적으로 시각을 다시 심는다.
@@ -831,8 +851,14 @@ function abandon(options = {}) {
831
851
  // ── board — 유도 뷰 ──────────────────────────────────────────────────────
832
852
 
833
853
  // 카드가 정본이고 이 출력은 유도물이다 (3.7 원칙 2 — 갱신을 강제하지 않는다).
854
+ // collectBoard() — 이 보드(cwd 기준). collectBoardAt(docRoot) — 플릿(monitor --all)이
855
+ // 다른 프로젝트의 보드를 같은 계산으로 유도하기 위한 진입점.
834
856
  function collectBoard() {
835
- const { paths, config } = requireBoard();
857
+ return collectBoardAt(findDocRoot());
858
+ }
859
+
860
+ function collectBoardAt(docRoot) {
861
+ const { paths, config } = requireBoardAt(docRoot);
836
862
  const active = kanban.listCardsInDir(paths.cardsDir);
837
863
  // 마일스톤 카드는 컬럼(작업 축)이 아니라 마일스톤 패널(목적 축)에만 나온다(3.8).
838
864
  const byStatus = status => active
@@ -904,6 +930,10 @@ function collectBoard() {
904
930
  const view = {
905
931
  schemaVersion: 1,
906
932
  kind: 'kanban-board',
933
+ // 보드 신원 — 모니터 멀티 인스턴스가 "같은 보드 재사용 vs 다음 포트"를 판정하는
934
+ // 키다(2026-09-17 접수 결함: 타 프로젝트 모니터를 가로채던 재사용). 읽기 전용
935
+ // localhost 뷰이므로 로컬 경로 노출은 안전하다. 이름은 리포 디렉터리(doc의 부모).
936
+ repo: { name: config.projectName || path.basename(path.dirname(docRoot)), docRoot },
907
937
  generatedAt: new Date().toISOString(),
908
938
  wip: { doing: { used: byStatus('doing').length, limit: config.wipLimits.doing } },
909
939
  columns: {
@@ -1247,5 +1277,6 @@ module.exports = {
1247
1277
  // monitor(kanban-monitor)가 보드 수집·루트 검증을 재사용한다 — 유도물은 한 곳에서.
1248
1278
  requireBoard,
1249
1279
  collectBoard,
1280
+ collectBoardAt,
1250
1281
  scaffold: kanban.scaffold,
1251
1282
  };
@@ -14,71 +14,104 @@
14
14
  // 그래서 monitor는 bin의 auto-update 트리거 목록에 없다(wait와 같은 이유 —
15
15
  // 배너가 첫 줄을 밀어내면 계약이 깨진다).
16
16
  const http = require('http');
17
+ const fs = require('fs');
17
18
  const kanban = require('./kanban');
19
+ const { recordProject, readProjects } = require('./auto-update');
18
20
  const {
19
- parseArgs, validateFlags, fail, USAGE, requireBoard, collectBoard,
21
+ parseArgs, validateFlags, fail, USAGE, requireBoard, collectBoard, collectBoardAt,
20
22
  } = require('./kanban-cmd');
21
23
 
22
- const MONITOR_FLAGS = { port: 'string' };
24
+ const MONITOR_FLAGS = { port: 'string', all: 'flag' };
23
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
+ };
24
33
 
25
- // 이미 떠 있는 인스턴스가 우리 모니터인지 본다 — /api/board가 kanban-board를
26
- // 내면 ours. 루프가 시작마다 monitor를 띄우므로 재기동은 멱등이어야 한다
27
- // (멱등 재시도≠실패 agent-cli-contract).
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.
28
40
  function probeExistingMonitor(port) {
29
- return new Promise(resolve => {
30
- const req = http.get({ host: '127.0.0.1', port, path: '/api/board', timeout: 1500 }, res => {
41
+ const get = apiPath => new Promise(resolve => {
42
+ const req = http.get({ host: '127.0.0.1', port, path: apiPath, timeout: 1500 }, res => {
31
43
  let body = '';
32
44
  res.setEncoding('utf8');
33
45
  res.on('data', d => { body += d; });
34
46
  res.on('end', () => {
35
- try { resolve(JSON.parse(body).kind === 'kanban-board' ? 'ours' : 'foreign'); }
36
- catch { resolve('foreign'); }
47
+ try { resolve(JSON.parse(body)); } catch { resolve(null); }
37
48
  });
38
49
  });
39
- req.on('error', () => resolve('none'));
40
- req.on('timeout', () => { req.destroy(); resolve('none'); });
50
+ req.on('error', () => resolve(null));
51
+ req.on('timeout', () => { req.destroy(); resolve(null); });
41
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
+ })();
42
63
  }
43
64
 
44
- async function monitor(options = {}) {
45
- const flags = { ...parseArgs(options.rest || []).flags };
46
- if (options.json) flags.json = true;
47
- validateFlags(flags, MONITOR_FLAGS);
48
-
49
- const port = flags.port !== undefined ? Number(flags.port) : DEFAULT_PORT;
50
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
51
- fail(`--port 값이 잘못됐다: ${flags.port} — 0~65535의 정수(0은 임시 포트 할당). ${USAGE.monitor}`);
52
- }
65
+ function reusePrint(port) {
66
+ console.log(`http://127.0.0.1:${port}`);
67
+ console.log('이미 떠 있다 — 이 보드의 모니터를 재사용한다 (멱등 재시도).');
68
+ }
53
69
 
54
- // 시작 시 한 번 검증한다 — requireBoard의 fail은 process.exit(1)이라 요청
55
- // 도중에 돌리면 서버가 그대로 죽는다. 이후 요청에서 보드가 사라지면 500.
56
- const { paths } = requireBoard();
57
-
58
- // 고정 포트에 이미 우리 모니터가 떠 있으면 재사용(exit 0) — 루프 시작이 몇 번을
59
- // 불러도 첫 번째만 듣고 나머지는 URL만 되뇐다. 임시 포트(0) 재사용 의미가 없다.
60
- if (port !== 0) {
61
- const existing = await probeExistingMonitor(port);
62
- if (existing === 'ours') {
63
- console.log(`http://127.0.0.1:${port}`);
64
- console.log('이미 떠 있다 — 이 모니터를 재사용한다 (멱등 재시도).');
65
- return;
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`);
66
79
  }
67
- }
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
+ }
68
96
 
97
+ // ── 플릿(monitor --all) — 기계 단위 집계 뷰 ────────────────────────────────
98
+ // 프로젝트 목록은 기계 등록부(auto-update의 projects.json)에서. 각 타일의 상태는
99
+ // 그 프로젝트의 doc/kanban 파일을 직접 읽어 유도한다 — 서버끼리 통신하는 배관
100
+ // 없음(파일이 정본). /p/<slug>/ 는 단일 보드 페이지·API를 그 프로젝트 경로로.
101
+ function startFleetServer(port) {
69
102
  const server = http.createServer((req, res) => {
70
103
  try {
71
- handle(req, res, paths);
104
+ handleFleet(req, res);
72
105
  } catch (e) {
73
106
  if (!res.headersSent) {
74
107
  res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
75
108
  }
76
- res.end(`보드를 읽다 실패했다: ${e.message}\n`);
109
+ res.end(`플릿을 읽다 실패했다: ${e.message}\n`);
77
110
  }
78
111
  });
79
112
  server.on('error', err => {
80
113
  if (err.code === 'EADDRINUSE') {
81
- console.error(`✗ 포트 ${port}가 이미 쓰이고 있다 — --port <다른 번호>로 바꿔라.`);
114
+ console.error(`✗ 포트 ${port}가 이미 쓰이고 있다 — --port <다른 번호> 또는 기본 실행(빈 포트 자동 탐색).`);
82
115
  } else {
83
116
  console.error(`✗ 서버 오류: ${err.message}`);
84
117
  }
@@ -87,23 +120,204 @@ async function monitor(options = {}) {
87
120
  server.listen(port, '127.0.0.1', () => {
88
121
  const actual = server.address().port;
89
122
  console.log(`http://127.0.0.1:${actual}`);
90
- console.log(`읽기 전용 모니터 정본은 ${paths.kanbanDir} 카드 파일, 쓰는 CLI뿐이다. 종료는 Ctrl+C.`);
123
+ console.log('플릿 머신의 모든 llm-wiki 프로젝트를 페이지로. 종료는 Ctrl+C.');
91
124
  });
125
+ return server;
92
126
  }
93
127
 
94
- function handle(req, res, paths) {
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
+ }
95
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;
96
309
  if (req.method !== 'GET') {
97
310
  res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET' });
98
311
  res.end('카드 쓰기는 CLI뿐이다 — 이 서버는 읽기 전용이다.\n');
99
312
  return;
100
313
  }
101
314
  const noStore = { 'cache-control': 'no-store' };
102
- if (url.pathname === '/') {
315
+ if (url.pathname === '/' || url.pathname === '') {
103
316
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...noStore });
104
317
  res.end(renderMonitorPage());
105
318
  } else if (url.pathname === '/api/board') {
106
- const { view } = collectBoard();
319
+ // 보드의 것만 플릿 경로로도 ctx.docRoot가 그 프로젝트를 가리킨다.
320
+ const { view } = collectBoardAt(ctx.docRoot);
107
321
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', ...noStore });
108
322
  res.end(JSON.stringify(view));
109
323
  } else if (url.pathname === '/api/activity') {
@@ -240,7 +454,7 @@ function renderMonitorPage() {
240
454
  </head>
241
455
  <body>
242
456
  <header>
243
- <h1>llm-wiki monitor</h1>
457
+ <h1>llm-wiki monitor <span id="project" class="who"></span></h1>
244
458
  <p id="summary">불러오는 중…</p>
245
459
  </header>
246
460
  <section id="milestones" hidden>
@@ -439,6 +653,9 @@ let lastBoard = null;
439
653
  function updateSummary() {
440
654
  const summary = document.getElementById('summary');
441
655
  if (!lastBoard) return;
656
+ // 프로젝트 이름 — 여러 탭(멀티 프로젝트)을 구분하는 표지다.
657
+ const projName = lastBoard.repo && lastBoard.repo.name;
658
+ document.getElementById('project').textContent = projName ? '· ' + projName : '';
442
659
  const t = lastBoard.terminal;
443
660
  summary.className = '';
444
661
  summary.textContent = 'WIP doing ' + lastBoard.wip.doing.used + '/' + lastBoard.wip.doing.limit +
@@ -449,7 +666,7 @@ function updateSummary() {
449
666
  async function tick() {
450
667
  const summary = document.getElementById('summary');
451
668
  try {
452
- const [b, a] = await Promise.all([fetch('/api/board'), fetch('/api/activity')]);
669
+ const [b, a] = await Promise.all([fetch('api/board'), fetch('api/activity')]);
453
670
  if (!b.ok || !a.ok) throw new Error('HTTP ' + b.status + '/' + a.status);
454
671
  const board = await b.json();
455
672
  const activity = await a.json();
@@ -490,7 +707,7 @@ async function openCard(title) {
490
707
  metaEl.textContent = '';
491
708
  body.replaceChildren(el('div', 'sec', '불러오는 중…'));
492
709
  try {
493
- const r = await fetch('/api/card?title=' + encodeURIComponent(title));
710
+ const r = await fetch('api/card?title=' + encodeURIComponent(title));
494
711
  if (!r.ok) throw new Error(r.status === 404 ? '카드를 못 찾았다 — 제목이 바뀌었거나 아직 안 만들어졌다' : 'HTTP ' + r.status);
495
712
  const data = await r.json();
496
713
  t.textContent = data.title;
@@ -541,4 +758,99 @@ tick();
541
758
  `;
542
759
  }
543
760
 
544
- module.exports = { monitor, renderMonitorPage };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybird/llm-wiki",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "LLM-friendly knowledge graph + kanban for AI coding agents. Raw daily logs → compiled wiki → merged semantic+grep search, and a card-per-file kanban the work-loop skill consumes unattended. Drop-in for any repo.",
5
5
  "license": "MIT",
6
6
  "author": "kybird",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: work-loop
3
3
  description: Unattended kanban loop — pick a card, resolve it with objective evidence, park judgment calls and move on. Board is the only task list; converge, don't diverge.
4
- skill-version: 7
4
+ skill-version: 8
5
5
  ---
6
6
  # When to use
7
7
 
@@ -21,10 +21,12 @@ skill-version: 7
21
21
  # Loop start — bring the monitor up (사람의 실시간 관측)
22
22
 
23
23
  루프를 시작할 때 `llm-wiki monitor`를 백그라운드로 띄운다 — 밤중에 사람이 들어와도
24
- 클레임·진행·마일스톤이 살아 있는 보드를 본다. **멱등이다**: 이미 있으면 exit 0으로
25
- 같은 URL을 되뇌므로 그대로 쓰고, 백그라운드 실행이 되는 환경이면 건너뛴다
26
- 모니터는 관측이지 의존성이 아니다. 정지 규칙에서도 모니터는 끄지 않는다(아침의
27
- 사람이 본다) 읽기 전용이라 남겨두는 비용이 없다.
24
+ 클레임·진행·마일스톤이 살아 있는 보드를 본다. **멱등이다**: 루프의 보드(같은
25
+ 프로젝트) 모니터가 이미 있으면 exit 0으로 같은 URL을 되뇐다. 다른 프로젝트의
26
+ 모니터가 기본 포트를 쓰고 있으면 **다음 포트에 자기 보드를 띄운다** — 멀티
27
+ 프로젝트 밤에도 각자의 보드가 각자의 URL에 뜬다. 백그라운드 실행이 안 되는
28
+ 환경이면 건너뛴다 — 모니터는 관측이지 의존성이 아니다. 정지 규칙에서도 모니터는
29
+ 끄지 않는다(아침의 사람이 본다) — 읽기 전용이라 남겨두는 비용이 없다.
28
30
 
29
31
  # Loop graph
30
32