codeloop-mcp-server 0.1.48 → 0.1.50

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 (58) hide show
  1. package/dist/auth/critical_floors.d.ts +8 -4
  2. package/dist/auth/critical_floors.d.ts.map +1 -1
  3. package/dist/auth/critical_floors.js +17 -17
  4. package/dist/auth/critical_floors.js.map +1 -1
  5. package/dist/auth/init_hint_cache.d.ts +35 -0
  6. package/dist/auth/init_hint_cache.d.ts.map +1 -0
  7. package/dist/auth/init_hint_cache.js +143 -0
  8. package/dist/auth/init_hint_cache.js.map +1 -0
  9. package/dist/evidence/screenshot_diff.d.ts +23 -0
  10. package/dist/evidence/screenshot_diff.d.ts.map +1 -1
  11. package/dist/evidence/screenshot_diff.js +46 -13
  12. package/dist/evidence/screenshot_diff.js.map +1 -1
  13. package/dist/index.js +291 -53
  14. package/dist/index.js.map +1 -1
  15. package/dist/runners/csproj_output_path.d.ts +22 -0
  16. package/dist/runners/csproj_output_path.d.ts.map +1 -0
  17. package/dist/runners/csproj_output_path.js +108 -0
  18. package/dist/runners/csproj_output_path.js.map +1 -0
  19. package/dist/runners/png_dims.d.ts +20 -0
  20. package/dist/runners/png_dims.d.ts.map +1 -0
  21. package/dist/runners/png_dims.js +58 -0
  22. package/dist/runners/png_dims.js.map +1 -0
  23. package/dist/runners/resolve_project_dir.d.ts +67 -0
  24. package/dist/runners/resolve_project_dir.d.ts.map +1 -0
  25. package/dist/runners/resolve_project_dir.js +82 -0
  26. package/dist/runners/resolve_project_dir.js.map +1 -0
  27. package/dist/runners/screenshot.d.ts.map +1 -1
  28. package/dist/runners/screenshot.js +17 -2
  29. package/dist/runners/screenshot.js.map +1 -1
  30. package/dist/runners/uia_resolver.d.ts +70 -0
  31. package/dist/runners/uia_resolver.d.ts.map +1 -0
  32. package/dist/runners/uia_resolver.js +210 -0
  33. package/dist/runners/uia_resolver.js.map +1 -0
  34. package/dist/runners/window_manager.d.ts +45 -4
  35. package/dist/runners/window_manager.d.ts.map +1 -1
  36. package/dist/runners/window_manager.js +254 -26
  37. package/dist/runners/window_manager.js.map +1 -1
  38. package/dist/tools/design_compare.d.ts.map +1 -1
  39. package/dist/tools/design_compare.js +85 -33
  40. package/dist/tools/design_compare.js.map +1 -1
  41. package/dist/tools/desktop_app_mode.d.ts +48 -0
  42. package/dist/tools/desktop_app_mode.d.ts.map +1 -0
  43. package/dist/tools/desktop_app_mode.js +86 -0
  44. package/dist/tools/desktop_app_mode.js.map +1 -0
  45. package/dist/tools/diagnose.d.ts.map +1 -1
  46. package/dist/tools/diagnose.js +32 -1
  47. package/dist/tools/diagnose.js.map +1 -1
  48. package/dist/tools/discover_screens.d.ts.map +1 -1
  49. package/dist/tools/discover_screens.js +94 -2
  50. package/dist/tools/discover_screens.js.map +1 -1
  51. package/dist/tools/self_test.d.ts +40 -0
  52. package/dist/tools/self_test.d.ts.map +1 -0
  53. package/dist/tools/self_test.js +205 -0
  54. package/dist/tools/self_test.js.map +1 -0
  55. package/dist/tools/verify.d.ts.map +1 -1
  56. package/dist/tools/verify.js +4 -5
  57. package/dist/tools/verify.js.map +1 -1
  58. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
5
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
6
6
  function dirHasFile(dir, predicate) {
7
7
  try {
8
8
  if (!existsSync(dir))
@@ -20,11 +20,13 @@ import { loadConfig } from "./config.js";
20
20
  import { validateApiKey, isActivationRequired } from "./auth/api_key.js";
21
21
  import { identifyKeySource, buildRevokedKeyDiagnostic } from "./auth/key_source.js";
22
22
  import { warmCliCache } from "./auth/cli_cache_warmer.js";
23
+ import { recordInitialisedDir, wasInitialisedAtPath, } from "./auth/init_hint_cache.js";
23
24
  import { startUpdateCheck, getUpdateInfo, formatUpdateNotice, getRunningVersion, } from "./auth/update_check.js";
24
25
  import { applyUpdate, applyUpdateInputSchema, } from "./tools/apply_update.js";
25
26
  import { trackUsage } from "./auth/usage_tracker.js";
26
27
  import { isLocalMode } from "./auth/local_mode.js";
27
28
  import { discoverProjectDir } from "./project-discovery.js";
29
+ import { resolveProjectDirPath } from "./runners/resolve_project_dir.js";
28
30
  function readImageAsBase64(path) {
29
31
  if (!existsSync(path))
30
32
  return null;
@@ -60,6 +62,18 @@ function mimeForPath(path) {
60
62
  // when the server's auto-discovered fallback is uninitialized.
61
63
  const discovery = discoverProjectDir();
62
64
  const projectDir = discovery.projectDir;
65
+ // 0.1.50 H4 — single helper that applies the project_dir precedence
66
+ // ladder (explicit > workspace_root > active recording > env > walked_up
67
+ // > default). Used by every capture / interact / record / replay / etc
68
+ // handler so we can't drift back to the Photometry-DB regression where
69
+ // missing project_dir wrote artifacts to the user's HOME folder.
70
+ function resolveCwd(params) {
71
+ return resolveProjectDirPath({
72
+ project_dir: params.project_dir,
73
+ workspace_root: params.workspace_root,
74
+ default_dir: projectDir,
75
+ });
76
+ }
63
77
  if (discovery.source !== "cwd" && discovery.source !== "env") {
64
78
  console.error(`[CodeLoop] Auto-discovered project at: ${projectDir} (via ${discovery.source} search)`);
65
79
  }
@@ -87,6 +101,49 @@ if (!process.env.CODELOOP_PROJECT_DIR &&
87
101
  `or set CODELOOP_PROJECT_DIR in your MCP config so future calls auto-resolve. ` +
88
102
  `codeloop_init_project will REFUSE to scaffold here.`);
89
103
  }
104
+ // 0.1.49 — stale CODELOOP_PROJECT_DIR detection.
105
+ //
106
+ // When init writes a workspace pin into .cursor/mcp.json, it bakes
107
+ // the absolute path of the workspace at the time. If the user later
108
+ // renames or moves the workspace folder (common on Windows when a
109
+ // project graduates from D:\Work\<name> to D:\Repos\<name>), the pin
110
+ // keeps pointing at the old path that no longer exists, and every
111
+ // MCP boot resolves projectDir to a non-existent directory — which
112
+ // silently turns init/verify/gate into no-ops because every "does
113
+ // the .codeloop/ folder exist?" check returns false.
114
+ //
115
+ // We log a single, loud, agent-readable line on stderr so the agent
116
+ // knows to re-run `npx codeloop init` (which rewrites the pin to
117
+ // the workspace's current absolute path — see G8 in the CLI).
118
+ {
119
+ const pinned = process.env.CODELOOP_PROJECT_DIR;
120
+ if (pinned) {
121
+ let stale = false;
122
+ let reason = "";
123
+ try {
124
+ if (!existsSync(pinned)) {
125
+ stale = true;
126
+ reason = "path does not exist";
127
+ }
128
+ else if (!statSync(pinned).isDirectory()) {
129
+ stale = true;
130
+ reason = "path is not a directory";
131
+ }
132
+ else if (!existsSync(join(pinned, ".codeloop", "config.json"))) {
133
+ stale = true;
134
+ reason = "no .codeloop/config.json under the pinned path";
135
+ }
136
+ }
137
+ catch (e) {
138
+ stale = true;
139
+ reason = e.message;
140
+ }
141
+ if (stale) {
142
+ console.error(`[CodeLoop] ⚠ CODELOOP_PROJECT_DIR=${pinned} is stale (${reason}) — falling back to discovery. ` +
143
+ `Re-run \`npx codeloop init\` from the workspace's current location to rewrite the pin.`);
144
+ }
145
+ }
146
+ }
90
147
  const config = loadConfig(projectDir);
91
148
  const apiKey = process.env.CODELOOP_API_KEY || config.api_key;
92
149
  // Pre-warm the npx cache for the `codeloop` CLI in the background so
@@ -310,6 +367,13 @@ function rememberInitializedDir(dir) {
310
367
  return;
311
368
  if (isProjectInitialized(dir)) {
312
369
  lastInitializedDir = dir;
370
+ // 0.1.49 — also persist to ~/.codeloop/init-hint-cache.json so
371
+ // the next MCP server boot (every IDE restart) doesn't false-
372
+ // positive the "project not initialised" hint until the agent
373
+ // has happened to forward `dir` to a handler that calls back
374
+ // into this function. Best-effort; failures swallowed inside
375
+ // recordInitialisedDir.
376
+ recordInitialisedDir(dir);
313
377
  }
314
378
  }
315
379
  function withInitHint(content, dir) {
@@ -336,7 +400,12 @@ function withInitHint(content, dir) {
336
400
  // home folder on Windows / Cursor — see CODELOOP_PROJECT_DIR
337
401
  // auto-injection notes in setup-project.ts).
338
402
  const candidates = [dir, lastInitializedDir, projectDir].filter((d) => typeof d === "string" && d.length > 0);
339
- const anyInitialized = candidates.some((d) => isProjectInitialized(d));
403
+ // 0.1.49 also consult the persistent cache so the very first
404
+ // tool call after an IDE restart doesn't false-positive the hint
405
+ // when `dir` wasn't passed and `lastInitializedDir` is empty (the
406
+ // session's not warmed up yet).
407
+ const anyInitialized = candidates.some((d) => isProjectInitialized(d)) ||
408
+ candidates.some((d) => wasInitialisedAtPath(d));
340
409
  if (!anyInitialized) {
341
410
  head.push({ type: "text", text: INIT_HINT });
342
411
  }
@@ -400,7 +469,7 @@ Returns: structured report with pass/fail counts, artifact paths, and next-step
400
469
  project_dir: z.string().optional().describe("Absolute path to the project root. Defaults to CODELOOP_PROJECT_DIR env var or auto-discovered project directory. MUST be an actual project folder — passing the user's home directory is rejected. If your IDE launches the MCP server from the wrong cwd (common on Windows where Cursor uses C:\\Users\\<name> as cwd), set CODELOOP_PROJECT_DIR or pass this param explicitly."),
401
470
  workspace_root: z.string().optional().describe("[Alias for project_dir] Same semantics; accepted because many agents reach for this conventional name. Pass either `project_dir` OR `workspace_root` — they're equivalent."),
402
471
  }, async (params) => {
403
- const cwd = (params.project_dir || params.workspace_root || projectDir);
472
+ const cwd = resolveCwd(params);
404
473
  const explicitDir = params.project_dir || params.workspace_root;
405
474
  const cfg = explicitDir ? loadConfig(explicitDir) : config;
406
475
  const result = await withAuth(async () => {
@@ -488,11 +557,11 @@ Returns: categorized issues with severity, evidence, root cause, and actionable
488
557
  run_id: params.run_id,
489
558
  focus_files: params.focus_files,
490
559
  };
491
- const cwd = (params.project_dir || params.workspace_root || projectDir);
560
+ const cwd = resolveCwd(params);
492
561
  const output = await runDiagnose(input, config, cwd);
493
562
  await trackUsage(apiKey, "verification_run");
494
563
  return output;
495
- }, { tool: "codeloop_diagnose", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
564
+ }, { tool: "codeloop_diagnose", cwd: resolveCwd(params), input: params });
496
565
  // Auto-fix-loop directive. Diagnose is only useful when it leads
497
566
  // to a fix + re-verify, not when it leads to a long deliberation
498
567
  // over which repair to do first. The repair_tasks array in the
@@ -556,7 +625,7 @@ Returns: pass/fail for each gate, overall confidence score, and recommendation.`
556
625
  spec_path: params.spec_path,
557
626
  acceptance_path: params.acceptance_path,
558
627
  };
559
- const cwd = (params.project_dir || params.workspace_root || projectDir);
628
+ const cwd = resolveCwd(params);
560
629
  const output = await runGateCheck(input, config, cwd);
561
630
  // Persist gate_result and confidence to meta.json
562
631
  try {
@@ -576,7 +645,7 @@ Returns: pass/fail for each gate, overall confidence score, and recommendation.`
576
645
  catch { /* best-effort persistence */ }
577
646
  await trackUsage(apiKey, "verification_run");
578
647
  return output;
579
- }, { tool: "codeloop_gate_check", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
648
+ }, { tool: "codeloop_gate_check", cwd: resolveCwd(params), input: params });
580
649
  const resultJson = JSON.stringify(result, null, 2);
581
650
  const gateResult = result;
582
651
  if (gateResult.recommendation === "continue_fixing") {
@@ -634,11 +703,11 @@ Returns: pass/fail for each gate, overall confidence score, and recommendation.`
634
703
  "INCOMPLETE CRUD ARC is NEVER a reason to stop — call codeloop_plan_user_journey, follow the returned per-entity script, re-record, THEN re-gate.",
635
704
  ].join("\n");
636
705
  return {
637
- content: withInitHint([{ type: "text", text: resultJson + loopDirective }], (params.project_dir || params.workspace_root || projectDir)),
706
+ content: withInitHint([{ type: "text", text: resultJson + loopDirective }], resolveCwd(params)),
638
707
  };
639
708
  }
640
709
  return {
641
- content: withInitHint([{ type: "text", text: resultJson }], (params.project_dir || params.workspace_root || projectDir)),
710
+ content: withInitHint([{ type: "text", text: resultJson }], resolveCwd(params)),
642
711
  };
643
712
  });
644
713
  // ── Vision Tools (agent-delegated: returns images for AI agent analysis) ──
@@ -665,11 +734,11 @@ Returns: deterministic diff results + screenshot images for visual analysis.`, {
665
734
  ux_checklist_path: params.ux_checklist_path,
666
735
  viewport_sizes: params.viewport_sizes,
667
736
  };
668
- const cwd = (params.project_dir || params.workspace_root || projectDir);
737
+ const cwd = resolveCwd(params);
669
738
  const result = await runVisualReview(input, config, cwd);
670
739
  await trackUsage(apiKey, "visual_review");
671
740
  return result;
672
- }, { tool: "codeloop_visual_review", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
741
+ }, { tool: "codeloop_visual_review", cwd: resolveCwd(params), input: params });
673
742
  if (typeof authResult === "object" && authResult !== null && "error" in authResult) {
674
743
  return { content: [{ type: "text", text: JSON.stringify(authResult, null, 2) }] };
675
744
  }
@@ -750,11 +819,11 @@ Returns: per-screen pixel diff scores + worst-failing reference, actual, and dif
750
819
  designs_dir: params.designs_dir,
751
820
  run_id: params.run_id,
752
821
  };
753
- const cwd = (params.project_dir || params.workspace_root || projectDir);
822
+ const cwd = resolveCwd(params);
754
823
  const result = await runDesignCompare(input, config, cwd);
755
824
  await trackUsage(apiKey, "visual_review");
756
825
  return result;
757
- }, { tool: "codeloop_design_compare", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
826
+ }, { tool: "codeloop_design_compare", cwd: resolveCwd(params), input: params });
758
827
  if (typeof authResult === "object" && authResult !== null && "error" in authResult) {
759
828
  return { content: [{ type: "text", text: JSON.stringify(authResult, null, 2) }] };
760
829
  }
@@ -1069,7 +1138,7 @@ Returns: extracted key frames as images + expected flow description + app logs f
1069
1138
  }, async (params) => {
1070
1139
  const authResult = await withAuth(async () => {
1071
1140
  const { runInteractionReplay } = await import("./tools/interaction_replay.js");
1072
- const cwd = (params.project_dir || params.workspace_root || projectDir);
1141
+ const cwd = resolveCwd(params);
1073
1142
  const output = await runInteractionReplay({
1074
1143
  video_path: params.video_path,
1075
1144
  run_id: params.run_id,
@@ -1077,7 +1146,7 @@ Returns: extracted key frames as images + expected flow description + app logs f
1077
1146
  }, config, cwd);
1078
1147
  await trackUsage(apiKey, "visual_review");
1079
1148
  return output;
1080
- }, { tool: "codeloop_interaction_replay", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
1149
+ }, { tool: "codeloop_interaction_replay", cwd: resolveCwd(params), input: params });
1081
1150
  if (typeof authResult === "object" && authResult !== null && "error" in authResult) {
1082
1151
  return { content: [{ type: "text", text: JSON.stringify(authResult, null, 2) }] };
1083
1152
  }
@@ -1186,7 +1255,7 @@ Returns: confirmation + the captured image as an MCP ImageContent block so you c
1186
1255
  const authResult = await withAuth(async () => {
1187
1256
  const { captureScreenshot } = await import("./runners/screenshot.js");
1188
1257
  const { createRunDir, getRunDir, getArtifactsBaseDir } = await import("./evidence/artifacts.js");
1189
- const cwd = (params.project_dir || params.workspace_root || projectDir);
1258
+ const cwd = resolveCwd(params);
1190
1259
  let screenshotsDir;
1191
1260
  if (params.run_id) {
1192
1261
  const base = getArtifactsBaseDir(cwd);
@@ -1206,13 +1275,12 @@ Returns: confirmation + the captured image as an MCP ImageContent block so you c
1206
1275
  // agent forgot app_name — and the auto-fix loop would then
1207
1276
  // burn cycles trying to "fix design diffs" against a
1208
1277
  // screenshot of the editor.
1209
- const { detectPlatform } = await import("./tools/verify.js");
1210
1278
  const { loadConfig } = await import("./config.js");
1211
- const platform = detectPlatform(cwd);
1212
- const isDesktopAppProject = platform === "dotnet" || platform === "xcode" || platform === "android";
1279
+ const { isDesktopAppProject } = await import("./tools/desktop_app_mode.js");
1280
+ const desktopApp = isDesktopAppProject(cwd);
1213
1281
  const cfg = loadConfig(cwd);
1214
1282
  const targetApp = params.app_name ?? cfg.evidence?.target_app;
1215
- const result = await captureScreenshot(screenshotsDir, params.screen_name, targetApp, undefined, { desktopAppMode: isDesktopAppProject });
1283
+ const result = await captureScreenshot(screenshotsDir, params.screen_name, targetApp, undefined, { desktopAppMode: desktopApp });
1216
1284
  // Photometry-DB E2E 8 follow-on: when we capture a desktop app
1217
1285
  // window, also resolve its on-screen bounds so the agent can
1218
1286
  // (a) compute window-relative coords from the returned image
@@ -1223,7 +1291,7 @@ Returns: confirmation + the captured image as an MCP ImageContent block so you c
1223
1291
  // of the image and clicked tens or hundreds of pixels off the
1224
1292
  // intended target.
1225
1293
  let windowBounds = null;
1226
- if (isDesktopAppProject && targetApp && result.captured) {
1294
+ if (desktopApp && targetApp && result.captured) {
1227
1295
  try {
1228
1296
  const wm = await import("./runners/window_manager.js");
1229
1297
  const b = await wm.getWindowBounds(targetApp);
@@ -1235,7 +1303,7 @@ Returns: confirmation + the captured image as an MCP ImageContent block so you c
1235
1303
  }
1236
1304
  await trackUsage(apiKey, "visual_review");
1237
1305
  return { ...result, windowBounds };
1238
- }, { tool: "codeloop_capture_screenshot", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
1306
+ }, { tool: "codeloop_capture_screenshot", cwd: resolveCwd(params), input: params });
1239
1307
  if (typeof authResult === "object" && authResult !== null && "error" in authResult) {
1240
1308
  return { content: [{ type: "text", text: JSON.stringify(authResult, null, 2) }] };
1241
1309
  }
@@ -1281,8 +1349,8 @@ Returns: list of discovered screens with routes, navigation triggers, confidence
1281
1349
  }, async (params) => {
1282
1350
  const result = await withAuth(async () => {
1283
1351
  const { discoverScreens } = await import("./tools/discover_screens.js");
1284
- return discoverScreens((params.project_dir || params.workspace_root || projectDir), params.platform);
1285
- }, { tool: "codeloop_discover_screens", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
1352
+ return discoverScreens(resolveCwd(params), params.platform);
1353
+ }, { tool: "codeloop_discover_screens", cwd: resolveCwd(params), input: params });
1286
1354
  return {
1287
1355
  content: withInitHint([{ type: "text", text: JSON.stringify(result, null, 2) }]),
1288
1356
  };
@@ -1316,8 +1384,8 @@ selects, datagrids, upload_areas, ai_features, forms }, ai_features_detected, sc
1316
1384
  }, async (params) => {
1317
1385
  const result = await withAuth(async () => {
1318
1386
  const { discoverInteractions } = await import("./tools/discover_interactions.js");
1319
- return discoverInteractions((params.project_dir || params.workspace_root || projectDir), params.platform);
1320
- }, { tool: "codeloop_discover_interactions", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
1387
+ return discoverInteractions(resolveCwd(params), params.platform);
1388
+ }, { tool: "codeloop_discover_interactions", cwd: resolveCwd(params), input: params });
1321
1389
  return {
1322
1390
  content: withInitHint([{ type: "text", text: JSON.stringify(result, null, 2) }]),
1323
1391
  };
@@ -1359,8 +1427,8 @@ ai_substantive_prompts, upload_actions, datagrid_edits }, advice, discovered_int
1359
1427
  }, async (params) => {
1360
1428
  const result = await withAuth(async () => {
1361
1429
  const { planUserJourney } = await import("./tools/plan_user_journey.js");
1362
- return planUserJourney((params.project_dir || params.workspace_root || projectDir), params.platform, params.top_n);
1363
- }, { tool: "codeloop_plan_user_journey", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
1430
+ return planUserJourney(resolveCwd(params), params.platform, params.top_n);
1431
+ }, { tool: "codeloop_plan_user_journey", cwd: resolveCwd(params), input: params });
1364
1432
  // Auto-fix loop directive. The plan is ONLY useful if the agent
1365
1433
  // now drives it via a recording session — otherwise it's a
1366
1434
  // detailed document that gets read and then deliberated over.
@@ -1402,7 +1470,7 @@ After recording, call codeloop_interaction_replay to extract frames and analyze
1402
1470
  const authResult = await withAuth(async () => {
1403
1471
  const { recordVideo } = await import("./runners/video_recorder.js");
1404
1472
  const { createRunDir, getRunDir, getArtifactsBaseDir } = await import("./evidence/artifacts.js");
1405
- const cwd = (params.project_dir || params.workspace_root || projectDir);
1473
+ const cwd = resolveCwd(params);
1406
1474
  let videosDir;
1407
1475
  if (params.run_id) {
1408
1476
  const base = getArtifactsBaseDir(cwd);
@@ -1415,7 +1483,7 @@ After recording, call codeloop_interaction_replay to extract frames and analyze
1415
1483
  const result = await recordVideo(videosDir, params.duration_seconds, params.app_name);
1416
1484
  await trackUsage(apiKey, "visual_review");
1417
1485
  return result;
1418
- }, { tool: "codeloop_record_interaction", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
1486
+ }, { tool: "codeloop_record_interaction", cwd: resolveCwd(params), input: params });
1419
1487
  if (typeof authResult === "object" && authResult !== null && "error" in authResult) {
1420
1488
  return { content: [{ type: "text", text: JSON.stringify(authResult, null, 2) }] };
1421
1489
  }
@@ -1444,7 +1512,7 @@ init for .NET/Xcode/Android projects via detect-target-app).`, {
1444
1512
  const authResult = await withAuth(async () => {
1445
1513
  const wm = await import("./runners/window_manager.js");
1446
1514
  const { loadConfig } = await import("./config.js");
1447
- const cwd = (params.project_dir || params.workspace_root || projectDir);
1515
+ const cwd = resolveCwd(params);
1448
1516
  const cfg = loadConfig(cwd);
1449
1517
  const appName = params.app_name || cfg.evidence?.target_app;
1450
1518
  if (!appName) {
@@ -1455,7 +1523,7 @@ init for .NET/Xcode/Android projects via detect-target-app).`, {
1455
1523
  }
1456
1524
  const r = await wm.launchDesktopApp(appName, cwd);
1457
1525
  return { app_name: appName, ...r };
1458
- }, { tool: "codeloop_launch_app", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
1526
+ }, { tool: "codeloop_launch_app", cwd: resolveCwd(params), input: params });
1459
1527
  if (typeof authResult === "object" && authResult !== null && "error" in authResult) {
1460
1528
  return { content: [{ type: "text", text: JSON.stringify(authResult, null, 2) }] };
1461
1529
  }
@@ -1503,7 +1571,7 @@ App logs (stdout, logcat, simctl log) are automatically captured alongside the v
1503
1571
  const { createRunDir, getRunDir, getArtifactsBaseDir } = await import("./evidence/artifacts.js");
1504
1572
  const { detectTargetType } = await import("./runners/platform_detect.js");
1505
1573
  const { loadConfig } = await import("./config.js");
1506
- const cwd = (params.project_dir || params.workspace_root || projectDir);
1574
+ const cwd = resolveCwd(params);
1507
1575
  let videosDir;
1508
1576
  if (params.run_id) {
1509
1577
  const base = getArtifactsBaseDir(cwd);
@@ -1565,7 +1633,7 @@ App logs (stdout, logcat, simctl log) are automatically captured alongside the v
1565
1633
  }
1566
1634
  await trackUsage(apiKey, "visual_review");
1567
1635
  return result;
1568
- }, { tool: "codeloop_start_recording", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
1636
+ }, { tool: "codeloop_start_recording", cwd: resolveCwd(params), input: params });
1569
1637
  if (typeof authResult === "object" && authResult !== null && "error" in authResult) {
1570
1638
  return { content: [{ type: "text", text: JSON.stringify(authResult, null, 2) }] };
1571
1639
  }
@@ -1648,7 +1716,7 @@ The agent MUST then write the report to docs/DEVELOPMENT_LOG.md and present it t
1648
1716
  const result = await withAuth(async () => {
1649
1717
  const { listRuns, loadRunMeta, getArtifactsBaseDir, getRunDir } = await import("./evidence/artifacts.js");
1650
1718
  const { readdirSync, existsSync } = await import("fs");
1651
- const cwd = (params.project_dir || params.workspace_root || projectDir);
1719
+ const cwd = resolveCwd(params);
1652
1720
  const baseDir = getArtifactsBaseDir(cwd);
1653
1721
  const runs = listRuns(baseDir);
1654
1722
  const runSummaries = [];
@@ -1793,7 +1861,7 @@ The agent MUST then write the report to docs/DEVELOPMENT_LOG.md and present it t
1793
1861
  };
1794
1862
  await trackUsage(apiKey, "verification_run");
1795
1863
  return report;
1796
- }, { tool: "codeloop_generate_dev_report", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
1864
+ }, { tool: "codeloop_generate_dev_report", cwd: resolveCwd(params), input: params });
1797
1865
  if (typeof result === "object" && result !== null && "error" in result) {
1798
1866
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1799
1867
  }
@@ -1915,7 +1983,7 @@ Returns: checklist of completed and pending verification steps.`, {
1915
1983
  const { listRuns, loadRunMeta, getArtifactsBaseDir, getRunDir } = await import("./evidence/artifacts.js");
1916
1984
  const { detectPlatform } = await import("./tools/verify.js");
1917
1985
  const { detectDesktopUI } = await import("./tools/desktop_detection.js");
1918
- const cwd = (params.project_dir || params.workspace_root || projectDir);
1986
+ const cwd = resolveCwd(params);
1919
1987
  const platform = detectPlatform(cwd);
1920
1988
  // UI detection includes desktop .NET / native: WPF, WinForms, MAUI,
1921
1989
  // Avalonia, WinUI, UWP. Without this, every WPF/.NET 8 / MAUI / Avalonia
@@ -2012,10 +2080,25 @@ Returns: checklist of completed and pending verification steps.`, {
2012
2080
  const verdict = evaluateDepth(coverage, minimums, discoverySnapshot);
2013
2081
  const b = coverage.buckets;
2014
2082
  const breakdown = `click=${b.click}, navigation=${b.navigation}, input=${b.input}, commit=${b.commit}, toggle=${b.toggle}, gesture=${b.gesture}, upload=${b.upload}, keystroke=${b.keystroke}, inspect=${b.inspect}`;
2083
+ // 0.1.49: coordinate_clicks_without_intent is now a HARD
2084
+ // step-7 PENDING blocker so the agent sees the gap BEFORE
2085
+ // gate_check, not after. Pre-0.1.49 this only surfaced as
2086
+ // a verify postscript note, which agents commonly ignored
2087
+ // until the user_journey_evidence gate failed at the
2088
+ // bottom of a long verify→capture→video→gate cycle —
2089
+ // wasting the entire UI-evidence loop.
2090
+ const coordsWithoutIntent = coverage.coordinate_clicks_without_intent;
2015
2091
  if (!minimums.enabled) {
2016
2092
  depthStatus = "n/a";
2017
2093
  depthDetail = `Depth gate disabled in .codeloop/config.json. Observed buckets: ${breakdown}.`;
2018
2094
  }
2095
+ else if (coordsWithoutIntent > 0) {
2096
+ depthStatus = "PENDING";
2097
+ depthDetail =
2098
+ `${coverage.successful} successful interactions across ${runs.length} run(s) (${breakdown}). ` +
2099
+ `BLOCKER: ${coordsWithoutIntent} coordinate-only click(s) dispatched without intent / description / purpose / step fields — the CRUD classifier in user_journey_evidence cannot credit them as edit/delete/create. ` +
2100
+ `Re-run codeloop_interact for those clicks WITH \`intent\` (e.g. intent="confirm delete dialog", intent="save form"). Otherwise gate_check will return continue_fixing on user_journey_evidence even after the rest of the workflow is green.`;
2101
+ }
2019
2102
  else if (verdict.passed) {
2020
2103
  depthStatus = "done";
2021
2104
  depthDetail = `${coverage.successful} successful interactions across ${runs.length} run(s) (${breakdown}). Depth minimums met.`;
@@ -2104,7 +2187,7 @@ Returns: checklist of completed and pending verification steps.`, {
2104
2187
  ? "All CodeLoop verification steps are complete. You may proceed."
2105
2188
  : `WARNING: ${pendingSteps.length} step(s) still pending. DO NOT declare this task complete. DO NOT ask the user what to do next. Complete the pending steps below, then call codeloop_gate_check. If gate returns continue_fixing, loop back and fix without asking.\n${pendingSteps.map(s => ` - ${s.step}: ${s.detail}`).join("\n")}`,
2106
2189
  };
2107
- }, { tool: "codeloop_check_workflow", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
2190
+ }, { tool: "codeloop_check_workflow", cwd: resolveCwd(params), input: params });
2108
2191
  return {
2109
2192
  content: withInitHint([{ type: "text", text: JSON.stringify(result, null, 2) }]),
2110
2193
  };
@@ -2144,11 +2227,13 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2144
2227
  y: z.number().optional().describe("Y coordinate for click/scroll/drag/swipe"),
2145
2228
  x2: z.number().optional().describe("End X for drag_drop/swipe"),
2146
2229
  y2: z.number().optional().describe("End Y for drag_drop/swipe"),
2147
- text: z.string().optional().describe("Text for type/type_and_submit/type_and_tab/fill"),
2230
+ text: z.string().optional().describe("Text for type/type_and_submit/type_and_tab/fill. 0.1.50+: ALSO accepted on click/double_click/right_click/hover with no x/y on Windows desktop targets — walks the UIA tree to find the first element whose Name property matches (exact, then substring) and clicks its centre. Closes the Photometry-DB E2E 8 regression where `{ action: \"click\", text: \"Luminaire Photometric Data\" }` produced `click at (undefined, undefined)`."),
2148
2231
  key: z.string().optional().describe("Key name for keystroke: enter, tab, escape, backspace, delete, etc."),
2149
2232
  keys: z.string().optional().describe("Key combo for hotkey: cmd+s, ctrl+enter, cmd+shift+z, etc."),
2150
2233
  selector: z.string().optional().describe("CSS selector (browser) or automation ID (Windows)"),
2151
2234
  selector2: z.string().optional().describe("Second selector for drag target"),
2235
+ automation_id: z.string().optional().describe("[Windows desktop] UIA AutomationId of the target element. 0.1.50+: when supplied for click/double_click/right_click/hover with no x/y, CodeLoop walks the UIA tree and resolves the element's screen coords automatically (DPI-aware, window-origin-aware), then clicks at the centre. Most stable selector for WPF/WinUI/UWP — prefer this over `text` whenever the control exposes one."),
2236
+ role: z.string().optional().describe("[Windows desktop] UIA ControlType programmatic name (e.g. `ControlType.Button`, `ControlType.TabItem`). 0.1.50+: when supplied for click/double_click/right_click/hover with no x/y, walks the UIA tree and clicks the FIRST element of that ControlType. Use as a last resort when neither AutomationId nor Name is specific enough."),
2152
2237
  url: z.string().optional().describe("URL for navigate_url or deep_link"),
2153
2238
  direction: z.enum(["up", "down", "left", "right"]).optional().describe("Scroll/swipe direction"),
2154
2239
  amount: z.number().optional().describe("Scroll amount or other numeric value"),
@@ -2180,7 +2265,8 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2180
2265
  description: z.string().optional().describe("[Alias for intent] Same semantics."),
2181
2266
  purpose: z.string().optional().describe("[Alias for intent] Same semantics."),
2182
2267
  step: z.string().optional().describe("Plan-step name when this interaction is driving a codeloop_plan_user_journey arc (e.g. 'edit', 'delete', 'create', 'save', 'verify'). Logged alongside `intent` and read by the CRUD classifier."),
2183
- coords: z.enum(["auto", "window", "screen"]).optional().describe("How to interpret x/y for desktop click/double_click/right_click/hover/scroll/drag/long_press. `auto` (default): if `app_name` resolves to a visible window AND (x, y) fits inside the window's client area, treat as window-relative and auto-offset by the window origin; otherwise leave as raw screen-absolute coords. `window`: ALWAYS add the window origin offset (errors if the window isn't found). `screen`: ALWAYS pass through (legacy behaviour, matches CGEvent / user32.dll / xdotool semantics). Fixes the Photometry-DB E2E 8 failure mode where the agent captured a 1600×900 window screenshot, computed click coords against the image, and missed the sidebar because the window's actual top-left was (286, 286) on a 5120×1440 screen."),
2268
+ coords: z.enum(["auto", "window", "screen", "screenshot"]).optional().describe("How to interpret x/y for desktop click/double_click/right_click/hover/scroll/drag/long_press. `auto` (default): if `app_name` resolves to a visible window AND (x, y) fits inside the window's client area, treat as window-relative and auto-offset by the window origin; otherwise leave as raw screen-absolute coords. `window`: ALWAYS add the window origin offset (errors if the window isn't found). `screen`: ALWAYS pass through (legacy behaviour, matches CGEvent / user32.dll / xdotool semantics). `screenshot` (most accurate for vision-driven agents): treat (x, y) as coordinates against a captured screenshot — provide `screenshot_path` so the runner can read the image's actual width/height, scale (x, y) to the window's true pixel dimensions, then add the window origin and apply DPI. Use this whenever you computed coords from the image returned by codeloop_capture_screenshot, especially when the MCP transport may have downscaled the PNG. Fixes the Photometry-DB E2E 8 failure mode where the agent captured a 1600×900 window screenshot, computed click coords against the image, and missed the sidebar because the window's actual top-left was (286, 286) on a 5120×1440 screen."),
2269
+ screenshot_path: z.string().optional().describe("Absolute path to the screenshot PNG that x/y were computed against. Used with `coords: \"screenshot\"` to scale agent-supplied coords from the captured image dimensions to the window's actual pixel dimensions before applying the window origin and DPI factor. Pass the `path` field returned by codeloop_capture_screenshot."),
2184
2270
  project_dir: z.string().optional().describe("Absolute path to project root."),
2185
2271
  workspace_root: z.string().optional().describe("[Alias for project_dir] Pass either; they're equivalent."),
2186
2272
  }, async (params) => {
@@ -2193,7 +2279,7 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2193
2279
  const bi = await import("./runners/browser_interaction.js");
2194
2280
  const vr = await import("./runners/video_recorder.js");
2195
2281
  // Auto-detect target_type when omitted
2196
- const cwd = (params.project_dir || params.workspace_root || projectDir);
2282
+ const cwd = resolveCwd(params);
2197
2283
  let tt = params.target_type;
2198
2284
  if (!tt) {
2199
2285
  const recordingTarget = vr.getActiveRecordingTargetType();
@@ -2211,6 +2297,7 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2211
2297
  }
2212
2298
  // Bring the app to front before desktop interactions (non-browser, non-mobile).
2213
2299
  let windowOriginOffset = null;
2300
+ let screenshotDims = null;
2214
2301
  if (tt === "desktop") {
2215
2302
  const appName = params.app_name || vr.getActiveRecordingAppName();
2216
2303
  if (appName && action !== "wait") {
@@ -2233,14 +2320,68 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2233
2320
  try {
2234
2321
  const b = await wm.getWindowBounds(appName);
2235
2322
  if (b && b.width > 0 && b.height > 0) {
2236
- windowOriginOffset = { dx: b.x, dy: b.y, width: b.width, height: b.height };
2323
+ windowOriginOffset = {
2324
+ dx: b.x,
2325
+ dy: b.y,
2326
+ width: b.width,
2327
+ height: b.height,
2328
+ dpiX: b.dpi_x,
2329
+ dpiY: b.dpi_y,
2330
+ };
2237
2331
  }
2238
2332
  }
2239
2333
  catch { /* best-effort */ }
2240
2334
  }
2335
+ // For coords:"screenshot", load the actual PNG dims so we
2336
+ // can scale agent-supplied (x, y) up from the (possibly
2337
+ // MCP-downscaled) image to the window's true pixel size.
2338
+ if (coordsMode === "screenshot" && params.screenshot_path) {
2339
+ try {
2340
+ const { readPngDims } = await import("./runners/png_dims.js");
2341
+ screenshotDims = readPngDims(params.screenshot_path);
2342
+ }
2343
+ catch { /* best-effort */ }
2344
+ }
2241
2345
  }
2242
2346
  }
2347
+ // 0.1.50 H1 — when an agent passes `text` / `role` /
2348
+ // `automation_id` (no x/y) to a desktop click-family action,
2349
+ // walk the UIA tree to resolve the centre of the matching
2350
+ // element. The resolved (x, y) is screen-absolute so it
2351
+ // bypasses translateXY (which is for agent-supplied coords).
2352
+ const resolveDesktopSelector = async () => {
2353
+ if (tt !== "desktop" || process.platform !== "win32")
2354
+ return null;
2355
+ if (params.x != null && params.y != null)
2356
+ return null;
2357
+ const appName = params.app_name || vr.getActiveRecordingAppName();
2358
+ if (!appName)
2359
+ return null;
2360
+ const hasSelector = (params.automation_id && params.automation_id.length > 0) ||
2361
+ (params.text && params.text.length > 0) ||
2362
+ (params.role && params.role.length > 0);
2363
+ if (!hasSelector)
2364
+ return null;
2365
+ try {
2366
+ const { resolveSelectorToXY } = await import("./runners/uia_resolver.js");
2367
+ const r = await resolveSelectorToXY({
2368
+ appName,
2369
+ automationId: params.automation_id,
2370
+ text: params.text,
2371
+ role: params.role,
2372
+ });
2373
+ if (r.found && r.x != null && r.y != null) {
2374
+ return { x: r.x, y: r.y, foundBy: r.foundBy ?? "unknown" };
2375
+ }
2376
+ }
2377
+ catch { /* best-effort */ }
2378
+ return null;
2379
+ };
2243
2380
  // Helper used by every coordinate-driven desktop action below.
2381
+ // Photometry-DB E2E 8 + 0.1.49 hardening: handles four modes
2382
+ // (auto / window / screen / screenshot) plus an optional DPI
2383
+ // factor on the window bounds so high-DPI Windows / Retina
2384
+ // displays don't drop clicks 100s of pixels off-target.
2244
2385
  const translateXY = (x, y) => {
2245
2386
  if (tt !== "desktop" || x == null || y == null || !windowOriginOffset) {
2246
2387
  return { x, y };
@@ -2248,8 +2389,35 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2248
2389
  const mode = params.coords ?? "auto";
2249
2390
  if (mode === "screen")
2250
2391
  return { x, y };
2392
+ const applyDpi = (px, py) => {
2393
+ // window_manager records DPI in physical-pixel-per-logical
2394
+ // form (1.0 = 96 DPI baseline; 2.0 = 200% / Retina). When
2395
+ // the screenshot was captured in logical pixels but the
2396
+ // OS click API expects physical pixels (Win32 user32.dll
2397
+ // and modern macOS CGEvent both expect physical), scale up.
2398
+ const dpiX = windowOriginOffset.dpiX ?? 1;
2399
+ const dpiY = windowOriginOffset.dpiY ?? 1;
2400
+ if (dpiX === 1 && dpiY === 1)
2401
+ return { x: px, y: py };
2402
+ return { x: px * dpiX, y: py * dpiY };
2403
+ };
2404
+ if (mode === "screenshot") {
2405
+ // Scale (x, y) from screenshot dims → window dims,
2406
+ // then add the window origin, then DPI.
2407
+ let sx = x;
2408
+ let sy = y;
2409
+ if (screenshotDims && screenshotDims.width > 0 && screenshotDims.height > 0) {
2410
+ const ratioX = windowOriginOffset.width / screenshotDims.width;
2411
+ const ratioY = windowOriginOffset.height / screenshotDims.height;
2412
+ sx = x * ratioX;
2413
+ sy = y * ratioY;
2414
+ }
2415
+ const dpi = applyDpi(sx, sy);
2416
+ return { x: dpi.x + windowOriginOffset.dx, y: dpi.y + windowOriginOffset.dy };
2417
+ }
2251
2418
  if (mode === "window") {
2252
- return { x: x + windowOriginOffset.dx, y: y + windowOriginOffset.dy };
2419
+ const dpi = applyDpi(x, y);
2420
+ return { x: dpi.x + windowOriginOffset.dx, y: dpi.y + windowOriginOffset.dy };
2253
2421
  }
2254
2422
  // auto: if (x, y) fits inside the window's client area,
2255
2423
  // assume the agent computed against a window-cropped
@@ -2258,7 +2426,8 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2258
2426
  const inside = x >= 0 && x <= windowOriginOffset.width &&
2259
2427
  y >= 0 && y <= windowOriginOffset.height;
2260
2428
  if (inside) {
2261
- return { x: x + windowOriginOffset.dx, y: y + windowOriginOffset.dy };
2429
+ const dpi = applyDpi(x, y);
2430
+ return { x: dpi.x + windowOriginOffset.dx, y: dpi.y + windowOriginOffset.dy };
2262
2431
  }
2263
2432
  return { x, y };
2264
2433
  };
@@ -2286,7 +2455,16 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2286
2455
  const t = translateXY(params.x, params.y);
2287
2456
  success = await wm.clickAtPosition(t.x, t.y);
2288
2457
  }
2289
- detail = `click at ${params.selector || `(${params.x},${params.y})`}`;
2458
+ else {
2459
+ // 0.1.50 H1 — UIA selector fallback for click without coords.
2460
+ const resolved = await resolveDesktopSelector();
2461
+ if (resolved) {
2462
+ success = await wm.clickAtPosition(resolved.x, resolved.y);
2463
+ detail = `click at ${resolved.foundBy}=${params.automation_id || params.text || params.role} → (${resolved.x},${resolved.y})`;
2464
+ break;
2465
+ }
2466
+ }
2467
+ detail = `click at ${params.selector || params.automation_id || params.text || params.role || `(${params.x},${params.y})`}`;
2290
2468
  break;
2291
2469
  case "double_click":
2292
2470
  if (tt === "browser" && params.selector) {
@@ -2296,7 +2474,15 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2296
2474
  const t = translateXY(params.x, params.y);
2297
2475
  success = await wm.doubleClickAtPosition(t.x, t.y);
2298
2476
  }
2299
- detail = `double_click at ${params.selector || `(${params.x},${params.y})`}`;
2477
+ else {
2478
+ const resolved = await resolveDesktopSelector();
2479
+ if (resolved) {
2480
+ success = await wm.doubleClickAtPosition(resolved.x, resolved.y);
2481
+ detail = `double_click at ${resolved.foundBy}=${params.automation_id || params.text || params.role} → (${resolved.x},${resolved.y})`;
2482
+ break;
2483
+ }
2484
+ }
2485
+ detail = `double_click at ${params.selector || params.automation_id || params.text || params.role || `(${params.x},${params.y})`}`;
2300
2486
  break;
2301
2487
  case "right_click":
2302
2488
  if (tt === "browser" && params.selector) {
@@ -2306,7 +2492,15 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2306
2492
  const t = translateXY(params.x, params.y);
2307
2493
  success = await wm.rightClickAtPosition(t.x, t.y);
2308
2494
  }
2309
- detail = `right_click at ${params.selector || `(${params.x},${params.y})`}`;
2495
+ else {
2496
+ const resolved = await resolveDesktopSelector();
2497
+ if (resolved) {
2498
+ success = await wm.rightClickAtPosition(resolved.x, resolved.y);
2499
+ detail = `right_click at ${resolved.foundBy}=${params.automation_id || params.text || params.role} → (${resolved.x},${resolved.y})`;
2500
+ break;
2501
+ }
2502
+ }
2503
+ detail = `right_click at ${params.selector || params.automation_id || params.text || params.role || `(${params.x},${params.y})`}`;
2310
2504
  break;
2311
2505
  case "hover":
2312
2506
  if (tt === "browser" && params.selector) {
@@ -2316,7 +2510,15 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2316
2510
  const t = translateXY(params.x, params.y);
2317
2511
  success = await wm.hoverAtPosition(t.x, t.y);
2318
2512
  }
2319
- detail = `hover at ${params.selector || `(${params.x},${params.y})`}`;
2513
+ else {
2514
+ const resolved = await resolveDesktopSelector();
2515
+ if (resolved) {
2516
+ success = await wm.hoverAtPosition(resolved.x, resolved.y);
2517
+ detail = `hover at ${resolved.foundBy}=${params.automation_id || params.text || params.role} → (${resolved.x},${resolved.y})`;
2518
+ break;
2519
+ }
2520
+ }
2521
+ detail = `hover at ${params.selector || params.automation_id || params.text || params.role || `(${params.x},${params.y})`}`;
2320
2522
  break;
2321
2523
  case "type":
2322
2524
  if (tt === "browser" && params.selector && params.text) {
@@ -2661,7 +2863,7 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2661
2863
  case "maestro_flow":
2662
2864
  if (params.maestro_steps) {
2663
2865
  const mg = await import("./runners/maestro_generator.js");
2664
- const cwd = (params.project_dir || params.workspace_root || projectDir);
2866
+ const cwd = resolveCwd(params);
2665
2867
  const genResult = await mg.generateMaestroFlow(params.maestro_steps, cwd);
2666
2868
  if ("error" in genResult) {
2667
2869
  return { success: false, action, detail: genResult.error };
@@ -2977,7 +3179,7 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2977
3179
  }
2978
3180
  catch { /* best-effort logging */ }
2979
3181
  return { success, action, detail };
2980
- }, { tool: "codeloop_interact", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
3182
+ }, { tool: "codeloop_interact", cwd: resolveCwd(params), input: params });
2981
3183
  return {
2982
3184
  content: withInitHint([{ type: "text", text: JSON.stringify(result, null, 2) }]),
2983
3185
  };
@@ -2999,7 +3201,7 @@ project. After it completes, proceed directly with \`codeloop_verify\`.`, {
2999
3201
  workspace_root: z.string().optional().describe("[Alias for project_dir] Same semantics; accepted because many agents reach for this conventional name. Pass either `project_dir` OR `workspace_root` — they're equivalent."),
3000
3202
  project_type: z.enum(["flutter", "web", "mobile", "xcode", "android", "dotnet", "node", "auto"]).default("auto").describe("Project type. Use 'auto' to detect automatically."),
3001
3203
  }, async (params) => {
3002
- const cwd = (params.project_dir || params.workspace_root || projectDir);
3204
+ const cwd = resolveCwd(params);
3003
3205
  const result = await (async () => {
3004
3206
  const { runInitProject } = await import("./tools/init-project.js");
3005
3207
  const output = await runInitProject({
@@ -3025,7 +3227,7 @@ Returns: counts for attempted / succeeded / requeued events and the queue locati
3025
3227
  project_dir: z.string().optional().describe("Absolute path to the project root. Defaults to CODELOOP_PROJECT_DIR env var or auto-discovered project directory. MUST be an actual project folder — passing the user's home directory is rejected. If your IDE launches the MCP server from the wrong cwd (common on Windows where Cursor uses C:\\Users\\<name> as cwd), set CODELOOP_PROJECT_DIR or pass this param explicitly."),
3026
3228
  workspace_root: z.string().optional().describe("[Alias for project_dir] Same semantics; accepted because many agents reach for this conventional name. Pass either `project_dir` OR `workspace_root` — they're equivalent."),
3027
3229
  }, async (params) => {
3028
- const cwd = (params.project_dir || params.workspace_root || projectDir);
3230
+ const cwd = resolveCwd(params);
3029
3231
  const { flushPersistedUsage } = await import("./auth/usage_tracker.js");
3030
3232
  const result = await flushPersistedUsage(cwd);
3031
3233
  return {
@@ -3113,6 +3315,42 @@ No project_dir / workspace_root required — this tool is workspace-independent.
3113
3315
  ]),
3114
3316
  };
3115
3317
  });
3318
+ server.tool("codeloop_self_test", TOOL_BOOTSTRAP + `Pre-flight smoke test for CodeLoop on the current workspace. Run this on any NEW project BEFORE your first verify cycle, or whenever something looks off (silent IDE captures, "no .exe found", design_compare returning 0%, etc.).
3319
+
3320
+ It validates every critical pre-condition synthetically (no live build, no live screenshot, no network past the platform sniff):
3321
+ - Workspace exists on disk and is a directory
3322
+ - codeloop_init_project has been run (.codeloop/config.json present)
3323
+ - Platform detection produced a known platform
3324
+ - isDesktopAppProject correctly identifies the project type (so captureScreenshot won't silently fall back to fullscreen)
3325
+ - evidence.target_app is set when desktop-app mode is ON (so launchDesktopApp + captureScreenshot can resolve a window)
3326
+ - PNG decoder skip path is wired (corrupt PNGs become skip warnings, not 0% match)
3327
+ - Coordinate translation round-trips on a synthetic high-DPI fixture (so clicks land where the agent expects on Retina / 200%-DPI displays)
3328
+
3329
+ Returns a structured pass/fail report with per-check fix suggestions and a single \`next_step\` directive.
3330
+
3331
+ Use this tool FIRST when:
3332
+ - The user reports CodeLoop "isn't working" or evidence is missing
3333
+ - Switching to a project / repo you've never run CodeLoop against
3334
+ - Debugging unexplained gate failures that don't match the agent's mental model
3335
+
3336
+ Idempotent and free — safe to call as the first step of every new chat.`, {
3337
+ project_dir: z.string().optional().describe("Absolute path to the project root. Defaults to CODELOOP_PROJECT_DIR env var or auto-discovered project directory."),
3338
+ workspace_root: z.string().optional().describe("[Alias for project_dir] Same semantics."),
3339
+ }, async (params) => {
3340
+ const result = await withAuth(async () => {
3341
+ const cwd = resolveCwd(params);
3342
+ const { runSelfTest } = await import("./tools/self_test.js");
3343
+ return runSelfTest(cwd);
3344
+ }, { tool: "codeloop_self_test", cwd: resolveCwd(params), input: params });
3345
+ if (typeof result === "object" && result !== null && "error" in result) {
3346
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3347
+ }
3348
+ return {
3349
+ content: withInitHint([
3350
+ { type: "text", text: JSON.stringify(result, null, 2) },
3351
+ ]),
3352
+ };
3353
+ });
3116
3354
  server.tool("codeloop_apply_update", TOOL_BOOTSTRAP + `Apply a pending CodeLoop MCP server update to the current chat session — without asking the user to restart their IDE.
3117
3355
 
3118
3356
  Use this tool when:
@@ -3138,7 +3376,7 @@ Returns: status, current/latest versions, critical reasons, commands_to_run, aut
3138
3376
  return applyUpdate({ auto_respawn: params.auto_respawn });
3139
3377
  }, {
3140
3378
  tool: "codeloop_apply_update",
3141
- cwd: params.project_dir || params.workspace_root || projectDir,
3379
+ cwd: resolveCwd(params),
3142
3380
  input: params,
3143
3381
  });
3144
3382
  if (typeof authResult === "object" && authResult !== null && "error" in authResult) {