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
@@ -0,0 +1,1454 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * UpdateTransaction - Two-phase commit for updates with automatic rollback
5
+ *
6
+ * Implements UPDATE-01 through UPDATE-04 requirements:
7
+ * - UPDATE-01: Create checkpoint before file sync
8
+ * - UPDATE-02: Two-phase commit (backup → sync → verify → update version)
9
+ * - UPDATE-03: Automatic rollback on failure
10
+ * - UPDATE-04: Recovery commands displayed prominently on failure
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const crypto = require('crypto');
16
+ const { execSync } = require('child_process');
17
+
18
+ /**
19
+ * Error codes for update operations
20
+ */
21
+ const UpdateErrorCodes = {
22
+ E_UPDATE_FAILED: 'E_UPDATE_FAILED',
23
+ E_CHECKPOINT_FAILED: 'E_CHECKPOINT_FAILED',
24
+ E_SYNC_FAILED: 'E_SYNC_FAILED',
25
+ E_VERIFY_FAILED: 'E_VERIFY_FAILED',
26
+ E_ROLLBACK_FAILED: 'E_ROLLBACK_FAILED',
27
+ E_REPO_DIRTY: 'E_REPO_DIRTY',
28
+ E_HUB_INACCESSIBLE: 'E_HUB_INACCESSIBLE',
29
+ E_PARTIAL_UPDATE: 'E_PARTIAL_UPDATE',
30
+ E_NETWORK_ERROR: 'E_NETWORK_ERROR',
31
+ };
32
+
33
+ /**
34
+ * UpdateError - Structured error with recovery commands
35
+ *
36
+ * Provides detailed error information and recovery commands for failed updates.
37
+ * Recovery commands are displayed prominently to help users recover from failures.
38
+ */
39
+ class UpdateError extends Error {
40
+ /**
41
+ * Create an UpdateError
42
+ * @param {string} code - Error code from UpdateErrorCodes
43
+ * @param {string} message - Human-readable error message
44
+ * @param {object} details - Additional error context
45
+ * @param {string[]} recoveryCommands - Array of shell commands to recover
46
+ */
47
+ constructor(code, message, details = {}, recoveryCommands = []) {
48
+ super(message);
49
+ this.name = 'UpdateError';
50
+ this.code = code;
51
+ this.details = details;
52
+ this.recoveryCommands = recoveryCommands;
53
+ this.timestamp = new Date().toISOString();
54
+
55
+ // Maintain proper stack trace in V8 environments
56
+ if (Error.captureStackTrace) {
57
+ Error.captureStackTrace(this, UpdateError);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Convert error to JSON representation
63
+ * @returns {object} Structured error object
64
+ */
65
+ toJSON() {
66
+ return {
67
+ error: {
68
+ name: this.name,
69
+ code: this.code,
70
+ message: this.message,
71
+ details: this.details,
72
+ recoveryCommands: this.recoveryCommands,
73
+ timestamp: this.timestamp,
74
+ stack: this.stack,
75
+ },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Convert error to formatted string with recovery commands
81
+ * @returns {string} Formatted error message
82
+ */
83
+ toString() {
84
+ let output = `${this.name}: ${this.code} - ${this.message}`;
85
+
86
+ if (this.details && Object.keys(this.details).length > 0) {
87
+ output += '\n\nDetails:';
88
+ for (const [key, value] of Object.entries(this.details)) {
89
+ if (Array.isArray(value)) {
90
+ output += `\n ${key}:`;
91
+ for (const item of value) {
92
+ output += `\n - ${item}`;
93
+ }
94
+ } else {
95
+ output += `\n ${key}: ${value}`;
96
+ }
97
+ }
98
+ }
99
+
100
+ if (this.recoveryCommands.length > 0) {
101
+ output += '\n\n========================================';
102
+ output += '\nUPDATE FAILED - RECOVERY REQUIRED';
103
+ output += '\n========================================';
104
+ output += '\n\nTo recover your workspace:';
105
+ for (const cmd of this.recoveryCommands) {
106
+ output += `\n ${cmd}`;
107
+ }
108
+ output += '\n\n========================================';
109
+ }
110
+
111
+ return output;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Transaction states for tracking update progress
117
+ */
118
+ const TransactionStates = {
119
+ PENDING: 'pending',
120
+ PREPARING: 'preparing',
121
+ SYNCING: 'syncing',
122
+ VERIFYING: 'verifying',
123
+ COMMITTING: 'committing',
124
+ COMMITTED: 'committed',
125
+ ROLLING_BACK: 'rolling_back',
126
+ ROLLED_BACK: 'rolled_back',
127
+ };
128
+
129
+ /**
130
+ * UpdateTransaction - Two-phase commit for safe updates
131
+ *
132
+ * Implements a four-phase update process:
133
+ * 1. Prepare: Create checkpoint for rollback safety
134
+ * 2. Sync: Copy files from hub to repo
135
+ * 3. Verify: Ensure all files copied correctly with hash verification
136
+ * 4. Commit: Update version.json
137
+ *
138
+ * On any failure, automatic rollback restores the checkpoint.
139
+ */
140
+ class UpdateTransaction {
141
+ /**
142
+ * Create an UpdateTransaction
143
+ * @param {string} repoPath - Path to repository being updated
144
+ * @param {object} options - Transaction options
145
+ * @param {string} options.sourceVersion - Version to update to
146
+ * @param {boolean} options.quiet - Suppress output
147
+ * @param {boolean} options.force - Force update even with dirty files
148
+ */
149
+ constructor(repoPath, options = {}) {
150
+ this.repoPath = repoPath;
151
+ this.sourceVersion = options.sourceVersion || null;
152
+ this.quiet = options.quiet || false;
153
+ this.force = options.force || false;
154
+
155
+ // Transaction state
156
+ this.state = TransactionStates.PENDING;
157
+ this.checkpoint = null;
158
+ this.syncResult = null;
159
+ this.errors = [];
160
+
161
+ // Hub paths (from cli.js)
162
+ this.HOME = process.env.HOME || process.env.USERPROFILE;
163
+ this.HUB_DIR = path.join(this.HOME, '.aether');
164
+ this.HUB_SYSTEM_DIR = path.join(this.HUB_DIR, 'system');
165
+ this.HUB_COMMANDS_CLAUDE = path.join(this.HUB_SYSTEM_DIR, 'commands', 'claude');
166
+ this.HUB_COMMANDS_OPENCODE = path.join(this.HUB_SYSTEM_DIR, 'commands', 'opencode');
167
+ this.HUB_AGENTS = path.join(this.HUB_SYSTEM_DIR, 'agents');
168
+ this.HUB_AGENTS_CLAUDE = path.join(this.HUB_SYSTEM_DIR, 'agents-claude');
169
+ this.HUB_RULES = path.join(this.HUB_SYSTEM_DIR, 'rules');
170
+ this.HUB_VERSION = path.join(this.HUB_DIR, 'version.json');
171
+ this.HUB_REGISTRY = path.join(this.HUB_DIR, 'registry.json');
172
+
173
+ // Directories to exclude from sync (user data, local state, and separately-synced dirs)
174
+ // v4.0: archive and chambers added — these are private and must not sync to target repos
175
+ this.EXCLUDE_DIRS = ['data', 'dreams', 'checkpoints', 'locks', 'temp', 'agents', 'commands', 'rules', 'archive', 'chambers'];
176
+
177
+ // Target directories for git safety checks
178
+ this.targetDirs = ['.aether', '.claude/commands/ant', '.claude/agents/ant', '.claude/rules', '.opencode/commands/ant', '.opencode/agents'];
179
+ }
180
+
181
+ /**
182
+ * Log a message (respects quiet mode)
183
+ * @param {string} msg - Message to log
184
+ */
185
+ log(msg) {
186
+ if (!this.quiet) {
187
+ console.log(msg);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Read JSON file safely
193
+ * @param {string} filePath - Path to JSON file
194
+ * @returns {object|null} Parsed JSON or null on error
195
+ * @private
196
+ */
197
+ readJsonSafe(filePath) {
198
+ try {
199
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Write JSON file atomically
207
+ * @param {string} filePath - Path to write
208
+ * @param {object} data - Data to write
209
+ * @private
210
+ */
211
+ writeJsonSync(filePath, data) {
212
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
213
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
214
+ }
215
+
216
+ /**
217
+ * Compute SHA-256 hash of a file
218
+ * @param {string} filePath - Path to file
219
+ * @returns {string|null} Hash in format 'sha256:hex' or null on error
220
+ * @private
221
+ */
222
+ hashFileSync(filePath) {
223
+ try {
224
+ const content = fs.readFileSync(filePath);
225
+ return 'sha256:' + crypto.createHash('sha256').update(content).digest('hex');
226
+ } catch (err) {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Check if path is a git repository
233
+ * @returns {boolean} True if git repo
234
+ * @private
235
+ */
236
+ isGitRepo() {
237
+ try {
238
+ execSync('git rev-parse --git-dir', { cwd: this.repoPath, stdio: 'pipe' });
239
+ return true;
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Detect dirty repository state with detailed categorization
247
+ * @returns {object} Dirty state info: { isDirty, tracked, untracked, staged }
248
+ */
249
+ detectDirtyRepo() {
250
+ try {
251
+ const args = this.targetDirs.filter(d => fs.existsSync(path.join(this.repoPath, d)));
252
+ if (args.length === 0) return { isDirty: false, tracked: [], untracked: [], staged: [] };
253
+
254
+ const result = execSync(`git status --porcelain -- ${args.map(d => `"${d}"`).join(' ')}`, {
255
+ cwd: this.repoPath,
256
+ stdio: 'pipe',
257
+ encoding: 'utf8',
258
+ });
259
+
260
+ const lines = result.trim().split('\n').filter(Boolean);
261
+ const tracked = [];
262
+ const untracked = [];
263
+ const staged = [];
264
+
265
+ for (const line of lines) {
266
+ const status = line.slice(0, 2);
267
+ const filePath = line.slice(3);
268
+
269
+ // Staged changes (in index)
270
+ if (status[0] !== ' ' && status[0] !== '?') {
271
+ staged.push(filePath);
272
+ }
273
+
274
+ // Untracked files
275
+ if (status === '??') {
276
+ untracked.push(filePath);
277
+ } else {
278
+ // Modified/tracked files
279
+ tracked.push(filePath);
280
+ }
281
+ }
282
+
283
+ return {
284
+ isDirty: lines.length > 0,
285
+ tracked,
286
+ untracked,
287
+ staged,
288
+ };
289
+ } catch {
290
+ return { isDirty: false, tracked: [], untracked: [], staged: [] };
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Get dirty files in target directories (legacy method for backward compatibility)
296
+ * @returns {string[]} Array of dirty file paths
297
+ * @private
298
+ */
299
+ getGitDirtyFiles() {
300
+ const dirty = this.detectDirtyRepo();
301
+ return [...dirty.tracked, ...dirty.untracked];
302
+ }
303
+
304
+ /**
305
+ * Validate repository state before update
306
+ * @returns {object} Validation result: { clean: boolean, dirtyState?: object }
307
+ * @throws {UpdateError} If repository has uncommitted changes
308
+ */
309
+ validateRepoState() {
310
+ const dirtyState = this.detectDirtyRepo();
311
+
312
+ if (!dirtyState.isDirty) {
313
+ return { clean: true };
314
+ }
315
+
316
+ // If force flag is set, allow dirty repo (will be stashed in checkpoint)
317
+ if (this.force) {
318
+ this.log(' Force flag set: proceeding with dirty repository (will stash changes)');
319
+ return { clean: true, dirty: dirtyState, force: true };
320
+ }
321
+
322
+ // Build detailed error message
323
+ const lines = [
324
+ 'Cannot update: repository has uncommitted changes',
325
+ '',
326
+ ];
327
+
328
+ if (dirtyState.tracked.length > 0) {
329
+ lines.push(`Modified files (${dirtyState.tracked.length}):`);
330
+ for (const f of dirtyState.tracked.slice(0, 10)) {
331
+ lines.push(` - ${f}`);
332
+ }
333
+ if (dirtyState.tracked.length > 10) {
334
+ lines.push(` ... and ${dirtyState.tracked.length - 10} more`);
335
+ }
336
+ lines.push('');
337
+ }
338
+
339
+ if (dirtyState.untracked.length > 0) {
340
+ lines.push(`Untracked files (${dirtyState.untracked.length}):`);
341
+ for (const f of dirtyState.untracked.slice(0, 10)) {
342
+ lines.push(` - ${f}`);
343
+ }
344
+ if (dirtyState.untracked.length > 10) {
345
+ lines.push(` ... and ${dirtyState.untracked.length - 10} more`);
346
+ }
347
+ lines.push('');
348
+ }
349
+
350
+ if (dirtyState.staged.length > 0) {
351
+ lines.push(`Staged files (${dirtyState.staged.length}):`);
352
+ for (const f of dirtyState.staged.slice(0, 10)) {
353
+ lines.push(` - ${f}`);
354
+ }
355
+ if (dirtyState.staged.length > 10) {
356
+ lines.push(` ... and ${dirtyState.staged.length - 10} more`);
357
+ }
358
+ lines.push('');
359
+ }
360
+
361
+ lines.push('Options:');
362
+ lines.push(' 1. Stash changes: git stash push -m "pre-update"');
363
+ lines.push(' 2. Commit changes: git add . && git commit -m "wip"');
364
+ lines.push(' 3. Discard changes: git checkout -- . (DANGER: loses work)');
365
+ lines.push('');
366
+ lines.push('After resolving, run: aether update');
367
+
368
+ const message = lines.join('\n');
369
+
370
+ throw new UpdateError(
371
+ UpdateErrorCodes.E_REPO_DIRTY,
372
+ 'Repository has uncommitted changes',
373
+ {
374
+ trackedCount: dirtyState.tracked.length,
375
+ untrackedCount: dirtyState.untracked.length,
376
+ stagedCount: dirtyState.staged.length,
377
+ tracked: dirtyState.tracked,
378
+ untracked: dirtyState.untracked,
379
+ staged: dirtyState.staged,
380
+ },
381
+ [
382
+ `cd ${this.repoPath} && git stash push -m "pre-update"`,
383
+ `cd ${this.repoPath} && git add . && git commit -m "wip"`,
384
+ `cd ${this.repoPath} && aether update`,
385
+ ]
386
+ );
387
+ }
388
+
389
+ /**
390
+ * Create git stash for files
391
+ * @param {string[]} files - Files to stash
392
+ * @returns {string|null} Stash reference or null on failure
393
+ * @private
394
+ */
395
+ gitStashFiles(files) {
396
+ try {
397
+ // Separate tracked and untracked files
398
+ const trackedFiles = [];
399
+ const untrackedFiles = [];
400
+
401
+ for (const file of files) {
402
+ const fullPath = path.join(this.repoPath, file);
403
+ try {
404
+ // Check if file is tracked by git
405
+ execSync(`git ls-files --error-unmatch "${file}"`, {
406
+ cwd: this.repoPath,
407
+ stdio: 'pipe'
408
+ });
409
+ trackedFiles.push(file);
410
+ } catch {
411
+ // File is not tracked (untracked or in .gitignore)
412
+ untrackedFiles.push(file);
413
+ }
414
+ }
415
+
416
+ let stashRef = null;
417
+
418
+ // Stash tracked files
419
+ if (trackedFiles.length > 0) {
420
+ const fileArgs = trackedFiles.map(f => `"${f}"`).join(' ');
421
+ execSync(`git stash push -m "aether-update-backup" -- ${fileArgs}`, {
422
+ cwd: this.repoPath,
423
+ stdio: 'pipe',
424
+ });
425
+
426
+ // Get the stash reference
427
+ const stashList = execSync('git stash list', { cwd: this.repoPath, encoding: 'utf8' });
428
+ const match = stashList.match(/^(stash@\{[^}]+\})/m);
429
+ stashRef = match ? match[1] : null;
430
+ }
431
+
432
+ // For untracked files, we can't stash them easily
433
+ // Just log a warning - they'll be left as-is during the update
434
+ if (untrackedFiles.length > 0) {
435
+ this.log(` Note: ${untrackedFiles.length} untracked files won't be stashed (left in place)`);
436
+ }
437
+
438
+ return stashRef;
439
+ } catch (err) {
440
+ this.log(` Warning: git stash failed (${err.message}). Proceeding without stash.`);
441
+ return null;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Create a checkpoint before update
447
+ * Implements UPDATE-01: Update creates checkpoint before file sync
448
+ *
449
+ * @returns {Promise<object>} Checkpoint object: { id, stashRef, timestamp }
450
+ * @throws {UpdateError} If checkpoint creation fails
451
+ */
452
+ async createCheckpoint() {
453
+ this.log(' Creating checkpoint for rollback safety...');
454
+
455
+ try {
456
+ // 1. Check if in git repo
457
+ if (!this.isGitRepo()) {
458
+ throw new UpdateError(
459
+ UpdateErrorCodes.E_CHECKPOINT_FAILED,
460
+ 'Not in a git repository',
461
+ { repoPath: this.repoPath },
462
+ ['git init', 'cd ' + this.repoPath]
463
+ );
464
+ }
465
+
466
+ // 2. Get dirty files in target directories
467
+ const dirtyFiles = this.getGitDirtyFiles();
468
+
469
+ // 3. Stash dirty files if any
470
+ let stashRef = null;
471
+ if (dirtyFiles.length > 0) {
472
+ stashRef = this.gitStashFiles(dirtyFiles);
473
+ }
474
+
475
+ // 4. Generate checkpoint ID
476
+ const now = new Date();
477
+ const checkpointId = `chk_${now.toISOString().slice(0, 10).replace(/-/g, '')}_${now.toTimeString().slice(0, 8).replace(/:/g, '')}`;
478
+
479
+ // 5. Create checkpoint metadata
480
+ const checkpoint = {
481
+ id: checkpointId,
482
+ stashRef,
483
+ timestamp: now.toISOString(),
484
+ dirtyFiles,
485
+ repoPath: this.repoPath,
486
+ };
487
+
488
+ // 6. Save checkpoint metadata
489
+ const checkpointsDir = path.join(this.repoPath, '.aether', 'checkpoints');
490
+ fs.mkdirSync(checkpointsDir, { recursive: true });
491
+ this.writeJsonSync(path.join(checkpointsDir, `${checkpointId}.json`), checkpoint);
492
+
493
+ this.checkpoint = checkpoint;
494
+ this.log(` Created checkpoint ${checkpointId} for rollback safety`);
495
+
496
+ return checkpoint;
497
+ } catch (error) {
498
+ if (error instanceof UpdateError) {
499
+ throw error;
500
+ }
501
+ throw new UpdateError(
502
+ UpdateErrorCodes.E_CHECKPOINT_FAILED,
503
+ `Failed to create checkpoint: ${error.message}`,
504
+ { originalError: error.message },
505
+ this.getRecoveryCommands()
506
+ );
507
+ }
508
+ }
509
+
510
+ /**
511
+ * List files recursively in a directory
512
+ * @param {string} dir - Directory to list
513
+ * @param {string} base - Base path for relative paths
514
+ * @returns {string[]} Array of relative file paths
515
+ * @private
516
+ */
517
+ listFilesRecursive(dir, base) {
518
+ base = base || dir;
519
+ const results = [];
520
+ if (!fs.existsSync(dir)) return results;
521
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
522
+ for (const entry of entries) {
523
+ if (entry.name.startsWith('.')) continue;
524
+ const fullPath = path.join(dir, entry.name);
525
+ if (entry.isDirectory()) {
526
+ results.push(...this.listFilesRecursive(fullPath, base));
527
+ } else {
528
+ results.push(path.relative(base, fullPath));
529
+ }
530
+ }
531
+ return results;
532
+ }
533
+
534
+ /**
535
+ * Sync directory with cleanup (copied from cli.js)
536
+ * @param {string} src - Source directory
537
+ * @param {string} dest - Destination directory
538
+ * @param {object} opts - Options
539
+ * @returns {object} Sync result: { copied, removed, skipped }
540
+ * @private
541
+ */
542
+ syncDirWithCleanup(src, dest, opts) {
543
+ opts = opts || {};
544
+ const dryRun = opts.dryRun || false;
545
+
546
+ try {
547
+ fs.mkdirSync(dest, { recursive: true });
548
+ } catch (err) {
549
+ if (err.code !== 'EEXIST') {
550
+ throw new UpdateError(
551
+ UpdateErrorCodes.E_SYNC_FAILED,
552
+ `Could not create directory ${dest}: ${err.message}`,
553
+ { src, dest },
554
+ this.getRecoveryCommands()
555
+ );
556
+ }
557
+ }
558
+
559
+ // Copy phase with hash comparison
560
+ let copied = 0;
561
+ let skipped = 0;
562
+ const srcFiles = this.listFilesRecursive(src);
563
+
564
+ if (!dryRun) {
565
+ for (const relPath of srcFiles) {
566
+ const srcPath = path.join(src, relPath);
567
+ const destPath = path.join(dest, relPath);
568
+ try {
569
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
570
+
571
+ // Hash comparison: only copy if file doesn't exist or hash differs
572
+ let shouldCopy = true;
573
+ if (fs.existsSync(destPath)) {
574
+ const srcHash = this.hashFileSync(srcPath);
575
+ const destHash = this.hashFileSync(destPath);
576
+ if (srcHash === destHash) {
577
+ shouldCopy = false;
578
+ skipped++;
579
+ }
580
+ }
581
+
582
+ if (shouldCopy) {
583
+ fs.copyFileSync(srcPath, destPath);
584
+ if (relPath.endsWith('.sh')) {
585
+ fs.chmodSync(destPath, 0o755);
586
+ }
587
+ copied++;
588
+ }
589
+ } catch (err) {
590
+ throw new UpdateError(
591
+ UpdateErrorCodes.E_SYNC_FAILED,
592
+ `Could not copy ${relPath}: ${err.message}`,
593
+ { srcPath, destPath },
594
+ this.getRecoveryCommands()
595
+ );
596
+ }
597
+ }
598
+ } else {
599
+ copied = srcFiles.length;
600
+ }
601
+
602
+ // Cleanup phase — remove files in dest that aren't in src
603
+ const destFiles = this.listFilesRecursive(dest);
604
+ const srcSet = new Set(srcFiles);
605
+ const removed = [];
606
+
607
+ for (const relPath of destFiles) {
608
+ if (!srcSet.has(relPath)) {
609
+ removed.push(relPath);
610
+ if (!dryRun) {
611
+ try {
612
+ fs.unlinkSync(path.join(dest, relPath));
613
+ } catch (err) {
614
+ this.log(` Warning: could not remove ${relPath}: ${err.message}`);
615
+ }
616
+ }
617
+ }
618
+ }
619
+
620
+ // Clean empty directories
621
+ if (!dryRun && removed.length > 0) {
622
+ this.cleanEmptyDirs(dest);
623
+ }
624
+
625
+ return { copied, removed, skipped };
626
+ }
627
+
628
+ /**
629
+ * Clean empty directories recursively
630
+ * @param {string} dir - Directory to clean
631
+ * @private
632
+ */
633
+ cleanEmptyDirs(dir) {
634
+ if (!fs.existsSync(dir)) return;
635
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
636
+ for (const entry of entries) {
637
+ if (entry.isDirectory()) {
638
+ this.cleanEmptyDirs(path.join(dir, entry.name));
639
+ }
640
+ }
641
+ // Re-read after recursive cleanup
642
+ const remaining = fs.readdirSync(dir);
643
+ if (remaining.length === 0) {
644
+ fs.rmdirSync(dir);
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Check if a path should be excluded from sync
650
+ * @param {string} relPath - Relative path
651
+ * @returns {boolean} True if should be excluded
652
+ * @private
653
+ */
654
+ shouldExclude(relPath) {
655
+ const parts = relPath.split(path.sep);
656
+ return parts.some(part => this.EXCLUDE_DIRS.includes(part));
657
+ }
658
+
659
+ /**
660
+ * Sync .aether/ directory from hub to repo, excluding user data directories
661
+ * @param {string} srcDir - Source hub directory
662
+ * @param {string} destDir - Destination repo .aether/ directory
663
+ * @param {object} opts - Options
664
+ * @returns {object} Sync result: { copied, removed, skipped }
665
+ * @private
666
+ */
667
+ syncAetherToRepo(srcDir, destDir, opts) {
668
+ opts = opts || {};
669
+ const dryRun = opts.dryRun || false;
670
+
671
+ if (!fs.existsSync(srcDir)) {
672
+ return { copied: 0, removed: [], skipped: 0 };
673
+ }
674
+
675
+ // Collect all files in source, filtering out excluded directories
676
+ const srcFiles = [];
677
+ const collectFiles = (dir, base) => {
678
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
679
+ for (const entry of entries) {
680
+ if (entry.name.startsWith('.')) continue;
681
+ const fullPath = path.join(dir, entry.name);
682
+ const relPath = path.relative(base, fullPath);
683
+
684
+ if (this.shouldExclude(relPath)) continue;
685
+
686
+ if (entry.isDirectory()) {
687
+ collectFiles(fullPath, base);
688
+ } else {
689
+ srcFiles.push(relPath);
690
+ }
691
+ }
692
+ };
693
+ collectFiles(srcDir, srcDir);
694
+
695
+ // Copy files with hash comparison
696
+ let copied = 0;
697
+ let skipped = 0;
698
+ for (const relPath of srcFiles) {
699
+ const srcPath = path.join(srcDir, relPath);
700
+ const destPath = path.join(destDir, relPath);
701
+
702
+ if (!dryRun) {
703
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
704
+
705
+ // Hash comparison
706
+ let shouldCopy = true;
707
+ if (fs.existsSync(destPath)) {
708
+ const srcHash = this.hashFileSync(srcPath);
709
+ const destHash = this.hashFileSync(destPath);
710
+ if (srcHash === destHash) {
711
+ shouldCopy = false;
712
+ skipped++;
713
+ }
714
+ }
715
+
716
+ if (shouldCopy) {
717
+ fs.copyFileSync(srcPath, destPath);
718
+ if (relPath.endsWith('.sh')) {
719
+ fs.chmodSync(destPath, 0o755);
720
+ }
721
+ }
722
+ }
723
+ copied++;
724
+ }
725
+
726
+ // Cleanup: remove files in dest that aren't in source
727
+ const destFiles = [];
728
+ const collectDestFiles = (dir, base) => {
729
+ if (!fs.existsSync(dir)) return;
730
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
731
+ for (const entry of entries) {
732
+ if (entry.name.startsWith('.')) continue;
733
+ const fullPath = path.join(dir, entry.name);
734
+ const relPath = path.relative(base, fullPath);
735
+
736
+ if (this.shouldExclude(relPath)) continue;
737
+
738
+ if (entry.isDirectory()) {
739
+ collectDestFiles(fullPath, base);
740
+ } else {
741
+ destFiles.push(relPath);
742
+ }
743
+ }
744
+ };
745
+ collectDestFiles(destDir, destDir);
746
+
747
+ const srcSet = new Set(srcFiles);
748
+ const removed = [];
749
+ for (const relPath of destFiles) {
750
+ if (!srcSet.has(relPath)) {
751
+ removed.push(relPath);
752
+ if (!dryRun) {
753
+ try {
754
+ fs.unlinkSync(path.join(destDir, relPath));
755
+ } catch (err) {
756
+ // Ignore cleanup errors
757
+ }
758
+ }
759
+ }
760
+ }
761
+
762
+ if (!dryRun && removed.length > 0) {
763
+ this.cleanEmptyDirs(destDir);
764
+ }
765
+
766
+ return { copied, removed, skipped };
767
+ }
768
+
769
+ /**
770
+ * Remove known stale directories and files left behind by pre-3.0.0 versions.
771
+ * These paths were incorrectly distributed in earlier releases and must be
772
+ * explicitly removed — EXCLUDE_DIRS prevents NEW pollution but does not clean
773
+ * items that already exist on disk.
774
+ *
775
+ * Idempotent: safe to call when items do not exist.
776
+ *
777
+ * @param {string} repoPath - Absolute path to the target repository root
778
+ * @returns {{ cleaned: string[], failed: Array<{label: string, error: string}> }}
779
+ */
780
+ cleanupStaleAetherDirs(repoPath) {
781
+ const staleItems = [
782
+ {
783
+ path: path.join(repoPath, '.aether', 'agents'),
784
+ label: '.aether/agents/ (stale duplicate)',
785
+ type: 'dir',
786
+ },
787
+ {
788
+ path: path.join(repoPath, '.aether', 'commands'),
789
+ label: '.aether/commands/ (stale duplicate)',
790
+ type: 'dir',
791
+ },
792
+ {
793
+ path: path.join(repoPath, '.aether', 'planning.md'),
794
+ label: '.aether/planning.md (phantom file)',
795
+ type: 'file',
796
+ },
797
+ ];
798
+
799
+ const cleaned = [];
800
+ const failed = [];
801
+
802
+ for (const item of staleItems) {
803
+ if (!fs.existsSync(item.path)) {
804
+ // Already clean — idempotent skip
805
+ continue;
806
+ }
807
+
808
+ try {
809
+ if (item.type === 'dir') {
810
+ fs.rmSync(item.path, { recursive: true, force: true });
811
+ } else {
812
+ fs.unlinkSync(item.path);
813
+ }
814
+ cleaned.push(item.label);
815
+ } catch (err) {
816
+ failed.push({ label: item.label, error: err.message });
817
+ }
818
+ }
819
+
820
+ return { cleaned, failed };
821
+ }
822
+
823
+ /**
824
+ * Sync files from hub to repo
825
+ * @param {string} sourceVersion - Version to sync from
826
+ * @param {boolean} dryRun - If true, don't actually copy files
827
+ * @returns {object} Sync result: { copied, removed, unchanged, errors }
828
+ */
829
+ syncFiles(sourceVersion, dryRun = false) {
830
+ this.state = TransactionStates.SYNCING;
831
+
832
+ const results = {
833
+ system: { copied: 0, removed: 0, skipped: 0 },
834
+ commands: { copied: 0, removed: 0, skipped: 0 },
835
+ agents: { copied: 0, removed: 0, skipped: 0 },
836
+ agents_claude: { copied: 0, removed: [], skipped: 0 },
837
+ rules: { copied: 0, removed: 0, skipped: 0 },
838
+ errors: [],
839
+ };
840
+
841
+ const repoAether = path.join(this.repoPath, '.aether');
842
+
843
+ // Sync .aether/ from hub to repo (excluding user data directories)
844
+ if (fs.existsSync(this.HUB_SYSTEM_DIR)) {
845
+ results.system = this.syncAetherToRepo(this.HUB_SYSTEM_DIR, repoAether, { dryRun });
846
+ }
847
+
848
+ // Sync commands from hub
849
+ const repoClaudeCmds = path.join(this.repoPath, '.claude', 'commands', 'ant');
850
+ if (fs.existsSync(this.HUB_COMMANDS_CLAUDE)) {
851
+ const result = this.syncDirWithCleanup(this.HUB_COMMANDS_CLAUDE, repoClaudeCmds, { dryRun });
852
+ results.commands = result;
853
+ }
854
+
855
+ const repoOpencodeCmds = path.join(this.repoPath, '.opencode', 'commands', 'ant');
856
+ if (fs.existsSync(this.HUB_COMMANDS_OPENCODE)) {
857
+ const result = this.syncDirWithCleanup(this.HUB_COMMANDS_OPENCODE, repoOpencodeCmds, { dryRun });
858
+ results.commands.copied += result.copied;
859
+ results.commands.removed.push(...result.removed);
860
+ results.commands.skipped += result.skipped;
861
+ }
862
+
863
+ // Sync agents from hub
864
+ const repoAgents = path.join(this.repoPath, '.opencode', 'agents');
865
+ if (fs.existsSync(this.HUB_AGENTS)) {
866
+ results.agents = this.syncDirWithCleanup(this.HUB_AGENTS, repoAgents, { dryRun });
867
+ }
868
+
869
+ // Sync claude agents from hub to .claude/agents/ant/
870
+ const repoClaudeAgents = path.join(this.repoPath, '.claude', 'agents', 'ant');
871
+ if (fs.existsSync(this.HUB_AGENTS_CLAUDE)) {
872
+ results.agents_claude = this.syncDirWithCleanup(this.HUB_AGENTS_CLAUDE, repoClaudeAgents, { dryRun });
873
+ }
874
+
875
+ // Sync rules from hub to .claude/rules/
876
+ const repoRules = path.join(this.repoPath, '.claude', 'rules');
877
+ if (fs.existsSync(this.HUB_RULES)) {
878
+ results.rules = this.syncDirWithCleanup(this.HUB_RULES, repoRules, { dryRun });
879
+ }
880
+
881
+ this.syncResult = results;
882
+ return results;
883
+ }
884
+
885
+ /**
886
+ * Verify integrity of synced files
887
+ * @returns {object} Verification result: { valid: boolean, errors: string[] }
888
+ */
889
+ verifyIntegrity() {
890
+ this.state = TransactionStates.VERIFYING;
891
+
892
+ const errors = [];
893
+
894
+ // Verify hub files exist and match expected
895
+ const verifyDir = (hubDir, repoDir) => {
896
+ if (!fs.existsSync(hubDir)) return;
897
+
898
+ const files = this.listFilesRecursive(hubDir);
899
+ for (const relPath of files) {
900
+ // Skip excluded directories
901
+ if (this.shouldExclude(relPath)) continue;
902
+
903
+ const hubPath = path.join(hubDir, relPath);
904
+ const repoPath = path.join(repoDir, relPath);
905
+
906
+ // Check file exists
907
+ if (!fs.existsSync(repoPath)) {
908
+ errors.push(`Missing file: ${relPath}`);
909
+ continue;
910
+ }
911
+
912
+ // Check hash matches
913
+ const hubHash = this.hashFileSync(hubPath);
914
+ const repoHash = this.hashFileSync(repoPath);
915
+
916
+ if (hubHash !== repoHash) {
917
+ errors.push(`Hash mismatch: ${relPath}`);
918
+ }
919
+ }
920
+ };
921
+
922
+ const repoAether = path.join(this.repoPath, '.aether');
923
+ verifyDir(this.HUB_SYSTEM_DIR, repoAether);
924
+ verifyDir(this.HUB_COMMANDS_CLAUDE, path.join(this.repoPath, '.claude', 'commands', 'ant'));
925
+ verifyDir(this.HUB_COMMANDS_OPENCODE, path.join(this.repoPath, '.opencode', 'commands', 'ant'));
926
+ verifyDir(this.HUB_AGENTS, path.join(this.repoPath, '.opencode', 'agents'));
927
+ verifyDir(this.HUB_AGENTS_CLAUDE, path.join(this.repoPath, '.claude', 'agents', 'ant'));
928
+ verifyDir(this.HUB_RULES, path.join(this.repoPath, '.claude', 'rules'));
929
+
930
+ return {
931
+ valid: errors.length === 0,
932
+ errors,
933
+ };
934
+ }
935
+
936
+ /**
937
+ * Check if hub is accessible before sync
938
+ * @returns {object} Accessibility result: { accessible: boolean, errors: string[] }
939
+ * @throws {UpdateError} If hub is not accessible
940
+ */
941
+ checkHubAccessibility() {
942
+ const errors = [];
943
+
944
+ // Check if HUB_DIR exists
945
+ if (!fs.existsSync(this.HUB_DIR)) {
946
+ errors.push(`Hub directory does not exist: ${this.HUB_DIR}`);
947
+ return {
948
+ accessible: false,
949
+ errors,
950
+ recoveryCommands: [
951
+ 'aether install',
952
+ `mkdir -p ${this.HUB_DIR}`,
953
+ ],
954
+ };
955
+ }
956
+
957
+ // Check if hub directories are readable
958
+ const checkDir = (dir, name) => {
959
+ if (!fs.existsSync(dir)) {
960
+ // Non-critical: directories may not exist if no files to sync
961
+ return;
962
+ }
963
+ try {
964
+ fs.accessSync(dir, fs.constants.R_OK);
965
+ } catch (err) {
966
+ errors.push(`Cannot read ${name} directory: ${dir} - ${err.message}`);
967
+ }
968
+ };
969
+
970
+ checkDir(this.HUB_DIR, '.aether');
971
+ checkDir(this.HUB_COMMANDS_CLAUDE, 'commands/claude');
972
+ checkDir(this.HUB_COMMANDS_OPENCODE, 'commands/opencode');
973
+ checkDir(this.HUB_AGENTS, 'agents');
974
+ checkDir(this.HUB_AGENTS_CLAUDE, 'agents-claude');
975
+ checkDir(this.HUB_RULES, 'rules');
976
+ checkDir(this.HUB_VERSION, 'version');
977
+
978
+ // Check if source files exist
979
+ const checkSourceFiles = () => {
980
+ if (fs.existsSync(this.HUB_VERSION)) {
981
+ return true;
982
+ }
983
+ errors.push(`Hub version file not found: ${this.HUB_VERSION}`);
984
+ return false;
985
+ };
986
+
987
+ const hasVersion = checkSourceFiles();
988
+
989
+ if (errors.length > 0 || !hasVersion) {
990
+ return {
991
+ accessible: false,
992
+ errors,
993
+ recoveryCommands: [
994
+ `ls -la ${this.HUB_DIR}`,
995
+ 'aether install',
996
+ 'aether update',
997
+ ],
998
+ };
999
+ }
1000
+
1001
+ return { accessible: true, errors: [] };
1002
+ }
1003
+
1004
+ /**
1005
+ * Detect partial update by comparing expected vs actual files
1006
+ * @returns {object} Detection result: { isPartial, missing, corrupted }
1007
+ */
1008
+ detectPartialUpdate() {
1009
+ const missing = [];
1010
+ const corrupted = [];
1011
+
1012
+ // Compare expected files (from hub) vs actual files (in repo)
1013
+ const checkDir = (hubDir, repoDir) => {
1014
+ if (!fs.existsSync(hubDir)) return;
1015
+
1016
+ const files = this.listFilesRecursive(hubDir);
1017
+ for (const relPath of files) {
1018
+ // Skip excluded directories
1019
+ if (this.shouldExclude(relPath)) continue;
1020
+
1021
+ const hubPath = path.join(hubDir, relPath);
1022
+ const repoPath = path.join(repoDir, relPath);
1023
+
1024
+ // Check if file exists
1025
+ if (!fs.existsSync(repoPath)) {
1026
+ missing.push({
1027
+ path: relPath,
1028
+ hubPath,
1029
+ repoPath,
1030
+ });
1031
+ continue;
1032
+ }
1033
+
1034
+ // Check file size
1035
+ try {
1036
+ const hubStat = fs.statSync(hubPath);
1037
+ const repoStat = fs.statSync(repoPath);
1038
+
1039
+ if (hubStat.size !== repoStat.size) {
1040
+ corrupted.push({
1041
+ path: relPath,
1042
+ reason: 'size_mismatch',
1043
+ hubSize: hubStat.size,
1044
+ repoSize: repoStat.size,
1045
+ });
1046
+ continue;
1047
+ }
1048
+
1049
+ // Check hash
1050
+ const hubHash = this.hashFileSync(hubPath);
1051
+ const repoHash = this.hashFileSync(repoPath);
1052
+
1053
+ if (hubHash !== repoHash) {
1054
+ corrupted.push({
1055
+ path: relPath,
1056
+ reason: 'hash_mismatch',
1057
+ hubHash,
1058
+ repoHash,
1059
+ });
1060
+ }
1061
+ } catch (err) {
1062
+ corrupted.push({
1063
+ path: relPath,
1064
+ reason: 'read_error',
1065
+ error: err.message,
1066
+ });
1067
+ }
1068
+ }
1069
+ };
1070
+
1071
+ const repoAether = path.join(this.repoPath, '.aether');
1072
+ checkDir(this.HUB_SYSTEM_DIR, repoAether);
1073
+ checkDir(this.HUB_COMMANDS_CLAUDE, path.join(this.repoPath, '.claude', 'commands', 'ant'));
1074
+ checkDir(this.HUB_COMMANDS_OPENCODE, path.join(this.repoPath, '.opencode', 'commands', 'ant'));
1075
+ checkDir(this.HUB_AGENTS, path.join(this.repoPath, '.opencode', 'agents'));
1076
+ checkDir(this.HUB_RULES, path.join(this.repoPath, '.claude', 'rules'));
1077
+
1078
+ return {
1079
+ isPartial: missing.length > 0 || corrupted.length > 0,
1080
+ missing,
1081
+ corrupted,
1082
+ };
1083
+ }
1084
+
1085
+ /**
1086
+ * Verify sync completeness after file sync
1087
+ * @throws {UpdateError} If partial update detected
1088
+ */
1089
+ verifySyncCompleteness() {
1090
+ const partial = this.detectPartialUpdate();
1091
+
1092
+ if (!partial.isPartial) {
1093
+ return;
1094
+ }
1095
+
1096
+ // Build detailed error message
1097
+ const lines = [
1098
+ `Update incomplete: ${partial.missing.length} files missing, ${partial.corrupted.length} files corrupted`,
1099
+ '',
1100
+ ];
1101
+
1102
+ if (partial.missing.length > 0) {
1103
+ lines.push('Missing files:');
1104
+ for (const f of partial.missing.slice(0, 10)) {
1105
+ lines.push(` - ${f.path}`);
1106
+ }
1107
+ if (partial.missing.length > 10) {
1108
+ lines.push(` ... and ${partial.missing.length - 10} more`);
1109
+ }
1110
+ lines.push('');
1111
+ }
1112
+
1113
+ if (partial.corrupted.length > 0) {
1114
+ lines.push('Corrupted files:');
1115
+ for (const f of partial.corrupted.slice(0, 10)) {
1116
+ lines.push(` - ${f.path} (${f.reason})`);
1117
+ }
1118
+ if (partial.corrupted.length > 10) {
1119
+ lines.push(` ... and ${partial.corrupted.length - 10} more`);
1120
+ }
1121
+ lines.push('');
1122
+ }
1123
+
1124
+ lines.push('The update has been rolled back. Your workspace is unchanged.');
1125
+ lines.push('');
1126
+ lines.push('To retry: aether update');
1127
+
1128
+ throw new UpdateError(
1129
+ UpdateErrorCodes.E_PARTIAL_UPDATE,
1130
+ 'Update incomplete: files missing or corrupted',
1131
+ {
1132
+ missingCount: partial.missing.length,
1133
+ corruptedCount: partial.corrupted.length,
1134
+ missing: partial.missing.map(f => f.path),
1135
+ corrupted: partial.corrupted.map(f => ({ path: f.path, reason: f.reason })),
1136
+ },
1137
+ [
1138
+ `cd ${this.repoPath}`,
1139
+ 'aether update',
1140
+ ]
1141
+ );
1142
+ }
1143
+
1144
+ /**
1145
+ * Handle network-related errors with enhanced diagnostics
1146
+ * @param {Error} error - Original error
1147
+ * @returns {UpdateError} Enhanced error with recovery commands
1148
+ */
1149
+ handleNetworkError(error) {
1150
+ const networkErrorCodes = ['ETIMEDOUT', 'ECONNREFUSED', 'ENETUNREACH', 'EACCES', 'EPERM'];
1151
+ const isNetworkError = networkErrorCodes.includes(error.code) ||
1152
+ error.message.includes('network') ||
1153
+ error.message.includes('timeout') ||
1154
+ error.message.includes('connection');
1155
+
1156
+ if (!isNetworkError) {
1157
+ // Not a network error, return generic error
1158
+ return new UpdateError(
1159
+ UpdateErrorCodes.E_UPDATE_FAILED,
1160
+ error.message,
1161
+ { originalError: error.stack },
1162
+ this.getRecoveryCommands()
1163
+ );
1164
+ }
1165
+
1166
+ // Build network-specific error message
1167
+ const lines = [
1168
+ `Network error during update: ${error.message}`,
1169
+ '',
1170
+ 'Possible causes:',
1171
+ ` - Hub directory not accessible: ${this.HUB_DIR}`,
1172
+ ' - Network filesystem unavailable',
1173
+ ' - Permission denied',
1174
+ '',
1175
+ 'Recovery:',
1176
+ ' 1. Check network connectivity',
1177
+ ` 2. Verify hub exists: ls -la ${this.HUB_DIR}`,
1178
+ ' 3. Retry: aether update',
1179
+ ];
1180
+
1181
+ return new UpdateError(
1182
+ UpdateErrorCodes.E_NETWORK_ERROR,
1183
+ `Network error: ${error.message}`,
1184
+ {
1185
+ hubDir: this.HUB_DIR,
1186
+ originalError: error.stack,
1187
+ errorCode: error.code,
1188
+ },
1189
+ [
1190
+ `ls -la ${this.HUB_DIR}`,
1191
+ 'aether install',
1192
+ 'aether update',
1193
+ ]
1194
+ );
1195
+ }
1196
+
1197
+ /**
1198
+ * Update version.json in repo
1199
+ * @param {string} sourceVersion - Version to set
1200
+ */
1201
+ updateVersion(sourceVersion) {
1202
+ const repoVersionFile = path.join(this.repoPath, '.aether', 'version.json');
1203
+ this.writeJsonSync(repoVersionFile, {
1204
+ version: sourceVersion,
1205
+ updated_at: new Date().toISOString(),
1206
+ });
1207
+
1208
+ // Update registry entry
1209
+ const registry = this.readJsonSafe(this.HUB_REGISTRY);
1210
+ if (registry) {
1211
+ const ts = new Date().toISOString();
1212
+ const existing = registry.repos.find(r => r.path === this.repoPath);
1213
+ if (existing) {
1214
+ existing.version = sourceVersion;
1215
+ existing.updated_at = ts;
1216
+ } else {
1217
+ registry.repos.push({
1218
+ path: this.repoPath,
1219
+ version: sourceVersion,
1220
+ registered_at: ts,
1221
+ updated_at: ts,
1222
+ });
1223
+ }
1224
+ this.writeJsonSync(this.HUB_REGISTRY, registry);
1225
+ }
1226
+ }
1227
+
1228
+ /**
1229
+ * Rollback to checkpoint
1230
+ * Implements UPDATE-03: Automatic rollback on sync failure
1231
+ *
1232
+ * @returns {Promise<boolean>} True if rollback succeeded
1233
+ */
1234
+ async rollback() {
1235
+ // Clear pending sentinel on rollback — we've cleanly returned to prior state
1236
+ const pendingPath = path.join(this.repoPath, '.aether', '.update-pending');
1237
+ try {
1238
+ if (fs.existsSync(pendingPath)) {
1239
+ fs.unlinkSync(pendingPath);
1240
+ }
1241
+ } catch { /* ignore — rollback cleanup is best-effort */ }
1242
+
1243
+ this.state = TransactionStates.ROLLING_BACK;
1244
+ this.log(' Rolling back to checkpoint...');
1245
+
1246
+ try {
1247
+ if (!this.checkpoint) {
1248
+ this.log(' No checkpoint to rollback to');
1249
+ this.state = TransactionStates.ROLLED_BACK;
1250
+ return false;
1251
+ }
1252
+
1253
+ // Restore from stash if available
1254
+ if (this.checkpoint.stashRef) {
1255
+ try {
1256
+ execSync(`git stash pop ${this.checkpoint.stashRef}`, {
1257
+ cwd: this.repoPath,
1258
+ stdio: 'pipe',
1259
+ });
1260
+ this.log(` Restored stash ${this.checkpoint.stashRef}`);
1261
+ } catch (err) {
1262
+ this.log(` Warning: could not restore stash: ${err.message}`);
1263
+ }
1264
+ }
1265
+
1266
+ // Remove checkpoint metadata file
1267
+ const checkpointPath = path.join(
1268
+ this.repoPath,
1269
+ '.aether',
1270
+ 'checkpoints',
1271
+ `${this.checkpoint.id}.json`
1272
+ );
1273
+ if (fs.existsSync(checkpointPath)) {
1274
+ fs.unlinkSync(checkpointPath);
1275
+ }
1276
+
1277
+ this.state = TransactionStates.ROLLED_BACK;
1278
+ this.log(' Rollback complete');
1279
+ return true;
1280
+ } catch (error) {
1281
+ this.errors.push(`Rollback failed: ${error.message}`);
1282
+ this.state = TransactionStates.ROLLED_BACK;
1283
+ return false;
1284
+ }
1285
+ }
1286
+
1287
+ /**
1288
+ * Get recovery commands based on transaction state
1289
+ * Implements UPDATE-04: Recovery commands displayed prominently on failure
1290
+ *
1291
+ * @returns {string[]} Array of shell commands to recover
1292
+ */
1293
+ getRecoveryCommands() {
1294
+ const commands = [];
1295
+
1296
+ // If stash was created, include git stash pop
1297
+ if (this.checkpoint?.stashRef) {
1298
+ commands.push(`cd ${this.repoPath} && git stash pop ${this.checkpoint.stashRef}`);
1299
+ }
1300
+
1301
+ // If checkpoint exists, include checkpoint restore
1302
+ if (this.checkpoint?.id) {
1303
+ commands.push(`aether checkpoint restore ${this.checkpoint.id}`);
1304
+ }
1305
+
1306
+ // Always include manual fallback
1307
+ commands.push(`cd ${this.repoPath} && git reset --hard HEAD`);
1308
+
1309
+ return commands;
1310
+ }
1311
+
1312
+ /**
1313
+ * Execute the full two-phase commit
1314
+ * Implements UPDATE-02: Two-phase commit (backup → sync → verify → update version)
1315
+ *
1316
+ * @param {string} sourceVersion - Version to update to
1317
+ * @param {object} options - Execution options
1318
+ * @param {boolean} options.dryRun - If true, don't actually modify files
1319
+ * @returns {Promise<object>} Result object
1320
+ * @throws {UpdateError} On any failure (with automatic rollback)
1321
+ */
1322
+ async execute(sourceVersion, options = {}) {
1323
+ const dryRun = options.dryRun || false;
1324
+
1325
+ // Write pending sentinel BEFORE any work — marks update as in-flight
1326
+ const pendingPath = path.join(this.repoPath, '.aether', '.update-pending');
1327
+ this.writeJsonSync(pendingPath, {
1328
+ target_version: sourceVersion,
1329
+ started_at: new Date().toISOString(),
1330
+ });
1331
+
1332
+ try {
1333
+ // Phase 0: Validate repo state (before any modifications)
1334
+ // Check for dirty repo and provide clear recovery instructions
1335
+ this.validateRepoState();
1336
+
1337
+ // Phase 1: Prepare
1338
+ // UPDATE-01: Create checkpoint before file sync
1339
+ this.state = TransactionStates.PREPARING;
1340
+
1341
+ // Check hub accessibility before proceeding
1342
+ const hubAccess = this.checkHubAccessibility();
1343
+ if (!hubAccess.accessible) {
1344
+ throw new UpdateError(
1345
+ UpdateErrorCodes.E_HUB_INACCESSIBLE,
1346
+ 'Hub is not accessible',
1347
+ { errors: hubAccess.errors },
1348
+ hubAccess.recoveryCommands || [
1349
+ `ls -la ${this.HUB_DIR}`,
1350
+ 'aether install',
1351
+ 'aether update',
1352
+ ]
1353
+ );
1354
+ }
1355
+
1356
+ await this.createCheckpoint();
1357
+
1358
+ // Clean up known stale directories/files from previous versions
1359
+ this.cleanupResult = this.cleanupStaleAetherDirs(this.repoPath);
1360
+
1361
+ // Phase 2: Sync (with network error handling)
1362
+ this.state = TransactionStates.SYNCING;
1363
+ try {
1364
+ this.syncFiles(sourceVersion, dryRun);
1365
+ } catch (syncError) {
1366
+ // Handle network errors specifically
1367
+ throw this.handleNetworkError(syncError);
1368
+ }
1369
+
1370
+ // Phase 3: Verify (skip if dryRun)
1371
+ if (!dryRun) {
1372
+ this.state = TransactionStates.VERIFYING;
1373
+
1374
+ // Check for partial updates first
1375
+ this.verifySyncCompleteness();
1376
+
1377
+ // Then run integrity verification
1378
+ const verification = this.verifyIntegrity();
1379
+ if (!verification.valid) {
1380
+ // UPDATE-03: Automatic rollback on sync failure
1381
+ await this.rollback();
1382
+ throw new UpdateError(
1383
+ UpdateErrorCodes.E_VERIFY_FAILED,
1384
+ 'Verification failed after sync',
1385
+ { errors: verification.errors },
1386
+ this.getRecoveryCommands()
1387
+ );
1388
+ }
1389
+ }
1390
+
1391
+ // Phase 4: Commit (skip if dryRun)
1392
+ if (!dryRun) {
1393
+ this.state = TransactionStates.COMMITTING;
1394
+ this.updateVersion(sourceVersion);
1395
+ this.state = TransactionStates.COMMITTED;
1396
+
1397
+ // Delete pending sentinel — update is now complete
1398
+ try {
1399
+ if (fs.existsSync(pendingPath)) {
1400
+ fs.unlinkSync(pendingPath);
1401
+ }
1402
+ } catch (err) {
1403
+ this.log(` Warning: could not clear update sentinel: ${err.message}`);
1404
+ }
1405
+ }
1406
+
1407
+ // Calculate totals
1408
+ const filesSynced = (this.syncResult?.system?.copied || 0) +
1409
+ (this.syncResult?.commands?.copied || 0) +
1410
+ (this.syncResult?.agents?.copied || 0) +
1411
+ (this.syncResult?.rules?.copied || 0);
1412
+ const filesRemoved = (this.syncResult?.system?.removed?.length || 0) +
1413
+ (this.syncResult?.commands?.removed?.length || 0) +
1414
+ (this.syncResult?.agents?.removed?.length || 0) +
1415
+ (this.syncResult?.rules?.removed?.length || 0);
1416
+
1417
+ return {
1418
+ success: true,
1419
+ status: dryRun ? 'dry-run' : 'updated',
1420
+ checkpoint_id: this.checkpoint?.id,
1421
+ files_synced: filesSynced,
1422
+ files_removed: filesRemoved,
1423
+ sync_result: this.syncResult,
1424
+ cleanup_result: this.cleanupResult || { cleaned: [], failed: [] },
1425
+ };
1426
+
1427
+ } catch (error) {
1428
+ // UPDATE-03: Automatic rollback on any failure
1429
+ if (this.state !== TransactionStates.ROLLED_BACK &&
1430
+ this.state !== TransactionStates.ROLLING_BACK) {
1431
+ await this.rollback();
1432
+ }
1433
+
1434
+ // Enhance error with recovery commands if not already an UpdateError
1435
+ if (!(error instanceof UpdateError)) {
1436
+ error = new UpdateError(
1437
+ UpdateErrorCodes.E_UPDATE_FAILED,
1438
+ error.message,
1439
+ { originalError: error.stack },
1440
+ this.getRecoveryCommands()
1441
+ );
1442
+ }
1443
+
1444
+ throw error;
1445
+ }
1446
+ }
1447
+ }
1448
+
1449
+ module.exports = {
1450
+ UpdateTransaction,
1451
+ UpdateError,
1452
+ UpdateErrorCodes,
1453
+ TransactionStates,
1454
+ };