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,72 @@
1
+ /**
2
+ * In-memory cache for parsed scopes.
3
+ * Dual-indexed: ID lookups (API, sprint orchestrator) and file-path reverse index (watcher deletions).
4
+ * Replaces the SQLite `scopes` table — filesystem frontmatter is the single source of truth.
5
+ */
6
+ export class ScopeCache {
7
+ byId = new Map();
8
+ filePathToId = new Map();
9
+ /** Bulk-load all scopes (called at startup from parseAllScopes result) */
10
+ loadAll(scopes) {
11
+ this.byId.clear();
12
+ this.filePathToId.clear();
13
+ for (const scope of scopes) {
14
+ this.byId.set(scope.id, scope);
15
+ this.filePathToId.set(scope.file_path, scope.id);
16
+ }
17
+ }
18
+ /** Insert or update a single scope */
19
+ set(scope) {
20
+ // Clean up old file_path mapping if the scope moved directories
21
+ const existing = this.byId.get(scope.id);
22
+ if (existing && existing.file_path !== scope.file_path) {
23
+ this.filePathToId.delete(existing.file_path);
24
+ }
25
+ this.byId.set(scope.id, scope);
26
+ this.filePathToId.set(scope.file_path, scope.id);
27
+ }
28
+ /** Remove a scope by its file path (used by watcher on file deletion) */
29
+ removeByFilePath(filePath) {
30
+ const id = this.filePathToId.get(filePath);
31
+ if (id !== undefined) {
32
+ this.byId.delete(id);
33
+ this.filePathToId.delete(filePath);
34
+ }
35
+ return id;
36
+ }
37
+ /** Look up scope ID by file path (used before removal to stash status) */
38
+ idByFilePath(filePath) {
39
+ return this.filePathToId.get(filePath);
40
+ }
41
+ /** Check if scope exists by ID */
42
+ has(id) {
43
+ return this.byId.has(id);
44
+ }
45
+ /** Get a scope by ID */
46
+ getById(id) {
47
+ return this.byId.get(id);
48
+ }
49
+ /** Get all scopes sorted by ID */
50
+ getAll() {
51
+ return [...this.byId.values()].sort((a, b) => a.id - b.id);
52
+ }
53
+ /** Get the maximum raw scope number excluding icebox scopes (for next-ID generation).
54
+ * Cache keys use encoded IDs (suffixed scopes like 047a → 1047, 075x → 9075),
55
+ * but next-ID generation needs the raw scope number (047, 075, 087). */
56
+ maxNonIceboxId() {
57
+ let max = 0;
58
+ for (const [id, scope] of this.byId) {
59
+ if (scope.status === 'icebox')
60
+ continue;
61
+ // Decode: encoded IDs ≥1000 have a suffix offset — raw number is id % 1000
62
+ const raw = id >= 1000 ? id % 1000 : id;
63
+ if (raw > max)
64
+ max = raw;
65
+ }
66
+ return max;
67
+ }
68
+ /** Total number of cached scopes */
69
+ get size() {
70
+ return this.byId.size;
71
+ }
72
+ }
@@ -0,0 +1,424 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import matter from 'gray-matter';
4
+ import { normalizeStatus, parseAllScopes, parseScopeFile, setValidStatuses } from '../parsers/scope-parser.js';
5
+ import { createLogger } from '../utils/logger.js';
6
+ const log = createLogger('scope');
7
+ export class ScopeService {
8
+ cache;
9
+ io;
10
+ scopesDir;
11
+ engine;
12
+ onStatusChangeCallbacks = [];
13
+ activeGroupCheck = null;
14
+ suppressedPaths = new Set();
15
+ /** Stash old status when removeByFilePath fires before updateFromFile (chokidar unlink→add) */
16
+ recentlyRemoved = new Map();
17
+ constructor(cache, io, scopesDir, engine) {
18
+ this.cache = cache;
19
+ this.io = io;
20
+ this.scopesDir = scopesDir;
21
+ this.engine = engine;
22
+ }
23
+ /** Register a callback that checks if a scope is in an active group (sprint/batch).
24
+ * Used to guard patch-context status changes. */
25
+ setActiveGroupCheck(fn) {
26
+ this.activeGroupCheck = fn;
27
+ }
28
+ /** Register a callback fired after every successful status update */
29
+ onStatusChange(cb) {
30
+ this.onStatusChangeCallbacks.push(cb);
31
+ }
32
+ /** Load all scopes from the filesystem into the in-memory cache */
33
+ syncFromFilesystem() {
34
+ // Push the engine's valid list IDs to the scope parser so
35
+ // inferStatusFromDir doesn't rely on a hardcoded set.
36
+ setValidStatuses(this.engine.getLists().map(l => l.id));
37
+ const scopes = parseAllScopes(this.scopesDir);
38
+ this.cache.loadAll(scopes);
39
+ return scopes.length;
40
+ }
41
+ /** Check if a path is suppressed from watcher processing (during programmatic moves) */
42
+ isSuppressed(filePath) {
43
+ return this.suppressedPaths.has(filePath);
44
+ }
45
+ /** Re-parse a single scope file and update the cache */
46
+ updateFromFile(filePath) {
47
+ const scope = parseScopeFile(filePath);
48
+ if (!scope)
49
+ return;
50
+ const previous = this.cache.getById(scope.id);
51
+ const previousStatus = previous?.status ?? this.recentlyRemoved.get(scope.id);
52
+ const existing = previous != null;
53
+ this.cache.set(scope);
54
+ this.recentlyRemoved.delete(scope.id);
55
+ const event = existing ? 'scope:updated' : 'scope:created';
56
+ this.io.emit(event, scope);
57
+ // Fire onStatusChange callbacks when status changed via external file move
58
+ // (e.g. scope-transition.sh, manual mv). This ensures batch/sprint
59
+ // orchestrators are notified even when the change bypasses updateStatus().
60
+ // Chokidar fires unlink→add for moves, so the cache entry may already be
61
+ // removed by removeByFilePath — check recentlyRemoved for the old status.
62
+ if (previousStatus != null && previousStatus !== scope.status) {
63
+ for (const cb of this.onStatusChangeCallbacks)
64
+ cb(scope.id, scope.status);
65
+ }
66
+ }
67
+ /** Remove a scope when its file is deleted */
68
+ removeByFilePath(filePath) {
69
+ // Stash status before removal so updateFromFile can detect external moves
70
+ // (chokidar fires unlink before add when a file is moved between directories)
71
+ const scopeId = this.cache.idByFilePath(filePath);
72
+ const previous = scopeId != null ? this.cache.getById(scopeId) : undefined;
73
+ const id = this.cache.removeByFilePath(filePath);
74
+ if (id !== undefined) {
75
+ if (previous)
76
+ this.recentlyRemoved.set(id, previous.status);
77
+ this.io.emit('scope:deleted', id);
78
+ // Clean up stash after a short window (if add never fires, this was a real delete)
79
+ setTimeout(() => this.recentlyRemoved.delete(id), 5000);
80
+ }
81
+ }
82
+ /** Get all scopes (already native arrays/objects — no JSON parsing needed) */
83
+ getAll() {
84
+ return this.cache.getAll();
85
+ }
86
+ /** Get a single scope by ID */
87
+ getById(id) {
88
+ return this.cache.getById(id);
89
+ }
90
+ /** Update a scope's status with transition validation.
91
+ * Writes the new status to the frontmatter file and updates the cache.
92
+ * @param context - caller trust level: 'patch', 'dispatch', 'event', 'bulk-sync', 'rollback' */
93
+ updateStatus(id, status, context = 'patch') {
94
+ if (!this.engine.isValidStatus(status)) {
95
+ return { ok: false, error: `Invalid status: '${status}'`, code: 'INVALID_STATUS' };
96
+ }
97
+ // For non-skip contexts, validate the transition
98
+ if (context !== 'bulk-sync' && context !== 'rollback') {
99
+ const current = this.cache.getById(id);
100
+ if (!current) {
101
+ return { ok: false, error: 'Scope not found', code: 'NOT_FOUND' };
102
+ }
103
+ // Guard: block manual moves for scopes in active groups (sprint/batch)
104
+ if (context === 'patch' && this.activeGroupCheck) {
105
+ const group = this.activeGroupCheck(id);
106
+ if (group) {
107
+ return { ok: false, error: `Scope is in an active ${group.group_type} (ID: ${group.sprint_id})`, code: 'SCOPE_IN_ACTIVE_GROUP' };
108
+ }
109
+ }
110
+ const check = this.engine.validateTransition(current.status, status, context);
111
+ if (!check.ok)
112
+ return check;
113
+ }
114
+ // Write to filesystem via updateScopeFrontmatter (which updates cache + emits)
115
+ const current = context === 'bulk-sync' || context === 'rollback'
116
+ ? this.cache.getById(id)
117
+ : this.cache.getById(id); // already fetched above for validation, but may be null in bulk-sync
118
+ const fromStatus = current?.status ?? 'unknown';
119
+ const result = this.updateScopeFrontmatter(id, { status }, context);
120
+ if (result.ok) {
121
+ log.info('Status updated', { id, from: fromStatus, to: status, context });
122
+ for (const cb of this.onStatusChangeCallbacks)
123
+ cb(id, status);
124
+ }
125
+ return result;
126
+ }
127
+ /** Compute the next sequential scope ID by scanning all non-icebox scopes.
128
+ * Checks both filesystem (all subdirs except icebox) and cache to prevent collisions. */
129
+ getNextScopeId() {
130
+ let maxId = 0;
131
+ // Scan all scope subdirectories except icebox
132
+ if (fs.existsSync(this.scopesDir)) {
133
+ for (const dir of fs.readdirSync(this.scopesDir, { withFileTypes: true })) {
134
+ if (!dir.isDirectory() || dir.name === 'icebox')
135
+ continue;
136
+ const dirPath = path.join(this.scopesDir, dir.name);
137
+ for (const file of fs.readdirSync(dirPath)) {
138
+ const m = file.match(/^(\d+)-/);
139
+ if (m)
140
+ maxId = Math.max(maxId, parseInt(m[1], 10));
141
+ }
142
+ }
143
+ }
144
+ // Cross-check cache (catches scopes in unexpected locations)
145
+ const cacheMax = this.cache.maxNonIceboxId();
146
+ maxId = Math.max(maxId, cacheMax);
147
+ return maxId + 1;
148
+ }
149
+ // ─── Idea CRUD (filesystem-backed icebox cards) ────────────
150
+ /** Get the next available icebox ID (starts at 501, increments from max found) */
151
+ getNextIceboxId() {
152
+ const iceboxDir = path.join(this.scopesDir, 'icebox');
153
+ if (!fs.existsSync(iceboxDir))
154
+ return 501;
155
+ let maxId = 500;
156
+ for (const file of fs.readdirSync(iceboxDir)) {
157
+ const m = file.match(/^(\d+)-/);
158
+ if (m)
159
+ maxId = Math.max(maxId, parseInt(m[1], 10));
160
+ }
161
+ return maxId + 1;
162
+ }
163
+ /** Find an icebox file by its ID prefix.
164
+ * Matches both padded (091-) and unpadded (91-) filenames
165
+ * since demoted scopes keep their 3-digit-padded names. */
166
+ findIdeaFile(iceboxDir, id) {
167
+ if (!fs.existsSync(iceboxDir))
168
+ return null;
169
+ const match = fs.readdirSync(iceboxDir).find((f) => {
170
+ if (!f.endsWith('.md'))
171
+ return false;
172
+ const m = f.match(/^(\d+)-/);
173
+ return m != null && parseInt(m[1], 10) === id;
174
+ });
175
+ return match ? path.join(iceboxDir, match) : null;
176
+ }
177
+ /** Create an icebox idea as a markdown file. IDs start at 501. */
178
+ createIdeaFile(title, description) {
179
+ const iceboxDir = path.join(this.scopesDir, 'icebox');
180
+ if (!fs.existsSync(iceboxDir))
181
+ fs.mkdirSync(iceboxDir, { recursive: true });
182
+ const nextId = this.getNextIceboxId();
183
+ const slug = title
184
+ .toLowerCase()
185
+ .replace(/[^a-z0-9]+/g, '-')
186
+ .replace(/^-|-$/g, '')
187
+ .slice(0, 60);
188
+ const fileName = `${nextId}-${slug}.md`;
189
+ const filePath = path.join(iceboxDir, fileName);
190
+ const now = new Date().toISOString().split('T')[0];
191
+ const content = [
192
+ '---',
193
+ `id: ${nextId}`,
194
+ `title: "${title.replace(/"/g, '\\"')}"`,
195
+ 'status: icebox',
196
+ `created: ${now}`,
197
+ `updated: ${now}`,
198
+ 'blocked_by: []',
199
+ 'blocks: []',
200
+ 'tags: []',
201
+ '---',
202
+ '',
203
+ description || '',
204
+ '',
205
+ ].join('\n');
206
+ fs.writeFileSync(filePath, content, 'utf-8');
207
+ // Eagerly sync to cache + emit scope:created
208
+ this.updateFromFile(filePath);
209
+ log.info('Idea created', { id: nextId, title });
210
+ return { id: nextId, title };
211
+ }
212
+ /** Update an icebox idea's title and description by rewriting its file */
213
+ updateIdeaFile(id, title, description) {
214
+ const iceboxDir = path.join(this.scopesDir, 'icebox');
215
+ const filePath = this.findIdeaFile(iceboxDir, id);
216
+ if (!filePath)
217
+ return false;
218
+ // Preserve the original created date from existing frontmatter
219
+ const existing = fs.readFileSync(filePath, 'utf-8');
220
+ const createdMatch = existing.match(/^created:\s*(.+)$/m);
221
+ const created = createdMatch?.[1]?.trim() ?? new Date().toISOString().split('T')[0];
222
+ const now = new Date().toISOString().split('T')[0];
223
+ const content = [
224
+ '---',
225
+ `id: ${id}`,
226
+ `title: "${title.replace(/"/g, '\\"')}"`,
227
+ 'status: icebox',
228
+ `created: ${created}`,
229
+ `updated: ${now}`,
230
+ 'blocked_by: []',
231
+ 'blocks: []',
232
+ 'tags: []',
233
+ '---',
234
+ '',
235
+ description || '',
236
+ '',
237
+ ].join('\n');
238
+ fs.writeFileSync(filePath, content, 'utf-8');
239
+ // Watcher handles cache sync + scope:updated event
240
+ return true;
241
+ }
242
+ /** Delete an icebox idea by removing its file */
243
+ deleteIdeaFile(id) {
244
+ const iceboxDir = path.join(this.scopesDir, 'icebox');
245
+ const filePath = this.findIdeaFile(iceboxDir, id);
246
+ if (!filePath)
247
+ return false;
248
+ fs.unlinkSync(filePath);
249
+ // Eagerly remove from cache + emit scope:deleted
250
+ this.removeByFilePath(filePath);
251
+ log.info('Idea deleted', { id });
252
+ return true;
253
+ }
254
+ /** Promote an icebox idea to planning — assigns a proper sequential scope ID,
255
+ * moves the file, and syncs cache. Returns the new scope ID. */
256
+ promoteIdea(id) {
257
+ const iceboxDir = path.join(this.scopesDir, 'icebox');
258
+ const oldPath = this.findIdeaFile(iceboxDir, id);
259
+ if (!oldPath)
260
+ return null;
261
+ // Read existing file for metadata
262
+ const content = fs.readFileSync(oldPath, 'utf-8');
263
+ const titleMatch = content.match(/^title:\s*"?([^"\n]+)"?\s*$/m);
264
+ const createdMatch = content.match(/^created:\s*(.+)$/m);
265
+ const title = titleMatch?.[1]?.trim() ?? 'Untitled';
266
+ const created = createdMatch?.[1]?.trim() ?? new Date().toISOString().split('T')[0];
267
+ // Extract body after frontmatter
268
+ const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
269
+ const description = fmEnd !== -1 ? content.slice(fmEnd + 3).trim() : '';
270
+ // Assign the next sequential scope ID (excludes icebox items)
271
+ const newId = this.getNextScopeId();
272
+ const paddedId = String(newId).padStart(3, '0');
273
+ // Build slug and new path
274
+ const slug = title
275
+ .toLowerCase()
276
+ .replace(/[^a-z0-9]+/g, '-')
277
+ .replace(/^-|-$/g, '')
278
+ .slice(0, 60);
279
+ const planningDir = path.join(this.scopesDir, 'planning');
280
+ if (!fs.existsSync(planningDir))
281
+ fs.mkdirSync(planningDir, { recursive: true });
282
+ const newFileName = `${paddedId}-${slug}.md`;
283
+ const newPath = path.join(planningDir, newFileName);
284
+ const now = new Date().toISOString().split('T')[0];
285
+ // Write new file with planning status and new sequential ID
286
+ const newContent = [
287
+ '---',
288
+ `id: ${paddedId}`,
289
+ `title: "${title.replace(/"/g, '\\"')}"`,
290
+ 'status: planning',
291
+ `created: ${created}`,
292
+ `updated: ${now}`,
293
+ 'blocked_by: []',
294
+ 'blocks: []',
295
+ 'tags: []',
296
+ '---',
297
+ '',
298
+ description || '',
299
+ '',
300
+ ].join('\n');
301
+ fs.writeFileSync(newPath, newContent, 'utf-8');
302
+ // Sync cache before deleting old file (avoids window where scope is missing)
303
+ this.updateFromFile(newPath);
304
+ fs.unlinkSync(oldPath);
305
+ this.removeByFilePath(oldPath);
306
+ const relPath = path.relative(path.resolve(this.scopesDir, '..'), newPath);
307
+ log.info('Idea promoted', { oldId: id, newId, title });
308
+ return { id: newId, filePath: relPath, title, description };
309
+ }
310
+ /** Find a scope file by its numeric ID prefix across all status directories */
311
+ findScopeFile(id) {
312
+ if (!fs.existsSync(this.scopesDir))
313
+ return null;
314
+ const paddedId = String(id).padStart(3, '0');
315
+ const prefixes = [`${id}-`, `${paddedId}-`];
316
+ for (const dir of fs.readdirSync(this.scopesDir, { withFileTypes: true })) {
317
+ if (!dir.isDirectory())
318
+ continue;
319
+ const dirPath = path.join(this.scopesDir, dir.name);
320
+ for (const file of fs.readdirSync(dirPath)) {
321
+ if (file.endsWith('.md') && prefixes.some((p) => file.startsWith(p))) {
322
+ return path.join(dirPath, file);
323
+ }
324
+ }
325
+ }
326
+ return null;
327
+ }
328
+ /** Update a scope's frontmatter fields and write back to the .md file.
329
+ * If status changes, validates the transition and moves the file to the new status directory.
330
+ * @param context - transition context for validation (default 'patch') */
331
+ updateScopeFrontmatter(id, fields, context = 'patch') {
332
+ const filePath = this.findScopeFile(id);
333
+ if (!filePath) {
334
+ return { ok: false, error: 'Scope file not found', code: 'NOT_FOUND' };
335
+ }
336
+ const raw = fs.readFileSync(filePath, 'utf-8');
337
+ const parsed = matter(raw);
338
+ const today = new Date().toISOString().split('T')[0];
339
+ // Validate status transition before any writes
340
+ const newStatus = fields.status;
341
+ const rawOldStatus = String(parsed.data.status ?? 'planning');
342
+ const oldStatus = normalizeStatus(rawOldStatus);
343
+ let needsMove = false;
344
+ if (newStatus && newStatus !== oldStatus) {
345
+ if (!this.engine.isValidStatus(newStatus)) {
346
+ return { ok: false, error: `Invalid status: '${newStatus}'`, code: 'INVALID_STATUS' };
347
+ }
348
+ const check = this.engine.validateTransition(oldStatus, newStatus, context);
349
+ if (!check.ok)
350
+ return check;
351
+ needsMove = true;
352
+ // Auto-unlock spec when reverting backlog → planning
353
+ if (newStatus === 'planning' && oldStatus === 'backlog')
354
+ fields.spec_locked = false;
355
+ }
356
+ // Merge editable fields into frontmatter
357
+ const editableKeys = ['title', 'status', 'priority', 'effort_estimate', 'category', 'tags', 'blocked_by', 'blocks', 'spec_locked'];
358
+ for (const key of editableKeys) {
359
+ if (key in fields) {
360
+ const val = fields[key];
361
+ // Treat empty strings / null as removal (delete the key)
362
+ if (val === null || val === '' || val === 'none') {
363
+ delete parsed.data[key];
364
+ }
365
+ else {
366
+ parsed.data[key] = val;
367
+ }
368
+ }
369
+ }
370
+ parsed.data.updated = today;
371
+ // Normalize Date objects to YYYY-MM-DD strings to prevent matter.stringify
372
+ // from converting them to full ISO timestamps (gray-matter auto-parses bare dates)
373
+ for (const key of Object.keys(parsed.data)) {
374
+ const val = parsed.data[key];
375
+ if (val instanceof Date) {
376
+ parsed.data[key] = val.toISOString().split('T')[0];
377
+ }
378
+ }
379
+ if (!needsMove) {
380
+ // Simple in-place rewrite
381
+ fs.writeFileSync(filePath, matter.stringify(parsed.content, parsed.data), 'utf-8');
382
+ // Chokidar will pick this up, but eagerly sync for instant feedback
383
+ this.updateFromFile(filePath);
384
+ log.info('Frontmatter updated', { id, fields: Object.keys(fields) });
385
+ return { ok: true };
386
+ }
387
+ // Status change → move file to new directory
388
+ const targetDir = path.join(this.scopesDir, newStatus);
389
+ if (!fs.existsSync(targetDir))
390
+ fs.mkdirSync(targetDir, { recursive: true });
391
+ const fileName = path.basename(filePath);
392
+ const newPath = path.join(targetDir, fileName);
393
+ const newContent = matter.stringify(parsed.content, parsed.data);
394
+ // Suppress watcher events during programmatic move to prevent race conditions
395
+ this.suppressedPaths.add(filePath);
396
+ this.suppressedPaths.add(newPath);
397
+ // Update content in-place, then atomic rename (no window where file is missing)
398
+ fs.writeFileSync(filePath, newContent, 'utf-8');
399
+ fs.renameSync(filePath, newPath);
400
+ this.updateFromFile(newPath);
401
+ this.removeByFilePath(filePath);
402
+ // Clear suppression after watcher events have drained
403
+ setTimeout(() => {
404
+ this.suppressedPaths.delete(filePath);
405
+ this.suppressedPaths.delete(newPath);
406
+ }, 500);
407
+ return { ok: true, moved: true };
408
+ }
409
+ /** Approve a ghost idea — removes ghost:true from frontmatter and refreshes cache */
410
+ approveGhostIdea(id) {
411
+ const iceboxDir = path.join(this.scopesDir, 'icebox');
412
+ const filePath = this.findIdeaFile(iceboxDir, id);
413
+ if (!filePath)
414
+ return false;
415
+ const content = fs.readFileSync(filePath, 'utf-8');
416
+ // Remove ghost: true line from frontmatter
417
+ const updated = content.replace(/^ghost:\s*true\n/m, '');
418
+ fs.writeFileSync(filePath, updated, 'utf-8');
419
+ // Re-parse file to refresh cache with is_ghost=false
420
+ this.updateFromFile(filePath);
421
+ log.info('Ghost approved', { id });
422
+ return true;
423
+ }
424
+ }