breakpoint-mcp 1.28.0 → 1.72.7

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 (45) hide show
  1. package/README.md +1 -1
  2. package/addon/breakpoint_mcp/operations.gd +248 -13
  3. package/addon/breakpoint_mcp/plugin.cfg +1 -1
  4. package/addon/breakpoint_mcp/runtime_bridge.gd +54 -1
  5. package/dist/annotations.js +3 -3
  6. package/dist/cli/doctor.js +1 -1
  7. package/dist/cli/init.js +1 -1
  8. package/dist/cli/tools.js +2 -2
  9. package/dist/csdap.js +88 -6
  10. package/dist/dap.js +172 -10
  11. package/dist/index.js +4 -4
  12. package/dist/path-cohort.js +177 -0
  13. package/dist/paths.js +111 -0
  14. package/dist/recipes.js +1 -1
  15. package/dist/schemas.js +49 -5
  16. package/dist/tools/assetgen.js +17 -1
  17. package/dist/tools/backend.js +18 -1
  18. package/dist/tools/cli.js +41 -4
  19. package/dist/tools/csdap.js +132 -8
  20. package/dist/tools/cslsp.js +110 -5
  21. package/dist/tools/dap.js +316 -59
  22. package/dist/tools/editor/audio.js +16 -2
  23. package/dist/tools/editor/common.js +39 -1
  24. package/dist/tools/editor/filesystem.js +23 -3
  25. package/dist/tools/editor/introspection.js +72 -1
  26. package/dist/tools/editor/node.js +7 -2
  27. package/dist/tools/editor/particles.js +7 -2
  28. package/dist/tools/editor/project_input_test.js +16 -2
  29. package/dist/tools/editor/resource.js +54 -7
  30. package/dist/tools/editor/scene.js +31 -4
  31. package/dist/tools/editor/shader.js +19 -3
  32. package/dist/tools/editor/spatial.js +16 -1
  33. package/dist/tools/editor/tiles.js +16 -1
  34. package/dist/tools/editor/ui.js +29 -2
  35. package/dist/tools/editor.js +20 -13
  36. package/dist/tools/knowledge.js +14 -1
  37. package/dist/tools/lsp-common.js +29 -0
  38. package/dist/tools/lsp.js +87 -8
  39. package/dist/tools/netcode.js +47 -4
  40. package/dist/tools/processes.js +14 -2
  41. package/dist/tools/runtime.js +49 -9
  42. package/dist/tools/tabletop.js +173 -7
  43. package/dist/tools/vcs.js +138 -22
  44. package/dist/toolsets.js +2 -2
  45. package/package.json +8 -2
@@ -1,6 +1,7 @@
1
+ import fs from "node:fs";
1
2
  import { z } from "zod";
2
3
  import { gate } from "../confirm.js";
3
- import { toFsPath, readFileText } from "../paths.js";
4
+ import { readFileText, resolveExistingFile, resolveInsideProject, resolveWriteTarget } from "../paths.js";
4
5
  /**
5
6
  * Group N — Card / board / piece authoring composites (`card_*`, and later
6
7
  * `board_*` / `piece_*` / `interact_*`).
@@ -550,9 +551,13 @@ export async function emitDeckFromTable(emit, readFile, args) {
550
551
  const base = sceneRootName(args.template_path);
551
552
  const face_up = args.face_up ?? true;
552
553
  const format = args.format ?? (args.table_path.toLowerCase().endsWith(".json") ? "json" : "csv");
554
+ // The reader seam guarantees the file EXISTS and is a regular file inside the
555
+ // project root (see resolveExistingFile), so "" here means one thing only: a
556
+ // real, reachable, zero-byte table. It used to mean four things at once and
557
+ // reported the wrong one for three of them.
553
558
  const text = readFile(args.table_path);
554
559
  if (text === "")
555
- throw new ComposeError("not_found", `Cannot read table ${args.table_path} (does it exist?)`);
560
+ throw new ComposeError("empty_table", `Table ${args.table_path} is empty (0 bytes) — there are no rows to stamp.`);
556
561
  const allRows = readTableRows(text, format);
557
562
  const rows_read = allRows.length;
558
563
  const header = new Set();
@@ -1361,7 +1366,70 @@ export async function emitAddDropZone(emit, args) {
1361
1366
  // ------------------------------------------------------------- registration ----
1362
1367
  export function registerTabletopTools(server, bridge, config) {
1363
1368
  const emit = (method, params) => bridge.request(method, params);
1364
- const readFile = (p) => readFileText(toFsPath(p, config.projectPath));
1369
+ // The reader REFUSES a path that escapes the project root, and separates
1370
+ // "missing" / "a directory" / "the project root" from "an empty file" —
1371
+ // `readFileText` alone answered "" to all four and the caller was told
1372
+ // "does it exist?" for three things that did.
1373
+ const readFile = (p) => readFileText(resolveExistingFile(p, config.projectPath, "table_path"));
1374
+ /**
1375
+ * Guard a caller-supplied res:// scene/script target before anything is
1376
+ * written: refuse a path that escapes the root, refuse an existing target
1377
+ * unless `overwrite`, and — when overwriting — close a stale editor tab.
1378
+ *
1379
+ * That last step is not defensive. The addon's `scene.new` saves a fresh
1380
+ * single-root scene to `path` and then calls
1381
+ * `EditorInterface.open_scene_from_path(path)`. When that scene is ALREADY
1382
+ * OPEN the editor switches to the existing tab, whose in-memory tree is the
1383
+ * OLD one — so every `node.add` that follows lands on top of it and the save
1384
+ * writes both trees. Measured (session 161): a second `board_create` against
1385
+ * the same path took a 5-node scene to 9, reported `saved: true` and a
1386
+ * `node_count` of 5, and never mentioned the four orphans it had just made.
1387
+ */
1388
+ function guardWriteTarget(p, overwrite, label) {
1389
+ resolveWriteTarget(p, config.projectPath, { overwrite, label });
1390
+ }
1391
+ /**
1392
+ * The second half, run only AFTER the confirmation gate has been cleared —
1393
+ * closing a scene tab is a visible side effect and must not happen on a call
1394
+ * the caller then declines.
1395
+ */
1396
+ async function clearStaleTab(p) {
1397
+ // Unconditional, and deliberately NOT gated on "the file exists": the stale
1398
+ // tab is the editor's, not the disk's. Measured — deleting the .tscn out
1399
+ // from under an OPEN editor tab still produced an append, because
1400
+ // open_scene_from_path reuses the tab whatever the disk says. Keying the
1401
+ // close on `exists` would have left that case open; keying it on "is this
1402
+ // scene open" closes the whole class.
1403
+ const open = await emit("scene.list_open", {});
1404
+ const scenes = Array.isArray(open?.scenes) ? open.scenes.map(String) : [];
1405
+ if (!scenes.includes(p))
1406
+ return; // not open: scene.new reopens it fresh from disk
1407
+ if (open?.current !== p) {
1408
+ // Only the CURRENT scene can be closed, so a non-current target has to be
1409
+ // made current first — and `scene.open` needs the file to still be there.
1410
+ // A scene whose file was deleted from under the editor cannot be reached
1411
+ // this way; skip rather than turn a create into "Scene not found". That
1412
+ // residual case needs someone to delete a .tscn behind the editor's back,
1413
+ // which no tool in this surface does.
1414
+ if (!fs.existsSync(resolveInsideProject(p, config.projectPath, "path")))
1415
+ return;
1416
+ await emit("scene.open", { path: p });
1417
+ }
1418
+ try {
1419
+ await emit("scene.close", { path: p });
1420
+ }
1421
+ catch (err) {
1422
+ // EditorInterface.close_scene is Godot 4.4+; the addon answers
1423
+ // "unsupported" below that. Refuse rather than proceed — proceeding is
1424
+ // exactly the silent append this guard exists to stop.
1425
+ if (err?.code === "unsupported") {
1426
+ throw new ComposeError("overwrite_unsupported", `Cannot overwrite ${p}: it is open in the editor, and closing a scene requires Godot 4.4+. ` +
1427
+ `Close that scene tab by hand and retry, or write to a different path. Overwriting an OPEN ` +
1428
+ `scene without closing it appends to the stale tab instead of replacing it.`);
1429
+ }
1430
+ throw err;
1431
+ }
1432
+ }
1365
1433
  const slotSchema = z.object({
1366
1434
  name: z.string().describe("Slot key used by card_instance / card_deck_from_table data (e.g. title, cost, body, art)"),
1367
1435
  kind: z.enum(["label", "rich_text", "texture", "panel", "badge"]).describe("label→Label, rich_text→RichTextLabel, texture→TextureRect, panel→Panel, badge→Label-in-Panel"),
@@ -1411,7 +1479,7 @@ export function registerTabletopTools(server, bridge, config) {
1411
1479
  }).optional(),
1412
1480
  }).optional().describe("Inline theme built via theme_create + theme_set_*"),
1413
1481
  script_path: z.string().optional().describe("Generated card script path (default derives from `path`)"),
1414
- overwrite: z.boolean().optional().describe("Overwrite an existing template at `path` (default false)"),
1482
+ overwrite: z.boolean().optional().describe("Replace an existing template at `path` (default false). With overwrite omitted or false an existing `path` is REFUSED (`exists`) rather than written — before 1.39.0 this flag was declared and never read, and a repeat call APPENDED to the existing scene while reporting success. With overwrite:true the scene is replaced, which requires closing it first if it is open in the editor (Godot 4.4+; below that an open target is refused rather than appended to)."),
1415
1483
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
1416
1484
  },
1417
1485
  }, async (raw) => {
@@ -1420,10 +1488,30 @@ export function registerTabletopTools(server, bridge, config) {
1420
1488
  return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
1421
1489
  if (a.theme_path && a.theme)
1422
1490
  return fail({ code: "bad_params", message: "Pass either theme_path or an inline theme, not both" });
1491
+ try {
1492
+ guardWriteTarget(a.path, a.overwrite, "path");
1493
+ if (a.script_path !== undefined)
1494
+ resolveInsideProject(a.script_path, config.projectPath, "script_path");
1495
+ if (a.theme_path !== undefined)
1496
+ resolveInsideProject(a.theme_path, config.projectPath, "theme_path");
1497
+ // 🔴 NESTED. `back.art` and `theme.font_path` live ONE LEVEL DOWN in the schema,
1498
+ // which is why no enumeration this project ever ran could see them: enum163 walks
1499
+ // TOP-LEVEL `inputSchema.properties` only. Measured — `back.art` was written
1500
+ // VERBATIM into the saved scene, and `theme.font_path` LOADED a font from outside
1501
+ // the root through all three spellings.
1502
+ if (a.back?.art !== undefined)
1503
+ resolveInsideProject(a.back.art, config.projectPath, "back.art");
1504
+ if (a.theme?.font_path !== undefined)
1505
+ resolveInsideProject(a.theme.font_path, config.projectPath, "theme.font_path");
1506
+ }
1507
+ catch (err) {
1508
+ return fail(err);
1509
+ }
1423
1510
  const blocked = await gate(server, a.confirm, `Create card template scene + script at ${a.path}`);
1424
1511
  if (blocked)
1425
1512
  return blocked;
1426
1513
  try {
1514
+ await clearStaleTab(a.path);
1427
1515
  return ok(await emitCardTemplate(emit, a));
1428
1516
  }
1429
1517
  catch (err) {
@@ -1446,6 +1534,12 @@ export function registerTabletopTools(server, bridge, config) {
1446
1534
  },
1447
1535
  }, async (raw) => {
1448
1536
  const a = raw;
1537
+ try {
1538
+ resolveInsideProject(a.template_path, config.projectPath, "template_path");
1539
+ }
1540
+ catch (err) {
1541
+ return fail(err);
1542
+ }
1449
1543
  try {
1450
1544
  return ok(await emitCardInstance(emit, a));
1451
1545
  }
@@ -1471,6 +1565,12 @@ export function registerTabletopTools(server, bridge, config) {
1471
1565
  },
1472
1566
  }, async (raw) => {
1473
1567
  const a = raw;
1568
+ try {
1569
+ resolveInsideProject(a.template_path, config.projectPath, "template_path");
1570
+ }
1571
+ catch (err) {
1572
+ return fail(err);
1573
+ }
1474
1574
  try {
1475
1575
  return ok(await emitCardHand(emit, a));
1476
1576
  }
@@ -1508,6 +1608,15 @@ export function registerTabletopTools(server, bridge, config) {
1508
1608
  },
1509
1609
  }, async (raw) => {
1510
1610
  const a = raw;
1611
+ // 🔴 `table_path` has been guarded since #173 (`resolveExistingFile`, via
1612
+ // `readFile`); `template_path` on the SAME CALL was not. Fourth session running
1613
+ // where the interesting defect is the second parameter on a guarded tool.
1614
+ try {
1615
+ resolveInsideProject(a.template_path, config.projectPath, "template_path");
1616
+ }
1617
+ catch (err) {
1618
+ return fail(err);
1619
+ }
1511
1620
  try {
1512
1621
  return ok(await emitDeckFromTable(emit, readFile, a));
1513
1622
  }
@@ -1579,17 +1688,27 @@ export function registerTabletopTools(server, bridge, config) {
1579
1688
  art: z.string().optional().describe("res:// texture background (Sprite2D under Node2D, TextureRect under Control)"),
1580
1689
  size: z.object({ w: z.number().optional(), h: z.number().optional() }).optional().describe("Background size in px (ColorRect / TextureRect)"),
1581
1690
  }).optional().describe("Optional background drawn behind the cells"),
1582
- overwrite: z.boolean().optional().describe("Overwrite an existing board at `path` (default false)"),
1691
+ overwrite: z.boolean().optional().describe("Replace an existing board at `path` (default false). With overwrite omitted or false an existing `path` is REFUSED (`exists`) rather than written — before 1.39.0 this flag was declared and never read, and a repeat call APPENDED to the existing scene while reporting success. With overwrite:true the scene is replaced, which requires closing it first if it is open in the editor (Godot 4.4+; below that an open target is refused rather than appended to)."),
1583
1692
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
1584
1693
  },
1585
1694
  }, async (raw) => {
1586
1695
  const a = raw;
1587
1696
  if (!a.path.startsWith("res://") || !a.path.endsWith(".tscn"))
1588
1697
  return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
1698
+ try {
1699
+ guardWriteTarget(a.path, a.overwrite, "path");
1700
+ // 🔴 NESTED — `background.art` is stored verbatim in the saved board scene.
1701
+ if (a.background?.art !== undefined)
1702
+ resolveInsideProject(a.background.art, config.projectPath, "background.art");
1703
+ }
1704
+ catch (err) {
1705
+ return fail(err);
1706
+ }
1589
1707
  const blocked = await gate(server, a.confirm, `Create board scene at ${a.path}`);
1590
1708
  if (blocked)
1591
1709
  return blocked;
1592
1710
  try {
1711
+ await clearStaleTab(a.path);
1593
1712
  return ok(await emitBoardCreate(emit, a));
1594
1713
  }
1595
1714
  catch (err) {
@@ -1633,7 +1752,7 @@ export function registerTabletopTools(server, bridge, config) {
1633
1752
  atlas_coords: z.array(z.number().int()).length(2).optional().describe("Tile atlas coordinates [x, y] within the source (default [0, 0])"),
1634
1753
  }).optional().describe("Fill the whole grid with one tile (needs an existing `tileset` that has the source); omitted → cells stay empty (a coordinate frame only)"),
1635
1754
  layer_name: z.string().optional().describe("TileMapLayer node name (default \"Cells\")"),
1636
- overwrite: z.boolean().optional().describe("Overwrite an existing board at `path` (default false)"),
1755
+ overwrite: z.boolean().optional().describe("Replace an existing board at `path` (default false). With overwrite omitted or false an existing `path` is REFUSED (`exists`) rather than written — before 1.39.0 this flag was declared and never read, and a repeat call APPENDED to the existing scene while reporting success. With overwrite:true the scene is replaced, which requires closing it first if it is open in the editor (Godot 4.4+; below that an open target is refused rather than appended to)."),
1637
1756
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
1638
1757
  },
1639
1758
  }, async (raw) => {
@@ -1642,10 +1761,21 @@ export function registerTabletopTools(server, bridge, config) {
1642
1761
  return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
1643
1762
  if (a.tileset !== undefined && !(a.tileset.startsWith("res://") && a.tileset.endsWith(".tres")))
1644
1763
  return fail({ code: "bad_params", message: "'tileset' must be a res:// .tres path" });
1764
+ try {
1765
+ guardWriteTarget(a.path, a.overwrite, "path");
1766
+ // A supplied tileset is READ, not written — but `res://../x.tres` would
1767
+ // reach outside the root just the same, so it is resolved too.
1768
+ if (a.tileset !== undefined)
1769
+ resolveInsideProject(a.tileset, config.projectPath, "tileset");
1770
+ }
1771
+ catch (err) {
1772
+ return fail(err);
1773
+ }
1645
1774
  const blocked = await gate(server, a.confirm, `Create tile-backed board scene at ${a.path}`);
1646
1775
  if (blocked)
1647
1776
  return blocked;
1648
1777
  try {
1778
+ await clearStaleTab(a.path);
1649
1779
  return ok(await emitBoardTileCreate(emit, a));
1650
1780
  }
1651
1781
  catch (err) {
@@ -1696,17 +1826,35 @@ export function registerTabletopTools(server, bridge, config) {
1696
1826
  color: z.string().regex(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/).optional().describe("Solid ColorRect back colour"),
1697
1827
  }).optional().describe("Optional back state; its presence makes the piece two-sided"),
1698
1828
  script_path: z.string().optional().describe("Generated piece script path (default derives from `path`)"),
1699
- overwrite: z.boolean().optional().describe("Overwrite an existing template at `path` (default false)"),
1829
+ overwrite: z.boolean().optional().describe("Replace an existing template at `path` (default false). With overwrite omitted or false an existing `path` is REFUSED (`exists`) rather than written — before 1.39.0 this flag was declared and never read, and a repeat call APPENDED to the existing scene while reporting success. With overwrite:true the scene is replaced, which requires closing it first if it is open in the editor (Godot 4.4+; below that an open target is refused rather than appended to)."),
1700
1830
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
1701
1831
  },
1702
1832
  }, async (raw) => {
1703
1833
  const a = raw;
1704
1834
  if (!a.path.startsWith("res://") || !a.path.endsWith(".tscn"))
1705
1835
  return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
1836
+ try {
1837
+ guardWriteTarget(a.path, a.overwrite, "path");
1838
+ if (a.script_path !== undefined)
1839
+ resolveInsideProject(a.script_path, config.projectPath, "script_path");
1840
+ // 🔴 `art` is STORED, not loaded: the escaping path was written verbatim into
1841
+ // the generated .tscn, so the template carried a reference out of the project.
1842
+ if (a.art !== undefined)
1843
+ resolveInsideProject(a.art, config.projectPath, "art");
1844
+ // 🔴 AND ITS NESTED TWIN. #173 guarded `art` and left `back.art` open, because the
1845
+ // enumerator that scoped that work could not see one level down. Same defect,
1846
+ // same tool, three sessions apart — measured storing the escaping path verbatim.
1847
+ if (a.back?.art !== undefined)
1848
+ resolveInsideProject(a.back.art, config.projectPath, "back.art");
1849
+ }
1850
+ catch (err) {
1851
+ return fail(err);
1852
+ }
1706
1853
  const blocked = await gate(server, a.confirm, `Create piece template scene + script at ${a.path}`);
1707
1854
  if (blocked)
1708
1855
  return blocked;
1709
1856
  try {
1857
+ await clearStaleTab(a.path);
1710
1858
  return ok(await emitPieceTemplate(emit, a));
1711
1859
  }
1712
1860
  catch (err) {
@@ -1734,6 +1882,12 @@ export function registerTabletopTools(server, bridge, config) {
1734
1882
  },
1735
1883
  }, async (raw) => {
1736
1884
  const a = raw;
1885
+ try {
1886
+ resolveInsideProject(a.template_path, config.projectPath, "template_path");
1887
+ }
1888
+ catch (err) {
1889
+ return fail(err);
1890
+ }
1737
1891
  try {
1738
1892
  return ok(await emitPieceInstance(emit, a));
1739
1893
  }
@@ -1790,6 +1944,12 @@ export function registerTabletopTools(server, bridge, config) {
1790
1944
  const a = raw;
1791
1945
  if (!a.script_path.startsWith("res://") || !a.script_path.endsWith(".gd"))
1792
1946
  return fail({ code: "bad_params", message: "'script_path' must be a res:// .gd path" });
1947
+ try {
1948
+ resolveInsideProject(a.script_path, config.projectPath, "script_path");
1949
+ }
1950
+ catch (err) {
1951
+ return fail(err);
1952
+ }
1793
1953
  const blocked = await gate(server, a.confirm, `Make ${a.node} draggable (writes script ${a.script_path})`);
1794
1954
  if (blocked)
1795
1955
  return blocked;
@@ -1827,6 +1987,12 @@ export function registerTabletopTools(server, bridge, config) {
1827
1987
  const a = raw;
1828
1988
  if (!a.script_path.startsWith("res://") || !a.script_path.endsWith(".gd"))
1829
1989
  return fail({ code: "bad_params", message: "'script_path' must be a res:// .gd path" });
1990
+ try {
1991
+ resolveInsideProject(a.script_path, config.projectPath, "script_path");
1992
+ }
1993
+ catch (err) {
1994
+ return fail(err);
1995
+ }
1830
1996
  const blocked = await gate(server, a.confirm, `Add a drop zone on ${a.node} (writes script ${a.script_path})`);
1831
1997
  if (blocked)
1832
1998
  return blocked;
package/dist/tools/vcs.js CHANGED
@@ -57,6 +57,30 @@ function gitFail(r) {
57
57
  content: [{ type: "text", text: `git error${r.code != null ? ` [${r.code}]` : ""}: ${msg}${hint}` }],
58
58
  };
59
59
  }
60
+ /**
61
+ * The paths from a `git ... --name-only` run, in git's own order and dropping the
62
+ * trailing blank. Used by `vcs_restore` to MEASURE what changed rather than echo what
63
+ * was requested — see the comment at its handler for why that distinction is the whole
64
+ * point (D5, 155 §2; the same family as #181, #183 and #188).
65
+ */
66
+ function nameOnly(stdout) {
67
+ return stdout.split("\n").map((s) => s.trim()).filter(Boolean);
68
+ }
69
+ /**
70
+ * Classify a `vcs_restore` run from the two readings around it.
71
+ *
72
+ * 🔴 A PURE FUNCTION TAKING ITS POPULATIONS AS PARAMETERS, AND THAT IS THE POINT
73
+ * (173 §6, whose own reverse sweep caught the same shape in the commit written to fix
74
+ * that session). Inlined in the handler, `stranded` could only ever be built from what
75
+ * a real `git restore` produces — which, when it works, is always the empty list. A
76
+ * collector you only ever assert is EMPTY is a collector nobody has proved collects,
77
+ * so the collapse case has to be constructible without git's cooperation.
78
+ */
79
+ export function restoreOutcome(rels, wasDirty, stillDirty) {
80
+ const stuck = new Set(stillDirty);
81
+ const restored = wasDirty.filter((p) => !stuck.has(p));
82
+ return { restored, count: restored.length, requested: rels, stranded: [...stuck] };
83
+ }
60
84
  /**
61
85
  * Truncate long text so a single tool result stays reasonable; report whether it
62
86
  * was cut. Keeps the HEAD (not the tail): for a patch the first changed files and
@@ -219,44 +243,68 @@ export function registerVcsTools(server, cfg) {
219
243
  server.registerTool("vcs_branch_list", {
220
244
  title: "Git branches",
221
245
  description: "List branches with their short object name and a flag for the current branch. Local only by default; " +
222
- "set remotes=true to include remote-tracking branches. Read-only.",
246
+ "set remotes=true to include remote-tracking branches (flagged remote=true). On a detached HEAD, " +
247
+ "`current` is null and `detached` is true — matching vcs_status — and no pseudo-branch is listed. " +
248
+ "Read-only.",
223
249
  inputSchema: {
224
250
  remotes: z.boolean().optional().describe("Include remote-tracking branches (default false)"),
225
251
  },
226
252
  }, async ({ remotes }) => {
227
- const args = ["branch", "--no-color", `--format=%(refname:short)${UNIT}%(objectname:short)${UNIT}%(HEAD)`];
253
+ const args = [
254
+ "branch", "--no-color",
255
+ `--format=%(refname)${UNIT}%(refname:short)${UNIT}%(objectname:short)${UNIT}%(HEAD)`,
256
+ ];
228
257
  if (remotes)
229
258
  args.push("--all");
230
259
  const r = await git(cfg, args);
231
260
  if (!r.ok)
232
261
  return gitFail(r);
233
262
  let current = null;
263
+ let detached = false;
234
264
  const branches = r.stdout
235
265
  .split("\n")
236
266
  .filter(Boolean)
237
267
  .map((line) => {
238
- const [name, short_sha, head] = line.split(UNIT);
268
+ const [full, name, short_sha, head] = line.split(UNIT);
239
269
  const isCurrent = head.trim() === "*";
270
+ // On a detached HEAD `git branch` emits a pseudo-entry whose refname is the
271
+ // literal "(HEAD detached at <sha>)" rather than refs/heads/… . It is NOT a
272
+ // branch — listing it as `current` contradicted vcs_status, which reports
273
+ // null for the same repo, and no vcs_switch could ever reach it.
274
+ if (!full.startsWith("refs/")) {
275
+ if (isCurrent)
276
+ detached = true;
277
+ return null;
278
+ }
240
279
  if (isCurrent)
241
280
  current = name;
242
- return { name, short_sha, current: isCurrent, remote: name.startsWith("remotes/") };
243
- });
244
- return ok({ current, branches, count: branches.length });
281
+ // Discriminate off the FULL refname: `%(refname:short)` of a remote-tracking
282
+ // branch is "origin/main", never "remotes/origin/main", so the old
283
+ // name.startsWith("remotes/") test could never match and every branch came
284
+ // back remote=false — including under remotes=true, the flag's whole point.
285
+ return { name, short_sha, current: isCurrent, remote: full.startsWith("refs/remotes/") };
286
+ })
287
+ .filter((b) => b !== null);
288
+ return ok({ current, branches, count: branches.length, detached });
245
289
  });
246
290
  // ---- vcs_blame -----------------------------------------------------------
247
291
  server.registerTool("vcs_blame", {
248
292
  title: "Git blame",
249
293
  description: "Per-line last-change attribution for a file: for each line, the short commit, author, ISO date, and the " +
250
- "line text. Optionally restrict to a [start,end] line range (1-based, inclusive). Read-only.",
294
+ "line text. Optionally restrict to a [start,end] line range (1-based, inclusive); either bound may be " +
295
+ "given alone (start alone runs to end-of-file). Read-only.",
251
296
  inputSchema: {
252
297
  path: z.string().describe("File to blame (res:// or repo-relative)"),
253
- start: z.number().int().positive().optional().describe("First line (1-based, inclusive)"),
254
- end: z.number().int().positive().optional().describe("Last line (1-based, inclusive)"),
298
+ start: z.number().int().positive().optional().describe("First line (1-based, inclusive). May be given without `end`."),
299
+ end: z.number().int().positive().optional().describe("Last line (1-based, inclusive). May be given without `start`."),
255
300
  },
256
301
  }, async ({ path, start, end }) => {
257
302
  const args = ["blame", "--line-porcelain"];
303
+ // An omitted end must be an EMPTY field: `-L 3,` blames line 3 to end-of-file.
304
+ // `$` is a `git log -L` form and `git blame` REJECTS it with usage exit 129, so
305
+ // the old `end ?? "$"` made every start-without-end call fail. Measured, not read.
258
306
  if (start != null || end != null)
259
- args.push("-L", `${start ?? 1},${end ?? "$"}`);
307
+ args.push("-L", `${start ?? 1},${end ?? ""}`);
260
308
  args.push("--", toRepoPath(path));
261
309
  const r = await git(cfg, args);
262
310
  if (!r.ok)
@@ -322,7 +370,10 @@ export function registerVcsTools(server, cfg) {
322
370
  title: "Git restore (discard changes)",
323
371
  description: "Discard uncommitted working-tree changes to the given paths, restoring them from the index/HEAD " +
324
372
  "(`git restore -- <paths>`). DESTRUCTIVE — the discarded edits are unrecoverable — so it is " +
325
- "elicitation-gated: pass confirm:true to bypass the prompt on clients that can't elicit.",
373
+ "elicitation-gated: pass confirm:true to bypass the prompt on clients that can't elicit. " +
374
+ "`restored` is MEASURED, not echoed: it lists only the paths git actually changed, so a path with " +
375
+ "nothing to discard is absent from it rather than reported as discarded work. `requested` carries " +
376
+ "what you asked for, and `stranded` names any path still dirty afterwards (a partial, not an error).",
326
377
  inputSchema: {
327
378
  paths: z.array(z.string()).min(1).describe("Paths to discard changes for (res:// or repo-relative)"),
328
379
  confirm: z.boolean().optional().describe("Skip the confirmation prompt"),
@@ -332,17 +383,44 @@ export function registerVcsTools(server, cfg) {
332
383
  const blocked = await gate(server, confirm, `Discard working-tree changes to: ${rels.join(", ")}`);
333
384
  if (blocked)
334
385
  return blocked;
386
+ // 🔴 D5 (155 §2), AND THE THIRD MEMBER OF #181/#183/#188's FAMILY: this used to
387
+ // return `restored: rels` — THE REQUEST, ECHOED. `git restore` exits 0 for a path
388
+ // with nothing to discard, so asking to discard five files of which one was dirty
389
+ // reported all five as restored, and the caller of a DESTRUCTIVE, gated tool was
390
+ // told it had thrown away work it had not touched. Every existing test restored a
391
+ // path that was dirty, so the clean-path branch had never run anywhere.
392
+ //
393
+ // What `git restore -- <paths>` discards is exactly the working-tree-vs-index
394
+ // diff, so that diff, read BEFORE and AFTER, is the measurement — not the request.
395
+ const before = await git(cfg, ["diff", "--name-only", "--", ...rels]);
396
+ if (!before.ok)
397
+ return gitFail(before);
398
+ const wasDirty = nameOnly(before.stdout);
335
399
  const r = await git(cfg, ["restore", "--", ...rels]);
336
400
  if (!r.ok)
337
401
  return gitFail(r);
338
- return ok({ restored: rels, count: rels.length });
402
+ const after = await git(cfg, ["diff", "--name-only", "--", ...rels]);
403
+ if (!after.ok)
404
+ return gitFail(after);
405
+ // Three outcomes, and one list cannot carry them (#188's two booleans, as lists):
406
+ // a path that was dirty and is now clean was RESTORED; a path git still reports as
407
+ // dirty is STRANDED; and `requested` stays available so a caller can see that the
408
+ // other paths had nothing to discard rather than having to infer it.
409
+ //
410
+ // A stranded path is a PARTIAL, not an error — deliberately, and for #188 §7's
411
+ // reason inverted: work WAS discarded for the other paths, and `_err` would claim
412
+ // nothing happened, which is the same misdescription pointing the other way.
413
+ return ok(restoreOutcome(rels, wasDirty, nameOnly(after.stdout)));
339
414
  });
340
415
  // ---- vcs_stash — push/pop/list ungated; drop GATED (destroys an entry) ----
341
416
  server.registerTool("vcs_stash", {
342
417
  title: "Git stash",
343
418
  description: "Manage stashes: op='push' saves and reverts your working changes (optional message); 'pop' " +
344
419
  "re-applies the latest stash; 'list' returns the stash entries; 'drop' deletes a stash entry. " +
345
- "Only 'drop' is destructive and elicitation-gated (confirm:true bypasses); push/pop/list are not.",
420
+ "Only 'drop' is destructive and elicitation-gated (confirm:true bypasses); push/pop/list are not. " +
421
+ "op='push' ERRORS when nothing was stashed (no tracked file has uncommitted changes) rather than " +
422
+ "reporting success — a success there would tell you work is parked when it is not. Untracked files " +
423
+ "are never stashed.",
346
424
  inputSchema: {
347
425
  op: z.enum(["push", "pop", "list", "drop"]).describe("Stash operation"),
348
426
  message: z.string().optional().describe("Message for op='push'"),
@@ -370,20 +448,45 @@ export function registerVcsTools(server, cfg) {
370
448
  return gitFail(r);
371
449
  return ok({ op, message: r.stdout.trim() || "dropped", stashes: [] });
372
450
  }
373
- // push / pop
374
- const args = op === "push"
375
- ? ["stash", "push", ...(message ? ["-m", message] : [])]
376
- : ["stash", "pop", ...(ref ? [ref] : [])];
377
- const r = await git(cfg, args);
451
+ if (op === "push") {
452
+ // `git stash push` EXITS 0 printing "No local changes to save" when there is
453
+ // nothing to stash. Passing that through as success is the worst shape this
454
+ // family has: the caller believes their work is safely parked and may then
455
+ // switch or restore over it. Decide on refs/stash BEFORE vs AFTER rather than
456
+ // on git's wording, which is not a stable interface across versions.
457
+ // Note untracked-only trees stash NOTHING here (no -u) — measured.
458
+ const before = await git(cfg, ["rev-parse", "--quiet", "--verify", "refs/stash"]);
459
+ const r = await git(cfg, ["stash", "push", ...(message ? ["-m", message] : [])]);
460
+ if (!r.ok)
461
+ return gitFail(r);
462
+ const after = await git(cfg, ["rev-parse", "--quiet", "--verify", "refs/stash"]);
463
+ const beforeOid = before.ok ? before.stdout.trim() : "";
464
+ const afterOid = after.ok ? after.stdout.trim() : "";
465
+ if (afterOid === "" || afterOid === beforeOid) {
466
+ return {
467
+ isError: true,
468
+ content: [{
469
+ type: "text",
470
+ text: "Nothing was stashed — no tracked file has uncommitted changes, so your working tree " +
471
+ "is unchanged and no stash entry was created. Do not treat this as work being parked. " +
472
+ `(git said: ${r.stdout.trim() || "nothing"})`,
473
+ }],
474
+ };
475
+ }
476
+ return ok({ op, message: r.stdout.trim() || "push ok", stashes: [] });
477
+ }
478
+ const r = await git(cfg, ["stash", "pop", ...(ref ? [ref] : [])]);
378
479
  if (!r.ok)
379
480
  return gitFail(r);
380
- return ok({ op, message: r.stdout.trim() || `${op} ok`, stashes: [] });
481
+ return ok({ op, message: r.stdout.trim() || "pop ok", stashes: [] });
381
482
  });
382
483
  // ---- vcs_branch_create — ungated (reversible) ----------------------------
383
484
  server.registerTool("vcs_branch_create", {
384
485
  title: "Git branch (create)",
385
486
  description: "Create a new branch, optionally starting from a given ref (default HEAD), and optionally switch to " +
386
- "it. Reversible (`git branch -d`), so not gated. Errors clearly if the branch already exists.",
487
+ "it. Reversible (`git branch -d`), so not gated. Errors clearly if the branch already exists. If " +
488
+ "switch=true and the switch is refused (e.g. local changes would be overwritten), the error SAYS the " +
489
+ "branch was still created — create and switch are two git calls and only the second can fail.",
387
490
  inputSchema: {
388
491
  name: z.string().min(1).describe("New branch name"),
389
492
  from: z.string().optional().describe("Start point (branch, tag, or sha; default HEAD)"),
@@ -396,8 +499,21 @@ export function registerVcsTools(server, cfg) {
396
499
  let switched = false;
397
500
  if (doSwitch) {
398
501
  const sw = await git(cfg, ["switch", name]);
399
- if (!sw.ok)
400
- return gitFail(sw);
502
+ if (!sw.ok) {
503
+ // The branch EXISTS at this point. Returning only the switch failure strands
504
+ // the caller: they believe nothing happened, retry, and get "already exists"
505
+ // from a branch they created themselves. Report BOTH halves.
506
+ const failure = gitFail(sw);
507
+ return {
508
+ isError: true,
509
+ content: [{
510
+ type: "text",
511
+ text: `Branch '${name}' WAS created, but switching to it failed — you are still on the ` +
512
+ `previous branch. Delete it with \`git branch -d ${name}\` if it is unwanted, or ` +
513
+ `resolve the block below and switch with vcs_switch. ${failure.content[0].text}`,
514
+ }],
515
+ };
516
+ }
401
517
  switched = true;
402
518
  }
403
519
  return ok({ created: true, name, from: from ?? null, switched });
package/dist/toolsets.js CHANGED
@@ -31,12 +31,12 @@ export function buildToolsets(d) {
31
31
  d.onPeers?.(peers);
32
32
  return [
33
33
  { id: "cli", describe: "Plane B — headless CLI (no editor required)", run: () => registerCliTools(s, c) },
34
- { id: "editor", describe: "Plane A — live editor authoring (addon bridge)", run: () => registerEditorTools(s, stub(d.bridge)) },
34
+ { id: "editor", describe: "Plane A — live editor authoring (addon bridge)", run: () => registerEditorTools(s, stub(d.bridge), c) },
35
35
  { id: "lsp", describe: "Plane D — GDScript language server (LSP)", run: () => registerLspTools(s, stub(d.lsp), c) },
36
36
  { id: "cslsp", describe: "Plane D — C# language server (OmniSharp)", run: () => registerCsLspTools(s, stub(d.csLsp), c) },
37
37
  { id: "dap", describe: "Plane D — GDScript debug adapter (DAP)", run: () => registerDapTools(s, stub(d.dap), c) },
38
38
  { id: "csdap", describe: "Plane D — C# debug adapter (netcoredbg)", run: () => registerCsDapTools(s, stub(d.csDap), c) },
39
- { id: "runtime", describe: "Plane C — running-game runtime + verification family (incl. F6 multi-peer)", run: () => registerRuntimeTools(s, stub(d.runtime), peers) },
39
+ { id: "runtime", describe: "Plane C — running-game runtime + verification family (incl. F6 multi-peer)", run: () => registerRuntimeTools(s, stub(d.runtime), peers, c) },
40
40
  { id: "processes", describe: "Managed run + captured console output", run: () => { const h = registerProcessTools(s, c); d.onProcesses?.(h); } },
41
41
  { id: "knowledge", describe: "Group K — host-side project grep / symbol & idiom lookup", run: () => registerKnowledgeTools(s, c) },
42
42
  { id: "vcs", describe: "Group L — read-only version control (git) over the project", run: () => registerVcsTools(s, c) },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.28.0",
3
+ "version": "1.72.7",
4
4
  "description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,13 @@
43
43
  "start": "node dist/index.js",
44
44
  "dev": "tsc && node dist/index.js",
45
45
  "stage-addon": "node scripts/stage-addon.mjs",
46
- "prepublishOnly": "npm run build && npm run stage-addon"
46
+ "prepublishOnly": "npm run build && npm run stage-addon",
47
+ "path-cohort": "node scripts/path-cohort.mjs",
48
+ "tautology-gate": "node scripts/tautology_gate.selftest.mjs && node scripts/tautology_gate.mjs",
49
+ "scope-gate": "python3 ../scripts/scope_gate.py",
50
+ "instrument-gate": "python3 ../scripts/instrument_gate.py",
51
+ "ledger-selftest": "node test-integration/_path_ledger.selftest.mjs",
52
+ "workspace-selftest": "node test-integration/_workspace.selftest.mjs"
47
53
  },
48
54
  "engines": {
49
55
  "node": ">=18"