@unbrained/pm-web 2026.7.26 → 2026.7.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/routes/pm.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { Router } from "express";
2
2
  import { requireAuth } from "../middleware/auth.js";
3
- import { ensureGraphExtension, runPm, runGetItemAt, PmCliError, EXIT_CODE } from "../services/pm-runner.js";
3
+ import { ensureGraphExtension, runPm, runGetItemAt, readPmSettings, PmCliError, EXIT_CODE } from "../services/pm-runner.js";
4
+ // The search-tuning resolvers live only on the narrow sdk/query entrypoint — the
5
+ // aggregate sdk barrel documents itself as re-exporting every supported export but
6
+ // omits 45 of them, these three included (upstream: unbraind/pm-cli#740).
7
+ import { resolveSearchMaxResults, resolveSearchScoreThreshold, resolveHybridSemanticWeight, } from "@unbrained/pm-cli/sdk/query";
8
+ import { QUERY_CURSOR_CONTRACT } from "@unbrained/pm-cli/sdk";
4
9
  import { boardColumns, filterItemsByQuery } from "../board.js";
5
10
  import { buildIcsCalendar } from "../ical.js";
6
11
  import { verifyProjectAccess } from "./projects.js";
@@ -28,6 +33,53 @@ function getNeo4jDriver() {
28
33
  }
29
34
  const router = Router({ mergeParams: true });
30
35
  router.use(requireAuth);
36
+ const BASE64URL_CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/;
37
+ /**
38
+ * Validate an incoming opaque pagination cursor. Returns the original cursor or
39
+ * `undefined` when none was supplied. Rejects cursors that exceed the SDK
40
+ * cursor contract's maximum length with a 400 so callers can map the failure
41
+ * to a proper client error instead of forwarding an oversized token to the SDK.
42
+ */
43
+ function validateCursor(raw) {
44
+ if (raw === undefined || raw === null || raw === "")
45
+ return {};
46
+ const cursor = String(raw);
47
+ if (cursor.length > QUERY_CURSOR_CONTRACT.max_length) {
48
+ return {
49
+ error: `Pagination cursor exceeds the maximum length of ${QUERY_CURSOR_CONTRACT.max_length} characters.`,
50
+ };
51
+ }
52
+ if (!BASE64URL_CURSOR_PATTERN.test(cursor)) {
53
+ return { error: "Pagination cursor must be a valid base64url token." };
54
+ }
55
+ return { cursor };
56
+ }
57
+ /** Coerce an optional request number into a finite, bounded value. */
58
+ function boundedNumber(raw, fallback, minimum, maximum, integer = false) {
59
+ const parsed = typeof raw === "number" ? raw : Number(raw);
60
+ if (!Number.isFinite(parsed))
61
+ return fallback;
62
+ const bounded = Math.min(maximum, Math.max(minimum, parsed));
63
+ return integer ? Math.trunc(bounded) : bounded;
64
+ }
65
+ /**
66
+ * Map a {@link PmRunResult} failure to the correct HTTP status using the pm CLI
67
+ * exit code surfaced by the in-process dispatcher. Expected validation failures
68
+ * (USAGE) and not-found (NOT_FOUND) become 4xx; anything without a recognised
69
+ * exit code is an unexpected runtime error and becomes 500 so it is not
70
+ * silently swallowed as a client error.
71
+ */
72
+ function pmErrorStatus(result) {
73
+ if (result.exitCode === EXIT_CODE.NOT_FOUND)
74
+ return 404;
75
+ if (result.exitCode === EXIT_CODE.USAGE)
76
+ return 400;
77
+ if (result.exitCode === EXIT_CODE.CONFLICT)
78
+ return 409;
79
+ if (result.exitCode === EXIT_CODE.DEPENDENCY_FAILED)
80
+ return 424;
81
+ return 500;
82
+ }
31
83
  const pendingGraphSyncs = new Map();
32
84
  router.use(async (req, res, next) => {
33
85
  if (["GET", "HEAD", "OPTIONS"].includes(req.method) || (req.method === "PATCH" && req.path.startsWith("/presence/"))) {
@@ -360,7 +412,7 @@ router.get("/list", async (req, res) => {
360
412
  res.status(404).json({ error: "Project not found" });
361
413
  return;
362
414
  }
363
- const { status, type, limit, priority, sprint, release, assignee } = req.query;
415
+ const { status, type, limit, priority, sprint, release, assignee, after } = req.query;
364
416
  const args = ["list"];
365
417
  if (status)
366
418
  args.push("--status", status);
@@ -376,7 +428,18 @@ router.get("/list", async (req, res) => {
376
428
  args.push("--release", release);
377
429
  if (assignee)
378
430
  args.push("--assignee", assignee);
431
+ const cursorResult = validateCursor(after);
432
+ if (cursorResult.error) {
433
+ res.status(400).json({ error: cursorResult.error, items: [] });
434
+ return;
435
+ }
436
+ if (cursorResult.cursor)
437
+ args.push("--after", cursorResult.cursor);
379
438
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
439
+ if (!result.ok && result.exitCode === EXIT_CODE.USAGE) {
440
+ res.status(400).json({ error: result.stderr, items: [] });
441
+ return;
442
+ }
380
443
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
381
444
  });
382
445
  // GET /api/projects/:projectId/pm/list-all
@@ -386,13 +449,24 @@ router.get("/list-all", async (req, res) => {
386
449
  res.status(404).json({ error: "Project not found" });
387
450
  return;
388
451
  }
389
- const { type, limit } = req.query;
452
+ const { type, limit, after } = req.query;
390
453
  const args = ["list-all"];
391
454
  if (type)
392
455
  args.push("--type", type);
393
456
  if (limit)
394
457
  args.push("--limit", limit);
458
+ const cursorResult = validateCursor(after);
459
+ if (cursorResult.error) {
460
+ res.status(400).json({ error: cursorResult.error, items: [] });
461
+ return;
462
+ }
463
+ if (cursorResult.cursor)
464
+ args.push("--after", cursorResult.cursor);
395
465
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
466
+ if (!result.ok && result.exitCode === EXIT_CODE.USAGE) {
467
+ res.status(400).json({ error: result.stderr, items: [] });
468
+ return;
469
+ }
396
470
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
397
471
  });
398
472
  // GET /api/projects/:projectId/pm/board
@@ -511,7 +585,7 @@ router.post("/create", async (req, res) => {
511
585
  args.push("--definition-of-ready", definitionOfReady);
512
586
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
513
587
  if (!result.ok) {
514
- res.status(400).json({ error: result.stderr || "Failed to create item" });
588
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to create item" });
515
589
  return;
516
590
  }
517
591
  // Broadcast SSE create event
@@ -628,7 +702,7 @@ router.patch("/update/:itemId", async (req, res) => {
628
702
  args.push("--type", body.type);
629
703
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
630
704
  if (!result.ok) {
631
- res.status(400).json({ error: result.stderr || "Failed to update item" });
705
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to update item" });
632
706
  return;
633
707
  }
634
708
  // Broadcast SSE update event
@@ -658,7 +732,7 @@ router.post("/close/:itemId", async (req, res) => {
658
732
  jsonOutput: true,
659
733
  });
660
734
  if (!result.ok) {
661
- res.status(400).json({ error: result.stderr || "Failed to close item" });
735
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to close item" });
662
736
  return;
663
737
  }
664
738
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -681,7 +755,7 @@ router.delete("/delete/:itemId", async (req, res) => {
681
755
  slug: project.slug,
682
756
  });
683
757
  if (!result.ok) {
684
- res.status(400).json({ error: result.stderr || "Failed to delete item" });
758
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to delete item" });
685
759
  return;
686
760
  }
687
761
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -710,7 +784,7 @@ router.post("/comments/:itemId", async (req, res) => {
710
784
  jsonOutput: true,
711
785
  });
712
786
  if (!result.ok) {
713
- res.status(400).json({ error: result.stderr || "Failed to add comment" });
787
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add comment" });
714
788
  return;
715
789
  }
716
790
  res.status(201).json(result.parsed || { ok: true });
@@ -764,7 +838,7 @@ router.post("/notes/:itemId", async (req, res) => {
764
838
  jsonOutput: true,
765
839
  });
766
840
  if (!result.ok) {
767
- res.status(400).json({ error: result.stderr || "Failed to add note" });
841
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add note" });
768
842
  return;
769
843
  }
770
844
  res.status(201).json(result.parsed || { ok: true });
@@ -838,21 +912,41 @@ router.post("/search", async (req, res) => {
838
912
  res.status(404).json({ error: "Project not found" });
839
913
  return;
840
914
  }
841
- const { query, mode } = req.body;
915
+ const body = req.body;
916
+ const query = typeof body["query"] === "string" ? body["query"] : "";
917
+ const mode = typeof body["mode"] === "string" ? body["mode"] : "";
842
918
  if (!query?.trim()) {
843
919
  res.status(400).json({ error: "Search query is required" });
844
920
  return;
845
921
  }
846
922
  const validModes = ["keyword", "semantic", "hybrid"];
847
- const safeMode = validModes.includes(mode || "") ? mode : "hybrid";
923
+ const safeMode = validModes.includes(mode) ? mode : "hybrid";
924
+ // Adopt the sdk/query search-tuning resolvers: read the workspace settings and
925
+ // resolve the bounded max-results, score threshold, and hybrid semantic weight
926
+ // from the same defaults the pm CLI applies. Explicit per-request overrides
927
+ // (limit / minScore / semanticWeight) win over the workspace defaults so the
928
+ // browser can still narrow a page.
929
+ const settings = readPmSettings(project.ownerUserId, project.slug);
930
+ const resolvedLimit = boundedNumber(body["limit"], resolveSearchMaxResults(settings), 1, 500, true);
931
+ const resolvedMinScore = boundedNumber(body["minScore"], resolveSearchScoreThreshold(settings), 0, 1_000_000);
932
+ const resolvedSemanticWeight = boundedNumber(body["semanticWeight"], resolveHybridSemanticWeight(settings), 0, 1);
933
+ const cursorResult = validateCursor(body["after"]);
934
+ if (cursorResult.error) {
935
+ res.status(400).json({ error: cursorResult.error, results: [] });
936
+ return;
937
+ }
938
+ const args = ["search", "--mode", safeMode, "--limit", String(resolvedLimit), "--min-score", String(resolvedMinScore), "--semantic-weight", String(resolvedSemanticWeight)];
939
+ if (cursorResult.cursor)
940
+ args.push("--after", cursorResult.cursor);
941
+ args.push("--", ...query.trim().split(/\s+/));
848
942
  const result = await runPm({
849
- args: ["search", "--mode", safeMode, ...query.trim().split(/\s+/)],
943
+ args,
850
944
  userId: project.ownerUserId,
851
945
  slug: project.slug,
852
946
  jsonOutput: true,
853
947
  });
854
948
  if (!result.ok) {
855
- res.status(400).json({
949
+ res.status(pmErrorStatus(result)).json({
856
950
  error: result.stderr || "Search failed. Check that Ollama is reachable and the configured embedding model is available.",
857
951
  results: [],
858
952
  });
@@ -951,7 +1045,7 @@ router.post("/append/:itemId", async (req, res) => {
951
1045
  jsonOutput: true,
952
1046
  });
953
1047
  if (!result.ok) {
954
- res.status(400).json({ error: result.stderr || "Failed to append" });
1048
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to append" });
955
1049
  return;
956
1050
  }
957
1051
  scheduleGraphSync(routeParam(req, "projectId"), project, "item-appended");
@@ -1007,7 +1101,7 @@ router.post("/deps/:itemId", async (req, res) => {
1007
1101
  jsonOutput: true,
1008
1102
  });
1009
1103
  if (!result.ok) {
1010
- res.status(400).json({ error: result.stderr || "Failed to add dependency" });
1104
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add dependency" });
1011
1105
  return;
1012
1106
  }
1013
1107
  scheduleGraphSync(routeParam(req, "projectId"), project, "dependency-added");
@@ -1040,7 +1134,7 @@ router.delete("/deps/:itemId", async (req, res) => {
1040
1134
  jsonOutput: true,
1041
1135
  });
1042
1136
  if (!result.ok) {
1043
- res.status(400).json({ error: result.stderr || "Failed to remove dependency" });
1137
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to remove dependency" });
1044
1138
  return;
1045
1139
  }
1046
1140
  scheduleGraphSync(routeParam(req, "projectId"), project, "dependency-removed");
@@ -1072,7 +1166,7 @@ router.post("/rel", async (req, res) => {
1072
1166
  jsonOutput: true,
1073
1167
  });
1074
1168
  if (!result.ok) {
1075
- res.status(400).json({ error: result.stderr || "Failed to create relationship" });
1169
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to create relationship" });
1076
1170
  return;
1077
1171
  }
1078
1172
  scheduleGraphSync(routeParam(req, "projectId"), project, "rel-created");
@@ -1105,7 +1199,7 @@ router.delete("/rel", async (req, res) => {
1105
1199
  jsonOutput: true,
1106
1200
  });
1107
1201
  if (!result.ok) {
1108
- res.status(400).json({ error: result.stderr || "Failed to remove relationship" });
1202
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to remove relationship" });
1109
1203
  return;
1110
1204
  }
1111
1205
  scheduleGraphSync(routeParam(req, "projectId"), project, "rel-removed");
@@ -1229,7 +1323,7 @@ router.post("/graph/query", async (req, res) => {
1229
1323
  jsonOutput: false,
1230
1324
  });
1231
1325
  if (!result.ok) {
1232
- res.status(400).json({ error: result.stderr || "pm-graph query failed — ensure Neo4j is configured and pm-graph extension is installed" });
1326
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "pm-graph query failed — ensure Neo4j is configured and pm-graph extension is installed" });
1233
1327
  return;
1234
1328
  }
1235
1329
  try {
@@ -1274,7 +1368,7 @@ router.post("/learnings/:itemId", async (req, res) => {
1274
1368
  jsonOutput: true,
1275
1369
  });
1276
1370
  if (!result.ok) {
1277
- res.status(400).json({ error: result.stderr || "Failed to add learning" });
1371
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add learning" });
1278
1372
  return;
1279
1373
  }
1280
1374
  res.status(201).json(result.parsed || { ok: true });
@@ -1293,7 +1387,7 @@ router.post("/claim/:itemId", async (req, res) => {
1293
1387
  jsonOutput: true,
1294
1388
  });
1295
1389
  if (!result.ok) {
1296
- res.status(400).json({ error: result.stderr || "Failed to claim item" });
1390
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to claim item" });
1297
1391
  return;
1298
1392
  }
1299
1393
  scheduleGraphSync(routeParam(req, "projectId"), project, "item-claimed");
@@ -1313,7 +1407,7 @@ router.post("/release/:itemId", async (req, res) => {
1313
1407
  jsonOutput: true,
1314
1408
  });
1315
1409
  if (!result.ok) {
1316
- res.status(400).json({ error: result.stderr || "Failed to release item" });
1410
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to release item" });
1317
1411
  return;
1318
1412
  }
1319
1413
  scheduleGraphSync(routeParam(req, "projectId"), project, "item-released");
@@ -1333,7 +1427,7 @@ router.post("/start-task/:itemId", async (req, res) => {
1333
1427
  jsonOutput: true,
1334
1428
  });
1335
1429
  if (!result.ok) {
1336
- res.status(400).json({ error: result.stderr || "Failed to start task" });
1430
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to start task" });
1337
1431
  return;
1338
1432
  }
1339
1433
  scheduleGraphSync(routeParam(req, "projectId"), project, "task-started");
@@ -1353,7 +1447,7 @@ router.post("/pause-task/:itemId", async (req, res) => {
1353
1447
  jsonOutput: true,
1354
1448
  });
1355
1449
  if (!result.ok) {
1356
- res.status(400).json({ error: result.stderr || "Failed to pause task" });
1450
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to pause task" });
1357
1451
  return;
1358
1452
  }
1359
1453
  scheduleGraphSync(routeParam(req, "projectId"), project, "task-paused");
@@ -1396,7 +1490,7 @@ router.post("/tests/:itemId", async (req, res) => {
1396
1490
  jsonOutput: true,
1397
1491
  });
1398
1492
  if (!result.ok) {
1399
- res.status(400).json({ error: result.stderr || "Failed to add test" });
1493
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add test" });
1400
1494
  return;
1401
1495
  }
1402
1496
  res.status(201).json(result.parsed || { ok: true });
@@ -1450,7 +1544,7 @@ router.post("/restore/:itemId", async (req, res) => {
1450
1544
  jsonOutput: true,
1451
1545
  });
1452
1546
  if (!result.ok) {
1453
- res.status(400).json({ error: result.stderr || "Failed to restore item" });
1547
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to restore item" });
1454
1548
  return;
1455
1549
  }
1456
1550
  scheduleGraphSync(routeParam(req, "projectId"), project, "item-restored");
@@ -1475,7 +1569,7 @@ router.post("/close-task/:itemId", async (req, res) => {
1475
1569
  jsonOutput: true,
1476
1570
  });
1477
1571
  if (!result.ok) {
1478
- res.status(400).json({ error: result.stderr || "Failed to close task" });
1572
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to close task" });
1479
1573
  return;
1480
1574
  }
1481
1575
  scheduleGraphSync(routeParam(req, "projectId"), project, "task-closed");
@@ -1557,7 +1651,7 @@ router.post("/files/:itemId", async (req, res) => {
1557
1651
  jsonOutput: true,
1558
1652
  });
1559
1653
  if (!result.ok) {
1560
- res.status(400).json({ error: result.stderr || "Failed to link file" });
1654
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to link file" });
1561
1655
  return;
1562
1656
  }
1563
1657
  scheduleGraphSync(routeParam(req, "projectId"), project, "file-linked");
@@ -1825,7 +1919,7 @@ router.post("/update-many", async (req, res) => {
1825
1919
  }
1826
1920
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1827
1921
  if (!result.ok) {
1828
- res.status(400).json({ error: result.stderr || "update-many failed" });
1922
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "update-many failed" });
1829
1923
  return;
1830
1924
  }
1831
1925
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -1867,7 +1961,7 @@ router.post("/close-many", async (req, res) => {
1867
1961
  }
1868
1962
  const listResult = await runPm({ args: listArgs, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1869
1963
  if (!listResult.ok) {
1870
- res.status(400).json({ error: listResult.stderr || "Failed to list items for close-many" });
1964
+ res.status(pmErrorStatus(listResult)).json({ error: listResult.stderr || "Failed to list items for close-many" });
1871
1965
  return;
1872
1966
  }
1873
1967
  const parsed = listResult.parsed;
@@ -1954,7 +2048,7 @@ router.post("/docs/:itemId", async (req, res) => {
1954
2048
  }
1955
2049
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1956
2050
  if (!result.ok) {
1957
- res.status(400).json({ error: result.stderr || "Failed to update docs" });
2051
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to update docs" });
1958
2052
  return;
1959
2053
  }
1960
2054
  scheduleGraphSync(routeParam(req, "projectId"), project, "docs-updated");
@@ -1977,7 +2071,7 @@ router.post("/test-all", async (req, res) => {
1977
2071
  args.push("--timeout", body.timeout);
1978
2072
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1979
2073
  if (!result.ok) {
1980
- res.status(400).json({ error: result.stderr || "test-all failed" });
2074
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "test-all failed" });
1981
2075
  return;
1982
2076
  }
1983
2077
  res.json(result.parsed || {});
@@ -2077,7 +2171,7 @@ function buildListShortcutRoute(pmCommand) {
2077
2171
  res.status(404).json({ error: "Project not found" });
2078
2172
  return;
2079
2173
  }
2080
- const { type, limit, offset, tag, priority, assignee, sprint, release } = req.query;
2174
+ const { type, limit, offset, tag, priority, assignee, sprint, release, after } = req.query;
2081
2175
  const args = [pmCommand];
2082
2176
  if (type)
2083
2177
  args.push("--type", type);
@@ -2095,7 +2189,18 @@ function buildListShortcutRoute(pmCommand) {
2095
2189
  args.push("--sprint", sprint);
2096
2190
  if (release)
2097
2191
  args.push("--release", release);
2192
+ const cursorResult = validateCursor(after);
2193
+ if (cursorResult.error) {
2194
+ res.status(400).json({ error: cursorResult.error, items: [] });
2195
+ return;
2196
+ }
2197
+ if (cursorResult.cursor)
2198
+ args.push("--after", cursorResult.cursor);
2098
2199
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2200
+ if (!result.ok && result.exitCode === EXIT_CODE.USAGE) {
2201
+ res.status(400).json({ error: result.stderr, items: [] });
2202
+ return;
2203
+ }
2099
2204
  res.json(result.ok ? (result.parsed || {}) : { items: [] });
2100
2205
  };
2101
2206
  }
@@ -2133,7 +2238,7 @@ router.post("/plan", async (req, res) => {
2133
2238
  args.push("--body", body);
2134
2239
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2135
2240
  if (!result.ok) {
2136
- res.status(400).json({ error: result.stderr || "Failed to create plan" });
2241
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to create plan" });
2137
2242
  return;
2138
2243
  }
2139
2244
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2176,7 +2281,7 @@ router.patch("/plan/:planId", async (req, res) => {
2176
2281
  args.push("--description", description);
2177
2282
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2178
2283
  if (!result.ok) {
2179
- res.status(400).json({ error: result.stderr || "Failed to update plan" });
2284
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to update plan" });
2180
2285
  return;
2181
2286
  }
2182
2287
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2199,7 +2304,7 @@ router.delete("/plan/:planId", async (req, res) => {
2199
2304
  jsonOutput: true,
2200
2305
  });
2201
2306
  if (!result.ok) {
2202
- res.status(400).json({ error: result.stderr || "Failed to delete plan" });
2307
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to delete plan" });
2203
2308
  return;
2204
2309
  }
2205
2310
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2227,7 +2332,7 @@ router.post("/plan/:planId/steps", async (req, res) => {
2227
2332
  args.push("--depends-on", dependsOn);
2228
2333
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2229
2334
  if (!result.ok) {
2230
- res.status(400).json({ error: result.stderr || "Failed to add step" });
2335
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add step" });
2231
2336
  return;
2232
2337
  }
2233
2338
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2251,7 +2356,7 @@ router.patch("/plan/:planId/steps/:stepRef", async (req, res) => {
2251
2356
  args.push("--description", description);
2252
2357
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2253
2358
  if (!result.ok) {
2254
- res.status(400).json({ error: result.stderr || "Failed to update step" });
2359
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to update step" });
2255
2360
  return;
2256
2361
  }
2257
2362
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2274,7 +2379,7 @@ router.post("/plan/:planId/steps/:stepRef/complete", async (req, res) => {
2274
2379
  jsonOutput: true,
2275
2380
  });
2276
2381
  if (!result.ok) {
2277
- res.status(400).json({ error: result.stderr || "Failed to complete step" });
2382
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to complete step" });
2278
2383
  return;
2279
2384
  }
2280
2385
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2302,7 +2407,7 @@ router.post("/plan/:planId/steps/:stepRef/block", async (req, res) => {
2302
2407
  jsonOutput: true,
2303
2408
  });
2304
2409
  if (!result.ok) {
2305
- res.status(400).json({ error: result.stderr || "Failed to block step" });
2410
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to block step" });
2306
2411
  return;
2307
2412
  }
2308
2413
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2325,7 +2430,7 @@ router.delete("/plan/:planId/steps/:stepRef", async (req, res) => {
2325
2430
  jsonOutput: true,
2326
2431
  });
2327
2432
  if (!result.ok) {
2328
- res.status(400).json({ error: result.stderr || "Failed to remove step" });
2433
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to remove step" });
2329
2434
  return;
2330
2435
  }
2331
2436
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2348,7 +2453,7 @@ router.post("/plan/:planId/approve", async (req, res) => {
2348
2453
  jsonOutput: true,
2349
2454
  });
2350
2455
  if (!result.ok) {
2351
- res.status(400).json({ error: result.stderr || "Failed to approve plan" });
2456
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to approve plan" });
2352
2457
  return;
2353
2458
  }
2354
2459
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2374,7 +2479,7 @@ router.post("/plan/:planId/materialize", async (req, res) => {
2374
2479
  args.push("--steps", steps);
2375
2480
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2376
2481
  if (!result.ok) {
2377
- res.status(400).json({ error: result.stderr || "Failed to materialize plan" });
2482
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to materialize plan" });
2378
2483
  return;
2379
2484
  }
2380
2485
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2402,7 +2507,7 @@ router.post("/plan/:planId/steps/:stepRef/reorder", async (req, res) => {
2402
2507
  jsonOutput: true,
2403
2508
  });
2404
2509
  if (!result.ok) {
2405
- res.status(400).json({ error: result.stderr || "Failed to reorder step" });
2510
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to reorder step" });
2406
2511
  return;
2407
2512
  }
2408
2513
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2432,7 +2537,7 @@ router.post("/plan/:planId/link", async (req, res) => {
2432
2537
  args.push("--promote-to-item-dep");
2433
2538
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2434
2539
  if (!result.ok) {
2435
- res.status(400).json({ error: result.stderr || "Failed to link plan" });
2540
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to link plan" });
2436
2541
  return;
2437
2542
  }
2438
2543
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2458,7 +2563,7 @@ router.delete("/plan/:planId/link", async (req, res) => {
2458
2563
  args.push("--link-kind", linkKind);
2459
2564
  const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2460
2565
  if (!result.ok) {
2461
- res.status(400).json({ error: result.stderr || "Failed to unlink plan" });
2566
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to unlink plan" });
2462
2567
  return;
2463
2568
  }
2464
2569
  broadcastProjectEvent(routeParam(req, "projectId"), {
@@ -2499,7 +2604,7 @@ router.post("/upgrade", async (req, res) => {
2499
2604
  jsonOutput: true,
2500
2605
  });
2501
2606
  if (!result.ok) {
2502
- res.status(400).json({ error: result.stderr || "Upgrade failed" });
2607
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || "Upgrade failed" });
2503
2608
  return;
2504
2609
  }
2505
2610
  res.json(result.parsed || { ok: true });