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,312 @@
1
+ import { launchInCategorizedTerminal, escapeForAnsiC, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
2
+ import { resolveDispatchEvent, linkPidToDispatch } from '../utils/dispatch-utils.js';
3
+ import { getConfig } from '../config.js';
4
+ import { createLogger } from '../utils/logger.js';
5
+ const log = createLogger('sprint');
6
+ const LAUNCH_STAGGER_MS = 2000;
7
+ function sleep(ms) {
8
+ return new Promise((resolve) => setTimeout(resolve, ms));
9
+ }
10
+ // ─── Orchestrator ───────────────────────────────────────────
11
+ export class SprintOrchestrator {
12
+ db;
13
+ io;
14
+ sprintService;
15
+ scopeService;
16
+ engine;
17
+ constructor(db, io, sprintService, scopeService, engine) {
18
+ this.db = db;
19
+ this.io = io;
20
+ this.sprintService = sprintService;
21
+ this.scopeService = scopeService;
22
+ this.engine = engine;
23
+ }
24
+ /** Build execution layers using Kahn's topological sort */
25
+ buildExecutionLayers(sprintScopeIds) {
26
+ const sprintSet = new Set(sprintScopeIds);
27
+ // Load dependency info for each scope in the sprint
28
+ const scopeDeps = new Map();
29
+ for (const id of sprintScopeIds) {
30
+ const scope = this.scopeService.getById(id);
31
+ if (!scope)
32
+ continue;
33
+ // Only keep deps that are WITHIN the sprint
34
+ scopeDeps.set(id, scope.blocked_by.filter((d) => sprintSet.has(d)));
35
+ }
36
+ // Build in-degree map — in-degree = count of internal deps for each scope
37
+ const inDegree = new Map();
38
+ for (const [id, deps] of scopeDeps) {
39
+ inDegree.set(id, deps.length);
40
+ }
41
+ const layers = [];
42
+ const remaining = new Set(sprintScopeIds);
43
+ while (remaining.size > 0) {
44
+ // Find all nodes with in-degree 0
45
+ const layer = [];
46
+ for (const id of remaining) {
47
+ if ((inDegree.get(id) ?? 0) === 0) {
48
+ layer.push(id);
49
+ }
50
+ }
51
+ if (layer.length === 0) {
52
+ // Cycle detected — return remaining as cycle
53
+ return { layers, cycle: [...remaining] };
54
+ }
55
+ // Remove this layer and decrement dependents
56
+ for (const id of layer) {
57
+ remaining.delete(id);
58
+ }
59
+ // Decrement in-degree for scopes that depended on this layer's scopes
60
+ for (const id of remaining) {
61
+ const deps = scopeDeps.get(id) ?? [];
62
+ let newDeg = 0;
63
+ for (const dep of deps) {
64
+ if (remaining.has(dep))
65
+ newDeg++;
66
+ }
67
+ inDegree.set(id, newDeg);
68
+ }
69
+ layers.push(layer.sort((a, b) => a - b));
70
+ }
71
+ return { layers, cycle: [] };
72
+ }
73
+ /** Start sprint dispatch: build layers, persist, launch Layer 0 */
74
+ async startSprint(sprintId) {
75
+ const sprint = this.sprintService.getById(sprintId);
76
+ if (!sprint)
77
+ return { ok: false, error: 'Sprint not found' };
78
+ if (sprint.status !== 'assembling')
79
+ return { ok: false, error: `Sprint status is '${sprint.status}', expected 'assembling'` };
80
+ if (sprint.scope_ids.length === 0)
81
+ return { ok: false, error: 'Sprint has no scopes' };
82
+ // Build dependency graph
83
+ const { layers, cycle } = this.buildExecutionLayers(sprint.scope_ids);
84
+ if (cycle.length > 0) {
85
+ return { ok: false, error: `Dependency cycle detected among scopes: ${cycle.join(', ')}` };
86
+ }
87
+ // Persist layer assignments
88
+ this.sprintService.setLayers(sprintId, layers);
89
+ this.sprintService.updateStatus(sprintId, 'dispatched');
90
+ log.info('Sprint started', { id: sprintId, layers: layers.length, scopes: sprint.scope_ids.length });
91
+ // Dispatch Layer 0
92
+ await this.dispatchLayer(sprintId, layers[0], sprint.concurrency_cap);
93
+ return { ok: true, layers };
94
+ }
95
+ /** Called when a scope reaches 'dev' status — advance the sprint */
96
+ async onScopeReachedDev(scopeId) {
97
+ const match = this.sprintService.findActiveSprintForScope(scopeId);
98
+ if (!match)
99
+ return;
100
+ log.debug('Scope reached dev', { scopeId, sprintId: match.sprint_id });
101
+ const sprintId = match.sprint_id;
102
+ this.sprintService.updateScopeStatus(sprintId, scopeId, 'completed');
103
+ // Ensure sprint is in 'in_progress' state
104
+ const sprint = this.sprintService.getById(sprintId);
105
+ if (!sprint)
106
+ return;
107
+ if (sprint.status === 'dispatched') {
108
+ this.sprintService.updateStatus(sprintId, 'in_progress');
109
+ }
110
+ // Check for newly unblocked scopes and dispatch them
111
+ await this.dispatchUnblockedScopes(sprintId);
112
+ this.checkSprintCompletion(sprintId);
113
+ }
114
+ /** Called when a scope fails during sprint execution */
115
+ async onScopeFailed(scopeId, error) {
116
+ const match = this.sprintService.findActiveSprintForScope(scopeId);
117
+ if (!match)
118
+ return;
119
+ const sprintId = match.sprint_id;
120
+ this.sprintService.updateScopeStatus(sprintId, scopeId, 'failed', error);
121
+ // Skip downstream dependents transitively
122
+ this.skipDownstream(sprintId, scopeId);
123
+ // Try dispatching other unblocked parallel paths
124
+ await this.dispatchUnblockedScopes(sprintId);
125
+ this.checkSprintCompletion(sprintId);
126
+ }
127
+ /** Cancel an active sprint */
128
+ cancelSprint(sprintId) {
129
+ const sprint = this.sprintService.getById(sprintId);
130
+ if (!sprint)
131
+ return false;
132
+ if (!['assembling', 'dispatched', 'in_progress'].includes(sprint.status))
133
+ return false;
134
+ // Mark pending/queued scopes as skipped
135
+ const scopes = this.sprintService.getSprintScopes(sprintId);
136
+ for (const ss of scopes) {
137
+ if (ss.dispatch_status === 'pending' || ss.dispatch_status === 'queued') {
138
+ this.sprintService.updateScopeStatus(sprintId, ss.scope_id, 'skipped');
139
+ }
140
+ }
141
+ this.sprintService.updateStatus(sprintId, 'cancelled');
142
+ return true;
143
+ }
144
+ /** Recover active sprints after server restart */
145
+ async recoverActiveSprints() {
146
+ const active = this.db.prepare(`SELECT id FROM sprints WHERE group_type = 'sprint' AND status IN ('dispatched', 'in_progress')`).all();
147
+ if (active.length > 0) {
148
+ log.info('Recovering active sprints', { count: active.length });
149
+ }
150
+ for (const { id } of active) {
151
+ // Check if any scopes completed while server was down
152
+ const scopes = this.sprintService.getSprintScopes(id);
153
+ for (const ss of scopes) {
154
+ if (ss.dispatch_status === 'dispatched' || ss.dispatch_status === 'in_progress') {
155
+ // Check actual scope status
156
+ const scope = this.scopeService.getById(ss.scope_id);
157
+ if (scope && this.engine.getStatusOrder(scope.status) >= this.engine.getStatusOrder('dev')) {
158
+ this.sprintService.updateScopeStatus(id, ss.scope_id, 'completed');
159
+ }
160
+ }
161
+ }
162
+ await this.dispatchUnblockedScopes(id);
163
+ this.checkSprintCompletion(id);
164
+ }
165
+ }
166
+ /** Get execution graph data for visualization */
167
+ getExecutionGraph(sprintId) {
168
+ const sprint = this.sprintService.getById(sprintId);
169
+ if (!sprint)
170
+ return null;
171
+ const layers = sprint.layers ?? [];
172
+ const sprintSet = new Set(sprint.scope_ids);
173
+ const edges = [];
174
+ for (const scopeId of sprint.scope_ids) {
175
+ const scope = this.scopeService.getById(scopeId);
176
+ if (!scope)
177
+ continue;
178
+ for (const dep of scope.blocked_by) {
179
+ if (sprintSet.has(dep)) {
180
+ edges.push({ from: dep, to: scopeId });
181
+ }
182
+ }
183
+ }
184
+ return { layers, edges };
185
+ }
186
+ // ─── Private Helpers ────────────────────────────────────────
187
+ async dispatchLayer(sprintId, scopeIds, concurrencyCap) {
188
+ const toDispatch = scopeIds.slice(0, concurrencyCap);
189
+ for (let i = 0; i < toDispatch.length; i++) {
190
+ const scopeId = toDispatch[i];
191
+ // Capture current status before optimistic update (for rollback)
192
+ const currentScope = this.scopeService.getById(scopeId);
193
+ const previousStatus = currentScope?.status ?? 'implementing';
194
+ // Record DISPATCH event
195
+ const eventId = crypto.randomUUID();
196
+ const command = `/scope implement ${scopeId}`;
197
+ this.db.prepare(`INSERT INTO events (id, type, scope_id, session_id, agent, data, timestamp)
198
+ VALUES (?, 'DISPATCH', ?, NULL, 'sprint-orchestrator', ?, ?)`).run(eventId, scopeId, JSON.stringify({ command, sprint_id: sprintId, resolved: null }), new Date().toISOString());
199
+ this.io.emit('event:new', {
200
+ id: eventId, type: 'DISPATCH', scope_id: scopeId,
201
+ session_id: null, agent: 'sprint-orchestrator',
202
+ data: { command, sprint_id: sprintId, resolved: null },
203
+ timestamp: new Date().toISOString(),
204
+ });
205
+ // Update scope + sprint_scope status
206
+ this.scopeService.updateStatus(scopeId, 'implementing', 'dispatch');
207
+ this.sprintService.updateScopeStatus(sprintId, scopeId, 'dispatched');
208
+ // Build scope-aware session name and snapshot PIDs
209
+ const scopeRow = this.scopeService.getById(scopeId);
210
+ const sessionName = buildSessionName({ scopeId, title: scopeRow?.title, command });
211
+ const beforePids = snapshotSessionPids(getConfig().projectRoot);
212
+ // Launch in iTerm — interactive TUI mode (no -p) for full visibility
213
+ const escaped = escapeForAnsiC(command);
214
+ const fullCmd = `cd '${getConfig().projectRoot}' && claude --dangerously-skip-permissions $'${escaped}'`;
215
+ try {
216
+ await launchInCategorizedTerminal(command, fullCmd, sessionName);
217
+ // Fire-and-forget: discover session PID, link to dispatch, and rename
218
+ discoverNewSession(getConfig().projectRoot, beforePids)
219
+ .then((session) => {
220
+ if (!session)
221
+ return;
222
+ linkPidToDispatch(this.db, eventId, session.pid);
223
+ if (sessionName)
224
+ renameSession(getConfig().projectRoot, session.sessionId, sessionName);
225
+ })
226
+ .catch(err => log.error('PID discovery failed', { error: err.message }));
227
+ }
228
+ catch (err) {
229
+ // Rollback scope status to previous value
230
+ this.scopeService.updateStatus(scopeId, previousStatus, 'rollback');
231
+ this.sprintService.updateScopeStatus(sprintId, scopeId, 'failed', `Launch failed: ${err}`);
232
+ resolveDispatchEvent(this.db, this.io, eventId, 'failed', `Launch failed: ${err}`);
233
+ }
234
+ // Stagger launches to prevent AppleScript race conditions
235
+ if (i < toDispatch.length - 1) {
236
+ await sleep(LAUNCH_STAGGER_MS);
237
+ }
238
+ }
239
+ }
240
+ async dispatchUnblockedScopes(sprintId) {
241
+ const sprint = this.sprintService.getById(sprintId);
242
+ if (!sprint)
243
+ return;
244
+ const scopes = this.sprintService.getSprintScopes(sprintId);
245
+ const completedSet = new Set(scopes.filter((ss) => ss.dispatch_status === 'completed').map((ss) => ss.scope_id));
246
+ const activeCount = scopes.filter((ss) => ss.dispatch_status === 'dispatched' || ss.dispatch_status === 'in_progress').length;
247
+ const available = sprint.concurrency_cap - activeCount;
248
+ if (available <= 0)
249
+ return;
250
+ // Find pending scopes whose internal deps are all completed
251
+ const ready = [];
252
+ for (const ss of scopes) {
253
+ if (ss.dispatch_status !== 'pending')
254
+ continue;
255
+ const scope = this.scopeService.getById(ss.scope_id);
256
+ if (!scope)
257
+ continue;
258
+ const internalDeps = scope.blocked_by.filter((d) => sprint.scope_ids.includes(d));
259
+ const allMet = internalDeps.every((d) => completedSet.has(d));
260
+ if (allMet)
261
+ ready.push(ss.scope_id);
262
+ if (ready.length >= available)
263
+ break;
264
+ }
265
+ if (ready.length > 0) {
266
+ await this.dispatchLayer(sprintId, ready, available);
267
+ }
268
+ }
269
+ skipDownstream(sprintId, failedScopeId) {
270
+ const scopes = this.sprintService.getSprintScopes(sprintId);
271
+ const sprintScopeIds = scopes.map((ss) => ss.scope_id);
272
+ // Build reverse dependency map: scope → scopes that depend on it
273
+ const dependents = new Map();
274
+ for (const scopeId of sprintScopeIds) {
275
+ const scope = this.scopeService.getById(scopeId);
276
+ if (!scope)
277
+ continue;
278
+ for (const dep of scope.blocked_by) {
279
+ if (!dependents.has(dep))
280
+ dependents.set(dep, []);
281
+ dependents.get(dep).push(scopeId);
282
+ }
283
+ }
284
+ // BFS to find all transitive dependents
285
+ const toSkip = new Set();
286
+ const queue = [failedScopeId];
287
+ while (queue.length > 0) {
288
+ const current = queue.shift();
289
+ const downstream = dependents.get(current) ?? [];
290
+ for (const id of downstream) {
291
+ if (!toSkip.has(id)) {
292
+ toSkip.add(id);
293
+ queue.push(id);
294
+ }
295
+ }
296
+ }
297
+ for (const scopeId of toSkip) {
298
+ const ss = scopes.find((s) => s.scope_id === scopeId);
299
+ if (ss && ss.dispatch_status === 'pending') {
300
+ this.sprintService.updateScopeStatus(sprintId, scopeId, 'skipped', `Skipped: dependency ${failedScopeId} failed`);
301
+ }
302
+ }
303
+ }
304
+ checkSprintCompletion(sprintId) {
305
+ const scopes = this.sprintService.getSprintScopes(sprintId);
306
+ const allDone = scopes.every((ss) => ss.dispatch_status === 'completed' || ss.dispatch_status === 'failed' || ss.dispatch_status === 'skipped');
307
+ if (!allDone)
308
+ return;
309
+ const anyFailed = scopes.some((ss) => ss.dispatch_status === 'failed');
310
+ this.sprintService.updateStatus(sprintId, anyFailed ? 'failed' : 'completed');
311
+ }
312
+ }
@@ -0,0 +1,293 @@
1
+ import { createLogger } from '../utils/logger.js';
2
+ const log = createLogger('sprint');
3
+ // ─── Service ────────────────────────────────────────────────
4
+ export class SprintService {
5
+ db;
6
+ io;
7
+ scopeService;
8
+ constructor(db, io, scopeService) {
9
+ this.db = db;
10
+ this.io = io;
11
+ this.scopeService = scopeService;
12
+ }
13
+ /** Create a new sprint or batch in assembling state */
14
+ create(name, options) {
15
+ const now = new Date().toISOString();
16
+ const targetColumn = options?.target_column ?? 'backlog';
17
+ const groupType = options?.group_type ?? 'sprint';
18
+ const result = this.db.prepare(`INSERT INTO sprints (name, status, concurrency_cap, created_at, updated_at, target_column, group_type)
19
+ VALUES (?, 'assembling', 5, ?, ?, ?, ?)`).run(name, now, now, targetColumn, groupType);
20
+ const sprint = this.getById(Number(result.lastInsertRowid));
21
+ log.info('Sprint created', { id: sprint.id, name, group_type: groupType, target_column: targetColumn });
22
+ this.io.emit('sprint:created', sprint);
23
+ return sprint;
24
+ }
25
+ /** Rename a sprint/batch (only while assembling) */
26
+ rename(id, name) {
27
+ const result = this.db.prepare(`UPDATE sprints SET name = ?, updated_at = ? WHERE id = ? AND status = 'assembling'`).run(name, new Date().toISOString(), id);
28
+ if (result.changes > 0) {
29
+ this.emitUpdate(id);
30
+ return true;
31
+ }
32
+ return false;
33
+ }
34
+ /** List sprints, optionally filtered by status and/or target column */
35
+ getAll(status, targetColumn) {
36
+ let rows;
37
+ if (status && targetColumn) {
38
+ rows = this.db.prepare('SELECT * FROM sprints WHERE status = ? AND target_column = ? ORDER BY created_at DESC')
39
+ .all(status, targetColumn);
40
+ }
41
+ else if (status) {
42
+ rows = this.db.prepare('SELECT * FROM sprints WHERE status = ? ORDER BY created_at DESC').all(status);
43
+ }
44
+ else if (targetColumn) {
45
+ rows = this.db.prepare('SELECT * FROM sprints WHERE target_column = ? ORDER BY created_at DESC').all(targetColumn);
46
+ }
47
+ else {
48
+ rows = this.db.prepare('SELECT * FROM sprints ORDER BY created_at DESC').all();
49
+ }
50
+ return rows.map((row) => this.buildDetail(row));
51
+ }
52
+ /** Get full sprint detail by ID */
53
+ getById(id) {
54
+ const row = this.db.prepare('SELECT * FROM sprints WHERE id = ?').get(id);
55
+ if (!row)
56
+ return null;
57
+ return this.buildDetail(row);
58
+ }
59
+ /** Delete a sprint (only if assembling) */
60
+ delete(id) {
61
+ const row = this.db.prepare('SELECT status FROM sprints WHERE id = ?').get(id);
62
+ if (!row || row.status !== 'assembling')
63
+ return false;
64
+ this.db.prepare('DELETE FROM sprint_scopes WHERE sprint_id = ?').run(id);
65
+ this.db.prepare('DELETE FROM sprints WHERE id = ?').run(id);
66
+ this.io.emit('sprint:deleted', { id });
67
+ return true;
68
+ }
69
+ /** Add scopes to a sprint; returns which were added and any unmet dependencies */
70
+ addScopes(sprintId, scopeIds) {
71
+ const sprint = this.db.prepare('SELECT * FROM sprints WHERE id = ?').get(sprintId);
72
+ if (!sprint || sprint.status !== 'assembling')
73
+ return null;
74
+ // Existing scope IDs already in this sprint
75
+ const existingIds = new Set(this.db.prepare('SELECT scope_id FROM sprint_scopes WHERE sprint_id = ?').all(sprintId)
76
+ .map((r) => r.scope_id));
77
+ const added = [];
78
+ const unmet = [];
79
+ const insert = this.db.prepare(`INSERT OR IGNORE INTO sprint_scopes (sprint_id, scope_id, dispatch_status)
80
+ VALUES (?, ?, 'pending')`);
81
+ for (const scopeId of scopeIds) {
82
+ if (existingIds.has(scopeId))
83
+ continue;
84
+ // Check dependencies via cache
85
+ const scope = this.scopeService.getById(scopeId);
86
+ if (!scope)
87
+ continue;
88
+ // W-8: For batch groups, validate scope status matches target column
89
+ if (sprint.group_type === 'batch' && scope.status !== sprint.target_column) {
90
+ continue; // silently skip — frontend shows toast for rejected drops
91
+ }
92
+ const missing = [];
93
+ for (const depId of scope.blocked_by) {
94
+ if (existingIds.has(depId) || scopeIds.includes(depId))
95
+ continue;
96
+ // Check if dependency is already complete (dev or beyond)
97
+ const dep = this.scopeService.getById(depId);
98
+ if (!dep)
99
+ continue;
100
+ const completedStatuses = ['dev', 'staging', 'production'];
101
+ if (!completedStatuses.includes(dep.status)) {
102
+ missing.push({ scope_id: dep.id, title: dep.title, status: dep.status });
103
+ }
104
+ }
105
+ if (missing.length > 0) {
106
+ unmet.push({ scope_id: scopeId, missing });
107
+ }
108
+ insert.run(sprintId, scopeId);
109
+ existingIds.add(scopeId);
110
+ added.push(scopeId);
111
+ }
112
+ this.touchUpdatedAt(sprintId);
113
+ this.emitUpdate(sprintId);
114
+ return { added, unmet_dependencies: unmet };
115
+ }
116
+ /** Remove scopes from a sprint (assembling only) */
117
+ removeScopes(sprintId, scopeIds) {
118
+ const sprint = this.db.prepare('SELECT status FROM sprints WHERE id = ?').get(sprintId);
119
+ if (!sprint || sprint.status !== 'assembling')
120
+ return false;
121
+ const remove = this.db.prepare('DELETE FROM sprint_scopes WHERE sprint_id = ? AND scope_id = ?');
122
+ for (const scopeId of scopeIds) {
123
+ remove.run(sprintId, scopeId);
124
+ }
125
+ this.touchUpdatedAt(sprintId);
126
+ this.emitUpdate(sprintId);
127
+ return true;
128
+ }
129
+ /** Update sprint status */
130
+ updateStatus(id, status) {
131
+ const now = new Date().toISOString();
132
+ const extras = {};
133
+ if (status === 'dispatched')
134
+ extras.dispatched_at = now;
135
+ if (status === 'completed' || status === 'failed' || status === 'cancelled')
136
+ extras.completed_at = now;
137
+ const setClauses = ['status = ?', 'updated_at = ?'];
138
+ const params = [status, now];
139
+ for (const [col, val] of Object.entries(extras)) {
140
+ setClauses.push(`${col} = ?`);
141
+ params.push(val);
142
+ }
143
+ params.push(id);
144
+ const result = this.db.prepare(`UPDATE sprints SET ${setClauses.join(', ')} WHERE id = ?`).run(...params);
145
+ if (result.changes > 0) {
146
+ log.info('Sprint status updated', { id, status });
147
+ this.emitUpdate(id);
148
+ if (status === 'completed') {
149
+ const detail = this.getById(id);
150
+ if (detail)
151
+ this.io.emit('sprint:completed', detail);
152
+ }
153
+ }
154
+ return result.changes > 0;
155
+ }
156
+ /** Update a sprint scope's dispatch status */
157
+ updateScopeStatus(sprintId, scopeId, status, error) {
158
+ const now = new Date().toISOString();
159
+ const extras = [];
160
+ const params = [status];
161
+ if (status === 'dispatched') {
162
+ extras.push('dispatched_at = ?');
163
+ params.push(now);
164
+ }
165
+ if (status === 'completed' || status === 'failed' || status === 'skipped') {
166
+ extras.push('completed_at = ?');
167
+ params.push(now);
168
+ }
169
+ if (error != null) {
170
+ extras.push('error = ?');
171
+ params.push(error);
172
+ }
173
+ const setClauses = ['dispatch_status = ?', ...extras];
174
+ params.push(sprintId, scopeId);
175
+ this.db.prepare(`UPDATE sprint_scopes SET ${setClauses.join(', ')} WHERE sprint_id = ? AND scope_id = ?`).run(...params);
176
+ this.emitUpdate(sprintId);
177
+ }
178
+ /** Persist layer assignments for all scopes in a sprint */
179
+ setLayers(sprintId, layers) {
180
+ const update = this.db.prepare('UPDATE sprint_scopes SET layer = ? WHERE sprint_id = ? AND scope_id = ?');
181
+ for (let i = 0; i < layers.length; i++) {
182
+ for (const scopeId of layers[i]) {
183
+ update.run(i, sprintId, scopeId);
184
+ }
185
+ }
186
+ this.db.prepare('UPDATE sprints SET dispatch_meta = ?, updated_at = ? WHERE id = ?')
187
+ .run(JSON.stringify({ layers }), new Date().toISOString(), sprintId);
188
+ }
189
+ /** Find the active sprint containing a given scope (for orchestrator callbacks) */
190
+ findActiveSprintForScope(scopeId) {
191
+ return this.db.prepare(`SELECT ss.sprint_id FROM sprint_scopes ss
192
+ JOIN sprints s ON s.id = ss.sprint_id
193
+ WHERE ss.scope_id = ? AND s.status IN ('dispatched', 'in_progress')
194
+ LIMIT 1`).get(scopeId);
195
+ }
196
+ /** Find any active group (assembling/dispatched/in_progress) containing a scope.
197
+ * Used to guard against moving scopes that are part of an active batch/sprint. */
198
+ getActiveGroupForScope(scopeId) {
199
+ return this.db.prepare(`SELECT ss.sprint_id, s.group_type FROM sprint_scopes ss
200
+ JOIN sprints s ON s.id = ss.sprint_id
201
+ WHERE ss.scope_id = ? AND s.status IN ('assembling', 'dispatched', 'in_progress')
202
+ LIMIT 1`).get(scopeId);
203
+ }
204
+ /** Force-remove a scope from a sprint regardless of sprint status.
205
+ * Used for cleanup when a scope's status diverges from the batch target. */
206
+ forceRemoveScope(sprintId, scopeId) {
207
+ this.db.prepare('DELETE FROM sprint_scopes WHERE sprint_id = ? AND scope_id = ?')
208
+ .run(sprintId, scopeId);
209
+ this.touchUpdatedAt(sprintId);
210
+ this.emitUpdate(sprintId);
211
+ }
212
+ /** Get all sprint scopes for a sprint */
213
+ getSprintScopes(sprintId) {
214
+ return this.db.prepare('SELECT * FROM sprint_scopes WHERE sprint_id = ?').all(sprintId);
215
+ }
216
+ /** Store typed dispatch result (commit SHA, PR URL, etc.) for a batch */
217
+ updateDispatchResult(id, result) {
218
+ this.db.prepare('UPDATE sprints SET dispatch_result = ?, updated_at = ? WHERE id = ?')
219
+ .run(JSON.stringify(result), new Date().toISOString(), id);
220
+ this.emitUpdate(id);
221
+ }
222
+ /** Check if there's an active (assembling/dispatched/in_progress) batch in the given column */
223
+ findActiveBatchForColumn(targetColumn) {
224
+ const row = this.db.prepare(`SELECT * FROM sprints WHERE group_type = 'batch' AND target_column = ? AND status IN ('assembling', 'dispatched', 'in_progress')
225
+ ORDER BY created_at DESC LIMIT 1`).get(targetColumn);
226
+ if (!row)
227
+ return null;
228
+ return this.buildDetail(row);
229
+ }
230
+ // ─── Private Helpers ────────────────────────────────────────
231
+ buildDetail(row) {
232
+ const ssRows = this.db.prepare(`SELECT scope_id, layer, dispatch_status FROM sprint_scopes
233
+ WHERE sprint_id = ? ORDER BY layer ASC, scope_id ASC`).all(row.id);
234
+ const progress = { pending: 0, in_progress: 0, completed: 0, failed: 0, skipped: 0 };
235
+ const scopes = [];
236
+ for (const ss of ssRows) {
237
+ const scope = this.scopeService.getById(ss.scope_id);
238
+ scopes.push({
239
+ scope_id: ss.scope_id,
240
+ title: scope?.title ?? `Scope ${ss.scope_id}`,
241
+ scope_status: scope?.status ?? 'unknown',
242
+ effort_estimate: scope?.effort_estimate ?? null,
243
+ layer: ss.layer,
244
+ dispatch_status: ss.dispatch_status,
245
+ });
246
+ const key = ss.dispatch_status === 'dispatched' || ss.dispatch_status === 'queued'
247
+ ? 'in_progress' : ss.dispatch_status;
248
+ if (key in progress)
249
+ progress[key]++;
250
+ else
251
+ progress.pending++;
252
+ }
253
+ let layers = null;
254
+ try {
255
+ const meta = JSON.parse(row.dispatch_meta || '{}');
256
+ if (meta.layers)
257
+ layers = meta.layers;
258
+ }
259
+ catch { /* ignore */ }
260
+ let dispatchResult = null;
261
+ try {
262
+ const parsed = JSON.parse(row.dispatch_result || '{}');
263
+ if (Object.keys(parsed).length > 0)
264
+ dispatchResult = parsed;
265
+ }
266
+ catch { /* ignore */ }
267
+ return {
268
+ id: row.id,
269
+ name: row.name,
270
+ status: row.status,
271
+ concurrency_cap: row.concurrency_cap,
272
+ group_type: row.group_type ?? 'sprint',
273
+ target_column: row.target_column ?? 'backlog',
274
+ dispatch_result: dispatchResult,
275
+ scope_ids: ssRows.map((ss) => ss.scope_id),
276
+ scopes,
277
+ layers,
278
+ progress,
279
+ created_at: row.created_at,
280
+ updated_at: row.updated_at,
281
+ dispatched_at: row.dispatched_at,
282
+ completed_at: row.completed_at,
283
+ };
284
+ }
285
+ touchUpdatedAt(id) {
286
+ this.db.prepare('UPDATE sprints SET updated_at = ? WHERE id = ?').run(new Date().toISOString(), id);
287
+ }
288
+ emitUpdate(id) {
289
+ const detail = this.getById(id);
290
+ if (detail)
291
+ this.io.emit('sprint:updated', detail);
292
+ }
293
+ }