@eunjae/il 1.1.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
+
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
+ ];
14
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
  };
@@ -230,141 +487,145 @@ const moveTaskFile = async (workspaceRoot, taskId, fromStatus, toStatus) => {
230
487
  };
231
488
 
232
489
  //#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);
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);
248
493
  await writeAliases(workspaceRoot, aliases);
249
- return { updated: true };
494
+ await saveTask(workspaceRoot, task.status, task);
495
+ return {
496
+ alias,
497
+ task
498
+ };
250
499
  };
251
500
 
252
501
  //#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;
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";
260
509
  }
261
510
  };
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;
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
+ });
275
547
  };
276
548
 
277
549
  //#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"));
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;
314
578
  }
315
- }
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
+ }));
316
597
  };
317
598
 
318
599
  //#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
600
+ //#region lib/aliasReconcile.ts
601
+ const reconcileAliases = async (workspaceRoot) => {
602
+ const aliases = await readAliases(workspaceRoot);
603
+ const tasks = await listAllTasks(workspaceRoot);
604
+ const tasksById = new Map(tasks.map((stored) => [stored.task.id, stored.task]));
605
+ const normalizePrefix = (prefix) => {
606
+ const next = {};
607
+ for (const [key, value] of Object.entries(aliases[prefix])) if (tasksById.has(value)) next[key] = value;
608
+ aliases[prefix] = next;
349
609
  };
610
+ normalizePrefix("T");
611
+ normalizePrefix("R");
612
+ const existingIds = new Set([...Object.values(aliases.T), ...Object.values(aliases.R)]);
613
+ const missing = tasks.filter((stored) => !existingIds.has(stored.task.id)).sort((a, b) => a.task.created_at.localeCompare(b.task.created_at));
614
+ for (const stored of missing) allocateAliasInMap(aliases, stored.task.type === "pr_review" ? "R" : "T", stored.task.id);
615
+ await writeAliases(workspaceRoot, aliases);
616
+ return { updated: true };
350
617
  };
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
- };
618
+
619
+ //#endregion
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
@@ -403,51 +664,41 @@ const applyTaskEdit = (task, dottedPath, rawValue) => {
403
664
  };
404
665
 
405
666
  //#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;
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;
412
679
  };
413
- const formatTaskListLine = (task, alias) => {
414
- return `${(alias ?? "--").padEnd(4)} ${task.ref.padEnd(9)} ${task.status.padEnd(9)} ${task.title}`;
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
+ };
415
688
  };
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");
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
+ }));
451
702
  };
452
703
 
453
704
  //#endregion
@@ -468,140 +719,65 @@ const ensureLockIgnored = async (repoRoot) => {
468
719
  };
469
720
 
470
721
  //#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) {
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());
500
726
  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;
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
+ }));
560
732
  };
561
733
 
562
734
  //#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()}`;
735
+ //#region lib/format.ts
736
+ const buildAliasLookup = (aliases) => {
737
+ const map = /* @__PURE__ */ new Map();
738
+ for (const [key, value] of Object.entries(aliases.T)) map.set(value, `T${key}`);
739
+ for (const [key, value] of Object.entries(aliases.R)) map.set(value, `R${key}`);
740
+ return map;
582
741
  };
583
- const generateStableRef = (taskType, taskId) => {
584
- return `${taskType === "pr_review" ? "R" : "T"}-${base32Encode(createHash("sha256").update(taskId).digest()).slice(0, 6)}`;
742
+ const formatTaskListLine = (task, alias) => {
743
+ return `${(alias ?? "--").padEnd(4)} ${task.ref.padEnd(9)} ${task.status.padEnd(9)} ${task.title}`;
585
744
  };
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
- };
745
+ const formatTaskDetails = (task, alias) => {
746
+ const lines = [];
747
+ lines.push(`${alias ?? "--"} ${task.ref} ${task.title}`);
748
+ lines.push(`id: ${task.id}`);
749
+ lines.push(`type: ${task.type}`);
750
+ lines.push(`status: ${task.status}`);
751
+ lines.push(`created: ${task.created_at}`);
752
+ lines.push(`updated: ${task.updated_at}`);
753
+ if (task.metadata.url) lines.push(`url: ${task.metadata.url}`);
754
+ if (task.metadata.pr?.url) {
755
+ lines.push(`pr: ${task.metadata.pr.url}`);
756
+ if (task.metadata.pr.fetched) {
757
+ const fetched = task.metadata.pr.fetched;
758
+ lines.push(`pr_state: ${fetched.state}`);
759
+ lines.push(`pr_title: ${fetched.title}`);
760
+ lines.push(`pr_author: ${fetched.author.login}`);
761
+ lines.push(`pr_updated_at: ${fetched.updated_at}`);
762
+ lines.push(`pr_refreshed_at: ${fetched.at}`);
763
+ }
764
+ }
765
+ if (task.logs.length > 0) {
766
+ lines.push("logs:");
767
+ for (const log of task.logs) {
768
+ const status = log.status ? ` [${log.status}]` : "";
769
+ lines.push(`- ${log.ts}${status} ${log.msg}`);
770
+ }
771
+ }
772
+ if (task.day_assignments.length > 0) {
773
+ lines.push("day_assignments:");
774
+ for (const entry of task.day_assignments) {
775
+ const order = entry.order ? ` order:${entry.order}` : "";
776
+ const msg = entry.msg ? ` (${entry.msg})` : "";
777
+ lines.push(`- ${entry.date} ${entry.action}${order} ${entry.ts}${msg}`);
778
+ }
779
+ }
780
+ return lines.join("\n");
605
781
  };
606
782
 
607
783
  //#endregion
@@ -669,15 +845,27 @@ const applyTransition = (task, action, message) => {
669
845
  };
670
846
  };
671
847
  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";
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
861
+ };
675
862
  };
676
- const appendDayAssignment = (task, date, action, msg) => {
863
+ const appendDayAssignment = (task, date, action, msg, order) => {
677
864
  const event = {
678
865
  date,
679
866
  action,
680
867
  ts: nowIso(),
868
+ order,
681
869
  msg
682
870
  };
683
871
  return {
@@ -688,44 +876,346 @@ const appendDayAssignment = (task, date, action, msg) => {
688
876
  };
689
877
 
690
878
  //#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
- };
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
+ }));
700
926
  };
701
927
 
702
928
  //#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;
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;
937
+ }
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
+ }));
1014
+ };
1015
+
1016
+ //#endregion
1017
+ //#region lib/search.ts
1018
+ const taskMatchesQuery = (task, query) => {
1019
+ const needle = query.toLowerCase();
1020
+ const contains = (value) => value ? value.toLowerCase().includes(needle) : false;
1021
+ if (contains(task.title)) return true;
1022
+ if (task.logs.some((log) => contains(log.msg))) return true;
1023
+ if (contains(task.metadata.pr?.url)) return true;
1024
+ if (contains(task.metadata.pr?.fetched?.title)) return true;
1025
+ return false;
1026
+ };
1027
+
1028
+ //#endregion
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
+ }));
1042
+ };
1043
+
1044
+ //#endregion
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
+ }));
1053
+ };
1054
+
1055
+ //#endregion
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;
712
1064
  }
713
- if (!await findTaskPathById(workspaceRoot, identifier)) throw new Error(`Task not found: ${identifier}`);
714
- return identifier;
715
1065
  };
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
- };
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
+ });
1088
+ };
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;
1107
+ };
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
+ }));
724
1174
  };
725
1175
 
726
1176
  //#endregion
727
- //#region scripts/il.ts
728
- const program = new Command();
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
+ }));
1195
+ };
1196
+ const registerTransitionCommands = (program, helpers) => {
1197
+ for (const name of transitionNames) registerTransitionCommand(program, helpers, name);
1198
+ };
1199
+
1200
+ //#endregion
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
+ }));
1212
+ };
1213
+
1214
+ //#endregion
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");
@@ -752,20 +1232,28 @@ const printLines = (lines) => {
752
1232
  }
753
1233
  process.stdout.write(`${lines.join("\n")}\n`);
754
1234
  };
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;
1235
+ const failWithHelp = (command, message) => {
1236
+ process.stderr.write(`${message}\n`);
1237
+ command.outputHelp();
1238
+ process.exitCode = 1;
1239
+ };
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
+ };
769
1257
  };
770
1258
  const resolvePackageVersion = async () => {
771
1259
  let current = path.dirname(fileURLToPath(import.meta.url));
@@ -784,237 +1272,27 @@ const resolvePackageVersion = async () => {
784
1272
  current = parent;
785
1273
  }
786
1274
  };
1275
+
1276
+ //#endregion
1277
+ //#region cli/index.ts
1278
+ const program = new Command();
1279
+ const helpers = createCliHelpers(program);
787
1280
  const packageVersion = await resolvePackageVersion();
788
1281
  program.name("il").description("Terminal task manager").option("--store <path>", "explicit workspace path").option("--global", "use global workspace").option("--repo", "use repo workspace");
789
1282
  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
- }));
1283
+ program.showHelpAfterError();
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);
1018
1296
  program.parseAsync(process.argv);
1019
1297
 
1020
1298
  //#endregion