aether-colony 1.1.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 (207) hide show
  1. package/.aether/CONTEXT.md +160 -0
  2. package/.aether/QUEEN.md +84 -0
  3. package/.aether/aether-utils.sh +7749 -0
  4. package/.aether/docs/QUEEN-SYSTEM.md +211 -0
  5. package/.aether/docs/README.md +68 -0
  6. package/.aether/docs/caste-system.md +48 -0
  7. package/.aether/docs/disciplines/DISCIPLINES.md +93 -0
  8. package/.aether/docs/disciplines/coding-standards.md +197 -0
  9. package/.aether/docs/disciplines/debugging.md +207 -0
  10. package/.aether/docs/disciplines/learning.md +254 -0
  11. package/.aether/docs/disciplines/tdd.md +257 -0
  12. package/.aether/docs/disciplines/verification-loop.md +167 -0
  13. package/.aether/docs/disciplines/verification.md +116 -0
  14. package/.aether/docs/error-codes.md +268 -0
  15. package/.aether/docs/known-issues.md +233 -0
  16. package/.aether/docs/pheromones.md +205 -0
  17. package/.aether/docs/queen-commands.md +97 -0
  18. package/.aether/exchange/colony-registry.xml +11 -0
  19. package/.aether/exchange/pheromone-xml.sh +575 -0
  20. package/.aether/exchange/pheromones.xml +87 -0
  21. package/.aether/exchange/queen-wisdom.xml +14 -0
  22. package/.aether/exchange/registry-xml.sh +273 -0
  23. package/.aether/exchange/wisdom-xml.sh +319 -0
  24. package/.aether/midden/approach-changes.md +5 -0
  25. package/.aether/midden/build-failures.md +5 -0
  26. package/.aether/midden/test-failures.md +5 -0
  27. package/.aether/model-profiles.yaml +100 -0
  28. package/.aether/rules/aether-colony.md +134 -0
  29. package/.aether/schemas/aether-types.xsd +255 -0
  30. package/.aether/schemas/colony-registry.xsd +309 -0
  31. package/.aether/schemas/example-prompt-builder.xml +234 -0
  32. package/.aether/schemas/pheromone.xsd +163 -0
  33. package/.aether/schemas/prompt.xsd +416 -0
  34. package/.aether/schemas/queen-wisdom.xsd +325 -0
  35. package/.aether/schemas/worker-priming.xsd +276 -0
  36. package/.aether/templates/QUEEN.md.template +79 -0
  37. package/.aether/templates/colony-state-reset.jq.template +22 -0
  38. package/.aether/templates/colony-state.template.json +35 -0
  39. package/.aether/templates/constraints.template.json +9 -0
  40. package/.aether/templates/crowned-anthill.template.md +36 -0
  41. package/.aether/templates/handoff-build-error.template.md +30 -0
  42. package/.aether/templates/handoff-build-success.template.md +39 -0
  43. package/.aether/templates/handoff.template.md +40 -0
  44. package/.aether/templates/learning-observations.template.json +6 -0
  45. package/.aether/templates/midden.template.json +7 -0
  46. package/.aether/templates/pheromones.template.json +6 -0
  47. package/.aether/templates/session.template.json +9 -0
  48. package/.aether/utils/atomic-write.sh +219 -0
  49. package/.aether/utils/chamber-compare.sh +193 -0
  50. package/.aether/utils/chamber-utils.sh +297 -0
  51. package/.aether/utils/colorize-log.sh +132 -0
  52. package/.aether/utils/error-handler.sh +212 -0
  53. package/.aether/utils/file-lock.sh +158 -0
  54. package/.aether/utils/queen-to-md.xsl +395 -0
  55. package/.aether/utils/semantic-cli.sh +413 -0
  56. package/.aether/utils/spawn-tree.sh +428 -0
  57. package/.aether/utils/spawn-with-model.sh +56 -0
  58. package/.aether/utils/state-loader.sh +215 -0
  59. package/.aether/utils/swarm-display.sh +268 -0
  60. package/.aether/utils/watch-spawn-tree.sh +253 -0
  61. package/.aether/utils/xml-compose.sh +253 -0
  62. package/.aether/utils/xml-convert.sh +273 -0
  63. package/.aether/utils/xml-core.sh +186 -0
  64. package/.aether/utils/xml-query.sh +201 -0
  65. package/.aether/utils/xml-utils.sh +110 -0
  66. package/.aether/workers.md +765 -0
  67. package/.claude/agents/ant/aether-ambassador.md +264 -0
  68. package/.claude/agents/ant/aether-archaeologist.md +322 -0
  69. package/.claude/agents/ant/aether-auditor.md +266 -0
  70. package/.claude/agents/ant/aether-builder.md +187 -0
  71. package/.claude/agents/ant/aether-chaos.md +268 -0
  72. package/.claude/agents/ant/aether-chronicler.md +304 -0
  73. package/.claude/agents/ant/aether-gatekeeper.md +325 -0
  74. package/.claude/agents/ant/aether-includer.md +373 -0
  75. package/.claude/agents/ant/aether-keeper.md +271 -0
  76. package/.claude/agents/ant/aether-measurer.md +317 -0
  77. package/.claude/agents/ant/aether-probe.md +210 -0
  78. package/.claude/agents/ant/aether-queen.md +325 -0
  79. package/.claude/agents/ant/aether-route-setter.md +173 -0
  80. package/.claude/agents/ant/aether-sage.md +353 -0
  81. package/.claude/agents/ant/aether-scout.md +142 -0
  82. package/.claude/agents/ant/aether-surveyor-disciplines.md +416 -0
  83. package/.claude/agents/ant/aether-surveyor-nest.md +354 -0
  84. package/.claude/agents/ant/aether-surveyor-pathogens.md +288 -0
  85. package/.claude/agents/ant/aether-surveyor-provisions.md +359 -0
  86. package/.claude/agents/ant/aether-tracker.md +265 -0
  87. package/.claude/agents/ant/aether-watcher.md +244 -0
  88. package/.claude/agents/ant/aether-weaver.md +247 -0
  89. package/.claude/commands/ant/archaeology.md +341 -0
  90. package/.claude/commands/ant/build.md +1160 -0
  91. package/.claude/commands/ant/chaos.md +349 -0
  92. package/.claude/commands/ant/colonize.md +270 -0
  93. package/.claude/commands/ant/continue.md +1070 -0
  94. package/.claude/commands/ant/council.md +309 -0
  95. package/.claude/commands/ant/dream.md +265 -0
  96. package/.claude/commands/ant/entomb.md +487 -0
  97. package/.claude/commands/ant/feedback.md +78 -0
  98. package/.claude/commands/ant/flag.md +139 -0
  99. package/.claude/commands/ant/flags.md +155 -0
  100. package/.claude/commands/ant/focus.md +58 -0
  101. package/.claude/commands/ant/help.md +122 -0
  102. package/.claude/commands/ant/history.md +137 -0
  103. package/.claude/commands/ant/init.md +409 -0
  104. package/.claude/commands/ant/interpret.md +267 -0
  105. package/.claude/commands/ant/lay-eggs.md +201 -0
  106. package/.claude/commands/ant/maturity.md +102 -0
  107. package/.claude/commands/ant/memory-details.md +77 -0
  108. package/.claude/commands/ant/migrate-state.md +165 -0
  109. package/.claude/commands/ant/oracle.md +387 -0
  110. package/.claude/commands/ant/organize.md +227 -0
  111. package/.claude/commands/ant/pause-colony.md +247 -0
  112. package/.claude/commands/ant/phase.md +126 -0
  113. package/.claude/commands/ant/plan.md +544 -0
  114. package/.claude/commands/ant/redirect.md +58 -0
  115. package/.claude/commands/ant/resume-colony.md +182 -0
  116. package/.claude/commands/ant/resume.md +363 -0
  117. package/.claude/commands/ant/seal.md +306 -0
  118. package/.claude/commands/ant/status.md +272 -0
  119. package/.claude/commands/ant/swarm.md +361 -0
  120. package/.claude/commands/ant/tunnels.md +425 -0
  121. package/.claude/commands/ant/update.md +209 -0
  122. package/.claude/commands/ant/verify-castes.md +95 -0
  123. package/.claude/commands/ant/watch.md +238 -0
  124. package/.opencode/agents/aether-ambassador.md +140 -0
  125. package/.opencode/agents/aether-archaeologist.md +108 -0
  126. package/.opencode/agents/aether-auditor.md +144 -0
  127. package/.opencode/agents/aether-builder.md +184 -0
  128. package/.opencode/agents/aether-chaos.md +115 -0
  129. package/.opencode/agents/aether-chronicler.md +122 -0
  130. package/.opencode/agents/aether-gatekeeper.md +116 -0
  131. package/.opencode/agents/aether-includer.md +117 -0
  132. package/.opencode/agents/aether-keeper.md +177 -0
  133. package/.opencode/agents/aether-measurer.md +128 -0
  134. package/.opencode/agents/aether-probe.md +133 -0
  135. package/.opencode/agents/aether-queen.md +286 -0
  136. package/.opencode/agents/aether-route-setter.md +130 -0
  137. package/.opencode/agents/aether-sage.md +106 -0
  138. package/.opencode/agents/aether-scout.md +101 -0
  139. package/.opencode/agents/aether-surveyor-disciplines.md +386 -0
  140. package/.opencode/agents/aether-surveyor-nest.md +324 -0
  141. package/.opencode/agents/aether-surveyor-pathogens.md +259 -0
  142. package/.opencode/agents/aether-surveyor-provisions.md +329 -0
  143. package/.opencode/agents/aether-tracker.md +137 -0
  144. package/.opencode/agents/aether-watcher.md +174 -0
  145. package/.opencode/agents/aether-weaver.md +130 -0
  146. package/.opencode/commands/ant/archaeology.md +338 -0
  147. package/.opencode/commands/ant/build.md +1200 -0
  148. package/.opencode/commands/ant/chaos.md +346 -0
  149. package/.opencode/commands/ant/colonize.md +202 -0
  150. package/.opencode/commands/ant/continue.md +938 -0
  151. package/.opencode/commands/ant/council.md +305 -0
  152. package/.opencode/commands/ant/dream.md +262 -0
  153. package/.opencode/commands/ant/entomb.md +367 -0
  154. package/.opencode/commands/ant/feedback.md +80 -0
  155. package/.opencode/commands/ant/flag.md +137 -0
  156. package/.opencode/commands/ant/flags.md +153 -0
  157. package/.opencode/commands/ant/focus.md +56 -0
  158. package/.opencode/commands/ant/help.md +124 -0
  159. package/.opencode/commands/ant/history.md +127 -0
  160. package/.opencode/commands/ant/init.md +337 -0
  161. package/.opencode/commands/ant/interpret.md +256 -0
  162. package/.opencode/commands/ant/lay-eggs.md +141 -0
  163. package/.opencode/commands/ant/maturity.md +92 -0
  164. package/.opencode/commands/ant/memory-details.md +77 -0
  165. package/.opencode/commands/ant/migrate-state.md +153 -0
  166. package/.opencode/commands/ant/oracle.md +338 -0
  167. package/.opencode/commands/ant/organize.md +224 -0
  168. package/.opencode/commands/ant/pause-colony.md +220 -0
  169. package/.opencode/commands/ant/phase.md +123 -0
  170. package/.opencode/commands/ant/plan.md +531 -0
  171. package/.opencode/commands/ant/redirect.md +67 -0
  172. package/.opencode/commands/ant/resume-colony.md +178 -0
  173. package/.opencode/commands/ant/resume.md +363 -0
  174. package/.opencode/commands/ant/seal.md +247 -0
  175. package/.opencode/commands/ant/status.md +272 -0
  176. package/.opencode/commands/ant/swarm.md +357 -0
  177. package/.opencode/commands/ant/tunnels.md +406 -0
  178. package/.opencode/commands/ant/update.md +191 -0
  179. package/.opencode/commands/ant/verify-castes.md +85 -0
  180. package/.opencode/commands/ant/watch.md +220 -0
  181. package/.opencode/opencode.json +3 -0
  182. package/CHANGELOG.md +325 -0
  183. package/DISCLAIMER.md +74 -0
  184. package/LICENSE +21 -0
  185. package/README.md +258 -0
  186. package/bin/cli.js +2436 -0
  187. package/bin/generate-commands.sh +291 -0
  188. package/bin/lib/caste-colors.js +57 -0
  189. package/bin/lib/colors.js +76 -0
  190. package/bin/lib/errors.js +255 -0
  191. package/bin/lib/event-types.js +190 -0
  192. package/bin/lib/file-lock.js +695 -0
  193. package/bin/lib/init.js +454 -0
  194. package/bin/lib/logger.js +242 -0
  195. package/bin/lib/model-profiles.js +445 -0
  196. package/bin/lib/model-verify.js +288 -0
  197. package/bin/lib/nestmate-loader.js +130 -0
  198. package/bin/lib/proxy-health.js +253 -0
  199. package/bin/lib/spawn-logger.js +266 -0
  200. package/bin/lib/state-guard.js +602 -0
  201. package/bin/lib/state-sync.js +516 -0
  202. package/bin/lib/telemetry.js +441 -0
  203. package/bin/lib/update-transaction.js +1454 -0
  204. package/bin/npx-install.js +178 -0
  205. package/bin/sync-to-runtime.sh +6 -0
  206. package/bin/validate-package.sh +88 -0
  207. package/package.json +70 -0
package/bin/cli.js ADDED
@@ -0,0 +1,2436 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { execSync } = require('child_process');
7
+ const { program } = require('commander');
8
+
9
+ // Error handling imports
10
+ const {
11
+ AetherError,
12
+ HubError,
13
+ RepoError,
14
+ GitError,
15
+ ValidationError,
16
+ FileSystemError,
17
+ ConfigurationError,
18
+ getExitCode,
19
+ wrapError,
20
+ } = require('./lib/errors');
21
+ const { logError, logActivity } = require('./lib/logger');
22
+ const { UpdateTransaction, UpdateError, UpdateErrorCodes } = require('./lib/update-transaction');
23
+ const { initializeRepo, isInitialized } = require('./lib/init');
24
+ const { syncStateFromPlanning, reconcileStates } = require('./lib/state-sync');
25
+ const { createVerificationReport } = require('./lib/model-verify');
26
+ const {
27
+ loadModelProfiles,
28
+ getAllAssignments,
29
+ getProviderForModel,
30
+ validateCaste,
31
+ validateModel,
32
+ setModelOverride,
33
+ resetModelOverride,
34
+ getEffectiveModel,
35
+ getUserOverrides,
36
+ getModelMetadata,
37
+ getProxyConfig,
38
+ } = require('./lib/model-profiles');
39
+ const {
40
+ checkProxyHealth,
41
+ verifyModelRouting,
42
+ formatProxyStatusColored,
43
+ } = require('./lib/proxy-health');
44
+ const { findNestmates, formatNestmates, loadNestmateTodos } = require('./lib/nestmate-loader');
45
+ const { logSpawn, formatSpawnTree } = require('./lib/spawn-logger');
46
+ const {
47
+ getTelemetrySummary,
48
+ getModelPerformance,
49
+ } = require('./lib/telemetry');
50
+
51
+ // Color palette
52
+ const c = require('./lib/colors');
53
+
54
+ const VERSION = require('../package.json').version;
55
+ const PACKAGE_DIR = path.resolve(__dirname, '..');
56
+ const HOME = process.env.HOME || process.env.USERPROFILE;
57
+ if (!HOME) {
58
+ const error = new ConfigurationError(
59
+ 'HOME environment variable is not set',
60
+ { env: Object.keys(process.env).filter(k => k.includes('HOME') || k.includes('USER')) },
61
+ 'Please ensure HOME or USERPROFILE is defined'
62
+ );
63
+ console.error(JSON.stringify(error.toJSON(), null, 2));
64
+ process.exit(getExitCode(error.code));
65
+ }
66
+
67
+ // Claude Code paths (global)
68
+ const COMMANDS_SRC = path.join(PACKAGE_DIR, 'commands', 'ant');
69
+ const COMMANDS_DEST = path.join(HOME, '.claude', 'commands', 'ant');
70
+ const AGENTS_DEST = path.join(HOME, '.claude', 'agents', 'ant');
71
+
72
+ // OpenCode paths (global)
73
+ const OPENCODE_COMMANDS_DEST = path.join(HOME, '.opencode', 'command');
74
+ const OPENCODE_AGENTS_DEST = path.join(HOME, '.opencode', 'agent');
75
+
76
+ // Hub paths
77
+ const HUB_DIR = path.join(HOME, '.aether');
78
+ const HUB_SYSTEM_DIR = path.join(HUB_DIR, 'system');
79
+ const HUB_COMMANDS_CLAUDE = path.join(HUB_SYSTEM_DIR, 'commands', 'claude');
80
+ const HUB_COMMANDS_OPENCODE = path.join(HUB_SYSTEM_DIR, 'commands', 'opencode');
81
+ const HUB_AGENTS = path.join(HUB_SYSTEM_DIR, 'agents');
82
+ const HUB_AGENTS_CLAUDE = path.join(HUB_SYSTEM_DIR, 'agents-claude');
83
+ const HUB_RULES = path.join(HUB_SYSTEM_DIR, 'rules');
84
+ const HUB_REGISTRY = path.join(HUB_DIR, 'registry.json');
85
+ const HUB_VERSION = path.join(HUB_DIR, 'version.json');
86
+
87
+ // Global quiet flag (set by --quiet option)
88
+ let globalQuiet = false;
89
+
90
+ // Global error handlers
91
+ process.on('uncaughtException', (error) => {
92
+ const structuredError = wrapError(error);
93
+ structuredError.code = 'E_UNCAUGHT_EXCEPTION';
94
+ structuredError.recovery = 'Please report this issue with the error details';
95
+
96
+ // Log to activity.log
97
+ logError(structuredError);
98
+
99
+ // Output structured JSON to stderr
100
+ console.error(JSON.stringify(structuredError.toJSON(), null, 2));
101
+
102
+ // Exit with appropriate code
103
+ process.exit(getExitCode(structuredError.code));
104
+ });
105
+
106
+ process.on('unhandledRejection', (reason, promise) => {
107
+ const message = reason instanceof Error ? reason.message : String(reason);
108
+ const details = reason instanceof Error ? { stack: reason.stack, name: reason.name } : {};
109
+
110
+ const error = new AetherError(
111
+ 'E_UNHANDLED_REJECTION',
112
+ message,
113
+ { ...details, promise: String(promise) },
114
+ 'Please report this issue with the error details'
115
+ );
116
+
117
+ // Log to activity.log
118
+ logError(error);
119
+
120
+ // Output structured JSON to stderr
121
+ console.error(JSON.stringify(error.toJSON(), null, 2));
122
+
123
+ // Exit with appropriate code
124
+ process.exit(getExitCode(error.code));
125
+ });
126
+
127
+ /**
128
+ * Feature Flags class for graceful degradation
129
+ * Tracks which features are available vs degraded
130
+ */
131
+ class FeatureFlags {
132
+ constructor() {
133
+ this.features = {
134
+ activityLog: true,
135
+ progressDisplay: true,
136
+ gitIntegration: true,
137
+ hashComparison: true,
138
+ manifestTracking: true,
139
+ };
140
+ this.degradedFeatures = new Set();
141
+ }
142
+
143
+ /**
144
+ * Disable a feature with a reason
145
+ * @param {string} feature - Feature name
146
+ * @param {string} reason - Why the feature was disabled
147
+ */
148
+ disable(feature, reason) {
149
+ if (this.features.hasOwnProperty(feature)) {
150
+ this.features[feature] = false;
151
+ this.degradedFeatures.add({ feature, reason, timestamp: new Date().toISOString() });
152
+
153
+ // Log degradation warning
154
+ console.warn(JSON.stringify({
155
+ warning: {
156
+ type: 'FEATURE_DEGRADED',
157
+ feature,
158
+ reason,
159
+ timestamp: new Date().toISOString(),
160
+ },
161
+ }));
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Check if a feature is enabled
167
+ * @param {string} feature - Feature name
168
+ * @returns {boolean} True if enabled
169
+ */
170
+ isEnabled(feature) {
171
+ return this.features[feature] || false;
172
+ }
173
+
174
+ /**
175
+ * Get list of degraded features
176
+ * @returns {Array} Array of degraded feature objects
177
+ */
178
+ getDegradedFeatures() {
179
+ return Array.from(this.degradedFeatures);
180
+ }
181
+ }
182
+
183
+ // Global feature flags instance
184
+ const features = new FeatureFlags();
185
+
186
+ /**
187
+ * Wrap a command function with error handling
188
+ * @param {Function} commandFn - Async command function to wrap
189
+ * @param {object} options - Options for error handling
190
+ * @param {boolean} options.logActivity - Whether to log activity (default: true)
191
+ * @returns {Function} Wrapped function
192
+ */
193
+ function wrapCommand(commandFn, options = {}) {
194
+ const { logActivity: shouldLog = true } = options;
195
+
196
+ return async (...args) => {
197
+ try {
198
+ return await commandFn(...args);
199
+ } catch (error) {
200
+ let structuredError;
201
+
202
+ if (error instanceof AetherError) {
203
+ structuredError = error;
204
+ } else {
205
+ structuredError = wrapError(error);
206
+ }
207
+
208
+ // Log to activity.log
209
+ if (shouldLog) {
210
+ logError(structuredError);
211
+ }
212
+
213
+ // Output structured JSON to stderr
214
+ console.error(JSON.stringify(structuredError.toJSON(), null, 2));
215
+
216
+ // Exit with appropriate code
217
+ process.exit(getExitCode(structuredError.code));
218
+ }
219
+ };
220
+ }
221
+
222
+ function log(msg) {
223
+ if (!globalQuiet) console.log(msg);
224
+ }
225
+
226
+ /**
227
+ * Format UpdateError with prominent recovery commands display
228
+ * @param {UpdateError} error - The update error to format
229
+ * @returns {string} Formatted error message with recovery box
230
+ */
231
+ function formatUpdateError(error) {
232
+ const lines = [];
233
+
234
+ // Header box
235
+ const headerWidth = 62;
236
+ lines.push(c.error('╔' + '═'.repeat(headerWidth) + '╗'));
237
+ lines.push(c.error('║') + ' UPDATE FAILED'.padEnd(headerWidth) + c.error('║'));
238
+ lines.push(c.error('╚' + '═'.repeat(headerWidth) + '╝'));
239
+ lines.push('');
240
+
241
+ // Error code and message
242
+ lines.push(`Error: ${c.bold(error.code)} - ${error.message}`);
243
+ lines.push('');
244
+
245
+ // Details section
246
+ if (error.details && Object.keys(error.details).length > 0) {
247
+ lines.push('Details:');
248
+
249
+ // Handle specific error types with formatted details
250
+ switch (error.code) {
251
+ case UpdateErrorCodes.E_REPO_DIRTY:
252
+ if (error.details.trackedCount > 0) {
253
+ lines.push(` Modified files: ${error.details.trackedCount}`);
254
+ }
255
+ if (error.details.untrackedCount > 0) {
256
+ lines.push(` Untracked files: ${error.details.untrackedCount}`);
257
+ }
258
+ if (error.details.stagedCount > 0) {
259
+ lines.push(` Staged files: ${error.details.stagedCount}`);
260
+ }
261
+ break;
262
+
263
+ case UpdateErrorCodes.E_HUB_INACCESSIBLE:
264
+ if (error.details.errors) {
265
+ for (const err of error.details.errors.slice(0, 3)) {
266
+ lines.push(` - ${err}`);
267
+ }
268
+ }
269
+ break;
270
+
271
+ case UpdateErrorCodes.E_PARTIAL_UPDATE:
272
+ lines.push(` Missing files: ${error.details.missingCount || 0}`);
273
+ lines.push(` Corrupted files: ${error.details.corruptedCount || 0}`);
274
+ break;
275
+
276
+ case UpdateErrorCodes.E_NETWORK_ERROR:
277
+ lines.push(` Hub directory: ${error.details.hubDir || 'unknown'}`);
278
+ if (error.details.errorCode) {
279
+ lines.push(` Error code: ${error.details.errorCode}`);
280
+ }
281
+ break;
282
+
283
+ default:
284
+ // Generic details display
285
+ for (const [key, value] of Object.entries(error.details)) {
286
+ if (typeof value === 'number' || typeof value === 'string') {
287
+ lines.push(` ${key}: ${value}`);
288
+ }
289
+ }
290
+ }
291
+ lines.push('');
292
+ }
293
+
294
+ // Recovery commands box
295
+ if (error.recoveryCommands && error.recoveryCommands.length > 0) {
296
+ const maxCmdLength = Math.max(...error.recoveryCommands.map(cmd => cmd.length));
297
+ const boxWidth = Math.max(maxCmdLength + 4, 40);
298
+
299
+ lines.push(c.warning('╔' + '═'.repeat(boxWidth) + '╗'));
300
+ lines.push(c.warning('║') + ' RECOVERY COMMANDS'.padEnd(boxWidth) + c.warning('║'));
301
+ lines.push(c.warning('║') + ' '.repeat(boxWidth) + c.warning('║'));
302
+
303
+ for (const cmd of error.recoveryCommands) {
304
+ lines.push(c.warning('║') + ' ' + c.bold(cmd).padEnd(boxWidth - 2) + c.warning('║'));
305
+ }
306
+
307
+ lines.push(c.warning('║') + ' '.repeat(boxWidth) + c.warning('║'));
308
+
309
+ // Add specific guidance based on error type
310
+ switch (error.code) {
311
+ case UpdateErrorCodes.E_REPO_DIRTY:
312
+ lines.push(c.warning('║') + ' Or to discard changes (DANGER):'.padEnd(boxWidth) + c.warning('║'));
313
+ lines.push(c.warning('║') + ' ' + c.bold('git checkout -- .').padEnd(boxWidth - 2) + c.warning('║'));
314
+ break;
315
+
316
+ case UpdateErrorCodes.E_HUB_INACCESSIBLE:
317
+ lines.push(c.warning('║') + ' Or to reinstall hub:'.padEnd(boxWidth) + c.warning('║'));
318
+ lines.push(c.warning('║') + ' ' + c.bold('aether install').padEnd(boxWidth - 2) + c.warning('║'));
319
+ break;
320
+
321
+ case UpdateErrorCodes.E_PARTIAL_UPDATE:
322
+ case UpdateErrorCodes.E_NETWORK_ERROR:
323
+ lines.push(c.warning('║') + ' Then retry:'.padEnd(boxWidth) + c.warning('║'));
324
+ lines.push(c.warning('║') + ' ' + c.bold('aether update').padEnd(boxWidth - 2) + c.warning('║'));
325
+ break;
326
+
327
+ case UpdateErrorCodes.E_VERIFY_FAILED:
328
+ lines.push(c.warning('║') + ' Or restore checkpoint:'.padEnd(boxWidth) + c.warning('║'));
329
+ if (error.details?.checkpoint_id) {
330
+ lines.push(c.warning('║') + ` ${c.bold(`aether checkpoint restore ${error.details.checkpoint_id}`)}`.padEnd(boxWidth) + c.warning('║'));
331
+ }
332
+ break;
333
+ }
334
+
335
+ lines.push(c.warning('╚' + '═'.repeat(boxWidth) + '╝'));
336
+ }
337
+
338
+ // Checkpoint ID if available
339
+ if (error.details?.checkpoint_id) {
340
+ lines.push('');
341
+ lines.push(`Checkpoint ID: ${c.dim(error.details.checkpoint_id)} (for manual restore if needed)`);
342
+ }
343
+
344
+ return lines.join('\n');
345
+ }
346
+
347
+ function copyDirSync(src, dest) {
348
+ fs.mkdirSync(dest, { recursive: true });
349
+ const entries = fs.readdirSync(src, { withFileTypes: true });
350
+ let count = 0;
351
+ for (const entry of entries) {
352
+ const srcPath = path.join(src, entry.name);
353
+ const destPath = path.join(dest, entry.name);
354
+ if (entry.isDirectory()) {
355
+ count += copyDirSync(srcPath, destPath);
356
+ } else {
357
+ fs.copyFileSync(srcPath, destPath);
358
+ // Preserve executable bit for shell scripts
359
+ if (entry.name.endsWith('.sh')) {
360
+ fs.chmodSync(destPath, 0o755);
361
+ }
362
+ count++;
363
+ }
364
+ }
365
+ return count;
366
+ }
367
+
368
+ function removeDirSync(dir) {
369
+ if (!fs.existsSync(dir)) return 0;
370
+ let count = 0;
371
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
372
+ for (const entry of entries) {
373
+ const fullPath = path.join(dir, entry.name);
374
+ if (entry.isDirectory()) {
375
+ count += removeDirSync(fullPath);
376
+ } else {
377
+ fs.unlinkSync(fullPath);
378
+ count++;
379
+ }
380
+ }
381
+ fs.rmdirSync(dir);
382
+ return count;
383
+ }
384
+
385
+ // Remove only files from dest that exist in source (safe for shared directories)
386
+ function removeFilesFromSource(sourceDir, destDir) {
387
+ if (!fs.existsSync(sourceDir) || !fs.existsSync(destDir)) return 0;
388
+ let count = 0;
389
+ const sourceFiles = fs.readdirSync(sourceDir).filter(f => f.endsWith('.md'));
390
+ for (const file of sourceFiles) {
391
+ const destPath = path.join(destDir, file);
392
+ if (fs.existsSync(destPath)) {
393
+ fs.unlinkSync(destPath);
394
+ count++;
395
+ }
396
+ }
397
+ return count;
398
+ }
399
+
400
+ function readJsonSafe(filePath) {
401
+ try {
402
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
403
+ } catch {
404
+ return null;
405
+ }
406
+ }
407
+
408
+ function writeJsonSync(filePath, data) {
409
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
410
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
411
+ }
412
+
413
+ function hashFileSync(filePath) {
414
+ try {
415
+ const content = fs.readFileSync(filePath);
416
+ return 'sha256:' + crypto.createHash('sha256').update(content).digest('hex');
417
+ } catch (err) {
418
+ console.error(`Warning: could not hash ${filePath}: ${err.message}`);
419
+ return null;
420
+ }
421
+ }
422
+
423
+ function validateManifest(manifest) {
424
+ if (!manifest || typeof manifest !== 'object') {
425
+ return { valid: false, error: 'Manifest must be an object' };
426
+ }
427
+ if (!manifest.generated_at || typeof manifest.generated_at !== 'string') {
428
+ return { valid: false, error: 'Manifest missing required field: generated_at' };
429
+ }
430
+ if (!manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) {
431
+ return { valid: false, error: 'Manifest missing required field: files' };
432
+ }
433
+ return { valid: true };
434
+ }
435
+
436
+ function listFilesRecursive(dir, base) {
437
+ base = base || dir;
438
+ const results = [];
439
+ if (!fs.existsSync(dir)) return results;
440
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
441
+ for (const entry of entries) {
442
+ if (entry.name.startsWith('.')) continue;
443
+ const fullPath = path.join(dir, entry.name);
444
+ if (entry.isDirectory()) {
445
+ results.push(...listFilesRecursive(fullPath, base));
446
+ } else {
447
+ results.push(path.relative(base, fullPath));
448
+ }
449
+ }
450
+ return results;
451
+ }
452
+
453
+ function cleanEmptyDirs(dir) {
454
+ if (!fs.existsSync(dir)) return;
455
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
456
+ for (const entry of entries) {
457
+ if (entry.isDirectory()) {
458
+ cleanEmptyDirs(path.join(dir, entry.name));
459
+ }
460
+ }
461
+ // Re-read after recursive cleanup
462
+ const remaining = fs.readdirSync(dir);
463
+ if (remaining.length === 0) {
464
+ fs.rmdirSync(dir);
465
+ }
466
+ }
467
+
468
+ function generateManifest(hubDir) {
469
+ const files = {};
470
+ const allFiles = listFilesRecursive(hubDir);
471
+ for (const relPath of allFiles) {
472
+ // Skip registry, version, and manifest metadata files
473
+ if (relPath === 'registry.json' || relPath === 'version.json' || relPath === 'manifest.json') continue;
474
+ const fullPath = path.join(hubDir, relPath);
475
+ const hash = hashFileSync(fullPath);
476
+ // Skip files that couldn't be hashed (permission issues, etc.)
477
+ if (hash) {
478
+ files[relPath] = hash;
479
+ }
480
+ }
481
+ return { generated_at: new Date().toISOString(), files };
482
+ }
483
+
484
+ function syncDirWithCleanup(src, dest, opts) {
485
+ opts = opts || {};
486
+ const dryRun = opts.dryRun || false;
487
+ try {
488
+ fs.mkdirSync(dest, { recursive: true });
489
+ } catch (err) {
490
+ if (err.code !== 'EEXIST') {
491
+ console.error(`Warning: could not create directory ${dest}: ${err.message}`);
492
+ }
493
+ }
494
+
495
+ // Copy phase with hash comparison
496
+ let copied = 0;
497
+ let skipped = 0;
498
+ const srcFiles = listFilesRecursive(src);
499
+ if (!dryRun) {
500
+ for (const relPath of srcFiles) {
501
+ const srcPath = path.join(src, relPath);
502
+ const destPath = path.join(dest, relPath);
503
+ try {
504
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
505
+
506
+ // Hash comparison: only copy if file doesn't exist or hash differs
507
+ let shouldCopy = true;
508
+ if (fs.existsSync(destPath)) {
509
+ const srcHash = hashFileSync(srcPath);
510
+ const destHash = hashFileSync(destPath);
511
+ if (srcHash === destHash) {
512
+ shouldCopy = false;
513
+ skipped++;
514
+ }
515
+ }
516
+
517
+ if (shouldCopy) {
518
+ fs.copyFileSync(srcPath, destPath);
519
+ if (relPath.endsWith('.sh')) {
520
+ fs.chmodSync(destPath, 0o755);
521
+ }
522
+ copied++;
523
+ }
524
+ } catch (err) {
525
+ console.error(`Warning: could not copy ${relPath}: ${err.message}`);
526
+ skipped++;
527
+ }
528
+ }
529
+ } else {
530
+ copied = srcFiles.length;
531
+ }
532
+
533
+ // Cleanup phase — remove files in dest that aren't in src
534
+ const destFiles = listFilesRecursive(dest);
535
+ const srcSet = new Set(srcFiles);
536
+ const removed = [];
537
+ for (const relPath of destFiles) {
538
+ if (!srcSet.has(relPath)) {
539
+ removed.push(relPath);
540
+ if (!dryRun) {
541
+ try {
542
+ fs.unlinkSync(path.join(dest, relPath));
543
+ } catch (err) {
544
+ console.error(`Warning: could not remove ${relPath}: ${err.message}`);
545
+ }
546
+ }
547
+ }
548
+ }
549
+
550
+ if (!dryRun && removed.length > 0) {
551
+ try {
552
+ cleanEmptyDirs(dest);
553
+ } catch (err) {
554
+ console.error(`Warning: could not clean directories: ${err.message}`);
555
+ }
556
+ }
557
+
558
+ return { copied, removed, skipped };
559
+ }
560
+
561
+ function computeFileHash(filePath) {
562
+ try {
563
+ const content = fs.readFileSync(filePath);
564
+ return crypto.createHash('sha256').update(content).digest('hex');
565
+ } catch {
566
+ return null;
567
+ }
568
+ }
569
+
570
+ // Checkpoint allowlist - only these files are captured in checkpoints
571
+ // NEVER include: data/, dreams/, oracle/, TO-DOs.md (user data)
572
+ // Note: runtime/ was removed in v4.0 — .aether/ is published directly
573
+ const CHECKPOINT_ALLOWLIST = [
574
+ '.aether/*.md', // All .md files directly in .aether/
575
+ '.claude/commands/ant/**', // All files in .claude/commands/ant/ recursively
576
+ '.claude/agents/ant/**', // All files in .claude/agents/ant/ recursively
577
+ '.opencode/commands/ant/**', // All files in .opencode/commands/ant/ recursively
578
+ '.opencode/agents/**', // All files in .opencode/agents/ recursively
579
+ 'bin/cli.js', // Specific file: bin/cli.js
580
+ ];
581
+
582
+ // Forbidden user data patterns - these are NEVER checkpointed
583
+ const USER_DATA_PATTERNS = [
584
+ 'data/',
585
+ 'dreams/',
586
+ 'oracle/',
587
+ 'TO-DOs.md',
588
+ ];
589
+
590
+ /**
591
+ * Check if a file path matches user data patterns
592
+ * @param {string} filePath - File path to check
593
+ * @returns {boolean} True if file is user data
594
+ */
595
+ function isUserData(filePath) {
596
+ for (const pattern of USER_DATA_PATTERNS) {
597
+ if (filePath.includes(pattern)) {
598
+ return true;
599
+ }
600
+ }
601
+ return false;
602
+ }
603
+
604
+ /**
605
+ * Check if a file is tracked by git
606
+ * @param {string} repoPath - Repository root path
607
+ * @param {string} filePath - File path relative to repo root
608
+ * @returns {boolean} True if file is tracked by git
609
+ */
610
+ function isGitTracked(repoPath, filePath) {
611
+ try {
612
+ execSync(`git ls-files --error-unmatch "${filePath}"`, {
613
+ cwd: repoPath,
614
+ stdio: 'pipe'
615
+ });
616
+ return true;
617
+ } catch {
618
+ return false;
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Get files matching the checkpoint allowlist
624
+ * @param {string} repoPath - Repository root path
625
+ * @returns {string[]} Array of file paths relative to repo root
626
+ */
627
+ function getAllowlistedFiles(repoPath) {
628
+ const files = [];
629
+
630
+ for (const pattern of CHECKPOINT_ALLOWLIST) {
631
+ if (pattern === 'bin/cli.js') {
632
+ // Specific file - must be tracked by git for stash to work
633
+ const fullPath = path.join(repoPath, pattern);
634
+ if (fs.existsSync(fullPath) && isGitTracked(repoPath, pattern)) {
635
+ files.push(pattern);
636
+ }
637
+ } else if (pattern === '.aether/*.md') {
638
+ // .md files directly in .aether/ (not subdirs)
639
+ const aetherDir = path.join(repoPath, '.aether');
640
+ if (fs.existsSync(aetherDir)) {
641
+ const entries = fs.readdirSync(aetherDir, { withFileTypes: true });
642
+ for (const entry of entries) {
643
+ if (entry.isFile() && entry.name.endsWith('.md')) {
644
+ const filePath = path.join('.aether', entry.name);
645
+ if (!isUserData(filePath) && isGitTracked(repoPath, filePath)) {
646
+ files.push(filePath);
647
+ }
648
+ }
649
+ }
650
+ }
651
+ } else if (pattern.endsWith('/**')) {
652
+ // Recursive directory pattern
653
+ const dirPath = pattern.slice(0, -3); // Remove '/**'
654
+ const fullDir = path.join(repoPath, dirPath);
655
+ if (fs.existsSync(fullDir)) {
656
+ const dirFiles = listFilesRecursive(fullDir);
657
+ for (const relFile of dirFiles) {
658
+ const filePath = path.join(dirPath, relFile);
659
+ if (!isUserData(filePath) && isGitTracked(repoPath, filePath)) {
660
+ files.push(filePath);
661
+ }
662
+ }
663
+ }
664
+ }
665
+ }
666
+
667
+ return files;
668
+ }
669
+
670
+ /**
671
+ * Generate checkpoint metadata with file hashes
672
+ * @param {string} repoPath - Repository root path
673
+ * @param {string} message - Optional checkpoint message
674
+ * @returns {object} Checkpoint metadata object
675
+ */
676
+ function generateCheckpointMetadata(repoPath, message) {
677
+ const now = new Date();
678
+ const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
679
+ const checkpointId = `chk_${now.toISOString().slice(0, 10).replace(/-/g, '')}_${now.toTimeString().slice(0, 8).replace(/:/g, '')}`;
680
+
681
+ const allowlistedFiles = getAllowlistedFiles(repoPath);
682
+ const files = {};
683
+ const excluded = [];
684
+
685
+ for (const filePath of allowlistedFiles) {
686
+ // Double-check for user data (safety)
687
+ if (isUserData(filePath)) {
688
+ excluded.push(filePath);
689
+ continue;
690
+ }
691
+
692
+ const fullPath = path.join(repoPath, filePath);
693
+ const hash = hashFileSync(fullPath);
694
+ if (hash) {
695
+ files[filePath] = hash;
696
+ }
697
+ }
698
+
699
+ return {
700
+ checkpoint_id: checkpointId,
701
+ created_at: now.toISOString(),
702
+ message: message || 'Checkpoint created',
703
+ files,
704
+ excluded: excluded.length > 0 ? excluded : undefined,
705
+ };
706
+ }
707
+
708
+ /**
709
+ * Save checkpoint metadata to .aether/checkpoints/
710
+ * @param {string} repoPath - Repository root path
711
+ * @param {object} metadata - Checkpoint metadata object
712
+ */
713
+ function saveCheckpointMetadata(repoPath, metadata) {
714
+ const checkpointsDir = path.join(repoPath, '.aether', 'checkpoints');
715
+ fs.mkdirSync(checkpointsDir, { recursive: true });
716
+ const metadataPath = path.join(checkpointsDir, `${metadata.checkpoint_id}.json`);
717
+ writeJsonSync(metadataPath, metadata);
718
+ }
719
+
720
+ /**
721
+ * Load checkpoint metadata by ID
722
+ * @param {string} repoPath - Repository root path
723
+ * @param {string} checkpointId - Checkpoint ID
724
+ * @returns {object|null} Checkpoint metadata or null if not found
725
+ */
726
+ function loadCheckpointMetadata(repoPath, checkpointId) {
727
+ const metadataPath = path.join(repoPath, '.aether', 'checkpoints', `${checkpointId}.json`);
728
+ return readJsonSafe(metadataPath);
729
+ }
730
+
731
+ function isGitRepo(repoPath) {
732
+ try {
733
+ execSync('git rev-parse --git-dir', { cwd: repoPath, stdio: 'pipe' });
734
+ return true;
735
+ } catch {
736
+ return false;
737
+ }
738
+ }
739
+
740
+ function getGitDirtyFiles(repoPath, targetDirs) {
741
+ try {
742
+ const args = targetDirs.filter(d => fs.existsSync(path.join(repoPath, d)));
743
+ if (args.length === 0) return [];
744
+ const result = execSync(`git status --porcelain -- ${args.map(d => `"${d}"`).join(' ')}`, {
745
+ cwd: repoPath,
746
+ stdio: 'pipe',
747
+ encoding: 'utf8',
748
+ });
749
+ return result.trim().split('\n').filter(Boolean).map(line => line.slice(3));
750
+ } catch {
751
+ return [];
752
+ }
753
+ }
754
+
755
+ function gitStashFiles(repoPath, files) {
756
+ try {
757
+ const fileArgs = files.map(f => `"${f}"`).join(' ');
758
+ execSync(`git stash push -m "aether-update-backup" -- ${fileArgs}`, {
759
+ cwd: repoPath,
760
+ stdio: 'pipe',
761
+ });
762
+ return true;
763
+ } catch (err) {
764
+ log(` Warning: git stash failed (${err.message}). Proceeding without stash.`);
765
+ return false;
766
+ }
767
+ }
768
+
769
+ // Directories to exclude from hub sync (user data, local state)
770
+ // 'rules' is excluded here because it is synced via a dedicated step (rulesSrc below)
771
+ const HUB_EXCLUDE_DIRS = ['data', 'dreams', 'checkpoints', 'locks', 'temp', 'rules'];
772
+
773
+ /**
774
+ * Check if a path should be excluded from hub sync
775
+ * @param {string} relPath - Relative path from .aether/
776
+ * @returns {boolean} True if should be excluded
777
+ */
778
+ function shouldExcludeFromHub(relPath) {
779
+ const parts = relPath.split(path.sep);
780
+ // Exclude if any part of the path is in the exclude list
781
+ return parts.some(part => HUB_EXCLUDE_DIRS.includes(part));
782
+ }
783
+
784
+ /**
785
+ * Sync .aether/ directory to hub, excluding user data directories
786
+ * @param {string} srcDir - Source .aether/ directory
787
+ * @param {string} destDir - Destination hub directory
788
+ * @returns {object} Sync result with copied, removed, skipped counts
789
+ */
790
+ function syncAetherToHub(srcDir, destDir) {
791
+ if (!fs.existsSync(srcDir)) {
792
+ return { copied: 0, removed: 0, skipped: 0 };
793
+ }
794
+
795
+ fs.mkdirSync(destDir, { recursive: true });
796
+
797
+ // Get all files in source, filtering out excluded directories
798
+ const srcFiles = [];
799
+ function collectFiles(dir, base) {
800
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
801
+ for (const entry of entries) {
802
+ if (entry.name.startsWith('.')) continue;
803
+ const fullPath = path.join(dir, entry.name);
804
+ const relPath = path.relative(base, fullPath);
805
+
806
+ if (shouldExcludeFromHub(relPath)) continue;
807
+
808
+ if (entry.isDirectory()) {
809
+ collectFiles(fullPath, base);
810
+ } else {
811
+ srcFiles.push(relPath);
812
+ }
813
+ }
814
+ }
815
+ collectFiles(srcDir, srcDir);
816
+
817
+ // Copy files with hash comparison
818
+ let copied = 0;
819
+ let skipped = 0;
820
+ for (const relPath of srcFiles) {
821
+ const srcPath = path.join(srcDir, relPath);
822
+ const destPath = path.join(destDir, relPath);
823
+
824
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
825
+
826
+ // Hash comparison
827
+ let shouldCopy = true;
828
+ if (fs.existsSync(destPath)) {
829
+ const srcHash = hashFileSync(srcPath);
830
+ const destHash = hashFileSync(destPath);
831
+ if (srcHash === destHash) {
832
+ shouldCopy = false;
833
+ skipped++;
834
+ }
835
+ }
836
+
837
+ if (shouldCopy) {
838
+ fs.copyFileSync(srcPath, destPath);
839
+ if (relPath.endsWith('.sh')) {
840
+ fs.chmodSync(destPath, 0o755);
841
+ }
842
+ copied++;
843
+ }
844
+ }
845
+
846
+ // Cleanup: remove files in dest that aren't in source (and aren't excluded)
847
+ const destFiles = [];
848
+ function collectDestFiles(dir, base) {
849
+ if (!fs.existsSync(dir)) return;
850
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
851
+ for (const entry of entries) {
852
+ if (entry.name.startsWith('.') || entry.name === 'registry.json' || entry.name === 'version.json' || entry.name === 'manifest.json') continue;
853
+ const fullPath = path.join(dir, entry.name);
854
+ const relPath = path.relative(base, fullPath);
855
+
856
+ if (shouldExcludeFromHub(relPath)) continue;
857
+
858
+ if (entry.isDirectory()) {
859
+ collectDestFiles(fullPath, base);
860
+ } else {
861
+ destFiles.push(relPath);
862
+ }
863
+ }
864
+ }
865
+ collectDestFiles(destDir, destDir);
866
+
867
+ const srcSet = new Set(srcFiles);
868
+ const removed = [];
869
+ for (const relPath of destFiles) {
870
+ if (!srcSet.has(relPath)) {
871
+ removed.push(relPath);
872
+ try {
873
+ fs.unlinkSync(path.join(destDir, relPath));
874
+ } catch (err) {
875
+ // Ignore cleanup errors
876
+ }
877
+ }
878
+ }
879
+
880
+ // Clean up empty directories
881
+ cleanEmptyDirs(destDir);
882
+
883
+ return { copied, removed, skipped };
884
+ }
885
+
886
+ function setupHub() {
887
+ // Create ~/.aether/ directory structure and populate from package
888
+ try {
889
+ fs.mkdirSync(HUB_DIR, { recursive: true });
890
+
891
+ // MIGRATION: Check for old structure and migrate to system/
892
+ const oldStructureFiles = [
893
+ path.join(HUB_DIR, 'aether-utils.sh'),
894
+ path.join(HUB_DIR, 'workers.md'),
895
+ ];
896
+ const hasOldStructure = oldStructureFiles.some(f => fs.existsSync(f));
897
+ const hasNewStructure = fs.existsSync(HUB_SYSTEM_DIR);
898
+
899
+ if (hasOldStructure && !hasNewStructure) {
900
+ log(' Migrating hub to new structure...');
901
+ fs.mkdirSync(HUB_SYSTEM_DIR, { recursive: true });
902
+
903
+ // Move system files to system/
904
+ const systemFiles = ['aether-utils.sh', 'workers.md', 'CONTEXT.md', 'model-profiles.yaml'];
905
+ const systemDirs = ['docs', 'utils', 'commands', 'agents', 'schemas', 'exchange', 'templates', 'lib'];
906
+
907
+ for (const file of systemFiles) {
908
+ const oldPath = path.join(HUB_DIR, file);
909
+ if (fs.existsSync(oldPath)) {
910
+ fs.renameSync(oldPath, path.join(HUB_SYSTEM_DIR, file));
911
+ }
912
+ }
913
+
914
+ for (const dir of systemDirs) {
915
+ const oldPath = path.join(HUB_DIR, dir);
916
+ if (fs.existsSync(oldPath)) {
917
+ fs.renameSync(oldPath, path.join(HUB_SYSTEM_DIR, dir));
918
+ }
919
+ }
920
+
921
+ log(' Migration complete: system files moved to ~/.aether/system/');
922
+ }
923
+
924
+ // Create system/ directory structure
925
+ fs.mkdirSync(HUB_SYSTEM_DIR, { recursive: true });
926
+ fs.mkdirSync(path.join(HUB_SYSTEM_DIR, 'commands', 'claude'), { recursive: true });
927
+ fs.mkdirSync(path.join(HUB_SYSTEM_DIR, 'commands', 'opencode'), { recursive: true });
928
+ fs.mkdirSync(path.join(HUB_SYSTEM_DIR, 'agents'), { recursive: true });
929
+ fs.mkdirSync(path.join(HUB_SYSTEM_DIR, 'agents-claude'), { recursive: true });
930
+ fs.mkdirSync(path.join(HUB_SYSTEM_DIR, 'rules'), { recursive: true });
931
+
932
+ // Read previous manifest for delta reporting
933
+ const prevManifestRaw = readJsonSafe(path.join(HUB_DIR, 'manifest.json'));
934
+ const prevManifest = prevManifestRaw && validateManifest(prevManifestRaw).valid ? prevManifestRaw : null;
935
+ if (prevManifestRaw && !prevManifest) {
936
+ log(` Warning: previous manifest is invalid, regenerating`);
937
+ }
938
+
939
+ // Sync .aether/ -> ~/.aether/system/ (direct packaging, no staging)
940
+ // v4.0: .aether/ is published directly — runtime/ staging removed
941
+ const aetherSrc = path.join(PACKAGE_DIR, '.aether');
942
+ if (fs.existsSync(aetherSrc)) {
943
+ const result = syncAetherToHub(aetherSrc, HUB_SYSTEM_DIR);
944
+ log(` Hub system: ${result.copied} files, ${result.skipped} unchanged -> ${HUB_SYSTEM_DIR}`);
945
+ if (result.removed.length > 0) {
946
+ log(` Hub system: removed ${result.removed.length} stale files`);
947
+ for (const f of result.removed) log(` - ${f}`);
948
+ }
949
+ }
950
+
951
+ // Migration message for users upgrading from pre-4.0 (runtime/ era)
952
+ const prevManifestForMigration = readJsonSafe(path.join(HUB_DIR, 'manifest.json'));
953
+ if (prevManifestForMigration && prevManifestForMigration.version && prevManifestForMigration.version.startsWith('3.')) {
954
+ log('');
955
+ log(' Distribution pipeline simplified (v4.0 change):');
956
+ log(' - runtime/ staging directory has been removed');
957
+ log(' - .aether/ is now published directly (private dirs excluded)');
958
+ log(' - Your colony state and data are unaffected');
959
+ log(' - See CHANGELOG.md for details');
960
+ log('');
961
+ }
962
+
963
+ // Clean up legacy directories from very old hub structure (pre-system/)
964
+ const legacyDirs = [
965
+ path.join(HUB_DIR, '.aether'),
966
+ path.join(HUB_DIR, 'visualizations'),
967
+ ];
968
+ for (const legacyDir of legacyDirs) {
969
+ if (fs.existsSync(legacyDir)) {
970
+ try {
971
+ removeDirSync(legacyDir);
972
+ log(` Cleaned up legacy: ${path.basename(legacyDir)}/`);
973
+ } catch (err) {
974
+ // Ignore cleanup errors
975
+ }
976
+ }
977
+ }
978
+
979
+ // Sync .claude/commands/ant/ -> ~/.aether/system/commands/claude/
980
+ const claudeCmdSrc = fs.existsSync(COMMANDS_SRC)
981
+ ? COMMANDS_SRC
982
+ : path.join(PACKAGE_DIR, '.claude', 'commands', 'ant');
983
+ if (fs.existsSync(claudeCmdSrc)) {
984
+ const result = syncDirWithCleanup(claudeCmdSrc, HUB_COMMANDS_CLAUDE);
985
+ log(` Hub commands (claude): ${result.copied} files -> ${HUB_COMMANDS_CLAUDE}`);
986
+ if (result.removed.length > 0) {
987
+ log(` Hub commands (claude): removed ${result.removed.length} stale files`);
988
+ for (const f of result.removed) log(` - ${f}`);
989
+ }
990
+ }
991
+
992
+ // Sync .opencode/commands/ant/ -> ~/.aether/system/commands/opencode/
993
+ const opencodeCmdSrc = path.join(PACKAGE_DIR, '.opencode', 'commands', 'ant');
994
+ if (fs.existsSync(opencodeCmdSrc)) {
995
+ const result = syncDirWithCleanup(opencodeCmdSrc, HUB_COMMANDS_OPENCODE);
996
+ log(` Hub commands (opencode): ${result.copied} files -> ${HUB_COMMANDS_OPENCODE}`);
997
+ if (result.removed.length > 0) {
998
+ log(` Hub commands (opencode): removed ${result.removed.length} stale files`);
999
+ for (const f of result.removed) log(` - ${f}`);
1000
+ }
1001
+ }
1002
+
1003
+ // Sync .opencode/agents/ -> ~/.aether/system/agents/
1004
+ const agentsSrc = path.join(PACKAGE_DIR, '.opencode', 'agents');
1005
+ if (fs.existsSync(agentsSrc)) {
1006
+ const result = syncDirWithCleanup(agentsSrc, HUB_AGENTS);
1007
+ log(` Hub agents: ${result.copied} files -> ${HUB_AGENTS}`);
1008
+ if (result.removed.length > 0) {
1009
+ log(` Hub agents: removed ${result.removed.length} stale files`);
1010
+ for (const f of result.removed) log(` - ${f}`);
1011
+ }
1012
+ }
1013
+
1014
+ // Sync .claude/agents/ant/ -> ~/.aether/system/agents-claude/
1015
+ const claudeAgentsSrc = path.join(PACKAGE_DIR, '.claude', 'agents', 'ant');
1016
+ if (fs.existsSync(claudeAgentsSrc)) {
1017
+ const result = syncDirWithCleanup(claudeAgentsSrc, HUB_AGENTS_CLAUDE);
1018
+ log(` Hub agents (claude): ${result.copied} files, ${result.skipped} unchanged -> ${HUB_AGENTS_CLAUDE}`);
1019
+ if (result.removed.length > 0) {
1020
+ log(` Hub agents (claude): removed ${result.removed.length} stale files`);
1021
+ for (const f of result.removed) log(` - ${f}`);
1022
+ }
1023
+ }
1024
+
1025
+ // Sync rules/ from .aether/ -> ~/.aether/system/rules/
1026
+ // v4.0: source is .aether/rules/ directly (no runtime/ staging)
1027
+ const rulesSrc = path.join(PACKAGE_DIR, '.aether', 'rules');
1028
+ if (fs.existsSync(rulesSrc)) {
1029
+ const result = syncDirWithCleanup(rulesSrc, HUB_RULES);
1030
+ log(` Hub rules: ${result.copied} files -> ${HUB_RULES}`);
1031
+ if (result.removed.length > 0) {
1032
+ log(` Hub rules: removed ${result.removed.length} stale files`);
1033
+ for (const f of result.removed) log(` - ${f}`);
1034
+ }
1035
+ }
1036
+
1037
+ // Create/preserve registry.json (at root, not in system/)
1038
+ if (!fs.existsSync(HUB_REGISTRY)) {
1039
+ writeJsonSync(HUB_REGISTRY, { schema_version: 1, repos: [] });
1040
+ log(` Registry: initialized ${HUB_REGISTRY}`);
1041
+ } else {
1042
+ log(` Registry: preserved existing ${HUB_REGISTRY}`);
1043
+ }
1044
+
1045
+ // Generate and write manifest (at root, tracks everything)
1046
+ const manifest = generateManifest(HUB_DIR);
1047
+ const manifestPath = path.join(HUB_DIR, 'manifest.json');
1048
+ writeJsonSync(manifestPath, manifest);
1049
+ const fileCount = Object.keys(manifest.files).length;
1050
+ log(` Manifest: ${fileCount} files tracked`);
1051
+
1052
+ // Report manifest delta
1053
+ if (prevManifest && prevManifest.files) {
1054
+ const prevKeys = new Set(Object.keys(prevManifest.files));
1055
+ const currKeys = new Set(Object.keys(manifest.files));
1056
+ const added = [...currKeys].filter(k => !prevKeys.has(k));
1057
+ const removed = [...prevKeys].filter(k => !currKeys.has(k));
1058
+ const changed = [...currKeys].filter(k => prevKeys.has(k) && prevManifest.files[k] !== manifest.files[k]);
1059
+ if (added.length || removed.length || changed.length) {
1060
+ log(` Manifest delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`);
1061
+ }
1062
+ }
1063
+
1064
+ // Write version.json (at root)
1065
+ writeJsonSync(HUB_VERSION, { version: VERSION, updated_at: new Date().toISOString() });
1066
+ log(` Hub version: ${VERSION}`);
1067
+ } catch (err) {
1068
+ // Hub setup failure doesn't block install
1069
+ log(` Hub setup warning: ${err.message}`);
1070
+ }
1071
+ }
1072
+
1073
+ async function updateRepo(repoPath, sourceVersion, opts) {
1074
+ opts = opts || {};
1075
+ const dryRun = opts.dryRun || false;
1076
+ const force = opts.force || false;
1077
+ const quiet = opts.quiet || false;
1078
+
1079
+ const repoAether = path.join(repoPath, '.aether');
1080
+ const repoVersionFile = path.join(repoAether, 'version.json');
1081
+
1082
+ if (!fs.existsSync(repoAether)) {
1083
+ return { status: 'skipped', reason: 'no .aether directory' };
1084
+ }
1085
+
1086
+ const currentVersion = readJsonSafe(repoVersionFile);
1087
+ const currentVer = currentVersion ? currentVersion.version : 'unknown';
1088
+
1089
+ // Target directories for git safety checks
1090
+ const targetDirs = ['.aether', '.claude/commands/ant', '.claude/agents/ant', '.claude/rules', '.opencode/commands/ant', '.opencode/agents'];
1091
+
1092
+ // Git safety: check for dirty files in target directories (skip in dry-run mode)
1093
+ let dirtyFiles = [];
1094
+ if (isGitRepo(repoPath)) {
1095
+ dirtyFiles = getGitDirtyFiles(repoPath, targetDirs);
1096
+ if (dirtyFiles.length > 0 && !force) {
1097
+ return { status: 'dirty', files: dirtyFiles };
1098
+ }
1099
+ // Note: --force handling is now done via checkpoint stash in UpdateTransaction
1100
+ }
1101
+
1102
+ // Use UpdateTransaction for two-phase commit with automatic rollback
1103
+ const transaction = new UpdateTransaction(repoPath, { sourceVersion, quiet, force });
1104
+
1105
+ try {
1106
+ const result = await transaction.execute(sourceVersion, { dryRun });
1107
+
1108
+ // Calculate file counts from sync result
1109
+ const systemCopied = result.sync_result?.system?.copied || 0;
1110
+ const commandsCopied = (result.sync_result?.commands?.copied || 0);
1111
+ const agentsCopied = result.sync_result?.agents?.copied || 0;
1112
+ const rulesCopied = result.sync_result?.rules?.copied || 0;
1113
+ const agentsClaudeCopied = result.sync_result?.agents_claude?.copied || 0;
1114
+
1115
+ const systemRemoved = result.sync_result?.system?.removed?.length || 0;
1116
+ const commandsRemoved = result.sync_result?.commands?.removed?.length || 0;
1117
+ const agentsRemoved = result.sync_result?.agents?.removed?.length || 0;
1118
+ const rulesRemoved = result.sync_result?.rules?.removed?.length || 0;
1119
+ const agentsClaudeRemoved = result.sync_result?.agents_claude?.removed?.length || 0;
1120
+
1121
+ const allRemovedFiles = [
1122
+ ...(result.sync_result?.system?.removed || []),
1123
+ ...(result.sync_result?.commands?.removed || []).map(f => `.claude/commands/ant/${f}`),
1124
+ ...(result.sync_result?.agents?.removed || []).map(f => `.opencode/agents/${f}`),
1125
+ ...(result.sync_result?.rules?.removed || []).map(f => `.claude/rules/${f}`),
1126
+ ...(result.sync_result?.agents_claude?.removed || []).map(f => `.claude/agents/ant/${f}`),
1127
+ ];
1128
+
1129
+ const cleanupResult = result.cleanup_result || { cleaned: [], failed: [] };
1130
+
1131
+ return {
1132
+ status: result.status,
1133
+ from: currentVer,
1134
+ to: sourceVersion,
1135
+ system: systemCopied,
1136
+ commands: commandsCopied,
1137
+ agents: agentsCopied,
1138
+ rules: rulesCopied,
1139
+ agentsClaude: agentsClaudeCopied,
1140
+ removed: systemRemoved + commandsRemoved + agentsRemoved + rulesRemoved + agentsClaudeRemoved,
1141
+ removedFiles: allRemovedFiles,
1142
+ stashCreated: !!transaction.checkpoint?.stashRef,
1143
+ checkpoint_id: result.checkpoint_id,
1144
+ cleanup: cleanupResult,
1145
+ };
1146
+ } catch (error) {
1147
+ // Handle UpdateError with recovery commands
1148
+ if (error instanceof UpdateError) {
1149
+ // Re-throw with additional context
1150
+ error.details = {
1151
+ ...error.details,
1152
+ repoPath,
1153
+ sourceVersion,
1154
+ from: currentVer,
1155
+ };
1156
+ }
1157
+ throw error;
1158
+ }
1159
+ }
1160
+
1161
+ // Commander.js program setup
1162
+ program
1163
+ .name('aether')
1164
+ .description('Aether Colony - Multi-agent system using ant colony intelligence')
1165
+ .version(VERSION, '-v, --version', 'show version')
1166
+ .option('--no-color', 'disable colored output')
1167
+ .option('-q, --quiet', 'suppress output')
1168
+ .helpOption('-h, --help', 'show help');
1169
+
1170
+ // Handle --no-color globally
1171
+ program.on('option:no-color', () => {
1172
+ process.env.NO_COLOR = '1';
1173
+ });
1174
+
1175
+ // Handle --quiet globally
1176
+ program.on('option:quiet', () => {
1177
+ globalQuiet = true;
1178
+ });
1179
+
1180
+ // Install command
1181
+ program
1182
+ .command('install')
1183
+ .description('Install commands and agents to ~/.claude/ and set up distribution hub')
1184
+ .action(wrapCommand(async () => {
1185
+ log(c.header(`aether-colony v${VERSION} — installing...`));
1186
+
1187
+ // Sync commands to ~/.claude/commands/ant/ (with orphan cleanup)
1188
+ if (!fs.existsSync(COMMANDS_SRC)) {
1189
+ // Running from source repo — commands are in .claude/commands/ant/
1190
+ const repoCommands = path.join(PACKAGE_DIR, '.claude', 'commands', 'ant');
1191
+ if (fs.existsSync(repoCommands)) {
1192
+ const result = syncDirWithCleanup(repoCommands, COMMANDS_DEST);
1193
+ log(` Commands: ${result.copied} files -> ${COMMANDS_DEST}`);
1194
+ if (result.removed.length > 0) {
1195
+ log(` Commands: removed ${result.removed.length} stale files`);
1196
+ for (const f of result.removed) log(` - ${f}`);
1197
+ }
1198
+ } else {
1199
+ console.error(' Commands source not found. Skipping.');
1200
+ }
1201
+ } else {
1202
+ const result = syncDirWithCleanup(COMMANDS_SRC, COMMANDS_DEST);
1203
+ log(` Commands: ${result.copied} files -> ${COMMANDS_DEST}`);
1204
+ if (result.removed.length > 0) {
1205
+ log(` Commands: removed ${result.removed.length} stale files`);
1206
+ for (const f of result.removed) log(` - ${f}`);
1207
+ }
1208
+ }
1209
+
1210
+ // Sync agents to ~/.claude/agents/ant/ (with orphan cleanup)
1211
+ const repoAgents = path.join(PACKAGE_DIR, '.claude', 'agents', 'ant');
1212
+ if (fs.existsSync(repoAgents)) {
1213
+ const result = syncDirWithCleanup(repoAgents, AGENTS_DEST);
1214
+ log(` Agents (claude): ${result.copied} files -> ${AGENTS_DEST}`);
1215
+ if (result.removed.length > 0) {
1216
+ log(` Agents (claude): removed ${result.removed.length} stale files`);
1217
+ for (const f of result.removed) log(` - ${f}`);
1218
+ }
1219
+ }
1220
+
1221
+ // Sync OpenCode commands to ~/.opencode/command/ (with orphan cleanup)
1222
+ const opencodeCmdsSrc = path.join(PACKAGE_DIR, '.opencode', 'commands', 'ant');
1223
+ if (fs.existsSync(opencodeCmdsSrc)) {
1224
+ const result = syncDirWithCleanup(opencodeCmdsSrc, OPENCODE_COMMANDS_DEST);
1225
+ log(` Commands (opencode): ${result.copied} files -> ${OPENCODE_COMMANDS_DEST}`);
1226
+ if (result.removed.length > 0) {
1227
+ log(` Commands (opencode): removed ${result.removed.length} stale files`);
1228
+ for (const f of result.removed) log(` - ${f}`);
1229
+ }
1230
+ }
1231
+
1232
+ // Sync OpenCode agents to ~/.opencode/agent/ (with orphan cleanup)
1233
+ const opencodeAgentsSrc = path.join(PACKAGE_DIR, '.opencode', 'agents');
1234
+ if (fs.existsSync(opencodeAgentsSrc)) {
1235
+ const result = syncDirWithCleanup(opencodeAgentsSrc, OPENCODE_AGENTS_DEST);
1236
+ log(` Agents (opencode): ${result.copied} files -> ${OPENCODE_AGENTS_DEST}`);
1237
+ if (result.removed.length > 0) {
1238
+ log(` Agents (opencode): removed ${result.removed.length} stale files`);
1239
+ for (const f of result.removed) log(` - ${f}`);
1240
+ }
1241
+ }
1242
+
1243
+ // Set up distribution hub at ~/.aether/
1244
+ log('');
1245
+ log(c.colony('Setting up distribution hub...'));
1246
+ setupHub();
1247
+
1248
+ log('');
1249
+ log(c.success('Install complete.'));
1250
+ log(` ${c.queen('Claude Code:')} run /ant to get started`);
1251
+ log(` ${c.colony('OpenCode:')} run /ant to get started`);
1252
+ log(` ${c.colony('Hub:')} ${c.dim('~/.aether/')} (for coordinated updates across repos)`);
1253
+ }));
1254
+
1255
+ // Update command
1256
+ program
1257
+ .command('update')
1258
+ .description('Update current repo from hub (use --all to update all registered repos)')
1259
+ .option('-f, --force', 'stash dirty files and force update')
1260
+ .option('-a, --all', 'update all registered repos')
1261
+ .option('-l, --list', 'show registered repos and versions')
1262
+ .option('-d, --dry-run', 'preview what would change without modifying files')
1263
+ .action(wrapCommand(async (options) => {
1264
+ const forceFlag = options.force || false;
1265
+ const allFlag = options.all || false;
1266
+ const listFlag = options.list || false;
1267
+ const dryRun = options.dryRun || false;
1268
+
1269
+ // Check hub exists
1270
+ if (!fs.existsSync(HUB_VERSION)) {
1271
+ const error = new HubError(
1272
+ 'No distribution hub found at ~/.aether/',
1273
+ { path: HUB_DIR }
1274
+ );
1275
+ logError(error);
1276
+ console.error(JSON.stringify(error.toJSON(), null, 2));
1277
+ process.exit(getExitCode(error.code));
1278
+ }
1279
+
1280
+ const hubVersion = readJsonSafe(HUB_VERSION);
1281
+ const sourceVersion = hubVersion ? hubVersion.version : VERSION;
1282
+
1283
+ if (listFlag) {
1284
+ // Show registered repos
1285
+ const registry = readJsonSafe(HUB_REGISTRY);
1286
+ if (!registry || registry.repos.length === 0) {
1287
+ console.log(c.info('No repos registered. Run the Claude Code slash command /ant:init in a repo to register it.'));
1288
+ return;
1289
+ }
1290
+ console.log(c.header(`Registered repos (hub v${sourceVersion}):\n`));
1291
+ for (const repo of registry.repos) {
1292
+ const exists = fs.existsSync(repo.path);
1293
+ const status = exists ? `v${repo.version}` : 'NOT FOUND';
1294
+ const marker = exists ? (repo.version === sourceVersion ? ' ' : '* ') : 'x ';
1295
+ console.log(`${marker}${repo.path} (${status})`);
1296
+ }
1297
+ console.log('');
1298
+ console.log(c.dim('* = update available, x = path no longer exists'));
1299
+ return;
1300
+ }
1301
+
1302
+ if (allFlag) {
1303
+ // Update all registered repos
1304
+ const registry = readJsonSafe(HUB_REGISTRY);
1305
+ if (!registry || registry.repos.length === 0) {
1306
+ console.log(c.info('No repos registered. Run the Claude Code slash command /ant:init in a repo to register it.'));
1307
+ return;
1308
+ }
1309
+
1310
+ let updated = 0;
1311
+ let upToDate = 0;
1312
+ let pruned = 0;
1313
+ let dirty = 0;
1314
+ let totalRemoved = 0;
1315
+ const survivingRepos = [];
1316
+
1317
+ if (dryRun) {
1318
+ console.log(c.warning('Dry run — no files will be modified.\n'));
1319
+ }
1320
+
1321
+ for (const repo of registry.repos) {
1322
+ if (!fs.existsSync(repo.path)) {
1323
+ log(` ${c.warning('Pruned:')} ${repo.path} (no longer exists)`);
1324
+ pruned++;
1325
+ continue;
1326
+ }
1327
+
1328
+ survivingRepos.push(repo);
1329
+
1330
+ if (!forceFlag && !dryRun && repo.version === sourceVersion) {
1331
+ log(` Up-to-date: ${repo.path} (v${repo.version})`);
1332
+ upToDate++;
1333
+ continue;
1334
+ }
1335
+
1336
+ try {
1337
+ const result = await updateRepo(repo.path, sourceVersion, { dryRun, force: forceFlag, quiet: true });
1338
+ if (result.status === 'dirty') {
1339
+ console.error(` ${c.error('Dirty:')} ${repo.path} — uncommitted changes in managed files:`);
1340
+ for (const f of result.files) console.error(` ${f}`);
1341
+ console.error(` Skipping. Use --force to stash and update.`);
1342
+ dirty++;
1343
+ } else if (result.status === 'dry-run') {
1344
+ log(` Would update: ${repo.path} (${result.from} -> ${result.to}) [${result.system} system, ${result.commands} commands, ${result.agents} agents, ${result.agentsClaude} claude agents]`);
1345
+ if (result.removed > 0) {
1346
+ log(` Would remove ${result.removed} stale files:`);
1347
+ for (const f of result.removedFiles) log(` - ${f}`);
1348
+ }
1349
+ updated++;
1350
+ } else if (result.status === 'updated') {
1351
+ log(` ${c.success('Updated:')} ${repo.path} (${result.from} -> ${result.to}) [${result.system} system, ${result.commands} commands, ${result.agents} agents, ${result.agentsClaude} claude agents]`);
1352
+ if (result.removed > 0) {
1353
+ log(` Removed ${result.removed} stale files:`);
1354
+ for (const f of result.removedFiles) log(` - ${f}`);
1355
+ totalRemoved += result.removed;
1356
+ }
1357
+ // Distribution chain cleanup reporting
1358
+ if (result.cleanup && result.cleanup.cleaned.length > 0) {
1359
+ for (const label of result.cleanup.cleaned) {
1360
+ log(` ${c.success('\u2713')} Removed ${label}`);
1361
+ }
1362
+ }
1363
+ for (const failure of (result.cleanup?.failed || [])) {
1364
+ log(` ${c.error('\u2717')} Failed to remove ${failure.label}: ${failure.error}`);
1365
+ }
1366
+ if (result.cleanup && result.cleanup.cleaned.length === 0 && result.cleanup.failed.length === 0) {
1367
+ log(` Distribution chain: ${c.success('\u2713')} clean`);
1368
+ }
1369
+ if (result.stashCreated) {
1370
+ log(` Stash created. Recover with: cd ${repo.path} && git stash pop`);
1371
+ }
1372
+ updated++;
1373
+ } else {
1374
+ log(` Skipped: ${repo.path} (${result.reason})`);
1375
+ }
1376
+ } catch (error) {
1377
+ // Handle UpdateError with formatted recovery commands
1378
+ if (error instanceof UpdateError) {
1379
+ console.error(formatUpdateError(error));
1380
+ }
1381
+ throw error;
1382
+ }
1383
+ }
1384
+
1385
+ // Save pruned registry
1386
+ if (pruned > 0 && !dryRun) {
1387
+ registry.repos = survivingRepos;
1388
+ writeJsonSync(HUB_REGISTRY, registry);
1389
+ }
1390
+
1391
+ const label = dryRun ? 'would update' : 'updated';
1392
+ let summary = `\nSummary: ${updated} ${label}, ${upToDate} up to date, ${pruned} pruned`;
1393
+ if (dirty > 0) summary += `, ${dirty} dirty (skipped)`;
1394
+ if (totalRemoved > 0) summary += `, ${totalRemoved} stale files removed`;
1395
+ console.log(summary);
1396
+ } else {
1397
+ // Update current repo
1398
+ const repoPath = process.cwd();
1399
+ const repoAether = path.join(repoPath, '.aether');
1400
+
1401
+ if (!fs.existsSync(repoAether)) {
1402
+ const error = new RepoError(
1403
+ 'No .aether/ directory found in current repo.',
1404
+ { path: repoPath }
1405
+ );
1406
+ logError(error);
1407
+ console.error(JSON.stringify(error.toJSON(), null, 2));
1408
+ process.exit(getExitCode(error.code));
1409
+ }
1410
+
1411
+ const pendingPath = path.join(repoAether, '.update-pending');
1412
+ const hasPending = fs.existsSync(pendingPath);
1413
+
1414
+ if (hasPending) {
1415
+ console.log('Detected incomplete update, re-syncing...');
1416
+ try { fs.unlinkSync(pendingPath); } catch { /* ignore */ }
1417
+ }
1418
+
1419
+ const currentVersion = readJsonSafe(path.join(repoAether, 'version.json'));
1420
+ const currentVer = currentVersion ? currentVersion.version : 'unknown';
1421
+
1422
+ if (!hasPending && !forceFlag && !dryRun && currentVer === sourceVersion) {
1423
+ console.log(c.info(`Already up to date (v${sourceVersion}).`));
1424
+ return;
1425
+ }
1426
+
1427
+ if (dryRun) {
1428
+ console.log(c.warning('Dry run — no files will be modified.\n'));
1429
+ }
1430
+
1431
+ try {
1432
+ const result = await updateRepo(repoPath, sourceVersion, { dryRun, force: forceFlag });
1433
+
1434
+ if (result.status === 'dirty') {
1435
+ const error = new GitError(
1436
+ 'Uncommitted changes in managed files',
1437
+ { files: result.files, repo: repoPath }
1438
+ );
1439
+ logError(error);
1440
+ console.error(JSON.stringify(error.toJSON(), null, 2));
1441
+ console.error('\nUse --force to stash changes and update, or commit/stash manually first.');
1442
+ process.exit(getExitCode(error.code));
1443
+ }
1444
+
1445
+ if (result.status === 'dry-run') {
1446
+ console.log(`Would update: ${result.from} -> ${result.to}`);
1447
+ console.log(` ${result.system} system files, ${result.commands} command files, ${result.agents} agent files, ${result.agentsClaude} claude agent files`);
1448
+ if (result.removed > 0) {
1449
+ console.log(` Would remove ${result.removed} stale files:`);
1450
+ for (const f of result.removedFiles) console.log(` - ${f}`);
1451
+ }
1452
+ console.log(' Colony data (.aether/data/) untouched.');
1453
+ return;
1454
+ }
1455
+
1456
+ console.log(c.success(`Updated: ${result.from} -> ${result.to}`));
1457
+ console.log(` ${result.system} system files, ${result.commands} command files, ${result.agents} agent files, ${result.agentsClaude} claude agent files`);
1458
+ if (result.removed > 0) {
1459
+ console.log(` Removed ${result.removed} stale files:`);
1460
+ for (const f of result.removedFiles) console.log(` - ${f}`);
1461
+ }
1462
+ // Distribution chain cleanup reporting
1463
+ if (result.cleanup && result.cleanup.cleaned.length > 0) {
1464
+ for (const label of result.cleanup.cleaned) {
1465
+ console.log(` ${c.success('\u2713')} Removed ${label}`);
1466
+ }
1467
+ }
1468
+ for (const failure of (result.cleanup?.failed || [])) {
1469
+ console.log(` ${c.error('\u2717')} Failed to remove ${failure.label}: ${failure.error}`);
1470
+ }
1471
+ if (result.cleanup && result.cleanup.cleaned.length === 0 && result.cleanup.failed.length === 0) {
1472
+ console.log(` Distribution chain: ${c.success('\u2713')} clean`);
1473
+ }
1474
+ if (result.stashCreated) {
1475
+ console.log(' Git stash created. Recover with: git stash pop');
1476
+ }
1477
+ if (result.checkpoint_id) {
1478
+ console.log(` Checkpoint: ${result.checkpoint_id}`);
1479
+ }
1480
+ console.log(' Colony data (.aether/data/) untouched.');
1481
+ } catch (error) {
1482
+ // Handle UpdateError with prominent recovery commands (UPDATE-04)
1483
+ if (error instanceof UpdateError) {
1484
+ console.error(`\n${c.error('========================================')}`);
1485
+ console.error(c.error('UPDATE FAILED - RECOVERY REQUIRED'));
1486
+ console.error(c.error('========================================'));
1487
+ console.error(`\n${c.error('Error:')} ${error.message}`);
1488
+ if (error.details?.checkpoint_id) {
1489
+ console.error(`\nCheckpoint ID: ${error.details.checkpoint_id}`);
1490
+ }
1491
+ console.error(`\n${c.warning('To recover your workspace:')}`);
1492
+ for (const cmd of error.recoveryCommands) {
1493
+ console.error(` ${cmd}`);
1494
+ }
1495
+ console.error(c.error('========================================'));
1496
+
1497
+ // Log to activity log
1498
+ logError(error);
1499
+
1500
+ // Output structured JSON to stderr
1501
+ console.error(JSON.stringify(error.toJSON(), null, 2));
1502
+ process.exit(getExitCode(error.code) || 1);
1503
+ }
1504
+ throw error;
1505
+ }
1506
+ }
1507
+ }));
1508
+
1509
+ // Version command
1510
+ program
1511
+ .command('version')
1512
+ .description('Show installed version and hub status')
1513
+ .action(() => {
1514
+ console.log(c.header(`aether-colony v${VERSION}`));
1515
+ });
1516
+
1517
+ // Uninstall command
1518
+ program
1519
+ .command('uninstall')
1520
+ .description('Remove slash-commands from ~/.claude/commands/ant/ and ~/.opencode/ (preserves project state and hub)')
1521
+ .action(wrapCommand(async () => {
1522
+ log(c.header(`aether-colony v${VERSION} — uninstalling...`));
1523
+
1524
+ // Remove Claude Code commands
1525
+ if (fs.existsSync(COMMANDS_DEST)) {
1526
+ const n = removeDirSync(COMMANDS_DEST);
1527
+ log(` Removed: ${n} command files from ${COMMANDS_DEST}`);
1528
+ } else {
1529
+ log(' Claude Code commands already removed.');
1530
+ }
1531
+
1532
+ // Remove Claude Code agents
1533
+ if (fs.existsSync(AGENTS_DEST)) {
1534
+ const n = removeDirSync(AGENTS_DEST);
1535
+ log(` Removed: ${n} agent files from ${AGENTS_DEST}`);
1536
+ }
1537
+
1538
+ // Remove OpenCode commands (only our files, preserve others)
1539
+ const opencodeCmdsSrc = path.join(PACKAGE_DIR, '.opencode', 'commands', 'ant');
1540
+ if (fs.existsSync(OPENCODE_COMMANDS_DEST) && fs.existsSync(opencodeCmdsSrc)) {
1541
+ const n = removeFilesFromSource(opencodeCmdsSrc, OPENCODE_COMMANDS_DEST);
1542
+ log(` Removed: ${n} command files from ${OPENCODE_COMMANDS_DEST}`);
1543
+ }
1544
+
1545
+ // Remove OpenCode agents (only our files, preserve others)
1546
+ const opencodeAgentsSrc = path.join(PACKAGE_DIR, '.opencode', 'agents');
1547
+ if (fs.existsSync(OPENCODE_AGENTS_DEST) && fs.existsSync(opencodeAgentsSrc)) {
1548
+ const n = removeFilesFromSource(opencodeAgentsSrc, OPENCODE_AGENTS_DEST);
1549
+ log(` Removed: ${n} agent files from ${OPENCODE_AGENTS_DEST}`);
1550
+ }
1551
+
1552
+ log('');
1553
+ log(c.success('Uninstall complete. Per-project .aether/data/ directories are untouched.'));
1554
+ log(` ${c.colony('Hub:')} ${c.dim('~/.aether/')} preserved (remove manually if desired).`);
1555
+ }));
1556
+
1557
+ // Checkpoint command
1558
+ program
1559
+ .command('checkpoint')
1560
+ .description('Manage Aether checkpoints (safe snapshots of system files)')
1561
+ .addCommand(
1562
+ program.createCommand('create')
1563
+ .description('Create a new checkpoint of Aether system files')
1564
+ .argument('[message]', 'optional message describing the checkpoint')
1565
+ .action(wrapCommand(async (message) => {
1566
+ const repoPath = process.cwd();
1567
+
1568
+ // 1. Check if in git repo
1569
+ if (!isGitRepo(repoPath)) {
1570
+ console.error(c.error('Error: Not in a git repository'));
1571
+ process.exit(1);
1572
+ }
1573
+
1574
+ // 2. Get allowlisted files using CHECKPOINT_ALLOWLIST
1575
+ const allowlistedFiles = getAllowlistedFiles(repoPath);
1576
+ if (allowlistedFiles.length === 0) {
1577
+ console.log(c.warning('No allowlisted files found to checkpoint'));
1578
+ return;
1579
+ }
1580
+
1581
+ // 3. Verify no user data in allowlist (safety check)
1582
+ const userDataFiles = allowlistedFiles.filter(f => isUserData(f));
1583
+ if (userDataFiles.length > 0) {
1584
+ console.error(c.error('Safety check failed: user data detected in allowlist:'));
1585
+ for (const f of userDataFiles) console.error(` - ${f}`);
1586
+ process.exit(1);
1587
+ }
1588
+
1589
+ // 4. Generate checkpoint metadata with hashes
1590
+ const metadata = generateCheckpointMetadata(repoPath, message);
1591
+
1592
+ // 5. Create git stash with allowlisted files
1593
+ // Command format: git stash push -m "aether-checkpoint-{timestamp}" -- {files}
1594
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1595
+ const stashMessage = `aether-checkpoint-${timestamp}`;
1596
+ const fileArgs = allowlistedFiles.map(f => `"${f}"`).join(' ');
1597
+
1598
+ try {
1599
+ execSync(`git stash push -m "${stashMessage}" -- ${fileArgs}`, {
1600
+ cwd: repoPath,
1601
+ stdio: 'pipe'
1602
+ });
1603
+ } catch (err) {
1604
+ console.error(c.error(`Failed to create git stash: ${err.message}`));
1605
+ process.exit(1);
1606
+ }
1607
+
1608
+ // 6. Save metadata to .aether/checkpoints/
1609
+ saveCheckpointMetadata(repoPath, metadata);
1610
+
1611
+ // 7. Output success with checkpoint ID
1612
+ console.log(c.success(`Checkpoint created: ${metadata.checkpoint_id}`));
1613
+ console.log(` Files: ${Object.keys(metadata.files).length}`);
1614
+ console.log(` Stash: ${stashMessage}`);
1615
+ if (message) console.log(` Message: ${message}`);
1616
+ }))
1617
+ )
1618
+ .addCommand(
1619
+ program.createCommand('list')
1620
+ .description('List all checkpoints')
1621
+ .action(wrapCommand(async () => {
1622
+ const repoPath = process.cwd();
1623
+ const checkpointsDir = path.join(repoPath, '.aether', 'checkpoints');
1624
+
1625
+ if (!fs.existsSync(checkpointsDir)) {
1626
+ console.log(c.info('No checkpoints found'));
1627
+ return;
1628
+ }
1629
+
1630
+ const files = fs.readdirSync(checkpointsDir)
1631
+ .filter(f => f.endsWith('.json'))
1632
+ .sort();
1633
+
1634
+ if (files.length === 0) {
1635
+ console.log(c.info('No checkpoints found'));
1636
+ return;
1637
+ }
1638
+
1639
+ console.log(c.header('Checkpoints:'));
1640
+ for (const file of files) {
1641
+ const metadata = loadCheckpointMetadata(repoPath, file.replace('.json', ''));
1642
+ if (metadata) {
1643
+ const fileCount = Object.keys(metadata.files).length;
1644
+ const date = new Date(metadata.created_at).toLocaleString();
1645
+ console.log(` ${metadata.checkpoint_id} ${date} ${fileCount} files ${metadata.message || ''}`);
1646
+ }
1647
+ }
1648
+ }))
1649
+ )
1650
+ .addCommand(
1651
+ program.createCommand('restore')
1652
+ .description('Restore Aether files from a checkpoint')
1653
+ .argument('<checkpoint-id>', 'checkpoint ID to restore from')
1654
+ .action(wrapCommand(async (checkpointId) => {
1655
+ const repoPath = process.cwd();
1656
+
1657
+ // 1. Load checkpoint metadata
1658
+ const metadata = loadCheckpointMetadata(repoPath, checkpointId);
1659
+ if (!metadata) {
1660
+ console.error(c.error(`Checkpoint not found: ${checkpointId}`));
1661
+ process.exit(1);
1662
+ }
1663
+
1664
+ // 2. Verify metadata integrity (hashes match current files if they exist)
1665
+ let integrityCheck = true;
1666
+ for (const [filePath, storedHash] of Object.entries(metadata.files)) {
1667
+ const fullPath = path.join(repoPath, filePath);
1668
+ if (fs.existsSync(fullPath)) {
1669
+ const currentHash = hashFileSync(fullPath);
1670
+ if (currentHash !== storedHash) {
1671
+ console.warn(c.warning(`File changed since checkpoint: ${filePath}`));
1672
+ integrityCheck = false;
1673
+ }
1674
+ }
1675
+ }
1676
+
1677
+ // 3. Use git stash to restore files
1678
+ // Find stash by message pattern
1679
+ try {
1680
+ const stashList = execSync('git stash list', { cwd: repoPath, encoding: 'utf8' });
1681
+ const stashMatch = stashList.match(/stash@\{([^}]+)\}.*aether-checkpoint-/);
1682
+ if (stashMatch) {
1683
+ execSync(`git stash pop stash@{${stashMatch[1]}}`, {
1684
+ cwd: repoPath,
1685
+ stdio: 'pipe'
1686
+ });
1687
+ console.log(c.success(`Restored from checkpoint: ${checkpointId}`));
1688
+ console.log(` Files restored: ${Object.keys(metadata.files).length}`);
1689
+ } else {
1690
+ console.error(c.error('Could not find matching git stash'));
1691
+ process.exit(1);
1692
+ }
1693
+ } catch (err) {
1694
+ console.error(c.error(`Failed to restore checkpoint: ${err.message}`));
1695
+ process.exit(1);
1696
+ }
1697
+ }))
1698
+ )
1699
+ .addCommand(
1700
+ program.createCommand('verify')
1701
+ .description('Verify checkpoint integrity')
1702
+ .argument('<checkpoint-id>', 'checkpoint ID to verify')
1703
+ .action(wrapCommand(async (checkpointId) => {
1704
+ const repoPath = process.cwd();
1705
+
1706
+ // 1. Load checkpoint metadata
1707
+ const metadata = loadCheckpointMetadata(repoPath, checkpointId);
1708
+ if (!metadata) {
1709
+ console.error(c.error(`Checkpoint not found: ${checkpointId}`));
1710
+ process.exit(1);
1711
+ }
1712
+
1713
+ // 2. Re-compute hashes for all files in metadata
1714
+ // 3. Compare with stored hashes
1715
+ let passed = 0;
1716
+ let failed = 0;
1717
+ let missing = 0;
1718
+
1719
+ for (const [filePath, storedHash] of Object.entries(metadata.files)) {
1720
+ const fullPath = path.join(repoPath, filePath);
1721
+ if (!fs.existsSync(fullPath)) {
1722
+ console.log(c.error(` MISSING: ${filePath}`));
1723
+ missing++;
1724
+ } else {
1725
+ const currentHash = hashFileSync(fullPath);
1726
+ if (currentHash === storedHash) {
1727
+ console.log(c.success(` OK: ${filePath}`));
1728
+ passed++;
1729
+ } else {
1730
+ console.log(c.error(` MISMATCH: ${filePath}`));
1731
+ failed++;
1732
+ }
1733
+ }
1734
+ }
1735
+
1736
+ // 4. Report any mismatches
1737
+ console.log('');
1738
+ if (failed === 0 && missing === 0) {
1739
+ console.log(c.success(`All ${passed} files verified successfully`));
1740
+ } else {
1741
+ console.log(c.warning(`Verification complete: ${passed} passed, ${failed} mismatched, ${missing} missing`));
1742
+ process.exit(1);
1743
+ }
1744
+ }))
1745
+ );
1746
+
1747
+ // Sync-state command - Synchronize COLONY_STATE.json with .planning/STATE.md
1748
+ program
1749
+ .command('sync-state')
1750
+ .description('Synchronize COLONY_STATE.json with .planning/STATE.md')
1751
+ .option('-d, --dry-run', 'Show what would change without applying')
1752
+ .action(wrapCommand(async (options) => {
1753
+ const repoPath = process.cwd();
1754
+
1755
+ if (!isInitialized(repoPath)) {
1756
+ console.error('Aether not initialized. Run: aether init');
1757
+ return;
1758
+ }
1759
+
1760
+ // Check for mismatches
1761
+ const reconciliation = reconcileStates(repoPath);
1762
+ if (!reconciliation.consistent) {
1763
+ console.log('State mismatch detected:');
1764
+ reconciliation.mismatches.forEach(m => console.log(` - ${m}`));
1765
+ console.log('');
1766
+ }
1767
+
1768
+ if (options.dryRun) {
1769
+ console.log('Dry run - no changes made');
1770
+ console.log(`Resolution: ${reconciliation.resolution}`);
1771
+ return;
1772
+ }
1773
+
1774
+ // Perform sync
1775
+ const result = syncStateFromPlanning(repoPath);
1776
+ if (result.synced) {
1777
+ if (result.changed) {
1778
+ console.log('State synchronized successfully');
1779
+ console.log('Updates:', result.updates.join(', '));
1780
+ } else {
1781
+ console.log('State already synchronized - no changes needed');
1782
+ }
1783
+ } else {
1784
+ console.error(`Sync failed: ${result.error}`);
1785
+ process.exit(1);
1786
+ }
1787
+ }));
1788
+
1789
+ // Caste emoji mapping for display
1790
+ const CASTE_EMOJIS = {
1791
+ builder: '🔨',
1792
+ watcher: '👁️',
1793
+ scout: '🔍',
1794
+ chaos: '🎲',
1795
+ oracle: '🔮',
1796
+ architect: '🏗️',
1797
+ prime: '🏛️',
1798
+ colonizer: '🌱',
1799
+ route_setter: '🧭',
1800
+ archaeologist: '📜',
1801
+ };
1802
+
1803
+ /**
1804
+ * Format context window for display
1805
+ * @param {number} contextWindow - Context window size
1806
+ * @returns {string} Formatted string (e.g., "256K")
1807
+ */
1808
+ function formatContextWindow(contextWindow) {
1809
+ if (!contextWindow) return '-';
1810
+ if (contextWindow >= 1000) {
1811
+ return `${Math.round(contextWindow / 1000)}K`;
1812
+ }
1813
+ return String(contextWindow);
1814
+ }
1815
+
1816
+ // Caste-models command - Manage caste-to-model assignments
1817
+ const casteModelsCmd = program
1818
+ .command('caste-models')
1819
+ .description('Manage caste-to-model assignments');
1820
+
1821
+ // list subcommand
1822
+ casteModelsCmd
1823
+ .command('list')
1824
+ .description('List current model assignments per caste')
1825
+ .option('--verify', 'Verify model availability on proxy')
1826
+ .action(wrapCommand(async (options) => {
1827
+ const repoPath = process.cwd();
1828
+ const profiles = loadModelProfiles(repoPath);
1829
+ const overrides = getUserOverrides(profiles);
1830
+ const proxyConfig = getProxyConfig(profiles);
1831
+
1832
+ // Check proxy health
1833
+ let proxyHealth = null;
1834
+ let proxyModels = null;
1835
+ if (proxyConfig?.endpoint) {
1836
+ proxyHealth = await checkProxyHealth(proxyConfig.endpoint);
1837
+ if (proxyHealth.healthy && proxyHealth.models) {
1838
+ proxyModels = proxyHealth.models;
1839
+ }
1840
+ }
1841
+
1842
+ console.log(c.header('Caste Model Assignments\n'));
1843
+
1844
+ // Display proxy status
1845
+ if (proxyConfig?.endpoint) {
1846
+ const proxyStatus = formatProxyStatusColored(proxyHealth, c) + c.dim(` @ ${proxyConfig.endpoint}`);
1847
+ console.log(`Proxy: ${proxyStatus}`);
1848
+ if (!proxyHealth?.healthy) {
1849
+ console.log(c.warning('Warning: Using default model (kimi-k2.5) for all castes'));
1850
+ }
1851
+ console.log('');
1852
+ }
1853
+
1854
+ // Table header - add Verify column if --verify flag
1855
+ const verifyFlag = options.verify;
1856
+ const header = verifyFlag
1857
+ ? `${'Caste'.padEnd(14)} ${'Model'.padEnd(14)} ${'Provider'.padEnd(10)} ${'Context'.padEnd(8)} Verify Status`
1858
+ : `${'Caste'.padEnd(14)} ${'Model'.padEnd(14)} ${'Provider'.padEnd(10)} ${'Context'.padEnd(8)} Status`;
1859
+ console.log(header);
1860
+ console.log(verifyFlag ? '─'.repeat(70) : '─'.repeat(60));
1861
+
1862
+ // Get all assignments
1863
+ const assignments = getAllAssignments(profiles);
1864
+
1865
+ for (const assignment of assignments) {
1866
+ const emoji = CASTE_EMOJIS[assignment.caste] || '•';
1867
+ const casteName = assignment.caste.charAt(0).toUpperCase() + assignment.caste.slice(1);
1868
+ const casteDisplay = `${emoji} ${casteName}`;
1869
+
1870
+ // Check for override
1871
+ const hasOverride = overrides[assignment.caste] !== undefined;
1872
+ const effectiveModel = getEffectiveModel(profiles, assignment.caste);
1873
+ const modelDisplay = effectiveModel.model + (hasOverride ? ' (override)' : '');
1874
+
1875
+ // Get model metadata
1876
+ const metadata = getModelMetadata(profiles, effectiveModel.model);
1877
+ const provider = metadata?.provider || assignment.provider || '-';
1878
+ const contextWindow = formatContextWindow(metadata?.context_window);
1879
+
1880
+ // Status indicator based on proxy health
1881
+ const status = proxyHealth?.healthy ? '✓' : '⚠';
1882
+
1883
+ // Verify flag - check if model is available on proxy
1884
+ let verifyStatus = '';
1885
+ if (verifyFlag) {
1886
+ if (proxyModels) {
1887
+ const isAvailable = proxyModels.includes(effectiveModel.model);
1888
+ verifyStatus = isAvailable ? '✓' : '✗';
1889
+ } else {
1890
+ verifyStatus = '?';
1891
+ }
1892
+ console.log(
1893
+ `${casteDisplay.padEnd(14)} ${modelDisplay.padEnd(14)} ${provider.padEnd(10)} ${contextWindow.padEnd(8)} ${verifyStatus.padEnd(7)} ${status}`
1894
+ );
1895
+ } else {
1896
+ console.log(
1897
+ `${casteDisplay.padEnd(14)} ${modelDisplay.padEnd(14)} ${provider.padEnd(10)} ${contextWindow.padEnd(8)} ${status}`
1898
+ );
1899
+ }
1900
+ }
1901
+
1902
+ // Show overrides summary if any exist
1903
+ const overrideCount = Object.keys(overrides).length;
1904
+ if (overrideCount > 0) {
1905
+ console.log('');
1906
+ console.log(c.info(`Active overrides: ${overrideCount}`));
1907
+ for (const [caste, model] of Object.entries(overrides)) {
1908
+ console.log(` ${caste}: ${model}`);
1909
+ }
1910
+ }
1911
+ }));
1912
+
1913
+ // set subcommand
1914
+ casteModelsCmd
1915
+ .command('set')
1916
+ .description('Set model override for a caste')
1917
+ .argument('<assignment>', 'caste=model (e.g., builder=glm-5)')
1918
+ .action(wrapCommand(async (assignment) => {
1919
+ // Parse caste=model format
1920
+ const match = assignment.match(/^([^=]+)=(.+)$/);
1921
+ if (!match) {
1922
+ const error = new ValidationError(
1923
+ `Invalid assignment format: '${assignment}'`,
1924
+ { received: assignment },
1925
+ 'Use format: caste=model (e.g., builder=glm-5)'
1926
+ );
1927
+ throw error;
1928
+ }
1929
+
1930
+ const [, caste, model] = match;
1931
+
1932
+ const repoPath = process.cwd();
1933
+
1934
+ // Validate and set
1935
+ try {
1936
+ const result = setModelOverride(repoPath, caste, model);
1937
+
1938
+ if (result.previous) {
1939
+ console.log(c.success(`Updated ${caste}: ${result.previous} → ${model}`));
1940
+ } else {
1941
+ console.log(c.success(`Set ${caste} to ${model}`));
1942
+ }
1943
+ } catch (error) {
1944
+ if (error.name === 'ValidationError') {
1945
+ // Add helpful suggestions
1946
+ if (error.details?.validCastes) {
1947
+ console.error(c.error(`Error: ${error.message}`));
1948
+ console.error('\nValid castes:');
1949
+ for (const casteName of error.details.validCastes) {
1950
+ const emoji = CASTE_EMOJIS[casteName] || '•';
1951
+ console.error(` ${emoji} ${casteName}`);
1952
+ }
1953
+ } else if (error.details?.validModels) {
1954
+ console.error(c.error(`Error: ${error.message}`));
1955
+ console.error('\nValid models:');
1956
+ for (const modelName of error.details.validModels) {
1957
+ console.error(` • ${modelName}`);
1958
+ }
1959
+ }
1960
+ process.exit(1);
1961
+ }
1962
+ throw error;
1963
+ }
1964
+ }));
1965
+
1966
+ // reset subcommand
1967
+ casteModelsCmd
1968
+ .command('reset')
1969
+ .description('Reset caste to default model (remove override)')
1970
+ .argument('<caste>', 'caste name (e.g., builder)')
1971
+ .action(wrapCommand(async (caste) => {
1972
+ const repoPath = process.cwd();
1973
+
1974
+ try {
1975
+ const result = resetModelOverride(repoPath, caste);
1976
+
1977
+ if (result.hadOverride) {
1978
+ console.log(c.success(`Reset ${caste} to default model`));
1979
+ } else {
1980
+ console.log(c.info(`${caste} was already using default model`));
1981
+ }
1982
+ } catch (error) {
1983
+ if (error.name === 'ValidationError' && error.details?.validCastes) {
1984
+ console.error(c.error(`Error: ${error.message}`));
1985
+ console.error('\nValid castes:');
1986
+ for (const casteName of error.details.validCastes) {
1987
+ const emoji = CASTE_EMOJIS[casteName] || '•';
1988
+ console.error(` ${emoji} ${casteName}`);
1989
+ }
1990
+ process.exit(1);
1991
+ }
1992
+ throw error;
1993
+ }
1994
+ }));
1995
+
1996
+ // Verify-models command - Verify model routing configuration
1997
+ program
1998
+ .command('verify-models')
1999
+ .description('Verify model routing configuration is active')
2000
+ .action(wrapCommand(async () => {
2001
+ const repoPath = process.cwd();
2002
+ const report = await createVerificationReport(repoPath);
2003
+
2004
+ console.log('=== Model Routing Verification ===\n');
2005
+
2006
+ // Proxy status
2007
+ console.log(`LiteLLM Proxy: ${report.proxy.running ? '✓ Running' : '✗ Not running'}`);
2008
+ if (report.proxy.running) {
2009
+ console.log(` Latency: ${report.proxy.latency}ms`);
2010
+ }
2011
+
2012
+ // Environment
2013
+ console.log(`\nEnvironment:`);
2014
+ console.log(` ANTHROPIC_MODEL: ${report.env.model || '(not set)'}`);
2015
+ console.log(` ANTHROPIC_BASE_URL: ${report.env.baseUrl || '(not set)'}`);
2016
+
2017
+ // Caste assignments
2018
+ console.log(`\nCaste Model Assignments:`);
2019
+ for (const [caste, info] of Object.entries(report.castes)) {
2020
+ const status = info.assigned ? '✓' : '✗';
2021
+ console.log(` ${status} ${caste}: ${info.model || 'default'}`);
2022
+ }
2023
+
2024
+ // Model profiles file
2025
+ console.log(`\nModel Profiles File:`);
2026
+ if (report.profilesFile.exists) {
2027
+ console.log(` ✓ Found: ${report.profilesFile.path}`);
2028
+ const profileCount = Object.keys(report.profilesFile.profiles).length;
2029
+ console.log(` Profiles: ${profileCount} castes configured`);
2030
+ } else {
2031
+ console.log(` ✗ Not found: ${report.profilesFile.path}`);
2032
+ }
2033
+
2034
+ // Issues
2035
+ if (report.issues.length > 0) {
2036
+ console.log(`\nIssues Found:`);
2037
+ report.issues.forEach(issue => console.log(` ⚠ ${issue}`));
2038
+ }
2039
+
2040
+ // Recommendation
2041
+ console.log(`\n${report.recommendation}`);
2042
+ }));
2043
+
2044
+ // Spawn-log command - Log a worker spawn event
2045
+ program
2046
+ .command('spawn-log')
2047
+ .description('Log a worker spawn event')
2048
+ .requiredOption('-p, --parent <parent>', 'Parent ant name')
2049
+ .requiredOption('-c, --caste <caste>', 'Worker caste')
2050
+ .requiredOption('-n, --name <name>', 'Child ant name')
2051
+ .requiredOption('-t, --task <task>', 'Task description')
2052
+ .requiredOption('-m, --model <model>', 'Model used')
2053
+ .option('-s, --status <status>', 'Spawn status', 'spawned')
2054
+ .action(wrapCommand(async (options) => {
2055
+ const repoPath = process.cwd();
2056
+ await logSpawn(repoPath, {
2057
+ parent: options.parent,
2058
+ caste: options.caste,
2059
+ child: options.name,
2060
+ task: options.task,
2061
+ model: options.model,
2062
+ status: options.status
2063
+ });
2064
+ console.log(`Logged spawn: ${options.parent} → ${options.name} (${options.caste}) [${options.model}]`);
2065
+ }));
2066
+
2067
+ // Spawn-tree command - Display worker spawn tree
2068
+ program
2069
+ .command('spawn-tree')
2070
+ .description('Display worker spawn tree with model information')
2071
+ .action(wrapCommand(async () => {
2072
+ const repoPath = process.cwd();
2073
+ const tree = formatSpawnTree(repoPath);
2074
+ console.log(tree);
2075
+ }));
2076
+
2077
+ // Status command - Show colony status
2078
+ program
2079
+ .command('status')
2080
+ .description('Show colony status')
2081
+ .option('-j, --json', 'Output as JSON')
2082
+ .action(wrapCommand(async (options) => {
2083
+ const repoPath = process.cwd();
2084
+ const colonyStatePath = path.join(repoPath, '.aether', 'data', 'COLONY_STATE.json');
2085
+
2086
+ // Check if colony exists
2087
+ if (!fs.existsSync(colonyStatePath)) {
2088
+ console.log(c.warning('No colony found in current directory.'));
2089
+ console.log(c.dim('Run /ant:init to create a new colony, or cd to a project with an existing colony.'));
2090
+ return;
2091
+ }
2092
+
2093
+ // Load colony state
2094
+ let state;
2095
+ try {
2096
+ state = JSON.parse(fs.readFileSync(colonyStatePath, 'utf8'));
2097
+ } catch (err) {
2098
+ console.log(c.error('Could not read colony state.'));
2099
+ console.log(c.dim(`Error: ${err.message}`));
2100
+ console.log(c.dim('The colony state file may be corrupted. Consider running /ant:init to reinitialize.'));
2101
+ return;
2102
+ }
2103
+
2104
+ // JSON output
2105
+ if (options.json) {
2106
+ console.log(JSON.stringify(state, null, 2));
2107
+ return;
2108
+ }
2109
+
2110
+ // Dashboard output
2111
+ console.log(c.header('Colony Status\n'));
2112
+
2113
+ // Goal
2114
+ if (state.goal) {
2115
+ console.log(`${c.queen('Goal:')} ${state.goal}`);
2116
+ }
2117
+
2118
+ // State
2119
+ if (state.state) {
2120
+ const stateDisplay = state.state === 'BUILDING' ? c.success(state.state) :
2121
+ state.state === 'PLANNING' ? c.warning(state.state) :
2122
+ state.state === 'COMPLETED' ? c.success(state.state) :
2123
+ state.state;
2124
+ console.log(`${c.colony('State:')} ${stateDisplay}`);
2125
+ }
2126
+
2127
+ // Phase
2128
+ if (state.current_phase !== undefined) {
2129
+ console.log(`${c.info('Phase:')} ${state.current_phase}`);
2130
+ }
2131
+
2132
+ // Version
2133
+ if (state.version) {
2134
+ console.log(`${c.dim('Version:')} ${state.version}`);
2135
+ }
2136
+
2137
+ // Last updated
2138
+ if (state.last_updated) {
2139
+ const lastUpdated = new Date(state.last_updated);
2140
+ const now = new Date();
2141
+ const hoursAgo = Math.round((now - lastUpdated) / (1000 * 60 * 60));
2142
+ const timeDisplay = hoursAgo < 1 ? 'just now' :
2143
+ hoursAgo < 24 ? `${hoursAgo} hours ago` :
2144
+ `${Math.round(hoursAgo / 24)} days ago`;
2145
+ console.log(`${c.dim('Last updated:')} ${timeDisplay}`);
2146
+ }
2147
+
2148
+ // Event count
2149
+ if (state.events && state.events.length > 0) {
2150
+ console.log(`${c.dim('Events:')} ${state.events.length}`);
2151
+ }
2152
+
2153
+ console.log('');
2154
+ }));
2155
+
2156
+ // Nestmates command - List sibling colonies
2157
+ program
2158
+ .command('nestmates')
2159
+ .description('List sibling colonies (nestmates)')
2160
+ .action(wrapCommand(async () => {
2161
+ const repoPath = process.cwd();
2162
+ const nestmates = findNestmates(repoPath);
2163
+
2164
+ if (nestmates.length === 0) {
2165
+ console.log('No nestmates found (sibling directories with .aether/).');
2166
+ return;
2167
+ }
2168
+
2169
+ console.log(`Found ${nestmates.length} nestmate(s):\n`);
2170
+ console.log(formatNestmates(nestmates));
2171
+ }));
2172
+
2173
+ // Telemetry command - View model performance telemetry
2174
+ const telemetryCmd = program
2175
+ .command('telemetry')
2176
+ .description('View model performance telemetry')
2177
+ .action(wrapCommand(async () => {
2178
+ // Default action: show summary
2179
+ const repoPath = process.cwd();
2180
+ const summary = getTelemetrySummary(repoPath);
2181
+
2182
+ console.log(c.header('Model Performance Telemetry\n'));
2183
+ console.log(`Total Spawns: ${summary.total_spawns}`);
2184
+ console.log(`Models Used: ${summary.total_models}\n`);
2185
+
2186
+ if (summary.total_spawns === 0) {
2187
+ console.log(c.info('No telemetry data yet. Run some builds to collect data.'));
2188
+ return;
2189
+ }
2190
+
2191
+ console.log('Model Performance:');
2192
+ console.log('─'.repeat(60));
2193
+ for (const [model, stats] of Object.entries(summary.models)) {
2194
+ const rate = (stats.success_rate * 100).toFixed(1);
2195
+ const rateColor = stats.success_rate >= 0.9 ? c.success :
2196
+ stats.success_rate >= 0.7 ? c.warning : c.error;
2197
+ console.log(` ${model.padEnd(15)} ${String(stats.total_spawns).padStart(4)} spawns ${rateColor(rate + '%')} success`);
2198
+ }
2199
+
2200
+ if (summary.recent_decisions.length > 0) {
2201
+ console.log('\nRecent Routing Decisions:');
2202
+ console.log('─'.repeat(60));
2203
+ for (const decision of summary.recent_decisions.slice(-5)) {
2204
+ console.log(` ${decision.caste.padEnd(10)} → ${decision.selected_model.padEnd(12)} (${decision.source})`);
2205
+ }
2206
+ }
2207
+ }));
2208
+
2209
+ // summary subcommand (explicit)
2210
+ telemetryCmd
2211
+ .command('summary')
2212
+ .description('Show overall telemetry summary')
2213
+ .action(wrapCommand(async () => {
2214
+ const repoPath = process.cwd();
2215
+ const summary = getTelemetrySummary(repoPath);
2216
+
2217
+ console.log(c.header('Model Performance Telemetry\n'));
2218
+ console.log(`Total Spawns: ${summary.total_spawns}`);
2219
+ console.log(`Models Used: ${summary.total_models}\n`);
2220
+
2221
+ if (summary.total_spawns === 0) {
2222
+ console.log(c.info('No telemetry data yet. Run some builds to collect data.'));
2223
+ return;
2224
+ }
2225
+
2226
+ console.log('Model Performance:');
2227
+ console.log('─'.repeat(60));
2228
+ for (const [model, stats] of Object.entries(summary.models)) {
2229
+ const rate = (stats.success_rate * 100).toFixed(1);
2230
+ const rateColor = stats.success_rate >= 0.9 ? c.success :
2231
+ stats.success_rate >= 0.7 ? c.warning : c.error;
2232
+ console.log(` ${model.padEnd(15)} ${String(stats.total_spawns).padStart(4)} spawns ${rateColor(rate + '%')} success`);
2233
+ }
2234
+
2235
+ if (summary.recent_decisions.length > 0) {
2236
+ console.log('\nRecent Routing Decisions:');
2237
+ console.log('─'.repeat(60));
2238
+ for (const decision of summary.recent_decisions.slice(-5)) {
2239
+ console.log(` ${decision.caste.padEnd(10)} → ${decision.selected_model.padEnd(12)} (${decision.source})`);
2240
+ }
2241
+ }
2242
+ }));
2243
+
2244
+ // model subcommand
2245
+ telemetryCmd
2246
+ .command('model <model-name>')
2247
+ .description('Show detailed performance for a specific model')
2248
+ .action(wrapCommand(async (modelName) => {
2249
+ const repoPath = process.cwd();
2250
+ const performance = getModelPerformance(repoPath, modelName);
2251
+
2252
+ if (!performance) {
2253
+ console.log(c.warning(`No data for model: ${modelName}`));
2254
+ return;
2255
+ }
2256
+
2257
+ console.log(c.header(`Model Performance: ${modelName}\n`));
2258
+ console.log(`Total Spawns: ${performance.total_spawns}`);
2259
+ console.log(`Success Rate: ${(performance.success_rate * 100).toFixed(1)}%`);
2260
+ console.log(` ✓ Completed: ${performance.successful_completions}`);
2261
+ console.log(` ✗ Failed: ${performance.failed_completions}`);
2262
+ console.log(` 🚫 Blocked: ${performance.blocked}`);
2263
+
2264
+ if (Object.keys(performance.by_caste).length > 0) {
2265
+ console.log('\nPerformance by Caste:');
2266
+ console.log('─'.repeat(50));
2267
+ for (const [caste, stats] of Object.entries(performance.by_caste)) {
2268
+ const casteRate = stats.spawns > 0 ? (stats.success / stats.spawns * 100).toFixed(1) : '0.0';
2269
+ console.log(` ${caste.padEnd(12)} ${String(stats.spawns).padStart(4)} spawns ${casteRate}% success`);
2270
+ }
2271
+ }
2272
+ }));
2273
+
2274
+ // performance subcommand
2275
+ telemetryCmd
2276
+ .command('performance')
2277
+ .description('Show models ranked by performance')
2278
+ .action(wrapCommand(async () => {
2279
+ const repoPath = process.cwd();
2280
+ const summary = getTelemetrySummary(repoPath);
2281
+
2282
+ console.log(c.header('Model Performance Ranking\n'));
2283
+
2284
+ if (summary.total_spawns === 0) {
2285
+ console.log(c.info('No telemetry data yet. Run some builds to collect data.'));
2286
+ return;
2287
+ }
2288
+
2289
+ // Sort models by success rate
2290
+ const ranked = Object.entries(summary.models)
2291
+ .map(([model, stats]) => ({ model, ...stats }))
2292
+ .sort((a, b) => b.success_rate - a.success_rate);
2293
+
2294
+ console.log(`${'Rank'.padEnd(6)} ${'Model'.padEnd(15)} ${'Spawns'.padStart(6)} ${'Success'.padStart(8)} ${'Rate'.padStart(6)}`);
2295
+ console.log('─'.repeat(60));
2296
+
2297
+ ranked.forEach((m, i) => {
2298
+ const rank = `${i + 1}.`.padEnd(6);
2299
+ const rate = (m.success_rate * 100).toFixed(1);
2300
+ const rateColor = m.success_rate >= 0.9 ? c.success :
2301
+ m.success_rate >= 0.7 ? c.warning : c.error;
2302
+ console.log(`${rank} ${m.model.padEnd(15)} ${String(m.total_spawns).padStart(6)} ${String(m.successful_completions || 0).padStart(8)} ${rateColor(rate.padStart(5) + '%')}`);
2303
+ });
2304
+
2305
+ console.log('\n' + c.dim('Tip: Use "aether telemetry model <name>" for detailed stats'));
2306
+ }));
2307
+
2308
+ // Context command - Show auto-loaded context
2309
+ program
2310
+ .command('context')
2311
+ .description('Show auto-loaded context including nestmates')
2312
+ .action(wrapCommand(async () => {
2313
+ const repoPath = process.cwd();
2314
+
2315
+ // Load nestmates
2316
+ const nestmates = findNestmates(repoPath);
2317
+ console.log('=== Auto-Loaded Context ===\n');
2318
+
2319
+ // Nestmates
2320
+ console.log(`Nestmates: ${nestmates.length} found`);
2321
+ if (nestmates.length > 0) {
2322
+ console.log(formatNestmates(nestmates));
2323
+ }
2324
+
2325
+ // TO-DOs from nestmates
2326
+ console.log('\nCross-Project TO-DOs:');
2327
+ let hasTodos = false;
2328
+ for (const nestmate of nestmates) {
2329
+ const todos = loadNestmateTodos(nestmate.path);
2330
+ if (todos.length > 0) {
2331
+ hasTodos = true;
2332
+ console.log(`\n${nestmate.name}:`);
2333
+ for (const todo of todos) {
2334
+ console.log(` ${todo.file}:`);
2335
+ for (const item of todo.items.slice(0, 5)) {
2336
+ console.log(` ${item}`);
2337
+ }
2338
+ if (todo.items.length > 5) {
2339
+ console.log(` ... and ${todo.items.length - 5} more`);
2340
+ }
2341
+ }
2342
+ }
2343
+ }
2344
+ if (!hasTodos) {
2345
+ console.log(' No cross-project TO-DOs found.');
2346
+ }
2347
+ }));
2348
+
2349
+ // Init command - Initialize Aether in current repository
2350
+ program
2351
+ .command('init')
2352
+ .description('Initialize Aether in current repository')
2353
+ .option('-g, --goal <goal>', 'Initial colony goal', 'Aether colony')
2354
+ .option('-f, --force', 'Reinitialize even if already initialized')
2355
+ .action(wrapCommand(async (options) => {
2356
+ const repoPath = process.cwd();
2357
+
2358
+ // Check if already initialized
2359
+ if (isInitialized(repoPath) && !options.force) {
2360
+ console.log('Aether is already initialized in this repository.');
2361
+ console.log('Use --force to reinitialize (WARNING: may overwrite state).');
2362
+ return;
2363
+ }
2364
+
2365
+ // Initialize
2366
+ const result = await initializeRepo(repoPath, {
2367
+ goal: options.goal,
2368
+ skipIfExists: !options.force
2369
+ });
2370
+
2371
+ if (result.success) {
2372
+ console.log('Aether initialized successfully!');
2373
+ console.log(`State file: ${result.stateFile}`);
2374
+ console.log('');
2375
+ console.log('Next steps:');
2376
+ console.log(' 1. Define your colony goal in .aether/data/COLONY_STATE.json');
2377
+ console.log(' 2. Run: aether sync-state');
2378
+ console.log(' 3. Run: aether verify-models');
2379
+ console.log(' 4. Start building: /ant:init');
2380
+ }
2381
+ }));
2382
+
2383
+ // Custom help handler to show CLI vs Slash command distinction
2384
+ program.on('--help', () => {
2385
+ console.log('');
2386
+ console.log(c.bold('CLI Commands (Terminal):'));
2387
+ console.log(' init Initialize Aether in current repository');
2388
+ console.log(' install Install slash-commands and set up distribution hub');
2389
+ console.log(' update Update current repo from hub');
2390
+ console.log(' sync-state Synchronize COLONY_STATE.json with .planning/STATE.md');
2391
+ console.log(' verify-models Verify model routing configuration');
2392
+ console.log(' version Show installed version');
2393
+ console.log(' uninstall Remove slash-commands (preserves project state and hub)');
2394
+ console.log('');
2395
+ console.log(c.bold('Slash Commands (Claude Code):'));
2396
+ console.log(' /ant:init <goal> Initialize colony in current repo');
2397
+ console.log(' /ant:status Show colony status');
2398
+ console.log(' /ant:plan Generate project plan');
2399
+ console.log(' /ant:build <n> Build phase N');
2400
+ console.log('');
2401
+ console.log(c.dim('Run these in Claude Code after installing with "aether install"'));
2402
+ console.log('');
2403
+ console.log(c.bold('Examples:'));
2404
+ console.log(' $ aether init --goal "My project" # Initialize Aether in current repo');
2405
+ console.log(' $ aether install # Install slash commands');
2406
+ console.log(' $ aether update --list # Show registered repos');
2407
+ console.log(' $ aether update --all --force # Force update all repos');
2408
+ console.log(' $ aether --no-color version # Show version without colors');
2409
+ });
2410
+
2411
+ // Configure error output to use colors
2412
+ program.configureOutput({
2413
+ outputError: (str, write) => write(c.error(str))
2414
+ });
2415
+
2416
+ // Export functions for testing
2417
+ module.exports = {
2418
+ hashFileSync,
2419
+ validateManifest,
2420
+ generateManifest,
2421
+ computeFileHash,
2422
+ isGitTracked,
2423
+ getAllowlistedFiles,
2424
+ generateCheckpointMetadata,
2425
+ loadCheckpointMetadata,
2426
+ saveCheckpointMetadata,
2427
+ isUserData,
2428
+ syncDirWithCleanup,
2429
+ listFilesRecursive,
2430
+ cleanEmptyDirs
2431
+ };
2432
+
2433
+ // Parse command line arguments only when run directly (not when required as a module)
2434
+ if (require.main === module) {
2435
+ program.parse();
2436
+ }