orbital-command 0.3.0 → 1.0.1

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 (160) hide show
  1. package/README.md +67 -42
  2. package/bin/commands/config.js +19 -0
  3. package/bin/commands/events.js +40 -0
  4. package/bin/commands/launch.js +126 -0
  5. package/bin/commands/manifest.js +283 -0
  6. package/bin/commands/registry.js +104 -0
  7. package/bin/commands/update.js +24 -0
  8. package/bin/lib/helpers.js +229 -0
  9. package/bin/orbital.js +90 -873
  10. package/dist/assets/Landing-CfQdHR0N.js +11 -0
  11. package/dist/assets/PrimitivesConfig-DThSipFy.js +32 -0
  12. package/dist/assets/QualityGates-B4kxM5UU.js +26 -0
  13. package/dist/assets/SessionTimeline-Bz1iZnmg.js +1 -0
  14. package/dist/assets/Settings-DLcZwbCT.js +12 -0
  15. package/dist/assets/SourceControl-BMNIz7Lt.js +36 -0
  16. package/dist/assets/WorkflowVisualizer-CxuSBOYu.js +69 -0
  17. package/dist/assets/{arrow-down-CPy85_J6.js → arrow-down-DVPp6_qp.js} +1 -1
  18. package/dist/assets/bot-NFaJBDn_.js +6 -0
  19. package/dist/assets/{charts-DbDg0Psc.js → charts-LGLb8hyU.js} +1 -1
  20. package/dist/assets/{circle-x-Cwz6ZQDV.js → circle-x-IsFCkBZu.js} +1 -1
  21. package/dist/assets/{file-text-C46Xr65c.js → file-text-J1cebZXF.js} +1 -1
  22. package/dist/assets/{globe-Cn2yNZUD.js → globe-WzeyHsUc.js} +1 -1
  23. package/dist/assets/index-BdJ57EhC.css +1 -0
  24. package/dist/assets/index-o4ScMAuR.js +349 -0
  25. package/dist/assets/{key-OPaNTWJ5.js → key-CKR8JJSj.js} +1 -1
  26. package/dist/assets/{minus-GMsbpKym.js → minus-CHBsJyjp.js} +1 -1
  27. package/dist/assets/radio-xqZaR-Uk.js +6 -0
  28. package/dist/assets/rocket-D_xvvNG6.js +6 -0
  29. package/dist/assets/{shield-DwAFkDYI.js → shield-TdB1yv_a.js} +1 -1
  30. package/dist/assets/useSocketListener-0L5yiN5i.js +1 -0
  31. package/dist/assets/useWorkflowEditor-CqeRWVQX.js +11 -0
  32. package/dist/assets/workflow-constants-Rw-GmgHZ.js +6 -0
  33. package/dist/assets/zap-C9wqYMpl.js +6 -0
  34. package/dist/index.html +3 -3
  35. package/dist/server/server/__tests__/data-routes.test.js +2 -0
  36. package/dist/server/server/__tests__/scope-routes.test.js +1 -0
  37. package/dist/server/server/config-migrator.js +0 -3
  38. package/dist/server/server/config.js +35 -6
  39. package/dist/server/server/database.js +0 -22
  40. package/dist/server/server/index.js +28 -816
  41. package/dist/server/server/init.js +32 -399
  42. package/dist/server/server/launch.js +1 -1
  43. package/dist/server/server/parsers/event-parser.js +4 -1
  44. package/dist/server/server/project-context.js +19 -9
  45. package/dist/server/server/project-manager.js +6 -6
  46. package/dist/server/server/routes/aggregate-routes.js +871 -0
  47. package/dist/server/server/routes/config-routes.js +41 -88
  48. package/dist/server/server/routes/data-routes.js +5 -15
  49. package/dist/server/server/routes/dispatch-routes.js +24 -8
  50. package/dist/server/server/routes/manifest-routes.js +1 -1
  51. package/dist/server/server/routes/scope-routes.js +10 -7
  52. package/dist/server/server/schema.js +1 -0
  53. package/dist/server/server/services/batch-orchestrator.js +17 -3
  54. package/dist/server/server/services/config-service.js +10 -1
  55. package/dist/server/server/services/scope-service.js +7 -7
  56. package/dist/server/server/services/sprint-orchestrator.js +24 -11
  57. package/dist/server/server/services/sprint-service.js +2 -2
  58. package/dist/server/server/uninstall.js +195 -0
  59. package/dist/server/server/update.js +212 -0
  60. package/dist/server/server/utils/dispatch-utils.js +8 -6
  61. package/dist/server/server/utils/flag-builder.js +54 -0
  62. package/dist/server/server/utils/json-fields.js +14 -0
  63. package/dist/server/server/utils/json-fields.test.js +73 -0
  64. package/dist/server/server/utils/route-helpers.js +37 -0
  65. package/dist/server/server/utils/route-helpers.test.js +115 -0
  66. package/dist/server/server/watchers/event-watcher.js +28 -13
  67. package/dist/server/server/wizard/config-editor.js +4 -4
  68. package/dist/server/server/wizard/doctor.js +2 -2
  69. package/dist/server/server/wizard/index.js +224 -39
  70. package/dist/server/server/wizard/phases/welcome.js +1 -4
  71. package/dist/server/server/wizard/ui.js +6 -7
  72. package/dist/server/shared/api-types.js +80 -1
  73. package/dist/server/shared/workflow-engine.js +1 -1
  74. package/package.json +20 -20
  75. package/schemas/orbital.config.schema.json +1 -19
  76. package/scripts/postinstall.js +6 -42
  77. package/scripts/release.sh +53 -0
  78. package/server/__tests__/data-routes.test.ts +2 -0
  79. package/server/__tests__/scope-routes.test.ts +1 -0
  80. package/server/config-migrator.ts +0 -3
  81. package/server/config.ts +39 -11
  82. package/server/database.ts +0 -26
  83. package/server/global-config.ts +4 -0
  84. package/server/index.ts +31 -896
  85. package/server/init.ts +32 -443
  86. package/server/launch.ts +1 -1
  87. package/server/parsers/event-parser.ts +4 -1
  88. package/server/project-context.ts +26 -10
  89. package/server/project-manager.ts +5 -6
  90. package/server/routes/aggregate-routes.ts +968 -0
  91. package/server/routes/config-routes.ts +41 -81
  92. package/server/routes/data-routes.ts +7 -16
  93. package/server/routes/dispatch-routes.ts +29 -8
  94. package/server/routes/manifest-routes.ts +1 -1
  95. package/server/routes/scope-routes.ts +12 -7
  96. package/server/schema.ts +1 -0
  97. package/server/services/batch-orchestrator.ts +18 -2
  98. package/server/services/config-service.ts +10 -1
  99. package/server/services/scope-service.ts +6 -6
  100. package/server/services/sprint-orchestrator.ts +24 -9
  101. package/server/services/sprint-service.ts +2 -2
  102. package/server/uninstall.ts +214 -0
  103. package/server/update.ts +263 -0
  104. package/server/utils/dispatch-utils.ts +8 -6
  105. package/server/utils/flag-builder.ts +56 -0
  106. package/server/utils/json-fields.test.ts +83 -0
  107. package/server/utils/json-fields.ts +14 -0
  108. package/server/utils/route-helpers.test.ts +144 -0
  109. package/server/utils/route-helpers.ts +38 -0
  110. package/server/watchers/event-watcher.ts +24 -12
  111. package/server/wizard/config-editor.ts +4 -4
  112. package/server/wizard/doctor.ts +2 -2
  113. package/server/wizard/index.ts +291 -40
  114. package/server/wizard/phases/welcome.ts +1 -5
  115. package/server/wizard/ui.ts +6 -7
  116. package/shared/api-types.ts +106 -0
  117. package/shared/workflow-engine.ts +1 -1
  118. package/templates/agents/QUICK-REFERENCE.md +1 -0
  119. package/templates/agents/README.md +1 -0
  120. package/templates/agents/SKILL-TRIGGERS.md +11 -0
  121. package/templates/agents/green-team/deep-dive.md +361 -0
  122. package/templates/hooks/end-session.sh +1 -0
  123. package/templates/hooks/init-session.sh +1 -0
  124. package/templates/hooks/scope-commit-logger.sh +2 -2
  125. package/templates/hooks/scope-create-gate.sh +2 -4
  126. package/templates/hooks/scope-gate.sh +4 -6
  127. package/templates/hooks/scope-helpers.sh +10 -1
  128. package/templates/hooks/scope-lifecycle-gate.sh +14 -5
  129. package/templates/hooks/scope-prepare.sh +1 -1
  130. package/templates/hooks/scope-transition.sh +14 -6
  131. package/templates/hooks/time-tracker.sh +2 -5
  132. package/templates/orbital.config.json +1 -4
  133. package/templates/presets/development.json +4 -4
  134. package/templates/presets/gitflow.json +7 -0
  135. package/templates/prompts/README.md +23 -0
  136. package/templates/prompts/deep-dive-audit.md +94 -0
  137. package/templates/quick/rules.md +56 -5
  138. package/templates/skills/git-commit/SKILL.md +21 -6
  139. package/templates/skills/git-dev/SKILL.md +8 -4
  140. package/templates/skills/git-main/SKILL.md +8 -4
  141. package/templates/skills/git-production/SKILL.md +6 -3
  142. package/templates/skills/git-staging/SKILL.md +6 -3
  143. package/templates/skills/scope-fix-review/SKILL.md +8 -4
  144. package/templates/skills/scope-implement/SKILL.md +13 -5
  145. package/templates/skills/scope-post-review/SKILL.md +16 -4
  146. package/templates/skills/scope-pre-review/SKILL.md +6 -2
  147. package/dist/assets/PrimitivesConfig-CrmQXYh4.js +0 -32
  148. package/dist/assets/QualityGates-BbasOsF3.js +0 -21
  149. package/dist/assets/SessionTimeline-CGeJsVvy.js +0 -1
  150. package/dist/assets/Settings-oiM496mc.js +0 -12
  151. package/dist/assets/SourceControl-B1fP2nJL.js +0 -41
  152. package/dist/assets/WorkflowVisualizer-CWLYf-f0.js +0 -74
  153. package/dist/assets/formatDistanceToNow-BMqsSP44.js +0 -1
  154. package/dist/assets/index-Aj4sV8Al.css +0 -1
  155. package/dist/assets/index-Bc9dK3MW.js +0 -354
  156. package/dist/assets/useWorkflowEditor-BJkTX_NR.js +0 -16
  157. package/dist/assets/zap-DfbUoOty.js +0 -11
  158. package/dist/server/server/services/telemetry-service.js +0 -143
  159. package/server/services/telemetry-service.ts +0 -195
  160. /package/{shared/default-workflow.json → templates/presets/default.json} +0 -0
@@ -3,7 +3,7 @@ import type { Emitter } from '../project-emitter.js';
3
3
  import { ConfigService, isValidPrimitiveType } from '../services/config-service.js';
4
4
  import type { ConfigPrimitiveType } from '../services/config-service.js';
5
5
  import type { WorkflowService } from '../services/workflow-service.js';
6
- import { errMsg } from '../utils/route-helpers.js';
6
+ import { catchRoute, inferErrorStatus } from '../utils/route-helpers.js';
7
7
 
8
8
  interface ConfigRouteDeps {
9
9
  projectRoot: string;
@@ -25,21 +25,17 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
25
25
  }
26
26
 
27
27
  // GET /config/:type/tree — directory tree with frontmatter
28
- router.get('/config/:type/tree', (req, res) => {
28
+ router.get('/config/:type/tree', catchRoute((req, res) => {
29
29
  const type = parseType(req.params.type, res);
30
30
  if (!type) return;
31
31
 
32
- try {
33
- const basePath = configService.getBasePath(type);
34
- const tree = configService.scanDirectory(basePath);
35
- res.json({ success: true, data: tree });
36
- } catch (err) {
37
- res.status(500).json({ success: false, error: errMsg(err) });
38
- }
39
- });
32
+ const basePath = configService.getBasePath(type);
33
+ const tree = configService.scanDirectory(basePath);
34
+ res.json({ success: true, data: tree });
35
+ }));
40
36
 
41
37
  // GET /config/:type/file?path=<relative> — file content
42
- router.get('/config/:type/file', (req, res) => {
38
+ router.get('/config/:type/file', catchRoute((req, res) => {
43
39
  const type = parseType(req.params.type, res);
44
40
  if (!type) return;
45
41
 
@@ -49,19 +45,13 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
49
45
  return;
50
46
  }
51
47
 
52
- try {
53
- const basePath = configService.getBasePath(type);
54
- const content = configService.readFile(basePath, filePath);
55
- res.json({ success: true, data: { path: filePath, content } });
56
- } catch (err) {
57
- const msg = errMsg(err);
58
- const status = msg.includes('traversal') ? 403 : msg.includes('ENOENT') || msg.includes('not found') ? 404 : 500;
59
- res.status(status).json({ success: false, error: msg });
60
- }
61
- });
48
+ const basePath = configService.getBasePath(type);
49
+ const content = configService.readFile(basePath, filePath);
50
+ res.json({ success: true, data: { path: filePath, content } });
51
+ }, inferErrorStatus));
62
52
 
63
53
  // PUT /config/:type/file — save file { path, content }
64
- router.put('/config/:type/file', (req, res) => {
54
+ router.put('/config/:type/file', catchRoute((req, res) => {
65
55
  const type = parseType(req.params.type, res);
66
56
  if (!type) return;
67
57
 
@@ -71,20 +61,14 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
71
61
  return;
72
62
  }
73
63
 
74
- try {
75
- const basePath = configService.getBasePath(type);
76
- configService.writeFile(basePath, filePath, content);
77
- io.emit(`config:${type}:changed`, { action: 'updated', path: filePath });
78
- res.json({ success: true });
79
- } catch (err) {
80
- const msg = errMsg(err);
81
- const status = msg.includes('traversal') ? 403 : msg.includes('not found') ? 404 : 500;
82
- res.status(status).json({ success: false, error: msg });
83
- }
84
- });
64
+ const basePath = configService.getBasePath(type);
65
+ configService.writeFile(basePath, filePath, content);
66
+ io.emit(`config:${type}:changed`, { action: 'updated', path: filePath });
67
+ res.json({ success: true });
68
+ }, inferErrorStatus));
85
69
 
86
70
  // POST /config/:type/file — create file { path, content }
87
- router.post('/config/:type/file', (req, res) => {
71
+ router.post('/config/:type/file', catchRoute((req, res) => {
88
72
  const type = parseType(req.params.type, res);
89
73
  if (!type) return;
90
74
 
@@ -94,20 +78,14 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
94
78
  return;
95
79
  }
96
80
 
97
- try {
98
- const basePath = configService.getBasePath(type);
99
- configService.createFile(basePath, filePath, content);
100
- io.emit(`config:${type}:changed`, { action: 'created', path: filePath });
101
- res.status(201).json({ success: true });
102
- } catch (err) {
103
- const msg = errMsg(err);
104
- const status = msg.includes('traversal') ? 403 : msg.includes('already exists') ? 409 : 500;
105
- res.status(status).json({ success: false, error: msg });
106
- }
107
- });
81
+ const basePath = configService.getBasePath(type);
82
+ configService.createFile(basePath, filePath, content);
83
+ io.emit(`config:${type}:changed`, { action: 'created', path: filePath });
84
+ res.status(201).json({ success: true });
85
+ }, inferErrorStatus));
108
86
 
109
87
  // DELETE /config/:type/file?path=<relative> — delete file
110
- router.delete('/config/:type/file', (req, res) => {
88
+ router.delete('/config/:type/file', catchRoute((req, res) => {
111
89
  const type = parseType(req.params.type, res);
112
90
  if (!type) return;
113
91
 
@@ -117,20 +95,14 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
117
95
  return;
118
96
  }
119
97
 
120
- try {
121
- const basePath = configService.getBasePath(type);
122
- configService.deleteFile(basePath, filePath);
123
- io.emit(`config:${type}:changed`, { action: 'deleted', path: filePath });
124
- res.json({ success: true });
125
- } catch (err) {
126
- const msg = errMsg(err);
127
- const status = msg.includes('traversal') ? 403 : msg.includes('not found') ? 404 : msg.includes('directory') ? 400 : 500;
128
- res.status(status).json({ success: false, error: msg });
129
- }
130
- });
98
+ const basePath = configService.getBasePath(type);
99
+ configService.deleteFile(basePath, filePath);
100
+ io.emit(`config:${type}:changed`, { action: 'deleted', path: filePath });
101
+ res.json({ success: true });
102
+ }, inferErrorStatus));
131
103
 
132
104
  // POST /config/:type/rename — rename { oldPath, newPath }
133
- router.post('/config/:type/rename', (req, res) => {
105
+ router.post('/config/:type/rename', catchRoute((req, res) => {
134
106
  const type = parseType(req.params.type, res);
135
107
  if (!type) return;
136
108
 
@@ -140,20 +112,14 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
140
112
  return;
141
113
  }
142
114
 
143
- try {
144
- const basePath = configService.getBasePath(type);
145
- configService.renameFile(basePath, oldPath, newPath);
146
- io.emit(`config:${type}:changed`, { action: 'renamed', oldPath, newPath });
147
- res.json({ success: true });
148
- } catch (err) {
149
- const msg = errMsg(err);
150
- const status = msg.includes('traversal') ? 403 : msg.includes('not found') ? 404 : msg.includes('already exists') ? 409 : 500;
151
- res.status(status).json({ success: false, error: msg });
152
- }
153
- });
115
+ const basePath = configService.getBasePath(type);
116
+ configService.renameFile(basePath, oldPath, newPath);
117
+ io.emit(`config:${type}:changed`, { action: 'renamed', oldPath, newPath });
118
+ res.json({ success: true });
119
+ }, inferErrorStatus));
154
120
 
155
121
  // POST /config/:type/folder — create folder { path }
156
- router.post('/config/:type/folder', (req, res) => {
122
+ router.post('/config/:type/folder', catchRoute((req, res) => {
157
123
  const type = parseType(req.params.type, res);
158
124
  if (!type) return;
159
125
 
@@ -163,17 +129,11 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
163
129
  return;
164
130
  }
165
131
 
166
- try {
167
- const basePath = configService.getBasePath(type);
168
- configService.createFolder(basePath, folderPath);
169
- io.emit(`config:${type}:changed`, { action: 'folder-created', path: folderPath });
170
- res.status(201).json({ success: true });
171
- } catch (err) {
172
- const msg = errMsg(err);
173
- const status = msg.includes('traversal') ? 403 : msg.includes('already exists') ? 409 : 500;
174
- res.status(status).json({ success: false, error: msg });
175
- }
176
- });
132
+ const basePath = configService.getBasePath(type);
133
+ configService.createFolder(basePath, folderPath);
134
+ io.emit(`config:${type}:changed`, { action: 'folder-created', path: folderPath });
135
+ res.status(201).json({ success: true });
136
+ }, inferErrorStatus));
177
137
 
178
138
  return router;
179
139
  }
@@ -12,26 +12,15 @@ import type { WorkflowEngine } from '../../shared/workflow-engine.js';
12
12
  import { getHookEnforcement } from '../../shared/workflow-config.js';
13
13
  import { getClaudeSessions, getSessionStats, type SessionStats } from '../services/claude-session-service.js';
14
14
  import { launchInTerminal } from '../utils/terminal-launcher.js';
15
+ import type { OrbitalConfig } from '../config.js';
16
+ import { buildClaudeFlags } from '../utils/flag-builder.js';
15
17
  import { createLogger } from '../utils/logger.js';
18
+ import { parseJsonFields, type Row } from '../utils/json-fields.js';
16
19
 
17
20
  const log = createLogger('server');
18
21
 
19
22
  const execFileAsync = promisify(execFile);
20
23
 
21
- const JSON_FIELDS = ['tags', 'blocked_by', 'blocks', 'data', 'discoveries', 'next_steps', 'details'];
22
-
23
- type Row = Record<string, unknown>;
24
-
25
- function parseJsonFields(row: Row): Row {
26
- const parsed = { ...row };
27
- for (const field of JSON_FIELDS) {
28
- if (typeof parsed[field] === 'string') {
29
- try { parsed[field] = JSON.parse(parsed[field] as string); } catch { /* keep string */ }
30
- }
31
- }
32
- return parsed;
33
- }
34
-
35
24
  // ─── Route Factory ──────────────────────────────────────────
36
25
 
37
26
  interface DataRouteDeps {
@@ -44,10 +33,11 @@ interface DataRouteDeps {
44
33
  engine: WorkflowEngine;
45
34
  projectRoot: string;
46
35
  inferScopeStatus: (type: string, scopeId: unknown, data: Record<string, unknown>) => void;
36
+ config: OrbitalConfig;
47
37
  }
48
38
 
49
39
  export function createDataRoutes({
50
- db, io, eventService, gateService, deployService, gitService, engine, projectRoot, inferScopeStatus,
40
+ db, io, eventService, gateService, deployService, gitService, engine, projectRoot, inferScopeStatus, config,
51
41
  }: DataRouteDeps): Router {
52
42
  const router = Router();
53
43
 
@@ -353,7 +343,8 @@ export function createDataRoutes({
353
343
  return;
354
344
  }
355
345
 
356
- const resumeCmd = `cd '${projectRoot}' && claude --dangerously-skip-permissions --resume '${claude_session_id}'`;
346
+ const flagsStr = buildClaudeFlags(config.claude.dispatchFlags);
347
+ const resumeCmd = `cd '${projectRoot}' && claude ${flagsStr} --resume '${claude_session_id}'`;
357
348
 
358
349
  try {
359
350
  await launchInTerminal(resumeCmd);
@@ -5,11 +5,13 @@ import type { ScopeService } from '../services/scope-service.js';
5
5
  import { launchInCategorizedTerminal, escapeForAnsiC, shellQuote, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
6
6
  import { resolveDispatchEvent, resolveAbandonedDispatchesForScope, getActiveScopeIds, getAbandonedScopeIds, linkPidToDispatch } from '../utils/dispatch-utils.js';
7
7
  import type { WorkflowEngine } from '../../shared/workflow-engine.js';
8
+ import type { OrbitalConfig } from '../config.js';
9
+ import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
8
10
  import { createLogger } from '../utils/logger.js';
9
11
 
10
12
  const log = createLogger('dispatch');
11
13
 
12
- const MAX_BATCH_SIZE = 20;
14
+ const DEFAULT_MAX_BATCH_SIZE = 20;
13
15
 
14
16
  interface DispatchBody {
15
17
  scope_id?: number;
@@ -24,9 +26,10 @@ interface DispatchRouteDeps {
24
26
  scopeService: ScopeService;
25
27
  projectRoot: string;
26
28
  engine: WorkflowEngine;
29
+ config: OrbitalConfig;
27
30
  }
28
31
 
29
- export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine }: DispatchRouteDeps): Router {
32
+ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine, config }: DispatchRouteDeps): Router {
30
33
  const router = Router();
31
34
 
32
35
  router.get('/dispatch/active-scopes', (_req, res) => {
@@ -79,6 +82,19 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
79
82
  }
80
83
  }
81
84
 
85
+ // Max concurrent dispatches guard
86
+ const maxConcurrent = config.dispatch.maxConcurrent;
87
+ if (maxConcurrent > 0) {
88
+ const activeCount = (db.prepare(
89
+ `SELECT COUNT(*) as count FROM events
90
+ WHERE type = 'DISPATCH' AND JSON_EXTRACT(data, '$.resolved') IS NULL`
91
+ ).get() as { count: number }).count;
92
+ if (activeCount >= maxConcurrent) {
93
+ res.status(429).json({ error: `Max concurrent dispatches reached (${maxConcurrent})` });
94
+ return;
95
+ }
96
+ }
97
+
82
98
  // Update scope status if transition provided
83
99
  if (scope_id != null && transition?.to) {
84
100
  const result = scopeService.updateStatus(scope_id, transition.to, 'dispatch');
@@ -107,10 +123,12 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
107
123
  const sessionName = buildSessionName({ scopeId: scope_id ?? undefined, title: scope?.title, command });
108
124
  const beforePids = snapshotSessionPids(projectRoot);
109
125
 
110
- // Launch in iTerm — interactive TUI mode (no -p) for full visibility
126
+ // Launch in iTerm — interactive TUI mode (no -p unless printMode) for full visibility
111
127
  const promptText = prompt ?? command;
112
128
  const escaped = escapeForAnsiC(promptText);
113
- const fullCmd = `cd '${shellQuote(projectRoot)}' && ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude --dangerously-skip-permissions $'${escaped}'`;
129
+ const flagsStr = buildClaudeFlags(config.claude.dispatchFlags);
130
+ const envPrefix = buildEnvVarPrefix(config.dispatch.envVars);
131
+ const fullCmd = `cd '${shellQuote(projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${flagsStr} $'${escaped}'`;
114
132
  try {
115
133
  await launchInCategorizedTerminal(command, fullCmd, sessionName);
116
134
  res.json({ ok: true, dispatch_id: eventId, scope_id: scope_id ?? null });
@@ -215,8 +233,9 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
215
233
  }
216
234
 
217
235
  // W-12: Validate batch size and scope ID types
218
- if (scope_ids.length > MAX_BATCH_SIZE) {
219
- res.status(400).json({ error: `Maximum batch size is ${MAX_BATCH_SIZE}` });
236
+ const maxBatch = config.dispatch.maxBatchSize || DEFAULT_MAX_BATCH_SIZE;
237
+ if (scope_ids.length > maxBatch) {
238
+ res.status(400).json({ error: `Maximum batch size is ${maxBatch}` });
220
239
  return;
221
240
  }
222
241
  if (!scope_ids.every(id => Number.isInteger(id) && id > 0)) {
@@ -249,10 +268,12 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
249
268
  timestamp: new Date().toISOString(),
250
269
  });
251
270
 
252
- // Launch single CLI session
271
+ // Launch single CLI session with batch env vars
253
272
  const batchEscaped = escapeForAnsiC(command);
254
273
  const beforePids = snapshotSessionPids(projectRoot);
255
- const fullCmd = `cd '${shellQuote(projectRoot)}' && ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude --dangerously-skip-permissions -p $'${batchEscaped}'`;
274
+ const batchFlags = buildClaudeFlags(config.claude.dispatchFlags);
275
+ const envPrefix = buildEnvVarPrefix(config.dispatch.envVars);
276
+ const fullCmd = `cd '${shellQuote(projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${batchFlags} $'${batchEscaped}'`;
256
277
  try {
257
278
  await launchInCategorizedTerminal(command, fullCmd);
258
279
  res.json({ ok: true, dispatch_id: eventId, scope_ids });
@@ -166,7 +166,7 @@ export function createManifestRoutes({
166
166
  }
167
167
 
168
168
  if (!manifest) {
169
- return res.status(400).json({ success: false, error: 'No manifest. Run orbital init first.' });
169
+ return res.status(400).json({ success: false, error: 'No manifest. Run orbital first.' });
170
170
  }
171
171
 
172
172
  if (dryRun) {
@@ -5,8 +5,10 @@ import type { Emitter } from '../project-emitter.js';
5
5
  import type { ScopeService } from '../services/scope-service.js';
6
6
  import type { ReadinessService } from '../services/readiness-service.js';
7
7
  import type { WorkflowEngine } from '../../shared/workflow-engine.js';
8
- import { launchInTerminal, escapeForAnsiC, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
8
+ import { launchInTerminal, escapeForAnsiC, shellQuote, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
9
9
  import { resolveDispatchEvent, linkPidToDispatch } from '../utils/dispatch-utils.js';
10
+ import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
11
+ import type { OrbitalConfig } from '../config.js';
10
12
  import { createLogger } from '../utils/logger.js';
11
13
 
12
14
  const log = createLogger('dispatch');
@@ -19,13 +21,14 @@ interface ScopeRouteDeps {
19
21
  projectRoot: string;
20
22
  projectName: string;
21
23
  engine: WorkflowEngine;
24
+ config: OrbitalConfig;
22
25
  }
23
26
 
24
27
  function isValidSlug(slug: string): boolean {
25
28
  return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug) && slug.length <= 80;
26
29
  }
27
30
 
28
- export function createScopeRoutes({ db, io, scopeService, readinessService, projectRoot, projectName, engine }: ScopeRouteDeps): Router {
31
+ export function createScopeRoutes({ db, io, scopeService, readinessService, projectRoot, projectName, engine, config }: ScopeRouteDeps): Router {
29
32
  const router = Router();
30
33
 
31
34
  // ─── Scope CRUD ──────────────────────────────────────────
@@ -123,7 +126,10 @@ export function createScopeRoutes({ db, io, scopeService, readinessService, proj
123
126
  router.post('/ideas/:slug/promote', async (req, res) => {
124
127
  const { slug } = req.params;
125
128
  if (!isValidSlug(slug)) { res.status(400).json({ error: 'Invalid slug' }); return; }
126
- const result = scopeService.promoteIdea(slug);
129
+ const entryPoint = engine.getEntryPoint();
130
+ const targets = engine.getValidTargets(entryPoint.id);
131
+ const promoteTarget = targets[0] ?? 'planning';
132
+ const result = scopeService.promoteIdea(slug, promoteTarget);
127
133
  if (!result) {
128
134
  res.status(404).json({ error: 'Idea not found' });
129
135
  return;
@@ -132,9 +138,6 @@ export function createScopeRoutes({ db, io, scopeService, readinessService, proj
132
138
  const scopeId = result.id;
133
139
 
134
140
  // Read command from workflow edge config (user-overridable)
135
- const entryPoint = engine.getEntryPoint();
136
- const targets = engine.getValidTargets(entryPoint.id);
137
- const promoteTarget = targets[0] ?? 'planning';
138
141
  const edge = engine.findEdge(entryPoint.id, promoteTarget);
139
142
  const edgeCommand = edge ? engine.buildCommand(edge, scopeId) : null;
140
143
  const command = edgeCommand ?? `/scope-create ${String(scopeId).padStart(3, '0')}`;
@@ -158,7 +161,9 @@ export function createScopeRoutes({ db, io, scopeService, readinessService, proj
158
161
  });
159
162
 
160
163
  const escaped = escapeForAnsiC(command);
161
- const fullCmd = `cd '${projectRoot}' && claude --dangerously-skip-permissions $'${escaped}'`;
164
+ const flagsStr = buildClaudeFlags(config.claude.dispatchFlags);
165
+ const envPrefix = buildEnvVarPrefix(config.dispatch.envVars);
166
+ const fullCmd = `cd '${shellQuote(projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${flagsStr} $'${escaped}'`;
162
167
 
163
168
  const promoteSessionName = buildSessionName({ scopeId, title: result.title, command });
164
169
  const promoteBeforePids = snapshotSessionPids(projectRoot);
package/server/schema.ts CHANGED
@@ -89,4 +89,5 @@ CREATE INDEX IF NOT EXISTS idx_sessions_scope ON sessions(scope_id);
89
89
  CREATE INDEX IF NOT EXISTS idx_sessions_claude_id ON sessions(claude_session_id);
90
90
  CREATE INDEX IF NOT EXISTS idx_sprints_status ON sprints(status);
91
91
  CREATE INDEX IF NOT EXISTS idx_sprint_scopes_sprint ON sprint_scopes(sprint_id);
92
+ CREATE INDEX IF NOT EXISTS idx_events_dispatch_unresolved ON events(type, scope_id) WHERE type = 'DISPATCH' AND JSON_EXTRACT(data, '$.resolved') IS NULL;
92
93
  `;
@@ -2,9 +2,11 @@ import type Database from 'better-sqlite3';
2
2
  import type { Emitter } from '../project-emitter.js';
3
3
  import type { SprintService } from './sprint-service.js';
4
4
  import type { ScopeService } from './scope-service.js';
5
- import { launchInCategorizedTerminal, escapeForAnsiC, snapshotSessionPids, discoverNewSession, isSessionPidAlive } from '../utils/terminal-launcher.js';
5
+ import { launchInCategorizedTerminal, escapeForAnsiC, shellQuote, snapshotSessionPids, discoverNewSession, isSessionPidAlive } from '../utils/terminal-launcher.js';
6
6
  import { linkPidToDispatch, resolveDispatchEvent } from '../utils/dispatch-utils.js';
7
7
  import type { WorkflowEngine } from '../../shared/workflow-engine.js';
8
+ import type { OrbitalConfig } from '../config.js';
9
+ import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
8
10
  import { createLogger } from '../utils/logger.js';
9
11
 
10
12
  const log = createLogger('batch');
@@ -20,6 +22,7 @@ export class BatchOrchestrator {
20
22
  private scopeService: ScopeService,
21
23
  private engine: WorkflowEngine,
22
24
  private projectRoot: string,
25
+ private config: OrbitalConfig,
23
26
  ) {}
24
27
 
25
28
  /** Dispatch a batch — validates constraints and routes to column-specific handler */
@@ -70,7 +73,9 @@ export class BatchOrchestrator {
70
73
 
71
74
  // Launch single CLI session with BATCH_SCOPE_IDS prepended to command
72
75
  const escaped = escapeForAnsiC(command);
73
- const fullCmd = `cd '${this.projectRoot}' && BATCH_SCOPE_IDS='${scopeIdsStr}' MERGE_MODE='${mergeModeStr}' claude --dangerously-skip-permissions $'${escaped}'`;
76
+ const flagsStr = buildClaudeFlags(this.config.claude.dispatchFlags);
77
+ const envPrefix = buildEnvVarPrefix(this.config.dispatch.envVars);
78
+ const fullCmd = `cd '${shellQuote(this.projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' BATCH_SCOPE_IDS='${scopeIdsStr}' MERGE_MODE='${mergeModeStr}' claude ${flagsStr} $'${escaped}'`;
74
79
  const beforePids = snapshotSessionPids(this.projectRoot);
75
80
 
76
81
  try {
@@ -148,6 +153,17 @@ export class BatchOrchestrator {
148
153
  if (batch.status !== 'dispatched' && batch.status !== 'in_progress') return;
149
154
 
150
155
  const scopes = this.sprintService.getSprintScopes(batchId);
156
+
157
+ // If batch never reached 'in_progress', the session never started —
158
+ // don't credit any scope regardless of their current workflow status
159
+ if (batch.status === 'dispatched') {
160
+ this.sprintService.updateStatus(batchId, 'failed');
161
+ for (const ss of scopes) {
162
+ this.sprintService.updateScopeStatus(batchId, ss.scope_id, 'failed', 'Session never started');
163
+ }
164
+ return;
165
+ }
166
+
151
167
  const allTransitioned = scopes.every((ss) => ss.dispatch_status === 'completed');
152
168
 
153
169
  if (allTransitioned) {
@@ -120,7 +120,16 @@ export class ConfigService {
120
120
  const fullPath = path.join(currentPath, entry.name);
121
121
  const relPath = path.relative(basePath, fullPath);
122
122
 
123
- if (entry.isDirectory()) {
123
+ // Resolve symlinks: Dirent.isDirectory() returns false for symlinks-to-dirs.
124
+ // Self-hosted projects symlink .claude/agents/*, .claude/hooks/*, etc. into templates/.
125
+ let stat: fs.Stats;
126
+ try {
127
+ stat = fs.statSync(fullPath);
128
+ } catch {
129
+ continue; // broken symlink — skip silently
130
+ }
131
+
132
+ if (stat.isDirectory()) {
124
133
  const children = this.walkDir(fullPath, basePath, parseFrontmatter);
125
134
  nodes.push({ name: entry.name, path: relPath, type: 'folder', children });
126
135
  } else {
@@ -120,7 +120,7 @@ export class ScopeService {
120
120
  const id = this.cache.removeByFilePath(filePath);
121
121
  if (id !== undefined) {
122
122
  if (previous) this.recentlyRemoved.set(id, previous.status);
123
- this.io.emit('scope:deleted', id);
123
+ this.io.emit('scope:deleted', { id });
124
124
  // Clean up stash after a short window (if add never fires, this was a real delete)
125
125
  setTimeout(() => this.recentlyRemoved.delete(id), 5000);
126
126
  }
@@ -379,7 +379,7 @@ export class ScopeService {
379
379
 
380
380
  /** Promote an icebox idea to planning — assigns a proper sequential scope ID,
381
381
  * moves the file, and syncs cache. Returns the new scope ID. */
382
- promoteIdea(slug: string): { id: number; filePath: string; title: string; description: string } | null {
382
+ promoteIdea(slug: string, targetStatus = 'planning'): { id: number; filePath: string; title: string; description: string } | null {
383
383
  const iceboxDir = path.join(this.scopesDir, 'icebox');
384
384
  const oldPath = this.findIdeaFile(iceboxDir, slug);
385
385
  if (!oldPath) return null;
@@ -397,15 +397,15 @@ export class ScopeService {
397
397
 
398
398
  // Build new path
399
399
  const titleSlug = this.slugify(title);
400
- const planningDir = path.join(this.scopesDir, 'planning');
401
- if (!fs.existsSync(planningDir)) fs.mkdirSync(planningDir, { recursive: true });
400
+ const targetDir = path.join(this.scopesDir, targetStatus);
401
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
402
402
  const newFileName = `${paddedId}-${titleSlug}.md`;
403
- const newPath = path.join(planningDir, newFileName);
403
+ const newPath = path.join(targetDir, newFileName);
404
404
  const now = new Date().toISOString().split('T')[0];
405
405
 
406
406
  // Update frontmatter in-place: assign ID and change status (preserve other fields)
407
407
  parsed.data.id = newId;
408
- parsed.data.status = 'planning';
408
+ parsed.data.status = targetStatus;
409
409
  parsed.data.updated = now;
410
410
  parsed.data.created = created;
411
411
  delete parsed.data.ghost;
@@ -2,9 +2,11 @@ import type Database from 'better-sqlite3';
2
2
  import type { Emitter } from '../project-emitter.js';
3
3
  import { SprintService } from './sprint-service.js';
4
4
  import { ScopeService } from './scope-service.js';
5
- import { launchInCategorizedTerminal, escapeForAnsiC, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
5
+ import { launchInCategorizedTerminal, escapeForAnsiC, shellQuote, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
6
6
  import { resolveDispatchEvent, linkPidToDispatch } from '../utils/dispatch-utils.js';
7
+ import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
7
8
  import type { WorkflowEngine } from '../../shared/workflow-engine.js';
9
+ import type { OrbitalConfig } from '../config.js';
8
10
  import { createLogger } from '../utils/logger.js';
9
11
 
10
12
  const log = createLogger('sprint');
@@ -24,6 +26,7 @@ export class SprintOrchestrator {
24
26
  private scopeService: ScopeService,
25
27
  private engine: WorkflowEngine,
26
28
  private projectRoot: string,
29
+ private config: OrbitalConfig,
27
30
  ) {}
28
31
 
29
32
  /** Build execution layers using Kahn's topological sort */
@@ -112,14 +115,16 @@ export class SprintOrchestrator {
112
115
  async onScopeReachedDev(scopeId: number): Promise<void> {
113
116
  const match = this.sprintService.findActiveSprintForScope(scopeId);
114
117
  if (!match) return;
115
- log.debug('Scope reached dev', { scopeId, sprintId: match.sprint_id });
116
118
 
119
+ // Batches are managed by BatchOrchestrator — don't dispatch individual scopes
117
120
  const sprintId = match.sprint_id;
121
+ const sprint = this.sprintService.getById(sprintId);
122
+ if (!sprint || sprint.group_type === 'batch') return;
123
+
124
+ log.debug('Scope reached dev', { scopeId, sprintId });
118
125
  this.sprintService.updateScopeStatus(sprintId, scopeId, 'completed');
119
126
 
120
127
  // Ensure sprint is in 'in_progress' state
121
- const sprint = this.sprintService.getById(sprintId);
122
- if (!sprint) return;
123
128
  if (sprint.status === 'dispatched') {
124
129
  this.sprintService.updateStatus(sprintId, 'in_progress');
125
130
  }
@@ -196,7 +201,7 @@ export class SprintOrchestrator {
196
201
  const sprint = this.sprintService.getById(sprintId);
197
202
  if (!sprint) return null;
198
203
 
199
- const layers = sprint.layers ?? [];
204
+ const layers = sprint.layers ?? this.buildExecutionLayers(sprint.scope_ids).layers;
200
205
  const sprintSet = new Set(sprint.scope_ids);
201
206
  const edges: Array<{ from: number; to: number }> = [];
202
207
 
@@ -225,9 +230,15 @@ export class SprintOrchestrator {
225
230
  const currentScope = this.scopeService.getById(scopeId);
226
231
  const previousStatus = currentScope?.status ?? 'implementing';
227
232
 
233
+ // Resolve command and target status from workflow engine
234
+ const sprint = this.sprintService.getById(sprintId);
235
+ const targetColumn = sprint?.target_column ?? 'backlog';
236
+ const edgeCommand = this.engine.getBatchCommand(targetColumn);
237
+ const targetStatus = this.engine.getBatchTargetStatus(targetColumn);
238
+
228
239
  // Record DISPATCH event
229
240
  const eventId = crypto.randomUUID();
230
- const command = `/scope implement ${scopeId}`;
241
+ const command = edgeCommand ?? `/scope-implement ${scopeId}`;
231
242
  this.db.prepare(
232
243
  `INSERT INTO events (id, type, scope_id, session_id, agent, data, timestamp)
233
244
  VALUES (?, 'DISPATCH', ?, NULL, 'sprint-orchestrator', ?, ?)`,
@@ -241,7 +252,9 @@ export class SprintOrchestrator {
241
252
  });
242
253
 
243
254
  // Update scope + sprint_scope status
244
- this.scopeService.updateStatus(scopeId, 'implementing', 'dispatch');
255
+ if (targetStatus) {
256
+ this.scopeService.updateStatus(scopeId, targetStatus, 'dispatch');
257
+ }
245
258
  this.sprintService.updateScopeStatus(sprintId, scopeId, 'dispatched');
246
259
 
247
260
  // Build scope-aware session name and snapshot PIDs
@@ -249,9 +262,11 @@ export class SprintOrchestrator {
249
262
  const sessionName = buildSessionName({ scopeId, title: scopeRow?.title, command });
250
263
  const beforePids = snapshotSessionPids(this.projectRoot);
251
264
 
252
- // Launch in iTerm — interactive TUI mode (no -p) for full visibility
265
+ // Launch in iTerm — interactive TUI mode for full visibility
253
266
  const escaped = escapeForAnsiC(command);
254
- const fullCmd = `cd '${this.projectRoot}' && claude --dangerously-skip-permissions $'${escaped}'`;
267
+ const flagsStr = buildClaudeFlags(this.config.claude.dispatchFlags);
268
+ const envPrefix = buildEnvVarPrefix(this.config.dispatch.envVars);
269
+ const fullCmd = `cd '${shellQuote(this.projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${flagsStr} $'${escaped}'`;
255
270
  try {
256
271
  await launchInCategorizedTerminal(command, fullCmd, sessionName);
257
272
 
@@ -157,10 +157,10 @@ export class SprintService {
157
157
  return this.buildDetail(row);
158
158
  }
159
159
 
160
- /** Delete a sprint (only if assembling) */
160
+ /** Delete a sprint/batch (assembling, failed, or cancelled) */
161
161
  delete(id: number): boolean {
162
162
  const row = this.db.prepare('SELECT status FROM sprints WHERE id = ?').get(id) as { status: string } | undefined;
163
- if (!row || row.status !== 'assembling') return false;
163
+ if (!row || !['assembling', 'failed', 'cancelled', 'completed'].includes(row.status)) return false;
164
164
 
165
165
  this.db.prepare('DELETE FROM sprint_scopes WHERE sprint_id = ?').run(id);
166
166
  this.db.prepare('DELETE FROM sprints WHERE id = ?').run(id);