@inteeka/task-cli 0.2.3 → 0.2.5

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.
package/dist/cli.js CHANGED
@@ -191,6 +191,9 @@ var CLI_AUDIT_ACTIONS = Object.freeze([
191
191
  "cli.run.tests_failed",
192
192
  "cli.run.pr_opened",
193
193
  "cli.run.pr_failed",
194
+ "cli.run.push_failed",
195
+ "cli.run.resumed",
196
+ "cli.work.pr_recovered",
194
197
  "cli.schedule.created",
195
198
  "cli.schedule.paused",
196
199
  "cli.schedule.resumed",
@@ -224,6 +227,13 @@ var c = {
224
227
  var CliError = class extends Error {
225
228
  code;
226
229
  hint;
230
+ /**
231
+ * Discriminator for the multi-work loop's between-iteration cleanup.
232
+ * `post_push` means the per-ticket branch is on origin (with a commit)
233
+ * and should be preserved for `task resume`. Pre-push failures (or
234
+ * unset / 'pre_push') let the loop purge the branch as usual.
235
+ */
236
+ phase;
227
237
  constructor(code, message, hint) {
228
238
  super(message);
229
239
  this.code = code;
@@ -1374,6 +1384,9 @@ function currentBranch(cwd) {
1374
1384
  }
1375
1385
  }
1376
1386
 
1387
+ // src/commands/work.ts
1388
+ import { execFileSync as execFileSync6 } from "child_process";
1389
+
1377
1390
  // src/git/branch.ts
1378
1391
  import { execFileSync as execFileSync4 } from "child_process";
1379
1392
  var VALID_BRANCH = /^[A-Za-z0-9._/-]{1,200}$/;
@@ -1636,7 +1649,13 @@ async function buildWorkContext(opts) {
1636
1649
  };
1637
1650
  }
1638
1651
  function registerWork(program2) {
1639
- program2.command("work [ticketId]").description("Run the agent on a CLI-approved ticket \u2014 cuts a per-ticket branch and opens a PR").option("--auto", "Pick the next eligible ticket without prompting").option("--next", "Alias for --auto --max 1").option("--dry-run", "Run the agent + tests but do not commit, push, or open a PR").option("--no-push", "[deprecated in Phase 2 \u2014 task work always pushes via per-ticket branch]").option("--max <n>", "Process up to N tickets in this invocation", "1").option("--silent", "Suppress TTY output (used by scheduled tasks)").option("--schedule-id <id>", "Internal: schedule id when invoked from a scheduled task").action(async (ticketId, opts) => {
1652
+ program2.command("work [ticketId]").description("Run the agent on a CLI-approved ticket \u2014 cuts a per-ticket branch and opens a PR").option("--auto", "Pick the next eligible ticket without prompting").option("--next", "Alias for --auto --max 1").option("--dry-run", "Run the agent + tests but do not commit, push, or open a PR").option("--no-push", "[deprecated in Phase 2 \u2014 task work always pushes via per-ticket branch]").option("--max <n>", "Process up to N tickets in this invocation", "1").option("--silent", "Suppress TTY output (used by scheduled tasks)").option(
1653
+ "--reset",
1654
+ "DESTRUCTIVE: discard all local working-tree changes before running. Requires interactive y/n; pair with --confirm in non-TTY contexts."
1655
+ ).option(
1656
+ "--confirm",
1657
+ "Confirm --reset in non-TTY (silent / scheduled-task) contexts. Has no effect without --reset."
1658
+ ).option("--schedule-id <id>", "Internal: schedule id when invoked from a scheduled task").action(async (ticketId, opts) => {
1640
1659
  await runWork(ticketId, opts);
1641
1660
  });
1642
1661
  }
@@ -1668,6 +1687,9 @@ async function runWork(ticketId, opts) {
1668
1687
  }
1669
1688
  async function processOneTicket(ctx, opts, ticketIdHint) {
1670
1689
  const { cwd, baseBranch, silent } = ctx;
1690
+ if (opts.reset) {
1691
+ await purgeWorkingTreeWithConsent(ctx, opts);
1692
+ }
1671
1693
  try {
1672
1694
  assertBaseBranch(cwd, baseBranch);
1673
1695
  } catch (err) {
@@ -1973,11 +1995,12 @@ Claude session: ${runId}
1973
1995
  body: {
1974
1996
  ticket_id: detail.id,
1975
1997
  schedule_id: opts.scheduleId,
1976
- event: "pr_failed",
1998
+ event: "push_failed",
1977
1999
  claude_session_id: runId,
1978
2000
  output_excerpt: err.message.slice(0, 4e3)
1979
2001
  }
1980
2002
  });
2003
+ if (err instanceof CliError) err.phase = "post_push";
1981
2004
  throw err;
1982
2005
  }
1983
2006
  if (!silent)
@@ -2024,6 +2047,7 @@ Claude session: ${runId}
2024
2047
  output_excerpt: err.message.slice(0, 4e3)
2025
2048
  }
2026
2049
  });
2050
+ if (err instanceof CliError) err.phase = "post_push";
2027
2051
  throw err;
2028
2052
  }
2029
2053
  try {
@@ -2063,6 +2087,79 @@ function buildPrBody(args) {
2063
2087
  "Please review carefully \u2014 this is an AI-generated change."
2064
2088
  ].filter(Boolean).join("\n");
2065
2089
  }
2090
+ async function purgeWorkingTreeWithConsent(ctx, opts) {
2091
+ const { cwd, baseBranch, silent } = ctx;
2092
+ const isTty = !silent && process.stdin.isTTY === true;
2093
+ const status = (() => {
2094
+ try {
2095
+ return execFileSync6("git", ["status", "--short"], { cwd, encoding: "utf8" }).trim();
2096
+ } catch {
2097
+ return "";
2098
+ }
2099
+ })();
2100
+ const onBranch = (() => {
2101
+ try {
2102
+ return currentBranch(cwd);
2103
+ } catch {
2104
+ return "(unknown)";
2105
+ }
2106
+ })();
2107
+ const willSwitchBranch = onBranch !== baseBranch && onBranch !== "(unknown)";
2108
+ if (!isTty) {
2109
+ if (!opts.confirm) {
2110
+ throw new CliError(
2111
+ CLI_EXIT_CODES.MISCONFIGURATION,
2112
+ "--reset requires --confirm in non-interactive (silent / scheduled-task) contexts",
2113
+ "Re-run with both flags only after confirming the destructive purge is intentional."
2114
+ );
2115
+ }
2116
+ if (!silent) {
2117
+ process.stdout.write(c.dim(" --reset --confirm: discarding working-tree changes\n"));
2118
+ if (willSwitchBranch) {
2119
+ process.stdout.write(
2120
+ c.dim(` --reset --confirm: also switching from "${onBranch}" to "${baseBranch}"
2121
+ `)
2122
+ );
2123
+ }
2124
+ }
2125
+ } else {
2126
+ if (!silent) {
2127
+ process.stdout.write(`${c.bold("Current branch:")} ${c.cyan(onBranch)}
2128
+ `);
2129
+ process.stdout.write(`${c.bold("Working tree changes:")}
2130
+ `);
2131
+ process.stdout.write(status.length > 0 ? `${c.dim(status)}
2132
+ ` : c.dim(" (none)\n"));
2133
+ process.stdout.write(`
2134
+ ${c.bold("--reset will:")}
2135
+ `);
2136
+ process.stdout.write(
2137
+ c.dim(" \u2022 discard ALL working-tree changes above (git restore --staged --worktree .)\n")
2138
+ );
2139
+ process.stdout.write(c.dim(" \u2022 delete untracked files + directories (git clean -fd)\n"));
2140
+ if (willSwitchBranch) {
2141
+ process.stdout.write(
2142
+ c.dim(` \u2022 switch from ${c.cyan(onBranch)} ${c.dim("to")} ${c.cyan(baseBranch)}
2143
+ `)
2144
+ );
2145
+ } else {
2146
+ process.stdout.write(
2147
+ c.dim(` \u2022 stay on ${c.cyan(baseBranch)} ${c.dim("(already on base)")}
2148
+ `)
2149
+ );
2150
+ }
2151
+ process.stdout.write("\n");
2152
+ }
2153
+ const answer = await inquirer2.prompt([
2154
+ { type: "confirm", name: "confirm", message: "Proceed?", default: false }
2155
+ ]);
2156
+ if (!answer.confirm) {
2157
+ if (!silent) process.stdout.write(c.dim("Aborted \u2014 working tree untouched.\n"));
2158
+ process.exit(CLI_EXIT_CODES.SUCCESS);
2159
+ }
2160
+ }
2161
+ enforceBaseBranchClean(cwd, baseBranch);
2162
+ }
2066
2163
  async function pickNextEligible(projectId) {
2067
2164
  const result = await apiCall("GET", "/api/v1/cli/me/tickets", {
2068
2165
  query: { project_id: projectId, limit: 1 }
@@ -2109,20 +2206,26 @@ function registerMultiWork(program2) {
2109
2206
  ).option("--max <n>", "Process up to N tickets in this batch", "10").option("--dry-run", "Run the agent + tests but do not commit, push, or open PRs").option("--silent", "Suppress TTY output (used by scheduled tasks)").option(
2110
2207
  "--abort-on-failure",
2111
2208
  "Stop the batch on the first per-ticket failure (default: skip and continue)"
2112
- ).option("--schedule-id <id>", "Internal: schedule id when invoked from a scheduled task").action(async (opts) => {
2209
+ ).option(
2210
+ "--reset",
2211
+ "DESTRUCTIVE: discard local working-tree changes before the first ticket. Requires --confirm in non-TTY contexts."
2212
+ ).option("--confirm", "Confirm --reset in non-TTY (silent / scheduled-task) contexts.").option("--schedule-id <id>", "Internal: schedule id when invoked from a scheduled task").action(async (opts) => {
2113
2213
  await runMultiWork(opts);
2114
2214
  });
2115
2215
  }
2116
2216
  async function runMultiWork(opts) {
2117
2217
  const ctx = await buildWorkContext({ max: opts.max, silent: opts.silent });
2118
2218
  const max = Math.max(1, parseInt(opts.max, 10) || 10);
2219
+ let firstIteration = true;
2119
2220
  const innerOpts = {
2120
2221
  auto: true,
2121
2222
  next: false,
2122
2223
  dryRun: opts.dryRun,
2123
2224
  max: "1",
2124
2225
  silent: opts.silent,
2125
- scheduleId: opts.scheduleId
2226
+ scheduleId: opts.scheduleId,
2227
+ reset: opts.reset,
2228
+ confirm: opts.confirm
2126
2229
  };
2127
2230
  const results = [];
2128
2231
  let processed = 0;
@@ -2137,11 +2240,28 @@ async function runMultiWork(opts) {
2137
2240
  let caughtError = null;
2138
2241
  try {
2139
2242
  outcome = await processOneTicket(ctx, innerOpts, null);
2243
+ if (firstIteration) {
2244
+ firstIteration = false;
2245
+ innerOpts.reset = false;
2246
+ innerOpts.confirm = false;
2247
+ }
2140
2248
  } catch (err) {
2249
+ if (firstIteration) {
2250
+ firstIteration = false;
2251
+ innerOpts.reset = false;
2252
+ innerOpts.confirm = false;
2253
+ }
2141
2254
  caughtError = err instanceof Error ? err.message : String(err);
2255
+ const phaseTag = err instanceof CliError && err.phase === "post_push" ? "post_push" : "pre_push";
2142
2256
  if (!ctx.silent) {
2143
2257
  process.stderr.write(`${c.err("\u2717 Ticket failed")}: ${caughtError}
2144
2258
  `);
2259
+ if (phaseTag === "post_push") {
2260
+ process.stderr.write(
2261
+ `${c.dim(" Branch kept on disk + remote \u2014 run `task resume` to retry the PR.")}
2262
+ `
2263
+ );
2264
+ }
2145
2265
  }
2146
2266
  if (opts.abortOnFailure) {
2147
2267
  try {
@@ -2224,6 +2344,248 @@ ${c.bold("Batch summary")}
2224
2344
  );
2225
2345
  }
2226
2346
 
2347
+ // src/commands/resume.ts
2348
+ import { execFileSync as execFileSync7 } from "child_process";
2349
+ import inquirer3 from "inquirer";
2350
+ function registerResume(program2) {
2351
+ program2.command("resume [ticketRef]").description(
2352
+ "Resume a ticket whose previous `task work` run failed after the per-ticket branch was pushed (i.e. ai_fix_status='building'). Re-pushes the branch and re-attempts the PR \u2014 idempotent end-to-end."
2353
+ ).option("--silent", "Suppress TTY output").action(async (ticketRef, opts) => {
2354
+ await runResume(ticketRef, opts);
2355
+ });
2356
+ }
2357
+ async function runResume(ticketRef, opts) {
2358
+ const cwd = findRepoRoot();
2359
+ const project = await readProjectConfig(cwd);
2360
+ if (!project) {
2361
+ throw new CliError(
2362
+ CLI_EXIT_CODES.MISCONFIGURATION,
2363
+ "No project link in this repo",
2364
+ "Run 'task link' first."
2365
+ );
2366
+ }
2367
+ const baseBranch = project.cli_base_branch ?? "development";
2368
+ const silent = !!opts.silent;
2369
+ assertBaseBranch(cwd, baseBranch);
2370
+ const access2 = await apiCallOrThrow("GET", "/api/v1/cli/access");
2371
+ const ticketId = await resolveTicketId(project, ticketRef, silent);
2372
+ if (!ticketId) {
2373
+ if (!silent) {
2374
+ process.stdout.write(c.dim("No in-flight tickets to resume.\n"));
2375
+ }
2376
+ return;
2377
+ }
2378
+ const detail = await apiCallOrThrow(
2379
+ "GET",
2380
+ `/api/v1/cli/me/tickets/${ticketId}`
2381
+ );
2382
+ if (detail.project_id !== project.project_id) {
2383
+ throw new CliError(
2384
+ CLI_EXIT_CODES.MISCONFIGURATION,
2385
+ `Ticket #${detail.sequence_number} belongs to a different project than this repo's link`,
2386
+ "cd to the project repo this ticket belongs to (or re-link with `task link`) and retry."
2387
+ );
2388
+ }
2389
+ if (detail.ai_fix_status !== "building") {
2390
+ throw new CliError(
2391
+ CLI_EXIT_CODES.GENERIC_ERROR,
2392
+ `Ticket #${detail.sequence_number} is in ai_fix_status='${detail.ai_fix_status}', not 'building' \u2014 nothing to resume`,
2393
+ "A previous resume attempt may already have succeeded. Check the dashboard for the ticket's PR."
2394
+ );
2395
+ }
2396
+ if (detail.claimed_by_user_id && detail.claimed_by_user_id !== access2.user_id) {
2397
+ throw new CliError(
2398
+ CLI_EXIT_CODES.UNAUTHORISED,
2399
+ `Ticket #${detail.sequence_number} is claimed by another user`,
2400
+ 'Ask that user to resume it, or have an admin reset ai_fix_status to "approved" so the ticket can be re-claimed fresh.'
2401
+ );
2402
+ }
2403
+ const branchName = branchSlug(detail.sequence_number, detail.title);
2404
+ const ticketBaseBranch = detail.project_cli_base_branch || baseBranch;
2405
+ if (!localBranchExists(cwd, branchName)) {
2406
+ throw new CliError(
2407
+ CLI_EXIT_CODES.GENERIC_ERROR,
2408
+ `Local branch '${branchName}' is missing \u2014 cannot resume`,
2409
+ 'The branch was deleted (or never existed locally). Ask an admin to set ai_fix_status back to "approved" so `task work` can produce a fresh fix.'
2410
+ );
2411
+ }
2412
+ if (!isAncestor(cwd, ticketBaseBranch, branchName)) {
2413
+ throw new CliError(
2414
+ CLI_EXIT_CODES.GENERIC_ERROR,
2415
+ `Base branch '${ticketBaseBranch}' is no longer an ancestor of '${branchName}' \u2014 cannot resume`,
2416
+ 'The base branch has moved (rebase/force-push). Have an admin reset ai_fix_status to "approved" and re-run `task work` for a fresh branch.'
2417
+ );
2418
+ }
2419
+ if (!silent) {
2420
+ process.stdout.write(`
2421
+ ${c.bold(`Resuming #${detail.sequence_number}: ${detail.title}`)}
2422
+ `);
2423
+ process.stdout.write(c.dim(` branch: ${branchName} \u2192 ${ticketBaseBranch}
2424
+ `));
2425
+ }
2426
+ checkoutBranch(cwd, branchName);
2427
+ try {
2428
+ pushBranch(cwd, branchName);
2429
+ if (!silent) process.stdout.write(c.dim(" \u2713 pushed (idempotent)\n"));
2430
+ } catch (err) {
2431
+ try {
2432
+ checkoutBranch(cwd, baseBranch);
2433
+ } catch {
2434
+ }
2435
+ await apiCall("POST", "/api/v1/cli/me/runs", {
2436
+ body: {
2437
+ ticket_id: detail.id,
2438
+ event: "push_failed",
2439
+ output_excerpt: err.message.slice(0, 4e3)
2440
+ }
2441
+ });
2442
+ throw err;
2443
+ }
2444
+ const prTitle = `task #${detail.sequence_number}: ${detail.title}`.slice(0, 200);
2445
+ const prBody = buildResumePrBody(detail, branchName, ticketBaseBranch);
2446
+ let prResp;
2447
+ try {
2448
+ prResp = await apiCallOrThrow("POST", `/api/v1/cli/me/tickets/${detail.id}/pull-requests`, {
2449
+ body: {
2450
+ source_branch: branchName,
2451
+ base_branch: ticketBaseBranch,
2452
+ title: prTitle,
2453
+ body: prBody
2454
+ }
2455
+ });
2456
+ } catch (err) {
2457
+ try {
2458
+ checkoutBranch(cwd, baseBranch);
2459
+ } catch {
2460
+ }
2461
+ await apiCall("POST", "/api/v1/cli/me/runs", {
2462
+ body: {
2463
+ ticket_id: detail.id,
2464
+ event: "pr_failed",
2465
+ output_excerpt: err.message.slice(0, 4e3)
2466
+ }
2467
+ });
2468
+ throw err;
2469
+ }
2470
+ await apiCall("POST", "/api/v1/cli/me/runs", {
2471
+ body: {
2472
+ ticket_id: detail.id,
2473
+ event: "resumed",
2474
+ output_excerpt: `${prResp.recovered ? "recovered" : "opened"} PR #${prResp.pr_number}: ${prResp.pr_url}`
2475
+ }
2476
+ });
2477
+ if (!silent) {
2478
+ const tag = prResp.recovered ? c.ok("\u2713 Recovered PR") : c.ok("\u2713 PR opened");
2479
+ process.stdout.write(`${tag} ${c.cyan(prResp.pr_url)}
2480
+ `);
2481
+ }
2482
+ try {
2483
+ checkoutBranch(cwd, baseBranch);
2484
+ } catch {
2485
+ }
2486
+ }
2487
+ async function resolveTicketId(project, ticketRef, silent) {
2488
+ if (ticketRef && ticketRef.length > 0) {
2489
+ if (/^[0-9a-f-]{36}$/i.test(ticketRef)) return ticketRef;
2490
+ const seqMatch = ticketRef.match(/^#?(\d+)$/);
2491
+ if (seqMatch) {
2492
+ const result2 = await apiCall("GET", "/api/v1/cli/me/tickets", {
2493
+ query: { project_id: project.project_id, ai_fix_status: "building", limit: 100 }
2494
+ });
2495
+ if (!result2.ok || !result2.data) {
2496
+ throw new CliError(
2497
+ CLI_EXIT_CODES.GENERIC_ERROR,
2498
+ `Could not list in-flight tickets (HTTP ${result2.status})${result2.error?.message ? `: ${result2.error.message}` : ""}`
2499
+ );
2500
+ }
2501
+ const seq = parseInt(seqMatch[1] ?? "", 10);
2502
+ const match = result2.data.find((t) => t.sequence_number === seq);
2503
+ if (!match) {
2504
+ throw new CliError(
2505
+ CLI_EXIT_CODES.GENERIC_ERROR,
2506
+ `No in-flight ticket #${seq} claimed by you on this project`,
2507
+ "Run `task doctor` to list your in-flight tickets, or pass the UUID directly."
2508
+ );
2509
+ }
2510
+ return match.id;
2511
+ }
2512
+ throw new CliError(
2513
+ CLI_EXIT_CODES.MISCONFIGURATION,
2514
+ `Invalid ticket reference: ${ticketRef}`,
2515
+ "Pass either a UUID or a sequence number like #42."
2516
+ );
2517
+ }
2518
+ const result = await apiCall("GET", "/api/v1/cli/me/tickets", {
2519
+ query: { project_id: project.project_id, ai_fix_status: "building", limit: 100 }
2520
+ });
2521
+ if (!result.ok) {
2522
+ throw new CliError(
2523
+ CLI_EXIT_CODES.GENERIC_ERROR,
2524
+ `Could not list in-flight tickets (HTTP ${result.status})${result.error?.message ? `: ${result.error.message}` : ""}`
2525
+ );
2526
+ }
2527
+ if (!result.data || result.data.length === 0) return null;
2528
+ if (result.data.length === 1) {
2529
+ const only = result.data[0];
2530
+ return only ? only.id : null;
2531
+ }
2532
+ if (silent) {
2533
+ throw new CliError(
2534
+ CLI_EXIT_CODES.MISCONFIGURATION,
2535
+ `${result.data.length} in-flight tickets \u2014 pass a specific reference in silent mode`,
2536
+ "Run `task doctor` to see the list, then `task resume #N` for the one you want."
2537
+ );
2538
+ }
2539
+ const answer = await inquirer3.prompt([
2540
+ {
2541
+ type: "list",
2542
+ name: "ticketId",
2543
+ message: "Pick an in-flight ticket to resume:",
2544
+ choices: result.data.map((t) => ({
2545
+ name: `#${t.sequence_number} \u2014 ${t.title}`,
2546
+ value: t.id
2547
+ }))
2548
+ }
2549
+ ]);
2550
+ return answer.ticketId;
2551
+ }
2552
+ function localBranchExists(cwd, branchName) {
2553
+ try {
2554
+ execFileSync7("git", ["rev-parse", "--verify", `refs/heads/${branchName}`], {
2555
+ cwd,
2556
+ stdio: ["ignore", "ignore", "ignore"]
2557
+ });
2558
+ return true;
2559
+ } catch {
2560
+ return false;
2561
+ }
2562
+ }
2563
+ function isAncestor(cwd, ancestor, descendant) {
2564
+ try {
2565
+ execFileSync7("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
2566
+ cwd,
2567
+ stdio: ["ignore", "ignore", "ignore"]
2568
+ });
2569
+ return true;
2570
+ } catch {
2571
+ return false;
2572
+ }
2573
+ }
2574
+ function buildResumePrBody(detail, branchName, baseBranch) {
2575
+ return [
2576
+ `Resolves ticket #${detail.sequence_number}: ${detail.title}`,
2577
+ "",
2578
+ detail.description ? `> ${detail.description.slice(0, 1500)}` : "",
2579
+ "",
2580
+ "---",
2581
+ "",
2582
+ `**Generated by:** \`task resume\` (recovery of a previous \`task work\` run)`,
2583
+ `**Branch:** \`${branchName}\` \u2190 \`${baseBranch}\``,
2584
+ "",
2585
+ "Please review carefully \u2014 this is an AI-generated change."
2586
+ ].filter(Boolean).join("\n");
2587
+ }
2588
+
2227
2589
  // src/commands/scan.ts
2228
2590
  import { randomUUID as randomUUID2 } from "crypto";
2229
2591
  import ora2 from "ora";
@@ -2931,7 +3293,7 @@ function clampInt(raw, min, max, fallback) {
2931
3293
  }
2932
3294
 
2933
3295
  // src/commands/pr-test.ts
2934
- import { execFileSync as execFileSync6 } from "child_process";
3296
+ import { execFileSync as execFileSync8 } from "child_process";
2935
3297
  function registerPrTest(program2) {
2936
3298
  program2.command("pr-test").description(
2937
3299
  "Dry-run the full PR pipeline \u2014 cuts a throwaway branch, opens a real PR via the dashboard, then cleans up. Use this to verify your git integration before running task work on real tickets."
@@ -2974,7 +3336,7 @@ async function runPrTest(opts) {
2974
3336
  try {
2975
3337
  if (!silent) process.stdout.write(`${c.dim("Step 3/6: empty commit\u2026")}
2976
3338
  `);
2977
- execFileSync6(
3339
+ execFileSync8(
2978
3340
  "git",
2979
3341
  ["commit", "--allow-empty", "-m", `task pr-test: connectivity probe ${timestamp}`],
2980
3342
  { cwd, stdio: ["ignore", "pipe", "pipe"] }
@@ -3077,7 +3439,7 @@ import { platform as platform2 } from "os";
3077
3439
  import { mkdir as mkdir7, readFile as readFile5, writeFile as writeFile8, unlink as unlink3, readdir } from "fs/promises";
3078
3440
  import { homedir as homedir6 } from "os";
3079
3441
  import { join as join8 } from "path";
3080
- import { execFileSync as execFileSync7, spawn as spawn4 } from "child_process";
3442
+ import { execFileSync as execFileSync9, spawn as spawn4 } from "child_process";
3081
3443
 
3082
3444
  // src/scheduler/cron-translate.ts
3083
3445
  function translateToLaunchd(cron) {
@@ -3245,17 +3607,17 @@ var launchdAdapter = {
3245
3607
  const path = plistPath(entry.id);
3246
3608
  await writeFile8(path, buildPlist(entry));
3247
3609
  try {
3248
- execFileSync7("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
3610
+ execFileSync9("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
3249
3611
  } catch {
3250
3612
  }
3251
3613
  if (entry.enabled) {
3252
- execFileSync7("launchctl", ["bootstrap", bootstrapDomain(), path]);
3614
+ execFileSync9("launchctl", ["bootstrap", bootstrapDomain(), path]);
3253
3615
  }
3254
3616
  },
3255
3617
  async remove(id) {
3256
3618
  const path = plistPath(id);
3257
3619
  try {
3258
- execFileSync7("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
3620
+ execFileSync9("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
3259
3621
  } catch {
3260
3622
  }
3261
3623
  try {
@@ -3320,10 +3682,10 @@ var launchdAdapter = {
3320
3682
  xml = xml.replace(/\s*<key>Disabled<\/key>\s*<true\/>/, "");
3321
3683
  await writeFile8(path, xml);
3322
3684
  try {
3323
- execFileSync7("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
3685
+ execFileSync9("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
3324
3686
  } catch {
3325
3687
  }
3326
- execFileSync7("launchctl", ["bootstrap", bootstrapDomain(), path]);
3688
+ execFileSync9("launchctl", ["bootstrap", bootstrapDomain(), path]);
3327
3689
  } else {
3328
3690
  if (!/<key>Disabled<\/key>/.test(xml)) {
3329
3691
  xml = xml.replace(
@@ -3333,7 +3695,7 @@ var launchdAdapter = {
3333
3695
  await writeFile8(path, xml);
3334
3696
  }
3335
3697
  try {
3336
- execFileSync7("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
3698
+ execFileSync9("launchctl", ["bootout", bootstrapDomain(), path], { stdio: "ignore" });
3337
3699
  } catch {
3338
3700
  }
3339
3701
  }
@@ -3341,7 +3703,7 @@ var launchdAdapter = {
3341
3703
  };
3342
3704
 
3343
3705
  // src/scheduler/cron.ts
3344
- import { execFileSync as execFileSync8, spawn as spawn5 } from "child_process";
3706
+ import { execFileSync as execFileSync10, spawn as spawn5 } from "child_process";
3345
3707
 
3346
3708
  // src/scheduler/safe-command.ts
3347
3709
  var FORBIDDEN = /[;&|`$()<>\\]/;
@@ -3396,7 +3758,7 @@ var MARK_OPEN = (id) => `# task-cli:${id}:start`;
3396
3758
  var MARK_CLOSE = (id) => `# task-cli:${id}:end`;
3397
3759
  function readCrontab() {
3398
3760
  try {
3399
- return execFileSync8("crontab", ["-l"], { encoding: "utf8" });
3761
+ return execFileSync10("crontab", ["-l"], { encoding: "utf8" });
3400
3762
  } catch {
3401
3763
  return "";
3402
3764
  }
@@ -3511,7 +3873,7 @@ var cronAdapter = {
3511
3873
  };
3512
3874
 
3513
3875
  // src/scheduler/windows.ts
3514
- import { execFileSync as execFileSync9, spawn as spawn6 } from "child_process";
3876
+ import { execFileSync as execFileSync11, spawn as spawn6 } from "child_process";
3515
3877
  var TASK_PREFIX = "TaskCLI_";
3516
3878
  function taskName(id) {
3517
3879
  return `${TASK_PREFIX}${id.replace(/[^A-Za-z0-9_-]/g, "_")}`;
@@ -3576,22 +3938,22 @@ function pad(v) {
3576
3938
  var windowsAdapter = {
3577
3939
  async upsert(entry) {
3578
3940
  const args = buildSchtasksArgs(entry, entry.command);
3579
- execFileSync9("schtasks.exe", args, { stdio: "ignore" });
3941
+ execFileSync11("schtasks.exe", args, { stdio: "ignore" });
3580
3942
  if (!entry.enabled) {
3581
- execFileSync9("schtasks.exe", ["/Change", "/TN", taskName(entry.id), "/DISABLE"], {
3943
+ execFileSync11("schtasks.exe", ["/Change", "/TN", taskName(entry.id), "/DISABLE"], {
3582
3944
  stdio: "ignore"
3583
3945
  });
3584
3946
  }
3585
3947
  },
3586
3948
  async remove(id) {
3587
3949
  try {
3588
- execFileSync9("schtasks.exe", ["/Delete", "/TN", taskName(id), "/F"], { stdio: "ignore" });
3950
+ execFileSync11("schtasks.exe", ["/Delete", "/TN", taskName(id), "/F"], { stdio: "ignore" });
3589
3951
  } catch {
3590
3952
  }
3591
3953
  },
3592
3954
  async list() {
3593
3955
  try {
3594
- const csv = execFileSync9("schtasks.exe", ["/Query", "/FO", "CSV", "/V"], {
3956
+ const csv = execFileSync11("schtasks.exe", ["/Query", "/FO", "CSV", "/V"], {
3595
3957
  encoding: "utf8"
3596
3958
  });
3597
3959
  const lines = csv.split(/\r?\n/);
@@ -3641,7 +4003,7 @@ var windowsAdapter = {
3641
4003
  },
3642
4004
  async setEnabled(id, enabled) {
3643
4005
  try {
3644
- execFileSync9(
4006
+ execFileSync11(
3645
4007
  "schtasks.exe",
3646
4008
  ["/Change", "/TN", taskName(id), enabled ? "/ENABLE" : "/DISABLE"],
3647
4009
  { stdio: "ignore" }
@@ -4049,10 +4411,14 @@ function registerConfig(program2) {
4049
4411
  }
4050
4412
 
4051
4413
  // src/commands/doctor.ts
4052
- import { execFileSync as execFileSync10 } from "child_process";
4414
+ import { execFileSync as execFileSync12 } from "child_process";
4415
+ import { readFile as readFile8, writeFile as writeFile10 } from "fs/promises";
4416
+ import { join as join11 } from "path";
4053
4417
  import { request as request5 } from "undici";
4418
+ var ALLOWED_TEST_EXECUTABLES = /* @__PURE__ */ new Set(["pnpm", "npm", "yarn", "bun", "node", "npx"]);
4419
+ var DEFAULT_TEST_COMMAND = "pnpm typecheck";
4054
4420
  function registerDoctor(program2) {
4055
- program2.command("doctor").description("Diagnose your CLI setup").action(async () => {
4421
+ program2.command("doctor").description("Diagnose your CLI setup").option("--fix", "attempt to auto-remediate fixable problems (e.g. add a typecheck script)").action(async (opts) => {
4056
4422
  const checks = [];
4057
4423
  const creds = await readCredentials();
4058
4424
  checks.push({
@@ -4097,7 +4463,7 @@ function registerDoctor(program2) {
4097
4463
  });
4098
4464
  }
4099
4465
  try {
4100
- const dirty = execFileSync10("git", ["status", "--porcelain"], {
4466
+ const dirty = execFileSync12("git", ["status", "--porcelain"], {
4101
4467
  cwd: root,
4102
4468
  encoding: "utf8"
4103
4469
  }).trim();
@@ -4109,19 +4475,167 @@ function registerDoctor(program2) {
4109
4475
  } catch {
4110
4476
  checks.push({ name: "working tree", ok: false, detail: "not in a git repo" });
4111
4477
  }
4478
+ const testCheck = await checkPrePushTest(
4479
+ root,
4480
+ project?.cli_test_command ?? null,
4481
+ opts.fix === true
4482
+ );
4483
+ checks.push(testCheck);
4484
+ let inFlight = [];
4485
+ if (creds && project) {
4486
+ inFlight = await listInFlightTickets(project.project_id, root);
4487
+ checks.push({
4488
+ name: "in-flight tickets",
4489
+ ok: true,
4490
+ detail: inFlight.length === 0 ? "none" : `${inFlight.length} ticket(s) waiting to be resumed`
4491
+ });
4492
+ }
4112
4493
  let allOk = true;
4113
4494
  for (const check of checks) {
4114
4495
  const sym = check.ok ? c.ok("\u2713") : c.err("\u2717");
4115
- process.stdout.write(`${sym} ${check.name.padEnd(16)} ${c.dim(check.detail)}
4496
+ process.stdout.write(`${sym} ${check.name.padEnd(20)} ${c.dim(check.detail)}
4116
4497
  `);
4117
- if (!check.ok) allOk = false;
4498
+ if (!check.ok) {
4499
+ allOk = false;
4500
+ if (check.remediation) {
4501
+ process.stdout.write(` ${c.dim("\u2192 " + check.remediation)}
4502
+ `);
4503
+ }
4504
+ }
4505
+ }
4506
+ for (const t of inFlight) {
4507
+ const status = t.branchPresent ? c.ok("local branch present") : c.err("local branch missing");
4508
+ process.stdout.write(
4509
+ ` ${c.dim("\u2192")} #${t.sequenceNumber} "${t.title}" \u2014 ${status} \u2014 ${c.cyan(`task resume #${t.sequenceNumber}`)}
4510
+ `
4511
+ );
4118
4512
  }
4119
4513
  if (!allOk) process.exit(1);
4120
4514
  });
4121
4515
  }
4516
+ async function listInFlightTickets(projectId, cwd) {
4517
+ const result = await apiCall(
4518
+ "GET",
4519
+ "/api/v1/cli/me/tickets",
4520
+ {
4521
+ query: { project_id: projectId, ai_fix_status: "building", limit: 100 }
4522
+ }
4523
+ );
4524
+ if (!result.ok || !result.data) return [];
4525
+ return result.data.map((t) => ({
4526
+ sequenceNumber: t.sequence_number,
4527
+ title: t.title,
4528
+ branchPresent: localBranchExists2(cwd, branchSlug(t.sequence_number, t.title))
4529
+ }));
4530
+ }
4531
+ function localBranchExists2(cwd, branchName) {
4532
+ try {
4533
+ execFileSync12("git", ["rev-parse", "--verify", `refs/heads/${branchName}`], {
4534
+ cwd,
4535
+ stdio: ["ignore", "ignore", "ignore"]
4536
+ });
4537
+ return true;
4538
+ } catch {
4539
+ return false;
4540
+ }
4541
+ }
4542
+ async function checkPrePushTest(root, configuredCommand, fix) {
4543
+ const command = configuredCommand && configuredCommand.trim().length > 0 ? configuredCommand.trim() : DEFAULT_TEST_COMMAND;
4544
+ const argv = command.split(/\s+/).filter((s) => s.length > 0);
4545
+ const exe = argv[0];
4546
+ if (!exe || !ALLOWED_TEST_EXECUTABLES.has(exe)) {
4547
+ return {
4548
+ name: "pre-push test",
4549
+ ok: false,
4550
+ detail: `command "${command}" not allowlisted (allowed: ${[...ALLOWED_TEST_EXECUTABLES].join(", ")})`,
4551
+ remediation: "update projects.cli_test_command via the dashboard"
4552
+ };
4553
+ }
4554
+ const scriptName = resolveScriptName(argv);
4555
+ if (!scriptName) {
4556
+ return {
4557
+ name: "pre-push test",
4558
+ ok: true,
4559
+ detail: `${command} (non-script executable, not statically verifiable)`
4560
+ };
4561
+ }
4562
+ const pkgPath = join11(root, "package.json");
4563
+ let pkgRaw;
4564
+ try {
4565
+ pkgRaw = await readFile8(pkgPath, "utf8");
4566
+ } catch {
4567
+ return {
4568
+ name: "pre-push test",
4569
+ ok: false,
4570
+ detail: `no package.json at repo root for "${command}"`,
4571
+ remediation: `add a package.json with a "${scriptName}" script, or set projects.cli_test_command in the dashboard`
4572
+ };
4573
+ }
4574
+ let pkg;
4575
+ try {
4576
+ pkg = JSON.parse(pkgRaw);
4577
+ } catch (err) {
4578
+ return {
4579
+ name: "pre-push test",
4580
+ ok: false,
4581
+ detail: `package.json is invalid JSON: ${err.message}`
4582
+ };
4583
+ }
4584
+ const scripts = pkg.scripts ?? {};
4585
+ if (typeof scripts[scriptName] === "string" && scripts[scriptName].trim().length > 0) {
4586
+ return {
4587
+ name: "pre-push test",
4588
+ ok: true,
4589
+ detail: `${command} \u2192 "${scripts[scriptName]}"`
4590
+ };
4591
+ }
4592
+ const isDefaultTypecheck = command === DEFAULT_TEST_COMMAND && scriptName === "typecheck";
4593
+ const hasTypeScript = !!pkg.devDependencies?.typescript || !!pkg.dependencies?.typescript;
4594
+ if (fix && isDefaultTypecheck && hasTypeScript) {
4595
+ pkg.scripts = { ...scripts, typecheck: "tsc --noEmit" };
4596
+ const indent = detectIndent(pkgRaw);
4597
+ const trailingNewline = pkgRaw.endsWith("\n") ? "\n" : "";
4598
+ await writeFile10(pkgPath, JSON.stringify(pkg, null, indent) + trailingNewline);
4599
+ return {
4600
+ name: "pre-push test",
4601
+ ok: true,
4602
+ detail: `added "typecheck": "tsc --noEmit" to ${pkgPath}`
4603
+ };
4604
+ }
4605
+ const remediation = isDefaultTypecheck ? hasTypeScript ? 're-run with --fix to add "typecheck": "tsc --noEmit" to package.json' : 'add a "typecheck" script to package.json, or set a different cli_test_command in the dashboard' : `add a "${scriptName}" script to package.json, or update cli_test_command in the dashboard`;
4606
+ return {
4607
+ name: "pre-push test",
4608
+ ok: false,
4609
+ detail: `"${scriptName}" script missing \u2014 "${command}" will exit 254 (ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL)`,
4610
+ remediation
4611
+ };
4612
+ }
4613
+ function resolveScriptName(argv) {
4614
+ const [exe, ...rest] = argv;
4615
+ if (!exe || rest.length === 0) return null;
4616
+ if (exe === "pnpm" || exe === "yarn" || exe === "bun") {
4617
+ const next = rest[0];
4618
+ if (!next) return null;
4619
+ if (next === "run") return rest[1] ?? null;
4620
+ if (next.startsWith("-")) return null;
4621
+ return next;
4622
+ }
4623
+ if (exe === "npm") {
4624
+ if (rest[0] === "run" || rest[0] === "run-script") return rest[1] ?? null;
4625
+ return null;
4626
+ }
4627
+ return null;
4628
+ }
4629
+ function detectIndent(raw) {
4630
+ const match = raw.match(/^(\s+)"/m);
4631
+ if (!match) return 2;
4632
+ const ws = match[1] ?? " ";
4633
+ if (ws.startsWith(" ")) return " ";
4634
+ return ws.length;
4635
+ }
4122
4636
  function checkBinary(name, command) {
4123
4637
  try {
4124
- const out = execFileSync10(command, ["--version"], { encoding: "utf8" }).trim();
4638
+ const out = execFileSync12(command, ["--version"], { encoding: "utf8" }).trim();
4125
4639
  return { name, ok: true, detail: out.split("\n")[0] ?? out };
4126
4640
  } catch {
4127
4641
  return { name, ok: false, detail: `'${command}' not found on PATH` };
@@ -4129,7 +4643,7 @@ function checkBinary(name, command) {
4129
4643
  }
4130
4644
 
4131
4645
  // src/commands/version.ts
4132
- var CLI_VERSION = true ? "0.2.3" : "0.0.0-dev";
4646
+ var CLI_VERSION = true ? "0.2.5" : "0.0.0-dev";
4133
4647
  function registerVersion(program2) {
4134
4648
  program2.command("version").description("Print the CLI version").action(() => {
4135
4649
  process.stdout.write(CLI_VERSION + "\n");
@@ -4153,6 +4667,7 @@ registerTickets(program);
4153
4667
  registerTicket(program);
4154
4668
  registerWork(program);
4155
4669
  registerMultiWork(program);
4670
+ registerResume(program);
4156
4671
  registerScan(program);
4157
4672
  registerPrTest(program);
4158
4673
  registerScheduledTask(program);