@dunnewold-labs/mr-manager 0.4.12 → 0.4.16

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 (2) hide show
  1. package/dist/index.mjs +419 -55
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -185,7 +185,7 @@ import { fileURLToPath } from "url";
185
185
  // cli/package.json
186
186
  var package_default = {
187
187
  name: "@dunnewold-labs/mr-manager",
188
- version: "0.4.12",
188
+ version: "0.4.16",
189
189
  description: "Mr. Manager - Task and project management CLI",
190
190
  bin: {
191
191
  mr: "./dist/index.mjs"
@@ -250,6 +250,45 @@ var ApiError = class extends Error {
250
250
  this.name = "ApiError";
251
251
  }
252
252
  };
253
+ var NETWORK_ERROR_CODES = /* @__PURE__ */ new Set([
254
+ "ECONNABORTED",
255
+ "ECONNREFUSED",
256
+ "ECONNRESET",
257
+ "EHOSTUNREACH",
258
+ "ENETDOWN",
259
+ "ENETUNREACH",
260
+ "ENOTFOUND",
261
+ "EAI_AGAIN",
262
+ "ETIMEDOUT"
263
+ ]);
264
+ var NETWORK_ERROR_PATTERNS = [
265
+ "fetch failed",
266
+ "network",
267
+ "offline",
268
+ "socket hang up",
269
+ "timed out",
270
+ "timeout",
271
+ "connection refused",
272
+ "connection reset",
273
+ "host is down",
274
+ "host unreachable",
275
+ "internet disconnected"
276
+ ];
277
+ function isLikelyNetworkError(err) {
278
+ if (!err || typeof err !== "object") return false;
279
+ if (err instanceof ApiError) return false;
280
+ const error = err;
281
+ const directCode = typeof error.code === "string" ? error.code.toUpperCase() : null;
282
+ const causeCode = typeof error.cause?.code === "string" ? error.cause.code.toUpperCase() : null;
283
+ if (directCode && NETWORK_ERROR_CODES.has(directCode) || causeCode && NETWORK_ERROR_CODES.has(causeCode)) {
284
+ return true;
285
+ }
286
+ const combinedMessage = [
287
+ typeof error.message === "string" ? error.message : "",
288
+ typeof error.cause?.message === "string" ? error.cause.message : ""
289
+ ].join(" ").toLowerCase();
290
+ return NETWORK_ERROR_PATTERNS.some((pattern) => combinedMessage.includes(pattern));
291
+ }
253
292
  async function request(method, path, body) {
254
293
  const config = loadConfig();
255
294
  if (!config.apiKey) {
@@ -492,7 +531,7 @@ import { Command as Command8 } from "commander";
492
531
  import { spawn as spawn4, exec } from "child_process";
493
532
  import { randomUUID } from "crypto";
494
533
  import { resolve as resolve2 } from "path";
495
- import { readFileSync as readFileSync5, readdirSync, unlinkSync, existsSync as existsSync7, statSync, writeFileSync as writeFileSync4 } from "fs";
534
+ import { readFileSync as readFileSync5, readdirSync, unlinkSync, existsSync as existsSync7, statSync, writeFileSync as writeFileSync3 } from "fs";
496
535
  import * as readline from "readline";
497
536
 
498
537
  // lib/test-runner.ts
@@ -557,10 +596,13 @@ function findExistingWorktreeForBranch(repoDir, branch) {
557
596
  return null;
558
597
  }
559
598
  }
560
- function createWorktree(repoDir, branch, worktreeName) {
599
+ function createWorktree(repoDir, branch, worktreeName, options = {}) {
561
600
  const wtPath = join4(repoDir, ".mr-worktrees", worktreeName);
562
601
  if (existsSync4(wtPath)) {
563
- execSync2(`git checkout ${branch}`, { cwd: wtPath, stdio: "pipe" });
602
+ execSync2(`git checkout "${branch}"`, { cwd: wtPath, stdio: "pipe" });
603
+ if (options.syncRemoteBranch) {
604
+ syncBranchWithRemote(repoDir, wtPath, branch);
605
+ }
564
606
  return {
565
607
  path: wtPath,
566
608
  created: false,
@@ -569,6 +611,9 @@ function createWorktree(repoDir, branch, worktreeName) {
569
611
  }
570
612
  const existingBranchWorktree = findExistingWorktreeForBranch(repoDir, branch);
571
613
  if (existingBranchWorktree) {
614
+ if (options.syncRemoteBranch) {
615
+ syncBranchWithRemote(repoDir, existingBranchWorktree, branch);
616
+ }
572
617
  return {
573
618
  path: existingBranchWorktree,
574
619
  created: false,
@@ -613,6 +658,18 @@ function createWorktree(repoDir, branch, worktreeName) {
613
658
  reusedBranchWorktree: false
614
659
  };
615
660
  }
661
+ function syncBranchWithRemote(repoDir, wtPath, branch) {
662
+ tryExec(`git fetch origin "${branch}"`, repoDir);
663
+ const hasRemoteBranch = tryExec(`git rev-parse --verify "origin/${branch}"`, repoDir);
664
+ if (!hasRemoteBranch) return;
665
+ try {
666
+ execSync2(`git pull --ff-only origin "${branch}"`, {
667
+ cwd: wtPath,
668
+ stdio: "pipe"
669
+ });
670
+ } catch {
671
+ }
672
+ }
616
673
  function removeWorktree(repoDir, wtPath) {
617
674
  try {
618
675
  execSync2(`git worktree remove --force "${wtPath}"`, {
@@ -819,7 +876,9 @@ async function runTest(options) {
819
876
  uploadScreenshot: uploadScreenshot2,
820
877
  uploadVideo,
821
878
  postUpdate,
822
- onProgress
879
+ onProgress,
880
+ recordingEnabled = true,
881
+ recordingContext = "test-run"
823
882
  } = options;
824
883
  const log2 = onProgress || (() => {
825
884
  });
@@ -828,7 +887,11 @@ async function runTest(options) {
828
887
  assertions: [],
829
888
  screenshots: [],
830
889
  errors: [],
831
- summary: ""
890
+ summary: "",
891
+ proof: {
892
+ state: recordingEnabled ? "proof_missing_capture_failed" : "proof_missing_disabled",
893
+ details: recordingEnabled ? "Proof recording did not complete." : "Proof recording disabled for this run."
894
+ }
832
895
  };
833
896
  let devProc = null;
834
897
  let wtPath = null;
@@ -858,6 +921,29 @@ async function runTest(options) {
858
921
  devProc = await startDevServer(wtPath, port);
859
922
  const baseUrl = `http://127.0.0.1:${port}`;
860
923
  log2(`Dev server running on ${baseUrl}`);
924
+ let recordingStarted = false;
925
+ if (recordingEnabled) {
926
+ try {
927
+ const recordingStart = await browseRunner(["recording-start"]);
928
+ if (recordingStart.exitCode !== 0) {
929
+ throw new Error(recordingStart.stdout || "recording-start failed");
930
+ }
931
+ recordingStarted = true;
932
+ log2("Proof recording started");
933
+ } catch (err) {
934
+ result.proof = {
935
+ state: "proof_missing_capture_failed",
936
+ details: `Proof recording could not start: ${err.message}`
937
+ };
938
+ log2(result.proof.details);
939
+ }
940
+ } else {
941
+ result.proof = {
942
+ state: "proof_missing_disabled",
943
+ details: "Proof recording disabled for this run."
944
+ };
945
+ log2(result.proof.details);
946
+ }
861
947
  await browseRunner(["goto", baseUrl]);
862
948
  const plan = customPlan || buildDefaultTestPlan(baseUrl);
863
949
  log2(`Executing ${plan.length}-step test plan...`);
@@ -917,6 +1003,62 @@ async function runTest(options) {
917
1003
  log2(`Step ${i + 1} error: ${err.message}`);
918
1004
  }
919
1005
  }
1006
+ if (recordingStarted) {
1007
+ const recordingPath = `/tmp/mr-proof-${taskId.slice(0, 8)}-${Date.now()}.webm`;
1008
+ try {
1009
+ const recordingStop = await browseRunner(["recording-stop", recordingPath]);
1010
+ const savedPath = recordingStop.stdout.match(/Recording saved: (.+)/)?.[1]?.trim();
1011
+ if (!savedPath) {
1012
+ throw new Error(recordingStop.stdout || "recording-stop did not return a file path");
1013
+ }
1014
+ result.proof.localPath = savedPath;
1015
+ log2(`Proof recording finalized at ${savedPath}`);
1016
+ if (uploadVideo) {
1017
+ const videoUrl = await uploadVideo(savedPath);
1018
+ if (videoUrl) {
1019
+ result.videoUrl = videoUrl;
1020
+ result.proof = {
1021
+ state: "proof_attached",
1022
+ details: result.status === "passed" ? "Proof recording attached." : "Proof recording attached for the failed run.",
1023
+ localPath: savedPath,
1024
+ videoUrl,
1025
+ media: [
1026
+ {
1027
+ kind: "recording",
1028
+ url: videoUrl,
1029
+ mimeType: "video/webm",
1030
+ label: "Proof recording",
1031
+ captureContext: recordingContext,
1032
+ uploadState: "uploaded",
1033
+ isCanonical: true
1034
+ }
1035
+ ]
1036
+ };
1037
+ log2(`Proof recording uploaded to ${videoUrl}`);
1038
+ } else {
1039
+ result.proof = {
1040
+ state: "proof_missing_upload_failed",
1041
+ details: "Proof recording captured, but upload failed.",
1042
+ localPath: savedPath
1043
+ };
1044
+ log2(result.proof.details);
1045
+ }
1046
+ } else {
1047
+ result.proof = {
1048
+ state: "proof_missing_upload_failed",
1049
+ details: "Proof recording captured, but no upload handler was configured.",
1050
+ localPath: savedPath
1051
+ };
1052
+ log2(result.proof.details);
1053
+ }
1054
+ } catch (err) {
1055
+ result.proof = {
1056
+ state: "proof_missing_capture_failed",
1057
+ details: `Proof recording could not be finalized: ${err.message}`
1058
+ };
1059
+ log2(result.proof.details);
1060
+ }
1061
+ }
920
1062
  const totalAssertions = result.assertions.length;
921
1063
  const passedAssertions = result.assertions.filter((a) => a.passed).length;
922
1064
  const failedSteps = result.assertions.filter((a) => !a.passed).map((a) => `step ${a.step + 1}`);
@@ -928,13 +1070,25 @@ async function runTest(options) {
928
1070
  if (result.errors.length > 0) {
929
1071
  result.summary += ` (${result.errors.length} errors)`;
930
1072
  }
931
- await postUpdate(result.summary);
1073
+ if (result.proof.state === "proof_attached") {
1074
+ result.summary += result.status === "passed" ? " Proof recording attached." : " Proof recording attached for review.";
1075
+ } else {
1076
+ result.summary = result.status === "passed" ? result.summary.replace(/^Test passed/, "Test passed with warning") : result.summary.replace(/^Test failed/, "Test failed with warning");
1077
+ result.summary += ` ${result.proof.details}`;
1078
+ }
1079
+ await postUpdate({
1080
+ message: result.summary,
1081
+ imageUrl: result.videoUrl,
1082
+ media: result.proof.media,
1083
+ proofState: result.proof.state,
1084
+ proofDetails: result.proof.details
1085
+ });
932
1086
  } catch (err) {
933
1087
  result.status = "failed";
934
1088
  result.summary = `Test failed: ${err.message}`;
935
1089
  result.errors.push(err.message);
936
1090
  try {
937
- await postUpdate(`Test failed: ${err.message}`);
1091
+ await postUpdate({ message: `Test failed: ${err.message}` });
938
1092
  } catch {
939
1093
  }
940
1094
  } finally {
@@ -1000,6 +1154,7 @@ var NON_CODE_TASK_PATTERNS = [
1000
1154
  ];
1001
1155
  function taskLikelyDoesNotNeedCodeChanges(task) {
1002
1156
  if (task.mode && task.mode !== "development") return true;
1157
+ if (task.mode === "development") return false;
1003
1158
  const haystack = normalizeWhitespace(
1004
1159
  [task.title, task.notes, task.prdContent].filter(Boolean).join(" ").toLowerCase()
1005
1160
  );
@@ -1008,7 +1163,7 @@ function taskLikelyDoesNotNeedCodeChanges(task) {
1008
1163
  }
1009
1164
 
1010
1165
  // cli/browse-runner.ts
1011
- import { execSync as execSync4, spawn as spawn3 } from "child_process";
1166
+ import { execSync as execSync3, spawn as spawn3 } from "child_process";
1012
1167
  import { existsSync as existsSync6 } from "fs";
1013
1168
  import { join as join6 } from "path";
1014
1169
  var BROWSE_DIR = join6(import.meta.dirname, "..", "..", "browse");
@@ -1019,7 +1174,7 @@ function getBrowseRunner() {
1019
1174
  return { cmd: BROWSE_BINARY, args: [] };
1020
1175
  }
1021
1176
  try {
1022
- execSync4("which bun", { stdio: "pipe" });
1177
+ execSync3("which bun", { stdio: "pipe" });
1023
1178
  if (existsSync6(BROWSE_DEV_CMD)) {
1024
1179
  return { cmd: "bun", args: ["run", BROWSE_DEV_CMD] };
1025
1180
  }
@@ -1246,9 +1401,19 @@ function logDispatch(prefix, msg) {
1246
1401
  function logSpinner(prefix, msg) {
1247
1402
  console.log(`${timestamp()} ${prefix} ${paint("blue", "\u27F3")} ${msg}`);
1248
1403
  }
1249
- async function postTaskUpdate(taskId, message, source = "system") {
1404
+ function isLikelyNetworkFailureDetail(detail) {
1405
+ return isLikelyNetworkError(new Error(detail));
1406
+ }
1407
+ async function postTaskUpdate(taskId, payloadOrMessage, source = "system") {
1250
1408
  try {
1251
- await api.post(`/api/tasks/${taskId}/updates`, { message, source });
1409
+ if (typeof payloadOrMessage === "string") {
1410
+ await api.post(`/api/tasks/${taskId}/updates`, { message: payloadOrMessage, source });
1411
+ return;
1412
+ }
1413
+ await api.post(`/api/tasks/${taskId}/updates`, {
1414
+ ...payloadOrMessage,
1415
+ source: payloadOrMessage.source || source
1416
+ });
1252
1417
  } catch (err) {
1253
1418
  logError(taskTag(shortId(taskId)), `Failed to post update: ${err.message}`);
1254
1419
  }
@@ -1284,6 +1449,12 @@ function ownerPrefix(task) {
1284
1449
  const first = name.split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9]/g, "");
1285
1450
  return first || "mr";
1286
1451
  }
1452
+ function taskBranchName(task) {
1453
+ if (task.attachedBranch?.trim()) {
1454
+ return task.attachedBranch.trim();
1455
+ }
1456
+ return `${ownerPrefix(task)}/${slugify(task.title)}`;
1457
+ }
1287
1458
  function formatElapsed(ms) {
1288
1459
  const totalMinutes = Math.max(1, Math.floor(ms / 6e4));
1289
1460
  const hours = Math.floor(totalMinutes / 60);
@@ -1467,7 +1638,7 @@ async function createPrInRepo(task, branchName, repoDir, vcs, subtasks, protoRef
1467
1638
  }
1468
1639
  const bodyPath = resolve2("/tmp", `mr-auto-pr-${randomUUID()}.md`);
1469
1640
  const body = buildAutomaticPrBody(task, branchName, vcs, subtasks, protoRefs, feedbackUpdates, existingResources, skillRefs);
1470
- writeFileSync4(bodyPath, `${body}
1641
+ writeFileSync3(bodyPath, `${body}
1471
1642
  `, "utf-8");
1472
1643
  const createCommand2 = vcs === "gitlab" ? `glab mr create --source-branch ${JSON.stringify(branchName)} --title ${JSON.stringify(task.title)} --description-file ${JSON.stringify(bodyPath)} --yes` : `gh pr create --head ${JSON.stringify(branchName)} --title ${JSON.stringify(task.title)} --body-file ${JSON.stringify(bodyPath)}`;
1473
1644
  try {
@@ -1614,7 +1785,7 @@ function buildPrototypeSection(protoRefs, workingDir) {
1614
1785
  const safeProtoId = proto.id.slice(0, 8);
1615
1786
  const tmpPath = resolve2(tmpDir, `.mr-proto-${safeProtoId}-v${selected[i] ?? i}.html`);
1616
1787
  try {
1617
- writeFileSync4(tmpPath, file.content, "utf-8");
1788
+ writeFileSync3(tmpPath, file.content, "utf-8");
1618
1789
  sections.push(`#### ${variantLabel}: ${file.name}`);
1619
1790
  sections.push(`File: \`${tmpPath}\` \u2014 read this file to see the full HTML content.`);
1620
1791
  } catch {
@@ -1720,13 +1891,15 @@ function buildFeaturesSection(repoDir) {
1720
1891
  ``
1721
1892
  ].join("\n");
1722
1893
  }
1723
- function buildExecutionPrompt(task, repoDir, subtasks, vcs = "github", protoRefs = [], feedbackUpdates = [], existingResources = [], skillRefs = [], executionDir, startWithoutWorktree = false) {
1894
+ function buildExecutionPrompt(task, repoDir, subtasks, vcs = "github", protoRefs = [], feedbackUpdates = [], existingResources = [], skillRefs = [], executionDir, startWithoutWorktree = false, preparedBranchName) {
1724
1895
  const slug = slugify(task.title);
1725
1896
  const owner = ownerPrefix(task);
1726
- const branchName = `${owner}/${slug}`;
1897
+ const generatedBranchName = `${owner}/${slug}`;
1898
+ const branchName = taskBranchName(task);
1727
1899
  const wtPath = worktreePath(`${owner}-${slug}`);
1728
1900
  const workingDir = executionDir ?? repoDir;
1729
1901
  const prBodyPath = "/tmp/mr-pr-body.md";
1902
+ const hasAttachedBranch = !!task.attachedBranch?.trim();
1730
1903
  const notes = task.prdContent ? `
1731
1904
 
1732
1905
  ## PRD (Product Requirements Document)
@@ -1765,10 +1938,11 @@ ${task.notes}` : "";
1765
1938
  ``,
1766
1939
  `1. **Gather project context first.** Run \`mr context\` to get the project description, current task list, and the last 10 completed tasks. Use this to understand what's already been built, recent decisions, and the overall project direction before you start coding.`,
1767
1940
  ``,
1768
- ...executionDir && executionDir !== repoDir ? [
1769
- `2. Your isolated git worktree is already prepared and this session starts inside it.`,
1941
+ ...preparedBranchName ? [
1942
+ `2. Your git working tree is already prepared and this session starts inside it.`,
1770
1943
  ` - Current working directory: ${executionDir}`,
1771
- ` - Branch checked out in this worktree: ${branchName}`,
1944
+ ` - Branch checked out for this task: ${preparedBranchName}`,
1945
+ ...hasAttachedBranch ? [` - This task is attached to an existing branch, so agent work should stay on that branch instead of creating ${generatedBranchName}.`] : [],
1772
1946
  ` - If the task spans additional repos, create matching worktrees there as needed.`
1773
1947
  ] : hasFeedback ? [
1774
1948
  `2. Set up your working environment:`,
@@ -1843,7 +2017,7 @@ ${task.notes}` : "";
1843
2017
  if (isRevision) {
1844
2018
  const prdTmpPath = resolve2(repoDir, ".mr-existing-prd.md");
1845
2019
  try {
1846
- writeFileSync4(prdTmpPath, existingPrd, "utf-8");
2020
+ writeFileSync3(prdTmpPath, existingPrd, "utf-8");
1847
2021
  } catch {
1848
2022
  }
1849
2023
  const prdSummaryLines = [];
@@ -2065,7 +2239,7 @@ function buildRefinementPrompt(proto, parentFiles, repoDir) {
2065
2239
  const f = parentFiles[i];
2066
2240
  const tmpPath = resolve2(repoDir, `.mr-parent-variant-${i + 1}.html`);
2067
2241
  try {
2068
- writeFileSync4(tmpPath, f.content, "utf-8");
2242
+ writeFileSync3(tmpPath, f.content, "utf-8");
2069
2243
  existingVariantLines.push(`### Previous Variant ${i + 1} (${f.name})`);
2070
2244
  existingVariantLines.push(`File: \`${tmpPath}\` \u2014 read this file to see the full HTML content.`);
2071
2245
  } catch {
@@ -2328,6 +2502,8 @@ var watchCommand = new Command8("watch").description(
2328
2502
  const failed = /* @__PURE__ */ new Map();
2329
2503
  const queued = /* @__PURE__ */ new Set();
2330
2504
  const finishing = /* @__PURE__ */ new Set();
2505
+ const networkPaused = /* @__PURE__ */ new Map();
2506
+ let networkOffline = false;
2331
2507
  let pollRunning = false;
2332
2508
  const approvalQueue = [];
2333
2509
  let approvalRunning = false;
@@ -2381,11 +2557,85 @@ var watchCommand = new Command8("watch").description(
2381
2557
  } catch {
2382
2558
  }
2383
2559
  }
2560
+ async function isApiReachable() {
2561
+ try {
2562
+ await api.get("/api/tasks?status=queued");
2563
+ return true;
2564
+ } catch (err) {
2565
+ return !isLikelyNetworkError(err);
2566
+ }
2567
+ }
2568
+ function pauseTaskForNetwork(taskId, reason) {
2569
+ const activeEntry = active.get(taskId);
2570
+ if (networkPaused.has(taskId)) return;
2571
+ networkPaused.set(taskId, {
2572
+ pausedAt: Date.now(),
2573
+ reason,
2574
+ resumeSession: activeEntry?.currentAgent === "claude"
2575
+ });
2576
+ failed.delete(taskId);
2577
+ queued.delete(taskId);
2578
+ finishing.delete(taskId);
2579
+ if (activeEntry) {
2580
+ activeEntry.terminatedForError = true;
2581
+ activeEntry.process.kill("SIGTERM");
2582
+ active.delete(taskId);
2583
+ }
2584
+ }
2585
+ function handleNetworkLoss(reason) {
2586
+ if (!networkOffline) {
2587
+ networkOffline = true;
2588
+ logWarn(watchTag(), `Network unavailable \u2014 pausing active tasks until connectivity returns (${reason})`);
2589
+ }
2590
+ for (const taskId of active.keys()) {
2591
+ const nonTaskPrefixes = ["proto-", "repo-", "scan-", "idea-", "test-"];
2592
+ if (nonTaskPrefixes.some((prefix) => taskId.startsWith(prefix))) continue;
2593
+ pauseTaskForNetwork(taskId, reason);
2594
+ }
2595
+ }
2596
+ async function resumeNetworkPausedTasks() {
2597
+ if (networkPaused.size === 0) {
2598
+ if (networkOffline) {
2599
+ networkOffline = false;
2600
+ logSuccess(watchTag(), "Network connection restored");
2601
+ }
2602
+ return true;
2603
+ }
2604
+ const pausedIds = [...networkPaused.keys()];
2605
+ for (const taskId of pausedIds) {
2606
+ try {
2607
+ const task = await api.get(`/api/tasks/${taskId}`);
2608
+ if (task.status === "delegated") {
2609
+ await api.patch(`/api/tasks/${taskId}`, { status: "queued" });
2610
+ }
2611
+ await postTaskUpdate(taskId, "Network connection restored \u2014 resuming task", "system");
2612
+ networkPaused.delete(taskId);
2613
+ } catch (err) {
2614
+ if (isLikelyNetworkError(err)) {
2615
+ handleNetworkLoss(err.message);
2616
+ return false;
2617
+ }
2618
+ logError(taskTag(shortId(taskId)), `Failed to resume paused task: ${err.message}`);
2619
+ networkPaused.delete(taskId);
2620
+ }
2621
+ }
2622
+ if (networkOffline) {
2623
+ networkOffline = false;
2624
+ logSuccess(watchTag(), "Network connection restored \u2014 paused tasks re-queued");
2625
+ }
2626
+ return true;
2627
+ }
2628
+ async function shouldPauseForNetwork(failureDetail) {
2629
+ if (networkOffline || isLikelyNetworkFailureDetail(failureDetail)) {
2630
+ return true;
2631
+ }
2632
+ return !await isApiReachable();
2633
+ }
2384
2634
  async function dispatchTask(task, repoDir) {
2385
2635
  const sid = shortId(task.id);
2386
2636
  const slug = slugify(task.title);
2387
2637
  const owner = ownerPrefix(task);
2388
- const branchName = `${owner}/${slug}`;
2638
+ const branchName = taskBranchName(task);
2389
2639
  const legacyBranchName = `mr/${sid}/${slug}`;
2390
2640
  const prefix = taskTag(sid);
2391
2641
  const vcs = detectVcs(repoDir)?.provider ?? "github";
@@ -2445,7 +2695,8 @@ var watchCommand = new Command8("watch").description(
2445
2695
  const worktree = createWorktree(
2446
2696
  repoDir,
2447
2697
  branchName,
2448
- worktreeNameFromPath(desiredWorktreePath)
2698
+ worktreeNameFromPath(desiredWorktreePath),
2699
+ { syncRemoteBranch: !!task.attachedBranch?.trim() }
2449
2700
  );
2450
2701
  executionDir = worktree.path;
2451
2702
  cleanupWorktreePath = worktree.created ? executionDir : void 0;
@@ -2454,7 +2705,7 @@ var watchCommand = new Command8("watch").description(
2454
2705
  worktree.reusedBranchWorktree ? `Reusing existing worktree ${paint("cyan", executionDir)} for branch ${paint("dim", branchName)}` : `Prepared worktree ${paint("cyan", executionDir)}`
2455
2706
  );
2456
2707
  }
2457
- const prompt2 = buildExecutionPrompt(task, repoDir, subtasks, vcs, protoRefs, feedbackUpdates, existingResources, skillRefs, executionDir, startWithoutWorktree);
2708
+ const prompt2 = buildExecutionPrompt(task, repoDir, subtasks, vcs, protoRefs, feedbackUpdates, existingResources, skillRefs, executionDir, startWithoutWorktree, !startWithoutWorktree && isGitRepo(repoDir) ? branchName : void 0);
2458
2709
  const activeEntry = {
2459
2710
  process: void 0,
2460
2711
  title: task.title,
@@ -2480,7 +2731,8 @@ var watchCommand = new Command8("watch").description(
2480
2731
  let attemptIndex = 0;
2481
2732
  const launchAttempt = async (attemptAgent) => {
2482
2733
  let spawnFailureReason = null;
2483
- const shouldResumeClaudeSession = attemptAgent === "claude" && hasFeedback && !!task.claudeSessionId;
2734
+ const pausedForNetwork = networkPaused.get(task.id);
2735
+ const shouldResumeClaudeSession = attemptAgent === "claude" && !!task.claudeSessionId && (hasFeedback || pausedForNetwork?.resumeSession === true);
2484
2736
  const sessionId = attemptAgent === "claude" ? task.claudeSessionId ?? randomUUID() : void 0;
2485
2737
  const executionSystemPrompt = composeSystemPrompt(EXECUTION_SYSTEM_SECTIONS);
2486
2738
  const child = spawnAgent(
@@ -2500,6 +2752,7 @@ var watchCommand = new Command8("watch").description(
2500
2752
  activeEntry.process = child;
2501
2753
  activeEntry.currentAgent = attemptAgent;
2502
2754
  active.set(task.id, activeEntry);
2755
+ networkPaused.delete(task.id);
2503
2756
  if (attemptAgent === "claude" && sessionId) {
2504
2757
  api.patch(`/api/tasks/${task.id}`, { claudeSessionId: sessionId }).catch(() => {
2505
2758
  });
@@ -2517,9 +2770,15 @@ var watchCommand = new Command8("watch").description(
2517
2770
  }
2518
2771
  const failedAttempt = code !== 0 || spawnFailureReason !== null;
2519
2772
  if (failedAttempt && !activeEntry.terminatedForError) {
2773
+ const failureDetail = spawnFailureReason ?? `exit code ${code}`;
2774
+ if (await shouldPauseForNetwork(failureDetail)) {
2775
+ pauseTaskForNetwork(task.id, failureDetail);
2776
+ handleNetworkLoss(failureDetail);
2777
+ logWarn(prefix, `${attemptAgent} paused after network loss (${failureDetail})`);
2778
+ return;
2779
+ }
2520
2780
  const nextAgent = attemptOrder[attemptIndex + 1];
2521
2781
  if (nextAgent) {
2522
- const failureDetail = spawnFailureReason ?? `exit code ${code}`;
2523
2782
  logWarn(prefix, `${attemptAgent} failed (${failureDetail}) \u2014 retrying with ${nextAgent}`);
2524
2783
  await postTaskUpdate(
2525
2784
  task.id,
@@ -2570,7 +2829,7 @@ var watchCommand = new Command8("watch").description(
2570
2829
  let prUrl = null;
2571
2830
  if (!noMrRequested) {
2572
2831
  prUrl = await findPrUrlAcrossRepos(branchName, repoDir, vcs);
2573
- if (!prUrl) {
2832
+ if (!prUrl && !task.attachedBranch?.trim()) {
2574
2833
  prUrl = await findPrUrlAcrossRepos(legacyBranchName, repoDir, vcs);
2575
2834
  }
2576
2835
  if (!prUrl) {
@@ -2591,7 +2850,7 @@ var watchCommand = new Command8("watch").description(
2591
2850
  existingResources,
2592
2851
  skillRefs
2593
2852
  );
2594
- if (!prUrl) {
2853
+ if (!prUrl && !task.attachedBranch?.trim()) {
2595
2854
  prUrl = await createPrAcrossRepos(
2596
2855
  task,
2597
2856
  legacyBranchName,
@@ -2622,8 +2881,20 @@ var watchCommand = new Command8("watch").description(
2622
2881
  ...prUrl ? { link: prUrl } : {}
2623
2882
  });
2624
2883
  logSuccess(prefix, `"${paint("bold", task.title)}" marked ready for review`);
2625
- await postTaskUpdate(task.id, noMrRequested ? `Task marked ready for review \u2014 no ${prLabel} needed: ${noMrDescription}` : "Task marked ready for review", "system");
2884
+ if (noMrRequested) {
2885
+ await postTaskUpdate(task.id, `No ${prLabel} required: ${noMrDescription}`, "system");
2886
+ }
2626
2887
  } catch (err) {
2888
+ if (isLikelyNetworkError(err)) {
2889
+ networkPaused.set(task.id, {
2890
+ pausedAt: Date.now(),
2891
+ reason: err.message,
2892
+ resumeSession: false
2893
+ });
2894
+ handleNetworkLoss(err.message);
2895
+ logWarn(prefix, `Task finalization paused after network loss (${err.message})`);
2896
+ return;
2897
+ }
2627
2898
  logError(prefix, `Failed to update task: ${err.message}`);
2628
2899
  }
2629
2900
  } else if (!activeEntry.terminatedForError) {
@@ -2633,7 +2904,7 @@ var watchCommand = new Command8("watch").description(
2633
2904
  await moveTaskToError(task, prefix, reason);
2634
2905
  }
2635
2906
  } finally {
2636
- if (activeEntry.cleanupRepoDir && activeEntry.cleanupWorktreePath) {
2907
+ if (!networkPaused.has(task.id) && activeEntry.cleanupRepoDir && activeEntry.cleanupWorktreePath) {
2637
2908
  removeWorktree(activeEntry.cleanupRepoDir, activeEntry.cleanupWorktreePath);
2638
2909
  }
2639
2910
  queued.delete(task.id);
@@ -2767,7 +3038,7 @@ var watchCommand = new Command8("watch").description(
2767
3038
  ...prdContent ? { prdContent } : {}
2768
3039
  });
2769
3040
  logSuccess(prefix, `"${paint("bold", task.title)}" PRD generated and marked ready for review`);
2770
- await postTaskUpdate(task.id, "PRD generated \u2014 ready for review", "system");
3041
+ await postTaskUpdate(task.id, "PRD generated", "system");
2771
3042
  } catch (err) {
2772
3043
  logError(prefix, `Failed to update task: ${err.message}`);
2773
3044
  }
@@ -3257,18 +3528,29 @@ ${divider}`);
3257
3528
  if (pollRunning) return;
3258
3529
  pollRunning = true;
3259
3530
  try {
3531
+ if (networkOffline) {
3532
+ const reachable = await isApiReachable();
3533
+ if (!reachable) return;
3534
+ const resumed = await resumeNetworkPausedTasks();
3535
+ if (!resumed) return;
3536
+ }
3260
3537
  let queuedTasks;
3261
3538
  let delegatedTasks;
3262
3539
  try {
3263
3540
  queuedTasks = await api.get("/api/tasks?status=queued");
3264
3541
  delegatedTasks = await api.get("/api/tasks?status=delegated");
3265
3542
  } catch (err) {
3543
+ if (isLikelyNetworkError(err)) {
3544
+ handleNetworkLoss(err.message);
3545
+ return;
3546
+ }
3266
3547
  logError(watchTag(), `Failed to fetch tasks: ${err.message}`);
3267
3548
  return;
3268
3549
  }
3269
3550
  const nonTestQueued = queuedTasks.filter((t) => t.mode !== "testing");
3270
3551
  const nonTestDelegated = delegatedTasks.filter((t) => t.mode !== "testing");
3271
3552
  const staleDelegatedTasks = nonTestDelegated.filter((task) => {
3553
+ if (networkPaused.has(task.id)) return false;
3272
3554
  if (!task.inProgressSince) return false;
3273
3555
  return Date.now() - new Date(task.inProgressSince).getTime() >= hungTaskTimeoutMs;
3274
3556
  });
@@ -3298,6 +3580,7 @@ ${divider}`);
3298
3580
  const sid = shortId(task.id);
3299
3581
  const prefix = taskTag(sid);
3300
3582
  const activeEntry = active.get(task.id);
3583
+ if (networkPaused.has(task.id)) continue;
3301
3584
  const idleMs = activeEntry ? Date.now() - activeEntry.lastActivityAt : null;
3302
3585
  const delegatedAtMs = task.inProgressSince ? Date.now() - new Date(task.inProgressSince).getTime() : null;
3303
3586
  const exceededIdleTimeout = idleMs !== null && idleMs >= taskStallTimeoutMs;
@@ -3347,12 +3630,17 @@ ${divider}`);
3347
3630
  if (failed.has(task.id)) continue;
3348
3631
  const sid = shortId(task.id);
3349
3632
  const prefix = taskTag(sid);
3350
- const repoDir = findDirectoryForProject(config, task.projectId, rootDir);
3633
+ let repoDir = findDirectoryForProject(config, task.projectId, rootDir);
3351
3634
  if (!repoDir) {
3352
- const reason = `no linked directory found under ${rootDir}`;
3353
- logError(prefix, `"${task.title}": ${reason} \u2014 will not retry`);
3354
- failed.set(task.id, reason);
3355
- continue;
3635
+ if (taskLikelyDoesNotNeedCodeChanges(task)) {
3636
+ repoDir = rootDir;
3637
+ logInfo(prefix, `No linked directory \u2014 using root dir for no-code task`);
3638
+ } else {
3639
+ const reason = `no linked directory found under ${rootDir}`;
3640
+ logError(prefix, `"${task.title}": ${reason} \u2014 will not retry`);
3641
+ failed.set(task.id, reason);
3642
+ continue;
3643
+ }
3356
3644
  }
3357
3645
  if (!existsSync7(repoDir)) {
3358
3646
  const reason = `linked directory "${repoDir}" does not exist`;
@@ -3526,6 +3814,16 @@ ${divider}`);
3526
3814
  await api.post(`/api/tasks/${task.id}/updates`, {
3527
3815
  message,
3528
3816
  imageUrl: uploadData.url,
3817
+ media: [
3818
+ {
3819
+ kind: "screenshot",
3820
+ url: uploadData.url,
3821
+ mimeType: "image/png",
3822
+ label: "Test screenshot",
3823
+ captureContext: "watch-completion",
3824
+ uploadState: "uploaded"
3825
+ }
3826
+ ],
3529
3827
  source: "system"
3530
3828
  });
3531
3829
  return uploadData.url;
@@ -3533,10 +3831,34 @@ ${divider}`);
3533
3831
  return null;
3534
3832
  }
3535
3833
  },
3536
- postUpdate: async (message, imageUrl) => {
3537
- await postTaskUpdate(task.id, message);
3834
+ uploadVideo: async (videoPath) => {
3835
+ try {
3836
+ const { readFileSync: readFileSync13 } = await import("fs");
3837
+ const cfg = loadConfig();
3838
+ const videoBuffer = readFileSync13(videoPath);
3839
+ const fileName = videoPath.split("/").pop() || "proof-recording.webm";
3840
+ const isMp4 = fileName.endsWith(".mp4");
3841
+ const formData = new FormData();
3842
+ const blob = new Blob([videoBuffer], { type: isMp4 ? "video/mp4" : "video/webm" });
3843
+ formData.append("file", blob, fileName);
3844
+ formData.append("prefix", "test-recordings");
3845
+ const uploadRes = await fetch(`${cfg.apiUrl}/api/upload`, {
3846
+ method: "POST",
3847
+ headers: { Authorization: `Bearer ${cfg.apiKey}` },
3848
+ body: formData
3849
+ });
3850
+ if (!uploadRes.ok) return null;
3851
+ const uploadData = await uploadRes.json();
3852
+ return uploadData.url;
3853
+ } catch {
3854
+ return null;
3855
+ }
3856
+ },
3857
+ postUpdate: async (payload) => {
3858
+ await postTaskUpdate(task.id, payload);
3538
3859
  },
3539
- onProgress: (msg) => logInfo(prefix, msg)
3860
+ onProgress: (msg) => logInfo(prefix, msg),
3861
+ recordingContext: "watch-completion"
3540
3862
  });
3541
3863
  await api.patch(`/api/tasks/${task.id}`, { status: "completed", testResult: result.status });
3542
3864
  logSuccess(prefix, result.summary);
@@ -4522,8 +4844,8 @@ var resumeCommand = new Command17("resume").description("Resume an interactive C
4522
4844
 
4523
4845
  // cli/commands/browse.ts
4524
4846
  import { Command as Command18 } from "commander";
4525
- import { execSync as execSync5, spawn as spawn6 } from "child_process";
4526
- import { existsSync as existsSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
4847
+ import { execSync as execSync4, spawn as spawn6 } from "child_process";
4848
+ import { existsSync as existsSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
4527
4849
  import { join as join8 } from "path";
4528
4850
  var BROWSE_DIR2 = join8(import.meta.dirname, "..", "..", "browse");
4529
4851
  function isProcessAlive(pid) {
@@ -4570,7 +4892,7 @@ async function ensureDevServer() {
4570
4892
  env: { ...process.env }
4571
4893
  });
4572
4894
  devProc.unref();
4573
- writeFileSync5(
4895
+ writeFileSync4(
4574
4896
  devStateFile,
4575
4897
  JSON.stringify({ pid: devProc.pid, port, startedAt: (/* @__PURE__ */ new Date()).toISOString() }),
4576
4898
  { mode: 384 }
@@ -4598,7 +4920,7 @@ var browseCommand = new Command18("browse").description("Control a headless brow
4598
4920
  if (command === "setup") {
4599
4921
  console.log("[browse] Running browse daemon setup...");
4600
4922
  try {
4601
- execSync5("./setup", { cwd: BROWSE_DIR2, stdio: "inherit" });
4923
+ execSync4("./setup", { cwd: BROWSE_DIR2, stdio: "inherit" });
4602
4924
  } catch {
4603
4925
  console.error("[browse] Setup failed");
4604
4926
  process.exit(1);
@@ -4691,7 +5013,7 @@ var setPathCommand = new Command19("set-path").description("Set or update the lo
4691
5013
  // cli/commands/test.ts
4692
5014
  import { Command as Command20 } from "commander";
4693
5015
  import { readFileSync as readFileSync8, existsSync as existsSync11 } from "fs";
4694
- var testCommand = new Command20("test").description("Run automated browser test for a task's MR/PR").argument("<task-id>", "Task ID to test").option("--plan <file>", "Path to a custom test plan JSON file").action(async (taskId, opts) => {
5016
+ var testCommand = new Command20("test").description("Run automated browser test for a task's MR/PR").argument("<task-id>", "Task ID to test").option("--plan <file>", "Path to a custom test plan JSON file").option("--no-recording", "Disable proof recording for this run").action(async (taskId, opts) => {
4695
5017
  const config = loadConfig();
4696
5018
  console.log("[test] Fetching task...");
4697
5019
  let task;
@@ -4739,7 +5061,7 @@ var testCommand = new Command20("test").description("Run automated browser test
4739
5061
  }
4740
5062
  }
4741
5063
  try {
4742
- await api.patch(`/api/tasks/${taskId}`, { testStatus: "running" });
5064
+ await api.patch(`/api/tasks/${taskId}`, { status: "delegated", testResult: null });
4743
5065
  } catch {
4744
5066
  }
4745
5067
  console.log(`[test] Starting test for "${task.title}"...`);
@@ -4769,6 +5091,16 @@ var testCommand = new Command20("test").description("Run automated browser test
4769
5091
  await api.post(`/api/tasks/${taskId}/updates`, {
4770
5092
  message,
4771
5093
  imageUrl: uploadData.url,
5094
+ media: [
5095
+ {
5096
+ kind: "screenshot",
5097
+ url: uploadData.url,
5098
+ mimeType: "image/png",
5099
+ label: "Test screenshot",
5100
+ captureContext: "test-run",
5101
+ uploadState: "uploaded"
5102
+ }
5103
+ ],
4772
5104
  source: "system"
4773
5105
  });
4774
5106
  return uploadData.url;
@@ -4776,20 +5108,52 @@ var testCommand = new Command20("test").description("Run automated browser test
4776
5108
  return null;
4777
5109
  }
4778
5110
  },
4779
- postUpdate: async (message, imageUrl) => {
5111
+ uploadVideo: async (videoPath) => {
5112
+ try {
5113
+ const videoBuffer = readFileSync8(videoPath);
5114
+ const fileName = videoPath.split("/").pop() || "proof-recording.webm";
5115
+ const isMp4 = fileName.endsWith(".mp4");
5116
+ const formData = new FormData();
5117
+ const blob = new Blob([videoBuffer], { type: isMp4 ? "video/mp4" : "video/webm" });
5118
+ formData.append("file", blob, fileName);
5119
+ formData.append("prefix", "test-recordings");
5120
+ const uploadRes = await fetch(`${config.apiUrl}/api/upload`, {
5121
+ method: "POST",
5122
+ headers: { Authorization: `Bearer ${config.apiKey}` },
5123
+ body: formData
5124
+ });
5125
+ if (!uploadRes.ok) return null;
5126
+ const uploadData = await uploadRes.json();
5127
+ return uploadData.url;
5128
+ } catch {
5129
+ return null;
5130
+ }
5131
+ },
5132
+ postUpdate: async ({
5133
+ message,
5134
+ imageUrl,
5135
+ media,
5136
+ proofState,
5137
+ proofDetails
5138
+ }) => {
4780
5139
  try {
4781
5140
  await api.post(`/api/tasks/${taskId}/updates`, {
4782
5141
  message,
4783
5142
  imageUrl,
5143
+ media,
5144
+ proofState,
5145
+ proofDetails,
4784
5146
  source: "system"
4785
5147
  });
4786
5148
  } catch {
4787
5149
  }
4788
5150
  },
4789
- onProgress: (msg) => console.log(`[test] ${msg}`)
5151
+ onProgress: (msg) => console.log(`[test] ${msg}`),
5152
+ recordingEnabled: opts.recording !== false,
5153
+ recordingContext: "test-run"
4790
5154
  });
4791
5155
  try {
4792
- await api.patch(`/api/tasks/${taskId}`, { testStatus: result.status });
5156
+ await api.patch(`/api/tasks/${taskId}`, { status: "completed", testResult: result.status });
4793
5157
  } catch {
4794
5158
  }
4795
5159
  console.log(`
@@ -4812,7 +5176,7 @@ var testCommand = new Command20("test").description("Run automated browser test
4812
5176
 
4813
5177
  // cli/commands/features.ts
4814
5178
  import { Command as Command21 } from "commander";
4815
- import { readFileSync as readFileSync9, writeFileSync as writeFileSync6, existsSync as existsSync12 } from "fs";
5179
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync5, existsSync as existsSync12 } from "fs";
4816
5180
  import { resolve as resolve5, sep as sep2 } from "path";
4817
5181
  var FEATURES_FILE3 = ".mr-features.md";
4818
5182
  var c7 = {
@@ -4853,13 +5217,13 @@ var featuresCommand = new Command21("features").description("View or update the
4853
5217
  if (opts.file) {
4854
5218
  const content2 = readFileSync9(resolve5(opts.file), "utf-8");
4855
5219
  const featuresPath = getFeaturesPath();
4856
- writeFileSync6(featuresPath, content2);
5220
+ writeFileSync5(featuresPath, content2);
4857
5221
  console.log(`${paint7("green", "\u2713")} Updated ${paint7("cyan", featuresPath)} from ${paint7("cyan", opts.file)}`);
4858
5222
  return;
4859
5223
  }
4860
5224
  if (opts.update) {
4861
5225
  const featuresPath = getFeaturesPath();
4862
- writeFileSync6(featuresPath, opts.update);
5226
+ writeFileSync5(featuresPath, opts.update);
4863
5227
  console.log(`${paint7("green", "\u2713")} Updated ${paint7("cyan", featuresPath)}`);
4864
5228
  return;
4865
5229
  }
@@ -4874,12 +5238,12 @@ var featuresCommand = new Command21("features").description("View or update the
4874
5238
 
4875
5239
  // cli/commands/no-mr.ts
4876
5240
  import { Command as Command22 } from "commander";
4877
- import { writeFileSync as writeFileSync7 } from "fs";
5241
+ import { writeFileSync as writeFileSync6 } from "fs";
4878
5242
  import { resolve as resolve6 } from "path";
4879
5243
  var NO_MR_FILE = ".mr-no-mr";
4880
5244
  var noMrCommand = new Command22("no-mr").description("Signal that a task does not require a merge/pull request and describe what was done instead").argument("<task-id>", "Task ID").argument("<description>", "Description of what was done instead of creating an MR/PR").action(async (taskId, description) => {
4881
5245
  const filePath = resolve6(process.cwd(), NO_MR_FILE);
4882
- writeFileSync7(filePath, description, "utf-8");
5246
+ writeFileSync6(filePath, description, "utf-8");
4883
5247
  await api.post(`/api/tasks/${taskId}/updates`, {
4884
5248
  message: `No MR/PR needed \u2014 ${description}`,
4885
5249
  source: "agent"
@@ -4961,7 +5325,7 @@ async function authenticateBrowseSession(magicUrl, runBrowse) {
4961
5325
  // lib/scanner/codebase-analysis.ts
4962
5326
  import { readdirSync as readdirSync2, readFileSync as readFileSync11, existsSync as existsSync14 } from "fs";
4963
5327
  import { join as join10, relative } from "path";
4964
- import { execSync as execSync6 } from "child_process";
5328
+ import { execSync as execSync5 } from "child_process";
4965
5329
  function resolveDir(projectPath, candidates) {
4966
5330
  for (const candidate of candidates) {
4967
5331
  const dir = join10(projectPath, candidate);
@@ -5087,7 +5451,7 @@ function extractInternalLinks(projectPath) {
5087
5451
  }
5088
5452
  function getRecentCommits(projectPath, count = 20) {
5089
5453
  try {
5090
- const output = execSync6(
5454
+ const output = execSync5(
5091
5455
  `git log --oneline -${count} --no-decorate`,
5092
5456
  { cwd: projectPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
5093
5457
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunnewold-labs/mr-manager",
3
- "version": "0.4.12",
3
+ "version": "0.4.16",
4
4
  "description": "Mr. Manager - Task and project management CLI",
5
5
  "bin": {
6
6
  "mr": "./dist/index.mjs"