shipwright-cli 2.3.1 → 3.0.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 (162) hide show
  1. package/README.md +95 -28
  2. package/completions/_shipwright +1 -1
  3. package/completions/shipwright.bash +3 -8
  4. package/completions/shipwright.fish +1 -1
  5. package/config/defaults.json +111 -0
  6. package/config/event-schema.json +81 -0
  7. package/config/policy.json +155 -2
  8. package/config/policy.schema.json +162 -1
  9. package/dashboard/coverage/coverage-summary.json +14 -0
  10. package/dashboard/public/index.html +1 -1
  11. package/dashboard/server.ts +306 -17
  12. package/dashboard/src/components/charts/bar.test.ts +79 -0
  13. package/dashboard/src/components/charts/donut.test.ts +68 -0
  14. package/dashboard/src/components/charts/pipeline-rail.test.ts +117 -0
  15. package/dashboard/src/components/charts/sparkline.test.ts +125 -0
  16. package/dashboard/src/core/api.test.ts +309 -0
  17. package/dashboard/src/core/helpers.test.ts +301 -0
  18. package/dashboard/src/core/router.test.ts +307 -0
  19. package/dashboard/src/core/router.ts +7 -0
  20. package/dashboard/src/core/sse.test.ts +144 -0
  21. package/dashboard/src/views/metrics.test.ts +186 -0
  22. package/dashboard/src/views/overview.test.ts +173 -0
  23. package/dashboard/src/views/pipelines.test.ts +183 -0
  24. package/dashboard/src/views/team.test.ts +253 -0
  25. package/dashboard/vitest.config.ts +14 -5
  26. package/docs/TIPS.md +1 -1
  27. package/docs/patterns/README.md +1 -1
  28. package/package.json +15 -5
  29. package/scripts/adapters/docker-deploy.sh +1 -1
  30. package/scripts/adapters/tmux-adapter.sh +11 -1
  31. package/scripts/adapters/wezterm-adapter.sh +1 -1
  32. package/scripts/check-version-consistency.sh +1 -1
  33. package/scripts/lib/architecture.sh +126 -0
  34. package/scripts/lib/bootstrap.sh +75 -0
  35. package/scripts/lib/compat.sh +89 -6
  36. package/scripts/lib/config.sh +91 -0
  37. package/scripts/lib/daemon-adaptive.sh +3 -3
  38. package/scripts/lib/daemon-dispatch.sh +39 -16
  39. package/scripts/lib/daemon-health.sh +1 -1
  40. package/scripts/lib/daemon-patrol.sh +24 -12
  41. package/scripts/lib/daemon-poll.sh +37 -25
  42. package/scripts/lib/daemon-state.sh +115 -23
  43. package/scripts/lib/daemon-triage.sh +30 -8
  44. package/scripts/lib/fleet-failover.sh +63 -0
  45. package/scripts/lib/helpers.sh +30 -6
  46. package/scripts/lib/pipeline-detection.sh +2 -2
  47. package/scripts/lib/pipeline-github.sh +9 -9
  48. package/scripts/lib/pipeline-intelligence.sh +85 -35
  49. package/scripts/lib/pipeline-quality-checks.sh +16 -16
  50. package/scripts/lib/pipeline-quality.sh +1 -1
  51. package/scripts/lib/pipeline-stages.sh +242 -28
  52. package/scripts/lib/pipeline-state.sh +40 -4
  53. package/scripts/lib/test-helpers.sh +247 -0
  54. package/scripts/postinstall.mjs +3 -11
  55. package/scripts/sw +10 -4
  56. package/scripts/sw-activity.sh +1 -11
  57. package/scripts/sw-adaptive.sh +109 -85
  58. package/scripts/sw-adversarial.sh +4 -14
  59. package/scripts/sw-architecture-enforcer.sh +1 -11
  60. package/scripts/sw-auth.sh +8 -17
  61. package/scripts/sw-autonomous.sh +111 -49
  62. package/scripts/sw-changelog.sh +1 -11
  63. package/scripts/sw-checkpoint.sh +144 -20
  64. package/scripts/sw-ci.sh +2 -12
  65. package/scripts/sw-cleanup.sh +13 -17
  66. package/scripts/sw-code-review.sh +16 -36
  67. package/scripts/sw-connect.sh +5 -12
  68. package/scripts/sw-context.sh +9 -26
  69. package/scripts/sw-cost.sh +6 -16
  70. package/scripts/sw-daemon.sh +75 -70
  71. package/scripts/sw-dashboard.sh +57 -17
  72. package/scripts/sw-db.sh +506 -15
  73. package/scripts/sw-decompose.sh +1 -11
  74. package/scripts/sw-deps.sh +15 -25
  75. package/scripts/sw-developer-simulation.sh +1 -11
  76. package/scripts/sw-discovery.sh +112 -30
  77. package/scripts/sw-doc-fleet.sh +7 -17
  78. package/scripts/sw-docs-agent.sh +6 -16
  79. package/scripts/sw-docs.sh +4 -12
  80. package/scripts/sw-doctor.sh +134 -43
  81. package/scripts/sw-dora.sh +11 -19
  82. package/scripts/sw-durable.sh +35 -52
  83. package/scripts/sw-e2e-orchestrator.sh +11 -27
  84. package/scripts/sw-eventbus.sh +115 -115
  85. package/scripts/sw-evidence.sh +748 -0
  86. package/scripts/sw-feedback.sh +3 -13
  87. package/scripts/sw-fix.sh +2 -20
  88. package/scripts/sw-fleet-discover.sh +1 -11
  89. package/scripts/sw-fleet-viz.sh +10 -18
  90. package/scripts/sw-fleet.sh +13 -17
  91. package/scripts/sw-github-app.sh +6 -16
  92. package/scripts/sw-github-checks.sh +1 -11
  93. package/scripts/sw-github-deploy.sh +1 -11
  94. package/scripts/sw-github-graphql.sh +2 -12
  95. package/scripts/sw-guild.sh +1 -11
  96. package/scripts/sw-heartbeat.sh +49 -12
  97. package/scripts/sw-hygiene.sh +45 -43
  98. package/scripts/sw-incident.sh +284 -67
  99. package/scripts/sw-init.sh +35 -37
  100. package/scripts/sw-instrument.sh +1 -11
  101. package/scripts/sw-intelligence.sh +362 -51
  102. package/scripts/sw-jira.sh +5 -14
  103. package/scripts/sw-launchd.sh +2 -12
  104. package/scripts/sw-linear.sh +8 -17
  105. package/scripts/sw-logs.sh +4 -12
  106. package/scripts/sw-loop.sh +641 -90
  107. package/scripts/sw-memory.sh +243 -17
  108. package/scripts/sw-mission-control.sh +2 -12
  109. package/scripts/sw-model-router.sh +73 -34
  110. package/scripts/sw-otel.sh +11 -21
  111. package/scripts/sw-oversight.sh +1 -11
  112. package/scripts/sw-patrol-meta.sh +5 -11
  113. package/scripts/sw-pipeline-composer.sh +7 -17
  114. package/scripts/sw-pipeline-vitals.sh +1 -11
  115. package/scripts/sw-pipeline.sh +478 -122
  116. package/scripts/sw-pm.sh +2 -12
  117. package/scripts/sw-pr-lifecycle.sh +203 -29
  118. package/scripts/sw-predictive.sh +16 -22
  119. package/scripts/sw-prep.sh +6 -16
  120. package/scripts/sw-ps.sh +1 -11
  121. package/scripts/sw-public-dashboard.sh +2 -12
  122. package/scripts/sw-quality.sh +77 -10
  123. package/scripts/sw-reaper.sh +1 -11
  124. package/scripts/sw-recruit.sh +15 -25
  125. package/scripts/sw-regression.sh +11 -21
  126. package/scripts/sw-release-manager.sh +19 -28
  127. package/scripts/sw-release.sh +8 -16
  128. package/scripts/sw-remote.sh +1 -11
  129. package/scripts/sw-replay.sh +48 -44
  130. package/scripts/sw-retro.sh +70 -92
  131. package/scripts/sw-review-rerun.sh +220 -0
  132. package/scripts/sw-scale.sh +109 -32
  133. package/scripts/sw-security-audit.sh +12 -22
  134. package/scripts/sw-self-optimize.sh +239 -23
  135. package/scripts/sw-session.sh +3 -13
  136. package/scripts/sw-setup.sh +8 -18
  137. package/scripts/sw-standup.sh +5 -15
  138. package/scripts/sw-status.sh +32 -23
  139. package/scripts/sw-strategic.sh +129 -13
  140. package/scripts/sw-stream.sh +1 -11
  141. package/scripts/sw-swarm.sh +76 -36
  142. package/scripts/sw-team-stages.sh +10 -20
  143. package/scripts/sw-templates.sh +4 -14
  144. package/scripts/sw-testgen.sh +3 -13
  145. package/scripts/sw-tmux-pipeline.sh +1 -19
  146. package/scripts/sw-tmux-role-color.sh +0 -10
  147. package/scripts/sw-tmux-status.sh +3 -11
  148. package/scripts/sw-tmux.sh +2 -20
  149. package/scripts/sw-trace.sh +1 -19
  150. package/scripts/sw-tracker-github.sh +0 -10
  151. package/scripts/sw-tracker-jira.sh +1 -11
  152. package/scripts/sw-tracker-linear.sh +1 -11
  153. package/scripts/sw-tracker.sh +7 -24
  154. package/scripts/sw-triage.sh +24 -34
  155. package/scripts/sw-upgrade.sh +5 -23
  156. package/scripts/sw-ux.sh +1 -19
  157. package/scripts/sw-webhook.sh +18 -32
  158. package/scripts/sw-widgets.sh +3 -21
  159. package/scripts/sw-worktree.sh +11 -27
  160. package/scripts/update-homebrew-sha.sh +67 -0
  161. package/templates/pipelines/tdd.json +72 -0
  162. package/scripts/sw-pipeline.sh.mock +0 -7
@@ -1,13 +1,174 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "title": "Shipwright Policy",
4
- "description": "Central policy for Shipwright — timeouts, limits, thresholds. Validated by CI platform-health workflow.",
4
+ "description": "Central policy for Shipwright — timeouts, limits, thresholds, risk tiers, merge gates. Single source of truth for the Code Factory pattern.",
5
5
  "type": "object",
6
6
  "required": ["version"],
7
7
  "properties": {
8
8
  "$schema": { "type": "string" },
9
9
  "description": { "type": "string" },
10
10
  "version": { "type": "string" },
11
+ "riskTierRules": {
12
+ "type": "object",
13
+ "description": "Path-based risk classification. Tiers: critical, high, medium, low. Glob patterns matched against changed files.",
14
+ "properties": {
15
+ "critical": { "type": "array", "items": { "type": "string" } },
16
+ "high": { "type": "array", "items": { "type": "string" } },
17
+ "medium": { "type": "array", "items": { "type": "string" } },
18
+ "low": { "type": "array", "items": { "type": "string" } }
19
+ },
20
+ "additionalProperties": false
21
+ },
22
+ "mergePolicy": {
23
+ "type": "object",
24
+ "description": "Required checks and evidence by risk tier before merge is allowed.",
25
+ "additionalProperties": {
26
+ "type": "object",
27
+ "properties": {
28
+ "requiredChecks": { "type": "array", "items": { "type": "string" } },
29
+ "requiredReviewers": { "type": "integer", "minimum": 0 },
30
+ "requiredEvidence": {
31
+ "type": "array",
32
+ "items": {
33
+ "type": "string",
34
+ "enum": ["browser", "api", "database", "cli", "webhook", "custom"]
35
+ },
36
+ "description": "Evidence types required before merge for this tier"
37
+ },
38
+ "requireDocsDriftCheck": { "type": "boolean" }
39
+ },
40
+ "additionalProperties": false
41
+ }
42
+ },
43
+ "docsDriftRules": {
44
+ "type": "object",
45
+ "description": "Detect when control-plane files change without corresponding doc updates.",
46
+ "properties": {
47
+ "trackedPairs": {
48
+ "type": "array",
49
+ "items": {
50
+ "type": "object",
51
+ "properties": {
52
+ "source": { "type": "string" },
53
+ "docs": { "type": "array", "items": { "type": "string" } }
54
+ },
55
+ "required": ["source", "docs"]
56
+ }
57
+ },
58
+ "failOnDrift": { "type": "boolean" },
59
+ "warnOnDrift": { "type": "boolean" }
60
+ },
61
+ "additionalProperties": false
62
+ },
63
+ "evidence": {
64
+ "type": "object",
65
+ "description": "Evidence framework — machine-verifiable proof for browser, API, database, CLI, and webhook changes. Each collector defines a type-specific verification strategy.",
66
+ "properties": {
67
+ "artifactMaxAgeMinutes": { "type": "integer", "minimum": 1 },
68
+ "requireFreshArtifacts": { "type": "boolean" },
69
+ "collectors": {
70
+ "type": "array",
71
+ "items": {
72
+ "type": "object",
73
+ "properties": {
74
+ "name": {
75
+ "type": "string",
76
+ "description": "Unique name for this evidence collector"
77
+ },
78
+ "type": {
79
+ "type": "string",
80
+ "enum": [
81
+ "browser",
82
+ "api",
83
+ "database",
84
+ "cli",
85
+ "webhook",
86
+ "custom"
87
+ ],
88
+ "description": "Evidence type: browser (HTTP page load), api (REST/GraphQL endpoint), database (schema/migration check), cli (command execution), webhook (callback verification), custom (user-defined script)"
89
+ },
90
+ "entrypoint": {
91
+ "type": "string",
92
+ "description": "URL path for browser evidence"
93
+ },
94
+ "baseUrl": {
95
+ "type": "string",
96
+ "description": "Base URL for browser/api evidence"
97
+ },
98
+ "url": {
99
+ "type": "string",
100
+ "description": "Full URL for api evidence"
101
+ },
102
+ "method": {
103
+ "type": "string",
104
+ "enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"],
105
+ "description": "HTTP method for api evidence"
106
+ },
107
+ "command": {
108
+ "type": "string",
109
+ "description": "Shell command for cli/database evidence"
110
+ },
111
+ "expectedStatus": {
112
+ "type": "integer",
113
+ "description": "Expected HTTP status code for api evidence"
114
+ },
115
+ "expectedExitCode": {
116
+ "type": "integer",
117
+ "description": "Expected exit code for cli/database evidence"
118
+ },
119
+ "headers": {
120
+ "type": "object",
121
+ "additionalProperties": { "type": "string" },
122
+ "description": "HTTP headers for api evidence"
123
+ },
124
+ "body": {
125
+ "type": "string",
126
+ "description": "Request body for api evidence"
127
+ },
128
+ "assertions": {
129
+ "type": "array",
130
+ "items": { "type": "string" },
131
+ "description": "Named assertions to validate"
132
+ },
133
+ "timeout": {
134
+ "type": "integer",
135
+ "minimum": 1,
136
+ "description": "Timeout in seconds for this collector"
137
+ }
138
+ },
139
+ "required": ["name", "type", "assertions"]
140
+ }
141
+ }
142
+ },
143
+ "additionalProperties": false
144
+ },
145
+ "harnessGapPolicy": {
146
+ "type": "object",
147
+ "description": "Incident-to-harness loop: every regression must produce a test case within SLA.",
148
+ "properties": {
149
+ "enabled": { "type": "boolean" },
150
+ "p0SlaHours": { "type": "integer", "minimum": 1 },
151
+ "p1SlaHours": { "type": "integer", "minimum": 1 },
152
+ "p2SlaHours": { "type": "integer", "minimum": 1 },
153
+ "autoCreateGapIssue": { "type": "boolean" },
154
+ "requireTestCaseBeforeClose": { "type": "boolean" }
155
+ },
156
+ "additionalProperties": false
157
+ },
158
+ "codeReviewAgent": {
159
+ "type": "object",
160
+ "description": "Code review agent configuration — provider-agnostic settings for rerun, resolve, and remediation.",
161
+ "properties": {
162
+ "provider": { "type": "string" },
163
+ "rerunMarker": { "type": "string" },
164
+ "timeoutMinutes": { "type": "integer", "minimum": 1 },
165
+ "treatVulnerabilityLanguageAsActionable": { "type": "boolean" },
166
+ "treatWeakConfidenceAsActionable": { "type": "boolean" },
167
+ "autoResolveBotsOnlyThreads": { "type": "boolean" },
168
+ "neverAutoResolveHumanThreads": { "type": "boolean" }
169
+ },
170
+ "additionalProperties": false
171
+ },
11
172
  "daemon": {
12
173
  "type": "object",
13
174
  "properties": {
@@ -0,0 +1,14 @@
1
+ {"total": {"lines":{"total":602,"covered":593,"skipped":0,"pct":98.5},"statements":{"total":671,"covered":657,"skipped":0,"pct":97.91},"functions":{"total":142,"covered":139,"skipped":0,"pct":97.88},"branches":{"total":311,"covered":285,"skipped":0,"pct":91.63},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}}
2
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/components/charts/bar.ts": {"lines":{"total":30,"covered":30,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":34,"covered":34,"skipped":0,"pct":100},"branches":{"total":28,"covered":27,"skipped":0,"pct":96.42}}
3
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/components/charts/donut.ts": {"lines":{"total":13,"covered":13,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":13,"covered":13,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
4
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/components/charts/pipeline-rail.ts": {"lines":{"total":58,"covered":58,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":62,"covered":62,"skipped":0,"pct":100},"branches":{"total":34,"covered":31,"skipped":0,"pct":91.17}}
5
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/components/charts/sparkline.ts": {"lines":{"total":44,"covered":44,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":52,"covered":52,"skipped":0,"pct":100},"branches":{"total":36,"covered":34,"skipped":0,"pct":94.44}}
6
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/core/api.ts": {"lines":{"total":142,"covered":141,"skipped":0,"pct":99.29},"functions":{"total":74,"covered":73,"skipped":0,"pct":98.64},"statements":{"total":151,"covered":149,"skipped":0,"pct":98.67},"branches":{"total":31,"covered":28,"skipped":0,"pct":90.32}}
7
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/core/helpers.ts": {"lines":{"total":65,"covered":65,"skipped":0,"pct":100},"functions":{"total":14,"covered":14,"skipped":0,"pct":100},"statements":{"total":87,"covered":87,"skipped":0,"pct":100},"branches":{"total":65,"covered":65,"skipped":0,"pct":100}}
8
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/core/router.ts": {"lines":{"total":94,"covered":94,"skipped":0,"pct":100},"functions":{"total":14,"covered":14,"skipped":0,"pct":100},"statements":{"total":100,"covered":99,"skipped":0,"pct":99},"branches":{"total":52,"covered":49,"skipped":0,"pct":94.23}}
9
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/core/sse.ts": {"lines":{"total":14,"covered":14,"skipped":0,"pct":100},"functions":{"total":6,"covered":6,"skipped":0,"pct":100},"statements":{"total":15,"covered":15,"skipped":0,"pct":100},"branches":{"total":4,"covered":3,"skipped":0,"pct":75}}
10
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/core/state.ts": {"lines":{"total":38,"covered":38,"skipped":0,"pct":100},"functions":{"total":13,"covered":13,"skipped":0,"pct":100},"statements":{"total":44,"covered":44,"skipped":0,"pct":100},"branches":{"total":14,"covered":14,"skipped":0,"pct":100}}
11
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/core/ws.ts": {"lines":{"total":85,"covered":77,"skipped":0,"pct":90.58},"functions":{"total":14,"covered":12,"skipped":0,"pct":85.71},"statements":{"total":93,"covered":82,"skipped":0,"pct":88.17},"branches":{"total":42,"covered":29,"skipped":0,"pct":69.04}}
12
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/design/icons.ts": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":5,"covered":5,"skipped":0,"pct":100}}
13
+ ,"/Users/sethford/Documents/shipwright/dashboard/src/design/tokens.ts": {"lines":{"total":13,"covered":13,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":13,"covered":13,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
14
+ }
@@ -733,7 +733,7 @@
733
733
 
734
734
  <!-- Footer -->
735
735
  <footer class="footer">
736
- <span>Shipwright Fleet Command v2.3.1</span>
736
+ <span>Shipwright Fleet Command v2.4.0</span>
737
737
  <span>Dashboard refreshes via WebSocket</span>
738
738
  </footer>
739
739
 
@@ -57,6 +57,21 @@ function getDb(): Database | null {
57
57
  }
58
58
  }
59
59
 
60
+ function dbQueryEventsByIdGreaterThan(
61
+ afterId: number,
62
+ limit = 100,
63
+ ): Array<Record<string, unknown>> {
64
+ const conn = getDb();
65
+ if (!conn) return [];
66
+ try {
67
+ return conn
68
+ .query(`SELECT * FROM events WHERE id > ? ORDER BY id ASC LIMIT ?`)
69
+ .all(afterId, limit) as Array<Record<string, unknown>>;
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
60
75
  function dbQueryEvents(since?: number, limit = 200): DaemonEvent[] {
61
76
  const conn = getDb();
62
77
  if (!conn) return [];
@@ -475,6 +490,7 @@ function isPublicRoute(pathname: string): boolean {
475
490
  pathname === "/login" ||
476
491
  pathname.startsWith("/auth/") ||
477
492
  pathname === "/api/health" ||
493
+ pathname === "/api/ws-status" ||
478
494
  pathname.startsWith("/api/join/") ||
479
495
  pathname.startsWith("/api/connect/") ||
480
496
  pathname === "/api/team" ||
@@ -876,6 +892,8 @@ function appendAuditLog(
876
892
 
877
893
  // ─── WebSocket client tracking ───────────────────────────────────────
878
894
  const wsClients = new Set<import("bun").ServerWebSocket<unknown>>();
895
+ const eventClients = new Set<import("bun").ServerWebSocket<unknown>>();
896
+ let lastBroadcastEventId = 0;
879
897
  const startTime = Date.now();
880
898
 
881
899
  function broadcastToClients(data: FleetState): void {
@@ -889,6 +907,30 @@ function broadcastToClients(data: FleetState): void {
889
907
  }
890
908
  }
891
909
 
910
+ function broadcastNewEvents(): void {
911
+ if (eventClients.size === 0) return;
912
+ const db = getDb();
913
+ if (!db) return;
914
+ try {
915
+ const newEvents = dbQueryEventsByIdGreaterThan(lastBroadcastEventId, 100);
916
+ if (newEvents.length > 0) {
917
+ const eventMsg = JSON.stringify({ type: "events", data: newEvents });
918
+ for (const ws of eventClients) {
919
+ try {
920
+ ws.send(eventMsg);
921
+ } catch {
922
+ eventClients.delete(ws);
923
+ }
924
+ }
925
+ const lastRow = newEvents[newEvents.length - 1];
926
+ const lastId = (lastRow?.id as number) | 0;
927
+ if (lastId > 0) lastBroadcastEventId = lastId;
928
+ }
929
+ } catch {
930
+ /* non-fatal */
931
+ }
932
+ }
933
+
892
934
  // ─── Data Collection ─────────────────────────────────────────────────
893
935
  function readEvents(): DaemonEvent[] {
894
936
  // Try SQLite first (faster for large event logs)
@@ -917,6 +959,47 @@ function readEvents(): DaemonEvent[] {
917
959
  }
918
960
 
919
961
  function readDaemonState(): Record<string, unknown> | null {
962
+ // Try DB first
963
+ try {
964
+ const conn = getDb();
965
+ if (conn) {
966
+ const active = dbQueryJobs("active") as Array<Record<string, unknown>>;
967
+ const completedRows = conn
968
+ .query(
969
+ "SELECT * FROM daemon_state WHERE status IN ('completed', 'failed') ORDER BY completed_at DESC LIMIT 20",
970
+ )
971
+ .all() as Array<Record<string, unknown>>;
972
+ if (active.length > 0 || completedRows.length > 0) {
973
+ const activeJobs = active.map((j) => ({
974
+ job_id: j.job_id,
975
+ issue: j.issue_number,
976
+ title: j.title || "",
977
+ stage: j.stage_name || "",
978
+ started_at: j.started_at,
979
+ started_epoch: j.started_at
980
+ ? Math.floor(new Date(j.started_at as string).getTime() / 1000)
981
+ : 0,
982
+ worktree: j.worktree || "",
983
+ branch: j.branch || "",
984
+ pid: j.pid || 0,
985
+ }));
986
+ const completed = completedRows.map((j) => ({
987
+ issue: j.issue_number,
988
+ result: j.result || "",
989
+ duration: j.duration || "",
990
+ completed_at: j.completed_at || "",
991
+ }));
992
+ return {
993
+ active_jobs: activeJobs,
994
+ completed,
995
+ queued: [] as number[],
996
+ };
997
+ }
998
+ }
999
+ } catch {
1000
+ /* fall through to file */
1001
+ }
1002
+ // Fallback to file
920
1003
  if (!existsSync(DAEMON_STATE)) return null;
921
1004
  try {
922
1005
  return JSON.parse(readFileSync(DAEMON_STATE, "utf-8"));
@@ -1546,7 +1629,63 @@ function getAgents(): AgentInfo[] {
1546
1629
  }
1547
1630
  }
1548
1631
 
1549
- // Read heartbeat files
1632
+ // Try DB heartbeats first
1633
+ try {
1634
+ const conn = getDb();
1635
+ if (conn) {
1636
+ const rows = dbQueryHeartbeats();
1637
+ if (rows.length > 0) {
1638
+ for (const hb of rows) {
1639
+ const jobId = String(hb.job_id || "");
1640
+ const issue = (hb.issue as number) || 0;
1641
+ const updatedAt = (hb.updated_at as string) || "";
1642
+ let hbEpoch = 0;
1643
+ try {
1644
+ hbEpoch = Math.floor(new Date(updatedAt).getTime() / 1000);
1645
+ } catch {
1646
+ /* ignore */
1647
+ }
1648
+ const age = hbEpoch > 0 ? now - hbEpoch : 9999;
1649
+
1650
+ let status: AgentInfo["status"] = "active";
1651
+ if (age > 120) status = "stale";
1652
+ else if (age > 30) status = "idle";
1653
+
1654
+ const job = issue ? jobMap[issue] : undefined;
1655
+ const startedAt = job ? (job.started_at as string) || "" : updatedAt;
1656
+ let elapsed = 0;
1657
+ if (startedAt) {
1658
+ try {
1659
+ elapsed = now - Math.floor(new Date(startedAt).getTime() / 1000);
1660
+ } catch {
1661
+ /* ignore */
1662
+ }
1663
+ }
1664
+
1665
+ agents.push({
1666
+ id: jobId,
1667
+ issue,
1668
+ title: job ? (job.title as string) || "" : "",
1669
+ machine: (hb.machine as string) || "localhost",
1670
+ stage: (hb.stage as string) || "",
1671
+ iteration: (hb.iteration as number) || 0,
1672
+ activity: (hb.last_activity as string) || "",
1673
+ memory_mb: (hb.memory_mb as number) || 0,
1674
+ cpu_pct: (hb.cpu_pct as number) || 0,
1675
+ status,
1676
+ heartbeat_age_s: age,
1677
+ started_at: startedAt,
1678
+ elapsed_s: elapsed,
1679
+ });
1680
+ }
1681
+ return agents;
1682
+ }
1683
+ }
1684
+ } catch {
1685
+ /* fall through to file */
1686
+ }
1687
+
1688
+ // Fallback: Read heartbeat files
1550
1689
  if (existsSync(HEARTBEAT_DIR)) {
1551
1690
  try {
1552
1691
  const files = readdirSync(HEARTBEAT_DIR).filter((f) =>
@@ -1869,10 +2008,48 @@ interface CostInfo {
1869
2008
  pct_used: number;
1870
2009
  }
1871
2010
 
2011
+ function dbQueryBudget(): { dailyBudget: number } {
2012
+ const conn = getDb();
2013
+ if (!conn) return { dailyBudget: 0 };
2014
+ try {
2015
+ const row = conn
2016
+ .query("SELECT daily_budget_usd, enabled FROM budgets WHERE id = 1")
2017
+ .get() as { daily_budget_usd: number; enabled: number } | null;
2018
+ if (!row || row.enabled !== 1) return { dailyBudget: 0 };
2019
+ return { dailyBudget: row.daily_budget_usd || 0 };
2020
+ } catch {
2021
+ return { dailyBudget: 0 };
2022
+ }
2023
+ }
2024
+
1872
2025
  function getCostInfo(): CostInfo {
1873
2026
  let todaySpent = 0;
1874
2027
  let dailyBudget = 0;
1875
2028
 
2029
+ // Try DB first
2030
+ try {
2031
+ const conn = getDb();
2032
+ if (conn) {
2033
+ const costs = dbQueryCostsToday();
2034
+ if (costs.count > 0 || costs.total > 0) {
2035
+ todaySpent = Math.round(costs.total * 100) / 100;
2036
+ const budget = dbQueryBudget();
2037
+ dailyBudget = budget.dailyBudget;
2038
+ const pctUsed =
2039
+ dailyBudget > 0
2040
+ ? Math.round((todaySpent / dailyBudget) * 10000) / 100
2041
+ : 0;
2042
+ return {
2043
+ today_spent: todaySpent,
2044
+ daily_budget: dailyBudget,
2045
+ pct_used: pctUsed,
2046
+ };
2047
+ }
2048
+ }
2049
+ } catch {
2050
+ /* fall through to file */
2051
+ }
2052
+
1876
2053
  if (existsSync(COSTS_FILE)) {
1877
2054
  try {
1878
2055
  const data = JSON.parse(readFileSync(COSTS_FILE, "utf-8"));
@@ -2126,6 +2303,7 @@ function startEventsWatcher(): void {
2126
2303
  // Check for new events and send notifications
2127
2304
  if (filename === "events.jsonl") {
2128
2305
  checkAndNotifyNewEvents();
2306
+ broadcastNewEvents();
2129
2307
  }
2130
2308
  }
2131
2309
  });
@@ -2138,15 +2316,22 @@ function startEventsWatcher(): void {
2138
2316
  let lastPushedJson = "";
2139
2317
 
2140
2318
  function periodicPush(): void {
2141
- if (wsClients.size === 0) return;
2319
+ const hasStateClients = wsClients.size > 0;
2320
+ const hasEventClients = eventClients.size > 0;
2142
2321
 
2143
- const state = getFleetState();
2144
- const json = JSON.stringify(state);
2145
- // Skip push if nothing changed (file watcher already pushed)
2146
- if (json === lastPushedJson) return;
2147
- lastPushedJson = json;
2322
+ if (hasStateClients) {
2323
+ const state = getFleetState();
2324
+ const json = JSON.stringify(state);
2325
+ // Skip push if nothing changed (file watcher already pushed)
2326
+ if (json !== lastPushedJson) {
2327
+ lastPushedJson = json;
2328
+ broadcastToClients(state);
2329
+ }
2330
+ }
2148
2331
 
2149
- broadcastToClients(state);
2332
+ if (hasEventClients) {
2333
+ broadcastNewEvents();
2334
+ }
2150
2335
  }
2151
2336
 
2152
2337
  // ─── GitHub OAuth helpers ───────────────────────────────────────────
@@ -2397,6 +2582,24 @@ const server = Bun.serve({
2397
2582
  });
2398
2583
  }
2399
2584
 
2585
+ // GET /api/ws-status — WebSocket connection status (for evidence collection)
2586
+ if (pathname === "/api/ws-status" && req.method === "GET") {
2587
+ const clients = wsClients?.size ?? 0;
2588
+ return new Response(
2589
+ JSON.stringify({
2590
+ status: "ok",
2591
+ websocket: {
2592
+ active_connections: clients,
2593
+ server_running: true,
2594
+ },
2595
+ timestamp: new Date().toISOString(),
2596
+ }),
2597
+ {
2598
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
2599
+ },
2600
+ );
2601
+ }
2602
+
2400
2603
  // GET /api/join/{token} — Serve join script (public, no auth required)
2401
2604
  if (pathname.startsWith("/api/join/") && req.method === "GET") {
2402
2605
  const token = pathname.split("/")[3] || "";
@@ -2492,7 +2695,7 @@ const server = Bun.serve({
2492
2695
  const session = getSession(req);
2493
2696
  if (!session) {
2494
2697
  // WebSocket upgrade attempt without auth
2495
- if (pathname === "/ws") {
2698
+ if (pathname === "/ws" || pathname === "/ws/events") {
2496
2699
  return new Response("Unauthorized", { status: 401 });
2497
2700
  }
2498
2701
  return new Response(null, {
@@ -2504,7 +2707,14 @@ const server = Bun.serve({
2504
2707
 
2505
2708
  // ── Protected routes ──────────────────────────────────────────
2506
2709
 
2507
- // WebSocket upgrade
2710
+ // WebSocket upgrade — /ws/events streams raw events, /ws streams aggregated state
2711
+ if (pathname === "/ws/events") {
2712
+ const upgraded = server.upgrade(req, {
2713
+ data: { type: "events", lastEventId: 0 },
2714
+ });
2715
+ if (upgraded) return undefined as unknown as Response;
2716
+ return new Response("WebSocket upgrade failed", { status: 400 });
2717
+ }
2508
2718
  if (pathname === "/ws") {
2509
2719
  const upgraded = server.upgrade(req);
2510
2720
  if (upgraded) return undefined as unknown as Response;
@@ -5289,6 +5499,68 @@ const server = Bun.serve({
5289
5499
  );
5290
5500
  }
5291
5501
 
5502
+ // VERIFY: re-read labels — if competing claimed:* labels exist, we lost the race
5503
+ try {
5504
+ const verifyLabels = execSync(
5505
+ `gh issue view ${issue}${repoFlag} --json labels -q '.labels[].name'`,
5506
+ {
5507
+ encoding: "utf-8",
5508
+ timeout: 10000,
5509
+ stdio: ["pipe", "pipe", "pipe"],
5510
+ },
5511
+ )
5512
+ .trim()
5513
+ .split("\n")
5514
+ .filter((l: string) => l.startsWith("claimed:"));
5515
+ if (
5516
+ verifyLabels.length !== 1 ||
5517
+ verifyLabels[0] !== `claimed:${machine}`
5518
+ ) {
5519
+ // Competing claim — remove ours and reject
5520
+ execSync(
5521
+ `gh issue edit ${issue}${repoFlag} --remove-label "claimed:${machine}"`,
5522
+ { timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
5523
+ );
5524
+ return new Response(
5525
+ JSON.stringify({
5526
+ approved: false,
5527
+ claimed_by:
5528
+ verifyLabels[0]?.replace("claimed:", "") || "another machine",
5529
+ error: "Claim race lost",
5530
+ }),
5531
+ {
5532
+ headers: {
5533
+ "Content-Type": "application/json",
5534
+ ...CORS_HEADERS,
5535
+ },
5536
+ },
5537
+ );
5538
+ }
5539
+ } catch {
5540
+ // Verification failed — conservative: remove our label and reject
5541
+ try {
5542
+ execSync(
5543
+ `gh issue edit ${issue}${repoFlag} --remove-label "claimed:${machine}"`,
5544
+ { timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
5545
+ );
5546
+ } catch {
5547
+ /* best-effort cleanup */
5548
+ }
5549
+ return new Response(
5550
+ JSON.stringify({
5551
+ approved: false,
5552
+ error: "Claim verification failed",
5553
+ }),
5554
+ {
5555
+ status: 500,
5556
+ headers: {
5557
+ "Content-Type": "application/json",
5558
+ ...CORS_HEADERS,
5559
+ },
5560
+ },
5561
+ );
5562
+ }
5563
+
5292
5564
  return new Response(
5293
5565
  JSON.stringify({ approved: true, claimed_by: machine }),
5294
5566
  {
@@ -5473,12 +5745,20 @@ const server = Bun.serve({
5473
5745
 
5474
5746
  websocket: {
5475
5747
  open(ws) {
5476
- wsClients.add(ws);
5477
- // Send initial state immediately on connect
5478
- try {
5479
- ws.send(JSON.stringify(getFleetState()));
5480
- } catch {
5481
- wsClients.delete(ws);
5748
+ const data = ws.data as
5749
+ | { type?: string; lastEventId?: number }
5750
+ | undefined;
5751
+ if (data?.type === "events") {
5752
+ eventClients.add(ws);
5753
+ // Event clients get events via broadcastNewEvents; no initial state
5754
+ } else {
5755
+ wsClients.add(ws);
5756
+ // Send initial state immediately on connect
5757
+ try {
5758
+ ws.send(JSON.stringify(getFleetState()));
5759
+ } catch {
5760
+ wsClients.delete(ws);
5761
+ }
5482
5762
  }
5483
5763
  },
5484
5764
  message(_ws, _message) {
@@ -5486,6 +5766,7 @@ const server = Bun.serve({
5486
5766
  },
5487
5767
  close(ws) {
5488
5768
  wsClients.delete(ws);
5769
+ eventClients.delete(ws);
5489
5770
  },
5490
5771
  },
5491
5772
  });
@@ -5572,7 +5853,15 @@ process.on("SIGINT", () => {
5572
5853
  // ignore
5573
5854
  }
5574
5855
  }
5856
+ for (const ws of eventClients) {
5857
+ try {
5858
+ ws.close(1001, "Server shutting down");
5859
+ } catch {
5860
+ // ignore
5861
+ }
5862
+ }
5575
5863
  wsClients.clear();
5864
+ eventClients.clear();
5576
5865
  server.stop();
5577
5866
  process.exit(0);
5578
5867
  });
@@ -5597,7 +5886,7 @@ console.log(
5597
5886
  ` ${GREEN}\u25CF${RESET} API: ${ULINE}http://localhost:${server.port}/api/status${RESET}`,
5598
5887
  );
5599
5888
  console.log(
5600
- ` ${GREEN}\u25CF${RESET} WebSocket: ${ULINE}ws://localhost:${server.port}/ws${RESET}`,
5889
+ ` ${GREEN}\u25CF${RESET} WebSocket: ${ULINE}ws://localhost:${server.port}/ws${RESET} | ${ULINE}/ws/events${RESET}`,
5601
5890
  );
5602
5891
  console.log(
5603
5892
  ` ${GREEN}\u25CF${RESET} Health: ${ULINE}http://localhost:${server.port}/api/health${RESET}`,