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