@eunjae/il 0.0.1 → 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,1021 @@
1
+ #!/usr/bin/env node
2
+ import { access, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Command } from "commander";
6
+ import writeFileAtomic from "write-file-atomic";
7
+ import { z } from "zod";
8
+ import { homedir } from "node:os";
9
+ import { Octokit } from "octokit";
10
+ import { DateTime } from "luxon";
11
+ import lockfile from "proper-lockfile";
12
+ import { createHash } from "node:crypto";
13
+ import { ulid } from "ulid";
14
+
15
+ //#region lib/json.ts
16
+ const readJsonFile = async (filePath) => {
17
+ const raw = await readFile(filePath, "utf8");
18
+ return JSON.parse(raw);
19
+ };
20
+ const writeJsonAtomic = async (filePath, data) => {
21
+ await writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n`, {
22
+ encoding: "utf8",
23
+ fsync: true
24
+ });
25
+ };
26
+
27
+ //#endregion
28
+ //#region lib/aliasRepo.ts
29
+ const emptyAliases = () => ({
30
+ T: {},
31
+ R: {}
32
+ });
33
+ const aliasFilePath = (workspaceRoot) => path.join(workspaceRoot, "aliases.json");
34
+ const fileExists$2 = async (filePath) => {
35
+ try {
36
+ await access(filePath);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ };
42
+ const readAliases = async (workspaceRoot) => {
43
+ const filePath = aliasFilePath(workspaceRoot);
44
+ if (!await fileExists$2(filePath)) return emptyAliases();
45
+ const data = await readJsonFile(filePath);
46
+ return {
47
+ T: data.T ?? {},
48
+ R: data.R ?? {}
49
+ };
50
+ };
51
+ const writeAliases = async (workspaceRoot, aliases) => {
52
+ await writeJsonAtomic(aliasFilePath(workspaceRoot), aliases);
53
+ };
54
+ const parseAlias = (alias) => {
55
+ const match = alias.match(/^([TR])(\d+)$/);
56
+ if (!match) return null;
57
+ return {
58
+ prefix: match[1],
59
+ key: match[2]
60
+ };
61
+ };
62
+ const formatAliasKey = (value) => String(value).padStart(2, "0");
63
+ const allocateAliasInMap = (aliases, prefix, taskId) => {
64
+ const used = new Set(Object.keys(aliases[prefix]).map((key) => Number.parseInt(key, 10)));
65
+ let next = 1;
66
+ while (used.has(next)) next += 1;
67
+ const key = formatAliasKey(next);
68
+ aliases[prefix][key] = taskId;
69
+ return {
70
+ alias: `${prefix}${key}`,
71
+ aliases
72
+ };
73
+ };
74
+ const allocateAlias = async (workspaceRoot, prefix, taskId) => {
75
+ return allocateAliasInMap(await readAliases(workspaceRoot), prefix, taskId);
76
+ };
77
+ const resolveAlias = async (workspaceRoot, alias) => {
78
+ const parsed = parseAlias(alias);
79
+ if (!parsed) return null;
80
+ return (await readAliases(workspaceRoot))[parsed.prefix][parsed.key] ?? null;
81
+ };
82
+
83
+ //#endregion
84
+ //#region lib/constants.ts
85
+ const APP_NAME = "il";
86
+ const APP_DIR = `.${APP_NAME}`;
87
+ const LOCK_DIR = ".lock";
88
+ const LOCK_FILE = "store.lock";
89
+ const TASKS_DIR = "tasks";
90
+ const STATUS_ORDER = [
91
+ "backlog",
92
+ "active",
93
+ "paused",
94
+ "completed",
95
+ "cancelled"
96
+ ];
97
+
98
+ //#endregion
99
+ //#region lib/types.ts
100
+ const taskStatuses = [
101
+ "backlog",
102
+ "active",
103
+ "paused",
104
+ "completed",
105
+ "cancelled"
106
+ ];
107
+ const taskTypes = ["regular", "pr_review"];
108
+
109
+ //#endregion
110
+ //#region lib/schema.ts
111
+ const logEntrySchema = z.object({
112
+ ts: z.string(),
113
+ msg: z.string(),
114
+ status: z.enum(taskStatuses).optional()
115
+ });
116
+ const dayAssignmentSchema = z.object({
117
+ date: z.string(),
118
+ action: z.enum(["assign", "unassign"]),
119
+ ts: z.string(),
120
+ msg: z.string().optional()
121
+ });
122
+ const prRepoSchema = z.object({
123
+ host: z.string(),
124
+ owner: z.string(),
125
+ name: z.string()
126
+ });
127
+ const prFetchedSchema = z.object({
128
+ at: z.string(),
129
+ title: z.string(),
130
+ author: z.object({ login: z.string() }),
131
+ state: z.enum([
132
+ "open",
133
+ "closed",
134
+ "merged"
135
+ ]),
136
+ draft: z.boolean(),
137
+ updated_at: z.string()
138
+ });
139
+ const prAttachmentSchema = z.object({
140
+ url: z.string(),
141
+ provider: z.literal("github"),
142
+ repo: prRepoSchema.optional(),
143
+ number: z.number().int().positive().optional(),
144
+ fetched: prFetchedSchema.optional()
145
+ });
146
+ const taskMetadataSchema = z.object({
147
+ url: z.string().optional(),
148
+ pr: prAttachmentSchema.optional()
149
+ });
150
+ const taskSchema = z.object({
151
+ id: z.string(),
152
+ ref: z.string(),
153
+ type: z.enum(taskTypes),
154
+ title: z.string(),
155
+ status: z.enum(taskStatuses),
156
+ created_at: z.string(),
157
+ updated_at: z.string(),
158
+ metadata: taskMetadataSchema,
159
+ logs: z.array(logEntrySchema),
160
+ day_assignments: z.array(dayAssignmentSchema)
161
+ }).superRefine((task, ctx) => {
162
+ if (task.type === "pr_review" && !task.metadata.pr?.url) ctx.addIssue({
163
+ code: z.ZodIssueCode.custom,
164
+ message: "PR review tasks require metadata.pr.url",
165
+ path: [
166
+ "metadata",
167
+ "pr",
168
+ "url"
169
+ ]
170
+ });
171
+ });
172
+ const validateTaskOrThrow = (task) => {
173
+ return taskSchema.parse(task);
174
+ };
175
+
176
+ //#endregion
177
+ //#region lib/taskRepo.ts
178
+ const taskFileName = (taskId) => `${taskId}.json`;
179
+ const getStatusDir = (workspaceRoot, status) => path.join(workspaceRoot, TASKS_DIR, status);
180
+ const getTaskPath = (workspaceRoot, status, taskId) => {
181
+ return path.join(getStatusDir(workspaceRoot, status), taskFileName(taskId));
182
+ };
183
+ const fileExists$1 = async (filePath) => {
184
+ try {
185
+ await access(filePath);
186
+ return true;
187
+ } catch {
188
+ return false;
189
+ }
190
+ };
191
+ const findTaskPathById = async (workspaceRoot, taskId) => {
192
+ for (const status of STATUS_ORDER) {
193
+ const candidate = getTaskPath(workspaceRoot, status, taskId);
194
+ if (await fileExists$1(candidate)) return {
195
+ filePath: candidate,
196
+ status
197
+ };
198
+ }
199
+ return null;
200
+ };
201
+ const loadTaskFromPath = async (filePath) => {
202
+ const parsed = await readJsonFile(filePath);
203
+ return taskSchema.parse(parsed);
204
+ };
205
+ const listTasksByStatus = async (workspaceRoot, status) => {
206
+ const statusDir = getStatusDir(workspaceRoot, status);
207
+ const entries = await readdir(statusDir, { withFileTypes: true });
208
+ return await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map(async (entry) => {
209
+ const filePath = path.join(statusDir, entry.name);
210
+ return {
211
+ task: await loadTaskFromPath(filePath),
212
+ status,
213
+ filePath
214
+ };
215
+ }));
216
+ };
217
+ const listAllTasks = async (workspaceRoot) => {
218
+ return (await Promise.all(STATUS_ORDER.map((status) => listTasksByStatus(workspaceRoot, status)))).flat();
219
+ };
220
+ const saveTask = async (workspaceRoot, status, task) => {
221
+ const filePath = getTaskPath(workspaceRoot, status, task.id);
222
+ await writeJsonAtomic(filePath, task);
223
+ return filePath;
224
+ };
225
+ const moveTaskFile = async (workspaceRoot, taskId, fromStatus, toStatus) => {
226
+ const fromPath = getTaskPath(workspaceRoot, fromStatus, taskId);
227
+ const toPath = getTaskPath(workspaceRoot, toStatus, taskId);
228
+ await rename(fromPath, toPath);
229
+ return toPath;
230
+ };
231
+
232
+ //#endregion
233
+ //#region lib/aliasReconcile.ts
234
+ const reconcileAliases = async (workspaceRoot) => {
235
+ const aliases = await readAliases(workspaceRoot);
236
+ const tasks = await listAllTasks(workspaceRoot);
237
+ const tasksById = new Map(tasks.map((stored) => [stored.task.id, stored.task]));
238
+ const normalizePrefix = (prefix) => {
239
+ const next = {};
240
+ for (const [key, value] of Object.entries(aliases[prefix])) if (tasksById.has(value)) next[key] = value;
241
+ aliases[prefix] = next;
242
+ };
243
+ normalizePrefix("T");
244
+ normalizePrefix("R");
245
+ const existingIds = new Set([...Object.values(aliases.T), ...Object.values(aliases.R)]);
246
+ const missing = tasks.filter((stored) => !existingIds.has(stored.task.id)).sort((a, b) => a.task.created_at.localeCompare(b.task.created_at));
247
+ for (const stored of missing) allocateAliasInMap(aliases, stored.task.type === "pr_review" ? "R" : "T", stored.task.id);
248
+ await writeAliases(workspaceRoot, aliases);
249
+ return { updated: true };
250
+ };
251
+
252
+ //#endregion
253
+ //#region lib/config.ts
254
+ const fileExists = async (filePath) => {
255
+ try {
256
+ await access(filePath);
257
+ return true;
258
+ } catch {
259
+ return false;
260
+ }
261
+ };
262
+ const resolveGlobalConfigPath = () => {
263
+ const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
264
+ return path.join(configHome, APP_NAME, "config.json");
265
+ };
266
+ const readConfigFile = async (filePath) => {
267
+ if (!await fileExists(filePath)) return {};
268
+ return await readJsonFile(filePath) ?? {};
269
+ };
270
+ const resolveGithubToken = async (workspaceRoot) => {
271
+ if (process.env.IL_GITHUB_TOKEN) return process.env.IL_GITHUB_TOKEN;
272
+ const workspaceConfig = await readConfigFile(path.join(workspaceRoot, "config.json"));
273
+ if (workspaceConfig.github?.token) return workspaceConfig.github.token;
274
+ return (await readConfigFile(resolveGlobalConfigPath())).github?.token ?? null;
275
+ };
276
+
277
+ //#endregion
278
+ //#region lib/time.ts
279
+ const DEFAULT_ZONE = "Europe/Paris";
280
+ const toIsoDate = (value, label) => {
281
+ const date = value.toISODate();
282
+ if (!date) throw new Error(`Failed to generate ${label} date`);
283
+ return date;
284
+ };
285
+ const buildDateRange = (start, end) => {
286
+ const dates = [];
287
+ let cursor = start.startOf("day");
288
+ const final = end.startOf("day");
289
+ while (cursor <= final) {
290
+ dates.push(toIsoDate(cursor, "range"));
291
+ cursor = cursor.plus({ days: 1 });
292
+ }
293
+ return dates;
294
+ };
295
+ const nowIso = () => {
296
+ const value = DateTime.now().setZone(DEFAULT_ZONE).toISO();
297
+ if (!value) throw new Error("Failed to generate timestamp");
298
+ return value;
299
+ };
300
+ const todayDate = (date) => {
301
+ const value = date ? DateTime.fromISO(date, { zone: DEFAULT_ZONE }).toISODate() : DateTime.now().setZone(DEFAULT_ZONE).toISODate();
302
+ if (!value) throw new Error("Failed to generate date");
303
+ return value;
304
+ };
305
+ const listDatesForScope = (scope) => {
306
+ const now = DateTime.now().setZone(DEFAULT_ZONE);
307
+ switch (scope) {
308
+ case "today": return [toIsoDate(now, "today")];
309
+ case "yesterday": return [toIsoDate(now.minus({ days: 1 }), "yesterday")];
310
+ case "this_week": return buildDateRange(now.startOf("week"), now.endOf("week"));
311
+ case "last_week": {
312
+ const lastWeek = now.minus({ weeks: 1 });
313
+ return buildDateRange(lastWeek.startOf("week"), lastWeek.endOf("week"));
314
+ }
315
+ }
316
+ };
317
+
318
+ //#endregion
319
+ //#region lib/pr.ts
320
+ const parseGitHubPrUrl = (value) => {
321
+ let url;
322
+ try {
323
+ url = new URL(value);
324
+ } catch {
325
+ return null;
326
+ }
327
+ const parts = url.pathname.split("/").filter(Boolean);
328
+ if (parts.length < 4 || parts[2] !== "pull") return null;
329
+ const number = Number(parts[3]);
330
+ if (!Number.isInteger(number)) return null;
331
+ return {
332
+ url: value,
333
+ provider: "github",
334
+ repo: {
335
+ host: url.host,
336
+ owner: parts[0],
337
+ name: parts[1]
338
+ },
339
+ number
340
+ };
341
+ };
342
+ const buildPrAttachment = (parsed, fetched) => {
343
+ return {
344
+ url: parsed.url,
345
+ provider: "github",
346
+ repo: parsed.repo,
347
+ number: parsed.number,
348
+ fetched
349
+ };
350
+ };
351
+ const fetchGitHubPr = async (parsed, token) => {
352
+ if (!parsed.repo || !parsed.number) return null;
353
+ if (!token) return null;
354
+ const data = (await new Octokit({ auth: token }).rest.pulls.get({
355
+ owner: parsed.repo.owner,
356
+ repo: parsed.repo.name,
357
+ pull_number: parsed.number
358
+ })).data;
359
+ const state = data.merged ? "merged" : data.state === "open" ? "open" : "closed";
360
+ return {
361
+ at: nowIso(),
362
+ title: data.title,
363
+ author: { login: data.user?.login ?? "unknown" },
364
+ state,
365
+ draft: data.draft ?? false,
366
+ updated_at: data.updated_at
367
+ };
368
+ };
369
+
370
+ //#endregion
371
+ //#region lib/edit.ts
372
+ const parseValue = (raw) => {
373
+ try {
374
+ return JSON.parse(raw);
375
+ } catch {
376
+ return raw;
377
+ }
378
+ };
379
+ const applyTaskEdit = (task, dottedPath, rawValue) => {
380
+ if (dottedPath === "status" || dottedPath.startsWith("status.")) throw new Error("Use status commands to change status");
381
+ const value = parseValue(rawValue);
382
+ const updated = structuredClone(task);
383
+ const segments = dottedPath.split(".");
384
+ let current = updated;
385
+ for (const segment of segments.slice(0, -1)) {
386
+ if (!(segment in current)) current[segment] = {};
387
+ const next = current[segment];
388
+ if (typeof next !== "object" || next === null || Array.isArray(next)) throw new Error(`Cannot set ${dottedPath} on non-object path`);
389
+ current = next;
390
+ }
391
+ const last = segments[segments.length - 1];
392
+ current[last] = value;
393
+ if (dottedPath === "metadata.pr.url") {
394
+ if (typeof value !== "string") throw new Error("metadata.pr.url must be a string");
395
+ const parsed = parseGitHubPrUrl(value);
396
+ if (!parsed) throw new Error("Invalid PR URL");
397
+ updated.metadata.pr = buildPrAttachment(parsed, updated.metadata.pr?.fetched);
398
+ }
399
+ return validateTaskOrThrow({
400
+ ...updated,
401
+ updated_at: nowIso()
402
+ });
403
+ };
404
+
405
+ //#endregion
406
+ //#region lib/format.ts
407
+ const buildAliasLookup = (aliases) => {
408
+ const map = /* @__PURE__ */ new Map();
409
+ for (const [key, value] of Object.entries(aliases.T)) map.set(value, `T${key}`);
410
+ for (const [key, value] of Object.entries(aliases.R)) map.set(value, `R${key}`);
411
+ return map;
412
+ };
413
+ const formatTaskListLine = (task, alias) => {
414
+ return `${(alias ?? "--").padEnd(4)} ${task.ref.padEnd(9)} ${task.status.padEnd(9)} ${task.title}`;
415
+ };
416
+ const formatTaskDetails = (task, alias) => {
417
+ const lines = [];
418
+ lines.push(`${alias ?? "--"} ${task.ref} ${task.title}`);
419
+ lines.push(`id: ${task.id}`);
420
+ lines.push(`type: ${task.type}`);
421
+ lines.push(`status: ${task.status}`);
422
+ lines.push(`created: ${task.created_at}`);
423
+ lines.push(`updated: ${task.updated_at}`);
424
+ if (task.metadata.url) lines.push(`url: ${task.metadata.url}`);
425
+ if (task.metadata.pr?.url) {
426
+ lines.push(`pr: ${task.metadata.pr.url}`);
427
+ if (task.metadata.pr.fetched) {
428
+ const fetched = task.metadata.pr.fetched;
429
+ lines.push(`pr_state: ${fetched.state}`);
430
+ lines.push(`pr_title: ${fetched.title}`);
431
+ lines.push(`pr_author: ${fetched.author.login}`);
432
+ lines.push(`pr_updated_at: ${fetched.updated_at}`);
433
+ lines.push(`pr_refreshed_at: ${fetched.at}`);
434
+ }
435
+ }
436
+ if (task.logs.length > 0) {
437
+ lines.push("logs:");
438
+ for (const log of task.logs) {
439
+ const status = log.status ? ` [${log.status}]` : "";
440
+ lines.push(`- ${log.ts}${status} ${log.msg}`);
441
+ }
442
+ }
443
+ if (task.day_assignments.length > 0) {
444
+ lines.push("day_assignments:");
445
+ for (const entry of task.day_assignments) {
446
+ const msg = entry.msg ? ` (${entry.msg})` : "";
447
+ lines.push(`- ${entry.date} ${entry.action} ${entry.ts}${msg}`);
448
+ }
449
+ }
450
+ return lines.join("\n");
451
+ };
452
+
453
+ //#endregion
454
+ //#region lib/gitignore.ts
455
+ const lockEntry = `${APP_DIR}/.lock/`;
456
+ const ensureLockIgnored = async (repoRoot) => {
457
+ const gitignorePath = path.join(repoRoot, ".gitignore");
458
+ let current = "";
459
+ try {
460
+ current = await readFile(gitignorePath, "utf8");
461
+ } catch {
462
+ current = "";
463
+ }
464
+ if (current.split("\n").some((line) => line.trim() === lockEntry)) return false;
465
+ const separator = current.endsWith("\n") || current.length === 0 ? "" : "\n";
466
+ await writeFile(gitignorePath, `${current}${separator}${lockEntry}\n`, "utf8");
467
+ return true;
468
+ };
469
+
470
+ //#endregion
471
+ //#region lib/workspace.ts
472
+ const exists = async (filePath) => {
473
+ try {
474
+ await access(filePath);
475
+ return true;
476
+ } catch {
477
+ return false;
478
+ }
479
+ };
480
+ const findGitRoot = async (startDir) => {
481
+ let current = path.resolve(startDir);
482
+ while (true) {
483
+ if (await exists(path.join(current, ".git"))) return current;
484
+ const parent = path.dirname(current);
485
+ if (parent === current) return null;
486
+ current = parent;
487
+ }
488
+ };
489
+ const resolveGlobalWorkspaceRoot = () => {
490
+ const dataHome = process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share");
491
+ return path.join(dataHome, APP_NAME);
492
+ };
493
+ const resolveWorkspace = async (options) => {
494
+ if (options.store) return {
495
+ root: path.resolve(options.store),
496
+ kind: "explicit"
497
+ };
498
+ const repoRoot = await findGitRoot(options.cwd ?? process.cwd());
499
+ if (options.repo) {
500
+ if (!repoRoot) throw new Error("Not inside a git repository");
501
+ return {
502
+ root: path.join(repoRoot, APP_DIR),
503
+ kind: "repo"
504
+ };
505
+ }
506
+ if (options.global) return {
507
+ root: resolveGlobalWorkspaceRoot(),
508
+ kind: "global"
509
+ };
510
+ if (repoRoot) {
511
+ const repoWorkspace = path.join(repoRoot, APP_DIR);
512
+ if (await exists(repoWorkspace)) return {
513
+ root: repoWorkspace,
514
+ kind: "repo"
515
+ };
516
+ }
517
+ return {
518
+ root: resolveGlobalWorkspaceRoot(),
519
+ kind: "global"
520
+ };
521
+ };
522
+ const ensureWorkspaceLayout = async (workspaceRoot) => {
523
+ await mkdir(path.join(workspaceRoot, TASKS_DIR), { recursive: true });
524
+ await mkdir(path.join(workspaceRoot, LOCK_DIR), { recursive: true });
525
+ await Promise.all(STATUS_ORDER.map((status) => mkdir(path.join(workspaceRoot, TASKS_DIR, status), { recursive: true })));
526
+ };
527
+
528
+ //#endregion
529
+ //#region lib/lock.ts
530
+ const withWorkspaceLock = async (workspaceRoot, fn) => {
531
+ await ensureWorkspaceLayout(workspaceRoot);
532
+ const lockPath = path.join(workspaceRoot, LOCK_DIR, LOCK_FILE);
533
+ await writeFile(lockPath, "", { flag: "a" });
534
+ const release = await lockfile.lock(lockPath, {
535
+ stale: 6e4,
536
+ retries: {
537
+ retries: 5,
538
+ factor: 1.5,
539
+ minTimeout: 50,
540
+ maxTimeout: 1e3
541
+ }
542
+ });
543
+ try {
544
+ return await fn();
545
+ } finally {
546
+ await release();
547
+ }
548
+ };
549
+
550
+ //#endregion
551
+ //#region lib/search.ts
552
+ const taskMatchesQuery = (task, query) => {
553
+ const needle = query.toLowerCase();
554
+ const contains = (value) => value ? value.toLowerCase().includes(needle) : false;
555
+ if (contains(task.title)) return true;
556
+ if (task.logs.some((log) => contains(log.msg))) return true;
557
+ if (contains(task.metadata.pr?.url)) return true;
558
+ if (contains(task.metadata.pr?.fetched?.title)) return true;
559
+ return false;
560
+ };
561
+
562
+ //#endregion
563
+ //#region lib/id.ts
564
+ const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
565
+ const base32Encode = (input) => {
566
+ let bits = 0;
567
+ let value = 0;
568
+ let output = "";
569
+ for (const byte of input) {
570
+ value = value << 8 | byte;
571
+ bits += 8;
572
+ while (bits >= 5) {
573
+ output += BASE32_ALPHABET[value >>> bits - 5 & 31];
574
+ bits -= 5;
575
+ }
576
+ }
577
+ if (bits > 0) output += BASE32_ALPHABET[value << 5 - bits & 31];
578
+ return output;
579
+ };
580
+ const generateTaskId = (taskType) => {
581
+ return `${taskType === "pr_review" ? "R" : "T"}${ulid()}`;
582
+ };
583
+ const generateStableRef = (taskType, taskId) => {
584
+ return `${taskType === "pr_review" ? "R" : "T"}-${base32Encode(createHash("sha256").update(taskId).digest()).slice(0, 6)}`;
585
+ };
586
+
587
+ //#endregion
588
+ //#region lib/taskFactory.ts
589
+ const buildTask = (input) => {
590
+ const id = generateTaskId(input.type);
591
+ const ref = generateStableRef(input.type, id);
592
+ const timestamp = nowIso();
593
+ return {
594
+ id,
595
+ ref,
596
+ type: input.type,
597
+ title: input.title,
598
+ status: "backlog",
599
+ created_at: timestamp,
600
+ updated_at: timestamp,
601
+ metadata: input.metadata ?? {},
602
+ logs: [],
603
+ day_assignments: []
604
+ };
605
+ };
606
+
607
+ //#endregion
608
+ //#region lib/fsm.ts
609
+ const transitionTable = {
610
+ backlog: {
611
+ start: "active",
612
+ cancel: "cancelled"
613
+ },
614
+ active: {
615
+ pause: "paused",
616
+ complete: "completed",
617
+ cancel: "cancelled"
618
+ },
619
+ paused: {
620
+ start: "active",
621
+ complete: "completed",
622
+ cancel: "cancelled"
623
+ },
624
+ completed: {},
625
+ cancelled: {}
626
+ };
627
+ const getNextStatus = (current, action) => {
628
+ const next = transitionTable[current][action];
629
+ if (!next) throw new Error(`Invalid transition: ${current} -> ${action}`);
630
+ return next;
631
+ };
632
+
633
+ //#endregion
634
+ //#region lib/taskOperations.ts
635
+ const defaultMessages = {
636
+ start: "Started task",
637
+ pause: "Paused task",
638
+ complete: "Completed task",
639
+ cancel: "Cancelled task"
640
+ };
641
+ const appendLog = (task, message, status) => {
642
+ const log = {
643
+ ts: nowIso(),
644
+ msg: message,
645
+ status
646
+ };
647
+ return {
648
+ ...task,
649
+ logs: [...task.logs, log],
650
+ updated_at: nowIso()
651
+ };
652
+ };
653
+ const applyTransition = (task, action, message) => {
654
+ const nextStatus = getNextStatus(task.status, action);
655
+ const logMessage = message ?? defaultMessages[action];
656
+ const log = {
657
+ ts: nowIso(),
658
+ msg: logMessage,
659
+ status: nextStatus
660
+ };
661
+ return {
662
+ task: {
663
+ ...task,
664
+ status: nextStatus,
665
+ updated_at: nowIso(),
666
+ logs: [...task.logs, log]
667
+ },
668
+ nextStatus
669
+ };
670
+ };
671
+ const isAssignedOnDate = (task, date) => {
672
+ const events = task.day_assignments.filter((entry) => entry.date === date);
673
+ if (events.length === 0) return false;
674
+ return events[events.length - 1].action === "assign";
675
+ };
676
+ const appendDayAssignment = (task, date, action, msg) => {
677
+ const event = {
678
+ date,
679
+ action,
680
+ ts: nowIso(),
681
+ msg
682
+ };
683
+ return {
684
+ ...task,
685
+ day_assignments: [...task.day_assignments, event],
686
+ updated_at: nowIso()
687
+ };
688
+ };
689
+
690
+ //#endregion
691
+ //#region lib/taskStore.ts
692
+ const createTaskInStore = async (workspaceRoot, task) => {
693
+ const { alias, aliases } = await allocateAlias(workspaceRoot, task.type === "pr_review" ? "R" : "T", task.id);
694
+ await writeAliases(workspaceRoot, aliases);
695
+ await saveTask(workspaceRoot, task.status, task);
696
+ return {
697
+ alias,
698
+ task
699
+ };
700
+ };
701
+
702
+ //#endregion
703
+ //#region lib/taskResolver.ts
704
+ const stableRefPattern = /^[TR]-[A-Z0-9]{6}$/;
705
+ const resolveTaskId = async (workspaceRoot, identifier) => {
706
+ const aliasMatch = await resolveAlias(workspaceRoot, identifier);
707
+ if (aliasMatch) return aliasMatch;
708
+ if (stableRefPattern.test(identifier)) {
709
+ const match = (await listAllTasks(workspaceRoot)).find((stored) => stored.task.ref === identifier);
710
+ if (!match) throw new Error(`Task not found for ref: ${identifier}`);
711
+ return match.task.id;
712
+ }
713
+ if (!await findTaskPathById(workspaceRoot, identifier)) throw new Error(`Task not found: ${identifier}`);
714
+ return identifier;
715
+ };
716
+ const resolveTask = async (workspaceRoot, identifier) => {
717
+ const found = await findTaskPathById(workspaceRoot, await resolveTaskId(workspaceRoot, identifier));
718
+ if (!found) throw new Error(`Task not found: ${identifier}`);
719
+ return {
720
+ task: await loadTaskFromPath(found.filePath),
721
+ status: found.status,
722
+ filePath: found.filePath
723
+ };
724
+ };
725
+
726
+ //#endregion
727
+ //#region scripts/il.ts
728
+ const program = new Command();
729
+ const handleAction = (fn) => async (...args) => {
730
+ try {
731
+ await fn(...args);
732
+ } catch (error) {
733
+ const message = error instanceof Error ? error.message : String(error);
734
+ process.stderr.write(`${message}\n`);
735
+ process.exitCode = 1;
736
+ }
737
+ };
738
+ const resolveWorkspaceFor = async (ensure) => {
739
+ const opts = program.opts();
740
+ const workspace = await resolveWorkspace({
741
+ store: opts.store,
742
+ global: opts.global,
743
+ repo: opts.repo
744
+ });
745
+ if (ensure) await ensureWorkspaceLayout(workspace.root);
746
+ return workspace.root;
747
+ };
748
+ const printLines = (lines) => {
749
+ if (lines.length === 0) {
750
+ process.stdout.write("No tasks.\n");
751
+ return;
752
+ }
753
+ process.stdout.write(`${lines.join("\n")}\n`);
754
+ };
755
+ const listScopes = [
756
+ "today",
757
+ "yesterday",
758
+ "this_week",
759
+ "last_week"
760
+ ];
761
+ const isTaskStatus = (value) => {
762
+ return taskStatuses.includes(value);
763
+ };
764
+ const isListScope = (value) => {
765
+ return listScopes.includes(value);
766
+ };
767
+ const isPackageJson = (value) => {
768
+ return value !== null && typeof value === "object" && "version" in value;
769
+ };
770
+ const resolvePackageVersion = async () => {
771
+ let current = path.dirname(fileURLToPath(import.meta.url));
772
+ while (true) {
773
+ const candidate = path.join(current, "package.json");
774
+ try {
775
+ const raw = await readFile(candidate, "utf8");
776
+ const parsed = JSON.parse(raw);
777
+ if (isPackageJson(parsed) && typeof parsed.version === "string") return parsed.version;
778
+ } catch (error) {
779
+ if (!(error instanceof Error)) throw error;
780
+ if (error.code !== "ENOENT") throw error;
781
+ }
782
+ const parent = path.dirname(current);
783
+ if (parent === current) return;
784
+ current = parent;
785
+ }
786
+ };
787
+ const packageVersion = await resolvePackageVersion();
788
+ program.name("il").description("Terminal task manager").option("--store <path>", "explicit workspace path").option("--global", "use global workspace").option("--repo", "use repo workspace");
789
+ if (packageVersion) program.version(packageVersion);
790
+ program.command("init").description("initialize repo workspace").action(handleAction(async () => {
791
+ const repoRoot = await findGitRoot(process.cwd());
792
+ if (!repoRoot) throw new Error("Not inside a git repository");
793
+ const workspaceRoot = path.join(repoRoot, APP_DIR);
794
+ await ensureWorkspaceLayout(workspaceRoot);
795
+ await ensureLockIgnored(repoRoot);
796
+ process.stdout.write(`Initialized workspace at ${workspaceRoot}\n`);
797
+ }));
798
+ program.command("where").description("show resolved workspace").action(handleAction(async () => {
799
+ const opts = program.opts();
800
+ const workspace = await resolveWorkspace({
801
+ store: opts.store,
802
+ global: opts.global,
803
+ repo: opts.repo
804
+ });
805
+ process.stdout.write(`${workspace.root} (${workspace.kind})\n`);
806
+ }));
807
+ program.command("add").description("add a task").argument("[title]", "task title").option("--type <type>", "regular|pr_review", "regular").option("--url <url>", "attach URL").option("--pr <url>", "attach PR URL").action(handleAction(async (title, options, command) => {
808
+ const taskType = options.type;
809
+ if (!taskTypes.includes(taskType)) throw new Error(`Invalid type: ${taskType}`);
810
+ if (taskType === "pr_review" && !options.pr) throw new Error("PR review tasks require --pr");
811
+ const workspaceRoot = await resolveWorkspaceFor(true);
812
+ await withWorkspaceLock(workspaceRoot, async () => {
813
+ let prAttachment;
814
+ let prTitle;
815
+ if (options.pr) {
816
+ const parsed = parseGitHubPrUrl(options.pr);
817
+ if (!parsed) throw new Error("Invalid PR URL");
818
+ const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
819
+ prAttachment = buildPrAttachment(parsed, fetched ?? void 0);
820
+ prTitle = fetched?.title ?? `PR #${parsed.number} ${parsed.repo.owner}/${parsed.repo.name}`;
821
+ }
822
+ const isExplicitTitle = Boolean(title);
823
+ let finalTitle = title ?? prTitle;
824
+ if (!finalTitle) throw new Error("Title is required when no PR URL is provided");
825
+ if (taskType === "pr_review" && !isExplicitTitle) finalTitle = `Review: ${finalTitle}`;
826
+ const metadata = {
827
+ url: options.url,
828
+ pr: prAttachment
829
+ };
830
+ const task = buildTask({
831
+ type: taskType,
832
+ title: finalTitle,
833
+ metadata
834
+ });
835
+ taskSchema.parse(task);
836
+ const created = await createTaskInStore(workspaceRoot, task);
837
+ process.stdout.write(`Created ${created.alias} ${created.task.ref} ${created.task.title}\n`);
838
+ });
839
+ }));
840
+ program.command("list").description("list tasks").argument("[status]", "task status or date scope").action(handleAction(async (status, command) => {
841
+ const workspaceRoot = await resolveWorkspaceFor(true);
842
+ const aliasLookup = buildAliasLookup(await readAliases(workspaceRoot));
843
+ const lines = [];
844
+ if (status && !isTaskStatus(status) && !isListScope(status)) throw new Error(`Invalid status or scope: ${status}`);
845
+ if (status && isListScope(status)) {
846
+ const dates = listDatesForScope(status);
847
+ const tasks = await listAllTasks(workspaceRoot);
848
+ for (const entry of tasks) {
849
+ if (!dates.some((date) => isAssignedOnDate(entry.task, date))) continue;
850
+ const alias = aliasLookup.get(entry.task.id);
851
+ let line = formatTaskListLine(entry.task, alias);
852
+ if (entry.task.metadata.pr?.fetched?.state) line += ` PR:${entry.task.metadata.pr.fetched.state}`;
853
+ lines.push(line);
854
+ }
855
+ printLines(lines);
856
+ return;
857
+ }
858
+ const statuses = status ? [status] : [...taskStatuses];
859
+ for (const currentStatus of statuses) {
860
+ const stored = await listTasksByStatus(workspaceRoot, currentStatus);
861
+ for (const entry of stored) {
862
+ const alias = aliasLookup.get(entry.task.id);
863
+ let line = formatTaskListLine(entry.task, alias);
864
+ if (entry.task.metadata.pr?.fetched?.state) line += ` PR:${entry.task.metadata.pr.fetched.state}`;
865
+ lines.push(line);
866
+ }
867
+ }
868
+ printLines(lines);
869
+ }));
870
+ program.command("show").description("show a task").argument("<id>", "task identifier").action(handleAction(async (identifier, command) => {
871
+ const workspaceRoot = await resolveWorkspaceFor(true);
872
+ const stored = await resolveTask(workspaceRoot, identifier);
873
+ const alias = buildAliasLookup(await readAliases(workspaceRoot)).get(stored.task.id);
874
+ process.stdout.write(`${formatTaskDetails(stored.task, alias)}\n`);
875
+ }));
876
+ const addTransitionCommand = (name) => {
877
+ program.command(name).description(`${name} a task`).argument("<id>", "task identifier").option("-m, --message <message>", "custom log message").action(handleAction(async (identifier, options, command) => {
878
+ const workspaceRoot = await resolveWorkspaceFor(true);
879
+ await withWorkspaceLock(workspaceRoot, async () => {
880
+ const stored = await resolveTask(workspaceRoot, identifier);
881
+ const { task: updated, nextStatus } = applyTransition(stored.task, name, options.message);
882
+ await saveTask(workspaceRoot, stored.status, updated);
883
+ await moveTaskFile(workspaceRoot, updated.id, stored.status, nextStatus);
884
+ process.stdout.write(`Updated ${updated.ref} -> ${nextStatus}\n`);
885
+ });
886
+ }));
887
+ };
888
+ addTransitionCommand("start");
889
+ addTransitionCommand("pause");
890
+ addTransitionCommand("complete");
891
+ addTransitionCommand("cancel");
892
+ program.command("log").description("append a log entry").argument("<id>", "task identifier").argument("<message>", "log message").option("--status <status>", "include status in log entry").action(handleAction(async (identifier, message, options, command) => {
893
+ const workspaceRoot = await resolveWorkspaceFor(true);
894
+ const status = options.status;
895
+ if (status && !taskStatuses.includes(status)) throw new Error(`Invalid status: ${status}`);
896
+ await withWorkspaceLock(workspaceRoot, async () => {
897
+ const stored = await resolveTask(workspaceRoot, identifier);
898
+ const updated = appendLog(stored.task, message, status);
899
+ await saveTask(workspaceRoot, stored.status, updated);
900
+ process.stdout.write(`Logged on ${updated.ref}\n`);
901
+ });
902
+ }));
903
+ program.command("edit").description("edit a task field").argument("<id>", "task identifier").argument("<path>", "dotted path").argument("<value>", "new value").action(handleAction(async (identifier, dottedPath, value, command) => {
904
+ const workspaceRoot = await resolveWorkspaceFor(true);
905
+ await withWorkspaceLock(workspaceRoot, async () => {
906
+ const stored = await resolveTask(workspaceRoot, identifier);
907
+ const updated = applyTaskEdit(stored.task, dottedPath, value);
908
+ await saveTask(workspaceRoot, stored.status, updated);
909
+ process.stdout.write(`Updated ${updated.ref}\n`);
910
+ });
911
+ }));
912
+ program.command("search").description("search tasks").argument("<query>", "search query").action(handleAction(async (query, command) => {
913
+ const workspaceRoot = await resolveWorkspaceFor(true);
914
+ const aliasLookup = buildAliasLookup(await readAliases(workspaceRoot));
915
+ printLines((await listAllTasks(workspaceRoot)).filter((stored) => taskMatchesQuery(stored.task, query)).map((stored) => {
916
+ const alias = aliasLookup.get(stored.task.id);
917
+ let line = formatTaskListLine(stored.task, alias);
918
+ if (stored.task.metadata.pr?.fetched?.state) line += ` PR:${stored.task.metadata.pr.fetched.state}`;
919
+ return line;
920
+ }));
921
+ }));
922
+ program.command("today").description("assign a task for today").argument("<id>", "task identifier").option("--date <date>", "YYYY-MM-DD").option("-m, --message <message>", "assignment message").action(handleAction(async (identifier, options, command) => {
923
+ const workspaceRoot = await resolveWorkspaceFor(true);
924
+ const date = todayDate(options.date);
925
+ await withWorkspaceLock(workspaceRoot, async () => {
926
+ const stored = await resolveTask(workspaceRoot, identifier);
927
+ if (isAssignedOnDate(stored.task, date)) {
928
+ process.stdout.write(`Already assigned ${stored.task.ref} to ${date}\n`);
929
+ return;
930
+ }
931
+ const updated = appendDayAssignment(stored.task, date, "assign", options.message);
932
+ await saveTask(workspaceRoot, stored.status, updated);
933
+ process.stdout.write(`Assigned ${updated.ref} to ${date}\n`);
934
+ });
935
+ }));
936
+ program.command("untoday").description("unassign a task from today").argument("<id>", "task identifier").option("--date <date>", "YYYY-MM-DD").action(handleAction(async (identifier, options, command) => {
937
+ const workspaceRoot = await resolveWorkspaceFor(true);
938
+ const date = todayDate(options.date);
939
+ await withWorkspaceLock(workspaceRoot, async () => {
940
+ const stored = await resolveTask(workspaceRoot, identifier);
941
+ if (!isAssignedOnDate(stored.task, date)) {
942
+ process.stdout.write(`Already unassigned ${stored.task.ref} from ${date}\n`);
943
+ return;
944
+ }
945
+ const updated = appendDayAssignment(stored.task, date, "unassign");
946
+ await saveTask(workspaceRoot, stored.status, updated);
947
+ process.stdout.write(`Unassigned ${updated.ref} from ${date}\n`);
948
+ });
949
+ }));
950
+ program.command("attach-pr").description("attach PR metadata to a task").argument("<id>", "task identifier").argument("<url>", "PR URL").option("-m, --message <message>", "log message").action(handleAction(async (identifier, url, options, command) => {
951
+ const workspaceRoot = await resolveWorkspaceFor(true);
952
+ await withWorkspaceLock(workspaceRoot, async () => {
953
+ const parsed = parseGitHubPrUrl(url);
954
+ if (!parsed) throw new Error("Invalid PR URL");
955
+ const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
956
+ const stored = await resolveTask(workspaceRoot, identifier);
957
+ const next = appendLog({
958
+ ...stored.task,
959
+ metadata: {
960
+ ...stored.task.metadata,
961
+ pr: buildPrAttachment(parsed, fetched ?? stored.task.metadata.pr?.fetched)
962
+ },
963
+ updated_at: nowIso()
964
+ }, options.message ?? `Attached PR ${url}`);
965
+ await saveTask(workspaceRoot, stored.status, next);
966
+ process.stdout.write(`Attached PR to ${next.ref}\n`);
967
+ });
968
+ }));
969
+ program.command("refresh").description("refresh PR metadata").argument("<id>", "task identifier").option("-m, --message <message>", "log message").action(handleAction(async (identifier, options, command) => {
970
+ const workspaceRoot = await resolveWorkspaceFor(true);
971
+ await withWorkspaceLock(workspaceRoot, async () => {
972
+ const stored = await resolveTask(workspaceRoot, identifier);
973
+ const prUrl = stored.task.metadata.pr?.url;
974
+ if (!prUrl) {
975
+ process.stdout.write("No PR attached.\n");
976
+ return;
977
+ }
978
+ const parsed = parseGitHubPrUrl(prUrl);
979
+ if (!parsed) throw new Error("Invalid PR URL");
980
+ const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
981
+ if (!fetched) throw new Error("No GitHub token configured");
982
+ const baseUpdated = {
983
+ ...stored.task,
984
+ metadata: {
985
+ ...stored.task.metadata,
986
+ pr: buildPrAttachment(parsed, fetched)
987
+ },
988
+ updated_at: nowIso()
989
+ };
990
+ const previousState = stored.task.metadata.pr?.fetched?.state;
991
+ const nextState = fetched.state;
992
+ const isTerminal = stored.task.status === "completed" || stored.task.status === "cancelled";
993
+ let finalTask = baseUpdated;
994
+ let nextStatus = stored.status;
995
+ if (!isTerminal && previousState !== nextState) {
996
+ if (nextState === "merged") {
997
+ const result = applyTransition(baseUpdated, "complete", options.message ?? "PR merged");
998
+ finalTask = result.task;
999
+ nextStatus = result.nextStatus;
1000
+ } else if (nextState === "closed") {
1001
+ const result = applyTransition(baseUpdated, "cancel", options.message ?? "PR closed without merge");
1002
+ finalTask = result.task;
1003
+ nextStatus = result.nextStatus;
1004
+ }
1005
+ }
1006
+ await saveTask(workspaceRoot, stored.status, finalTask);
1007
+ if (nextStatus !== stored.status) await moveTaskFile(workspaceRoot, finalTask.id, stored.status, nextStatus);
1008
+ process.stdout.write(`Refreshed ${finalTask.ref}\n`);
1009
+ });
1010
+ }));
1011
+ program.command("alias").description("alias helpers").command("reconcile").description("reconcile alias mapping").action(handleAction(async (command) => {
1012
+ const workspaceRoot = await resolveWorkspaceFor(true);
1013
+ await withWorkspaceLock(workspaceRoot, async () => {
1014
+ await reconcileAliases(workspaceRoot);
1015
+ process.stdout.write("Aliases reconciled.\n");
1016
+ });
1017
+ }));
1018
+ program.parseAsync(process.argv);
1019
+
1020
+ //#endregion
1021
+ export { };