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.
- package/LICENSE +21 -0
- package/README.md +396 -0
- package/bin/orbital.js +362 -0
- package/dist/assets/WorkflowVisualizer-BZ21PIIF.js +84 -0
- package/dist/assets/WorkflowVisualizer-BZV40eAE.css +1 -0
- package/dist/assets/charts-D__PA1zp.js +72 -0
- package/dist/assets/index-D1G6i0nS.css +1 -0
- package/dist/assets/index-DpItvKpf.js +419 -0
- package/dist/assets/ui-BvF022GT.js +53 -0
- package/dist/assets/vendor-Dzv9lrRc.js +59 -0
- package/dist/index.html +19 -0
- package/dist/scanner-sweep.png +0 -0
- package/dist/server/server/adapters/index.js +34 -0
- package/dist/server/server/adapters/iterm2-adapter.js +29 -0
- package/dist/server/server/adapters/subprocess-adapter.js +21 -0
- package/dist/server/server/adapters/terminal-adapter.js +1 -0
- package/dist/server/server/config.js +156 -0
- package/dist/server/server/database.js +90 -0
- package/dist/server/server/index.js +372 -0
- package/dist/server/server/init.js +811 -0
- package/dist/server/server/parsers/event-parser.js +64 -0
- package/dist/server/server/parsers/scope-parser.js +188 -0
- package/dist/server/server/routes/config-routes.js +163 -0
- package/dist/server/server/routes/data-routes.js +461 -0
- package/dist/server/server/routes/dispatch-routes.js +215 -0
- package/dist/server/server/routes/git-routes.js +92 -0
- package/dist/server/server/routes/scope-routes.js +215 -0
- package/dist/server/server/routes/sprint-routes.js +116 -0
- package/dist/server/server/routes/version-routes.js +130 -0
- package/dist/server/server/routes/workflow-routes.js +185 -0
- package/dist/server/server/schema.js +90 -0
- package/dist/server/server/services/batch-orchestrator.js +253 -0
- package/dist/server/server/services/claude-session-service.js +352 -0
- package/dist/server/server/services/config-service.js +132 -0
- package/dist/server/server/services/deploy-service.js +51 -0
- package/dist/server/server/services/event-service.js +63 -0
- package/dist/server/server/services/gate-service.js +83 -0
- package/dist/server/server/services/git-service.js +309 -0
- package/dist/server/server/services/github-service.js +145 -0
- package/dist/server/server/services/readiness-service.js +184 -0
- package/dist/server/server/services/scope-cache.js +72 -0
- package/dist/server/server/services/scope-service.js +424 -0
- package/dist/server/server/services/sprint-orchestrator.js +312 -0
- package/dist/server/server/services/sprint-service.js +293 -0
- package/dist/server/server/services/workflow-service.js +397 -0
- package/dist/server/server/utils/cc-hooks-parser.js +49 -0
- package/dist/server/server/utils/dispatch-utils.js +305 -0
- package/dist/server/server/utils/logger.js +86 -0
- package/dist/server/server/utils/terminal-launcher.js +388 -0
- package/dist/server/server/utils/worktree-manager.js +98 -0
- package/dist/server/server/watchers/event-watcher.js +81 -0
- package/dist/server/server/watchers/scope-watcher.js +33 -0
- package/dist/server/shared/api-types.js +5 -0
- package/dist/server/shared/default-workflow.json +616 -0
- package/dist/server/shared/workflow-config.js +44 -0
- package/dist/server/shared/workflow-engine.js +353 -0
- package/index.html +15 -0
- package/package.json +110 -0
- package/postcss.config.js +6 -0
- package/schemas/orbital.config.schema.json +83 -0
- package/scripts/postinstall.js +24 -0
- package/scripts/start.sh +20 -0
- package/server/adapters/index.ts +41 -0
- package/server/adapters/iterm2-adapter.ts +37 -0
- package/server/adapters/subprocess-adapter.ts +25 -0
- package/server/adapters/terminal-adapter.ts +24 -0
- package/server/config.ts +234 -0
- package/server/database.ts +107 -0
- package/server/index.ts +452 -0
- package/server/init.ts +891 -0
- package/server/parsers/event-parser.ts +74 -0
- package/server/parsers/scope-parser.ts +240 -0
- package/server/routes/config-routes.ts +182 -0
- package/server/routes/data-routes.ts +548 -0
- package/server/routes/dispatch-routes.ts +275 -0
- package/server/routes/git-routes.ts +112 -0
- package/server/routes/scope-routes.ts +262 -0
- package/server/routes/sprint-routes.ts +142 -0
- package/server/routes/version-routes.ts +156 -0
- package/server/routes/workflow-routes.ts +198 -0
- package/server/schema.ts +90 -0
- package/server/services/batch-orchestrator.ts +286 -0
- package/server/services/claude-session-service.ts +441 -0
- package/server/services/config-service.ts +151 -0
- package/server/services/deploy-service.ts +98 -0
- package/server/services/event-service.ts +98 -0
- package/server/services/gate-service.ts +126 -0
- package/server/services/git-service.ts +391 -0
- package/server/services/github-service.ts +183 -0
- package/server/services/readiness-service.ts +250 -0
- package/server/services/scope-cache.ts +81 -0
- package/server/services/scope-service.ts +476 -0
- package/server/services/sprint-orchestrator.ts +361 -0
- package/server/services/sprint-service.ts +415 -0
- package/server/services/workflow-service.ts +461 -0
- package/server/utils/cc-hooks-parser.ts +70 -0
- package/server/utils/dispatch-utils.ts +395 -0
- package/server/utils/logger.ts +109 -0
- package/server/utils/terminal-launcher.ts +462 -0
- package/server/utils/worktree-manager.ts +104 -0
- package/server/watchers/event-watcher.ts +100 -0
- package/server/watchers/scope-watcher.ts +38 -0
- package/shared/api-types.ts +20 -0
- package/shared/default-workflow.json +616 -0
- package/shared/workflow-config.ts +170 -0
- package/shared/workflow-engine.ts +427 -0
- package/src/App.tsx +33 -0
- package/src/components/AgentBadge.tsx +40 -0
- package/src/components/BatchPreflightModal.tsx +115 -0
- package/src/components/CardDisplayToggle.tsx +74 -0
- package/src/components/ColumnHeaderActions.tsx +55 -0
- package/src/components/ColumnMenu.tsx +99 -0
- package/src/components/DeployHistory.tsx +141 -0
- package/src/components/DispatchModal.tsx +164 -0
- package/src/components/DispatchPopover.tsx +139 -0
- package/src/components/DragOverlay.tsx +25 -0
- package/src/components/DriftSidebar.tsx +140 -0
- package/src/components/EnvironmentStrip.tsx +88 -0
- package/src/components/ErrorBoundary.tsx +62 -0
- package/src/components/FilterChip.tsx +105 -0
- package/src/components/GateIndicator.tsx +33 -0
- package/src/components/IdeaDetailModal.tsx +190 -0
- package/src/components/IdeaFormDialog.tsx +113 -0
- package/src/components/KanbanColumn.tsx +201 -0
- package/src/components/MarkdownRenderer.tsx +114 -0
- package/src/components/NeonGrid.tsx +128 -0
- package/src/components/PromotionQueue.tsx +89 -0
- package/src/components/ScopeCard.tsx +234 -0
- package/src/components/ScopeDetailModal.tsx +255 -0
- package/src/components/ScopeFilterBar.tsx +152 -0
- package/src/components/SearchInput.tsx +102 -0
- package/src/components/SessionPanel.tsx +335 -0
- package/src/components/SprintContainer.tsx +303 -0
- package/src/components/SprintDependencyDialog.tsx +78 -0
- package/src/components/SprintPreflightModal.tsx +138 -0
- package/src/components/StatusBar.tsx +168 -0
- package/src/components/SwimCell.tsx +67 -0
- package/src/components/SwimLaneRow.tsx +94 -0
- package/src/components/SwimlaneBoardView.tsx +108 -0
- package/src/components/VersionBadge.tsx +139 -0
- package/src/components/ViewModeSelector.tsx +114 -0
- package/src/components/config/AgentChip.tsx +53 -0
- package/src/components/config/AgentCreateDialog.tsx +321 -0
- package/src/components/config/AgentEditor.tsx +175 -0
- package/src/components/config/DirectoryTree.tsx +582 -0
- package/src/components/config/FileEditor.tsx +550 -0
- package/src/components/config/HookChip.tsx +50 -0
- package/src/components/config/StageCard.tsx +198 -0
- package/src/components/config/TransitionZone.tsx +173 -0
- package/src/components/config/UnifiedWorkflowPipeline.tsx +216 -0
- package/src/components/config/WorkflowPipeline.tsx +161 -0
- package/src/components/source-control/BranchList.tsx +93 -0
- package/src/components/source-control/BranchPanel.tsx +105 -0
- package/src/components/source-control/CommitLog.tsx +100 -0
- package/src/components/source-control/CommitRow.tsx +47 -0
- package/src/components/source-control/GitHubPanel.tsx +110 -0
- package/src/components/source-control/GitHubSetupGuide.tsx +52 -0
- package/src/components/source-control/GitOverviewBar.tsx +101 -0
- package/src/components/source-control/PullRequestList.tsx +69 -0
- package/src/components/source-control/WorktreeList.tsx +80 -0
- package/src/components/ui/badge.tsx +41 -0
- package/src/components/ui/button.tsx +55 -0
- package/src/components/ui/card.tsx +78 -0
- package/src/components/ui/dialog.tsx +94 -0
- package/src/components/ui/popover.tsx +33 -0
- package/src/components/ui/scroll-area.tsx +54 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/components/ui/toggle-switch.tsx +35 -0
- package/src/components/ui/tooltip.tsx +27 -0
- package/src/components/workflow/AddEdgeDialog.tsx +217 -0
- package/src/components/workflow/AddListDialog.tsx +201 -0
- package/src/components/workflow/ChecklistEditor.tsx +239 -0
- package/src/components/workflow/CommandPrefixManager.tsx +118 -0
- package/src/components/workflow/ConfigSettingsPanel.tsx +189 -0
- package/src/components/workflow/DirectionSelector.tsx +133 -0
- package/src/components/workflow/DispatchConfigPanel.tsx +180 -0
- package/src/components/workflow/EdgeDetailPanel.tsx +236 -0
- package/src/components/workflow/EdgePropertyEditor.tsx +251 -0
- package/src/components/workflow/EditToolbar.tsx +138 -0
- package/src/components/workflow/HookDetailPanel.tsx +250 -0
- package/src/components/workflow/HookExecutionLog.tsx +24 -0
- package/src/components/workflow/HookSourceModal.tsx +129 -0
- package/src/components/workflow/HooksDashboard.tsx +363 -0
- package/src/components/workflow/ListPropertyEditor.tsx +251 -0
- package/src/components/workflow/MigrationPreviewDialog.tsx +237 -0
- package/src/components/workflow/MovementRulesPanel.tsx +188 -0
- package/src/components/workflow/NodeDetailPanel.tsx +245 -0
- package/src/components/workflow/PresetSelector.tsx +414 -0
- package/src/components/workflow/SkillCommandBuilder.tsx +174 -0
- package/src/components/workflow/WorkflowEdgeComponent.tsx +145 -0
- package/src/components/workflow/WorkflowNode.tsx +147 -0
- package/src/components/workflow/graphLayout.ts +186 -0
- package/src/components/workflow/mergeHooks.ts +85 -0
- package/src/components/workflow/useEditHistory.ts +88 -0
- package/src/components/workflow/useWorkflowEditor.ts +262 -0
- package/src/components/workflow/validateConfig.ts +70 -0
- package/src/hooks/useActiveDispatches.ts +198 -0
- package/src/hooks/useBoardSettings.ts +170 -0
- package/src/hooks/useCardDisplay.ts +57 -0
- package/src/hooks/useCcHooks.ts +24 -0
- package/src/hooks/useConfigTree.ts +51 -0
- package/src/hooks/useEnforcementRules.ts +46 -0
- package/src/hooks/useEvents.ts +59 -0
- package/src/hooks/useFileEditor.ts +165 -0
- package/src/hooks/useGates.ts +57 -0
- package/src/hooks/useIdeaActions.ts +53 -0
- package/src/hooks/useKanbanDnd.ts +410 -0
- package/src/hooks/useOrbitalConfig.ts +54 -0
- package/src/hooks/usePipeline.ts +47 -0
- package/src/hooks/usePipelineData.ts +338 -0
- package/src/hooks/useReconnect.ts +25 -0
- package/src/hooks/useScopeFilters.ts +125 -0
- package/src/hooks/useScopeSessions.ts +44 -0
- package/src/hooks/useScopes.ts +67 -0
- package/src/hooks/useSearch.ts +67 -0
- package/src/hooks/useSettings.tsx +187 -0
- package/src/hooks/useSocket.ts +25 -0
- package/src/hooks/useSourceControl.ts +105 -0
- package/src/hooks/useSprintPreflight.ts +55 -0
- package/src/hooks/useSprints.ts +154 -0
- package/src/hooks/useStatusBarHighlight.ts +18 -0
- package/src/hooks/useSwimlaneBoardSettings.ts +104 -0
- package/src/hooks/useTheme.ts +9 -0
- package/src/hooks/useTransitionReadiness.ts +53 -0
- package/src/hooks/useVersion.ts +155 -0
- package/src/hooks/useViolations.ts +65 -0
- package/src/hooks/useWorkflow.tsx +125 -0
- package/src/hooks/useZoomModifier.ts +19 -0
- package/src/index.css +797 -0
- package/src/layouts/DashboardLayout.tsx +113 -0
- package/src/lib/collisionDetection.ts +20 -0
- package/src/lib/scope-fields.ts +61 -0
- package/src/lib/swimlane.ts +146 -0
- package/src/lib/utils.ts +15 -0
- package/src/main.tsx +19 -0
- package/src/socket.ts +11 -0
- package/src/types/index.ts +497 -0
- package/src/views/AgentFeed.tsx +339 -0
- package/src/views/DeployPipeline.tsx +59 -0
- package/src/views/EnforcementView.tsx +378 -0
- package/src/views/PrimitivesConfig.tsx +500 -0
- package/src/views/QualityGates.tsx +1012 -0
- package/src/views/ScopeBoard.tsx +454 -0
- package/src/views/SessionTimeline.tsx +516 -0
- package/src/views/Settings.tsx +183 -0
- package/src/views/SourceControl.tsx +95 -0
- package/src/views/WorkflowVisualizer.tsx +382 -0
- package/tailwind.config.js +161 -0
- package/templates/agents/AUTO-INVOKE.md +180 -0
- package/templates/agents/CONFLICT-RESOLUTION.md +128 -0
- package/templates/agents/QUICK-REFERENCE.md +122 -0
- package/templates/agents/README.md +188 -0
- package/templates/agents/SKILL-TRIGGERS.md +100 -0
- package/templates/agents/blue-team/frontend-designer.md +424 -0
- package/templates/agents/green-team/architect.md +526 -0
- package/templates/agents/green-team/rules-enforcer.md +131 -0
- package/templates/agents/red-team/attacker-learned.md +24 -0
- package/templates/agents/red-team/attacker.md +486 -0
- package/templates/agents/red-team/chaos.md +548 -0
- package/templates/agents/reference/component-registry.md +82 -0
- package/templates/agents/workflows/full-mode.md +218 -0
- package/templates/agents/workflows/quick-mode.md +118 -0
- package/templates/agents/workflows/security-mode.md +283 -0
- package/templates/anti-patterns/dangerous-shortcuts.md +427 -0
- package/templates/config/agent-triggers.json +92 -0
- package/templates/hooks/agent-team-gate.sh +31 -0
- package/templates/hooks/agent-trigger.sh +97 -0
- package/templates/hooks/block-push.sh +66 -0
- package/templates/hooks/block-workarounds.sh +61 -0
- package/templates/hooks/blocker-check.sh +28 -0
- package/templates/hooks/completion-checklist.sh +28 -0
- package/templates/hooks/decision-capture.sh +15 -0
- package/templates/hooks/dependency-check.sh +27 -0
- package/templates/hooks/end-session.sh +31 -0
- package/templates/hooks/exploration-logger.sh +37 -0
- package/templates/hooks/files-changed-summary.sh +37 -0
- package/templates/hooks/get-session-id.sh +49 -0
- package/templates/hooks/git-commit-guard.sh +34 -0
- package/templates/hooks/init-session.sh +93 -0
- package/templates/hooks/orbital-emit.sh +79 -0
- package/templates/hooks/orbital-report-deploy.sh +78 -0
- package/templates/hooks/orbital-report-gates.sh +40 -0
- package/templates/hooks/orbital-report-violation.sh +36 -0
- package/templates/hooks/orbital-scope-update.sh +53 -0
- package/templates/hooks/phase-verify-reminder.sh +26 -0
- package/templates/hooks/review-gate-check.sh +82 -0
- package/templates/hooks/scope-commit-logger.sh +37 -0
- package/templates/hooks/scope-create-cleanup.sh +36 -0
- package/templates/hooks/scope-create-gate.sh +80 -0
- package/templates/hooks/scope-create-tracker.sh +17 -0
- package/templates/hooks/scope-file-sync.sh +53 -0
- package/templates/hooks/scope-gate.sh +35 -0
- package/templates/hooks/scope-helpers.sh +188 -0
- package/templates/hooks/scope-lifecycle-gate.sh +139 -0
- package/templates/hooks/scope-prepare.sh +244 -0
- package/templates/hooks/scope-transition.sh +172 -0
- package/templates/hooks/session-enforcer.sh +143 -0
- package/templates/hooks/time-tracker.sh +33 -0
- package/templates/lessons-learned.md +15 -0
- package/templates/orbital.config.json +35 -0
- package/templates/presets/development.json +42 -0
- package/templates/presets/gitflow.json +712 -0
- package/templates/presets/minimal.json +23 -0
- package/templates/quick/rules.md +218 -0
- package/templates/scopes/_template.md +255 -0
- package/templates/settings-hooks.json +98 -0
- package/templates/skills/git-commit/SKILL.md +85 -0
- package/templates/skills/git-dev/SKILL.md +99 -0
- package/templates/skills/git-hotfix/SKILL.md +223 -0
- package/templates/skills/git-main/SKILL.md +84 -0
- package/templates/skills/git-production/SKILL.md +165 -0
- package/templates/skills/git-staging/SKILL.md +112 -0
- package/templates/skills/scope-create/SKILL.md +81 -0
- package/templates/skills/scope-fix-review/SKILL.md +168 -0
- package/templates/skills/scope-implement/SKILL.md +110 -0
- package/templates/skills/scope-post-review/SKILL.md +144 -0
- package/templates/skills/scope-pre-review/SKILL.md +211 -0
- package/templates/skills/scope-verify/SKILL.md +201 -0
- package/templates/skills/session-init/SKILL.md +62 -0
- package/templates/skills/session-resume/SKILL.md +201 -0
- package/templates/skills/test-checks/SKILL.md +171 -0
- package/templates/skills/test-code-review/SKILL.md +252 -0
- package/tsconfig.json +25 -0
- 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
|
+
}
|