@eunjae/il 1.2.0 → 1.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.
@@ -1,17 +1,32 @@
1
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
2
  import { Command } from "commander";
6
- import writeFileAtomic from "write-file-atomic";
7
3
  import { z } from "zod";
4
+ import { access, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
8
5
  import { homedir } from "node:os";
6
+ import path from "node:path";
7
+ import writeFileAtomic from "write-file-atomic";
8
+ import lockfile from "proper-lockfile";
9
9
  import { Octokit } from "octokit";
10
10
  import { DateTime } from "luxon";
11
- import lockfile from "proper-lockfile";
12
11
  import { createHash } from "node:crypto";
13
12
  import { ulid } from "ulid";
13
+ import { fileURLToPath } from "node:url";
14
14
 
15
+ //#region lib/constants.ts
16
+ const APP_NAME = "il";
17
+ const APP_DIR = `.${APP_NAME}`;
18
+ const LOCK_DIR = ".lock";
19
+ const LOCK_FILE = "store.lock";
20
+ const TASKS_DIR = "tasks";
21
+ const STATUS_ORDER = [
22
+ "backlog",
23
+ "active",
24
+ "paused",
25
+ "completed",
26
+ "cancelled"
27
+ ];
28
+
29
+ //#endregion
15
30
  //#region lib/json.ts
16
31
  const readJsonFile = async (filePath) => {
17
32
  const raw = await readFile(filePath, "utf8");
@@ -25,12 +40,7 @@ const writeJsonAtomic = async (filePath, data) => {
25
40
  };
26
41
 
27
42
  //#endregion
28
- //#region lib/aliasRepo.ts
29
- const emptyAliases = () => ({
30
- T: {},
31
- R: {}
32
- });
33
- const aliasFilePath = (workspaceRoot) => path.join(workspaceRoot, "aliases.json");
43
+ //#region lib/config.ts
34
44
  const fileExists$2 = async (filePath) => {
35
45
  try {
36
46
  await access(filePath);
@@ -39,62 +49,194 @@ const fileExists$2 = async (filePath) => {
39
49
  return false;
40
50
  }
41
51
  };
42
- const readAliases = async (workspaceRoot) => {
43
- const filePath = aliasFilePath(workspaceRoot);
44
- if (!await fileExists$2(filePath)) return emptyAliases();
45
- const data = await readJsonFile(filePath);
52
+ const resolveGlobalConfigPath = () => {
53
+ const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
54
+ return path.join(configHome, APP_NAME, "config.json");
55
+ };
56
+ const readConfigFile = async (filePath) => {
57
+ if (!await fileExists$2(filePath)) return {};
58
+ return await readJsonFile(filePath) ?? {};
59
+ };
60
+ const resolveGithubToken = async (workspaceRoot) => {
61
+ if (process.env.IL_GITHUB_TOKEN) return process.env.IL_GITHUB_TOKEN;
62
+ const workspaceConfig = await readConfigFile(path.join(workspaceRoot, "config.json"));
63
+ if (workspaceConfig.github?.token) return workspaceConfig.github.token;
64
+ return (await readConfigFile(resolveGlobalConfigPath())).github?.token ?? null;
65
+ };
66
+
67
+ //#endregion
68
+ //#region lib/workspace.ts
69
+ const exists = async (filePath) => {
70
+ try {
71
+ await access(filePath);
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ };
77
+ const findGitRoot = async (startDir) => {
78
+ let current = path.resolve(startDir);
79
+ while (true) {
80
+ if (await exists(path.join(current, ".git"))) return current;
81
+ const parent = path.dirname(current);
82
+ if (parent === current) return null;
83
+ current = parent;
84
+ }
85
+ };
86
+ const resolveGlobalWorkspaceRoot = () => {
87
+ const dataHome = process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share");
88
+ return path.join(dataHome, APP_NAME);
89
+ };
90
+ const resolveWorkspace = async (options) => {
91
+ if (options.store) return {
92
+ root: path.resolve(options.store),
93
+ kind: "explicit"
94
+ };
95
+ const repoRoot = await findGitRoot(options.cwd ?? process.cwd());
96
+ if (options.repo) {
97
+ if (!repoRoot) throw new Error("Not inside a git repository");
98
+ return {
99
+ root: path.join(repoRoot, APP_DIR),
100
+ kind: "repo"
101
+ };
102
+ }
103
+ if (options.global) return {
104
+ root: resolveGlobalWorkspaceRoot(),
105
+ kind: "global"
106
+ };
107
+ if (repoRoot) {
108
+ const repoWorkspace = path.join(repoRoot, APP_DIR);
109
+ if (await exists(repoWorkspace)) return {
110
+ root: repoWorkspace,
111
+ kind: "repo"
112
+ };
113
+ }
46
114
  return {
47
- T: data.T ?? {},
48
- R: data.R ?? {}
115
+ root: resolveGlobalWorkspaceRoot(),
116
+ kind: "global"
49
117
  };
50
118
  };
51
- const writeAliases = async (workspaceRoot, aliases) => {
52
- await writeJsonAtomic(aliasFilePath(workspaceRoot), aliases);
119
+ const ensureWorkspaceLayout = async (workspaceRoot) => {
120
+ await mkdir(path.join(workspaceRoot, TASKS_DIR), { recursive: true });
121
+ await mkdir(path.join(workspaceRoot, LOCK_DIR), { recursive: true });
122
+ await Promise.all(STATUS_ORDER.map((status) => mkdir(path.join(workspaceRoot, TASKS_DIR, status), { recursive: true })));
53
123
  };
54
- const parseAlias = (alias) => {
55
- const match = alias.match(/^([TR])(\d+)$/);
56
- if (!match) return null;
124
+
125
+ //#endregion
126
+ //#region lib/lock.ts
127
+ const withWorkspaceLock = async (workspaceRoot, fn) => {
128
+ await ensureWorkspaceLayout(workspaceRoot);
129
+ const lockPath = path.join(workspaceRoot, LOCK_DIR, LOCK_FILE);
130
+ await writeFile(lockPath, "", { flag: "a" });
131
+ const release = await lockfile.lock(lockPath, {
132
+ stale: 6e4,
133
+ retries: {
134
+ retries: 5,
135
+ factor: 1.5,
136
+ minTimeout: 50,
137
+ maxTimeout: 1e3
138
+ }
139
+ });
140
+ try {
141
+ return await fn();
142
+ } finally {
143
+ await release();
144
+ }
145
+ };
146
+
147
+ //#endregion
148
+ //#region lib/time.ts
149
+ const DEFAULT_ZONE = "Europe/Paris";
150
+ const toIsoDate = (value, label) => {
151
+ const date = value.toISODate();
152
+ if (!date) throw new Error(`Failed to generate ${label} date`);
153
+ return date;
154
+ };
155
+ const buildDateRange = (start, end) => {
156
+ const dates = [];
157
+ let cursor = start.startOf("day");
158
+ const final = end.startOf("day");
159
+ while (cursor <= final) {
160
+ dates.push(toIsoDate(cursor, "range"));
161
+ cursor = cursor.plus({ days: 1 });
162
+ }
163
+ return dates;
164
+ };
165
+ const nowIso = () => {
166
+ const value = DateTime.now().setZone(DEFAULT_ZONE).toISO();
167
+ if (!value) throw new Error("Failed to generate timestamp");
168
+ return value;
169
+ };
170
+ const todayDate = (date) => {
171
+ const value = date ? DateTime.fromISO(date, { zone: DEFAULT_ZONE }).toISODate() : DateTime.now().setZone(DEFAULT_ZONE).toISODate();
172
+ if (!value) throw new Error("Failed to generate date");
173
+ return value;
174
+ };
175
+ const listDatesForScope = (scope) => {
176
+ const now = DateTime.now().setZone(DEFAULT_ZONE);
177
+ switch (scope) {
178
+ case "today": return [toIsoDate(now, "today")];
179
+ case "yesterday": return [toIsoDate(now.minus({ days: 1 }), "yesterday")];
180
+ case "this_week": return buildDateRange(now.startOf("week"), now.endOf("week"));
181
+ case "last_week": {
182
+ const lastWeek = now.minus({ weeks: 1 });
183
+ return buildDateRange(lastWeek.startOf("week"), lastWeek.endOf("week"));
184
+ }
185
+ }
186
+ };
187
+
188
+ //#endregion
189
+ //#region lib/pr.ts
190
+ const parseGitHubPrUrl = (value) => {
191
+ let url;
192
+ try {
193
+ url = new URL(value);
194
+ } catch {
195
+ return null;
196
+ }
197
+ const parts = url.pathname.split("/").filter(Boolean);
198
+ if (parts.length < 4 || parts[2] !== "pull") return null;
199
+ const number = Number(parts[3]);
200
+ if (!Number.isInteger(number)) return null;
57
201
  return {
58
- prefix: match[1],
59
- key: match[2]
202
+ url: value,
203
+ provider: "github",
204
+ repo: {
205
+ host: url.host,
206
+ owner: parts[0],
207
+ name: parts[1]
208
+ },
209
+ number
60
210
  };
61
211
  };
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;
212
+ const buildPrAttachment = (parsed, fetched) => {
69
213
  return {
70
- alias: `${prefix}${key}`,
71
- aliases
214
+ url: parsed.url,
215
+ provider: "github",
216
+ repo: parsed.repo,
217
+ number: parsed.number,
218
+ fetched
72
219
  };
73
220
  };
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;
221
+ const fetchGitHubPr = async (parsed, token) => {
222
+ if (!parsed.repo || !parsed.number) return null;
223
+ if (!token) return null;
224
+ const data = (await new Octokit({ auth: token }).rest.pulls.get({
225
+ owner: parsed.repo.owner,
226
+ repo: parsed.repo.name,
227
+ pull_number: parsed.number
228
+ })).data;
229
+ const state = data.merged ? "merged" : data.state === "open" ? "open" : "closed";
230
+ return {
231
+ at: nowIso(),
232
+ title: data.title,
233
+ author: { login: data.user?.login ?? "unknown" },
234
+ state,
235
+ draft: data.draft ?? false,
236
+ updated_at: data.updated_at
237
+ };
81
238
  };
82
239
 
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
240
  //#endregion
99
241
  //#region lib/types.ts
100
242
  const taskStatuses = [
@@ -107,18 +249,29 @@ const taskStatuses = [
107
249
  const taskTypes = ["regular", "pr_review"];
108
250
 
109
251
  //#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
- });
252
+ //#region lib/schemas/dayAssignment.ts
116
253
  const dayAssignmentSchema = z.object({
117
254
  date: z.string(),
118
- action: z.enum(["assign", "unassign"]),
255
+ action: z.enum([
256
+ "assign",
257
+ "unassign",
258
+ "reorder"
259
+ ]),
119
260
  ts: z.string(),
261
+ order: z.number().int().positive().optional(),
120
262
  msg: z.string().optional()
121
263
  });
264
+
265
+ //#endregion
266
+ //#region lib/schemas/logEntry.ts
267
+ const logEntrySchema = z.object({
268
+ ts: z.string(),
269
+ msg: z.string(),
270
+ status: z.enum(taskStatuses).optional()
271
+ });
272
+
273
+ //#endregion
274
+ //#region lib/schemas/pr.ts
122
275
  const prRepoSchema = z.object({
123
276
  host: z.string(),
124
277
  owner: z.string(),
@@ -143,6 +296,9 @@ const prAttachmentSchema = z.object({
143
296
  number: z.number().int().positive().optional(),
144
297
  fetched: prFetchedSchema.optional()
145
298
  });
299
+
300
+ //#endregion
301
+ //#region lib/schemas/task.ts
146
302
  const taskMetadataSchema = z.object({
147
303
  url: z.string().optional(),
148
304
  pr: prAttachmentSchema.optional()
@@ -173,6 +329,107 @@ const validateTaskOrThrow = (task) => {
173
329
  return taskSchema.parse(task);
174
330
  };
175
331
 
332
+ //#endregion
333
+ //#region lib/id.ts
334
+ const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
335
+ const base32Encode = (input) => {
336
+ let bits = 0;
337
+ let value = 0;
338
+ let output = "";
339
+ for (const byte of input) {
340
+ value = value << 8 | byte;
341
+ bits += 8;
342
+ while (bits >= 5) {
343
+ output += BASE32_ALPHABET[value >>> bits - 5 & 31];
344
+ bits -= 5;
345
+ }
346
+ }
347
+ if (bits > 0) output += BASE32_ALPHABET[value << 5 - bits & 31];
348
+ return output;
349
+ };
350
+ const generateTaskId = (taskType) => {
351
+ return `${taskType === "pr_review" ? "R" : "T"}${ulid()}`;
352
+ };
353
+ const generateStableRef = (taskType, taskId) => {
354
+ return `${taskType === "pr_review" ? "R" : "T"}-${base32Encode(createHash("sha256").update(taskId).digest()).slice(0, 6)}`;
355
+ };
356
+
357
+ //#endregion
358
+ //#region lib/taskFactory.ts
359
+ const buildTask = (input) => {
360
+ const id = generateTaskId(input.type);
361
+ const ref = generateStableRef(input.type, id);
362
+ const timestamp = nowIso();
363
+ return {
364
+ id,
365
+ ref,
366
+ type: input.type,
367
+ title: input.title,
368
+ status: "backlog",
369
+ created_at: timestamp,
370
+ updated_at: timestamp,
371
+ metadata: input.metadata ?? {},
372
+ logs: [],
373
+ day_assignments: []
374
+ };
375
+ };
376
+
377
+ //#endregion
378
+ //#region lib/aliasRepo.ts
379
+ const emptyAliases = () => ({
380
+ T: {},
381
+ R: {}
382
+ });
383
+ const aliasFilePath = (workspaceRoot) => path.join(workspaceRoot, "aliases.json");
384
+ const fileExists$1 = async (filePath) => {
385
+ try {
386
+ await access(filePath);
387
+ return true;
388
+ } catch {
389
+ return false;
390
+ }
391
+ };
392
+ const readAliases = async (workspaceRoot) => {
393
+ const filePath = aliasFilePath(workspaceRoot);
394
+ if (!await fileExists$1(filePath)) return emptyAliases();
395
+ const data = await readJsonFile(filePath);
396
+ return {
397
+ T: data.T ?? {},
398
+ R: data.R ?? {}
399
+ };
400
+ };
401
+ const writeAliases = async (workspaceRoot, aliases) => {
402
+ await writeJsonAtomic(aliasFilePath(workspaceRoot), aliases);
403
+ };
404
+ const parseAlias = (alias) => {
405
+ const match = alias.match(/^([TR])(\d+)$/);
406
+ if (!match) return null;
407
+ return {
408
+ prefix: match[1],
409
+ key: match[2]
410
+ };
411
+ };
412
+ const formatAliasKey = (value) => String(value).padStart(2, "0");
413
+ const allocateAliasInMap = (aliases, prefix, taskId) => {
414
+ const used = new Set(Object.keys(aliases[prefix]).map((key) => Number.parseInt(key, 10)));
415
+ let next = 1;
416
+ while (used.has(next)) next += 1;
417
+ const key = formatAliasKey(next);
418
+ aliases[prefix][key] = taskId;
419
+ return {
420
+ alias: `${prefix}${key}`,
421
+ aliases
422
+ };
423
+ };
424
+ const allocateAlias = async (workspaceRoot, prefix, taskId) => {
425
+ return allocateAliasInMap(await readAliases(workspaceRoot), prefix, taskId);
426
+ };
427
+ const resolveAlias = async (workspaceRoot, alias) => {
428
+ const parsed = parseAlias(alias);
429
+ if (!parsed) return null;
430
+ return (await readAliases(workspaceRoot))[parsed.prefix][parsed.key] ?? null;
431
+ };
432
+
176
433
  //#endregion
177
434
  //#region lib/taskRepo.ts
178
435
  const taskFileName = (taskId) => `${taskId}.json`;
@@ -180,7 +437,7 @@ const getStatusDir = (workspaceRoot, status) => path.join(workspaceRoot, TASKS_D
180
437
  const getTaskPath = (workspaceRoot, status, taskId) => {
181
438
  return path.join(getStatusDir(workspaceRoot, status), taskFileName(taskId));
182
439
  };
183
- const fileExists$1 = async (filePath) => {
440
+ const fileExists = async (filePath) => {
184
441
  try {
185
442
  await access(filePath);
186
443
  return true;
@@ -191,7 +448,7 @@ const fileExists$1 = async (filePath) => {
191
448
  const findTaskPathById = async (workspaceRoot, taskId) => {
192
449
  for (const status of STATUS_ORDER) {
193
450
  const candidate = getTaskPath(workspaceRoot, status, taskId);
194
- if (await fileExists$1(candidate)) return {
451
+ if (await fileExists(candidate)) return {
195
452
  filePath: candidate,
196
453
  status
197
454
  };
@@ -229,6 +486,116 @@ const moveTaskFile = async (workspaceRoot, taskId, fromStatus, toStatus) => {
229
486
  return toPath;
230
487
  };
231
488
 
489
+ //#endregion
490
+ //#region lib/taskStore.ts
491
+ const createTaskInStore = async (workspaceRoot, task) => {
492
+ const { alias, aliases } = await allocateAlias(workspaceRoot, task.type === "pr_review" ? "R" : "T", task.id);
493
+ await writeAliases(workspaceRoot, aliases);
494
+ await saveTask(workspaceRoot, task.status, task);
495
+ return {
496
+ alias,
497
+ task
498
+ };
499
+ };
500
+
501
+ //#endregion
502
+ //#region lib/addTask.ts
503
+ var AddTaskError = class extends Error {
504
+ code;
505
+ constructor(code, message) {
506
+ super(message);
507
+ this.code = code;
508
+ this.name = "AddTaskError";
509
+ }
510
+ };
511
+ const addTaskInputSchema = z.object({
512
+ type: z.enum(taskTypes),
513
+ title: z.string().optional(),
514
+ url: z.string().optional(),
515
+ prUrl: z.string().optional(),
516
+ workspaceRoot: z.string()
517
+ });
518
+ const addTask = async (input) => {
519
+ const parsedInput = addTaskInputSchema.parse(input);
520
+ return withWorkspaceLock(parsedInput.workspaceRoot, async () => {
521
+ let prAttachment;
522
+ let prTitle;
523
+ if (parsedInput.type === "pr_review" && !parsedInput.prUrl) throw new AddTaskError("MISSING_PR_URL", "PR review tasks require PR URL");
524
+ if (parsedInput.prUrl) {
525
+ const parsed = parseGitHubPrUrl(parsedInput.prUrl);
526
+ if (!parsed) throw new AddTaskError("INVALID_PR_URL", "Invalid PR URL");
527
+ const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(parsedInput.workspaceRoot));
528
+ prAttachment = buildPrAttachment(parsed, fetched ?? void 0);
529
+ prTitle = fetched?.title ?? `PR #${parsed.number} ${parsed.repo.owner}/${parsed.repo.name}`;
530
+ }
531
+ const isExplicitTitle = Boolean(parsedInput.title);
532
+ let finalTitle = parsedInput.title ?? prTitle;
533
+ if (!finalTitle) throw new AddTaskError("MISSING_TITLE", "Title is required when no PR URL is provided");
534
+ if (parsedInput.type === "pr_review" && !isExplicitTitle) finalTitle = `Review: ${finalTitle}`;
535
+ const metadata = {
536
+ url: parsedInput.url,
537
+ pr: prAttachment
538
+ };
539
+ const task = buildTask({
540
+ type: parsedInput.type,
541
+ title: finalTitle,
542
+ metadata
543
+ });
544
+ taskSchema.parse(task);
545
+ return createTaskInStore(parsedInput.workspaceRoot, task);
546
+ });
547
+ };
548
+
549
+ //#endregion
550
+ //#region cli/schemas/addTask.ts
551
+ const addTaskCliSchema = z.object({
552
+ title: z.string().optional(),
553
+ type: z.enum(taskTypes),
554
+ url: z.string().optional(),
555
+ pr: z.string().optional()
556
+ }).superRefine((data, ctx) => {
557
+ if (data.type === "pr_review" && !data.pr) ctx.addIssue({
558
+ code: z.ZodIssueCode.custom,
559
+ message: "PR review tasks require --pr",
560
+ path: ["pr"]
561
+ });
562
+ });
563
+
564
+ //#endregion
565
+ //#region cli/commands/add.ts
566
+ const registerAddCommand = (program, helpers) => {
567
+ 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(helpers.handleAction(async (title, options, command) => {
568
+ const parsed = addTaskCliSchema.safeParse({
569
+ title,
570
+ type: options.type ?? "regular",
571
+ url: options.url,
572
+ pr: options.pr
573
+ });
574
+ if (!parsed.success) {
575
+ const message = parsed.error.errors.map((issue) => issue.message).join(", ");
576
+ helpers.failWithHelp(command, message);
577
+ return;
578
+ }
579
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
580
+ try {
581
+ const created = await addTask({
582
+ type: parsed.data.type,
583
+ title: parsed.data.title,
584
+ url: parsed.data.url,
585
+ prUrl: parsed.data.pr,
586
+ workspaceRoot
587
+ });
588
+ process.stdout.write(`Created ${created.alias} ${created.task.ref} ${created.task.title}\n`);
589
+ } catch (error) {
590
+ if (error instanceof AddTaskError) {
591
+ helpers.failWithHelp(command, error.message);
592
+ return;
593
+ }
594
+ throw error;
595
+ }
596
+ }));
597
+ };
598
+
232
599
  //#endregion
233
600
  //#region lib/aliasReconcile.ts
234
601
  const reconcileAliases = async (workspaceRoot) => {
@@ -250,121 +617,15 @@ const reconcileAliases = async (workspaceRoot) => {
250
617
  };
251
618
 
252
619
  //#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
- };
620
+ //#region cli/commands/alias.ts
621
+ const registerAliasCommand = (program, helpers) => {
622
+ program.command("alias").description("alias helpers").command("reconcile").description("reconcile alias mapping").action(helpers.handleAction(async () => {
623
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
624
+ await withWorkspaceLock(workspaceRoot, async () => {
625
+ await reconcileAliases(workspaceRoot);
626
+ process.stdout.write("Aliases reconciled.\n");
627
+ });
628
+ }));
368
629
  };
369
630
 
370
631
  //#endregion
@@ -396,10 +657,78 @@ const applyTaskEdit = (task, dottedPath, rawValue) => {
396
657
  if (!parsed) throw new Error("Invalid PR URL");
397
658
  updated.metadata.pr = buildPrAttachment(parsed, updated.metadata.pr?.fetched);
398
659
  }
399
- return validateTaskOrThrow({
400
- ...updated,
401
- updated_at: nowIso()
402
- });
660
+ return validateTaskOrThrow({
661
+ ...updated,
662
+ updated_at: nowIso()
663
+ });
664
+ };
665
+
666
+ //#endregion
667
+ //#region lib/taskResolver.ts
668
+ const stableRefPattern = /^[TR]-[A-Z0-9]{6}$/;
669
+ const resolveTaskId = async (workspaceRoot, identifier) => {
670
+ const aliasMatch = await resolveAlias(workspaceRoot, identifier);
671
+ if (aliasMatch) return aliasMatch;
672
+ if (stableRefPattern.test(identifier)) {
673
+ const match = (await listAllTasks(workspaceRoot)).find((stored) => stored.task.ref === identifier);
674
+ if (!match) throw new Error(`Task not found for ref: ${identifier}`);
675
+ return match.task.id;
676
+ }
677
+ if (!await findTaskPathById(workspaceRoot, identifier)) throw new Error(`Task not found: ${identifier}`);
678
+ return identifier;
679
+ };
680
+ const resolveTask = async (workspaceRoot, identifier) => {
681
+ const found = await findTaskPathById(workspaceRoot, await resolveTaskId(workspaceRoot, identifier));
682
+ if (!found) throw new Error(`Task not found: ${identifier}`);
683
+ return {
684
+ task: await loadTaskFromPath(found.filePath),
685
+ status: found.status,
686
+ filePath: found.filePath
687
+ };
688
+ };
689
+
690
+ //#endregion
691
+ //#region cli/commands/edit.ts
692
+ const registerEditCommand = (program, helpers) => {
693
+ program.command("edit").description("edit a task field").argument("<id>", "task identifier").argument("<path>", "dotted path").argument("<value>", "new value").action(helpers.handleAction(async (identifier, dottedPath, value) => {
694
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
695
+ await withWorkspaceLock(workspaceRoot, async () => {
696
+ const stored = await resolveTask(workspaceRoot, identifier);
697
+ const updated = applyTaskEdit(stored.task, dottedPath, value);
698
+ await saveTask(workspaceRoot, stored.status, updated);
699
+ process.stdout.write(`Updated ${updated.ref}\n`);
700
+ });
701
+ }));
702
+ };
703
+
704
+ //#endregion
705
+ //#region lib/gitignore.ts
706
+ const lockEntry = `${APP_DIR}/.lock/`;
707
+ const ensureLockIgnored = async (repoRoot) => {
708
+ const gitignorePath = path.join(repoRoot, ".gitignore");
709
+ let current = "";
710
+ try {
711
+ current = await readFile(gitignorePath, "utf8");
712
+ } catch {
713
+ current = "";
714
+ }
715
+ if (current.split("\n").some((line) => line.trim() === lockEntry)) return false;
716
+ const separator = current.endsWith("\n") || current.length === 0 ? "" : "\n";
717
+ await writeFile(gitignorePath, `${current}${separator}${lockEntry}\n`, "utf8");
718
+ return true;
719
+ };
720
+
721
+ //#endregion
722
+ //#region cli/commands/init.ts
723
+ const registerInitCommand = (program, helpers) => {
724
+ program.command("init").description("initialize repo workspace").action(helpers.handleAction(async () => {
725
+ const repoRoot = await findGitRoot(process.cwd());
726
+ if (!repoRoot) throw new Error("Not inside a git repository");
727
+ const workspaceRoot = path.join(repoRoot, APP_DIR);
728
+ await ensureWorkspaceLayout(workspaceRoot);
729
+ await ensureLockIgnored(repoRoot);
730
+ process.stdout.write(`Initialized workspace at ${workspaceRoot}\n`);
731
+ }));
403
732
  };
404
733
 
405
734
  //#endregion
@@ -443,108 +772,245 @@ const formatTaskDetails = (task, alias) => {
443
772
  if (task.day_assignments.length > 0) {
444
773
  lines.push("day_assignments:");
445
774
  for (const entry of task.day_assignments) {
775
+ const order = entry.order ? ` order:${entry.order}` : "";
446
776
  const msg = entry.msg ? ` (${entry.msg})` : "";
447
- lines.push(`- ${entry.date} ${entry.action} ${entry.ts}${msg}`);
777
+ lines.push(`- ${entry.date} ${entry.action}${order} ${entry.ts}${msg}`);
448
778
  }
449
779
  }
450
780
  return lines.join("\n");
451
781
  };
452
782
 
453
783
  //#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;
784
+ //#region lib/fsm.ts
785
+ const transitionTable = {
786
+ backlog: {
787
+ start: "active",
788
+ cancel: "cancelled"
789
+ },
790
+ active: {
791
+ pause: "paused",
792
+ complete: "completed",
793
+ cancel: "cancelled"
794
+ },
795
+ paused: {
796
+ start: "active",
797
+ complete: "completed",
798
+ cancel: "cancelled"
799
+ },
800
+ completed: {},
801
+ cancelled: {}
802
+ };
803
+ const getNextStatus = (current, action) => {
804
+ const next = transitionTable[current][action];
805
+ if (!next) throw new Error(`Invalid transition: ${current} -> ${action}`);
806
+ return next;
468
807
  };
469
808
 
470
809
  //#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
- }
810
+ //#region lib/taskOperations.ts
811
+ const defaultMessages = {
812
+ start: "Started task",
813
+ pause: "Paused task",
814
+ complete: "Completed task",
815
+ cancel: "Cancelled task"
479
816
  };
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
- }
817
+ const appendLog = (task, message, status) => {
818
+ const log = {
819
+ ts: nowIso(),
820
+ msg: message,
821
+ status
822
+ };
823
+ return {
824
+ ...task,
825
+ logs: [...task.logs, log],
826
+ updated_at: nowIso()
827
+ };
488
828
  };
489
- const resolveGlobalWorkspaceRoot = () => {
490
- const dataHome = process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share");
491
- return path.join(dataHome, APP_NAME);
829
+ const applyTransition = (task, action, message) => {
830
+ const nextStatus = getNextStatus(task.status, action);
831
+ const logMessage = message ?? defaultMessages[action];
832
+ const log = {
833
+ ts: nowIso(),
834
+ msg: logMessage,
835
+ status: nextStatus
836
+ };
837
+ return {
838
+ task: {
839
+ ...task,
840
+ status: nextStatus,
841
+ updated_at: nowIso(),
842
+ logs: [...task.logs, log]
843
+ },
844
+ nextStatus
845
+ };
492
846
  };
493
- const resolveWorkspace = async (options) => {
494
- if (options.store) return {
495
- root: path.resolve(options.store),
496
- kind: "explicit"
847
+ const isAssignedOnDate = (task, date) => {
848
+ const latest = getLatestDayAssignment(task, date);
849
+ if (!latest) return false;
850
+ return latest.event.action === "assign" || latest.event.action === "reorder";
851
+ };
852
+ const getLatestDayAssignment = (task, date) => {
853
+ let latestIndex = -1;
854
+ for (let i = 0; i < task.day_assignments.length; i += 1) if (task.day_assignments[i]?.date === date) latestIndex = i;
855
+ if (latestIndex === -1) return null;
856
+ const event = task.day_assignments[latestIndex];
857
+ if (!event) return null;
858
+ return {
859
+ event,
860
+ index: latestIndex
497
861
  };
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"
862
+ };
863
+ const appendDayAssignment = (task, date, action, msg, order) => {
864
+ const event = {
865
+ date,
866
+ action,
867
+ ts: nowIso(),
868
+ order,
869
+ msg
509
870
  };
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
871
  return {
518
- root: resolveGlobalWorkspaceRoot(),
519
- kind: "global"
872
+ ...task,
873
+ day_assignments: [...task.day_assignments, event],
874
+ updated_at: nowIso()
520
875
  };
521
876
  };
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 })));
877
+
878
+ //#endregion
879
+ //#region cli/commands/list.ts
880
+ const listScopes = [
881
+ "today",
882
+ "yesterday",
883
+ "this_week",
884
+ "last_week"
885
+ ];
886
+ const isTaskStatus = (value) => {
887
+ return taskStatuses.includes(value);
888
+ };
889
+ const isListScope = (value) => {
890
+ return listScopes.includes(value);
891
+ };
892
+ const registerListCommand = (program, helpers) => {
893
+ program.command("list").description("list tasks").argument("[status]", "task status or date scope").action(helpers.handleAction(async (status, command) => {
894
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
895
+ const aliasLookup = buildAliasLookup(await readAliases(workspaceRoot));
896
+ const lines = [];
897
+ if (status && !isTaskStatus(status) && !isListScope(status)) {
898
+ helpers.failWithHelp(command, `Invalid status or scope: ${status}`);
899
+ return;
900
+ }
901
+ if (status && isListScope(status)) {
902
+ const dates = listDatesForScope(status);
903
+ const tasks = await listAllTasks(workspaceRoot);
904
+ for (const entry of tasks) {
905
+ if (!dates.some((date) => isAssignedOnDate(entry.task, date))) continue;
906
+ const alias = aliasLookup.get(entry.task.id);
907
+ let line = formatTaskListLine(entry.task, alias);
908
+ if (entry.task.metadata.pr?.fetched?.state) line += ` PR:${entry.task.metadata.pr.fetched.state}`;
909
+ lines.push(line);
910
+ }
911
+ helpers.printLines(lines);
912
+ return;
913
+ }
914
+ const statuses = status ? [status] : [...taskStatuses];
915
+ for (const currentStatus of statuses) {
916
+ const stored = await listTasksByStatus(workspaceRoot, currentStatus);
917
+ for (const entry of stored) {
918
+ const alias = aliasLookup.get(entry.task.id);
919
+ let line = formatTaskListLine(entry.task, alias);
920
+ if (entry.task.metadata.pr?.fetched?.state) line += ` PR:${entry.task.metadata.pr.fetched.state}`;
921
+ lines.push(line);
922
+ }
923
+ }
924
+ helpers.printLines(lines);
925
+ }));
526
926
  };
527
927
 
528
928
  //#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
929
+ //#region cli/commands/log.ts
930
+ const registerLogCommand = (program, helpers) => {
931
+ 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(helpers.handleAction(async (identifier, message, options, command) => {
932
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
933
+ const status = options.status;
934
+ if (status && !taskStatuses.includes(status)) {
935
+ helpers.failWithHelp(command, `Invalid status: ${status}`);
936
+ return;
541
937
  }
542
- });
543
- try {
544
- return await fn();
545
- } finally {
546
- await release();
547
- }
938
+ await withWorkspaceLock(workspaceRoot, async () => {
939
+ const stored = await resolveTask(workspaceRoot, identifier);
940
+ const updated = appendLog(stored.task, message, status);
941
+ await saveTask(workspaceRoot, stored.status, updated);
942
+ process.stdout.write(`Logged on ${updated.ref}\n`);
943
+ });
944
+ }));
945
+ };
946
+
947
+ //#endregion
948
+ //#region cli/commands/pr.ts
949
+ const registerPrCommands = (program, helpers) => {
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(helpers.handleAction(async (identifier, url, options, command) => {
951
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
952
+ await withWorkspaceLock(workspaceRoot, async () => {
953
+ const parsed = parseGitHubPrUrl(url);
954
+ if (!parsed) {
955
+ helpers.failWithHelp(command, "Invalid PR URL");
956
+ return;
957
+ }
958
+ const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
959
+ const stored = await resolveTask(workspaceRoot, identifier);
960
+ const next = appendLog({
961
+ ...stored.task,
962
+ metadata: {
963
+ ...stored.task.metadata,
964
+ pr: buildPrAttachment(parsed, fetched ?? stored.task.metadata.pr?.fetched)
965
+ },
966
+ updated_at: nowIso()
967
+ }, options.message ?? `Attached PR ${url}`);
968
+ await saveTask(workspaceRoot, stored.status, next);
969
+ process.stdout.write(`Attached PR to ${next.ref}\n`);
970
+ });
971
+ }));
972
+ program.command("refresh").description("refresh PR metadata").argument("<id>", "task identifier").option("-m, --message <message>", "log message").action(helpers.handleAction(async (identifier, options) => {
973
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
974
+ await withWorkspaceLock(workspaceRoot, async () => {
975
+ const stored = await resolveTask(workspaceRoot, identifier);
976
+ const prUrl = stored.task.metadata.pr?.url;
977
+ if (!prUrl) {
978
+ process.stdout.write("No PR attached.\n");
979
+ return;
980
+ }
981
+ const parsed = parseGitHubPrUrl(prUrl);
982
+ if (!parsed) throw new Error("Invalid PR URL");
983
+ const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
984
+ if (!fetched) throw new Error("No GitHub token configured");
985
+ const baseUpdated = {
986
+ ...stored.task,
987
+ metadata: {
988
+ ...stored.task.metadata,
989
+ pr: buildPrAttachment(parsed, fetched)
990
+ },
991
+ updated_at: nowIso()
992
+ };
993
+ const previousState = stored.task.metadata.pr?.fetched?.state;
994
+ const nextState = fetched.state;
995
+ const isTerminal = stored.task.status === "completed" || stored.task.status === "cancelled";
996
+ let finalTask = baseUpdated;
997
+ let nextStatus = stored.status;
998
+ if (!isTerminal && previousState !== nextState) {
999
+ if (nextState === "merged") {
1000
+ const result = applyTransition(baseUpdated, "complete", options.message ?? "PR merged");
1001
+ finalTask = result.task;
1002
+ nextStatus = result.nextStatus;
1003
+ } else if (nextState === "closed") {
1004
+ const result = applyTransition(baseUpdated, "cancel", options.message ?? "PR closed without merge");
1005
+ finalTask = result.task;
1006
+ nextStatus = result.nextStatus;
1007
+ }
1008
+ }
1009
+ await saveTask(workspaceRoot, stored.status, finalTask);
1010
+ if (nextStatus !== stored.status) await moveTaskFile(workspaceRoot, finalTask.id, stored.status, nextStatus);
1011
+ process.stdout.write(`Refreshed ${finalTask.ref}\n`);
1012
+ });
1013
+ }));
548
1014
  };
549
1015
 
550
1016
  //#endregion
@@ -560,172 +1026,196 @@ const taskMatchesQuery = (task, query) => {
560
1026
  };
561
1027
 
562
1028
  //#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
- };
1029
+ //#region cli/commands/search.ts
1030
+ const registerSearchCommand = (program, helpers) => {
1031
+ program.command("search").description("search tasks").argument("<query>", "search query").action(helpers.handleAction(async (query) => {
1032
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
1033
+ const aliasLookup = buildAliasLookup(await readAliases(workspaceRoot));
1034
+ const lines = (await listAllTasks(workspaceRoot)).filter((stored) => taskMatchesQuery(stored.task, query)).map((stored) => {
1035
+ const alias = aliasLookup.get(stored.task.id);
1036
+ let line = formatTaskListLine(stored.task, alias);
1037
+ if (stored.task.metadata.pr?.fetched?.state) line += ` PR:${stored.task.metadata.pr.fetched.state}`;
1038
+ return line;
1039
+ });
1040
+ helpers.printLines(lines);
1041
+ }));
605
1042
  };
606
1043
 
607
1044
  //#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;
1045
+ //#region cli/commands/show.ts
1046
+ const registerShowCommand = (program, helpers) => {
1047
+ program.command("show").description("show a task").argument("<id>", "task identifier").action(helpers.handleAction(async (identifier) => {
1048
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
1049
+ const stored = await resolveTask(workspaceRoot, identifier);
1050
+ const alias = buildAliasLookup(await readAliases(workspaceRoot)).get(stored.task.id);
1051
+ process.stdout.write(`${formatTaskDetails(stored.task, alias)}\n`);
1052
+ }));
631
1053
  };
632
1054
 
633
1055
  //#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
- };
1056
+ //#region cli/commands/today.ts
1057
+ const resolveDate = (helpers, command, dateInput) => {
1058
+ try {
1059
+ return todayDate(dateInput);
1060
+ } catch (error) {
1061
+ const message = error instanceof Error ? error.message : String(error);
1062
+ helpers.failWithHelp(command, message);
1063
+ return;
1064
+ }
652
1065
  };
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
- };
1066
+ const collectAssignments = (tasks, date) => {
1067
+ const entries = [];
1068
+ for (const stored of tasks) {
1069
+ const latest = getLatestDayAssignment(stored.task, date);
1070
+ if (!latest) continue;
1071
+ if (latest.event.action === "assign" || latest.event.action === "reorder") entries.push({
1072
+ stored,
1073
+ lastEvent: latest.event,
1074
+ lastIndex: latest.index
1075
+ });
1076
+ }
1077
+ return entries;
1078
+ };
1079
+ const sortAssignments = (entries) => {
1080
+ return [...entries].sort((a, b) => {
1081
+ const orderA = a.lastEvent.order;
1082
+ const orderB = b.lastEvent.order;
1083
+ if (orderA != null && orderB != null) return orderA - orderB;
1084
+ if (orderA != null) return -1;
1085
+ if (orderB != null) return 1;
1086
+ return a.lastIndex - b.lastIndex;
1087
+ });
670
1088
  };
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";
1089
+ const buildOrderedAssignments = (tasks, date) => {
1090
+ return sortAssignments(collectAssignments(tasks, date));
1091
+ };
1092
+ const resolveMoveTarget = (helpers, command, position, currentIndex, total) => {
1093
+ if (position === "up") return Math.max(0, currentIndex - 1);
1094
+ if (position === "down") return Math.min(total - 1, currentIndex + 1);
1095
+ if (position === "top") return 0;
1096
+ if (position === "bottom") return total - 1;
1097
+ const parsed = Number(position);
1098
+ if (!Number.isInteger(parsed)) {
1099
+ helpers.failWithHelp(command, `Invalid position: ${position}`);
1100
+ return;
1101
+ }
1102
+ if (parsed < 1 || parsed > total) {
1103
+ helpers.failWithHelp(command, `Position out of range: ${position}`);
1104
+ return;
1105
+ }
1106
+ return parsed - 1;
675
1107
  };
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
- };
1108
+ const registerTodayCommands = (program, helpers) => {
1109
+ 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(helpers.handleAction(async (identifier, options, command) => {
1110
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
1111
+ const date = resolveDate(helpers, command, options.date);
1112
+ if (!date) return;
1113
+ await withWorkspaceLock(workspaceRoot, async () => {
1114
+ const tasks = await listAllTasks(workspaceRoot);
1115
+ const stored = await resolveTask(workspaceRoot, identifier);
1116
+ if (isAssignedOnDate(stored.task, date)) {
1117
+ process.stdout.write(`Already assigned ${stored.task.ref} to ${date}\n`);
1118
+ return;
1119
+ }
1120
+ const order = buildOrderedAssignments(tasks, date).length + 1;
1121
+ const updated = appendDayAssignment(stored.task, date, "assign", options.message, order);
1122
+ await saveTask(workspaceRoot, stored.status, updated);
1123
+ process.stdout.write(`Assigned ${updated.ref} to ${date}\n`);
1124
+ });
1125
+ })).command("move").description("reorder tasks assigned to a day").argument("<id>", "task identifier").argument("<position>", "up, down, top, bottom, or 1-based position").option("--date <date>", "YYYY-MM-DD").action(helpers.handleAction(async (identifier, position, options, command) => {
1126
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
1127
+ const date = resolveDate(helpers, command, options.date);
1128
+ if (!date) return;
1129
+ await withWorkspaceLock(workspaceRoot, async () => {
1130
+ const stored = await resolveTask(workspaceRoot, identifier);
1131
+ const ordered = buildOrderedAssignments(await listAllTasks(workspaceRoot), date);
1132
+ const currentIndex = ordered.findIndex((entry) => entry.stored.task.id === stored.task.id);
1133
+ if (currentIndex === -1) {
1134
+ process.stdout.write(`Task ${stored.task.ref} is not assigned to ${date}\n`);
1135
+ return;
1136
+ }
1137
+ const targetIndex = resolveMoveTarget(helpers, command, position, currentIndex, ordered.length);
1138
+ if (targetIndex === void 0) return;
1139
+ if (targetIndex === currentIndex) {
1140
+ process.stdout.write(`Task ${stored.task.ref} is already in position ${currentIndex + 1}\n`);
1141
+ return;
1142
+ }
1143
+ const reordered = [...ordered];
1144
+ const [moved] = reordered.splice(currentIndex, 1);
1145
+ if (!moved) {
1146
+ process.stdout.write(`Task ${stored.task.ref} is not assigned to ${date}\n`);
1147
+ return;
1148
+ }
1149
+ reordered.splice(targetIndex, 0, moved);
1150
+ for (const [index, entry] of reordered.entries()) {
1151
+ const nextOrder = index + 1;
1152
+ if (entry.lastEvent.order === nextOrder) continue;
1153
+ const updated = appendDayAssignment(entry.stored.task, date, "reorder", void 0, nextOrder);
1154
+ await saveTask(workspaceRoot, entry.stored.status, updated);
1155
+ }
1156
+ process.stdout.write(`Moved ${stored.task.ref} to position ${targetIndex + 1}\n`);
1157
+ });
1158
+ }));
1159
+ program.command("untoday").description("unassign a task from today").argument("<id>", "task identifier").option("--date <date>", "YYYY-MM-DD").action(helpers.handleAction(async (identifier, options, command) => {
1160
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
1161
+ const date = resolveDate(helpers, command, options.date);
1162
+ if (!date) return;
1163
+ await withWorkspaceLock(workspaceRoot, async () => {
1164
+ const stored = await resolveTask(workspaceRoot, identifier);
1165
+ if (!isAssignedOnDate(stored.task, date)) {
1166
+ process.stdout.write(`Already unassigned ${stored.task.ref} from ${date}\n`);
1167
+ return;
1168
+ }
1169
+ const updated = appendDayAssignment(stored.task, date, "unassign");
1170
+ await saveTask(workspaceRoot, stored.status, updated);
1171
+ process.stdout.write(`Unassigned ${updated.ref} from ${date}\n`);
1172
+ });
1173
+ }));
688
1174
  };
689
1175
 
690
1176
  //#endregion
691
- //#region lib/taskResolver.ts
692
- const stableRefPattern = /^[TR]-[A-Z0-9]{6}$/;
693
- const resolveTaskId = async (workspaceRoot, identifier) => {
694
- const aliasMatch = await resolveAlias(workspaceRoot, identifier);
695
- if (aliasMatch) return aliasMatch;
696
- if (stableRefPattern.test(identifier)) {
697
- const match = (await listAllTasks(workspaceRoot)).find((stored) => stored.task.ref === identifier);
698
- if (!match) throw new Error(`Task not found for ref: ${identifier}`);
699
- return match.task.id;
700
- }
701
- if (!await findTaskPathById(workspaceRoot, identifier)) throw new Error(`Task not found: ${identifier}`);
702
- return identifier;
1177
+ //#region cli/commands/transitions.ts
1178
+ const transitionNames = [
1179
+ "start",
1180
+ "pause",
1181
+ "complete",
1182
+ "cancel"
1183
+ ];
1184
+ const registerTransitionCommand = (program, helpers, name) => {
1185
+ program.command(name).description(`${name} a task`).argument("<id>", "task identifier").option("-m, --message <message>", "custom log message").action(helpers.handleAction(async (identifier, options) => {
1186
+ const workspaceRoot = await helpers.resolveWorkspaceFor(true);
1187
+ await withWorkspaceLock(workspaceRoot, async () => {
1188
+ const stored = await resolveTask(workspaceRoot, identifier);
1189
+ const { task: updated, nextStatus } = applyTransition(stored.task, name, options.message);
1190
+ await saveTask(workspaceRoot, stored.status, updated);
1191
+ await moveTaskFile(workspaceRoot, updated.id, stored.status, nextStatus);
1192
+ process.stdout.write(`Updated ${updated.ref} -> ${nextStatus}\n`);
1193
+ });
1194
+ }));
703
1195
  };
704
- const resolveTask = async (workspaceRoot, identifier) => {
705
- const found = await findTaskPathById(workspaceRoot, await resolveTaskId(workspaceRoot, identifier));
706
- if (!found) throw new Error(`Task not found: ${identifier}`);
707
- return {
708
- task: await loadTaskFromPath(found.filePath),
709
- status: found.status,
710
- filePath: found.filePath
711
- };
1196
+ const registerTransitionCommands = (program, helpers) => {
1197
+ for (const name of transitionNames) registerTransitionCommand(program, helpers, name);
712
1198
  };
713
1199
 
714
1200
  //#endregion
715
- //#region lib/taskStore.ts
716
- const createTaskInStore = async (workspaceRoot, task) => {
717
- const { alias, aliases } = await allocateAlias(workspaceRoot, task.type === "pr_review" ? "R" : "T", task.id);
718
- await writeAliases(workspaceRoot, aliases);
719
- await saveTask(workspaceRoot, task.status, task);
720
- return {
721
- alias,
722
- task
723
- };
1201
+ //#region cli/commands/where.ts
1202
+ const registerWhereCommand = (program, helpers) => {
1203
+ program.command("where").description("show resolved workspace").action(helpers.handleAction(async () => {
1204
+ const opts = program.opts();
1205
+ const workspace = await resolveWorkspace({
1206
+ store: opts.store,
1207
+ global: opts.global,
1208
+ repo: opts.repo
1209
+ });
1210
+ process.stdout.write(`${workspace.root} (${workspace.kind})\n`);
1211
+ }));
724
1212
  };
725
1213
 
726
1214
  //#endregion
727
- //#region scripts/il.ts
728
- const program = new Command();
1215
+ //#region cli/helpers.ts
1216
+ const isPackageJson = (value) => {
1217
+ return value !== null && typeof value === "object" && "version" in value;
1218
+ };
729
1219
  const handleAction = (fn) => async (...args) => {
730
1220
  try {
731
1221
  await fn(...args);
@@ -735,16 +1225,6 @@ const handleAction = (fn) => async (...args) => {
735
1225
  process.exitCode = 1;
736
1226
  }
737
1227
  };
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
1228
  const printLines = (lines) => {
749
1229
  if (lines.length === 0) {
750
1230
  process.stdout.write("No tasks.\n");
@@ -757,20 +1237,23 @@ const failWithHelp = (command, message) => {
757
1237
  command.outputHelp();
758
1238
  process.exitCode = 1;
759
1239
  };
760
- const listScopes = [
761
- "today",
762
- "yesterday",
763
- "this_week",
764
- "last_week"
765
- ];
766
- const isTaskStatus = (value) => {
767
- return taskStatuses.includes(value);
768
- };
769
- const isListScope = (value) => {
770
- return listScopes.includes(value);
771
- };
772
- const isPackageJson = (value) => {
773
- return value !== null && typeof value === "object" && "version" in value;
1240
+ const createCliHelpers = (program) => {
1241
+ const resolveWorkspaceFor = async (ensure) => {
1242
+ const opts = program.opts();
1243
+ const workspace = await resolveWorkspace({
1244
+ store: opts.store,
1245
+ global: opts.global,
1246
+ repo: opts.repo
1247
+ });
1248
+ if (ensure) await ensureWorkspaceLayout(workspace.root);
1249
+ return workspace.root;
1250
+ };
1251
+ return {
1252
+ handleAction,
1253
+ resolveWorkspaceFor,
1254
+ printLines,
1255
+ failWithHelp
1256
+ };
774
1257
  };
775
1258
  const resolvePackageVersion = async () => {
776
1259
  let current = path.dirname(fileURLToPath(import.meta.url));
@@ -789,271 +1272,27 @@ const resolvePackageVersion = async () => {
789
1272
  current = parent;
790
1273
  }
791
1274
  };
1275
+
1276
+ //#endregion
1277
+ //#region cli/index.ts
1278
+ const program = new Command();
1279
+ const helpers = createCliHelpers(program);
792
1280
  const packageVersion = await resolvePackageVersion();
793
1281
  program.name("il").description("Terminal task manager").option("--store <path>", "explicit workspace path").option("--global", "use global workspace").option("--repo", "use repo workspace");
794
1282
  if (packageVersion) program.version(packageVersion);
795
1283
  program.showHelpAfterError();
796
- program.command("init").description("initialize repo workspace").action(handleAction(async () => {
797
- const repoRoot = await findGitRoot(process.cwd());
798
- if (!repoRoot) throw new Error("Not inside a git repository");
799
- const workspaceRoot = path.join(repoRoot, APP_DIR);
800
- await ensureWorkspaceLayout(workspaceRoot);
801
- await ensureLockIgnored(repoRoot);
802
- process.stdout.write(`Initialized workspace at ${workspaceRoot}\n`);
803
- }));
804
- program.command("where").description("show resolved workspace").action(handleAction(async () => {
805
- const opts = program.opts();
806
- const workspace = await resolveWorkspace({
807
- store: opts.store,
808
- global: opts.global,
809
- repo: opts.repo
810
- });
811
- process.stdout.write(`${workspace.root} (${workspace.kind})\n`);
812
- }));
813
- 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) => {
814
- const taskType = options.type;
815
- if (!taskTypes.includes(taskType)) {
816
- failWithHelp(command, `Invalid type: ${taskType}`);
817
- return;
818
- }
819
- if (taskType === "pr_review" && !options.pr) {
820
- failWithHelp(command, "PR review tasks require --pr");
821
- return;
822
- }
823
- const workspaceRoot = await resolveWorkspaceFor(true);
824
- await withWorkspaceLock(workspaceRoot, async () => {
825
- let prAttachment;
826
- let prTitle;
827
- if (options.pr) {
828
- const parsed = parseGitHubPrUrl(options.pr);
829
- if (!parsed) {
830
- failWithHelp(command, "Invalid PR URL");
831
- return;
832
- }
833
- const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
834
- prAttachment = buildPrAttachment(parsed, fetched ?? void 0);
835
- prTitle = fetched?.title ?? `PR #${parsed.number} ${parsed.repo.owner}/${parsed.repo.name}`;
836
- }
837
- const isExplicitTitle = Boolean(title);
838
- let finalTitle = title ?? prTitle;
839
- if (!finalTitle) {
840
- failWithHelp(command, "Title is required when no PR URL is provided");
841
- return;
842
- }
843
- if (taskType === "pr_review" && !isExplicitTitle) finalTitle = `Review: ${finalTitle}`;
844
- const metadata = {
845
- url: options.url,
846
- pr: prAttachment
847
- };
848
- const task = buildTask({
849
- type: taskType,
850
- title: finalTitle,
851
- metadata
852
- });
853
- taskSchema.parse(task);
854
- const created = await createTaskInStore(workspaceRoot, task);
855
- process.stdout.write(`Created ${created.alias} ${created.task.ref} ${created.task.title}\n`);
856
- });
857
- }));
858
- program.command("list").description("list tasks").argument("[status]", "task status or date scope").action(handleAction(async (status, command) => {
859
- const workspaceRoot = await resolveWorkspaceFor(true);
860
- const aliasLookup = buildAliasLookup(await readAliases(workspaceRoot));
861
- const lines = [];
862
- if (status && !isTaskStatus(status) && !isListScope(status)) {
863
- failWithHelp(command, `Invalid status or scope: ${status}`);
864
- return;
865
- }
866
- if (status && isListScope(status)) {
867
- const dates = listDatesForScope(status);
868
- const tasks = await listAllTasks(workspaceRoot);
869
- for (const entry of tasks) {
870
- if (!dates.some((date) => isAssignedOnDate(entry.task, date))) continue;
871
- const alias = aliasLookup.get(entry.task.id);
872
- let line = formatTaskListLine(entry.task, alias);
873
- if (entry.task.metadata.pr?.fetched?.state) line += ` PR:${entry.task.metadata.pr.fetched.state}`;
874
- lines.push(line);
875
- }
876
- printLines(lines);
877
- return;
878
- }
879
- const statuses = status ? [status] : [...taskStatuses];
880
- for (const currentStatus of statuses) {
881
- const stored = await listTasksByStatus(workspaceRoot, currentStatus);
882
- for (const entry of stored) {
883
- const alias = aliasLookup.get(entry.task.id);
884
- let line = formatTaskListLine(entry.task, alias);
885
- if (entry.task.metadata.pr?.fetched?.state) line += ` PR:${entry.task.metadata.pr.fetched.state}`;
886
- lines.push(line);
887
- }
888
- }
889
- printLines(lines);
890
- }));
891
- program.command("show").description("show a task").argument("<id>", "task identifier").action(handleAction(async (identifier, command) => {
892
- const workspaceRoot = await resolveWorkspaceFor(true);
893
- const stored = await resolveTask(workspaceRoot, identifier);
894
- const alias = buildAliasLookup(await readAliases(workspaceRoot)).get(stored.task.id);
895
- process.stdout.write(`${formatTaskDetails(stored.task, alias)}\n`);
896
- }));
897
- const addTransitionCommand = (name) => {
898
- program.command(name).description(`${name} a task`).argument("<id>", "task identifier").option("-m, --message <message>", "custom log message").action(handleAction(async (identifier, options, command) => {
899
- const workspaceRoot = await resolveWorkspaceFor(true);
900
- await withWorkspaceLock(workspaceRoot, async () => {
901
- const stored = await resolveTask(workspaceRoot, identifier);
902
- const { task: updated, nextStatus } = applyTransition(stored.task, name, options.message);
903
- await saveTask(workspaceRoot, stored.status, updated);
904
- await moveTaskFile(workspaceRoot, updated.id, stored.status, nextStatus);
905
- process.stdout.write(`Updated ${updated.ref} -> ${nextStatus}\n`);
906
- });
907
- }));
908
- };
909
- addTransitionCommand("start");
910
- addTransitionCommand("pause");
911
- addTransitionCommand("complete");
912
- addTransitionCommand("cancel");
913
- 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) => {
914
- const workspaceRoot = await resolveWorkspaceFor(true);
915
- const status = options.status;
916
- if (status && !taskStatuses.includes(status)) {
917
- failWithHelp(command, `Invalid status: ${status}`);
918
- return;
919
- }
920
- await withWorkspaceLock(workspaceRoot, async () => {
921
- const stored = await resolveTask(workspaceRoot, identifier);
922
- const updated = appendLog(stored.task, message, status);
923
- await saveTask(workspaceRoot, stored.status, updated);
924
- process.stdout.write(`Logged on ${updated.ref}\n`);
925
- });
926
- }));
927
- 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) => {
928
- const workspaceRoot = await resolveWorkspaceFor(true);
929
- await withWorkspaceLock(workspaceRoot, async () => {
930
- const stored = await resolveTask(workspaceRoot, identifier);
931
- const updated = applyTaskEdit(stored.task, dottedPath, value);
932
- await saveTask(workspaceRoot, stored.status, updated);
933
- process.stdout.write(`Updated ${updated.ref}\n`);
934
- });
935
- }));
936
- program.command("search").description("search tasks").argument("<query>", "search query").action(handleAction(async (query, command) => {
937
- const workspaceRoot = await resolveWorkspaceFor(true);
938
- const aliasLookup = buildAliasLookup(await readAliases(workspaceRoot));
939
- printLines((await listAllTasks(workspaceRoot)).filter((stored) => taskMatchesQuery(stored.task, query)).map((stored) => {
940
- const alias = aliasLookup.get(stored.task.id);
941
- let line = formatTaskListLine(stored.task, alias);
942
- if (stored.task.metadata.pr?.fetched?.state) line += ` PR:${stored.task.metadata.pr.fetched.state}`;
943
- return line;
944
- }));
945
- }));
946
- 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) => {
947
- const workspaceRoot = await resolveWorkspaceFor(true);
948
- let date;
949
- try {
950
- date = todayDate(options.date);
951
- } catch (error) {
952
- failWithHelp(command, error instanceof Error ? error.message : String(error));
953
- return;
954
- }
955
- await withWorkspaceLock(workspaceRoot, async () => {
956
- const stored = await resolveTask(workspaceRoot, identifier);
957
- if (isAssignedOnDate(stored.task, date)) {
958
- process.stdout.write(`Already assigned ${stored.task.ref} to ${date}\n`);
959
- return;
960
- }
961
- const updated = appendDayAssignment(stored.task, date, "assign", options.message);
962
- await saveTask(workspaceRoot, stored.status, updated);
963
- process.stdout.write(`Assigned ${updated.ref} to ${date}\n`);
964
- });
965
- }));
966
- 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) => {
967
- const workspaceRoot = await resolveWorkspaceFor(true);
968
- let date;
969
- try {
970
- date = todayDate(options.date);
971
- } catch (error) {
972
- failWithHelp(command, error instanceof Error ? error.message : String(error));
973
- return;
974
- }
975
- await withWorkspaceLock(workspaceRoot, async () => {
976
- const stored = await resolveTask(workspaceRoot, identifier);
977
- if (!isAssignedOnDate(stored.task, date)) {
978
- process.stdout.write(`Already unassigned ${stored.task.ref} from ${date}\n`);
979
- return;
980
- }
981
- const updated = appendDayAssignment(stored.task, date, "unassign");
982
- await saveTask(workspaceRoot, stored.status, updated);
983
- process.stdout.write(`Unassigned ${updated.ref} from ${date}\n`);
984
- });
985
- }));
986
- 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) => {
987
- const workspaceRoot = await resolveWorkspaceFor(true);
988
- await withWorkspaceLock(workspaceRoot, async () => {
989
- const parsed = parseGitHubPrUrl(url);
990
- if (!parsed) {
991
- failWithHelp(command, "Invalid PR URL");
992
- return;
993
- }
994
- const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
995
- const stored = await resolveTask(workspaceRoot, identifier);
996
- const next = appendLog({
997
- ...stored.task,
998
- metadata: {
999
- ...stored.task.metadata,
1000
- pr: buildPrAttachment(parsed, fetched ?? stored.task.metadata.pr?.fetched)
1001
- },
1002
- updated_at: nowIso()
1003
- }, options.message ?? `Attached PR ${url}`);
1004
- await saveTask(workspaceRoot, stored.status, next);
1005
- process.stdout.write(`Attached PR to ${next.ref}\n`);
1006
- });
1007
- }));
1008
- program.command("refresh").description("refresh PR metadata").argument("<id>", "task identifier").option("-m, --message <message>", "log message").action(handleAction(async (identifier, options, command) => {
1009
- const workspaceRoot = await resolveWorkspaceFor(true);
1010
- await withWorkspaceLock(workspaceRoot, async () => {
1011
- const stored = await resolveTask(workspaceRoot, identifier);
1012
- const prUrl = stored.task.metadata.pr?.url;
1013
- if (!prUrl) {
1014
- process.stdout.write("No PR attached.\n");
1015
- return;
1016
- }
1017
- const parsed = parseGitHubPrUrl(prUrl);
1018
- if (!parsed) throw new Error("Invalid PR URL");
1019
- const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
1020
- if (!fetched) throw new Error("No GitHub token configured");
1021
- const baseUpdated = {
1022
- ...stored.task,
1023
- metadata: {
1024
- ...stored.task.metadata,
1025
- pr: buildPrAttachment(parsed, fetched)
1026
- },
1027
- updated_at: nowIso()
1028
- };
1029
- const previousState = stored.task.metadata.pr?.fetched?.state;
1030
- const nextState = fetched.state;
1031
- const isTerminal = stored.task.status === "completed" || stored.task.status === "cancelled";
1032
- let finalTask = baseUpdated;
1033
- let nextStatus = stored.status;
1034
- if (!isTerminal && previousState !== nextState) {
1035
- if (nextState === "merged") {
1036
- const result = applyTransition(baseUpdated, "complete", options.message ?? "PR merged");
1037
- finalTask = result.task;
1038
- nextStatus = result.nextStatus;
1039
- } else if (nextState === "closed") {
1040
- const result = applyTransition(baseUpdated, "cancel", options.message ?? "PR closed without merge");
1041
- finalTask = result.task;
1042
- nextStatus = result.nextStatus;
1043
- }
1044
- }
1045
- await saveTask(workspaceRoot, stored.status, finalTask);
1046
- if (nextStatus !== stored.status) await moveTaskFile(workspaceRoot, finalTask.id, stored.status, nextStatus);
1047
- process.stdout.write(`Refreshed ${finalTask.ref}\n`);
1048
- });
1049
- }));
1050
- program.command("alias").description("alias helpers").command("reconcile").description("reconcile alias mapping").action(handleAction(async (command) => {
1051
- const workspaceRoot = await resolveWorkspaceFor(true);
1052
- await withWorkspaceLock(workspaceRoot, async () => {
1053
- await reconcileAliases(workspaceRoot);
1054
- process.stdout.write("Aliases reconciled.\n");
1055
- });
1056
- }));
1284
+ registerInitCommand(program, helpers);
1285
+ registerWhereCommand(program, helpers);
1286
+ registerAddCommand(program, helpers);
1287
+ registerListCommand(program, helpers);
1288
+ registerShowCommand(program, helpers);
1289
+ registerTransitionCommands(program, helpers);
1290
+ registerLogCommand(program, helpers);
1291
+ registerEditCommand(program, helpers);
1292
+ registerSearchCommand(program, helpers);
1293
+ registerTodayCommands(program, helpers);
1294
+ registerPrCommands(program, helpers);
1295
+ registerAliasCommand(program, helpers);
1057
1296
  program.parseAsync(process.argv);
1058
1297
 
1059
1298
  //#endregion