aether-colony 5.1.0 → 5.3.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 (185) hide show
  1. package/.aether/aether-utils.sh +157 -42
  2. package/.aether/agents/aether-ambassador.md +140 -0
  3. package/.aether/agents/aether-archaeologist.md +108 -0
  4. package/.aether/agents/aether-architect.md +133 -0
  5. package/.aether/agents/aether-auditor.md +144 -0
  6. package/.aether/agents/aether-builder.md +184 -0
  7. package/.aether/agents/aether-chaos.md +115 -0
  8. package/.aether/agents/aether-chronicler.md +122 -0
  9. package/.aether/agents/aether-gatekeeper.md +116 -0
  10. package/.aether/agents/aether-includer.md +117 -0
  11. package/.aether/agents/aether-keeper.md +177 -0
  12. package/.aether/agents/aether-measurer.md +128 -0
  13. package/.aether/agents/aether-oracle.md +137 -0
  14. package/.aether/agents/aether-probe.md +133 -0
  15. package/.aether/agents/aether-queen.md +286 -0
  16. package/.aether/agents/aether-route-setter.md +130 -0
  17. package/.aether/agents/aether-sage.md +106 -0
  18. package/.aether/agents/aether-scout.md +101 -0
  19. package/.aether/agents/aether-surveyor-disciplines.md +391 -0
  20. package/.aether/agents/aether-surveyor-nest.md +329 -0
  21. package/.aether/agents/aether-surveyor-pathogens.md +264 -0
  22. package/.aether/agents/aether-surveyor-provisions.md +334 -0
  23. package/.aether/agents/aether-tracker.md +137 -0
  24. package/.aether/agents/aether-watcher.md +174 -0
  25. package/.aether/agents/aether-weaver.md +130 -0
  26. package/.aether/commands/claude/archaeology.md +334 -0
  27. package/.aether/commands/claude/build.md +65 -0
  28. package/.aether/commands/claude/chaos.md +336 -0
  29. package/.aether/commands/claude/colonize.md +259 -0
  30. package/.aether/commands/claude/continue.md +60 -0
  31. package/.aether/commands/claude/council.md +507 -0
  32. package/.aether/commands/claude/data-clean.md +81 -0
  33. package/.aether/commands/claude/dream.md +268 -0
  34. package/.aether/commands/claude/entomb.md +498 -0
  35. package/.aether/commands/claude/export-signals.md +57 -0
  36. package/.aether/commands/claude/feedback.md +96 -0
  37. package/.aether/commands/claude/flag.md +151 -0
  38. package/.aether/commands/claude/flags.md +169 -0
  39. package/.aether/commands/claude/focus.md +76 -0
  40. package/.aether/commands/claude/help.md +154 -0
  41. package/.aether/commands/claude/history.md +140 -0
  42. package/.aether/commands/claude/import-signals.md +71 -0
  43. package/.aether/commands/claude/init.md +505 -0
  44. package/.aether/commands/claude/insert-phase.md +105 -0
  45. package/.aether/commands/claude/interpret.md +278 -0
  46. package/.aether/commands/claude/lay-eggs.md +210 -0
  47. package/.aether/commands/claude/maturity.md +113 -0
  48. package/.aether/commands/claude/memory-details.md +77 -0
  49. package/.aether/commands/claude/migrate-state.md +171 -0
  50. package/.aether/commands/claude/oracle.md +642 -0
  51. package/.aether/commands/claude/organize.md +232 -0
  52. package/.aether/commands/claude/patrol.md +620 -0
  53. package/.aether/commands/claude/pause-colony.md +233 -0
  54. package/.aether/commands/claude/phase.md +115 -0
  55. package/.aether/commands/claude/pheromones.md +156 -0
  56. package/.aether/commands/claude/plan.md +693 -0
  57. package/.aether/commands/claude/preferences.md +65 -0
  58. package/.aether/commands/claude/quick.md +100 -0
  59. package/.aether/commands/claude/redirect.md +76 -0
  60. package/.aether/commands/claude/resume-colony.md +197 -0
  61. package/.aether/commands/claude/resume.md +388 -0
  62. package/.aether/commands/claude/run.md +231 -0
  63. package/.aether/commands/claude/seal.md +774 -0
  64. package/.aether/commands/claude/skill-create.md +286 -0
  65. package/.aether/commands/claude/status.md +410 -0
  66. package/.aether/commands/claude/swarm.md +349 -0
  67. package/.aether/commands/claude/tunnels.md +426 -0
  68. package/.aether/commands/claude/update.md +132 -0
  69. package/.aether/commands/claude/verify-castes.md +143 -0
  70. package/.aether/commands/claude/watch.md +239 -0
  71. package/.aether/commands/colonize.yaml +4 -0
  72. package/.aether/commands/council.yaml +205 -0
  73. package/.aether/commands/init.yaml +46 -13
  74. package/.aether/commands/insert-phase.yaml +4 -0
  75. package/.aether/commands/opencode/archaeology.md +331 -0
  76. package/.aether/commands/opencode/build.md +1168 -0
  77. package/.aether/commands/opencode/chaos.md +329 -0
  78. package/.aether/commands/opencode/colonize.md +195 -0
  79. package/.aether/commands/opencode/continue.md +1436 -0
  80. package/.aether/commands/opencode/council.md +437 -0
  81. package/.aether/commands/opencode/data-clean.md +77 -0
  82. package/.aether/commands/opencode/dream.md +260 -0
  83. package/.aether/commands/opencode/entomb.md +377 -0
  84. package/.aether/commands/opencode/export-signals.md +54 -0
  85. package/.aether/commands/opencode/feedback.md +99 -0
  86. package/.aether/commands/opencode/flag.md +149 -0
  87. package/.aether/commands/opencode/flags.md +167 -0
  88. package/.aether/commands/opencode/focus.md +73 -0
  89. package/.aether/commands/opencode/help.md +157 -0
  90. package/.aether/commands/opencode/history.md +136 -0
  91. package/.aether/commands/opencode/import-signals.md +68 -0
  92. package/.aether/commands/opencode/init.md +518 -0
  93. package/.aether/commands/opencode/insert-phase.md +111 -0
  94. package/.aether/commands/opencode/interpret.md +272 -0
  95. package/.aether/commands/opencode/lay-eggs.md +213 -0
  96. package/.aether/commands/opencode/maturity.md +108 -0
  97. package/.aether/commands/opencode/memory-details.md +83 -0
  98. package/.aether/commands/opencode/migrate-state.md +165 -0
  99. package/.aether/commands/opencode/oracle.md +593 -0
  100. package/.aether/commands/opencode/organize.md +226 -0
  101. package/.aether/commands/opencode/patrol.md +626 -0
  102. package/.aether/commands/opencode/pause-colony.md +203 -0
  103. package/.aether/commands/opencode/phase.md +113 -0
  104. package/.aether/commands/opencode/pheromones.md +162 -0
  105. package/.aether/commands/opencode/plan.md +684 -0
  106. package/.aether/commands/opencode/preferences.md +71 -0
  107. package/.aether/commands/opencode/quick.md +91 -0
  108. package/.aether/commands/opencode/redirect.md +84 -0
  109. package/.aether/commands/opencode/resume-colony.md +190 -0
  110. package/.aether/commands/opencode/resume.md +394 -0
  111. package/.aether/commands/opencode/run.md +237 -0
  112. package/.aether/commands/opencode/seal.md +452 -0
  113. package/.aether/commands/opencode/skill-create.md +63 -0
  114. package/.aether/commands/opencode/status.md +307 -0
  115. package/.aether/commands/opencode/swarm.md +15 -0
  116. package/.aether/commands/opencode/tunnels.md +400 -0
  117. package/.aether/commands/opencode/update.md +127 -0
  118. package/.aether/commands/opencode/verify-castes.md +139 -0
  119. package/.aether/commands/opencode/watch.md +227 -0
  120. package/.aether/commands/plan.yaml +53 -2
  121. package/.aether/commands/quick.yaml +104 -0
  122. package/.aether/commands/resume-colony.yaml +6 -4
  123. package/.aether/commands/resume.yaml +9 -0
  124. package/.aether/commands/run.yaml +37 -1
  125. package/.aether/commands/seal.yaml +9 -0
  126. package/.aether/commands/status.yaml +45 -1
  127. package/.aether/docs/command-playbooks/build-full.md +3 -2
  128. package/.aether/docs/command-playbooks/build-prep.md +12 -4
  129. package/.aether/docs/command-playbooks/build-verify.md +51 -0
  130. package/.aether/docs/command-playbooks/continue-advance.md +115 -6
  131. package/.aether/docs/command-playbooks/continue-full.md +1 -0
  132. package/.aether/docs/command-playbooks/continue-verify.md +33 -0
  133. package/.aether/utils/clash-detect.sh +239 -0
  134. package/.aether/utils/council.sh +425 -0
  135. package/.aether/utils/error-handler.sh +3 -3
  136. package/.aether/utils/flag.sh +23 -12
  137. package/.aether/utils/hive.sh +2 -2
  138. package/.aether/utils/hooks/clash-pre-tool-use.js +99 -0
  139. package/.aether/utils/immune.sh +508 -0
  140. package/.aether/utils/learning.sh +2 -2
  141. package/.aether/utils/merge-driver-lockfile.sh +35 -0
  142. package/.aether/utils/midden.sh +712 -0
  143. package/.aether/utils/pheromone.sh +1376 -108
  144. package/.aether/utils/queen.sh +31 -21
  145. package/.aether/utils/session.sh +264 -0
  146. package/.aether/utils/spawn-tree.sh +7 -7
  147. package/.aether/utils/spawn.sh +2 -2
  148. package/.aether/utils/state-api.sh +216 -5
  149. package/.aether/utils/swarm.sh +1 -1
  150. package/.aether/utils/worktree.sh +189 -0
  151. package/.claude/commands/ant/colonize.md +2 -0
  152. package/.claude/commands/ant/council.md +205 -0
  153. package/.claude/commands/ant/init.md +53 -14
  154. package/.claude/commands/ant/insert-phase.md +4 -0
  155. package/.claude/commands/ant/plan.md +27 -1
  156. package/.claude/commands/ant/quick.md +100 -0
  157. package/.claude/commands/ant/resume-colony.md +3 -2
  158. package/.claude/commands/ant/resume.md +9 -0
  159. package/.claude/commands/ant/run.md +37 -1
  160. package/.claude/commands/ant/seal.md +9 -0
  161. package/.claude/commands/ant/status.md +45 -1
  162. package/.opencode/commands/ant/colonize.md +2 -0
  163. package/.opencode/commands/ant/council.md +143 -0
  164. package/.opencode/commands/ant/init.md +53 -13
  165. package/.opencode/commands/ant/insert-phase.md +4 -0
  166. package/.opencode/commands/ant/plan.md +26 -1
  167. package/.opencode/commands/ant/quick.md +91 -0
  168. package/.opencode/commands/ant/resume-colony.md +3 -2
  169. package/.opencode/commands/ant/resume.md +9 -0
  170. package/.opencode/commands/ant/run.md +37 -1
  171. package/.opencode/commands/ant/status.md +2 -0
  172. package/CHANGELOG.md +116 -0
  173. package/README.md +34 -8
  174. package/bin/cli.js +103 -61
  175. package/bin/lib/banner.js +14 -0
  176. package/bin/lib/init.js +8 -7
  177. package/bin/lib/interactive-setup.js +251 -0
  178. package/bin/npx-entry.js +21 -0
  179. package/bin/npx-install.js +9 -167
  180. package/bin/validate-package.sh +23 -0
  181. package/package.json +11 -3
  182. package/.aether/docs/plans/pheromone-display-plan.md +0 -257
  183. package/.aether/schemas/example-prompt-builder.xml +0 -234
  184. package/.aether/scripts/incident-test-add.sh +0 -47
  185. package/.aether/scripts/weekly-audit.sh +0 -79
@@ -244,6 +244,184 @@ _midden_ingest_errors() {
244
244
  return 0
245
245
  }
246
246
 
247
+ _midden_search() {
248
+ # Search midden entries by keyword match in message field
249
+ # Usage: midden-search <query> [--category <cat>] [--source <src>] [--limit N] [--include-acknowledged]
250
+ # Returns: JSON with query, match_count, and entries array
251
+
252
+ ms_query=""
253
+ ms_category=""
254
+ ms_source=""
255
+ ms_limit=10
256
+ ms_include_ack=false
257
+
258
+ # First positional arg is the query
259
+ if [[ $# -gt 0 && "$1" != --* ]]; then
260
+ ms_query="$1"
261
+ shift
262
+ fi
263
+
264
+ while [[ $# -gt 0 ]]; do
265
+ case "$1" in
266
+ --category) ms_category="${2:-}"; shift 2 ;;
267
+ --source) ms_source="${2:-}"; shift 2 ;;
268
+ --limit) ms_limit="${2:-10}"; shift 2 ;;
269
+ --include-acknowledged) ms_include_ack=true; shift ;;
270
+ *) shift ;;
271
+ esac
272
+ done
273
+
274
+ ms_midden_file="$COLONY_DATA_DIR/midden/midden.json"
275
+
276
+ if [[ ! -f "$ms_midden_file" ]]; then
277
+ json_ok "{\"query\":$(printf '%s' "$ms_query" | jq -Rs .),\"match_count\":0,\"entries\":[]}"
278
+ return 0
279
+ fi
280
+
281
+ ms_result=$(jq \
282
+ --arg query "$ms_query" \
283
+ --arg category "$ms_category" \
284
+ --arg source "$ms_source" \
285
+ --argjson limit "$ms_limit" \
286
+ --argjson include_ack "$ms_include_ack" \
287
+ '
288
+ [.entries // [] | .[] |
289
+ # Filter acknowledged unless --include-acknowledged
290
+ if $include_ack then . else select(.acknowledged != true) end |
291
+ # Filter by category if specified
292
+ if ($category | length) > 0 then select(.category == $category) else . end |
293
+ # Filter by source if specified
294
+ if ($source | length) > 0 then select(.source == $source) else . end |
295
+ # Filter by keyword match in message (case-insensitive)
296
+ if ($query | length) > 0 then
297
+ select(.message | ascii_downcase | contains($query | ascii_downcase))
298
+ else
299
+ .
300
+ end
301
+ ] |
302
+ sort_by(.timestamp) | reverse |
303
+ . as $all |
304
+ {
305
+ query: $query,
306
+ match_count: ($all | length),
307
+ entries: ($all | .[:$limit])
308
+ }
309
+ ' "$ms_midden_file" 2>/dev/null)
310
+
311
+ if [[ -z "$ms_result" ]]; then
312
+ json_ok "{\"query\":$(printf '%s' "$ms_query" | jq -Rs .),\"match_count\":0,\"entries\":[]}"
313
+ else
314
+ json_ok "$ms_result"
315
+ fi
316
+ return 0
317
+ }
318
+
319
+ _midden_tag() {
320
+ # Add or remove a tag from a midden entry's tags array
321
+ # Usage: midden-tag --id <entry_id> --tag <tag_name>
322
+ # OR: midden-tag --id <entry_id> --untag <tag_name>
323
+ # Returns: JSON with entry_id, tags array, and action
324
+
325
+ mt_id=""
326
+ mt_tag=""
327
+ mt_untag=""
328
+
329
+ while [[ $# -gt 0 ]]; do
330
+ case "$1" in
331
+ --id) mt_id="${2:-}"; shift 2 ;;
332
+ --tag) mt_tag="${2:-}"; shift 2 ;;
333
+ --untag) mt_untag="${2:-}"; shift 2 ;;
334
+ *) shift ;;
335
+ esac
336
+ done
337
+
338
+ # Validate: need --id
339
+ if [[ -z "$mt_id" ]]; then
340
+ json_err "$E_VALIDATION_FAILED" "midden-tag requires --id"
341
+ fi
342
+
343
+ # Validate: need --tag or --untag (but not both)
344
+ if [[ -z "$mt_tag" && -z "$mt_untag" ]]; then
345
+ json_err "$E_VALIDATION_FAILED" "midden-tag requires --tag or --untag"
346
+ fi
347
+
348
+ if [[ -n "$mt_tag" && -n "$mt_untag" ]]; then
349
+ json_err "$E_VALIDATION_FAILED" "midden-tag requires --tag or --untag, not both"
350
+ fi
351
+
352
+ mt_midden_file="$COLONY_DATA_DIR/midden/midden.json"
353
+
354
+ if [[ ! -f "$mt_midden_file" ]]; then
355
+ json_err "$E_FILE_NOT_FOUND" "midden.json not found"
356
+ fi
357
+
358
+ # Check entry exists
359
+ mt_exists=$(jq --arg id "$mt_id" '[.entries[]? | select(.id == $id)] | length > 0' "$mt_midden_file" 2>/dev/null || echo "false")
360
+ if [[ "$mt_exists" != "true" ]]; then
361
+ json_err "$E_RESOURCE_NOT_FOUND" "Midden entry '$mt_id' not found"
362
+ fi
363
+
364
+ # Acquire lock with trap-based cleanup
365
+ acquire_lock "$mt_midden_file" || {
366
+ json_err "$E_LOCK_FAILED" "Failed to acquire lock on midden.json"
367
+ }
368
+ trap 'release_lock 2>/dev/null || true' EXIT
369
+
370
+ if [[ -n "$mt_tag" ]]; then
371
+ # Add tag — create tags array if absent, append if tag not already present
372
+ mt_updated=$(jq \
373
+ --arg id "$mt_id" \
374
+ --arg tag "$mt_tag" \
375
+ '
376
+ .entries = [.entries[] |
377
+ if .id == $id then
378
+ . + {tags: ((.tags // []) | if contains([$tag]) then . else . + [$tag] end)}
379
+ else
380
+ .
381
+ end
382
+ ]
383
+ ' "$mt_midden_file" 2>/dev/null)
384
+ mt_action="added"
385
+ else
386
+ # Remove tag — remove from tags array if present
387
+ mt_updated=$(jq \
388
+ --arg id "$mt_id" \
389
+ --arg tag "$mt_untag" \
390
+ '
391
+ .entries = [.entries[] |
392
+ if .id == $id then
393
+ . + {tags: ((.tags // []) | map(select(. != $tag)))}
394
+ else
395
+ .
396
+ end
397
+ ]
398
+ ' "$mt_midden_file" 2>/dev/null)
399
+ mt_action="removed"
400
+ mt_tag="$mt_untag"
401
+ fi
402
+
403
+ if [[ -z "$mt_updated" ]]; then
404
+ trap - EXIT
405
+ release_lock 2>/dev/null || true
406
+ json_err "$E_INTERNAL" "Failed to update midden.json"
407
+ fi
408
+
409
+ atomic_write "$mt_midden_file" "$mt_updated"
410
+
411
+ trap - EXIT
412
+ release_lock 2>/dev/null || true
413
+
414
+ # Read back the updated tags for the entry
415
+ mt_tags=$(jq --arg id "$mt_id" '[.entries[]? | select(.id == $id) | .tags // []] | .[0] // []' "$mt_midden_file" 2>/dev/null || echo "[]")
416
+
417
+ json_ok "$(jq -n \
418
+ --arg entry_id "$mt_id" \
419
+ --argjson tags "$mt_tags" \
420
+ --arg action "$mt_action" \
421
+ '{entry_id: $entry_id, tags: $tags, action: $action}')"
422
+ return 0
423
+ }
424
+
247
425
  _midden_acknowledge() {
248
426
  # Acknowledge midden entries by id or by category
249
427
  # Usage: midden-acknowledge --id <entry_id> [--reason <reason>]
@@ -340,3 +518,537 @@ _midden_acknowledge() {
340
518
  '{acknowledged: true, count: $count, reason: $reason}')"
341
519
  return 0
342
520
  }
521
+
522
+ # ============================================================================
523
+ # Cross-Branch Midden Collection (Phase 41)
524
+ # ============================================================================
525
+
526
+ _midden_collect() {
527
+ # Collect midden entries from a merged branch worktree into main's midden
528
+ # Usage: midden-collect --branch <name> --merge-sha <sha> [--dry-run]
529
+ # Returns: JSON with collection status and counts
530
+ #
531
+ # Dual-layer idempotency:
532
+ # Layer 1: Merge fingerprint in collected-merges.json (fast path)
533
+ # Layer 2: Per-entry ID dedup (safety net)
534
+
535
+ mc_branch=""
536
+ mc_merge_sha=""
537
+ mc_dry_run=false
538
+
539
+ while [[ $# -gt 0 ]]; do
540
+ case "$1" in
541
+ --branch) mc_branch="${2:-}"; shift 2 ;;
542
+ --merge-sha) mc_merge_sha="${2:-}"; shift 2 ;;
543
+ --dry-run) mc_dry_run=true; shift ;;
544
+ *) shift ;;
545
+ esac
546
+ done
547
+
548
+ # Validate required args
549
+ if [[ -z "$mc_branch" ]]; then
550
+ json_err "$E_VALIDATION_FAILED" "midden-collect requires --branch"
551
+ fi
552
+ if [[ -z "$mc_merge_sha" ]]; then
553
+ json_err "$E_VALIDATION_FAILED" "midden-collect requires --merge-sha"
554
+ fi
555
+
556
+ # Resolve worktree midden path
557
+ mc_worktree_midden=""
558
+ mc_candidate="$AETHER_ROOT/.aether/worktrees/$mc_branch/.aether/data/midden/midden.json"
559
+
560
+ if [[ -f "$mc_candidate" ]]; then
561
+ mc_worktree_midden="$mc_candidate"
562
+ else
563
+ # Fallback: check git worktree list
564
+ mc_wt_path=$(git -C "$AETHER_ROOT" worktree list --porcelain 2>/dev/null | grep -F "worktree" | head -1 | cut -d' ' -f2 || true)
565
+ if [[ -n "$mc_wt_path" && -f "$mc_wt_path/.aether/data/midden/midden.json" ]]; then
566
+ mc_worktree_midden="$mc_wt_path/.aether/data/midden/midden.json"
567
+ fi
568
+ fi
569
+
570
+ if [[ -z "$mc_worktree_midden" || ! -f "$mc_worktree_midden" ]]; then
571
+ json_ok "$(jq -n --arg branch "$mc_branch" \
572
+ '{status: "worktree_not_found", entries_collected: 0, branch: $branch}')"
573
+ return 0
574
+ fi
575
+
576
+ # Read branch midden.json
577
+ mc_branch_data=$(cat "$mc_worktree_midden" 2>/dev/null || echo "")
578
+
579
+ if [[ -z "$mc_branch_data" ]]; then
580
+ json_ok '{"status":"empty_branch_midden","entries_collected":0}'
581
+ return 0
582
+ fi
583
+
584
+ # Validate branch midden.json is valid JSON
585
+ if ! echo "$mc_branch_data" | jq empty 2>/dev/null; then
586
+ json_err "$E_INTERNAL" "Branch midden.json is corrupt"
587
+ fi
588
+
589
+ # Check for entries
590
+ mc_branch_count=$(echo "$mc_branch_data" | jq '[.entries[]?] | length' 2>/dev/null || echo "0")
591
+ if [[ "$mc_branch_count" -eq 0 ]]; then
592
+ json_ok '{"status":"empty_branch_midden","entries_collected":0}'
593
+ return 0
594
+ fi
595
+
596
+ mc_midden_dir="$COLONY_DATA_DIR/midden"
597
+ mc_midden_file="$mc_midden_dir/midden.json"
598
+ mc_merges_file="$mc_midden_dir/collected-merges.json"
599
+ mc_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
600
+
601
+ mkdir -p "$mc_midden_dir"
602
+
603
+ # Initialize midden.json if missing
604
+ if [[ ! -f "$mc_midden_file" ]]; then
605
+ printf '%s\n' '{"version":"1.0.0","entries":[]}' > "$mc_midden_file"
606
+ fi
607
+
608
+ # Initialize collected-merges.json if missing
609
+ if [[ ! -f "$mc_merges_file" ]]; then
610
+ printf '%s\n' '{"version":"1.0.0","merges":[]}' > "$mc_merges_file"
611
+ fi
612
+
613
+ # LAYER 1: Check merge fingerprint
614
+ mc_already=$(jq --arg sha "$mc_merge_sha" --arg branch "$mc_branch" \
615
+ '[.merges[]? | select(.merge_commit == $sha and .branch_name == $branch)] | length > 0' \
616
+ "$mc_merges_file" 2>/dev/null || echo "false")
617
+
618
+ if [[ "$mc_already" == "true" ]]; then
619
+ json_ok "$(jq -n --arg sha "$mc_merge_sha" --arg branch "$mc_branch" \
620
+ '{status: "already_collected", merge_commit: $sha, branch: $branch, entries_collected: 0}')"
621
+ return 0
622
+ fi
623
+
624
+ if [[ "$mc_dry_run" == "true" ]]; then
625
+ json_ok "$(jq -n --arg branch "$mc_branch" --arg sha "$mc_merge_sha" --argjson count "$mc_branch_count" \
626
+ '{status: "dry_run", branch: $branch, merge_commit: $sha, entries_would_collect: $count}')"
627
+ return 0
628
+ fi
629
+
630
+ # LAYER 2: Per-entry ID dedup — get existing IDs from main's midden
631
+ mc_existing_ids=$(jq -r '[.entries[]?.id] | map(select(. != null))' "$mc_midden_file" 2>/dev/null || echo "[]")
632
+
633
+ # Filter branch entries: exclude those with IDs already in main, then enrich
634
+ mc_new_entries=$(jq --argjson existing_ids "$mc_existing_ids" \
635
+ --arg branch "$mc_branch" \
636
+ --arg ts "$mc_timestamp" \
637
+ --arg sha "$mc_merge_sha" \
638
+ '
639
+ [.entries[]?] |
640
+ map(select([.id] | inside($existing_ids) | not)) |
641
+ map(. + {
642
+ collected_from: $branch,
643
+ collected_at: $ts,
644
+ merge_commit: $sha,
645
+ original_entry_id: .id
646
+ })
647
+ ' "$mc_worktree_midden" 2>/dev/null || echo "[]")
648
+
649
+ mc_new_count=$(echo "$mc_new_entries" | jq 'length' 2>/dev/null || echo "0")
650
+ mc_skipped=$((mc_branch_count - mc_new_count))
651
+
652
+ if [[ "$mc_new_count" -gt 0 ]]; then
653
+ # Append enriched entries to main's midden
654
+ acquire_lock "$mc_midden_file" || {
655
+ json_err "$E_LOCK_FAILED" "Failed to acquire lock on midden.json"
656
+ }
657
+ trap 'release_lock 2>/dev/null || true' EXIT
658
+
659
+ mc_updated=$(jq --argjson new_entries "$mc_new_entries" \
660
+ '.entries += $new_entries' "$mc_midden_file" 2>/dev/null)
661
+
662
+ if [[ -z "$mc_updated" ]]; then
663
+ trap - EXIT
664
+ release_lock 2>/dev/null || true
665
+ json_err "$E_INTERNAL" "Failed to update midden.json with collected entries"
666
+ fi
667
+
668
+ atomic_write "$mc_midden_file" "$mc_updated"
669
+
670
+ trap - EXIT
671
+ release_lock 2>/dev/null || true
672
+ fi
673
+
674
+ # Write fingerprint to collected-merges.json
675
+ mc_fingerprint=$(printf '%s|%s|%d' "$mc_branch" "$mc_merge_sha" "$mc_new_count" | shasum -a 256 | cut -d' ' -f1)
676
+
677
+ acquire_lock "$mc_merges_file" || {
678
+ # Non-fatal — entries were collected but fingerprint may be missing
679
+ json_ok "$(jq -n --arg branch "$mc_branch" --arg sha "$mc_merge_sha" \
680
+ --argjson collected "$mc_new_count" --argjson skipped "$mc_skipped" \
681
+ '{status: "collected", entries_collected: $collected, entries_skipped_dup: $skipped, branch: $branch, merge_commit: $sha, warning: "fingerprint_write_failed"}')"
682
+ return 0
683
+ }
684
+ trap 'release_lock 2>/dev/null || true' EXIT
685
+
686
+ mc_merges_updated=$(jq --arg sha "$mc_merge_sha" --arg branch "$mc_branch" \
687
+ --arg ts "$mc_timestamp" --argjson collected "$mc_new_count" \
688
+ --argjson skipped "$mc_skipped" --arg fp "$mc_fingerprint" \
689
+ '.merges += [{
690
+ merge_commit: $sha,
691
+ branch_name: $branch,
692
+ collected_at: $ts,
693
+ entries_collected: $collected,
694
+ entries_skipped_dup: $skipped,
695
+ fingerprint: $fp
696
+ }]' "$mc_merges_file" 2>/dev/null)
697
+
698
+ if [[ -n "$mc_merges_updated" ]]; then
699
+ atomic_write "$mc_merges_file" "$mc_merges_updated"
700
+ fi
701
+
702
+ trap - EXIT
703
+ release_lock 2>/dev/null || true
704
+
705
+ json_ok "$(jq -n --arg branch "$mc_branch" --arg sha "$mc_merge_sha" \
706
+ --argjson collected "$mc_new_count" --argjson skipped "$mc_skipped" \
707
+ '{status: "collected", entries_collected: $collected, entries_skipped_dup: $skipped, branch: $branch, merge_commit: $sha}')"
708
+ return 0
709
+ }
710
+
711
+ _midden_handle_revert() {
712
+ # Tag entries from a reverted merge commit (not delete)
713
+ # Usage: midden-handle-revert --sha <revert-sha>
714
+ # OR: midden-handle-revert --revert-commit <sha> --original-merge <sha>
715
+ # Returns: JSON with revert status and tagged count
716
+
717
+ mhr_revert_sha=""
718
+ mhr_original_merge=""
719
+
720
+ while [[ $# -gt 0 ]]; do
721
+ case "$1" in
722
+ --sha) mhr_revert_sha="${2:-}"; shift 2 ;;
723
+ --revert-commit) mhr_revert_sha="${2:-}"; shift 2 ;;
724
+ --original-merge) mhr_original_merge="${2:-}"; shift 2 ;;
725
+ *) shift ;;
726
+ esac
727
+ done
728
+
729
+ if [[ -z "$mhr_revert_sha" ]]; then
730
+ json_err "$E_VALIDATION_FAILED" "midden-handle-revert requires --sha or --revert-commit"
731
+ fi
732
+
733
+ mc_midden_dir="$COLONY_DATA_DIR/midden"
734
+ mc_merges_file="$mc_midden_dir/collected-merges.json"
735
+
736
+ # If no original-merge given, try to find it from collected-merges by parsing commit message
737
+ if [[ -z "$mhr_original_merge" ]]; then
738
+ # Try git log to find the reverted merge
739
+ mhr_original_merge=$(git -C "$AETHER_ROOT" log -1 --format="%b" "$mhr_revert_sha" 2>/dev/null \
740
+ | grep -oE '[0-9a-f]{7,40}' | head -1 || true)
741
+ fi
742
+
743
+ if [[ -z "$mhr_original_merge" ]]; then
744
+ json_ok "$(jq -n --arg sha "$mhr_revert_sha" \
745
+ '{status: "original_merge_not_resolved", revert_commit: $sha, entries_tagged: 0}')"
746
+ return 0
747
+ fi
748
+
749
+ # Check collected-merges.json exists
750
+ if [[ ! -f "$mc_merges_file" ]]; then
751
+ json_ok "$(jq -n --arg sha "$mhr_revert_sha" --arg merge "$mhr_original_merge" \
752
+ '{status: "merge_not_found", revert_commit: $sha, original_merge: $merge, entries_tagged: 0}')"
753
+ return 0
754
+ fi
755
+
756
+ # Check if the original merge exists in collected-merges
757
+ mhr_found=$(jq --arg merge "$mhr_original_merge" \
758
+ '[.merges[]? | select(.merge_commit == $merge)] | length > 0' \
759
+ "$mc_merges_file" 2>/dev/null || echo "false")
760
+
761
+ if [[ "$mhr_found" != "true" ]]; then
762
+ json_ok "$(jq -n --arg sha "$mhr_revert_sha" --arg merge "$mhr_original_merge" \
763
+ '{status: "merge_not_found", revert_commit: $sha, original_merge: $merge, entries_tagged: 0}')"
764
+ return 0
765
+ fi
766
+
767
+ mhr_midden_file="$mc_midden_dir/midden.json"
768
+ mhr_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
769
+ mhr_tagged=0
770
+
771
+ # Tag entries in main's midden.json
772
+ if [[ -f "$mhr_midden_file" ]]; then
773
+ acquire_lock "$mhr_midden_file" || {
774
+ json_err "$E_LOCK_FAILED" "Failed to acquire lock on midden.json"
775
+ }
776
+ trap 'release_lock 2>/dev/null || true' EXIT
777
+
778
+ mhr_updated=$(jq --arg revert_sha "$mhr_revert_sha" --arg merge_sha "$mhr_original_merge" \
779
+ '
780
+ .entries = [.entries[] |
781
+ if .merge_commit == $merge_sha then
782
+ . + {
783
+ tags: ((.tags // []) + ["reverted:" + $revert_sha]) | unique,
784
+ reviewed: false
785
+ }
786
+ else
787
+ .
788
+ end
789
+ ]
790
+ ' "$mhr_midden_file" 2>/dev/null)
791
+
792
+ if [[ -n "$mhr_updated" ]]; then
793
+ atomic_write "$mhr_midden_file" "$mhr_updated"
794
+ mhr_tagged=$(echo "$mhr_updated" | jq --arg merge_sha "$mhr_original_merge" \
795
+ '[.entries[] | select(.merge_commit == $merge_sha)] | length' 2>/dev/null || echo "0")
796
+ fi
797
+
798
+ trap - EXIT
799
+ release_lock 2>/dev/null || true
800
+ fi
801
+
802
+ # Update collected-merges.json: mark merge as reverted
803
+ acquire_lock "$mc_merges_file" || true
804
+ trap 'release_lock 2>/dev/null || true' EXIT
805
+
806
+ mhr_merges_updated=$(jq --arg revert_sha "$mhr_revert_sha" \
807
+ --arg merge_sha "$mhr_original_merge" --arg ts "$mhr_timestamp" \
808
+ '
809
+ .merges = [.merges[] |
810
+ if .merge_commit == $merge_sha then
811
+ . + {reverted_by: $revert_sha, reverted_at: $ts, status: "reverted"}
812
+ else
813
+ .
814
+ end
815
+ ]
816
+ ' "$mc_merges_file" 2>/dev/null)
817
+
818
+ if [[ -n "$mhr_merges_updated" ]]; then
819
+ atomic_write "$mc_merges_file" "$mhr_merges_updated"
820
+ fi
821
+
822
+ trap - EXIT
823
+ release_lock 2>/dev/null || true
824
+
825
+ json_ok "$(jq -n --arg sha "$mhr_revert_sha" --arg merge "$mhr_original_merge" \
826
+ --argjson tagged "$mhr_tagged" \
827
+ '{revert_commit: $sha, original_merge: $merge, entries_tagged: $tagged, entries_deleted: 0}')"
828
+ return 0
829
+ }
830
+
831
+ _midden_cross_pr_analysis() {
832
+ # Detect cross-PR failure patterns and auto-emit REDIRECT for systemic issues
833
+ # Usage: midden-cross-pr-analysis [--category <cat>] [--window <days>]
834
+ # Returns: JSON with category analysis, scores, classifications
835
+
836
+ mca_category=""
837
+ mca_window=14
838
+
839
+ while [[ $# -gt 0 ]]; do
840
+ case "$1" in
841
+ --category) mca_category="${2:-}"; shift 2 ;;
842
+ --window) mca_window="${2:-14}"; shift 2 ;;
843
+ *) shift ;;
844
+ esac
845
+ done
846
+
847
+ mca_midden_file="$COLONY_DATA_DIR/midden/midden.json"
848
+
849
+ if [[ ! -f "$mca_midden_file" ]]; then
850
+ json_ok "$(jq -n --argjson window "$mca_window" \
851
+ '{analysis_timestamp: "now", window_days: $window, total_entries_scanned: 0, categories: {}, systemic_categories: []}')"
852
+ return 0
853
+ fi
854
+
855
+ mca_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
856
+
857
+ # Calculate cutoff timestamp: NOW - window_days
858
+ mca_cutoff=$(date -u -v-"${mca_window}d" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || \
859
+ python3 -c "import datetime; print((datetime.datetime.utcnow() - datetime.timedelta(days=$mca_window)).strftime('%Y-%m-%dT%H:%M:%SZ'))" 2>/dev/null || \
860
+ date -u -d "$mca_window days ago" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
861
+
862
+ mca_result=$(jq --arg cutoff "$mca_cutoff" \
863
+ --arg category "$mca_category" --argjson window "$mca_window" \
864
+ '
865
+ # Collect cross-branch entries within window, excluding reverted
866
+ [.entries // [] | .[] |
867
+ select(.collected_from != null) |
868
+ select(.reviewed != true) |
869
+ select(.tags // [] | map(startswith("reverted:")) | any | not) |
870
+ select(if ($cutoff | length) > 0 then .timestamp >= $cutoff else true end) |
871
+ if ($category | length) > 0 then select(.category == $category) else . end
872
+ ] |
873
+ . as $entries |
874
+
875
+ # Group by category
876
+ ($entries | group_by(.category) | map({key: .[0].category, value: .}) | from_entries) as $by_cat |
877
+
878
+ # Compute metrics per category
879
+ ($by_cat | to_entries | map({
880
+ key: .key,
881
+ value: {
882
+ total_entries: (.value | length),
883
+ unique_prs: ([.value[].collected_from] | unique | length),
884
+ entries_per_pr: (.value | group_by(.collected_from) | map({key: .[0].collected_from, value: length}) | from_entries),
885
+ cross_pr_score: (
886
+ (([.value[].collected_from] | unique | length) / 5) * 0.6 +
887
+ ((.value | length) / 10) * 0.4
888
+ ),
889
+ classification: (
890
+ if (([.value[].collected_from] | unique | length) >= 3) and ((.value | length) >= 5) then
891
+ "cross-pr-critical"
892
+ elif (([.value[].collected_from] | unique | length) >= 2) and ((.value | length) >= 3) then
893
+ "cross-pr-systemic"
894
+ else
895
+ "single-pr"
896
+ end
897
+ ),
898
+ auto_redirect_emitted: false
899
+ }
900
+ }) | from_entries) as $analysis |
901
+
902
+ # Collect systemic categories
903
+ [$analysis | to_entries[] | select(.value.classification == "cross-pr-systemic" or .value.classification == "cross-pr-critical") | .key] as $systemic |
904
+
905
+ {
906
+ total_entries_scanned: ($entries | length),
907
+ categories: $analysis,
908
+ systemic_categories: $systemic
909
+ }
910
+ ' "$mca_midden_file" 2>/dev/null)
911
+
912
+ if [[ -z "$mca_result" ]]; then
913
+ json_ok "$(jq -n --argjson window "$mca_window" \
914
+ '{analysis_timestamp: "now", window_days: $window, total_entries_scanned: 0, categories: {}, systemic_categories: []}')"
915
+ return 0
916
+ fi
917
+
918
+ # Auto-emit REDIRECT for systemic/critical categories
919
+ mca_systemic=$(echo "$mca_result" | jq -r '.systemic_categories // [] | .[]' 2>/dev/null || true)
920
+ for mca_cat in $mca_systemic; do
921
+ mca_cat_data=$(echo "$mca_result" | jq --arg cat "$mca_cat" '.categories[$cat]' 2>/dev/null || echo "{}")
922
+ mca_unique_prs=$(echo "$mca_cat_data" | jq -r '.unique_prs // 0' 2>/dev/null || echo "0")
923
+ mca_total=$(echo "$mca_cat_data" | jq -r '.total_entries // 0' 2>/dev/null || echo "0")
924
+ mca_score=$(echo "$mca_cat_data" | jq -r '.cross_pr_score // 0' 2>/dev/null || echo "0")
925
+
926
+ # Compute strength: 0.5 + (score * 0.5), capped at 1.0
927
+ mca_strength=$(jq -n --argjson score "$mca_score" '0.5 + ($score * 0.5) | if . > 1.0 then 1.0 else . * 100 | round / 100 end' 2>/dev/null || echo "0.7")
928
+
929
+ # NON-BLOCKING: emit REDIRECT, swallow all output
930
+ bash "$AETHER_ROOT/.aether/aether-utils.sh" pheromone-write REDIRECT \
931
+ "[cross-pr-pattern] $mca_cat failures across $mca_unique_prs PRs in $mca_window days ($mca_total entries)" \
932
+ --strength "$mca_strength" \
933
+ --source "auto:cross-pr" \
934
+ --reason "Auto-emitted: cross-PR systemic failure pattern detected" \
935
+ --ttl "30d" >/dev/null 2>&1 || true
936
+
937
+ # Mark as emitted in the result
938
+ mca_result=$(echo "$mca_result" | jq --arg cat "$mca_cat" '.categories[$cat].auto_redirect_emitted = true' 2>/dev/null || echo "$mca_result")
939
+ done
940
+
941
+ json_ok "$(echo "$mca_result" | jq --arg ts "$mca_timestamp" --argjson window "$mca_window" \
942
+ '. + {analysis_timestamp: $ts, window_days: $window}' 2>/dev/null || echo "$mca_result")"
943
+ return 0
944
+ }
945
+
946
+ _midden_prune() {
947
+ # Retention cleanup for collected merges and reverted entries
948
+ # Usage: midden-prune --stale-merges
949
+ # OR: midden-prune --reverted --age <days>
950
+ # Returns: JSON with prune counts
951
+
952
+ mp_stale_merges=false
953
+ mp_reverted=false
954
+ mp_age=30
955
+
956
+ while [[ $# -gt 0 ]]; do
957
+ case "$1" in
958
+ --stale-merges) mp_stale_merges=true; shift ;;
959
+ --reverted) mp_reverted=true; shift ;;
960
+ --age) mp_age="${2:-30}"; shift 2 ;;
961
+ *) shift ;;
962
+ esac
963
+ done
964
+
965
+ mp_midden_dir="$COLONY_DATA_DIR/midden"
966
+ mp_merges_file="$mp_midden_dir/collected-merges.json"
967
+ mp_midden_file="$mp_midden_dir/midden.json"
968
+ mp_pruned_merges=0
969
+ mp_pruned_reverted=0
970
+
971
+ if [[ "$mp_stale_merges" == "true" ]]; then
972
+ if [[ -f "$mp_merges_file" ]]; then
973
+ mp_cutoff=$(date -u -v-"90d" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || \
974
+ python3 -c "import datetime; print((datetime.datetime.utcnow() - datetime.timedelta(days=90)).strftime('%Y-%m-%dT%H:%M:%SZ'))" 2>/dev/null || \
975
+ date -u -d "90 days ago" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
976
+
977
+ if [[ -n "$mp_cutoff" ]]; then
978
+ acquire_lock "$mp_merges_file" || true
979
+ trap 'release_lock 2>/dev/null || true' EXIT
980
+
981
+ mp_before=$(jq '.merges | length' "$mp_merges_file" 2>/dev/null || echo "0")
982
+ mp_before=${mp_before:-0}
983
+ mp_merges_updated=$(jq --arg cutoff "$mp_cutoff" \
984
+ '.merges = [.merges // [] | .[] | select(.collected_at >= $cutoff)]' \
985
+ "$mp_merges_file" 2>/dev/null)
986
+ mp_after=$(echo "$mp_merges_updated" | jq '.merges | length' 2>/dev/null || echo "0")
987
+ mp_after=${mp_after:-0}
988
+
989
+ if [[ -n "$mp_merges_updated" ]]; then
990
+ atomic_write "$mp_merges_file" "$mp_merges_updated"
991
+ mp_pruned_merges=$((mp_before - mp_after))
992
+ fi
993
+
994
+ trap - EXIT
995
+ release_lock 2>/dev/null || true
996
+ fi
997
+ fi
998
+ fi
999
+
1000
+ if [[ "$mp_reverted" == "true" ]]; then
1001
+ if [[ -f "$mp_midden_file" && -f "$mp_merges_file" ]]; then
1002
+ mp_cutoff=$(date -u -v-"${mp_age}d" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || \
1003
+ python3 -c "import datetime; print((datetime.datetime.utcnow() - datetime.timedelta(days=$mp_age)).strftime('%Y-%m-%dT%H:%M:%SZ'))" 2>/dev/null || \
1004
+ date -u -d "$mp_age days ago" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
1005
+
1006
+ if [[ -n "$mp_cutoff" ]]; then
1007
+ # Find revert timestamps from collected-merges.json
1008
+ mp_revert_map=$(jq -r '[.merges // [] | .[] | select(.status == "reverted")] | map({merge_commit: .merge_commit, reverted_at: .reverted_at})' "$mp_merges_file" 2>/dev/null || echo "[]")
1009
+
1010
+ acquire_lock "$mp_midden_file" || true
1011
+ trap 'release_lock 2>/dev/null || true' EXIT
1012
+
1013
+ mp_now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1014
+
1015
+ # Acknowledge reverted entries older than threshold
1016
+ mp_updated=$(jq --argjson revert_map "$mp_revert_map" \
1017
+ --arg cutoff "$mp_cutoff" --arg now "$mp_now" --argjson age "$mp_age" \
1018
+ '
1019
+ .entries = [.entries // [] | .[] |
1020
+ if .tags // [] | map(startswith("reverted:")) | any then
1021
+ # Find the revert timestamp for this entry
1022
+ ($revert_map | map(select(.merge_commit == .merge_commit)) | first // {}) as $merge_info |
1023
+ if ($merge_info.reverted_at // "") != "" and ($merge_info.reverted_at < $cutoff) and .acknowledged != true then
1024
+ . + {
1025
+ acknowledged: true,
1026
+ acknowledged_at: $now,
1027
+ acknowledge_reason: ("auto-pruned: reverted entry older than " + ($age | tostring) + " days")
1028
+ }
1029
+ else .
1030
+ end
1031
+ else .
1032
+ end
1033
+ ]
1034
+ ' "$mp_midden_file" 2>/dev/null)
1035
+
1036
+ if [[ -n "$mp_updated" ]]; then
1037
+ mp_before=$(jq '[.entries // [] | .[] | select(.tags // [] | map(startswith("reverted:")) | any and .acknowledged != true)] | length' "$mp_midden_file" 2>/dev/null || echo "0")
1038
+ mp_before=${mp_before:-0}
1039
+ mp_after=$(jq '[.entries // [] | .[] | select(.tags // [] | map(startswith("reverted:")) | any and .acknowledged != true)] | length' <<< "$mp_updated" 2>/dev/null || echo "0")
1040
+ mp_after=${mp_after:-0}
1041
+ atomic_write "$mp_midden_file" "$mp_updated"
1042
+ mp_pruned_reverted=$((mp_before - mp_after))
1043
+ fi
1044
+
1045
+ trap - EXIT
1046
+ release_lock 2>/dev/null || true
1047
+ fi
1048
+ fi
1049
+ fi
1050
+
1051
+ json_ok "$(jq -n --argjson pruned_merges "$mp_pruned_merges" --argjson pruned_reverted "$mp_pruned_reverted" \
1052
+ '{pruned_merges: $pruned_merges, pruned_reverted: $pruned_reverted}')"
1053
+ return 0
1054
+ }