@kybird/llm-wiki 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/bin/llm-wiki.js +31 -12
- package/lib/kanban-cmd.js +1251 -1129
- package/lib/kanban-monitor.js +544 -0
- package/package.json +2 -2
- package/skills/kanban-plan/SKILL.md +21 -4
- package/skills/work-loop/SKILL.md +13 -1
- package/templates/AGENTS.md +2 -1
|
@@ -0,0 +1,544 @@
|
|
|
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 kanban = require('./kanban');
|
|
18
|
+
const {
|
|
19
|
+
parseArgs, validateFlags, fail, USAGE, requireBoard, collectBoard,
|
|
20
|
+
} = require('./kanban-cmd');
|
|
21
|
+
|
|
22
|
+
const MONITOR_FLAGS = { port: 'string' };
|
|
23
|
+
const DEFAULT_PORT = 4747;
|
|
24
|
+
|
|
25
|
+
// 이미 떠 있는 인스턴스가 우리 모니터인지 본다 — /api/board가 kanban-board를
|
|
26
|
+
// 내면 ours. 루프가 매 시작마다 monitor를 띄우므로 재기동은 멱등이어야 한다
|
|
27
|
+
// (멱등 재시도≠실패 — agent-cli-contract).
|
|
28
|
+
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 => {
|
|
31
|
+
let body = '';
|
|
32
|
+
res.setEncoding('utf8');
|
|
33
|
+
res.on('data', d => { body += d; });
|
|
34
|
+
res.on('end', () => {
|
|
35
|
+
try { resolve(JSON.parse(body).kind === 'kanban-board' ? 'ours' : 'foreign'); }
|
|
36
|
+
catch { resolve('foreign'); }
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
req.on('error', () => resolve('none'));
|
|
40
|
+
req.on('timeout', () => { req.destroy(); resolve('none'); });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
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
|
+
}
|
|
53
|
+
|
|
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;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const server = http.createServer((req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
handle(req, res, paths);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (!res.headersSent) {
|
|
74
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
75
|
+
}
|
|
76
|
+
res.end(`보드를 읽다 실패했다: ${e.message}\n`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
server.on('error', err => {
|
|
80
|
+
if (err.code === 'EADDRINUSE') {
|
|
81
|
+
console.error(`✗ 포트 ${port}가 이미 쓰이고 있다 — --port <다른 번호>로 바꿔라.`);
|
|
82
|
+
} else {
|
|
83
|
+
console.error(`✗ 서버 오류: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
87
|
+
server.listen(port, '127.0.0.1', () => {
|
|
88
|
+
const actual = server.address().port;
|
|
89
|
+
console.log(`http://127.0.0.1:${actual}`);
|
|
90
|
+
console.log(`읽기 전용 모니터 — 정본은 ${paths.kanbanDir} 의 카드 파일, 쓰는 건 CLI뿐이다. 종료는 Ctrl+C.`);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handle(req, res, paths) {
|
|
95
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
96
|
+
if (req.method !== 'GET') {
|
|
97
|
+
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET' });
|
|
98
|
+
res.end('카드 쓰기는 CLI뿐이다 — 이 서버는 읽기 전용이다.\n');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const noStore = { 'cache-control': 'no-store' };
|
|
102
|
+
if (url.pathname === '/') {
|
|
103
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...noStore });
|
|
104
|
+
res.end(renderMonitorPage());
|
|
105
|
+
} else if (url.pathname === '/api/board') {
|
|
106
|
+
const { view } = collectBoard();
|
|
107
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', ...noStore });
|
|
108
|
+
res.end(JSON.stringify(view));
|
|
109
|
+
} else if (url.pathname === '/api/activity') {
|
|
110
|
+
// 최근 50건만 — readActivity 자체가 ACTIVITY_CAP(10000줄)으로 봉인돼 있다.
|
|
111
|
+
const events = kanban.readActivity(paths).slice(-50);
|
|
112
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', ...noStore });
|
|
113
|
+
res.end(JSON.stringify({ schemaVersion: 1, kind: 'kanban-activity', events }));
|
|
114
|
+
} else if (url.pathname === '/api/card') {
|
|
115
|
+
// 카드 본문 — 제목은 파일명이 아니라 파싱된 meta.title과 비교한다
|
|
116
|
+
// (findCard). 경로로 쓰지 않으니 조회 키일 뿐이다. 종결 카드도 같이 찾는다.
|
|
117
|
+
const title = url.searchParams.get('title');
|
|
118
|
+
if (!title) {
|
|
119
|
+
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
120
|
+
res.end('title 쿼리가 필요하다 — /api/card?title=<카드 제목>\n');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const cardObj = kanban.findCard(paths, title);
|
|
124
|
+
if (!cardObj) {
|
|
125
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
126
|
+
res.end(`카드를 못 찾았다: ${title}\n`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Goal·AC는 센티넬 블록으로 파싱돼 card.goal/card.ac에 있고 sections는
|
|
130
|
+
// 나머지(Plan/Notes/Handoff/Result)다 — 파일 순서(Goal → AC → 나머지)로 합친다.
|
|
131
|
+
const sections = {};
|
|
132
|
+
if (cardObj.card.goal && cardObj.card.goal.trim()) sections.Goal = cardObj.card.goal.trim();
|
|
133
|
+
if (cardObj.card.ac.length) {
|
|
134
|
+
sections['Acceptance Criteria'] = cardObj.card.ac
|
|
135
|
+
.map(a => (a.checked ? '- [x] ' : '- [ ] ') + '#' + a.idx + ' ' + a.text)
|
|
136
|
+
.join('\n');
|
|
137
|
+
}
|
|
138
|
+
for (const [name, bodyText] of cardObj.card.sections) {
|
|
139
|
+
if (bodyText && bodyText.trim()) sections[name] = bodyText;
|
|
140
|
+
}
|
|
141
|
+
const payload = {
|
|
142
|
+
schemaVersion: 1,
|
|
143
|
+
kind: 'kanban-card',
|
|
144
|
+
title: cardObj.meta.title,
|
|
145
|
+
meta: cardObj.meta,
|
|
146
|
+
sections,
|
|
147
|
+
};
|
|
148
|
+
// 마일스톤 상세는 멤버 목록까지 유도해 실는다 — 회고 브라우징의 뼈대(3.8):
|
|
149
|
+
// 대의(Goal)를 읽고 멤버를 따라 들어간다.
|
|
150
|
+
if (cardObj.meta.kind === 'milestone') {
|
|
151
|
+
payload.members = kanban.listAllCards(paths)
|
|
152
|
+
.filter(c => c.meta.milestone === cardObj.meta.title)
|
|
153
|
+
.map(c => ({ title: c.meta.title, status: c.meta.status, claimedBy: c.meta.claimed_by || null }));
|
|
154
|
+
}
|
|
155
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', ...noStore });
|
|
156
|
+
res.end(JSON.stringify(payload));
|
|
157
|
+
} else {
|
|
158
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
159
|
+
res.end('없는 경로다 — / · /api/board · /api/activity · /api/card?title=….\n');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 페이지 — 정적 골격 + DOM API 데이터 바인딩 ─────────────────────────────
|
|
164
|
+
// 카드 제목·질문은 에이전트가 쓴 문자열이다 — innerHTML로 심지 않고
|
|
165
|
+
// textContent로만 넣는다(주입 차단). 골격 문자열에는 데이터가 없다.
|
|
166
|
+
function renderMonitorPage() {
|
|
167
|
+
return `<!doctype html>
|
|
168
|
+
<html lang="ko">
|
|
169
|
+
<head>
|
|
170
|
+
<meta charset="utf-8">
|
|
171
|
+
<title>llm-wiki monitor</title>
|
|
172
|
+
<style>
|
|
173
|
+
body { font-family: -apple-system, 'Segoe UI', 'Malgun Gothic', sans-serif; margin: 20px;
|
|
174
|
+
background: #0f141a; color: #d7dee6; }
|
|
175
|
+
header h1 { font-size: 18px; margin: 0 0 4px; }
|
|
176
|
+
header p { margin: 0 0 16px; color: #7f8b98; font-size: 13px; }
|
|
177
|
+
header p.off { color: #ff8f8f; }
|
|
178
|
+
main { display: grid; grid-template-columns: repeat(5, minmax(180px, 1fr)); gap: 12px; align-items: start; }
|
|
179
|
+
section { background: #161d26; border: 1px solid #232d3a; border-radius: 10px; padding: 10px; }
|
|
180
|
+
h2 { font-size: 12px; margin: 0 0 8px; text-transform: uppercase; letter-spacing: .05em;
|
|
181
|
+
color: #8a97a3; font-weight: 600; }
|
|
182
|
+
.count { color: #5b6a78; font-weight: 400; }
|
|
183
|
+
.card { background: #1c2531; border: 1px solid #2a3543; border-radius: 8px;
|
|
184
|
+
padding: 8px 10px; margin-bottom: 8px; }
|
|
185
|
+
.card[data-title] { cursor: pointer; }
|
|
186
|
+
.card[data-title]:hover { border-color: #48596d; }
|
|
187
|
+
.act[data-title] { cursor: pointer; }
|
|
188
|
+
.act[data-title]:hover { color: #d7dee6; }
|
|
189
|
+
#modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }
|
|
190
|
+
#modal[hidden] { display: none; }
|
|
191
|
+
#modal-backdrop { position: absolute; inset: 0; background: rgba(4, 8, 12, .72); }
|
|
192
|
+
#modal-panel { position: relative; background: #161d26; border: 1px solid #2a3543;
|
|
193
|
+
border-radius: 10px; width: min(720px, calc(100vw - 48px));
|
|
194
|
+
max-height: calc(100vh - 96px); overflow: auto; padding: 16px 20px; }
|
|
195
|
+
#modal-panel header { display: flex; align-items: baseline; gap: 12px; }
|
|
196
|
+
#modal-title { font-size: 16px; margin: 0; flex: 1; }
|
|
197
|
+
#modal-close { background: none; border: 1px solid #2a3543; color: #9fb0c0;
|
|
198
|
+
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 12px; }
|
|
199
|
+
#modal-close:hover { color: #d7dee6; border-color: #48596d; }
|
|
200
|
+
#modal-meta { color: #8a97a3; font-size: 12px; margin: 6px 0 12px; }
|
|
201
|
+
#modal-body h4 { font-size: 11px; margin: 14px 0 4px; text-transform: uppercase;
|
|
202
|
+
letter-spacing: .05em; color: #8a97a3; }
|
|
203
|
+
#modal-body .sec { white-space: pre-wrap; font-size: 13px; line-height: 1.55;
|
|
204
|
+
color: #c6d0da; }
|
|
205
|
+
.card .t { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
|
|
206
|
+
.q { font-size: 12px; color: #9fb0c0; margin: 2px 0 4px; }
|
|
207
|
+
.badge { display: inline-block; font-size: 11px; background: #26313f; color: #a9b7c4;
|
|
208
|
+
border-radius: 999px; padding: 2px 8px; margin: 2px 4px 0 0; }
|
|
209
|
+
.badge.warn { background: #4a2f14; color: #ffb45e; }
|
|
210
|
+
.badge.gate { background: #16324f; color: #7cb6ff; }
|
|
211
|
+
.badge.t-done { background: #12331f; color: #6ee7a0; }
|
|
212
|
+
.badge.t-superseded { background: #2b1f4d; color: #c9a6ff; }
|
|
213
|
+
.badge.t-abandoned { background: #47201f; color: #ff8f8f; }
|
|
214
|
+
.act { font-size: 12px; padding: 4px 2px; border-bottom: 1px solid #1c2531; color: #9fb0c0; }
|
|
215
|
+
.who { color: #8a97a3; }
|
|
216
|
+
.act .a-done { color: #6ee7a0; } .act .a-abandoned { color: #ff8f8f; }
|
|
217
|
+
.act .a-claimed { color: #7cb6ff; } .act .a-handoff { color: #ffb45e; }
|
|
218
|
+
.act .a-milestone-done { color: #6ee7a0; font-weight: 600; }
|
|
219
|
+
.empty { color: #55616d; font-size: 12px; }
|
|
220
|
+
#milestones { background: #161d26; border: 1px solid #232d3a; border-radius: 10px;
|
|
221
|
+
padding: 10px; margin-bottom: 12px; }
|
|
222
|
+
#milestones[hidden] { display: none; }
|
|
223
|
+
.ms { border: 1px solid #2a3543; border-radius: 8px; padding: 8px 10px;
|
|
224
|
+
margin-bottom: 8px; background: #1c2531; }
|
|
225
|
+
.ms.done { opacity: .82; }
|
|
226
|
+
.ms-head { display: flex; align-items: center; gap: 10px; cursor: pointer; }
|
|
227
|
+
.ms-title { font-size: 13px; font-weight: 600; max-width: 34%; overflow: hidden;
|
|
228
|
+
text-overflow: ellipsis; white-space: nowrap; }
|
|
229
|
+
.ms-bar { flex: 1; height: 6px; background: #10161d; border-radius: 999px; overflow: hidden; }
|
|
230
|
+
.ms-bar-fill { height: 100%; background: linear-gradient(90deg, #2f6f4f, #6ee7a0); }
|
|
231
|
+
.ms-count { font-size: 12px; color: #8a97a3; white-space: nowrap; }
|
|
232
|
+
.ms-members { margin-top: 6px; }
|
|
233
|
+
.ms-member { font-size: 12px; padding: 3px 0; cursor: pointer; }
|
|
234
|
+
.ms-member:hover { color: #d7dee6; }
|
|
235
|
+
.badge.st-doing { background: #16324f; color: #7cb6ff; }
|
|
236
|
+
.badge.st-review { background: #4a2f14; color: #ffb45e; }
|
|
237
|
+
.badge.st-todo { background: #26313f; color: #a9b7c4; }
|
|
238
|
+
.badge.ms-tag { background: #233043; color: #9fb0c0; font-size: 10px; }
|
|
239
|
+
</style>
|
|
240
|
+
</head>
|
|
241
|
+
<body>
|
|
242
|
+
<header>
|
|
243
|
+
<h1>llm-wiki monitor</h1>
|
|
244
|
+
<p id="summary">불러오는 중…</p>
|
|
245
|
+
</header>
|
|
246
|
+
<section id="milestones" hidden>
|
|
247
|
+
<h2>마일스톤 <span class="count" id="n-ms"></span></h2>
|
|
248
|
+
<div id="list-milestones"></div>
|
|
249
|
+
</section>
|
|
250
|
+
<main>
|
|
251
|
+
<section><h2>Doing <span class="count" id="n-doing"></span></h2><div id="list-doing"></div></section>
|
|
252
|
+
<section><h2>Review — 사람 판정 대기 <span class="count" id="n-review"></span></h2><div id="list-review"></div></section>
|
|
253
|
+
<section><h2>Todo <span class="count" id="n-todo"></span></h2><div id="list-todo"></div></section>
|
|
254
|
+
<section><h2>종결 <span class="count" id="n-terminal"></span></h2><div id="list-terminal"></div></section>
|
|
255
|
+
<section><h2>활동 <span class="count" id="n-act"></span></h2><div id="list-activity"></div></section>
|
|
256
|
+
</main>
|
|
257
|
+
<div id="modal" hidden>
|
|
258
|
+
<div id="modal-backdrop"></div>
|
|
259
|
+
<div id="modal-panel" role="dialog" aria-modal="true">
|
|
260
|
+
<header><h3 id="modal-title"></h3><button id="modal-close" type="button">닫기 (Esc)</button></header>
|
|
261
|
+
<div id="modal-meta"></div>
|
|
262
|
+
<div id="modal-body"></div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
<script>
|
|
266
|
+
'use strict';
|
|
267
|
+
const ACTION_LABEL = { created: '생성', claimed: '집김', done: '완료', handoff: '판정 대기',
|
|
268
|
+
abandoned: '폐기', superseded: '대체', reopened: '재검토', resumed: '복귀', edited: '편집',
|
|
269
|
+
reverted: '되돌림', 'milestone-done': '계획 완료', 'milestone-reverted': '계획 되돌림' };
|
|
270
|
+
const el = (tag, cls, text) => {
|
|
271
|
+
const n = document.createElement(tag);
|
|
272
|
+
if (cls) n.className = cls;
|
|
273
|
+
if (text !== undefined) n.textContent = text;
|
|
274
|
+
return n;
|
|
275
|
+
};
|
|
276
|
+
const fmtElapsed = ms => {
|
|
277
|
+
const m = Math.floor(ms / 60000);
|
|
278
|
+
return m < 60 ? m + '분' : Math.floor(m / 60) + '시간 ' + (m % 60) + '분';
|
|
279
|
+
};
|
|
280
|
+
const fmtClock = ts => new Date(ts).toLocaleTimeString('ko-KR', { hour12: false });
|
|
281
|
+
const fmtDayClock = ts => {
|
|
282
|
+
const d = new Date(ts);
|
|
283
|
+
const pad = n => String(n).padStart(2, '0');
|
|
284
|
+
return (d.getMonth() + 1) + '월 ' + d.getDate() + '일 ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
285
|
+
};
|
|
286
|
+
const TERMINAL_LABEL = { done: '완료', superseded: '대체', abandoned: '폐기' };
|
|
287
|
+
const STATUS_LABEL = { todo: '할 일', doing: '진행 중', review: '판정 대기', done: '완료', superseded: '대체', abandoned: '폐기' };
|
|
288
|
+
// 클릭 가능 카드 — data-title이 상세 보기 조회 키다(이벤트 위임 한 곳에서 처리).
|
|
289
|
+
const cardEl = title => {
|
|
290
|
+
const n = el('div', 'card');
|
|
291
|
+
n.dataset.title = title;
|
|
292
|
+
return n;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
function renderBoard(view) {
|
|
296
|
+
renderMilestones(view.milestones || []);
|
|
297
|
+
const resolved = new Set(view.resolved);
|
|
298
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
299
|
+
|
|
300
|
+
const doing = document.getElementById('list-doing');
|
|
301
|
+
doing.replaceChildren(
|
|
302
|
+
...view.columns.doing.map(c => {
|
|
303
|
+
const card = cardEl(c.title);
|
|
304
|
+
card.append(el('div', 't', c.title));
|
|
305
|
+
const meta = el('div', 'meta');
|
|
306
|
+
if (c.claimExpired) {
|
|
307
|
+
meta.append(el('span', 'badge warn', '클레임 만료 — 재집기 가능'));
|
|
308
|
+
} else if (c.claimedBy) {
|
|
309
|
+
let label = c.claimedBy;
|
|
310
|
+
const ms = c.claimedAt ? Date.now() - Date.parse(c.claimedAt) : NaN;
|
|
311
|
+
if (Number.isFinite(ms) && ms >= 0) label += ' · ' + fmtElapsed(ms);
|
|
312
|
+
meta.append(el('span', 'badge', label));
|
|
313
|
+
}
|
|
314
|
+
card.append(meta);
|
|
315
|
+
return card;
|
|
316
|
+
})
|
|
317
|
+
);
|
|
318
|
+
if (!view.columns.doing.length) doing.append(el('div', 'empty', '비어 있음'));
|
|
319
|
+
document.getElementById('n-doing').textContent = view.wip.doing.used + '/' + view.wip.doing.limit;
|
|
320
|
+
|
|
321
|
+
const review = document.getElementById('list-review');
|
|
322
|
+
review.replaceChildren(
|
|
323
|
+
...view.columns.review.flatMap(c => {
|
|
324
|
+
const card = cardEl(c.title);
|
|
325
|
+
card.append(el('div', 't', c.title));
|
|
326
|
+
if (c.question) card.append(el('div', 'q', '↳ ' + c.question));
|
|
327
|
+
return [card];
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
if (!view.columns.review.length) review.append(el('div', 'empty', '비어 있음'));
|
|
331
|
+
document.getElementById('n-review').textContent = view.columns.review.length;
|
|
332
|
+
|
|
333
|
+
const todo = document.getElementById('list-todo');
|
|
334
|
+
todo.replaceChildren(
|
|
335
|
+
...view.columns.todo.map(c => {
|
|
336
|
+
const card = cardEl(c.title);
|
|
337
|
+
card.append(el('div', 't', c.title));
|
|
338
|
+
const meta = el('div', 'meta');
|
|
339
|
+
if (c.notBefore && c.notBefore > today) meta.append(el('span', 'badge gate', '시작 예정 ' + c.notBefore));
|
|
340
|
+
const unmet = c.dependsOn.filter(d => !resolved.has(d));
|
|
341
|
+
if (unmet.length) meta.append(el('span', 'badge gate', '의존 대기: ' + unmet.join(', ')));
|
|
342
|
+
card.append(meta);
|
|
343
|
+
return card;
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
if (!view.columns.todo.length) todo.append(el('div', 'empty', '비어 있음'));
|
|
347
|
+
document.getElementById('n-todo').textContent = view.columns.todo.length;
|
|
348
|
+
|
|
349
|
+
// 종결 적체 — 완료가 쌓이는 모습이 수렴의 증거다(3.2). 최근 8건, 새 것부터.
|
|
350
|
+
const terminal = document.getElementById('list-terminal');
|
|
351
|
+
terminal.replaceChildren(
|
|
352
|
+
...(view.terminalRecent || []).map(t => {
|
|
353
|
+
const card = cardEl(t.title);
|
|
354
|
+
card.append(el('div', 't', t.title));
|
|
355
|
+
const meta = el('div', 'meta');
|
|
356
|
+
meta.append(el('span', 'badge t-' + t.kind, TERMINAL_LABEL[t.kind] || t.kind));
|
|
357
|
+
if (t.milestone) meta.append(el('span', 'badge ms-tag', t.milestone));
|
|
358
|
+
if (t.at) meta.append(el('span', 'who', ' ' + fmtDayClock(t.at)));
|
|
359
|
+
card.append(meta);
|
|
360
|
+
return card;
|
|
361
|
+
})
|
|
362
|
+
);
|
|
363
|
+
if (!(view.terminalRecent || []).length) terminal.append(el('div', 'empty', '비어 있음'));
|
|
364
|
+
document.getElementById('n-terminal').textContent = '최근 ' + (view.terminalRecent || []).length;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── 마일스톤 패널 — 목적 축(3.8). 진행 중은 펼쳐 멤버를 보이고(2026-09-16 확정),
|
|
368
|
+
// 완료는 접힌다. 제목·멤버 클릭은 상세 모달로(위임 핸들러가 잡는다).
|
|
369
|
+
const MEMBER_RANK = { doing: 0, review: 1, todo: 2, done: 3, superseded: 4, abandoned: 5 };
|
|
370
|
+
const memberBadgeClass = status =>
|
|
371
|
+
['done', 'superseded', 'abandoned'].includes(status) ? 't-' + status : 'st-' + status;
|
|
372
|
+
|
|
373
|
+
function renderMilestones(list) {
|
|
374
|
+
const panel = document.getElementById('milestones');
|
|
375
|
+
const container = document.getElementById('list-milestones');
|
|
376
|
+
panel.hidden = !list.length;
|
|
377
|
+
document.getElementById('n-ms').textContent = list.length;
|
|
378
|
+
if (!list.length) { container.replaceChildren(); return; }
|
|
379
|
+
container.replaceChildren(...list.map(ms => {
|
|
380
|
+
const isActive = ['todo', 'doing', 'review'].includes(ms.status);
|
|
381
|
+
const finished = ms.total - (ms.counts.active || 0);
|
|
382
|
+
const box = el('div', 'ms' + (isActive ? '' : ' done'));
|
|
383
|
+
const head = el('div', 'ms-head');
|
|
384
|
+
head.dataset.title = ms.title;
|
|
385
|
+
head.append(el('span', 'ms-title', (isActive ? '' : '✓ ') + ms.title));
|
|
386
|
+
const barWrap = el('div', 'ms-bar');
|
|
387
|
+
const bar = el('div', 'ms-bar-fill');
|
|
388
|
+
bar.style.width = (ms.total ? Math.round(finished / ms.total * 100) : 0) + '%';
|
|
389
|
+
barWrap.append(bar);
|
|
390
|
+
head.append(barWrap, el('span', 'ms-count', finished + '/' + ms.total));
|
|
391
|
+
if (ms.completedAt) head.append(el('span', 'who', ' ' + fmtDayClock(ms.completedAt)));
|
|
392
|
+
box.append(head);
|
|
393
|
+
if (isActive) {
|
|
394
|
+
const members = el('div', 'ms-members');
|
|
395
|
+
const sorted = [...ms.members].sort((a, b) => (MEMBER_RANK[a.status] ?? 9) - (MEMBER_RANK[b.status] ?? 9));
|
|
396
|
+
for (const mem of sorted) {
|
|
397
|
+
const row = el('div', 'ms-member');
|
|
398
|
+
row.dataset.title = mem.title;
|
|
399
|
+
row.append(el('span', 'badge ' + memberBadgeClass(mem.status), STATUS_LABEL[mem.status] || mem.status));
|
|
400
|
+
row.append(document.createTextNode(' ' + mem.title));
|
|
401
|
+
members.append(row);
|
|
402
|
+
}
|
|
403
|
+
if (!sorted.length) members.append(el('div', 'empty', '멤버 없음 — card new --milestone로 붙여라'));
|
|
404
|
+
box.append(members);
|
|
405
|
+
}
|
|
406
|
+
return box;
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function renderActivity(payload) {
|
|
411
|
+
const list = document.getElementById('list-activity');
|
|
412
|
+
const newest = [...payload.events].reverse();
|
|
413
|
+
list.replaceChildren(
|
|
414
|
+
...newest.map(ev => {
|
|
415
|
+
const row = el('div', 'act');
|
|
416
|
+
if (ev.title) row.dataset.title = ev.title;
|
|
417
|
+
const label = ACTION_LABEL[ev.action] || ev.action;
|
|
418
|
+
row.append(el('span', 'a-' + ev.action, label));
|
|
419
|
+
row.append(document.createTextNode(' ' + (ev.title || '')));
|
|
420
|
+
if (ev.actor) row.append(el('span', 'who', ' — ' + ev.actor));
|
|
421
|
+
row.append(el('div', 'who', fmtClock(ev.ts)));
|
|
422
|
+
return row;
|
|
423
|
+
})
|
|
424
|
+
);
|
|
425
|
+
if (!newest.length) list.append(el('div', 'empty', '활동 없음'));
|
|
426
|
+
document.getElementById('n-act').textContent = newest.length;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 직전 응답과 같으면 DOM을 다시 짓지 않는다 — 2초마다 replaceChildren하면
|
|
430
|
+
// 클릭 순간 노드가 교체돼 클릭이 씹힌다(2026-09-16 실측: 자동 클릭이
|
|
431
|
+
// actionability 대기에서 타임아웃). 렌더는 변화에만 반응한다. generatedAt는
|
|
432
|
+
// 매 응답마다 바뀌므로 비교 서명에서 제외한다.
|
|
433
|
+
let lastBoardSig = '';
|
|
434
|
+
let lastActivitySig = '';
|
|
435
|
+
let lastBoard = null;
|
|
436
|
+
|
|
437
|
+
// 요약 줄은 매 틱 다시 쓴다 — '갱신' 시계가 폴링 생존 신호다(멈추면 죽은 것이다).
|
|
438
|
+
// 컬럼 DOM은 변화에만 다시 짓는다.
|
|
439
|
+
function updateSummary() {
|
|
440
|
+
const summary = document.getElementById('summary');
|
|
441
|
+
if (!lastBoard) return;
|
|
442
|
+
const t = lastBoard.terminal;
|
|
443
|
+
summary.className = '';
|
|
444
|
+
summary.textContent = 'WIP doing ' + lastBoard.wip.doing.used + '/' + lastBoard.wip.doing.limit +
|
|
445
|
+
' · 종결: done ' + t.done + ' · superseded ' + t.superseded + ' · abandoned ' + t.abandoned +
|
|
446
|
+
' · 갱신 ' + fmtClock(Date.now());
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function tick() {
|
|
450
|
+
const summary = document.getElementById('summary');
|
|
451
|
+
try {
|
|
452
|
+
const [b, a] = await Promise.all([fetch('/api/board'), fetch('/api/activity')]);
|
|
453
|
+
if (!b.ok || !a.ok) throw new Error('HTTP ' + b.status + '/' + a.status);
|
|
454
|
+
const board = await b.json();
|
|
455
|
+
const activity = await a.json();
|
|
456
|
+
const boardSig = JSON.stringify({ ...board, generatedAt: null });
|
|
457
|
+
const activitySig = JSON.stringify(activity);
|
|
458
|
+
if (boardSig !== lastBoardSig) {
|
|
459
|
+
lastBoardSig = boardSig;
|
|
460
|
+
lastBoard = board;
|
|
461
|
+
renderBoard(board);
|
|
462
|
+
}
|
|
463
|
+
if (activitySig !== lastActivitySig) {
|
|
464
|
+
lastActivitySig = activitySig;
|
|
465
|
+
renderActivity(activity);
|
|
466
|
+
}
|
|
467
|
+
updateSummary();
|
|
468
|
+
} catch (e) {
|
|
469
|
+
summary.className = 'off';
|
|
470
|
+
summary.textContent = '연결 끊김 — 서버가 죽었거나 보드를 읽을 수 없다 (' + e.message + ')';
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── 카드 상세 보기 — 클릭된 제목의 카드 파일 본문을 읽어 온다 ────────────────
|
|
475
|
+
// 본문은 pre-wrap 텍스트로만 심는다(textContent) — 카드 내용은 에이전트가 쓴
|
|
476
|
+
// 문자열이라 마크다운 해석·innerHTML을 섞지 않는다. 조회 키(제목)도
|
|
477
|
+
// encodeURIComponent로 감싼다.
|
|
478
|
+
const modal = document.getElementById('modal');
|
|
479
|
+
function closeModal() { modal.hidden = true; }
|
|
480
|
+
document.getElementById('modal-close').addEventListener('click', closeModal);
|
|
481
|
+
document.getElementById('modal-backdrop').addEventListener('click', closeModal);
|
|
482
|
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
|
|
483
|
+
|
|
484
|
+
async function openCard(title) {
|
|
485
|
+
modal.hidden = false;
|
|
486
|
+
const t = document.getElementById('modal-title');
|
|
487
|
+
const metaEl = document.getElementById('modal-meta');
|
|
488
|
+
const body = document.getElementById('modal-body');
|
|
489
|
+
t.textContent = title;
|
|
490
|
+
metaEl.textContent = '';
|
|
491
|
+
body.replaceChildren(el('div', 'sec', '불러오는 중…'));
|
|
492
|
+
try {
|
|
493
|
+
const r = await fetch('/api/card?title=' + encodeURIComponent(title));
|
|
494
|
+
if (!r.ok) throw new Error(r.status === 404 ? '카드를 못 찾았다 — 제목이 바뀌었거나 아직 안 만들어졌다' : 'HTTP ' + r.status);
|
|
495
|
+
const data = await r.json();
|
|
496
|
+
t.textContent = data.title;
|
|
497
|
+
const m = data.meta || {};
|
|
498
|
+
const bits = [STATUS_LABEL[m.status] || m.status];
|
|
499
|
+
if (m.claimed_by) bits.push('클레임 ' + m.claimed_by + (m.claimed_at ? ' (' + m.claimed_at + ')' : ''));
|
|
500
|
+
if (m.not_before) bits.push('시작 예정 ' + m.not_before);
|
|
501
|
+
if (Array.isArray(m.depends_on) && m.depends_on.length) bits.push('의존 ' + m.depends_on.join(', '));
|
|
502
|
+
if (m.created) bits.push('생성 ' + m.created);
|
|
503
|
+
metaEl.textContent = bits.join(' · ');
|
|
504
|
+
const nodes = Object.entries(data.sections || {})
|
|
505
|
+
.filter(([, text]) => text && text.trim())
|
|
506
|
+
.flatMap(([name, text]) => [el('h4', '', name), el('div', 'sec', text.trim())]);
|
|
507
|
+
// 마일스톤 상세 — 멤버 전원 목록(회고 브라우징). 행 클릭은 위임 핸들러가
|
|
508
|
+
// 다시 openCard로 보내 모달이 멤버 카드로 교체된다.
|
|
509
|
+
if (Array.isArray(data.members)) {
|
|
510
|
+
nodes.push(el('h4', '', '멤버 (' + data.members.length + ')'));
|
|
511
|
+
const memberList = el('div', 'ms-members');
|
|
512
|
+
const sorted = [...data.members].sort((a, b) => (MEMBER_RANK[a.status] ?? 9) - (MEMBER_RANK[b.status] ?? 9));
|
|
513
|
+
for (const mem of sorted) {
|
|
514
|
+
const row = el('div', 'ms-member');
|
|
515
|
+
row.dataset.title = mem.title;
|
|
516
|
+
row.append(el('span', 'badge ' + memberBadgeClass(mem.status), STATUS_LABEL[mem.status] || mem.status));
|
|
517
|
+
row.append(document.createTextNode(' ' + mem.title));
|
|
518
|
+
memberList.append(row);
|
|
519
|
+
}
|
|
520
|
+
if (!sorted.length) memberList.append(el('div', 'empty', '멤버 없음'));
|
|
521
|
+
nodes.push(memberList);
|
|
522
|
+
}
|
|
523
|
+
body.replaceChildren(...nodes);
|
|
524
|
+
if (!body.childElementCount) body.append(el('div', 'sec', '(비어 있는 섹션)'));
|
|
525
|
+
} catch (e) {
|
|
526
|
+
body.replaceChildren(el('div', 'sec', '카드를 못 읽었다: ' + e.message));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 위임 한 곳 — 컬럼 카드·종결 카드·활동 행 어디를 눌러도 같은 규칙.
|
|
531
|
+
document.addEventListener('click', e => {
|
|
532
|
+
const hit = e.target.closest('[data-title]');
|
|
533
|
+
if (hit && hit.dataset.title) openCard(hit.dataset.title);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
setInterval(tick, 2000);
|
|
537
|
+
tick();
|
|
538
|
+
</script>
|
|
539
|
+
</body>
|
|
540
|
+
</html>
|
|
541
|
+
`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
module.exports = { monitor, renderMonitorPage };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybird/llm-wiki",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -38,6 +38,6 @@
|
|
|
38
38
|
"work-loop"
|
|
39
39
|
],
|
|
40
40
|
"scripts": {
|
|
41
|
-
"test": "node --test test/roundtrip.test.js test/kanban-write.test.js test/kanban-pick-guards.test.js test/kanban-wait.test.js test/find-doc-root-worktree.test.js test/auto-update.test.js"
|
|
41
|
+
"test": "node --test test/roundtrip.test.js test/kanban-write.test.js test/kanban-pick-guards.test.js test/kanban-board-flags.test.js test/kanban-milestone.test.js test/kanban-monitor.test.js test/kanban-wait.test.js test/find-doc-root-worktree.test.js test/auto-update.test.js"
|
|
42
42
|
}
|
|
43
43
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kanban-plan
|
|
3
|
-
description: Planning loop — turn a plan into board cards the work-loop can consume. Cards are written only via the CLI; decompose with divergence guards, gate what isn't ready.
|
|
4
|
-
skill-version:
|
|
3
|
+
description: Planning loop — turn a plan into board cards the work-loop can consume. Cards are written only via the CLI; decompose with divergence guards, gate what isn't ready, group multi-card plans under a milestone.
|
|
4
|
+
skill-version: 4
|
|
5
5
|
---
|
|
6
6
|
# When to use
|
|
7
7
|
|
|
@@ -15,7 +15,7 @@ skill-version: 2
|
|
|
15
15
|
- 기획(this skill) = **분해**: a plan becomes cards.
|
|
16
16
|
- 개발(`work-loop`) = **해소**: pick → done.
|
|
17
17
|
- QA = **수렴 강제**: fake dones get reverted; `board report` shows the ratio.
|
|
18
|
-
- The person watches progress through `llm-wiki board` / `board report` / `
|
|
18
|
+
- The person watches progress through `llm-wiki board` / `board report` / `llm-wiki monitor`.
|
|
19
19
|
Plan so that this one screen is enough — 리뷰가 카드 수에 비례하면 무인의 의미가 없다.
|
|
20
20
|
|
|
21
21
|
# Before creating cards
|
|
@@ -26,6 +26,23 @@ skill-version: 2
|
|
|
26
26
|
it (`supersede`); never spawn a near-duplicate. Titles are identifiers (plan.md 3.5):
|
|
27
27
|
no numbers, no versions in titles.
|
|
28
28
|
|
|
29
|
+
# Milestones — 계획 단위 소속 (plan.md 3.8)
|
|
30
|
+
|
|
31
|
+
A plan that decomposes into **2+ cards gets one milestone card** — the board's purpose
|
|
32
|
+
axis. One-card work needs no milestone (과잉이다).
|
|
33
|
+
|
|
34
|
+
1. `card new "<계획 제목>" --kind milestone --goal "<대의 한 문장>"` — the Goal is why
|
|
35
|
+
the plan exists; the morning human reads it above the terminal stream.
|
|
36
|
+
2. Every member card carries `--milestone "<계획 제목>"` (or `card edit --milestone`
|
|
37
|
+
later — active cards only; terminal membership is history).
|
|
38
|
+
3. The milestone is never picked and never closed by hand: `done`/`supersede`/`abandon`
|
|
39
|
+
of the last member auto-completes it (review-parked milestones only report).
|
|
40
|
+
Milestone progress is **derived**, never stored — don't manage its state.
|
|
41
|
+
|
|
42
|
+
Rules: milestones stay **flat** (a milestone never belongs to another milestone — CLI
|
|
43
|
+
rejects it). Gates don't propagate — each member carries its own `--not-before`.
|
|
44
|
+
Abandoning a milestone requires its members to be terminal first (CLI enforces, lists them).
|
|
45
|
+
|
|
29
46
|
# Decomposition rules (divergence guards, plan.md 2.3)
|
|
30
47
|
|
|
31
48
|
- One card = one context = one commit. If you cannot say what the commit would be, it is
|
|
@@ -33,7 +50,7 @@ skill-version: 2
|
|
|
33
50
|
- Children must be **strictly smaller** than the parent. When children replace a parent,
|
|
34
51
|
`supersede` it — the parent dissolves, it is not marked done.
|
|
35
52
|
- Depth ≤ 3 (plan → subtask → task). Deeper than that means you are writing the work,
|
|
36
|
-
not planning it.
|
|
53
|
+
not planning it. (The milestone sits **above** this ladder as grouping, not depth.)
|
|
37
54
|
- Do not board what is not ready to start:
|
|
38
55
|
- Time condition → `card new "<t>" --not-before 2026-09-05`. `pick` skips it until then.
|
|
39
56
|
- Observational condition → create it, then `handoff <t> --question "조건: …"`. It waits
|
|
@@ -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:
|
|
4
|
+
skill-version: 7
|
|
5
5
|
---
|
|
6
6
|
# When to use
|
|
7
7
|
|
|
@@ -18,6 +18,14 @@ skill-version: 5
|
|
|
18
18
|
3. Never build or assume a process supervisor. The loop lives in the session; the board
|
|
19
19
|
lives in files.
|
|
20
20
|
|
|
21
|
+
# Loop start — bring the monitor up (사람의 실시간 관측)
|
|
22
|
+
|
|
23
|
+
루프를 시작할 때 `llm-wiki monitor`를 백그라운드로 띄운다 — 밤중에 사람이 들어와도
|
|
24
|
+
클레임·진행·마일스톤이 살아 있는 보드를 본다. **멱등이다**: 이미 떠 있으면 exit 0으로
|
|
25
|
+
같은 URL을 되뇌므로 그대로 쓰고, 백그라운드 실행이 안 되는 환경이면 건너뛴다 —
|
|
26
|
+
모니터는 관측이지 의존성이 아니다. 정지 규칙에서도 모니터는 끄지 않는다(아침의
|
|
27
|
+
사람이 본다) — 읽기 전용이라 남겨두는 비용이 없다.
|
|
28
|
+
|
|
21
29
|
# Loop graph
|
|
22
30
|
|
|
23
31
|
```
|
|
@@ -61,6 +69,10 @@ pick → work → 판정 ─ done / handoff / abandon / supersede
|
|
|
61
69
|
(보고서와 함께 세션 타임랩스 영상을 남긴다 — 아침의 사람이 40초로 밤을 본다),
|
|
62
70
|
leave the questions in one place, and STOP. Do not invent new cards to look
|
|
63
71
|
productive. 밤새 카드가 300장이 되는 것이 이 시스템이 죽는 방식이다 (plan.md 2.3).
|
|
72
|
+
Milestone cards are **never pickable** (they are grouping, not work — plan.md 3.8):
|
|
73
|
+
if the No-pickable detail lists them, that is normal — don't `done` or `abandon` a
|
|
74
|
+
milestone by hand; it auto-completes when its last member terminates (a `done` that
|
|
75
|
+
prints `◉ milestone 완료` closed it for you).
|
|
64
76
|
|
|
65
77
|
5. **Search before work.** `llm-wiki search "<keywords>"` before starting a card. If the
|
|
66
78
|
wall you are about to hit already has an abandoned card or an anti-pattern page, skip
|
package/templates/AGENTS.md
CHANGED
|
@@ -13,8 +13,9 @@ Files are the source of truth. Kanban card files are written ONLY via the CLI
|
|
|
13
13
|
| After fixing a bug / making a decision / discovering something | `wiki-log` skill → a Case in `doc/raw/YYYY-MM-DD.md` with verbatim error + `hash:` grounding |
|
|
14
14
|
| When raw logs have accumulated | `wiki-compile` skill → promote to `doc/wiki/` pages, then `llm-wiki compile index` |
|
|
15
15
|
| Sanity check of the knowledge base | `wiki-lint` skill or `llm-wiki lint` |
|
|
16
|
-
| Planning work (person present) | `kanban-plan` skill → cards via `llm-wiki card new "<title>"` |
|
|
16
|
+
| Planning work (person present) | `kanban-plan` skill → cards via `llm-wiki card new "<title>"`. A plan that splits into 2+ cards gets **one milestone card** (`--kind milestone`, members carry `--milestone`); milestones are never picked and auto-complete when all members terminate |
|
|
17
17
|
| Unattended execution | `work-loop` skill → `llm-wiki pick --claim <name>`, park judgment calls with `handoff` |
|
|
18
|
+
| Watching the board (human) | `llm-wiki monitor` — read-only live view (claims, elapsed, gates, terminal pile, milestones) at `http://127.0.0.1:4747` |
|
|
18
19
|
|
|
19
20
|
## Rules
|
|
20
21
|
|