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,420 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { CliError, usageError } from "../cli/errors.js";
7
+ import { confirm } from "../cli/confirm.js";
8
+ import { McpClient, PROTOCOL_VERSION } from "../mcp/client.js";
9
+ import { sensitivity } from "../cli/policy.js";
10
+ import { kebab, requireArgs } from "../mcp/schema.js";
11
+ import { renderResult } from "../render.js";
12
+ import { EXIT } from "../types.js";
13
+ import { fmtDate, parseWhen, startOfDay, startOfWeek, startOfMonth } from "../util/format.js";
14
+ import { PROJECT_FLAG, cmd, num, personName, projectIdArg, renderTaskTable, str } from "./_shared.js";
15
+ import { executeGuarded } from "./tools.js";
16
+ // ---------------------------------------------------------------------------
17
+ // watch tasks
18
+ // ---------------------------------------------------------------------------
19
+ export const watchTasks = cmd({
20
+ path: "watch tasks",
21
+ group: "Automation & agents",
22
+ summary: "Stream task changes to the terminal (or NDJSON with --json); --on-change runs a command per change.",
23
+ description: "Polls list_tasks(updated_since) every --interval seconds and prints each changed task. With --json each change is one NDJSON line (pipe into jq). --on-change <cmd> runs a shell command per change with SCRIBECLOCK_EVENT (the task as JSON), SCRIBECLOCK_TASK_KEY, SCRIBECLOCK_TASK_STATUS and SCRIBECLOCK_TASK_TITLE in its environment.",
24
+ tool: "list_tasks",
25
+ flags: [
26
+ { name: "interval", alias: "i", type: "number", description: "Seconds between polls (default 30, min 5).", default: 30 },
27
+ { name: "on-change", type: "string", placeholder: "<cmd>", description: "Shell command to run for each changed task." },
28
+ { name: "assignee", alias: "a", type: "string", description: "Only this person's tasks (me, unassigned, name…)." },
29
+ PROJECT_FLAG,
30
+ { name: "status", alias: "s", type: "string", choices: ["open", "all", "pending", "in-progress", "completed", "cancelled"], description: "Status filter (default all)." },
31
+ { name: "since", type: "string", placeholder: "<when>", description: "Start from this time instead of now." },
32
+ { name: "once", type: "boolean", description: "Poll once and exit (for cron / tests)." },
33
+ ],
34
+ examples: ["scribeclock watch tasks --assignee me", "scribeclock watch tasks --json | jq -r '.key + \" \" + .status'", "scribeclock watch tasks --on-change 'osascript -e \"display notification \\\"$SCRIBECLOCK_TASK_TITLE\\\" with title \\\"$SCRIBECLOCK_TASK_KEY\\\"\"'"],
35
+ async run(ctx, inv) {
36
+ const interval = Math.max(5, Number(inv.flags.interval ?? 30)) * 1000;
37
+ let since = str(inv.flags.since) ? parseWhen(String(inv.flags.since), ctx.now()) ?? ctx.now() : ctx.now();
38
+ const seen = new Map();
39
+ let stop = false;
40
+ const onSignal = () => {
41
+ stop = true;
42
+ };
43
+ process.once("SIGINT", onSignal);
44
+ process.once("SIGTERM", onSignal);
45
+ if (!ctx.flags.json)
46
+ ctx.out.info(ctx.out.dim(`Watching tasks every ${interval / 1000}s from ${fmtDate(since.toISOString(), { time: true })} (Ctrl-C to stop)…`));
47
+ try {
48
+ do {
49
+ const pollStart = ctx.now();
50
+ const data = await ctx.call("list_tasks", { updated_since: since.toISOString(), status: str(inv.flags.status) ?? "all", assignee: str(inv.flags.assignee), project_id: await projectIdArg(ctx, inv), order_by: "updated", limit: 200 });
51
+ const tasks = (data.tasks ?? []).filter((t) => seen.get(String(t.id)) !== String(t.updated_at));
52
+ for (const t of tasks.reverse()) {
53
+ seen.set(String(t.id), String(t.updated_at));
54
+ if (ctx.flags.json)
55
+ ctx.out.ndjson({ event: "task_updated", at: t.updated_at, ...t });
56
+ else
57
+ ctx.out.log(`${ctx.out.dim(fmtDate(String(t.updated_at), { time: true }))} ${ctx.out.bold(String(t.key))} ${String(t.title)} · ${String(t.status)}${t.assignee ? ` · ${personName(t.assignee)}` : ""}`);
58
+ if (inv.flags["on-change"])
59
+ await runHook(ctx, String(inv.flags["on-change"]), t);
60
+ }
61
+ since = new Date(pollStart.getTime() - 1000);
62
+ if (inv.flags.once || stop)
63
+ break;
64
+ await new Promise((r) => setTimeout(r, interval));
65
+ } while (!stop);
66
+ }
67
+ finally {
68
+ process.off("SIGINT", onSignal);
69
+ process.off("SIGTERM", onSignal);
70
+ }
71
+ return EXIT.OK;
72
+ },
73
+ });
74
+ function runHook(ctx, command, task) {
75
+ return new Promise((resolve) => {
76
+ const child = spawn(command, { shell: true, cwd: ctx.cwd, stdio: ["ignore", "inherit", "inherit"], env: { ...ctx.env, SCRIBECLOCK_EVENT: JSON.stringify(task), SCRIBECLOCK_TASK_KEY: String(task.key ?? ""), SCRIBECLOCK_TASK_STATUS: String(task.status ?? ""), SCRIBECLOCK_TASK_TITLE: String(task.title ?? "") } });
77
+ child.on("exit", (code) => {
78
+ if (code)
79
+ ctx.out.warn(`--on-change exited ${code}`);
80
+ resolve();
81
+ });
82
+ child.on("error", (err) => {
83
+ ctx.out.warn(`--on-change failed: ${err.message}`);
84
+ resolve();
85
+ });
86
+ });
87
+ }
88
+ export const serveCommand = cmd({
89
+ path: "serve",
90
+ group: "Automation & agents",
91
+ summary: "Run as a local MCP server over stdio (for clients that prefer local servers); proxies every tool to ScribeClock with your login.",
92
+ description: "Speaks MCP over stdin/stdout (newline-delimited JSON-RPC) and forwards initialize / tools/list / tools/call to the remote server using the CLI's stored login or SCRIBECLOCK_TOKEN. `scribeclock mcp-config <client> --local` prints the matching client configuration.",
93
+ tool: ["get_viewer"],
94
+ flags: [{ name: "stdio", type: "boolean", description: "Use stdio (the only transport; present for symmetry with other servers).", default: true }],
95
+ examples: ["scribeclock serve --stdio", "scribeclock mcp-config claude-code --local"],
96
+ async run(ctx) {
97
+ const client = ctx.client();
98
+ const rpc = client instanceof McpClient ? client : null;
99
+ const log = (line) => ctx.out.stderr.write(`[scribeclock serve] ${line}\n`);
100
+ const write = (msg) => ctx.out.stdout.write(JSON.stringify(msg) + "\n");
101
+ const handle = async (msg) => {
102
+ const id = msg.id ?? null;
103
+ const method = msg.method ?? "";
104
+ if (!method)
105
+ return;
106
+ if (msg.id === undefined || method.startsWith("notifications/"))
107
+ return; // notifications get no reply
108
+ try {
109
+ switch (method) {
110
+ case "initialize": {
111
+ const remote = rpc ? await rpc.initialize() : { instructions: "" };
112
+ const requested = String(msg.params?.protocolVersion ?? PROTOCOL_VERSION);
113
+ write({ jsonrpc: "2.0", id, result: { protocolVersion: ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"].includes(requested) ? requested : PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } }, serverInfo: { name: "scribeclock-cli", title: "ScribeClock (via CLI)", version: ctx.version }, instructions: typeof remote.instructions === "string" ? remote.instructions : undefined } });
114
+ return;
115
+ }
116
+ case "ping":
117
+ write({ jsonrpc: "2.0", id, result: {} });
118
+ return;
119
+ case "tools/list": {
120
+ const result = rpc ? await rpc.rpc("tools/list") : { tools: await client.listTools() };
121
+ write({ jsonrpc: "2.0", id, result });
122
+ return;
123
+ }
124
+ case "tools/call": {
125
+ if (rpc)
126
+ write({ jsonrpc: "2.0", id, result: await rpc.rpc("tools/call", msg.params ?? {}) });
127
+ else {
128
+ const outcome = await client.callTool(String(msg.params?.name ?? ""), msg.params?.arguments ?? {});
129
+ write({ jsonrpc: "2.0", id, result: { content: outcome.content, structuredContent: outcome.data, isError: !outcome.ok } });
130
+ }
131
+ return;
132
+ }
133
+ case "resources/list":
134
+ write({ jsonrpc: "2.0", id, result: { resources: [] } });
135
+ return;
136
+ case "prompts/list":
137
+ write({ jsonrpc: "2.0", id, result: { prompts: [] } });
138
+ return;
139
+ default:
140
+ write({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
141
+ }
142
+ }
143
+ catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ const code = err instanceof CliError && err.code === EXIT.AUTH ? -32001 : -32603;
146
+ log(message);
147
+ write({ jsonrpc: "2.0", id, error: { code, message } });
148
+ }
149
+ };
150
+ log(`proxying ${ctx.endpoint} (Ctrl-C to stop)`);
151
+ const rl = createInterface({ input: ctx.stdin.stream, crlfDelay: Infinity });
152
+ for await (const line of rl) {
153
+ const text = line.trim();
154
+ if (!text)
155
+ continue;
156
+ let parsed;
157
+ try {
158
+ parsed = JSON.parse(text);
159
+ }
160
+ catch {
161
+ write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
162
+ continue;
163
+ }
164
+ for (const msg of Array.isArray(parsed) ? parsed : [parsed])
165
+ await handle(msg);
166
+ }
167
+ return EXIT.OK;
168
+ },
169
+ });
170
+ // ---------------------------------------------------------------------------
171
+ // mcp-config
172
+ // ---------------------------------------------------------------------------
173
+ const CLIENTS = ["claude-code", "claude-ai", "chatgpt", "cursor", "vscode", "windsurf", "codex", "gemini", "generic"];
174
+ export function mcpConfigFor(client, endpoint, local, bin = "scribeclock") {
175
+ const name = "scribeclock";
176
+ const remote = { url: endpoint };
177
+ const stdio = { command: bin, args: ["serve", "--stdio"] };
178
+ switch (client) {
179
+ case "claude-code":
180
+ return { text: local ? `claude mcp add ${name} -- ${bin} serve --stdio` : `claude mcp add --transport http ${name} ${endpoint}`, file: ".mcp.json", merge: { path: ["mcpServers", name], value: local ? { type: "stdio", ...stdio } : { type: "http", url: endpoint }, format: "json" } };
181
+ case "cursor":
182
+ return { text: JSON.stringify({ mcpServers: { [name]: local ? stdio : remote } }, null, 2), file: ".cursor/mcp.json", merge: { path: ["mcpServers", name], value: local ? stdio : remote, format: "json" } };
183
+ case "vscode":
184
+ return { text: JSON.stringify({ servers: { [name]: local ? { type: "stdio", ...stdio } : { type: "http", url: endpoint } } }, null, 2), file: ".vscode/mcp.json", merge: { path: ["servers", name], value: local ? { type: "stdio", ...stdio } : { type: "http", url: endpoint }, format: "json" } };
185
+ case "windsurf":
186
+ return { text: JSON.stringify({ mcpServers: { [name]: local ? stdio : { serverUrl: endpoint } } }, null, 2), file: join(homedir(), ".codeium", "windsurf", "mcp_config.json"), merge: { path: ["mcpServers", name], value: local ? stdio : { serverUrl: endpoint }, format: "json" } };
187
+ case "codex":
188
+ return { text: local ? `[mcp_servers.${name}]\ncommand = "${bin}"\nargs = ["serve", "--stdio"]` : `[mcp_servers.${name}]\nurl = "${endpoint}"`, file: join(homedir(), ".codex", "config.toml"), merge: { path: ["mcp_servers", name], value: local ? stdio : remote, format: "toml" } };
189
+ case "gemini":
190
+ return { text: JSON.stringify({ mcpServers: { [name]: local ? stdio : { httpUrl: endpoint } } }, null, 2), file: ".gemini/settings.json", merge: { path: ["mcpServers", name], value: local ? stdio : { httpUrl: endpoint }, format: "json" } };
191
+ case "claude-ai":
192
+ return { text: local ? "claude.ai only supports remote connectors: Settings → Connectors → Add custom connector → " + endpoint : `Settings → Connectors → Add custom connector → ${endpoint}` };
193
+ case "chatgpt":
194
+ return { text: `Settings → Connectors → Create (developer mode) → ${endpoint}` };
195
+ default:
196
+ return { text: JSON.stringify(local ? stdio : { type: "http", url: endpoint }, null, 2) };
197
+ }
198
+ }
199
+ export const mcpConfigCommand = cmd({
200
+ path: "mcp-config",
201
+ group: "Automation & agents",
202
+ summary: `Print (or --install) the MCP client config for ${CLIENTS.filter((c) => c !== "generic").join(", ")}.`,
203
+ tool: [],
204
+ positionals: [{ name: "client", required: true, description: CLIENTS.join(" | ") }],
205
+ flags: [
206
+ { name: "local", type: "boolean", description: "Configure the local stdio server (scribeclock serve --stdio) instead of the remote URL." },
207
+ { name: "install", type: "boolean", description: "Write/merge the config into the client's file (asks first)." },
208
+ { name: "global", type: "boolean", description: "With --install for cursor/vscode/gemini: write the user-level file instead of the project one." },
209
+ ],
210
+ examples: ["scribeclock mcp-config claude-code", "scribeclock mcp-config cursor --install", "scribeclock mcp-config codex --local"],
211
+ async run(ctx, inv) {
212
+ const client = inv.positionals[0];
213
+ if (!CLIENTS.includes(client))
214
+ throw usageError(`Unknown client "${String(client)}".`, `One of: ${CLIENTS.join(", ")}`);
215
+ const cfg = mcpConfigFor(client, ctx.endpoint, Boolean(inv.flags.local), "scribeclock");
216
+ if (!inv.flags.install) {
217
+ if (ctx.flags.json)
218
+ ctx.out.jsonOut({ ok: true, client, local: Boolean(inv.flags.local), config: cfg.text, file: cfg.file ?? null });
219
+ else {
220
+ ctx.out.log(cfg.text);
221
+ if (cfg.file)
222
+ ctx.out.info(ctx.out.dim(`\nFile: ${cfg.file} (add --install to write it)`));
223
+ }
224
+ return EXIT.OK;
225
+ }
226
+ if (!cfg.merge || !cfg.file)
227
+ throw usageError(`${client} has no config file to install into; follow the printed steps instead.`);
228
+ let target = cfg.file;
229
+ if (inv.flags.global) {
230
+ if (client === "cursor")
231
+ target = join(homedir(), ".cursor", "mcp.json");
232
+ else if (client === "vscode")
233
+ throw usageError("VS Code's user-level MCP config lives in its settings UI; use the project file (omit --global).");
234
+ else if (client === "gemini")
235
+ target = join(homedir(), ".gemini", "settings.json");
236
+ }
237
+ const path = target.startsWith("/") || /^[A-Za-z]:\\/.test(target) ? target : join(ctx.cwd, target);
238
+ const ok = await confirm(ctx, `Add the "scribeclock" MCP server to ${path}?`);
239
+ if (!ok)
240
+ return EXIT.ERROR;
241
+ if (cfg.merge.format === "json") {
242
+ let json = {};
243
+ if (existsSync(path)) {
244
+ try {
245
+ json = JSON.parse(readFileSync(path, "utf8"));
246
+ }
247
+ catch {
248
+ throw new CliError(`${path} is not valid JSON; edit it by hand.`, { id: "invalid_config" });
249
+ }
250
+ }
251
+ let node = json;
252
+ for (const key of cfg.merge.path.slice(0, -1)) {
253
+ if (!node[key] || typeof node[key] !== "object")
254
+ node[key] = {};
255
+ node = node[key];
256
+ }
257
+ node[cfg.merge.path[cfg.merge.path.length - 1]] = cfg.merge.value;
258
+ mkdirSync(dirname(path), { recursive: true });
259
+ writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
260
+ }
261
+ else {
262
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
263
+ if (/\[mcp_servers\.scribeclock\]/.test(existing))
264
+ throw new CliError(`${path} already has an [mcp_servers.scribeclock] section.`, { id: "exists", hint: "Edit it by hand to change the URL or command." });
265
+ mkdirSync(dirname(path), { recursive: true });
266
+ writeFileSync(path, `${existing.trimEnd()}${existing ? "\n\n" : ""}${cfg.text}\n`);
267
+ }
268
+ if (ctx.flags.json)
269
+ ctx.out.jsonOut({ ok: true, client, file: path, local: Boolean(inv.flags.local) });
270
+ else
271
+ ctx.out.log(`${ctx.out.green("✓")} Wrote ${path}. ${client === "claude-code" ? "Restart Claude Code and run /mcp to authenticate." : "Restart the client; it will discover OAuth (or use your CLI login with --local)."}`);
272
+ return EXIT.OK;
273
+ },
274
+ });
275
+ const TASK_REF = String.raw `(?:#|task\s*)?(\d{1,6})(?!\d)`;
276
+ const INTENTS = [
277
+ { match: /^(who am i|whoami|my (account|identity)|which org)/i, tool: "get_viewer", args: () => ({}), why: "identity question" },
278
+ { match: /\b(am i|is .* )?clocked? ?(in|out)\b.*\?|^clock status|^(am i|are we) (clocked|working)|\bhow long have i been (clocked in|working)/i, tool: "clock_status", args: () => ({}), why: "asks about the clock" },
279
+ { match: /\bclock\s*(me\s*)?in\b|\bstart (my )?(shift|day|timer)\b/i, tool: "clock_in", args: (_m, q) => ({ project: /(?:on|under|for) (?:project )?["“']?([^"”']+?)["”']?$/i.exec(q)?.[1]?.trim() }), why: "clock in" },
280
+ { match: /\bclock\s*(me\s*)?out\b|\bend (my )?(shift|day|timer)\b|\bstop (the )?(timer|clock)\b/i, tool: "clock_out", args: (_m, q, c) => ({ summary: /(?:summary|note|saying|with)[:\s]+["“']?(.+?)["”']?$/i.exec(q)?.[1], at: /\bat (\d{1,2}:\d{2})/i.exec(q) ? parseWhen(/\bat (\d{1,2}:\d{2})/i.exec(q)[1], c.now)?.toISOString() : undefined }), why: "clock out" },
281
+ { match: new RegExp(String.raw `\b(complete|finish|close|mark .*done|done with)\b.*?${TASK_REF}`, "i"), tool: "complete_task", args: (m, q) => ({ task_id: `#${m[m.length - 1]}`, comment: /(?:saying|comment|with|:)\s*["“']?(.+?)["”']?$/i.exec(q)?.[1] }), why: "complete a task" },
282
+ { match: new RegExp(String.raw `\b(claim|take|grab|start|pick up|work on)\b.*?${TASK_REF}`, "i"), tool: "claim_task", args: (m) => ({ task_id: `#${m[m.length - 1]}` }), why: "claim a task" },
283
+ { match: new RegExp(String.raw `\b(release|give back|unassign me from|drop)\b.*?${TASK_REF}`, "i"), tool: "release_task", args: (m) => ({ task_id: `#${m[m.length - 1]}` }), why: "release a task" },
284
+ { match: new RegExp(String.raw `\b(delete|trash|remove)\b.*?${TASK_REF}`, "i"), tool: "delete_task", args: (m) => ({ task_id: `#${m[m.length - 1]}` }), why: "delete a task" },
285
+ { match: new RegExp(String.raw `\b(restore|undelete)\b.*?${TASK_REF}`, "i"), tool: "restore_task", args: (m) => ({ task_id: `#${m[m.length - 1]}` }), why: "restore a task" },
286
+ { match: new RegExp(String.raw `\b(comment|note|reply|say)\b.*?${TASK_REF}[:\s]+(.+)$`, "i"), tool: "add_task_comment", args: (m) => ({ task_id: `#${m[m.length - 2]}`, body: m[m.length - 1].replace(/^["“'\s]+|["”'\s]+$/g, "") }), why: "comment on a task" },
287
+ { match: new RegExp(String.raw `\bassign\b.*?${TASK_REF}\s+to\s+(.+)$`, "i"), tool: "assign_task", args: (m) => ({ task_id: `#${m[m.length - 2]}`, assignee: m[m.length - 1].trim() }), why: "assign a task" },
288
+ { match: new RegExp(String.raw `\b(comments|thread|discussion)\b.*?${TASK_REF}`, "i"), tool: "list_task_comments", args: (m) => ({ task_id: `#${m[m.length - 1]}` }), why: "read a task's comments" },
289
+ { match: new RegExp(String.raw `\b(create|add|new|make|file)\b.*\btask\b[:\s]+["“']?(.+?)["”']?$`, "i"), tool: "create_task", args: (m, q) => ({ title: m[m.length - 1].replace(/\s+(high|low|medium) priority.*$/i, "").trim(), priority: /\b(high|low|medium) priority\b/i.exec(q)?.[1]?.toLowerCase(), assignee: /\b(?:for|assign(?:ed)? to) me\b/i.test(q) ? "me" : undefined }), why: "create a task" },
290
+ { match: new RegExp(String.raw `(?:^|\b(show|open|read|what(?:'s| is)|details? (?:of|for)|tell me about)\b.*?)${TASK_REF}\b`, "i"), tool: "get_task", args: (m) => ({ task_id: `#${m[m.length - 1]}` }), why: "read one task" },
291
+ { match: /\b(overdue|late|past due)\b.*\btasks?\b|\btasks?\b.*\b(overdue|late|past due)\b/i, tool: "list_tasks", args: (_m, q, c) => ({ assignee: /\bmy\b|\bmine\b/i.test(q) ? "me" : undefined, due_before: c.now.toISOString(), status: "open" }), why: "overdue tasks" },
292
+ { match: /\b(unassigned|free|unowned|nobody'?s)\b.*\b(tasks?|work)\b/i, tool: "list_tasks", args: () => ({ assignee: "unassigned", status: "open" }), why: "unassigned tasks" },
293
+ { match: /\b(my|mine)\b.*\b(tasks?|queue|work|todo)\b|\bwhat (am i|should i) (work(ing)? on|do)\b|\btasks? (for|assigned to) me\b/i, tool: "list_tasks", args: () => ({ assignee: "me", status: "open" }), why: "my tasks" },
294
+ { match: /\b(in[- ]progress|being worked on)\b/i, tool: "list_tasks", args: () => ({ status: "in-progress" }), why: "in-progress tasks" },
295
+ { match: /\b(who(?:'s| is) (on|in|working in|working on) (this|the) (repo|codebase|repository))|\bteam\b.*\brepo\b/i, tool: "get_repo_team", args: (_m, _q, c) => ({ repo: c.repo }), why: "repo team" },
296
+ { match: /\b(who(?:'s| is) (working|busy|doing what)|workload|team load|capacity)\b/i, tool: "get_workload", args: () => ({}), why: "team workload" },
297
+ { match: /\b(search|find|look for|grep)\b.*\btasks?\b.*["“']?([^"”']+)["”']?$|\btasks?\b (about|mentioning|containing) (.+)$/i, tool: "list_tasks", args: (m) => ({ query: (m[2] ?? m[4] ?? "").trim(), status: "all" }), why: "task search" },
298
+ { match: /\b(list|show|all)\b.*\btasks\b|^tasks$/i, tool: "list_tasks", args: () => ({ status: "open" }), why: "list tasks" },
299
+ { match: /\b(members|teammates|people|who is in (the|my) (org|team|organization))\b/i, tool: "list_members", args: () => ({}), why: "members" },
300
+ { match: /\b(hours|timesheet|time (logged|tracked|entries)|how (long|much) (did i|have i) work)/i, tool: "list_time_entries", args: (_m, q, c) => ({ since: /\bthis month\b/i.test(q) ? startOfMonth(c.now).toISOString() : /\btoday\b/i.test(q) ? startOfDay(c.now).toISOString() : /\bthis week\b/i.test(q) ? startOfWeek(c.now).toISOString() : undefined, person: /\b(team|everyone|all)\b/i.test(q) ? "all" : undefined }), why: "time entries" },
301
+ { match: /\b(prd|requirements?( doc(ument)?)?)\b.*\b(for|of)\b\s+["“']?(.+?)["”']?$/i, tool: "get_prd_version", args: (m) => ({ project_id: m[m.length - 1].trim() }), why: "read a PRD" },
302
+ { match: /\bproject\b.*\b(status|details?|show|open)\b.*["“']?(.+?)["”']?$|^(show|open) project (.+)$/i, tool: "get_project", args: (m) => ({ project_id: (m[2] ?? m[4] ?? "").trim() }), why: "one project" },
303
+ { match: /\bprojects?\b/i, tool: "list_projects", args: (_m, q) => ({ status: /\bactive\b/i.test(q) ? "active" : undefined }), why: "projects" },
304
+ { match: /\btranscript\b.*([0-9a-f]{8}-[0-9a-f-]{27})/i, tool: "get_meeting_transcript", args: (m) => ({ meeting_id: m[1] }), why: "meeting transcript" },
305
+ { match: /\bmeeting\b.*([0-9a-f]{8}-[0-9a-f-]{27})/i, tool: "get_meeting", args: (m) => ({ meeting_id: m[1] }), why: "one meeting" },
306
+ { match: /\bmeetings?\b/i, tool: "list_meetings", args: (_m, q, c) => ({ since: /\b(today)\b/i.test(q) ? startOfDay(c.now).toISOString() : /\b(this week)\b/i.test(q) ? startOfWeek(c.now).toISOString() : /\byesterday\b/i.test(q) ? new Date(startOfDay(c.now).getTime() - 86400000).toISOString() : undefined, query: /\babout (.+)$/i.exec(q)?.[1] }), why: "meetings" },
307
+ { match: /\b(secrets?|env(ironment)? var(iable)?s?|vault)\b.*\b(for|of|in)\b\s+["“']?(.+?)["”']?$/i, tool: "vault_list_secrets", args: (m) => ({ project_id: m[m.length - 1].trim() }), why: "vault listing (names only)" },
308
+ { match: /\b(dns|records?)\b.*\b(for|of)\b\s+([a-z0-9.-]+\.[a-z]{2,})/i, tool: "cloudflare_dns_records", args: (m) => ({ zone: m[m.length - 1] }), why: "DNS records" },
309
+ { match: /\b(purge|clear|flush)\b.*\bcache\b.*\b([a-z0-9.-]+\.[a-z]{2,})/i, tool: "cloudflare_purge_cache", args: (m) => ({ zone: m[m.length - 1], purge_everything: true }), why: "purge cache" },
310
+ { match: /\b(zones|domains)\b/i, tool: "cloudflare_zones", args: () => ({}), why: "zones" },
311
+ { match: /\b(deploy(ment)?s?|pages)\b.*\b(for|of)\b\s+([a-z0-9-]+)/i, tool: "cloudflare_pages_deployments", args: (m) => ({ project: m[m.length - 1] }), why: "deployments" },
312
+ { match: /\bhandoff\b|\bwhere did .* leave off\b|\bpick up where\b/i, tool: "get_handoff", args: (_m, _q, c) => ({ repo: c.repo }), why: "handoff" },
313
+ { match: /\b(files?|documents?)\b.*\b(in|for|of)\b\s+(?:project\s+)?["“']?(.+?)["”']?$/i, tool: "list_project_files", args: (m) => ({ project_id: m[m.length - 1].trim() }), why: "project files" },
314
+ { match: /\b(files?|documents?|drive)\b/i, tool: "list_organization_files", args: (_m, q) => ({ query: /\b(named|called|about) (.+)$/i.exec(q)?.[2] }), why: "files tab" },
315
+ ];
316
+ /** Keyword fallback: score tools by overlap between the question and name/title/description. */
317
+ function keywordPlan(question, tools) {
318
+ const words = question.toLowerCase().split(/[^a-z0-9#]+/).filter((w) => w.length > 2 && !["the", "and", "for", "with", "show", "please", "can", "you", "what", "all", "list"].includes(w));
319
+ let best = null;
320
+ for (const t of tools) {
321
+ const hay = `${t.name.replace(/_/g, " ")} ${t.title ?? ""} ${t.description.slice(0, 300)}`.toLowerCase();
322
+ let score = 0;
323
+ for (const w of words) {
324
+ if (t.name.includes(w))
325
+ score += 3;
326
+ else if (hay.includes(w))
327
+ score += 1;
328
+ }
329
+ if (t.annotations?.readOnlyHint)
330
+ score += 0.5;
331
+ if (!best || score > best.score)
332
+ best = { tool: t, score };
333
+ }
334
+ if (!best || best.score < 2)
335
+ return null;
336
+ const required = best.tool.inputSchema.required ?? [];
337
+ const args = {};
338
+ const taskRef = /#(\d{1,6})/.exec(question);
339
+ if (required.includes("task_id") && taskRef)
340
+ args.task_id = `#${taskRef[1]}`;
341
+ return { tool: best.tool.name, args, confidence: Math.min(0.6, best.score / 10), why: `keywords matched ${best.tool.name}`, alternatives: [] };
342
+ }
343
+ export function planAsk(question, tools, ctx) {
344
+ const available = new Set(tools.map((t) => t.name));
345
+ const q = question.trim();
346
+ for (const intent of INTENTS) {
347
+ const m = q.match(intent.match);
348
+ if (!m || !available.has(intent.tool))
349
+ continue;
350
+ const args = intent.args(m, q, ctx);
351
+ for (const k of Object.keys(args))
352
+ if (args[k] === undefined || args[k] === "")
353
+ delete args[k];
354
+ const alternatives = INTENTS.filter((i) => i !== intent && i.tool !== intent.tool && q.match(i.match) && available.has(i.tool)).map((i) => i.tool).slice(0, 3);
355
+ return { tool: intent.tool, args, confidence: alternatives.length ? 0.7 : 0.9, why: intent.why, alternatives };
356
+ }
357
+ return keywordPlan(q, tools);
358
+ }
359
+ export const askCommand = cmd({
360
+ path: "ask",
361
+ group: "Automation & agents",
362
+ summary: 'Plain-language request → one tool call, shown before it runs: ask "what\'s overdue for me?".',
363
+ description: "Maps a request to a server tool with built-in rules (no AI call, nothing leaves your machine except the tool call itself). Read-only tools run right away; anything that writes or deletes shows the exact call and asks for confirmation (--yes to skip, --dry-run to only show it). Use --plan to print the mapping without running anything.",
364
+ tool: [],
365
+ positionals: [{ name: "question", required: true, variadic: true }],
366
+ flags: [{ name: "plan", type: "boolean", description: "Only print the tool call the request maps to." }],
367
+ examples: ['scribeclock ask "what are my overdue tasks"', 'scribeclock ask "clock me in on Kisa Print"', 'scribeclock ask "comment on #731: domain purchased" --yes', 'scribeclock ask "how many hours this week" --json'],
368
+ async run(ctx, inv) {
369
+ const question = inv.positionals.join(" ").trim();
370
+ if (!question)
371
+ throw usageError("Ask something.", 'Example: scribeclock ask "what should I work on next?"');
372
+ const tools = await ctx.tools();
373
+ const { repoRef } = await import("../git.js");
374
+ const plan = planAsk(question, tools, { now: ctx.now(), repo: repoRef(ctx.cwd) ?? undefined });
375
+ if (!plan)
376
+ throw new CliError(`I could not map "${question}" to a tool.`, { id: "no_match", hint: "Try naming the thing: tasks, projects, meetings, clock, timesheet, vault, dns… or run `scribeclock tools` and call one directly." });
377
+ const def = tools.find((t) => t.name === plan.tool);
378
+ const readOnly = def?.annotations?.readOnlyHint === true;
379
+ const policy = sensitivity(plan.tool, def, plan.args);
380
+ const call = `${plan.tool} ${JSON.stringify(plan.args)}`;
381
+ if (inv.flags.plan || ctx.flags.dryRun) {
382
+ if (ctx.flags.json)
383
+ ctx.out.jsonOut({ ok: true, plan, read_only: readOnly });
384
+ else {
385
+ ctx.out.log(`${ctx.out.dim("→")} ${call} ${ctx.out.dim(`(${plan.why}, confidence ${Math.round(plan.confidence * 100)}%${plan.alternatives.length ? `; also possible: ${plan.alternatives.join(", ")}` : ""})`)}`);
386
+ ctx.out.log(ctx.out.dim(readOnly ? "read-only: would run immediately" : "writes: would ask for confirmation"));
387
+ }
388
+ return EXIT.OK;
389
+ }
390
+ if (!ctx.flags.json)
391
+ ctx.out.info(`${ctx.out.dim("→")} ${call} ${ctx.out.dim(`(${plan.why})`)}`);
392
+ if (def) {
393
+ try {
394
+ requireArgs(plan.args, def.inputSchema, plan.tool);
395
+ }
396
+ catch (err) {
397
+ if (err instanceof CliError)
398
+ throw new CliError(`${err.message}`, { code: EXIT.USAGE, hint: `Run it directly: scribeclock ${plan.tool} ${(def.inputSchema.required ?? []).map((r) => `--${kebab(r)} <value>`).join(" ")}` });
399
+ throw err;
400
+ }
401
+ }
402
+ if (!readOnly && !policy.confirm && !ctx.flags.yes) {
403
+ const ok = await confirm(ctx, `Run ${call}?`);
404
+ if (!ok)
405
+ return EXIT.ERROR;
406
+ }
407
+ const result = await executeGuarded(ctx, plan.tool, plan.args, def);
408
+ if (result.status !== "done")
409
+ return result.status === "cancelled" ? EXIT.ERROR : EXIT.OK;
410
+ if (ctx.flags.json)
411
+ ctx.out.jsonOut({ ...result.data, _plan: plan });
412
+ else if (plan.tool === "list_tasks" && Array.isArray(result.data.tasks))
413
+ renderTaskTable(ctx, result.data.tasks);
414
+ else
415
+ renderResult(ctx.out, result.data);
416
+ return EXIT.OK;
417
+ },
418
+ });
419
+ export const AUTOMATION_COMMANDS = [watchTasks, serveCommand, mcpConfigCommand, askCommand];
420
+ void num;