@motir/cli 0.0.1 → 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.
Files changed (3) hide show
  1. package/README.md +532 -5
  2. package/dist/index.js +3770 -0
  3. package/package.json +39 -6
package/dist/index.js ADDED
@@ -0,0 +1,3770 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/program.ts
4
+ import { Command as Command2 } from "commander";
5
+
6
+ // src/version.ts
7
+ var CLI_VERSION = "0.1.0";
8
+
9
+ // src/mcpClient.ts
10
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
11
+ import {
12
+ StreamableHTTPClientTransport,
13
+ StreamableHTTPError
14
+ } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
15
+
16
+ // src/errors.ts
17
+ var CliError = class extends Error {
18
+ exitCode;
19
+ /** An optional one-line hint shown under the error (e.g. how to recover). */
20
+ hint;
21
+ constructor(message, opts = {}) {
22
+ super(message);
23
+ this.name = "CliError";
24
+ this.exitCode = opts.exitCode ?? 1;
25
+ this.hint = opts.hint;
26
+ }
27
+ };
28
+ var AuthError = class extends CliError {
29
+ constructor(message = "Token invalid or expired.") {
30
+ super(message, { exitCode: 1, hint: "Run `motir auth login` to authenticate." });
31
+ this.name = "AuthError";
32
+ }
33
+ };
34
+ var NotLinkedError = class extends CliError {
35
+ constructor() {
36
+ super("No Motir project link found in this directory or any parent.", {
37
+ exitCode: 1,
38
+ hint: "Run `motir link` at your workspace root to bind a project."
39
+ });
40
+ this.name = "NotLinkedError";
41
+ }
42
+ };
43
+
44
+ // src/config/userConfig.ts
45
+ import { homedir } from "os";
46
+ import { join } from "path";
47
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
48
+ var EMPTY = { tokens: {} };
49
+ function normalizeServerUrl(url) {
50
+ return url.trim().replace(/\/+$/, "");
51
+ }
52
+ function configDir() {
53
+ const base = process.env["MOTIR_CONFIG_HOME"] || process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config");
54
+ return join(base, "motir");
55
+ }
56
+ function configPath() {
57
+ return join(configDir(), "config.json");
58
+ }
59
+ function stateDir() {
60
+ const base = process.env["MOTIR_STATE_HOME"] || process.env["MOTIR_CONFIG_HOME"] || process.env["XDG_STATE_HOME"] || join(homedir(), ".local", "state");
61
+ return join(base, "motir");
62
+ }
63
+ function readUserConfig() {
64
+ const path = configPath();
65
+ if (!existsSync(path)) return { tokens: {} };
66
+ try {
67
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
68
+ const config = { tokens: parsed.tokens ?? {} };
69
+ if (typeof parsed.agentCommand === "string") config.agentCommand = parsed.agentCommand;
70
+ return config;
71
+ } catch {
72
+ return { ...EMPTY, tokens: {} };
73
+ }
74
+ }
75
+ function writeUserConfig(config) {
76
+ const dir = configDir();
77
+ mkdirSync(dir, { recursive: true, mode: 448 });
78
+ const path = configPath();
79
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
80
+ chmodSync(path, 384);
81
+ }
82
+ function getCredential(serverUrl) {
83
+ return readUserConfig().tokens[normalizeServerUrl(serverUrl)];
84
+ }
85
+ var TOKEN_ENV_VAR = "MOTIR_TOKEN";
86
+ var SERVER_ENV_VAR = "MOTIR_SERVER";
87
+ function envValue(name) {
88
+ const raw = process.env[name];
89
+ if (typeof raw !== "string") return void 0;
90
+ const trimmed = raw.trim();
91
+ return trimmed ? trimmed : void 0;
92
+ }
93
+ function envToken() {
94
+ return envValue(TOKEN_ENV_VAR);
95
+ }
96
+ function envServerUrl() {
97
+ const value = envValue(SERVER_ENV_VAR);
98
+ return value ? normalizeServerUrl(value) : void 0;
99
+ }
100
+ function credentialOriginLabel(source) {
101
+ return source === "environment" ? `environment (${TOKEN_ENV_VAR})` : configPath();
102
+ }
103
+ function resolveCredential(serverUrl) {
104
+ const token = envToken();
105
+ if (token) return { token, source: "environment", origin: credentialOriginLabel("environment") };
106
+ const stored = getCredential(serverUrl);
107
+ if (!stored) return void 0;
108
+ return { ...stored, source: "config", origin: credentialOriginLabel("config") };
109
+ }
110
+ function setCredential(serverUrl, credential) {
111
+ const config = readUserConfig();
112
+ config.tokens[normalizeServerUrl(serverUrl)] = credential;
113
+ writeUserConfig(config);
114
+ }
115
+ function removeCredential(serverUrl) {
116
+ const config = readUserConfig();
117
+ const key = normalizeServerUrl(serverUrl);
118
+ if (!(key in config.tokens)) return false;
119
+ delete config.tokens[key];
120
+ writeUserConfig(config);
121
+ return true;
122
+ }
123
+ function getAgentCommand() {
124
+ const value = readUserConfig().agentCommand?.trim();
125
+ return value ? value : void 0;
126
+ }
127
+ function listServers() {
128
+ return Object.keys(readUserConfig().tokens);
129
+ }
130
+ function displayTokenPrefix(token) {
131
+ const head = token.slice(0, 14);
132
+ return token.length > head.length ? `${head}\u2026` : head;
133
+ }
134
+
135
+ // src/mcpClient.ts
136
+ function textOf(result) {
137
+ return (result.content ?? []).filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n").trim();
138
+ }
139
+ function isUnauthorized(err) {
140
+ if (err instanceof StreamableHTTPError) return err.code === 401;
141
+ const message = err instanceof Error ? err.message : String(err);
142
+ return /\b401\b|unauthorized/i.test(message);
143
+ }
144
+ function mcpEndpoint(serverUrl) {
145
+ return new URL("/api/mcp", normalizeServerUrl(serverUrl) + "/");
146
+ }
147
+ var MotirClient = class {
148
+ serverUrl;
149
+ token;
150
+ client = null;
151
+ transport = null;
152
+ constructor(opts) {
153
+ this.serverUrl = normalizeServerUrl(opts.serverUrl);
154
+ this.token = opts.token;
155
+ }
156
+ /** Open the connection. A 401 (bad/revoked/expired PAT) → {@link AuthError}. */
157
+ async connect() {
158
+ if (this.client) return;
159
+ const transport = new StreamableHTTPClientTransport(mcpEndpoint(this.serverUrl), {
160
+ requestInit: { headers: { Authorization: `Bearer ${this.token}` } }
161
+ });
162
+ const client = new Client({ name: "motir-cli", version: CLI_VERSION });
163
+ try {
164
+ await client.connect(transport);
165
+ } catch (err) {
166
+ if (isUnauthorized(err)) throw new AuthError();
167
+ throw new CliError(`Could not reach the Motir server at ${this.serverUrl}: ${errMsg(err)}`);
168
+ }
169
+ this.client = client;
170
+ this.transport = transport;
171
+ }
172
+ async close() {
173
+ await this.client?.close();
174
+ await this.transport?.close();
175
+ this.client = null;
176
+ this.transport = null;
177
+ }
178
+ requireClient() {
179
+ if (!this.client) throw new CliError("MCP client used before connect().");
180
+ return this.client;
181
+ }
182
+ /** The server's advertised tool names — the `auth login` validation probe. */
183
+ async listToolNames() {
184
+ try {
185
+ const { tools } = await this.requireClient().listTools();
186
+ return tools.map((t) => t.name);
187
+ } catch (err) {
188
+ throw this.mapCallError(err);
189
+ }
190
+ }
191
+ /**
192
+ * Call a tool and return its `structuredContent` typed as `T`. A tool that
193
+ * comes back `isError` throws a {@link CliError} carrying the tool's own
194
+ * `code: message` text — never a swallowed JSON-RPC error. Unauthorized →
195
+ * {@link AuthError}.
196
+ */
197
+ async callStructured(name, args = {}) {
198
+ let result;
199
+ try {
200
+ result = await this.requireClient().callTool({
201
+ name,
202
+ arguments: args
203
+ });
204
+ } catch (err) {
205
+ throw this.mapCallError(err);
206
+ }
207
+ if (result.isError) {
208
+ throw new CliError(textOf(result) || `Tool ${name} failed.`);
209
+ }
210
+ return result.structuredContent;
211
+ }
212
+ mapCallError(err) {
213
+ if (err instanceof CliError) return err;
214
+ if (isUnauthorized(err)) return new AuthError();
215
+ return new CliError(errMsg(err));
216
+ }
217
+ // ── Typed tool wrappers ──────────────────────────────────────────────────
218
+ // These name the MCP tools the CLI consumes. Listing/auth/link commands use
219
+ // `whoami`; the read (7.9.2) and dispatch (7.9.3) commands use the rest.
220
+ whoami() {
221
+ return this.callStructured("whoami");
222
+ }
223
+ listReady(args) {
224
+ return this.callStructured("list_ready", { ...args });
225
+ }
226
+ nextReady(args) {
227
+ return this.callStructured("next_ready", { ...args });
228
+ }
229
+ getWorkItem(key) {
230
+ return this.callStructured("get_work_item", { key });
231
+ }
232
+ transitionStatus(args) {
233
+ return this.callStructured("transition_status", { ...args });
234
+ }
235
+ /**
236
+ * The CANONICAL, server-generated prompt for one item (MOTIR-1802). A pure
237
+ * READ — it never claims the item or moves its status, so re-printing an
238
+ * in-progress item's prompt is safe. The CLI prints this text verbatim; it
239
+ * never assembles a prompt grammar of its own.
240
+ *
241
+ * `sessionBranch` is the unattended-run SEED (`motir auto`, MOTIR-882): the
242
+ * branch to use when the item carries no lineage of its own. The server treats
243
+ * it as a fallback only — an item already on a lineage keeps that one — so the
244
+ * CLI can start a run's first item on the run's branch without ever being able
245
+ * to redirect an existing chain.
246
+ */
247
+ dispatchPrompt(key, opts = {}) {
248
+ return this.callStructured("dispatch_prompt", {
249
+ key,
250
+ ...opts.sessionBranch ? { sessionBranch: opts.sessionBranch } : {}
251
+ });
252
+ }
253
+ /** Record an item's work as integrated on a session branch (7.8.11): moves it
254
+ * to `in_review` AND stamps `session_branch` in one transaction. */
255
+ markIntegrated(args) {
256
+ return this.callStructured("mark_integrated", { ...args });
257
+ }
258
+ /** Bulk close-out for a merged session PR (7.8.11): every item recorded on
259
+ * the branch → done, `session_branch` cleared, with per-item outcomes. */
260
+ completeSession(args) {
261
+ return this.callStructured("complete_session", { ...args });
262
+ }
263
+ /**
264
+ * Submit an AI expansion of one CONTAINER item (MOTIR-1825) and return the
265
+ * moment the job is accepted — `{ jobId, planId }`, no streaming, no poll.
266
+ *
267
+ * There is deliberately no REST fallback here: expansion also has a
268
+ * cookie-authed `POST /api/ai/expand`, but the CLI is an MCP client only (the
269
+ * Story 7.9 header — one auth path), so the tool IS the mechanism.
270
+ *
271
+ * Firing this does NOT grow the tree. `motir auto --include-planning` calls it
272
+ * for an unexpanded epic/story and moves on; what comes back is a plan awaiting
273
+ * a human's approval in Motir.
274
+ */
275
+ expandItem(key) {
276
+ return this.callStructured("expand_item", { key });
277
+ }
278
+ /**
279
+ * Open — or RESUME — the planning conversation for a scope, and read its
280
+ * thread (MOTIR-1832).
281
+ *
282
+ * Addressed by SCOPE (`projectKey` + optional `targetKeys`), never by a
283
+ * session id: re-opening the same anchor set returns the SAME row the web
284
+ * panel is looking at, so the CLI cannot fork a second conversation about the
285
+ * same items. Opening submits nothing and costs nothing.
286
+ */
287
+ openPlanSession(args) {
288
+ return this.callStructured("open_plan_session", { ...args });
289
+ }
290
+ /**
291
+ * Add ONE turn to the conversation. APPENDING IS NOT SUBMITTING — the turn is
292
+ * server-side the moment this returns (so quitting can never lose it) and no
293
+ * job starts, no credits are spent, and no work item changes.
294
+ */
295
+ appendPlanTurn(args) {
296
+ return this.callStructured("append_plan_turn", { ...args });
297
+ }
298
+ /**
299
+ * Send the thread's ACCUMULATED intent — every turn, in order — as ONE change,
300
+ * returning `{ jobId, planId }` the moment the job is accepted.
301
+ *
302
+ * This does NOT create work items: the job produces a plan of PROPOSALS, and
303
+ * approving that plan in Motir is the only thing that turns one into a work
304
+ * item. A thread with no turns is refused by the server; a failed submit
305
+ * leaves the thread intact.
306
+ */
307
+ submitPlanSession(args) {
308
+ return this.callStructured("submit_plan_session", { ...args });
309
+ }
310
+ /** What became of a submitted planning job (MOTIR-1825) — the plan's status
311
+ * AND, while it is still generating, whether the job is alive or already
312
+ * dead (a failed job leaves the plan generating forever). */
313
+ getPlanStatus(args) {
314
+ return this.callStructured("get_plan_status", { ...args });
315
+ }
316
+ /** Read a plan WITH its proposals (MOTIR-1837) — what was proposed, not just
317
+ * how many. Still PROPOSALS: nothing here exists in the tree. */
318
+ getPlan(args) {
319
+ return this.callStructured("get_plan", { ...args });
320
+ }
321
+ listSprints(args) {
322
+ return this.callStructured("list_sprints", { ...args });
323
+ }
324
+ searchWorkItems(args) {
325
+ return this.callStructured("search_work_items", { ...args });
326
+ }
327
+ };
328
+ function errMsg(err) {
329
+ return err instanceof Error ? err.message : String(err);
330
+ }
331
+
332
+ // src/output.ts
333
+ function out(line = "") {
334
+ process.stdout.write(line + "\n");
335
+ }
336
+ function info(line = "") {
337
+ process.stderr.write(line + "\n");
338
+ }
339
+ function outVerbatim(text) {
340
+ process.stdout.write(text.endsWith("\n") ? text : text + "\n");
341
+ }
342
+ function json(value) {
343
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
344
+ }
345
+
346
+ // src/prompts.ts
347
+ import { createInterface } from "readline";
348
+ function isInteractive() {
349
+ return Boolean(process.stdin.isTTY && process.stderr.isTTY);
350
+ }
351
+ function promptLine(question, defaultValue) {
352
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
353
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
354
+ return new Promise((resolve3) => {
355
+ rl.question(`${question}${suffix}: `, (answer) => {
356
+ rl.close();
357
+ const trimmed = answer.trim();
358
+ resolve3(trimmed.length > 0 ? trimmed : defaultValue ?? "");
359
+ });
360
+ });
361
+ }
362
+ function promptLineOrNull(question) {
363
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
364
+ return new Promise((resolve3) => {
365
+ let answered = false;
366
+ rl.question(`${question}`, (answer) => {
367
+ answered = true;
368
+ rl.close();
369
+ resolve3(answer);
370
+ });
371
+ rl.on("close", () => {
372
+ if (!answered) resolve3(null);
373
+ });
374
+ });
375
+ }
376
+ function promptSecret(question) {
377
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
378
+ rl._writeToOutput = () => {
379
+ };
380
+ process.stderr.write(`${question}: `);
381
+ return new Promise((resolve3) => {
382
+ rl.question("", (answer) => {
383
+ rl.close();
384
+ process.stderr.write("\n");
385
+ resolve3(answer.trim());
386
+ });
387
+ });
388
+ }
389
+
390
+ // src/config/linkConfig.ts
391
+ import { dirname, isAbsolute, join as join2, resolve } from "path";
392
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
393
+ var LINK_FILENAME = ".motir.json";
394
+ function isLinkConfig(value) {
395
+ if (!value || typeof value !== "object") return false;
396
+ const v = value;
397
+ return typeof v["serverUrl"] === "string" && typeof v["workspace"] === "string" && typeof v["project"] === "string";
398
+ }
399
+ function findLink(startDir = process.cwd()) {
400
+ let dir = resolve(startDir);
401
+ for (; ; ) {
402
+ const path = join2(dir, LINK_FILENAME);
403
+ if (existsSync2(path)) {
404
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
405
+ if (!isLinkConfig(parsed)) {
406
+ throw new NotLinkedError();
407
+ }
408
+ return { dir, path, config: parsed };
409
+ }
410
+ const parent = dirname(dir);
411
+ if (parent === dir) return null;
412
+ dir = parent;
413
+ }
414
+ }
415
+ function requireLink(startDir = process.cwd()) {
416
+ const found = findLink(startDir);
417
+ if (!found) throw new NotLinkedError();
418
+ return found;
419
+ }
420
+ function writeLink(dir, config) {
421
+ const path = join2(dir, LINK_FILENAME);
422
+ writeFileSync2(path, JSON.stringify(config, null, 2) + "\n");
423
+ return path;
424
+ }
425
+ function resolveRepo(rootDir, config, repoName) {
426
+ const override = config.repos?.[repoName];
427
+ let path;
428
+ let source;
429
+ if (override !== void 0) {
430
+ path = isAbsolute(override) ? override : resolve(rootDir, override);
431
+ source = "override";
432
+ } else {
433
+ path = resolve(rootDir, repoName);
434
+ source = "convention";
435
+ }
436
+ return { repoName, path, source, exists: existsSync2(path) };
437
+ }
438
+ function overrideRepoNames(config) {
439
+ return Object.keys(config.repos ?? {}).sort();
440
+ }
441
+ function withRepoOverride(config, repoName, path) {
442
+ return { ...config, repos: { ...config.repos ?? {}, [repoName]: path } };
443
+ }
444
+ function withoutRepoOverride(config, repoName) {
445
+ if (config.repos?.[repoName] === void 0) {
446
+ throw new Error(`No override for repo "${repoName}".`);
447
+ }
448
+ const next = { ...config.repos ?? {} };
449
+ delete next[repoName];
450
+ const result = { ...config };
451
+ if (Object.keys(next).length > 0) result.repos = next;
452
+ else delete result.repos;
453
+ return result;
454
+ }
455
+
456
+ // src/serverResolve.ts
457
+ var DEFAULT_SERVER_URL = "https://app.motir.co";
458
+ function resolveServerUrl(explicit) {
459
+ if (explicit) return normalizeServerUrl(explicit);
460
+ const fromEnv = envServerUrl();
461
+ if (fromEnv) return fromEnv;
462
+ const link = findLink();
463
+ if (link) return normalizeServerUrl(link.config.serverUrl);
464
+ const servers = listServers();
465
+ if (servers.length === 1) return servers[0];
466
+ if (servers.length === 0) return DEFAULT_SERVER_URL;
467
+ if (servers.some((stored) => stored === DEFAULT_SERVER_URL)) return DEFAULT_SERVER_URL;
468
+ throw new CliError("Multiple servers are configured; pass --server <url> to choose one.", {
469
+ hint: `Configured: ${servers.join(", ")}`
470
+ });
471
+ }
472
+
473
+ // src/commands/auth.ts
474
+ async function authLogin(opts) {
475
+ let serverUrl = opts.server;
476
+ if (!serverUrl) {
477
+ if (!isInteractive()) {
478
+ throw new CliError("No server URL given.", { hint: "Pass --server <url>." });
479
+ }
480
+ serverUrl = await promptLine("Server URL", DEFAULT_SERVER_URL);
481
+ }
482
+ serverUrl = normalizeServerUrl(serverUrl);
483
+ let token = opts.token ?? envToken();
484
+ if (!token) {
485
+ if (!isInteractive()) {
486
+ throw new CliError("No token given.", {
487
+ hint: "Pass --token <pat>, set MOTIR_TOKEN, or run interactively."
488
+ });
489
+ }
490
+ token = await promptSecret("Personal access token");
491
+ }
492
+ if (!token) throw new CliError("A token is required.");
493
+ const client = new MotirClient({ serverUrl, token });
494
+ try {
495
+ await client.connect();
496
+ await client.listToolNames();
497
+ const who = await client.whoami();
498
+ setCredential(serverUrl, { token, user: who.user });
499
+ const ws = who.workspace ? ` (workspace ${who.workspace.name})` : "";
500
+ info(`Logged in as ${who.user.email} on ${serverUrl}${ws}.`);
501
+ } finally {
502
+ await client.close();
503
+ }
504
+ }
505
+ async function authStatus(opts) {
506
+ const serverUrl = resolveServerUrl(opts.server);
507
+ const cred = resolveCredential(serverUrl);
508
+ if (!cred) {
509
+ throw new CliError(`Not logged in to ${serverUrl}.`, {
510
+ hint: `Run \`motir auth login\`, or set ${TOKEN_ENV_VAR}.`
511
+ });
512
+ }
513
+ out(`Server: ${serverUrl}`);
514
+ out(`Token: ${displayTokenPrefix(cred.token)}`);
515
+ out(`Source: ${cred.origin}`);
516
+ const client = new MotirClient({ serverUrl, token: cred.token });
517
+ try {
518
+ await client.connect();
519
+ const who = await client.whoami();
520
+ out(`User: ${who.user.name || who.user.email} <${who.user.email}>`);
521
+ if (who.workspace) out(`Workspace: ${who.workspace.name} (${who.workspace.slug})`);
522
+ } finally {
523
+ await client.close();
524
+ }
525
+ }
526
+ async function authLogout(opts) {
527
+ const serverUrl = resolveServerUrl(opts.server);
528
+ const removed = removeCredential(serverUrl);
529
+ if (removed) info(`Logged out of ${serverUrl}.`);
530
+ else info(`No stored credential for ${serverUrl}.`);
531
+ if (envToken()) {
532
+ info(`${TOKEN_ENV_VAR} is still set \u2014 it overrides the stored credential. Unset it to finish.`);
533
+ }
534
+ }
535
+
536
+ // src/commands/link.ts
537
+ function tokenFor(serverUrl) {
538
+ const cred = resolveCredential(serverUrl);
539
+ if (!cred) {
540
+ throw new CliError(`Not logged in to ${serverUrl}.`, {
541
+ hint: "Run `motir auth login`, or set MOTIR_TOKEN."
542
+ });
543
+ }
544
+ return cred.token;
545
+ }
546
+ async function assertProjectAccessible(client, projectKey) {
547
+ try {
548
+ await client.listReady({ projectKey, limit: 1 });
549
+ } catch (err) {
550
+ if (err instanceof CliError) {
551
+ throw new CliError(`Project "${projectKey}" is not accessible with this token.`, {
552
+ hint: "Check the project key, or your token\u2019s workspace."
553
+ });
554
+ }
555
+ throw err;
556
+ }
557
+ }
558
+ async function linkCommand(opts) {
559
+ const cwd = process.cwd();
560
+ const existing = findLink(cwd);
561
+ const noBindingFlags = !opts.server && !opts.workspace && !opts.project && !opts.repo;
562
+ if (existing && noBindingFlags) {
563
+ showLink(existing.dir, existing.config);
564
+ return;
565
+ }
566
+ const serverUrl = resolveServerUrl(opts.server ?? existing?.config.serverUrl);
567
+ const token = tokenFor(serverUrl);
568
+ const client = new MotirClient({ serverUrl, token });
569
+ try {
570
+ await client.connect();
571
+ const who = await client.whoami();
572
+ const workspace = opts.workspace ?? existing?.config.workspace ?? who.workspace?.slug;
573
+ if (!workspace) {
574
+ throw new CliError("Could not determine the workspace.", {
575
+ hint: "Pass --workspace <slug>."
576
+ });
577
+ }
578
+ let project = opts.project ?? existing?.config.project;
579
+ if (!project) {
580
+ if (!isInteractive()) {
581
+ throw new CliError("No project given.", { hint: "Pass --project <key>." });
582
+ }
583
+ project = await promptLine("Project key");
584
+ }
585
+ if (!project) throw new CliError("A project key is required.");
586
+ await assertProjectAccessible(client, project);
587
+ const config = {
588
+ serverUrl,
589
+ workspace,
590
+ project,
591
+ ...existing?.config.repos ? { repos: { ...existing.config.repos } } : {}
592
+ };
593
+ const withRepo = opts.repo ? withRepoOverride(config, opts.repo, ".") : config;
594
+ const targetDir = existing?.dir ?? cwd;
595
+ const path = writeLink(targetDir, withRepo);
596
+ info(`Linked ${targetDir} \u2192 ${workspace}/${project} on ${serverUrl}`);
597
+ info(`Wrote ${path}`);
598
+ showLink(targetDir, withRepo);
599
+ } finally {
600
+ await client.close();
601
+ }
602
+ }
603
+ function linkAddCommand(repo, path) {
604
+ const found = requireLink();
605
+ const next = withRepoOverride(found.config, repo, path);
606
+ writeLink(found.dir, next);
607
+ info(`Override set: ${repo} \u2192 ${path}`);
608
+ showLink(found.dir, next);
609
+ }
610
+ function linkRemoveCommand(repo) {
611
+ const found = requireLink();
612
+ let next;
613
+ try {
614
+ next = withoutRepoOverride(found.config, repo);
615
+ } catch {
616
+ throw new CliError(`No override for repo "${repo}".`);
617
+ }
618
+ writeLink(found.dir, next);
619
+ info(`Override removed: ${repo}`);
620
+ showLink(found.dir, next);
621
+ }
622
+ function showLink(rootDir, config) {
623
+ out(`Root: ${rootDir}`);
624
+ out(`Server: ${config.serverUrl}`);
625
+ out(`Workspace: ${config.workspace}`);
626
+ out(`Project: ${config.project}`);
627
+ const names = overrideRepoNames(config);
628
+ if (names.length === 0) {
629
+ out("Repos: (none listed) \u2014 checkouts resolve by convention <root>/<repoName>");
630
+ return;
631
+ }
632
+ out("Repos:");
633
+ for (const name of names) {
634
+ const r = resolveRepo(rootDir, config, name);
635
+ const state = r.exists ? "exists" : "not yet";
636
+ out(` ${name} \u2192 ${r.path} [${r.source}, ${state}]`);
637
+ }
638
+ }
639
+
640
+ // src/render.ts
641
+ var FILTER_VERSION = "v1";
642
+ var IN_FLIGHT_STATUS_KEYS = ["in_progress", "in_review"];
643
+ function inFlightFilter() {
644
+ return {
645
+ version: FILTER_VERSION,
646
+ combinator: "and",
647
+ conditions: [{ field: "status", operator: "is_any_of", value: [...IN_FLIGHT_STATUS_KEYS] }]
648
+ };
649
+ }
650
+ function sprintFilter(sprintId, kinds) {
651
+ const conditions = [
652
+ { field: "sprint", operator: "is_any_of", value: [sprintId] }
653
+ ];
654
+ if (kinds && kinds.length > 0) {
655
+ conditions.push({ field: "kind", operator: "is_any_of", value: kinds });
656
+ }
657
+ return { version: FILTER_VERSION, combinator: "and", conditions };
658
+ }
659
+ function issueUrl(serverUrl, key) {
660
+ return `${normalizeServerUrl(serverUrl)}/issues/${encodeURIComponent(key)}`;
661
+ }
662
+ function truncate(text, max) {
663
+ if (max <= 1) return text.slice(0, Math.max(0, max));
664
+ return text.length > max ? text.slice(0, max - 1) + "\u2026" : text;
665
+ }
666
+ function formatTable(headers, rows, opts = {}) {
667
+ const right = new Set(opts.rightAlign ?? []);
668
+ const widths = headers.map(
669
+ (h, col) => Math.max(h.length, ...rows.map((r) => (r[col] ?? "").length))
670
+ );
671
+ const pad = (cell, col) => {
672
+ const w = widths[col] ?? cell.length;
673
+ return right.has(col) ? cell.padStart(w) : cell.padEnd(w);
674
+ };
675
+ const line = (cells) => cells.map((c, col) => pad(c, col)).join(" ").trimEnd();
676
+ const header = line(headers);
677
+ const underline = widths.map((w) => "\u2500".repeat(w)).join(" ");
678
+ return [header, underline, ...rows.map(line)].join("\n");
679
+ }
680
+ var TERMINAL_STATUS_KEYS = ["done", "cancelled"];
681
+ function isSatisfiedBlocker(status) {
682
+ return TERMINAL_STATUS_KEYS.includes(status);
683
+ }
684
+ var EDGE_KEYS_BUDGET = 3;
685
+ var SATISFIED_MARK = "\u2713";
686
+ var EXTERNAL_MARK = "\u2197";
687
+ var MARK_LEGEND = [
688
+ [SATISFIED_MARK, `${SATISFIED_MARK} = already done`],
689
+ [EXTERNAL_MARK, `${EXTERNAL_MARK} = blocker outside this parent`]
690
+ ];
691
+ function markLegend(cells) {
692
+ const shown = MARK_LEGEND.filter(([mark]) => cells.some((cell) => cell.includes(mark)));
693
+ return shown.length > 0 ? shown.map(([, text]) => text).join(" \xB7 ") : null;
694
+ }
695
+ function overflowKeys(keys, budget) {
696
+ if (keys.length === 0) return "";
697
+ if (keys.length <= budget) return keys.join(", ");
698
+ return `${keys.slice(0, budget).join(", ")} +${keys.length - budget}`;
699
+ }
700
+ function edgeCell(edges, budget = EDGE_KEYS_BUDGET) {
701
+ if (!edges) return "";
702
+ const live = edges.filter((e) => !isSatisfiedBlocker(e.status)).map((e) => e.key);
703
+ const settled = edges.filter((e) => isSatisfiedBlocker(e.status)).map((e) => `${e.key}${SATISFIED_MARK}`);
704
+ return overflowKeys([...live, ...settled], budget);
705
+ }
706
+ function hasEdges(rows) {
707
+ return rows.length > 0 && rows.every((row) => row.dependencies !== void 0);
708
+ }
709
+ function withLegend(table, cells) {
710
+ const legend = markLegend(cells);
711
+ return legend ? `${table}
712
+ ${legend}` : table;
713
+ }
714
+ var READY_HEADERS = ["KEY", "KIND", "PRIORITY", "ASSIGNEE", "TITLE"];
715
+ var READY_EDGE_HEADERS = ["KEY", "KIND", "PRIORITY", "ASSIGNEE", "BLOCKS", "TITLE"];
716
+ function renderReadyTable(items, titleWidth = 60) {
717
+ if (items.length === 0) return "No ready work items.";
718
+ const graphed = hasEdges(items);
719
+ const rows = items.map((it) => [
720
+ it.key,
721
+ it.kind,
722
+ it.priority,
723
+ it.assignee?.name ?? "unassigned",
724
+ ...graphed ? [edgeCell(it.dependencies?.blocks)] : [],
725
+ truncate(it.title, titleWidth)
726
+ ]);
727
+ const count = `${items.length} ready work item${items.length === 1 ? "" : "s"}:`;
728
+ const headers = graphed ? READY_EDGE_HEADERS : READY_HEADERS;
729
+ const table = `${count}
730
+ ${formatTable(headers, rows)}`;
731
+ return graphed ? withLegend(
732
+ table,
733
+ rows.map((row) => row[4] ?? "")
734
+ ) : table;
735
+ }
736
+ function formatSprintWindow(sprint) {
737
+ if (!sprint.startDate && !sprint.endDate) return "";
738
+ return `${sprint.startDate ?? "\u2014"} \u2192 ${sprint.endDate ?? "\u2014"}`;
739
+ }
740
+ var NONE = "\u2014";
741
+ var SPRINTS_HEADERS = ["", "STATE", "NAME", "ITEMS", "POINTS", "WINDOW"];
742
+ function renderSprintsTable(sprints, nameWidth = 40) {
743
+ if (sprints.length === 0) return "No sprints.";
744
+ const ordered = [...sprints].sort((a, b) => a.sequence - b.sequence);
745
+ const rows = ordered.map((s) => [
746
+ s.state === "active" ? "*" : "",
747
+ s.state,
748
+ truncate(s.name, nameWidth),
749
+ String(s.issueCount),
750
+ s.committedPoints === null ? NONE : String(s.committedPoints),
751
+ formatSprintWindow(s) || NONE
752
+ ]);
753
+ const count = `${ordered.length} sprint${ordered.length === 1 ? "" : "s"}:`;
754
+ return `${count}
755
+ ${formatTable(SPRINTS_HEADERS, rows, { rightAlign: [3, 4] })}`;
756
+ }
757
+ function renderSprintHeader(sprint) {
758
+ const lines = [`${sprint.name} [${sprint.state}]`];
759
+ const window = formatSprintWindow(sprint);
760
+ if (window) lines.push(window);
761
+ if (sprint.goal) lines.push(`goal: ${sprint.goal}`);
762
+ if (sprint.committedIssueCount !== null || sprint.committedPoints !== null) {
763
+ const issues = sprint.committedIssueCount === null ? NONE : String(sprint.committedIssueCount);
764
+ const points = sprint.committedPoints === null ? NONE : String(sprint.committedPoints);
765
+ lines.push(`committed: ${issues} issues \xB7 ${points} points`);
766
+ }
767
+ return lines.join("\n");
768
+ }
769
+ var SPRINT_ITEM_HEADERS = ["KEY", "KIND", "STATUS", "PRIORITY", "TITLE"];
770
+ var SPRINT_ITEM_EDGE_HEADERS = [
771
+ "KEY",
772
+ "KIND",
773
+ "STATUS",
774
+ "PRIORITY",
775
+ "BLOCKED BY",
776
+ "BLOCKS",
777
+ "TITLE"
778
+ ];
779
+ function renderSprintItems(items, total, titleWidth = 60) {
780
+ if (items.length === 0) return "No work items in this sprint.";
781
+ const graphed = hasEdges(items);
782
+ const rows = items.map((it) => [
783
+ it.identifier,
784
+ it.kind,
785
+ it.status,
786
+ it.priority,
787
+ ...graphed ? [edgeCell(it.dependencies?.blockedBy), edgeCell(it.dependencies?.blocks)] : [],
788
+ truncate(it.title, titleWidth)
789
+ ]);
790
+ const noun = `work item${total === 1 ? "" : "s"}`;
791
+ const count = items.length === total ? `${total} ${noun}:` : `${items.length} of ${total} ${noun} (the rest could not be collected):`;
792
+ const headers = graphed ? SPRINT_ITEM_EDGE_HEADERS : SPRINT_ITEM_HEADERS;
793
+ const table = `${count}
794
+ ${formatTable(headers, rows)}`;
795
+ return graphed ? withLegend(
796
+ table,
797
+ rows.flatMap((row) => [row[4] ?? "", row[5] ?? ""])
798
+ ) : table;
799
+ }
800
+ function resolveSprintRef(sprints, ref) {
801
+ if (ref === void 0 || ref.trim() === "") {
802
+ const active = sprints.find((s) => s.state === "active");
803
+ if (active) return active;
804
+ throw new CliError("No sprint is active in this project.", {
805
+ hint: "Run `motir sprints` to see every sprint, then `motir sprint <name>`."
806
+ });
807
+ }
808
+ const needle = ref.trim();
809
+ const byId = sprints.find((s) => s.id === needle);
810
+ if (byId) return byId;
811
+ const lower = needle.toLowerCase();
812
+ const byName = sprints.filter((s) => s.name.toLowerCase() === lower);
813
+ if (byName.length === 1) return byName[0];
814
+ const byPrefix = byName.length > 0 ? byName : sprints.filter(prefixMatch(lower));
815
+ if (byPrefix.length === 1) return byPrefix[0];
816
+ if (byPrefix.length > 1) {
817
+ throw new CliError(`"${needle}" matches ${byPrefix.length} sprints.`, {
818
+ hint: `Name one of: ${byPrefix.map((s) => s.name).join(", ")}.`
819
+ });
820
+ }
821
+ throw new CliError(`No sprint matches "${needle}".`, {
822
+ hint: "Run `motir sprints` to see this project\u2019s sprints (a name, a name prefix, or an id)."
823
+ });
824
+ }
825
+ function prefixMatch(lower) {
826
+ return (sprint) => sprint.name.toLowerCase().startsWith(lower);
827
+ }
828
+ function renderStatusBlock(pulse) {
829
+ const lines = [
830
+ `Project: ${pulse.projectKey}`,
831
+ `Ready: ${pulse.readyCount}`,
832
+ `In flight: ${pulse.inFlightCount} (in progress + in review)`
833
+ ];
834
+ if (pulse.activeSprint) {
835
+ const s = pulse.activeSprint;
836
+ lines.push(
837
+ `Sprint: ${s.name} [active, ${s.issueCount} issue${s.issueCount === 1 ? "" : "s"}]`
838
+ );
839
+ const window = formatSprintWindow(s);
840
+ if (window) lines.push(` ${window}`);
841
+ if (s.goal) lines.push(` goal: ${s.goal}`);
842
+ } else if (pulse.totalSprints === 0) {
843
+ lines.push("Sprint: (no sprints)");
844
+ } else {
845
+ lines.push("Sprint: (none active)");
846
+ }
847
+ return lines.join("\n");
848
+ }
849
+ var CHILD_HEADERS = ["KEY", "KIND", "STATUS", "TITLE"];
850
+ var EDGE_HEADERS = ["KEY", "STATUS", "TITLE"];
851
+ function childRow(child, titleWidth = 60) {
852
+ return [child.identifier, child.kind, child.status, truncate(child.title, titleWidth)];
853
+ }
854
+ function childRows(children, titleWidth = 60) {
855
+ return children.map((child) => childRow(child, titleWidth));
856
+ }
857
+ function edgeRows(links, titleWidth = 60) {
858
+ return links.map(({ item }) => [item.identifier, item.status, truncate(item.title, titleWidth)]);
859
+ }
860
+ function renderRelationTable(heading, rows, emptyLine, headers = EDGE_HEADERS, note) {
861
+ const title = `${heading} (${rows.length})`;
862
+ if (rows.length === 0) return `${title}
863
+ ${emptyLine}`;
864
+ return `${note ? `${title} \u2014 ${note}` : title}
865
+ ${formatTable(headers, rows)}`;
866
+ }
867
+ function assignChildWaves(children) {
868
+ const siblingKeys = new Set(children.map((c) => c.identifier));
869
+ const classified = children.map((child) => {
870
+ const entry = {
871
+ child,
872
+ wave: null,
873
+ siblingBlockers: [],
874
+ externalBlockers: [],
875
+ satisfiedBlockers: []
876
+ };
877
+ for (const edge of child.dependencies?.blockedBy ?? []) {
878
+ if (isSatisfiedBlocker(edge.status)) entry.satisfiedBlockers.push(edge.key);
879
+ else if (siblingKeys.has(edge.key)) entry.siblingBlockers.push(edge.key);
880
+ else entry.externalBlockers.push(edge.key);
881
+ }
882
+ return entry;
883
+ });
884
+ const placed = /* @__PURE__ */ new Map();
885
+ const ordered = [];
886
+ let remaining = classified;
887
+ for (let wave = 1; remaining.length > 0; wave += 1) {
888
+ const ready = remaining.filter((e) => e.siblingBlockers.every((k) => placed.has(k)));
889
+ if (ready.length === 0) break;
890
+ for (const entry of ready) {
891
+ entry.wave = wave;
892
+ ordered.push(entry);
893
+ }
894
+ for (const entry of ready) placed.set(entry.child.identifier, wave);
895
+ remaining = remaining.filter((e) => !placed.has(e.child.identifier));
896
+ }
897
+ return [...ordered, ...remaining];
898
+ }
899
+ function cycleMembers(waves) {
900
+ return waves.filter((w) => w.wave === null).map((w) => w.child.identifier);
901
+ }
902
+ var WAVE_CHILD_HEADERS = ["WAVE", "KEY", "KIND", "STATUS", "BLOCKED BY", "TITLE"];
903
+ var NO_WAVE = "\u2014";
904
+ function blockedByCell(entry, budget = EDGE_KEYS_BUDGET) {
905
+ return overflowKeys(
906
+ [
907
+ ...entry.siblingBlockers,
908
+ ...entry.externalBlockers.map((k) => `${k}${EXTERNAL_MARK}`),
909
+ ...entry.satisfiedBlockers.map((k) => `${k}${SATISFIED_MARK}`)
910
+ ],
911
+ budget
912
+ );
913
+ }
914
+ function waveChildRows(waves, titleWidth = 60, budget = EDGE_KEYS_BUDGET) {
915
+ return waves.map((entry) => {
916
+ const cells = childRow(entry.child, titleWidth);
917
+ const wave = entry.wave === null ? NO_WAVE : String(entry.wave);
918
+ return [wave, ...cells.slice(0, 3), blockedByCell(entry, budget), ...cells.slice(3)];
919
+ });
920
+ }
921
+ function renderChildrenSection(children, titleWidth = 60) {
922
+ if (!hasEdges(children)) {
923
+ return renderRelationTable(
924
+ "CHILDREN",
925
+ childRows(children, titleWidth),
926
+ "no children",
927
+ CHILD_HEADERS
928
+ );
929
+ }
930
+ const waves = assignChildWaves(children);
931
+ const rows = waveChildRows(waves, titleWidth);
932
+ const lines = [
933
+ renderRelationTable("CHILDREN", rows, "no children", WAVE_CHILD_HEADERS, "build order")
934
+ ];
935
+ const legend = markLegend(rows.map((row) => row[4] ?? ""));
936
+ if (legend) lines.push(legend);
937
+ const cycle = cycleMembers(waves);
938
+ if (cycle.length > 0) {
939
+ lines.push(
940
+ `\u26A0 dependency CYCLE \u2014 ${cycle.join(", ")} block each other and have no build order. Fix the blocked_by edges.`
941
+ );
942
+ }
943
+ return lines.join("\n");
944
+ }
945
+ function detailWithWaves(detail) {
946
+ if (!hasEdges(detail.children)) return detail;
947
+ return {
948
+ ...detail,
949
+ children: assignChildWaves(detail.children).map((entry) => ({
950
+ ...entry.child,
951
+ wave: entry.wave
952
+ }))
953
+ };
954
+ }
955
+ function renderItemHeader(item) {
956
+ const kindType = item.type ? `${item.kind}/${item.type}` : item.kind;
957
+ const fields = [`status ${item.status}`, `priority ${item.priority}`];
958
+ const add = (label, value) => {
959
+ if (value !== null) fields.push(`${label} ${value}`);
960
+ };
961
+ add("assignee", item.assigneeId);
962
+ add("type", item.type);
963
+ add("executor", item.executor);
964
+ add("points", item.storyPoints);
965
+ add("estimate", item.estimateMinutes === null ? null : `${item.estimateMinutes}m`);
966
+ add("sprint", item.sprintId);
967
+ add("repo", item.targetRepo);
968
+ return `${item.identifier} [${kindType}] ${item.title}
969
+ ${fields.join(" \xB7 ")}`;
970
+ }
971
+ function renderReadinessLine(readiness) {
972
+ if (readiness.ready) return "ready";
973
+ if (readiness.openBlockers.length > 0) {
974
+ return `blocked by ${readiness.openBlockers.map((b) => b.identifier).join(", ")}`;
975
+ }
976
+ const ancestor = readiness.blockedByAncestor;
977
+ if (ancestor) return `blocked by ancestor ${ancestor.identifier} \u2014 ${ancestor.title}`;
978
+ return "blocked";
979
+ }
980
+ function renderLineage(detail) {
981
+ return [...detail.ancestors.map((a) => a.identifier), detail.item.identifier].join(" \u203A ");
982
+ }
983
+ function renderWorkItemDetail(detail, titleWidth = 60) {
984
+ const sections = [
985
+ renderItemHeader(detail.item),
986
+ `READINESS
987
+ ${renderReadinessLine(detail.readiness)}`,
988
+ `LINEAGE
989
+ ${renderLineage(detail)}`,
990
+ renderChildrenSection(detail.children, titleWidth),
991
+ renderRelationTable("BLOCKED BY", edgeRows(detail.blockedBy, titleWidth), "none"),
992
+ renderRelationTable("BLOCKS", edgeRows(detail.blocks, titleWidth), "none"),
993
+ renderRelationTable("RELATES TO", edgeRows(detail.relatesTo, titleWidth), "none")
994
+ ];
995
+ const body = detail.item.descriptionMd;
996
+ if (body) sections.push(`DESCRIPTION
997
+ ${body.trimEnd()}`);
998
+ return sections.join("\n\n");
999
+ }
1000
+
1001
+ // src/session.ts
1002
+ async function openProjectSession() {
1003
+ const link = requireLink();
1004
+ const serverUrl = resolveServerUrl();
1005
+ const cred = resolveCredential(serverUrl);
1006
+ if (!cred) {
1007
+ throw new CliError(`Not logged in to ${serverUrl}.`, {
1008
+ hint: "Run `motir auth login`, or set MOTIR_TOKEN."
1009
+ });
1010
+ }
1011
+ const client = new MotirClient({ serverUrl, token: cred.token });
1012
+ await client.connect();
1013
+ return { link, serverUrl, projectKey: link.config.project, client };
1014
+ }
1015
+ async function withProjectSession(fn) {
1016
+ const session = await openProjectSession();
1017
+ try {
1018
+ return await fn(session);
1019
+ } finally {
1020
+ await session.client.close();
1021
+ }
1022
+ }
1023
+ var READY_PAGE_SIZE = 200;
1024
+ async function collectReady(client, projectKey, filter = {}) {
1025
+ const all = [];
1026
+ let cursor;
1027
+ do {
1028
+ const page = await client.listReady({
1029
+ projectKey,
1030
+ kinds: filter.kinds,
1031
+ assigneeId: filter.assigneeId,
1032
+ cursor,
1033
+ limit: READY_PAGE_SIZE
1034
+ });
1035
+ all.push(...page.items);
1036
+ cursor = page.nextCursor ?? void 0;
1037
+ } while (cursor);
1038
+ return all;
1039
+ }
1040
+ var SEARCH_PAGE_SIZE = 50;
1041
+ async function collectSprintItems(client, projectKey, sprintId, filter = {}) {
1042
+ const items = [];
1043
+ let cursor;
1044
+ let total = 0;
1045
+ do {
1046
+ const page = await client.searchWorkItems({
1047
+ projectKey,
1048
+ filter: sprintFilter(sprintId, filter.kinds),
1049
+ cursor,
1050
+ limit: SEARCH_PAGE_SIZE
1051
+ });
1052
+ items.push(...page.items);
1053
+ total = page.total;
1054
+ cursor = page.nextCursor ?? void 0;
1055
+ } while (cursor);
1056
+ return { items, total };
1057
+ }
1058
+
1059
+ // src/browser.ts
1060
+ import { spawn } from "child_process";
1061
+ function launcher(platform) {
1062
+ if (platform === "darwin") return { cmd: "open", args: [] };
1063
+ if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
1064
+ return { cmd: "xdg-open", args: [] };
1065
+ }
1066
+ function hasDisplay(env, platform) {
1067
+ if (platform === "darwin" || platform === "win32") return true;
1068
+ return Boolean(env["DISPLAY"] || env["WAYLAND_DISPLAY"]);
1069
+ }
1070
+ function openUrl(url, opts = {}) {
1071
+ const platform = opts.platform ?? process.platform;
1072
+ const env = opts.env ?? process.env;
1073
+ if (!hasDisplay(env, platform)) return Promise.resolve(false);
1074
+ const { cmd, args } = launcher(platform);
1075
+ return new Promise((resolve3) => {
1076
+ try {
1077
+ const child = spawn(cmd, [...args, url], { stdio: "ignore", detached: true });
1078
+ child.on("error", () => resolve3(false));
1079
+ child.unref();
1080
+ setTimeout(() => resolve3(true), 0);
1081
+ } catch {
1082
+ resolve3(false);
1083
+ }
1084
+ });
1085
+ }
1086
+
1087
+ // src/commands/read.ts
1088
+ var WORK_ITEM_KINDS = /* @__PURE__ */ new Set(["epic", "story", "task", "bug", "subtask"]);
1089
+ function parseKinds(raw) {
1090
+ if (!raw) return void 0;
1091
+ const kinds = raw.split(",").map((k) => k.trim().toLowerCase()).filter((k) => k.length > 0);
1092
+ if (kinds.length === 0) return void 0;
1093
+ const bad = kinds.filter((k) => !WORK_ITEM_KINDS.has(k));
1094
+ if (bad.length > 0) {
1095
+ throw new CliError(`Unknown work item kind(s): ${bad.join(", ")}.`, {
1096
+ hint: `Valid kinds: ${[...WORK_ITEM_KINDS].join(", ")}.`
1097
+ });
1098
+ }
1099
+ return kinds;
1100
+ }
1101
+ async function resolveAssignee(client, raw) {
1102
+ if (raw === void 0) return void 0;
1103
+ const value = raw.trim().toLowerCase();
1104
+ if (value === "unassigned" || value === "none") return "unassigned";
1105
+ if (value === "me") {
1106
+ const who = await client.whoami();
1107
+ return who.user.id;
1108
+ }
1109
+ return raw;
1110
+ }
1111
+ async function readyCommand(opts) {
1112
+ const kinds = parseKinds(opts.kinds);
1113
+ await withProjectSession(async ({ client, projectKey }) => {
1114
+ const assigneeId = await resolveAssignee(client, opts.assignee);
1115
+ const items = await collectReady(client, projectKey, { kinds, assigneeId });
1116
+ if (opts.json) {
1117
+ json(items);
1118
+ return;
1119
+ }
1120
+ out(renderReadyTable(items));
1121
+ });
1122
+ }
1123
+ async function statusCommand(opts) {
1124
+ const pulse = await withProjectSession(async ({ client, projectKey }) => {
1125
+ const ready = await collectReady(client, projectKey);
1126
+ const search = await client.searchWorkItems({
1127
+ projectKey,
1128
+ filter: inFlightFilter(),
1129
+ limit: 1
1130
+ });
1131
+ const { sprints } = await client.listSprints({ projectKey });
1132
+ const result = {
1133
+ projectKey,
1134
+ readyCount: ready.length,
1135
+ inFlightCount: search.total,
1136
+ activeSprint: sprints.find((s) => s.state === "active") ?? null,
1137
+ totalSprints: sprints.length
1138
+ };
1139
+ return result;
1140
+ });
1141
+ if (opts.json) {
1142
+ json(pulse);
1143
+ return;
1144
+ }
1145
+ out(renderStatusBlock(pulse));
1146
+ }
1147
+ var SPRINT_STATES = /* @__PURE__ */ new Set(["planned", "active", "complete"]);
1148
+ function parseSprintState(raw) {
1149
+ if (raw === void 0) return void 0;
1150
+ const state = raw.trim().toLowerCase();
1151
+ if (state === "") return void 0;
1152
+ if (!SPRINT_STATES.has(state)) {
1153
+ throw new CliError(`Unknown sprint state: ${raw.trim()}.`, {
1154
+ hint: `Valid states: ${[...SPRINT_STATES].join(", ")}.`
1155
+ });
1156
+ }
1157
+ return state;
1158
+ }
1159
+ async function sprintsCommand(opts) {
1160
+ const state = parseSprintState(opts.state);
1161
+ const sprints = await withProjectSession(async ({ client, projectKey }) => {
1162
+ const list = await client.listSprints({ projectKey });
1163
+ return state ? list.sprints.filter((s) => s.state === state) : list.sprints;
1164
+ });
1165
+ if (opts.json) {
1166
+ json(sprints);
1167
+ return;
1168
+ }
1169
+ out(renderSprintsTable(sprints));
1170
+ }
1171
+ async function sprintCommand(ref, opts) {
1172
+ const kinds = parseKinds(opts.kinds);
1173
+ const result = await withProjectSession(async ({ client, projectKey }) => {
1174
+ const { sprints } = await client.listSprints({ projectKey });
1175
+ const sprint = resolveSprintRef(sprints, ref);
1176
+ const { items, total } = await collectSprintItems(client, projectKey, sprint.id, { kinds });
1177
+ return { sprint, items, total };
1178
+ });
1179
+ if (opts.json) {
1180
+ json(result);
1181
+ return;
1182
+ }
1183
+ out(renderSprintHeader(result.sprint));
1184
+ out();
1185
+ out(renderSprintItems(result.items, result.total));
1186
+ }
1187
+ var ITEM_KEY = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
1188
+ function parseItemKey(raw, command) {
1189
+ const key = raw.trim().toUpperCase();
1190
+ if (!ITEM_KEY.test(key)) {
1191
+ throw new CliError(`"${raw.trim()}" is not a work item key.`, {
1192
+ hint: `Keys look like PROD-7. Run \`motir ${command} PROD-7\`, or \`motir ready\` to list what you can pick up.`
1193
+ });
1194
+ }
1195
+ return key;
1196
+ }
1197
+ async function showCommand(key, opts) {
1198
+ const identifier = parseItemKey(key, "show");
1199
+ const detail = await withProjectSession(({ client }) => client.getWorkItem(identifier));
1200
+ if (opts.json) {
1201
+ json(detailWithWaves(detail));
1202
+ return;
1203
+ }
1204
+ out(renderWorkItemDetail(detail));
1205
+ }
1206
+ async function openCommand(key, opts) {
1207
+ const trimmed = key.trim();
1208
+ if (!trimmed) throw new CliError("A work item key is required, e.g. `motir open PROD-7`.");
1209
+ const link = requireLink();
1210
+ const url = issueUrl(link.config.serverUrl, trimmed);
1211
+ out(url);
1212
+ if (opts.print) return;
1213
+ const launched = await openUrl(url);
1214
+ if (!launched) info("(Could not open a browser here \u2014 the URL is above.)");
1215
+ }
1216
+
1217
+ // src/commands/doctor.ts
1218
+ import { homedir as homedir2 } from "os";
1219
+ import { accessSync, constants, existsSync as existsSync3, statSync } from "fs";
1220
+ import { delimiter, isAbsolute as isAbsolute2, join as join4, resolve as resolve2 } from "path";
1221
+ import { execFile } from "child_process";
1222
+
1223
+ // src/agentProfiles.ts
1224
+ import { basename, join as join3 } from "path";
1225
+ var sandboxAgentConfigHome = (home) => join3(home, ".motir-sandbox", "agent-config");
1226
+ var AGENT_PROFILES = [
1227
+ {
1228
+ id: "claude",
1229
+ label: "Claude Code",
1230
+ tier: 1,
1231
+ binaries: ["claude"],
1232
+ installSource: "npm install -g @anthropic-ai/claude-code",
1233
+ credentialPaths: ({ home }) => [join3(home, ".claude")],
1234
+ credentialEnv: ["ANTHROPIC_API_KEY"],
1235
+ credentialKnown: true,
1236
+ credentialHint: "Run `claude` once to sign in, or set ANTHROPIC_API_KEY.",
1237
+ codegraphTarget: "claude",
1238
+ codegraphConfig: {
1239
+ // Claude Code reads its MCP servers from <CLAUDE_CONFIG_DIR>/.claude.json
1240
+ // — by default ~/.claude/.claude.json, INSIDE the `:ro` mount. (The
1241
+ // ~/.claude.json codegraph writes is a legacy path the shipped 2.x CLI no
1242
+ // longer reads at all — verified, which is why this profile had no tools
1243
+ // rather than merely no permission list.) The entrypoint redirects
1244
+ // CLAUDE_CONFIG_DIR to the image-owned home, seeds it from the mount and
1245
+ // merges codegraph's stanza into the copy.
1246
+ mcpServers: ({ home }) => join3(sandboxAgentConfigHome(home), ".claude", ".claude.json"),
1247
+ // The auto-allow list is a SEPARATE file from the server list here, and
1248
+ // it moves with the same redirect.
1249
+ autoAllow: ({ home }) => join3(sandboxAgentConfigHome(home), ".claude", "settings.json"),
1250
+ redirect: {
1251
+ env: "CLAUDE_CONFIG_DIR",
1252
+ why: "CLAUDE_CONFIG_DIR governs the state file, settings.json, CLAUDE.md AND .credentials.json, so the redirected dir is seeded from the read-only mount before codegraph merges into it."
1253
+ },
1254
+ knownAutoAllowGap: null
1255
+ }
1256
+ },
1257
+ {
1258
+ id: "codex",
1259
+ label: "Codex CLI",
1260
+ tier: 1,
1261
+ binaries: ["codex"],
1262
+ installSource: "npm install -g @openai/codex",
1263
+ credentialPaths: ({ home }) => [join3(home, ".codex")],
1264
+ credentialEnv: ["OPENAI_API_KEY"],
1265
+ credentialKnown: true,
1266
+ credentialHint: "Run `codex` once to sign in, or set OPENAI_API_KEY.",
1267
+ codegraphTarget: "codex",
1268
+ codegraphConfig: {
1269
+ // codegraph's default target is ~/.codex/config.toml — INSIDE the `:ro`
1270
+ // mount, so the host copy shadowed it and the agent saw no tools at all.
1271
+ // The entrypoint redirects CODEX_HOME to the image-owned home, seeds it
1272
+ // from the mount and lets codegraph merge its stanza into the copy.
1273
+ mcpServers: ({ home }) => join3(sandboxAgentConfigHome(home), ".codex", "config.toml"),
1274
+ // config.toml carries the server AND its trust/approval settings.
1275
+ autoAllow: null,
1276
+ redirect: {
1277
+ env: "CODEX_HOME",
1278
+ why: "CODEX_HOME governs config.toml AND auth.json, so the redirected home is seeded from the read-only mount before codegraph merges into it."
1279
+ },
1280
+ knownAutoAllowGap: null
1281
+ }
1282
+ },
1283
+ {
1284
+ id: "opencode",
1285
+ label: "OpenCode",
1286
+ tier: 1,
1287
+ binaries: ["opencode"],
1288
+ installSource: "npm, or the OpenCode install script (opencode.ai)",
1289
+ // OpenCode SPLITS the two: `~/.config/opencode` holds configuration, while
1290
+ // the credential is `auth.json` under the XDG DATA home — which is why the
1291
+ // sandbox mounts both dirs. Testing the config dir passed on any machine
1292
+ // that had ever run opencode, signed in or not; the check is the auth FILE.
1293
+ credentialPaths: ({ xdgDataHome }) => [join3(xdgDataHome, "opencode", "auth.json")],
1294
+ credentialEnv: [],
1295
+ credentialKnown: true,
1296
+ credentialHint: "Run `opencode auth login` to sign in \u2014 it writes auth.json.",
1297
+ codegraphTarget: "opencode",
1298
+ codegraphConfig: {
1299
+ // codegraph's default target is ~/.config/opencode/opencode.jsonc —
1300
+ // INSIDE the `:ro` config mount. OPENCODE_CONFIG is MERGED on top of the
1301
+ // global config rather than replacing it (verified), so pointing it at the
1302
+ // image-owned copy adds the codegraph stanza while the host's own config
1303
+ // keeps applying, and the credential (XDG data dir) is untouched.
1304
+ mcpServers: ({ home }) => join3(sandboxAgentConfigHome(home), ".config", "opencode", "opencode.jsonc"),
1305
+ autoAllow: null,
1306
+ redirect: {
1307
+ env: "OPENCODE_CONFIG",
1308
+ why: "OPENCODE_CONFIG merges over the global config, so no credential is copied and the host config still applies."
1309
+ },
1310
+ knownAutoAllowGap: null
1311
+ }
1312
+ },
1313
+ {
1314
+ id: "kimi",
1315
+ label: "Kimi Code CLI",
1316
+ tier: 1,
1317
+ binaries: ["kimi"],
1318
+ installSource: "npm (@moonshot-ai/kimi-code) \u2014 needs Node \u2265 22.19",
1319
+ credentialPaths: ({ home }) => [join3(home, ".kimi-code")],
1320
+ credentialEnv: [],
1321
+ credentialKnown: true,
1322
+ credentialHint: "Run `kimi` once to sign in \u2014 it writes ~/.kimi-code.",
1323
+ codegraphTarget: null,
1324
+ codegraphConfig: null
1325
+ },
1326
+ {
1327
+ id: "antigravity",
1328
+ label: "Antigravity CLI",
1329
+ tier: 2,
1330
+ // The installer links `agy`, not `antigravity`; the profile id is kept as a
1331
+ // tolerated alias for anyone who aliased it that way.
1332
+ binaries: ["agy", "antigravity"],
1333
+ installSource: "curl -fsSL https://antigravity.google/cli/install.sh | bash",
1334
+ // `agy` keeps its token in the OS KEYRING, with no documented portable
1335
+ // file — the one profile with genuinely nowhere to look (the sandbox
1336
+ // mounts no credential for it either).
1337
+ credentialPaths: () => [],
1338
+ credentialEnv: [],
1339
+ credentialKnown: false,
1340
+ credentialHint: "Sign in with `agy` (its token lives in the OS keyring, not a file).",
1341
+ codegraphTarget: "antigravity",
1342
+ codegraphConfig: {
1343
+ // This profile mounts NO credential directory at all, so nothing can
1344
+ // shadow the wiring.
1345
+ mcpServers: ({ home }) => join3(home, ".gemini", "antigravity", "mcp_config.json"),
1346
+ autoAllow: null,
1347
+ redirect: null,
1348
+ knownAutoAllowGap: null
1349
+ }
1350
+ },
1351
+ {
1352
+ id: "cursor",
1353
+ label: "Cursor CLI",
1354
+ tier: 2,
1355
+ // The installer symlinks the executable as `agent`, keeping `cursor-agent`
1356
+ // as the legacy alias — `cursor` is neither, and matched nothing until the
1357
+ // list arrived. It stays last so a user who aliased it keeps a profile.
1358
+ binaries: ["agent", "cursor-agent", "cursor"],
1359
+ installSource: "curl https://cursor.com/install -fsS | bash",
1360
+ // The matrix's `~/.local/share/cursor-agent` mount is also where the
1361
+ // installer unpacks the CLI itself, so its presence proves an INSTALL, not
1362
+ // a sign-in. The API key is the one unambiguous signal, so it is the only
1363
+ // one tested.
1364
+ credentialPaths: () => [],
1365
+ credentialEnv: ["CURSOR_API_KEY"],
1366
+ credentialKnown: true,
1367
+ credentialHint: "Run `cursor-agent login` to sign in, or set CURSOR_API_KEY.",
1368
+ codegraphTarget: "cursor",
1369
+ codegraphConfig: {
1370
+ // Cursor's credential mount is ~/.local/share/cursor-agent, which does not
1371
+ // contain ~/.cursor — the wiring is clear of it without a redirect.
1372
+ mcpServers: ({ home }) => join3(home, ".cursor", "mcp.json"),
1373
+ autoAllow: null,
1374
+ redirect: null,
1375
+ knownAutoAllowGap: null
1376
+ }
1377
+ },
1378
+ {
1379
+ id: "aider",
1380
+ label: "Aider",
1381
+ tier: 2,
1382
+ binaries: ["aider"],
1383
+ installSource: "pip (Python) \u2014 PyPI `aider-chat`",
1384
+ // Aider's credential IS the model key it reads from the environment. Its
1385
+ // `~/.aider.conf.yml` is configuration, and the sandbox asks the user to
1386
+ // create it (even empty) so docker can bind it — so its presence would
1387
+ // prove nothing at all.
1388
+ credentialPaths: () => [],
1389
+ credentialEnv: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
1390
+ credentialKnown: true,
1391
+ credentialHint: "Give Aider a model key: set ANTHROPIC_API_KEY or OPENAI_API_KEY.",
1392
+ codegraphTarget: null,
1393
+ codegraphConfig: null
1394
+ },
1395
+ {
1396
+ id: "goose",
1397
+ label: "Goose",
1398
+ tier: 2,
1399
+ binaries: ["goose"],
1400
+ installSource: "the Goose installer (Block)",
1401
+ // Goose stores provider secrets in the OS KEYRING by default and only falls
1402
+ // back to a file under `~/.config/goose` when the keyring is disabled (as
1403
+ // the sandbox does). Neither state is testable from outside: an existing
1404
+ // config dir need not hold a key, and a keyring-backed key leaves no file.
1405
+ credentialPaths: () => [],
1406
+ credentialEnv: [],
1407
+ credentialKnown: false,
1408
+ credentialHint: "Run `goose configure` to store a provider key.",
1409
+ codegraphTarget: null,
1410
+ codegraphConfig: null
1411
+ }
1412
+ ];
1413
+ function parseAgentCommand(command) {
1414
+ const trimmed = (command ?? "").trim();
1415
+ if (!trimmed) return null;
1416
+ const boundary = trimmed.search(/\s/);
1417
+ if (boundary === -1) return { command: trimmed, binary: trimmed, args: [] };
1418
+ return {
1419
+ command: trimmed,
1420
+ binary: trimmed.slice(0, boundary),
1421
+ args: trimmed.slice(boundary + 1).trim().split(/\s+/)
1422
+ };
1423
+ }
1424
+ function findAgentProfile(binary) {
1425
+ const name = basename(binary).toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/, "");
1426
+ return AGENT_PROFILES.find((p) => p.binaries.includes(name)) ?? null;
1427
+ }
1428
+
1429
+ // src/doctor.ts
1430
+ function credentialDirs(probe) {
1431
+ return {
1432
+ home: probe.home(),
1433
+ xdgConfigHome: probe.xdgConfigHome(),
1434
+ xdgDataHome: probe.xdgDataHome()
1435
+ };
1436
+ }
1437
+ var AGENT_SOURCE_LABEL = {
1438
+ flag: "--agent",
1439
+ env: "MOTIR_AGENT",
1440
+ config: "config agentCommand"
1441
+ };
1442
+ function resolveAgentCommand(opts, probe) {
1443
+ const flag = opts.agent?.trim();
1444
+ if (flag) return { command: flag, source: "flag" };
1445
+ const env = probe.agentEnvOverride()?.trim();
1446
+ if (env) return { command: env, source: "env" };
1447
+ const configured = probe.configuredAgentCommand()?.trim();
1448
+ if (configured) return { command: configured, source: "config" };
1449
+ return null;
1450
+ }
1451
+ function credentialLocations(profile, probe) {
1452
+ const paths = profile.credentialPaths(credentialDirs(probe));
1453
+ return [...paths, ...profile.credentialEnv.map((name) => `the ${name} env var`)];
1454
+ }
1455
+ async function runDoctor(opts, probe) {
1456
+ const checks = [];
1457
+ const link = probe.findLink();
1458
+ checks.push(
1459
+ link ? {
1460
+ id: "link",
1461
+ label: "Project link",
1462
+ status: "pass",
1463
+ detail: `${link.config.project} in ${link.config.workspace} \xB7 ${link.path}`
1464
+ } : {
1465
+ id: "link",
1466
+ label: "Project link",
1467
+ status: "fail",
1468
+ detail: "No .motir.json found in this directory or any parent.",
1469
+ remediation: "Run `motir link` at your workspace root to bind a project."
1470
+ }
1471
+ );
1472
+ let serverUrl = null;
1473
+ let serverError = null;
1474
+ try {
1475
+ serverUrl = probe.resolveServerUrl();
1476
+ } catch (err) {
1477
+ serverError = err instanceof CliError ? { message: err.message, ...err.hint ? { hint: err.hint } : {} } : { message: err instanceof Error ? err.message : String(err) };
1478
+ }
1479
+ const credentialOrigin = serverUrl ? probe.credentialOrigin(serverUrl) : null;
1480
+ let server = null;
1481
+ if (serverUrl && credentialOrigin) {
1482
+ const projectKey = link?.config.project;
1483
+ server = await probe.probeServer({
1484
+ serverUrl,
1485
+ ...projectKey ? { projectKey } : {}
1486
+ });
1487
+ }
1488
+ checks.push(authCheck({ serverUrl, serverError, server, credentialOrigin }));
1489
+ checks.push(projectCheck({ link, server }));
1490
+ checks.push(reposCheck(link, probe));
1491
+ const agent = resolveAgentCommand(opts, probe);
1492
+ const parsed = agent ? parseAgentCommand(agent.command) : null;
1493
+ const profile = parsed ? findAgentProfile(parsed.binary) : null;
1494
+ if (!agent || !parsed) {
1495
+ checks.push({
1496
+ id: "agent",
1497
+ label: "Coding agent",
1498
+ status: "warn",
1499
+ detail: "No coding agent configured.",
1500
+ remediation: 'Pass --agent <cmd>, set MOTIR_AGENT, or add "agentCommand" to the user config. (`motir next --print` needs no agent \u2014 it hands you the prompt.)'
1501
+ });
1502
+ checks.push({
1503
+ id: "credential",
1504
+ label: "Agent credential",
1505
+ status: "warn",
1506
+ detail: "Skipped \u2014 no coding agent configured.",
1507
+ remediation: "Configure an agent first, then re-run `motir doctor`."
1508
+ });
1509
+ return summarize(checks);
1510
+ }
1511
+ const probed = await probe.probeAgent(parsed.binary);
1512
+ checks.push(agentCheck({ parsed, source: agent.source, profile, probed }));
1513
+ checks.push(credentialCheck({ binary: parsed.binary, profile, probe }));
1514
+ return summarize(checks);
1515
+ }
1516
+ function authCheck(input) {
1517
+ const { serverUrl, serverError, server, credentialOrigin } = input;
1518
+ const base = { id: "auth", label: "Auth" };
1519
+ if (!serverUrl) {
1520
+ return {
1521
+ ...base,
1522
+ status: "fail",
1523
+ detail: serverError?.message ?? "No Motir server configured.",
1524
+ remediation: serverError?.hint ?? "Run `motir auth login` first."
1525
+ };
1526
+ }
1527
+ if (!server) {
1528
+ return {
1529
+ ...base,
1530
+ status: "fail",
1531
+ detail: `Not logged in to ${serverUrl}.`,
1532
+ remediation: "Run `motir auth login` to store a personal access token, or set MOTIR_TOKEN (no file needed \u2014 the tier CI and the sandbox use)."
1533
+ };
1534
+ }
1535
+ if (!server.ok) {
1536
+ return {
1537
+ ...base,
1538
+ status: "fail",
1539
+ detail: `${serverUrl}: ${server.error?.message ?? "the server could not be reached."}`,
1540
+ remediation: server.error?.hint ?? "Run `motir auth login` to re-authenticate."
1541
+ };
1542
+ }
1543
+ const who = server.user ? `${server.user.email}` : "unknown user";
1544
+ const ws = server.workspace ? ` \xB7 workspace ${server.workspace.slug}` : "";
1545
+ const tools = server.toolCount === void 0 ? "" : ` \xB7 ${server.toolCount} tools`;
1546
+ const via = credentialOrigin ? ` \xB7 via ${credentialOrigin}` : "";
1547
+ return { ...base, status: "pass", detail: `${who} on ${serverUrl}${ws}${tools}${via}` };
1548
+ }
1549
+ function projectCheck(input) {
1550
+ const { link, server } = input;
1551
+ const base = { id: "project", label: "Workspace + project" };
1552
+ if (!link) {
1553
+ return {
1554
+ ...base,
1555
+ status: "warn",
1556
+ detail: "Skipped \u2014 no project link to resolve.",
1557
+ remediation: "Run `motir link` first."
1558
+ };
1559
+ }
1560
+ if (!server?.ok || !server.project) {
1561
+ return {
1562
+ ...base,
1563
+ status: "warn",
1564
+ detail: "Skipped \u2014 the server could not be reached.",
1565
+ remediation: "Fix the auth check above, then re-run `motir doctor`."
1566
+ };
1567
+ }
1568
+ const { project } = server;
1569
+ if (!project.reachable) {
1570
+ return {
1571
+ ...base,
1572
+ status: "fail",
1573
+ detail: `Project ${project.key} is not reachable: ${project.error ?? "unknown error"}`,
1574
+ remediation: `Check the project key in .motir.json, and that your token's user is a member of ${link.config.workspace}.`
1575
+ };
1576
+ }
1577
+ const linked = link.config.workspace;
1578
+ const active = server.workspace?.slug;
1579
+ const count = project.total === void 0 ? "" : ` \xB7 ${project.total} work items`;
1580
+ if (active && active !== linked) {
1581
+ return {
1582
+ ...base,
1583
+ status: "warn",
1584
+ detail: `${project.key} reachable${count}, but the token's active workspace is ${active}, not ${linked}.`,
1585
+ remediation: `Confirm ${linked} is the workspace you meant to link.`
1586
+ };
1587
+ }
1588
+ return { ...base, status: "pass", detail: `${project.key} in ${linked} reachable${count}` };
1589
+ }
1590
+ function reposCheck(link, probe) {
1591
+ const base = { id: "repos", label: "Repo checkouts" };
1592
+ if (!link) {
1593
+ return {
1594
+ ...base,
1595
+ status: "warn",
1596
+ detail: "Skipped \u2014 no project link to resolve.",
1597
+ remediation: "Run `motir link` first."
1598
+ };
1599
+ }
1600
+ const repos = probe.resolveRepos(link);
1601
+ if (repos.length === 0) {
1602
+ return {
1603
+ ...base,
1604
+ status: "pass",
1605
+ detail: `No overrides \u2014 checkouts resolve by convention under ${link.dir}`
1606
+ };
1607
+ }
1608
+ const missingOverrides = repos.filter((r) => r.source === "override" && !r.exists);
1609
+ const summary = repos.map((r) => `${r.repoName} \u2192 ${r.path}${r.exists ? "" : " (not yet)"}`);
1610
+ if (missingOverrides.length > 0) {
1611
+ return {
1612
+ ...base,
1613
+ status: "warn",
1614
+ detail: `${missingOverrides.length} override path(s) do not exist: ${summary.join(", ")}`,
1615
+ remediation: "Fix with `motir link add <repo> <path>`, or clone the checkout there."
1616
+ };
1617
+ }
1618
+ return { ...base, status: "pass", detail: summary.join(", ") };
1619
+ }
1620
+ function agentCheck(input) {
1621
+ const { parsed, source, profile, probed } = input;
1622
+ const base = { id: "agent", label: "Coding agent" };
1623
+ const via = `via ${AGENT_SOURCE_LABEL[source]}`;
1624
+ if (!probed.onPath) {
1625
+ return {
1626
+ ...base,
1627
+ status: "fail",
1628
+ detail: `\`${parsed.binary}\` (${via}) is not on PATH.`,
1629
+ remediation: profile ? `Install ${profile.label}: ${profile.installSource}` : `Install \`${parsed.binary}\` and make sure it is on PATH (Motir runs your agent, it never bundles one).`
1630
+ };
1631
+ }
1632
+ const where = probed.resolvedPath ? ` \u2192 ${probed.resolvedPath}` : "";
1633
+ if (!probed.version) {
1634
+ return {
1635
+ ...base,
1636
+ status: "warn",
1637
+ detail: `\`${parsed.binary}\`${where} is on PATH but did not answer --version${probed.versionError ? ` (${probed.versionError})` : ""}.`,
1638
+ remediation: "Run the agent once by hand to confirm it starts before `motir auto`."
1639
+ };
1640
+ }
1641
+ const label = profile ? `${profile.label} ` : "";
1642
+ return {
1643
+ ...base,
1644
+ status: "pass",
1645
+ detail: `${label}\`${parsed.command}\` (${via})${where} \xB7 ${probed.version}`
1646
+ };
1647
+ }
1648
+ function credentialCheck(input) {
1649
+ const { binary, profile, probe } = input;
1650
+ const base = { id: "credential", label: "Agent credential" };
1651
+ if (!profile) {
1652
+ return {
1653
+ ...base,
1654
+ status: "warn",
1655
+ detail: `No credential profile for \`${binary}\` \u2014 Motir knows the sandbox-matrix agents only.`,
1656
+ remediation: `Check \`${binary}\`'s own docs for where it stores its credential, and confirm it is signed in.`
1657
+ };
1658
+ }
1659
+ if (!profile.credentialKnown) {
1660
+ return {
1661
+ ...base,
1662
+ status: "warn",
1663
+ detail: `${profile.label}'s credential location is not pinned by the profile matrix.`,
1664
+ remediation: profile.credentialHint
1665
+ };
1666
+ }
1667
+ const paths = profile.credentialPaths(credentialDirs(probe));
1668
+ const foundPath = paths.find((p) => probe.pathExists(p));
1669
+ if (foundPath) {
1670
+ return {
1671
+ ...base,
1672
+ status: "pass",
1673
+ detail: `${profile.label} credential present at ${foundPath} (presence only \u2014 never read).`
1674
+ };
1675
+ }
1676
+ const foundEnv = profile.credentialEnv.find((name) => probe.hasEnv(name));
1677
+ if (foundEnv) {
1678
+ return {
1679
+ ...base,
1680
+ status: "pass",
1681
+ detail: `${profile.label} credential present via ${foundEnv} (set \u2014 value never read).`
1682
+ };
1683
+ }
1684
+ return {
1685
+ ...base,
1686
+ status: "fail",
1687
+ detail: `No ${profile.label} credential found.`,
1688
+ remediation: `${profile.credentialHint} Expected ${credentialLocations(profile, probe).join(" or ")}.`
1689
+ };
1690
+ }
1691
+ function summarize(checks) {
1692
+ return { ok: !checks.some((c) => c.status === "fail"), checks };
1693
+ }
1694
+ function doctorExitCode(report) {
1695
+ return report.ok ? 0 : 1;
1696
+ }
1697
+ var STATUS_ORDER = ["fail", "warn", "pass"];
1698
+ function countByStatus(checks) {
1699
+ const counts = { pass: 0, warn: 0, fail: 0 };
1700
+ for (const check of checks) counts[check.status] += 1;
1701
+ return counts;
1702
+ }
1703
+ function plural(n, one, many) {
1704
+ return `${n} ${n === 1 ? one : many}`;
1705
+ }
1706
+ function renderDoctorReport(report) {
1707
+ const labelWidth = Math.max(...report.checks.map((c) => c.label.length), 0);
1708
+ const lines = ["motir doctor \u2014 BYOK preflight", ""];
1709
+ for (const check of report.checks) {
1710
+ lines.push(
1711
+ `${check.status.toUpperCase().padEnd(4)} ${check.label.padEnd(labelWidth)} ${check.detail}`
1712
+ );
1713
+ if (check.remediation) lines.push(`${" ".repeat(6 + labelWidth + 2)}\u21B3 ${check.remediation}`);
1714
+ }
1715
+ const counts = countByStatus(report.checks);
1716
+ const parts = STATUS_ORDER.filter((s) => counts[s] > 0).map(
1717
+ (s) => s === "fail" ? plural(counts.fail, "failed", "failed") : s === "warn" ? plural(counts.warn, "warning", "warnings") : plural(counts.pass, "passed", "passed")
1718
+ );
1719
+ lines.push(
1720
+ "",
1721
+ report.ok ? `All hard checks passed \u2014 ${parts.join(", ")}.` : `${parts.join(", ")}.`
1722
+ );
1723
+ return lines.join("\n");
1724
+ }
1725
+
1726
+ // src/commands/doctor.ts
1727
+ var AGENT_VERSION_TIMEOUT_MS = 5e3;
1728
+ async function probeServerWith(client, projectKey) {
1729
+ try {
1730
+ await client.connect();
1731
+ } catch (err) {
1732
+ return { ok: false, error: describeError(err) };
1733
+ }
1734
+ try {
1735
+ const toolCount = (await client.listToolNames()).length;
1736
+ const who = await client.whoami();
1737
+ const result = {
1738
+ ok: true,
1739
+ toolCount,
1740
+ user: { name: who.user.name, email: who.user.email },
1741
+ workspace: who.workspace ? { name: who.workspace.name, slug: who.workspace.slug } : null
1742
+ };
1743
+ if (projectKey) {
1744
+ try {
1745
+ const page = await client.searchWorkItems({ projectKey, limit: 1 });
1746
+ result.project = { key: projectKey, reachable: true, total: page.total };
1747
+ } catch (err) {
1748
+ result.project = { key: projectKey, reachable: false, error: describeError(err).message };
1749
+ }
1750
+ }
1751
+ return result;
1752
+ } catch (err) {
1753
+ return { ok: false, error: describeError(err) };
1754
+ } finally {
1755
+ await client.close().catch(() => void 0);
1756
+ }
1757
+ }
1758
+ function describeError(err) {
1759
+ if (err instanceof CliError) {
1760
+ return { message: err.message, ...err.hint ? { hint: err.hint } : {} };
1761
+ }
1762
+ return { message: err instanceof Error ? err.message : String(err) };
1763
+ }
1764
+ function isExecutableFile(path) {
1765
+ try {
1766
+ if (!statSync(path).isFile()) return false;
1767
+ accessSync(path, constants.X_OK);
1768
+ return true;
1769
+ } catch {
1770
+ return false;
1771
+ }
1772
+ }
1773
+ function resolveOnPath(binary, env = process.env, platform = process.platform) {
1774
+ const withExtensions = (base) => {
1775
+ if (platform !== "win32") return [base];
1776
+ const exts = (env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
1777
+ return [base, ...exts.map((ext) => base + ext)];
1778
+ };
1779
+ if (binary.includes("/") || binary.includes("\\")) {
1780
+ const direct = isAbsolute2(binary) ? binary : resolve2(binary);
1781
+ return withExtensions(direct).find(isExecutableFile) ?? null;
1782
+ }
1783
+ for (const dir of (env["PATH"] ?? "").split(delimiter).filter(Boolean)) {
1784
+ const hit = withExtensions(join4(dir, binary)).find(isExecutableFile);
1785
+ if (hit) return hit;
1786
+ }
1787
+ return null;
1788
+ }
1789
+ function firstLine(text) {
1790
+ const newline = text.indexOf("\n");
1791
+ return (newline === -1 ? text : text.slice(0, newline)).trim();
1792
+ }
1793
+ function probeAgentVersion(path) {
1794
+ return new Promise((done) => {
1795
+ execFile(
1796
+ path,
1797
+ ["--version"],
1798
+ { timeout: AGENT_VERSION_TIMEOUT_MS, windowsHide: true },
1799
+ (err, stdout, stderr) => {
1800
+ if (err) {
1801
+ done({ error: firstLine(err.message) });
1802
+ return;
1803
+ }
1804
+ const text = firstLine(`${stdout}${stderr}`.trim());
1805
+ done(text ? { version: text } : { error: "no output" });
1806
+ }
1807
+ );
1808
+ });
1809
+ }
1810
+ function defaultDoctorProbe() {
1811
+ return {
1812
+ findLink: () => findLink(),
1813
+ resolveServerUrl: () => resolveServerUrl(),
1814
+ credentialOrigin: (serverUrl) => resolveCredential(serverUrl)?.origin ?? null,
1815
+ probeServer: async ({ serverUrl, projectKey }) => {
1816
+ const url = normalizeServerUrl(serverUrl);
1817
+ const cred = resolveCredential(url);
1818
+ if (!cred) return { ok: false, error: { message: `Not logged in to ${url}.` } };
1819
+ const client = new MotirClient({ serverUrl: url, token: cred.token });
1820
+ return probeServerWith(client, projectKey);
1821
+ },
1822
+ resolveRepos: (link) => overrideRepoNames(link.config).map((name) => resolveRepo(link.dir, link.config, name)),
1823
+ probeAgent: async (binary) => {
1824
+ const resolved = resolveOnPath(binary);
1825
+ if (!resolved) return { onPath: false };
1826
+ const { version, error } = await probeAgentVersion(resolved);
1827
+ return {
1828
+ onPath: true,
1829
+ resolvedPath: resolved,
1830
+ ...version ? { version } : {},
1831
+ ...error ? { versionError: error } : {}
1832
+ };
1833
+ },
1834
+ configuredAgentCommand: () => getAgentCommand(),
1835
+ agentEnvOverride: () => process.env["MOTIR_AGENT"],
1836
+ // PRESENCE predicates only — the credential itself is never opened, and an
1837
+ // env var's VALUE is never returned. See doctor.ts's structural note.
1838
+ pathExists: (path) => existsSync3(path),
1839
+ hasEnv: (name) => {
1840
+ const value = process.env[name];
1841
+ return typeof value === "string" && value.length > 0;
1842
+ },
1843
+ home: () => homedir2(),
1844
+ xdgConfigHome: () => process.env["XDG_CONFIG_HOME"] || join4(homedir2(), ".config"),
1845
+ xdgDataHome: () => process.env["XDG_DATA_HOME"] || join4(homedir2(), ".local", "share")
1846
+ };
1847
+ }
1848
+ async function doctorCommand(opts, probe = defaultDoctorProbe()) {
1849
+ const report = await runDoctor(opts, probe);
1850
+ if (opts.json) json(report);
1851
+ else out(renderDoctorReport(report));
1852
+ process.exitCode = doctorExitCode(report);
1853
+ }
1854
+
1855
+ // src/agentRun.ts
1856
+ import { spawn as spawn2 } from "child_process";
1857
+ import { mkdtempSync, rmSync, writeFileSync as writeFileSync3 } from "fs";
1858
+ import { tmpdir } from "os";
1859
+ import { join as join5 } from "path";
1860
+ var PROMPT_FILE_MODE = 384;
1861
+ function defaultTempDir() {
1862
+ return mkdtempSync(join5(tmpdir(), "motir-prompt-"));
1863
+ }
1864
+ async function runAgent(opts) {
1865
+ const spawnFn = opts.spawnFn ?? spawn2;
1866
+ const dir = (opts.tempDirFactory ?? defaultTempDir)();
1867
+ const promptFile = join5(dir, "prompt.md");
1868
+ writeFileSync3(promptFile, opts.prompt, { mode: PROMPT_FILE_MODE });
1869
+ try {
1870
+ return await new Promise((resolve3, reject) => {
1871
+ const child = spawnFn(opts.command.binary, opts.command.args, {
1872
+ cwd: opts.cwd,
1873
+ // stdin piped (we write the prompt); stdout/stderr inherited so the
1874
+ // agent's output streams straight through to the user's terminal.
1875
+ stdio: ["pipe", "inherit", "inherit"],
1876
+ env: { ...opts.env ?? process.env, MOTIR_PROMPT_FILE: promptFile }
1877
+ });
1878
+ child.on("error", (err) => {
1879
+ reject(
1880
+ err.code === "ENOENT" ? new CliError(`Could not launch the agent "${opts.command.binary}": not found.`, {
1881
+ hint: "Run `motir doctor` to check your agent command and credentials."
1882
+ }) : new CliError(`Could not launch the agent "${opts.command.binary}": ${err.message}`)
1883
+ );
1884
+ });
1885
+ const stdin = child.stdin;
1886
+ if (stdin) {
1887
+ stdin.on("error", () => {
1888
+ });
1889
+ stdin.end(opts.prompt);
1890
+ }
1891
+ child.on("close", (code, signal) => {
1892
+ resolve3({ exitCode: signal ? 1 : code ?? 1, signal });
1893
+ });
1894
+ });
1895
+ } finally {
1896
+ rmSync(dir, { recursive: true, force: true });
1897
+ }
1898
+ }
1899
+
1900
+ // src/sessionExcludes.ts
1901
+ import { join as join6 } from "path";
1902
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
1903
+ var EXCLUDES_FILENAME = "session-excludes.json";
1904
+ function excludesPath() {
1905
+ return join6(stateDir(), EXCLUDES_FILENAME);
1906
+ }
1907
+ function legacyExcludesPath() {
1908
+ return join6(configDir(), EXCLUDES_FILENAME);
1909
+ }
1910
+ function scopeKey(serverUrl, projectKey) {
1911
+ return `${normalizeServerUrl(serverUrl)}|${projectKey.toUpperCase()}`;
1912
+ }
1913
+ function readStore() {
1914
+ const primary = excludesPath();
1915
+ const path = existsSync4(primary) ? primary : legacyExcludesPath();
1916
+ if (!existsSync4(path)) return {};
1917
+ try {
1918
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
1919
+ if (!parsed || typeof parsed !== "object") return {};
1920
+ return parsed;
1921
+ } catch {
1922
+ return {};
1923
+ }
1924
+ }
1925
+ var warnedPaths = /* @__PURE__ */ new Set();
1926
+ function writeStore(store) {
1927
+ const path = excludesPath();
1928
+ try {
1929
+ mkdirSync2(stateDir(), { recursive: true, mode: 448 });
1930
+ writeFileSync4(path, JSON.stringify(store, null, 2) + "\n");
1931
+ warnedPaths.delete(path);
1932
+ return true;
1933
+ } catch (err) {
1934
+ if (!warnedPaths.has(path)) {
1935
+ warnedPaths.add(path);
1936
+ const reason = err instanceof Error ? err.message : String(err);
1937
+ info(
1938
+ `Note: could not write the session exclude list (${path}): ${reason}
1939
+ Continuing \u2014 the list only avoids re-picking an item that just failed,
1940
+ and a failed item is already held out of the ready set by its status.
1941
+ Set MOTIR_STATE_HOME to a writable directory to persist it.`
1942
+ );
1943
+ }
1944
+ return false;
1945
+ }
1946
+ }
1947
+ function readExcludes(serverUrl, projectKey) {
1948
+ return readStore()[scopeKey(serverUrl, projectKey)] ?? [];
1949
+ }
1950
+ function addExclude(serverUrl, projectKey, entry) {
1951
+ const store = readStore();
1952
+ const key = scopeKey(serverUrl, projectKey);
1953
+ const current = store[key] ?? [];
1954
+ if (current.some((e) => e.id === entry.id)) return;
1955
+ store[key] = [...current, entry];
1956
+ writeStore(store);
1957
+ }
1958
+ function removeExclude(serverUrl, projectKey, id) {
1959
+ const store = readStore();
1960
+ const key = scopeKey(serverUrl, projectKey);
1961
+ const current = store[key] ?? [];
1962
+ const next = current.filter((e) => e.id !== id);
1963
+ if (next.length === current.length) return false;
1964
+ if (next.length > 0) store[key] = next;
1965
+ else delete store[key];
1966
+ return writeStore(store);
1967
+ }
1968
+ function removeExcludeByKey(serverUrl, projectKey, key) {
1969
+ const entry = readExcludes(serverUrl, projectKey).find(
1970
+ (e) => e.key.toUpperCase() === key.toUpperCase()
1971
+ );
1972
+ return entry ? removeExclude(serverUrl, projectKey, entry.id) : false;
1973
+ }
1974
+ function clearExcludes(serverUrl, projectKey) {
1975
+ const store = readStore();
1976
+ const key = scopeKey(serverUrl, projectKey);
1977
+ const count = store[key]?.length ?? 0;
1978
+ if (count === 0) return 0;
1979
+ delete store[key];
1980
+ return writeStore(store) ? count : 0;
1981
+ }
1982
+
1983
+ // src/dispatch.ts
1984
+ import { existsSync as existsSync5 } from "fs";
1985
+ function resolveDispatchTarget(rootDir, config, targetRepo, opts = {}) {
1986
+ const exists = opts.exists ?? existsSync5;
1987
+ if (!targetRepo) {
1988
+ return {
1989
+ targetRepo: null,
1990
+ cwd: rootDir,
1991
+ reason: "unpinned_root",
1992
+ repoPath: null,
1993
+ repoSource: null,
1994
+ verifyCheckoutAfterRun: false
1995
+ };
1996
+ }
1997
+ const resolved = resolveRepo(rootDir, config, targetRepo);
1998
+ const present = exists(resolved.path);
1999
+ return {
2000
+ targetRepo,
2001
+ cwd: present ? resolved.path : rootDir,
2002
+ reason: present ? "repo_checkout" : "bootstrap_root",
2003
+ repoPath: resolved.path,
2004
+ repoSource: resolved.source,
2005
+ verifyCheckoutAfterRun: !present
2006
+ };
2007
+ }
2008
+ function checkBootstrapCheckout(target, opts = {}) {
2009
+ const exists = opts.exists ?? existsSync5;
2010
+ if (!target.verifyCheckoutAfterRun || !target.targetRepo || !target.repoPath) return null;
2011
+ if (exists(target.repoPath)) return null;
2012
+ return {
2013
+ repoName: target.targetRepo,
2014
+ expectedPath: target.repoPath,
2015
+ message: `Suspect dispatch: the agent exited 0 but "${target.targetRepo}" still has no checkout at ${target.repoPath}.`,
2016
+ hint: `If this repo lives elsewhere, run \`motir link add ${target.targetRepo} <path>\`.`
2017
+ };
2018
+ }
2019
+ var AGENT_SOURCE_LABEL2 = {
2020
+ flag: "--agent",
2021
+ env: "MOTIR_AGENT",
2022
+ config: "config agentCommand"
2023
+ };
2024
+ function agentSourceLabel(source) {
2025
+ return AGENT_SOURCE_LABEL2[source];
2026
+ }
2027
+ function cwdReasonLabel(target) {
2028
+ switch (target.reason) {
2029
+ case "repo_checkout":
2030
+ return `${target.targetRepo ?? ""} checkout (${target.repoSource})`;
2031
+ case "bootstrap_root":
2032
+ return `workspace root \u2014 no "${target.targetRepo ?? ""}" checkout yet, the prompt creates it`;
2033
+ case "unpinned_root":
2034
+ return "workspace root \u2014 the item pins no repo";
2035
+ }
2036
+ }
2037
+ function workflowLabel(mode, sessionBranch) {
2038
+ return mode === "session_lineage" ? `session lineage on ${sessionBranch ?? "(unnamed branch)"}` : "one pull request of its own";
2039
+ }
2040
+ function renderDispatchSummary(input) {
2041
+ const { key, title, dispatch, target, agent } = input;
2042
+ const lines = [
2043
+ `Dispatch: ${key}${title ? ` \u2014 ${title}` : ""}`,
2044
+ `Repo: ${dispatch.targetRepo ?? "not pinned (Motir cannot say)"}`,
2045
+ `Path: ${target.cwd}`,
2046
+ ` ${cwdReasonLabel(target)}`,
2047
+ `Workflow: ${workflowLabel(dispatch.workflowMode, dispatch.sessionBranch)}`
2048
+ ];
2049
+ lines.push(
2050
+ agent ? `Agent: ${agent.command} (${agentSourceLabel(agent.source)})` : "Agent: none \u2014 printing the prompt (copy it into your agent)"
2051
+ );
2052
+ return lines.join("\n");
2053
+ }
2054
+ function renderAgentSuccess(key, dispatch) {
2055
+ if (dispatch.workflowMode === "session_lineage" && dispatch.sessionBranch) {
2056
+ return [
2057
+ `${key}: agent finished \u2014 integrated on "${dispatch.sessionBranch}" (now In Review).`,
2058
+ `Next: review + merge the session PR, then \`motir done --session ${dispatch.sessionBranch}\`.`
2059
+ ].join("\n");
2060
+ }
2061
+ return [
2062
+ `${key}: agent finished \u2014 its pull request should be open (now In Review).`,
2063
+ `Next: review + merge the PR, then \`motir done ${key}\`.`
2064
+ ].join("\n");
2065
+ }
2066
+ function renderAgentFailure(key, exitCode) {
2067
+ return [
2068
+ `${key}: agent exited ${exitCode}. The item stays In Progress (nothing was reverted).`,
2069
+ `It is excluded from the next \`motir next\` in this session; re-run it with \`motir run ${key}\`.`
2070
+ ].join("\n");
2071
+ }
2072
+ function renderSessionOutcomes(sessionBranch, results) {
2073
+ if (results.length === 0) return `No work items are recorded on "${sessionBranch}".`;
2074
+ const counts = results.reduce((acc, r) => {
2075
+ acc[r.outcome] = (acc[r.outcome] ?? 0) + 1;
2076
+ return acc;
2077
+ }, {});
2078
+ const head = `Completed session "${sessionBranch}": ` + Object.entries(counts).map(([outcome, n]) => `${n} ${outcome.replace("_", " ")}`).join(", ") + ".";
2079
+ const detail = results.map((r) => ` ${r.key}: ${r.outcome}${r.reason ? ` \u2014 ${r.reason}` : ""}`);
2080
+ return [head, ...detail].join("\n");
2081
+ }
2082
+
2083
+ // src/commands/dispatch.ts
2084
+ var IN_PROGRESS = "in_progress";
2085
+ var IN_REVIEW = "in_review";
2086
+ var DONE = "done";
2087
+ var HARNESS = `motir-cli/${CLI_VERSION}`;
2088
+ function resolveAgent(opts, env = process.env, configured = getAgentCommand) {
2089
+ if (opts.print) return null;
2090
+ const candidates = [
2091
+ [opts.agent, "flag"],
2092
+ [env["MOTIR_AGENT"], "env"],
2093
+ [configured(), "config"]
2094
+ ];
2095
+ for (const [raw, source] of candidates) {
2096
+ const parsed = parseAgentCommand(raw);
2097
+ if (parsed) return { parsed, source };
2098
+ }
2099
+ return null;
2100
+ }
2101
+ async function ensureInProgress(client, key, currentStatus) {
2102
+ if (currentStatus === IN_PROGRESS) {
2103
+ info(`${key}: already In Progress \u2014 leaving the status as it is.`);
2104
+ return;
2105
+ }
2106
+ await client.transitionStatus({ key, status: IN_PROGRESS });
2107
+ }
2108
+ async function deliver(input) {
2109
+ const { session, key, title, id, dispatch, opts } = input;
2110
+ const { client, link, serverUrl, projectKey } = session;
2111
+ const agent = resolveAgent(opts);
2112
+ const target = resolveDispatchTarget(link.dir, link.config, dispatch.targetRepo);
2113
+ const summary = renderDispatchSummary({
2114
+ key,
2115
+ title,
2116
+ dispatch,
2117
+ target,
2118
+ agent: agent ? { command: agent.parsed.command, source: agent.source } : null
2119
+ });
2120
+ if (!agent) {
2121
+ info(summary);
2122
+ info("");
2123
+ outVerbatim(dispatch.prompt);
2124
+ return;
2125
+ }
2126
+ info(summary);
2127
+ info("");
2128
+ const result = await runAgent({
2129
+ command: agent.parsed,
2130
+ prompt: dispatch.prompt,
2131
+ cwd: target.cwd
2132
+ });
2133
+ if (result.exitCode !== 0) {
2134
+ if (id) addExclude(serverUrl, projectKey, { id, key });
2135
+ info("");
2136
+ info(renderAgentFailure(key, result.exitCode));
2137
+ process.exitCode = result.exitCode;
2138
+ return;
2139
+ }
2140
+ if (dispatch.workflowMode === "session_lineage" && dispatch.sessionBranch) {
2141
+ await client.markIntegrated({
2142
+ key,
2143
+ sessionBranch: dispatch.sessionBranch,
2144
+ implementationHarness: HARNESS
2145
+ });
2146
+ } else {
2147
+ await client.transitionStatus({ key, status: IN_REVIEW });
2148
+ }
2149
+ if (id) removeExclude(serverUrl, projectKey, id);
2150
+ const suspect = checkBootstrapCheckout(target);
2151
+ info("");
2152
+ info(renderAgentSuccess(key, dispatch));
2153
+ if (suspect) {
2154
+ info("");
2155
+ info(suspect.message);
2156
+ info(`Hint: ${suspect.hint}`);
2157
+ }
2158
+ }
2159
+ async function nextCommand(opts) {
2160
+ const kinds = parseKinds(opts.kinds);
2161
+ await withProjectSession(async (session) => {
2162
+ const { client, serverUrl, projectKey } = session;
2163
+ if (opts.reset) {
2164
+ const cleared = clearExcludes(serverUrl, projectKey);
2165
+ info(`Cleared ${cleared} excluded item${cleared === 1 ? "" : "s"}.`);
2166
+ }
2167
+ const excluded = readExcludes(serverUrl, projectKey);
2168
+ if (excluded.length > 0) {
2169
+ info(`Skipping ${excluded.length} previously-failed item(s): ${keyList(excluded)}.`);
2170
+ }
2171
+ const { item } = await client.nextReady({
2172
+ projectKey,
2173
+ ...kinds ? { kinds } : {},
2174
+ ...excluded.length > 0 ? { excludeIds: excluded.map((e) => e.id) } : {}
2175
+ });
2176
+ if (!item) {
2177
+ info(
2178
+ excluded.length > 0 ? "No ready work items (excluding the skipped ones \u2014 `motir next --reset` to retry them)." : "No ready work items."
2179
+ );
2180
+ return;
2181
+ }
2182
+ await ensureInProgress(client, item.key, item.status?.key);
2183
+ const dispatch = await client.dispatchPrompt(item.key);
2184
+ await deliver({
2185
+ session,
2186
+ key: item.key,
2187
+ title: item.title,
2188
+ id: item.id,
2189
+ dispatch,
2190
+ opts
2191
+ });
2192
+ });
2193
+ }
2194
+ function keyList(entries) {
2195
+ return entries.map((e) => e.key).join(", ");
2196
+ }
2197
+ function notReadyError(detail) {
2198
+ const reasons = detail.openBlockers.map((b) => `${b.identifier} (${b.title})`);
2199
+ if (detail.blockedByAncestor) {
2200
+ reasons.push(`its ancestor ${detail.blockedByAncestor.identifier} is blocked`);
2201
+ }
2202
+ const because = reasons.length > 0 ? ` Waiting on: ${reasons.join(", ")}.` : "";
2203
+ return new CliError(`${detail.identifier} is not ready.${because}`, {
2204
+ hint: `Pass --force to dispatch it anyway.`
2205
+ });
2206
+ }
2207
+ async function runCommand(key, opts) {
2208
+ const trimmed = key.trim();
2209
+ if (!trimmed) throw new CliError("A work item key is required, e.g. `motir run PROD-7`.");
2210
+ await withProjectSession(async (session) => {
2211
+ const { client } = session;
2212
+ const detail = await client.getWorkItem(trimmed);
2213
+ const { item, readiness } = detail;
2214
+ if (!readiness.ready && !opts.force) {
2215
+ throw notReadyError({
2216
+ identifier: item.identifier,
2217
+ openBlockers: readiness.openBlockers,
2218
+ blockedByAncestor: readiness.blockedByAncestor
2219
+ });
2220
+ }
2221
+ if (!readiness.ready) {
2222
+ info(`${item.identifier} is not ready \u2014 dispatching anyway (--force).`);
2223
+ }
2224
+ await ensureInProgress(client, item.identifier, item.status);
2225
+ const dispatch = await client.dispatchPrompt(item.identifier);
2226
+ await deliver({
2227
+ session,
2228
+ key: item.identifier,
2229
+ title: item.title,
2230
+ id: item.id,
2231
+ dispatch,
2232
+ opts
2233
+ });
2234
+ });
2235
+ }
2236
+ async function doneCommand(key, opts) {
2237
+ if (opts.session) {
2238
+ if (key) {
2239
+ throw new CliError("Pass either a work item key or --session <branch>, not both.");
2240
+ }
2241
+ await withProjectSession(async ({ client }) => {
2242
+ const result = await client.completeSession({
2243
+ sessionBranch: opts.session,
2244
+ implementationHarness: HARNESS
2245
+ });
2246
+ info(renderSessionOutcomes(result.sessionBranch, result.results));
2247
+ });
2248
+ return;
2249
+ }
2250
+ const trimmed = (key ?? "").trim();
2251
+ if (!trimmed) {
2252
+ throw new CliError("A work item key is required, e.g. `motir done PROD-7`.", {
2253
+ hint: "Or close out a merged session PR with `motir done --session <branch>`."
2254
+ });
2255
+ }
2256
+ await withProjectSession(async ({ client, serverUrl, projectKey }) => {
2257
+ if (opts.via) {
2258
+ await client.transitionStatus({ key: trimmed, status: opts.via });
2259
+ info(`${trimmed}: \u2192 ${opts.via}`);
2260
+ }
2261
+ try {
2262
+ await client.transitionStatus({ key: trimmed, status: DONE });
2263
+ } catch (err) {
2264
+ if (err instanceof CliError && !opts.via) {
2265
+ throw new CliError(err.message, {
2266
+ hint: `If the PR is merged but the item never reached In Review, try \`motir done --via ${IN_REVIEW} ${trimmed}\`.`
2267
+ });
2268
+ }
2269
+ throw err;
2270
+ }
2271
+ removeExcludeByKey(serverUrl, projectKey, trimmed);
2272
+ info(`${trimmed}: done.`);
2273
+ });
2274
+ }
2275
+
2276
+ // src/autoLoop.ts
2277
+ var CONTAINER_KINDS = /* @__PURE__ */ new Set(["epic", "story"]);
2278
+ function classifyReadyItem(item) {
2279
+ if (CONTAINER_KINDS.has(item.kind.toLowerCase())) return "needs_planning";
2280
+ if (item.executor === "human" || item.type === "manual") return "needs_human";
2281
+ return "dispatch";
2282
+ }
2283
+ function planReviewUrl(serverUrl, planId) {
2284
+ try {
2285
+ return new URL(`/plans/${encodeURIComponent(planId)}`, serverUrl).toString();
2286
+ } catch {
2287
+ return null;
2288
+ }
2289
+ }
2290
+ function formatDuration(ms) {
2291
+ const total = Math.max(0, Math.round(ms / 1e3));
2292
+ const minutes = Math.floor(total / 60);
2293
+ const seconds = total % 60;
2294
+ return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, "0")}s` : `${seconds}s`;
2295
+ }
2296
+ var OUTCOME_LABEL = {
2297
+ integrated: "integrated",
2298
+ in_review: "in review",
2299
+ failed: "FAILED"
2300
+ };
2301
+ var STOP_LABEL = {
2302
+ drained: "the ready set is drained",
2303
+ max: "--max reached",
2304
+ halted: "halted on the first agent failure (--keep-going continues past one)",
2305
+ interrupted: "interrupted (Ctrl-C)"
2306
+ };
2307
+ var SKIP_LABEL = {
2308
+ needs_planning: "needs planning",
2309
+ needs_human: "needs a human"
2310
+ };
2311
+ function sessionPrTitle(runId, itemCount) {
2312
+ return `Motir auto run ${runId} \u2014 ${itemCount} work item${itemCount === 1 ? "" : "s"}`;
2313
+ }
2314
+ function renderSessionPrBody(runId, branch, records) {
2315
+ const carried = records.filter((r) => r.outcome !== "failed");
2316
+ const failed = records.filter((r) => r.outcome === "failed");
2317
+ const lines = [
2318
+ `Unattended \`motir auto\` run \`${runId}\`, integrated on \`${branch}\`.`,
2319
+ "",
2320
+ `## Work items carried (${carried.length})`,
2321
+ "",
2322
+ ...carried.map((r) => `- ${r.key} \u2014 ${r.title ?? "(untitled)"}`)
2323
+ ];
2324
+ if (failed.length > 0) {
2325
+ lines.push(
2326
+ "",
2327
+ `## Attempted and failed (${failed.length}) \u2014 still In Progress, not in this branch`,
2328
+ "",
2329
+ ...failed.map((r) => `- ${r.key} \u2014 ${r.title ?? "(untitled)"}`)
2330
+ );
2331
+ }
2332
+ lines.push(
2333
+ "",
2334
+ "## Close-out",
2335
+ "",
2336
+ "Review this pull request as ONE unit. After merging it, run:",
2337
+ "",
2338
+ "```",
2339
+ `motir done --session ${branch}`,
2340
+ "```",
2341
+ "",
2342
+ "That flips every item recorded on the branch to Done and clears the recorded",
2343
+ "branch. If the pull request is rejected instead, the items honestly stay In",
2344
+ "Review \u2014 flip them back or re-dispatch them."
2345
+ );
2346
+ return lines.join("\n");
2347
+ }
2348
+ var SUMMARY_HEADERS = ["ITEM", "OUTCOME", "TIME", "BRANCH", "TITLE"];
2349
+ function renderAutoSummary(summary, titleWidth = 44) {
2350
+ const blocks = [];
2351
+ blocks.push(`Run ${summary.runId} \u2014 stopped: ${STOP_LABEL[summary.stopReason]}.`);
2352
+ if (summary.records.length === 0) {
2353
+ blocks.push("No work items were dispatched.");
2354
+ } else {
2355
+ const rows = summary.records.map((r) => [
2356
+ r.key,
2357
+ OUTCOME_LABEL[r.outcome] + (r.detail ? ` (${r.detail})` : ""),
2358
+ formatDuration(r.durationMs),
2359
+ r.sessionBranch ?? "\u2014",
2360
+ truncate(r.title ?? "", titleWidth)
2361
+ ]);
2362
+ blocks.push(formatTable(SUMMARY_HEADERS, rows));
2363
+ }
2364
+ for (const reason of ["needs_planning", "needs_human"]) {
2365
+ const group = summary.skipped.filter((s) => s.reason === reason);
2366
+ if (group.length === 0) continue;
2367
+ blocks.push(
2368
+ [
2369
+ `Skipped \u2014 ${SKIP_LABEL[reason]} (${group.length}):`,
2370
+ ...group.map((s) => ` ${s.key} \u2014 ${truncate(s.title ?? "", titleWidth)}`)
2371
+ ].join("\n")
2372
+ );
2373
+ }
2374
+ blocks.push(...renderPlanningBlocks(summary.planning, titleWidth));
2375
+ const inReview = summary.records.filter((r) => r.outcome !== "failed");
2376
+ if (inReview.length > 0) {
2377
+ blocks.push(
2378
+ [
2379
+ `In Review \u2014 awaiting your merge (${inReview.length}):`,
2380
+ ...inReview.map((r) => ` ${r.key} on ${r.sessionBranch ?? "(no branch recorded)"}`)
2381
+ ].join("\n")
2382
+ );
2383
+ }
2384
+ const failed = summary.records.filter((r) => r.outcome === "failed");
2385
+ if (failed.length > 0) {
2386
+ blocks.push(
2387
+ [
2388
+ `Failed \u2014 still In Progress, nothing reverted (${failed.length}):`,
2389
+ ...failed.map((r) => ` ${r.key} \u2014 re-run it with \`motir run ${r.key}\``)
2390
+ ].join("\n")
2391
+ );
2392
+ }
2393
+ if (summary.prs.length > 0) {
2394
+ blocks.push(["Pull requests:", ...summary.prs.map(prLine)].join("\n"));
2395
+ }
2396
+ return blocks.join("\n\n");
2397
+ }
2398
+ function renderPlanningBlocks(planning, titleWidth) {
2399
+ const blocks = [];
2400
+ const triggered = planning.filter((p) => p.outcome === "triggered");
2401
+ const failed = planning.filter((p) => p.outcome === "failed");
2402
+ if (triggered.length > 0) {
2403
+ blocks.push(
2404
+ [
2405
+ `Planning triggered \u2014 awaiting your approval (${triggered.length}):`,
2406
+ ...triggered.map(
2407
+ (p) => ` ${p.key} \u2014 ${truncate(p.title ?? "", titleWidth)}
2408
+ plan ${p.planId ?? "(id not returned)"}${p.reviewUrl ? ` \u2014 ${p.reviewUrl}` : ""}`
2409
+ ),
2410
+ " These are PROPOSALS. Nothing was added to the plan: approve them in Motir",
2411
+ " and their subtasks join a later run like any other ready work."
2412
+ ].join("\n")
2413
+ );
2414
+ }
2415
+ if (failed.length > 0) {
2416
+ blocks.push(
2417
+ [
2418
+ `Planning failed \u2014 still unexpanded (${failed.length}):`,
2419
+ ...failed.map((p) => ` ${p.key} \u2014 ${p.detail ?? "the expansion was refused"}`)
2420
+ ].join("\n")
2421
+ );
2422
+ }
2423
+ return blocks;
2424
+ }
2425
+ function prLine(pr) {
2426
+ const repo = pr.repoName ?? "the checkout";
2427
+ switch (pr.outcome) {
2428
+ case "opened":
2429
+ return ` ${repo}: ${pr.url ?? pr.branch} (opened)`;
2430
+ case "existing":
2431
+ return ` ${repo}: ${pr.url ?? pr.branch} (already open \u2014 updated by this run)`;
2432
+ case "empty":
2433
+ return ` ${repo}: no pull request \u2014 ${pr.branch} carries no commits beyond main.`;
2434
+ case "failed":
2435
+ return ` ${repo}: NOT opened. ${pr.message ?? ""}
2436
+ The work IS pushed to ${pr.branch} \u2014 open the pull request by hand, then \`motir done --session ${pr.branch}\`.`;
2437
+ }
2438
+ }
2439
+ function autoExitCode(summary) {
2440
+ if (summary.records.some((r) => r.outcome === "failed")) return 1;
2441
+ if (summary.stopReason === "interrupted") return 130;
2442
+ return 0;
2443
+ }
2444
+
2445
+ // src/git.ts
2446
+ import { spawnSync } from "child_process";
2447
+ var execCommand = (bin, args, cwd) => {
2448
+ const res = spawnSync(bin, args, { cwd, encoding: "utf8", shell: false });
2449
+ if (res.error) {
2450
+ return { exitCode: 127, stdout: "", stderr: res.error.message };
2451
+ }
2452
+ return {
2453
+ exitCode: res.status ?? 1,
2454
+ stdout: (res.stdout ?? "").trim(),
2455
+ stderr: (res.stderr ?? "").trim()
2456
+ };
2457
+ };
2458
+ function sessionBranchName(runId) {
2459
+ return `motir/auto-${runId}`;
2460
+ }
2461
+ function runIdFromDate(now) {
2462
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
2463
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
2464
+ }
2465
+ var GitError = class extends Error {
2466
+ constructor(message) {
2467
+ super(message);
2468
+ this.name = "GitError";
2469
+ }
2470
+ };
2471
+ function requireOk(res, what) {
2472
+ if (res.exitCode !== 0) {
2473
+ throw new GitError(`${what} failed: ${res.stderr || res.stdout || `exit ${res.exitCode}`}`);
2474
+ }
2475
+ return res;
2476
+ }
2477
+ function ensureSessionBranchOnOrigin(cwd, branch, run = execCommand) {
2478
+ requireOk(run("git", ["fetch", "origin"], cwd), "git fetch origin");
2479
+ const exists = run(
2480
+ "git",
2481
+ ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`],
2482
+ cwd
2483
+ );
2484
+ if (exists.exitCode === 0) return "already_on_origin";
2485
+ requireOk(
2486
+ run("git", ["push", "origin", `refs/remotes/origin/main:refs/heads/${branch}`], cwd),
2487
+ `creating ${branch} on origin`
2488
+ );
2489
+ return "created";
2490
+ }
2491
+ function pushSessionBranchIfAhead(cwd, branch, run = execCommand) {
2492
+ const local = run("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd);
2493
+ if (local.exitCode !== 0) return "no_local_branch";
2494
+ const ahead = run("git", ["rev-list", "--count", `origin/${branch}..${branch}`], cwd);
2495
+ if (ahead.exitCode === 0 && ahead.stdout.trim() === "0") return "up_to_date";
2496
+ requireOk(run("git", ["push", "origin", branch], cwd), `pushing ${branch}`);
2497
+ return "pushed";
2498
+ }
2499
+ function sessionBranchHasCommits(cwd, branch, run = execCommand) {
2500
+ const res = run("git", ["rev-list", "--count", `origin/main..origin/${branch}`], cwd);
2501
+ if (res.exitCode !== 0) return false;
2502
+ return Number.parseInt(res.stdout.trim(), 10) > 0;
2503
+ }
2504
+ function openSessionPr(cwd, input, run = execCommand) {
2505
+ const existing = run(
2506
+ "gh",
2507
+ ["pr", "list", "--head", input.branch, "--state", "open", "--json", "url", "--jq", ".[0].url"],
2508
+ cwd
2509
+ );
2510
+ if (existing.exitCode === 0 && existing.stdout.trim()) {
2511
+ return { url: existing.stdout.trim(), outcome: "existing" };
2512
+ }
2513
+ const created = run(
2514
+ "gh",
2515
+ [
2516
+ "pr",
2517
+ "create",
2518
+ "--base",
2519
+ "main",
2520
+ "--head",
2521
+ input.branch,
2522
+ "--title",
2523
+ input.title,
2524
+ "--body",
2525
+ input.body
2526
+ ],
2527
+ cwd
2528
+ );
2529
+ if (created.exitCode !== 0) {
2530
+ return {
2531
+ url: null,
2532
+ outcome: "failed",
2533
+ message: `Could not open the pull request for ${input.branch}: ${created.stderr || created.stdout || `gh exited ${created.exitCode}`}`
2534
+ };
2535
+ }
2536
+ const url = created.stdout.split("\n").filter(Boolean).pop() ?? null;
2537
+ return { url, outcome: "opened" };
2538
+ }
2539
+
2540
+ // src/commands/auto.ts
2541
+ var HARNESS2 = `motir-cli/${CLI_VERSION}`;
2542
+ function parseMax(raw) {
2543
+ if (raw === void 0) return null;
2544
+ const n = Number.parseInt(raw, 10);
2545
+ if (!Number.isFinite(n) || n <= 0 || String(n) !== raw.trim()) {
2546
+ throw new CliError(`--max must be a positive whole number, got "${raw}".`);
2547
+ }
2548
+ return n;
2549
+ }
2550
+ function requireAgent(opts) {
2551
+ if (opts.print) {
2552
+ throw new CliError("`motir auto` cannot run in --print mode.", {
2553
+ hint: "An unattended loop has nobody to paste a prompt. Use `motir next --print` for one item, or pass --agent <cmd>."
2554
+ });
2555
+ }
2556
+ const agent = resolveAgent(opts);
2557
+ if (!agent) {
2558
+ throw new CliError("`motir auto` needs an agent to run.", {
2559
+ hint: 'Pass --agent "<cmd>", set MOTIR_AGENT, or configure agentCommand. `motir doctor` checks it.'
2560
+ });
2561
+ }
2562
+ return agent;
2563
+ }
2564
+ var RepoSessions = class {
2565
+ constructor(branch, run) {
2566
+ this.branch = branch;
2567
+ this.run = run;
2568
+ }
2569
+ branch;
2570
+ run;
2571
+ byPath = /* @__PURE__ */ new Map();
2572
+ ensure(target) {
2573
+ const existing = this.byPath.get(target.cwd);
2574
+ if (existing !== void 0) return existing;
2575
+ if (target.reason !== "repo_checkout") {
2576
+ const session2 = this.tryCreate(target, { tolerateFailure: true });
2577
+ this.byPath.set(target.cwd, session2);
2578
+ return session2;
2579
+ }
2580
+ const session = this.tryCreate(target, { tolerateFailure: false });
2581
+ this.byPath.set(target.cwd, session);
2582
+ return session;
2583
+ }
2584
+ tryCreate(target, opts) {
2585
+ try {
2586
+ const outcome = ensureSessionBranchOnOrigin(target.cwd, this.branch, this.run);
2587
+ info(
2588
+ outcome === "created" ? `Session branch ${this.branch} created on origin in ${target.cwd}.` : `Session branch ${this.branch} already on origin in ${target.cwd} \u2014 reusing it.`
2589
+ );
2590
+ return { repoName: target.targetRepo, cwd: target.cwd, branch: this.branch, keys: [] };
2591
+ } catch (err) {
2592
+ if (!opts.tolerateFailure) throw err;
2593
+ info(
2594
+ `No session branch in ${target.cwd} (${err instanceof Error ? err.message : String(err)}). Items routed here ship as their own pull request.`
2595
+ );
2596
+ return null;
2597
+ }
2598
+ }
2599
+ /** Every repo a session branch was opened in, in first-touch order. A repo
2600
+ * whose only item failed is included on purpose: its branch EXISTS on origin,
2601
+ * and the summary saying so beats leaving a stray branch unmentioned. */
2602
+ touched() {
2603
+ return [...this.byPath.values()].filter((s) => s !== null);
2604
+ }
2605
+ };
2606
+ async function autoCommand(opts, deps = {}) {
2607
+ const run = deps.run ?? execCommand;
2608
+ const clock = deps.clock ?? Date.now;
2609
+ const kinds = parseKinds(opts.kinds);
2610
+ const max = parseMax(opts.max);
2611
+ const agent = requireAgent(opts);
2612
+ const runId = runIdFromDate((deps.now ?? (() => /* @__PURE__ */ new Date()))());
2613
+ const branch = sessionBranchName(runId);
2614
+ await withProjectSession(async (session) => {
2615
+ const summary = await runAutoLoop({
2616
+ session,
2617
+ opts,
2618
+ kinds,
2619
+ max,
2620
+ agent,
2621
+ runId,
2622
+ branch,
2623
+ run,
2624
+ clock,
2625
+ runAgentFn: deps.runAgentFn ?? runAgent
2626
+ });
2627
+ closeOutRepos(summary, run);
2628
+ info("");
2629
+ info(renderAutoSummary(summary));
2630
+ process.exitCode = autoExitCode(summary);
2631
+ });
2632
+ }
2633
+ async function runAutoLoop(input) {
2634
+ const { session, opts, kinds, max, agent, runId, branch, run, clock, runAgentFn } = input;
2635
+ const { client, serverUrl, projectKey } = session;
2636
+ if (opts.reset) {
2637
+ const cleared = clearExcludes(serverUrl, projectKey);
2638
+ info(`Cleared ${cleared} excluded item${cleared === 1 ? "" : "s"}.`);
2639
+ }
2640
+ const excludeIds = new Set(readExcludes(serverUrl, projectKey).map((e) => e.id));
2641
+ const records = [];
2642
+ const skipped = [];
2643
+ const planning = [];
2644
+ const repos = new RepoSessions(branch, run);
2645
+ let interrupted = false;
2646
+ const onSigint = () => {
2647
+ if (interrupted) process.exit(130);
2648
+ interrupted = true;
2649
+ info("");
2650
+ info("Interrupt received \u2014 finishing up and opening the session pull request(s).");
2651
+ info("Press Ctrl-C again to exit immediately.");
2652
+ };
2653
+ process.on("SIGINT", onSigint);
2654
+ let stopReason = "drained";
2655
+ try {
2656
+ for (; ; ) {
2657
+ if (interrupted) {
2658
+ stopReason = "interrupted";
2659
+ break;
2660
+ }
2661
+ if (max !== null && records.length >= max) {
2662
+ stopReason = "max";
2663
+ break;
2664
+ }
2665
+ const { item } = await client.nextReady({
2666
+ projectKey,
2667
+ ...kinds ? { kinds } : {},
2668
+ ...excludeIds.size > 0 ? { excludeIds: [...excludeIds] } : {}
2669
+ });
2670
+ if (!item) {
2671
+ stopReason = "drained";
2672
+ break;
2673
+ }
2674
+ const disposition = classifyReadyItem(item);
2675
+ if (disposition === "needs_planning" && opts.includePlanning) {
2676
+ planning.push(await triggerExpansion(client, serverUrl, item));
2677
+ excludeIds.add(item.id);
2678
+ continue;
2679
+ }
2680
+ if (disposition !== "dispatch") {
2681
+ skipped.push({ key: item.key, title: item.title, reason: disposition });
2682
+ excludeIds.add(item.id);
2683
+ info(
2684
+ `${item.key}: skipped \u2014 ${disposition === "needs_planning" ? "an unexpanded container needs planning" : "human work"}.`
2685
+ );
2686
+ continue;
2687
+ }
2688
+ const target = resolveDispatchTarget(session.link.dir, session.link.config, item.targetRepo);
2689
+ let repo;
2690
+ try {
2691
+ repo = repos.ensure(target);
2692
+ } catch (err) {
2693
+ info("");
2694
+ info(`${item.key}: ${err instanceof GitError ? err.message : String(err)}`);
2695
+ stopReason = "halted";
2696
+ break;
2697
+ }
2698
+ const record = await dispatchOne({
2699
+ client,
2700
+ item,
2701
+ target,
2702
+ repo,
2703
+ agent,
2704
+ clock,
2705
+ runAgentFn,
2706
+ onIntegrated: (key) => repo?.keys.push(key)
2707
+ });
2708
+ records.push(record);
2709
+ if (record.outcome === "failed") {
2710
+ addExclude(serverUrl, projectKey, { id: item.id, key: item.key });
2711
+ excludeIds.add(item.id);
2712
+ if (!opts.keepGoing) {
2713
+ stopReason = interrupted ? "interrupted" : "halted";
2714
+ break;
2715
+ }
2716
+ } else {
2717
+ removeExclude(serverUrl, projectKey, item.id);
2718
+ }
2719
+ }
2720
+ } finally {
2721
+ process.off("SIGINT", onSigint);
2722
+ }
2723
+ return { runId, records, skipped, planning, repos: repos.touched(), prs: [], stopReason };
2724
+ }
2725
+ async function triggerExpansion(client, serverUrl, item) {
2726
+ const base = { key: item.key, title: item.title };
2727
+ try {
2728
+ const { planId, jobId } = await client.expandItem(item.key);
2729
+ const reviewUrl = planReviewUrl(serverUrl, planId);
2730
+ info(
2731
+ `${item.key}: planning triggered \u2014 job ${jobId}, plan ${planId}. It produces PROPOSALS awaiting your approval; the loop moves on.`
2732
+ );
2733
+ return { ...base, outcome: "triggered", planId, reviewUrl };
2734
+ } catch (err) {
2735
+ const detail = err instanceof Error ? err.message : String(err);
2736
+ info(`${item.key}: planning FAILED \u2014 ${detail}. Left unexpanded; the loop continues.`);
2737
+ return { ...base, outcome: "failed", planId: null, reviewUrl: null, detail };
2738
+ }
2739
+ }
2740
+ async function dispatchOne(input) {
2741
+ const { client, item, target, repo, agent, clock, runAgentFn, onIntegrated } = input;
2742
+ await ensureInProgress(client, item.key, item.status?.key);
2743
+ const dispatch = await client.dispatchPrompt(item.key, { sessionBranch: repo?.branch ?? null });
2744
+ info("");
2745
+ info(`\u2500\u2500 ${item.key} \u2014 ${item.title}`);
2746
+ info(` ${target.cwd} (${cwdReasonLabel(target)})`);
2747
+ info(
2748
+ dispatch.sessionBranch ? ` integrating into ${dispatch.sessionBranch}` : " no session lineage \u2014 this item ships as its own pull request"
2749
+ );
2750
+ const started = clock();
2751
+ const result = await runAgentFn({
2752
+ command: agent.parsed,
2753
+ prompt: dispatch.prompt,
2754
+ cwd: target.cwd
2755
+ });
2756
+ const durationMs = clock() - started;
2757
+ const base = {
2758
+ key: item.key,
2759
+ title: item.title,
2760
+ durationMs,
2761
+ repo: dispatch.targetRepo
2762
+ };
2763
+ if (result.exitCode !== 0) {
2764
+ info(`${item.key}: agent exited ${result.exitCode} \u2014 left In Progress, nothing reverted.`);
2765
+ return {
2766
+ ...base,
2767
+ outcome: "failed",
2768
+ sessionBranch: null,
2769
+ detail: result.signal ? `killed by ${result.signal}` : `exit ${result.exitCode}`
2770
+ };
2771
+ }
2772
+ const suspect = checkBootstrapCheckout(target);
2773
+ if (suspect) {
2774
+ info(`${item.key}: ${suspect.message}`);
2775
+ info(`Hint: ${suspect.hint}`);
2776
+ return {
2777
+ ...base,
2778
+ outcome: "failed",
2779
+ sessionBranch: null,
2780
+ detail: "bootstrap checkout missing"
2781
+ };
2782
+ }
2783
+ if (dispatch.workflowMode === "session_lineage" && dispatch.sessionBranch) {
2784
+ await client.markIntegrated({
2785
+ key: item.key,
2786
+ sessionBranch: dispatch.sessionBranch,
2787
+ implementationHarness: HARNESS2
2788
+ });
2789
+ onIntegrated(item.key);
2790
+ info(`${item.key}: integrated on ${dispatch.sessionBranch} in ${formatDuration(durationMs)}.`);
2791
+ return { ...base, outcome: "integrated", sessionBranch: dispatch.sessionBranch };
2792
+ }
2793
+ await client.transitionStatus({ key: item.key, status: "in_review" });
2794
+ info(`${item.key}: In Review via its own pull request in ${formatDuration(durationMs)}.`);
2795
+ return { ...base, outcome: "in_review", sessionBranch: null, detail: "own pull request" };
2796
+ }
2797
+ function closeOutRepos(summary, run) {
2798
+ for (const repo of summary.repos) {
2799
+ const report = closeOutRepo(summary, repo, run);
2800
+ summary.prs.push(report);
2801
+ }
2802
+ }
2803
+ function closeOutRepo(summary, repo, run) {
2804
+ const base = { repoName: repo.repoName, branch: repo.branch };
2805
+ try {
2806
+ const pushed = pushSessionBranchIfAhead(repo.cwd, repo.branch, run);
2807
+ if (pushed === "pushed") info(`Pushed ${repo.branch} in ${repo.cwd}.`);
2808
+ if (!sessionBranchHasCommits(repo.cwd, repo.branch, run)) {
2809
+ return { ...base, url: null, outcome: "empty" };
2810
+ }
2811
+ const mine = summary.records.filter(
2812
+ (r) => r.sessionBranch === repo.branch || r.outcome === "failed" && r.repo === repo.repoName
2813
+ );
2814
+ const carried = mine.filter((r) => r.outcome !== "failed");
2815
+ const result = openSessionPr(
2816
+ repo.cwd,
2817
+ {
2818
+ branch: repo.branch,
2819
+ title: sessionPrTitle(summary.runId, carried.length),
2820
+ body: renderSessionPrBody(summary.runId, repo.branch, mine)
2821
+ },
2822
+ run
2823
+ );
2824
+ return {
2825
+ ...base,
2826
+ url: result.url,
2827
+ outcome: result.outcome,
2828
+ ...result.message ? { message: result.message } : {}
2829
+ };
2830
+ } catch (err) {
2831
+ return {
2832
+ ...base,
2833
+ url: null,
2834
+ outcome: "failed",
2835
+ message: err instanceof Error ? err.message : String(err)
2836
+ };
2837
+ }
2838
+ }
2839
+
2840
+ // src/batchPlan.ts
2841
+ function classifySnapshotItem(item) {
2842
+ const shared = classifyReadyItem(item);
2843
+ if (shared !== "dispatch") return shared;
2844
+ if (item.sessionBranch) return "integrated_dep";
2845
+ return "take";
2846
+ }
2847
+ var OUTCOME_LABEL2 = {
2848
+ in_review: "in review",
2849
+ failed: "FAILED"
2850
+ };
2851
+ var STOP_LABEL2 = {
2852
+ completed: "the whole snapshot was attempted",
2853
+ max: "--max reached",
2854
+ halted: "halted on the first agent failure (--keep-going continues past one)",
2855
+ interrupted: "interrupted (Ctrl-C)"
2856
+ };
2857
+ var SKIP_LABEL2 = {
2858
+ needs_planning: "needs planning",
2859
+ needs_human: "needs a human",
2860
+ integrated_dep: "ready only via an integrated dependency (not on main)"
2861
+ };
2862
+ var SKIP_ORDER = ["needs_planning", "needs_human", "integrated_dep"];
2863
+ var PLAN_HEADERS = ["ITEM", "KIND", "REPO", "TITLE"];
2864
+ function renderSnapshotPlan(snapshot, titleWidth = 44) {
2865
+ const blocks = [];
2866
+ const { taken, skipped } = snapshot;
2867
+ if (taken.length === 0) {
2868
+ blocks.push("Snapshot: no work items to implement.");
2869
+ } else {
2870
+ blocks.push(
2871
+ `Snapshot: ${taken.length} work item${taken.length === 1 ? "" : "s"} \u2014 this exact list, one at a time. Items that become ready during the run are NOT picked up.`
2872
+ );
2873
+ blocks.push(
2874
+ formatTable(
2875
+ PLAN_HEADERS,
2876
+ taken.map((e) => [e.key, e.kind, e.targetRepo ?? "\u2014", truncate(e.title ?? "", titleWidth)])
2877
+ )
2878
+ );
2879
+ }
2880
+ blocks.push(...renderSkipGroups(skipped, titleWidth));
2881
+ return blocks.join("\n\n");
2882
+ }
2883
+ function renderSkipGroups(skipped, titleWidth) {
2884
+ const blocks = [];
2885
+ for (const reason of SKIP_ORDER) {
2886
+ const group = skipped.filter((s) => s.reason === reason);
2887
+ if (group.length === 0) continue;
2888
+ const lines = [
2889
+ `Not in the snapshot \u2014 ${SKIP_LABEL2[reason]} (${group.length}):`,
2890
+ ...group.map((s) => ` ${s.key} \u2014 ${truncate(s.title ?? "", titleWidth)}`)
2891
+ ];
2892
+ if (reason === "integrated_dep") {
2893
+ lines.push(" Run these with `motir auto`, which carries that lineage on a session branch.");
2894
+ }
2895
+ blocks.push(lines.join("\n"));
2896
+ }
2897
+ return blocks;
2898
+ }
2899
+ var SUMMARY_HEADERS2 = ["ITEM", "OUTCOME", "TIME", "REPO", "TITLE"];
2900
+ function renderBatchSummary(summary, titleWidth = 44) {
2901
+ const blocks = [];
2902
+ blocks.push(`Batch finished \u2014 stopped: ${STOP_LABEL2[summary.stopReason]}.`);
2903
+ if (summary.records.length === 0) {
2904
+ blocks.push("No work items were dispatched.");
2905
+ } else {
2906
+ blocks.push(
2907
+ formatTable(
2908
+ SUMMARY_HEADERS2,
2909
+ summary.records.map((r) => [
2910
+ r.key,
2911
+ OUTCOME_LABEL2[r.outcome] + (r.detail ? ` (${r.detail})` : ""),
2912
+ formatDuration(r.durationMs),
2913
+ r.repo ?? "\u2014",
2914
+ truncate(r.title ?? "", titleWidth)
2915
+ ])
2916
+ )
2917
+ );
2918
+ }
2919
+ const inReview = summary.records.filter((r) => r.outcome === "in_review");
2920
+ if (inReview.length > 0) {
2921
+ blocks.push(
2922
+ [
2923
+ `In Review \u2014 each has its OWN pull request to merge (${inReview.length}):`,
2924
+ ...inReview.map((r) => ` ${r.key} \u2014 review + merge it, then \`motir done ${r.key}\``)
2925
+ ].join("\n")
2926
+ );
2927
+ }
2928
+ const failed = summary.records.filter((r) => r.outcome === "failed");
2929
+ if (failed.length > 0) {
2930
+ blocks.push(
2931
+ [
2932
+ `Failed \u2014 still In Progress, nothing reverted (${failed.length}):`,
2933
+ ...failed.map((r) => ` ${r.key} \u2014 re-run it with \`motir run ${r.key}\``)
2934
+ ].join("\n")
2935
+ );
2936
+ }
2937
+ if (summary.notReached.length > 0) {
2938
+ blocks.push(
2939
+ [
2940
+ `Not reached \u2014 still in the snapshot, never started (${summary.notReached.length}):`,
2941
+ ...summary.notReached.map((e) => ` ${e.key} \u2014 ${truncate(e.title ?? "", titleWidth)}`),
2942
+ " Re-run `motir batch` to take a fresh snapshot including these."
2943
+ ].join("\n")
2944
+ );
2945
+ }
2946
+ blocks.push(...renderSkipGroups(summary.skipped, titleWidth));
2947
+ if (summary.newlyReady.length > 0) {
2948
+ blocks.push(
2949
+ [
2950
+ `${summary.newlyReady.length} became ready during the run \u2014 NOT dispatched (a snapshot is frozen on purpose):`,
2951
+ ...summary.newlyReady.map((n) => ` ${n.key} \u2014 ${truncate(n.title ?? "", titleWidth)}`),
2952
+ " Re-run `motir batch`, or use `motir auto` to follow the ready set live."
2953
+ ].join("\n")
2954
+ );
2955
+ }
2956
+ return blocks.join("\n\n");
2957
+ }
2958
+ function batchExitCode(summary) {
2959
+ if (summary.records.some((r) => r.outcome === "failed")) return 1;
2960
+ if (summary.stopReason === "interrupted") return 130;
2961
+ return 0;
2962
+ }
2963
+
2964
+ // src/commands/batch.ts
2965
+ function requireAgent2(opts) {
2966
+ if (opts.print) {
2967
+ throw new CliError("`motir batch` cannot run in --print mode.", {
2968
+ hint: "A batch has nobody to paste a prompt. Use `motir next --print` for one item, or pass --agent <cmd>."
2969
+ });
2970
+ }
2971
+ const agent = resolveAgent(opts);
2972
+ if (!agent) {
2973
+ throw new CliError("`motir batch` needs an agent to run.", {
2974
+ hint: 'Pass --agent "<cmd>", set MOTIR_AGENT, or configure agentCommand. `motir doctor` checks it.'
2975
+ });
2976
+ }
2977
+ return agent;
2978
+ }
2979
+ async function enumerateReady(client, projectKey, kinds, seedExcludeIds) {
2980
+ const seen = new Set(seedExcludeIds);
2981
+ const items = [];
2982
+ for (; ; ) {
2983
+ const { item } = await client.nextReady({
2984
+ projectKey,
2985
+ ...kinds ? { kinds } : {},
2986
+ ...seen.size > 0 ? { excludeIds: [...seen] } : {}
2987
+ });
2988
+ if (!item) return items;
2989
+ if (seen.has(item.id)) return items;
2990
+ seen.add(item.id);
2991
+ items.push(item);
2992
+ }
2993
+ }
2994
+ function planSnapshot(items) {
2995
+ const taken = [];
2996
+ const skipped = [];
2997
+ for (const item of items) {
2998
+ const disposition = classifySnapshotItem(item);
2999
+ if (disposition === "take") {
3000
+ taken.push({
3001
+ id: item.id,
3002
+ key: item.key,
3003
+ title: item.title,
3004
+ kind: item.kind,
3005
+ targetRepo: item.targetRepo,
3006
+ statusKey: item.status?.key
3007
+ });
3008
+ } else {
3009
+ skipped.push({ key: item.key, title: item.title, reason: disposition });
3010
+ }
3011
+ }
3012
+ return { taken, skipped };
3013
+ }
3014
+ async function batchCommand(opts, deps = {}) {
3015
+ const kinds = parseKinds(opts.kinds);
3016
+ const max = parseMax(opts.max);
3017
+ const agent = requireAgent2(opts);
3018
+ await withProjectSession(async (session) => {
3019
+ const summary = await runBatch({
3020
+ session,
3021
+ opts,
3022
+ kinds,
3023
+ max,
3024
+ agent,
3025
+ clock: deps.clock ?? Date.now,
3026
+ runAgentFn: deps.runAgentFn ?? runAgent
3027
+ });
3028
+ info("");
3029
+ info(renderBatchSummary(summary));
3030
+ process.exitCode = batchExitCode(summary);
3031
+ });
3032
+ }
3033
+ async function runBatch(input) {
3034
+ const { session, opts, kinds, max, agent, clock, runAgentFn } = input;
3035
+ const { client, serverUrl, projectKey } = session;
3036
+ if (opts.reset) {
3037
+ const cleared = clearExcludes(serverUrl, projectKey);
3038
+ info(`Cleared ${cleared} excluded item${cleared === 1 ? "" : "s"}.`);
3039
+ }
3040
+ const persistedExcludes = new Set(readExcludes(serverUrl, projectKey).map((e) => e.id));
3041
+ if (persistedExcludes.size > 0) {
3042
+ info(
3043
+ `Skipping ${persistedExcludes.size} previously-failed item(s) \u2014 \`--reset\` retries them.`
3044
+ );
3045
+ }
3046
+ const snapshot = planSnapshot(await enumerateReady(client, projectKey, kinds, persistedExcludes));
3047
+ const snapshotIds = /* @__PURE__ */ new Set([...snapshot.taken.map((e) => e.id), ...persistedExcludes]);
3048
+ const skippedIds = /* @__PURE__ */ new Set();
3049
+ info(renderSnapshotPlan(snapshot));
3050
+ const records = [];
3051
+ const skipped = [...snapshot.skipped];
3052
+ let stopIndex = snapshot.taken.length;
3053
+ let stopReason = "completed";
3054
+ let interrupted = false;
3055
+ const onSigint = () => {
3056
+ if (interrupted) process.exit(130);
3057
+ interrupted = true;
3058
+ info("");
3059
+ info("Interrupt received \u2014 finishing the item in flight, then stopping.");
3060
+ info("Press Ctrl-C again to exit immediately.");
3061
+ };
3062
+ process.on("SIGINT", onSigint);
3063
+ try {
3064
+ for (const [index, entry] of snapshot.taken.entries()) {
3065
+ if (interrupted) {
3066
+ stopReason = "interrupted";
3067
+ stopIndex = index;
3068
+ break;
3069
+ }
3070
+ if (max !== null && records.length >= max) {
3071
+ stopReason = "max";
3072
+ stopIndex = index;
3073
+ break;
3074
+ }
3075
+ const outcome = await dispatchOne2({
3076
+ session,
3077
+ entry,
3078
+ agent,
3079
+ clock,
3080
+ runAgentFn
3081
+ });
3082
+ if (outcome.kind === "skipped") {
3083
+ skipped.push(outcome.skip);
3084
+ skippedIds.add(entry.id);
3085
+ continue;
3086
+ }
3087
+ records.push(outcome.record);
3088
+ if (outcome.record.outcome === "failed") {
3089
+ addExclude(serverUrl, projectKey, { id: entry.id, key: entry.key });
3090
+ if (!opts.keepGoing) {
3091
+ stopReason = interrupted ? "interrupted" : "halted";
3092
+ stopIndex = index + 1;
3093
+ break;
3094
+ }
3095
+ } else {
3096
+ removeExclude(serverUrl, projectKey, entry.id);
3097
+ }
3098
+ }
3099
+ } finally {
3100
+ process.off("SIGINT", onSigint);
3101
+ }
3102
+ const notReached = snapshot.taken.slice(stopIndex).filter((e) => !skippedIds.has(e.id) && !records.some((r) => r.key === e.key));
3103
+ return {
3104
+ records,
3105
+ skipped,
3106
+ notReached,
3107
+ // Reporting only — read AFTER the drain, and never fed back into it.
3108
+ newlyReady: await countNewlyReady(client, projectKey, kinds, snapshotIds, snapshot.skipped),
3109
+ stopReason
3110
+ };
3111
+ }
3112
+ async function countNewlyReady(client, projectKey, kinds, snapshotIds, snapshotSkips) {
3113
+ const skippedKeys = new Set(snapshotSkips.map((s) => s.key));
3114
+ const after = await enumerateReady(client, projectKey, kinds, /* @__PURE__ */ new Set());
3115
+ return after.filter((item) => !snapshotIds.has(item.id) && !skippedKeys.has(item.key)).map((item) => ({ key: item.key, title: item.title }));
3116
+ }
3117
+ async function dispatchOne2(input) {
3118
+ const { session, entry, agent, clock, runAgentFn } = input;
3119
+ const { client, link } = session;
3120
+ const dispatch = await client.dispatchPrompt(entry.key);
3121
+ if (dispatch.workflowMode === "session_lineage") {
3122
+ info(
3123
+ `${entry.key}: skipped \u2014 a dependency was integrated on ${dispatch.sessionBranch ?? "a session branch"} after the snapshot was taken.`
3124
+ );
3125
+ return {
3126
+ kind: "skipped",
3127
+ skip: { key: entry.key, title: entry.title, reason: "integrated_dep" }
3128
+ };
3129
+ }
3130
+ await ensureInProgress(client, entry.key, entry.statusKey);
3131
+ const target = resolveDispatchTarget(link.dir, link.config, dispatch.targetRepo);
3132
+ info("");
3133
+ info(`\u2500\u2500 ${entry.key} \u2014 ${entry.title ?? ""}`);
3134
+ info(` ${target.cwd} (${cwdReasonLabel(target)})`);
3135
+ info(" its own branch off origin/main, its own pull request");
3136
+ const started = clock();
3137
+ const result = await runAgentFn({
3138
+ command: agent.parsed,
3139
+ prompt: dispatch.prompt,
3140
+ cwd: target.cwd
3141
+ });
3142
+ const durationMs = clock() - started;
3143
+ const base = { key: entry.key, title: entry.title, durationMs, repo: dispatch.targetRepo };
3144
+ if (result.exitCode !== 0) {
3145
+ info(`${entry.key}: agent exited ${result.exitCode} \u2014 left In Progress, nothing reverted.`);
3146
+ return {
3147
+ kind: "record",
3148
+ record: {
3149
+ ...base,
3150
+ outcome: "failed",
3151
+ detail: result.signal ? `killed by ${result.signal}` : `exit ${result.exitCode}`
3152
+ }
3153
+ };
3154
+ }
3155
+ const suspect = checkBootstrapCheckout(target);
3156
+ if (suspect) {
3157
+ info(`${entry.key}: ${suspect.message}`);
3158
+ info(`Hint: ${suspect.hint}`);
3159
+ return {
3160
+ kind: "record",
3161
+ record: { ...base, outcome: "failed", detail: "bootstrap checkout missing" }
3162
+ };
3163
+ }
3164
+ await client.transitionStatus({ key: entry.key, status: "in_review" });
3165
+ info(`${entry.key}: In Review via its own pull request.`);
3166
+ return { kind: "record", record: { ...base, outcome: "in_review" } };
3167
+ }
3168
+
3169
+ // src/plan.ts
3170
+ var WORK_ITEM_KEY = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
3171
+ var WATCH_TIMEOUT_MS = 10 * 60 * 1e3;
3172
+ var WATCH_POLL_MS = 3e3;
3173
+ var PROPOSALS_NOT_WORK_ITEMS = "These are PROPOSALS, not work items. Nothing above exists in the tree yet \u2014 approving the plan in Motir is the only thing that creates one.";
3174
+ function parsePlanArgs(args) {
3175
+ const targetKeys = [];
3176
+ let index = 0;
3177
+ while (index < args.length && WORK_ITEM_KEY.test(args[index].trim())) {
3178
+ const key = args[index].trim().toUpperCase();
3179
+ if (!targetKeys.includes(key)) targetKeys.push(key);
3180
+ index += 1;
3181
+ }
3182
+ const text = args.slice(index).join(" ").trim();
3183
+ return { targetKeys, text: text.length > 0 ? text : null };
3184
+ }
3185
+ function classifyInput(line) {
3186
+ if (line === null) return { kind: "exit" };
3187
+ const trimmed = line.trim();
3188
+ if (trimmed.length === 0) return { kind: "none" };
3189
+ if (!trimmed.startsWith("/")) return { kind: "turn", body: trimmed };
3190
+ const word = trimmed.slice(1).toLowerCase();
3191
+ if (word === "submit" || word === "send") return { kind: "submit" };
3192
+ if (word === "exit" || word === "quit") return { kind: "exit" };
3193
+ if (word === "help" || word === "?") return { kind: "help" };
3194
+ return { kind: "unknown", input: trimmed };
3195
+ }
3196
+ function loopCommands() {
3197
+ return [
3198
+ " Type what you want changed about the plan; press Enter to add it as a turn.",
3199
+ " Turns ACCUMULATE \u2014 nothing reaches the planner until you submit.",
3200
+ "",
3201
+ " /submit send every turn on this thread as ONE change",
3202
+ " /exit leave; the thread and its turns stay saved",
3203
+ " /help this list"
3204
+ ].join("\n");
3205
+ }
3206
+ function onboardingUrl(serverUrl) {
3207
+ return `${normalizeServerUrl(serverUrl)}/onboarding`;
3208
+ }
3209
+ function scopeLabel(targetKeys) {
3210
+ return targetKeys.length > 0 ? `anchored at ${targetKeys.join(", ")}` : "project-wide";
3211
+ }
3212
+ function renderTurn(turn, ordinal) {
3213
+ const marker = turn.role === "system" ? `submitted \u2192 job ${turn.jobId ?? "?"}` : "you";
3214
+ const head = ` ${String(ordinal).padStart(2)}. [${marker}] `;
3215
+ const indent = " ".repeat(head.length);
3216
+ const [first = "", ...rest] = turn.body.split("\n");
3217
+ return [head + first, ...rest.map((line) => indent + line)].join("\n");
3218
+ }
3219
+ function renderThread(session) {
3220
+ const resumed = session.turnCount > 0;
3221
+ const lines = [
3222
+ `${resumed ? "Resumed" : "Opened"} the planning conversation (${scopeLabel(
3223
+ session.targetKeys
3224
+ )}) \u2014 ${session.turnCount} turn${session.turnCount === 1 ? "" : "s"}.`,
3225
+ session.lastSubmittedAt ? `Last submitted ${session.lastSubmittedAt}${session.lastJobId ? ` as job ${session.lastJobId}` : ""}.` : "Never submitted \u2014 nothing has been sent to the planner yet."
3226
+ ];
3227
+ if (session.turns.length > 0) {
3228
+ lines.push("", ...session.turns.map((turn, i) => renderTurn(turn, i + 1)));
3229
+ }
3230
+ return lines.join("\n");
3231
+ }
3232
+ function watchVerdict(outcome) {
3233
+ if (outcome.status !== "generating") return { kind: "ready" };
3234
+ const job = outcome.job;
3235
+ if (!job) return { kind: "pending" };
3236
+ if (!job.reachable) {
3237
+ return {
3238
+ kind: "failed",
3239
+ reachable: false,
3240
+ code: job.failure?.code ?? "AI_UNREACHABLE",
3241
+ message: job.failure?.message ?? "motir-ai could not be reached."
3242
+ };
3243
+ }
3244
+ if (job.status === "failed" || job.status === "canceled") {
3245
+ return {
3246
+ kind: "failed",
3247
+ reachable: true,
3248
+ code: job.failure?.code ?? `JOB_${job.status.toUpperCase()}`,
3249
+ message: job.failure?.message ?? `The planning job ${job.status}.`
3250
+ };
3251
+ }
3252
+ return { kind: "pending" };
3253
+ }
3254
+ var TEMP_REF_PREFIX = "planItem:";
3255
+ var OP_MARKER = { add: "+", modify: "~", remove: "-" };
3256
+ function sizing(points, minutes) {
3257
+ const parts = [];
3258
+ if (points != null) parts.push(`${points} pts`);
3259
+ if (minutes != null) parts.push(`${minutes}m`);
3260
+ return parts.length > 0 ? ` (${parts.join(" \xB7 ")})` : "";
3261
+ }
3262
+ function blockers(refs) {
3263
+ return refs && refs.length > 0 ? ` \xB7 blocked_by: ${refs.join(", ")}` : "";
3264
+ }
3265
+ function describeProposal(item) {
3266
+ const marker = OP_MARKER[item.op] ?? "?";
3267
+ if (item.op === "add") {
3268
+ const fields = item.proposedFields;
3269
+ const kind = fields?.kind ?? "task";
3270
+ const type = fields?.type ? `/${fields.type}` : "";
3271
+ return `${marker} [${kind}${type}] ${fields?.title ?? "(untitled)"}` + sizing(fields?.storyPoints, fields?.estimateMinutes) + blockers(item.blockedByRefs);
3272
+ }
3273
+ const target = item.workItemId ?? "(no target)";
3274
+ if (item.op === "remove") return `${marker} remove ${target}`;
3275
+ const changed = Object.keys(item.patch ?? {}).filter(
3276
+ (key) => item.patch[key] !== void 0
3277
+ );
3278
+ return `${marker} modify ${target}` + (changed.length > 0 ? ` \u2014 ${changed.join(", ")}` : "") + blockers(item.blockedByRefs);
3279
+ }
3280
+ function renderProposalTree(items) {
3281
+ const byId = new Map(items.map((item) => [item.id, item]));
3282
+ const childrenOf = /* @__PURE__ */ new Map();
3283
+ const roots = [];
3284
+ for (const item of items) {
3285
+ const ref = item.parentRef;
3286
+ const parentId = ref && ref.startsWith(TEMP_REF_PREFIX) ? ref.slice(TEMP_REF_PREFIX.length) : null;
3287
+ if (parentId != null && byId.has(parentId)) {
3288
+ const siblings = childrenOf.get(parentId);
3289
+ if (siblings) siblings.push(item);
3290
+ else childrenOf.set(parentId, [item]);
3291
+ } else {
3292
+ roots.push(item);
3293
+ }
3294
+ }
3295
+ const lines = [];
3296
+ const seen = /* @__PURE__ */ new Set();
3297
+ const walk = (item, depth) => {
3298
+ if (seen.has(item.id)) return;
3299
+ seen.add(item.id);
3300
+ lines.push(`${" ".repeat(depth + 1)}${describeProposal(item)}`);
3301
+ for (const child of childrenOf.get(item.id) ?? []) walk(child, depth + 1);
3302
+ };
3303
+ for (const root of roots) walk(root, 0);
3304
+ for (const item of items) walk(item, 0);
3305
+ return lines;
3306
+ }
3307
+ function renderPlan(plan) {
3308
+ const lines = [
3309
+ `Plan ${plan.id} \u2014 ${plan.status}, ${plan.itemCount} proposal${plan.itemCount === 1 ? "" : "s"}.`
3310
+ ];
3311
+ if (plan.title) lines.push(`Title: ${plan.title}`);
3312
+ if (plan.summary) lines.push(`Summary: ${plan.summary}`);
3313
+ lines.push("");
3314
+ if (plan.items.length === 0) {
3315
+ lines.push(
3316
+ plan.status === "generating" ? "No proposals have arrived yet \u2014 the planner is still generating this plan." : "This plan bundles no proposals."
3317
+ );
3318
+ } else {
3319
+ lines.push(
3320
+ "Proposals (indented under their proposed parent):",
3321
+ ...renderProposalTree(plan.items)
3322
+ );
3323
+ }
3324
+ lines.push("", PROPOSALS_NOT_WORK_ITEMS);
3325
+ return lines.join("\n");
3326
+ }
3327
+ function nextStepHint(reviewUrl, anchored) {
3328
+ const resume = anchored.length > 0 ? `motir plan ${anchored.join(" ")}` : "motir plan";
3329
+ return [
3330
+ "Next:",
3331
+ ` \u2022 refine \u2014 \`${resume}\` adds another turn to the same conversation`,
3332
+ ` \u2022 approve \u2014 ${reviewUrl ?? "open the plan in Motir"}`
3333
+ ].join("\n");
3334
+ }
3335
+
3336
+ // src/commands/plan.ts
3337
+ function delay(ms) {
3338
+ return new Promise((resolve3) => {
3339
+ setTimeout(resolve3, ms);
3340
+ });
3341
+ }
3342
+ async function planCommand(args = [], opts = {}, deps = {}) {
3343
+ const { targetKeys, text } = parsePlanArgs(args);
3344
+ const readLine = deps.readLine ?? promptLineOrNull;
3345
+ const interactive = deps.interactive ?? isInteractive;
3346
+ if (!text && !interactive()) {
3347
+ throw new CliError("`motir plan` needs a terminal to converse in.", {
3348
+ hint: 'Non-interactively, pass the turn as text: `motir plan "<what to change>"` (add --detach to skip the wait).'
3349
+ });
3350
+ }
3351
+ await withProjectSession(async (session) => {
3352
+ await requirePlannedProject(session);
3353
+ const scope = {
3354
+ projectKey: session.projectKey,
3355
+ ...targetKeys.length > 0 ? { targetKeys } : {}
3356
+ };
3357
+ const submitted = text ? await submitOneShot(session.client, scope, text) : await converse(session.client, scope, readLine);
3358
+ if (!submitted) return;
3359
+ info(
3360
+ `Submitted this conversation's accumulated intent (${scopeLabel(targetKeys)}) \u2014 job ${submitted.jobId} \xB7 plan ${submitted.planId}.`
3361
+ );
3362
+ const reviewUrl = planReviewUrl(session.serverUrl, submitted.planId);
3363
+ if (opts.detach) {
3364
+ reportDetached(submitted, reviewUrl, targetKeys);
3365
+ return;
3366
+ }
3367
+ await watchAndShow(session.client, submitted.planId, reviewUrl, targetKeys, deps);
3368
+ });
3369
+ }
3370
+ async function requirePlannedProject(session) {
3371
+ const page = await session.client.searchWorkItems({ projectKey: session.projectKey, limit: 1 });
3372
+ if (page.total > 0) return;
3373
+ throw new CliError(
3374
+ `${session.projectKey} has no work items yet \u2014 there is no plan here to change.`,
3375
+ {
3376
+ hint: `Start it in Motir: ${onboardingUrl(session.serverUrl)} \u2014 the discovery interview is what GENERATES a first plan, and it is a guided web flow. \`motir plan\` joins the conversation once there is a tree to evolve.`
3377
+ }
3378
+ );
3379
+ }
3380
+ async function submitOneShot(client, scope, body) {
3381
+ const session = await client.appendPlanTurn({ ...scope, body });
3382
+ info(`Turn ${session.turnCount} added to the ${scopeLabel(session.targetKeys)} conversation.`);
3383
+ return client.submitPlanSession(scope);
3384
+ }
3385
+ async function converse(client, scope, readLine) {
3386
+ let session = await client.openPlanSession(scope);
3387
+ info(renderThread(session));
3388
+ info("");
3389
+ info(loopCommands());
3390
+ info("");
3391
+ for (; ; ) {
3392
+ const input = classifyInput(await readLine("plan> "));
3393
+ switch (input.kind) {
3394
+ case "none":
3395
+ continue;
3396
+ case "help":
3397
+ info(loopCommands());
3398
+ continue;
3399
+ case "unknown":
3400
+ info(
3401
+ `Unknown command ${input.input}. Type /help for the list, or plain text to add a turn.`
3402
+ );
3403
+ continue;
3404
+ case "exit":
3405
+ info(
3406
+ `Left the conversation \u2014 ${session.turnCount} turn${session.turnCount === 1 ? "" : "s"} saved and nothing submitted. Re-enter any time; the thread continues where it stopped.`
3407
+ );
3408
+ return null;
3409
+ case "turn":
3410
+ session = await client.appendPlanTurn({ ...scope, body: input.body });
3411
+ info(
3412
+ `Turn ${session.turnCount} added \u2014 NOT submitted. /submit sends every turn as one change.`
3413
+ );
3414
+ continue;
3415
+ case "submit":
3416
+ return client.submitPlanSession(scope);
3417
+ }
3418
+ }
3419
+ }
3420
+ function reportDetached(submitted, reviewUrl, targetKeys) {
3421
+ out(`Job: ${submitted.jobId} \xB7 Plan: ${submitted.planId}`);
3422
+ if (reviewUrl) out(`Review: ${reviewUrl}`);
3423
+ out("");
3424
+ out("The planner runs in the background \u2014 nothing here waited for it.");
3425
+ out(PROPOSALS_NOT_WORK_ITEMS);
3426
+ out("");
3427
+ out(nextStepHint(reviewUrl, targetKeys));
3428
+ }
3429
+ async function watchAndShow(client, planId, reviewUrl, targetKeys, deps) {
3430
+ const now = deps.now ?? Date.now;
3431
+ const sleep = deps.sleep ?? delay;
3432
+ const started = now();
3433
+ info(
3434
+ `Waiting for the planner (up to ${Math.round(WATCH_TIMEOUT_MS / 6e4)} minutes; --detach skips the wait)\u2026`
3435
+ );
3436
+ for (; ; ) {
3437
+ const verdict = watchVerdict(await client.getPlanStatus({ planId }));
3438
+ if (verdict.kind === "ready") break;
3439
+ if (verdict.kind === "failed") {
3440
+ throw new CliError(
3441
+ verdict.reachable ? `The planning job failed \u2014 ${verdict.code}: ${verdict.message}` : `Motir could not reach the AI planner \u2014 ${verdict.code}: ${verdict.message}`,
3442
+ {
3443
+ hint: `Your turns are intact on the thread \u2014 re-run and /submit to retry. Plan ${planId}` + (reviewUrl ? ` \xB7 ${reviewUrl}` : "")
3444
+ }
3445
+ );
3446
+ }
3447
+ if (now() - started >= WATCH_TIMEOUT_MS) {
3448
+ throw new CliError(
3449
+ `Plan ${planId} was still generating after ${Math.round(
3450
+ WATCH_TIMEOUT_MS / 6e4
3451
+ )} minutes \u2014 giving up on the wait, not on the plan.`,
3452
+ {
3453
+ hint: reviewUrl ? `It may still land: ${reviewUrl}. Use --detach to submit without waiting.` : "It may still land. Use --detach to submit without waiting."
3454
+ }
3455
+ );
3456
+ }
3457
+ await sleep(WATCH_POLL_MS);
3458
+ }
3459
+ const plan = await client.getPlan({ planId });
3460
+ out(renderPlan(plan));
3461
+ out("");
3462
+ out(nextStepHint(reviewUrl, targetKeys));
3463
+ }
3464
+
3465
+ // src/help.ts
3466
+ import { Help } from "commander";
3467
+ var HELP_GROUP = {
3468
+ setup: "SETUP COMMANDS:",
3469
+ read: "READ COMMANDS:",
3470
+ workLoop: "WORK LOOP COMMANDS:",
3471
+ topics: "HELP TOPICS:",
3472
+ additional: "ADDITIONAL COMMANDS:"
3473
+ };
3474
+ var FLAGS_HEADING = "FLAGS:";
3475
+ function agentCredentialEnvNames() {
3476
+ const names = AGENT_PROFILES.flatMap((profile) => profile.credentialEnv);
3477
+ return [...new Set(names)].sort();
3478
+ }
3479
+ var ENVIRONMENT_TOPIC = {
3480
+ name: "environment",
3481
+ summary: "Environment variables Motir reads, and what each one overrides.",
3482
+ body: () => [
3483
+ "ENVIRONMENT VARIABLES",
3484
+ "",
3485
+ " Motir reads seven environment variables. None is required \u2014 each one",
3486
+ " overrides a default, and every value stays where you put it: the CLI",
3487
+ " never echoes a variable back.",
3488
+ "",
3489
+ " MOTIR_TOKEN",
3490
+ " A personal access token, honoured by EVERY command. Set it and there",
3491
+ " is no login step and no file: nothing is written to disk, which is",
3492
+ " what makes it the tier for CI, a container, or any box whose config",
3493
+ " dir is read-only. It outranks a stored credential \u2014 so a stale one",
3494
+ " exported in a shell profile silently wins; `motir auth status` names",
3495
+ " the source it used for exactly that reason. Empty counts as unset.",
3496
+ " Precedence: --token <pat> > MOTIR_TOKEN > config.json",
3497
+ "",
3498
+ " MOTIR_SERVER",
3499
+ " Which Motir server to talk to \u2014 the self-hosting counterpart of the",
3500
+ " default, " + DEFAULT_SERVER_URL + ". It outranks the .motir.json link",
3501
+ " because a container has no link to walk up to. Empty counts as unset.",
3502
+ " Precedence: --server <url> > MOTIR_SERVER > .motir.json >",
3503
+ " the single stored server > " + DEFAULT_SERVER_URL,
3504
+ "",
3505
+ " MOTIR_AGENT",
3506
+ " The coding agent Motir preflights on your behalf \u2014 a full command",
3507
+ ' line, e.g. "claude --dangerously-skip-permissions". Motir is BYOK:',
3508
+ " the agent authenticates with its OWN key, which Motir never reads.",
3509
+ " Precedence: --agent <cmd> > MOTIR_AGENT > agentCommand in config.json",
3510
+ "",
3511
+ " MOTIR_CONFIG_HOME",
3512
+ " The directory Motir keeps its `motir/config.json` under. Highest",
3513
+ " precedence of the three config-home sources \u2014 and the second-choice",
3514
+ " STATE home, so relocating it still moves the whole CLI state.",
3515
+ "",
3516
+ " XDG_CONFIG_HOME",
3517
+ " The standard XDG config home, used when MOTIR_CONFIG_HOME is unset.",
3518
+ " Precedence: MOTIR_CONFIG_HOME > XDG_CONFIG_HOME > ~/.config",
3519
+ "",
3520
+ " MOTIR_STATE_HOME",
3521
+ " Where Motir keeps `motir/session-excludes.json` \u2014 mutable state, not",
3522
+ " a credential, which is why it does not live beside the token: the",
3523
+ " sandbox image mounts the config dir read-only, leaving no writable",
3524
+ " state directory there at all.",
3525
+ " Precedence: MOTIR_STATE_HOME > MOTIR_CONFIG_HOME >",
3526
+ " XDG_STATE_HOME > ~/.local/state",
3527
+ "",
3528
+ " XDG_DATA_HOME",
3529
+ " The standard XDG data home. Motir keeps nothing here \u2014 `motir doctor`",
3530
+ " reads it only to know where your AGENT keeps its credential (OpenCode",
3531
+ " signs in to $XDG_DATA_HOME/opencode/auth.json).",
3532
+ " Precedence: XDG_DATA_HOME > ~/.local/share",
3533
+ "",
3534
+ " Probed, never read: `motir doctor` asks whether your agent\u2019s own key",
3535
+ " variable is SET \u2014 one of " + agentCredentialEnvNames().join(", ") + " \u2014",
3536
+ " so it can report the agent as signed in without opening anything. That",
3537
+ " check is a presence predicate; the value is never obtained.",
3538
+ "",
3539
+ " See also: `motir help files`."
3540
+ ].join("\n")
3541
+ };
3542
+ var FILES_TOPIC = {
3543
+ name: "files",
3544
+ summary: "The files Motir keeps: the credential store, the project link, the exclude list.",
3545
+ body: () => [
3546
+ "FILES",
3547
+ "",
3548
+ " Motir keeps three files. Only one holds a secret, and it is not the one",
3549
+ " that lives in your repo.",
3550
+ "",
3551
+ " ~/.config/motir/config.json (secret \u2014 never commit)",
3552
+ " The credential store \u2014 the only FILE a personal access token is ever",
3553
+ " written to, chmod 600 inside a 0700 directory. Keyed by server URL, so",
3554
+ " one machine can hold tokens for several Motir servers. Also holds",
3555
+ " `agentCommand`, the coding agent you configured. (MOTIR_TOKEN is the",
3556
+ " other way to supply a token, and it is never written anywhere.)",
3557
+ " Relocate it with MOTIR_CONFIG_HOME or XDG_CONFIG_HOME",
3558
+ " (`motir help environment`).",
3559
+ "",
3560
+ " .motir.json (no secret \u2014 safe to commit)",
3561
+ " The project link at your workspace root: the server, workspace and",
3562
+ " project this folder is bound to, plus an optional `repos` override",
3563
+ " map. It carries no credential, so it belongs in version control.",
3564
+ " Every command resolves it by walking UPWARD from the current",
3565
+ " directory, so any command works from inside any checkout under the",
3566
+ " root. Repo checkouts otherwise resolve by convention, as",
3567
+ " <root>/<repoName>; the `repos` map carries overrides only.",
3568
+ "",
3569
+ " ~/.local/state/motir/session-excludes.json (no secret)",
3570
+ " The session exclude list: the ids and keys of items whose dispatch",
3571
+ " FAILED, so the next run moves past them instead of re-picking the",
3572
+ " same failure. State, not a credential \u2014 which is why it does not sit",
3573
+ " beside the token: the sandbox mounts the config dir read-only, and a",
3574
+ " run must never die because it could not write this file. If it is",
3575
+ " unwritable Motir warns once and continues; a failed item is already",
3576
+ " held out of the ready set by its In Progress status.",
3577
+ " Relocate it with MOTIR_STATE_HOME (`motir help environment`).",
3578
+ "",
3579
+ " The first two are inspected by `motir doctor`, which reports whether",
3580
+ " they exist and resolve \u2014 never what is in them.",
3581
+ "",
3582
+ " See also: `motir help environment`."
3583
+ ].join("\n")
3584
+ };
3585
+ var HELP_TOPICS = [ENVIRONMENT_TOPIC, FILES_TOPIC];
3586
+ function findHelpTopic(name) {
3587
+ return HELP_TOPICS.find((topic) => topic.name === name);
3588
+ }
3589
+ var EXAMPLES = [
3590
+ "EXAMPLES:",
3591
+ " $ motir auth login --server https://app.motir.co # store a token",
3592
+ " $ motir link --project MOTIR # bind this folder",
3593
+ " $ motir doctor # preflight the setup",
3594
+ " $ motir ready --kinds subtask --assignee me # what can I pick up?",
3595
+ " $ motir status --json # the project pulse",
3596
+ " $ motir sprint # what\u2019s in the active sprint",
3597
+ " $ motir show MOTIR-7 # the item, its edges, its body",
3598
+ " $ motir open MOTIR-7 --print # the item\u2019s URL",
3599
+ " $ motir auto --agent claude --max 5 # drain 5 items unattended",
3600
+ " $ motir batch --agent claude # snapshot now, one PR each",
3601
+ ' $ motir plan "split the billing epic" # plan by talking; prints proposals'
3602
+ ].join("\n");
3603
+ var LEARN_MORE = [
3604
+ "LEARN MORE:",
3605
+ " Use `motir <command> --help` for the flags of a single command.",
3606
+ ` Use \`motir help <topic>\` for a topic: ${HELP_TOPICS.map((t) => t.name).join(", ")}.`,
3607
+ " Read packages/cli/README.md in the Motir checkout for the full guide."
3608
+ ].join("\n");
3609
+ function renderFlagsSection(program, error) {
3610
+ const helper = program.createHelp();
3611
+ const output = program.configureOutput();
3612
+ const helpWidth = error ? output.getErrHelpWidth?.() : output.getOutHelpWidth?.();
3613
+ helper.prepareContext({ error, ...helpWidth === void 0 ? {} : { helpWidth } });
3614
+ const options = Help.prototype.visibleOptions.call(helper, program);
3615
+ if (options.length === 0) return "";
3616
+ const terms = options.map((option) => helper.styleOptionTerm(helper.optionTerm(option)));
3617
+ const termWidth = Math.max(
3618
+ helper.padWidth(program, helper),
3619
+ ...terms.map((term) => helper.displayWidth(term))
3620
+ );
3621
+ const items = options.map(
3622
+ (option, index) => helper.formatItem(
3623
+ terms[index] ?? "",
3624
+ termWidth,
3625
+ helper.styleOptionDescription(helper.optionDescription(option)),
3626
+ helper
3627
+ )
3628
+ );
3629
+ return helper.formatItemList(FLAGS_HEADING, items, helper).join("\n").trimEnd();
3630
+ }
3631
+ function applyHelpConfiguration(program) {
3632
+ program.commandsGroup(HELP_GROUP.additional);
3633
+ program.configureHelp({
3634
+ // Group and command order is AUTHORED (registration order), not alphabetical.
3635
+ sortSubcommands: false,
3636
+ sortOptions: false,
3637
+ visibleOptions(cmd) {
3638
+ if (cmd === program) return [];
3639
+ return Help.prototype.visibleOptions.call(this, cmd);
3640
+ }
3641
+ });
3642
+ }
3643
+ function registerHelpSurface(program) {
3644
+ program.helpCommand(false);
3645
+ program.command("help [command...]").description("Show help for a command, or read a help topic.").helpGroup(HELP_GROUP.topics).action((path = []) => {
3646
+ helpAction(program, path);
3647
+ });
3648
+ for (const topic of HELP_TOPICS) {
3649
+ program.command(topic.name).description(topic.summary).helpGroup(HELP_GROUP.topics).action(() => {
3650
+ out(topic.body());
3651
+ });
3652
+ }
3653
+ program.addHelpText("after", (context) => {
3654
+ if (context.command !== program) return "";
3655
+ const blocks = [renderFlagsSection(program, context.error), EXAMPLES, LEARN_MORE].filter(
3656
+ (block) => block !== ""
3657
+ );
3658
+ return blocks.length === 0 ? "" : `
3659
+ ${blocks.join("\n\n")}`;
3660
+ });
3661
+ program.allowExcessArguments();
3662
+ program.action((_options, command) => {
3663
+ const unknown = command.args[0];
3664
+ if (unknown !== void 0) throw unknownCommandError(unknown);
3665
+ program.outputHelp();
3666
+ });
3667
+ }
3668
+ function unknownCommandError(name) {
3669
+ return new CliError(`Unknown command "${name}".`, {
3670
+ hint: "Run `motir help` to see the available commands and topics."
3671
+ });
3672
+ }
3673
+ function helpAction(program, path) {
3674
+ if (path.length === 0) {
3675
+ program.outputHelp();
3676
+ return;
3677
+ }
3678
+ if (path.length === 1) {
3679
+ const topic = findHelpTopic(path[0]);
3680
+ if (topic) {
3681
+ out(topic.body());
3682
+ return;
3683
+ }
3684
+ }
3685
+ let command = program;
3686
+ for (const name of path) {
3687
+ const child = command.commands.find(
3688
+ (candidate) => candidate.name() === name || candidate.aliases().includes(name)
3689
+ );
3690
+ if (!child) {
3691
+ throw new CliError(`No help topic or command named "${path.join(" ")}".`, {
3692
+ hint: "Run `motir help` to see the available commands and topics."
3693
+ });
3694
+ }
3695
+ command = child;
3696
+ }
3697
+ command.outputHelp();
3698
+ }
3699
+
3700
+ // src/program.ts
3701
+ function buildProgram() {
3702
+ const program = new Command2();
3703
+ program.name("motir").description(
3704
+ "Motir CLI \u2014 terminal dispatch of the work loop (an MCP client of the Motir server)."
3705
+ ).version(CLI_VERSION, "-v, --version", "Print the CLI version.");
3706
+ applyHelpConfiguration(program);
3707
+ const auth = program.command("auth").description("Authenticate to a Motir server with a PAT.").helpGroup(HELP_GROUP.setup);
3708
+ auth.command("login").description("Validate and store a personal access token for a server.").option("--server <url>", "Server base URL, e.g. https://app.motir.co").option("--token <pat>", "Personal access token (or set MOTIR_TOKEN; prompted if omitted).").action(authLogin);
3709
+ auth.command("status").description("Show the resolved server, token prefix, and owning user.").option("--server <url>", "Server to report (defaults to the linked / single server).").action(authStatus);
3710
+ auth.command("logout").description("Remove the stored token for a server.").option("--server <url>", "Server to log out of (defaults to the linked / single server).").action(authLogout);
3711
+ const link = program.command("link").description("Bind this workspace-root folder to a server + workspace + project.").helpGroup(HELP_GROUP.setup).option("--server <url>", "Server base URL (defaults to the existing link / single server).").option("--workspace <slug>", "Workspace slug (defaults to the token\u2019s active workspace).").option("--project <key>", "Project key, e.g. PROD.").option(
3712
+ "--repo <name>",
3713
+ 'Mark THIS directory as a single repo\u2019s checkout (writes a "." override).'
3714
+ ).action(linkCommand);
3715
+ link.command("add <repo> <path>").description("Add a repo checkout-path override (relative to the link root, or absolute).").action(linkAddCommand);
3716
+ link.command("remove <repo>").description("Remove a repo checkout-path override.").action(linkRemoveCommand);
3717
+ program.command("ready").description("List the linked project\u2019s ready set (every dependency satisfied).").helpGroup(HELP_GROUP.read).option("--kinds <list>", "Comma-separated kinds: epic,story,task,bug,subtask.").option("--assignee <id>", 'Filter by assignee: a user id, "me", or "unassigned".').option("--json", "Emit the ready items as JSON.").action(readyCommand);
3718
+ program.command("status").description("Show the project pulse: ready / in-flight counts + the active sprint.").helpGroup(HELP_GROUP.read).option("--json", "Emit the pulse as JSON.").action(statusCommand);
3719
+ program.command("sprints").description("List the project\u2019s sprints: state, item count, points, window.").helpGroup(HELP_GROUP.read).option("--state <state>", "Only sprints in this state: planned, active, or complete.").option("--json", "Emit the sprint rows as JSON.").action(sprintsCommand);
3720
+ program.command("sprint [ref]").description("List ONE sprint\u2019s work items (defaults to the active sprint).").helpGroup(HELP_GROUP.read).option("--kinds <list>", "Comma-separated kinds: epic,story,task,bug,subtask.").option("--json", "Emit the sprint and its items as JSON.").action((ref, opts) => sprintCommand(ref, opts));
3721
+ program.command("show <key>").description("Read one work item (e.g. PROD-7): fields, readiness, children, edges, body.").helpGroup(HELP_GROUP.read).option("--json", "Emit the get_work_item payload as JSON.").action(showCommand);
3722
+ program.command("doctor").description(
3723
+ "Preflight your BYOK setup: auth, project link, agent binary, credential presence."
3724
+ ).helpGroup(HELP_GROUP.setup).option("--agent <cmd>", "Check THIS agent command instead of the configured one.").option("--json", "Emit the check results as JSON.").action((opts) => doctorCommand(opts));
3725
+ program.command("open <key>").description("Open a work item (e.g. PROD-7) in the browser; prints the URL.").helpGroup(HELP_GROUP.read).option("--print", "Print the URL only; do not launch a browser.").action(openCommand);
3726
+ program.command("next").description("Dispatch the next ready work item: claim it and deliver its prompt.").helpGroup(HELP_GROUP.workLoop).option("--kinds <list>", "Comma-separated kinds: epic,story,task,bug,subtask.").option("--print", "Print the prompt to stdout instead of launching an agent (default).").option("--agent <cmd>", "Run THIS agent command on the prompt (overrides MOTIR_AGENT).").option("--reset", "Clear this project\u2019s session exclude list before picking.").action(nextCommand);
3727
+ program.command("run <key>").description("Dispatch a SPECIFIC work item (e.g. PROD-7), ready or forced.").helpGroup(HELP_GROUP.workLoop).option("--print", "Print the prompt to stdout instead of launching an agent (default).").option("--agent <cmd>", "Run THIS agent command on the prompt (overrides MOTIR_AGENT).").option("--force", "Dispatch even though the item is not ready (dependencies unmet).").action(runCommand);
3728
+ program.command("auto").description("Drain the ready set unattended: one item at a time onto a session branch.").helpGroup(HELP_GROUP.workLoop).option("--agent <cmd>", "Run THIS agent command on every prompt (overrides MOTIR_AGENT).").option("--kinds <list>", "Comma-separated kinds: epic,story,task,bug,subtask.").option("--max <n>", "Stop after dispatching n work items.").option("--keep-going", "Continue past a failed agent instead of halting on the first one.").option("--reset", "Clear this project\u2019s session exclude list before starting.").option(
3729
+ "--include-planning",
3730
+ "Trigger an AI expansion for each unexpanded epic/story instead of skipping it. Never waits: the plan needs your approval."
3731
+ ).option("--print", "Not supported \u2014 an unattended loop has nobody to paste a prompt.").action((opts) => autoCommand(opts));
3732
+ program.command("batch").description("Implement a FROZEN snapshot of the ready set: one pull request per item.").helpGroup(HELP_GROUP.workLoop).option("--agent <cmd>", "Run THIS agent command on every prompt (overrides MOTIR_AGENT).").option("--kinds <list>", "Comma-separated kinds: epic,story,task,bug,subtask.").option("--max <n>", "Stop after dispatching n work items.").option("--keep-going", "Continue past a failed agent instead of halting on the first one.").option("--reset", "Clear this project\u2019s session exclude list before snapshotting.").option("--print", "Not supported \u2014 a frozen snapshot has nobody to paste a prompt.").action((opts) => batchCommand(opts));
3733
+ program.command("plan [args...]").description("Plan by talking: resume the project\u2019s planning conversation, add turns, submit.").helpGroup(HELP_GROUP.workLoop).option("--detach", "Submit and return with the job/plan ids; do not wait for the planner.").addHelpText(
3734
+ "after",
3735
+ [
3736
+ "",
3737
+ "Leading MOTIR-<n> arguments ANCHOR the conversation at those items; the rest is a turn.",
3738
+ "",
3739
+ " $ motir plan # resume the project-wide conversation",
3740
+ " $ motir plan MOTIR-42 # resume the conversation anchored at MOTIR-42",
3741
+ ' $ motir plan "split the billing epic" # one turn, submitted, proposals printed',
3742
+ ' $ motir plan MOTIR-42 "size these" --detach',
3743
+ "",
3744
+ "In the conversation: /submit sends every turn as ONE change, /exit leaves it saved.",
3745
+ "A submit produces PROPOSALS \u2014 approving the plan in Motir is what creates work items."
3746
+ ].join("\n")
3747
+ ).action((args, opts) => planCommand(args, opts));
3748
+ program.command("done [key]").description("Close out a merged item \u2014 or a whole merged session branch.").helpGroup(HELP_GROUP.workLoop).option("--session <branch>", "Bulk close-out: flip every item on this session branch.").option("--via <status>", "Move through this status first (e.g. in_review).").action((key, opts) => doneCommand(key, opts));
3749
+ registerHelpSurface(program);
3750
+ return program;
3751
+ }
3752
+
3753
+ // src/index.ts
3754
+ async function main() {
3755
+ const program = buildProgram();
3756
+ await program.parseAsync(process.argv);
3757
+ }
3758
+ main().catch((err) => {
3759
+ if (err instanceof CliError) {
3760
+ process.stderr.write(`Error: ${err.message}
3761
+ `);
3762
+ if (err.hint) process.stderr.write(`Hint: ${err.hint}
3763
+ `);
3764
+ process.exit(err.exitCode);
3765
+ }
3766
+ const detail = err instanceof Error ? err.stack ?? err.message : String(err);
3767
+ process.stderr.write(`Unexpected error: ${detail}
3768
+ `);
3769
+ process.exit(1);
3770
+ });