orbital-command 0.1.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 (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +396 -0
  3. package/bin/orbital.js +362 -0
  4. package/dist/assets/WorkflowVisualizer-BZ21PIIF.js +84 -0
  5. package/dist/assets/WorkflowVisualizer-BZV40eAE.css +1 -0
  6. package/dist/assets/charts-D__PA1zp.js +72 -0
  7. package/dist/assets/index-D1G6i0nS.css +1 -0
  8. package/dist/assets/index-DpItvKpf.js +419 -0
  9. package/dist/assets/ui-BvF022GT.js +53 -0
  10. package/dist/assets/vendor-Dzv9lrRc.js +59 -0
  11. package/dist/index.html +19 -0
  12. package/dist/scanner-sweep.png +0 -0
  13. package/dist/server/server/adapters/index.js +34 -0
  14. package/dist/server/server/adapters/iterm2-adapter.js +29 -0
  15. package/dist/server/server/adapters/subprocess-adapter.js +21 -0
  16. package/dist/server/server/adapters/terminal-adapter.js +1 -0
  17. package/dist/server/server/config.js +156 -0
  18. package/dist/server/server/database.js +90 -0
  19. package/dist/server/server/index.js +372 -0
  20. package/dist/server/server/init.js +811 -0
  21. package/dist/server/server/parsers/event-parser.js +64 -0
  22. package/dist/server/server/parsers/scope-parser.js +188 -0
  23. package/dist/server/server/routes/config-routes.js +163 -0
  24. package/dist/server/server/routes/data-routes.js +461 -0
  25. package/dist/server/server/routes/dispatch-routes.js +215 -0
  26. package/dist/server/server/routes/git-routes.js +92 -0
  27. package/dist/server/server/routes/scope-routes.js +215 -0
  28. package/dist/server/server/routes/sprint-routes.js +116 -0
  29. package/dist/server/server/routes/version-routes.js +130 -0
  30. package/dist/server/server/routes/workflow-routes.js +185 -0
  31. package/dist/server/server/schema.js +90 -0
  32. package/dist/server/server/services/batch-orchestrator.js +253 -0
  33. package/dist/server/server/services/claude-session-service.js +352 -0
  34. package/dist/server/server/services/config-service.js +132 -0
  35. package/dist/server/server/services/deploy-service.js +51 -0
  36. package/dist/server/server/services/event-service.js +63 -0
  37. package/dist/server/server/services/gate-service.js +83 -0
  38. package/dist/server/server/services/git-service.js +309 -0
  39. package/dist/server/server/services/github-service.js +145 -0
  40. package/dist/server/server/services/readiness-service.js +184 -0
  41. package/dist/server/server/services/scope-cache.js +72 -0
  42. package/dist/server/server/services/scope-service.js +424 -0
  43. package/dist/server/server/services/sprint-orchestrator.js +312 -0
  44. package/dist/server/server/services/sprint-service.js +293 -0
  45. package/dist/server/server/services/workflow-service.js +397 -0
  46. package/dist/server/server/utils/cc-hooks-parser.js +49 -0
  47. package/dist/server/server/utils/dispatch-utils.js +305 -0
  48. package/dist/server/server/utils/logger.js +86 -0
  49. package/dist/server/server/utils/terminal-launcher.js +388 -0
  50. package/dist/server/server/utils/worktree-manager.js +98 -0
  51. package/dist/server/server/watchers/event-watcher.js +81 -0
  52. package/dist/server/server/watchers/scope-watcher.js +33 -0
  53. package/dist/server/shared/api-types.js +5 -0
  54. package/dist/server/shared/default-workflow.json +616 -0
  55. package/dist/server/shared/workflow-config.js +44 -0
  56. package/dist/server/shared/workflow-engine.js +353 -0
  57. package/index.html +15 -0
  58. package/package.json +110 -0
  59. package/postcss.config.js +6 -0
  60. package/schemas/orbital.config.schema.json +83 -0
  61. package/scripts/postinstall.js +24 -0
  62. package/scripts/start.sh +20 -0
  63. package/server/adapters/index.ts +41 -0
  64. package/server/adapters/iterm2-adapter.ts +37 -0
  65. package/server/adapters/subprocess-adapter.ts +25 -0
  66. package/server/adapters/terminal-adapter.ts +24 -0
  67. package/server/config.ts +234 -0
  68. package/server/database.ts +107 -0
  69. package/server/index.ts +452 -0
  70. package/server/init.ts +891 -0
  71. package/server/parsers/event-parser.ts +74 -0
  72. package/server/parsers/scope-parser.ts +240 -0
  73. package/server/routes/config-routes.ts +182 -0
  74. package/server/routes/data-routes.ts +548 -0
  75. package/server/routes/dispatch-routes.ts +275 -0
  76. package/server/routes/git-routes.ts +112 -0
  77. package/server/routes/scope-routes.ts +262 -0
  78. package/server/routes/sprint-routes.ts +142 -0
  79. package/server/routes/version-routes.ts +156 -0
  80. package/server/routes/workflow-routes.ts +198 -0
  81. package/server/schema.ts +90 -0
  82. package/server/services/batch-orchestrator.ts +286 -0
  83. package/server/services/claude-session-service.ts +441 -0
  84. package/server/services/config-service.ts +151 -0
  85. package/server/services/deploy-service.ts +98 -0
  86. package/server/services/event-service.ts +98 -0
  87. package/server/services/gate-service.ts +126 -0
  88. package/server/services/git-service.ts +391 -0
  89. package/server/services/github-service.ts +183 -0
  90. package/server/services/readiness-service.ts +250 -0
  91. package/server/services/scope-cache.ts +81 -0
  92. package/server/services/scope-service.ts +476 -0
  93. package/server/services/sprint-orchestrator.ts +361 -0
  94. package/server/services/sprint-service.ts +415 -0
  95. package/server/services/workflow-service.ts +461 -0
  96. package/server/utils/cc-hooks-parser.ts +70 -0
  97. package/server/utils/dispatch-utils.ts +395 -0
  98. package/server/utils/logger.ts +109 -0
  99. package/server/utils/terminal-launcher.ts +462 -0
  100. package/server/utils/worktree-manager.ts +104 -0
  101. package/server/watchers/event-watcher.ts +100 -0
  102. package/server/watchers/scope-watcher.ts +38 -0
  103. package/shared/api-types.ts +20 -0
  104. package/shared/default-workflow.json +616 -0
  105. package/shared/workflow-config.ts +170 -0
  106. package/shared/workflow-engine.ts +427 -0
  107. package/src/App.tsx +33 -0
  108. package/src/components/AgentBadge.tsx +40 -0
  109. package/src/components/BatchPreflightModal.tsx +115 -0
  110. package/src/components/CardDisplayToggle.tsx +74 -0
  111. package/src/components/ColumnHeaderActions.tsx +55 -0
  112. package/src/components/ColumnMenu.tsx +99 -0
  113. package/src/components/DeployHistory.tsx +141 -0
  114. package/src/components/DispatchModal.tsx +164 -0
  115. package/src/components/DispatchPopover.tsx +139 -0
  116. package/src/components/DragOverlay.tsx +25 -0
  117. package/src/components/DriftSidebar.tsx +140 -0
  118. package/src/components/EnvironmentStrip.tsx +88 -0
  119. package/src/components/ErrorBoundary.tsx +62 -0
  120. package/src/components/FilterChip.tsx +105 -0
  121. package/src/components/GateIndicator.tsx +33 -0
  122. package/src/components/IdeaDetailModal.tsx +190 -0
  123. package/src/components/IdeaFormDialog.tsx +113 -0
  124. package/src/components/KanbanColumn.tsx +201 -0
  125. package/src/components/MarkdownRenderer.tsx +114 -0
  126. package/src/components/NeonGrid.tsx +128 -0
  127. package/src/components/PromotionQueue.tsx +89 -0
  128. package/src/components/ScopeCard.tsx +234 -0
  129. package/src/components/ScopeDetailModal.tsx +255 -0
  130. package/src/components/ScopeFilterBar.tsx +152 -0
  131. package/src/components/SearchInput.tsx +102 -0
  132. package/src/components/SessionPanel.tsx +335 -0
  133. package/src/components/SprintContainer.tsx +303 -0
  134. package/src/components/SprintDependencyDialog.tsx +78 -0
  135. package/src/components/SprintPreflightModal.tsx +138 -0
  136. package/src/components/StatusBar.tsx +168 -0
  137. package/src/components/SwimCell.tsx +67 -0
  138. package/src/components/SwimLaneRow.tsx +94 -0
  139. package/src/components/SwimlaneBoardView.tsx +108 -0
  140. package/src/components/VersionBadge.tsx +139 -0
  141. package/src/components/ViewModeSelector.tsx +114 -0
  142. package/src/components/config/AgentChip.tsx +53 -0
  143. package/src/components/config/AgentCreateDialog.tsx +321 -0
  144. package/src/components/config/AgentEditor.tsx +175 -0
  145. package/src/components/config/DirectoryTree.tsx +582 -0
  146. package/src/components/config/FileEditor.tsx +550 -0
  147. package/src/components/config/HookChip.tsx +50 -0
  148. package/src/components/config/StageCard.tsx +198 -0
  149. package/src/components/config/TransitionZone.tsx +173 -0
  150. package/src/components/config/UnifiedWorkflowPipeline.tsx +216 -0
  151. package/src/components/config/WorkflowPipeline.tsx +161 -0
  152. package/src/components/source-control/BranchList.tsx +93 -0
  153. package/src/components/source-control/BranchPanel.tsx +105 -0
  154. package/src/components/source-control/CommitLog.tsx +100 -0
  155. package/src/components/source-control/CommitRow.tsx +47 -0
  156. package/src/components/source-control/GitHubPanel.tsx +110 -0
  157. package/src/components/source-control/GitHubSetupGuide.tsx +52 -0
  158. package/src/components/source-control/GitOverviewBar.tsx +101 -0
  159. package/src/components/source-control/PullRequestList.tsx +69 -0
  160. package/src/components/source-control/WorktreeList.tsx +80 -0
  161. package/src/components/ui/badge.tsx +41 -0
  162. package/src/components/ui/button.tsx +55 -0
  163. package/src/components/ui/card.tsx +78 -0
  164. package/src/components/ui/dialog.tsx +94 -0
  165. package/src/components/ui/popover.tsx +33 -0
  166. package/src/components/ui/scroll-area.tsx +54 -0
  167. package/src/components/ui/separator.tsx +28 -0
  168. package/src/components/ui/tabs.tsx +52 -0
  169. package/src/components/ui/toggle-switch.tsx +35 -0
  170. package/src/components/ui/tooltip.tsx +27 -0
  171. package/src/components/workflow/AddEdgeDialog.tsx +217 -0
  172. package/src/components/workflow/AddListDialog.tsx +201 -0
  173. package/src/components/workflow/ChecklistEditor.tsx +239 -0
  174. package/src/components/workflow/CommandPrefixManager.tsx +118 -0
  175. package/src/components/workflow/ConfigSettingsPanel.tsx +189 -0
  176. package/src/components/workflow/DirectionSelector.tsx +133 -0
  177. package/src/components/workflow/DispatchConfigPanel.tsx +180 -0
  178. package/src/components/workflow/EdgeDetailPanel.tsx +236 -0
  179. package/src/components/workflow/EdgePropertyEditor.tsx +251 -0
  180. package/src/components/workflow/EditToolbar.tsx +138 -0
  181. package/src/components/workflow/HookDetailPanel.tsx +250 -0
  182. package/src/components/workflow/HookExecutionLog.tsx +24 -0
  183. package/src/components/workflow/HookSourceModal.tsx +129 -0
  184. package/src/components/workflow/HooksDashboard.tsx +363 -0
  185. package/src/components/workflow/ListPropertyEditor.tsx +251 -0
  186. package/src/components/workflow/MigrationPreviewDialog.tsx +237 -0
  187. package/src/components/workflow/MovementRulesPanel.tsx +188 -0
  188. package/src/components/workflow/NodeDetailPanel.tsx +245 -0
  189. package/src/components/workflow/PresetSelector.tsx +414 -0
  190. package/src/components/workflow/SkillCommandBuilder.tsx +174 -0
  191. package/src/components/workflow/WorkflowEdgeComponent.tsx +145 -0
  192. package/src/components/workflow/WorkflowNode.tsx +147 -0
  193. package/src/components/workflow/graphLayout.ts +186 -0
  194. package/src/components/workflow/mergeHooks.ts +85 -0
  195. package/src/components/workflow/useEditHistory.ts +88 -0
  196. package/src/components/workflow/useWorkflowEditor.ts +262 -0
  197. package/src/components/workflow/validateConfig.ts +70 -0
  198. package/src/hooks/useActiveDispatches.ts +198 -0
  199. package/src/hooks/useBoardSettings.ts +170 -0
  200. package/src/hooks/useCardDisplay.ts +57 -0
  201. package/src/hooks/useCcHooks.ts +24 -0
  202. package/src/hooks/useConfigTree.ts +51 -0
  203. package/src/hooks/useEnforcementRules.ts +46 -0
  204. package/src/hooks/useEvents.ts +59 -0
  205. package/src/hooks/useFileEditor.ts +165 -0
  206. package/src/hooks/useGates.ts +57 -0
  207. package/src/hooks/useIdeaActions.ts +53 -0
  208. package/src/hooks/useKanbanDnd.ts +410 -0
  209. package/src/hooks/useOrbitalConfig.ts +54 -0
  210. package/src/hooks/usePipeline.ts +47 -0
  211. package/src/hooks/usePipelineData.ts +338 -0
  212. package/src/hooks/useReconnect.ts +25 -0
  213. package/src/hooks/useScopeFilters.ts +125 -0
  214. package/src/hooks/useScopeSessions.ts +44 -0
  215. package/src/hooks/useScopes.ts +67 -0
  216. package/src/hooks/useSearch.ts +67 -0
  217. package/src/hooks/useSettings.tsx +187 -0
  218. package/src/hooks/useSocket.ts +25 -0
  219. package/src/hooks/useSourceControl.ts +105 -0
  220. package/src/hooks/useSprintPreflight.ts +55 -0
  221. package/src/hooks/useSprints.ts +154 -0
  222. package/src/hooks/useStatusBarHighlight.ts +18 -0
  223. package/src/hooks/useSwimlaneBoardSettings.ts +104 -0
  224. package/src/hooks/useTheme.ts +9 -0
  225. package/src/hooks/useTransitionReadiness.ts +53 -0
  226. package/src/hooks/useVersion.ts +155 -0
  227. package/src/hooks/useViolations.ts +65 -0
  228. package/src/hooks/useWorkflow.tsx +125 -0
  229. package/src/hooks/useZoomModifier.ts +19 -0
  230. package/src/index.css +797 -0
  231. package/src/layouts/DashboardLayout.tsx +113 -0
  232. package/src/lib/collisionDetection.ts +20 -0
  233. package/src/lib/scope-fields.ts +61 -0
  234. package/src/lib/swimlane.ts +146 -0
  235. package/src/lib/utils.ts +15 -0
  236. package/src/main.tsx +19 -0
  237. package/src/socket.ts +11 -0
  238. package/src/types/index.ts +497 -0
  239. package/src/views/AgentFeed.tsx +339 -0
  240. package/src/views/DeployPipeline.tsx +59 -0
  241. package/src/views/EnforcementView.tsx +378 -0
  242. package/src/views/PrimitivesConfig.tsx +500 -0
  243. package/src/views/QualityGates.tsx +1012 -0
  244. package/src/views/ScopeBoard.tsx +454 -0
  245. package/src/views/SessionTimeline.tsx +516 -0
  246. package/src/views/Settings.tsx +183 -0
  247. package/src/views/SourceControl.tsx +95 -0
  248. package/src/views/WorkflowVisualizer.tsx +382 -0
  249. package/tailwind.config.js +161 -0
  250. package/templates/agents/AUTO-INVOKE.md +180 -0
  251. package/templates/agents/CONFLICT-RESOLUTION.md +128 -0
  252. package/templates/agents/QUICK-REFERENCE.md +122 -0
  253. package/templates/agents/README.md +188 -0
  254. package/templates/agents/SKILL-TRIGGERS.md +100 -0
  255. package/templates/agents/blue-team/frontend-designer.md +424 -0
  256. package/templates/agents/green-team/architect.md +526 -0
  257. package/templates/agents/green-team/rules-enforcer.md +131 -0
  258. package/templates/agents/red-team/attacker-learned.md +24 -0
  259. package/templates/agents/red-team/attacker.md +486 -0
  260. package/templates/agents/red-team/chaos.md +548 -0
  261. package/templates/agents/reference/component-registry.md +82 -0
  262. package/templates/agents/workflows/full-mode.md +218 -0
  263. package/templates/agents/workflows/quick-mode.md +118 -0
  264. package/templates/agents/workflows/security-mode.md +283 -0
  265. package/templates/anti-patterns/dangerous-shortcuts.md +427 -0
  266. package/templates/config/agent-triggers.json +92 -0
  267. package/templates/hooks/agent-team-gate.sh +31 -0
  268. package/templates/hooks/agent-trigger.sh +97 -0
  269. package/templates/hooks/block-push.sh +66 -0
  270. package/templates/hooks/block-workarounds.sh +61 -0
  271. package/templates/hooks/blocker-check.sh +28 -0
  272. package/templates/hooks/completion-checklist.sh +28 -0
  273. package/templates/hooks/decision-capture.sh +15 -0
  274. package/templates/hooks/dependency-check.sh +27 -0
  275. package/templates/hooks/end-session.sh +31 -0
  276. package/templates/hooks/exploration-logger.sh +37 -0
  277. package/templates/hooks/files-changed-summary.sh +37 -0
  278. package/templates/hooks/get-session-id.sh +49 -0
  279. package/templates/hooks/git-commit-guard.sh +34 -0
  280. package/templates/hooks/init-session.sh +93 -0
  281. package/templates/hooks/orbital-emit.sh +79 -0
  282. package/templates/hooks/orbital-report-deploy.sh +78 -0
  283. package/templates/hooks/orbital-report-gates.sh +40 -0
  284. package/templates/hooks/orbital-report-violation.sh +36 -0
  285. package/templates/hooks/orbital-scope-update.sh +53 -0
  286. package/templates/hooks/phase-verify-reminder.sh +26 -0
  287. package/templates/hooks/review-gate-check.sh +82 -0
  288. package/templates/hooks/scope-commit-logger.sh +37 -0
  289. package/templates/hooks/scope-create-cleanup.sh +36 -0
  290. package/templates/hooks/scope-create-gate.sh +80 -0
  291. package/templates/hooks/scope-create-tracker.sh +17 -0
  292. package/templates/hooks/scope-file-sync.sh +53 -0
  293. package/templates/hooks/scope-gate.sh +35 -0
  294. package/templates/hooks/scope-helpers.sh +188 -0
  295. package/templates/hooks/scope-lifecycle-gate.sh +139 -0
  296. package/templates/hooks/scope-prepare.sh +244 -0
  297. package/templates/hooks/scope-transition.sh +172 -0
  298. package/templates/hooks/session-enforcer.sh +143 -0
  299. package/templates/hooks/time-tracker.sh +33 -0
  300. package/templates/lessons-learned.md +15 -0
  301. package/templates/orbital.config.json +35 -0
  302. package/templates/presets/development.json +42 -0
  303. package/templates/presets/gitflow.json +712 -0
  304. package/templates/presets/minimal.json +23 -0
  305. package/templates/quick/rules.md +218 -0
  306. package/templates/scopes/_template.md +255 -0
  307. package/templates/settings-hooks.json +98 -0
  308. package/templates/skills/git-commit/SKILL.md +85 -0
  309. package/templates/skills/git-dev/SKILL.md +99 -0
  310. package/templates/skills/git-hotfix/SKILL.md +223 -0
  311. package/templates/skills/git-main/SKILL.md +84 -0
  312. package/templates/skills/git-production/SKILL.md +165 -0
  313. package/templates/skills/git-staging/SKILL.md +112 -0
  314. package/templates/skills/scope-create/SKILL.md +81 -0
  315. package/templates/skills/scope-fix-review/SKILL.md +168 -0
  316. package/templates/skills/scope-implement/SKILL.md +110 -0
  317. package/templates/skills/scope-post-review/SKILL.md +144 -0
  318. package/templates/skills/scope-pre-review/SKILL.md +211 -0
  319. package/templates/skills/scope-verify/SKILL.md +201 -0
  320. package/templates/skills/session-init/SKILL.md +62 -0
  321. package/templates/skills/session-resume/SKILL.md +201 -0
  322. package/templates/skills/test-checks/SKILL.md +171 -0
  323. package/templates/skills/test-code-review/SKILL.md +252 -0
  324. package/tsconfig.json +25 -0
  325. package/vite.config.ts +38 -0
@@ -0,0 +1,352 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import readline from 'readline';
4
+ import { getConfig, getClaudeSessionsDir } from '../config.js';
5
+ function getSessionsDir() {
6
+ return getClaudeSessionsDir(getConfig().projectRoot);
7
+ }
8
+ let cache = null;
9
+ const CACHE_TTL_MS = 60_000;
10
+ /**
11
+ * Extract metadata from a JSONL session file by reading the first few
12
+ * lines and the last line (avoids parsing the entire file).
13
+ */
14
+ async function parseSessionFile(filePath) {
15
+ const stat = fs.statSync(filePath);
16
+ const filename = path.basename(filePath, '.jsonl');
17
+ let sessionId = filename;
18
+ let slug = '';
19
+ let branch = '';
20
+ let startedAt = '';
21
+ let summary = null;
22
+ // Read first 20 lines for metadata
23
+ const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
24
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
25
+ let lineNum = 0;
26
+ for await (const line of rl) {
27
+ if (lineNum > 20)
28
+ break;
29
+ lineNum++;
30
+ try {
31
+ const data = JSON.parse(line);
32
+ if (data.sessionId && !sessionId)
33
+ sessionId = data.sessionId;
34
+ if (data.slug && !slug)
35
+ slug = data.slug;
36
+ if (data.gitBranch && !branch)
37
+ branch = data.gitBranch;
38
+ // Capture the earliest timestamp
39
+ if (!startedAt) {
40
+ const ts = data.timestamp ??
41
+ data.snapshot?.timestamp;
42
+ if (ts)
43
+ startedAt = ts;
44
+ }
45
+ // If we have all fields, stop early
46
+ if (sessionId && slug && branch && startedAt)
47
+ break;
48
+ }
49
+ catch {
50
+ // skip unparseable lines
51
+ }
52
+ }
53
+ rl.close();
54
+ stream.destroy();
55
+ if (!sessionId)
56
+ return null;
57
+ // Read file content for summary extraction
58
+ try {
59
+ const fullContent = fs.readFileSync(filePath, 'utf-8');
60
+ const lines = fullContent.trimEnd().split('\n');
61
+ // Prefer explicit summary line from Claude
62
+ const lastLine = JSON.parse(lines[lines.length - 1]);
63
+ if (lastLine.type === 'summary' && lastLine.summary) {
64
+ summary = lastLine.summary;
65
+ }
66
+ // Fall back to first user message
67
+ if (!summary) {
68
+ summary = extractFirstUserMessage(lines, 120);
69
+ }
70
+ }
71
+ catch {
72
+ // ignore
73
+ }
74
+ return {
75
+ id: sessionId,
76
+ slug: slug || filename,
77
+ branch: branch || 'unknown',
78
+ startedAt: startedAt || stat.birthtime.toISOString(),
79
+ lastActiveAt: stat.mtime.toISOString(),
80
+ summary,
81
+ fileSize: stat.size,
82
+ };
83
+ }
84
+ export async function getClaudeSessions(since) {
85
+ if (cache && Date.now() < cache.expiry) {
86
+ return filterSince(cache.sessions, since);
87
+ }
88
+ const sessionsDir = getSessionsDir();
89
+ if (!fs.existsSync(sessionsDir))
90
+ return [];
91
+ const files = fs
92
+ .readdirSync(sessionsDir)
93
+ .filter((f) => f.endsWith('.jsonl'))
94
+ .map((f) => path.join(sessionsDir, f));
95
+ const sessions = [];
96
+ for (const file of files) {
97
+ const session = await parseSessionFile(file);
98
+ if (session)
99
+ sessions.push(session);
100
+ }
101
+ // Sort by most recent first
102
+ sessions.sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime());
103
+ cache = { sessions, expiry: Date.now() + CACHE_TTL_MS };
104
+ return filterSince(sessions, since);
105
+ }
106
+ function filterSince(sessions, since) {
107
+ if (!since)
108
+ return sessions;
109
+ const cutoff = new Date(since).getTime();
110
+ return sessions.filter((s) => new Date(s.lastActiveAt).getTime() >= cutoff);
111
+ }
112
+ /** Truncate text to max length with ellipsis */
113
+ function truncate(text, max) {
114
+ if (!text)
115
+ return '';
116
+ return text.length > max ? text.slice(0, max) + '...' : text;
117
+ }
118
+ /**
119
+ * Extract a meaningful session name from JSONL lines.
120
+ *
121
+ * Priority:
122
+ * 1. First non-meta user message (what the user actually typed)
123
+ * 2. Slash command name from the first command-message (e.g. "/scope review 1")
124
+ * 3. null if nothing useful found
125
+ *
126
+ * Skips isMeta messages (skill prompts injected by the system),
127
+ * tool_result lines, and raw command XML.
128
+ */
129
+ function extractFirstUserMessage(lines, max) {
130
+ let slashCommand = null;
131
+ for (const line of lines) {
132
+ try {
133
+ const data = JSON.parse(line);
134
+ if (data.type !== 'user')
135
+ continue;
136
+ const content = data.message?.content;
137
+ let text = '';
138
+ if (typeof content === 'string') {
139
+ text = content;
140
+ }
141
+ else if (Array.isArray(content)) {
142
+ for (const block of content) {
143
+ if (block?.type === 'text' && typeof block.text === 'string') {
144
+ text = block.text;
145
+ break;
146
+ }
147
+ }
148
+ }
149
+ if (!text)
150
+ continue;
151
+ // Capture slash command as fallback (e.g. "/scope review 1")
152
+ if (!slashCommand && text.includes('<command-name>')) {
153
+ const cmdMatch = text.match(/<command-name>\/?(.+?)<\/command-name>/);
154
+ const argsMatch = text.match(/<command-args>(.+?)<\/command-args>/);
155
+ if (cmdMatch) {
156
+ slashCommand = '/' + cmdMatch[1] + (argsMatch ? ' ' + argsMatch[1] : '');
157
+ }
158
+ }
159
+ // Skip system-injected lines: commands, meta/skill prompts, tool results
160
+ if (text.startsWith('<command') || text.startsWith('<tool_result'))
161
+ continue;
162
+ if (data.isMeta)
163
+ continue;
164
+ return truncate(text.trim(), max);
165
+ }
166
+ catch {
167
+ // skip unparseable lines
168
+ }
169
+ }
170
+ return slashCommand ?? null;
171
+ }
172
+ /**
173
+ * Parse a full JSONL file and return detailed stats grouped by line type.
174
+ * This is heavier than parseSessionFile — only called for the detail view.
175
+ */
176
+ export function getSessionStats(claudeSessionId) {
177
+ const filePath = path.join(getSessionsDir(), `${claudeSessionId}.jsonl`);
178
+ if (!fs.existsSync(filePath))
179
+ return null;
180
+ const stats = {
181
+ typeCounts: {},
182
+ user: { totalMessages: 0, metaMessages: 0, toolResults: 0, commands: [], permissionModes: [], cwd: null, version: null },
183
+ assistant: { totalMessages: 0, models: [], totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheCreationTokens: 0, toolsUsed: {} },
184
+ system: { totalMessages: 0, subtypes: [], stopReasons: [], totalDurationMs: 0, hookCount: 0, hookErrors: 0 },
185
+ progress: { totalLines: 0 },
186
+ timing: { firstTimestamp: null, lastTimestamp: null, durationMs: 0 },
187
+ };
188
+ let content;
189
+ try {
190
+ content = fs.readFileSync(filePath, 'utf-8');
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ const lines = content.trimEnd().split('\n');
196
+ for (const line of lines) {
197
+ let data;
198
+ try {
199
+ data = JSON.parse(line);
200
+ }
201
+ catch {
202
+ continue;
203
+ }
204
+ const type = data.type ?? 'unknown';
205
+ stats.typeCounts[type] = (stats.typeCounts[type] ?? 0) + 1;
206
+ // Track timestamps
207
+ const ts = data.timestamp ?? null;
208
+ if (ts) {
209
+ if (!stats.timing.firstTimestamp)
210
+ stats.timing.firstTimestamp = ts;
211
+ stats.timing.lastTimestamp = ts;
212
+ }
213
+ if (type === 'user') {
214
+ stats.user.totalMessages++;
215
+ if (data.isMeta)
216
+ stats.user.metaMessages++;
217
+ if (data.toolUseResult)
218
+ stats.user.toolResults++;
219
+ if (!stats.user.cwd && data.cwd)
220
+ stats.user.cwd = data.cwd;
221
+ if (!stats.user.version && data.version)
222
+ stats.user.version = data.version;
223
+ const pm = data.permissionMode;
224
+ if (pm && !stats.user.permissionModes.includes(pm))
225
+ stats.user.permissionModes.push(pm);
226
+ // Extract slash commands
227
+ const content = data.message?.content;
228
+ const text = typeof content === 'string' ? content : '';
229
+ const cmdMatch = text.match(/<command-name>\/?(.+?)<\/command-name>/);
230
+ if (cmdMatch) {
231
+ const cmd = '/' + cmdMatch[1];
232
+ if (!stats.user.commands.includes(cmd))
233
+ stats.user.commands.push(cmd);
234
+ }
235
+ }
236
+ if (type === 'assistant') {
237
+ stats.assistant.totalMessages++;
238
+ const msg = data.message;
239
+ if (msg) {
240
+ const model = msg.model;
241
+ if (model && !stats.assistant.models.includes(model))
242
+ stats.assistant.models.push(model);
243
+ const usage = msg.usage;
244
+ if (usage) {
245
+ stats.assistant.totalInputTokens += Number(usage.input_tokens) || 0;
246
+ stats.assistant.totalOutputTokens += Number(usage.output_tokens) || 0;
247
+ stats.assistant.totalCacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
248
+ stats.assistant.totalCacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
249
+ }
250
+ // Track tool usage
251
+ const msgContent = msg.content;
252
+ if (Array.isArray(msgContent)) {
253
+ for (const block of msgContent) {
254
+ if (block?.type === 'tool_use' && block.name) {
255
+ const name = block.name;
256
+ stats.assistant.toolsUsed[name] = (stats.assistant.toolsUsed[name] ?? 0) + 1;
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+ if (type === 'system') {
263
+ stats.system.totalMessages++;
264
+ const subtype = data.subtype;
265
+ if (subtype && !stats.system.subtypes.includes(subtype))
266
+ stats.system.subtypes.push(subtype);
267
+ const stopReason = data.stopReason;
268
+ if (stopReason && !stats.system.stopReasons.includes(stopReason))
269
+ stats.system.stopReasons.push(stopReason);
270
+ stats.system.totalDurationMs += Number(data.durationMs) || 0;
271
+ stats.system.hookCount += Number(data.hookCount) || 0;
272
+ stats.system.hookErrors += Number(data.hookErrors) || 0;
273
+ }
274
+ if (type === 'progress') {
275
+ stats.progress.totalLines++;
276
+ }
277
+ }
278
+ // Compute session duration
279
+ if (stats.timing.firstTimestamp && stats.timing.lastTimestamp) {
280
+ stats.timing.durationMs = new Date(stats.timing.lastTimestamp).getTime() - new Date(stats.timing.firstTimestamp).getTime();
281
+ }
282
+ return stats;
283
+ }
284
+ /**
285
+ * Sync sessions into the DB from scope frontmatter.
286
+ *
287
+ * Algorithm:
288
+ * 1. Read scopes with non-empty sessions JSON from DB
289
+ * 2. For each scope, parse the sessions JSON: Record<phase, uuid[]>
290
+ * 3. For each (phase, uuid), UPSERT into sessions table with JSONL metadata if available
291
+ */
292
+ export async function syncClaudeSessionsToDB(db, scopeService) {
293
+ cache = null; // Force fresh read from filesystem
294
+ const scopeRows = scopeService.getAll()
295
+ .filter(s => Object.keys(s.sessions).length > 0)
296
+ .map(s => ({ id: s.id, sessions: s.sessions }));
297
+ const upsert = db.prepare(`
298
+ INSERT INTO sessions (id, scope_id, claude_session_id, action, started_at, ended_at, summary, handoff_file, discoveries, next_steps)
299
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, '[]', '[]')
300
+ ON CONFLICT(id) DO UPDATE SET
301
+ started_at = excluded.started_at,
302
+ ended_at = excluded.ended_at,
303
+ summary = excluded.summary,
304
+ claude_session_id = excluded.claude_session_id,
305
+ action = excluded.action
306
+ `);
307
+ let count = 0;
308
+ const insertAll = db.transaction(() => {
309
+ for (const row of scopeRows) {
310
+ for (const [phase, uuids] of Object.entries(row.sessions)) {
311
+ if (!Array.isArray(uuids))
312
+ continue;
313
+ for (const uuid of uuids) {
314
+ if (typeof uuid !== 'string' || !uuid)
315
+ continue;
316
+ // Check if JSONL file exists for metadata enrichment
317
+ const jsonlPath = path.join(getSessionsDir(), `${uuid}.jsonl`);
318
+ let startedAt = null;
319
+ let endedAt = null;
320
+ let summary = null;
321
+ if (fs.existsSync(jsonlPath)) {
322
+ try {
323
+ const stat = fs.statSync(jsonlPath);
324
+ startedAt = stat.birthtime.toISOString();
325
+ endedAt = stat.mtime.toISOString();
326
+ const content = fs.readFileSync(jsonlPath, 'utf-8');
327
+ const lines = content.trimEnd().split('\n');
328
+ // Prefer explicit summary line from Claude
329
+ const lastLine = JSON.parse(lines[lines.length - 1]);
330
+ if (lastLine.type === 'summary' && lastLine.summary) {
331
+ summary = truncate(lastLine.summary, 200);
332
+ }
333
+ // Fall back to first user message
334
+ if (!summary) {
335
+ summary = extractFirstUserMessage(lines, 200);
336
+ }
337
+ }
338
+ catch {
339
+ // Metadata unavailable — row still created with nulls
340
+ }
341
+ }
342
+ // Composite key includes phase so same UUID under different phases creates distinct rows
343
+ const compositeId = `${uuid}-scope-${row.id}-${phase}`;
344
+ upsert.run(compositeId, row.id, uuid, phase, startedAt, endedAt, summary);
345
+ count++;
346
+ }
347
+ }
348
+ }
349
+ });
350
+ insertAll();
351
+ return count;
352
+ }
@@ -0,0 +1,132 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import matter from 'gray-matter';
4
+ const VALID_TYPES = new Set(['agents', 'skills', 'hooks']);
5
+ export function isValidPrimitiveType(type) {
6
+ return VALID_TYPES.has(type);
7
+ }
8
+ export class ConfigService {
9
+ projectRoot;
10
+ constructor(projectRoot) {
11
+ this.projectRoot = projectRoot;
12
+ }
13
+ /** Resolve the base directory for a primitive type */
14
+ getBasePath(type) {
15
+ switch (type) {
16
+ case 'agents': return path.join(this.projectRoot, '.claude', 'agents');
17
+ case 'skills': return path.join(this.projectRoot, '.claude', 'skills');
18
+ case 'hooks': return path.join(this.projectRoot, '.claude', 'hooks');
19
+ }
20
+ }
21
+ /** Scan a directory tree and parse SKILL.md / agent frontmatter */
22
+ scanDirectory(basePath, parseFrontmatter = true) {
23
+ if (!fs.existsSync(basePath))
24
+ return [];
25
+ return this.walkDir(basePath, basePath, parseFrontmatter);
26
+ }
27
+ readFile(basePath, relativePath) {
28
+ const resolved = this.validatePath(basePath, relativePath);
29
+ return fs.readFileSync(resolved, 'utf-8');
30
+ }
31
+ writeFile(basePath, relativePath, content) {
32
+ const resolved = this.validatePath(basePath, relativePath);
33
+ if (!fs.existsSync(resolved)) {
34
+ throw new Error('File not found');
35
+ }
36
+ // Atomic write: write to .tmp, then rename
37
+ const tmpPath = resolved + '.tmp';
38
+ fs.writeFileSync(tmpPath, content, 'utf-8');
39
+ fs.renameSync(tmpPath, resolved);
40
+ }
41
+ createFile(basePath, relativePath, content) {
42
+ const resolved = this.validatePath(basePath, relativePath);
43
+ if (fs.existsSync(resolved)) {
44
+ throw new Error('File already exists');
45
+ }
46
+ const dir = path.dirname(resolved);
47
+ if (!fs.existsSync(dir))
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ fs.writeFileSync(resolved, content, 'utf-8');
50
+ }
51
+ deleteFile(basePath, relativePath) {
52
+ const resolved = this.validatePath(basePath, relativePath);
53
+ if (!fs.existsSync(resolved)) {
54
+ throw new Error('File not found');
55
+ }
56
+ const stat = fs.statSync(resolved);
57
+ if (stat.isDirectory()) {
58
+ throw new Error('Cannot delete a directory');
59
+ }
60
+ fs.unlinkSync(resolved);
61
+ }
62
+ renameFile(basePath, oldPath, newPath) {
63
+ const resolvedOld = this.validatePath(basePath, oldPath);
64
+ const resolvedNew = this.validatePath(basePath, newPath);
65
+ if (!fs.existsSync(resolvedOld)) {
66
+ throw new Error('Source file not found');
67
+ }
68
+ if (fs.existsSync(resolvedNew)) {
69
+ throw new Error('Destination already exists');
70
+ }
71
+ const destDir = path.dirname(resolvedNew);
72
+ if (!fs.existsSync(destDir))
73
+ fs.mkdirSync(destDir, { recursive: true });
74
+ fs.renameSync(resolvedOld, resolvedNew);
75
+ }
76
+ createFolder(basePath, relativePath) {
77
+ const resolved = this.validatePath(basePath, relativePath);
78
+ if (fs.existsSync(resolved)) {
79
+ throw new Error('Folder already exists');
80
+ }
81
+ fs.mkdirSync(resolved, { recursive: true });
82
+ }
83
+ // ─── Private Helpers ────────────────────────────────────
84
+ /** Path traversal validation — ensure resolved path is within basePath */
85
+ validatePath(basePath, relativePath) {
86
+ const resolvedBase = path.resolve(basePath);
87
+ const resolved = path.resolve(basePath, relativePath);
88
+ if (resolved !== resolvedBase && !resolved.startsWith(resolvedBase + path.sep)) {
89
+ throw new Error('Path traversal detected');
90
+ }
91
+ return resolved;
92
+ }
93
+ /** Recursively walk a directory and build a file tree */
94
+ walkDir(currentPath, basePath, parseFrontmatter) {
95
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
96
+ const nodes = [];
97
+ for (const entry of entries) {
98
+ // Skip hidden files/dirs (e.g. .DS_Store)
99
+ if (entry.name.startsWith('.'))
100
+ continue;
101
+ const fullPath = path.join(currentPath, entry.name);
102
+ const relPath = path.relative(basePath, fullPath);
103
+ if (entry.isDirectory()) {
104
+ const children = this.walkDir(fullPath, basePath, parseFrontmatter);
105
+ nodes.push({ name: entry.name, path: relPath, type: 'folder', children });
106
+ }
107
+ else {
108
+ const node = { name: entry.name, path: relPath, type: 'file' };
109
+ if (parseFrontmatter && entry.name.endsWith('.md')) {
110
+ try {
111
+ const content = fs.readFileSync(fullPath, 'utf-8');
112
+ const parsed = matter(content);
113
+ if (Object.keys(parsed.data).length > 0) {
114
+ node.frontmatter = parsed.data;
115
+ }
116
+ }
117
+ catch {
118
+ // Skip frontmatter parsing errors
119
+ }
120
+ }
121
+ nodes.push(node);
122
+ }
123
+ }
124
+ // Sort: folders first, then files, alphabetical within each group
125
+ nodes.sort((a, b) => {
126
+ if (a.type !== b.type)
127
+ return a.type === 'folder' ? -1 : 1;
128
+ return a.name.localeCompare(b.name);
129
+ });
130
+ return nodes;
131
+ }
132
+ }
@@ -0,0 +1,51 @@
1
+ import { createLogger } from '../utils/logger.js';
2
+ const log = createLogger('deploy');
3
+ export class DeployService {
4
+ db;
5
+ io;
6
+ constructor(db, io) {
7
+ this.db = db;
8
+ this.io = io;
9
+ }
10
+ /** Record a deployment event */
11
+ record(deploy) {
12
+ const now = new Date().toISOString();
13
+ const result = this.db.prepare(`INSERT INTO deployments (environment, status, commit_sha, branch, pr_number, health_check_url, started_at, details)
14
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(deploy.environment, deploy.status, deploy.commit_sha, deploy.branch, deploy.pr_number, deploy.health_check_url, now, JSON.stringify(deploy.details ?? {}));
15
+ const id = result.lastInsertRowid;
16
+ log.info('Deploy recorded', { id, env: deploy.environment, status: deploy.status, commit_sha: deploy.commit_sha, branch: deploy.branch });
17
+ const inserted = this.db.prepare('SELECT * FROM deployments WHERE id = ?').get(id);
18
+ if (inserted) {
19
+ this.io.emit('deploy:updated', inserted);
20
+ }
21
+ return id;
22
+ }
23
+ /** Update deployment status */
24
+ updateStatus(id, status, details) {
25
+ const completedAt = (status === 'healthy' || status === 'failed' || status === 'rolled-back')
26
+ ? new Date().toISOString()
27
+ : null;
28
+ this.db.prepare(`UPDATE deployments SET status = ?, completed_at = COALESCE(?, completed_at), details = COALESCE(?, details) WHERE id = ?`).run(status, completedAt, details, id);
29
+ log.info('Deploy status updated', { id, status });
30
+ const updated = this.db.prepare('SELECT * FROM deployments WHERE id = ?').get(id);
31
+ if (updated) {
32
+ this.io.emit('deploy:updated', updated);
33
+ }
34
+ }
35
+ /** Get recent deployments */
36
+ getRecent(limit = 20) {
37
+ return this.db
38
+ .prepare('SELECT * FROM deployments ORDER BY started_at DESC LIMIT ?')
39
+ .all(limit);
40
+ }
41
+ /** Get latest deployment per environment */
42
+ getLatestPerEnv() {
43
+ return this.db.prepare(`
44
+ SELECT * FROM deployments
45
+ WHERE id IN (
46
+ SELECT MAX(id) FROM deployments GROUP BY environment
47
+ )
48
+ ORDER BY environment
49
+ `).all();
50
+ }
51
+ }
@@ -0,0 +1,63 @@
1
+ import { createLogger } from '../utils/logger.js';
2
+ const log = createLogger('event');
3
+ export class EventService {
4
+ db;
5
+ io;
6
+ onIngestCallbacks = [];
7
+ constructor(db, io) {
8
+ this.db = db;
9
+ this.io = io;
10
+ }
11
+ /** Register a callback to be called after each successful event ingest */
12
+ onIngest(callback) {
13
+ this.onIngestCallbacks.push(callback);
14
+ }
15
+ /** Ingest a parsed event into the database and broadcast it */
16
+ ingest(event) {
17
+ const result = this.db.prepare(`INSERT OR IGNORE INTO events (id, type, scope_id, session_id, agent, data, timestamp, processed)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1)`).run(event.id, event.type, event.scope_id ?? null, event.session_id ?? null, event.agent ?? null, JSON.stringify(event.data ?? {}), event.timestamp);
19
+ // Only broadcast if this was a new insert (not a duplicate)
20
+ if (result.changes === 0) {
21
+ log.debug('Event duplicate skipped', { id: event.id });
22
+ return;
23
+ }
24
+ log.info('Event ingested', { type: event.type, id: event.id, scope_id: event.scope_id, agent: event.agent });
25
+ const data = event.data ?? {};
26
+ this.io.emit('event:new', {
27
+ id: event.id,
28
+ type: event.type,
29
+ scope_id: event.scope_id ?? null,
30
+ session_id: event.session_id ?? null,
31
+ agent: event.agent ?? null,
32
+ data,
33
+ timestamp: event.timestamp,
34
+ });
35
+ // Trigger event-driven inference
36
+ for (const cb of this.onIngestCallbacks) {
37
+ cb(event.type, event.scope_id ?? data.scope_id, data);
38
+ }
39
+ }
40
+ /** Get recent events, optionally filtered by type */
41
+ getRecent(limit = 50, type) {
42
+ if (type) {
43
+ return this.db
44
+ .prepare('SELECT * FROM events WHERE type = ? ORDER BY timestamp DESC LIMIT ?')
45
+ .all(type, limit);
46
+ }
47
+ return this.db
48
+ .prepare('SELECT * FROM events ORDER BY timestamp DESC LIMIT ?')
49
+ .all(limit);
50
+ }
51
+ /** Get events for a specific agent */
52
+ getByAgent(agent, limit = 50) {
53
+ return this.db
54
+ .prepare('SELECT * FROM events WHERE agent = ? ORDER BY timestamp DESC LIMIT ?')
55
+ .all(agent, limit);
56
+ }
57
+ /** Get events for a specific scope */
58
+ getByScope(scopeId, limit = 50) {
59
+ return this.db
60
+ .prepare('SELECT * FROM events WHERE scope_id = ? ORDER BY timestamp DESC LIMIT ?')
61
+ .all(scopeId, limit);
62
+ }
63
+ }
@@ -0,0 +1,83 @@
1
+ import { createLogger } from '../utils/logger.js';
2
+ const log = createLogger('gate');
3
+ // The 13 quality gates from /test-checks
4
+ export const GATE_NAMES = [
5
+ 'type-check',
6
+ 'lint',
7
+ 'build',
8
+ 'template-validation',
9
+ 'doc-links',
10
+ 'doc-freshness',
11
+ 'rule-enforcement',
12
+ 'no-placeholders',
13
+ 'no-mock-data',
14
+ 'no-shortcuts',
15
+ 'no-default-secrets',
16
+ 'no-stale-scopes',
17
+ 'tests',
18
+ ];
19
+ export class GateService {
20
+ db;
21
+ io;
22
+ constructor(db, io) {
23
+ this.db = db;
24
+ this.io = io;
25
+ }
26
+ /** Record a gate result */
27
+ record(gate) {
28
+ const result = this.db.prepare(`INSERT INTO quality_gates (scope_id, gate_name, status, details, duration_ms, run_at, commit_sha)
29
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(gate.scope_id, gate.gate_name, gate.status, gate.details, gate.duration_ms, new Date().toISOString(), gate.commit_sha);
30
+ log.info('Gate recorded', { scope_id: gate.scope_id, gate: gate.gate_name, status: gate.status, duration_ms: gate.duration_ms });
31
+ const inserted = this.db.prepare('SELECT * FROM quality_gates WHERE id = ?').get(result.lastInsertRowid);
32
+ if (inserted) {
33
+ this.io.emit('gate:updated', inserted);
34
+ }
35
+ }
36
+ /** Get latest gate results for a scope */
37
+ getLatestForScope(scopeId) {
38
+ return this.db.prepare(`
39
+ SELECT * FROM quality_gates
40
+ WHERE scope_id = ? AND id IN (
41
+ SELECT MAX(id) FROM quality_gates
42
+ WHERE scope_id = ?
43
+ GROUP BY gate_name
44
+ )
45
+ ORDER BY gate_name
46
+ `).all(scopeId, scopeId);
47
+ }
48
+ /** Get latest gate run (all gates from most recent execution) */
49
+ getLatestRun() {
50
+ // Get the most recent run_at timestamp
51
+ const latest = this.db.prepare('SELECT run_at FROM quality_gates ORDER BY run_at DESC LIMIT 1').get();
52
+ if (!latest)
53
+ return [];
54
+ // Get all gates from that run (within 60 seconds of each other)
55
+ return this.db.prepare(`
56
+ SELECT * FROM quality_gates
57
+ WHERE run_at >= datetime(?, '-60 seconds')
58
+ ORDER BY gate_name
59
+ `).all(latest.run_at);
60
+ }
61
+ /** Get gate history for trend chart */
62
+ getTrend(limit = 30) {
63
+ return this.db.prepare(`
64
+ SELECT gate_name, status, run_at, duration_ms
65
+ FROM quality_gates
66
+ ORDER BY run_at DESC
67
+ LIMIT ?
68
+ `).all(limit * GATE_NAMES.length); // Get enough to cover N runs
69
+ }
70
+ /** Get aggregate pass/fail stats */
71
+ getStats() {
72
+ return this.db.prepare(`
73
+ SELECT
74
+ gate_name,
75
+ COUNT(*) as total,
76
+ SUM(CASE WHEN status = 'pass' THEN 1 ELSE 0 END) as passed,
77
+ SUM(CASE WHEN status = 'fail' THEN 1 ELSE 0 END) as failed
78
+ FROM quality_gates
79
+ GROUP BY gate_name
80
+ ORDER BY gate_name
81
+ `).all();
82
+ }
83
+ }