@unbrained/pm-web 2026.9.18 → 2026.9.26

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 (126) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -4
  3. package/dist/app.d.ts +10 -0
  4. package/dist/app.js +19 -9
  5. package/dist/app.js.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/routes/admin.js +6 -6
  8. package/dist/routes/admin.js.map +1 -1
  9. package/dist/routes/auth.js +2 -2
  10. package/dist/routes/auth.js.map +1 -1
  11. package/dist/routes/extensions.js +24 -37
  12. package/dist/routes/extensions.js.map +1 -1
  13. package/dist/routes/github.js +169 -107
  14. package/dist/routes/github.js.map +1 -1
  15. package/dist/routes/groups.js +9 -14
  16. package/dist/routes/groups.js.map +1 -1
  17. package/dist/routes/pm.js +532 -1015
  18. package/dist/routes/pm.js.map +1 -1
  19. package/dist/routes/projects.js +10 -6
  20. package/dist/routes/projects.js.map +1 -1
  21. package/dist/routes/route-helpers.d.ts +74 -0
  22. package/dist/routes/route-helpers.js +85 -0
  23. package/dist/routes/route-helpers.js.map +1 -0
  24. package/dist/routes/sharing.js +30 -18
  25. package/dist/routes/sharing.js.map +1 -1
  26. package/dist/server.js +1 -20
  27. package/dist/server.js.map +1 -1
  28. package/dist/services/mutation-event-watcher.js +3 -28
  29. package/dist/services/mutation-event-watcher.js.map +1 -1
  30. package/dist/services/pm-runner.js +3 -1
  31. package/dist/services/pm-runner.js.map +1 -1
  32. package/dist/services/project-watcher.js +14 -38
  33. package/dist/services/project-watcher.js.map +1 -1
  34. package/dist/services/watcher-utils.d.ts +42 -0
  35. package/dist/services/watcher-utils.js +60 -0
  36. package/dist/services/watcher-utils.js.map +1 -0
  37. package/manifest.json +2 -2
  38. package/package.json +17 -6
  39. package/public/src/app.js +22 -52
  40. package/public/src/app.js.map +1 -1
  41. package/public/src/app.ts +18 -75
  42. package/public/src/browser-window.js +29 -0
  43. package/public/src/browser-window.js.map +1 -0
  44. package/public/src/browser-window.ts +56 -0
  45. package/public/src/components/modals.js +1 -1
  46. package/public/src/components/modals.js.map +1 -1
  47. package/public/src/components/modals.ts +1 -1
  48. package/public/src/i18n.js +14 -6
  49. package/public/src/i18n.js.map +1 -1
  50. package/public/src/i18n.ts +16 -6
  51. package/public/src/sw.ts +1 -0
  52. package/public/src/utils.js +58 -0
  53. package/public/src/utils.js.map +1 -1
  54. package/public/src/utils.ts +53 -0
  55. package/public/src/views/admin.js +2 -5
  56. package/public/src/views/admin.js.map +1 -1
  57. package/public/src/views/admin.ts +2 -2
  58. package/public/src/views/auth.js +5 -1
  59. package/public/src/views/auth.js.map +1 -1
  60. package/public/src/views/auth.ts +6 -1
  61. package/public/src/views/calendar.js +1 -2
  62. package/public/src/views/calendar.js.map +1 -1
  63. package/public/src/views/calendar.ts +1 -2
  64. package/public/src/views/config.js +14 -21
  65. package/public/src/views/config.js.map +1 -1
  66. package/public/src/views/config.ts +14 -19
  67. package/public/src/views/context.js +11 -12
  68. package/public/src/views/context.js.map +1 -1
  69. package/public/src/views/context.ts +13 -12
  70. package/public/src/views/create.js +3 -11
  71. package/public/src/views/create.js.map +1 -1
  72. package/public/src/views/create.ts +3 -3
  73. package/public/src/views/export.js +2 -3
  74. package/public/src/views/export.js.map +1 -1
  75. package/public/src/views/export.ts +2 -3
  76. package/public/src/views/github.js +33 -47
  77. package/public/src/views/github.js.map +1 -1
  78. package/public/src/views/github.ts +35 -28
  79. package/public/src/views/graph-canvas.js +92 -99
  80. package/public/src/views/graph-canvas.js.map +1 -1
  81. package/public/src/views/graph-canvas.ts +94 -93
  82. package/public/src/views/graph.js +88 -73
  83. package/public/src/views/graph.js.map +1 -1
  84. package/public/src/views/graph.ts +97 -63
  85. package/public/src/views/groups.js +2 -5
  86. package/public/src/views/groups.js.map +1 -1
  87. package/public/src/views/groups.ts +2 -2
  88. package/public/src/views/guide.js +11 -10
  89. package/public/src/views/guide.js.map +1 -1
  90. package/public/src/views/guide.ts +12 -10
  91. package/public/src/views/items.js +100 -130
  92. package/public/src/views/items.js.map +1 -1
  93. package/public/src/views/items.ts +99 -117
  94. package/public/src/views/normalize.js +2 -9
  95. package/public/src/views/normalize.js.map +1 -1
  96. package/public/src/views/normalize.ts +2 -9
  97. package/public/src/views/packages.js +7 -4
  98. package/public/src/views/packages.js.map +1 -1
  99. package/public/src/views/packages.ts +8 -4
  100. package/public/src/views/plan-execution.js.map +1 -1
  101. package/public/src/views/plan-execution.ts +5 -15
  102. package/public/src/views/plan.js +2 -1
  103. package/public/src/views/plan.js.map +1 -1
  104. package/public/src/views/plan.ts +6 -17
  105. package/public/src/views/projects.js +5 -6
  106. package/public/src/views/projects.js.map +1 -1
  107. package/public/src/views/projects.ts +5 -6
  108. package/public/src/views/search.js +5 -1
  109. package/public/src/views/search.js.map +1 -1
  110. package/public/src/views/search.ts +7 -1
  111. package/public/src/views/settings.js +30 -55
  112. package/public/src/views/settings.js.map +1 -1
  113. package/public/src/views/settings.ts +23 -17
  114. package/public/src/views/shared.js +2 -5
  115. package/public/src/views/shared.js.map +1 -1
  116. package/public/src/views/shared.ts +2 -5
  117. package/public/src/views/sharing.js +2 -5
  118. package/public/src/views/sharing.js.map +1 -1
  119. package/public/src/views/sharing.ts +2 -2
  120. package/public/src/views/templates.js +9 -16
  121. package/public/src/views/templates.js.map +1 -1
  122. package/public/src/views/templates.ts +9 -14
  123. package/public/src/views/validate.js +2 -9
  124. package/public/src/views/validate.js.map +1 -1
  125. package/public/src/views/validate.ts +2 -9
  126. package/public/sw.js +1 -0
package/dist/routes/pm.js CHANGED
@@ -470,12 +470,7 @@ async function pmGraphExtensionGraphForProject(project) {
470
470
  if (!provision.ok) {
471
471
  return { error: provision.error };
472
472
  }
473
- const extensionResult = await runPm({
474
- args: ["pm-graph", "export", "--json"],
475
- userId: project.ownerUserId,
476
- slug: project.slug,
477
- jsonOutput: false,
478
- });
473
+ const extensionResult = await projectPm(project, ["pm-graph", "export", "--json"], false);
479
474
  let extensionData;
480
475
  if (extensionResult.ok && extensionResult.stdout) {
481
476
  try {
@@ -502,16 +497,223 @@ async function verifyProject(userId, projectId) {
502
497
  return null;
503
498
  return { slug: access.slug, prefix: access.prefix, ownerUserId: access.ownerUserId };
504
499
  }
505
- // GET /api/projects/:projectId/pm/schema
506
- // Returns runtime types/statuses from `pm contracts --json` so the frontend
507
- // stays in sync with whatever pm CLI version + extensions are installed.
508
- router.get("/schema", async (req, res) => {
500
+ /**
501
+ * Load the caller's project or answer 404.
502
+ *
503
+ * The route layer repeats this access check before every pm invocation. One
504
+ * helper keeps the status and error body identical without copying the lookup.
505
+ *
506
+ * @param req - Authenticated request carrying the project id.
507
+ * @param res - Response used for the not-found body.
508
+ * @returns The project reference, or undefined after a 404 was sent.
509
+ */
510
+ async function requireProject(req, res) {
509
511
  const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
510
512
  if (!project) {
511
513
  res.status(404).json({ error: "Project not found" });
514
+ return undefined;
515
+ }
516
+ return project;
517
+ }
518
+ /**
519
+ * Run pm in the verified project's workspace.
520
+ *
521
+ * @param project - Owner and slug that locate the workspace.
522
+ * @param args - pm arguments, already validated by the route.
523
+ * @param jsonOutput - When set, request JSON output; omit to keep the CLI default.
524
+ * @returns The pm invocation result.
525
+ */
526
+ function projectPm(project, args, jsonOutput) {
527
+ return runPm({
528
+ args,
529
+ userId: project.ownerUserId,
530
+ slug: project.slug,
531
+ ...(jsonOutput === undefined ? {} : { jsonOutput }),
532
+ });
533
+ }
534
+ /**
535
+ * Shared body of the cursor-paginated list endpoints.
536
+ *
537
+ * Copies the base arguments, appends each filter flag whose query value is
538
+ * present, validates the `after` cursor (sending the shared 400 body when it
539
+ * is malformed), and maps a USAGE exit to a 400. Returns the command result
540
+ * so the caller can send its success or failure payload, or `undefined`
541
+ * after an error response has already been sent.
542
+ *
543
+ * @param res - Response used for the cursor and USAGE error bodies.
544
+ * @param project - Verified project that owns the workspace.
545
+ * @param baseArgs - Command and fixed arguments; copied, never mutated.
546
+ * @param flags - Ordered `[flag, value]` pairs appended when `value` is present.
547
+ * @param after - Raw `after` query value to validate as a pagination cursor.
548
+ * @returns The pm result, or `undefined` after an error response was sent.
549
+ */
550
+ async function runCursorList(res, project, baseArgs, flags, after) {
551
+ const args = [...baseArgs];
552
+ for (const [flag, value] of flags) {
553
+ if (value)
554
+ args.push(flag, value);
555
+ }
556
+ const cursorResult = validateCursor(after);
557
+ if (cursorResult.error) {
558
+ res.status(400).json({ error: cursorResult.error, items: [] });
559
+ return undefined;
560
+ }
561
+ if (cursorResult.cursor)
562
+ args.push("--after", cursorResult.cursor);
563
+ const result = await projectPm(project, args, true);
564
+ if (!result.ok && result.exitCode === EXIT_CODE.USAGE) {
565
+ res.status(400).json({ error: result.stderr, items: [] });
566
+ return undefined;
567
+ }
568
+ return result;
569
+ }
570
+ /**
571
+ * Run a mutating pm command and send the shared failure response.
572
+ *
573
+ * Every item and plan mutation maps a failed pm run to
574
+ * `pmErrorStatus(result)` with `result.stderr || fallback` as the body.
575
+ * Centralising it keeps the status mapping identical everywhere while the
576
+ * per-route fallback text stays at the call site.
577
+ *
578
+ * @param res - Response used for the failure body.
579
+ * @param project - Verified project that owns the workspace.
580
+ * @param args - pm arguments, already validated by the route.
581
+ * @param fallback - Error text used when pm wrote nothing to stderr.
582
+ * @param jsonOutput - Request JSON output (default); pass `false` to keep the
583
+ * CLI default output, as the raw delete invocation does.
584
+ * @returns The successful pm result, or `undefined` after the error response.
585
+ */
586
+ async function runMutation(res, project, args, fallback, jsonOutput = true) {
587
+ const result = await projectPm(project, args, jsonOutput);
588
+ if (!result.ok) {
589
+ res.status(pmErrorStatus(result)).json({ error: result.stderr || fallback });
590
+ return undefined;
591
+ }
592
+ return result;
593
+ }
594
+ /**
595
+ * Broadcast an item lifecycle event to the project's SSE clients and queue a
596
+ * graph sync for the same project.
597
+ *
598
+ * @param req - Authenticated request carrying the project and user ids.
599
+ * @param project - Verified project that owns the workspace.
600
+ * @param type - Event name to broadcast and graph-sync reason.
601
+ * @param itemId - Item the event is about; defaults to the route's `itemId`.
602
+ */
603
+ function emitItemEvent(req, project, type, itemId = routeParam(req, "itemId")) {
604
+ broadcastProjectEvent(routeParam(req, "projectId"), {
605
+ type,
606
+ data: { itemId, userId: req.user.userId },
607
+ });
608
+ scheduleGraphSync(routeParam(req, "projectId"), project, type);
609
+ }
610
+ /**
611
+ * Queue a graph sync and broadcast a dependency event for one graph edge.
612
+ *
613
+ * The four dependency mutation routes (deps and rel, add and remove) all pair
614
+ * the same two side effects with the same payload shape; only the sync reason
615
+ * and the broadcast event name vary between them.
616
+ *
617
+ * @param req - Authenticated request carrying the project and user ids.
618
+ * @param project - Verified project that owns the workspace.
619
+ * @param syncReason - Graph-sync queue reason for this mutation.
620
+ * @param event - SSE dependency event name to broadcast.
621
+ * @param from - Source item id of the edge.
622
+ * @param to - Target item id of the edge.
623
+ * @param rel - Dependency kind of the edge.
624
+ */
625
+ function emitDependencyEvent(req, project, syncReason, event, from, to, rel) {
626
+ scheduleGraphSync(routeParam(req, "projectId"), project, syncReason);
627
+ broadcastDependencyEvent(routeParam(req, "projectId"), event, {
628
+ from,
629
+ to,
630
+ rel,
631
+ userId: req.user.userId,
632
+ });
633
+ }
634
+ /**
635
+ * Run a plan mutation and complete the whole response.
636
+ *
637
+ * The plan step routes share one response shape end to end: map a failure via
638
+ * {@link pmErrorStatus} with the per-route fallback text, broadcast an
639
+ * `item-updated` event naming the plan id, and answer with the parsed result.
640
+ * This helper performs all of it; the caller supplies only the arguments and
641
+ * the failure text, and nothing after the call.
642
+ *
643
+ * @param req - Authenticated request carrying the project, plan and user ids.
644
+ * @param res - Response used for both the failure and success bodies.
645
+ * @param project - Verified project that owns the workspace.
646
+ * @param args - pm arguments, already validated by the route.
647
+ * @param fallback - Error text used when pm wrote nothing to stderr.
648
+ * @param successStatus - HTTP status for the success response (default 200).
649
+ */
650
+ async function runPlanMutation(req, res, project, args, fallback, successStatus = 200) {
651
+ const result = await runMutation(res, project, args, fallback);
652
+ if (!result)
512
653
  return;
654
+ broadcastProjectEvent(routeParam(req, "projectId"), {
655
+ type: "item-updated",
656
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
657
+ });
658
+ res.status(successStatus).json(result.parsed || {});
659
+ }
660
+ /** Validate a `text` body field and run a mutation with it. Used by the
661
+ * comments, notes, append and learnings POST routes that all share the same
662
+ * body shape and error-handling pattern. */
663
+ async function runTextBodyMutation(req, res, project, cmd, errorMsg, failedMsg) {
664
+ const { text } = req.body;
665
+ if (!text?.trim()) {
666
+ res.status(400).json({ error: errorMsg });
667
+ return undefined;
668
+ }
669
+ return runMutation(res, project, [cmd, routeParam(req, "itemId"), text.trim()], failedMsg);
670
+ }
671
+ /** Validate a `reason` body field and run a mutation with it. Used by the
672
+ * close and close-task POST routes that share the same body shape. */
673
+ async function runReasonBodyMutation(req, res, project, cmd, errorMsg, failedMsg) {
674
+ const { reason } = req.body;
675
+ if (!reason?.trim()) {
676
+ res.status(400).json({ error: errorMsg });
677
+ return undefined;
678
+ }
679
+ return runMutation(res, project, [cmd, routeParam(req, "itemId"), reason.trim()], failedMsg);
680
+ }
681
+ /** Validate the `targetId`/`rel` body fields for deps add/remove routes and
682
+ * return the normalized dependency kind. */
683
+ function parseDepBody(req, res, defaultRel) {
684
+ const { targetId, rel } = req.body;
685
+ if (!targetId?.trim()) {
686
+ res.status(400).json({ error: "targetId is required" });
687
+ return undefined;
688
+ }
689
+ return { targetId: targetId.trim(), depRel: normalizeDependencyKind(rel || defaultRel) };
690
+ }
691
+ /** Validate the `from`/`to`/`type` body fields for rel add/remove routes and
692
+ * return the normalized values. */
693
+ function parseRelBody(req, res) {
694
+ const { from, to, type: relType } = req.body;
695
+ if (!from?.trim() || !to?.trim()) {
696
+ res.status(400).json({ error: "from and to item IDs are required" });
697
+ return undefined;
513
698
  }
514
- const result = await runPm({ args: ["contracts", "--json"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
699
+ return { from: from.trim(), to: to.trim(), depRel: normalizeDependencyKind(relType || "relates_to") };
700
+ }
701
+ /** Push `--title` and `--description` args from a plan/step PATCH body when
702
+ * present. Used by the plan and step update routes. */
703
+ function pushTitleDescArgs(args, body) {
704
+ if (body.title?.trim())
705
+ args.push("--title", body.title.trim());
706
+ if (body.description !== undefined)
707
+ args.push("--description", body.description);
708
+ }
709
+ // GET /api/projects/:projectId/pm/schema
710
+ // Returns runtime types/statuses from `pm contracts --json` so the frontend
711
+ // stays in sync with whatever pm CLI version + extensions are installed.
712
+ router.get("/schema", async (req, res) => {
713
+ const project = await requireProject(req, res);
714
+ if (!project)
715
+ return;
716
+ const result = await projectPm(project, ["contracts", "--json"], true);
515
717
  const contracts = result.ok && result.parsed ? result.parsed : null;
516
718
  const rt = contracts?.["runtime_schema"];
517
719
  res.json({
@@ -568,11 +770,9 @@ function isSafeTypeFolder(folder) {
568
770
  * collaborators with 403 before this handler runs.
569
771
  */
570
772
  router.post("/schema/add-type", async (req, res) => {
571
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
572
- if (!project) {
573
- res.status(404).json({ error: "Project not found" });
773
+ const project = await requireProject(req, res);
774
+ if (!project)
574
775
  return;
575
- }
576
776
  const body = req.body;
577
777
  const name = typeof body.name === "string" ? body.name.trim() : "";
578
778
  if (!name) {
@@ -607,7 +807,7 @@ router.post("/schema/add-type", async (req, res) => {
607
807
  args.push("--alias", alias.trim());
608
808
  }
609
809
  }
610
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
810
+ const result = await projectPm(project, args, true);
611
811
  if (!result.ok) {
612
812
  res.status(400).json({ error: result.stderr || "pm schema add-type failed" });
613
813
  return;
@@ -623,11 +823,9 @@ router.post("/schema/add-type", async (req, res) => {
623
823
  * Dry Run controls against a route that was never mounted.
624
824
  */
625
825
  router.post("/items/:itemId/history-repair", async (req, res) => {
626
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
627
- if (!project) {
628
- res.status(404).json({ error: "Project not found" });
826
+ const project = await requireProject(req, res);
827
+ if (!project)
629
828
  return;
630
- }
631
829
  const itemId = routeParam(req, "itemId").trim();
632
830
  if (!itemId) {
633
831
  res.status(400).json({ error: "itemId is required" });
@@ -640,7 +838,7 @@ router.post("/items/:itemId/history-repair", async (req, res) => {
640
838
  const args = ["history-repair", itemId];
641
839
  if (req.body?.dryRun === true)
642
840
  args.push("--dry-run");
643
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
841
+ const result = await projectPm(project, args, true);
644
842
  if (!result.ok) {
645
843
  res.status(400).json({ error: result.stderr || "pm history-repair failed" });
646
844
  return;
@@ -649,81 +847,48 @@ router.post("/items/:itemId/history-repair", async (req, res) => {
649
847
  });
650
848
  // GET /api/projects/:projectId/pm/list
651
849
  router.get("/list", async (req, res) => {
652
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
653
- if (!project) {
654
- res.status(404).json({ error: "Project not found" });
850
+ const project = await requireProject(req, res);
851
+ if (!project)
655
852
  return;
656
- }
657
853
  const { status, type, limit, priority, sprint, release, assignee, after } = req.query;
658
- const args = ["list"];
659
- if (status)
660
- args.push("--status", status);
661
- if (type)
662
- args.push("--type", type);
663
- if (limit)
664
- args.push("--limit", limit);
665
- if (priority)
666
- args.push("--priority", priority);
667
- if (sprint)
668
- args.push("--sprint", sprint);
669
- if (release)
670
- args.push("--release", release);
671
- if (assignee)
672
- args.push("--assignee", assignee);
673
- const cursorResult = validateCursor(after);
674
- if (cursorResult.error) {
675
- res.status(400).json({ error: cursorResult.error, items: [] });
676
- return;
677
- }
678
- if (cursorResult.cursor)
679
- args.push("--after", cursorResult.cursor);
680
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
681
- if (!result.ok && result.exitCode === EXIT_CODE.USAGE) {
682
- res.status(400).json({ error: result.stderr, items: [] });
854
+ const result = await runCursorList(res, project, ["list"], [
855
+ ["--status", status],
856
+ ["--type", type],
857
+ ["--limit", limit],
858
+ ["--priority", priority],
859
+ ["--sprint", sprint],
860
+ ["--release", release],
861
+ ["--assignee", assignee],
862
+ ], after);
863
+ if (!result)
683
864
  return;
684
- }
685
865
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
686
866
  });
687
867
  // GET /api/projects/:projectId/pm/list-all
688
868
  router.get("/list-all", async (req, res) => {
689
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
690
- if (!project) {
691
- res.status(404).json({ error: "Project not found" });
869
+ const project = await requireProject(req, res);
870
+ if (!project)
692
871
  return;
693
- }
694
872
  const { type, limit, after } = req.query;
695
873
  // Preserve the public HTTP compatibility route while invoking the canonical
696
874
  // CLI/SDK command internally. This route is intentionally paginated and is
697
875
  // therefore distinct from readCompletePmItems used by whole-corpus views.
698
- const args = ["list", "--all"];
699
- if (type)
700
- args.push("--type", type);
701
- if (limit)
702
- args.push("--limit", limit);
703
- const cursorResult = validateCursor(after);
704
- if (cursorResult.error) {
705
- res.status(400).json({ error: cursorResult.error, items: [] });
706
- return;
707
- }
708
- if (cursorResult.cursor)
709
- args.push("--after", cursorResult.cursor);
710
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
711
- if (!result.ok && result.exitCode === EXIT_CODE.USAGE) {
712
- res.status(400).json({ error: result.stderr, items: [] });
876
+ const result = await runCursorList(res, project, ["list", "--all"], [
877
+ ["--type", type],
878
+ ["--limit", limit],
879
+ ], after);
880
+ if (!result)
713
881
  return;
714
- }
715
882
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
716
883
  });
717
884
  // GET /api/projects/:projectId/pm/board
718
885
  // Kanban board: items grouped into columns by the workspace's runtime statuses
719
886
  // (read live from `pm contracts`) so the board matches the installed CLI.
720
887
  router.get("/board", async (req, res) => {
721
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
722
- if (!project) {
723
- res.status(404).json({ error: "Project not found" });
888
+ const project = await requireProject(req, res);
889
+ if (!project)
724
890
  return;
725
- }
726
- const contracts = await runPm({ args: ["contracts", "--json"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
891
+ const contracts = await projectPm(project, ["contracts", "--json"], true);
727
892
  const rt = contracts.ok && contracts.parsed
728
893
  ? contracts.parsed["runtime_schema"]
729
894
  : undefined;
@@ -739,11 +904,9 @@ router.get("/board", async (req, res) => {
739
904
  // GET /api/projects/:projectId/pm/search?q=...
740
905
  // Full-text search over id/title/tags/body via one certified complete read.
741
906
  router.get("/search", async (req, res) => {
742
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
743
- if (!project) {
744
- res.status(404).json({ error: "Project not found" });
907
+ const project = await requireProject(req, res);
908
+ if (!project)
745
909
  return;
746
- }
747
910
  const query = String(req.query["q"] ?? "");
748
911
  const listed = await readCompletePmItems(project.ownerUserId, project.slug, true);
749
912
  if (!listed.ok) {
@@ -755,11 +918,9 @@ router.get("/search", async (req, res) => {
755
918
  });
756
919
  // POST /api/projects/:projectId/pm/create
757
920
  router.post("/create", async (req, res) => {
758
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
759
- if (!project) {
760
- res.status(404).json({ error: "Project not found" });
921
+ const project = await requireProject(req, res);
922
+ if (!project)
761
923
  return;
762
- }
763
924
  const { title, type, priority, description, tags, parent, deadline, assignee, sprint, release, estimate, body, acceptanceCriteria, reporter, component, severity, risk, goal, objective, environment, "blocked-by": blockedBy, "blocked-reason": blockedReason, "repro-steps": reproSteps, "expected-result": expectedResult, "actual-result": actualResult, reviewer, confidence, "why-now": whyNow, value, impact, outcome, "definition-of-ready": definitionOfReady, } = req.body;
764
925
  if (!title?.trim()) {
765
926
  res.status(400).json({ error: "Title is required" });
@@ -828,7 +989,7 @@ router.post("/create", async (req, res) => {
828
989
  args.push("--outcome", outcome);
829
990
  if (definitionOfReady)
830
991
  args.push("--definition-of-ready", definitionOfReady);
831
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
992
+ const result = await projectPm(project, args, true);
832
993
  if (!result.ok) {
833
994
  res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to create item" });
834
995
  return;
@@ -843,17 +1004,10 @@ router.post("/create", async (req, res) => {
843
1004
  });
844
1005
  // GET /api/projects/:projectId/pm/get/:itemId
845
1006
  router.get("/get/:itemId", async (req, res) => {
846
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
847
- if (!project) {
848
- res.status(404).json({ error: "Project not found" });
1007
+ const project = await requireProject(req, res);
1008
+ if (!project)
849
1009
  return;
850
- }
851
- const result = await runPm({
852
- args: ["get", routeParam(req, "itemId")],
853
- userId: project.ownerUserId,
854
- slug: project.slug,
855
- jsonOutput: true,
856
- });
1010
+ const result = await projectPm(project, ["get", routeParam(req, "itemId")], true);
857
1011
  if (!result.ok) {
858
1012
  res.status(404).json({ error: "Item not found" });
859
1013
  return;
@@ -870,11 +1024,9 @@ router.get("/get/:itemId", async (req, res) => {
870
1024
  // 400 — invalid ref, or version/timestamp outside the available history range
871
1025
  // 404 — unknown item, or item with no recorded history
872
1026
  router.get("/at/:itemId/:ref", async (req, res) => {
873
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
874
- if (!project) {
875
- res.status(404).json({ error: "Project not found" });
1027
+ const project = await requireProject(req, res);
1028
+ if (!project)
876
1029
  return;
877
- }
878
1030
  const itemId = routeParam(req, "itemId");
879
1031
  const ref = routeParam(req, "ref");
880
1032
  try {
@@ -912,11 +1064,9 @@ router.get("/at/:itemId/:ref", async (req, res) => {
912
1064
  });
913
1065
  // PATCH /api/projects/:projectId/pm/update/:itemId
914
1066
  router.patch("/update/:itemId", async (req, res) => {
915
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
916
- if (!project) {
917
- res.status(404).json({ error: "Project not found" });
1067
+ const project = await requireProject(req, res);
1068
+ if (!project)
918
1069
  return;
919
- }
920
1070
  const body = req.body;
921
1071
  const args = ["update", routeParam(req, "itemId")];
922
1072
  // String options
@@ -945,218 +1095,115 @@ router.patch("/update/:itemId", async (req, res) => {
945
1095
  // Type can be set but must use --type
946
1096
  if (body.type)
947
1097
  args.push("--type", body.type);
948
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
949
- if (!result.ok) {
950
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to update item" });
1098
+ const result = await runMutation(res, project, args, "Failed to update item");
1099
+ if (!result)
951
1100
  return;
952
- }
953
1101
  // Broadcast SSE update event
954
- broadcastProjectEvent(routeParam(req, "projectId"), {
955
- type: "item-updated",
956
- data: { itemId: routeParam(req, "itemId"), userId: req.user.userId },
957
- });
958
- scheduleGraphSync(routeParam(req, "projectId"), project, "item-updated");
1102
+ emitItemEvent(req, project, "item-updated");
959
1103
  res.json(result.parsed || {});
960
1104
  });
961
1105
  // POST /api/projects/:projectId/pm/close/:itemId
962
1106
  router.post("/close/:itemId", async (req, res) => {
963
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
964
- if (!project) {
965
- res.status(404).json({ error: "Project not found" });
966
- return;
967
- }
968
- const { reason } = req.body;
969
- if (!reason?.trim()) {
970
- res.status(400).json({ error: "Close reason is required" });
1107
+ const project = await requireProject(req, res);
1108
+ if (!project)
971
1109
  return;
972
- }
973
- const result = await runPm({
974
- args: ["close", routeParam(req, "itemId"), reason.trim()],
975
- userId: project.ownerUserId,
976
- slug: project.slug,
977
- jsonOutput: true,
978
- });
979
- if (!result.ok) {
980
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to close item" });
1110
+ const result = await runReasonBodyMutation(req, res, project, "close", "Close reason is required", "Failed to close item");
1111
+ if (!result)
981
1112
  return;
982
- }
983
- broadcastProjectEvent(routeParam(req, "projectId"), {
984
- type: "item-closed",
985
- data: { itemId: routeParam(req, "itemId"), userId: req.user.userId },
986
- });
987
- scheduleGraphSync(routeParam(req, "projectId"), project, "item-closed");
1113
+ emitItemEvent(req, project, "item-closed");
988
1114
  res.json(result.parsed || {});
989
1115
  });
990
1116
  // DELETE /api/projects/:projectId/pm/delete/:itemId
991
1117
  router.delete("/delete/:itemId", async (req, res) => {
992
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
993
- if (!project) {
994
- res.status(404).json({ error: "Project not found" });
1118
+ const project = await requireProject(req, res);
1119
+ if (!project)
995
1120
  return;
996
- }
997
- const result = await runPm({
998
- args: ["delete", routeParam(req, "itemId"), "--yes"],
999
- userId: project.ownerUserId,
1000
- slug: project.slug,
1001
- });
1002
- if (!result.ok) {
1003
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to delete item" });
1121
+ const result = await runMutation(res, project, ["delete", routeParam(req, "itemId"), "--yes"], "Failed to delete item", false);
1122
+ if (!result)
1004
1123
  return;
1005
- }
1006
- broadcastProjectEvent(routeParam(req, "projectId"), {
1007
- type: "item-deleted",
1008
- data: { itemId: routeParam(req, "itemId"), userId: req.user.userId },
1009
- });
1010
- scheduleGraphSync(routeParam(req, "projectId"), project, "item-deleted");
1124
+ emitItemEvent(req, project, "item-deleted");
1011
1125
  res.json({ ok: true });
1012
1126
  });
1013
1127
  // POST /api/projects/:projectId/pm/comments/:itemId
1014
1128
  router.post("/comments/:itemId", async (req, res) => {
1015
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1016
- if (!project) {
1017
- res.status(404).json({ error: "Project not found" });
1018
- return;
1019
- }
1020
- const { text } = req.body;
1021
- if (!text?.trim()) {
1022
- res.status(400).json({ error: "Comment text is required" });
1129
+ const project = await requireProject(req, res);
1130
+ if (!project)
1023
1131
  return;
1024
- }
1025
- const result = await runPm({
1026
- args: ["comments", routeParam(req, "itemId"), text.trim()],
1027
- userId: project.ownerUserId,
1028
- slug: project.slug,
1029
- jsonOutput: true,
1030
- });
1031
- if (!result.ok) {
1032
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add comment" });
1132
+ const result = await runTextBodyMutation(req, res, project, "comments", "Comment text is required", "Failed to add comment");
1133
+ if (!result)
1033
1134
  return;
1034
- }
1035
1135
  res.status(201).json(result.parsed || { ok: true });
1036
1136
  });
1037
1137
  // GET /api/projects/:projectId/pm/comments/:itemId
1038
1138
  router.get("/comments/:itemId", async (req, res) => {
1039
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1040
- if (!project) {
1041
- res.status(404).json({ error: "Project not found" });
1139
+ const project = await requireProject(req, res);
1140
+ if (!project)
1042
1141
  return;
1043
- }
1044
- const result = await runPm({
1045
- args: ["comments", routeParam(req, "itemId")],
1046
- userId: project.ownerUserId,
1047
- slug: project.slug,
1048
- jsonOutput: true,
1049
- });
1142
+ const result = await projectPm(project, ["comments", routeParam(req, "itemId")], true);
1050
1143
  res.json(result.ok ? (result.parsed || {}) : { comments: [] });
1051
1144
  });
1052
1145
  // GET /api/projects/:projectId/pm/notes/:itemId
1053
1146
  router.get("/notes/:itemId", async (req, res) => {
1054
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1055
- if (!project) {
1056
- res.status(404).json({ error: "Project not found" });
1147
+ const project = await requireProject(req, res);
1148
+ if (!project)
1057
1149
  return;
1058
- }
1059
- const result = await runPm({
1060
- args: ["notes", routeParam(req, "itemId")],
1061
- userId: project.ownerUserId,
1062
- slug: project.slug,
1063
- jsonOutput: true,
1064
- });
1150
+ const result = await projectPm(project, ["notes", routeParam(req, "itemId")], true);
1065
1151
  res.json(result.ok ? (result.parsed || {}) : { notes: [] });
1066
1152
  });
1067
1153
  // POST /api/projects/:projectId/pm/notes/:itemId
1068
1154
  router.post("/notes/:itemId", async (req, res) => {
1069
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1070
- if (!project) {
1071
- res.status(404).json({ error: "Project not found" });
1072
- return;
1073
- }
1074
- const { text } = req.body;
1075
- if (!text?.trim()) {
1076
- res.status(400).json({ error: "Note text is required" });
1155
+ const project = await requireProject(req, res);
1156
+ if (!project)
1077
1157
  return;
1078
- }
1079
- const result = await runPm({
1080
- args: ["notes", routeParam(req, "itemId"), text.trim()],
1081
- userId: project.ownerUserId,
1082
- slug: project.slug,
1083
- jsonOutput: true,
1084
- });
1085
- if (!result.ok) {
1086
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add note" });
1158
+ const result = await runTextBodyMutation(req, res, project, "notes", "Note text is required", "Failed to add note");
1159
+ if (!result)
1087
1160
  return;
1088
- }
1089
1161
  res.status(201).json(result.parsed || { ok: true });
1090
1162
  });
1091
1163
  // GET /api/projects/:projectId/pm/context
1092
1164
  router.get("/context", async (req, res) => {
1093
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1094
- if (!project) {
1095
- res.status(404).json({ error: "Project not found" });
1165
+ const project = await requireProject(req, res);
1166
+ if (!project)
1096
1167
  return;
1097
- }
1098
1168
  const { depth } = req.query;
1099
1169
  const validDepths = ["brief", "standard", "deep"];
1100
1170
  const resolvedDepth = validDepths.includes(depth) ? depth : "standard";
1101
- const result = await runPm({
1102
- args: ["context", "--depth", resolvedDepth],
1103
- userId: project.ownerUserId,
1104
- slug: project.slug,
1105
- jsonOutput: true,
1106
- });
1171
+ const result = await projectPm(project, ["context", "--depth", resolvedDepth], true);
1107
1172
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1108
1173
  });
1109
1174
  // GET /api/projects/:projectId/pm/activity
1110
1175
  router.get("/activity", async (req, res) => {
1111
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1112
- if (!project) {
1113
- res.status(404).json({ error: "Project not found" });
1176
+ const project = await requireProject(req, res);
1177
+ if (!project)
1114
1178
  return;
1115
- }
1116
1179
  const { limit } = req.query;
1117
1180
  const args = ["activity"];
1118
1181
  if (limit)
1119
1182
  args.push("--limit", limit);
1120
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1183
+ const result = await projectPm(project, args, true);
1121
1184
  res.json(result.ok ? (result.parsed || {}) : { activity: [] });
1122
1185
  });
1123
1186
  // GET /api/projects/:projectId/pm/stats
1124
1187
  router.get("/stats", async (req, res) => {
1125
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1126
- if (!project) {
1127
- res.status(404).json({ error: "Project not found" });
1188
+ const project = await requireProject(req, res);
1189
+ if (!project)
1128
1190
  return;
1129
- }
1130
- const result = await runPm({
1131
- args: ["stats"],
1132
- userId: project.ownerUserId,
1133
- slug: project.slug,
1134
- jsonOutput: true,
1135
- });
1191
+ const result = await projectPm(project, ["stats"], true);
1136
1192
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1137
1193
  });
1138
1194
  // GET /api/projects/:projectId/pm/aggregate
1139
1195
  router.get("/aggregate", async (req, res) => {
1140
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1141
- if (!project) {
1142
- res.status(404).json({ error: "Project not found" });
1196
+ const project = await requireProject(req, res);
1197
+ if (!project)
1143
1198
  return;
1144
- }
1145
- const result = await runPm({
1146
- args: ["aggregate"],
1147
- userId: project.ownerUserId,
1148
- slug: project.slug,
1149
- jsonOutput: true,
1150
- });
1199
+ const result = await projectPm(project, ["aggregate"], true);
1151
1200
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1152
1201
  });
1153
1202
  // POST /api/projects/:projectId/pm/search
1154
1203
  router.post("/search", async (req, res) => {
1155
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1156
- if (!project) {
1157
- res.status(404).json({ error: "Project not found" });
1204
+ const project = await requireProject(req, res);
1205
+ if (!project)
1158
1206
  return;
1159
- }
1160
1207
  const body = req.body;
1161
1208
  const query = typeof body["query"] === "string" ? body["query"] : "";
1162
1209
  const mode = typeof body["mode"] === "string" ? body["mode"] : "";
@@ -1184,12 +1231,7 @@ router.post("/search", async (req, res) => {
1184
1231
  if (cursorResult.cursor)
1185
1232
  args.push("--after", cursorResult.cursor);
1186
1233
  args.push("--", ...query.trim().split(/\s+/));
1187
- const result = await runPm({
1188
- args,
1189
- userId: project.ownerUserId,
1190
- slug: project.slug,
1191
- jsonOutput: true,
1192
- });
1234
+ const result = await projectPm(project, args, true);
1193
1235
  if (!result.ok) {
1194
1236
  res.status(pmErrorStatus(result)).json({
1195
1237
  error: result.stderr || "Search failed. Check that Ollama is reachable and the configured embedding model is available.",
@@ -1201,17 +1243,10 @@ router.post("/search", async (req, res) => {
1201
1243
  });
1202
1244
  // GET /api/projects/:projectId/pm/calendar
1203
1245
  router.get("/calendar", async (req, res) => {
1204
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1205
- if (!project) {
1206
- res.status(404).json({ error: "Project not found" });
1246
+ const project = await requireProject(req, res);
1247
+ if (!project)
1207
1248
  return;
1208
- }
1209
- const result = await runPm({
1210
- args: ["calendar", "--view", "month"],
1211
- userId: project.ownerUserId,
1212
- slug: project.slug,
1213
- jsonOutput: true,
1214
- });
1249
+ const result = await projectPm(project, ["calendar", "--view", "month"], true);
1215
1250
  res.json(result.ok ? (result.parsed || {}) : { events: [] });
1216
1251
  });
1217
1252
  // GET /api/projects/:projectId/pm/calendar.ics
@@ -1220,11 +1255,9 @@ router.get("/calendar", async (req, res) => {
1220
1255
  // Auth works via the usual token (header/cookie) or a
1221
1256
  // `?token=` query param, since calendar clients cannot send cookies.
1222
1257
  router.get("/calendar.ics", async (req, res) => {
1223
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1224
- if (!project) {
1225
- res.status(404).json({ error: "Project not found" });
1258
+ const project = await requireProject(req, res);
1259
+ if (!project)
1226
1260
  return;
1227
- }
1228
1261
  const listed = await readCompletePmItems(project.ownerUserId, project.slug);
1229
1262
  if (!listed.ok) {
1230
1263
  res.status(502).json({ error: listed.stderr || "Failed to load items" });
@@ -1253,211 +1286,102 @@ router.get("/calendar.ics", async (req, res) => {
1253
1286
  });
1254
1287
  // GET /api/projects/:projectId/pm/health
1255
1288
  router.get("/health", async (req, res) => {
1256
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1257
- if (!project) {
1258
- res.status(404).json({ error: "Project not found" });
1289
+ const project = await requireProject(req, res);
1290
+ if (!project)
1259
1291
  return;
1260
- }
1261
- const result = await runPm({
1262
- args: ["health"],
1263
- userId: project.ownerUserId,
1264
- slug: project.slug,
1265
- jsonOutput: true,
1266
- });
1292
+ const result = await projectPm(project, ["health"], true);
1267
1293
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1268
1294
  });
1269
1295
  // POST /api/projects/:projectId/pm/append/:itemId
1270
1296
  router.post("/append/:itemId", async (req, res) => {
1271
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1272
- if (!project) {
1273
- res.status(404).json({ error: "Project not found" });
1297
+ const project = await requireProject(req, res);
1298
+ if (!project)
1274
1299
  return;
1275
- }
1276
- const { text } = req.body;
1277
- if (!text?.trim()) {
1278
- res.status(400).json({ error: "Text is required" });
1300
+ const result = await runTextBodyMutation(req, res, project, "append", "Text is required", "Failed to append");
1301
+ if (!result)
1279
1302
  return;
1280
- }
1281
- const result = await runPm({
1282
- args: ["append", routeParam(req, "itemId"), text.trim()],
1283
- userId: project.ownerUserId,
1284
- slug: project.slug,
1285
- jsonOutput: true,
1286
- });
1287
- if (!result.ok) {
1288
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to append" });
1289
- return;
1290
- }
1291
1303
  scheduleGraphSync(routeParam(req, "projectId"), project, "item-appended");
1292
1304
  res.json(result.parsed || { ok: true });
1293
1305
  });
1294
1306
  // GET /api/projects/:projectId/pm/history/:itemId
1295
1307
  router.get("/history/:itemId", async (req, res) => {
1296
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1297
- if (!project) {
1298
- res.status(404).json({ error: "Project not found" });
1308
+ const project = await requireProject(req, res);
1309
+ if (!project)
1299
1310
  return;
1300
- }
1301
- const result = await runPm({
1302
- args: ["history", routeParam(req, "itemId")],
1303
- userId: project.ownerUserId,
1304
- slug: project.slug,
1305
- jsonOutput: true,
1306
- });
1311
+ const result = await projectPm(project, ["history", routeParam(req, "itemId")], true);
1307
1312
  res.json(result.ok ? (result.parsed || {}) : { history: [] });
1308
1313
  });
1309
1314
  // GET /api/projects/:projectId/pm/deps/:itemId
1310
1315
  router.get("/deps/:itemId", async (req, res) => {
1311
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1312
- if (!project) {
1313
- res.status(404).json({ error: "Project not found" });
1316
+ const project = await requireProject(req, res);
1317
+ if (!project)
1314
1318
  return;
1315
- }
1316
- const result = await runPm({
1317
- args: ["deps", routeParam(req, "itemId")],
1318
- userId: project.ownerUserId,
1319
- slug: project.slug,
1320
- jsonOutput: true,
1321
- });
1319
+ const result = await projectPm(project, ["deps", routeParam(req, "itemId")], true);
1322
1320
  res.json(result.ok ? (result.parsed || {}) : { deps: [] });
1323
1321
  });
1324
1322
  // POST /api/projects/:projectId/pm/deps/:itemId
1325
1323
  router.post("/deps/:itemId", async (req, res) => {
1326
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1327
- if (!project) {
1328
- res.status(404).json({ error: "Project not found" });
1324
+ const project = await requireProject(req, res);
1325
+ if (!project)
1329
1326
  return;
1330
- }
1331
- const { targetId, rel } = req.body;
1332
- if (!targetId?.trim()) {
1333
- res.status(400).json({ error: "targetId is required" });
1327
+ const dep = parseDepBody(req, res);
1328
+ if (!dep)
1334
1329
  return;
1335
- }
1336
- const depRel = normalizeDependencyKind(rel);
1337
- const result = await runPm({
1338
- args: ["update", routeParam(req, "itemId"), "--dep", `id=${targetId.trim()},kind=${depRel}`],
1339
- userId: project.ownerUserId,
1340
- slug: project.slug,
1341
- jsonOutput: true,
1342
- });
1343
- if (!result.ok) {
1344
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add dependency" });
1330
+ const result = await runMutation(res, project, ["update", routeParam(req, "itemId"), "--dep", `id=${dep.targetId},kind=${dep.depRel}`], "Failed to add dependency");
1331
+ if (!result)
1345
1332
  return;
1346
- }
1347
- scheduleGraphSync(routeParam(req, "projectId"), project, "dependency-added");
1348
- broadcastDependencyEvent(routeParam(req, "projectId"), "dependency-added", {
1349
- from: routeParam(req, "itemId"),
1350
- to: targetId.trim(),
1351
- rel: depRel,
1352
- userId: req.user.userId,
1353
- });
1333
+ emitDependencyEvent(req, project, "dependency-added", "dependency-added", routeParam(req, "itemId"), dep.targetId, dep.depRel);
1354
1334
  res.status(201).json(result.parsed || { ok: true });
1355
1335
  });
1356
1336
  // DELETE /api/projects/:projectId/pm/deps/:itemId
1357
1337
  router.delete("/deps/:itemId", async (req, res) => {
1358
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1359
- if (!project) {
1360
- res.status(404).json({ error: "Project not found" });
1338
+ const project = await requireProject(req, res);
1339
+ if (!project)
1361
1340
  return;
1362
- }
1363
- const { targetId, rel } = req.body;
1364
- if (!targetId?.trim()) {
1365
- res.status(400).json({ error: "targetId is required" });
1341
+ const dep = parseDepBody(req, res, "relates_to");
1342
+ if (!dep)
1366
1343
  return;
1367
- }
1368
- const depRel = normalizeDependencyKind(rel || "relates_to");
1369
- const selector = `id=${targetId.trim()},kind=${depRel}`;
1370
- const result = await runPm({
1371
- args: ["update", routeParam(req, "itemId"), "--dep-remove", selector],
1372
- userId: project.ownerUserId,
1373
- slug: project.slug,
1374
- jsonOutput: true,
1375
- });
1376
- if (!result.ok) {
1377
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to remove dependency" });
1344
+ const selector = `id=${dep.targetId},kind=${dep.depRel}`;
1345
+ const result = await runMutation(res, project, ["update", routeParam(req, "itemId"), "--dep-remove", selector], "Failed to remove dependency");
1346
+ if (!result)
1378
1347
  return;
1379
- }
1380
- scheduleGraphSync(routeParam(req, "projectId"), project, "dependency-removed");
1381
- broadcastDependencyEvent(routeParam(req, "projectId"), "dependency-removed", {
1382
- from: routeParam(req, "itemId"),
1383
- to: targetId.trim(),
1384
- rel: depRel,
1385
- userId: req.user.userId,
1386
- });
1387
- res.status(200).json({ ok: true, from: routeParam(req, "itemId"), to: targetId.trim(), type: depRel, result: result.parsed || null });
1348
+ emitDependencyEvent(req, project, "dependency-removed", "dependency-removed", routeParam(req, "itemId"), dep.targetId, dep.depRel);
1349
+ res.status(200).json({ ok: true, from: routeParam(req, "itemId"), to: dep.targetId, type: dep.depRel, result: result.parsed || null });
1388
1350
  });
1389
1351
  // POST /api/projects/:projectId/pm/rel — Create a relationship between two items
1390
1352
  router.post("/rel", async (req, res) => {
1391
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1392
- if (!project) {
1393
- res.status(404).json({ error: "Project not found" });
1353
+ const project = await requireProject(req, res);
1354
+ if (!project)
1394
1355
  return;
1395
- }
1396
- const { from, to, type: relType } = req.body;
1397
- if (!from?.trim() || !to?.trim()) {
1398
- res.status(400).json({ error: "from and to item IDs are required" });
1356
+ const rel = parseRelBody(req, res);
1357
+ if (!rel)
1399
1358
  return;
1400
- }
1401
- const depRel = normalizeDependencyKind(relType || "relates_to");
1402
- const result = await runPm({
1403
- args: ["update", from.trim(), "--dep", `id=${to.trim()},kind=${depRel}`],
1404
- userId: project.ownerUserId,
1405
- slug: project.slug,
1406
- jsonOutput: true,
1407
- });
1408
- if (!result.ok) {
1409
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to create relationship" });
1359
+ const result = await runMutation(res, project, ["update", rel.from, "--dep", `id=${rel.to},kind=${rel.depRel}`], "Failed to create relationship");
1360
+ if (!result)
1410
1361
  return;
1411
- }
1412
- scheduleGraphSync(routeParam(req, "projectId"), project, "rel-created");
1413
- broadcastDependencyEvent(routeParam(req, "projectId"), "dependency-added", {
1414
- from: from.trim(),
1415
- to: to.trim(),
1416
- rel: depRel,
1417
- userId: req.user.userId,
1418
- });
1419
- res.status(201).json({ ok: true, from: from.trim(), to: to.trim(), type: depRel });
1362
+ emitDependencyEvent(req, project, "rel-created", "dependency-added", rel.from, rel.to, rel.depRel);
1363
+ res.status(201).json({ ok: true, from: rel.from, to: rel.to, type: rel.depRel });
1420
1364
  });
1421
1365
  // DELETE /api/projects/:projectId/pm/rel — Remove a relationship between two items
1422
1366
  router.delete("/rel", async (req, res) => {
1423
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1424
- if (!project) {
1425
- res.status(404).json({ error: "Project not found" });
1367
+ const project = await requireProject(req, res);
1368
+ if (!project)
1426
1369
  return;
1427
- }
1428
- const { from, to, type: relType } = req.body;
1429
- if (!from?.trim() || !to?.trim()) {
1430
- res.status(400).json({ error: "from and to item IDs are required" });
1370
+ const rel = parseRelBody(req, res);
1371
+ if (!rel)
1431
1372
  return;
1432
- }
1433
- const depRel = normalizeDependencyKind(relType || "relates_to");
1434
- const selector = `id=${to.trim()},kind=${depRel}`;
1435
- const result = await runPm({
1436
- args: ["update", from.trim(), "--dep-remove", selector, "--message", `Remove ${depRel} dependency on ${to.trim()}`],
1437
- userId: project.ownerUserId,
1438
- slug: project.slug,
1439
- jsonOutput: true,
1440
- });
1441
- if (!result.ok) {
1442
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to remove relationship" });
1373
+ const selector = `id=${rel.to},kind=${rel.depRel}`;
1374
+ const result = await runMutation(res, project, ["update", rel.from, "--dep-remove", selector, "--message", `Remove ${rel.depRel} dependency on ${rel.to}`], "Failed to remove relationship");
1375
+ if (!result)
1443
1376
  return;
1444
- }
1445
- scheduleGraphSync(routeParam(req, "projectId"), project, "rel-removed");
1446
- broadcastDependencyEvent(routeParam(req, "projectId"), "dependency-removed", {
1447
- from: from.trim(),
1448
- to: to.trim(),
1449
- rel: depRel,
1450
- userId: req.user.userId,
1451
- });
1452
- res.json({ ok: true, from: from.trim(), to: to.trim(), type: depRel, result: result.parsed || null });
1377
+ emitDependencyEvent(req, project, "rel-removed", "dependency-removed", rel.from, rel.to, rel.depRel);
1378
+ res.json({ ok: true, from: rel.from, to: rel.to, type: rel.depRel, result: result.parsed || null });
1453
1379
  });
1454
1380
  // GET /api/projects/:projectId/pm/graph
1455
1381
  router.get("/graph", async (req, res) => {
1456
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1457
- if (!project) {
1458
- res.status(404).json({ error: "Project not found" });
1382
+ const project = await requireProject(req, res);
1383
+ if (!project)
1459
1384
  return;
1460
- }
1461
1385
  const extensionGraph = await pmGraphExtensionGraphForProject(project);
1462
1386
  if (extensionGraph.graph) {
1463
1387
  res.json({
@@ -1481,11 +1405,9 @@ router.get("/graph", async (req, res) => {
1481
1405
  });
1482
1406
  // POST /api/projects/:projectId/pm/graph/sync
1483
1407
  router.post("/graph/sync", async (req, res) => {
1484
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1485
- if (!project) {
1486
- res.status(404).json({ error: "Project not found" });
1408
+ const project = await requireProject(req, res);
1409
+ if (!project)
1487
1410
  return;
1488
- }
1489
1411
  try {
1490
1412
  const syncResult = await syncProjectGraph(project);
1491
1413
  const payload = { reason: "manual-sync", ...syncResult, projectKey: graphProjectKey(project), source: "pm-web" };
@@ -1515,22 +1437,15 @@ router.post("/graph/sync", async (req, res) => {
1515
1437
  });
1516
1438
  // GET /api/projects/:projectId/pm/graph/neighbors/:nodeId
1517
1439
  router.get("/graph/neighbors/:nodeId", async (req, res) => {
1518
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1519
- if (!project) {
1520
- res.status(404).json({ error: "Project not found" });
1440
+ const project = await requireProject(req, res);
1441
+ if (!project)
1521
1442
  return;
1522
- }
1523
1443
  const nodeId = routeParam(req, "nodeId");
1524
1444
  if (!nodeId) {
1525
1445
  res.status(400).json({ error: "nodeId is required" });
1526
1446
  return;
1527
1447
  }
1528
- const result = await runPm({
1529
- args: ["pm-graph", "neighbors", nodeId, "--json"],
1530
- userId: project.ownerUserId,
1531
- slug: project.slug,
1532
- jsonOutput: false,
1533
- });
1448
+ const result = await projectPm(project, ["pm-graph", "neighbors", nodeId, "--json"], false);
1534
1449
  if (!result.ok) {
1535
1450
  // Extension not available — return empty neighbors
1536
1451
  res.json({ ok: true, center: null, neighbors: [], extensionAvailable: false, error: result.stderr || "pm-graph extension not available" });
@@ -1546,22 +1461,15 @@ router.get("/graph/neighbors/:nodeId", async (req, res) => {
1546
1461
  });
1547
1462
  // POST /api/projects/:projectId/pm/graph/query
1548
1463
  router.post("/graph/query", async (req, res) => {
1549
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1550
- if (!project) {
1551
- res.status(404).json({ error: "Project not found" });
1464
+ const project = await requireProject(req, res);
1465
+ if (!project)
1552
1466
  return;
1553
- }
1554
1467
  const { cypher } = req.body;
1555
1468
  if (!cypher?.trim()) {
1556
1469
  res.status(400).json({ error: "cypher query is required" });
1557
1470
  return;
1558
1471
  }
1559
- const result = await runPm({
1560
- args: ["pm-graph", "query", cypher.trim(), "--json"],
1561
- userId: project.ownerUserId,
1562
- slug: project.slug,
1563
- jsonOutput: false,
1564
- });
1472
+ const result = await projectPm(project, ["pm-graph", "query", cypher.trim(), "--json"], false);
1565
1473
  if (!result.ok) {
1566
1474
  res.status(pmErrorStatus(result)).json({ error: result.stderr || "pm-graph query failed — ensure Neo4j is configured and pm-graph extension is installed" });
1567
1475
  return;
@@ -1576,145 +1484,79 @@ router.post("/graph/query", async (req, res) => {
1576
1484
  });
1577
1485
  // GET /api/projects/:projectId/pm/learnings/:itemId
1578
1486
  router.get("/learnings/:itemId", async (req, res) => {
1579
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1580
- if (!project) {
1581
- res.status(404).json({ error: "Project not found" });
1487
+ const project = await requireProject(req, res);
1488
+ if (!project)
1582
1489
  return;
1583
- }
1584
- const result = await runPm({
1585
- args: ["learnings", routeParam(req, "itemId")],
1586
- userId: project.ownerUserId,
1587
- slug: project.slug,
1588
- jsonOutput: true,
1589
- });
1490
+ const result = await projectPm(project, ["learnings", routeParam(req, "itemId")], true);
1590
1491
  res.json(result.ok ? (result.parsed || {}) : { learnings: [] });
1591
1492
  });
1592
1493
  // POST /api/projects/:projectId/pm/learnings/:itemId
1593
1494
  router.post("/learnings/:itemId", async (req, res) => {
1594
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1595
- if (!project) {
1596
- res.status(404).json({ error: "Project not found" });
1495
+ const project = await requireProject(req, res);
1496
+ if (!project)
1597
1497
  return;
1598
- }
1599
- const { text } = req.body;
1600
- if (!text?.trim()) {
1601
- res.status(400).json({ error: "Learning text is required" });
1498
+ const result = await runTextBodyMutation(req, res, project, "learnings", "Learning text is required", "Failed to add learning");
1499
+ if (!result)
1602
1500
  return;
1603
- }
1604
- const result = await runPm({
1605
- args: ["learnings", routeParam(req, "itemId"), text.trim()],
1606
- userId: project.ownerUserId,
1607
- slug: project.slug,
1608
- jsonOutput: true,
1609
- });
1610
- if (!result.ok) {
1611
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add learning" });
1612
- return;
1613
- }
1614
1501
  res.status(201).json(result.parsed || { ok: true });
1615
1502
  });
1616
1503
  // POST /api/projects/:projectId/pm/claim/:itemId
1617
1504
  router.post("/claim/:itemId", async (req, res) => {
1618
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1619
- if (!project) {
1620
- res.status(404).json({ error: "Project not found" });
1505
+ const project = await requireProject(req, res);
1506
+ if (!project)
1621
1507
  return;
1622
- }
1623
- const result = await runPm({
1624
- args: ["claim", routeParam(req, "itemId")],
1625
- userId: project.ownerUserId,
1626
- slug: project.slug,
1627
- jsonOutput: true,
1628
- });
1629
- if (!result.ok) {
1630
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to claim item" });
1508
+ const result = await runMutation(res, project, ["claim", routeParam(req, "itemId")], "Failed to claim item");
1509
+ if (!result)
1631
1510
  return;
1632
- }
1633
1511
  scheduleGraphSync(routeParam(req, "projectId"), project, "item-claimed");
1634
1512
  res.json(result.parsed || { ok: true });
1635
1513
  });
1636
1514
  // POST /api/projects/:projectId/pm/release/:itemId
1637
1515
  router.post("/release/:itemId", async (req, res) => {
1638
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1639
- if (!project) {
1640
- res.status(404).json({ error: "Project not found" });
1516
+ const project = await requireProject(req, res);
1517
+ if (!project)
1641
1518
  return;
1642
- }
1643
- const result = await runPm({
1644
- args: ["release", routeParam(req, "itemId")],
1645
- userId: project.ownerUserId,
1646
- slug: project.slug,
1647
- jsonOutput: true,
1648
- });
1649
- if (!result.ok) {
1650
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to release item" });
1519
+ const result = await runMutation(res, project, ["release", routeParam(req, "itemId")], "Failed to release item");
1520
+ if (!result)
1651
1521
  return;
1652
- }
1653
1522
  scheduleGraphSync(routeParam(req, "projectId"), project, "item-released");
1654
1523
  res.json(result.parsed || { ok: true });
1655
1524
  });
1656
1525
  // POST /api/projects/:projectId/pm/start-task/:itemId
1657
1526
  router.post("/start-task/:itemId", async (req, res) => {
1658
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1659
- if (!project) {
1660
- res.status(404).json({ error: "Project not found" });
1527
+ const project = await requireProject(req, res);
1528
+ if (!project)
1661
1529
  return;
1662
- }
1663
- const result = await runPm({
1664
- args: ["start-task", routeParam(req, "itemId")],
1665
- userId: project.ownerUserId,
1666
- slug: project.slug,
1667
- jsonOutput: true,
1668
- });
1669
- if (!result.ok) {
1670
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to start task" });
1530
+ const result = await runMutation(res, project, ["start-task", routeParam(req, "itemId")], "Failed to start task");
1531
+ if (!result)
1671
1532
  return;
1672
- }
1673
1533
  scheduleGraphSync(routeParam(req, "projectId"), project, "task-started");
1674
1534
  res.json(result.parsed || { ok: true });
1675
1535
  });
1676
1536
  // POST /api/projects/:projectId/pm/pause-task/:itemId
1677
1537
  router.post("/pause-task/:itemId", async (req, res) => {
1678
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1679
- if (!project) {
1680
- res.status(404).json({ error: "Project not found" });
1538
+ const project = await requireProject(req, res);
1539
+ if (!project)
1681
1540
  return;
1682
- }
1683
- const result = await runPm({
1684
- args: ["pause-task", routeParam(req, "itemId")],
1685
- userId: project.ownerUserId,
1686
- slug: project.slug,
1687
- jsonOutput: true,
1688
- });
1689
- if (!result.ok) {
1690
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to pause task" });
1541
+ const result = await runMutation(res, project, ["pause-task", routeParam(req, "itemId")], "Failed to pause task");
1542
+ if (!result)
1691
1543
  return;
1692
- }
1693
1544
  scheduleGraphSync(routeParam(req, "projectId"), project, "task-paused");
1694
1545
  res.json(result.parsed || { ok: true });
1695
1546
  });
1696
1547
  // GET /api/projects/:projectId/pm/tests/:itemId
1697
1548
  router.get("/tests/:itemId", async (req, res) => {
1698
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1699
- if (!project) {
1700
- res.status(404).json({ error: "Project not found" });
1549
+ const project = await requireProject(req, res);
1550
+ if (!project)
1701
1551
  return;
1702
- }
1703
- const result = await runPm({
1704
- args: ["test", routeParam(req, "itemId")],
1705
- userId: project.ownerUserId,
1706
- slug: project.slug,
1707
- jsonOutput: true,
1708
- });
1552
+ const result = await projectPm(project, ["test", routeParam(req, "itemId")], true);
1709
1553
  res.json(result.ok ? (result.parsed || {}) : { tests: [] });
1710
1554
  });
1711
1555
  // POST /api/projects/:projectId/pm/tests/:itemId
1712
1556
  router.post("/tests/:itemId", async (req, res) => {
1713
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1714
- if (!project) {
1715
- res.status(404).json({ error: "Project not found" });
1557
+ const project = await requireProject(req, res);
1558
+ if (!project)
1716
1559
  return;
1717
- }
1718
1560
  const { command, description } = req.body;
1719
1561
  if (!command?.trim()) {
1720
1562
  res.status(400).json({ error: "Test command is required" });
@@ -1723,113 +1565,63 @@ router.post("/tests/:itemId", async (req, res) => {
1723
1565
  const args = ["test", routeParam(req, "itemId"), "--add", "--command", command.trim()];
1724
1566
  if (description)
1725
1567
  args.push("--description", description.trim());
1726
- const result = await runPm({
1727
- args,
1728
- userId: project.ownerUserId,
1729
- slug: project.slug,
1730
- jsonOutput: true,
1731
- });
1732
- if (!result.ok) {
1733
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add test" });
1568
+ const result = await runMutation(res, project, args, "Failed to add test");
1569
+ if (!result)
1734
1570
  return;
1735
- }
1736
1571
  res.status(201).json(result.parsed || { ok: true });
1737
1572
  });
1738
1573
  // GET /api/projects/:projectId/pm/dedupe-audit
1739
1574
  router.get("/dedupe-audit", async (req, res) => {
1740
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1741
- if (!project) {
1742
- res.status(404).json({ error: "Project not found" });
1575
+ const project = await requireProject(req, res);
1576
+ if (!project)
1743
1577
  return;
1744
- }
1745
- const result = await runPm({
1746
- args: ["dedupe-audit"],
1747
- userId: project.ownerUserId,
1748
- slug: project.slug,
1749
- jsonOutput: true,
1750
- });
1578
+ const result = await projectPm(project, ["dedupe-audit"], true);
1751
1579
  res.json(result.ok ? (result.parsed || {}) : { duplicates: [] });
1752
1580
  });
1753
1581
  // GET /api/projects/:projectId/pm/validate
1754
1582
  router.get("/validate", async (req, res) => {
1755
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1756
- if (!project) {
1757
- res.status(404).json({ error: "Project not found" });
1583
+ const project = await requireProject(req, res);
1584
+ if (!project)
1758
1585
  return;
1759
- }
1760
- const result = await runPm({
1761
- args: ["validate"],
1762
- userId: project.ownerUserId,
1763
- slug: project.slug,
1764
- jsonOutput: true,
1765
- });
1586
+ const result = await projectPm(project, ["validate"], true);
1766
1587
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1767
1588
  });
1768
1589
  // POST /api/projects/:projectId/pm/restore/:itemId
1769
1590
  router.post("/restore/:itemId", async (req, res) => {
1770
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1771
- if (!project) {
1772
- res.status(404).json({ error: "Project not found" });
1591
+ const project = await requireProject(req, res);
1592
+ if (!project)
1773
1593
  return;
1774
- }
1775
1594
  const { target } = req.body;
1776
1595
  if (!target?.trim()) {
1777
1596
  res.status(400).json({ error: "Restore target (timestamp or version) is required" });
1778
1597
  return;
1779
1598
  }
1780
- const result = await runPm({
1781
- args: ["restore", routeParam(req, "itemId"), target.trim()],
1782
- userId: project.ownerUserId,
1783
- slug: project.slug,
1784
- jsonOutput: true,
1785
- });
1786
- if (!result.ok) {
1787
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to restore item" });
1599
+ const result = await runMutation(res, project, ["restore", routeParam(req, "itemId"), target.trim()], "Failed to restore item");
1600
+ if (!result)
1788
1601
  return;
1789
- }
1790
1602
  scheduleGraphSync(routeParam(req, "projectId"), project, "item-restored");
1791
1603
  res.json(result.parsed || { ok: true });
1792
1604
  });
1793
1605
  // POST /api/projects/:projectId/pm/close-task/:itemId
1794
1606
  router.post("/close-task/:itemId", async (req, res) => {
1795
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1796
- if (!project) {
1797
- res.status(404).json({ error: "Project not found" });
1607
+ const project = await requireProject(req, res);
1608
+ if (!project)
1798
1609
  return;
1799
- }
1800
- const { reason } = req.body;
1801
- if (!reason?.trim()) {
1802
- res.status(400).json({ error: "Close reason is required" });
1803
- return;
1804
- }
1805
- const result = await runPm({
1806
- args: ["close-task", routeParam(req, "itemId"), reason.trim()],
1807
- userId: project.ownerUserId,
1808
- slug: project.slug,
1809
- jsonOutput: true,
1810
- });
1811
- if (!result.ok) {
1812
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to close task" });
1610
+ const result = await runReasonBodyMutation(req, res, project, "close-task", "Close reason is required", "Failed to close task");
1611
+ if (!result)
1813
1612
  return;
1814
- }
1815
1613
  scheduleGraphSync(routeParam(req, "projectId"), project, "task-closed");
1816
1614
  res.json(result.parsed || { ok: true });
1817
1615
  });
1818
1616
  // POST /api/projects/:projectId/pm/reindex
1819
1617
  router.post("/reindex", async (req, res) => {
1820
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1821
- if (!project) {
1822
- res.status(404).json({ error: "Project not found" });
1618
+ const project = await requireProject(req, res);
1619
+ if (!project)
1823
1620
  return;
1824
- }
1825
1621
  const { mode = "keyword" } = req.body;
1826
1622
  const validModes = ["keyword", "semantic", "hybrid"];
1827
1623
  const safeMode = validModes.includes(mode) ? mode : "keyword";
1828
- const result = await runPm({
1829
- args: ["reindex", "--mode", safeMode],
1830
- userId: project.ownerUserId,
1831
- slug: project.slug,
1832
- });
1624
+ const result = await projectPm(project, ["reindex", "--mode", safeMode]);
1833
1625
  if (!result.ok) {
1834
1626
  res.status(400).json({
1835
1627
  error: result.stderr || "Reindex failed. Check that Ollama is reachable and the configured embedding model is available.",
@@ -1840,41 +1632,25 @@ router.post("/reindex", async (req, res) => {
1840
1632
  });
1841
1633
  // POST /api/projects/:projectId/pm/normalize
1842
1634
  router.post("/normalize", async (req, res) => {
1843
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1844
- if (!project) {
1845
- res.status(404).json({ error: "Project not found" });
1635
+ const project = await requireProject(req, res);
1636
+ if (!project)
1846
1637
  return;
1847
- }
1848
- const result = await runPm({
1849
- args: ["normalize"],
1850
- userId: project.ownerUserId,
1851
- slug: project.slug,
1852
- jsonOutput: true,
1853
- });
1638
+ const result = await projectPm(project, ["normalize"], true);
1854
1639
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1855
1640
  });
1856
1641
  // GET /api/projects/:projectId/pm/comments-audit
1857
1642
  router.get("/comments-audit", async (req, res) => {
1858
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1859
- if (!project) {
1860
- res.status(404).json({ error: "Project not found" });
1643
+ const project = await requireProject(req, res);
1644
+ if (!project)
1861
1645
  return;
1862
- }
1863
- const result = await runPm({
1864
- args: ["comments-audit"],
1865
- userId: project.ownerUserId,
1866
- slug: project.slug,
1867
- jsonOutput: true,
1868
- });
1646
+ const result = await projectPm(project, ["comments-audit"], true);
1869
1647
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1870
1648
  });
1871
1649
  // POST /api/projects/:projectId/pm/files/:itemId
1872
1650
  router.post("/files/:itemId", async (req, res) => {
1873
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1874
- if (!project) {
1875
- res.status(404).json({ error: "Project not found" });
1651
+ const project = await requireProject(req, res);
1652
+ if (!project)
1876
1653
  return;
1877
- }
1878
1654
  const { path: filePath, scope } = req.body;
1879
1655
  if (!filePath?.trim()) {
1880
1656
  res.status(400).json({ error: "File path is required" });
@@ -1884,62 +1660,34 @@ router.post("/files/:itemId", async (req, res) => {
1884
1660
  if (scope)
1885
1661
  addVal += `,scope=${scope}`;
1886
1662
  const args = ["files", routeParam(req, "itemId"), "--add", addVal];
1887
- const result = await runPm({
1888
- args,
1889
- userId: project.ownerUserId,
1890
- slug: project.slug,
1891
- jsonOutput: true,
1892
- });
1893
- if (!result.ok) {
1894
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to link file" });
1663
+ const result = await runMutation(res, project, args, "Failed to link file");
1664
+ if (!result)
1895
1665
  return;
1896
- }
1897
1666
  scheduleGraphSync(routeParam(req, "projectId"), project, "file-linked");
1898
1667
  res.status(201).json(result.parsed || { ok: true });
1899
1668
  });
1900
1669
  // GET /api/projects/:projectId/pm/files/:itemId
1901
1670
  router.get("/files/:itemId", async (req, res) => {
1902
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1903
- if (!project) {
1904
- res.status(404).json({ error: "Project not found" });
1671
+ const project = await requireProject(req, res);
1672
+ if (!project)
1905
1673
  return;
1906
- }
1907
- const result = await runPm({
1908
- args: ["files", routeParam(req, "itemId")],
1909
- userId: project.ownerUserId,
1910
- slug: project.slug,
1911
- jsonOutput: true,
1912
- });
1674
+ const result = await projectPm(project, ["files", routeParam(req, "itemId")], true);
1913
1675
  res.json(result.ok ? (result.parsed || {}) : { files: [] });
1914
1676
  });
1915
1677
  // GET /api/projects/:projectId/pm/guide — list guide topics
1916
- router.get("/guide", async (req, res) => {
1917
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1918
- if (!project) {
1919
- res.status(404).json({ error: "Project not found" });
1678
+ router.get("/guide", async (req, res) => {
1679
+ const project = await requireProject(req, res);
1680
+ if (!project)
1920
1681
  return;
1921
- }
1922
- const result = await runPm({
1923
- args: ["guide"],
1924
- userId: project.ownerUserId,
1925
- slug: project.slug,
1926
- jsonOutput: true,
1927
- });
1682
+ const result = await projectPm(project, ["guide"], true);
1928
1683
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1929
1684
  });
1930
1685
  // GET /api/projects/:projectId/pm/guide/:topicId — get single guide topic
1931
1686
  router.get("/guide/:topicId", async (req, res) => {
1932
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1933
- if (!project) {
1934
- res.status(404).json({ error: "Project not found" });
1687
+ const project = await requireProject(req, res);
1688
+ if (!project)
1935
1689
  return;
1936
- }
1937
- const result = await runPm({
1938
- args: ["guide", routeParam(req, "topicId")],
1939
- userId: project.ownerUserId,
1940
- slug: project.slug,
1941
- jsonOutput: true,
1942
- });
1690
+ const result = await projectPm(project, ["guide", routeParam(req, "topicId")], true);
1943
1691
  if (!result.ok) {
1944
1692
  res.status(404).json({ error: result.stderr || "Topic not found" });
1945
1693
  return;
@@ -1953,11 +1701,9 @@ router.get("/guide/:topicId", async (req, res) => {
1953
1701
  // ─────────────────────────────────────────────────────────
1954
1702
  // GET /api/projects/:projectId/pm/export?format=json|csv|yaml
1955
1703
  router.get("/export", async (req, res) => {
1956
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1957
- if (!project) {
1958
- res.status(404).json({ error: "Project not found" });
1704
+ const project = await requireProject(req, res);
1705
+ if (!project)
1959
1706
  return;
1960
- }
1961
1707
  const format = req.query["format"] || "json";
1962
1708
  const result = await readCompletePmItems(project.ownerUserId, project.slug, true);
1963
1709
  if (!result.ok) {
@@ -2047,11 +1793,9 @@ router.get("/export", async (req, res) => {
2047
1793
  });
2048
1794
  // POST /api/projects/:projectId/pm/import — import JSON items
2049
1795
  router.post("/import", async (req, res) => {
2050
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2051
- if (!project) {
2052
- res.status(404).json({ error: "Project not found" });
1796
+ const project = await requireProject(req, res);
1797
+ if (!project)
2053
1798
  return;
2054
- }
2055
1799
  const { items } = req.body;
2056
1800
  if (!items || !Array.isArray(items) || items.length === 0) {
2057
1801
  res.status(400).json({ error: "items array is required" });
@@ -2094,7 +1838,7 @@ router.post("/import", async (req, res) => {
2094
1838
  args.push("--body", item.body);
2095
1839
  if (item.parent)
2096
1840
  args.push("--parent", item.parent);
2097
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1841
+ const result = await projectPm(project, args, true);
2098
1842
  if (result.ok && result.parsed) {
2099
1843
  // `pm create --json` (and the in-process SDK dispatcher) return the flat
2100
1844
  // envelope { id, status, changed_field_count } — no `item` wrapper.
@@ -2115,11 +1859,9 @@ router.post("/import", async (req, res) => {
2115
1859
  });
2116
1860
  // POST /api/projects/:projectId/pm/update-many
2117
1861
  router.post("/update-many", async (req, res) => {
2118
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2119
- if (!project) {
2120
- res.status(404).json({ error: "Project not found" });
1862
+ const project = await requireProject(req, res);
1863
+ if (!project)
2121
1864
  return;
2122
- }
2123
1865
  const body = req.body;
2124
1866
  const args = ["update-many"];
2125
1867
  // Filter options
@@ -2153,7 +1895,7 @@ router.post("/update-many", async (req, res) => {
2153
1895
  if (body[key])
2154
1896
  args.push(flag, body[key]);
2155
1897
  }
2156
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1898
+ const result = await projectPm(project, args, true);
2157
1899
  if (!result.ok) {
2158
1900
  res.status(pmErrorStatus(result)).json({ error: result.stderr || "update-many failed" });
2159
1901
  return;
@@ -2170,11 +1912,9 @@ router.post("/update-many", async (req, res) => {
2170
1912
  // Accepts same filter options as update-many plus a required `reason` field.
2171
1913
  // Returns { closed_count, failed_count, skipped_count, rows }.
2172
1914
  router.post("/close-many", async (req, res) => {
2173
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2174
- if (!project) {
2175
- res.status(404).json({ error: "Project not found" });
1915
+ const project = await requireProject(req, res);
1916
+ if (!project)
2176
1917
  return;
2177
- }
2178
1918
  const body = req.body;
2179
1919
  const reason = body.reason?.trim();
2180
1920
  if (!reason) {
@@ -2195,7 +1935,7 @@ router.post("/close-many", async (req, res) => {
2195
1935
  if (body[key])
2196
1936
  listArgs.push(flag, body[key]);
2197
1937
  }
2198
- const listResult = await runPm({ args: listArgs, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1938
+ const listResult = await projectPm(project, listArgs, true);
2199
1939
  if (!listResult.ok) {
2200
1940
  res.status(pmErrorStatus(listResult)).json({ error: listResult.stderr || "Failed to list items for close-many" });
2201
1941
  return;
@@ -2215,7 +1955,7 @@ router.post("/close-many", async (req, res) => {
2215
1955
  const closeArgs = targetStatus === "canceled"
2216
1956
  ? ["update", itemId, "--status", "canceled"]
2217
1957
  : ["close", itemId, reason];
2218
- const closeResult = await runPm({ args: closeArgs, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1958
+ const closeResult = await projectPm(project, closeArgs, true);
2219
1959
  if (closeResult.ok) {
2220
1960
  rows.push({ id: itemId, status: "ok" });
2221
1961
  closedCount++;
@@ -2242,26 +1982,17 @@ router.post("/close-many", async (req, res) => {
2242
1982
  });
2243
1983
  // GET /api/projects/:projectId/pm/docs/:itemId
2244
1984
  router.get("/docs/:itemId", async (req, res) => {
2245
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2246
- if (!project) {
2247
- res.status(404).json({ error: "Project not found" });
1985
+ const project = await requireProject(req, res);
1986
+ if (!project)
2248
1987
  return;
2249
- }
2250
- const result = await runPm({
2251
- args: ["docs", routeParam(req, "itemId")],
2252
- userId: project.ownerUserId,
2253
- slug: project.slug,
2254
- jsonOutput: true,
2255
- });
1988
+ const result = await projectPm(project, ["docs", routeParam(req, "itemId")], true);
2256
1989
  res.json(result.ok ? (result.parsed || {}) : { docs: [] });
2257
1990
  });
2258
1991
  // POST /api/projects/:projectId/pm/docs/:itemId
2259
1992
  router.post("/docs/:itemId", async (req, res) => {
2260
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2261
- if (!project) {
2262
- res.status(404).json({ error: "Project not found" });
1993
+ const project = await requireProject(req, res);
1994
+ if (!project)
2263
1995
  return;
2264
- }
2265
1996
  const { path: docPath, scope, note, remove, validatePaths } = req.body;
2266
1997
  const args = ["docs", routeParam(req, "itemId")];
2267
1998
  if (remove) {
@@ -2282,7 +2013,7 @@ router.post("/docs/:itemId", async (req, res) => {
2282
2013
  res.status(400).json({ error: "path, remove, or validatePaths is required" });
2283
2014
  return;
2284
2015
  }
2285
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2016
+ const result = await projectPm(project, args, true);
2286
2017
  if (!result.ok) {
2287
2018
  res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to update docs" });
2288
2019
  return;
@@ -2292,11 +2023,9 @@ router.post("/docs/:itemId", async (req, res) => {
2292
2023
  });
2293
2024
  // POST /api/projects/:projectId/pm/test-all
2294
2025
  router.post("/test-all", async (req, res) => {
2295
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2296
- if (!project) {
2297
- res.status(404).json({ error: "Project not found" });
2026
+ const project = await requireProject(req, res);
2027
+ if (!project)
2298
2028
  return;
2299
- }
2300
2029
  const body = req.body;
2301
2030
  const args = ["test-all"];
2302
2031
  if (body.status)
@@ -2305,7 +2034,7 @@ router.post("/test-all", async (req, res) => {
2305
2034
  args.push("--limit", body.limit);
2306
2035
  if (body.timeout)
2307
2036
  args.push("--timeout", body.timeout);
2308
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2037
+ const result = await projectPm(project, args, true);
2309
2038
  if (!result.ok) {
2310
2039
  res.status(pmErrorStatus(result)).json({ error: result.stderr || "test-all failed" });
2311
2040
  return;
@@ -2314,78 +2043,64 @@ router.post("/test-all", async (req, res) => {
2314
2043
  });
2315
2044
  // GET /api/projects/:projectId/pm/test-runs
2316
2045
  router.get("/test-runs", async (req, res) => {
2317
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2318
- if (!project) {
2319
- res.status(404).json({ error: "Project not found" });
2046
+ const project = await requireProject(req, res);
2047
+ if (!project)
2320
2048
  return;
2321
- }
2322
2049
  const { status, limit } = req.query;
2323
2050
  const args = ["test-runs", "list"];
2324
2051
  if (status)
2325
2052
  args.push("--status", status);
2326
2053
  if (limit)
2327
2054
  args.push("--limit", limit);
2328
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2055
+ const result = await projectPm(project, args, true);
2329
2056
  res.json(result.ok ? (result.parsed || {}) : { runs: [] });
2330
2057
  });
2331
2058
  // POST /api/projects/:projectId/pm/gc
2332
2059
  router.post("/gc", async (req, res) => {
2333
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2334
- if (!project) {
2335
- res.status(404).json({ error: "Project not found" });
2060
+ const project = await requireProject(req, res);
2061
+ if (!project)
2336
2062
  return;
2337
- }
2338
- const result = await runPm({ args: ["gc"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2063
+ const result = await projectPm(project, ["gc"], true);
2339
2064
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
2340
2065
  });
2341
2066
  // GET /api/projects/:projectId/pm/templates
2342
2067
  router.get("/templates", async (req, res) => {
2343
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2344
- if (!project) {
2345
- res.status(404).json({ error: "Project not found" });
2068
+ const project = await requireProject(req, res);
2069
+ if (!project)
2346
2070
  return;
2347
- }
2348
- const result = await runPm({ args: ["templates", "list"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2071
+ const result = await projectPm(project, ["templates", "list"], true);
2349
2072
  res.json(result.ok ? (result.parsed || {}) : { templates: [] });
2350
2073
  });
2351
2074
  // GET /api/projects/:projectId/pm/templates/:name
2352
2075
  router.get("/templates/:name", async (req, res) => {
2353
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2354
- if (!project) {
2355
- res.status(404).json({ error: "Project not found" });
2076
+ const project = await requireProject(req, res);
2077
+ if (!project)
2356
2078
  return;
2357
- }
2358
- const result = await runPm({ args: ["templates", "show", routeParam(req, "name")], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2079
+ const result = await projectPm(project, ["templates", "show", routeParam(req, "name")], true);
2359
2080
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
2360
2081
  });
2361
2082
  // GET /api/projects/:projectId/pm/config
2362
2083
  router.get("/config", async (req, res) => {
2363
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2364
- if (!project) {
2365
- res.status(404).json({ error: "Project not found" });
2084
+ const project = await requireProject(req, res);
2085
+ if (!project)
2366
2086
  return;
2367
- }
2368
- const result = await runPm({ args: ["config", "project", "list"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2087
+ const result = await projectPm(project, ["config", "project", "list"], true);
2369
2088
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
2370
2089
  });
2371
2090
  // GET /api/projects/:projectId/pm/config/:key
2372
2091
  router.get("/config/:key", async (req, res) => {
2373
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2374
- if (!project) {
2375
- res.status(404).json({ error: "Project not found" });
2092
+ const project = await requireProject(req, res);
2093
+ if (!project)
2376
2094
  return;
2377
- }
2378
2095
  const key = routeParam(req, "key");
2379
- const result = await runPm({ args: ["config", "project", "get", key], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2096
+ const result = await projectPm(project, ["config", "project", "get", key], true);
2380
2097
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
2381
2098
  });
2382
2099
  // PATCH /api/projects/:projectId/pm/config/:key
2383
2100
  router.patch("/config/:key", async (req, res) => {
2384
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2385
- if (!project) {
2386
- res.status(404).json({ error: "Project not found" });
2101
+ const project = await requireProject(req, res);
2102
+ if (!project)
2387
2103
  return;
2388
- }
2389
2104
  const key = routeParam(req, "key");
2390
2105
  const body = req.body;
2391
2106
  const args = ["config", "project", "set", key];
@@ -2395,7 +2110,7 @@ router.patch("/config/:key", async (req, res) => {
2395
2110
  args.push("--policy", body.policy);
2396
2111
  if (body.format)
2397
2112
  args.push("--format", body.format);
2398
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2113
+ const result = await projectPm(project, args, true);
2399
2114
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
2400
2115
  });
2401
2116
  // ─── List status shortcut routes ───
@@ -2414,41 +2129,22 @@ router.patch("/config/:key", async (req, res) => {
2414
2129
  */
2415
2130
  function buildListShortcutRoute(pmCommand) {
2416
2131
  return async (req, res) => {
2417
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2418
- if (!project) {
2419
- res.status(404).json({ error: "Project not found" });
2132
+ const project = await requireProject(req, res);
2133
+ if (!project)
2420
2134
  return;
2421
- }
2422
2135
  const { type, limit, offset, tag, priority, assignee, sprint, release, after } = req.query;
2423
- const args = [pmCommand];
2424
- if (type)
2425
- args.push("--type", type);
2426
- if (limit)
2427
- args.push("--limit", limit);
2428
- if (offset)
2429
- args.push("--offset", offset);
2430
- if (tag)
2431
- args.push("--tag", tag);
2432
- if (priority)
2433
- args.push("--priority", priority);
2434
- if (assignee)
2435
- args.push("--assignee", assignee);
2436
- if (sprint)
2437
- args.push("--sprint", sprint);
2438
- if (release)
2439
- args.push("--release", release);
2440
- const cursorResult = validateCursor(after);
2441
- if (cursorResult.error) {
2442
- res.status(400).json({ error: cursorResult.error, items: [] });
2443
- return;
2444
- }
2445
- if (cursorResult.cursor)
2446
- args.push("--after", cursorResult.cursor);
2447
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2448
- if (!result.ok && result.exitCode === EXIT_CODE.USAGE) {
2449
- res.status(400).json({ error: result.stderr, items: [] });
2136
+ const result = await runCursorList(res, project, [pmCommand], [
2137
+ ["--type", type],
2138
+ ["--limit", limit],
2139
+ ["--offset", offset],
2140
+ ["--tag", tag],
2141
+ ["--priority", priority],
2142
+ ["--assignee", assignee],
2143
+ ["--sprint", sprint],
2144
+ ["--release", release],
2145
+ ], after);
2146
+ if (!result)
2450
2147
  return;
2451
- }
2452
2148
  res.json(result.ok ? (result.parsed || {}) : { items: [] });
2453
2149
  };
2454
2150
  }
@@ -2463,11 +2159,9 @@ router.get("/list-canceled", buildListShortcutRoute("list-canceled"));
2463
2159
  // ─────────────────────────────────────────────────────────
2464
2160
  // POST /api/projects/:projectId/pm/plan
2465
2161
  router.post("/plan", async (req, res) => {
2466
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2467
- if (!project) {
2468
- res.status(404).json({ error: "Project not found" });
2162
+ const project = await requireProject(req, res);
2163
+ if (!project)
2469
2164
  return;
2470
- }
2471
2165
  const { title, description, scope, tags, priority, body } = req.body;
2472
2166
  if (!title?.trim()) {
2473
2167
  res.status(400).json({ error: "Title is required" });
@@ -2484,7 +2178,7 @@ router.post("/plan", async (req, res) => {
2484
2178
  args.push("--priority", priority);
2485
2179
  if (body)
2486
2180
  args.push("--body", body);
2487
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2181
+ const result = await projectPm(project, args, true);
2488
2182
  if (!result.ok) {
2489
2183
  res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to create plan" });
2490
2184
  return;
@@ -2497,17 +2191,10 @@ router.post("/plan", async (req, res) => {
2497
2191
  });
2498
2192
  // GET /api/projects/:projectId/pm/plan/:planId
2499
2193
  router.get("/plan/:planId", async (req, res) => {
2500
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2501
- if (!project) {
2502
- res.status(404).json({ error: "Project not found" });
2194
+ const project = await requireProject(req, res);
2195
+ if (!project)
2503
2196
  return;
2504
- }
2505
- const result = await runPm({
2506
- args: ["plan", "show", routeParam(req, "planId"), "--depth", "standard"],
2507
- userId: project.ownerUserId,
2508
- slug: project.slug,
2509
- jsonOutput: true,
2510
- });
2197
+ const result = await projectPm(project, ["plan", "show", routeParam(req, "planId"), "--depth", "standard"], true);
2511
2198
  if (!result.ok) {
2512
2199
  res.status(404).json({ error: result.stderr || "Plan not found" });
2513
2200
  return;
@@ -2516,41 +2203,19 @@ router.get("/plan/:planId", async (req, res) => {
2516
2203
  });
2517
2204
  // PATCH /api/projects/:projectId/pm/plan/:planId
2518
2205
  router.patch("/plan/:planId", async (req, res) => {
2519
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2520
- if (!project) {
2521
- res.status(404).json({ error: "Project not found" });
2206
+ const project = await requireProject(req, res);
2207
+ if (!project)
2522
2208
  return;
2523
- }
2524
- const { title, description } = req.body;
2525
2209
  const args = ["update", routeParam(req, "planId")];
2526
- if (title?.trim())
2527
- args.push("--title", title.trim());
2528
- if (description !== undefined)
2529
- args.push("--description", description);
2530
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2531
- if (!result.ok) {
2532
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to update plan" });
2533
- return;
2534
- }
2535
- broadcastProjectEvent(routeParam(req, "projectId"), {
2536
- type: "item-updated",
2537
- data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2538
- });
2539
- res.json(result.parsed || {});
2210
+ pushTitleDescArgs(args, req.body);
2211
+ await runPlanMutation(req, res, project, args, "Failed to update plan");
2540
2212
  });
2541
2213
  // DELETE /api/projects/:projectId/pm/plan/:planId
2542
2214
  router.delete("/plan/:planId", async (req, res) => {
2543
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2544
- if (!project) {
2545
- res.status(404).json({ error: "Project not found" });
2215
+ const project = await requireProject(req, res);
2216
+ if (!project)
2546
2217
  return;
2547
- }
2548
- const result = await runPm({
2549
- args: ["delete", routeParam(req, "planId")],
2550
- userId: project.ownerUserId,
2551
- slug: project.slug,
2552
- jsonOutput: true,
2553
- });
2218
+ const result = await projectPm(project, ["delete", routeParam(req, "planId")], true);
2554
2219
  if (!result.ok) {
2555
2220
  res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to delete plan" });
2556
2221
  return;
@@ -2563,11 +2228,9 @@ router.delete("/plan/:planId", async (req, res) => {
2563
2228
  });
2564
2229
  // POST /api/projects/:projectId/pm/plan/:planId/steps
2565
2230
  router.post("/plan/:planId/steps", async (req, res) => {
2566
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2567
- if (!project) {
2568
- res.status(404).json({ error: "Project not found" });
2231
+ const project = await requireProject(req, res);
2232
+ if (!project)
2569
2233
  return;
2570
- }
2571
2234
  const { title, description, dependsOn } = req.body;
2572
2235
  if (!title?.trim()) {
2573
2236
  res.status(400).json({ error: "Title is required" });
@@ -2578,145 +2241,55 @@ router.post("/plan/:planId/steps", async (req, res) => {
2578
2241
  args.push("--description", description);
2579
2242
  if (dependsOn)
2580
2243
  args.push("--depends-on", dependsOn);
2581
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2582
- if (!result.ok) {
2583
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to add step" });
2584
- return;
2585
- }
2586
- broadcastProjectEvent(routeParam(req, "projectId"), {
2587
- type: "item-updated",
2588
- data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2589
- });
2590
- res.status(201).json(result.parsed || {});
2244
+ await runPlanMutation(req, res, project, args, "Failed to add step", 201);
2591
2245
  });
2592
2246
  // PATCH /api/projects/:projectId/pm/plan/:planId/steps/:stepRef
2593
2247
  router.patch("/plan/:planId/steps/:stepRef", async (req, res) => {
2594
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2595
- if (!project) {
2596
- res.status(404).json({ error: "Project not found" });
2248
+ const project = await requireProject(req, res);
2249
+ if (!project)
2597
2250
  return;
2598
- }
2599
- const { title, description } = req.body;
2600
2251
  const args = ["plan", "update-step", routeParam(req, "planId"), routeParam(req, "stepRef")];
2601
- if (title)
2602
- args.push("--title", title);
2603
- if (description)
2604
- args.push("--description", description);
2605
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2606
- if (!result.ok) {
2607
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to update step" });
2608
- return;
2609
- }
2610
- broadcastProjectEvent(routeParam(req, "projectId"), {
2611
- type: "item-updated",
2612
- data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2613
- });
2614
- res.json(result.parsed || {});
2252
+ pushTitleDescArgs(args, req.body);
2253
+ await runPlanMutation(req, res, project, args, "Failed to update step");
2615
2254
  });
2616
2255
  // POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/complete
2617
2256
  router.post("/plan/:planId/steps/:stepRef/complete", async (req, res) => {
2618
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2619
- if (!project) {
2620
- res.status(404).json({ error: "Project not found" });
2257
+ const project = await requireProject(req, res);
2258
+ if (!project)
2621
2259
  return;
2622
- }
2623
- const result = await runPm({
2624
- args: ["plan", "complete-step", routeParam(req, "planId"), routeParam(req, "stepRef")],
2625
- userId: project.ownerUserId,
2626
- slug: project.slug,
2627
- jsonOutput: true,
2628
- });
2629
- if (!result.ok) {
2630
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to complete step" });
2631
- return;
2632
- }
2633
- broadcastProjectEvent(routeParam(req, "projectId"), {
2634
- type: "item-updated",
2635
- data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2636
- });
2637
- res.json(result.parsed || {});
2260
+ await runPlanMutation(req, res, project, ["plan", "complete-step", routeParam(req, "planId"), routeParam(req, "stepRef")], "Failed to complete step");
2638
2261
  });
2639
2262
  // POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/block
2640
2263
  router.post("/plan/:planId/steps/:stepRef/block", async (req, res) => {
2641
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2642
- if (!project) {
2643
- res.status(404).json({ error: "Project not found" });
2264
+ const project = await requireProject(req, res);
2265
+ if (!project)
2644
2266
  return;
2645
- }
2646
2267
  const { reason } = req.body;
2647
2268
  if (!reason?.trim()) {
2648
2269
  res.status(400).json({ error: "Block reason is required" });
2649
2270
  return;
2650
2271
  }
2651
- const result = await runPm({
2652
- args: ["plan", "block-step", routeParam(req, "planId"), routeParam(req, "stepRef"), "--step-blocked-reason", reason.trim()],
2653
- userId: project.ownerUserId,
2654
- slug: project.slug,
2655
- jsonOutput: true,
2656
- });
2657
- if (!result.ok) {
2658
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to block step" });
2659
- return;
2660
- }
2661
- broadcastProjectEvent(routeParam(req, "projectId"), {
2662
- type: "item-updated",
2663
- data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2664
- });
2665
- res.json(result.parsed || {});
2272
+ await runPlanMutation(req, res, project, ["plan", "block-step", routeParam(req, "planId"), routeParam(req, "stepRef"), "--step-blocked-reason", reason.trim()], "Failed to block step");
2666
2273
  });
2667
2274
  // DELETE /api/projects/:projectId/pm/plan/:planId/steps/:stepRef
2668
2275
  router.delete("/plan/:planId/steps/:stepRef", async (req, res) => {
2669
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2670
- if (!project) {
2671
- res.status(404).json({ error: "Project not found" });
2672
- return;
2673
- }
2674
- const result = await runPm({
2675
- args: ["plan", "remove-step", routeParam(req, "planId"), routeParam(req, "stepRef")],
2676
- userId: project.ownerUserId,
2677
- slug: project.slug,
2678
- jsonOutput: true,
2679
- });
2680
- if (!result.ok) {
2681
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to remove step" });
2276
+ const project = await requireProject(req, res);
2277
+ if (!project)
2682
2278
  return;
2683
- }
2684
- broadcastProjectEvent(routeParam(req, "projectId"), {
2685
- type: "item-updated",
2686
- data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2687
- });
2688
- res.json(result.parsed || {});
2279
+ await runPlanMutation(req, res, project, ["plan", "remove-step", routeParam(req, "planId"), routeParam(req, "stepRef")], "Failed to remove step");
2689
2280
  });
2690
2281
  // POST /api/projects/:projectId/pm/plan/:planId/approve
2691
2282
  router.post("/plan/:planId/approve", async (req, res) => {
2692
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2693
- if (!project) {
2694
- res.status(404).json({ error: "Project not found" });
2695
- return;
2696
- }
2697
- const result = await runPm({
2698
- args: ["plan", "approve", routeParam(req, "planId")],
2699
- userId: project.ownerUserId,
2700
- slug: project.slug,
2701
- jsonOutput: true,
2702
- });
2703
- if (!result.ok) {
2704
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to approve plan" });
2283
+ const project = await requireProject(req, res);
2284
+ if (!project)
2705
2285
  return;
2706
- }
2707
- broadcastProjectEvent(routeParam(req, "projectId"), {
2708
- type: "item-updated",
2709
- data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2710
- });
2711
- res.json(result.parsed || {});
2286
+ await runPlanMutation(req, res, project, ["plan", "approve", routeParam(req, "planId")], "Failed to approve plan");
2712
2287
  });
2713
2288
  // POST /api/projects/:projectId/pm/plan/:planId/materialize
2714
2289
  router.post("/plan/:planId/materialize", async (req, res) => {
2715
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2716
- if (!project) {
2717
- res.status(404).json({ error: "Project not found" });
2290
+ const project = await requireProject(req, res);
2291
+ if (!project)
2718
2292
  return;
2719
- }
2720
2293
  const { materializeType, materializeParent, steps } = req.body;
2721
2294
  const args = ["plan", "materialize", routeParam(req, "planId")];
2722
2295
  if (materializeType)
@@ -2725,7 +2298,7 @@ router.post("/plan/:planId/materialize", async (req, res) => {
2725
2298
  args.push("--materialize-parent", materializeParent);
2726
2299
  if (steps)
2727
2300
  args.push("--steps", steps);
2728
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2301
+ const result = await projectPm(project, args, true);
2729
2302
  if (!result.ok) {
2730
2303
  res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to materialize plan" });
2731
2304
  return;
@@ -2738,39 +2311,21 @@ router.post("/plan/:planId/materialize", async (req, res) => {
2738
2311
  });
2739
2312
  // POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/reorder
2740
2313
  router.post("/plan/:planId/steps/:stepRef/reorder", async (req, res) => {
2741
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2742
- if (!project) {
2743
- res.status(404).json({ error: "Project not found" });
2314
+ const project = await requireProject(req, res);
2315
+ if (!project)
2744
2316
  return;
2745
- }
2746
2317
  const { reorderTo } = req.body;
2747
2318
  if (reorderTo === undefined || reorderTo === null || reorderTo === "") {
2748
2319
  res.status(400).json({ error: "reorderTo (new order integer) is required" });
2749
2320
  return;
2750
2321
  }
2751
- const result = await runPm({
2752
- args: ["plan", "reorder-step", routeParam(req, "planId"), routeParam(req, "stepRef"), String(reorderTo)],
2753
- userId: project.ownerUserId,
2754
- slug: project.slug,
2755
- jsonOutput: true,
2756
- });
2757
- if (!result.ok) {
2758
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to reorder step" });
2759
- return;
2760
- }
2761
- broadcastProjectEvent(routeParam(req, "projectId"), {
2762
- type: "item-updated",
2763
- data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2764
- });
2765
- res.json(result.parsed || {});
2322
+ await runPlanMutation(req, res, project, ["plan", "reorder-step", routeParam(req, "planId"), routeParam(req, "stepRef"), String(reorderTo)], "Failed to reorder step");
2766
2323
  });
2767
2324
  // POST /api/projects/:projectId/pm/plan/:planId/link
2768
2325
  router.post("/plan/:planId/link", async (req, res) => {
2769
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2770
- if (!project) {
2771
- res.status(404).json({ error: "Project not found" });
2326
+ const project = await requireProject(req, res);
2327
+ if (!project)
2772
2328
  return;
2773
- }
2774
2329
  const { link, linkKind, linkNote, promoteToItemDep } = req.body;
2775
2330
  if (!link?.trim()) {
2776
2331
  res.status(400).json({ error: "link (item id) is required" });
@@ -2783,24 +2338,13 @@ router.post("/plan/:planId/link", async (req, res) => {
2783
2338
  args.push("--link-note", linkNote);
2784
2339
  if (promoteToItemDep === "true")
2785
2340
  args.push("--promote-to-item-dep");
2786
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2787
- if (!result.ok) {
2788
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to link plan" });
2789
- return;
2790
- }
2791
- broadcastProjectEvent(routeParam(req, "projectId"), {
2792
- type: "item-updated",
2793
- data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2794
- });
2795
- res.status(201).json(result.parsed || {});
2341
+ await runPlanMutation(req, res, project, args, "Failed to link plan", 201);
2796
2342
  });
2797
2343
  // DELETE /api/projects/:projectId/pm/plan/:planId/link
2798
2344
  router.delete("/plan/:planId/link", async (req, res) => {
2799
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2800
- if (!project) {
2801
- res.status(404).json({ error: "Project not found" });
2345
+ const project = await requireProject(req, res);
2346
+ if (!project)
2802
2347
  return;
2803
- }
2804
2348
  const { link, linkKind } = req.body;
2805
2349
  if (!link?.trim()) {
2806
2350
  res.status(400).json({ error: "link (item id) is required" });
@@ -2809,48 +2353,25 @@ router.delete("/plan/:planId/link", async (req, res) => {
2809
2353
  const args = ["plan", "unlink", routeParam(req, "planId"), "--link", link.trim()];
2810
2354
  if (linkKind)
2811
2355
  args.push("--link-kind", linkKind);
2812
- const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2813
- if (!result.ok) {
2814
- res.status(pmErrorStatus(result)).json({ error: result.stderr || "Failed to unlink plan" });
2815
- return;
2816
- }
2817
- broadcastProjectEvent(routeParam(req, "projectId"), {
2818
- type: "item-updated",
2819
- data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2820
- });
2821
- res.json(result.parsed || {});
2356
+ await runPlanMutation(req, res, project, args, "Failed to unlink plan");
2822
2357
  });
2823
2358
  // GET /api/projects/:projectId/pm/upgrade
2824
2359
  // Returns a dry-run preview of what upgrade would do (safe, read-only).
2825
2360
  // POST /api/projects/:projectId/pm/upgrade
2826
2361
  // Runs pm upgrade --packages-only (never upgrades the CLI itself via the web UI).
2827
2362
  router.get("/upgrade", async (req, res) => {
2828
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2829
- if (!project) {
2830
- res.status(404).json({ error: "Project not found" });
2363
+ const project = await requireProject(req, res);
2364
+ if (!project)
2831
2365
  return;
2832
- }
2833
- const result = await runPm({
2834
- args: ["upgrade", "--dry-run", "--packages-only"],
2835
- userId: project.ownerUserId,
2836
- slug: project.slug,
2837
- jsonOutput: true,
2838
- });
2366
+ const result = await projectPm(project, ["upgrade", "--dry-run", "--packages-only"], true);
2839
2367
  res.json(result.ok ? (result.parsed || { dryRun: true }) : { error: result.stderr });
2840
2368
  });
2841
2369
  router.post("/upgrade", async (req, res) => {
2842
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2843
- if (!project) {
2844
- res.status(404).json({ error: "Project not found" });
2370
+ const project = await requireProject(req, res);
2371
+ if (!project)
2845
2372
  return;
2846
- }
2847
2373
  // Only allow package upgrades from the web UI — never self-upgrade the CLI binary.
2848
- const result = await runPm({
2849
- args: ["upgrade", "--packages-only"],
2850
- userId: project.ownerUserId,
2851
- slug: project.slug,
2852
- jsonOutput: true,
2853
- });
2374
+ const result = await projectPm(project, ["upgrade", "--packages-only"], true);
2854
2375
  if (!result.ok) {
2855
2376
  res.status(pmErrorStatus(result)).json({ error: result.stderr || "Upgrade failed" });
2856
2377
  return;
@@ -2882,11 +2403,9 @@ router.patch("/presence/:clientId", async (req, res) => {
2882
2403
  // GET /api/projects/:projectId/pm/events
2883
2404
  router.get("/events", async (req, res) => {
2884
2405
  try {
2885
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2886
- if (!project) {
2887
- res.status(404).json({ error: "Project not found" });
2406
+ const project = await requireProject(req, res);
2407
+ if (!project)
2888
2408
  return;
2889
- }
2890
2409
  }
2891
2410
  catch (err) {
2892
2411
  console.error("SSE project verification failed:", err);
@@ -2935,11 +2454,9 @@ router.get("/events", async (req, res) => {
2935
2454
  // ─── Presence endpoint ───
2936
2455
  // GET /api/projects/:projectId/pm/presence
2937
2456
  router.get("/presence", async (req, res) => {
2938
- const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2939
- if (!project) {
2940
- res.status(404).json({ error: "Project not found" });
2457
+ const project = await requireProject(req, res);
2458
+ if (!project)
2941
2459
  return;
2942
- }
2943
2460
  const users = getProjectPresence(routeParam(req, "projectId"));
2944
2461
  res.json({ users });
2945
2462
  });