tokenbill-mcp 1.0.0 → 1.1.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,507 @@
1
+ /**
2
+ * Duet 핵심 — 폴더와 파일, 그리고 상태 규칙. (Python duet/core.py 를 그대로 옮긴 것)
3
+ *
4
+ * ~/.duet/
5
+ * ├── duet/ 프로젝트 = 폴더
6
+ * │ ├── T001-대시보드-만들기/
7
+ * │ │ ├── task.md 제목·설명 (+ 사람이 정한 상태 한 줄)
8
+ * │ │ └── s-a1b2c3d4.json 세션 (그 프로세스만 쓴다)
9
+ * │ └── T002-…/
10
+ * ├── _미분류/ 어느 프로젝트인지 못 알아낸 타스크
11
+ * ├── _미참여/ 아직 join 안 한 세션 파일
12
+ * ├── _보관/ 끝난 프로젝트를 폴더째 옮겨 두는 곳
13
+ * └── 프로젝트.md 폴더 경로 = 부르는 이름
14
+ *
15
+ * 규칙 두 개가 전부다:
16
+ * 1. 타스크 상태는 저장하지 않고 센다. 세션 파일을 세서 정한다.
17
+ * 전부 완료면 완료, 하나라도 살아 있으면 진행중, 끊긴 세션이 남았으면 확인 필요.
18
+ * 2. 사람이 정한 것이 이긴다. task.md 에 `상태: 완료` 한 줄이 있으면 그게 상태다.
19
+ *
20
+ * 한 파일에 두 주인이 없다. 세션 파일은 그 세션의 프로세스만 쓴다. task.md 는 편집만 쓴다.
21
+ * 기록 형식은 Python 판과 같아서 어느 쪽으로 만든 기록이든 서로 읽는다.
22
+ */
23
+ "use strict";
24
+ const fs = require("fs");
25
+ const path = require("path");
26
+ const os = require("os");
27
+ const crypto = require("crypto");
28
+
29
+ const HOME_ENV = "DUET_HOME";
30
+ const IDLE_DIRNAME = "_미참여";
31
+ const ARCHIVE_DIRNAME = "_보관";
32
+ const UNSORTED_DIRNAME = "_미분류";
33
+ const ALIAS_FILENAME = "프로젝트.md";
34
+
35
+ const HEARTBEAT_SEC = 30;
36
+ const STALE_SEC = 90;
37
+
38
+ const WAITING = "대기", RUNNING = "진행중", DONE = "완료", STOPPED = "중지";
39
+ const ATTENTION = "확인 필요", HOLD = "보류", CANCELLED = "취소";
40
+ const TASK_STATUSES = [WAITING, RUNNING, ATTENTION, DONE, HOLD, CANCELLED];
41
+ const HUMAN_STATUSES = [DONE, HOLD, CANCELLED];
42
+ const CLOSED = [DONE, STOPPED];
43
+
44
+ const TASK_DIR = /^(T\d{3,})(?:-.*)?$/;
45
+ const STATUS_LINE = /^상태:\s*(\S+)\s*$/m;
46
+ const PROJECT_LINE = /^프로젝트:\s*(.+?)\s*$/m;
47
+ const META_LINE = /^(?:상태|프로젝트):\s*.*$/;
48
+ const NOT_PROJECT = new Set(["", "/", "\\", "system32", "windows", "desktop", "바탕 화면", "temp", "tmp"]);
49
+ const TEMP_FOLDER = /^(scratch|tmp|temp)[-_.]/i;
50
+ const BAD_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
51
+
52
+ class DuetError extends Error {}
53
+
54
+ function pad(n, w = 2) { return String(n).padStart(w, "0"); }
55
+ /** 로컬 시각, 밀리초까지. Python 의 isoformat(timespec="milliseconds") 와 같은 꼴. */
56
+ function now() {
57
+ const d = new Date();
58
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
59
+ }
60
+ function age(stamp) {
61
+ const t = Date.parse(stamp || "");
62
+ return Number.isNaN(t) ? Infinity : (Date.now() - t) / 1000;
63
+ }
64
+
65
+ const migrated = new Set();
66
+ function home() {
67
+ const override = process.env[HOME_ENV];
68
+ const base = override ? path.resolve(override) : path.join(os.homedir(), ".duet");
69
+ fs.mkdirSync(path.join(base, IDLE_DIRNAME), { recursive: true });
70
+ if (!migrated.has(base)) { migrated.add(base); migrateFlatLayout(base); }
71
+ return base;
72
+ }
73
+
74
+ // ── 세션 파일 ─────────────────────────────────────────────────
75
+
76
+ class Session {
77
+ constructor(data = {}) {
78
+ this.id = data.id;
79
+ this.title = data.title || "";
80
+ this.client = data.client || "알 수 없음";
81
+ this.status = data.status || RUNNING;
82
+ this.cwd = data.cwd || "";
83
+ this.client_session = data.client_session || "";
84
+ this.started = data.started || "";
85
+ this.heartbeat = data.heartbeat || "";
86
+ this.summary = data.summary || "";
87
+ this.by_human = !!data.by_human;
88
+ this.progress = Array.isArray(data.progress) ? data.progress : [];
89
+ this.path = null;
90
+ }
91
+ get alive() { return !CLOSED.includes(this.status) && age(this.heartbeat) <= STALE_SEC; }
92
+ get shownStatus() { return CLOSED.includes(this.status) ? this.status : (this.alive ? RUNNING : STOPPED); }
93
+ toDict() {
94
+ return { id: this.id, title: this.title, client: this.client, cwd: this.cwd, client_session: this.client_session,
95
+ status: this.shownStatus, by_human: this.by_human, alive: this.alive, started: this.started,
96
+ heartbeat: this.heartbeat, summary: this.summary, progress: this.progress };
97
+ }
98
+ toFile() {
99
+ return { id: this.id, title: this.title, client: this.client, status: this.status, cwd: this.cwd,
100
+ client_session: this.client_session, started: this.started, heartbeat: this.heartbeat,
101
+ summary: this.summary, by_human: this.by_human, progress: this.progress };
102
+ }
103
+ /** 임시 파일에 쓰고 바꿔치기한다. 읽는 쪽이 반쪽 JSON 을 보지 않게. */
104
+ save() {
105
+ const tmp = `${this.path}.${process.pid}.tmp`;
106
+ fs.writeFileSync(tmp, JSON.stringify(this.toFile(), null, 2), "utf8");
107
+ fs.renameSync(tmp, this.path);
108
+ }
109
+ }
110
+
111
+ function readSession(file) {
112
+ let data;
113
+ try { data = JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
114
+ const s = new Session(data);
115
+ s.path = file;
116
+ return s;
117
+ }
118
+ function readSessions(dir) {
119
+ let names = [];
120
+ try { names = fs.readdirSync(dir).filter((n) => /^s-.*\.json$/.test(n)).sort(); } catch { return []; }
121
+ return names.map((n) => readSession(path.join(dir, n))).filter(Boolean)
122
+ .sort((a, b) => (a.started < b.started ? -1 : a.started > b.started ? 1 : 0));
123
+ }
124
+
125
+ // ── 타스크 ────────────────────────────────────────────────────
126
+
127
+ class Task {
128
+ constructor({ id, title, description, override, dir, sessions }) {
129
+ Object.assign(this, { id, title, description, override, dir, sessions: sessions || [] });
130
+ }
131
+ get project() { const n = path.basename(path.dirname(this.dir)); return n === UNSORTED_DIRNAME ? "" : n; }
132
+ get ref() { return `${this.project || UNSORTED_DIRNAME}/${this.id}`; }
133
+ get counts() {
134
+ const c = { [RUNNING]: 0, [DONE]: 0, [STOPPED]: 0 };
135
+ for (const s of this.sessions) c[s.shownStatus]++;
136
+ return c;
137
+ }
138
+ get status() {
139
+ if (this.override) return this.override;
140
+ const c = this.counts;
141
+ if (!this.sessions.length) return WAITING;
142
+ if (c[RUNNING]) return RUNNING;
143
+ if (c[STOPPED]) return ATTENTION;
144
+ return DONE;
145
+ }
146
+ get lastActivity() {
147
+ const stamps = this.sessions.map((s) => s.heartbeat).filter(Boolean);
148
+ return stamps.length ? stamps.reduce((a, b) => (a > b ? a : b)) : "";
149
+ }
150
+ get warning() {
151
+ return this.status === ATTENTION ? `끊긴 세션 ${this.counts[STOPPED]}개 — 이어갈지 끝낼지 사람이 정한다` : "";
152
+ }
153
+ toDict() {
154
+ return { id: this.id, ref: this.ref, project: this.project, title: this.title, status: this.status,
155
+ counts: this.counts, override: this.override, warning: this.warning, last_activity: this.lastActivity };
156
+ }
157
+ toDetail() {
158
+ return { ...this.toDict(), description: this.description, folder: this.dir, sessions: this.sessions.map((s) => s.toDict()) };
159
+ }
160
+ }
161
+
162
+ function readTask(dir) {
163
+ const text = fs.readFileSync(path.join(dir, "task.md"), "utf8");
164
+ let override = null;
165
+ const m = STATUS_LINE.exec(text);
166
+ if (m) {
167
+ const written = m[1] === STOPPED ? HOLD : m[1];
168
+ if (HUMAN_STATUSES.includes(written)) override = written;
169
+ }
170
+ const lines = text.split(/\r?\n/).filter((ln) => !META_LINE.test(ln));
171
+ const title = lines.length ? lines[0].replace(/^#\s*/, "").trim() : path.basename(dir);
172
+ return new Task({
173
+ id: TASK_DIR.exec(path.basename(dir))[1], title,
174
+ description: lines.slice(1).join("\n").trim(), override, dir, sessions: readSessions(dir),
175
+ });
176
+ }
177
+ function writeTask(dir, title, description, override) {
178
+ const out = [`# ${title}`];
179
+ if (override) out.push(`상태: ${override}`);
180
+ out.push("", (description || "").trim(), "");
181
+ fs.writeFileSync(path.join(dir, "task.md"), out.join("\n"), "utf8");
182
+ }
183
+ function safeRead(dir) { try { return readTask(dir); } catch { return null; } }
184
+
185
+ // ── 프로젝트 폴더 ───────────────────────────────────────────────
186
+
187
+ function projectDir(name, base) {
188
+ const folder = (name || "").trim() || UNSORTED_DIRNAME;
189
+ if (folder !== UNSORTED_DIRNAME && (folder.startsWith("_") || BAD_CHARS.test(folder))) {
190
+ BAD_CHARS.lastIndex = 0;
191
+ throw new DuetError(`프로젝트 이름으로 쓸 수 없다: ${name}`);
192
+ }
193
+ BAD_CHARS.lastIndex = 0;
194
+ return path.join(base || home(), folder);
195
+ }
196
+ function projectDirs(base) {
197
+ const root = base || home();
198
+ let entries = [];
199
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; }
200
+ return entries.filter((e) => e.isDirectory() && (!e.name.startsWith("_") || e.name === UNSORTED_DIRNAME))
201
+ .map((e) => path.join(root, e.name)).sort();
202
+ }
203
+ function taskDirsIn(folder) {
204
+ let entries = [];
205
+ try { entries = fs.readdirSync(folder, { withFileTypes: true }); } catch { return []; }
206
+ return entries.filter((e) => e.isDirectory() && TASK_DIR.test(e.name)).map((e) => path.join(folder, e.name)).sort();
207
+ }
208
+ function taskDirs() { return projectDirs().flatMap(taskDirsIn); }
209
+ function archiveDir() { const p = path.join(home(), ARCHIVE_DIRNAME); fs.mkdirSync(p, { recursive: true }); return p; }
210
+
211
+ /** 그 프로젝트의 다음 번호로 타스크 폴더를 만든다. mkdir 이 원자적이라 동시에 만들어도 진 쪽만 밀린다. */
212
+ function nextTaskDir(folder, title) {
213
+ fs.mkdirSync(folder, { recursive: true });
214
+ const slug = (title || "").replace(BAD_CHARS, "").trim().replace(/\s+/g, "-").slice(0, 40).replace(/^[-. ]+|[-. ]+$/g, "");
215
+ const used = new Set(taskDirsIn(folder).map((p) => TASK_DIR.exec(path.basename(p))[1]));
216
+ for (let n = 1; ; n++) {
217
+ const tid = `T${pad(n, 3)}`;
218
+ if (used.has(tid)) continue;
219
+ const candidate = path.join(folder, slug ? `${tid}-${slug}` : tid);
220
+ try { fs.mkdirSync(candidate); return candidate; } catch (e) { if (e.code !== "EEXIST") throw e; }
221
+ }
222
+ }
223
+
224
+ // ── 타스크 찾기·만들기·고치기 ────────────────────────────────────
225
+
226
+ function parseRef(ref) {
227
+ let text = String(ref).trim(), project = null;
228
+ if (text.includes("/")) {
229
+ const i = text.lastIndexOf("/");
230
+ project = text.slice(0, i).trim();
231
+ text = text.slice(i + 1);
232
+ if (project === UNSORTED_DIRNAME) project = "";
233
+ }
234
+ const number = text.trim().toUpperCase().replace(/^T+/, "");
235
+ if (!/^\d+$/.test(number)) throw new DuetError(`타스크 id가 아니다: ${ref}`);
236
+ return [project, `T${pad(Number(number), 3)}`];
237
+ }
238
+ const tidOf = (dir) => TASK_DIR.exec(path.basename(dir))[1];
239
+
240
+ function findTask(ref, hint) {
241
+ const [project, tid] = parseRef(ref);
242
+ if (project !== null) {
243
+ const hit = taskDirsIn(projectDir(project)).find((p) => tidOf(p) === tid);
244
+ if (hit) return hit;
245
+ throw new DuetError(`${project || UNSORTED_DIRNAME}/${tid} 타스크가 없다.`);
246
+ }
247
+ if (hint !== undefined && hint !== null) {
248
+ const hit = taskDirsIn(projectDir(hint)).find((p) => tidOf(p) === tid);
249
+ if (hit) return hit;
250
+ }
251
+ const found = taskDirs().filter((p) => tidOf(p) === tid);
252
+ if (found.length === 1) return found[0];
253
+ if (!found.length) throw new DuetError(`${tid} 타스크가 없다.`);
254
+ throw new DuetError(`${tid}가 여러 프로젝트에 있다: ${found.map((p) => `${path.basename(path.dirname(p))}/${tid}`).join(", ")}. 프로젝트를 붙여서 불러라.`);
255
+ }
256
+ function getTask(ref, hint) { return readTask(findTask(ref, hint)); }
257
+
258
+ function createTask(title, description = "", project = "") {
259
+ title = (title || "").trim();
260
+ if (!title) throw new DuetError("타스크 제목이 비어 있다.");
261
+ const dir = nextTaskDir(projectDir(project), title);
262
+ writeTask(dir, title, description, null);
263
+ return readTask(dir);
264
+ }
265
+ function listTasks(status) {
266
+ reap();
267
+ let tasks = taskDirs().map(safeRead).filter(Boolean);
268
+ if (status) tasks = tasks.filter((t) => t.status === status);
269
+ const key = (t) => `${t.lastActivity}${t.project}${t.id}`;
270
+ return tasks.sort((a, b) => (key(a) < key(b) ? 1 : key(a) > key(b) ? -1 : 0));
271
+ }
272
+ function projects() {
273
+ const out = [];
274
+ for (const p of projectDirs()) {
275
+ const n = path.basename(p);
276
+ if (n === UNSORTED_DIRNAME) { if (taskDirsIn(p).length) out.push(""); } else out.push(n);
277
+ }
278
+ return out;
279
+ }
280
+ function words(s) { return new Set(s.toLowerCase().split(/[\s·,]+/).filter((w) => w.length > 1)); }
281
+ function similarTasks(title, limit = 3) {
282
+ const ws = words(title), lower = title.toLowerCase(), found = [];
283
+ for (const t of listTasks()) {
284
+ if ([DONE, CANCELLED].includes(t.status)) continue;
285
+ const other = words(t.title), tl = t.title.toLowerCase();
286
+ const nested = lower.includes(tl) || tl.includes(lower);
287
+ if (nested || [...ws].some((w) => other.has(w))) found.push(t);
288
+ }
289
+ return found.slice(0, limit);
290
+ }
291
+ function updateTask(ref, { title, description, project, hint } = {}) {
292
+ const task = getTask(ref, hint);
293
+ const newTitle = title == null ? task.title : title.trim();
294
+ if (!newTitle) throw new DuetError("타스크 제목이 비어 있다.");
295
+ const newDesc = description == null ? task.description : description.trim();
296
+ writeTask(task.dir, newTitle, newDesc, task.override);
297
+ if (project != null && project.trim() !== task.project) {
298
+ if (task.counts[RUNNING]) throw new DuetError("살아 있는 세션이 붙어 있어 다른 프로젝트로 못 옮긴다.");
299
+ const target = nextTaskDir(projectDir(project), newTitle);
300
+ fs.rmdirSync(target);
301
+ fs.renameSync(task.dir, target);
302
+ return readTask(target);
303
+ }
304
+ return readTask(task.dir);
305
+ }
306
+ function setStatus(ref, status, hint) {
307
+ if (status != null && !HUMAN_STATUSES.includes(status)) {
308
+ throw new DuetError(`고를 수 있는 상태: ${HUMAN_STATUSES.join(", ")}. '${WAITING}'·'${RUNNING}'·'${ATTENTION}'는 세션이 없느냐 살아 있느냐 끊겼느냐는 사실이라 고르는 것이 아니다. 되돌리려면 status=null(🔒 풀기).`);
309
+ }
310
+ const task = getTask(ref, hint);
311
+ writeTask(task.dir, task.title, task.description, status || null);
312
+ return readTask(task.dir);
313
+ }
314
+
315
+ // ── 세션 ─────────────────────────────────────────────────────
316
+
317
+ function aliases() {
318
+ let text;
319
+ try { text = fs.readFileSync(path.join(home(), ALIAS_FILENAME), "utf8"); } catch { return {}; }
320
+ const out = {};
321
+ for (const line of text.split(/\r?\n/)) {
322
+ if (line.startsWith("#") || !line.includes("=")) continue;
323
+ const i = line.indexOf("=");
324
+ const folder = line.slice(0, i).trim(), name = line.slice(i + 1).trim();
325
+ if (folder && name) out[path.normalize(folder).toLowerCase()] = name;
326
+ }
327
+ return out;
328
+ }
329
+ function projectName(cwd) {
330
+ if (!cwd) return "";
331
+ const named = aliases()[path.normalize(cwd).toLowerCase()];
332
+ if (named) return named;
333
+ const name = path.basename(cwd).trim();
334
+ if (NOT_PROJECT.has(name.toLowerCase()) || name.startsWith(".") || TEMP_FOLDER.test(name)) return "";
335
+ return name;
336
+ }
337
+ function registerSession(client = "알 수 없음", cwd = "", clientSession = "") {
338
+ const s = new Session({ id: "s-" + crypto.randomBytes(4).toString("hex"), client, cwd, client_session: clientSession, started: now(), heartbeat: now() });
339
+ s.path = path.join(home(), IDLE_DIRNAME, `${s.id}.json`);
340
+ s.save();
341
+ return s;
342
+ }
343
+ function join(session, ref, title = "") {
344
+ if (CLOSED.includes(session.status)) {
345
+ const done = taskOf(session);
346
+ throw new DuetError(`이 세션은 ${done ? `[${done.ref}] ${done.title}을(를)` : "일을"} 이미 끝냈다. 세션 하나는 타스크 하나다 — 다른 일은 새 세션에서.`);
347
+ }
348
+ const current = taskOf(session);
349
+ if (current && session.progress.length) throw new DuetError(`이 세션은 이미 [${current.ref}] ${current.title}에 붙어 보고까지 했다. 세션 하나는 타스크 하나다.`);
350
+ const dir = findTask(ref, projectName(session.cwd));
351
+ const busy = readSessions(dir).find((s) => s.id !== session.id && s.alive);
352
+ if (busy) {
353
+ throw new DuetError(`[${path.basename(path.dirname(dir))}/${tidOf(dir)}]에는 이미 살아 있는 세션이 있다: '${busy.title || busy.id}' (${busy.client}). 타스크 하나에는 세션 하나만 붙는다 — 그 세션이 끝나거나 멈춘 뒤에 이어받는다. 같은 일을 다른 창에서 하고 있는 게 아닌지 사용자에게 확인하라.`);
354
+ }
355
+ const old = session.path;
356
+ session.title = (title || "").trim() || session.title;
357
+ session.status = RUNNING;
358
+ session.heartbeat = now();
359
+ session.path = path.join(dir, `${session.id}.json`);
360
+ session.save();
361
+ if (old !== session.path) { try { fs.unlinkSync(old); } catch {} }
362
+ return readTask(dir);
363
+ }
364
+ function taskOf(session) {
365
+ if (!session.path || !TASK_DIR.test(path.basename(path.dirname(session.path)))) return null;
366
+ return safeRead(path.dirname(session.path));
367
+ }
368
+ function heartbeat(session) { if (!CLOSED.includes(session.status)) { session.heartbeat = now(); session.save(); } }
369
+ function report(session, message, percent = null) {
370
+ message = (message || "").trim();
371
+ if (!message) throw new DuetError("진행 내용이 비어 있다.");
372
+ if (CLOSED.includes(session.status)) throw new DuetError("이미 끝난 세션이다. 보고할 수 없다.");
373
+ const entry = { t: now(), msg: message, pct: percent == null ? null : percent };
374
+ session.progress.push(entry);
375
+ session.heartbeat = entry.t;
376
+ session.save();
377
+ return entry;
378
+ }
379
+ /** 세션을 끝낸다. 먼저 적힌 종료가 남는다 — 나중 것이 덮지 않는다. */
380
+ function finish(session, status, summary = "") {
381
+ const onDisk = session.path ? readSession(session.path) : null;
382
+ if (onDisk && CLOSED.includes(onDisk.status)) { session.status = onDisk.status; session.summary = onDisk.summary; return taskOf(session); }
383
+ session.status = status;
384
+ session.summary = (summary || "").trim();
385
+ session.save();
386
+ return taskOf(session);
387
+ }
388
+ function setSessionStatus(ref, sessionId, status, hint) {
389
+ if (!CLOSED.includes(status)) throw new DuetError("세션은 완료 또는 중지로만 바꾼다.");
390
+ const task = getTask(ref, hint);
391
+ const s = task.sessions.find((x) => x.id === sessionId);
392
+ if (!s) throw new DuetError(`${task.ref}에 ${sessionId} 세션이 없다.`);
393
+ if (s.alive) throw new DuetError("아직 살아 있는 세션이다. 그 창을 닫거나 pause_session을 부르게 하라.");
394
+ s.status = status; s.by_human = true; s.save();
395
+ return readTask(task.dir);
396
+ }
397
+
398
+ // ── 보관·삭제 (프로젝트 폴더째) ───────────────────────────────────
399
+
400
+ function projectTasks(name, archived = false) {
401
+ return taskDirsIn(projectDir(name, archived ? archiveDir() : undefined)).map(safeRead).filter(Boolean);
402
+ }
403
+ function listArchived() {
404
+ const out = projectDirs(archiveDir()).flatMap((p) => taskDirsIn(p).map(safeRead).filter(Boolean));
405
+ const key = (t) => `${t.project}${t.id}`;
406
+ return out.sort((a, b) => (key(a) < key(b) ? 1 : key(a) > key(b) ? -1 : 0));
407
+ }
408
+ function archiveProject(name) {
409
+ const src = projectDir(name), tasks = projectTasks(name);
410
+ if (!tasks.length) throw new DuetError(`'${name || UNSORTED_DIRNAME}'에 타스크가 없다.`);
411
+ const busy = tasks.filter((t) => t.counts[RUNNING]).map((t) => t.ref);
412
+ if (busy.length) throw new DuetError(`살아 있는 세션이 있다: ${busy.join(", ")}. 창을 닫고 나서 보관한다.`);
413
+ const target = path.join(archiveDir(), path.basename(src));
414
+ if (fs.existsSync(target)) throw new DuetError(`보관함에 '${path.basename(src)}'이 이미 있다. 먼저 되돌리거나 이름을 바꿔라.`);
415
+ fs.renameSync(src, target);
416
+ return projectTasks(name, true);
417
+ }
418
+ function unarchiveProject(name) {
419
+ const src = projectDir(name, archiveDir());
420
+ if (!fs.existsSync(src)) throw new DuetError(`보관함에 '${name || UNSORTED_DIRNAME}'이 없다.`);
421
+ const target = path.join(home(), path.basename(src));
422
+ if (fs.existsSync(target)) throw new DuetError(`보드에 '${path.basename(src)}'이 이미 있다.`);
423
+ fs.renameSync(src, target);
424
+ return projectTasks(name);
425
+ }
426
+ function deleteTask(ref, hint) {
427
+ const task = getTask(ref, hint);
428
+ if (task.counts[RUNNING]) throw new DuetError("살아 있는 세션이 붙어 있어 지울 수 없다. 그 창을 닫거나 끝낸 뒤에.");
429
+ fs.rmSync(task.dir, { recursive: true, force: true });
430
+ return task.ref;
431
+ }
432
+ function deleteProject(name) {
433
+ const folder = projectDir(name);
434
+ if (!fs.existsSync(folder)) throw new DuetError(`'${name || UNSORTED_DIRNAME}' 프로젝트가 없다.`);
435
+ const busy = projectTasks(name).filter((t) => t.counts[RUNNING]).map((t) => t.ref);
436
+ if (busy.length) throw new DuetError(`살아 있는 세션이 있다: ${busy.join(", ")}. 창을 닫은 뒤에 지운다.`);
437
+ fs.rmSync(folder, { recursive: true, force: true });
438
+ return path.basename(folder);
439
+ }
440
+
441
+ // ── 정리 ─────────────────────────────────────────────────────
442
+
443
+ /** 하트비트가 끊긴 세션을 중지로 적는다. 쓸 프로세스가 이미 없어서 허용되는 유일한 예외. */
444
+ function reap() {
445
+ const dead = [];
446
+ for (const dir of [path.join(home(), IDLE_DIRNAME), ...taskDirs()]) {
447
+ for (const s of readSessions(dir)) {
448
+ if (CLOSED.includes(s.status) || s.alive) continue;
449
+ s.status = STOPPED;
450
+ s.summary = s.summary || "하트비트 끊김 (프로세스 종료 추정)";
451
+ s.save();
452
+ dead.push(s.id);
453
+ }
454
+ }
455
+ return dead;
456
+ }
457
+
458
+ /** ~/.duet 아래 파일들이 지금 어떤 모양인지 한 줄. 바뀌면 값이 바뀐다. 보드가 이걸 보고 다시 읽는다. */
459
+ function fingerprint() {
460
+ const root = home(), parts = [];
461
+ const folders = [path.join(root, IDLE_DIRNAME), ...projectDirs(root), ...taskDirs()];
462
+ for (const folder of folders) {
463
+ let entries = [];
464
+ try { entries = fs.readdirSync(folder, { withFileTypes: true }); } catch { continue; }
465
+ for (const e of entries) {
466
+ if (!e.isFile() || !(e.name.endsWith(".json") || e.name === "task.md")) continue;
467
+ try { const st = fs.statSync(path.join(folder, e.name)); parts.push(`${folder}/${e.name}:${st.mtimeMs}:${st.size}`); } catch {}
468
+ }
469
+ }
470
+ parts.push(JSON.stringify(folders.map((f) => path.basename(f)).sort()));
471
+ const digest = crypto.createHash("sha256").update(parts.sort().join("\n")).digest("hex").slice(0, 16);
472
+ return `${parts.length}:${digest}`;
473
+ }
474
+
475
+ /** 옛 평평한 배치(~/.duet/T001-…)를 프로젝트 폴더 아래로. 한 번만 일어난다. */
476
+ function migrateFlatLayout(base) {
477
+ for (const root of [base, path.join(base, ARCHIVE_DIRNAME)]) {
478
+ let entries = [];
479
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
480
+ for (const e of entries.filter((x) => x.isDirectory() && TASK_DIR.test(x.name)).sort((a, b) => a.name.localeCompare(b.name))) {
481
+ const old = path.join(root, e.name);
482
+ let text;
483
+ try { text = fs.readFileSync(path.join(old, "task.md"), "utf8"); } catch { continue; }
484
+ const m = PROJECT_LINE.exec(text);
485
+ let project = m ? m[1].trim() : "";
486
+ if (!project) for (const s of readSessions(old)) { project = projectName(s.cwd); if (project) break; }
487
+ let folder;
488
+ try { folder = projectDir(project, root); } catch { folder = path.join(root, UNSORTED_DIRNAME); }
489
+ fs.mkdirSync(folder, { recursive: true });
490
+ const title = e.name.includes("-") ? e.name.slice(e.name.indexOf("-") + 1) : "";
491
+ const target = nextTaskDir(folder, title.replace(/-/g, " "));
492
+ fs.rmdirSync(target);
493
+ fs.renameSync(old, target);
494
+ fs.writeFileSync(path.join(target, "task.md"), text.split(/\r?\n/).filter((ln) => !PROJECT_LINE.test(ln)).join("\n"), "utf8");
495
+ }
496
+ }
497
+ }
498
+
499
+ module.exports = {
500
+ DuetError, Session, Task,
501
+ HEARTBEAT_SEC, STALE_SEC, WAITING, RUNNING, DONE, STOPPED, ATTENTION, HOLD, CANCELLED,
502
+ TASK_STATUSES, HUMAN_STATUSES, CLOSED, UNSORTED_DIRNAME, IDLE_DIRNAME, ARCHIVE_DIRNAME,
503
+ now, age, home, readSession, readSessions, projectDir, projectDirs, taskDirsIn, taskDirs, archiveDir,
504
+ findTask, getTask, createTask, listTasks, projects, similarTasks, updateTask, setStatus,
505
+ aliases, projectName, registerSession, join, taskOf, heartbeat, report, finish, setSessionStatus,
506
+ projectTasks, listArchived, archiveProject, unarchiveProject, deleteTask, deleteProject, reap, fingerprint,
507
+ };