@kybird/llm-wiki 0.2.2 → 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 +23 -10
- package/TROUBLESHOOTING.md +9 -0
- package/bin/llm-wiki.js +100 -12
- package/lib/auto-update.js +79 -0
- package/lib/find-doc-root.js +62 -8
- package/lib/init.js +42 -26
- package/lib/kanban-cmd.js +758 -335
- package/lib/kanban-monitor.js +544 -0
- package/lib/kanban-wait.js +215 -0
- package/lib/kanban.js +89 -13
- package/lib/local-today.js +9 -0
- package/lib/skills.js +351 -0
- package/lib/wiki-compile.js +373 -369
- package/lib/wiki-search.js +41 -39
- package/package.json +5 -2
- package/skills/kanban-plan/SKILL.md +21 -4
- package/skills/work-loop/SKILL.md +22 -2
- package/templates/AGENTS.md +6 -1
package/lib/kanban-cmd.js
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
// kanban 명령 계층 — 카드 쓰기는 전부 이 모듈의 CLI 경로로만 일어난다.
|
|
2
2
|
// 사람은 카드를 읽기만 하고, 에이전트는 이 명령들로만 고친다 (개선계획 §3.2).
|
|
3
3
|
// 서브커맨드:
|
|
4
|
-
// card new "<제목>" [--goal …] [--ac … …] [--depends a,b]
|
|
4
|
+
// card new "<제목>" [--goal …] [--ac … …] [--depends a,b] [--not-before YYYY-MM-DD]
|
|
5
|
+
// [--kind milestone] [--milestone "<마일스톤 제목>"]
|
|
5
6
|
// card show <제목>
|
|
6
|
-
// card edit <제목> [--goal …] [--ac … …] [--add-ac …] [--check-ac 1,2]
|
|
7
|
-
//
|
|
7
|
+
// card edit <제목> [--goal …] [--plan …] [--ac … …] [--add-ac …] [--check-ac 1,2]
|
|
8
|
+
// [--note …] [--renew-claim] [--depends a,b] [--add-depends a,b]
|
|
9
|
+
// [--remove-depends a,b] [--milestone "<마일스톤 제목>"]
|
|
10
|
+
// pick [--claim 이름] [--card 제목] 원자적 집기 (락 안에서)
|
|
8
11
|
// handoff <제목> --question "…" review로 park + 클레임 반납
|
|
9
12
|
// done <제목> --result "…" 완료 — Result 없으면 거부
|
|
10
13
|
// supersede <제목> --by a,b 대체 — 부모는 superseded/로 소멸
|
|
11
14
|
// abandon <제목> --reason "…" 폐기 — 사유 없으면 거부
|
|
12
|
-
// board
|
|
15
|
+
// board 유도 뷰 (컬럼/WIP/대기/만료 클레임) — 플래그 없음
|
|
16
|
+
// card new/edit은 모르는 플래그·값 없는 플래그·바꿀 것 없는 호출을 실패로 끝낸다 —
|
|
17
|
+
// no-op을 성공으로 보고하면 에이전트가 다음 단계를 거짓 전제 위에 세운다.
|
|
13
18
|
const fs = require('fs');
|
|
14
19
|
const path = require('path');
|
|
15
20
|
const { execSync } = require('child_process');
|
|
16
21
|
const { findDocRoot, loadConfig } = require('./find-doc-root');
|
|
17
22
|
const kanban = require('./kanban');
|
|
23
|
+
const { localToday } = require('./local-today');
|
|
18
24
|
|
|
19
25
|
// ── 공용 ────────────────────────────────────────────────────────────────
|
|
20
26
|
|
|
@@ -31,10 +37,16 @@ function parseArgs(rest) {
|
|
|
31
37
|
value = arg.slice(eq + 1);
|
|
32
38
|
} else {
|
|
33
39
|
key = arg.slice(2);
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
// 다음 토큰이 또 플래그면 값을 삼키지 않는다 — `done --result --yes`가
|
|
41
|
+
// result='--yes'로 기록되던 결함(2026-09-09 리뷰 5-2). 값 자체가 --로
|
|
42
|
+
// 시작해야 하면 `--result=--x` 등호 형식으로 쓴다.
|
|
43
|
+
if (rest[i + 1] !== undefined && !rest[i + 1].startsWith('--')) {
|
|
44
|
+
value = rest[i + 1];
|
|
45
|
+
i++;
|
|
46
|
+
} else {
|
|
47
|
+
value = true; // 값 없는 플래그 — 필수 문자열 플래그에 이 값이 오면 거부된다.
|
|
48
|
+
}
|
|
36
49
|
}
|
|
37
|
-
value = value === undefined ? true : value;
|
|
38
50
|
// 같은 플래그 반복(--ac a --ac b)은 배열로 누적.
|
|
39
51
|
if (flags[key] !== undefined) {
|
|
40
52
|
flags[key] = [].concat(flags[key], value);
|
|
@@ -52,6 +64,40 @@ function asArray(v) {
|
|
|
52
64
|
return v === undefined ? [] : [].concat(v);
|
|
53
65
|
}
|
|
54
66
|
|
|
67
|
+
// 필수 문자열 플래그 검증 — 값 없는 `--result` 등은 parseArgs가 true를 심는다.
|
|
68
|
+
// 그대로 쓰면 기록에 문자열 'true'가 남는다(2026-09-09 리뷰 5-2). 문자열만 통과.
|
|
69
|
+
function flagString(v) {
|
|
70
|
+
return typeof v === 'string' && v.trim() ? v : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 선택 문자열 플래그 — 배열 원소 중 문자열만 (값 없는 반복 플래그의 true 제거).
|
|
74
|
+
function stringArray(v) {
|
|
75
|
+
return asArray(v).filter(s => typeof s === 'string');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 플래그 명세 검증 (card new/edit 공용) — 모르는 플래그와 값 없는 필수 플래그를
|
|
79
|
+
// 조용히 삼키면 no-op이 성공으로 보고된다(2026-09-10 접수 결함 A). 주 사용자는
|
|
80
|
+
// 종료 코드와 출력으로만 결과를 판단하는 에이전트다 — 오타 하나가 조용한 성공이
|
|
81
|
+
// 되면 다음 단계가 "걸렸다"를 전제로 진행된다.
|
|
82
|
+
// spec: { 이름: 'string' | 'flag' } — 'string'은 값 필수(반복 허용), 'flag'는 값 없는 스위치.
|
|
83
|
+
function validateFlags(flags, spec) {
|
|
84
|
+
const unknown = Object.keys(flags).filter(k => !(k in spec));
|
|
85
|
+
if (unknown.length) {
|
|
86
|
+
fail(`모르는 플래그: ${unknown.map(k => `--${k}`).join(', ')} — 쓸 수 있는 플래그: ${Object.keys(spec).map(k => `--${k}`).join(', ')}`);
|
|
87
|
+
}
|
|
88
|
+
for (const key of Object.keys(spec)) {
|
|
89
|
+
if (spec[key] !== 'string' || flags[key] === undefined) continue;
|
|
90
|
+
if ([].concat(flags[key]).some(v => typeof v !== 'string' || !String(v).trim())) {
|
|
91
|
+
fail(`--${key} 에 값이 없다 — \`--${key} "<값>"\` 또는 \`--${key}=값\` 형태로 줘야 한다.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 쉼표 목록 플래그("--depends a,b" → [a, b]) — validateFlags가 값 있음을 보증한다.
|
|
97
|
+
function csvList(v) {
|
|
98
|
+
return stringArray(v).flatMap(s => String(s).split(',')).map(s => s.trim()).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
55
101
|
// 로컬 타임존 ISO (분 단위) — 2026-08-29T23:14+09:00 형태. 클레임 만료 판정은
|
|
56
102
|
// Date.parse가 오프셋을 이해하므로 이 형태로 충분하다.
|
|
57
103
|
function localIso(date = new Date()) {
|
|
@@ -68,118 +114,333 @@ function fail(message) {
|
|
|
68
114
|
process.exit(1);
|
|
69
115
|
}
|
|
70
116
|
|
|
71
|
-
// 보드가 스캐폴드돼 있어야 한다 — 없으면 init으로 안내.
|
|
117
|
+
// 보드가 스캐폴드돼 있어야 한다 — 없으면 init으로 안내. 에러는 실제 탐색 루트와
|
|
118
|
+
// LLM_WIKI_ROOT 값을 함께 보고한다 — 오버라이드가 다른 곳을 보고 있었다는 단서.
|
|
72
119
|
function requireBoard() {
|
|
73
120
|
const docRoot = findDocRoot();
|
|
74
121
|
const paths = kanban.kanbanPaths(docRoot);
|
|
75
122
|
if (!fs.existsSync(paths.kanbanDir)) {
|
|
76
|
-
fail(
|
|
123
|
+
fail(`doc/kanban/ 이 없다 (${paths.kanbanDir}${process.env.LLM_WIKI_ROOT ? ` — LLM_WIKI_ROOT=${process.env.LLM_WIKI_ROOT}` : ''}). 먼저 \`llm-wiki init\`을 실행하라.`);
|
|
77
124
|
}
|
|
78
125
|
return { docRoot, paths, config: kanban.loadConfig(paths) };
|
|
79
126
|
}
|
|
80
127
|
|
|
81
128
|
function nowIso() { return localIso(); }
|
|
82
129
|
|
|
130
|
+
// ── depends_on 검증 (card new/edit 공용) ──────────────────────────────────
|
|
131
|
+
// 의존성은 보통 카드를 만들 때가 아니라 계획하다가 알게 된다 — card new 때만 걸 수
|
|
132
|
+
// 있으면 선행 카드가 늦게 생기는 정상 흐름을 기록할 길이 없다(2026-09-10 결함 B).
|
|
133
|
+
// 여기는 쓰는 규칙만 정한다 — pick이 읽는 규칙(resolved 판정)은 바꾸지 않는다.
|
|
134
|
+
|
|
135
|
+
// start에서 target까지 depends_on 간선을 따라 닿는 경로 — 순환 보고용. 없으면 null.
|
|
136
|
+
function depCyclePath(graph, start, target) {
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
const walk = (node, trail) => {
|
|
139
|
+
if (node === target) return trail;
|
|
140
|
+
if (seen.has(node)) return null;
|
|
141
|
+
seen.add(node);
|
|
142
|
+
for (const next of graph.get(node) || []) {
|
|
143
|
+
const found = walk(next, [...trail, next]);
|
|
144
|
+
if (found) return found;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
};
|
|
148
|
+
return walk(start, [start]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 새로 거는 의존성(deps)만 검증한다 — 이미 있던 것의 제거는 레거시의 끊어진 간선을
|
|
152
|
+
// 정리하는 길이어야 한다. 카드는 4폴더를 딱 한 번 로드한다(findCard 1회와 같은
|
|
153
|
+
// 비용) — 락 안에서 도니 반복 로드를 피한다.
|
|
154
|
+
function validateDepTargets(paths, title, deps) {
|
|
155
|
+
if (deps.includes(title)) {
|
|
156
|
+
fail(`자기 자신을 의존성으로 넣을 수 없다: ${title}`);
|
|
157
|
+
}
|
|
158
|
+
const all = kanban.listAllCards(paths);
|
|
159
|
+
const known = new Set(all.map(c => c.meta.title));
|
|
160
|
+
const missing = deps.filter(d => !known.has(d));
|
|
161
|
+
if (missing.length) {
|
|
162
|
+
fail(`의존성 카드가 없다: ${missing.join(', ')} — 끊어진 의존성은 pick을 영구히 막는다. card new로 먼저 만들어라.`);
|
|
163
|
+
}
|
|
164
|
+
// 순환은 활성(cards/) 카드의 간선으로만 판정한다 — 종결 카드를 의존성으로 넣는 것은
|
|
165
|
+
// 허용되고(pick의 resolved 판정: done/superseded는 이미 해소), 종결 카드 자신의
|
|
166
|
+
// depends_on은 아무도 기다리지 않는 죽은 이력이라 순환을 만들지 않는다.
|
|
167
|
+
const graph = new Map(
|
|
168
|
+
all
|
|
169
|
+
.filter(c => kanban.ACTIVE_STATUSES.includes(c.meta.status))
|
|
170
|
+
.map(c => [c.meta.title, Array.isArray(c.meta.depends_on) ? c.meta.depends_on : []])
|
|
171
|
+
);
|
|
172
|
+
for (const d of deps) {
|
|
173
|
+
const cyc = depCyclePath(graph, d, title);
|
|
174
|
+
if (cyc) fail(`순환 의존성이다: ${[title, ...cyc].join(' → ')} — 순환은 두 카드를 다 영구히 못 집게 만든다.`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
83
178
|
// ── card new / show / edit ──────────────────────────────────────────────
|
|
84
179
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
180
|
+
const CARD_NEW_FLAGS = { goal: 'string', ac: 'string', depends: 'string', 'not-before': 'string', kind: 'string', milestone: 'string' };
|
|
181
|
+
const CARD_EDIT_FLAGS = {
|
|
182
|
+
goal: 'string', plan: 'string',
|
|
183
|
+
ac: 'string', 'add-ac': 'string', 'check-ac': 'string',
|
|
184
|
+
note: 'string', 'renew-claim': 'flag',
|
|
185
|
+
depends: 'string', 'add-depends': 'string', 'remove-depends': 'string',
|
|
186
|
+
milestone: 'string',
|
|
187
|
+
};
|
|
88
188
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
189
|
+
// 각 명령의 사용법 문자열 — fail(인자 오류)과 bin의 --help 가드가 같은 문자열을
|
|
190
|
+
// 쓴다(2026-09-11·12 사고: `pick --help`를 확인하려다 실제로 카드를 집었다). 두
|
|
191
|
+
// 군데로 갈라지면 어느 한쪽만 갱신돼 거짓 사용법을 가르치게 된다.
|
|
192
|
+
const USAGE = {
|
|
193
|
+
'card new': '사용법: llm-wiki card new "<제목>" [--goal …] [--ac …] [--depends a,b] [--not-before YYYY-MM-DD] [--kind milestone] [--milestone "<마일스톤 제목>"]',
|
|
194
|
+
'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 "<마일스톤 제목>"]',
|
|
196
|
+
card: '사용법: llm-wiki card <new|show|edit> …',
|
|
197
|
+
handoff: '사용법: llm-wiki handoff <제목> --question "…"',
|
|
198
|
+
done: '사용법: llm-wiki done <제목> --result "…" — Result 없는 완료는 거부한다.',
|
|
199
|
+
supersede: '사용법: llm-wiki supersede <제목> --by 자식1,자식2',
|
|
200
|
+
abandon: '사용법: llm-wiki abandon <제목> --reason "…" — 폐기 사유가 안티패턴의 원재료다.',
|
|
201
|
+
reopen: '사용법: llm-wiki reopen <제목> --why "…" — 되돌림 사유가 기록되지 않으면 QA가 아니다.',
|
|
202
|
+
resume: '사용법: llm-wiki resume <제목> [--note "…"]',
|
|
203
|
+
board: '사용법: llm-wiki board — 텍스트 유도 뷰(플래그 없음). 하위: board report [--json] 대시보드 · board video 타임랩스',
|
|
204
|
+
monitor: '사용법: llm-wiki monitor [--port <번호>] — 읽기 전용 실시간 보드 뷰어(127.0.0.1, 기본 4747)',
|
|
205
|
+
pick: '사용법: llm-wiki pick [--claim <이름>] [--card "<제목>"] — --card 없으면 ordinal 최저를 집는다',
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// 카드를 집거나 끝내는 명령의 플래그 명세 — card new/edit과 같은 규칙으로
|
|
209
|
+
// validateFlags를 태운다(2026-09-12 사고: 없는 pick --card 플래그가 검증 없이
|
|
210
|
+
// 위임 프롬프트에 들어가 무시됐고 다른 카드가 집혔다). spec은 지금 코드가 실제로
|
|
211
|
+
// 읽는 플래그만 담는다 — 임의 플래그 추가가 아니라 오타·조용한 no-op 차단이 목적.
|
|
212
|
+
const PICK_FLAGS = { claim: 'string', card: 'string' };
|
|
213
|
+
const HANDOFF_FLAGS = { question: 'string' };
|
|
214
|
+
const DONE_FLAGS = { result: 'string' };
|
|
215
|
+
const SUPERSEDE_FLAGS = { by: 'string' };
|
|
216
|
+
const ABANDON_FLAGS = { reason: 'string', 'no-raw-log': 'flag' };
|
|
217
|
+
const REOPEN_FLAGS = { why: 'string', reason: 'string' };
|
|
218
|
+
const RESUME_FLAGS = { note: 'string' };
|
|
219
|
+
|
|
220
|
+
// ── milestone 검증 (card new/edit 공용) ───────────────────────────────────
|
|
221
|
+
// 소속 대상은 존재하고 kind: milestone이어야 하며, 마일스톤 카드 자신은 다른
|
|
222
|
+
// 마일스톤에 속할 수 없다(평면 — plan.md 3.8). 검증은 쓰기 전에 — 의존성과 같은
|
|
223
|
+
// 규칙(끊어진 소속은 회고 뷰를 영구히 비뚜러뜨린다).
|
|
224
|
+
function validateMilestoneTarget(paths, milestoneTitle, selfKind) {
|
|
225
|
+
const target = kanban.findCard(paths, milestoneTitle);
|
|
226
|
+
if (!target) {
|
|
227
|
+
fail(`마일스톤 카드가 없다: ${milestoneTitle} — \`card new "${milestoneTitle}" --kind milestone\` 로 먼저 만들어라.`);
|
|
228
|
+
}
|
|
229
|
+
if (target.meta.kind !== 'milestone') {
|
|
230
|
+
fail(`마일스톤 대상이 마일스톤 카드가 아니다: ${milestoneTitle} — 일반 카드에는 소속을 걸 수 없다.`);
|
|
93
231
|
}
|
|
232
|
+
if (selfKind === 'milestone') {
|
|
233
|
+
fail(`마일스톤 카드는 다른 마일스톤에 속할 수 없다(평면 — plan.md 3.8): ${milestoneTitle}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
94
236
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
237
|
+
function cardNew(paths, config, { positional, flags }) {
|
|
238
|
+
return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
|
|
239
|
+
const title = positional[0];
|
|
240
|
+
if (!title) fail(USAGE['card new']);
|
|
241
|
+
validateFlags(flags, CARD_NEW_FLAGS);
|
|
242
|
+
|
|
243
|
+
const fileName = `${kanban.slugify(title)}.md`;
|
|
244
|
+
const filePath = path.join(paths.cardsDir, fileName);
|
|
245
|
+
if (fs.existsSync(filePath)) {
|
|
246
|
+
fail(`같은 제목 카드가 이미 있다: ${filePath} — 제목이 곧 식별자다(3.5). 다른 제목을 쓰거나 supersede로 대체하라.`);
|
|
247
|
+
}
|
|
248
|
+
// cards/뿐 아니라 종결 3폴더까지 검사 — 예전엔 cards/만 보아 같은 제목 재생성을
|
|
249
|
+
// 허용했고, 그 카드의 종결이 아카이브 원본을 덮어썼다(2026-09-09 리뷰 5-1-3).
|
|
250
|
+
const existingAnywhere = kanban.findCard(paths, title);
|
|
251
|
+
if (existingAnywhere) {
|
|
252
|
+
fail(`같은 제목 카드가 이미 있다: ${existingAnywhere.filePath} — 제목이 곧 식별자다(3.5). 종결 기록과 충돌하므로 다른 제목을 쓰거나 기존 카드를 reopen/resume하라.`);
|
|
253
|
+
}
|
|
254
|
+
// 슬러그 충돌도 같은 파일명이 된다 — slugify가 '/', ':' 등을 같은 '-'로 접으므로
|
|
255
|
+
// 'a/b'와 'a:b'는 같은 파일에 쓴다. 두 번째 생성이 첫 카드를 조용히 덮어쓰는
|
|
256
|
+
// 일을 만들지 않는다(2026-09-09 리뷰 5-2).
|
|
257
|
+
const slugClash = ['cardsDir', 'doneDir', 'supersededDir', 'abandonedDir']
|
|
258
|
+
.map(k => paths[k])
|
|
259
|
+
.filter(dir => fs.existsSync(dir))
|
|
260
|
+
.some(dir => fs.readdirSync(dir).includes(fileName));
|
|
261
|
+
if (slugClash) {
|
|
262
|
+
fail(`같은 파일명(${fileName})이 이미 있다 — 제목이 달라도 슬러그가 같으면 한 파일을 공유해 덮어쓴다. 제목을 달리하라.`);
|
|
263
|
+
}
|
|
98
264
|
|
|
99
|
-
|
|
100
|
-
|
|
265
|
+
// ordinal: float, 스텝 1000, 컬럼(todo) 내 순서 (Backlog.md 방식).
|
|
266
|
+
const ordinals = kanban.listCardsInDir(paths.cardsDir).map(c => Number(c.meta.ordinal) || 0);
|
|
267
|
+
const ordinal = ordinals.length ? Math.max(...ordinals) + 1000 : 1000;
|
|
101
268
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
};
|
|
108
|
-
if (dependsOn.length) meta.depends_on = dependsOn;
|
|
109
|
-
// 시간 게이트 — 이 날짜 전에는 pick이 집지 않는다 (조건만족시 진행, plan.md 9번 원칙의 기계적 절반).
|
|
110
|
-
if (flags['not-before']) meta.not_before = String(flags['not-before']);
|
|
111
|
-
|
|
112
|
-
const parsed = {
|
|
113
|
-
goal: flags.goal ? String(flags.goal) : '',
|
|
114
|
-
ac: acTexts.map((text, i) => ({ checked: false, idx: i + 1, text })),
|
|
115
|
-
sections: new Map([['Plan', ''], ['Notes', ''], ['Handoff', ''], ['Result', '']]),
|
|
116
|
-
};
|
|
269
|
+
const dependsOn = csvList(flags.depends);
|
|
270
|
+
// 의존성 검증은 파일을 쓰기 전에 — 결함 B의 쓰기 규칙을 card new에도 같이
|
|
271
|
+
// 적용한다(어디는 엄격하고 어디는 관대하면 규칙을 못 배운다).
|
|
272
|
+
if (dependsOn.length) validateDepTargets(paths, title, dependsOn);
|
|
273
|
+
const acTexts = stringArray(flags.ac);
|
|
117
274
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
275
|
+
// kind — 현재 유일한 값은 'milestone'. 일반 카드는 kind를 아예 안 적는다:
|
|
276
|
+
// 기본값 필드는 전 카드에 누적되는 잡음이다.
|
|
277
|
+
if (flags.kind !== undefined && flags.kind !== 'milestone') {
|
|
278
|
+
fail(`--kind 값이 잘못됐다: ${flags.kind} — 쓸 수 있는 값: milestone`);
|
|
279
|
+
}
|
|
280
|
+
// 소속 검증도 쓰기 전에(같은 규칙) — 대상·평면 위반은 여기서 막힌다.
|
|
281
|
+
const milestoneTitle = flagString(flags.milestone);
|
|
282
|
+
if (milestoneTitle) validateMilestoneTarget(paths, milestoneTitle, flags.kind);
|
|
283
|
+
|
|
284
|
+
const meta = {
|
|
285
|
+
title,
|
|
286
|
+
status: 'todo',
|
|
287
|
+
ordinal,
|
|
288
|
+
created: localToday(),
|
|
289
|
+
};
|
|
290
|
+
if (flags.kind === 'milestone') meta.kind = 'milestone';
|
|
291
|
+
if (dependsOn.length) meta.depends_on = dependsOn;
|
|
292
|
+
// 시간 게이트 — 이 날짜 전에는 pick이 집지 않는다 (조건만족시 진행, plan.md 9번 원칙의 기계적 절반).
|
|
293
|
+
if (typeof flags['not-before'] === 'string') meta.not_before = flags['not-before'];
|
|
294
|
+
// 계획 단위 소속 (plan.md 3.8) — 마일스톤의 진행 상태는 전부 유도된다.
|
|
295
|
+
if (milestoneTitle) meta.milestone = milestoneTitle;
|
|
296
|
+
|
|
297
|
+
const parsed = {
|
|
298
|
+
goal: flagString(flags.goal) || '',
|
|
299
|
+
ac: acTexts.map((text, i) => ({ checked: false, idx: i + 1, text: String(text) })),
|
|
300
|
+
sections: new Map([['Plan', ''], ['Notes', ''], ['Handoff', ''], ['Result', '']]),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
fs.writeFileSync(filePath, kanban.serializeCard(meta, parsed));
|
|
304
|
+
kanban.appendActivity(paths, { action: 'created', title });
|
|
305
|
+
console.log(`Card created: ${filePath}`);
|
|
306
|
+
console.log(` ordinal: ${ordinal}${flags.kind === 'milestone' ? ', kind: milestone' : ''}${dependsOn.length ? `, depends_on: ${dependsOn.join(', ')}` : ''}${milestoneTitle ? `, milestone: ${milestoneTitle}` : ''}`);
|
|
307
|
+
});
|
|
122
308
|
}
|
|
123
309
|
|
|
124
310
|
function cardShow(paths, { positional }) {
|
|
125
311
|
const title = positional[0];
|
|
126
|
-
if (!title) fail('
|
|
312
|
+
if (!title) fail(USAGE['card show']);
|
|
127
313
|
const cardObj = kanban.findCard(paths, title);
|
|
128
314
|
if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
|
|
129
315
|
console.log(fs.readFileSync(cardObj.filePath, 'utf8'));
|
|
130
316
|
}
|
|
131
317
|
|
|
132
318
|
function cardEdit(paths, { positional, flags }) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
319
|
+
return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
|
|
320
|
+
const title = positional[0];
|
|
321
|
+
if (!title) {
|
|
322
|
+
fail(USAGE['card edit']);
|
|
323
|
+
}
|
|
324
|
+
// 검증은 파일을 건드리기 전에 — 모르는 플래그(오타)·값 없는 플래그·바꿀 것 없는
|
|
325
|
+
// 호출은 전부 조용한 no-op이 성공으로 보고되던 길이다(결함 A).
|
|
326
|
+
validateFlags(flags, CARD_EDIT_FLAGS);
|
|
327
|
+
if (!Object.keys(CARD_EDIT_FLAGS).some(k => flags[k] !== undefined)) {
|
|
328
|
+
fail('바꿀 것이 지정되지 않았다 — 최소한 하나의 플래그가 필요하다.');
|
|
329
|
+
}
|
|
330
|
+
const cardObj = kanban.findCard(paths, title);
|
|
331
|
+
if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
|
|
139
332
|
|
|
140
|
-
|
|
141
|
-
|
|
333
|
+
const meta = { ...cardObj.meta };
|
|
334
|
+
const parsed = { goal: cardObj.card.goal, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
|
|
335
|
+
const changes = [];
|
|
142
336
|
|
|
143
|
-
|
|
144
|
-
|
|
337
|
+
if (typeof flags.goal === 'string') { parsed.goal = flags.goal; changes.push('goal'); }
|
|
338
|
+
if (typeof flags.plan === 'string') { parsed.sections.set('Plan', flags.plan); changes.push('plan'); }
|
|
145
339
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
340
|
+
// --ac는 목록 전체 교체, --add-ac는 뒤에 붙이기(번호는 최대+1 — 재정렬 후에도 #N 안정).
|
|
341
|
+
const replaceAc = stringArray(flags.ac);
|
|
342
|
+
if (replaceAc.length) {
|
|
343
|
+
parsed.ac = replaceAc.map((text, i) => ({ checked: false, idx: i + 1, text: String(text) }));
|
|
344
|
+
changes.push(`ac=${replaceAc.length}`);
|
|
345
|
+
}
|
|
346
|
+
const addAc = stringArray(flags['add-ac']);
|
|
347
|
+
if (addAc.length) {
|
|
348
|
+
let nextIdx = parsed.ac.reduce((m, a) => Math.max(m, a.idx), 0) + 1;
|
|
349
|
+
for (const text of addAc) parsed.ac.push({ checked: false, idx: nextIdx++, text: String(text) });
|
|
350
|
+
changes.push(`ac +${addAc.length}`);
|
|
351
|
+
}
|
|
154
352
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
353
|
+
// --check-ac 1,2 — AC는 객관적 증거로만 체크하라는 건 에이전트의 규율(루프 스킬)이고
|
|
354
|
+
// CLI는 번호의 안전성(#N)만 보장한다.
|
|
355
|
+
if (flags['check-ac'] !== undefined) {
|
|
356
|
+
const idxs = asArray(flags['check-ac']).map(String).join(',').split(',').map(s => Number(s.trim()));
|
|
357
|
+
for (const idx of idxs) {
|
|
358
|
+
if (!Number.isInteger(idx) || idx < 1) fail(`--check-ac 는 AC 번호(쉼표 구분)를 받는다 — 받은 값: ${String(flags['check-ac'])}`);
|
|
359
|
+
const ac = parsed.ac.find(a => a.idx === idx);
|
|
360
|
+
if (!ac) fail(`AC #${idx} 가 없다 — 번호는 재정렬해도 유지된다.`);
|
|
361
|
+
ac.checked = true;
|
|
362
|
+
}
|
|
363
|
+
changes.push(`check-ac #${idxs.join(', #')}`);
|
|
163
364
|
}
|
|
164
|
-
}
|
|
165
365
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
366
|
+
// Notes는 append-only 저널 — 타임스탬프와 함께 덧붙인다.
|
|
367
|
+
const notes = stringArray(flags.note);
|
|
368
|
+
if (notes.length) {
|
|
369
|
+
const existing = parsed.sections.get('Notes') || '';
|
|
370
|
+
const stamped = notes.map(n => `- ${nowIso()} — ${n}`).join('\n');
|
|
371
|
+
parsed.sections.set('Notes', existing ? `${existing}\n${stamped}` : stamped);
|
|
372
|
+
changes.push(`note +${notes.length}`);
|
|
373
|
+
}
|
|
173
374
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
375
|
+
// depends_on 편집 — --depends 전체 교체 → --add-depends 합집합 → --remove-depends
|
|
376
|
+
// 차집합 순서(ac 계열 어법). 종결 카드의 의존성은 pick이 읽지 않는 이력이다.
|
|
377
|
+
const replaceDeps = csvList(flags.depends);
|
|
378
|
+
const addDeps = csvList(flags['add-depends']);
|
|
379
|
+
const removeDeps = csvList(flags['remove-depends']);
|
|
380
|
+
if (replaceDeps.length || addDeps.length || removeDeps.length) {
|
|
381
|
+
if (!kanban.ACTIVE_STATUSES.includes(cardObj.meta.status)) {
|
|
382
|
+
fail(`종결 카드(${cardObj.meta.status})의 의존성은 바꾸지 않는다 — pick이 이미 기다리지 않는 이력이다: ${cardObj.filePath}`);
|
|
383
|
+
}
|
|
384
|
+
const before = Array.isArray(meta.depends_on) ? [...new Set(meta.depends_on)] : [];
|
|
385
|
+
let next = replaceDeps.length ? [...new Set(replaceDeps)] : [...before];
|
|
386
|
+
for (const d of [...new Set(addDeps)]) {
|
|
387
|
+
if (!next.includes(d)) next.push(d);
|
|
388
|
+
}
|
|
389
|
+
// 제거 대상이 목록에 없으면 오타일 확률이 높다 — 조용히 성공하면 "걸었다"고
|
|
390
|
+
// 믿은 채 진행된다(check-ac가 없는 번호를 거부하는 것과 같은 규칙).
|
|
391
|
+
const absent = removeDeps.filter(d => !next.includes(d));
|
|
392
|
+
if (absent.length) {
|
|
393
|
+
fail(`제거할 의존성이 이 카드에 없다: ${absent.join(', ')} — 현재 depends_on: [${next.join(', ') || '없음'}]`);
|
|
394
|
+
}
|
|
395
|
+
next = next.filter(d => !removeDeps.includes(d));
|
|
396
|
+
// 검증은 새로 들어온 간선에만 — 이미 있던 의존성의 제거(레거시 정리)는
|
|
397
|
+
// 대상 카드가 살아있어야 하는 게 아니다.
|
|
398
|
+
const introduced = next.filter(d => !before.includes(d));
|
|
399
|
+
validateDepTargets(paths, title, introduced);
|
|
400
|
+
const removed = before.filter(d => !next.includes(d));
|
|
401
|
+
if (introduced.length) changes.push(`depends +${introduced.join(', ')}`);
|
|
402
|
+
if (removed.length) changes.push(`depends -${removed.join(', ')}`);
|
|
403
|
+
if (next.length) meta.depends_on = next;
|
|
404
|
+
else delete meta.depends_on;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// --milestone 재지정 — 소속은 활성 카드에만 건다. 종결 카드의 소속은 회고
|
|
408
|
+
// 뷰(마일스톤 패널·완료율)가 읽는 이력이라 소급 재편하지 않는다(plan.md 3.8).
|
|
409
|
+
const milestoneTitle = flagString(flags.milestone);
|
|
410
|
+
if (milestoneTitle) {
|
|
411
|
+
if (!kanban.ACTIVE_STATUSES.includes(cardObj.meta.status)) {
|
|
412
|
+
fail(`종결 카드(${cardObj.meta.status})의 마일스톤은 바꾸지 않는다 — 회고 뷰가 읽는 이력이다: ${cardObj.filePath}`);
|
|
413
|
+
}
|
|
414
|
+
validateMilestoneTarget(paths, milestoneTitle, cardObj.meta.kind);
|
|
415
|
+
if (meta.milestone !== milestoneTitle) {
|
|
416
|
+
meta.milestone = milestoneTitle;
|
|
417
|
+
changes.push(`milestone=${milestoneTitle}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// --renew-claim — 긴 카드는 타임아웃 전에 클레임을 갱신한다 (루프 스킬 규칙 6).
|
|
422
|
+
// 클레임은 만료 있는 협동 락이므로, 살아있는 작업자는 주기적으로 시각을 다시 심는다.
|
|
423
|
+
// 락 안에서 만료를 재검증한다 — 만료된 클레임의 갱신은 다른 세션이 이미 재집어
|
|
424
|
+
// 작업 중인 카드의 시각을 되살려 이중 작업을 만든다. 갱신 거부 → 재집기(pick) 유도.
|
|
425
|
+
if (flags['renew-claim']) {
|
|
426
|
+
if (!meta.claimed_by) fail(`클레임이 없는 카드다: ${title} — renew가 아니라 pick으로 집어라.`);
|
|
427
|
+
const config = kanban.loadConfig(paths);
|
|
428
|
+
if (kanban.isClaimExpired({ meta }, config)) {
|
|
429
|
+
fail(`클레임이 만료됐다 (${meta.claimed_at}) — 다른 세션이 재집었을 수 있다. pick으로 다시 집어라.`);
|
|
430
|
+
}
|
|
431
|
+
meta.claimed_at = nowIso();
|
|
432
|
+
changes.push('renew-claim');
|
|
433
|
+
}
|
|
180
434
|
|
|
181
|
-
|
|
182
|
-
|
|
435
|
+
// 성공 문구는 실제로 무엇이 바뀌었는지 말한다. 플래그는 있었지만 결과가 동일하면
|
|
436
|
+
// (멱등 재시도) 성공을 주장하지 않고 파일도 다시 쓰지 않는다.
|
|
437
|
+
if (!changes.length) {
|
|
438
|
+
console.log(`변경 없음 — 이미 그 상태다: ${cardObj.filePath}`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
kanban.writeCard(cardObj, meta, parsed);
|
|
442
|
+
console.log(`Card edited: ${cardObj.filePath} (${changes.join(', ')})`);
|
|
443
|
+
});
|
|
183
444
|
}
|
|
184
445
|
|
|
185
446
|
// QA 루프의 되돌림 원시형 (개선계획 4-3) — 증거 빈약한 완료를 doing으로 역류시킨다.
|
|
@@ -188,40 +449,101 @@ function reopen(options = {}) {
|
|
|
188
449
|
const { paths } = requireBoard();
|
|
189
450
|
const { positional, flags } = parseArgs(options.rest || []);
|
|
190
451
|
const title = positional[0];
|
|
191
|
-
const why = flags.why || flags.reason;
|
|
192
|
-
if (!title || !why) fail(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
452
|
+
const why = flagString(flags.why) || flagString(flags.reason);
|
|
453
|
+
if (!title || !why) fail(USAGE.reopen);
|
|
454
|
+
validateFlags(flags, REOPEN_FLAGS);
|
|
455
|
+
|
|
456
|
+
return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
|
|
457
|
+
const cardObj = kanban.findCard(paths, title);
|
|
458
|
+
if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
|
|
459
|
+
if (cardObj.meta.status !== 'done') fail(`done 카드만 되돌린다 (현재: ${cardObj.meta.status}): ${cardObj.filePath}`);
|
|
460
|
+
|
|
461
|
+
const content = fs.readFileSync(cardObj.filePath, 'utf8');
|
|
462
|
+
const meta = kanban.parseFrontmatter(content);
|
|
463
|
+
const parsed = kanban.parseBody(content);
|
|
464
|
+
meta.status = 'doing';
|
|
465
|
+
delete meta.claimed_by;
|
|
466
|
+
delete meta.claimed_at;
|
|
467
|
+
const notes = parsed.sections.get('Notes') || '';
|
|
468
|
+
const line = `- ${nowIso()} — REVERTED: ${why}`;
|
|
469
|
+
parsed.sections.set('Notes', notes ? `${notes}\n${line}` : line);
|
|
470
|
+
|
|
471
|
+
fs.writeFileSync(path.join(paths.cardsDir, cardObj.fileName), kanban.serializeCard(meta, parsed));
|
|
472
|
+
fs.unlinkSync(cardObj.filePath);
|
|
473
|
+
kanban.appendActivity(paths, { action: 'reverted', title: meta.title, detail: String(why) });
|
|
474
|
+
console.log(`Reverted to doing: ${path.join(paths.cardsDir, cardObj.fileName)}`);
|
|
475
|
+
|
|
476
|
+
// 마일스톤 역동기화 — 멤버가 되돌아왔는데 마일스톤이 이미 종결돼 있으면 같이
|
|
477
|
+
// 되돌린다. 자동 종결의 역방향: 안 하면 종결된 계획 아래 살아있는 멤버라는
|
|
478
|
+
// 썩은 상태(3.7 원칙 1)가 된다.
|
|
479
|
+
if (meta.milestone) {
|
|
480
|
+
const ms = kanban.findCard(paths, meta.milestone);
|
|
481
|
+
if (ms && ms.meta.kind === 'milestone' && kanban.TERMINAL_FOLDERS.includes(ms.meta.status)) {
|
|
482
|
+
const msMeta = kanban.parseFrontmatter(fs.readFileSync(ms.filePath, 'utf8'));
|
|
483
|
+
const msParsed = kanban.parseBody(fs.readFileSync(ms.filePath, 'utf8'));
|
|
484
|
+
msMeta.status = 'todo';
|
|
485
|
+
delete msMeta.claimed_by;
|
|
486
|
+
delete msMeta.claimed_at;
|
|
487
|
+
const msNotes = msParsed.sections.get('Notes') || '';
|
|
488
|
+
const msLine = `- ${nowIso()} — REVERTED: 멤버 '${meta.title}'의 reopen으로 마일스톤도 복귀`;
|
|
489
|
+
msParsed.sections.set('Notes', msNotes ? `${msNotes}\n${msLine}` : msLine);
|
|
490
|
+
fs.writeFileSync(path.join(paths.cardsDir, ms.fileName), kanban.serializeCard(msMeta, msParsed));
|
|
491
|
+
fs.unlinkSync(ms.filePath);
|
|
492
|
+
kanban.appendActivity(paths, { action: 'milestone-reverted', title: ms.meta.title, detail: `멤버 ${meta.title} reopen` });
|
|
493
|
+
console.log(`Milestone reverted to todo: ${path.join(paths.cardsDir, ms.fileName)}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
});
|
|
212
497
|
}
|
|
213
498
|
|
|
214
499
|
// ── pick — 원자적 집기 ───────────────────────────────────────────────────
|
|
215
500
|
|
|
216
501
|
// 준비 필터(미클레임/만료/의존 충족/WIP 여유) → ordinal 정렬 → 클레임 → doing.
|
|
217
502
|
// 전 과정을 락 안에서 — 동시 pick 빈틈을 처음부터 막고 출발한다 (kanban-md 교훈).
|
|
503
|
+
|
|
504
|
+
// 지정 집기(--card)의 게이트 판정 — 통상 pick의 후보 판정과 같은 규칙(review →
|
|
505
|
+
// not_before → 클레임 → todo 의존)을 지정 카드 한 장에 적용한다. --card는 우선권이
|
|
506
|
+
// 아니라 지정이다: 하나라도 걸리면 집지 않고 이 사유를 낸다. 통과면 null.
|
|
507
|
+
function pickGateBlock(cardObj, resolvedTitles, config) {
|
|
508
|
+
const status = cardObj.meta.status;
|
|
509
|
+
if (kanban.TERMINAL_FOLDERS.includes(status)) {
|
|
510
|
+
return `이미 종결됐다 (${status}): ${cardObj.filePath}`;
|
|
511
|
+
}
|
|
512
|
+
if (!kanban.ACTIVE_STATUSES.includes(status)) {
|
|
513
|
+
return `활성 카드가 아니다 (status: ${status || '없음'}): ${cardObj.filePath}`;
|
|
514
|
+
}
|
|
515
|
+
if (status === 'review') {
|
|
516
|
+
return '사람 판정 대기(review)다 — resume으로 todo에 복귀시킨 뒤 집어라';
|
|
517
|
+
}
|
|
518
|
+
if (cardObj.meta.kind === 'milestone') {
|
|
519
|
+
return '마일스톤 카드다 — 작업 항목이 아니라 목적 축이다. 소속 카드를 집어라 (plan.md 3.8)';
|
|
520
|
+
}
|
|
521
|
+
if (cardObj.meta.not_before && String(cardObj.meta.not_before) > localToday()) {
|
|
522
|
+
return `시작 예정: ${cardObj.meta.not_before} 이후 (not_before)`;
|
|
523
|
+
}
|
|
524
|
+
if (cardObj.meta.claimed_by && !kanban.isClaimExpired(cardObj, config)) {
|
|
525
|
+
return `클레임 중: ${cardObj.meta.claimed_by}`;
|
|
526
|
+
}
|
|
527
|
+
if (status === 'todo') {
|
|
528
|
+
const deps = Array.isArray(cardObj.meta.depends_on) ? cardObj.meta.depends_on : [];
|
|
529
|
+
const unmet = deps.filter(dep => !resolvedTitles.has(dep));
|
|
530
|
+
if (unmet.length) return `의존 미충족: ${unmet.join(', ')}`;
|
|
531
|
+
}
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
|
|
218
535
|
function pick(options = {}) {
|
|
219
536
|
const { paths, config } = requireBoard();
|
|
220
537
|
const { flags } = parseArgs(options.rest || []);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
538
|
+
validateFlags(flags, PICK_FLAGS);
|
|
539
|
+
const claimName = typeof flags.claim === 'string' && flags.claim ? flags.claim : 'unnamed-agent';
|
|
540
|
+
const wantTitle = flagString(flags.card) || null;
|
|
541
|
+
|
|
542
|
+
const result = kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
|
|
543
|
+
// cards/의 모든 .md가 카드는 아니다(README 등) — 활성 status가 없는 파일은
|
|
544
|
+
// 후보에서 제외한다. 예전엔 이를 집어 재직렬화하며 `## ` 앞 본문을 지웠다(5-1-4).
|
|
545
|
+
const active = kanban.listCardsInDir(paths.cardsDir)
|
|
546
|
+
.filter(c => kanban.ACTIVE_STATUSES.includes(c.meta.status));
|
|
225
547
|
const doing = active.filter(c => c.meta.status === 'doing');
|
|
226
548
|
const wipLimit = config.wipLimits.doing !== undefined ? config.wipLimits.doing : Infinity;
|
|
227
549
|
|
|
@@ -237,31 +559,49 @@ function pick(options = {}) {
|
|
|
237
559
|
|
|
238
560
|
const blocked = [];
|
|
239
561
|
const candidates = [];
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
// 시간 게이트 — not_before가 미래면 아직 시작하지 않는 카드다.
|
|
247
|
-
if (cardObj.meta.not_before && String(cardObj.meta.not_before) > today) {
|
|
248
|
-
blocked.push({ title: cardObj.meta.title, notBefore: cardObj.meta.not_before });
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// 클레임이 살아있는 doing은 남의 작업. 만료됐으면 재집기 후보 — 밤에 죽은
|
|
253
|
-
// 세션의 카드를 자동 회수하는 것이 협동 락의 존재 이유다.
|
|
254
|
-
if (cardObj.meta.claimed_by && !kanban.isClaimExpired(cardObj, config)) {
|
|
255
|
-
blocked.push({ title: cardObj.meta.title, claimedBy: cardObj.meta.claimed_by });
|
|
256
|
-
continue;
|
|
562
|
+
if (wantTitle) {
|
|
563
|
+
// 지정 집기 — 매칭은 findCard 규칙 그대로(제목이 곧 식별자, 3.5). 없는 제목은
|
|
564
|
+
// 인자 오류로 실패하고, 게이트에 막히면 이유를 내고 파일을 그대로 둔다.
|
|
565
|
+
const target = kanban.findCard(paths, wantTitle);
|
|
566
|
+
if (!target) {
|
|
567
|
+
fail(`--card 대상 카드를 못 찾았다: ${wantTitle} — board로 제목을 확인하라.`);
|
|
257
568
|
}
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
569
|
+
const why = pickGateBlock(target, resolvedTitles, config);
|
|
570
|
+
if (why) return { picked: null, reason: 'blocked', detail: `${target.meta.title} — ${why}` };
|
|
571
|
+
candidates.push(target);
|
|
572
|
+
} else {
|
|
573
|
+
const today = localToday(); // not_before 게이트 — 현지 날짜(5-1-5)
|
|
574
|
+
for (const cardObj of active) {
|
|
575
|
+
const status = cardObj.meta.status;
|
|
576
|
+
// 마일스톤 카드는 작업 대상이 아니다(3.8) — 집을 게 없을 때 이유로는
|
|
577
|
+
// 보고한다(사람이 보는 No pickable 상세).
|
|
578
|
+
if (cardObj.meta.kind === 'milestone') {
|
|
579
|
+
blocked.push({ title: cardObj.meta.title, isMilestone: true });
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
// review는 사람 판정 대기 — pick이 집지 않는다.
|
|
583
|
+
if (status === 'review') continue;
|
|
584
|
+
|
|
585
|
+
// 시간 게이트 — not_before가 미래면 아직 시작하지 않는 카드다.
|
|
586
|
+
if (cardObj.meta.not_before && String(cardObj.meta.not_before) > today) {
|
|
587
|
+
blocked.push({ title: cardObj.meta.title, notBefore: cardObj.meta.not_before });
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// 클레임이 살아있는 doing은 남의 작업. 만료됐으면 재집기 후보 — 밤에 죽은
|
|
592
|
+
// 세션의 카드를 자동 회수하는 것이 협동 락의 존재 이유다.
|
|
593
|
+
if (cardObj.meta.claimed_by && !kanban.isClaimExpired(cardObj, config)) {
|
|
594
|
+
blocked.push({ title: cardObj.meta.title, claimedBy: cardObj.meta.claimed_by });
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (status === 'todo') {
|
|
599
|
+
const deps = Array.isArray(cardObj.meta.depends_on) ? cardObj.meta.depends_on : [];
|
|
600
|
+
const unmet = deps.filter(dep => !resolvedTitles.has(dep));
|
|
601
|
+
if (unmet.length) { blocked.push({ title: cardObj.meta.title, unmet }); continue; }
|
|
602
|
+
}
|
|
603
|
+
candidates.push(cardObj);
|
|
263
604
|
}
|
|
264
|
-
candidates.push(cardObj);
|
|
265
605
|
}
|
|
266
606
|
|
|
267
607
|
candidates.sort((a, b) => (Number(a.meta.ordinal) || 0) - (Number(b.meta.ordinal) || 0));
|
|
@@ -290,6 +630,7 @@ function pick(options = {}) {
|
|
|
290
630
|
if (b.notBefore) console.log(` - ${b.title} — 시작 예정: ${b.notBefore} 이후 (not_before)`);
|
|
291
631
|
else if (b.unmet && b.unmet.length) console.log(` - ${b.title} — 의존 미충족: ${b.unmet.join(', ')}`);
|
|
292
632
|
else if (b.claimedBy) console.log(` - ${b.title} — 클레임 중: ${b.claimedBy}`);
|
|
633
|
+
else if (b.isMilestone) console.log(` - ${b.title} — 마일스톤 카드(집기 대상 아님, plan.md 3.8)`);
|
|
293
634
|
}
|
|
294
635
|
} else if (result.detail) {
|
|
295
636
|
console.log(` ${result.detail}`);
|
|
@@ -316,52 +657,96 @@ function handoff(options = {}) {
|
|
|
316
657
|
const { paths } = requireBoard();
|
|
317
658
|
const { positional, flags } = parseArgs(options.rest || []);
|
|
318
659
|
const title = positional[0];
|
|
319
|
-
const question = flags.question;
|
|
320
|
-
if (!title || !question) fail(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
660
|
+
const question = flagString(flags.question);
|
|
661
|
+
if (!title || !question) fail(USAGE.handoff);
|
|
662
|
+
validateFlags(flags, HANDOFF_FLAGS);
|
|
663
|
+
|
|
664
|
+
return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
|
|
665
|
+
const cardObj = requireActiveCard(paths, title);
|
|
666
|
+
const meta = { ...cardObj.meta };
|
|
667
|
+
const parsed = { ...cardObj.card, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
|
|
668
|
+
|
|
669
|
+
meta.status = 'review';
|
|
670
|
+
delete meta.claimed_by; // 클레임 반납 — park하고 다음 카드로
|
|
671
|
+
delete meta.claimed_at;
|
|
672
|
+
|
|
673
|
+
const existing = parsed.sections.get('Handoff') || '';
|
|
674
|
+
const line = `- ${nowIso()} — QUESTION: ${question}`;
|
|
675
|
+
parsed.sections.set('Handoff', existing ? `${existing}\n${line}` : line);
|
|
676
|
+
|
|
677
|
+
kanban.writeCard(cardObj, meta, parsed);
|
|
678
|
+
kanban.appendActivity(paths, { action: 'handoff', title: meta.title, detail: String(question) });
|
|
679
|
+
console.log(`Handed off (parked for human judgment): ${cardObj.filePath}`);
|
|
680
|
+
});
|
|
681
|
+
}
|
|
325
682
|
|
|
326
|
-
|
|
327
|
-
|
|
683
|
+
// ── 마일스톤 자동 종결 (done·supersede·abandon이 멤버 종결 뒤에 부른다) ────
|
|
684
|
+
// 마지막 멤버가 종결되는 순간 마일스톤도 종결한다 — 상태를 저장하면 반드시
|
|
685
|
+
// 어느 밤엔 잊히고 썩는다(3.7 원칙 1), 종결 사건이 일어나는 쓰기 안에서
|
|
686
|
+
// 동기화한다. review 대기 중이면 판정을 덮지 않고 보고만. 반드시 락 안에서.
|
|
687
|
+
function completeMilestoneIfAllTerminal(paths, milestoneTitle) {
|
|
688
|
+
const ms = kanban.findCard(paths, milestoneTitle);
|
|
689
|
+
if (!ms || ms.meta.kind !== 'milestone') {
|
|
690
|
+
console.log(` ⚠ 마일스톤 카드를 못 찾았다 — 소속 참조가 끊어졌다: ${milestoneTitle}`);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (kanban.TERMINAL_FOLDERS.includes(ms.meta.status)) return; // 이미 종결된 이력
|
|
694
|
+
const members = kanban.listAllCards(paths).filter(c => c.meta.milestone === milestoneTitle);
|
|
695
|
+
if (members.some(c => kanban.ACTIVE_STATUSES.includes(c.meta.status))) return; // 진행 중 멤버 남음
|
|
696
|
+
if (ms.meta.status === 'review') {
|
|
697
|
+
console.log(` 마일스톤 전원 종결 — 다만 판정 대기(review) 중이라 자동 종결하지 않는다: ${milestoneTitle} — resume 뒤 다음 종결 사건에 종결된다`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const counts = { done: 0, superseded: 0, abandoned: 0 };
|
|
701
|
+
for (const m of members) counts[m.meta.status] += 1;
|
|
702
|
+
const meta = { ...ms.meta };
|
|
703
|
+
const parsed = { ...ms.card, ac: ms.card.ac.map(a => ({ ...a })), sections: new Map(ms.card.sections) };
|
|
704
|
+
const result = `- ${nowIso()} — 전원 종결 자동 반영: 멤버 ${members.length}장 (done ${counts.done} · superseded ${counts.superseded} · abandoned ${counts.abandoned})`;
|
|
705
|
+
const existing = parsed.sections.get('Result') || '';
|
|
706
|
+
parsed.sections.set('Result', existing ? `${existing}\n${result}` : result);
|
|
707
|
+
delete meta.claimed_by;
|
|
328
708
|
delete meta.claimed_at;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
kanban.writeCard(cardObj, meta, parsed);
|
|
335
|
-
kanban.appendActivity(paths, { action: 'handoff', title: meta.title, detail: String(question) });
|
|
336
|
-
console.log(`Handed off (parked for human judgment): ${cardObj.filePath}`);
|
|
709
|
+
kanban.writeCard(ms, meta, parsed);
|
|
710
|
+
kanban.moveCardTo(ms, paths.doneDir, 'done');
|
|
711
|
+
kanban.appendActivity(paths, { action: 'milestone-done', title: ms.meta.title, detail: `멤버 ${members.length}장 전원 종결` });
|
|
712
|
+
console.log(` ◉ milestone 완료: '${ms.meta.title}' — 전원 종결 (${members.length}/${members.length}: done ${counts.done} · superseded ${counts.superseded} · abandoned ${counts.abandoned})`);
|
|
337
713
|
}
|
|
338
714
|
|
|
339
715
|
function doneCard(options = {}) {
|
|
340
716
|
const { paths } = requireBoard();
|
|
341
717
|
const { positional, flags } = parseArgs(options.rest || []);
|
|
342
718
|
const title = positional[0];
|
|
343
|
-
const result = flags.result;
|
|
344
|
-
if (!title || !result) fail(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
719
|
+
const result = flagString(flags.result);
|
|
720
|
+
if (!title || !result) fail(USAGE.done);
|
|
721
|
+
validateFlags(flags, DONE_FLAGS);
|
|
722
|
+
|
|
723
|
+
return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
|
|
724
|
+
const cardObj = requireActiveCard(paths, title);
|
|
725
|
+
const meta = { ...cardObj.meta };
|
|
726
|
+
const parsed = { ...cardObj.card, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
|
|
727
|
+
|
|
728
|
+
const unchecked = parsed.ac.filter(a => !a.checked);
|
|
729
|
+
if (unchecked.length) {
|
|
730
|
+
console.error(`⚠ AC ${unchecked.map(a => `#${a.idx}`).join(', ')} 가 체크 안 됐다 — 증거로 체크했는지 스스로 검증하라 (QA 루프가 되돌릴 수 있다).`);
|
|
731
|
+
}
|
|
354
732
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
733
|
+
const existing = parsed.sections.get('Result') || '';
|
|
734
|
+
const line = `- ${nowIso()} — ${result}`;
|
|
735
|
+
parsed.sections.set('Result', existing ? `${existing}\n${line}` : line);
|
|
358
736
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
737
|
+
delete meta.claimed_by;
|
|
738
|
+
delete meta.claimed_at;
|
|
739
|
+
kanban.writeCard(cardObj, meta, parsed);
|
|
740
|
+
try {
|
|
741
|
+
kanban.moveCardTo(cardObj, paths.doneDir, 'done');
|
|
742
|
+
} catch (e) {
|
|
743
|
+
fail(e.message); // 카드는 활성으로 남는다 — Result는 이미 반영돼 있다.
|
|
744
|
+
}
|
|
745
|
+
kanban.appendActivity(paths, { action: 'done', title: meta.title });
|
|
746
|
+
console.log(`Done: ${path.join(paths.doneDir, cardObj.fileName)}`);
|
|
747
|
+
// 마일스톤 소속이 있으면 전원 종결 확인 — 자동 종결은 이 쓰기 안에서(3.8).
|
|
748
|
+
if (meta.milestone) completeMilestoneIfAllTerminal(paths, meta.milestone);
|
|
749
|
+
});
|
|
365
750
|
}
|
|
366
751
|
|
|
367
752
|
// 대체 — 이 카드가 저 카드들이 됨. 부모는 완료가 아니라 소멸(3.1).
|
|
@@ -369,24 +754,32 @@ function supersede(options = {}) {
|
|
|
369
754
|
const { paths } = requireBoard();
|
|
370
755
|
const { positional, flags } = parseArgs(options.rest || []);
|
|
371
756
|
const title = positional[0];
|
|
372
|
-
const by =
|
|
373
|
-
if (!title || !by.length) fail(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
fail(`자식
|
|
757
|
+
const by = stringArray(flags.by).flatMap(s => s.split(',')).map(s => s.trim()).filter(Boolean);
|
|
758
|
+
if (!title || !by.length) fail(USAGE.supersede);
|
|
759
|
+
validateFlags(flags, SUPERSEDE_FLAGS);
|
|
760
|
+
|
|
761
|
+
return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
|
|
762
|
+
const parent = requireActiveCard(paths, title);
|
|
763
|
+
for (const child of by) {
|
|
764
|
+
const childCard = kanban.findCard(paths, child);
|
|
765
|
+
if (!childCard) fail(`자식 카드를 먼저 만들어라 (card new): ${child}`);
|
|
766
|
+
if (!kanban.ACTIVE_STATUSES.includes(childCard.meta.status)) {
|
|
767
|
+
fail(`자식 카드가 이미 종결됐다 (${childCard.meta.status}): ${child}`);
|
|
768
|
+
}
|
|
381
769
|
}
|
|
382
|
-
}
|
|
383
770
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
771
|
+
const meta = { ...parent.meta, superseded_by: by };
|
|
772
|
+
kanban.writeCard(parent, meta);
|
|
773
|
+
try {
|
|
774
|
+
kanban.moveCardTo(parent, paths.supersededDir, 'superseded');
|
|
775
|
+
} catch (e) {
|
|
776
|
+
fail(e.message);
|
|
777
|
+
}
|
|
778
|
+
kanban.appendActivity(paths, { action: 'superseded', title: parent.meta.title, detail: `by ${by.join(', ')}` });
|
|
779
|
+
console.log(`Superseded: ${parent.meta.title} → [${by.join(', ')}]`);
|
|
780
|
+
console.log(` parent: ${path.join(paths.supersededDir, parent.fileName)}`);
|
|
781
|
+
if (parent.meta.milestone) completeMilestoneIfAllTerminal(paths, parent.meta.milestone);
|
|
782
|
+
});
|
|
390
783
|
}
|
|
391
784
|
|
|
392
785
|
// 폐기 — 사유가 가장 값비싼 정보다(3.3). 없으면 거부하고, 기록은 지우지 않는다.
|
|
@@ -394,26 +787,45 @@ function abandon(options = {}) {
|
|
|
394
787
|
const { docRoot, paths } = requireBoard();
|
|
395
788
|
const { positional, flags } = parseArgs(options.rest || []);
|
|
396
789
|
const title = positional[0];
|
|
397
|
-
const reason = flags.reason;
|
|
398
|
-
if (!title || !reason) fail(
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
790
|
+
const reason = flagString(flags.reason);
|
|
791
|
+
if (!title || !reason) fail(USAGE.abandon);
|
|
792
|
+
validateFlags(flags, ABANDON_FLAGS);
|
|
793
|
+
|
|
794
|
+
return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
|
|
795
|
+
const cardObj = requireActiveCard(paths, title);
|
|
796
|
+
// 마일스톤 폐기 가드 — 살아있는 멤버가 남은 계획을 조용히 버리지 않는다.
|
|
797
|
+
// 회고 이력(3.3)을 지키려면 멤버를 먼저 종결하거나, 의도된 계획 접기면
|
|
798
|
+
// 멤버부터 폐기하라.
|
|
799
|
+
if (cardObj.meta.kind === 'milestone') {
|
|
800
|
+
const activeMembers = kanban.listAllCards(paths)
|
|
801
|
+
.filter(c => c.meta.milestone === cardObj.meta.title && kanban.ACTIVE_STATUSES.includes(c.meta.status));
|
|
802
|
+
if (activeMembers.length) {
|
|
803
|
+
fail(`마일스톤에 활성 멤버가 ${activeMembers.length}장 남아 있다: ${activeMembers.map(c => c.meta.title).join(', ')} — 멤버를 먼저 종결하거나 폐기하라.`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const meta = { ...cardObj.meta, discard_reason: String(reason) };
|
|
807
|
+
kanban.writeCard(cardObj, meta);
|
|
408
808
|
try {
|
|
409
|
-
|
|
410
|
-
if (rawPath) console.log(`폐기 사유를 raw에 기록했다 (안티패턴 원재료): ${rawPath}`);
|
|
809
|
+
kanban.moveCardTo(cardObj, paths.abandonedDir, 'abandoned');
|
|
411
810
|
} catch (e) {
|
|
412
|
-
|
|
811
|
+
fail(e.message);
|
|
812
|
+
}
|
|
813
|
+
kanban.appendActivity(paths, { action: 'abandoned', title: cardObj.meta.title, detail: String(reason) });
|
|
814
|
+
|
|
815
|
+
// 4-2: 폐기 사유를 raw에 자동 기록 (기본 on — --no-raw-log로 끈다).
|
|
816
|
+
if (!flags['no-raw-log']) {
|
|
817
|
+
try {
|
|
818
|
+
const rawPath = appendAbandonRaw(docRoot, cardObj.meta.title, String(reason), path.relative(process.cwd(), path.join(paths.abandonedDir, cardObj.fileName)));
|
|
819
|
+
if (rawPath) console.log(`폐기 사유를 raw에 기록했다 (안티패턴 원재료): ${rawPath}`);
|
|
820
|
+
} catch (e) {
|
|
821
|
+
console.error(`⚠ raw 기록 실패 (폐기 자체는 완료): ${e.message}`);
|
|
822
|
+
}
|
|
413
823
|
}
|
|
414
|
-
}
|
|
415
824
|
|
|
416
|
-
|
|
825
|
+
console.log(`Abandoned: ${path.join(paths.abandonedDir, cardObj.fileName)}`);
|
|
826
|
+
// 멤버 폐기도 종결 사건이다 — 마일스톤 전원 종결 확인(3.8).
|
|
827
|
+
if (meta.milestone) completeMilestoneIfAllTerminal(paths, meta.milestone);
|
|
828
|
+
});
|
|
417
829
|
}
|
|
418
830
|
|
|
419
831
|
// ── board — 유도 뷰 ──────────────────────────────────────────────────────
|
|
@@ -422,10 +834,73 @@ function abandon(options = {}) {
|
|
|
422
834
|
function collectBoard() {
|
|
423
835
|
const { paths, config } = requireBoard();
|
|
424
836
|
const active = kanban.listCardsInDir(paths.cardsDir);
|
|
837
|
+
// 마일스톤 카드는 컬럼(작업 축)이 아니라 마일스톤 패널(목적 축)에만 나온다(3.8).
|
|
425
838
|
const byStatus = status => active
|
|
426
|
-
.filter(c => c.meta.status === status)
|
|
839
|
+
.filter(c => c.meta.status === status && c.meta.kind !== 'milestone')
|
|
427
840
|
.sort((a, b) => (Number(a.meta.ordinal) || 0) - (Number(b.meta.ordinal) || 0));
|
|
428
841
|
|
|
842
|
+
// 소속 멤버는 4폴더 전역에서 유도한다 — 종결 멤버가 회고 뷰를 채운다(3.8).
|
|
843
|
+
const membersByMilestone = new Map();
|
|
844
|
+
const addMember = c => {
|
|
845
|
+
const m = c.meta.milestone;
|
|
846
|
+
if (!m) return;
|
|
847
|
+
if (!membersByMilestone.has(m)) membersByMilestone.set(m, []);
|
|
848
|
+
membersByMilestone.get(m).push({ title: c.meta.title, status: c.meta.status, claimedBy: c.meta.claimed_by || null });
|
|
849
|
+
};
|
|
850
|
+
active.forEach(addMember);
|
|
851
|
+
|
|
852
|
+
// 종결 3폴더를 한 번만 훑는다 — 개수·최근 목록(모니터 종결 컬럼)·의존 해소
|
|
853
|
+
// 판정·마일스톤 수집을 같은 패스에서 뽑는다(모니터가 2초 폴링으로 이 뷰를
|
|
854
|
+
// 계속 요청한다). 최근 항목의 시각은 파일 mtime — 카드가 종결 폴더로 옮겨진
|
|
855
|
+
// 시각이다.
|
|
856
|
+
const terminalView = {
|
|
857
|
+
counts: { done: 0, superseded: 0, abandoned: 0 },
|
|
858
|
+
recent: [],
|
|
859
|
+
resolvedTitles: [],
|
|
860
|
+
milestones: [],
|
|
861
|
+
};
|
|
862
|
+
for (const [kind, dir] of [['done', paths.doneDir], ['superseded', paths.supersededDir], ['abandoned', paths.abandonedDir]]) {
|
|
863
|
+
for (const c of kanban.listCardsInDir(dir)) {
|
|
864
|
+
terminalView.counts[kind] += 1;
|
|
865
|
+
if (kind !== 'abandoned') terminalView.resolvedTitles.push(c.meta.title);
|
|
866
|
+
terminalView.recent.push({ title: c.meta.title, kind, at: fs.statSync(c.filePath).mtime.toISOString(), milestone: c.meta.milestone || null });
|
|
867
|
+
addMember(c);
|
|
868
|
+
if (c.meta.kind === 'milestone') terminalView.milestones.push({ card: c, at: fs.statSync(c.filePath).mtime.toISOString() });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 마일스톤 진행 상태는 전부 유도 — 저장하지 않는다(3.7 원칙 1·2). 활성
|
|
873
|
+
// 마일스톤(ordinal순) 뒤에 종결 마일스톤(최근순).
|
|
874
|
+
const msCounts = members => {
|
|
875
|
+
const counts = { active: 0, done: 0, superseded: 0, abandoned: 0 };
|
|
876
|
+
for (const m of members) {
|
|
877
|
+
counts[kanban.ACTIVE_STATUSES.includes(m.status) ? 'active' : m.status] += 1;
|
|
878
|
+
}
|
|
879
|
+
return counts;
|
|
880
|
+
};
|
|
881
|
+
const msView = (c, completedAt) => {
|
|
882
|
+
const members = (membersByMilestone.get(c.meta.title) || []);
|
|
883
|
+
return {
|
|
884
|
+
title: c.meta.title,
|
|
885
|
+
status: c.meta.status,
|
|
886
|
+
goal: c.card.goal || '',
|
|
887
|
+
ordinal: Number(c.meta.ordinal) || 0,
|
|
888
|
+
members,
|
|
889
|
+
counts: msCounts(members),
|
|
890
|
+
total: members.length,
|
|
891
|
+
completedAt: completedAt || null,
|
|
892
|
+
};
|
|
893
|
+
};
|
|
894
|
+
const milestones = [
|
|
895
|
+
...active
|
|
896
|
+
.filter(c => c.meta.kind === 'milestone')
|
|
897
|
+
.sort((a, b) => (Number(a.meta.ordinal) || 0) - (Number(b.meta.ordinal) || 0))
|
|
898
|
+
.map(c => msView(c, null)),
|
|
899
|
+
...terminalView.milestones
|
|
900
|
+
.sort((a, b) => (a.at < b.at ? 1 : -1))
|
|
901
|
+
.map(({ card, at }) => msView(card, at)),
|
|
902
|
+
];
|
|
903
|
+
|
|
429
904
|
const view = {
|
|
430
905
|
schemaVersion: 1,
|
|
431
906
|
kind: 'kanban-board',
|
|
@@ -436,41 +911,29 @@ function collectBoard() {
|
|
|
436
911
|
doing: byStatus('doing').map(c => viewCard(c, config)),
|
|
437
912
|
review: byStatus('review').map(c => viewCard(c, config)),
|
|
438
913
|
},
|
|
439
|
-
terminal
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// 종결
|
|
445
|
-
terminalRecent:
|
|
446
|
-
...kanban.listCardsInDir(paths.doneDir).map(c => ({ title: c.meta.title, kind: 'done', m: fs.statSync(c.filePath).mtimeMs })),
|
|
447
|
-
...kanban.listCardsInDir(paths.supersededDir).map(c => ({ title: c.meta.title, kind: 'superseded', m: fs.statSync(c.filePath).mtimeMs })),
|
|
448
|
-
...kanban.listCardsInDir(paths.abandonedDir).map(c => ({ title: c.meta.title, kind: 'abandoned', m: fs.statSync(c.filePath).mtimeMs })),
|
|
449
|
-
]
|
|
450
|
-
.sort((a, b) => b.m - a.m)
|
|
451
|
-
.slice(0, 8)
|
|
452
|
-
.map(({ title, kind }) => ({ title, kind })),
|
|
453
|
-
// 의존 충족 판정용 종결 제목들 — 텍스트/HTML 렌더러가 공유한다.
|
|
454
|
-
resolved: [...kanban.listCardsInDir(paths.doneDir), ...kanban.listCardsInDir(paths.supersededDir)]
|
|
455
|
-
.map(c => c.meta.title),
|
|
914
|
+
// 목적 축 — 시간 축(terminal·terminalRecent)과 분리(3.8).
|
|
915
|
+
milestones,
|
|
916
|
+
terminal: terminalView.counts,
|
|
917
|
+
// 의존 충족 판정용 종결 제목들 — done/superseded는 이미 해소(pick 판정과 일치).
|
|
918
|
+
resolved: terminalView.resolvedTitles,
|
|
919
|
+
// 모니터 종결 컬럼 — 완료가 쌓이는 모습이 수렴의 증거다(3.2). mtime 내림차순 8건.
|
|
920
|
+
terminalRecent: terminalView.recent.sort((a, b) => (a.at < b.at ? 1 : -1)).slice(0, 8),
|
|
456
921
|
};
|
|
457
922
|
return { view, paths, config };
|
|
458
923
|
}
|
|
459
924
|
|
|
460
|
-
|
|
461
|
-
|
|
925
|
+
// board/video는 플래그를 받지 않는다 — 유도 뷰·타임랩스는 인자 없는 명령이다.
|
|
926
|
+
// bin이 --json을 전역 선추출해 dispatch로 넘기므로 여기서 명시적으로 실패하지
|
|
927
|
+
// 않으면 텍스트 보드를 뱉으며 exit 0 — 조용한 no-op 성공이다(2026-09-16 폐지:
|
|
928
|
+
// --html/--json 유도물 경로 제거와 함께 막았다).
|
|
929
|
+
const BOARD_FLAGS = {};
|
|
930
|
+
const VIDEO_FLAGS = {};
|
|
462
931
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const htmlPath = path.join(paths.kanbanDir, 'board.html');
|
|
469
|
-
fs.writeFileSync(htmlPath, renderBoardHtml(view));
|
|
470
|
-
console.log(`Board written: ${htmlPath}`);
|
|
471
|
-
console.log(' 유도물 — 서버 없이 브라우저에서 열면 된다. 갱신은 다시 `llm-wiki board --html`.');
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
932
|
+
function boardView(options = {}) {
|
|
933
|
+
const flags = { ...parseArgs(options.rest || []).flags };
|
|
934
|
+
if (options.json) flags.json = true;
|
|
935
|
+
validateFlags(flags, BOARD_FLAGS);
|
|
936
|
+
const { view, config } = collectBoard();
|
|
474
937
|
renderBoard(view, config);
|
|
475
938
|
}
|
|
476
939
|
|
|
@@ -484,18 +947,38 @@ function viewCard(cardObj, config) {
|
|
|
484
947
|
title: cardObj.meta.title,
|
|
485
948
|
ordinal: Number(cardObj.meta.ordinal) || 0,
|
|
486
949
|
claimedBy: cardObj.meta.claimed_by || null,
|
|
950
|
+
// 모니터 뷰어가 경과 시간을 계산한다 — board --json 폐지(2026-09-16)로 이
|
|
951
|
+
// 스키마를 소비하는 건 모니터뿐이라 노출이 안전해졌다.
|
|
952
|
+
claimedAt: cardObj.meta.claimed_at || null,
|
|
487
953
|
claimExpired: expired,
|
|
488
954
|
notBefore: cardObj.meta.not_before || null,
|
|
489
955
|
question: qLine ? qLine.split('QUESTION:')[1].trim() : null,
|
|
490
956
|
dependsOn: cardObj.meta.depends_on || [],
|
|
957
|
+
milestone: cardObj.meta.milestone || null,
|
|
491
958
|
};
|
|
492
959
|
}
|
|
493
960
|
|
|
494
961
|
function renderBoard(view, config) {
|
|
495
962
|
const line = '────────────────────────────────────────';
|
|
496
|
-
console.log(`Kanban board (${
|
|
963
|
+
console.log(`Kanban board (${localToday()}) — 정본은 doc/kanban/ 카드 파일들`);
|
|
497
964
|
console.log(line);
|
|
498
965
|
|
|
966
|
+
// 목적 축(3.8) — 활성 마일스톤은 멤버 상태 요약, 종결은 ✓ 한 줄. 컬럼에는
|
|
967
|
+
// 마일스톤 카드가 안 나온다(collectBoard에서 제외).
|
|
968
|
+
if (view.milestones.length) {
|
|
969
|
+
console.log(`마일스톤 (${view.milestones.length})`);
|
|
970
|
+
for (const m of view.milestones) {
|
|
971
|
+
if (kanban.ACTIVE_STATUSES.includes(m.status)) {
|
|
972
|
+
const finished = m.total - m.counts.active;
|
|
973
|
+
const byMs = s => m.members.filter(x => x.status === s).length;
|
|
974
|
+
console.log(` • ${m.title} — ${finished}/${m.total} (todo ${byMs('todo')} · doing ${byMs('doing')} · review ${byMs('review')})`);
|
|
975
|
+
} else {
|
|
976
|
+
console.log(` ✓ ${m.title} — ${m.total}/${m.total} (${m.status})`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
console.log(line);
|
|
980
|
+
}
|
|
981
|
+
|
|
499
982
|
const wip = view.wip.doing;
|
|
500
983
|
console.log(`DOING (${wip.used}/${wip.limit}${wip.used >= wip.limit ? ' — WIP 상한' : ''})`);
|
|
501
984
|
if (!view.columns.doing.length) console.log(' (비어 있음)');
|
|
@@ -517,7 +1000,7 @@ function renderBoard(view, config) {
|
|
|
517
1000
|
if (!view.columns.todo.length) console.log(' (비어 있음)');
|
|
518
1001
|
for (const c of view.columns.todo) {
|
|
519
1002
|
const unmet = c.dependsOn.filter(dep => !resolved.has(dep));
|
|
520
|
-
const gate = c.notBefore && c.notBefore >
|
|
1003
|
+
const gate = c.notBefore && c.notBefore > localToday();
|
|
521
1004
|
console.log(` • ${c.title}${gate ? ` [시작 예정 — ${c.notBefore}]` : ''}${unmet.length ? ` [대기 — 의존: ${unmet.join(', ')}]` : ''}`);
|
|
522
1005
|
}
|
|
523
1006
|
|
|
@@ -525,88 +1008,6 @@ function renderBoard(view, config) {
|
|
|
525
1008
|
console.log(`종결: done ${view.terminal.done} · superseded ${view.terminal.superseded} · abandoned ${view.terminal.abandoned}`);
|
|
526
1009
|
}
|
|
527
1010
|
|
|
528
|
-
// ── 정적 HTML 보드 — 사람용 시각화 (유도 뷰) ─────────────────────────────
|
|
529
|
-
// 서버 없음, file://로 열린다. 드래그 이동이 있는 웹 뷰는 포맷이 굳은 뒤의
|
|
530
|
-
// 확정 대상(plan.md) — 그때까지는 읽기 전용이 원칙이다.
|
|
531
|
-
function escapeHtml(s) {
|
|
532
|
-
return String(s).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function renderBoardHtml(view) {
|
|
536
|
-
const today = new Date().toISOString().split('T')[0];
|
|
537
|
-
const resolved = new Set(view.resolved);
|
|
538
|
-
|
|
539
|
-
const badges = c => {
|
|
540
|
-
const out = [];
|
|
541
|
-
if (c.claimExpired) out.push('<span class="badge warn">클레임 만료</span>');
|
|
542
|
-
else if (c.claimedBy) out.push(`<span class="badge">${escapeHtml(c.claimedBy)}</span>`);
|
|
543
|
-
if (c.notBefore && c.notBefore > today) out.push(`<span class="badge gate">시작 예정 ${escapeHtml(c.notBefore)}</span>`);
|
|
544
|
-
const unmet = c.dependsOn.filter(dep => !resolved.has(dep));
|
|
545
|
-
if (unmet.length) out.push(`<span class="badge gate">의존 대기: ${unmet.map(escapeHtml).join(', ')}</span>`);
|
|
546
|
-
return out.join(' ');
|
|
547
|
-
};
|
|
548
|
-
const cardDiv = c => `<div class="card"><div class="t">${escapeHtml(c.title)}</div>${c.question ? `<div class="q">↳ ${escapeHtml(c.question)}</div>` : ''}${badges(c)}</div>`;
|
|
549
|
-
const kindLabel = { done: '완료', superseded: '대체', abandoned: '폐기' };
|
|
550
|
-
const terminalList = view.terminalRecent.length
|
|
551
|
-
? view.terminalRecent
|
|
552
|
-
.map(t => `<div class="card"><span class="badge t-${t.kind}">${kindLabel[t.kind]}</span><span class="tt">${escapeHtml(t.title)}</span></div>`)
|
|
553
|
-
.join('\n')
|
|
554
|
-
: '<div class="empty">비어 있음</div>';
|
|
555
|
-
const column = (label, cards) => `
|
|
556
|
-
<section>
|
|
557
|
-
<h2>${label} <span class="count">${cards.length}</span></h2>
|
|
558
|
-
${cards.map(cardDiv).join('\n') || '<div class="empty">비어 있음</div>'}
|
|
559
|
-
</section>`;
|
|
560
|
-
const terminalSection = `
|
|
561
|
-
<section>
|
|
562
|
-
<h2>종결 적체 <span class="count">최근 ${view.terminalRecent.length}</span></h2>
|
|
563
|
-
${terminalList}
|
|
564
|
-
</section>`;
|
|
565
|
-
|
|
566
|
-
return `<!doctype html>
|
|
567
|
-
<html lang="ko">
|
|
568
|
-
<head>
|
|
569
|
-
<meta charset="utf-8">
|
|
570
|
-
<title>llm-wiki board</title>
|
|
571
|
-
<style>
|
|
572
|
-
body { font-family: -apple-system, 'Segoe UI', 'Malgun Gothic', sans-serif; margin: 24px; background: #f6f7f9; color: #1c2733; }
|
|
573
|
-
header h1 { font-size: 20px; margin: 0 0 4px; }
|
|
574
|
-
header p { margin: 0 0 20px; color: #5b6a78; font-size: 13px; }
|
|
575
|
-
main { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
|
576
|
-
section { background: #fff; border: 1px solid #e2e6ea; border-radius: 10px; padding: 12px; }
|
|
577
|
-
h2 { font-size: 14px; margin: 0 0 10px; text-transform: uppercase; letter-spacing: .04em; color: #40505f; }
|
|
578
|
-
.count { color: #8a97a3; font-weight: 400; }
|
|
579
|
-
.card { background: #f2f4f7; border: 1px solid #dde2e8; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
|
|
580
|
-
.card .t { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
|
581
|
-
.q { font-size: 12px; color: #5b6a78; margin: 2px 0 4px; }
|
|
582
|
-
.badge { display: inline-block; font-size: 11px; background: #e3e8ee; color: #44525f; border-radius: 999px; padding: 2px 8px; margin: 2px 4px 0 0; }
|
|
583
|
-
.badge.warn { background: #fdeccf; color: #7a4d05; }
|
|
584
|
-
.badge.gate { background: #dbe7fd; color: #1c4d8f; }
|
|
585
|
-
.empty { color: #98a3ad; font-size: 13px; }
|
|
586
|
-
.tt { font-size: 13px; color: #1c2733; }
|
|
587
|
-
.t-done { background: #6ee7a0; }
|
|
588
|
-
.t-superseded { background: #c9a6ff; }
|
|
589
|
-
.t-abandoned { background: #ff8f8f; }
|
|
590
|
-
footer { margin-top: 18px; color: #5b6a78; font-size: 13px; }
|
|
591
|
-
</style>
|
|
592
|
-
</head>
|
|
593
|
-
<body>
|
|
594
|
-
<header>
|
|
595
|
-
<h1>Kanban board</h1>
|
|
596
|
-
<p>생성: ${escapeHtml(view.generatedAt)} · WIP doing ${view.wip.doing.used}/${view.wip.doing.limit} · 이 파일은 유도물 — 정본은 doc/kanban/ 카드 파일들</p>
|
|
597
|
-
</header>
|
|
598
|
-
<main style="grid-template-columns: repeat(4, 1fr)">
|
|
599
|
-
${column('Doing', view.columns.doing)}
|
|
600
|
-
${column('Review (사람 판정 대기)', view.columns.review)}
|
|
601
|
-
${column('Todo', view.columns.todo)}
|
|
602
|
-
${terminalSection}
|
|
603
|
-
</main>
|
|
604
|
-
<footer>종결: done ${view.terminal.done} · superseded ${view.terminal.superseded} · abandoned ${view.terminal.abandoned} — 갱신은 <code>llm-wiki board --html</code></footer>
|
|
605
|
-
</body>
|
|
606
|
-
</html>
|
|
607
|
-
`;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
1011
|
// review(대기) → todo 복귀 — 조건이 충족됐거나 사람이 판정을 내렸을 때.
|
|
611
1012
|
// 조건부 카드(Activation/not_before)의 진행 스위치다. 대기에서 저절로 풀리는 건
|
|
612
1013
|
// not_before(기계 판정)뿐이고, 관측형 조건은 이 명령으로 명시적으로 푼다.
|
|
@@ -614,21 +1015,24 @@ function resume(options = {}) {
|
|
|
614
1015
|
const { paths } = requireBoard();
|
|
615
1016
|
const { positional, flags } = parseArgs(options.rest || []);
|
|
616
1017
|
const title = positional[0];
|
|
617
|
-
if (!title) fail(
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
1018
|
+
if (!title) fail(USAGE.resume);
|
|
1019
|
+
validateFlags(flags, RESUME_FLAGS);
|
|
1020
|
+
|
|
1021
|
+
return kanban.withLock(paths, kanban.BOARD_LOCK_NAME, () => {
|
|
1022
|
+
const cardObj = kanban.findCard(paths, title);
|
|
1023
|
+
if (!cardObj) fail(`카드를 못 찾았다: ${title}`);
|
|
1024
|
+
if (cardObj.meta.status !== 'review') fail(`review 카드만 복귀시킨다 (현재: ${cardObj.meta.status}): ${cardObj.filePath}`);
|
|
1025
|
+
|
|
1026
|
+
const meta = { ...cardObj.meta, status: 'todo' };
|
|
1027
|
+
const parsed = { ...cardObj.card, ac: cardObj.card.ac.map(a => ({ ...a })), sections: new Map(cardObj.card.sections) };
|
|
1028
|
+
const line = `- ${nowIso()} — RESUMED: ${typeof flags.note === 'string' && flags.note ? flags.note : '대기 조건 충족 또는 사람 판정'}`;
|
|
1029
|
+
const notes = parsed.sections.get('Notes') || '';
|
|
1030
|
+
parsed.sections.set('Notes', notes ? `${notes}\n${line}` : line);
|
|
1031
|
+
|
|
1032
|
+
kanban.writeCard(cardObj, meta, parsed);
|
|
1033
|
+
kanban.appendActivity(paths, { action: 'resumed', title: meta.title, detail: typeof flags.note === 'string' ? flags.note : undefined });
|
|
1034
|
+
console.log(`Resumed to todo: ${cardObj.filePath}`);
|
|
1035
|
+
});
|
|
632
1036
|
}
|
|
633
1037
|
|
|
634
1038
|
// ── board report — 계기판 (개선계획 4-1) ─────────────────────────────────
|
|
@@ -656,7 +1060,7 @@ function boardReport(options = {}) {
|
|
|
656
1060
|
const days = [];
|
|
657
1061
|
for (let i = 13; i >= 0; i--) {
|
|
658
1062
|
const d = new Date(); d.setDate(d.getDate() - i);
|
|
659
|
-
days.push(d
|
|
1063
|
+
days.push(localToday(d));
|
|
660
1064
|
}
|
|
661
1065
|
const trend = days.map(day => {
|
|
662
1066
|
const on = action => activity.filter(a => a.action === action && String(a.ts).startsWith(day)).length;
|
|
@@ -728,7 +1132,7 @@ function appendAbandonRaw(docRoot, title, reason, cardPath) {
|
|
|
728
1132
|
const rawDir = path.join(docRoot, 'raw');
|
|
729
1133
|
if (!fs.existsSync(rawDir)) return null;
|
|
730
1134
|
|
|
731
|
-
const today =
|
|
1135
|
+
const today = localToday(); // raw 로그 파일명은 현지 날짜(header-date-over-mtime)
|
|
732
1136
|
const rawPath = path.join(rawDir, `${today}.md`);
|
|
733
1137
|
let caseNum = 1;
|
|
734
1138
|
let header = `# ${today}\n`;
|
|
@@ -770,6 +1174,11 @@ function appendAbandonRaw(docRoot, title, reason, cardPath) {
|
|
|
770
1174
|
// 렌더는 headless Chrome 기반 CPU 작업이다 (GPU 불필요).
|
|
771
1175
|
function boardVideo(options = {}) {
|
|
772
1176
|
const { paths, config } = requireBoard();
|
|
1177
|
+
// 미검증 플래그를 조용히 무시하던 결함(2026-09-16 설계 리뷰에서 접수) —
|
|
1178
|
+
// video는 플래그를 받지 않는다. bin이 전역 선추출한 --json도 여기로 넘어온다.
|
|
1179
|
+
const flags = { ...parseArgs(options.rest || []).flags };
|
|
1180
|
+
if (options.json) flags.json = true;
|
|
1181
|
+
validateFlags(flags, VIDEO_FLAGS);
|
|
773
1182
|
const activity = kanban.readActivity(paths);
|
|
774
1183
|
const cfg = loadConfig(findDocRoot());
|
|
775
1184
|
const repo = cfg.projectName || path.basename(process.cwd());
|
|
@@ -788,16 +1197,21 @@ function boardVideo(options = {}) {
|
|
|
788
1197
|
|
|
789
1198
|
const videoProject = path.join(process.cwd(), 'video');
|
|
790
1199
|
if (!fs.existsSync(path.join(videoProject, 'package.json'))) {
|
|
791
|
-
console.log('video/ Remotion 프로젝트가 없다 —
|
|
1200
|
+
console.log('video/ Remotion 프로젝트가 없다 — 타임라인(board-timeline.json)만 남긴다.');
|
|
1201
|
+
console.log(' 렌더 프로젝트는 npm 패키지에 포함되지 않는다(llm-wiki 레포의 video/ 참고).');
|
|
792
1202
|
return;
|
|
793
1203
|
}
|
|
794
1204
|
|
|
795
1205
|
console.log('Rendering board timelapse (headless Chrome — 첫 실행은 Chrome 내려받기로 몇 분 걸린다)...');
|
|
1206
|
+
// kanbanDir 기준 절대경로로 렌더 — ../doc/ 하드코딩은 LLM_WIKI_ROOT 오버라이드와
|
|
1207
|
+
// 다른 doc 루트에서 엉뚱한 곳을 읽게 한다(2026-09-09 리뷰 5-2). 따옴표로 감싸
|
|
1208
|
+
// 경로의 공백도 안전하게.
|
|
1209
|
+
const mp4Path = path.join(paths.kanbanDir, 'board-timelapse.mp4');
|
|
796
1210
|
execSync(
|
|
797
|
-
|
|
1211
|
+
`npx remotion render src/index.ts BoardTimelapse "${mp4Path}" --props="${timelinePath}"`,
|
|
798
1212
|
{ cwd: videoProject, stdio: 'inherit', timeout: 570000 }
|
|
799
1213
|
);
|
|
800
|
-
console.log(
|
|
1214
|
+
console.log(`Video written: ${mp4Path}`);
|
|
801
1215
|
}
|
|
802
1216
|
|
|
803
1217
|
// ── 디스패치 ─────────────────────────────────────────────────────────────
|
|
@@ -809,10 +1223,16 @@ function dispatchCard(rest) {
|
|
|
809
1223
|
if (sub === 'new') cardNew(paths, kanban.loadConfig(paths), parsedArgs);
|
|
810
1224
|
else if (sub === 'show') cardShow(paths, parsedArgs);
|
|
811
1225
|
else if (sub === 'edit') cardEdit(paths, parsedArgs);
|
|
812
|
-
else fail(
|
|
1226
|
+
else fail(USAGE.card);
|
|
813
1227
|
}
|
|
814
1228
|
|
|
815
1229
|
module.exports = {
|
|
1230
|
+
// wait(kanban-wait)가 같은 플래그 규칙으로 재사용한다 — 계층 간 단일 파서.
|
|
1231
|
+
parseArgs,
|
|
1232
|
+
validateFlags,
|
|
1233
|
+
fail,
|
|
1234
|
+
// bin의 --help 가드가 fail()과 같은 사용법 문자열을 쓴다(한 곳 관리).
|
|
1235
|
+
USAGE,
|
|
816
1236
|
dispatchCard,
|
|
817
1237
|
pick,
|
|
818
1238
|
handoff,
|
|
@@ -824,5 +1244,8 @@ module.exports = {
|
|
|
824
1244
|
boardView,
|
|
825
1245
|
boardReport,
|
|
826
1246
|
boardVideo,
|
|
1247
|
+
// monitor(kanban-monitor)가 보드 수집·루트 검증을 재사용한다 — 유도물은 한 곳에서.
|
|
1248
|
+
requireBoard,
|
|
1249
|
+
collectBoard,
|
|
827
1250
|
scaffold: kanban.scaffold,
|
|
828
1251
|
};
|