shipwright-cli 2.2.2 → 2.3.0

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 (143) hide show
  1. package/README.md +12 -11
  2. package/dashboard/public/index.html +224 -8
  3. package/dashboard/public/styles.css +1078 -4
  4. package/dashboard/server.ts +1100 -15
  5. package/dashboard/src/canvas/interactions.ts +74 -0
  6. package/dashboard/src/canvas/layout.ts +85 -0
  7. package/dashboard/src/canvas/overlays.ts +117 -0
  8. package/dashboard/src/canvas/particles.ts +105 -0
  9. package/dashboard/src/canvas/renderer.ts +191 -0
  10. package/dashboard/src/components/charts/bar.ts +54 -0
  11. package/dashboard/src/components/charts/donut.ts +25 -0
  12. package/dashboard/src/components/charts/pipeline-rail.ts +105 -0
  13. package/dashboard/src/components/charts/sparkline.ts +82 -0
  14. package/dashboard/src/components/header.ts +616 -0
  15. package/dashboard/src/components/modal.ts +413 -0
  16. package/dashboard/src/components/terminal.ts +144 -0
  17. package/dashboard/src/core/api.ts +381 -0
  18. package/dashboard/src/core/helpers.ts +118 -0
  19. package/dashboard/src/core/router.ts +190 -0
  20. package/dashboard/src/core/sse.ts +38 -0
  21. package/dashboard/src/core/state.ts +150 -0
  22. package/dashboard/src/core/ws.ts +143 -0
  23. package/dashboard/src/design/icons.ts +131 -0
  24. package/dashboard/src/design/tokens.ts +160 -0
  25. package/dashboard/src/main.ts +68 -0
  26. package/dashboard/src/types/api.ts +337 -0
  27. package/dashboard/src/views/activity.ts +185 -0
  28. package/dashboard/src/views/agent-cockpit.ts +236 -0
  29. package/dashboard/src/views/agents.ts +72 -0
  30. package/dashboard/src/views/fleet-map.ts +299 -0
  31. package/dashboard/src/views/insights.ts +298 -0
  32. package/dashboard/src/views/machines.ts +162 -0
  33. package/dashboard/src/views/metrics.ts +420 -0
  34. package/dashboard/src/views/overview.ts +409 -0
  35. package/dashboard/src/views/pipeline-theater.ts +219 -0
  36. package/dashboard/src/views/pipelines.ts +595 -0
  37. package/dashboard/src/views/team.ts +362 -0
  38. package/dashboard/src/views/timeline.ts +389 -0
  39. package/dashboard/tsconfig.json +21 -0
  40. package/docs/AGI-WHATS-NEXT.md +15 -15
  41. package/package.json +8 -1
  42. package/scripts/lib/helpers.sh +30 -0
  43. package/scripts/lib/pipeline-quality-checks.sh +1 -1
  44. package/scripts/sw +86 -167
  45. package/scripts/sw-activity.sh +1 -1
  46. package/scripts/sw-adaptive.sh +1 -1
  47. package/scripts/sw-adversarial.sh +1 -1
  48. package/scripts/sw-architecture-enforcer.sh +1 -1
  49. package/scripts/sw-auth.sh +14 -6
  50. package/scripts/sw-autonomous.sh +1 -1
  51. package/scripts/sw-changelog.sh +2 -2
  52. package/scripts/sw-checkpoint.sh +1 -1
  53. package/scripts/sw-ci.sh +1 -1
  54. package/scripts/sw-cleanup.sh +1 -1
  55. package/scripts/sw-code-review.sh +1 -1
  56. package/scripts/sw-connect.sh +1 -1
  57. package/scripts/sw-context.sh +1 -1
  58. package/scripts/sw-cost.sh +1 -1
  59. package/scripts/sw-daemon.sh +2 -2
  60. package/scripts/sw-dashboard.sh +1 -1
  61. package/scripts/sw-db.sh +1 -1
  62. package/scripts/sw-decompose.sh +1 -1
  63. package/scripts/sw-deps.sh +1 -1
  64. package/scripts/sw-developer-simulation.sh +1 -1
  65. package/scripts/sw-discovery.sh +1 -1
  66. package/scripts/sw-doc-fleet.sh +1 -1
  67. package/scripts/sw-docs-agent.sh +1 -1
  68. package/scripts/sw-docs.sh +1 -1
  69. package/scripts/sw-doctor.sh +8 -1
  70. package/scripts/sw-dora.sh +1 -1
  71. package/scripts/sw-durable.sh +1 -1
  72. package/scripts/sw-e2e-orchestrator.sh +1 -1
  73. package/scripts/sw-eventbus.sh +1 -1
  74. package/scripts/sw-feedback.sh +1 -1
  75. package/scripts/sw-fix.sh +6 -5
  76. package/scripts/sw-fleet-discover.sh +1 -1
  77. package/scripts/sw-fleet-viz.sh +1 -1
  78. package/scripts/sw-fleet.sh +1 -1
  79. package/scripts/sw-github-app.sh +5 -2
  80. package/scripts/sw-github-checks.sh +1 -1
  81. package/scripts/sw-github-deploy.sh +1 -1
  82. package/scripts/sw-github-graphql.sh +1 -1
  83. package/scripts/sw-guild.sh +1 -1
  84. package/scripts/sw-heartbeat.sh +1 -1
  85. package/scripts/sw-hygiene.sh +1 -1
  86. package/scripts/sw-incident.sh +1 -1
  87. package/scripts/sw-init.sh +112 -9
  88. package/scripts/sw-instrument.sh +6 -1
  89. package/scripts/sw-intelligence.sh +5 -1
  90. package/scripts/sw-jira.sh +1 -1
  91. package/scripts/sw-launchd.sh +1 -1
  92. package/scripts/sw-linear.sh +20 -9
  93. package/scripts/sw-logs.sh +1 -1
  94. package/scripts/sw-loop.sh +2 -1
  95. package/scripts/sw-memory.sh +10 -1
  96. package/scripts/sw-mission-control.sh +1 -1
  97. package/scripts/sw-model-router.sh +4 -1
  98. package/scripts/sw-otel.sh +1 -1
  99. package/scripts/sw-oversight.sh +1 -1
  100. package/scripts/sw-pipeline-composer.sh +3 -1
  101. package/scripts/sw-pipeline-vitals.sh +4 -6
  102. package/scripts/sw-pipeline.sh +4 -1
  103. package/scripts/sw-pm.sh +5 -2
  104. package/scripts/sw-pr-lifecycle.sh +1 -1
  105. package/scripts/sw-predictive.sh +4 -1
  106. package/scripts/sw-prep.sh +3 -2
  107. package/scripts/sw-ps.sh +1 -1
  108. package/scripts/sw-public-dashboard.sh +10 -4
  109. package/scripts/sw-quality.sh +1 -1
  110. package/scripts/sw-reaper.sh +1 -1
  111. package/scripts/sw-recruit.sh +16 -0
  112. package/scripts/sw-regression.sh +2 -1
  113. package/scripts/sw-release-manager.sh +1 -1
  114. package/scripts/sw-release.sh +7 -5
  115. package/scripts/sw-remote.sh +1 -1
  116. package/scripts/sw-replay.sh +1 -1
  117. package/scripts/sw-retro.sh +1 -1
  118. package/scripts/sw-scale.sh +4 -1
  119. package/scripts/sw-security-audit.sh +1 -1
  120. package/scripts/sw-self-optimize.sh +15 -1
  121. package/scripts/sw-session.sh +1 -1
  122. package/scripts/sw-setup.sh +1 -1
  123. package/scripts/sw-standup.sh +2 -1
  124. package/scripts/sw-status.sh +1 -1
  125. package/scripts/sw-strategic.sh +2 -1
  126. package/scripts/sw-stream.sh +1 -1
  127. package/scripts/sw-swarm.sh +6 -1
  128. package/scripts/sw-team-stages.sh +1 -1
  129. package/scripts/sw-templates.sh +1 -1
  130. package/scripts/sw-testgen.sh +3 -2
  131. package/scripts/sw-tmux-pipeline.sh +2 -1
  132. package/scripts/sw-tmux.sh +1 -1
  133. package/scripts/sw-trace.sh +1 -1
  134. package/scripts/sw-tracker-jira.sh +1 -0
  135. package/scripts/sw-tracker-linear.sh +1 -0
  136. package/scripts/sw-tracker.sh +1 -1
  137. package/scripts/sw-triage.sh +1 -1
  138. package/scripts/sw-upgrade.sh +1 -1
  139. package/scripts/sw-ux.sh +1 -1
  140. package/scripts/sw-webhook.sh +1 -1
  141. package/scripts/sw-widgets.sh +2 -2
  142. package/scripts/sw-worktree.sh +1 -1
  143. package/dashboard/public/app.js +0 -4422
@@ -268,6 +268,7 @@ interface Session {
268
268
  accessToken: string;
269
269
  avatarUrl: string;
270
270
  isAdmin: boolean;
271
+ role: "viewer" | "operator" | "admin";
271
272
  expiresAt: number;
272
273
  }
273
274
 
@@ -687,6 +688,192 @@ function accessDeniedHTML(repo: string): string {
687
688
  </html>`;
688
689
  }
689
690
 
691
+ // ─── Notification / Webhook System ──────────────────────────────────
692
+ const NOTIFICATIONS_CONFIG_FILE = join(
693
+ HOME,
694
+ ".shipwright",
695
+ "notifications.json",
696
+ );
697
+
698
+ interface NotificationConfig {
699
+ enabled: boolean;
700
+ webhooks: Array<{
701
+ url: string;
702
+ label: string;
703
+ events: string[]; // "pipeline.completed", "pipeline.failed", "alert", "all"
704
+ created_at: string;
705
+ }>;
706
+ }
707
+
708
+ function loadNotificationConfig(): NotificationConfig {
709
+ try {
710
+ if (existsSync(NOTIFICATIONS_CONFIG_FILE)) {
711
+ return JSON.parse(
712
+ readFileOr(
713
+ NOTIFICATIONS_CONFIG_FILE,
714
+ '{"enabled":false,"webhooks":[]}',
715
+ ),
716
+ );
717
+ }
718
+ } catch {}
719
+ return { enabled: false, webhooks: [] };
720
+ }
721
+
722
+ function saveNotificationConfig(config: NotificationConfig): void {
723
+ try {
724
+ const dir = join(HOME, ".shipwright");
725
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
726
+ const tmp = NOTIFICATIONS_CONFIG_FILE + ".tmp";
727
+ writeFileSync(tmp, JSON.stringify(config, null, 2));
728
+ renameSync(tmp, NOTIFICATIONS_CONFIG_FILE);
729
+ } catch {}
730
+ }
731
+
732
+ let lastNotifiedEvents = new Set<string>();
733
+
734
+ async function sendNotifications(
735
+ eventType: string,
736
+ payload: Record<string, unknown>,
737
+ ): Promise<void> {
738
+ const config = loadNotificationConfig();
739
+ if (!config.enabled || config.webhooks.length === 0) return;
740
+
741
+ // Deduplicate: create a key from event type + issue + timestamp
742
+ const eventKey = `${eventType}:${payload.issue || ""}:${payload.ts || ""}`;
743
+ if (lastNotifiedEvents.has(eventKey)) return;
744
+ lastNotifiedEvents.add(eventKey);
745
+ // Trim to prevent memory leak
746
+ if (lastNotifiedEvents.size > 500) {
747
+ const arr = Array.from(lastNotifiedEvents);
748
+ lastNotifiedEvents = new Set(arr.slice(-250));
749
+ }
750
+
751
+ for (const webhook of config.webhooks) {
752
+ if (webhook.events.includes("all") || webhook.events.includes(eventType)) {
753
+ try {
754
+ await fetch(webhook.url, {
755
+ method: "POST",
756
+ headers: { "Content-Type": "application/json" },
757
+ body: JSON.stringify({
758
+ event: eventType,
759
+ timestamp: new Date().toISOString(),
760
+ ...payload,
761
+ }),
762
+ });
763
+ } catch {}
764
+ }
765
+ }
766
+ }
767
+
768
+ // Hook into event processing to detect new completions/failures
769
+ let lastEventCount = 0;
770
+ function checkAndNotifyNewEvents(): void {
771
+ const events = readEvents();
772
+ if (events.length <= lastEventCount) {
773
+ lastEventCount = events.length;
774
+ return;
775
+ }
776
+ const newEvents = events.slice(lastEventCount);
777
+ lastEventCount = events.length;
778
+
779
+ for (const evt of newEvents) {
780
+ const e = evt as Record<string, unknown>;
781
+ const type = String(e.type || "");
782
+ if (type === "pipeline.completed") {
783
+ const result =
784
+ e.result === "failure" ? "pipeline.failed" : "pipeline.completed";
785
+ sendNotifications(result, {
786
+ issue: e.issue,
787
+ result: e.result,
788
+ duration_s: e.duration_s,
789
+ ts: e.ts,
790
+ });
791
+ }
792
+ if (type.includes("failed") && !type.includes("pipeline")) {
793
+ sendNotifications("stage.failed", {
794
+ issue: e.issue,
795
+ stage: e.stage,
796
+ ts: e.ts,
797
+ });
798
+ }
799
+ }
800
+ }
801
+
802
+ // ─── RBAC (Role-Based Access Control) ───────────────────────────────
803
+ const RBAC_CONFIG_FILE = join(HOME, ".shipwright", "rbac.json");
804
+
805
+ interface RBACConfig {
806
+ default_role: "viewer" | "operator" | "admin";
807
+ users: Record<string, "viewer" | "operator" | "admin">;
808
+ }
809
+
810
+ function loadRBACConfig(): RBACConfig {
811
+ try {
812
+ if (existsSync(RBAC_CONFIG_FILE)) {
813
+ return JSON.parse(
814
+ readFileOr(RBAC_CONFIG_FILE, '{"default_role":"admin","users":{}}'),
815
+ );
816
+ }
817
+ } catch {}
818
+ return { default_role: "admin", users: {} };
819
+ }
820
+
821
+ function saveRBACConfig(config: RBACConfig): void {
822
+ try {
823
+ const dir = join(HOME, ".shipwright");
824
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
825
+ const tmp = RBAC_CONFIG_FILE + ".tmp";
826
+ writeFileSync(tmp, JSON.stringify(config, null, 2));
827
+ renameSync(tmp, RBAC_CONFIG_FILE);
828
+ } catch {}
829
+ }
830
+
831
+ function resolveUserRole(username: string): "viewer" | "operator" | "admin" {
832
+ const config = loadRBACConfig();
833
+ return config.users[username] || config.default_role;
834
+ }
835
+
836
+ // Role permissions: what each role can do
837
+ const ROLE_PERMISSIONS = {
838
+ viewer: new Set(["read"]),
839
+ operator: new Set(["read", "intervene", "approve", "control-daemon"]),
840
+ admin: new Set([
841
+ "read",
842
+ "intervene",
843
+ "approve",
844
+ "control-daemon",
845
+ "configure",
846
+ "manage-users",
847
+ ]),
848
+ };
849
+
850
+ function hasPermission(
851
+ role: "viewer" | "operator" | "admin",
852
+ permission: string,
853
+ ): boolean {
854
+ return ROLE_PERMISSIONS[role]?.has(permission) ?? false;
855
+ }
856
+
857
+ // ─── Audit Log ──────────────────────────────────────────────────────
858
+ const AUDIT_LOG_FILE = join(HOME, ".shipwright", "audit-log.jsonl");
859
+
860
+ function appendAuditLog(
861
+ action: string,
862
+ details: Record<string, unknown>,
863
+ ): void {
864
+ try {
865
+ const dir = join(HOME, ".shipwright");
866
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
867
+ const entry = JSON.stringify({
868
+ ts: new Date().toISOString(),
869
+ ts_epoch: Math.floor(Date.now() / 1000),
870
+ action,
871
+ ...details,
872
+ });
873
+ appendFileSync(AUDIT_LOG_FILE, entry + "\n");
874
+ } catch {}
875
+ }
876
+
690
877
  // ─── WebSocket client tracking ───────────────────────────────────────
691
878
  const wsClients = new Set<import("bun").ServerWebSocket<unknown>>();
692
879
  const startTime = Date.now();
@@ -945,9 +1132,10 @@ function resolveWorktreePath(relative: string): string | null {
945
1132
  // Try well-known repo locations
946
1133
  const candidates: string[] = [];
947
1134
 
948
- // Check env var
949
- if (process.env.VOICEAI_REPO) {
950
- candidates.push(join(process.env.VOICEAI_REPO, relative));
1135
+ // Check env vars for repo location
1136
+ const repoEnv = process.env.SHIPWRIGHT_REPO || process.env.VOICEAI_REPO;
1137
+ if (repoEnv) {
1138
+ candidates.push(join(repoEnv, relative));
951
1139
  }
952
1140
 
953
1141
  // Scan daemon state for any repo paths from completed or active jobs
@@ -961,15 +1149,15 @@ function resolveWorktreePath(relative: string): string | null {
961
1149
  }
962
1150
  }
963
1151
 
964
- // Try common parent directories where worktrees might live
965
- const homeDir = process.env.HOME || "";
966
- const commonBases = [
967
- join(homeDir, "Documents/voiceai"),
968
- join(homeDir, "Documents/claude-code-teams-tmux"),
969
- join(homeDir, "voiceai"),
970
- ];
971
- for (const base of commonBases) {
972
- candidates.push(join(base, relative));
1152
+ // Try discovering repo root via git
1153
+ try {
1154
+ const gitRoot = execSync("git rev-parse --show-toplevel 2>/dev/null", {
1155
+ encoding: "utf-8",
1156
+ timeout: 3000,
1157
+ }).trim();
1158
+ if (gitRoot) candidates.push(join(gitRoot, relative));
1159
+ } catch {
1160
+ // not in a git repo or git not available
973
1161
  }
974
1162
 
975
1163
  for (const candidate of candidates) {
@@ -1935,6 +2123,10 @@ function startEventsWatcher(): void {
1935
2123
  if (wsClients.size > 0) {
1936
2124
  broadcastToClients(getFleetState());
1937
2125
  }
2126
+ // Check for new events and send notifications
2127
+ if (filename === "events.jsonl") {
2128
+ checkAndNotifyNewEvents();
2129
+ }
1938
2130
  }
1939
2131
  });
1940
2132
  } catch {
@@ -2078,6 +2270,7 @@ async function handleAuthCallback(url: URL): Promise<Response> {
2078
2270
  accessToken,
2079
2271
  avatarUrl: user.avatar_url,
2080
2272
  isAdmin,
2273
+ role: resolveUserRole(user.login),
2081
2274
  });
2082
2275
 
2083
2276
  return new Response(null, {
@@ -2159,6 +2352,7 @@ async function handlePatLogin(req: Request): Promise<Response> {
2159
2352
  accessToken: "", // PAT mode doesn't give per-user tokens
2160
2353
  avatarUrl,
2161
2354
  isAdmin,
2355
+ role: resolveUserRole(username),
2162
2356
  });
2163
2357
 
2164
2358
  return new Response(null, {
@@ -2324,8 +2518,8 @@ const server = Bun.serve({
2324
2518
  });
2325
2519
  }
2326
2520
 
2327
- // REST: pipeline detail for a specific issue
2328
- if (pathname.startsWith("/api/pipeline/")) {
2521
+ // REST: pipeline detail for a specific issue (only exact /api/pipeline/:num)
2522
+ if (/^\/api\/pipeline\/\d+$/.test(pathname)) {
2329
2523
  const issueNum = parseInt(pathname.split("/")[3] || "0");
2330
2524
  if (!issueNum || isNaN(issueNum)) {
2331
2525
  return new Response(JSON.stringify({ error: "Invalid issue number" }), {
@@ -2552,6 +2746,7 @@ const server = Bun.serve({
2552
2746
  headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2553
2747
  });
2554
2748
  }
2749
+ appendAuditLog("intervention", { action, issue: issueNum, pid });
2555
2750
  return new Response(
2556
2751
  JSON.stringify({ ok: true, action, issue: issueNum }),
2557
2752
  { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
@@ -2575,7 +2770,12 @@ const server = Bun.serve({
2575
2770
  if (pathname === "/api/me") {
2576
2771
  if (!isAuthEnabled()) {
2577
2772
  return new Response(
2578
- JSON.stringify({ username: "local", avatarUrl: "", isAdmin: true }),
2773
+ JSON.stringify({
2774
+ username: "local",
2775
+ avatarUrl: "",
2776
+ isAdmin: true,
2777
+ role: "admin",
2778
+ }),
2579
2779
  { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
2580
2780
  );
2581
2781
  }
@@ -2591,6 +2791,7 @@ const server = Bun.serve({
2591
2791
  username: session.githubUser,
2592
2792
  avatarUrl: session.avatarUrl,
2593
2793
  isAdmin: session.isAdmin,
2794
+ role: session.role || "admin",
2594
2795
  }),
2595
2796
  { headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
2596
2797
  );
@@ -2598,6 +2799,100 @@ const server = Bun.serve({
2598
2799
 
2599
2800
  // ── Phase 1: Pipeline Deep-Dive endpoints ─────────────────────
2600
2801
 
2802
+ // SSE: Live log streaming for a pipeline
2803
+ if (pathname.match(/^\/api\/logs\/\d+\/stream$/)) {
2804
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
2805
+ if (!issueNum || isNaN(issueNum)) {
2806
+ return new Response(JSON.stringify({ error: "Invalid issue number" }), {
2807
+ status: 400,
2808
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2809
+ });
2810
+ }
2811
+ const logFile = join(LOGS_DIR, `issue-${issueNum}.log`);
2812
+ if (!existsSync(logFile)) {
2813
+ return new Response(JSON.stringify({ error: "Log file not found" }), {
2814
+ status: 404,
2815
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2816
+ });
2817
+ }
2818
+
2819
+ // Stream the log file via SSE
2820
+ let lastSize = 0;
2821
+ const encoder = new TextEncoder();
2822
+ let sseWatcher: ReturnType<typeof watch> | null = null;
2823
+ let sseHeartbeat: ReturnType<typeof setInterval> | null = null;
2824
+
2825
+ const sseCleanup = () => {
2826
+ if (sseWatcher) {
2827
+ try {
2828
+ sseWatcher.close();
2829
+ } catch {}
2830
+ sseWatcher = null;
2831
+ }
2832
+ if (sseHeartbeat) {
2833
+ clearInterval(sseHeartbeat);
2834
+ sseHeartbeat = null;
2835
+ }
2836
+ };
2837
+
2838
+ const stream = new ReadableStream({
2839
+ start(controller) {
2840
+ // Send existing content first
2841
+ try {
2842
+ const existing = readFileSync(logFile, "utf-8");
2843
+ lastSize = existing.length;
2844
+ const lines = stripAnsi(existing).split("\n");
2845
+ for (const line of lines) {
2846
+ controller.enqueue(encoder.encode(`data: ${line}\n\n`));
2847
+ }
2848
+ } catch {
2849
+ /* ignore */
2850
+ }
2851
+
2852
+ // Watch for new content
2853
+ sseWatcher = watch(logFile, (_event) => {
2854
+ try {
2855
+ const content = readFileSync(logFile, "utf-8");
2856
+ if (content.length > lastSize) {
2857
+ const newContent = content.slice(lastSize);
2858
+ lastSize = content.length;
2859
+ const lines = stripAnsi(newContent).split("\n");
2860
+ for (const line of lines) {
2861
+ if (line)
2862
+ controller.enqueue(encoder.encode(`data: ${line}\n\n`));
2863
+ }
2864
+ }
2865
+ } catch {
2866
+ /* file may have been removed */
2867
+ }
2868
+ });
2869
+
2870
+ controller.enqueue(encoder.encode(":ok\n\n"));
2871
+
2872
+ // Keep connection alive with periodic heartbeat
2873
+ sseHeartbeat = setInterval(() => {
2874
+ try {
2875
+ controller.enqueue(encoder.encode(":\n\n"));
2876
+ } catch {
2877
+ sseCleanup();
2878
+ }
2879
+ }, 15000);
2880
+ },
2881
+ cancel() {
2882
+ sseCleanup();
2883
+ },
2884
+ });
2885
+
2886
+ return new Response(stream, {
2887
+ headers: {
2888
+ "Content-Type": "text/event-stream",
2889
+ "Cache-Control": "no-cache",
2890
+ Connection: "keep-alive",
2891
+ ...CORS_HEADERS,
2892
+ },
2893
+ });
2894
+ }
2895
+
2601
2896
  // REST: Pipeline build logs
2602
2897
  if (pathname.startsWith("/api/logs/")) {
2603
2898
  const issueNum = parseInt(pathname.split("/")[3] || "0");
@@ -2614,6 +2909,208 @@ const server = Bun.serve({
2614
2909
  });
2615
2910
  }
2616
2911
 
2912
+ // REST: Pipeline live diff (git diff from worktree)
2913
+ if (/^\/api\/pipeline\/\d+\/diff$/.test(pathname)) {
2914
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
2915
+ if (!issueNum || isNaN(issueNum)) {
2916
+ return new Response(JSON.stringify({ error: "Invalid issue" }), {
2917
+ status: 400,
2918
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2919
+ });
2920
+ }
2921
+ const worktreeBase = findWorktreeBase(issueNum);
2922
+ let diff = "";
2923
+ let stats = { files_changed: 0, insertions: 0, deletions: 0 };
2924
+ if (worktreeBase && existsSync(worktreeBase)) {
2925
+ try {
2926
+ diff = execSync(
2927
+ "git diff HEAD --no-color 2>/dev/null || git diff --no-color 2>/dev/null || echo ''",
2928
+ {
2929
+ encoding: "utf-8",
2930
+ timeout: 10000,
2931
+ cwd: worktreeBase,
2932
+ },
2933
+ ).trim();
2934
+ const statRaw = execSync(
2935
+ "git diff HEAD --stat --no-color 2>/dev/null || echo ''",
2936
+ {
2937
+ encoding: "utf-8",
2938
+ timeout: 10000,
2939
+ cwd: worktreeBase,
2940
+ },
2941
+ ).trim();
2942
+ const statMatch = statRaw.match(
2943
+ /(\d+) files? changed(?:, (\d+) insertions?[^,]*)?(?:, (\d+) deletions?)?/,
2944
+ );
2945
+ if (statMatch) {
2946
+ stats.files_changed = parseInt(statMatch[1]) || 0;
2947
+ stats.insertions = parseInt(statMatch[2]) || 0;
2948
+ stats.deletions = parseInt(statMatch[3]) || 0;
2949
+ }
2950
+ } catch {}
2951
+ }
2952
+ return new Response(
2953
+ JSON.stringify({ diff, stats, worktree: worktreeBase || "" }),
2954
+ {
2955
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2956
+ },
2957
+ );
2958
+ }
2959
+
2960
+ // REST: Pipeline changed files list
2961
+ if (/^\/api\/pipeline\/\d+\/files$/.test(pathname)) {
2962
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
2963
+ if (!issueNum || isNaN(issueNum)) {
2964
+ return new Response(JSON.stringify({ error: "Invalid issue" }), {
2965
+ status: 400,
2966
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2967
+ });
2968
+ }
2969
+ const worktreeBase = findWorktreeBase(issueNum);
2970
+ let files: Array<{ path: string; status: string }> = [];
2971
+ if (worktreeBase && existsSync(worktreeBase)) {
2972
+ try {
2973
+ const raw = execSync(
2974
+ "git diff HEAD --name-status --no-color 2>/dev/null || echo ''",
2975
+ {
2976
+ encoding: "utf-8",
2977
+ timeout: 10000,
2978
+ cwd: worktreeBase,
2979
+ },
2980
+ ).trim();
2981
+ if (raw) {
2982
+ for (const line of raw.split("\n")) {
2983
+ const parts = line.split("\t");
2984
+ if (parts.length >= 2) {
2985
+ const statusCode = parts[0].trim();
2986
+ const filePath = parts.slice(1).join("\t");
2987
+ const status =
2988
+ statusCode === "A"
2989
+ ? "added"
2990
+ : statusCode === "D"
2991
+ ? "deleted"
2992
+ : statusCode === "M"
2993
+ ? "modified"
2994
+ : statusCode;
2995
+ files.push({ path: filePath, status });
2996
+ }
2997
+ }
2998
+ }
2999
+ } catch {}
3000
+ }
3001
+ return new Response(JSON.stringify({ files }), {
3002
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3003
+ });
3004
+ }
3005
+
3006
+ // REST: Pipeline test results
3007
+ if (/^\/api\/pipeline\/\d+\/test-results$/.test(pathname)) {
3008
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
3009
+ const worktreeBase = findWorktreeBase(issueNum);
3010
+ let testResults: Record<string, unknown> = { available: false };
3011
+ if (worktreeBase) {
3012
+ const resultsPath = join(
3013
+ worktreeBase,
3014
+ ".claude",
3015
+ "pipeline-artifacts",
3016
+ "test-results.json",
3017
+ );
3018
+ if (existsSync(resultsPath)) {
3019
+ try {
3020
+ testResults = JSON.parse(readFileOr(resultsPath, "{}"));
3021
+ testResults.available = true;
3022
+ } catch {}
3023
+ }
3024
+ }
3025
+ return new Response(JSON.stringify(testResults), {
3026
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3027
+ });
3028
+ }
3029
+
3030
+ // REST: Pipeline reasoning (agent thinking/summary per stage)
3031
+ if (/^\/api\/pipeline\/\d+\/reasoning$/.test(pathname)) {
3032
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
3033
+ const worktreeBase = findWorktreeBase(issueNum);
3034
+ let reasoning: Array<Record<string, unknown>> = [];
3035
+ if (worktreeBase) {
3036
+ const artifactsDir = join(
3037
+ worktreeBase,
3038
+ ".claude",
3039
+ "pipeline-artifacts",
3040
+ );
3041
+ // Look for reasoning.json or individual stage reasoning files
3042
+ const reasoningPath = join(artifactsDir, "reasoning.json");
3043
+ if (existsSync(reasoningPath)) {
3044
+ try {
3045
+ const raw = JSON.parse(readFileOr(reasoningPath, "[]"));
3046
+ reasoning = Array.isArray(raw) ? raw : [raw];
3047
+ } catch {}
3048
+ }
3049
+ // Also look for stage-specific reasoning files
3050
+ for (const stage of [
3051
+ "intake",
3052
+ "plan",
3053
+ "design",
3054
+ "build",
3055
+ "test",
3056
+ "review",
3057
+ "merge",
3058
+ ]) {
3059
+ const stagePath = join(artifactsDir, `${stage}-reasoning.md`);
3060
+ if (existsSync(stagePath)) {
3061
+ reasoning.push({
3062
+ stage,
3063
+ content: readFileOr(stagePath, ""),
3064
+ type: "markdown",
3065
+ });
3066
+ }
3067
+ }
3068
+ }
3069
+ return new Response(JSON.stringify({ reasoning }), {
3070
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3071
+ });
3072
+ }
3073
+
3074
+ // REST: Pipeline failure analysis
3075
+ if (/^\/api\/pipeline\/\d+\/failures$/.test(pathname)) {
3076
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
3077
+ let failures: Array<Record<string, unknown>> = [];
3078
+ // Check memory directory for this issue
3079
+ const memDir = join(HOME, ".shipwright", "memory");
3080
+ if (existsSync(memDir)) {
3081
+ try {
3082
+ const dirs = readdirSync(memDir);
3083
+ for (const d of dirs) {
3084
+ const failPath = join(memDir, d, "failures.json");
3085
+ if (existsSync(failPath)) {
3086
+ try {
3087
+ const data = JSON.parse(readFileOr(failPath, "[]"));
3088
+ const items = Array.isArray(data) ? data : [data];
3089
+ for (const item of items) {
3090
+ if (item.issue === issueNum || !item.issue) {
3091
+ failures.push(item);
3092
+ }
3093
+ }
3094
+ } catch {}
3095
+ }
3096
+ }
3097
+ } catch {}
3098
+ }
3099
+ // Also check events for stage failures
3100
+ const events = readEvents();
3101
+ for (const evt of events) {
3102
+ if (
3103
+ (evt as Record<string, unknown>).issue === issueNum &&
3104
+ String((evt as Record<string, unknown>).type || "").includes("failed")
3105
+ ) {
3106
+ failures.push(evt as Record<string, unknown>);
3107
+ }
3108
+ }
3109
+ return new Response(JSON.stringify({ failures }), {
3110
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3111
+ });
3112
+ }
3113
+
2617
3114
  // REST: Pipeline artifacts (plan, design, dod, test-results, review, coverage)
2618
3115
  if (pathname.startsWith("/api/artifacts/")) {
2619
3116
  const parts = pathname.split("/");
@@ -3429,6 +3926,121 @@ const server = Bun.serve({
3429
3926
  });
3430
3927
  }
3431
3928
 
3929
+ // REST: Predictions for a pipeline (ETA, success probability, cost estimate)
3930
+ if (pathname.match(/^\/api\/predictions\/\d+$/) && req.method === "GET") {
3931
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
3932
+ if (!issueNum || isNaN(issueNum)) {
3933
+ return new Response(JSON.stringify({ error: "Invalid issue" }), {
3934
+ status: 400,
3935
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3936
+ });
3937
+ }
3938
+
3939
+ try {
3940
+ const fleetState = getFleetState();
3941
+ const pipeline = fleetState.pipelines?.find(
3942
+ (p: any) => p.issue === issueNum,
3943
+ );
3944
+
3945
+ if (!pipeline) {
3946
+ return new Response(JSON.stringify({}), {
3947
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3948
+ });
3949
+ }
3950
+
3951
+ // Compute predictions from real historical data
3952
+ const metricsHistory = getMetricsHistory(30);
3953
+ const successRate =
3954
+ metricsHistory.success_rate != null
3955
+ ? metricsHistory.success_rate / 100
3956
+ : 0.8;
3957
+ const stagesDone = pipeline.stagesDone?.length || 0;
3958
+
3959
+ // Use real per-stage durations for ETA calculation
3960
+ const stageDurations = metricsHistory.stage_durations || {};
3961
+ const allStages = [
3962
+ "intake",
3963
+ "plan",
3964
+ "design",
3965
+ "build",
3966
+ "test",
3967
+ "review",
3968
+ "merge",
3969
+ ];
3970
+ const currentStageIdx = allStages.indexOf(pipeline.stage || "intake");
3971
+ let remainingTime = 0;
3972
+
3973
+ // Sum average durations for stages not yet completed
3974
+ for (let i = currentStageIdx; i < allStages.length; i++) {
3975
+ const stageName = allStages[i];
3976
+ const stageDur = stageDurations[stageName];
3977
+ if (typeof stageDur === "number" && stageDur > 0) {
3978
+ remainingTime += stageDur;
3979
+ } else {
3980
+ // Fallback: use overall average divided by stage count
3981
+ const avgDuration = metricsHistory.avg_duration_s || 600;
3982
+ remainingTime += avgDuration / allStages.length;
3983
+ }
3984
+ }
3985
+
3986
+ // For the current stage, subtract time already spent
3987
+ if (pipeline.elapsed_s && stagesDone > 0) {
3988
+ const completedDurs = pipeline.stagesDone || [];
3989
+ let completedTime = 0;
3990
+ for (const sd of completedDurs) {
3991
+ completedTime += (sd as any).duration_s || 0;
3992
+ }
3993
+ const currentStageElapsed = Math.max(
3994
+ 0,
3995
+ (pipeline.elapsed_s || 0) - completedTime,
3996
+ );
3997
+ remainingTime = Math.max(0, remainingTime - currentStageElapsed);
3998
+ }
3999
+
4000
+ const eta_s = Math.max(0, Math.round(remainingTime));
4001
+ const progressPct =
4002
+ currentStageIdx >= 0 ? currentStageIdx / allStages.length : 0;
4003
+
4004
+ // Success probability: starts at historical rate, decreases with iterations
4005
+ const iterRatio =
4006
+ (pipeline.iteration || 0) / (pipeline.maxIterations || 20);
4007
+ const success_probability = Math.max(
4008
+ 0.05,
4009
+ successRate * (1 - iterRatio * 0.5),
4010
+ );
4011
+
4012
+ // Cost estimate: use real historical average cost per pipeline
4013
+ const costInfo = getCostInfo();
4014
+ const totalCompleted =
4015
+ (metricsHistory.total_completed || 0) +
4016
+ (metricsHistory.total_failed || 0);
4017
+ const avgCostPerPipeline =
4018
+ totalCompleted > 0
4019
+ ? (costInfo.total_spent || 0) / totalCompleted
4020
+ : 2.5; // only fall back to 2.5 if no history at all
4021
+ const estimated_cost =
4022
+ avgCostPerPipeline * (1 - progressPct) + (pipeline.cost || 0);
4023
+
4024
+ return new Response(
4025
+ JSON.stringify({
4026
+ eta_s: Math.round(eta_s),
4027
+ success_probability: Math.round(success_probability * 100) / 100,
4028
+ estimated_cost: Math.round(estimated_cost * 100) / 100,
4029
+ }),
4030
+ {
4031
+ headers: {
4032
+ "Content-Type": "application/json",
4033
+ ...CORS_HEADERS,
4034
+ },
4035
+ },
4036
+ );
4037
+ } catch (err) {
4038
+ return new Response(JSON.stringify({}), {
4039
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4040
+ });
4041
+ }
4042
+ }
4043
+
3432
4044
  // REST: Emergency brake — pause all active pipelines + clear queue
3433
4045
  if (pathname === "/api/emergency-brake" && req.method === "POST") {
3434
4046
  try {
@@ -3453,6 +4065,7 @@ const server = Bun.serve({
3453
4065
  queued = ((daemonState.queued as Array<unknown>) || []).length;
3454
4066
  }
3455
4067
 
4068
+ appendAuditLog("emergency-brake", { paused, queued });
3456
4069
  return new Response(JSON.stringify({ paused, queued }), {
3457
4070
  headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3458
4071
  });
@@ -3815,6 +4428,7 @@ const server = Bun.serve({
3815
4428
  timeout: 10000,
3816
4429
  stdio: "pipe",
3817
4430
  });
4431
+ appendAuditLog("daemon-control", { action: "start" });
3818
4432
  return new Response(JSON.stringify({ ok: true, action: "started" }), {
3819
4433
  headers: { "Content-Type": "application/json", ...CORS_HEADERS },
3820
4434
  });
@@ -3904,6 +4518,477 @@ const server = Bun.serve({
3904
4518
  }
3905
4519
  }
3906
4520
 
4521
+ // GET /api/notifications/config — Return notification configuration
4522
+ if (pathname === "/api/notifications/config" && req.method === "GET") {
4523
+ return new Response(JSON.stringify(loadNotificationConfig()), {
4524
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4525
+ });
4526
+ }
4527
+
4528
+ // POST /api/notifications/config — Update notification configuration
4529
+ if (pathname === "/api/notifications/config" && req.method === "POST") {
4530
+ try {
4531
+ const body = (await req.json()) as Partial<NotificationConfig>;
4532
+ const current = loadNotificationConfig();
4533
+ if (body.enabled !== undefined) current.enabled = body.enabled;
4534
+ if (body.webhooks) current.webhooks = body.webhooks;
4535
+ saveNotificationConfig(current);
4536
+ return new Response(JSON.stringify({ ok: true, config: current }), {
4537
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4538
+ });
4539
+ } catch (err) {
4540
+ return new Response(JSON.stringify({ error: String(err) }), {
4541
+ status: 400,
4542
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4543
+ });
4544
+ }
4545
+ }
4546
+
4547
+ // POST /api/notifications/webhook — Add a single webhook
4548
+ if (pathname === "/api/notifications/webhook" && req.method === "POST") {
4549
+ try {
4550
+ const body = (await req.json()) as {
4551
+ url: string;
4552
+ label?: string;
4553
+ events?: string[];
4554
+ };
4555
+ if (!body.url) throw new Error("url is required");
4556
+ const config = loadNotificationConfig();
4557
+ config.webhooks.push({
4558
+ url: body.url,
4559
+ label: body.label || new URL(body.url).hostname,
4560
+ events: body.events || ["all"],
4561
+ created_at: new Date().toISOString(),
4562
+ });
4563
+ config.enabled = true;
4564
+ saveNotificationConfig(config);
4565
+ return new Response(JSON.stringify({ ok: true }), {
4566
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4567
+ });
4568
+ } catch (err) {
4569
+ return new Response(JSON.stringify({ error: String(err) }), {
4570
+ status: 400,
4571
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4572
+ });
4573
+ }
4574
+ }
4575
+
4576
+ // DELETE /api/notifications/webhook — Remove a webhook by URL
4577
+ if (pathname === "/api/notifications/webhook" && req.method === "DELETE") {
4578
+ try {
4579
+ const body = (await req.json()) as { url: string };
4580
+ const config = loadNotificationConfig();
4581
+ config.webhooks = config.webhooks.filter((w) => w.url !== body.url);
4582
+ if (config.webhooks.length === 0) config.enabled = false;
4583
+ saveNotificationConfig(config);
4584
+ return new Response(JSON.stringify({ ok: true }), {
4585
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4586
+ });
4587
+ } catch (err) {
4588
+ return new Response(JSON.stringify({ error: String(err) }), {
4589
+ status: 400,
4590
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4591
+ });
4592
+ }
4593
+ }
4594
+
4595
+ // POST /api/notifications/test — Send a test notification
4596
+ if (pathname === "/api/notifications/test" && req.method === "POST") {
4597
+ await sendNotifications("test", {
4598
+ message: "Test notification from Fleet Command",
4599
+ });
4600
+ return new Response(JSON.stringify({ ok: true }), {
4601
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4602
+ });
4603
+ }
4604
+
4605
+ // ─── RBAC Management ──────────────────────────────────────────
4606
+ // GET /api/rbac — Get RBAC configuration
4607
+ if (pathname === "/api/rbac" && req.method === "GET") {
4608
+ return new Response(JSON.stringify(loadRBACConfig()), {
4609
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4610
+ });
4611
+ }
4612
+
4613
+ // POST /api/rbac — Update RBAC configuration
4614
+ if (pathname === "/api/rbac" && req.method === "POST") {
4615
+ try {
4616
+ const body = (await req.json()) as Partial<RBACConfig>;
4617
+ const current = loadRBACConfig();
4618
+ if (body.default_role) current.default_role = body.default_role;
4619
+ if (body.users) current.users = { ...current.users, ...body.users };
4620
+ saveRBACConfig(current);
4621
+ appendAuditLog("rbac-update", { changes: body });
4622
+ return new Response(JSON.stringify({ ok: true, config: current }), {
4623
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4624
+ });
4625
+ } catch (err) {
4626
+ return new Response(JSON.stringify({ error: String(err) }), {
4627
+ status: 400,
4628
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4629
+ });
4630
+ }
4631
+ }
4632
+
4633
+ // ─── Audit Log ────────────────────────────────────────────────
4634
+ // GET /api/audit-log — Read audit log entries
4635
+ if (pathname === "/api/audit-log" && req.method === "GET") {
4636
+ const entries: Array<Record<string, unknown>> = [];
4637
+ if (existsSync(AUDIT_LOG_FILE)) {
4638
+ try {
4639
+ const content = readFileOr(AUDIT_LOG_FILE, "").trim();
4640
+ if (content) {
4641
+ for (const line of content.split("\n")) {
4642
+ try {
4643
+ entries.push(JSON.parse(line));
4644
+ } catch {}
4645
+ }
4646
+ }
4647
+ } catch {}
4648
+ }
4649
+ // Return newest first, limit to 100
4650
+ entries.reverse();
4651
+ return new Response(JSON.stringify({ entries: entries.slice(0, 100) }), {
4652
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4653
+ });
4654
+ }
4655
+
4656
+ // ─── Approval Gates ──────────────────────────────────────────────
4657
+ // GET /api/approval-gates — Get approval gate configuration
4658
+ if (pathname === "/api/approval-gates" && req.method === "GET") {
4659
+ const configPath = join(HOME, ".shipwright", "approval-gates.json");
4660
+ const config = existsSync(configPath)
4661
+ ? JSON.parse(
4662
+ readFileOr(
4663
+ configPath,
4664
+ '{"enabled":false,"stages":[],"pending":[]}',
4665
+ ),
4666
+ )
4667
+ : { enabled: false, stages: [], pending: [] };
4668
+ return new Response(JSON.stringify(config), {
4669
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4670
+ });
4671
+ }
4672
+
4673
+ // POST /api/approval-gates — Update gate configuration
4674
+ if (pathname === "/api/approval-gates" && req.method === "POST") {
4675
+ try {
4676
+ const body = (await req.json()) as Record<string, unknown>;
4677
+ const configPath = join(HOME, ".shipwright", "approval-gates.json");
4678
+ const current = existsSync(configPath)
4679
+ ? JSON.parse(
4680
+ readFileOr(
4681
+ configPath,
4682
+ '{"enabled":false,"stages":[],"pending":[]}',
4683
+ ),
4684
+ )
4685
+ : { enabled: false, stages: [], pending: [] };
4686
+ if (body.enabled !== undefined) current.enabled = body.enabled;
4687
+ if (body.stages) current.stages = body.stages;
4688
+ const dir = join(HOME, ".shipwright");
4689
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
4690
+ const tmp = configPath + ".tmp";
4691
+ writeFileSync(tmp, JSON.stringify(current, null, 2));
4692
+ renameSync(tmp, configPath);
4693
+ return new Response(JSON.stringify({ ok: true, config: current }), {
4694
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4695
+ });
4696
+ } catch (err) {
4697
+ return new Response(JSON.stringify({ error: String(err) }), {
4698
+ status: 400,
4699
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4700
+ });
4701
+ }
4702
+ }
4703
+
4704
+ // POST /api/approval-gates/:issue/approve — Approve a stage transition
4705
+ if (
4706
+ /^\/api\/approval-gates\/\d+\/approve$/.test(pathname) &&
4707
+ req.method === "POST"
4708
+ ) {
4709
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
4710
+ try {
4711
+ const body = (await req.json()) as {
4712
+ stage?: string;
4713
+ approver?: string;
4714
+ };
4715
+ const configPath = join(HOME, ".shipwright", "approval-gates.json");
4716
+ const current = existsSync(configPath)
4717
+ ? JSON.parse(
4718
+ readFileOr(
4719
+ configPath,
4720
+ '{"enabled":false,"stages":[],"pending":[]}',
4721
+ ),
4722
+ )
4723
+ : { enabled: false, stages: [], pending: [] };
4724
+ // Remove from pending
4725
+ current.pending = (current.pending || []).filter(
4726
+ (p: any) =>
4727
+ !(p.issue === issueNum && (!body.stage || p.stage === body.stage)),
4728
+ );
4729
+ // Write approval signal to worktree
4730
+ const worktreeBase = findWorktreeBase(issueNum);
4731
+ if (worktreeBase) {
4732
+ const artifactsDir = join(
4733
+ worktreeBase,
4734
+ ".claude",
4735
+ "pipeline-artifacts",
4736
+ );
4737
+ if (!existsSync(artifactsDir))
4738
+ mkdirSync(artifactsDir, { recursive: true });
4739
+ writeFileSync(
4740
+ join(artifactsDir, "human-approval.txt"),
4741
+ JSON.stringify({
4742
+ approved: true,
4743
+ stage: body.stage,
4744
+ approver: body.approver || "dashboard-user",
4745
+ timestamp: new Date().toISOString(),
4746
+ }),
4747
+ );
4748
+ }
4749
+ const dir = join(HOME, ".shipwright");
4750
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
4751
+ const tmp = configPath + ".tmp";
4752
+ writeFileSync(tmp, JSON.stringify(current, null, 2));
4753
+ renameSync(tmp, configPath);
4754
+ // Log the approval for audit
4755
+ appendAuditLog("approval", {
4756
+ issue: issueNum,
4757
+ stage: body.stage,
4758
+ approver: body.approver || "dashboard-user",
4759
+ });
4760
+ return new Response(JSON.stringify({ ok: true }), {
4761
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4762
+ });
4763
+ } catch (err) {
4764
+ return new Response(JSON.stringify({ error: String(err) }), {
4765
+ status: 400,
4766
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4767
+ });
4768
+ }
4769
+ }
4770
+
4771
+ // POST /api/approval-gates/:issue/reject — Reject a stage transition
4772
+ if (
4773
+ /^\/api\/approval-gates\/\d+\/reject$/.test(pathname) &&
4774
+ req.method === "POST"
4775
+ ) {
4776
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
4777
+ try {
4778
+ const body = (await req.json()) as {
4779
+ stage?: string;
4780
+ reason?: string;
4781
+ approver?: string;
4782
+ };
4783
+ const configPath = join(HOME, ".shipwright", "approval-gates.json");
4784
+ const current = existsSync(configPath)
4785
+ ? JSON.parse(
4786
+ readFileOr(
4787
+ configPath,
4788
+ '{"enabled":false,"stages":[],"pending":[]}',
4789
+ ),
4790
+ )
4791
+ : { enabled: false, stages: [], pending: [] };
4792
+ current.pending = (current.pending || []).filter(
4793
+ (p: any) =>
4794
+ !(p.issue === issueNum && (!body.stage || p.stage === body.stage)),
4795
+ );
4796
+ // Write rejection signal
4797
+ const worktreeBase = findWorktreeBase(issueNum);
4798
+ if (worktreeBase) {
4799
+ const artifactsDir = join(
4800
+ worktreeBase,
4801
+ ".claude",
4802
+ "pipeline-artifacts",
4803
+ );
4804
+ if (!existsSync(artifactsDir))
4805
+ mkdirSync(artifactsDir, { recursive: true });
4806
+ writeFileSync(
4807
+ join(artifactsDir, "human-approval.txt"),
4808
+ JSON.stringify({
4809
+ approved: false,
4810
+ stage: body.stage,
4811
+ reason: body.reason,
4812
+ approver: body.approver || "dashboard-user",
4813
+ timestamp: new Date().toISOString(),
4814
+ }),
4815
+ );
4816
+ }
4817
+ const dir = join(HOME, ".shipwright");
4818
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
4819
+ const tmp = configPath + ".tmp";
4820
+ writeFileSync(tmp, JSON.stringify(current, null, 2));
4821
+ renameSync(tmp, configPath);
4822
+ appendAuditLog("rejection", {
4823
+ issue: issueNum,
4824
+ stage: body.stage,
4825
+ reason: body.reason,
4826
+ });
4827
+ return new Response(JSON.stringify({ ok: true }), {
4828
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4829
+ });
4830
+ } catch (err) {
4831
+ return new Response(JSON.stringify({ error: String(err) }), {
4832
+ status: 400,
4833
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4834
+ });
4835
+ }
4836
+ }
4837
+
4838
+ // ─── Quality Gates ─────────────────────────────────────────────
4839
+ // GET /api/quality-gates — Get quality gate configuration
4840
+ if (pathname === "/api/quality-gates" && req.method === "GET") {
4841
+ const configPath = join(HOME, ".shipwright", "quality-gates.json");
4842
+ const config = existsSync(configPath)
4843
+ ? JSON.parse(readFileOr(configPath, '{"enabled":false,"rules":[]}'))
4844
+ : {
4845
+ enabled: false,
4846
+ rules: [
4847
+ {
4848
+ name: "test_coverage",
4849
+ operator: ">=",
4850
+ threshold: 80,
4851
+ unit: "%",
4852
+ },
4853
+ {
4854
+ name: "lint_errors",
4855
+ operator: "<=",
4856
+ threshold: 0,
4857
+ unit: "errors",
4858
+ },
4859
+ {
4860
+ name: "type_errors",
4861
+ operator: "<=",
4862
+ threshold: 0,
4863
+ unit: "errors",
4864
+ },
4865
+ ],
4866
+ };
4867
+ return new Response(JSON.stringify(config), {
4868
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4869
+ });
4870
+ }
4871
+
4872
+ // POST /api/quality-gates — Update quality gate configuration
4873
+ if (pathname === "/api/quality-gates" && req.method === "POST") {
4874
+ try {
4875
+ const body = (await req.json()) as Record<string, unknown>;
4876
+ const configPath = join(HOME, ".shipwright", "quality-gates.json");
4877
+ const dir = join(HOME, ".shipwright");
4878
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
4879
+ const tmp = configPath + ".tmp";
4880
+ writeFileSync(tmp, JSON.stringify(body, null, 2));
4881
+ renameSync(tmp, configPath);
4882
+ return new Response(JSON.stringify({ ok: true }), {
4883
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4884
+ });
4885
+ } catch (err) {
4886
+ return new Response(JSON.stringify({ error: String(err) }), {
4887
+ status: 400,
4888
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4889
+ });
4890
+ }
4891
+ }
4892
+
4893
+ // GET /api/pipeline/:issue/quality — Get quality metrics for a pipeline
4894
+ if (
4895
+ /^\/api\/pipeline\/\d+\/quality$/.test(pathname) &&
4896
+ req.method === "GET"
4897
+ ) {
4898
+ const issueNum = parseInt(pathname.split("/")[3] || "0");
4899
+ const worktreeBase = findWorktreeBase(issueNum);
4900
+ const quality: Record<string, unknown> = {
4901
+ test_coverage: null,
4902
+ lint_errors: null,
4903
+ type_errors: null,
4904
+ tests_passing: null,
4905
+ tests_total: null,
4906
+ };
4907
+
4908
+ if (worktreeBase) {
4909
+ // Check for quality metrics in artifacts
4910
+ const artifactsDir = join(
4911
+ worktreeBase,
4912
+ ".claude",
4913
+ "pipeline-artifacts",
4914
+ );
4915
+
4916
+ // Test results
4917
+ const testResultsPath = join(artifactsDir, "test-results.json");
4918
+ if (existsSync(testResultsPath)) {
4919
+ try {
4920
+ const tr = JSON.parse(readFileOr(testResultsPath, "{}"));
4921
+ quality.tests_passing = tr.passing ?? tr.numPassed ?? null;
4922
+ quality.tests_total = tr.total ?? tr.numTests ?? null;
4923
+ quality.test_coverage = tr.coverage ?? null;
4924
+ } catch {}
4925
+ }
4926
+
4927
+ // Coverage file
4928
+ const coveragePath = join(artifactsDir, "coverage.json");
4929
+ if (existsSync(coveragePath) && quality.test_coverage === null) {
4930
+ try {
4931
+ const cov = JSON.parse(readFileOr(coveragePath, "{}"));
4932
+ quality.test_coverage =
4933
+ cov.total?.lines?.pct ?? cov.percentage ?? null;
4934
+ } catch {}
4935
+ }
4936
+
4937
+ // Lint results
4938
+ const lintPath = join(artifactsDir, "lint-results.json");
4939
+ if (existsSync(lintPath)) {
4940
+ try {
4941
+ const lint = JSON.parse(readFileOr(lintPath, "{}"));
4942
+ quality.lint_errors = lint.errorCount ?? lint.errors ?? null;
4943
+ } catch {}
4944
+ }
4945
+
4946
+ // Type check results
4947
+ const typePath = join(artifactsDir, "type-check.json");
4948
+ if (existsSync(typePath)) {
4949
+ try {
4950
+ const types = JSON.parse(readFileOr(typePath, "{}"));
4951
+ quality.type_errors = types.errorCount ?? types.errors ?? null;
4952
+ } catch {}
4953
+ }
4954
+ }
4955
+
4956
+ // Check quality rules
4957
+ const configPath = join(HOME, ".shipwright", "quality-gates.json");
4958
+ const gateConfig = existsSync(configPath)
4959
+ ? JSON.parse(readFileOr(configPath, '{"enabled":false,"rules":[]}'))
4960
+ : { enabled: false, rules: [] };
4961
+
4962
+ const results: Array<Record<string, unknown>> = [];
4963
+ if (gateConfig.enabled && gateConfig.rules) {
4964
+ for (const rule of gateConfig.rules) {
4965
+ const value = quality[rule.name as string];
4966
+ let passed = true;
4967
+ if (value !== null && value !== undefined) {
4968
+ const numVal = Number(value);
4969
+ if (rule.operator === ">=" && numVal < rule.threshold)
4970
+ passed = false;
4971
+ if (rule.operator === "<=" && numVal > rule.threshold)
4972
+ passed = false;
4973
+ if (rule.operator === ">" && numVal <= rule.threshold)
4974
+ passed = false;
4975
+ if (rule.operator === "<" && numVal >= rule.threshold)
4976
+ passed = false;
4977
+ } else {
4978
+ passed = true; // Can't evaluate = pass by default
4979
+ }
4980
+ results.push({ ...rule, value, passed });
4981
+ }
4982
+ }
4983
+
4984
+ return new Response(
4985
+ JSON.stringify({ quality, gates: gateConfig, results }),
4986
+ {
4987
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
4988
+ },
4989
+ );
4990
+ }
4991
+
3907
4992
  // GET /api/daemon/config — Return daemon configuration
3908
4993
  if (pathname === "/api/daemon/config" && req.method === "GET") {
3909
4994
  try {