orbital-command 0.1.4 → 0.3.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/bin/orbital.js +676 -53
- package/dist/assets/PrimitivesConfig-CrmQXYh4.js +32 -0
- package/dist/assets/QualityGates-BbasOsF3.js +21 -0
- package/dist/assets/SessionTimeline-CGeJsVvy.js +1 -0
- package/dist/assets/Settings-oiM496mc.js +12 -0
- package/dist/assets/SourceControl-B1fP2nJL.js +41 -0
- package/dist/assets/WorkflowVisualizer-CWLYf-f0.js +74 -0
- package/dist/assets/arrow-down-CPy85_J6.js +6 -0
- package/dist/assets/charts-DbDg0Psc.js +68 -0
- package/dist/assets/circle-x-Cwz6ZQDV.js +6 -0
- package/dist/assets/file-text-C46Xr65c.js +6 -0
- package/dist/assets/formatDistanceToNow-BMqsSP44.js +1 -0
- package/dist/assets/globe-Cn2yNZUD.js +6 -0
- package/dist/assets/index-Aj4sV8Al.css +1 -0
- package/dist/assets/index-Bc9dK3MW.js +354 -0
- package/dist/assets/key-OPaNTWJ5.js +6 -0
- package/dist/assets/minus-GMsbpKym.js +6 -0
- package/dist/assets/shield-DwAFkDYI.js +6 -0
- package/dist/assets/ui-BmsSg9jU.js +53 -0
- package/dist/assets/useWorkflowEditor-BJkTX_NR.js +16 -0
- package/dist/assets/{vendor-Dzv9lrRc.js → vendor-Bqt8AJn2.js} +1 -1
- package/dist/assets/zap-DfbUoOty.js +11 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +6 -5
- package/dist/server/server/__tests__/data-routes.test.js +124 -0
- package/dist/server/server/__tests__/helpers/db.js +17 -0
- package/dist/server/server/__tests__/helpers/mock-emitter.js +8 -0
- package/dist/server/server/__tests__/scope-routes.test.js +137 -0
- package/dist/server/server/__tests__/sprint-routes.test.js +102 -0
- package/dist/server/server/__tests__/workflow-routes.test.js +107 -0
- package/dist/server/server/config-migrator.js +138 -0
- package/dist/server/server/config.js +17 -2
- package/dist/server/server/database.js +27 -12
- package/dist/server/server/global-config.js +143 -0
- package/dist/server/server/index.js +882 -252
- package/dist/server/server/init.js +579 -194
- package/dist/server/server/launch.js +29 -0
- package/dist/server/server/manifest-types.js +8 -0
- package/dist/server/server/manifest.js +454 -0
- package/dist/server/server/migrate-legacy.js +229 -0
- package/dist/server/server/parsers/event-parser.test.js +117 -0
- package/dist/server/server/parsers/scope-parser.js +74 -28
- package/dist/server/server/parsers/scope-parser.test.js +230 -0
- package/dist/server/server/project-context.js +255 -0
- package/dist/server/server/project-emitter.js +41 -0
- package/dist/server/server/project-manager.js +297 -0
- package/dist/server/server/routes/config-routes.js +1 -3
- package/dist/server/server/routes/data-routes.js +22 -110
- package/dist/server/server/routes/dispatch-routes.js +15 -9
- package/dist/server/server/routes/git-routes.js +74 -0
- package/dist/server/server/routes/manifest-routes.js +319 -0
- package/dist/server/server/routes/scope-routes.js +37 -23
- package/dist/server/server/routes/sync-routes.js +134 -0
- package/dist/server/server/routes/version-routes.js +1 -15
- package/dist/server/server/routes/workflow-routes.js +9 -3
- package/dist/server/server/schema.js +2 -0
- package/dist/server/server/services/batch-orchestrator.js +26 -16
- package/dist/server/server/services/claude-session-service.js +17 -14
- package/dist/server/server/services/deploy-service.test.js +119 -0
- package/dist/server/server/services/event-service.js +64 -1
- package/dist/server/server/services/event-service.test.js +191 -0
- package/dist/server/server/services/gate-service.test.js +105 -0
- package/dist/server/server/services/git-service.js +108 -4
- package/dist/server/server/services/github-service.js +110 -2
- package/dist/server/server/services/readiness-service.test.js +190 -0
- package/dist/server/server/services/scope-cache.js +5 -1
- package/dist/server/server/services/scope-cache.test.js +142 -0
- package/dist/server/server/services/scope-service.js +217 -126
- package/dist/server/server/services/scope-service.test.js +137 -0
- package/dist/server/server/services/sprint-orchestrator.js +7 -6
- package/dist/server/server/services/sprint-service.js +21 -1
- package/dist/server/server/services/sprint-service.test.js +238 -0
- package/dist/server/server/services/sync-service.js +434 -0
- package/dist/server/server/services/sync-types.js +2 -0
- package/dist/server/server/services/telemetry-service.js +143 -0
- package/dist/server/server/services/workflow-service.js +26 -5
- package/dist/server/server/services/workflow-service.test.js +159 -0
- package/dist/server/server/settings-sync.js +284 -0
- package/dist/server/server/update-planner.js +279 -0
- package/dist/server/server/utils/cc-hooks-parser.js +3 -0
- package/dist/server/server/utils/cc-hooks-parser.test.js +86 -0
- package/dist/server/server/utils/dispatch-utils.js +77 -20
- package/dist/server/server/utils/dispatch-utils.test.js +182 -0
- package/dist/server/server/utils/logger.js +37 -3
- package/dist/server/server/utils/package-info.js +30 -0
- package/dist/server/server/utils/route-helpers.js +10 -0
- package/dist/server/server/utils/terminal-launcher.js +79 -25
- package/dist/server/server/utils/worktree-manager.js +13 -4
- package/dist/server/server/validator.js +230 -0
- package/dist/server/server/watchers/global-watcher.js +63 -0
- package/dist/server/server/watchers/scope-watcher.js +27 -12
- package/dist/server/server/wizard/config-editor.js +237 -0
- package/dist/server/server/wizard/detect.js +96 -0
- package/dist/server/server/wizard/doctor.js +115 -0
- package/dist/server/server/wizard/index.js +155 -0
- package/dist/server/server/wizard/phases/confirm.js +39 -0
- package/dist/server/server/wizard/phases/project-setup.js +90 -0
- package/dist/server/server/wizard/phases/setup-wizard.js +66 -0
- package/dist/server/server/wizard/phases/welcome.js +35 -0
- package/dist/server/server/wizard/phases/workflow-setup.js +22 -0
- package/dist/server/server/wizard/types.js +29 -0
- package/dist/server/server/wizard/ui.js +74 -0
- package/dist/server/shared/__fixtures__/workflow-configs.js +75 -0
- package/dist/server/shared/default-workflow.json +65 -0
- package/dist/server/shared/onboarding-tour.test.js +81 -0
- package/dist/server/shared/project-colors.js +24 -0
- package/dist/server/shared/workflow-config.test.js +84 -0
- package/dist/server/shared/workflow-engine.test.js +302 -0
- package/dist/server/shared/workflow-normalizer.js +101 -0
- package/dist/server/shared/workflow-normalizer.test.js +100 -0
- package/dist/server/src/components/onboarding/tour-steps.js +84 -0
- package/package.json +20 -15
- package/schemas/orbital.config.schema.json +16 -1
- package/scripts/postinstall.js +55 -7
- package/server/__tests__/data-routes.test.ts +149 -0
- package/server/__tests__/helpers/db.ts +19 -0
- package/server/__tests__/helpers/mock-emitter.ts +10 -0
- package/server/__tests__/scope-routes.test.ts +157 -0
- package/server/__tests__/sprint-routes.test.ts +118 -0
- package/server/__tests__/workflow-routes.test.ts +120 -0
- package/server/config-migrator.ts +163 -0
- package/server/config.ts +26 -2
- package/server/database.ts +35 -18
- package/server/global-config.ts +200 -0
- package/server/index.ts +975 -287
- package/server/init.ts +625 -182
- package/server/launch.ts +32 -0
- package/server/manifest-types.ts +145 -0
- package/server/manifest.ts +494 -0
- package/server/migrate-legacy.ts +290 -0
- package/server/parsers/event-parser.test.ts +135 -0
- package/server/parsers/scope-parser.test.ts +270 -0
- package/server/parsers/scope-parser.ts +79 -31
- package/server/project-context.ts +309 -0
- package/server/project-emitter.ts +50 -0
- package/server/project-manager.ts +369 -0
- package/server/routes/config-routes.ts +3 -5
- package/server/routes/data-routes.ts +28 -141
- package/server/routes/dispatch-routes.ts +19 -11
- package/server/routes/git-routes.ts +77 -0
- package/server/routes/manifest-routes.ts +388 -0
- package/server/routes/scope-routes.ts +29 -25
- package/server/routes/sync-routes.ts +175 -0
- package/server/routes/version-routes.ts +1 -16
- package/server/routes/workflow-routes.ts +9 -3
- package/server/schema.ts +2 -0
- package/server/services/batch-orchestrator.ts +24 -16
- package/server/services/claude-session-service.ts +16 -14
- package/server/services/deploy-service.test.ts +145 -0
- package/server/services/deploy-service.ts +2 -2
- package/server/services/event-service.test.ts +242 -0
- package/server/services/event-service.ts +92 -3
- package/server/services/gate-service.test.ts +131 -0
- package/server/services/gate-service.ts +2 -2
- package/server/services/git-service.ts +137 -4
- package/server/services/github-service.ts +120 -2
- package/server/services/readiness-service.test.ts +217 -0
- package/server/services/scope-cache.test.ts +167 -0
- package/server/services/scope-cache.ts +4 -1
- package/server/services/scope-service.test.ts +169 -0
- package/server/services/scope-service.ts +220 -126
- package/server/services/sprint-orchestrator.ts +7 -7
- package/server/services/sprint-service.test.ts +271 -0
- package/server/services/sprint-service.ts +27 -3
- package/server/services/sync-service.ts +482 -0
- package/server/services/sync-types.ts +77 -0
- package/server/services/telemetry-service.ts +195 -0
- package/server/services/workflow-service.test.ts +190 -0
- package/server/services/workflow-service.ts +29 -9
- package/server/settings-sync.ts +359 -0
- package/server/update-planner.ts +346 -0
- package/server/utils/cc-hooks-parser.test.ts +96 -0
- package/server/utils/cc-hooks-parser.ts +4 -0
- package/server/utils/dispatch-utils.test.ts +245 -0
- package/server/utils/dispatch-utils.ts +97 -27
- package/server/utils/logger.ts +40 -3
- package/server/utils/package-info.ts +32 -0
- package/server/utils/route-helpers.ts +12 -0
- package/server/utils/terminal-launcher.ts +85 -25
- package/server/utils/worktree-manager.ts +9 -4
- package/server/validator.ts +270 -0
- package/server/watchers/global-watcher.ts +77 -0
- package/server/watchers/scope-watcher.ts +21 -9
- package/server/wizard/config-editor.ts +248 -0
- package/server/wizard/detect.ts +104 -0
- package/server/wizard/doctor.ts +114 -0
- package/server/wizard/index.ts +187 -0
- package/server/wizard/phases/confirm.ts +45 -0
- package/server/wizard/phases/project-setup.ts +106 -0
- package/server/wizard/phases/setup-wizard.ts +78 -0
- package/server/wizard/phases/welcome.ts +43 -0
- package/server/wizard/phases/workflow-setup.ts +28 -0
- package/server/wizard/types.ts +56 -0
- package/server/wizard/ui.ts +93 -0
- package/shared/__fixtures__/workflow-configs.ts +80 -0
- package/shared/default-workflow.json +65 -0
- package/shared/onboarding-tour.test.ts +94 -0
- package/shared/project-colors.ts +24 -0
- package/shared/workflow-config.test.ts +111 -0
- package/shared/workflow-config.ts +7 -0
- package/shared/workflow-engine.test.ts +388 -0
- package/shared/workflow-normalizer.test.ts +119 -0
- package/shared/workflow-normalizer.ts +118 -0
- package/templates/hooks/end-session.sh +3 -1
- package/templates/hooks/orbital-emit.sh +2 -2
- package/templates/hooks/orbital-report-deploy.sh +4 -4
- package/templates/hooks/orbital-report-gates.sh +4 -4
- package/templates/hooks/orbital-scope-update.sh +1 -1
- package/templates/hooks/scope-create-cleanup.sh +2 -2
- package/templates/hooks/scope-create-gate.sh +0 -1
- package/templates/hooks/scope-helpers.sh +18 -0
- package/templates/hooks/scope-prepare.sh +66 -11
- package/templates/migrations/renames.json +1 -0
- package/templates/orbital.config.json +7 -2
- package/templates/settings-hooks.json +1 -1
- package/templates/skills/git-commit/SKILL.md +9 -4
- package/templates/skills/git-dev/SKILL.md +8 -3
- package/templates/skills/git-main/SKILL.md +8 -2
- package/templates/skills/git-production/SKILL.md +6 -2
- package/templates/skills/git-staging/SKILL.md +8 -3
- package/templates/skills/scope-create/SKILL.md +17 -3
- package/templates/skills/scope-fix-review/SKILL.md +6 -3
- package/templates/skills/scope-implement/SKILL.md +4 -1
- package/templates/skills/scope-post-review/SKILL.md +63 -5
- package/templates/skills/scope-pre-review/SKILL.md +5 -2
- package/templates/skills/scope-verify/SKILL.md +5 -3
- package/templates/skills/test-code-review/SKILL.md +41 -33
- package/templates/skills/test-scaffold/SKILL.md +222 -0
- package/dist/assets/WorkflowVisualizer-BZ21PIIF.js +0 -84
- package/dist/assets/charts-D__PA1zp.js +0 -72
- package/dist/assets/index-D1G6i0nS.css +0 -1
- package/dist/assets/index-DpItvKpf.js +0 -419
- package/dist/assets/ui-BvF022GT.js +0 -53
- package/index.html +0 -15
- package/postcss.config.js +0 -6
- package/src/App.tsx +0 -33
- package/src/components/AgentBadge.tsx +0 -40
- package/src/components/BatchPreflightModal.tsx +0 -115
- package/src/components/CardDisplayToggle.tsx +0 -74
- package/src/components/ColumnHeaderActions.tsx +0 -55
- package/src/components/ColumnMenu.tsx +0 -99
- package/src/components/DeployHistory.tsx +0 -141
- package/src/components/DispatchModal.tsx +0 -164
- package/src/components/DispatchPopover.tsx +0 -139
- package/src/components/DragOverlay.tsx +0 -25
- package/src/components/DriftSidebar.tsx +0 -140
- package/src/components/EnvironmentStrip.tsx +0 -88
- package/src/components/ErrorBoundary.tsx +0 -62
- package/src/components/FilterChip.tsx +0 -105
- package/src/components/GateIndicator.tsx +0 -33
- package/src/components/IdeaDetailModal.tsx +0 -190
- package/src/components/IdeaFormDialog.tsx +0 -113
- package/src/components/KanbanColumn.tsx +0 -201
- package/src/components/MarkdownRenderer.tsx +0 -114
- package/src/components/NeonGrid.tsx +0 -128
- package/src/components/PromotionQueue.tsx +0 -89
- package/src/components/ScopeCard.tsx +0 -234
- package/src/components/ScopeDetailModal.tsx +0 -255
- package/src/components/ScopeFilterBar.tsx +0 -152
- package/src/components/SearchInput.tsx +0 -102
- package/src/components/SessionPanel.tsx +0 -335
- package/src/components/SprintContainer.tsx +0 -303
- package/src/components/SprintDependencyDialog.tsx +0 -78
- package/src/components/SprintPreflightModal.tsx +0 -138
- package/src/components/StatusBar.tsx +0 -168
- package/src/components/SwimCell.tsx +0 -67
- package/src/components/SwimLaneRow.tsx +0 -94
- package/src/components/SwimlaneBoardView.tsx +0 -108
- package/src/components/VersionBadge.tsx +0 -139
- package/src/components/ViewModeSelector.tsx +0 -114
- package/src/components/config/AgentChip.tsx +0 -53
- package/src/components/config/AgentCreateDialog.tsx +0 -321
- package/src/components/config/AgentEditor.tsx +0 -175
- package/src/components/config/DirectoryTree.tsx +0 -582
- package/src/components/config/FileEditor.tsx +0 -550
- package/src/components/config/HookChip.tsx +0 -50
- package/src/components/config/StageCard.tsx +0 -198
- package/src/components/config/TransitionZone.tsx +0 -173
- package/src/components/config/UnifiedWorkflowPipeline.tsx +0 -216
- package/src/components/config/WorkflowPipeline.tsx +0 -161
- package/src/components/source-control/BranchList.tsx +0 -93
- package/src/components/source-control/BranchPanel.tsx +0 -105
- package/src/components/source-control/CommitLog.tsx +0 -100
- package/src/components/source-control/CommitRow.tsx +0 -47
- package/src/components/source-control/GitHubPanel.tsx +0 -110
- package/src/components/source-control/GitHubSetupGuide.tsx +0 -52
- package/src/components/source-control/GitOverviewBar.tsx +0 -101
- package/src/components/source-control/PullRequestList.tsx +0 -69
- package/src/components/source-control/WorktreeList.tsx +0 -80
- package/src/components/ui/badge.tsx +0 -41
- package/src/components/ui/button.tsx +0 -55
- package/src/components/ui/card.tsx +0 -78
- package/src/components/ui/dialog.tsx +0 -94
- package/src/components/ui/popover.tsx +0 -33
- package/src/components/ui/scroll-area.tsx +0 -54
- package/src/components/ui/separator.tsx +0 -28
- package/src/components/ui/tabs.tsx +0 -52
- package/src/components/ui/toggle-switch.tsx +0 -35
- package/src/components/ui/tooltip.tsx +0 -27
- package/src/components/workflow/AddEdgeDialog.tsx +0 -217
- package/src/components/workflow/AddListDialog.tsx +0 -201
- package/src/components/workflow/ChecklistEditor.tsx +0 -239
- package/src/components/workflow/CommandPrefixManager.tsx +0 -118
- package/src/components/workflow/ConfigSettingsPanel.tsx +0 -189
- package/src/components/workflow/DirectionSelector.tsx +0 -133
- package/src/components/workflow/DispatchConfigPanel.tsx +0 -180
- package/src/components/workflow/EdgeDetailPanel.tsx +0 -236
- package/src/components/workflow/EdgePropertyEditor.tsx +0 -251
- package/src/components/workflow/EditToolbar.tsx +0 -138
- package/src/components/workflow/HookDetailPanel.tsx +0 -250
- package/src/components/workflow/HookExecutionLog.tsx +0 -24
- package/src/components/workflow/HookSourceModal.tsx +0 -129
- package/src/components/workflow/HooksDashboard.tsx +0 -363
- package/src/components/workflow/ListPropertyEditor.tsx +0 -251
- package/src/components/workflow/MigrationPreviewDialog.tsx +0 -237
- package/src/components/workflow/MovementRulesPanel.tsx +0 -188
- package/src/components/workflow/NodeDetailPanel.tsx +0 -245
- package/src/components/workflow/PresetSelector.tsx +0 -414
- package/src/components/workflow/SkillCommandBuilder.tsx +0 -174
- package/src/components/workflow/WorkflowEdgeComponent.tsx +0 -145
- package/src/components/workflow/WorkflowNode.tsx +0 -147
- package/src/components/workflow/graphLayout.ts +0 -186
- package/src/components/workflow/mergeHooks.ts +0 -85
- package/src/components/workflow/useEditHistory.ts +0 -88
- package/src/components/workflow/useWorkflowEditor.ts +0 -262
- package/src/components/workflow/validateConfig.ts +0 -70
- package/src/hooks/useActiveDispatches.ts +0 -198
- package/src/hooks/useBoardSettings.ts +0 -170
- package/src/hooks/useCardDisplay.ts +0 -57
- package/src/hooks/useCcHooks.ts +0 -24
- package/src/hooks/useConfigTree.ts +0 -51
- package/src/hooks/useEnforcementRules.ts +0 -46
- package/src/hooks/useEvents.ts +0 -59
- package/src/hooks/useFileEditor.ts +0 -165
- package/src/hooks/useGates.ts +0 -57
- package/src/hooks/useIdeaActions.ts +0 -53
- package/src/hooks/useKanbanDnd.ts +0 -410
- package/src/hooks/useOrbitalConfig.ts +0 -54
- package/src/hooks/usePipeline.ts +0 -47
- package/src/hooks/usePipelineData.ts +0 -338
- package/src/hooks/useReconnect.ts +0 -25
- package/src/hooks/useScopeFilters.ts +0 -125
- package/src/hooks/useScopeSessions.ts +0 -44
- package/src/hooks/useScopes.ts +0 -67
- package/src/hooks/useSearch.ts +0 -67
- package/src/hooks/useSettings.tsx +0 -187
- package/src/hooks/useSocket.ts +0 -25
- package/src/hooks/useSourceControl.ts +0 -105
- package/src/hooks/useSprintPreflight.ts +0 -55
- package/src/hooks/useSprints.ts +0 -154
- package/src/hooks/useStatusBarHighlight.ts +0 -18
- package/src/hooks/useSwimlaneBoardSettings.ts +0 -104
- package/src/hooks/useTheme.ts +0 -9
- package/src/hooks/useTransitionReadiness.ts +0 -53
- package/src/hooks/useVersion.ts +0 -155
- package/src/hooks/useViolations.ts +0 -65
- package/src/hooks/useWorkflow.tsx +0 -125
- package/src/hooks/useZoomModifier.ts +0 -19
- package/src/index.css +0 -797
- package/src/layouts/DashboardLayout.tsx +0 -113
- package/src/lib/collisionDetection.ts +0 -20
- package/src/lib/scope-fields.ts +0 -61
- package/src/lib/swimlane.ts +0 -146
- package/src/lib/utils.ts +0 -15
- package/src/main.tsx +0 -19
- package/src/socket.ts +0 -11
- package/src/types/index.ts +0 -497
- package/src/views/AgentFeed.tsx +0 -339
- package/src/views/DeployPipeline.tsx +0 -59
- package/src/views/EnforcementView.tsx +0 -378
- package/src/views/PrimitivesConfig.tsx +0 -500
- package/src/views/QualityGates.tsx +0 -1012
- package/src/views/ScopeBoard.tsx +0 -454
- package/src/views/SessionTimeline.tsx +0 -516
- package/src/views/Settings.tsx +0 -183
- package/src/views/SourceControl.tsx +0 -95
- package/src/views/WorkflowVisualizer.tsx +0 -382
- package/tailwind.config.js +0 -161
- package/tsconfig.json +0 -25
- package/vite.config.ts +0 -49
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
|
-
import {
|
|
4
|
+
import { parseAllScopes, parseScopeFile, setValidStatuses } from '../parsers/scope-parser.js';
|
|
5
5
|
import { createLogger } from '../utils/logger.js';
|
|
6
6
|
const log = createLogger('scope');
|
|
7
7
|
export class ScopeService {
|
|
@@ -38,6 +38,44 @@ export class ScopeService {
|
|
|
38
38
|
this.cache.loadAll(scopes);
|
|
39
39
|
return scopes.length;
|
|
40
40
|
}
|
|
41
|
+
/** Reconcile files whose directory doesn't match their frontmatter status.
|
|
42
|
+
* Frontmatter is the authoritative source — files are moved to match it.
|
|
43
|
+
* Called once at startup after syncFromFilesystem(). */
|
|
44
|
+
reconcileDirectories() {
|
|
45
|
+
let moved = 0;
|
|
46
|
+
for (const scope of this.cache.getAll()) {
|
|
47
|
+
if (scope.id < 0)
|
|
48
|
+
continue; // slug-only icebox items (negative IDs)
|
|
49
|
+
const currentDir = path.basename(path.dirname(scope.file_path));
|
|
50
|
+
if (currentDir === scope.status)
|
|
51
|
+
continue;
|
|
52
|
+
if (!this.engine.isValidStatus(scope.status))
|
|
53
|
+
continue;
|
|
54
|
+
const targetDir = path.join(this.scopesDir, scope.status);
|
|
55
|
+
if (!fs.existsSync(targetDir))
|
|
56
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
57
|
+
const newPath = path.join(targetDir, path.basename(scope.file_path));
|
|
58
|
+
this.suppressedPaths.add(scope.file_path);
|
|
59
|
+
this.suppressedPaths.add(newPath);
|
|
60
|
+
try {
|
|
61
|
+
fs.renameSync(scope.file_path, newPath);
|
|
62
|
+
this.updateFromFile(newPath);
|
|
63
|
+
this.removeByFilePath(scope.file_path);
|
|
64
|
+
moved++;
|
|
65
|
+
log.warn('Reconciled directory mismatch', {
|
|
66
|
+
id: scope.id, frontmatter: scope.status, directory: currentDir,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
log.error('Failed to reconcile scope directory', { id: scope.id, error: String(err) });
|
|
71
|
+
}
|
|
72
|
+
setTimeout(() => {
|
|
73
|
+
this.suppressedPaths.delete(scope.file_path);
|
|
74
|
+
this.suppressedPaths.delete(newPath);
|
|
75
|
+
}, 2000);
|
|
76
|
+
}
|
|
77
|
+
return moved;
|
|
78
|
+
}
|
|
41
79
|
/** Check if a path is suppressed from watcher processing (during programmatic moves) */
|
|
42
80
|
isSuppressed(filePath) {
|
|
43
81
|
return this.suppressedPaths.has(filePath);
|
|
@@ -89,6 +127,7 @@ export class ScopeService {
|
|
|
89
127
|
}
|
|
90
128
|
/** Update a scope's status with transition validation.
|
|
91
129
|
* Writes the new status to the frontmatter file and updates the cache.
|
|
130
|
+
* This is the SINGLE validation point — all status changes must flow through here.
|
|
92
131
|
* @param context - caller trust level: 'patch', 'dispatch', 'event', 'bulk-sync', 'rollback' */
|
|
93
132
|
updateStatus(id, status, context = 'patch') {
|
|
94
133
|
if (!this.engine.isValidStatus(status)) {
|
|
@@ -111,12 +150,11 @@ export class ScopeService {
|
|
|
111
150
|
if (!check.ok)
|
|
112
151
|
return check;
|
|
113
152
|
}
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
: this.cache.getById(id); // already fetched above for validation, but may be null in bulk-sync
|
|
153
|
+
// Fetch current scope for fromStatus logging. In bulk-sync/rollback contexts
|
|
154
|
+
// the validation block above is skipped, so this may be the first lookup.
|
|
155
|
+
const current = this.cache.getById(id);
|
|
118
156
|
const fromStatus = current?.status ?? 'unknown';
|
|
119
|
-
const result = this.
|
|
157
|
+
const result = this._writeFrontmatter(id, { status });
|
|
120
158
|
if (result.ok) {
|
|
121
159
|
log.info('Status updated', { id, from: fromStatus, to: status, context });
|
|
122
160
|
for (const cb of this.onStatusChangeCallbacks)
|
|
@@ -124,8 +162,31 @@ export class ScopeService {
|
|
|
124
162
|
}
|
|
125
163
|
return result;
|
|
126
164
|
}
|
|
165
|
+
/** Update scope fields via a public API (e.g. PATCH route).
|
|
166
|
+
* Status changes are routed through updateStatus for validation.
|
|
167
|
+
* Non-status fields are written directly via _writeFrontmatter. */
|
|
168
|
+
updateFields(id, fields) {
|
|
169
|
+
const { status, ...nonStatusFields } = fields;
|
|
170
|
+
// Status changes go through updateStatus (validates transition, fires callbacks)
|
|
171
|
+
if (status) {
|
|
172
|
+
const current = this.cache.getById(id);
|
|
173
|
+
if (!current)
|
|
174
|
+
return { ok: false, error: 'Scope not found', code: 'NOT_FOUND' };
|
|
175
|
+
if (status !== current.status) {
|
|
176
|
+
const result = this.updateStatus(id, status);
|
|
177
|
+
if (!result.ok)
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Non-status field updates written directly
|
|
182
|
+
if (Object.keys(nonStatusFields).length > 0) {
|
|
183
|
+
return this._writeFrontmatter(id, nonStatusFields);
|
|
184
|
+
}
|
|
185
|
+
return { ok: true, moved: !!status };
|
|
186
|
+
}
|
|
127
187
|
/** 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.
|
|
188
|
+
* Checks both filesystem (all subdirs except icebox) and cache to prevent collisions.
|
|
189
|
+
* Skips IDs >= 500 to handle legacy icebox-origin files during migration. */
|
|
129
190
|
getNextScopeId() {
|
|
130
191
|
let maxId = 0;
|
|
131
192
|
// Scan all scope subdirectories except icebox
|
|
@@ -136,8 +197,13 @@ export class ScopeService {
|
|
|
136
197
|
const dirPath = path.join(this.scopesDir, dir.name);
|
|
137
198
|
for (const file of fs.readdirSync(dirPath)) {
|
|
138
199
|
const m = file.match(/^(\d+)-/);
|
|
139
|
-
if (m)
|
|
140
|
-
|
|
200
|
+
if (!m)
|
|
201
|
+
continue;
|
|
202
|
+
const id = parseInt(m[1], 10);
|
|
203
|
+
// Skip legacy icebox-origin IDs (500+) to prevent namespace pollution
|
|
204
|
+
if (id >= 500)
|
|
205
|
+
continue;
|
|
206
|
+
maxId = Math.max(maxId, id);
|
|
141
207
|
}
|
|
142
208
|
}
|
|
143
209
|
}
|
|
@@ -147,50 +213,61 @@ export class ScopeService {
|
|
|
147
213
|
return maxId + 1;
|
|
148
214
|
}
|
|
149
215
|
// ─── Idea CRUD (filesystem-backed icebox cards) ────────────
|
|
150
|
-
/**
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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));
|
|
216
|
+
/** Normalize Date objects in gray-matter frontmatter to YYYY-MM-DD strings */
|
|
217
|
+
normalizeFrontmatterDates(data) {
|
|
218
|
+
for (const key of Object.keys(data)) {
|
|
219
|
+
if (data[key] instanceof Date) {
|
|
220
|
+
data[key] = data[key].toISOString().split('T')[0];
|
|
221
|
+
}
|
|
160
222
|
}
|
|
161
|
-
return maxId + 1;
|
|
162
223
|
}
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
224
|
+
/** Generate a slug from a title */
|
|
225
|
+
slugify(title) {
|
|
226
|
+
const slug = title
|
|
227
|
+
.toLowerCase()
|
|
228
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
229
|
+
.replace(/^-|-$/g, '')
|
|
230
|
+
.slice(0, 60);
|
|
231
|
+
if (!slug)
|
|
232
|
+
return 'untitled';
|
|
233
|
+
return slug;
|
|
234
|
+
}
|
|
235
|
+
/** Find an icebox file by its slug.
|
|
236
|
+
* Matches slug-only files ({slug}.md) and legacy numeric-prefixed files ({NNN}-{slug}.md). */
|
|
237
|
+
findIdeaFile(iceboxDir, slug) {
|
|
167
238
|
if (!fs.existsSync(iceboxDir))
|
|
168
239
|
return null;
|
|
169
240
|
const match = fs.readdirSync(iceboxDir).find((f) => {
|
|
170
241
|
if (!f.endsWith('.md'))
|
|
171
242
|
return false;
|
|
172
|
-
|
|
173
|
-
|
|
243
|
+
// Match slug-only: {slug}.md
|
|
244
|
+
if (f === `${slug}.md`)
|
|
245
|
+
return true;
|
|
246
|
+
// Match legacy numeric-prefixed: {NNN}-{slug}.md
|
|
247
|
+
return f.match(/^\d+-/) && f.slice(f.indexOf('-') + 1) === `${slug}.md`;
|
|
174
248
|
});
|
|
175
249
|
return match ? path.join(iceboxDir, match) : null;
|
|
176
250
|
}
|
|
177
|
-
/** Create an icebox idea as a markdown file.
|
|
251
|
+
/** Create an icebox idea as a slug-only markdown file. */
|
|
178
252
|
createIdeaFile(title, description) {
|
|
179
253
|
const iceboxDir = path.join(this.scopesDir, 'icebox');
|
|
180
254
|
if (!fs.existsSync(iceboxDir))
|
|
181
255
|
fs.mkdirSync(iceboxDir, { recursive: true });
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
256
|
+
const slug = this.slugify(title);
|
|
257
|
+
let fileName = `${slug}.md`;
|
|
258
|
+
let filePath = path.join(iceboxDir, fileName);
|
|
259
|
+
// Handle slug collisions by appending -2, -3, etc.
|
|
260
|
+
if (fs.existsSync(filePath)) {
|
|
261
|
+
let suffix = 2;
|
|
262
|
+
while (fs.existsSync(path.join(iceboxDir, `${slug}-${suffix}.md`)))
|
|
263
|
+
suffix++;
|
|
264
|
+
fileName = `${slug}-${suffix}.md`;
|
|
265
|
+
filePath = path.join(iceboxDir, fileName);
|
|
266
|
+
}
|
|
267
|
+
const finalSlug = fileName.replace(/\.md$/, '');
|
|
190
268
|
const now = new Date().toISOString().split('T')[0];
|
|
191
269
|
const content = [
|
|
192
270
|
'---',
|
|
193
|
-
`id: ${nextId}`,
|
|
194
271
|
`title: "${title.replace(/"/g, '\\"')}"`,
|
|
195
272
|
'status: icebox',
|
|
196
273
|
`created: ${now}`,
|
|
@@ -206,105 +283,124 @@ export class ScopeService {
|
|
|
206
283
|
fs.writeFileSync(filePath, content, 'utf-8');
|
|
207
284
|
// Eagerly sync to cache + emit scope:created
|
|
208
285
|
this.updateFromFile(filePath);
|
|
209
|
-
log.info('Idea created', {
|
|
210
|
-
return {
|
|
286
|
+
log.info('Idea created', { slug: finalSlug, title });
|
|
287
|
+
return { slug: finalSlug, title };
|
|
211
288
|
}
|
|
212
|
-
/** Update an icebox idea's title and description
|
|
213
|
-
updateIdeaFile(
|
|
289
|
+
/** Update an icebox idea's title and description in-place. Renames the file if the title slug changes. */
|
|
290
|
+
updateIdeaFile(slug, title, description) {
|
|
214
291
|
const iceboxDir = path.join(this.scopesDir, 'icebox');
|
|
215
|
-
const filePath = this.findIdeaFile(iceboxDir,
|
|
292
|
+
const filePath = this.findIdeaFile(iceboxDir, slug);
|
|
216
293
|
if (!filePath)
|
|
217
294
|
return false;
|
|
218
295
|
// Preserve the original created date from existing frontmatter
|
|
219
296
|
const existing = fs.readFileSync(filePath, 'utf-8');
|
|
220
|
-
const
|
|
221
|
-
const created =
|
|
297
|
+
const parsed = matter(existing);
|
|
298
|
+
const created = parsed.data.created ? String(parsed.data.created) : new Date().toISOString().split('T')[0];
|
|
222
299
|
const now = new Date().toISOString().split('T')[0];
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
300
|
+
// Update frontmatter fields while preserving other data (like ghost)
|
|
301
|
+
parsed.data.title = title;
|
|
302
|
+
parsed.data.updated = now;
|
|
303
|
+
parsed.data.created = created;
|
|
304
|
+
this.normalizeFrontmatterDates(parsed.data);
|
|
305
|
+
const newContent = matter.stringify(description ? `\n${description}\n` : '\n', parsed.data);
|
|
306
|
+
fs.writeFileSync(filePath, newContent, 'utf-8');
|
|
307
|
+
// If title changed, rename file to new slug
|
|
308
|
+
const newSlug = this.slugify(title);
|
|
309
|
+
if (newSlug !== slug) {
|
|
310
|
+
const newFileName = `${newSlug}.md`;
|
|
311
|
+
const newPath = path.join(iceboxDir, newFileName);
|
|
312
|
+
if (!fs.existsSync(newPath)) {
|
|
313
|
+
this.suppressedPaths.add(filePath);
|
|
314
|
+
this.suppressedPaths.add(newPath);
|
|
315
|
+
this.removeByFilePath(filePath);
|
|
316
|
+
fs.renameSync(filePath, newPath);
|
|
317
|
+
this.updateFromFile(newPath);
|
|
318
|
+
setTimeout(() => {
|
|
319
|
+
this.suppressedPaths.delete(filePath);
|
|
320
|
+
this.suppressedPaths.delete(newPath);
|
|
321
|
+
}, 2000);
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
// Collision with existing slug — keep old filename, still sync content changes
|
|
325
|
+
log.warn('Slug collision during rename, keeping old filename', { slug, newSlug });
|
|
326
|
+
this.updateFromFile(filePath);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
// Eagerly sync content changes to cache
|
|
331
|
+
this.updateFromFile(filePath);
|
|
332
|
+
}
|
|
240
333
|
return true;
|
|
241
334
|
}
|
|
242
335
|
/** Delete an icebox idea by removing its file */
|
|
243
|
-
deleteIdeaFile(
|
|
336
|
+
deleteIdeaFile(slug) {
|
|
244
337
|
const iceboxDir = path.join(this.scopesDir, 'icebox');
|
|
245
|
-
const filePath = this.findIdeaFile(iceboxDir,
|
|
338
|
+
const filePath = this.findIdeaFile(iceboxDir, slug);
|
|
246
339
|
if (!filePath)
|
|
247
340
|
return false;
|
|
248
341
|
fs.unlinkSync(filePath);
|
|
249
342
|
// Eagerly remove from cache + emit scope:deleted
|
|
250
343
|
this.removeByFilePath(filePath);
|
|
251
|
-
log.info('Idea deleted', {
|
|
344
|
+
log.info('Idea deleted', { slug });
|
|
252
345
|
return true;
|
|
253
346
|
}
|
|
254
347
|
/** Promote an icebox idea to planning — assigns a proper sequential scope ID,
|
|
255
348
|
* moves the file, and syncs cache. Returns the new scope ID. */
|
|
256
|
-
promoteIdea(
|
|
349
|
+
promoteIdea(slug) {
|
|
257
350
|
const iceboxDir = path.join(this.scopesDir, 'icebox');
|
|
258
|
-
const oldPath = this.findIdeaFile(iceboxDir,
|
|
351
|
+
const oldPath = this.findIdeaFile(iceboxDir, slug);
|
|
259
352
|
if (!oldPath)
|
|
260
353
|
return null;
|
|
261
354
|
// Read existing file for metadata
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
// Extract body after frontmatter
|
|
268
|
-
const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
|
|
269
|
-
const description = fmEnd !== -1 ? content.slice(fmEnd + 3).trim() : '';
|
|
355
|
+
const raw = fs.readFileSync(oldPath, 'utf-8');
|
|
356
|
+
const parsed = matter(raw);
|
|
357
|
+
const title = parsed.data.title ? String(parsed.data.title) : 'Untitled';
|
|
358
|
+
const created = parsed.data.created ? String(parsed.data.created) : new Date().toISOString().split('T')[0];
|
|
359
|
+
const description = parsed.content.trim();
|
|
270
360
|
// Assign the next sequential scope ID (excludes icebox items)
|
|
271
361
|
const newId = this.getNextScopeId();
|
|
272
362
|
const paddedId = String(newId).padStart(3, '0');
|
|
273
|
-
// Build
|
|
274
|
-
const
|
|
275
|
-
.toLowerCase()
|
|
276
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
277
|
-
.replace(/^-|-$/g, '')
|
|
278
|
-
.slice(0, 60);
|
|
363
|
+
// Build new path
|
|
364
|
+
const titleSlug = this.slugify(title);
|
|
279
365
|
const planningDir = path.join(this.scopesDir, 'planning');
|
|
280
366
|
if (!fs.existsSync(planningDir))
|
|
281
367
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
282
|
-
const newFileName = `${paddedId}-${
|
|
368
|
+
const newFileName = `${paddedId}-${titleSlug}.md`;
|
|
283
369
|
const newPath = path.join(planningDir, newFileName);
|
|
284
370
|
const now = new Date().toISOString().split('T')[0];
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
371
|
+
// Update frontmatter in-place: assign ID and change status (preserve other fields)
|
|
372
|
+
parsed.data.id = newId;
|
|
373
|
+
parsed.data.status = 'planning';
|
|
374
|
+
parsed.data.updated = now;
|
|
375
|
+
parsed.data.created = created;
|
|
376
|
+
delete parsed.data.ghost;
|
|
377
|
+
this.normalizeFrontmatterDates(parsed.data);
|
|
378
|
+
const newContent = matter.stringify(parsed.content, parsed.data);
|
|
379
|
+
// Write updated content to old path, then rename/move (no intermediate missing state)
|
|
380
|
+
const originalContent = fs.readFileSync(oldPath, 'utf-8');
|
|
381
|
+
fs.writeFileSync(oldPath, newContent, 'utf-8');
|
|
382
|
+
// Suppress watcher events during programmatic move
|
|
383
|
+
this.suppressedPaths.add(oldPath);
|
|
384
|
+
this.suppressedPaths.add(newPath);
|
|
385
|
+
try {
|
|
386
|
+
fs.renameSync(oldPath, newPath);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
// Restore original content on rename failure
|
|
390
|
+
fs.writeFileSync(oldPath, originalContent, 'utf-8');
|
|
391
|
+
this.suppressedPaths.delete(oldPath);
|
|
392
|
+
this.suppressedPaths.delete(newPath);
|
|
393
|
+
log.error('Failed to rename during promote', { oldPath, newPath, error: String(err) });
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
303
396
|
this.updateFromFile(newPath);
|
|
304
|
-
fs.unlinkSync(oldPath);
|
|
305
397
|
this.removeByFilePath(oldPath);
|
|
398
|
+
setTimeout(() => {
|
|
399
|
+
this.suppressedPaths.delete(oldPath);
|
|
400
|
+
this.suppressedPaths.delete(newPath);
|
|
401
|
+
}, 2000);
|
|
306
402
|
const relPath = path.relative(path.resolve(this.scopesDir, '..'), newPath);
|
|
307
|
-
log.info('Idea promoted', {
|
|
403
|
+
log.info('Idea promoted', { slug, newId, title });
|
|
308
404
|
return { id: newId, filePath: relPath, title, description };
|
|
309
405
|
}
|
|
310
406
|
/** Find a scope file by its numeric ID prefix across all status directories */
|
|
@@ -325,10 +421,11 @@ export class ScopeService {
|
|
|
325
421
|
}
|
|
326
422
|
return null;
|
|
327
423
|
}
|
|
328
|
-
/**
|
|
329
|
-
* If status
|
|
330
|
-
*
|
|
331
|
-
|
|
424
|
+
/** Write frontmatter fields to a scope's .md file.
|
|
425
|
+
* If the effective status differs from the current directory, moves the file.
|
|
426
|
+
* This is a trusted write operation — callers must validate transitions
|
|
427
|
+
* via updateStatus() before calling this method with status changes. */
|
|
428
|
+
_writeFrontmatter(id, fields) {
|
|
332
429
|
const filePath = this.findScopeFile(id);
|
|
333
430
|
if (!filePath) {
|
|
334
431
|
return { ok: false, error: 'Scope file not found', code: 'NOT_FOUND' };
|
|
@@ -336,25 +433,19 @@ export class ScopeService {
|
|
|
336
433
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
337
434
|
const parsed = matter(raw);
|
|
338
435
|
const today = new Date().toISOString().split('T')[0];
|
|
339
|
-
//
|
|
436
|
+
// Determine if the file needs to move to a different directory.
|
|
437
|
+
// Compare against the DIRECTORY name (not frontmatter status) since the question
|
|
438
|
+
// is whether the physical file location matches the desired status.
|
|
340
439
|
const newStatus = fields.status;
|
|
341
|
-
const
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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;
|
|
440
|
+
const dirName = path.basename(path.dirname(filePath));
|
|
441
|
+
const effectiveStatus = newStatus ?? String(parsed.data.status ?? dirName);
|
|
442
|
+
const needsMove = effectiveStatus !== dirName && this.engine.isValidStatus(effectiveStatus);
|
|
443
|
+
// Auto-unlock spec when reverting backlog → planning
|
|
444
|
+
if (newStatus === 'planning' && dirName === 'backlog') {
|
|
445
|
+
fields = { ...fields, spec_locked: false };
|
|
355
446
|
}
|
|
356
447
|
// Merge editable fields into frontmatter
|
|
357
|
-
const editableKeys = ['title', 'status', 'priority', 'effort_estimate', 'category', 'tags', 'blocked_by', 'blocks', 'spec_locked'];
|
|
448
|
+
const editableKeys = ['title', 'status', 'priority', 'effort_estimate', 'category', 'tags', 'blocked_by', 'blocks', 'spec_locked', 'favourite'];
|
|
358
449
|
for (const key of editableKeys) {
|
|
359
450
|
if (key in fields) {
|
|
360
451
|
const val = fields[key];
|
|
@@ -384,8 +475,8 @@ export class ScopeService {
|
|
|
384
475
|
log.info('Frontmatter updated', { id, fields: Object.keys(fields) });
|
|
385
476
|
return { ok: true };
|
|
386
477
|
}
|
|
387
|
-
// Status
|
|
388
|
-
const targetDir = path.join(this.scopesDir,
|
|
478
|
+
// Status differs from directory → move file to correct directory
|
|
479
|
+
const targetDir = path.join(this.scopesDir, effectiveStatus);
|
|
389
480
|
if (!fs.existsSync(targetDir))
|
|
390
481
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
391
482
|
const fileName = path.basename(filePath);
|
|
@@ -403,13 +494,13 @@ export class ScopeService {
|
|
|
403
494
|
setTimeout(() => {
|
|
404
495
|
this.suppressedPaths.delete(filePath);
|
|
405
496
|
this.suppressedPaths.delete(newPath);
|
|
406
|
-
},
|
|
497
|
+
}, 2000);
|
|
407
498
|
return { ok: true, moved: true };
|
|
408
499
|
}
|
|
409
500
|
/** Approve a ghost idea — removes ghost:true from frontmatter and refreshes cache */
|
|
410
|
-
approveGhostIdea(
|
|
501
|
+
approveGhostIdea(slug) {
|
|
411
502
|
const iceboxDir = path.join(this.scopesDir, 'icebox');
|
|
412
|
-
const filePath = this.findIdeaFile(iceboxDir,
|
|
503
|
+
const filePath = this.findIdeaFile(iceboxDir, slug);
|
|
413
504
|
if (!filePath)
|
|
414
505
|
return false;
|
|
415
506
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
@@ -418,7 +509,7 @@ export class ScopeService {
|
|
|
418
509
|
fs.writeFileSync(filePath, updated, 'utf-8');
|
|
419
510
|
// Re-parse file to refresh cache with is_ghost=false
|
|
420
511
|
this.updateFromFile(filePath);
|
|
421
|
-
log.info('Ghost approved', {
|
|
512
|
+
log.info('Ghost approved', { slug });
|
|
422
513
|
return true;
|
|
423
514
|
}
|
|
424
515
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { ScopeService } from './scope-service.js';
|
|
3
|
+
import { ScopeCache } from './scope-cache.js';
|
|
4
|
+
import { WorkflowEngine } from '../../shared/workflow-engine.js';
|
|
5
|
+
import { CONFIG_WITH_HOOKS } from '../../shared/__fixtures__/workflow-configs.js';
|
|
6
|
+
import { createMockEmitter } from '../__tests__/helpers/mock-emitter.js';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
describe('ScopeService', () => {
|
|
11
|
+
let tmpDir;
|
|
12
|
+
let cache;
|
|
13
|
+
let emitter;
|
|
14
|
+
let engine;
|
|
15
|
+
let service;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scope-svc-test-'));
|
|
18
|
+
cache = new ScopeCache();
|
|
19
|
+
emitter = createMockEmitter();
|
|
20
|
+
engine = new WorkflowEngine(CONFIG_WITH_HOOKS);
|
|
21
|
+
service = new ScopeService(cache, emitter, tmpDir, engine);
|
|
22
|
+
// Create status directories
|
|
23
|
+
for (const status of ['icebox', 'backlog', 'active', 'review', 'shipped']) {
|
|
24
|
+
fs.mkdirSync(path.join(tmpDir, status), { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
function writeScopeFile(status, filename, frontmatter, body = '') {
|
|
31
|
+
const filePath = path.join(tmpDir, status, filename);
|
|
32
|
+
const yamlLines = Object.entries(frontmatter).map(([k, v]) => {
|
|
33
|
+
if (Array.isArray(v))
|
|
34
|
+
return `${k}: [${v.join(', ')}]`;
|
|
35
|
+
return `${k}: ${v}`;
|
|
36
|
+
}).join('\n');
|
|
37
|
+
fs.writeFileSync(filePath, `---\n${yamlLines}\n---\n${body}\n`);
|
|
38
|
+
return filePath;
|
|
39
|
+
}
|
|
40
|
+
// ─── syncFromFilesystem() ─────────────────────────────────
|
|
41
|
+
describe('syncFromFilesystem()', () => {
|
|
42
|
+
it('loads all .md files into cache', () => {
|
|
43
|
+
writeScopeFile('backlog', '001-first.md', { title: 'First', status: 'backlog' });
|
|
44
|
+
writeScopeFile('active', '002-second.md', { title: 'Second', status: 'active' });
|
|
45
|
+
const count = service.syncFromFilesystem();
|
|
46
|
+
expect(count).toBe(2);
|
|
47
|
+
expect(cache.size).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
it('returns 0 for empty directories', () => {
|
|
50
|
+
expect(service.syncFromFilesystem()).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
// ─── getAll() / getById() ─────────────────────────────────
|
|
54
|
+
describe('getAll() / getById()', () => {
|
|
55
|
+
it('delegates to cache', () => {
|
|
56
|
+
writeScopeFile('backlog', '001-test.md', { title: 'Test', status: 'backlog' });
|
|
57
|
+
service.syncFromFilesystem();
|
|
58
|
+
expect(service.getAll()).toHaveLength(1);
|
|
59
|
+
expect(service.getById(1)?.title).toBe('Test');
|
|
60
|
+
expect(service.getById(999)).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
// ─── updateStatus() ──────────────────────────────────────
|
|
64
|
+
describe('updateStatus()', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
writeScopeFile('backlog', '001-test.md', { title: 'Test', status: 'backlog' });
|
|
67
|
+
service.syncFromFilesystem();
|
|
68
|
+
});
|
|
69
|
+
it('validates transition via engine', () => {
|
|
70
|
+
// backlog → shipped is not a valid edge in CONFIG_WITH_HOOKS
|
|
71
|
+
const result = service.updateStatus(1, 'shipped', 'patch');
|
|
72
|
+
expect(result.ok).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
it('bulk-sync bypasses validation', () => {
|
|
75
|
+
const result = service.updateStatus(1, 'shipped', 'bulk-sync');
|
|
76
|
+
expect(result.ok).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it('returns NOT_FOUND for unknown scope', () => {
|
|
79
|
+
const result = service.updateStatus(999, 'active', 'dispatch');
|
|
80
|
+
expect(result.ok).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
it('fires onStatusChange callbacks on successful transition', () => {
|
|
83
|
+
const callback = vi.fn();
|
|
84
|
+
service.onStatusChange(callback);
|
|
85
|
+
// Use bulk-sync to bypass edge validation — focuses on the callback mechanism
|
|
86
|
+
const result = service.updateStatus(1, 'active', 'bulk-sync');
|
|
87
|
+
if (result.ok) {
|
|
88
|
+
expect(callback).toHaveBeenCalledWith(1, 'active');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// ─── createIdeaFile() ─────────────────────────────────────
|
|
93
|
+
describe('createIdeaFile()', () => {
|
|
94
|
+
it('creates file in icebox directory', () => {
|
|
95
|
+
const result = service.createIdeaFile('My New Idea', 'A description of the idea');
|
|
96
|
+
expect(result.slug).toBeDefined();
|
|
97
|
+
expect(result.title).toBe('My New Idea');
|
|
98
|
+
// Verify file exists
|
|
99
|
+
const files = fs.readdirSync(path.join(tmpDir, 'icebox'));
|
|
100
|
+
expect(files.length).toBe(1);
|
|
101
|
+
expect(files[0]).toMatch(/\.md$/);
|
|
102
|
+
});
|
|
103
|
+
it('slugifies the title', () => {
|
|
104
|
+
const result = service.createIdeaFile('Some Feature Idea!', '');
|
|
105
|
+
expect(result.slug).toMatch(/^[a-z0-9-]+$/);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
// ─── deleteIdeaFile() ─────────────────────────────────────
|
|
109
|
+
describe('deleteIdeaFile()', () => {
|
|
110
|
+
it('removes the idea file', () => {
|
|
111
|
+
const { slug } = service.createIdeaFile('To Delete', '');
|
|
112
|
+
const result = service.deleteIdeaFile(slug);
|
|
113
|
+
expect(result).toBe(true);
|
|
114
|
+
const files = fs.readdirSync(path.join(tmpDir, 'icebox'));
|
|
115
|
+
expect(files).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
it('returns false for non-existent slug', () => {
|
|
118
|
+
expect(service.deleteIdeaFile('nonexistent-slug')).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
// ─── reconcileDirectories() ───────────────────────────────
|
|
122
|
+
describe('reconcileDirectories()', () => {
|
|
123
|
+
it('moves files to correct directory based on frontmatter', () => {
|
|
124
|
+
// Put a file in backlog but with status: active in frontmatter
|
|
125
|
+
writeScopeFile('backlog', '001-misplaced.md', { title: 'Misplaced', status: 'active' });
|
|
126
|
+
service.syncFromFilesystem();
|
|
127
|
+
const moved = service.reconcileDirectories();
|
|
128
|
+
expect(moved).toBeGreaterThanOrEqual(0);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
// ─── isSuppressed() ──────────────────────────────────────
|
|
132
|
+
describe('isSuppressed()', () => {
|
|
133
|
+
it('returns false by default', () => {
|
|
134
|
+
expect(service.isSuppressed('/some/path.md')).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|