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
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Uninstall logic — extracted from init.ts for focused maintainability.
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { loadManifest } from './manifest.js';
8
+ import { removeAllOrbitalHooks } from './settings-sync.js';
9
+ import { unregisterProject } from './global-config.js';
10
+ import {
11
+ TEMPLATES_DIR,
12
+ cleanEmptyDirs,
13
+ listTemplateFiles,
14
+ } from './init.js';
15
+
16
+ // ─── Uninstall ──────────────────────────────────────────────
17
+
18
+ export interface UninstallOptions {
19
+ dryRun?: boolean;
20
+ keepConfig?: boolean;
21
+ }
22
+
23
+ export function runUninstall(projectRoot: string, options: UninstallOptions = {}): void {
24
+ const { dryRun = false, keepConfig = false } = options;
25
+ const claudeDir = path.join(projectRoot, '.claude');
26
+
27
+ console.log(`\nOrbital Command — uninstall${dryRun ? ' (dry run)' : ''}`);
28
+ console.log(`Project root: ${projectRoot}\n`);
29
+
30
+ const manifest = loadManifest(projectRoot);
31
+
32
+ // Fall back to legacy uninstall if no manifest
33
+ if (!manifest) {
34
+ console.log(' No manifest found — falling back to legacy uninstall.');
35
+ runLegacyUninstall(projectRoot);
36
+ return;
37
+ }
38
+
39
+ // Compute what to remove vs preserve
40
+ const toRemove: string[] = [];
41
+ const toPreserve: string[] = [];
42
+
43
+ for (const [filePath, record] of Object.entries(manifest.files)) {
44
+ if (record.origin === 'user') {
45
+ toPreserve.push(filePath);
46
+ } else if (record.status === 'modified' || record.status === 'outdated') {
47
+ toPreserve.push(filePath);
48
+ } else {
49
+ toRemove.push(filePath);
50
+ }
51
+ }
52
+
53
+ if (dryRun) {
54
+ console.log(' Files to REMOVE:');
55
+ for (const f of toRemove) console.log(` ${f}`);
56
+ if (toPreserve.length > 0) {
57
+ console.log(' Files to PRESERVE:');
58
+ for (const f of toPreserve) console.log(` ${f} (${manifest.files[f].origin}/${manifest.files[f].status})`);
59
+ }
60
+ console.log(`\n Would also remove: settings hooks, generated artifacts, config files, gitignore entries`);
61
+ console.log(` No changes made. Run without --dry-run to apply.`);
62
+ return;
63
+ }
64
+
65
+ // 1. Remove _orbital hooks from settings.local.json
66
+ const settingsPath = path.join(claudeDir, 'settings.local.json');
67
+ const removedHooks = removeAllOrbitalHooks(settingsPath);
68
+ console.log(` Removed ${removedHooks} orbital hook registrations`);
69
+
70
+ // 2. Delete template files (synced + pinned, not modified or user-owned)
71
+ let filesRemoved = 0;
72
+ for (const filePath of toRemove) {
73
+ const absPath = path.join(claudeDir, filePath);
74
+ if (fs.existsSync(absPath)) {
75
+ fs.unlinkSync(absPath);
76
+ filesRemoved++;
77
+ }
78
+ }
79
+ console.log(` Removed ${filesRemoved} template files`);
80
+ if (toPreserve.length > 0) {
81
+ console.log(` Preserved ${toPreserve.length} user/modified files`);
82
+ }
83
+
84
+ // 3. Clean up empty directories
85
+ for (const dir of ['hooks', 'skills', 'agents', 'config/workflows', 'quick', 'anti-patterns']) {
86
+ const dirPath = path.join(claudeDir, dir);
87
+ if (fs.existsSync(dirPath)) cleanEmptyDirs(dirPath);
88
+ }
89
+
90
+ // 4. Remove generated artifacts
91
+ for (const artifact of manifest.generatedArtifacts) {
92
+ const artifactPath = path.join(claudeDir, artifact);
93
+ if (fs.existsSync(artifactPath)) {
94
+ fs.unlinkSync(artifactPath);
95
+ console.log(` Removed .claude/${artifact}`);
96
+ }
97
+ }
98
+
99
+ // 5. Remove template-sourced config files
100
+ const configFiles = [
101
+ 'config/agent-triggers.json',
102
+ 'config/workflow.json',
103
+ 'lessons-learned.md',
104
+ ];
105
+ for (const file of configFiles) {
106
+ const filePath = path.join(claudeDir, file);
107
+ if (fs.existsSync(filePath)) {
108
+ fs.unlinkSync(filePath);
109
+ console.log(` Removed .claude/${file}`);
110
+ }
111
+ }
112
+
113
+ // Remove config/workflows/ directory entirely
114
+ const workflowsDir = path.join(claudeDir, 'config', 'workflows');
115
+ if (fs.existsSync(workflowsDir)) {
116
+ fs.rmSync(workflowsDir, { recursive: true, force: true });
117
+ console.log(` Removed .claude/config/workflows/`);
118
+ }
119
+
120
+ // 6. Remove gitignore entries
121
+ removeGitignoreEntries(projectRoot, manifest.gitignoreEntries);
122
+ console.log(` Cleaned .gitignore`);
123
+
124
+ // 7. Deregister from global registry
125
+ if (unregisterProject(projectRoot)) {
126
+ console.log(` Removed project from ~/.orbital/config.json`);
127
+ }
128
+
129
+ // 8. Remove orbital config and manifest (unless --keep-config)
130
+ if (!keepConfig) {
131
+ const toClean = ['orbital.config.json', 'orbital-manifest.json', 'orbital-sync.json'];
132
+ for (const file of toClean) {
133
+ const filePath = path.join(claudeDir, file);
134
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
135
+ }
136
+
137
+ // Remove backups directory
138
+ const backupsDir = path.join(claudeDir, '.orbital-backups');
139
+ if (fs.existsSync(backupsDir)) fs.rmSync(backupsDir, { recursive: true, force: true });
140
+
141
+ console.log(` Removed orbital config and manifest`);
142
+ } else {
143
+ // Still remove the manifest — it's invalid after uninstall
144
+ const manifestPath = path.join(claudeDir, 'orbital-manifest.json');
145
+ if (fs.existsSync(manifestPath)) fs.unlinkSync(manifestPath);
146
+ console.log(` Kept orbital.config.json (--keep-config)`);
147
+ }
148
+
149
+ // Clean up remaining empty directories
150
+ for (const dir of ['config', 'quick', 'anti-patterns', 'review-verdicts']) {
151
+ const dirPath = path.join(claudeDir, dir);
152
+ if (fs.existsSync(dirPath)) cleanEmptyDirs(dirPath);
153
+ }
154
+
155
+ const total = removedHooks + filesRemoved;
156
+ console.log(`\nUninstall complete. ${total} items removed.`);
157
+ if (toPreserve.length > 0) {
158
+ console.log(`Note: ${toPreserve.length} user/modified files were preserved.`);
159
+ }
160
+ console.log(`Note: scopes/ and .claude/orbital-events/ were preserved.\n`);
161
+ }
162
+
163
+ // ─── Helpers ────────────────────────────────────────────────
164
+
165
+ /** Legacy uninstall for projects without a manifest (backward compat). */
166
+ function runLegacyUninstall(projectRoot: string): void {
167
+ const claudeDir = path.join(projectRoot, '.claude');
168
+
169
+ // Remove orbital hooks from settings.local.json
170
+ const settingsPath = path.join(claudeDir, 'settings.local.json');
171
+ const removedHooks = removeAllOrbitalHooks(settingsPath);
172
+ console.log(` Removed ${removedHooks} orbital hook registrations`);
173
+
174
+ // Delete hooks/skills/agents that match template files
175
+ for (const dir of ['hooks', 'skills', 'agents']) {
176
+ const templateDir = listTemplateFiles(path.join(TEMPLATES_DIR, dir), path.join(claudeDir, dir));
177
+ let removed = 0;
178
+ for (const f of templateDir) {
179
+ if (fs.existsSync(f)) { fs.unlinkSync(f); removed++; }
180
+ }
181
+ const dirPath = path.join(claudeDir, dir);
182
+ if (fs.existsSync(dirPath)) cleanEmptyDirs(dirPath);
183
+ console.log(` Removed ${removed} ${dir} files`);
184
+ }
185
+
186
+ console.log(`\nLegacy uninstall complete.`);
187
+ console.log(`Note: scopes/ and .claude/orbital-events/ were preserved.\n`);
188
+ }
189
+
190
+ /** Remove Orbital-added entries from .gitignore. */
191
+ function removeGitignoreEntries(projectRoot: string, entries: string[]): void {
192
+ const gitignorePath = path.join(projectRoot, '.gitignore');
193
+ if (!fs.existsSync(gitignorePath)) return;
194
+
195
+ let content = fs.readFileSync(gitignorePath, 'utf-8');
196
+ const marker = '# Orbital Command';
197
+
198
+ const markerIdx = content.indexOf(marker);
199
+ if (markerIdx !== -1) {
200
+ const before = content.slice(0, markerIdx).replace(/\n+$/, '');
201
+ const after = content.slice(markerIdx);
202
+ const lines = after.split('\n');
203
+ let endIdx = 0;
204
+ for (let i = 0; i < lines.length; i++) {
205
+ const line = lines[i].trim();
206
+ if (i === 0) { endIdx = i + 1; continue; }
207
+ if (line === '' || entries.includes(line)) { endIdx = i + 1; continue; }
208
+ break;
209
+ }
210
+ const remaining = lines.slice(endIdx).join('\n');
211
+ content = before + (remaining ? '\n' + remaining : '') + '\n';
212
+ fs.writeFileSync(gitignorePath, content, 'utf-8');
213
+ }
214
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Template update logic — extracted from init.ts for focused maintainability.
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import {
8
+ loadManifest,
9
+ saveManifest,
10
+ buildTemplateInventory,
11
+ refreshFileStatuses,
12
+ templateFileRecord,
13
+ safeBackupFile,
14
+ safeCopyTemplate,
15
+ reverseRemapPath,
16
+ } from './manifest.js';
17
+ import { needsLegacyMigration, migrateFromLegacy } from './migrate-legacy.js';
18
+ import { computeUpdatePlan, loadRenameMap, formatPlan, getFilesToBackup } from './update-planner.js';
19
+ import { syncSettingsHooks, getTemplateChecksum } from './settings-sync.js';
20
+ import { migrateConfig } from './config-migrator.js';
21
+ import { validate, formatValidationReport } from './validator.js';
22
+ import { createBackup } from './manifest.js';
23
+ import {
24
+ TEMPLATES_DIR,
25
+ ensureDir,
26
+ cleanEmptyDirs,
27
+ chmodScripts,
28
+ writeManifest,
29
+ generateIndexMd,
30
+ seedGlobalPrimitives,
31
+ getPackageVersion,
32
+ } from './init.js';
33
+
34
+ // ─── Update ─────────────────────────────────────────────────
35
+
36
+ export interface UpdateOptions {
37
+ dryRun?: boolean;
38
+ force?: boolean;
39
+ }
40
+
41
+ export function runUpdate(projectRoot: string, options: UpdateOptions = {}): void {
42
+ const { dryRun = false } = options;
43
+ const claudeDir = path.join(projectRoot, '.claude');
44
+ const newVersion = getPackageVersion();
45
+
46
+ console.log(`\nOrbital Command — update${dryRun ? ' (dry run)' : ''}`);
47
+ console.log(`Project root: ${projectRoot}\n`);
48
+
49
+ // 1. Load or create manifest (auto-migrate legacy installs)
50
+ let manifest = loadManifest(projectRoot);
51
+ if (!manifest) {
52
+ if (needsLegacyMigration(projectRoot)) {
53
+ console.log(' Migrating from legacy install...');
54
+ const result = migrateFromLegacy(projectRoot, TEMPLATES_DIR, newVersion);
55
+ console.log(` Migrated ${result.synced} synced, ${result.modified} modified, ${result.userOwned} user-owned files`);
56
+ if (result.importedPins > 0) console.log(` Imported ${result.importedPins} pinned files from orbital-sync.json`);
57
+ manifest = loadManifest(projectRoot);
58
+ }
59
+ if (!manifest) {
60
+ console.log(' No manifest found. Run `orbital` first.');
61
+ return;
62
+ }
63
+ }
64
+
65
+ const oldVersion = manifest.packageVersion;
66
+
67
+ // 1b. Refresh file statuses so outdated vs modified is accurate
68
+ refreshFileStatuses(manifest, claudeDir);
69
+
70
+ // 2. Compute update plan
71
+ const renameMap = loadRenameMap(TEMPLATES_DIR, oldVersion, newVersion);
72
+ const plan = computeUpdatePlan({
73
+ templatesDir: TEMPLATES_DIR,
74
+ claudeDir,
75
+ manifest,
76
+ newVersion,
77
+ renameMap,
78
+ });
79
+
80
+ // 3. Dry-run mode — print plan and exit
81
+ if (dryRun) {
82
+ console.log(formatPlan(plan, oldVersion, newVersion));
83
+ return;
84
+ }
85
+
86
+ if (plan.isEmpty && oldVersion === newVersion) {
87
+ console.log(' Everything up to date. No changes needed.');
88
+ }
89
+
90
+ // 4. Create backup of files that will be modified
91
+ const filesToBackup = getFilesToBackup(plan);
92
+ if (filesToBackup.length > 0) {
93
+ const backupDir = createBackup(claudeDir, filesToBackup);
94
+ if (backupDir) {
95
+ console.log(` Backup ${filesToBackup.length} files → ${path.relative(claudeDir, backupDir)}/`);
96
+ }
97
+ }
98
+
99
+ // 5. Execute plan
100
+ const templateInventory = buildTemplateInventory(TEMPLATES_DIR);
101
+
102
+ // 5a. Handle renames
103
+ for (const { from, to } of plan.toRename) {
104
+ const fromPath = path.join(claudeDir, from);
105
+ const toPath = path.join(claudeDir, to);
106
+ const toDir = path.dirname(toPath);
107
+ if (!fs.existsSync(toDir)) fs.mkdirSync(toDir, { recursive: true });
108
+
109
+ if (fs.existsSync(fromPath)) {
110
+ safeBackupFile(fromPath);
111
+ const stat = fs.lstatSync(fromPath);
112
+ if (stat.isSymbolicLink()) {
113
+ const target = fs.readlinkSync(fromPath);
114
+ fs.unlinkSync(fromPath);
115
+ fs.symlinkSync(target, toPath);
116
+ } else {
117
+ fs.renameSync(fromPath, toPath);
118
+ }
119
+ }
120
+
121
+ const record = manifest.files[from];
122
+ if (record) {
123
+ delete manifest.files[from];
124
+ const newHash = templateInventory.get(to);
125
+ manifest.files[to] = { ...record, templateHash: newHash, installedHash: newHash || record.installedHash };
126
+ }
127
+ console.log(` RENAME ${from} → ${to}`);
128
+ }
129
+
130
+ // 5b. Add new files
131
+ for (const filePath of plan.toAdd) {
132
+ const templateHash = templateInventory.get(filePath);
133
+ if (!templateHash) continue;
134
+
135
+ const destPath = path.join(claudeDir, filePath);
136
+ const destDir = path.dirname(destPath);
137
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
138
+
139
+ copyTemplateFile(filePath, destPath);
140
+ manifest.files[filePath] = templateFileRecord(templateHash);
141
+ console.log(` ADD ${filePath}`);
142
+ }
143
+
144
+ // 5c. Update changed synced/outdated files
145
+ for (const filePath of plan.toUpdate) {
146
+ const templateHash = templateInventory.get(filePath);
147
+ if (!templateHash) continue;
148
+
149
+ const destPath = path.join(claudeDir, filePath);
150
+ safeBackupFile(destPath);
151
+ copyTemplateFile(filePath, destPath);
152
+ manifest.files[filePath] = templateFileRecord(templateHash);
153
+ console.log(` UPDATE ${filePath}`);
154
+ }
155
+
156
+ // 5d. Remove deleted files
157
+ for (const filePath of plan.toRemove) {
158
+ const absPath = path.join(claudeDir, filePath);
159
+ if (fs.existsSync(absPath)) {
160
+ safeBackupFile(absPath);
161
+ fs.unlinkSync(absPath);
162
+ }
163
+ delete manifest.files[filePath];
164
+ console.log(` REMOVE ${filePath}`);
165
+ }
166
+
167
+ // 5e. Update pinned/modified file records (record new template hash without touching file)
168
+ for (const { file, reason, newTemplateHash } of plan.toSkip) {
169
+ if (manifest.files[file]) {
170
+ manifest.files[file].templateHash = newTemplateHash;
171
+ }
172
+ if (reason === 'modified') {
173
+ console.log(` SKIP ${file} (user modified)`);
174
+ } else {
175
+ console.log(` SKIP ${file} (pinned)`);
176
+ }
177
+ }
178
+
179
+ // 5f. Clean up empty directories
180
+ for (const dir of ['hooks', 'skills', 'agents', 'config/workflows']) {
181
+ const dirPath = path.join(claudeDir, dir);
182
+ if (fs.existsSync(dirPath)) cleanEmptyDirs(dirPath);
183
+ }
184
+
185
+ // 6. Bidirectional settings hook sync
186
+ const settingsTarget = path.join(claudeDir, 'settings.local.json');
187
+ const settingsSrc = path.join(TEMPLATES_DIR, 'settings-hooks.json');
188
+ const syncResult = syncSettingsHooks(settingsTarget, settingsSrc, manifest.settingsHooksChecksum, renameMap);
189
+ if (!syncResult.skipped) {
190
+ console.log(` Settings +${syncResult.added} -${syncResult.removed} hooks (${syncResult.updated} renamed)`);
191
+ manifest.settingsHooksChecksum = getTemplateChecksum(settingsSrc);
192
+ }
193
+
194
+ // 7. Config migrations
195
+ const configPath = path.join(claudeDir, 'orbital.config.json');
196
+ const migrationResult = migrateConfig(configPath, manifest.appliedMigrations);
197
+ if (migrationResult.applied.length > 0) {
198
+ manifest.appliedMigrations.push(...migrationResult.applied);
199
+ console.log(` Config ${migrationResult.applied.length} migration(s) applied`);
200
+ }
201
+ if (migrationResult.defaultsFilled.length > 0) {
202
+ console.log(` Config ${migrationResult.defaultsFilled.length} default(s) filled`);
203
+ }
204
+
205
+ // 8. Regenerate derived artifacts (always)
206
+ const workflowManifestOk = writeManifest(claudeDir);
207
+ console.log(` ${workflowManifestOk ? 'Updated' : 'Skipped'} .claude/config/workflow-manifest.sh`);
208
+
209
+ const indexContent = generateIndexMd(projectRoot, claudeDir);
210
+ fs.writeFileSync(path.join(claudeDir, 'INDEX.md'), indexContent, 'utf8');
211
+ console.log(` Updated .claude/INDEX.md`);
212
+
213
+ // 9. Update agent-triggers.json (template-managed)
214
+ const triggersSrc = path.join(TEMPLATES_DIR, 'config', 'agent-triggers.json');
215
+ const triggersDest = path.join(claudeDir, 'config', 'agent-triggers.json');
216
+ if (fs.existsSync(triggersSrc)) {
217
+ fs.copyFileSync(triggersSrc, triggersDest);
218
+ console.log(` Updated .claude/config/agent-triggers.json`);
219
+ }
220
+
221
+ // 10. Update scope template
222
+ const scopeTemplateSrc = path.join(TEMPLATES_DIR, 'scopes', '_template.md');
223
+ const scopeTemplateDest = path.join(projectRoot, 'scopes', '_template.md');
224
+ if (fs.existsSync(scopeTemplateSrc)) {
225
+ ensureDir(path.join(projectRoot, 'scopes'));
226
+ fs.copyFileSync(scopeTemplateSrc, scopeTemplateDest);
227
+ }
228
+
229
+ // 11. Make hook scripts executable
230
+ chmodScripts(path.join(claudeDir, 'hooks'));
231
+
232
+ // 12. Refresh global primitives
233
+ seedGlobalPrimitives();
234
+
235
+ // 13. Update manifest metadata
236
+ manifest.previousPackageVersion = oldVersion;
237
+ manifest.packageVersion = newVersion;
238
+ manifest.updatedAt = new Date().toISOString();
239
+ saveManifest(projectRoot, manifest);
240
+
241
+ // 14. Validate
242
+ const report = validate(projectRoot, newVersion);
243
+ if (report.errors > 0) {
244
+ console.log(`\n Validation: ${report.errors} errors found`);
245
+ console.log(formatValidationReport(report));
246
+ }
247
+
248
+ const totalChanges = plan.toAdd.length + plan.toUpdate.length + plan.toRemove.length + plan.toRename.length;
249
+ console.log(`\nUpdate complete. ${totalChanges} file changes, ${plan.toSkip.length} skipped.\n`);
250
+ }
251
+
252
+ // ─── Helpers ────────────────────────────────────────────────
253
+
254
+ function copyTemplateFile(claudeRelPath: string, destPath: string): void {
255
+ const templateRelPath = reverseRemapPath(claudeRelPath);
256
+ const srcPath = path.join(TEMPLATES_DIR, templateRelPath);
257
+ if (!fs.existsSync(srcPath)) {
258
+ throw new Error(`Template file not found: ${templateRelPath}`);
259
+ }
260
+ const destDir = path.dirname(destPath);
261
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
262
+ safeCopyTemplate(srcPath, destPath);
263
+ }
@@ -208,13 +208,13 @@ export function resolveDispatchesByDispatchId(
208
208
  return [row.id];
209
209
  }
210
210
 
211
- /** Fallback age threshold for dispatches without a linked PID (10 minutes). */
212
- const STALE_AGE_MS = 10 * 60 * 1000;
211
+ /** Default fallback age threshold for dispatches without a linked PID (10 minutes). */
212
+ const DEFAULT_STALE_AGE_MS = 10 * 60 * 1000;
213
213
 
214
214
  /** Get all scope IDs that have actively running DISPATCH events.
215
215
  * Uses PID liveness (process.kill(pid, 0)) when available, falls back to
216
216
  * age-based heuristic for legacy dispatches without a linked PID. */
217
- export function getActiveScopeIds(db: Database.Database, scopeService: ScopeService, engine: WorkflowEngine): number[] {
217
+ export function getActiveScopeIds(db: Database.Database, scopeService: ScopeService, engine: WorkflowEngine, staleTimeoutMinutes?: number): number[] {
218
218
  const rows = db.prepare(
219
219
  `SELECT scope_id, data FROM events
220
220
  WHERE type = 'DISPATCH'
@@ -222,7 +222,8 @@ export function getActiveScopeIds(db: Database.Database, scopeService: ScopeServ
222
222
  AND JSON_EXTRACT(data, '$.resolved') IS NULL`,
223
223
  ).all() as Array<{ scope_id: number; data: string }>;
224
224
 
225
- const cutoff = new Date(Date.now() - STALE_AGE_MS).toISOString();
225
+ const staleMs = staleTimeoutMinutes != null ? staleTimeoutMinutes * 60 * 1000 : DEFAULT_STALE_AGE_MS;
226
+ const cutoff = new Date(Date.now() - staleMs).toISOString();
226
227
  const active = new Set<number>();
227
228
 
228
229
  for (const row of rows) {
@@ -310,8 +311,9 @@ export function getActiveScopeIds(db: Database.Database, scopeService: ScopeServ
310
311
  * safe recovery for edges like backlog→implementing where the session crashed
311
312
  * before doing meaningful work. Edges without autoRevert leave the scope in place
312
313
  * for manual recovery from the dashboard. */
313
- export function resolveStaleDispatches(db: Database.Database, io: Emitter, scopeService: ScopeService, engine: WorkflowEngine): number {
314
- const cutoff = new Date(Date.now() - STALE_AGE_MS).toISOString();
314
+ export function resolveStaleDispatches(db: Database.Database, io: Emitter, scopeService: ScopeService, engine: WorkflowEngine, staleTimeoutMinutes?: number): number {
315
+ const staleMs = staleTimeoutMinutes != null ? staleTimeoutMinutes * 60 * 1000 : DEFAULT_STALE_AGE_MS;
316
+ const cutoff = new Date(Date.now() - staleMs).toISOString();
315
317
 
316
318
  // Single query on events only — split by cache status
317
319
  const rows = db.prepare(
@@ -0,0 +1,56 @@
1
+ import type { DispatchFlags } from '../../shared/api-types.js';
2
+ import { VALID_OUTPUT_FORMATS, validateToolName, validateEnvKey } from '../../shared/api-types.js';
3
+ import { shellQuote } from './terminal-launcher.js';
4
+
5
+ /**
6
+ * Compile a structured DispatchFlags object into a CLI flags string
7
+ * for the `claude` command. All parameterized values are validated
8
+ * and shell-quoted to prevent injection.
9
+ */
10
+ export function buildClaudeFlags(flags: DispatchFlags): string {
11
+ const parts: string[] = [];
12
+
13
+ // Permission mode — 'default' means no flag (use Claude's built-in default)
14
+ if (flags.permissionMode === 'bypass') {
15
+ parts.push('--dangerously-skip-permissions');
16
+ } else if (flags.permissionMode && flags.permissionMode !== 'default') {
17
+ parts.push('--permission-mode', flags.permissionMode);
18
+ }
19
+
20
+ if (flags.verbose) parts.push('--verbose');
21
+ if (flags.noMarkdown) parts.push('--no-markdown');
22
+ if (flags.printMode) parts.push('-p');
23
+
24
+ if (flags.outputFormat && VALID_OUTPUT_FORMATS.includes(flags.outputFormat)) {
25
+ parts.push('--output-format', flags.outputFormat);
26
+ }
27
+
28
+ if (flags.allowedTools.length > 0) {
29
+ const safe = flags.allowedTools.filter(validateToolName);
30
+ if (safe.length > 0) parts.push('--allowedTools', safe.join(','));
31
+ }
32
+ if (flags.disallowedTools.length > 0) {
33
+ const safe = flags.disallowedTools.filter(validateToolName);
34
+ if (safe.length > 0) parts.push('--disallowedTools', safe.join(','));
35
+ }
36
+
37
+ if (flags.appendSystemPrompt) {
38
+ const sanitized = flags.appendSystemPrompt.replace(/\n/g, '\\n');
39
+ parts.push('--append-system-prompt', `'${shellQuote(sanitized)}'`);
40
+ }
41
+
42
+ return parts.join(' ');
43
+ }
44
+
45
+ /**
46
+ * Build env var prefix string for dispatch commands.
47
+ * Keys are validated against POSIX naming rules.
48
+ * Returns empty string if no vars configured.
49
+ */
50
+ export function buildEnvVarPrefix(envVars: Record<string, string>): string {
51
+ const entries = Object.entries(envVars).filter(([k]) => validateEnvKey(k));
52
+ if (entries.length === 0) return '';
53
+ return entries
54
+ .map(([k, v]) => `${k}='${v.replace(/'/g, "'\\''")}'`)
55
+ .join(' ') + ' ';
56
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseJsonFields } from './json-fields.js';
3
+
4
+ describe('parseJsonFields', () => {
5
+ it('parses stringified JSON arrays in known fields', () => {
6
+ const row = { tags: '["a","b"]', blocked_by: '[1,2]', title: 'scope-1' };
7
+ const result = parseJsonFields(row);
8
+ expect(result.tags).toEqual(['a', 'b']);
9
+ expect(result.blocked_by).toEqual([1, 2]);
10
+ expect(result.title).toBe('scope-1');
11
+ });
12
+
13
+ it('parses stringified JSON objects in data field', () => {
14
+ const row = { data: '{"key":"val","nested":{"a":1}}' };
15
+ const result = parseJsonFields(row);
16
+ expect(result.data).toEqual({ key: 'val', nested: { a: 1 } });
17
+ });
18
+
19
+ it('handles all 7 known JSON fields', () => {
20
+ const row = {
21
+ tags: '[]', blocked_by: '[]', blocks: '[]',
22
+ data: '{}', discoveries: '[]', next_steps: '[]', details: '{}',
23
+ };
24
+ const result = parseJsonFields(row);
25
+ expect(result.tags).toEqual([]);
26
+ expect(result.blocked_by).toEqual([]);
27
+ expect(result.blocks).toEqual([]);
28
+ expect(result.data).toEqual({});
29
+ expect(result.discoveries).toEqual([]);
30
+ expect(result.next_steps).toEqual([]);
31
+ expect(result.details).toEqual({});
32
+ });
33
+
34
+ it('passes through already-parsed objects untouched', () => {
35
+ const tags = ['a', 'b'];
36
+ const row = { tags, data: { foo: 1 } };
37
+ const result = parseJsonFields(row);
38
+ expect(result.tags).toBe(tags); // same reference — not re-parsed
39
+ expect(result.data).toEqual({ foo: 1 });
40
+ });
41
+
42
+ it('keeps malformed JSON strings as-is without throwing', () => {
43
+ const row = { tags: '{broken json', data: 'not json at all', blocks: '["valid"]' };
44
+ const result = parseJsonFields(row);
45
+ expect(result.tags).toBe('{broken json');
46
+ expect(result.data).toBe('not json at all');
47
+ expect(result.blocks).toEqual(['valid']);
48
+ });
49
+
50
+ it('returns row unchanged when no JSON fields are present', () => {
51
+ const row = { id: 1, title: 'hello', status: 'active' };
52
+ const result = parseJsonFields(row);
53
+ expect(result).toEqual(row);
54
+ });
55
+
56
+ it('does not mutate the original row', () => {
57
+ const row = { tags: '["x"]', title: 'scope' };
58
+ const result = parseJsonFields(row);
59
+ expect(row.tags).toBe('["x"]'); // original unchanged
60
+ expect(result.tags).toEqual(['x']); // copy was parsed
61
+ expect(result).not.toBe(row);
62
+ });
63
+
64
+ it('handles null and undefined field values', () => {
65
+ const row = { tags: null, data: undefined, blocks: '["a"]' };
66
+ const result = parseJsonFields(row);
67
+ expect(result.tags).toBeNull();
68
+ expect(result.data).toBeUndefined();
69
+ expect(result.blocks).toEqual(['a']);
70
+ });
71
+
72
+ it('handles empty row', () => {
73
+ const result = parseJsonFields({});
74
+ expect(result).toEqual({});
75
+ });
76
+
77
+ it('ignores non-JSON-field strings', () => {
78
+ const row = { title: '["not a json field"]', tags: '["real"]' };
79
+ const result = parseJsonFields(row);
80
+ expect(result.title).toBe('["not a json field"]'); // title is not in JSON_FIELDS
81
+ expect(result.tags).toEqual(['real']);
82
+ });
83
+ });
@@ -0,0 +1,14 @@
1
+ const JSON_FIELDS = ['tags', 'blocked_by', 'blocks', 'data', 'discoveries', 'next_steps', 'details'];
2
+
3
+ export type Row = Record<string, unknown>;
4
+
5
+ /** Parse stringified JSON fields in a database row back to objects. */
6
+ export function parseJsonFields(row: Row): Row {
7
+ const parsed = { ...row };
8
+ for (const field of JSON_FIELDS) {
9
+ if (typeof parsed[field] === 'string') {
10
+ try { parsed[field] = JSON.parse(parsed[field] as string); } catch { /* keep string */ }
11
+ }
12
+ }
13
+ return parsed;
14
+ }