scribeclock 0.0.0-stage → 0.1.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,525 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { CliError, usageError } from "../cli/errors.js";
4
+ import { changedFiles, commitsSince, currentBranch, detectTaskKey, gitState, gitUserEmail, githubSlug, lastCommit, remoteUrl, repoRef } from "../git.js";
5
+ import { EXIT } from "../types.js";
6
+ import { endOfDay, fmtDate, fmtDuration, fmtHours, parseWhen, plural, priorityRank, startOfDay, startOfMonth, startOfWeek, toCsv } from "../util/format.js";
7
+ import { PROJECT_FLAG, bool, cmd, emit, list, num, personName, projectIdArg, renderTaskDetail, renderTaskTable, statusPaint, str, taskKey } from "./_shared.js";
8
+ import { renderClockStatus } from "./clock.js";
9
+ /** Calls that are nice-to-have (a missing scope must not break the whole screen). */
10
+ async function optional(ctx, tool, args) {
11
+ try {
12
+ return await ctx.call(tool, args);
13
+ }
14
+ catch (err) {
15
+ if (err instanceof CliError && err.code === EXIT.SCOPE) {
16
+ ctx.out.info(ctx.out.dim(`(${tool} skipped: ${err.message})`));
17
+ return null;
18
+ }
19
+ throw err;
20
+ }
21
+ }
22
+ async function viewerId(ctx) {
23
+ const v = await ctx.call("get_viewer", {}, { skipOrg: true });
24
+ const viewer = (v.viewer ?? {});
25
+ return { id: typeof viewer.id === "string" ? viewer.id : null, name: typeof viewer.name === "string" ? viewer.name : null, email: typeof viewer.email === "string" ? viewer.email : null };
26
+ }
27
+ export const todayCommand = cmd({
28
+ path: "today",
29
+ group: "Daily workflow",
30
+ summary: "One screen: running timer, what's in progress, due today / overdue, your next meeting.",
31
+ tool: ["clock_status", "list_tasks", "list_meetings"],
32
+ flags: [{ name: "all", type: "boolean", description: "Include every open task, not only in-progress / due soon." }],
33
+ async run(ctx, inv) {
34
+ const now = ctx.now();
35
+ const [clock, tasks, meetings] = await Promise.all([
36
+ ctx.call("clock_status", {}, { skipOrg: true }),
37
+ ctx.call("list_tasks", { assignee: "me", status: "open", order_by: "due", limit: 200 }),
38
+ optional(ctx, "list_meetings", { since: startOfDay(now).toISOString(), until: endOfDay(now).toISOString(), limit: 20 }),
39
+ ]);
40
+ const all = (tasks.tasks ?? []);
41
+ const inProgress = all.filter((t) => t.status === "in-progress");
42
+ const dueSoon = all.filter((t) => t.status !== "in-progress" && t.due_date && new Date(String(t.due_date)) < endOfDay(now));
43
+ const rest = all.filter((t) => !inProgress.includes(t) && !dueSoon.includes(t));
44
+ const todays = (meetings?.meetings ?? []).sort((a, b) => String(a.started_at ?? "").localeCompare(String(b.started_at ?? "")));
45
+ const next = todays.find((m) => m.status === "scheduled" || m.status === "live" || new Date(String(m.started_at ?? 0)) > now) ?? null;
46
+ const result = { ok: true, date: fmtDate(now.toISOString()), clock, in_progress: inProgress, due: dueSoon, other_open: rest.length, next_meeting: next, meetings_today: todays };
47
+ return emit(ctx, result, () => {
48
+ ctx.out.log(ctx.out.bold(`Today · ${fmtDate(now.toISOString())}`));
49
+ renderClockStatus(ctx, clock);
50
+ ctx.out.log("");
51
+ ctx.out.log(ctx.out.bold(`In progress (${inProgress.length})`));
52
+ renderTaskTable(ctx, inProgress, { showAssignee: false });
53
+ ctx.out.log("");
54
+ ctx.out.log(ctx.out.bold(`Due today or overdue (${dueSoon.length})`));
55
+ renderTaskTable(ctx, dueSoon, { showAssignee: false });
56
+ if (inv.flags.all && rest.length) {
57
+ ctx.out.log("");
58
+ ctx.out.log(ctx.out.bold(`Other open (${rest.length})`));
59
+ renderTaskTable(ctx, rest, { showAssignee: false });
60
+ }
61
+ else if (rest.length)
62
+ ctx.out.log(ctx.out.dim(` + ${rest.length} other open task${rest.length === 1 ? "" : "s"} (--all)`));
63
+ if (meetings) {
64
+ ctx.out.log("");
65
+ if (next)
66
+ ctx.out.log(`${ctx.out.bold("Next meeting")} ${fmtDate(String(next.started_at), { time: true })} ${String(next.title ?? "")}${next.status === "live" ? ctx.out.green(" (live)") : ""}`);
67
+ else
68
+ ctx.out.log(ctx.out.dim(`No more meetings today${todays.length ? ` (${todays.length} earlier)` : ""}.`));
69
+ }
70
+ if (!inProgress.length && all.length)
71
+ ctx.out.info(ctx.out.dim("Nothing in progress — `scribeclock next` suggests what to pick up."));
72
+ });
73
+ },
74
+ });
75
+ export const nextCommand = cmd({
76
+ path: "next",
77
+ group: "Daily workflow",
78
+ summary: "Suggest the next task to pick up (in progress first, then priority, due date, age).",
79
+ tool: "list_tasks",
80
+ flags: [{ name: "count", alias: "n", type: "number", description: "How many suggestions (default 5).", default: 5 }, { name: "include-unassigned", alias: "u", type: "boolean", description: "Also consider unassigned tasks." }, PROJECT_FLAG],
81
+ async run(ctx, inv) {
82
+ const now = ctx.now();
83
+ const mine = await ctx.call("list_tasks", { assignee: "me", status: "open", order_by: "priority", limit: 200, project_id: await projectIdArg(ctx, inv) });
84
+ const free = inv.flags["include-unassigned"] ? await ctx.call("list_tasks", { assignee: "unassigned", status: "open", order_by: "priority", limit: 100, project_id: await projectIdArg(ctx, inv) }) : null;
85
+ const candidates = [...(mine.tasks ?? []).map((t) => ({ t, mine: true })), ...((free?.tasks ?? []).map((t) => ({ t, mine: false })))];
86
+ const score = (t, isMine) => {
87
+ let s = 0;
88
+ const reasons = [];
89
+ if (t.status === "in-progress") {
90
+ s += 100;
91
+ reasons.push("already in progress");
92
+ }
93
+ if (isMine)
94
+ s += 20;
95
+ else
96
+ reasons.push("unassigned");
97
+ const pr = priorityRank(t.priority);
98
+ s += (3 - pr) * 10;
99
+ if (t.priority === "high")
100
+ reasons.push("high priority");
101
+ if (t.due_date) {
102
+ const days = (new Date(String(t.due_date)).getTime() - now.getTime()) / 86400000;
103
+ if (days < 0) {
104
+ s += 40;
105
+ reasons.push(`overdue ${Math.ceil(-days)}d`);
106
+ }
107
+ else if (days < 1) {
108
+ s += 30;
109
+ reasons.push("due today");
110
+ }
111
+ else if (days < 3) {
112
+ s += 15;
113
+ reasons.push("due soon");
114
+ }
115
+ }
116
+ if (t.created_at)
117
+ s += Math.min(10, (now.getTime() - new Date(String(t.created_at)).getTime()) / (7 * 86400000));
118
+ return { score: Math.round(s), reasons };
119
+ };
120
+ const ranked = candidates.map(({ t, mine: m }) => ({ ...score(t, m), task: t })).sort((a, b) => b.score - a.score).slice(0, Number(inv.flags.count ?? 5));
121
+ return emit(ctx, { ok: true, suggestions: ranked.map((r) => ({ key: r.task.key, title: r.task.title, score: r.score, reasons: r.reasons, task: r.task })) }, () => {
122
+ if (!ranked.length) {
123
+ ctx.out.log(ctx.out.dim("No open tasks assigned to you. Try --include-unassigned."));
124
+ return;
125
+ }
126
+ ranked.forEach((r, i) => ctx.out.log(`${ctx.out.bold(`${i + 1}. ${String(r.task.key)}`)} ${String(r.task.title)} ${ctx.out.dim(`— ${r.reasons.join(", ") || "next in your queue"}${r.task.project?.name ? ` · ${r.task.project.name}` : ""}`)}`));
127
+ ctx.out.info(ctx.out.dim(`Start one: scribeclock start ${String(ranked[0].task.key)}`));
128
+ });
129
+ },
130
+ });
131
+ export const startCommand = cmd({
132
+ path: "start",
133
+ group: "Daily workflow",
134
+ summary: "Claim a task, clock in against it and print its context, in one step.",
135
+ tool: ["claim_task", "clock_in", "get_task"],
136
+ positionals: [{ name: "task", required: true, description: "Task key (#123), uuid, or auto." }],
137
+ flags: [
138
+ { name: "comment", alias: "m", type: "string", description: "Note to post when claiming." },
139
+ { name: "force", type: "boolean", description: "Take over someone else's task." },
140
+ { name: "no-clock", type: "boolean", description: "Do not clock in." },
141
+ { name: "branch", type: "boolean", description: "Also create a git branch named from the task (off by default; this repo works on main)." },
142
+ ],
143
+ examples: ["scribeclock start #731", "scribeclock start 731 -m 'Taking this today'"],
144
+ async run(ctx, inv) {
145
+ const task_id = taskKey(ctx, inv.positionals[0], { allowAuto: false });
146
+ const claimed = await ctx.call("claim_task", { task_id, comment: str(inv.flags.comment), force: bool(inv.flags.force) });
147
+ const task = (claimed.task ?? {});
148
+ const key = String(task.key ?? task_id);
149
+ ctx.out.info(`${ctx.out.green("✓")} Claimed ${ctx.out.bold(key)}${task.title ? ` ${String(task.title)}` : ""}`);
150
+ let clock = null;
151
+ if (!inv.flags["no-clock"]) {
152
+ clock = await ctx.call("clock_in", { task_id: key.startsWith("#") ? key : task_id }, { skipOrg: true });
153
+ if (clock.already_clocked_in)
154
+ ctx.out.info(ctx.out.dim(`Already clocked in since ${fmtDate(String(clock.started_at), { time: true })}.`));
155
+ else
156
+ ctx.out.info(`${ctx.out.green("●")} Clocked in${clock.project_name ? ` · ${String(clock.project_name)}` : ""}${clock.task_key ? ` · ${String(clock.task_key)}` : ""}`);
157
+ }
158
+ let branch = null;
159
+ if (inv.flags.branch) {
160
+ const { branchNameFor } = await import("./gitflow.js");
161
+ branch = branchNameFor(ctx, { key, title: String(task.title ?? "") });
162
+ const r = spawnSync("git", ["switch", "-c", branch], { cwd: ctx.cwd, encoding: "utf8" });
163
+ if (r.status !== 0)
164
+ ctx.out.warn(`Could not create branch ${branch}: ${(r.stderr || r.stdout).trim()}`);
165
+ else
166
+ ctx.out.info(`${ctx.out.green("✓")} On branch ${branch}`);
167
+ }
168
+ const detail = await ctx.call("get_task", { task_id: key.startsWith("#") ? key : task_id, comment_limit: 10 });
169
+ if (ctx.flags.json) {
170
+ ctx.out.jsonOut({ ok: true, claimed, clock, branch, task: detail.task, comments: detail.comments });
171
+ return EXIT.OK;
172
+ }
173
+ ctx.out.log("");
174
+ renderTaskDetail(ctx, detail);
175
+ return EXIT.OK;
176
+ },
177
+ });
178
+ export const inboxCommand = cmd({
179
+ path: "inbox",
180
+ group: "Daily workflow",
181
+ summary: "What is waiting on you: new assignments and comments on your tasks since you last looked.",
182
+ tool: ["list_tasks", "list_task_comments"],
183
+ flags: [
184
+ { name: "since", type: "string", placeholder: "<when>", description: "Look back to this time (default: last `inbox --mark-read`, else 7 days)." },
185
+ { name: "mark-read", type: "boolean", description: "Remember now as the last time you looked." },
186
+ { name: "limit", alias: "n", type: "number", description: "Tasks to scan (default 60)." },
187
+ ],
188
+ async run(ctx, inv) {
189
+ const now = ctx.now();
190
+ const sinceRaw = str(inv.flags.since);
191
+ const since = sinceRaw ? parseWhen(sinceRaw, now) : ctx.config.read().inbox_last_seen ? new Date(ctx.config.read().inbox_last_seen) : new Date(now.getTime() - 7 * 86400000);
192
+ if (!since)
193
+ throw usageError(`Could not understand --since "${sinceRaw}".`);
194
+ const me = await viewerId(ctx);
195
+ const tasks = await ctx.call("list_tasks", { assignee: "me", status: "all", updated_since: since.toISOString(), order_by: "updated", limit: num(inv.flags.limit) ?? 60 });
196
+ const changed = (tasks.tasks ?? []);
197
+ const assignments = changed.filter((t) => t.assigned_at && new Date(String(t.assigned_at)) >= since && t.assigned_by?.id !== me.id);
198
+ const commentItems = [];
199
+ const withComments = changed.filter((t) => Number(t.comment_count ?? 0) > 0).slice(0, 25);
200
+ const threads = await Promise.all(withComments.map((t) => ctx.call("list_task_comments", { task_id: String(t.id), limit: 30 }).catch(() => null)));
201
+ threads.forEach((thread, i) => {
202
+ for (const c of (thread?.comments ?? [])) {
203
+ const author = (c.author ?? c.created_by ?? c.user);
204
+ if (new Date(String(c.created_at ?? 0)) >= since && author?.id !== me.id)
205
+ commentItems.push({ task: withComments[i], comment: c });
206
+ }
207
+ });
208
+ commentItems.sort((a, b) => String(b.comment.created_at).localeCompare(String(a.comment.created_at)));
209
+ const mentions = commentItems.filter((c) => me.name && String(c.comment.body ?? c.comment.content ?? "").toLowerCase().includes(`@${me.name.toLowerCase().split(" ")[0]}`));
210
+ if (inv.flags["mark-read"])
211
+ ctx.config.write({ inbox_last_seen: now.toISOString() });
212
+ const result = { ok: true, since: since.toISOString(), assignments, comments: commentItems.map((c) => ({ task_key: c.task.key, task_title: c.task.title, ...c.comment })), mentions: mentions.length, changed_tasks: changed.length };
213
+ return emit(ctx, result, () => {
214
+ ctx.out.log(ctx.out.bold(`Inbox since ${fmtDate(since.toISOString(), { time: true })}`));
215
+ ctx.out.log("");
216
+ ctx.out.log(ctx.out.bold(`New assignments (${assignments.length})`));
217
+ renderTaskTable(ctx, assignments, { showAssignee: false });
218
+ ctx.out.log("");
219
+ ctx.out.log(ctx.out.bold(`Comments on your tasks (${commentItems.length}${mentions.length ? `, ${mentions.length} mention you` : ""})`));
220
+ if (!commentItems.length)
221
+ ctx.out.log(ctx.out.dim(" (none)"));
222
+ for (const c of commentItems.slice(0, 30)) {
223
+ ctx.out.log(` ${ctx.out.cyan(String(c.task.key))} ${personName(c.comment.author ?? c.comment.created_by ?? c.comment.user)} ${ctx.out.dim(fmtDate(String(c.comment.created_at), { time: true }))}`);
224
+ ctx.out.log(` ${String(c.comment.body ?? c.comment.content ?? "").split("\n")[0].slice(0, 160)}`);
225
+ }
226
+ if (!inv.flags["mark-read"])
227
+ ctx.out.info(ctx.out.dim("Add --mark-read to start the next inbox from now."));
228
+ });
229
+ },
230
+ });
231
+ export const standupCommand = cmd({
232
+ path: "standup",
233
+ group: "Daily workflow",
234
+ summary: "Yesterday / today / blockers from your task updates, commits and meetings — paste it into Slack.",
235
+ tool: ["list_tasks", "list_task_comments", "list_meetings", "clock_status"],
236
+ flags: [
237
+ { name: "since", type: "string", placeholder: "<when>", description: "Start of 'yesterday' (default: yesterday 00:00; use 3d after a weekend)." },
238
+ { name: "no-git", type: "boolean", description: "Skip commits from the current repository." },
239
+ { name: "plain", type: "boolean", description: "No Markdown bullets/bold (for plain-text chats)." },
240
+ ],
241
+ examples: ["scribeclock standup", "scribeclock standup --since 3d | pbcopy"],
242
+ async run(ctx, inv) {
243
+ const now = ctx.now();
244
+ const since = str(inv.flags.since) ? parseWhen(String(inv.flags.since), now) : (() => {
245
+ const d = startOfDay(now);
246
+ d.setDate(d.getDate() - 1);
247
+ return d;
248
+ })();
249
+ if (!since)
250
+ throw usageError(`Could not understand --since "${String(inv.flags.since)}".`);
251
+ const me = await viewerId(ctx);
252
+ const [changed, open, meetings] = await Promise.all([
253
+ ctx.call("list_tasks", { assignee: "me", status: "all", updated_since: since.toISOString(), order_by: "updated", limit: 100 }),
254
+ ctx.call("list_tasks", { assignee: "me", status: "open", order_by: "due", limit: 100 }),
255
+ optional(ctx, "list_meetings", { since: since.toISOString(), until: now.toISOString(), limit: 20 }),
256
+ ]);
257
+ const changedTasks = (changed.tasks ?? []);
258
+ const openTasks = (open.tasks ?? []);
259
+ const completed = changedTasks.filter((t) => t.status === "completed" && t.completed_at && new Date(String(t.completed_at)) >= since);
260
+ const worked = changedTasks.filter((t) => !completed.includes(t) && (t.status === "in-progress" || (t.started_at && new Date(String(t.started_at)) >= since)));
261
+ // My own notes since then (progress reports, comments) say what happened in my words.
262
+ const threads = await Promise.all(changedTasks.filter((t) => Number(t.comment_count ?? 0) > 0).slice(0, 20).map(async (t) => ({ t, thread: await ctx.call("list_task_comments", { task_id: String(t.id), limit: 20 }).catch(() => null) })));
263
+ const myNotes = [];
264
+ for (const { t, thread } of threads) {
265
+ for (const c of (thread?.comments ?? [])) {
266
+ const author = (c.author ?? c.created_by ?? c.user);
267
+ if (author?.id !== me.id || new Date(String(c.created_at ?? 0)) < since)
268
+ continue;
269
+ const text = String(c.body ?? c.content ?? "");
270
+ myNotes.push({ task: t, text: text.replace(/^Agent update: \w+\s*/i, "").split("\n").find((l) => l.trim()) ?? text, blocked: /blocked/i.test(text) });
271
+ }
272
+ }
273
+ const repo = inv.flags["no-git"] ? null : repoRef(ctx.cwd);
274
+ const commits = repo ? commitsSince(ctx.cwd, since.toISOString(), { author: gitUserEmail(ctx.cwd) ?? me.email ?? undefined, max: 30 }) : [];
275
+ const todays = openTasks.filter((t) => t.status === "in-progress" || (t.due_date && new Date(String(t.due_date)) < endOfDay(now))).slice(0, 8);
276
+ const blockers = [
277
+ ...myNotes.filter((n) => n.blocked).map((n) => `${String(n.task.key)} ${n.text}`),
278
+ ...openTasks.filter((t) => t.is_overdue).map((t) => `${String(t.key)} ${String(t.title)} is overdue (due ${fmtDate(String(t.due_date))})`),
279
+ ];
280
+ const metTitles = (meetings?.meetings ?? []).map((m) => String(m.title ?? "Meeting"));
281
+ const b = inv.flags.plain ? "- " : "• ";
282
+ const bold = (s) => (inv.flags.plain ? s : `*${s}*`);
283
+ const lines = [];
284
+ lines.push(bold(`Yesterday${since.getTime() < startOfDay(now).getTime() - 86400000 ? ` (since ${fmtDate(since.toISOString())})` : ""}`));
285
+ for (const t of completed)
286
+ lines.push(`${b}✅ ${String(t.key)} ${String(t.title)}`);
287
+ for (const n of myNotes.filter((n) => !n.blocked && !completed.includes(n.task)))
288
+ lines.push(`${b}${String(n.task.key)} ${n.text}`);
289
+ for (const t of worked.filter((t) => !myNotes.some((n) => n.task.id === t.id)))
290
+ lines.push(`${b}Worked on ${String(t.key)} ${String(t.title)}`);
291
+ for (const c of commits.slice(0, 8))
292
+ lines.push(`${b}\`${c.short}\` ${c.message}`);
293
+ if (commits.length > 8)
294
+ lines.push(`${b}…and ${commits.length - 8} more commits`);
295
+ for (const m of metTitles)
296
+ lines.push(`${b}Meeting: ${m}`);
297
+ if (lines.length === 1)
298
+ lines.push(`${b}(no task updates, commits or meetings recorded)`);
299
+ lines.push("");
300
+ lines.push(bold("Today"));
301
+ for (const t of todays)
302
+ lines.push(`${b}${String(t.key)} ${String(t.title)}${t.status === "in-progress" ? " (in progress)" : t.due_date ? ` (due ${fmtDate(String(t.due_date))})` : ""}`);
303
+ if (!todays.length)
304
+ lines.push(`${b}Pick up the next task: scribeclock next`);
305
+ lines.push("");
306
+ lines.push(bold("Blockers"));
307
+ for (const bl of blockers.slice(0, 6))
308
+ lines.push(`${b}${bl}`);
309
+ if (!blockers.length)
310
+ lines.push(`${b}None`);
311
+ const text = lines.join("\n");
312
+ if (ctx.flags.json) {
313
+ ctx.out.jsonOut({ ok: true, since: since.toISOString(), yesterday: { completed, notes: myNotes, worked, commits, meetings: metTitles }, today: todays, blockers, text });
314
+ return EXIT.OK;
315
+ }
316
+ ctx.out.log(text);
317
+ return EXIT.OK;
318
+ },
319
+ });
320
+ export const timesheetCommand = cmd({
321
+ path: "timesheet",
322
+ group: "Time",
323
+ summary: "Hours per task, project and person for a week / month / range; --csv or --json for invoicing and payroll.",
324
+ tool: "list_time_entries",
325
+ flags: [
326
+ { name: "week", alias: "w", type: "boolean", description: "This week (Monday to now). Default." },
327
+ { name: "last-week", type: "boolean", description: "Previous Monday–Sunday." },
328
+ { name: "month", type: "boolean", description: "This month." },
329
+ { name: "last-month", type: "boolean", description: "Previous month." },
330
+ { name: "since", type: "string", placeholder: "<when>", description: "Custom start (YYYY-MM-DD, 14d…)." },
331
+ { name: "until", type: "string", placeholder: "<when>", description: "Custom end (exclusive)." },
332
+ { name: "person", type: "string", placeholder: "<me|all|name|email>", description: "Whose time (default me; all = team, for admins/staff)." },
333
+ PROJECT_FLAG,
334
+ { name: "task", alias: "t", type: "string", description: "Only one task." },
335
+ { name: "csv", type: "boolean", description: "CSV of the entries to stdout." },
336
+ { name: "by", type: "string", choices: ["task", "project", "person", "entry"], description: "Which breakdown to print (default: all three summaries)." },
337
+ { name: "entries", alias: "e", type: "boolean", description: "Also list every entry." },
338
+ { name: "closed-only", type: "boolean", description: "Exclude the running shift." },
339
+ ],
340
+ examples: ["scribeclock timesheet --week", "scribeclock timesheet --last-month --person all --csv > october.csv", "scribeclock timesheet --since 2026-10-01 --until 2026-10-16 --project 'Kisa Print' --json"],
341
+ async run(ctx, inv) {
342
+ const now = ctx.now();
343
+ let since;
344
+ let until;
345
+ if (inv.flags["last-week"]) {
346
+ since = startOfWeek(now);
347
+ since.setDate(since.getDate() - 7);
348
+ until = startOfWeek(now);
349
+ }
350
+ else if (inv.flags.month)
351
+ since = startOfMonth(now);
352
+ else if (inv.flags["last-month"]) {
353
+ since = startOfMonth(now);
354
+ since.setMonth(since.getMonth() - 1);
355
+ until = startOfMonth(now);
356
+ }
357
+ else if (inv.flags.since) {
358
+ const s = parseWhen(String(inv.flags.since), now);
359
+ if (!s)
360
+ throw usageError(`Could not understand --since "${String(inv.flags.since)}".`);
361
+ since = s;
362
+ }
363
+ else
364
+ since = startOfWeek(now);
365
+ if (inv.flags.until) {
366
+ const u = parseWhen(String(inv.flags.until), now);
367
+ if (!u)
368
+ throw usageError(`Could not understand --until "${String(inv.flags.until)}".`);
369
+ until = u;
370
+ }
371
+ const data = await ctx.call("list_time_entries", {
372
+ since: since.toISOString(),
373
+ until: until?.toISOString(),
374
+ person: str(inv.flags.person),
375
+ project_id: await projectIdArg(ctx, inv),
376
+ task_id: str(inv.flags.task) ? taskKey(ctx, String(inv.flags.task)) : undefined,
377
+ include_open: inv.flags["closed-only"] ? false : undefined,
378
+ limit: 2000,
379
+ });
380
+ const entries = (data.entries ?? []);
381
+ if (inv.flags.csv) {
382
+ const rows = entries.map((e) => ({
383
+ started_at: e.started_at,
384
+ ended_at: e.ended_at ?? "",
385
+ hours: e.hours,
386
+ seconds: e.seconds,
387
+ person: personName(e.person),
388
+ email: e.person?.email ?? "",
389
+ project: e.project?.name ?? "",
390
+ task_key: e.task?.key ?? "",
391
+ task: e.task?.title ?? "",
392
+ billable: e.is_billable,
393
+ description: e.description ?? "",
394
+ organization: e.organization?.name ?? "",
395
+ }));
396
+ ctx.out.log(toCsv(rows, ["started_at", "ended_at", "hours", "seconds", "person", "email", "project", "task_key", "task", "billable", "description", "organization"]));
397
+ return EXIT.OK;
398
+ }
399
+ return emit(ctx, data, () => {
400
+ const range = `${fmtDate(since.toISOString())} → ${until ? fmtDate(until.toISOString()) : "now"}`;
401
+ ctx.out.log(`${ctx.out.bold("Timesheet")} ${range} · ${ctx.out.bold(fmtHours(Number(data.total_seconds ?? 0)))} h in ${plural(Number(data.count ?? entries.length), "entry", "entries")}${data.truncated ? ctx.out.yellow(" (truncated; narrow the range)") : ""}`);
402
+ const by = str(inv.flags.by);
403
+ const section = (title, rows, cols) => {
404
+ if (!rows.length)
405
+ return;
406
+ ctx.out.log("");
407
+ ctx.out.log(ctx.out.bold(title));
408
+ ctx.out.table(cols.map(([h]) => h), rows.map((r) => cols.map(([, f]) => f(r))));
409
+ };
410
+ if (!by || by === "task")
411
+ section("By task", (data.by_task ?? []), [["task", (r) => r.task_key ?? ""], ["title", (r) => r.title ?? ctx.out.dim("(no task)")], ["project", (r) => r.project_name ?? ""], ["hours", (r) => fmtHours(Number(r.seconds))], ["entries", (r) => r.entries]]);
412
+ if (!by || by === "project")
413
+ section("By project", (data.by_project ?? []), [["project", (r) => r.project_name], ["hours", (r) => fmtHours(Number(r.seconds))], ["entries", (r) => r.entries]]);
414
+ if (!by || by === "person")
415
+ section("By person", (data.by_person ?? []), [["person", (r) => r.name ?? r.email], ["hours", (r) => fmtHours(Number(r.seconds))], ["entries", (r) => r.entries]]);
416
+ if (inv.flags.entries || by === "entry")
417
+ section("Entries", entries, [["start", (e) => fmtDate(String(e.started_at), { time: true })], ["end", (e) => (e.ended_at ? fmtDate(String(e.ended_at), { time: true }) : ctx.out.green("running"))], ["hours", (e) => fmtHours(Number(e.seconds))], ["person", (e) => personName(e.person)], ["project", (e) => e.project?.name ?? ""], ["task", (e) => `${String(e.task?.key ?? "")} ${String(e.task?.title ?? "")}`.trim()], ["note", (e) => e.description ?? ""]]);
418
+ if (typeof data.hint === "string")
419
+ ctx.out.info(ctx.out.dim(data.hint));
420
+ });
421
+ },
422
+ });
423
+ /** Runs the configured checks (or --check commands) and returns their outcome as evidence items. */
424
+ export async function runChecks(ctx, commands) {
425
+ const evidence = [];
426
+ const failed = [];
427
+ for (const command of commands) {
428
+ ctx.out.info(ctx.out.dim(`$ ${command}`));
429
+ const started = Date.now();
430
+ const r = spawnSync(command, { cwd: ctx.cwd, shell: true, encoding: "utf8", env: ctx.env, maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
431
+ const output = `${r.stdout ?? ""}${r.stderr ?? ""}`;
432
+ const tail = output.trim().split("\n").slice(-25).join("\n").slice(-2500);
433
+ const ok = r.status === 0;
434
+ if (!ok)
435
+ failed.push(command);
436
+ evidence.push({ kind: "test_result", title: `${ok ? "PASS" : "FAIL"} ${command}`, detail: `exit ${r.status ?? "?"} in ${Math.round((Date.now() - started) / 1000)}s\n${tail}` });
437
+ if (!ok)
438
+ ctx.out.error(`${command} failed (exit ${r.status ?? "?"})`);
439
+ if (tail && (!ok || ctx.flags.debug))
440
+ ctx.out.info(ctx.out.dim(tail));
441
+ }
442
+ return { ok: failed.length === 0, evidence, failed };
443
+ }
444
+ export const doneCommand = cmd({
445
+ path: "done",
446
+ group: "Daily workflow",
447
+ summary: "Run your checks, then complete the task with the evidence attached (test output, commit, PR).",
448
+ description: "Runs the commands in .scribeclock.json → checks (or --check …), and when they pass reports outcome=completed on the task with the check output, the last commit and the PR (when gh is installed) as evidence. Checks that fail stop the completion (use --skip-checks to complete anyway).",
449
+ tool: "report_task_progress",
450
+ positionals: [{ name: "task", description: "Task key, uuid, or auto (default: from git)." }, { name: "summary", description: "What was done (default: built from the last commit message)." }],
451
+ flags: [
452
+ { name: "summary", alias: "m", type: "string", description: "Closing summary (what, where, how verified)." },
453
+ { name: "check", alias: "c", type: "array", description: "Command(s) to run (overrides .scribeclock.json checks)." },
454
+ { name: "skip-checks", type: "boolean", description: "Complete without running checks." },
455
+ { name: "evidence", alias: "e", type: "array", description: "Extra evidence links or notes." },
456
+ { name: "no-git", type: "boolean", description: "Do not attach the commit / branch / PR." },
457
+ { name: "clock-out", type: "boolean", description: "Also clock out afterwards." },
458
+ ],
459
+ examples: ['scribeclock done #731 -m "Site live at kisaprint.com; SSL and checkout verified."', "scribeclock done auto --check 'npm test' --check 'npm run lint'"],
460
+ async run(ctx, inv) {
461
+ const first = inv.positionals[0];
462
+ const looksLikeTask = first !== undefined && (/^#?\d{1,6}$/.test(first) || /^[0-9a-f-]{36}$/i.test(first) || first === "auto");
463
+ const task_id = taskKey(ctx, looksLikeTask ? first : undefined);
464
+ const commit = inv.flags["no-git"] ? null : lastCommit(ctx.cwd);
465
+ let summary = str(inv.flags.summary) ?? inv.positionals.slice(looksLikeTask ? 1 : 0).join(" ");
466
+ if (!summary && commit)
467
+ summary = `Completed: ${commit.message}`;
468
+ if (!summary || summary.length < 10)
469
+ throw usageError("Describe what was done (-m, at least 10 characters).");
470
+ const checks = list(inv.flags.check) ?? ctx.repo.config.checks ?? [];
471
+ const evidence = [];
472
+ if (!inv.flags["skip-checks"] && checks.length) {
473
+ const result = await runChecks(ctx, checks);
474
+ evidence.push(...result.evidence);
475
+ if (!result.ok) {
476
+ if (ctx.flags.json)
477
+ ctx.out.jsonOut({ ok: false, error: "checks_failed", failed: result.failed, evidence: result.evidence });
478
+ throw new CliError(`${plural(result.failed.length, "check")} failed: ${result.failed.join(", ")}. The task was not completed.`, { id: "checks_failed", hint: "Fix the failures, or pass --skip-checks to complete anyway." });
479
+ }
480
+ }
481
+ else if (!checks.length && !inv.flags["skip-checks"])
482
+ ctx.out.info(ctx.out.dim("No checks configured (.scribeclock.json → checks, or --check). Completing with git evidence only."));
483
+ for (const e of list(inv.flags.evidence) ?? [])
484
+ evidence.push(/^https?:\/\//.test(e) ? { kind: /\/pull\/\d+/.test(e) ? "pull_request" : "link", url: e, title: e.slice(0, 80) } : { kind: "note", detail: e });
485
+ const repo = inv.flags["no-git"] ? null : repoRef(ctx.cwd);
486
+ const args = { task_id, outcome: "completed", summary, evidence };
487
+ if (repo) {
488
+ const slug = githubSlug(remoteUrl(ctx.cwd));
489
+ if (commit)
490
+ 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 });
491
+ const pr = prUrl(ctx);
492
+ if (pr)
493
+ evidence.push({ kind: "pull_request", title: "Pull request", url: pr });
494
+ Object.assign(args, { repo, branch: currentBranch(ctx.cwd) ?? undefined, files_changed: changedFiles(ctx.cwd), last_commit: commit ? { sha: commit.sha, message: commit.message.slice(0, 300) } : undefined, git: gitState(ctx.cwd) });
495
+ }
496
+ if (!evidence.length)
497
+ throw usageError("Completion needs evidence.", "Run inside the repository (the last commit counts), configure checks, or pass -e <url|note>.");
498
+ if (ctx.flags.dryRun) {
499
+ ctx.out.log(`${ctx.out.dim("dry run →")} report_task_progress ${JSON.stringify({ ...args, evidence: evidence.map((e) => e.kind) })}`);
500
+ return EXIT.OK;
501
+ }
502
+ const data = await ctx.call("report_task_progress", args);
503
+ let clock = null;
504
+ if (inv.flags["clock-out"])
505
+ clock = await ctx.call("clock_out", { summary }, { skipOrg: true });
506
+ return emit(ctx, { ...data, clock_out: clock }, () => {
507
+ const t = (data.task ?? {});
508
+ ctx.out.log(`${ctx.out.green("✓")} Completed ${ctx.out.bold(String(t.key ?? task_id))}${t.title ? ` ${String(t.title)}` : ""} with ${plural(evidence.length, "evidence item")}.`);
509
+ if (clock?.was_clocked_in)
510
+ ctx.out.log(`${ctx.out.green("✓")} Clocked out · ${fmtDuration(Number(clock.duration_seconds ?? 0))}`);
511
+ });
512
+ },
513
+ });
514
+ /** The open PR for the current branch, when the GitHub CLI is available. */
515
+ export function prUrl(ctx) {
516
+ const r = spawnSync("gh", ["pr", "view", "--json", "url", "-q", ".url"], { cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10000 });
517
+ if (r.status !== 0)
518
+ return null;
519
+ const url = (r.stdout ?? "").trim();
520
+ return /^https?:\/\//.test(url) ? url : null;
521
+ }
522
+ export const WORKFLOW_COMMANDS = [todayCommand, nextCommand, startCommand, inboxCommand, standupCommand, timesheetCommand, doneCommand];
523
+ void readFileSync;
524
+ void detectTaskKey;
525
+ void statusPaint;
package/dist/config.js ADDED
@@ -0,0 +1,102 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, dirname } from "node:path";
4
+ import { EXIT } from "./types.js";
5
+ import { CliError } from "./cli/errors.js";
6
+ export const DEFAULT_ENDPOINT = "https://scribeclock.com/mcp";
7
+ export const DOCS_URL = "https://scribeclock.com/help/mcp-server";
8
+ export const CLI_DOCS_URL = "https://scribeclock.com/help/mcp-server#cli";
9
+ export const SETTINGS_URL = "https://scribeclock.com/dashboard/settings/ai-agents";
10
+ export function configDir(env = process.env) {
11
+ if (env.SCRIBECLOCK_CONFIG_DIR)
12
+ return env.SCRIBECLOCK_CONFIG_DIR;
13
+ const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
14
+ return join(base, "scribeclock");
15
+ }
16
+ /**
17
+ * Where the MCP endpoint is. `--url` > SCRIBECLOCK_URL > config > default. Accepts a bare host
18
+ * (https://staging.example.com → https://staging.example.com/mcp), the mcp.scribeclock.com alias,
19
+ * or a full Supabase function URL.
20
+ */
21
+ export function resolveEndpoint(flagUrl, env, configured) {
22
+ const raw = (flagUrl || env.SCRIBECLOCK_URL || configured || DEFAULT_ENDPOINT).trim();
23
+ const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
24
+ const url = new URL(withScheme);
25
+ // Bearer tokens travel on every request: plain HTTP is only acceptable to a loopback dev server.
26
+ if (url.protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) {
27
+ throw new CliError(`Refusing to send tokens over plain HTTP to ${url.host}.`, { code: EXIT.USAGE, id: "insecure_endpoint", hint: "Use https://, or a loopback address (localhost / 127.0.0.1) for local development." });
28
+ }
29
+ url.hash = "";
30
+ url.search = "";
31
+ let path = url.pathname.replace(/\/+$/, "");
32
+ if (path === "" && url.hostname !== "mcp.scribeclock.com")
33
+ path = "/mcp";
34
+ return `${url.protocol}//${url.host}${path}`;
35
+ }
36
+ /** Credential profile name for an endpoint: one login per server. */
37
+ export function profileFor(endpoint) {
38
+ const u = new URL(endpoint);
39
+ const path = u.pathname.replace(/\/+$/, "").replace(/[^a-zA-Z0-9]+/g, "_");
40
+ return `${u.hostname}${path && path !== "_mcp" ? path : ""}`;
41
+ }
42
+ /** Per-user, non-secret settings (`<configDir>/config.json`). */
43
+ export class ConfigStore {
44
+ path;
45
+ cache = null;
46
+ constructor(dir) {
47
+ this.path = join(dir, "config.json");
48
+ }
49
+ read() {
50
+ if (this.cache)
51
+ return this.cache;
52
+ try {
53
+ this.cache = JSON.parse(readFileSync(this.path, "utf8"));
54
+ }
55
+ catch {
56
+ this.cache = {};
57
+ }
58
+ return this.cache;
59
+ }
60
+ write(patch) {
61
+ const next = { ...this.read(), ...patch };
62
+ for (const k of Object.keys(next))
63
+ if (next[k] === undefined)
64
+ delete next[k];
65
+ writeJsonPrivate(this.path, next);
66
+ this.cache = next;
67
+ return next;
68
+ }
69
+ }
70
+ export function readRepoConfig(startDir) {
71
+ let dir = startDir;
72
+ for (let i = 0; i < 12; i++) {
73
+ const candidate = join(dir, ".scribeclock.json");
74
+ if (existsSync(candidate)) {
75
+ try {
76
+ return { path: candidate, config: JSON.parse(readFileSync(candidate, "utf8")) };
77
+ }
78
+ catch {
79
+ return { path: candidate, config: {} };
80
+ }
81
+ }
82
+ const parent = dirname(dir);
83
+ if (parent === dir)
84
+ break;
85
+ dir = parent;
86
+ }
87
+ return { path: null, config: {} };
88
+ }
89
+ export function ensureDirPrivate(dir) {
90
+ if (!existsSync(dir))
91
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
92
+ }
93
+ export function writeJsonPrivate(path, value) {
94
+ ensureDirPrivate(dirname(path));
95
+ writeFileSync(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
96
+ try {
97
+ chmodSync(path, 0o600);
98
+ }
99
+ catch {
100
+ /* best effort on platforms without POSIX modes */
101
+ }
102
+ }