scribeclock 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,492 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { usageError } from "../cli/errors.js";
4
+ import { changedFiles, currentBranch, gitState, lastCommit, repoRef, githubSlug, remoteUrl } from "../git.js";
5
+ import { EXIT } from "../types.js";
6
+ import { fmtDate, parseWhen, plural, relTime } from "../util/format.js";
7
+ import { PROJECT_FLAG, TASK_POSITIONAL, bool, cmd, emit, guardedEmit, list, num, personName, projectIdArg, resolveProjectId, renderTaskDetail, renderTaskTable, requirePositional, statusPaint, str, taskKey } from "./_shared.js";
8
+ const LIST_FLAGS = [
9
+ { name: "status", alias: "s", type: "string", description: "open (default), all, pending, in-progress, completed, cancelled", choices: ["open", "all", "pending", "in-progress", "completed", "cancelled"] },
10
+ { name: "assignee", alias: "a", type: "string", placeholder: "<me|unassigned|name|email>", description: "Whose tasks. Omit for everyone." },
11
+ { name: "mine", type: "boolean", description: "Shorthand for --assignee me." },
12
+ { name: "unassigned", type: "boolean", description: "Shorthand for --assignee unassigned." },
13
+ PROJECT_FLAG,
14
+ { name: "priority", type: "string", choices: ["low", "medium", "high"], description: "Only this priority." },
15
+ { name: "query", alias: "q", type: "string", description: 'Text in title/description, or "#42".' },
16
+ { name: "due-before", type: "string", placeholder: "<when>", description: "Due before (ISO, YYYY-MM-DD, today, tomorrow, 7d…)." },
17
+ { name: "due-after", type: "string", placeholder: "<when>", description: "Due at or after." },
18
+ { name: "overdue", type: "boolean", description: "Only overdue tasks (due before now)." },
19
+ { name: "updated-since", type: "string", placeholder: "<when>", description: "Changed since (ISO or relative like 2h, 3d)." },
20
+ { name: "order", alias: "o", type: "string", choices: ["updated", "created", "due", "priority", "number", "status"], description: "Sort order (default updated)." },
21
+ { name: "limit", alias: "n", type: "number", description: "Max tasks (default 50, max 200)." },
22
+ { name: "offset", type: "number", description: "Paging offset." },
23
+ { name: "deleted", type: "boolean", description: "Include tasks in the Trash." },
24
+ { name: "ids", type: "boolean", description: "Print only task keys, one per line (for scripts)." },
25
+ ];
26
+ function when(v, ctx) {
27
+ if (typeof v !== "string")
28
+ return undefined;
29
+ const d = parseWhen(v, ctx.now());
30
+ if (!d)
31
+ throw usageError(`Could not understand the date "${v}".`, "Use YYYY-MM-DD, an ISO timestamp, today, yesterday, tomorrow, or 7d / 2h.");
32
+ return d.toISOString();
33
+ }
34
+ export const tasksList = cmd({
35
+ path: "tasks list",
36
+ group: "Tasks",
37
+ summary: "List or search tasks (mine, unassigned, overdue, by project…).",
38
+ tool: "list_tasks",
39
+ flags: LIST_FLAGS,
40
+ examples: ["scribeclock tasks list --mine", "scribeclock tasks list --unassigned --priority high", "scribeclock tasks list --overdue --ids", "scribeclock tasks list -q 'login' --status all --json | jq '.tasks[] | {key,title}'"],
41
+ async run(ctx, inv) {
42
+ const args = {
43
+ status: str(inv.flags.status),
44
+ assignee: inv.flags.mine ? "me" : inv.flags.unassigned ? "unassigned" : str(inv.flags.assignee),
45
+ project_id: await projectIdArg(ctx, inv),
46
+ priority: str(inv.flags.priority),
47
+ query: str(inv.flags.query) ?? inv.positionals.join(" ") ?? undefined,
48
+ due_before: inv.flags.overdue ? ctx.now().toISOString() : when(inv.flags["due-before"], ctx),
49
+ due_after: when(inv.flags["due-after"], ctx),
50
+ updated_since: when(inv.flags["updated-since"], ctx),
51
+ order_by: str(inv.flags.order),
52
+ limit: num(inv.flags.limit),
53
+ offset: num(inv.flags.offset),
54
+ include_deleted: bool(inv.flags.deleted),
55
+ };
56
+ if (args.query === "")
57
+ delete args.query;
58
+ const data = await ctx.call("list_tasks", args);
59
+ const tasks = (data.tasks ?? []);
60
+ if (inv.flags.ids && !ctx.flags.json) {
61
+ for (const t of tasks)
62
+ ctx.out.log(String(t.key ?? t.id));
63
+ return EXIT.OK;
64
+ }
65
+ return emit(ctx, data, () => {
66
+ renderTaskTable(ctx, tasks, { showAssignee: args.assignee !== "me" });
67
+ const total = Number(data.total ?? tasks.length);
68
+ ctx.out.info(ctx.out.dim(`${tasks.length} of ${total} task${total === 1 ? "" : "s"}${data.has_more ? ` · --offset ${Number(data.offset ?? 0) + tasks.length} for more` : ""}`));
69
+ });
70
+ },
71
+ });
72
+ export const tasksShow = cmd({
73
+ path: "tasks show",
74
+ group: "Tasks",
75
+ summary: "Everything about one task: detail, comments, attachments, time, GitHub activity.",
76
+ tool: "get_task",
77
+ positionals: [TASK_POSITIONAL],
78
+ flags: [
79
+ { name: "full", type: "boolean", description: "Print the whole description." },
80
+ { name: "comments", type: "number", description: "How many comments to fetch (default 50)." },
81
+ ],
82
+ examples: ["scribeclock tasks show #731", "scribeclock tasks show auto # task from the current branch", "scribeclock tasks show 731 --json | jq .task.description"],
83
+ async run(ctx, inv) {
84
+ const data = await ctx.call("get_task", { task_id: taskKey(ctx, inv.positionals[0]), comment_limit: num(inv.flags.comments) });
85
+ return emit(ctx, data, () => renderTaskDetail(ctx, data, { full: Boolean(inv.flags.full) }));
86
+ },
87
+ });
88
+ const TASK_FIELD_FLAGS = [
89
+ { name: "description", alias: "d", type: "string", description: "Markdown body (@file.md reads a file)." },
90
+ PROJECT_FLAG,
91
+ { name: "priority", type: "string", choices: ["low", "medium", "high"], description: "Priority." },
92
+ { name: "due", type: "string", placeholder: "<date>", description: "Due date (YYYY-MM-DD, tomorrow, 7d…)." },
93
+ { name: "assignee", alias: "a", type: "string", placeholder: "<me|name|email|uuid>", description: "Owner (me, a name, an email, a uuid; unassigned to clear)." },
94
+ { name: "category", type: "string", choices: ["general", "tech", "operations", "finance", "feedback"], description: "Category." },
95
+ { name: "estimate", type: "number", placeholder: "<hours>", description: "Estimated hours." },
96
+ { name: "repo", type: "string", placeholder: "<owner/repo>", description: "GitHub repository to link (default: this repo's origin when --link-repo)." },
97
+ ];
98
+ function readMaybeFile(v) {
99
+ if (typeof v !== "string")
100
+ return undefined;
101
+ if (v.startsWith("@") && v.length > 1)
102
+ return readFileSync(v.slice(1), "utf8");
103
+ return v;
104
+ }
105
+ export const tasksCreate = cmd({
106
+ path: "tasks create",
107
+ group: "Tasks",
108
+ summary: "Create a task: tasks create \"Title\" [--priority high --due tomorrow --assignee me].",
109
+ tool: "create_task",
110
+ positionals: [{ name: "title", required: true, description: "Task title." }],
111
+ flags: [
112
+ ...TASK_FIELD_FLAGS,
113
+ { name: "status", type: "string", choices: ["pending", "in-progress"], description: "Initial status (default pending)." },
114
+ { name: "comment", type: "string", description: "First comment to post." },
115
+ { name: "link-repo", type: "boolean", description: "Link the current git repository." },
116
+ ],
117
+ examples: ['scribeclock tasks create "Fix login redirect" --priority high --assignee me --due tomorrow', 'scribeclock tasks create "Write release notes" -d @notes.md --project "Scribe Clock"'],
118
+ async run(ctx, inv) {
119
+ const title = requirePositional(inv, 0, "A title", 'scribeclock tasks create "Fix login redirect"');
120
+ const due = inv.flags.due ? parseWhen(String(inv.flags.due), ctx.now()) : undefined;
121
+ if (inv.flags.due && !due)
122
+ throw usageError(`Could not understand the due date "${String(inv.flags.due)}".`);
123
+ const data = await ctx.call("create_task", {
124
+ title,
125
+ description: readMaybeFile(inv.flags.description),
126
+ project_id: await projectIdArg(ctx, inv),
127
+ priority: str(inv.flags.priority),
128
+ status: str(inv.flags.status),
129
+ due_date: due ? fmtDate(due.toISOString()) : undefined,
130
+ assignee: str(inv.flags.assignee),
131
+ category: str(inv.flags.category),
132
+ estimated_hours: num(inv.flags.estimate),
133
+ github_repo_full_name: str(inv.flags.repo) ?? (inv.flags["link-repo"] ? githubSlug(remoteUrl(ctx.cwd)) ?? undefined : undefined),
134
+ comment: str(inv.flags.comment),
135
+ });
136
+ return emit(ctx, data, () => {
137
+ const t = (data.task ?? data);
138
+ ctx.out.log(`${ctx.out.green("✓")} Created ${ctx.out.bold(String(t.key ?? ""))} ${String(t.title ?? title)}${t.url ? ctx.out.dim(` ${String(t.url)}`) : ""}`);
139
+ });
140
+ },
141
+ });
142
+ export const tasksUpdate = cmd({
143
+ path: "tasks update",
144
+ group: "Tasks",
145
+ summary: "Edit a task's title, description, priority, due date, project, status or assignee.",
146
+ tool: "update_task",
147
+ positionals: [TASK_POSITIONAL],
148
+ flags: [
149
+ { name: "title", type: "string", description: "New title." },
150
+ ...TASK_FIELD_FLAGS,
151
+ { name: "status", type: "string", choices: ["pending", "in-progress", "completed", "cancelled"], description: "New status." },
152
+ { name: "clear-due", type: "boolean", description: "Remove the due date." },
153
+ { name: "clear-project", type: "boolean", description: "Detach from its project." },
154
+ ],
155
+ examples: ["scribeclock tasks update #731 --priority medium --due 2026-11-01", "scribeclock tasks update 731 --status in-progress --assignee me"],
156
+ async run(ctx, inv) {
157
+ const due = inv.flags.due ? parseWhen(String(inv.flags.due), ctx.now()) : undefined;
158
+ if (inv.flags.due && !due)
159
+ throw usageError(`Could not understand the due date "${String(inv.flags.due)}".`);
160
+ const args = {
161
+ task_id: taskKey(ctx, inv.positionals[0]),
162
+ title: str(inv.flags.title),
163
+ description: readMaybeFile(inv.flags.description),
164
+ priority: str(inv.flags.priority),
165
+ due_date: inv.flags["clear-due"] ? null : due ? fmtDate(due.toISOString()) : undefined,
166
+ project_id: inv.flags["clear-project"] ? null : await resolveProjectId(ctx, str(inv.flags.project)),
167
+ status: str(inv.flags.status),
168
+ assignee: inv.flags.assignee === "unassigned" ? null : str(inv.flags.assignee),
169
+ category: str(inv.flags.category),
170
+ estimated_hours: num(inv.flags.estimate),
171
+ github_repo_full_name: str(inv.flags.repo),
172
+ };
173
+ const changes = Object.entries(args).filter(([k, v]) => k !== "task_id" && v !== undefined);
174
+ if (!changes.length)
175
+ throw usageError("Nothing to change.", "Pass at least one of --title, --description, --priority, --due, --project, --status, --assignee…");
176
+ const data = await ctx.call("update_task", args);
177
+ return emit(ctx, data, () => {
178
+ const t = (data.task ?? data);
179
+ ctx.out.log(`${ctx.out.green("✓")} Updated ${ctx.out.bold(String(t.key ?? args.task_id))}: ${changes.map(([k, v]) => `${k}=${v === null ? "∅" : String(v).slice(0, 40)}`).join(", ")}`);
180
+ });
181
+ },
182
+ });
183
+ function simpleTaskAction(path, tool, summary, verb, extraFlags = [], build = () => ({})) {
184
+ return cmd({
185
+ path,
186
+ group: "Tasks",
187
+ summary,
188
+ tool,
189
+ positionals: [TASK_POSITIONAL],
190
+ flags: [{ name: "comment", alias: "m", type: "string", description: "Note to post on the task." }, ...(extraFlags ?? [])],
191
+ examples: [`scribeclock ${path} #731`, `scribeclock ${path} auto -m "Picking this up"`],
192
+ async run(ctx, inv) {
193
+ const args = { task_id: taskKey(ctx, inv.positionals[0]), comment: str(inv.flags.comment), ...build(inv) };
194
+ return guardedEmit(ctx, tool, args, (data) => {
195
+ const t = (data.task ?? data);
196
+ ctx.out.log(`${ctx.out.green("✓")} ${verb} ${ctx.out.bold(String(t.key ?? args.task_id))}${t.title ? ` ${String(t.title)}` : ""}${t.status ? ` · ${statusPaint(ctx, t.status)}` : ""}`);
197
+ if (typeof data.hint === "string" && !ctx.out.quiet)
198
+ ctx.out.info(ctx.out.dim(data.hint));
199
+ });
200
+ },
201
+ });
202
+ }
203
+ export const tasksClaim = simpleTaskAction("tasks claim", "claim_task", "Assign a task to yourself and mark it in progress.", "Claimed", [{ name: "force", type: "boolean", description: "Take over someone else's task or reopen a closed one." }], (inv) => ({ force: bool(inv.flags.force) }));
204
+ export const tasksRelease = simpleTaskAction("tasks release", "release_task", "Hand a task back to the queue (unassign, back to pending).", "Released");
205
+ export const tasksComplete = cmd({
206
+ path: "tasks complete",
207
+ group: "Tasks",
208
+ summary: 'Mark a task completed with a closing comment: tasks complete #12 -m "what, where, how verified".',
209
+ tool: "complete_task",
210
+ positionals: [TASK_POSITIONAL],
211
+ flags: [{ name: "comment", alias: "m", type: "string", description: "What was done, where (files/PR) and how it was verified." }],
212
+ examples: ['scribeclock tasks complete #731 -m "Deployed to kisaprint.com; DNS + SSL verified in the browser."', "scribeclock done #731 # runs your checks and attaches evidence first"],
213
+ async run(ctx, inv) {
214
+ const args = { task_id: taskKey(ctx, inv.positionals[0]), comment: str(inv.flags.comment) };
215
+ if (!args.comment)
216
+ ctx.out.warn("No --comment given; the people following the task will only see the status change.");
217
+ const data = await ctx.call("complete_task", args);
218
+ return emit(ctx, data, () => {
219
+ const t = (data.task ?? data);
220
+ ctx.out.log(`${ctx.out.green("✓")} Completed ${ctx.out.bold(String(t.key ?? args.task_id))}${t.title ? ` ${String(t.title)}` : ""}`);
221
+ });
222
+ },
223
+ });
224
+ export const tasksDelete = simpleTaskAction("tasks delete", "delete_task", "Move a task to the Trash (asks for confirmation; restorable).", "Deleted");
225
+ export const tasksRestore = simpleTaskAction("tasks restore", "restore_task", "Bring a task back from the Trash.", "Restored");
226
+ export const tasksComment = cmd({
227
+ path: "tasks comment",
228
+ group: "Tasks",
229
+ summary: 'Post a comment on a task: tasks comment #12 "text" (or pipe it in).',
230
+ tool: "add_task_comment",
231
+ positionals: [TASK_POSITIONAL, { name: "text", description: "Comment body (omit to read stdin; @file.md reads a file)." }],
232
+ examples: ['scribeclock tasks comment #731 "Domain purchased, DNS propagating."', "git log -1 --format=%B | scribeclock tasks comment #731"],
233
+ async run(ctx, inv) {
234
+ let body = readMaybeFile(inv.positionals.slice(1).join(" ")) ?? "";
235
+ if (!body.trim()) {
236
+ if (ctx.stdin.isTTY)
237
+ throw usageError("Comment text is required.", 'Example: scribeclock tasks comment #12 "Done with the API part"');
238
+ body = readFileSync(0, "utf8").trim();
239
+ }
240
+ if (!body)
241
+ throw usageError("Comment text is empty.");
242
+ const data = await ctx.call("add_task_comment", { task_id: taskKey(ctx, inv.positionals[0]), body });
243
+ return emit(ctx, data, () => ctx.out.log(`${ctx.out.green("✓")} Comment posted on ${String(data.task?.key ?? data.task_key ?? inv.positionals[0])}.`));
244
+ },
245
+ });
246
+ export const tasksComments = cmd({
247
+ path: "tasks comments",
248
+ group: "Tasks",
249
+ summary: "The comment thread of a task, oldest first.",
250
+ tool: "list_task_comments",
251
+ positionals: [TASK_POSITIONAL],
252
+ flags: [{ name: "limit", alias: "n", type: "number", description: "Max comments (default 100)." }],
253
+ async run(ctx, inv) {
254
+ const data = await ctx.call("list_task_comments", { task_id: taskKey(ctx, inv.positionals[0]), limit: num(inv.flags.limit) });
255
+ return emit(ctx, data, () => {
256
+ const comments = (data.comments ?? []);
257
+ if (!comments.length)
258
+ ctx.out.log(ctx.out.dim("No comments yet."));
259
+ for (const c of comments) {
260
+ ctx.out.log(`${ctx.out.cyan(personName(c.author ?? c.created_by ?? c.user))} ${ctx.out.dim(fmtDate(String(c.created_at ?? ""), { time: true }))}`);
261
+ for (const line of String(c.body ?? c.content ?? c.note ?? "").split("\n"))
262
+ ctx.out.log(` ${line}`);
263
+ ctx.out.log("");
264
+ }
265
+ });
266
+ },
267
+ });
268
+ export const tasksAssign = cmd({
269
+ path: "tasks assign",
270
+ group: "Tasks",
271
+ summary: "Set, add or remove assignees: tasks assign #12 <who> [--add|--remove].",
272
+ tool: "assign_task",
273
+ positionals: [TASK_POSITIONAL, { name: "who", required: true, description: "me, a name, an email, a uuid, or unassigned." }],
274
+ flags: [
275
+ { name: "add", type: "boolean", description: "Add as a co-assignee instead of replacing the owner." },
276
+ { name: "remove", type: "boolean", description: "Remove this person." },
277
+ ],
278
+ async run(ctx, inv) {
279
+ const who = requirePositional(inv, 1, "An assignee", "scribeclock tasks assign #12 me");
280
+ const data = await ctx.call("assign_task", { task_id: taskKey(ctx, inv.positionals[0]), assignee: who === "unassigned" ? null : who, mode: inv.flags.add ? "add" : inv.flags.remove ? "remove" : "replace" });
281
+ return emit(ctx, data, () => {
282
+ const t = (data.task ?? data);
283
+ ctx.out.log(`${ctx.out.green("✓")} ${String(t.key ?? inv.positionals[0])} → ${t.assignees?.length ? t.assignees.map(personName).join(", ") : personName(t.assignee) || "unassigned"}`);
284
+ });
285
+ },
286
+ });
287
+ export const tasksAttach = cmd({
288
+ path: "tasks attach",
289
+ group: "Tasks",
290
+ summary: "Upload a screenshot or file and attach it to a task.",
291
+ tool: "attach_file_to_task",
292
+ positionals: [TASK_POSITIONAL, { name: "file", required: true, description: "Path of the file to upload (≤ 5 MB in one call; larger files are sent in parts)." }],
293
+ flags: [{ name: "comment", alias: "m", type: "string", description: "Comment to post with the attachment." }, { name: "name", type: "string", description: "File name to store (default: the file's name)." }],
294
+ examples: ["scribeclock tasks attach #731 ./screenshots/live-site.png -m 'Site live with SSL'"],
295
+ async run(ctx, inv) {
296
+ const path = requirePositional(inv, 1, "A file path", "scribeclock tasks attach #12 screenshot.png");
297
+ const bytes = readFileSync(path);
298
+ const filename = str(inv.flags.name) ?? basename(path);
299
+ const task_id = taskKey(ctx, inv.positionals[0]);
300
+ const PART = 4 * 1024 * 1024;
301
+ if (bytes.length <= 5 * 1024 * 1024) {
302
+ const data = await ctx.call("attach_file_to_task", { task_id, filename, content_base64: bytes.toString("base64"), comment: str(inv.flags.comment) });
303
+ return emit(ctx, data, () => ctx.out.log(`${ctx.out.green("✓")} Attached ${filename} (${(bytes.length / 1024).toFixed(0)} KB) to ${task_id}.`));
304
+ }
305
+ const parts = Math.ceil(bytes.length / PART);
306
+ const upload_id = `cli-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
307
+ let data = {};
308
+ for (let i = 0; i < parts; i++) {
309
+ ctx.out.info(ctx.out.dim(`Uploading part ${i + 1}/${parts}…`));
310
+ data = await ctx.call("attach_file_to_task", { task_id, filename, upload_id, part: i + 1, parts, content_base64: bytes.subarray(i * PART, (i + 1) * PART).toString("base64"), comment: i === parts - 1 ? str(inv.flags.comment) : undefined });
311
+ }
312
+ return emit(ctx, data, () => ctx.out.log(`${ctx.out.green("✓")} Attached ${filename} (${(bytes.length / 1024 / 1024).toFixed(1)} MB, ${parts} parts) to ${task_id}.`));
313
+ },
314
+ });
315
+ export const tasksMatch = cmd({
316
+ path: "tasks match",
317
+ group: "Tasks",
318
+ summary: "Which of my tasks does this work belong to? tasks match \"what I did\".",
319
+ tool: "match_my_tasks",
320
+ positionals: [{ name: "work", required: true, description: "A sentence about the work." }],
321
+ async run(ctx, inv) {
322
+ const work = inv.positionals.join(" ");
323
+ if (!work)
324
+ throw usageError("Describe the work.", 'Example: scribeclock tasks match "fixed the login redirect loop"');
325
+ const data = await ctx.call("match_my_tasks", { work, repo: repoRef(ctx.cwd) ?? undefined });
326
+ return emit(ctx, data, () => {
327
+ const matches = (data.matches ?? []);
328
+ const confident = data.confident_match;
329
+ if (confident)
330
+ ctx.out.log(`${ctx.out.green("Confident match:")} ${String(confident.key)} ${String(confident.title)}`);
331
+ if (!matches.length)
332
+ ctx.out.log(ctx.out.dim("No assigned task looks related."));
333
+ else
334
+ ctx.out.table(["score", "key", "title", "status"], matches.map((m) => [m.score, m.key, m.title, statusPaint(ctx, m.status)]));
335
+ });
336
+ },
337
+ });
338
+ export const tasksRepo = cmd({
339
+ path: "tasks repo",
340
+ group: "Tasks",
341
+ summary: "Tasks for the repository you are in: mine, teammates' in-progress work, unassigned.",
342
+ tool: "get_repo_tasks",
343
+ flags: [{ name: "repo", type: "string", description: "Repository (default: this folder's origin)." }, { name: "limit", alias: "n", type: "number", description: "Per section (default 25)." }],
344
+ async run(ctx, inv) {
345
+ const repo = str(inv.flags.repo) ?? repoRef(ctx.cwd) ?? undefined;
346
+ const data = await ctx.call("get_repo_tasks", { repo, limit: num(inv.flags.limit) });
347
+ return emit(ctx, data, () => {
348
+ const r = data.repo;
349
+ ctx.out.log(`${ctx.out.bold("Repository")} ${String(r?.full_name ?? r?.name ?? repo ?? "(none)")}${r?.matched === false ? ctx.out.dim(" — not linked to any project") : ""}`);
350
+ const projects = (data.matched_projects ?? []);
351
+ if (projects.length)
352
+ ctx.out.log(ctx.out.dim(`Projects: ${projects.map((p) => String(p.name)).join(", ")}`));
353
+ const section = (title, tasks) => {
354
+ const arr = Array.isArray(tasks) ? tasks : Array.isArray(tasks?.tasks) ? tasks.tasks : [];
355
+ ctx.out.log("");
356
+ ctx.out.log(ctx.out.bold(`${title} (${arr.length})`));
357
+ renderTaskTable(ctx, arr, { showProject: true });
358
+ };
359
+ section("My tasks", data.my_tasks);
360
+ section("Teammates in progress", data.teammates_in_progress);
361
+ section("Teammates queued", data.teammates_queued);
362
+ section("Unassigned", data.unassigned);
363
+ section("Recently completed", data.recently_completed);
364
+ if (typeof data.hint === "string")
365
+ ctx.out.info(ctx.out.dim(data.hint));
366
+ });
367
+ },
368
+ });
369
+ export const tasksWorkload = cmd({
370
+ path: "tasks workload",
371
+ group: "Tasks",
372
+ summary: "Who is working on what: in progress, queued and overdue per person, plus the unassigned backlog.",
373
+ tool: "get_workload",
374
+ flags: [PROJECT_FLAG, { name: "unassigned", alias: "n", type: "number", description: "How many unassigned tasks to list (default 30)." }],
375
+ async run(ctx, inv) {
376
+ const data = await ctx.call("get_workload", { project_id: await projectIdArg(ctx, inv), unassigned_limit: num(inv.flags.unassigned) });
377
+ return emit(ctx, data, () => {
378
+ const people = (data.people ?? []);
379
+ ctx.out.table(["person", "in progress", "queued", "overdue", "working on"], people.map((p) => [personName(p.assignee) + (p.is_me ? ctx.out.dim(" (me)") : ""), p.in_progress_count ?? p.in_progress?.length ?? 0, p.pending_count ?? 0, Number(p.overdue_count ?? 0) ? ctx.out.red(String(p.overdue_count)) : "0", (p.in_progress ?? []).map((t) => String(t.key)).join(", ")]));
380
+ const un = data.unassigned;
381
+ if (un) {
382
+ ctx.out.log("");
383
+ ctx.out.log(ctx.out.bold(`Unassigned backlog (${String(un.count)})`));
384
+ renderTaskTable(ctx, (un.tasks ?? []), { showAssignee: false, showProject: false });
385
+ }
386
+ });
387
+ },
388
+ });
389
+ export const tasksStale = cmd({
390
+ path: "tasks stale",
391
+ group: "Tasks",
392
+ summary: "Open tasks nobody has touched for N days (default 14) — for nightly reports.",
393
+ tool: "list_tasks",
394
+ flags: [{ name: "days", alias: "d", type: "number", description: "Idle threshold in days.", default: 14 }, PROJECT_FLAG, { name: "assignee", alias: "a", type: "string", description: "Only this person's tasks." }, { name: "limit", alias: "n", type: "number", description: "Max tasks to scan (default 200)." }],
395
+ examples: ["scribeclock tasks stale --days 21", "scribeclock tasks stale --json | jq -r '.tasks[] | \"\\(.key) \\(.title)\"'"],
396
+ async run(ctx, inv) {
397
+ const days = Number(inv.flags.days ?? 14);
398
+ const cutoff = new Date(ctx.now().getTime() - days * 86400000);
399
+ const data = await ctx.call("list_tasks", { status: "open", order_by: "updated", limit: num(inv.flags.limit) ?? 200, project_id: await projectIdArg(ctx, inv), assignee: str(inv.flags.assignee) });
400
+ const stale = (data.tasks ?? []).filter((t) => t.updated_at && new Date(String(t.updated_at)) < cutoff);
401
+ const result = { ok: true, days, cutoff: cutoff.toISOString(), count: stale.length, scanned: (data.tasks ?? []).length, tasks: stale };
402
+ return emit(ctx, result, () => {
403
+ ctx.out.log(ctx.out.bold(`${plural(stale.length, "stale task")} (no update for ${days}+ days)`));
404
+ renderTaskTable(ctx, stale);
405
+ for (const t of stale.slice(0, 0))
406
+ void t;
407
+ if (stale.length)
408
+ ctx.out.info(ctx.out.dim(`Oldest: ${relTime(String(stale[stale.length - 1].updated_at), ctx.now())}`));
409
+ });
410
+ },
411
+ });
412
+ export const membersCommand = cmd({
413
+ path: "members",
414
+ group: "Team",
415
+ summary: "People in the organization with their open / in-progress counts.",
416
+ tool: "list_members",
417
+ positionals: [{ name: "query", description: "Filter by name, email or job title." }],
418
+ flags: [{ name: "limit", alias: "n", type: "number", description: "Max members (default 200)." }],
419
+ async run(ctx, inv) {
420
+ const data = await ctx.call("list_members", { query: inv.positionals.join(" ") || undefined, limit: num(inv.flags.limit) });
421
+ return emit(ctx, data, () => {
422
+ const members = (data.members ?? []);
423
+ ctx.out.table(["name", "email", "role", "title", "open", "in progress"], members.map((m) => [String(m.name ?? "") + (m.is_me ? ctx.out.dim(" (me)") : ""), m.email, m.role, m.job_title, m.open_count, m.in_progress_count]));
424
+ });
425
+ },
426
+ });
427
+ /** `progress`: report started / progress / completed / blocked with evidence from git. */
428
+ export const progressCommand = cmd({
429
+ path: "progress",
430
+ group: "Daily workflow",
431
+ summary: 'Report progress on a task with evidence: progress #12 "what I did" [--done|--blocked|--started].',
432
+ description: "Posts a structured update on one of your tasks (report_task_progress). In a git repository the last commit, branch and changed files are attached as evidence automatically and the same note is stored as a team handoff. `--done` marks the task completed (evidence required). The task defaults to the one named in the branch or last commit.",
433
+ tool: "report_task_progress",
434
+ positionals: [{ name: "task", description: "Task key, uuid, or auto (default: from git)." }, { name: "summary", description: "What was done, 1–4 sentences (or --summary / stdin)." }],
435
+ flags: [
436
+ { name: "summary", alias: "m", type: "string", description: "The update text (alternative to the positional)." },
437
+ { name: "done", type: "boolean", description: "outcome=completed (marks the task done; evidence required)." },
438
+ { name: "blocked", type: "boolean", description: "outcome=blocked." },
439
+ { name: "started", type: "boolean", description: "outcome=started (moves it to in progress)." },
440
+ { name: "evidence", alias: "e", type: "array", placeholder: "<url|text>", description: "Extra evidence: https links become link/PR/commit items, other text becomes a note." },
441
+ { name: "remaining", alias: "r", type: "array", description: "What is still needed." },
442
+ { name: "next", type: "array", description: "Next steps, in order." },
443
+ { name: "no-git", type: "boolean", description: "Do not attach commit / branch / files from the repository." },
444
+ { name: "force", type: "boolean", description: "Reopen a completed or cancelled task (only when asked)." },
445
+ ],
446
+ examples: ['scribeclock progress #731 "Domain bought, DNS pointed at Pages; waiting for SSL."', 'scribeclock progress auto "Finished the Stripe webhook" --done -e https://github.com/o/r/pull/12', 'scribeclock progress 59 "Need the device logs" --blocked -r "logs from QA"'],
447
+ async run(ctx, inv) {
448
+ const first = inv.positionals[0];
449
+ const looksLikeTask = first !== undefined && (/^#?\d{1,6}$/.test(first) || /^[0-9a-f-]{36}$/i.test(first) || first === "auto");
450
+ const task_id = taskKey(ctx, looksLikeTask ? first : undefined);
451
+ let summary = str(inv.flags.summary) ?? inv.positionals.slice(looksLikeTask ? 1 : 0).join(" ");
452
+ if (!summary && !ctx.stdin.isTTY)
453
+ summary = readFileSync(0, "utf8").trim();
454
+ if (!summary || summary.length < 10)
455
+ throw usageError("Describe what was done (at least 10 characters).", 'Example: scribeclock progress #12 "Implemented the export endpoint and tests."');
456
+ const outcome = inv.flags.done ? "completed" : inv.flags.blocked ? "blocked" : inv.flags.started ? "started" : "progress";
457
+ const evidence = [];
458
+ for (const e of list(inv.flags.evidence) ?? []) {
459
+ if (/^https?:\/\//.test(e))
460
+ evidence.push({ kind: /\/pull\/\d+/.test(e) ? "pull_request" : /\/commit\//.test(e) ? "commit" : "link", url: e, title: e.replace(/^https?:\/\//, "").slice(0, 80) });
461
+ else
462
+ evidence.push({ kind: "note", detail: e });
463
+ }
464
+ const args = { task_id, outcome, summary, evidence, remaining: list(inv.flags.remaining), next_steps: list(inv.flags.next), force: bool(inv.flags.force) };
465
+ const repo = inv.flags["no-git"] ? null : repoRef(ctx.cwd);
466
+ if (repo) {
467
+ const commit = lastCommit(ctx.cwd);
468
+ const slug = githubSlug(remoteUrl(ctx.cwd));
469
+ if (commit)
470
+ evidence.push({ kind: "commit", title: commit.message.slice(0, 80), detail: `${commit.sha.slice(0, 10)} ${commit.message}`.slice(0, 300), url: slug ? `https://github.com/${slug}/commit/${commit.sha}` : undefined });
471
+ args.repo = repo;
472
+ args.branch = currentBranch(ctx.cwd) ?? undefined;
473
+ args.files_changed = changedFiles(ctx.cwd);
474
+ args.last_commit = commit ? { sha: commit.sha, message: commit.message.slice(0, 300) } : undefined;
475
+ args.git = gitState(ctx.cwd);
476
+ }
477
+ if (outcome === "completed" && !evidence.length)
478
+ throw usageError("--done needs evidence.", "Add -e <PR or commit url>, -e 'tests passed: 41/41', or run inside the repository so the last commit is attached.");
479
+ if (!evidence.length)
480
+ delete args.evidence;
481
+ const data = await ctx.call("report_task_progress", args);
482
+ return emit(ctx, data, () => {
483
+ const t = (data.task ?? {});
484
+ ctx.out.log(`${ctx.out.green("✓")} ${outcome} reported on ${ctx.out.bold(String(t.key ?? task_id))}${t.title ? ` ${String(t.title)}` : ""}${t.status ? ` · ${statusPaint(ctx, t.status)}` : ""}`);
485
+ if (evidence.length)
486
+ ctx.out.info(ctx.out.dim(`${plural(evidence.length, "evidence item")} attached${repo ? `; handoff stored for ${repo}` : ""}.`));
487
+ if (typeof data.hint === "string")
488
+ ctx.out.info(ctx.out.dim(data.hint));
489
+ });
490
+ },
491
+ });
492
+ export const TASK_COMMANDS = [tasksList, tasksShow, tasksCreate, tasksUpdate, tasksClaim, tasksRelease, tasksComplete, tasksComment, tasksComments, tasksAssign, tasksAttach, tasksDelete, tasksRestore, tasksMatch, tasksRepo, tasksWorkload, tasksStale, membersCommand, progressCommand];