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