@eunjae/il 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ import { generateStableRef, generateTaskId } from './id';
2
+ import { nowIso } from './time';
3
+ export const buildTask = (input) => {
4
+ const id = generateTaskId(input.type);
5
+ const ref = generateStableRef(input.type, id);
6
+ const timestamp = nowIso();
7
+ return {
8
+ id,
9
+ ref,
10
+ type: input.type,
11
+ title: input.title,
12
+ status: 'backlog',
13
+ created_at: timestamp,
14
+ updated_at: timestamp,
15
+ metadata: input.metadata ?? {},
16
+ logs: [],
17
+ day_assignments: []
18
+ };
19
+ };
@@ -0,0 +1,57 @@
1
+ import { getNextStatus } from './fsm';
2
+ import { nowIso } from './time';
3
+ const defaultMessages = {
4
+ start: 'Started task',
5
+ pause: 'Paused task',
6
+ complete: 'Completed task',
7
+ cancel: 'Cancelled task'
8
+ };
9
+ export const appendLog = (task, message, status) => {
10
+ const log = {
11
+ ts: nowIso(),
12
+ msg: message,
13
+ status
14
+ };
15
+ return {
16
+ ...task,
17
+ logs: [...task.logs, log],
18
+ updated_at: nowIso()
19
+ };
20
+ };
21
+ export const applyTransition = (task, action, message) => {
22
+ const nextStatus = getNextStatus(task.status, action);
23
+ const logMessage = message ?? defaultMessages[action];
24
+ const log = {
25
+ ts: nowIso(),
26
+ msg: logMessage,
27
+ status: nextStatus
28
+ };
29
+ const updated = {
30
+ ...task,
31
+ status: nextStatus,
32
+ updated_at: nowIso(),
33
+ logs: [...task.logs, log]
34
+ };
35
+ return { task: updated, nextStatus };
36
+ };
37
+ export const isAssignedOnDate = (task, date) => {
38
+ const events = task.day_assignments.filter((entry) => entry.date === date);
39
+ if (events.length === 0) {
40
+ return false;
41
+ }
42
+ const lastEvent = events[events.length - 1];
43
+ return lastEvent.action === 'assign';
44
+ };
45
+ export const appendDayAssignment = (task, date, action, msg) => {
46
+ const event = {
47
+ date,
48
+ action,
49
+ ts: nowIso(),
50
+ msg
51
+ };
52
+ return {
53
+ ...task,
54
+ day_assignments: [...task.day_assignments, event],
55
+ updated_at: nowIso()
56
+ };
57
+ };
@@ -0,0 +1,67 @@
1
+ import { access, readdir, rename } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { STATUS_ORDER, TASKS_DIR } from './constants';
4
+ import { readJsonFile, writeJsonAtomic } from './json';
5
+ import { taskSchema } from './schema';
6
+ const taskFileName = (taskId) => `${taskId}.json`;
7
+ const getStatusDir = (workspaceRoot, status) => path.join(workspaceRoot, TASKS_DIR, status);
8
+ export const getTaskPath = (workspaceRoot, status, taskId) => {
9
+ return path.join(getStatusDir(workspaceRoot, status), taskFileName(taskId));
10
+ };
11
+ const fileExists = async (filePath) => {
12
+ try {
13
+ await access(filePath);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ };
20
+ export const findTaskPathById = async (workspaceRoot, taskId) => {
21
+ for (const status of STATUS_ORDER) {
22
+ const candidate = getTaskPath(workspaceRoot, status, taskId);
23
+ if (await fileExists(candidate)) {
24
+ return { filePath: candidate, status };
25
+ }
26
+ }
27
+ return null;
28
+ };
29
+ export const loadTaskFromPath = async (filePath) => {
30
+ const parsed = await readJsonFile(filePath);
31
+ return taskSchema.parse(parsed);
32
+ };
33
+ export const loadTaskById = async (workspaceRoot, taskId) => {
34
+ const found = await findTaskPathById(workspaceRoot, taskId);
35
+ if (!found) {
36
+ throw new Error(`Task not found: ${taskId}`);
37
+ }
38
+ const task = await loadTaskFromPath(found.filePath);
39
+ return { task, status: found.status, filePath: found.filePath };
40
+ };
41
+ export const listTasksByStatus = async (workspaceRoot, status) => {
42
+ const statusDir = getStatusDir(workspaceRoot, status);
43
+ const entries = await readdir(statusDir, { withFileTypes: true });
44
+ const tasks = await Promise.all(entries
45
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
46
+ .map(async (entry) => {
47
+ const filePath = path.join(statusDir, entry.name);
48
+ const task = await loadTaskFromPath(filePath);
49
+ return { task, status, filePath };
50
+ }));
51
+ return tasks;
52
+ };
53
+ export const listAllTasks = async (workspaceRoot) => {
54
+ const all = await Promise.all(STATUS_ORDER.map((status) => listTasksByStatus(workspaceRoot, status)));
55
+ return all.flat();
56
+ };
57
+ export const saveTask = async (workspaceRoot, status, task) => {
58
+ const filePath = getTaskPath(workspaceRoot, status, task.id);
59
+ await writeJsonAtomic(filePath, task);
60
+ return filePath;
61
+ };
62
+ export const moveTaskFile = async (workspaceRoot, taskId, fromStatus, toStatus) => {
63
+ const fromPath = getTaskPath(workspaceRoot, fromStatus, taskId);
64
+ const toPath = getTaskPath(workspaceRoot, toStatus, taskId);
65
+ await rename(fromPath, toPath);
66
+ return toPath;
67
+ };
@@ -0,0 +1,31 @@
1
+ import { resolveAlias } from './aliasRepo';
2
+ import { findTaskPathById, listAllTasks, loadTaskFromPath } from './taskRepo';
3
+ const stableRefPattern = /^[TR]-[A-Z0-9]{6}$/;
4
+ export const resolveTaskId = async (workspaceRoot, identifier) => {
5
+ const aliasMatch = await resolveAlias(workspaceRoot, identifier);
6
+ if (aliasMatch) {
7
+ return aliasMatch;
8
+ }
9
+ if (stableRefPattern.test(identifier)) {
10
+ const tasks = await listAllTasks(workspaceRoot);
11
+ const match = tasks.find((stored) => stored.task.ref === identifier);
12
+ if (!match) {
13
+ throw new Error(`Task not found for ref: ${identifier}`);
14
+ }
15
+ return match.task.id;
16
+ }
17
+ const found = await findTaskPathById(workspaceRoot, identifier);
18
+ if (!found) {
19
+ throw new Error(`Task not found: ${identifier}`);
20
+ }
21
+ return identifier;
22
+ };
23
+ export const resolveTask = async (workspaceRoot, identifier) => {
24
+ const taskId = await resolveTaskId(workspaceRoot, identifier);
25
+ const found = await findTaskPathById(workspaceRoot, taskId);
26
+ if (!found) {
27
+ throw new Error(`Task not found: ${identifier}`);
28
+ }
29
+ const task = await loadTaskFromPath(found.filePath);
30
+ return { task, status: found.status, filePath: found.filePath };
31
+ };
@@ -0,0 +1,9 @@
1
+ import { allocateAlias, writeAliases } from './aliasRepo';
2
+ import { saveTask } from './taskRepo';
3
+ export const createTaskInStore = async (workspaceRoot, task) => {
4
+ const prefix = task.type === 'pr_review' ? 'R' : 'T';
5
+ const { alias, aliases } = await allocateAlias(workspaceRoot, prefix, task.id);
6
+ await writeAliases(workspaceRoot, aliases);
7
+ await saveTask(workspaceRoot, task.status, task);
8
+ return { alias, task };
9
+ };
@@ -0,0 +1,18 @@
1
+ import { DateTime } from 'luxon';
2
+ const DEFAULT_ZONE = 'Europe/Paris';
3
+ export const nowIso = () => {
4
+ const value = DateTime.now().setZone(DEFAULT_ZONE).toISO();
5
+ if (!value) {
6
+ throw new Error('Failed to generate timestamp');
7
+ }
8
+ return value;
9
+ };
10
+ export const todayDate = (date) => {
11
+ const value = date
12
+ ? DateTime.fromISO(date, { zone: DEFAULT_ZONE }).toISODate()
13
+ : DateTime.now().setZone(DEFAULT_ZONE).toISODate();
14
+ if (!value) {
15
+ throw new Error('Failed to generate date');
16
+ }
17
+ return value;
18
+ };
@@ -0,0 +1,3 @@
1
+ export const taskStatuses = ['backlog', 'active', 'paused', 'completed', 'cancelled'];
2
+ export const taskTypes = ['regular', 'pr_review'];
3
+ export const taskActions = ['start', 'pause', 'complete', 'cancel'];
@@ -0,0 +1,58 @@
1
+ import { access, mkdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { APP_DIR, APP_NAME, LOCK_DIR, STATUS_ORDER, TASKS_DIR } from './constants';
5
+ const exists = async (filePath) => {
6
+ try {
7
+ await access(filePath);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ };
14
+ export const findGitRoot = async (startDir) => {
15
+ let current = path.resolve(startDir);
16
+ while (true) {
17
+ if (await exists(path.join(current, '.git'))) {
18
+ return current;
19
+ }
20
+ const parent = path.dirname(current);
21
+ if (parent === current) {
22
+ return null;
23
+ }
24
+ current = parent;
25
+ }
26
+ };
27
+ export const resolveGlobalWorkspaceRoot = () => {
28
+ const dataHome = process.env.XDG_DATA_HOME ?? path.join(homedir(), '.local', 'share');
29
+ return path.join(dataHome, APP_NAME);
30
+ };
31
+ export const resolveWorkspace = async (options) => {
32
+ if (options.store) {
33
+ return { root: path.resolve(options.store), kind: 'explicit' };
34
+ }
35
+ const cwd = options.cwd ?? process.cwd();
36
+ const repoRoot = await findGitRoot(cwd);
37
+ if (options.repo) {
38
+ if (!repoRoot) {
39
+ throw new Error('Not inside a git repository');
40
+ }
41
+ return { root: path.join(repoRoot, APP_DIR), kind: 'repo' };
42
+ }
43
+ if (options.global) {
44
+ return { root: resolveGlobalWorkspaceRoot(), kind: 'global' };
45
+ }
46
+ if (repoRoot) {
47
+ const repoWorkspace = path.join(repoRoot, APP_DIR);
48
+ if (await exists(repoWorkspace)) {
49
+ return { root: repoWorkspace, kind: 'repo' };
50
+ }
51
+ }
52
+ return { root: resolveGlobalWorkspaceRoot(), kind: 'global' };
53
+ };
54
+ export const ensureWorkspaceLayout = async (workspaceRoot) => {
55
+ await mkdir(path.join(workspaceRoot, TASKS_DIR), { recursive: true });
56
+ await mkdir(path.join(workspaceRoot, LOCK_DIR), { recursive: true });
57
+ await Promise.all(STATUS_ORDER.map((status) => mkdir(path.join(workspaceRoot, TASKS_DIR, status), { recursive: true })));
58
+ };
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { Command } from 'commander';
4
+ import { readAliases } from '../lib/aliasRepo';
5
+ import { reconcileAliases } from '../lib/aliasReconcile';
6
+ import { resolveGithubToken } from '../lib/config';
7
+ import { APP_DIR } from '../lib/constants';
8
+ import { applyTaskEdit } from '../lib/edit';
9
+ import { formatTaskDetails, formatTaskListLine, buildAliasLookup } from '../lib/format';
10
+ import { ensureLockIgnored } from '../lib/gitignore';
11
+ import { withWorkspaceLock } from '../lib/lock';
12
+ import { buildPrAttachment, fetchGitHubPr, parseGitHubPrUrl } from '../lib/pr';
13
+ import { taskSchema } from '../lib/schema';
14
+ import { taskMatchesQuery } from '../lib/search';
15
+ import { buildTask } from '../lib/taskFactory';
16
+ import { appendDayAssignment, appendLog, applyTransition, isAssignedOnDate } from '../lib/taskOperations';
17
+ import { createTaskInStore } from '../lib/taskStore';
18
+ import { listAllTasks, listTasksByStatus, moveTaskFile, saveTask } from '../lib/taskRepo';
19
+ import { resolveTask } from '../lib/taskResolver';
20
+ import { nowIso, todayDate } from '../lib/time';
21
+ import { taskStatuses, taskTypes } from '../lib/types';
22
+ import { ensureWorkspaceLayout, findGitRoot, resolveWorkspace } from '../lib/workspace';
23
+ const program = new Command();
24
+ const handleAction = (fn) => async (...args) => {
25
+ try {
26
+ await fn(...args);
27
+ }
28
+ catch (error) {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ process.stderr.write(`${message}\n`);
31
+ process.exitCode = 1;
32
+ }
33
+ };
34
+ const resolveWorkspaceFor = async (ensure) => {
35
+ const opts = program.opts();
36
+ const workspace = await resolveWorkspace({
37
+ store: opts.store,
38
+ global: opts.global,
39
+ repo: opts.repo
40
+ });
41
+ if (ensure) {
42
+ await ensureWorkspaceLayout(workspace.root);
43
+ }
44
+ return workspace.root;
45
+ };
46
+ const printLines = (lines) => {
47
+ if (lines.length === 0) {
48
+ process.stdout.write('No tasks.\n');
49
+ return;
50
+ }
51
+ process.stdout.write(`${lines.join('\n')}\n`);
52
+ };
53
+ const isTaskStatus = (value) => {
54
+ return taskStatuses.includes(value);
55
+ };
56
+ program
57
+ .name('il')
58
+ .description('Terminal task manager')
59
+ .option('--store <path>', 'explicit workspace path')
60
+ .option('--global', 'use global workspace')
61
+ .option('--repo', 'use repo workspace');
62
+ program
63
+ .command('init')
64
+ .description('initialize repo workspace')
65
+ .action(handleAction(async () => {
66
+ const repoRoot = await findGitRoot(process.cwd());
67
+ if (!repoRoot) {
68
+ throw new Error('Not inside a git repository');
69
+ }
70
+ const workspaceRoot = path.join(repoRoot, APP_DIR);
71
+ await ensureWorkspaceLayout(workspaceRoot);
72
+ await ensureLockIgnored(repoRoot);
73
+ process.stdout.write(`Initialized workspace at ${workspaceRoot}\n`);
74
+ }));
75
+ program
76
+ .command('where')
77
+ .description('show resolved workspace')
78
+ .action(handleAction(async () => {
79
+ const opts = program.opts();
80
+ const workspace = await resolveWorkspace({
81
+ store: opts.store,
82
+ global: opts.global,
83
+ repo: opts.repo
84
+ });
85
+ process.stdout.write(`${workspace.root} (${workspace.kind})\n`);
86
+ }));
87
+ program
88
+ .command('add')
89
+ .description('add a task')
90
+ .argument('[title]', 'task title')
91
+ .option('--type <type>', 'regular|pr_review', 'regular')
92
+ .option('--url <url>', 'attach URL')
93
+ .option('--pr <url>', 'attach PR URL')
94
+ .action(handleAction(async (title, options, command) => {
95
+ const taskType = options.type;
96
+ if (!taskTypes.includes(taskType)) {
97
+ throw new Error(`Invalid type: ${taskType}`);
98
+ }
99
+ if (taskType === 'pr_review' && !options.pr) {
100
+ throw new Error('PR review tasks require --pr');
101
+ }
102
+ const workspaceRoot = await resolveWorkspaceFor(true);
103
+ await withWorkspaceLock(workspaceRoot, async () => {
104
+ let prAttachment;
105
+ let prTitle;
106
+ if (options.pr) {
107
+ const parsed = parseGitHubPrUrl(options.pr);
108
+ if (!parsed) {
109
+ throw new Error('Invalid PR URL');
110
+ }
111
+ const token = await resolveGithubToken(workspaceRoot);
112
+ const fetched = await fetchGitHubPr(parsed, token);
113
+ prAttachment = buildPrAttachment(parsed, fetched ?? undefined);
114
+ prTitle = fetched?.title ?? `PR #${parsed.number} ${parsed.repo.owner}/${parsed.repo.name}`;
115
+ }
116
+ const isExplicitTitle = Boolean(title);
117
+ let finalTitle = title ?? prTitle;
118
+ if (!finalTitle) {
119
+ throw new Error('Title is required when no PR URL is provided');
120
+ }
121
+ if (taskType === 'pr_review' && !isExplicitTitle) {
122
+ finalTitle = `Review: ${finalTitle}`;
123
+ }
124
+ const metadata = {
125
+ url: options.url,
126
+ pr: prAttachment
127
+ };
128
+ const task = buildTask({
129
+ type: taskType,
130
+ title: finalTitle,
131
+ metadata
132
+ });
133
+ taskSchema.parse(task);
134
+ const created = await createTaskInStore(workspaceRoot, task);
135
+ process.stdout.write(`Created ${created.alias} ${created.task.ref} ${created.task.title}\n`);
136
+ });
137
+ }));
138
+ program
139
+ .command('list')
140
+ .description('list tasks')
141
+ .argument('[status]', 'task status')
142
+ .action(handleAction(async (status, command) => {
143
+ const workspaceRoot = await resolveWorkspaceFor(true);
144
+ const aliases = await readAliases(workspaceRoot);
145
+ const aliasLookup = buildAliasLookup(aliases);
146
+ if (status && !isTaskStatus(status)) {
147
+ throw new Error(`Invalid status: ${status}`);
148
+ }
149
+ const statuses = status ? [status] : [...taskStatuses];
150
+ const lines = [];
151
+ for (const currentStatus of statuses) {
152
+ const stored = await listTasksByStatus(workspaceRoot, currentStatus);
153
+ for (const entry of stored) {
154
+ const alias = aliasLookup.get(entry.task.id);
155
+ let line = formatTaskListLine(entry.task, alias);
156
+ if (entry.task.metadata.pr?.fetched?.state) {
157
+ line += ` PR:${entry.task.metadata.pr.fetched.state}`;
158
+ }
159
+ lines.push(line);
160
+ }
161
+ }
162
+ printLines(lines);
163
+ }));
164
+ program
165
+ .command('show')
166
+ .description('show a task')
167
+ .argument('<id>', 'task identifier')
168
+ .action(handleAction(async (identifier, command) => {
169
+ const workspaceRoot = await resolveWorkspaceFor(true);
170
+ const stored = await resolveTask(workspaceRoot, identifier);
171
+ const aliases = await readAliases(workspaceRoot);
172
+ const aliasLookup = buildAliasLookup(aliases);
173
+ const alias = aliasLookup.get(stored.task.id);
174
+ process.stdout.write(`${formatTaskDetails(stored.task, alias)}\n`);
175
+ }));
176
+ const addTransitionCommand = (name) => {
177
+ program
178
+ .command(name)
179
+ .description(`${name} a task`)
180
+ .argument('<id>', 'task identifier')
181
+ .option('-m, --message <message>', 'custom log message')
182
+ .action(handleAction(async (identifier, options, command) => {
183
+ const workspaceRoot = await resolveWorkspaceFor(true);
184
+ await withWorkspaceLock(workspaceRoot, async () => {
185
+ const stored = await resolveTask(workspaceRoot, identifier);
186
+ const { task: updated, nextStatus } = applyTransition(stored.task, name, options.message);
187
+ await saveTask(workspaceRoot, stored.status, updated);
188
+ await moveTaskFile(workspaceRoot, updated.id, stored.status, nextStatus);
189
+ process.stdout.write(`Updated ${updated.ref} -> ${nextStatus}\n`);
190
+ });
191
+ }));
192
+ };
193
+ addTransitionCommand('start');
194
+ addTransitionCommand('pause');
195
+ addTransitionCommand('complete');
196
+ addTransitionCommand('cancel');
197
+ program
198
+ .command('log')
199
+ .description('append a log entry')
200
+ .argument('<id>', 'task identifier')
201
+ .argument('<message>', 'log message')
202
+ .option('--status <status>', 'include status in log entry')
203
+ .action(handleAction(async (identifier, message, options, command) => {
204
+ const workspaceRoot = await resolveWorkspaceFor(true);
205
+ const status = options.status;
206
+ if (status && !taskStatuses.includes(status)) {
207
+ throw new Error(`Invalid status: ${status}`);
208
+ }
209
+ await withWorkspaceLock(workspaceRoot, async () => {
210
+ const stored = await resolveTask(workspaceRoot, identifier);
211
+ const updated = appendLog(stored.task, message, status);
212
+ await saveTask(workspaceRoot, stored.status, updated);
213
+ process.stdout.write(`Logged on ${updated.ref}\n`);
214
+ });
215
+ }));
216
+ program
217
+ .command('edit')
218
+ .description('edit a task field')
219
+ .argument('<id>', 'task identifier')
220
+ .argument('<path>', 'dotted path')
221
+ .argument('<value>', 'new value')
222
+ .action(handleAction(async (identifier, dottedPath, value, command) => {
223
+ const workspaceRoot = await resolveWorkspaceFor(true);
224
+ await withWorkspaceLock(workspaceRoot, async () => {
225
+ const stored = await resolveTask(workspaceRoot, identifier);
226
+ const updated = applyTaskEdit(stored.task, dottedPath, value);
227
+ await saveTask(workspaceRoot, stored.status, updated);
228
+ process.stdout.write(`Updated ${updated.ref}\n`);
229
+ });
230
+ }));
231
+ program
232
+ .command('search')
233
+ .description('search tasks')
234
+ .argument('<query>', 'search query')
235
+ .action(handleAction(async (query, command) => {
236
+ const workspaceRoot = await resolveWorkspaceFor(true);
237
+ const aliases = await readAliases(workspaceRoot);
238
+ const aliasLookup = buildAliasLookup(aliases);
239
+ const tasks = await listAllTasks(workspaceRoot);
240
+ const lines = tasks
241
+ .filter((stored) => taskMatchesQuery(stored.task, query))
242
+ .map((stored) => {
243
+ const alias = aliasLookup.get(stored.task.id);
244
+ let line = formatTaskListLine(stored.task, alias);
245
+ if (stored.task.metadata.pr?.fetched?.state) {
246
+ line += ` PR:${stored.task.metadata.pr.fetched.state}`;
247
+ }
248
+ return line;
249
+ });
250
+ printLines(lines);
251
+ }));
252
+ program
253
+ .command('today')
254
+ .description('assign or list today tasks')
255
+ .argument('[id]', 'task identifier')
256
+ .option('--date <date>', 'YYYY-MM-DD')
257
+ .option('-m, --message <message>', 'assignment message')
258
+ .action(handleAction(async (identifier, options, command) => {
259
+ const workspaceRoot = await resolveWorkspaceFor(true);
260
+ const date = todayDate(options.date);
261
+ if (!identifier) {
262
+ const aliases = await readAliases(workspaceRoot);
263
+ const aliasLookup = buildAliasLookup(aliases);
264
+ const tasks = await listAllTasks(workspaceRoot);
265
+ const assigned = tasks.filter((stored) => isAssignedOnDate(stored.task, date));
266
+ const lines = assigned.map((stored) => formatTaskListLine(stored.task, aliasLookup.get(stored.task.id)));
267
+ printLines(lines);
268
+ return;
269
+ }
270
+ await withWorkspaceLock(workspaceRoot, async () => {
271
+ const stored = await resolveTask(workspaceRoot, identifier);
272
+ if (isAssignedOnDate(stored.task, date)) {
273
+ process.stdout.write(`Already assigned ${stored.task.ref} to ${date}\n`);
274
+ return;
275
+ }
276
+ const updated = appendDayAssignment(stored.task, date, 'assign', options.message);
277
+ await saveTask(workspaceRoot, stored.status, updated);
278
+ process.stdout.write(`Assigned ${updated.ref} to ${date}\n`);
279
+ });
280
+ }));
281
+ program
282
+ .command('untoday')
283
+ .description('unassign a task from today')
284
+ .argument('<id>', 'task identifier')
285
+ .option('--date <date>', 'YYYY-MM-DD')
286
+ .action(handleAction(async (identifier, options, command) => {
287
+ const workspaceRoot = await resolveWorkspaceFor(true);
288
+ const date = todayDate(options.date);
289
+ await withWorkspaceLock(workspaceRoot, async () => {
290
+ const stored = await resolveTask(workspaceRoot, identifier);
291
+ if (!isAssignedOnDate(stored.task, date)) {
292
+ process.stdout.write(`Already unassigned ${stored.task.ref} from ${date}\n`);
293
+ return;
294
+ }
295
+ const updated = appendDayAssignment(stored.task, date, 'unassign');
296
+ await saveTask(workspaceRoot, stored.status, updated);
297
+ process.stdout.write(`Unassigned ${updated.ref} from ${date}\n`);
298
+ });
299
+ }));
300
+ program
301
+ .command('attach-pr')
302
+ .description('attach PR metadata to a task')
303
+ .argument('<id>', 'task identifier')
304
+ .argument('<url>', 'PR URL')
305
+ .option('-m, --message <message>', 'log message')
306
+ .action(handleAction(async (identifier, url, options, command) => {
307
+ const workspaceRoot = await resolveWorkspaceFor(true);
308
+ await withWorkspaceLock(workspaceRoot, async () => {
309
+ const parsed = parseGitHubPrUrl(url);
310
+ if (!parsed) {
311
+ throw new Error('Invalid PR URL');
312
+ }
313
+ const token = await resolveGithubToken(workspaceRoot);
314
+ const fetched = await fetchGitHubPr(parsed, token);
315
+ const stored = await resolveTask(workspaceRoot, identifier);
316
+ const updated = {
317
+ ...stored.task,
318
+ metadata: {
319
+ ...stored.task.metadata,
320
+ pr: buildPrAttachment(parsed, fetched ?? stored.task.metadata.pr?.fetched)
321
+ },
322
+ updated_at: nowIso()
323
+ };
324
+ const logMessage = options.message ?? `Attached PR ${url}`;
325
+ const next = appendLog(updated, logMessage);
326
+ await saveTask(workspaceRoot, stored.status, next);
327
+ process.stdout.write(`Attached PR to ${next.ref}\n`);
328
+ });
329
+ }));
330
+ program
331
+ .command('refresh')
332
+ .description('refresh PR metadata')
333
+ .argument('<id>', 'task identifier')
334
+ .option('-m, --message <message>', 'log message')
335
+ .action(handleAction(async (identifier, options, command) => {
336
+ const workspaceRoot = await resolveWorkspaceFor(true);
337
+ await withWorkspaceLock(workspaceRoot, async () => {
338
+ const stored = await resolveTask(workspaceRoot, identifier);
339
+ const prUrl = stored.task.metadata.pr?.url;
340
+ if (!prUrl) {
341
+ process.stdout.write('No PR attached.\n');
342
+ return;
343
+ }
344
+ const parsed = parseGitHubPrUrl(prUrl);
345
+ if (!parsed) {
346
+ throw new Error('Invalid PR URL');
347
+ }
348
+ const token = await resolveGithubToken(workspaceRoot);
349
+ const fetched = await fetchGitHubPr(parsed, token);
350
+ if (!fetched) {
351
+ throw new Error('No GitHub token configured');
352
+ }
353
+ const baseUpdated = {
354
+ ...stored.task,
355
+ metadata: {
356
+ ...stored.task.metadata,
357
+ pr: buildPrAttachment(parsed, fetched)
358
+ },
359
+ updated_at: nowIso()
360
+ };
361
+ const previousState = stored.task.metadata.pr?.fetched?.state;
362
+ const nextState = fetched.state;
363
+ const isTerminal = stored.task.status === 'completed' || stored.task.status === 'cancelled';
364
+ let finalTask = baseUpdated;
365
+ let nextStatus = stored.status;
366
+ if (!isTerminal && previousState !== nextState) {
367
+ if (nextState === 'merged') {
368
+ const result = applyTransition(baseUpdated, 'complete', options.message ?? 'PR merged');
369
+ finalTask = result.task;
370
+ nextStatus = result.nextStatus;
371
+ }
372
+ else if (nextState === 'closed') {
373
+ const result = applyTransition(baseUpdated, 'cancel', options.message ?? 'PR closed without merge');
374
+ finalTask = result.task;
375
+ nextStatus = result.nextStatus;
376
+ }
377
+ }
378
+ await saveTask(workspaceRoot, stored.status, finalTask);
379
+ if (nextStatus !== stored.status) {
380
+ await moveTaskFile(workspaceRoot, finalTask.id, stored.status, nextStatus);
381
+ }
382
+ process.stdout.write(`Refreshed ${finalTask.ref}\n`);
383
+ });
384
+ }));
385
+ const aliasCommand = program.command('alias').description('alias helpers');
386
+ aliasCommand
387
+ .command('reconcile')
388
+ .description('reconcile alias mapping')
389
+ .action(handleAction(async (command) => {
390
+ const workspaceRoot = await resolveWorkspaceFor(true);
391
+ await withWorkspaceLock(workspaceRoot, async () => {
392
+ await reconcileAliases(workspaceRoot);
393
+ process.stdout.write('Aliases reconciled.\n');
394
+ });
395
+ }));
396
+ program.parseAsync(process.argv);