orbital-command 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +95 -870
  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 +26 -814
  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 +29 -894
  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
@@ -1,6 +1,6 @@
1
1
  import { Router } from 'express';
2
2
  import { ConfigService, isValidPrimitiveType } from '../services/config-service.js';
3
- import { errMsg } from '../utils/route-helpers.js';
3
+ import { catchRoute, inferErrorStatus } from '../utils/route-helpers.js';
4
4
  export function createConfigRoutes({ projectRoot, workflowService: _workflowService, io }) {
5
5
  const router = Router();
6
6
  const configService = new ConfigService(projectRoot);
@@ -13,21 +13,16 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
13
13
  return typeParam;
14
14
  }
15
15
  // GET /config/:type/tree — directory tree with frontmatter
16
- router.get('/config/:type/tree', (req, res) => {
16
+ router.get('/config/:type/tree', catchRoute((req, res) => {
17
17
  const type = parseType(req.params.type, res);
18
18
  if (!type)
19
19
  return;
20
- try {
21
- const basePath = configService.getBasePath(type);
22
- const tree = configService.scanDirectory(basePath);
23
- res.json({ success: true, data: tree });
24
- }
25
- catch (err) {
26
- res.status(500).json({ success: false, error: errMsg(err) });
27
- }
28
- });
20
+ const basePath = configService.getBasePath(type);
21
+ const tree = configService.scanDirectory(basePath);
22
+ res.json({ success: true, data: tree });
23
+ }));
29
24
  // GET /config/:type/file?path=<relative> — file content
30
- router.get('/config/:type/file', (req, res) => {
25
+ router.get('/config/:type/file', catchRoute((req, res) => {
31
26
  const type = parseType(req.params.type, res);
32
27
  if (!type)
33
28
  return;
@@ -36,19 +31,12 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
36
31
  res.status(400).json({ success: false, error: 'path query parameter is required' });
37
32
  return;
38
33
  }
39
- try {
40
- const basePath = configService.getBasePath(type);
41
- const content = configService.readFile(basePath, filePath);
42
- res.json({ success: true, data: { path: filePath, content } });
43
- }
44
- catch (err) {
45
- const msg = errMsg(err);
46
- const status = msg.includes('traversal') ? 403 : msg.includes('ENOENT') || msg.includes('not found') ? 404 : 500;
47
- res.status(status).json({ success: false, error: msg });
48
- }
49
- });
34
+ const basePath = configService.getBasePath(type);
35
+ const content = configService.readFile(basePath, filePath);
36
+ res.json({ success: true, data: { path: filePath, content } });
37
+ }, inferErrorStatus));
50
38
  // PUT /config/:type/file — save file { path, content }
51
- router.put('/config/:type/file', (req, res) => {
39
+ router.put('/config/:type/file', catchRoute((req, res) => {
52
40
  const type = parseType(req.params.type, res);
53
41
  if (!type)
54
42
  return;
@@ -57,20 +45,13 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
57
45
  res.status(400).json({ success: false, error: 'path and content are required' });
58
46
  return;
59
47
  }
60
- try {
61
- const basePath = configService.getBasePath(type);
62
- configService.writeFile(basePath, filePath, content);
63
- io.emit(`config:${type}:changed`, { action: 'updated', path: filePath });
64
- res.json({ success: true });
65
- }
66
- catch (err) {
67
- const msg = errMsg(err);
68
- const status = msg.includes('traversal') ? 403 : msg.includes('not found') ? 404 : 500;
69
- res.status(status).json({ success: false, error: msg });
70
- }
71
- });
48
+ const basePath = configService.getBasePath(type);
49
+ configService.writeFile(basePath, filePath, content);
50
+ io.emit(`config:${type}:changed`, { action: 'updated', path: filePath });
51
+ res.json({ success: true });
52
+ }, inferErrorStatus));
72
53
  // POST /config/:type/file — create file { path, content }
73
- router.post('/config/:type/file', (req, res) => {
54
+ router.post('/config/:type/file', catchRoute((req, res) => {
74
55
  const type = parseType(req.params.type, res);
75
56
  if (!type)
76
57
  return;
@@ -79,20 +60,13 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
79
60
  res.status(400).json({ success: false, error: 'path and content are required' });
80
61
  return;
81
62
  }
82
- try {
83
- const basePath = configService.getBasePath(type);
84
- configService.createFile(basePath, filePath, content);
85
- io.emit(`config:${type}:changed`, { action: 'created', path: filePath });
86
- res.status(201).json({ success: true });
87
- }
88
- catch (err) {
89
- const msg = errMsg(err);
90
- const status = msg.includes('traversal') ? 403 : msg.includes('already exists') ? 409 : 500;
91
- res.status(status).json({ success: false, error: msg });
92
- }
93
- });
63
+ const basePath = configService.getBasePath(type);
64
+ configService.createFile(basePath, filePath, content);
65
+ io.emit(`config:${type}:changed`, { action: 'created', path: filePath });
66
+ res.status(201).json({ success: true });
67
+ }, inferErrorStatus));
94
68
  // DELETE /config/:type/file?path=<relative> — delete file
95
- router.delete('/config/:type/file', (req, res) => {
69
+ router.delete('/config/:type/file', catchRoute((req, res) => {
96
70
  const type = parseType(req.params.type, res);
97
71
  if (!type)
98
72
  return;
@@ -101,20 +75,13 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
101
75
  res.status(400).json({ success: false, error: 'path query parameter is required' });
102
76
  return;
103
77
  }
104
- try {
105
- const basePath = configService.getBasePath(type);
106
- configService.deleteFile(basePath, filePath);
107
- io.emit(`config:${type}:changed`, { action: 'deleted', path: filePath });
108
- res.json({ success: true });
109
- }
110
- catch (err) {
111
- const msg = errMsg(err);
112
- const status = msg.includes('traversal') ? 403 : msg.includes('not found') ? 404 : msg.includes('directory') ? 400 : 500;
113
- res.status(status).json({ success: false, error: msg });
114
- }
115
- });
78
+ const basePath = configService.getBasePath(type);
79
+ configService.deleteFile(basePath, filePath);
80
+ io.emit(`config:${type}:changed`, { action: 'deleted', path: filePath });
81
+ res.json({ success: true });
82
+ }, inferErrorStatus));
116
83
  // POST /config/:type/rename — rename { oldPath, newPath }
117
- router.post('/config/:type/rename', (req, res) => {
84
+ router.post('/config/:type/rename', catchRoute((req, res) => {
118
85
  const type = parseType(req.params.type, res);
119
86
  if (!type)
120
87
  return;
@@ -123,20 +90,13 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
123
90
  res.status(400).json({ success: false, error: 'oldPath and newPath are required' });
124
91
  return;
125
92
  }
126
- try {
127
- const basePath = configService.getBasePath(type);
128
- configService.renameFile(basePath, oldPath, newPath);
129
- io.emit(`config:${type}:changed`, { action: 'renamed', oldPath, newPath });
130
- res.json({ success: true });
131
- }
132
- catch (err) {
133
- const msg = errMsg(err);
134
- const status = msg.includes('traversal') ? 403 : msg.includes('not found') ? 404 : msg.includes('already exists') ? 409 : 500;
135
- res.status(status).json({ success: false, error: msg });
136
- }
137
- });
93
+ const basePath = configService.getBasePath(type);
94
+ configService.renameFile(basePath, oldPath, newPath);
95
+ io.emit(`config:${type}:changed`, { action: 'renamed', oldPath, newPath });
96
+ res.json({ success: true });
97
+ }, inferErrorStatus));
138
98
  // POST /config/:type/folder — create folder { path }
139
- router.post('/config/:type/folder', (req, res) => {
99
+ router.post('/config/:type/folder', catchRoute((req, res) => {
140
100
  const type = parseType(req.params.type, res);
141
101
  if (!type)
142
102
  return;
@@ -145,17 +105,10 @@ export function createConfigRoutes({ projectRoot, workflowService: _workflowServ
145
105
  res.status(400).json({ success: false, error: 'path is required' });
146
106
  return;
147
107
  }
148
- try {
149
- const basePath = configService.getBasePath(type);
150
- configService.createFolder(basePath, folderPath);
151
- io.emit(`config:${type}:changed`, { action: 'folder-created', path: folderPath });
152
- res.status(201).json({ success: true });
153
- }
154
- catch (err) {
155
- const msg = errMsg(err);
156
- const status = msg.includes('traversal') ? 403 : msg.includes('already exists') ? 409 : 500;
157
- res.status(status).json({ success: false, error: msg });
158
- }
159
- });
108
+ const basePath = configService.getBasePath(type);
109
+ configService.createFolder(basePath, folderPath);
110
+ io.emit(`config:${type}:changed`, { action: 'folder-created', path: folderPath });
111
+ res.status(201).json({ success: true });
112
+ }, inferErrorStatus));
160
113
  return router;
161
114
  }
@@ -5,23 +5,12 @@ import { promisify } from 'util';
5
5
  import { getHookEnforcement } from '../../shared/workflow-config.js';
6
6
  import { getClaudeSessions, getSessionStats } from '../services/claude-session-service.js';
7
7
  import { launchInTerminal } from '../utils/terminal-launcher.js';
8
+ import { buildClaudeFlags } from '../utils/flag-builder.js';
8
9
  import { createLogger } from '../utils/logger.js';
10
+ import { parseJsonFields } from '../utils/json-fields.js';
9
11
  const log = createLogger('server');
10
12
  const execFileAsync = promisify(execFile);
11
- const JSON_FIELDS = ['tags', 'blocked_by', 'blocks', 'data', 'discoveries', 'next_steps', 'details'];
12
- function parseJsonFields(row) {
13
- const parsed = { ...row };
14
- for (const field of JSON_FIELDS) {
15
- if (typeof parsed[field] === 'string') {
16
- try {
17
- parsed[field] = JSON.parse(parsed[field]);
18
- }
19
- catch { /* keep string */ }
20
- }
21
- }
22
- return parsed;
23
- }
24
- export function createDataRoutes({ db, io, eventService, gateService, deployService, gitService, engine, projectRoot, inferScopeStatus, }) {
13
+ export function createDataRoutes({ db, io, eventService, gateService, deployService, gitService, engine, projectRoot, inferScopeStatus, config, }) {
25
14
  const router = Router();
26
15
  // ─── Event Routes ──────────────────────────────────────────
27
16
  router.get('/events', (req, res) => {
@@ -289,7 +278,8 @@ export function createDataRoutes({ db, io, eventService, gateService, deployServ
289
278
  res.status(400).json({ error: 'Valid claude_session_id (UUID) required' });
290
279
  return;
291
280
  }
292
- const resumeCmd = `cd '${projectRoot}' && claude --dangerously-skip-permissions --resume '${claude_session_id}'`;
281
+ const flagsStr = buildClaudeFlags(config.claude.dispatchFlags);
282
+ const resumeCmd = `cd '${projectRoot}' && claude ${flagsStr} --resume '${claude_session_id}'`;
293
283
  try {
294
284
  await launchInTerminal(resumeCmd);
295
285
  res.json({ ok: true, session_id: claude_session_id });
@@ -1,10 +1,11 @@
1
1
  import { Router } from 'express';
2
2
  import { launchInCategorizedTerminal, escapeForAnsiC, shellQuote, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
3
3
  import { resolveDispatchEvent, resolveAbandonedDispatchesForScope, getActiveScopeIds, getAbandonedScopeIds, linkPidToDispatch } from '../utils/dispatch-utils.js';
4
+ import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
4
5
  import { createLogger } from '../utils/logger.js';
5
6
  const log = createLogger('dispatch');
6
- const MAX_BATCH_SIZE = 20;
7
- export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine }) {
7
+ const DEFAULT_MAX_BATCH_SIZE = 20;
8
+ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine, config }) {
8
9
  const router = Router();
9
10
  router.get('/dispatch/active-scopes', (_req, res) => {
10
11
  const scope_ids = getActiveScopeIds(db, scopeService, engine);
@@ -44,6 +45,16 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
44
45
  return;
45
46
  }
46
47
  }
48
+ // Max concurrent dispatches guard
49
+ const maxConcurrent = config.dispatch.maxConcurrent;
50
+ if (maxConcurrent > 0) {
51
+ const activeCount = db.prepare(`SELECT COUNT(*) as count FROM events
52
+ WHERE type = 'DISPATCH' AND JSON_EXTRACT(data, '$.resolved') IS NULL`).get().count;
53
+ if (activeCount >= maxConcurrent) {
54
+ res.status(429).json({ error: `Max concurrent dispatches reached (${maxConcurrent})` });
55
+ return;
56
+ }
57
+ }
47
58
  // Update scope status if transition provided
48
59
  if (scope_id != null && transition?.to) {
49
60
  const result = scopeService.updateStatus(scope_id, transition.to, 'dispatch');
@@ -66,10 +77,12 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
66
77
  const scope = scope_id != null ? scopeService.getById(scope_id) : undefined;
67
78
  const sessionName = buildSessionName({ scopeId: scope_id ?? undefined, title: scope?.title, command });
68
79
  const beforePids = snapshotSessionPids(projectRoot);
69
- // Launch in iTerm — interactive TUI mode (no -p) for full visibility
80
+ // Launch in iTerm — interactive TUI mode (no -p unless printMode) for full visibility
70
81
  const promptText = prompt ?? command;
71
82
  const escaped = escapeForAnsiC(promptText);
72
- const fullCmd = `cd '${shellQuote(projectRoot)}' && ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude --dangerously-skip-permissions $'${escaped}'`;
83
+ const flagsStr = buildClaudeFlags(config.claude.dispatchFlags);
84
+ const envPrefix = buildEnvVarPrefix(config.dispatch.envVars);
85
+ const fullCmd = `cd '${shellQuote(projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${flagsStr} $'${escaped}'`;
73
86
  try {
74
87
  await launchInCategorizedTerminal(command, fullCmd, sessionName);
75
88
  res.json({ ok: true, dispatch_id: eventId, scope_id: scope_id ?? null });
@@ -160,8 +173,9 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
160
173
  return;
161
174
  }
162
175
  // W-12: Validate batch size and scope ID types
163
- if (scope_ids.length > MAX_BATCH_SIZE) {
164
- res.status(400).json({ error: `Maximum batch size is ${MAX_BATCH_SIZE}` });
176
+ const maxBatch = config.dispatch.maxBatchSize || DEFAULT_MAX_BATCH_SIZE;
177
+ if (scope_ids.length > maxBatch) {
178
+ res.status(400).json({ error: `Maximum batch size is ${maxBatch}` });
165
179
  return;
166
180
  }
167
181
  if (!scope_ids.every(id => Number.isInteger(id) && id > 0)) {
@@ -188,10 +202,12 @@ export function createDispatchRoutes({ db, io, scopeService, projectRoot, engine
188
202
  session_id: null, agent: 'dashboard', data: eventData,
189
203
  timestamp: new Date().toISOString(),
190
204
  });
191
- // Launch single CLI session
205
+ // Launch single CLI session with batch env vars
192
206
  const batchEscaped = escapeForAnsiC(command);
193
207
  const beforePids = snapshotSessionPids(projectRoot);
194
- const fullCmd = `cd '${shellQuote(projectRoot)}' && ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude --dangerously-skip-permissions -p $'${batchEscaped}'`;
208
+ const batchFlags = buildClaudeFlags(config.claude.dispatchFlags);
209
+ const envPrefix = buildEnvVarPrefix(config.dispatch.envVars);
210
+ const fullCmd = `cd '${shellQuote(projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${batchFlags} $'${batchEscaped}'`;
195
211
  try {
196
212
  await launchInCategorizedTerminal(command, fullCmd);
197
213
  res.json({ ok: true, dispatch_id: eventId, scope_ids });
@@ -120,7 +120,7 @@ export function createManifestRoutes({ projectRoot, templatesDir, packageVersion
120
120
  manifest = loadManifest(projectRoot);
121
121
  }
122
122
  if (!manifest) {
123
- return res.status(400).json({ success: false, error: 'No manifest. Run orbital init first.' });
123
+ return res.status(400).json({ success: false, error: 'No manifest. Run orbital first.' });
124
124
  }
125
125
  if (dryRun) {
126
126
  refreshFileStatuses(manifest, claudeDir);
@@ -1,13 +1,14 @@
1
1
  import { Router } from 'express';
2
2
  import { spawn } from 'child_process';
3
- import { launchInTerminal, escapeForAnsiC, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
3
+ import { launchInTerminal, escapeForAnsiC, shellQuote, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
4
4
  import { resolveDispatchEvent, linkPidToDispatch } from '../utils/dispatch-utils.js';
5
+ import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
5
6
  import { createLogger } from '../utils/logger.js';
6
7
  const log = createLogger('dispatch');
7
8
  function isValidSlug(slug) {
8
9
  return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug) && slug.length <= 80;
9
10
  }
10
- export function createScopeRoutes({ db, io, scopeService, readinessService, projectRoot, projectName, engine }) {
11
+ export function createScopeRoutes({ db, io, scopeService, readinessService, projectRoot, projectName, engine, config }) {
11
12
  const router = Router();
12
13
  // ─── Scope CRUD ──────────────────────────────────────────
13
14
  router.get('/scopes', (_req, res) => {
@@ -103,16 +104,16 @@ export function createScopeRoutes({ db, io, scopeService, readinessService, proj
103
104
  res.status(400).json({ error: 'Invalid slug' });
104
105
  return;
105
106
  }
106
- const result = scopeService.promoteIdea(slug);
107
+ const entryPoint = engine.getEntryPoint();
108
+ const targets = engine.getValidTargets(entryPoint.id);
109
+ const promoteTarget = targets[0] ?? 'planning';
110
+ const result = scopeService.promoteIdea(slug, promoteTarget);
107
111
  if (!result) {
108
112
  res.status(404).json({ error: 'Idea not found' });
109
113
  return;
110
114
  }
111
115
  const scopeId = result.id;
112
116
  // Read command from workflow edge config (user-overridable)
113
- const entryPoint = engine.getEntryPoint();
114
- const targets = engine.getValidTargets(entryPoint.id);
115
- const promoteTarget = targets[0] ?? 'planning';
116
117
  const edge = engine.findEdge(entryPoint.id, promoteTarget);
117
118
  const edgeCommand = edge ? engine.buildCommand(edge, scopeId) : null;
118
119
  const command = edgeCommand ?? `/scope-create ${String(scopeId).padStart(3, '0')}`;
@@ -131,7 +132,9 @@ export function createScopeRoutes({ db, io, scopeService, readinessService, proj
131
132
  timestamp: new Date().toISOString(),
132
133
  });
133
134
  const escaped = escapeForAnsiC(command);
134
- const fullCmd = `cd '${projectRoot}' && claude --dangerously-skip-permissions $'${escaped}'`;
135
+ const flagsStr = buildClaudeFlags(config.claude.dispatchFlags);
136
+ const envPrefix = buildEnvVarPrefix(config.dispatch.envVars);
137
+ const fullCmd = `cd '${shellQuote(projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${flagsStr} $'${escaped}'`;
135
138
  const promoteSessionName = buildSessionName({ scopeId, title: result.title, command });
136
139
  const promoteBeforePids = snapshotSessionPids(projectRoot);
137
140
  try {
@@ -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
  `;
@@ -1,5 +1,6 @@
1
- import { launchInCategorizedTerminal, escapeForAnsiC, snapshotSessionPids, discoverNewSession, isSessionPidAlive } from '../utils/terminal-launcher.js';
1
+ import { launchInCategorizedTerminal, escapeForAnsiC, shellQuote, snapshotSessionPids, discoverNewSession, isSessionPidAlive } from '../utils/terminal-launcher.js';
2
2
  import { linkPidToDispatch, resolveDispatchEvent } from '../utils/dispatch-utils.js';
3
+ import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
3
4
  import { createLogger } from '../utils/logger.js';
4
5
  const log = createLogger('batch');
5
6
  const VALID_MERGE_MODES = ['push', 'pr'];
@@ -11,13 +12,15 @@ export class BatchOrchestrator {
11
12
  scopeService;
12
13
  engine;
13
14
  projectRoot;
14
- constructor(db, io, sprintService, scopeService, engine, projectRoot) {
15
+ config;
16
+ constructor(db, io, sprintService, scopeService, engine, projectRoot, config) {
15
17
  this.db = db;
16
18
  this.io = io;
17
19
  this.sprintService = sprintService;
18
20
  this.scopeService = scopeService;
19
21
  this.engine = engine;
20
22
  this.projectRoot = projectRoot;
23
+ this.config = config;
21
24
  }
22
25
  /** Dispatch a batch — validates constraints and routes to column-specific handler */
23
26
  async dispatch(batchId, mergeMode) {
@@ -63,7 +66,9 @@ export class BatchOrchestrator {
63
66
  });
64
67
  // Launch single CLI session with BATCH_SCOPE_IDS prepended to command
65
68
  const escaped = escapeForAnsiC(command);
66
- const fullCmd = `cd '${this.projectRoot}' && BATCH_SCOPE_IDS='${scopeIdsStr}' MERGE_MODE='${mergeModeStr}' claude --dangerously-skip-permissions $'${escaped}'`;
69
+ const flagsStr = buildClaudeFlags(this.config.claude.dispatchFlags);
70
+ const envPrefix = buildEnvVarPrefix(this.config.dispatch.envVars);
71
+ const fullCmd = `cd '${shellQuote(this.projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' BATCH_SCOPE_IDS='${scopeIdsStr}' MERGE_MODE='${mergeModeStr}' claude ${flagsStr} $'${escaped}'`;
67
72
  const beforePids = snapshotSessionPids(this.projectRoot);
68
73
  try {
69
74
  await launchInCategorizedTerminal(command, fullCmd);
@@ -137,6 +142,15 @@ export class BatchOrchestrator {
137
142
  if (batch.status !== 'dispatched' && batch.status !== 'in_progress')
138
143
  return;
139
144
  const scopes = this.sprintService.getSprintScopes(batchId);
145
+ // If batch never reached 'in_progress', the session never started —
146
+ // don't credit any scope regardless of their current workflow status
147
+ if (batch.status === 'dispatched') {
148
+ this.sprintService.updateStatus(batchId, 'failed');
149
+ for (const ss of scopes) {
150
+ this.sprintService.updateScopeStatus(batchId, ss.scope_id, 'failed', 'Session never started');
151
+ }
152
+ return;
153
+ }
140
154
  const allTransitioned = scopes.every((ss) => ss.dispatch_status === 'completed');
141
155
  if (allTransitioned) {
142
156
  this.sprintService.updateStatus(batchId, 'completed');
@@ -100,7 +100,16 @@ export class ConfigService {
100
100
  continue;
101
101
  const fullPath = path.join(currentPath, entry.name);
102
102
  const relPath = path.relative(basePath, fullPath);
103
- if (entry.isDirectory()) {
103
+ // Resolve symlinks: Dirent.isDirectory() returns false for symlinks-to-dirs.
104
+ // Self-hosted projects symlink .claude/agents/*, .claude/hooks/*, etc. into templates/.
105
+ let stat;
106
+ try {
107
+ stat = fs.statSync(fullPath);
108
+ }
109
+ catch {
110
+ continue; // broken symlink — skip silently
111
+ }
112
+ if (stat.isDirectory()) {
104
113
  const children = this.walkDir(fullPath, basePath, parseFrontmatter);
105
114
  nodes.push({ name: entry.name, path: relPath, type: 'folder', children });
106
115
  }
@@ -112,7 +112,7 @@ export class ScopeService {
112
112
  if (id !== undefined) {
113
113
  if (previous)
114
114
  this.recentlyRemoved.set(id, previous.status);
115
- this.io.emit('scope:deleted', id);
115
+ this.io.emit('scope:deleted', { id });
116
116
  // Clean up stash after a short window (if add never fires, this was a real delete)
117
117
  setTimeout(() => this.recentlyRemoved.delete(id), 5000);
118
118
  }
@@ -346,7 +346,7 @@ export class ScopeService {
346
346
  }
347
347
  /** Promote an icebox idea to planning — assigns a proper sequential scope ID,
348
348
  * moves the file, and syncs cache. Returns the new scope ID. */
349
- promoteIdea(slug) {
349
+ promoteIdea(slug, targetStatus = 'planning') {
350
350
  const iceboxDir = path.join(this.scopesDir, 'icebox');
351
351
  const oldPath = this.findIdeaFile(iceboxDir, slug);
352
352
  if (!oldPath)
@@ -362,15 +362,15 @@ export class ScopeService {
362
362
  const paddedId = String(newId).padStart(3, '0');
363
363
  // Build new path
364
364
  const titleSlug = this.slugify(title);
365
- const planningDir = path.join(this.scopesDir, 'planning');
366
- if (!fs.existsSync(planningDir))
367
- fs.mkdirSync(planningDir, { recursive: true });
365
+ const targetDir = path.join(this.scopesDir, targetStatus);
366
+ if (!fs.existsSync(targetDir))
367
+ fs.mkdirSync(targetDir, { recursive: true });
368
368
  const newFileName = `${paddedId}-${titleSlug}.md`;
369
- const newPath = path.join(planningDir, newFileName);
369
+ const newPath = path.join(targetDir, newFileName);
370
370
  const now = new Date().toISOString().split('T')[0];
371
371
  // Update frontmatter in-place: assign ID and change status (preserve other fields)
372
372
  parsed.data.id = newId;
373
- parsed.data.status = 'planning';
373
+ parsed.data.status = targetStatus;
374
374
  parsed.data.updated = now;
375
375
  parsed.data.created = created;
376
376
  delete parsed.data.ghost;
@@ -1,5 +1,6 @@
1
- import { launchInCategorizedTerminal, escapeForAnsiC, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
1
+ import { launchInCategorizedTerminal, escapeForAnsiC, shellQuote, buildSessionName, snapshotSessionPids, discoverNewSession, renameSession } from '../utils/terminal-launcher.js';
2
2
  import { resolveDispatchEvent, linkPidToDispatch } from '../utils/dispatch-utils.js';
3
+ import { buildClaudeFlags, buildEnvVarPrefix } from '../utils/flag-builder.js';
3
4
  import { createLogger } from '../utils/logger.js';
4
5
  const log = createLogger('sprint');
5
6
  const LAUNCH_STAGGER_MS = 2000;
@@ -14,13 +15,15 @@ export class SprintOrchestrator {
14
15
  scopeService;
15
16
  engine;
16
17
  projectRoot;
17
- constructor(db, io, sprintService, scopeService, engine, projectRoot) {
18
+ config;
19
+ constructor(db, io, sprintService, scopeService, engine, projectRoot, config) {
18
20
  this.db = db;
19
21
  this.io = io;
20
22
  this.sprintService = sprintService;
21
23
  this.scopeService = scopeService;
22
24
  this.engine = engine;
23
25
  this.projectRoot = projectRoot;
26
+ this.config = config;
24
27
  }
25
28
  /** Build execution layers using Kahn's topological sort */
26
29
  buildExecutionLayers(sprintScopeIds) {
@@ -98,13 +101,14 @@ export class SprintOrchestrator {
98
101
  const match = this.sprintService.findActiveSprintForScope(scopeId);
99
102
  if (!match)
100
103
  return;
101
- log.debug('Scope reached dev', { scopeId, sprintId: match.sprint_id });
104
+ // Batches are managed by BatchOrchestrator don't dispatch individual scopes
102
105
  const sprintId = match.sprint_id;
103
- this.sprintService.updateScopeStatus(sprintId, scopeId, 'completed');
104
- // Ensure sprint is in 'in_progress' state
105
106
  const sprint = this.sprintService.getById(sprintId);
106
- if (!sprint)
107
+ if (!sprint || sprint.group_type === 'batch')
107
108
  return;
109
+ log.debug('Scope reached dev', { scopeId, sprintId });
110
+ this.sprintService.updateScopeStatus(sprintId, scopeId, 'completed');
111
+ // Ensure sprint is in 'in_progress' state
108
112
  if (sprint.status === 'dispatched') {
109
113
  this.sprintService.updateStatus(sprintId, 'in_progress');
110
114
  }
@@ -169,7 +173,7 @@ export class SprintOrchestrator {
169
173
  const sprint = this.sprintService.getById(sprintId);
170
174
  if (!sprint)
171
175
  return null;
172
- const layers = sprint.layers ?? [];
176
+ const layers = sprint.layers ?? this.buildExecutionLayers(sprint.scope_ids).layers;
173
177
  const sprintSet = new Set(sprint.scope_ids);
174
178
  const edges = [];
175
179
  for (const scopeId of sprint.scope_ids) {
@@ -192,9 +196,14 @@ export class SprintOrchestrator {
192
196
  // Capture current status before optimistic update (for rollback)
193
197
  const currentScope = this.scopeService.getById(scopeId);
194
198
  const previousStatus = currentScope?.status ?? 'implementing';
199
+ // Resolve command and target status from workflow engine
200
+ const sprint = this.sprintService.getById(sprintId);
201
+ const targetColumn = sprint?.target_column ?? 'backlog';
202
+ const edgeCommand = this.engine.getBatchCommand(targetColumn);
203
+ const targetStatus = this.engine.getBatchTargetStatus(targetColumn);
195
204
  // Record DISPATCH event
196
205
  const eventId = crypto.randomUUID();
197
- const command = `/scope implement ${scopeId}`;
206
+ const command = edgeCommand ?? `/scope-implement ${scopeId}`;
198
207
  this.db.prepare(`INSERT INTO events (id, type, scope_id, session_id, agent, data, timestamp)
199
208
  VALUES (?, 'DISPATCH', ?, NULL, 'sprint-orchestrator', ?, ?)`).run(eventId, scopeId, JSON.stringify({ command, sprint_id: sprintId, resolved: null }), new Date().toISOString());
200
209
  this.io.emit('event:new', {
@@ -204,15 +213,19 @@ export class SprintOrchestrator {
204
213
  timestamp: new Date().toISOString(),
205
214
  });
206
215
  // Update scope + sprint_scope status
207
- this.scopeService.updateStatus(scopeId, 'implementing', 'dispatch');
216
+ if (targetStatus) {
217
+ this.scopeService.updateStatus(scopeId, targetStatus, 'dispatch');
218
+ }
208
219
  this.sprintService.updateScopeStatus(sprintId, scopeId, 'dispatched');
209
220
  // Build scope-aware session name and snapshot PIDs
210
221
  const scopeRow = this.scopeService.getById(scopeId);
211
222
  const sessionName = buildSessionName({ scopeId, title: scopeRow?.title, command });
212
223
  const beforePids = snapshotSessionPids(this.projectRoot);
213
- // Launch in iTerm — interactive TUI mode (no -p) for full visibility
224
+ // Launch in iTerm — interactive TUI mode for full visibility
214
225
  const escaped = escapeForAnsiC(command);
215
- const fullCmd = `cd '${this.projectRoot}' && claude --dangerously-skip-permissions $'${escaped}'`;
226
+ const flagsStr = buildClaudeFlags(this.config.claude.dispatchFlags);
227
+ const envPrefix = buildEnvVarPrefix(this.config.dispatch.envVars);
228
+ const fullCmd = `cd '${shellQuote(this.projectRoot)}' && ${envPrefix}ORBITAL_DISPATCH_ID='${shellQuote(eventId)}' claude ${flagsStr} $'${escaped}'`;
216
229
  try {
217
230
  await launchInCategorizedTerminal(command, fullCmd, sessionName);
218
231
  // Fire-and-forget: discover session PID, link to dispatch, and rename
@@ -73,10 +73,10 @@ export class SprintService {
73
73
  return null;
74
74
  return this.buildDetail(row);
75
75
  }
76
- /** Delete a sprint (only if assembling) */
76
+ /** Delete a sprint/batch (assembling, failed, or cancelled) */
77
77
  delete(id) {
78
78
  const row = this.db.prepare('SELECT status FROM sprints WHERE id = ?').get(id);
79
- if (!row || row.status !== 'assembling')
79
+ if (!row || !['assembling', 'failed', 'cancelled', 'completed'].includes(row.status))
80
80
  return false;
81
81
  this.db.prepare('DELETE FROM sprint_scopes WHERE sprint_id = ?').run(id);
82
82
  this.db.prepare('DELETE FROM sprints WHERE id = ?').run(id);