@kybird/llm-wiki 0.2.1 → 0.3.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.
@@ -0,0 +1,215 @@
1
+ // llm-wiki wait — 보드 활동 로그(doc/kanban/activity.jsonl)의 이벤트를 기다렸다 종료하는
2
+ // 읽기 전용 명령. 무인 루프가 밤새 handoff 판정을 폴링하지 않게 한다 — 호출자는 이 명령을
3
+ // 백그라운드에 걸어 두고 프로세스 종료를 신호로 쓴다. 아무 일이 없으면 아무 비용도 없다.
4
+ //
5
+ // 종료 코드가 이 명령의 계약이다:
6
+ // 0 이벤트 감지 — 해당 이벤트를 stdout 한 줄로 출력
7
+ // 2 타임아웃 — 아무것도 출력하지 않는다. 호출자는 조용히 재무장
8
+ // 1 오류 — 모르는 플래그, 해석 불가능한 --since/--timeout/--stall-min, 파일 접근 불가
9
+ // 130 SIGINT/SIGTERM — 호출자가 죽였다
10
+ //
11
+ // 견고성 규칙(2026-09-12 병합 충돌로 activity.jsonl이 통째로 재작성된 실측에서 나온 것들):
12
+ // - 바이트 오프셋을 기억하지 않는다. 파일은 10k 줄 캡(appendActivity)이나 병합 수습으로
13
+ // 통째로 다시 쓰인다 — 매번 처음부터 읽고 타임스탬프로 거른다.
14
+ // - 파일이 없어도 죽지 않는다(init 전·이벤트 0회). 생길 때까지 기다린다.
15
+ // - JSON 파싱에 실패한 줄은 건너뛴다 — 실제로 병합 충돌 마커가 섞인 적이 있다.
16
+ // - fs.watch + 저주기 폴링(기본 5초)을 항상 함께 돈다. doc/이 링크 워크트리의 정션
17
+ // 너머에 있으면(find-doc-root) Windows fs.watch가 이벤트를 놓친다.
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { findDocRoot } = require('./find-doc-root');
21
+ const kanban = require('./kanban');
22
+ const { parseArgs, validateFlags, fail } = require('./kanban-cmd');
23
+
24
+ const POLL_MS_DEFAULT = 5000;
25
+ const FILTERS = ['handoff', 'done', 'any', 'stall'];
26
+
27
+ // ts 필드를 밀리초로 — ts 없음·해석 불가는 이벤트로 치지 않는다.
28
+ function eventTs(ev) {
29
+ if (!ev || typeof ev.ts !== 'string') return null;
30
+ const t = Date.parse(ev.ts);
31
+ return Number.isNaN(t) ? null : t;
32
+ }
33
+
34
+ function wait({ rest, json } = {}) {
35
+ const { positional, flags } = parseArgs(rest || []);
36
+ if (positional.length) fail(`wait는 위치 인자를 받지 않는다: ${positional.join(' ')}`);
37
+ validateFlags(flags, {
38
+ for: 'string',
39
+ since: 'string',
40
+ timeout: 'string',
41
+ 'stall-min': 'string',
42
+ });
43
+
44
+ // 단일값 플래그 — 반복 지정(--for a --for b)은 parseArgs가 배열로 누적하므로 거부.
45
+ const single = (v, name) => {
46
+ if (Array.isArray(v)) fail(`--${name}은 한 번만 쓸 수 있다`);
47
+ return v;
48
+ };
49
+
50
+ const filter = flags.for === undefined ? 'handoff' : single(flags.for, 'for');
51
+ if (!FILTERS.includes(filter)) {
52
+ fail(`--for은 ${FILTERS.join(' | ')} 중 하나여야 한다 — 받은 값: ${filter}`);
53
+ }
54
+
55
+ const sinceRaw = flags.since === undefined ? new Date().toISOString() : single(flags.since, 'since');
56
+ const sinceMs = Date.parse(sinceRaw);
57
+ if (Number.isNaN(sinceMs)) {
58
+ fail(`--since "${sinceRaw}"를 시각으로 해석할 수 없다 — ISO 8601 형태로 준다 (예: 2026-09-12T00:00:00Z)`);
59
+ }
60
+
61
+ const timeoutSec = flags.timeout === undefined ? null : Number(single(flags.timeout, 'timeout'));
62
+ if (timeoutSec !== null && (Number.isNaN(timeoutSec) || timeoutSec < 0)) {
63
+ fail(`--timeout은 0 이상의 숫자(초)여야 한다 — 받은 값: ${single(flags.timeout, 'timeout')} (0 = 기다리지 않는다)`);
64
+ }
65
+
66
+ // 소수 분을 허용한다(예: 0.05 = 3초) — 회귀 테스트가 분 단위를 기다리지 않게.
67
+ const stallMin = flags['stall-min'] === undefined ? 20 : Number(single(flags['stall-min'], 'stall-min'));
68
+ if (Number.isNaN(stallMin) || stallMin <= 0) {
69
+ fail(`--stall-min은 0보다 큰 숫자(분)여야 한다 — 받은 값: ${single(flags['stall-min'], 'stall-min')}`);
70
+ }
71
+
72
+ const activityPath = kanban.kanbanPaths(findDocRoot()).activityPath;
73
+ const stallMs = stallMin * 60e3;
74
+ const pollMs = Math.max(50, Number(process.env.LLM_WIKI_WAIT_POLL_MS) || POLL_MS_DEFAULT);
75
+ const startedAt = Date.now();
76
+
77
+ let watcher = null;
78
+ let pollTimer = null;
79
+ let timeoutTimer = null;
80
+ let stallTimer = null;
81
+ let finished = false;
82
+ // stall 기준점 — --since 이후 활동이 없으면 since 시각 자체가 마지막 활동이 된다.
83
+ let lastActivityMs = sinceMs;
84
+
85
+ // 종료는 출력 이후에 — Windows 파이프 stdout은 비동기라 쓰기 완료 전에 루프가 마르면
86
+ // 그 한 줄이 유실된다(이벤트는 감지했는데 호출자가 못 받는 상태). 쓰기 완료 콜백에서
87
+ // 명시 종료한다. 콜백이 오지 않는 극단적 경우를 대비해 exitCode도 심어둔다.
88
+ function finish(code, line) {
89
+ if (finished) return;
90
+ finished = true;
91
+ if (watcher) { try { watcher.close(); } catch { /* 이미 닫혔다 */ } }
92
+ if (pollTimer) clearInterval(pollTimer);
93
+ if (timeoutTimer) clearTimeout(timeoutTimer);
94
+ if (stallTimer) clearTimeout(stallTimer);
95
+ if (line !== undefined) {
96
+ process.exitCode = code;
97
+ process.stdout.write(`${line}\n`, () => process.exit(code));
98
+ } else {
99
+ process.exit(code);
100
+ }
101
+ }
102
+
103
+ // activity.jsonl 전체를 매번 다시 읽는다(오프셋 금지 규칙). 깨진 줄은 건너뛴다.
104
+ function readEvents() {
105
+ let content;
106
+ try {
107
+ content = fs.readFileSync(activityPath, 'utf8');
108
+ } catch (e) {
109
+ if (e.code === 'ENOENT') return []; // 아직 없다 — 생길 때까지 기다린다
110
+ throw e;
111
+ }
112
+ const events = [];
113
+ for (const line of content.split('\n')) {
114
+ if (!line.trim()) continue;
115
+ try {
116
+ events.push(JSON.parse(line));
117
+ } catch {
118
+ // 깨진 줄(병합 충돌 마커·잘린 쓰기)은 건너뛴다.
119
+ }
120
+ }
121
+ return events;
122
+ }
123
+
124
+ // --since 이후(엄격히 초과 — 같은 이벤트에 다시 깨지 않는다)의 이벤트 중 필터에 맞는
125
+ // 첫 줄을 돌려준다. stall 기준점(lastActivityMs)은 필터와 무관하게 모든 액션이 갱신한다.
126
+ function scan() {
127
+ let matched = null;
128
+ for (const ev of readEvents()) {
129
+ const t = eventTs(ev);
130
+ if (t === null || t <= sinceMs) continue;
131
+ if (t > lastActivityMs) lastActivityMs = t;
132
+ if (!matched && (filter === 'any' || ev.action === filter)) matched = ev;
133
+ }
134
+ return matched;
135
+ }
136
+
137
+ function emitEvent(ev) {
138
+ finish(0, json
139
+ ? JSON.stringify(ev)
140
+ : `${ev.ts} ${ev.action} ${ev.title !== undefined ? ev.title : ''}`.trimEnd());
141
+ }
142
+
143
+ // stall 발사 — 마지막 활동(또는 --since)으로부터 stall-min 분의 침묵이 확인됐다.
144
+ // 새 활동이 오면 lastActivityMs가 밀리고 check()가 다시 부르므로 마감도 밀린다.
145
+ function armStall() {
146
+ if (stallTimer) clearTimeout(stallTimer);
147
+ stallTimer = setTimeout(() => {
148
+ finish(0, json
149
+ ? JSON.stringify({ ts: new Date().toISOString(), action: 'stall', since: sinceRaw, stallMinutes: stallMin })
150
+ : `stall — ${stallMin}분간 새 이벤트 없음 (since ${sinceRaw})`);
151
+ }, Math.max(0, lastActivityMs + stallMs - Date.now()));
152
+ }
153
+
154
+ // watch 이벤트는 한 쓰기에 여러 번 터진다 — setImmediate로 한 틱에 모아 한 번 검사한다.
155
+ let checkScheduled = false;
156
+ function check() {
157
+ if (finished || checkScheduled) return;
158
+ checkScheduled = true;
159
+ setImmediate(() => {
160
+ checkScheduled = false;
161
+ if (finished) return;
162
+ try {
163
+ const ev = scan();
164
+ if (filter === 'stall') armStall();
165
+ else if (ev) emitEvent(ev);
166
+ } catch (e) {
167
+ fail(`activity.jsonl을 읽을 수 없다: ${e.message}`);
168
+ }
169
+ });
170
+ }
171
+
172
+ // 진입 검사 — 대기에 들어가기 전에 --since 이후의 기존 이벤트를 먼저 본다. 호출자가
173
+ // 이전 이벤트를 처리하는 동안 쌓인 것을 놓치지 않는 것이 이 명령의 핵심 계약이다.
174
+ try {
175
+ const existing = scan();
176
+ if (filter !== 'stall' && existing) {
177
+ emitEvent(existing);
178
+ return;
179
+ }
180
+ } catch (e) {
181
+ fail(`activity.jsonl을 읽을 수 없다: ${e.message}`);
182
+ }
183
+
184
+ // 대기 진입 알림은 stderr — stdout은 계약상 이벤트 한 줄 전용이다.
185
+ console.error(`waiting: for=${filter} since=${sinceRaw}` +
186
+ (timeoutSec !== null ? ` timeout=${timeoutSec}s` : '') +
187
+ (filter === 'stall' ? ` stall-min=${stallMin}` : '') +
188
+ ` file=${activityPath} (Ctrl+C to stop)`);
189
+
190
+ if (filter === 'stall') armStall(); // lastActivityMs는 진입 검사에서 갱신됐다
191
+
192
+ // 감시 대상은 디렉터리다 — activity.jsonl은 통째로 다시 쓰일 수 있어 파일 단위 watch
193
+ // 핸들이 끊기고, 파일 생성 이벤트도 디렉터리에서 잡힌다.
194
+ try {
195
+ watcher = fs.watch(path.dirname(activityPath), (_evt, filename) => {
196
+ if (filename && path.basename(activityPath) !== filename) return;
197
+ check();
198
+ });
199
+ watcher.on('error', () => { /* 감시 실패 — 아래 폴링이 잡는다 */ });
200
+ } catch {
201
+ // 감시 실패(정션 너머 등) — 아래 폴링이 잡는다
202
+ }
203
+
204
+ // 저주기 폴링 폴백 — watch가 이벤트를 놓쳐도 이것이 잡는다(항상 함께 돈다).
205
+ pollTimer = setInterval(check, pollMs);
206
+
207
+ process.on('SIGINT', () => finish(130));
208
+ process.on('SIGTERM', () => finish(130));
209
+
210
+ if (timeoutSec !== null) {
211
+ timeoutTimer = setTimeout(() => finish(2), Math.max(0, startedAt + timeoutSec * 1000 - Date.now()));
212
+ }
213
+ }
214
+
215
+ module.exports = { wait };
package/lib/kanban.js CHANGED
@@ -52,7 +52,7 @@ function loadConfig(paths) {
52
52
  const lines = fs.readFileSync(paths.boardYmlPath, 'utf8').split(/\r?\n/);
53
53
  let inWip = false;
54
54
  for (const line of lines) {
55
- const statusMatch = line.match(/^statuses:\s*\[(.*)\]/);
55
+ const statusMatch = line.match(/^statuses:\s*\[([^\]]*)\]/);
56
56
  if (statusMatch) {
57
57
  config.statuses = statusMatch[1].split(',').map(s => s.trim()).filter(Boolean);
58
58
  continue;
@@ -86,6 +86,9 @@ function slugify(title) {
86
86
  // ── 카드 파싱/직렬화 ─────────────────────────────────────────────────────
87
87
 
88
88
  // frontmatter의 `key: value` / `key: [a, b]`만 다룬다. 중첩 없음.
89
+ // 값 형태 보존 계약(2026-09-09 리뷰 5-2): 배열은 JSON 꼴로 감싸 원소의 쉼표까지
90
+ // 왕복하고, 배열·따옴표로 보이는 문자열은 역따옴표로 감싸 재파싱에서 변형되지
91
+ // 않게 한다. 따옴표 박리는 짝이 맞을 때만(비대칭 `"a'` → `a'` 문자열 보존).
89
92
  function parseFrontmatter(content) {
90
93
  const meta = {};
91
94
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
@@ -94,11 +97,27 @@ function parseFrontmatter(content) {
94
97
  const kv = line.match(/^(\w+):\s*(.*)$/);
95
98
  if (!kv) continue;
96
99
  const [, key, rawValue] = kv;
97
- const listMatch = rawValue.match(/^\[(.*)\]$/);
98
- if (listMatch) {
99
- meta[key] = listMatch[1].split(',').map(s => s.trim()).filter(Boolean);
100
+ const trimmed = rawValue.trim();
101
+ const quoted = trimmed.match(/^("|')([\s\S]*)\1$/);
102
+ if (quoted) {
103
+ // 쌍따옴표는 직렬화가 JSON.stringify로 감쌌다 — 역이스케이프까지 왕복하려면
104
+ // JSON.parse가 정확하다(내부 따옴표 `\"` 등). 실패(비JSON 레거시)면 내부 원문.
105
+ if (quoted[1] === '"') {
106
+ try { meta[key] = JSON.parse(trimmed); } catch { meta[key] = quoted[2]; }
107
+ } else {
108
+ meta[key] = quoted[2]; // 홑따옴표 — 이스케이프 규약 없음, 내부 그대로
109
+ }
110
+ } else if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
111
+ // JSON 꼴(원소 따옴표)이면 원소의 쉼표·괄호까지 정확히 왕복한다.
112
+ try {
113
+ const arr = JSON.parse(trimmed);
114
+ meta[key] = Array.isArray(arr) ? arr.map(String) : trimmed;
115
+ } catch {
116
+ // 비인용 레거시 `[a, b]` — 쉼표 분해 외에 방법이 없다(원소 쉼표는 원래 손실).
117
+ meta[key] = trimmed.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
118
+ }
100
119
  } else {
101
- meta[key] = rawValue.trim().replace(/^["']|["']$/g, '');
120
+ meta[key] = trimmed;
102
121
  }
103
122
  }
104
123
  return meta;
@@ -107,8 +126,13 @@ function parseFrontmatter(content) {
107
126
  function serializeFrontmatter(meta) {
108
127
  const lines = ['---'];
109
128
  for (const [key, value] of Object.entries(meta)) {
110
- if (Array.isArray(value)) lines.push(`${key}: [${value.join(', ')}]`);
111
- else if (value !== undefined && value !== '') lines.push(`${key}: ${value}`);
129
+ if (Array.isArray(value)) {
130
+ lines.push(`${key}: ${JSON.stringify(value)}`);
131
+ } else if (value !== undefined && value !== '') {
132
+ const s = String(value);
133
+ // `[a, b]` 모양 제목이 재파싱에서 배열로 변형되지 않게 따옴표로 감싼다.
134
+ lines.push(`${key}: ${s.startsWith('[') || /^["']/.test(s) ? JSON.stringify(s) : s}`);
135
+ }
112
136
  }
113
137
  lines.push('---');
114
138
  return lines.join('\n');
@@ -127,7 +151,13 @@ function parseBody(content) {
127
151
  if (acMatch) {
128
152
  for (const line of acMatch[1].split(/\r?\n/)) {
129
153
  const ac = line.match(/^-\s+\[( |x)\]\s+#(\d+)\s+(.*)$/);
130
- if (ac) card.ac.push({ checked: ac[1] === 'x', idx: Number(ac[2]), text: ac[3] });
154
+ if (ac) {
155
+ card.ac.push({ checked: ac[1] === 'x', idx: Number(ac[2]), text: ac[3] });
156
+ } else if (line.trim() && card.ac.length) {
157
+ // 센티넬 안의 비-AC 줄(예: AC 텍스트의 줄바꿈 연속행)은 앞 항목에 붙인다 —
158
+ // 그냥 버리면 왕복에서 소실된다(2026-09-09 리뷰 5-2).
159
+ card.ac[card.ac.length - 1].text += '\n' + line;
160
+ }
131
161
  }
132
162
  }
133
163
 
@@ -157,6 +187,21 @@ function parseBody(content) {
157
187
  return card;
158
188
  }
159
189
 
190
+ // CLI가 관리하는 섹션명 — 본문에 이 이름과 정확히 같은 모양의 `## ` 줄이 있으면
191
+ // 다음 파싱에서 진짜 섹션 경계로 오인된다. Notes에 `## Goal`을 적은 경우 뒷줄이
192
+ // 전부 삭제되기까지 했다(2026-09-09 리뷰 5-1-2) — append-only 계약의 정면 반례.
193
+ const MANAGED_SECTIONS = ['Goal', 'Acceptance Criteria', 'Plan', 'Notes', 'Handoff', 'Result'];
194
+
195
+ // 섹션 본문을 쓸 때 관리 섹션명과 같은 모양의 줄은 앞에 공백을 하나 붙여 이스케이프한다.
196
+ // ` ## Goal`은 `^##\s` 경계와 안 겹치므로 영구히 본문으로 남는다.
197
+ function escapeSectionBody(text) {
198
+ if (!text) return text || '';
199
+ return String(text).split(/\r?\n/).map(line => {
200
+ const header = line.match(/^##\s+(.+?)\s*$/);
201
+ return header && MANAGED_SECTIONS.includes(header[1]) ? ` ${line}` : line;
202
+ }).join('\n');
203
+ }
204
+
160
205
  // Goal/AC는 센티넬과 함께, 나머지 섹션은 헤더만으로 직렬화.
161
206
  // Notes는 append-only 저널이라 CLI가 줄을 덧붙인다 — 전체를 재조립하지 않는 섹션 단위
162
207
  // 재작성을 위해 항상 고정 순서(Goal, AC, Plan, Notes, Handoff, Result, 기타)로 출력.
@@ -177,14 +222,14 @@ function serializeCard(meta, parsed) {
177
222
  const order = ['Plan', 'Notes', 'Handoff', 'Result'];
178
223
  for (const name of order) {
179
224
  out.push(`## ${name}`);
180
- out.push(parsed.sections.get(name) || '');
225
+ out.push(escapeSectionBody(parsed.sections.get(name)));
181
226
  out.push('');
182
227
  }
183
228
  // CLI가 모르는 섹션도 지우지 않는다 — 맨 뒤에 보존.
184
229
  for (const [name, bodyText] of parsed.sections) {
185
230
  if (order.includes(name)) continue;
186
231
  out.push(`## ${name}`);
187
- out.push(bodyText || '');
232
+ out.push(escapeSectionBody(bodyText));
188
233
  out.push('');
189
234
  }
190
235
  return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '\n');
@@ -236,10 +281,16 @@ function writeCard(cardObj, newMeta, newParsed) {
236
281
  // 종결 이동 — 파일 이동 + frontmatter status를 폴더와 일치시킨다.
237
282
  function moveCardTo(cardObj, targetDir, statusValue) {
238
283
  fs.mkdirSync(targetDir, { recursive: true });
284
+ const targetPath = path.join(targetDir, cardObj.fileName);
285
+ // 종결 폴더의 기록은 지우지 않는다(3.3) — 같은 이름이 이미 있으면 조용히 덮어쓰는
286
+ // 대신 거부한다(2026-09-09 리뷰 5-1-3: 재생성 카드의 두 번째 done이 아카이브 Result를 소멸시켰다).
287
+ if (fs.existsSync(targetPath)) {
288
+ throw new Error(`종결 폴더에 같은 이름의 카드가 이미 있다: ${targetPath} — 아카이브 덮어쓰기를 거부한다. 제목을 바꿔 재생성하라.`);
289
+ }
239
290
  const content = fs.readFileSync(cardObj.filePath, 'utf8');
240
291
  const meta = parseFrontmatter(content);
241
292
  meta.status = statusValue;
242
- fs.writeFileSync(path.join(targetDir, cardObj.fileName), serializeCard(meta, parseBody(content)));
293
+ fs.writeFileSync(targetPath, serializeCard(meta, parseBody(content)));
243
294
  fs.unlinkSync(cardObj.filePath);
244
295
  }
245
296
 
@@ -277,8 +328,15 @@ function isClaimExpired(cardObj, config, now = Date.now()) {
277
328
 
278
329
  // ── 파일 락 — 잠금 폴더 mkdir 원자성 (Windows 호환) ─────────────────────
279
330
 
331
+ // 모든 카드 변이 명령이 통과하는 공용 락 이름. pick만의 락이 아니라 보드 전체
332
+ // 단일 쓰기자 직렬화다 — pick이 'pick', done이 'done'으로 나뉘면 서로 동시에
333
+ // 돌아 이중 상태(활성+종결 동시 존재)를 만든다.
334
+ const BOARD_LOCK_NAME = 'write';
335
+
280
336
  // mkdir은 이미 존재하면 EEXIST로 실패하는 원자적 연산이라 락으로 쓴다.
281
337
  // 락 폴더 안 lock.json에 {pid, at}을 남겨, LOCK_STALE_MS가 지나면 강탈 허용.
338
+ // lock.json 없는 고아 폴더(mkdir과 marker 사이에 죽은 프로세스의 흔적)는
339
+ // 폴더 mtime으로 판정해 강탈한다 — 없으면 영원히 획득 불가능해진다.
282
340
  function withLock(paths, name, fn) {
283
341
  const lockPath = path.join(paths.lockDir, name);
284
342
  fs.mkdirSync(paths.lockDir, { recursive: true });
@@ -291,7 +349,8 @@ function withLock(paths, name, fn) {
291
349
  if (e.code !== 'EEXIST') throw e;
292
350
  // 이미 락이 있다 — 오래됐으면 강탈 (한 번만)
293
351
  const markerPath = path.join(lockPath, 'lock.json');
294
- if (!stolen && fs.existsSync(markerPath)) {
352
+ if (stolen) return false;
353
+ if (fs.existsSync(markerPath)) {
295
354
  try {
296
355
  const { at } = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
297
356
  if (Date.now() - at > LOCK_STALE_MS) {
@@ -299,6 +358,14 @@ function withLock(paths, name, fn) {
299
358
  return acquire(true);
300
359
  }
301
360
  } catch { /* 판정 불가 — 락 유지로 간주 */ }
361
+ } else {
362
+ // 고아 락 폴더 — marker가 없으니 폴더 mtime이 유일한 단서다.
363
+ try {
364
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
365
+ fs.rmSync(lockPath, { recursive: true, force: true });
366
+ return acquire(true);
367
+ }
368
+ } catch { /* 판정 불가 — 락 유지로 간주 */ }
302
369
  }
303
370
  return false;
304
371
  }
@@ -307,11 +374,19 @@ function withLock(paths, name, fn) {
307
374
  if (!acquire()) {
308
375
  throw new Error(`칸반 락 획득 실패: ${name} — 다른 세션이 보드를 고치는 중. 잠시 후 재시도.`);
309
376
  }
377
+ // 반납 이중 보험: 콜백 안의 fail()이 process.exit를 치면 finally가 안 돈다
378
+ // (실측: 만료 클레임 renew 거부 직후 락이 누출). exit 핸들러는 동기 코드를
379
+ // 돌리므로 rmSync로 반납을 보장한다.
380
+ const release = () => {
381
+ try { fs.rmSync(lockPath, { recursive: true, force: true }); } catch { /* 이미 없음 */ }
382
+ };
383
+ process.on('exit', release);
310
384
  try {
311
385
  fs.writeFileSync(path.join(lockPath, 'lock.json'), JSON.stringify({ pid: process.pid, at: Date.now() }));
312
386
  return fn();
313
387
  } finally {
314
- fs.rmSync(lockPath, { recursive: true, force: true });
388
+ process.removeListener('exit', release);
389
+ release();
315
390
  }
316
391
  }
317
392
 
@@ -343,6 +418,7 @@ module.exports = {
343
418
  ACTIVE_STATUSES,
344
419
  TERMINAL_FOLDERS,
345
420
  DEFAULT_CONFIG,
421
+ BOARD_LOCK_NAME,
346
422
  kanbanPaths,
347
423
  loadConfig,
348
424
  slugify,
@@ -0,0 +1,9 @@
1
+ // "오늘"은 현지 날짜로 — raw 로그 헤더·카드 created·not_before 게이트가 전부 현지
2
+ // 날짜로 쓰인다. UTC(toISOString)와 섞으면 KST 자정~9시 사이에 판정이 하루 어긋난다
3
+ // (2026-08-30 lastCompiled 실측 → 4c5f27c, 2026-09-09 칸반 6곳 동일 적용 → 개선계획 5-1-5).
4
+ function localToday(date = new Date()) {
5
+ const pad = n => String(n).padStart(2, '0');
6
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
7
+ }
8
+
9
+ module.exports = { localToday };