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
@@ -730,6 +730,161 @@ pp_log_json=$(printf '%s' "$pp_log_line" | jq -Rs '.' 2>/dev/null || echo '"Prim
730
730
  json_ok "$(jq -n --argjson signal_count "$pp_signal_count" --argjson instinct_count "$pp_instinct_count" --argjson prompt_section "$pp_section_json" --argjson log_line "$pp_log_json" '{signal_count: $signal_count, instinct_count: $instinct_count, prompt_section: $prompt_section, log_line: $log_line}')"
731
731
  }
732
732
 
733
+ # ============================================================================
734
+ # _budget_enforce
735
+ # Shared budget enforcement for colony-prime and pr-context.
736
+ # Trims sections in priority order when assembled prompt exceeds character budget.
737
+ # Usage: _budget_enforce "<prefix>"
738
+ # prefix: variable prefix ("cp_" for colony-prime, "pc_" for pr-context)
739
+ # Reads/writes via indirect access:
740
+ # {prefix}max_chars, {prefix}budget_len, {prefix}final_prompt,
741
+ # {prefix}sec_rolling, {prefix}sec_learnings, {prefix}sec_decisions,
742
+ # {prefix}sec_hive, {prefix}sec_capsule, {prefix}sec_user_prefs,
743
+ # {prefix}sec_queen_global, {prefix}sec_queen_local, {prefix}sec_signals,
744
+ # {prefix}sec_blockers, {prefix}budget_trimmed_list
745
+ # Trim order: rolling > learnings > decisions > hive > capsule > user_prefs >
746
+ # queen_global > queen_local > signals (preserves REDIRECTs). NEVER trims blockers.
747
+ # ============================================================================
748
+ _budget_enforce() {
749
+ local _be_prefix="${1:-cp_}"
750
+
751
+ # Assemble final_prompt from sections
752
+ eval "local _be_sec_queen_global=\"\${${_be_prefix}sec_queen_global}\""
753
+ eval "local _be_sec_queen_local=\"\${${_be_prefix}sec_queen_local}\""
754
+ eval "local _be_sec_user_prefs=\"\${${_be_prefix}sec_user_prefs}\""
755
+ eval "local _be_sec_hive=\"\${${_be_prefix}sec_hive}\""
756
+ eval "local _be_sec_capsule=\"\${${_be_prefix}sec_capsule}\""
757
+ eval "local _be_sec_learnings=\"\${${_be_prefix}sec_learnings}\""
758
+ eval "local _be_sec_decisions=\"\${${_be_prefix}sec_decisions}\""
759
+ eval "local _be_sec_blockers=\"\${${_be_prefix}sec_blockers}\""
760
+ eval "local _be_sec_rolling=\"\${${_be_prefix}sec_rolling}\""
761
+ eval "local _be_sec_signals=\"\${${_be_prefix}sec_signals}\""
762
+
763
+ eval "local _be_max_chars=\${${_be_prefix}max_chars}"
764
+
765
+ # Assemble all sections in order
766
+ local _be_final_prompt="$_be_sec_queen_global$_be_sec_queen_local$_be_sec_user_prefs$_be_sec_hive$_be_sec_capsule$_be_sec_learnings$_be_sec_decisions$_be_sec_blockers$_be_sec_rolling$_be_sec_signals"
767
+
768
+ local _be_budget_len=${#_be_final_prompt}
769
+ local _be_trimmed_list=""
770
+
771
+ if [[ "$_be_budget_len" -gt "$_be_max_chars" ]]; then
772
+ # Over budget -- trim sections in priority order (first = trimmed first)
773
+
774
+ # 1. Trim rolling-summary
775
+ if [[ "$_be_budget_len" -gt "$_be_max_chars" && -n "$_be_sec_rolling" ]]; then
776
+ _be_sec_rolling=""
777
+ _be_trimmed_list="rolling-summary"
778
+ _be_final_prompt="$_be_sec_queen_global$_be_sec_queen_local$_be_sec_user_prefs$_be_sec_hive$_be_sec_capsule$_be_sec_learnings$_be_sec_decisions$_be_sec_blockers$_be_sec_rolling$_be_sec_signals"
779
+ _be_budget_len=${#_be_final_prompt}
780
+ fi
781
+
782
+ # 2. Trim phase-learnings
783
+ if [[ "$_be_budget_len" -gt "$_be_max_chars" && -n "$_be_sec_learnings" ]]; then
784
+ _be_sec_learnings=""
785
+ _be_trimmed_list="${_be_trimmed_list:+$_be_trimmed_list,}phase-learnings"
786
+ _be_final_prompt="$_be_sec_queen_global$_be_sec_queen_local$_be_sec_user_prefs$_be_sec_hive$_be_sec_capsule$_be_sec_learnings$_be_sec_decisions$_be_sec_blockers$_be_sec_rolling$_be_sec_signals"
787
+ _be_budget_len=${#_be_final_prompt}
788
+ fi
789
+
790
+ # 3. Trim key-decisions
791
+ if [[ "$_be_budget_len" -gt "$_be_max_chars" && -n "$_be_sec_decisions" ]]; then
792
+ _be_sec_decisions=""
793
+ _be_trimmed_list="${_be_trimmed_list:+$_be_trimmed_list,}key-decisions"
794
+ _be_final_prompt="$_be_sec_queen_global$_be_sec_queen_local$_be_sec_user_prefs$_be_sec_hive$_be_sec_capsule$_be_sec_learnings$_be_sec_decisions$_be_sec_blockers$_be_sec_rolling$_be_sec_signals"
795
+ _be_budget_len=${#_be_final_prompt}
796
+ fi
797
+
798
+ # 4. Trim hive-wisdom
799
+ if [[ "$_be_budget_len" -gt "$_be_max_chars" && -n "$_be_sec_hive" ]]; then
800
+ _be_sec_hive=""
801
+ _be_trimmed_list="${_be_trimmed_list:+$_be_trimmed_list,}hive-wisdom"
802
+ _be_final_prompt="$_be_sec_queen_global$_be_sec_queen_local$_be_sec_user_prefs$_be_sec_hive$_be_sec_capsule$_be_sec_learnings$_be_sec_decisions$_be_sec_blockers$_be_sec_rolling$_be_sec_signals"
803
+ _be_budget_len=${#_be_final_prompt}
804
+ fi
805
+
806
+ # 5. Trim context-capsule
807
+ if [[ "$_be_budget_len" -gt "$_be_max_chars" && -n "$_be_sec_capsule" ]]; then
808
+ _be_sec_capsule=""
809
+ _be_trimmed_list="${_be_trimmed_list:+$_be_trimmed_list,}context-capsule"
810
+ _be_final_prompt="$_be_sec_queen_global$_be_sec_queen_local$_be_sec_user_prefs$_be_sec_hive$_be_sec_capsule$_be_sec_learnings$_be_sec_decisions$_be_sec_blockers$_be_sec_rolling$_be_sec_signals"
811
+ _be_budget_len=${#_be_final_prompt}
812
+ fi
813
+
814
+ # 6. Trim user-prefs
815
+ if [[ "$_be_budget_len" -gt "$_be_max_chars" && -n "$_be_sec_user_prefs" ]]; then
816
+ _be_sec_user_prefs=""
817
+ _be_trimmed_list="${_be_trimmed_list:+$_be_trimmed_list,}user-prefs"
818
+ _be_final_prompt="$_be_sec_queen_global$_be_sec_queen_local$_be_sec_user_prefs$_be_sec_hive$_be_sec_capsule$_be_sec_learnings$_be_sec_decisions$_be_sec_blockers$_be_sec_rolling$_be_sec_signals"
819
+ _be_budget_len=${#_be_final_prompt}
820
+ fi
821
+
822
+ # 7. Trim queen-wisdom-global (trim global before local -- local is more relevant)
823
+ if [[ "$_be_budget_len" -gt "$_be_max_chars" && -n "$_be_sec_queen_global" ]]; then
824
+ _be_sec_queen_global=""
825
+ _be_trimmed_list="${_be_trimmed_list:+$_be_trimmed_list,}queen-wisdom-global"
826
+ _be_final_prompt="$_be_sec_queen_global$_be_sec_queen_local$_be_sec_user_prefs$_be_sec_hive$_be_sec_capsule$_be_sec_learnings$_be_sec_decisions$_be_sec_blockers$_be_sec_rolling$_be_sec_signals"
827
+ _be_budget_len=${#_be_final_prompt}
828
+ fi
829
+
830
+ # 8. Trim queen-wisdom-local
831
+ if [[ "$_be_budget_len" -gt "$_be_max_chars" && -n "$_be_sec_queen_local" ]]; then
832
+ _be_sec_queen_local=""
833
+ _be_trimmed_list="${_be_trimmed_list:+$_be_trimmed_list,}queen-wisdom-local"
834
+ _be_final_prompt="$_be_sec_queen_global$_be_sec_queen_local$_be_sec_user_prefs$_be_sec_hive$_be_sec_capsule$_be_sec_learnings$_be_sec_decisions$_be_sec_blockers$_be_sec_rolling$_be_sec_signals"
835
+ _be_budget_len=${#_be_final_prompt}
836
+ fi
837
+
838
+ # 9. Trim pheromone-signals (preserve REDIRECTs)
839
+ if [[ "$_be_budget_len" -gt "$_be_max_chars" && -n "$_be_sec_signals" ]]; then
840
+ # Extract REDIRECT lines and preserve them
841
+ local _be_redirect_preserved=""
842
+ if [[ "$_be_sec_signals" == *"REDIRECT (HARD CONSTRAINTS"* ]]; then
843
+ local _be_redirect_lines=""
844
+ local _be_in_redirect=false
845
+ local _be_rl
846
+ while IFS= read -r _be_rl; do
847
+ if [[ "$_be_rl" == *"REDIRECT (HARD CONSTRAINTS"* ]]; then
848
+ _be_in_redirect=true
849
+ _be_redirect_lines+="$_be_rl"$'\n'
850
+ elif [[ "$_be_in_redirect" == "true" ]]; then
851
+ if [[ "$_be_rl" == "FOCUS "* ]] || [[ "$_be_rl" == "FEEDBACK "* ]] || \
852
+ [[ "$_be_rl" == "POSITION "* ]] || [[ "$_be_rl" == "--- "* ]]; then
853
+ _be_in_redirect=false
854
+ else
855
+ _be_redirect_lines+="$_be_rl"$'\n'
856
+ fi
857
+ fi
858
+ done <<< "$_be_sec_signals"
859
+ if [[ -n "$_be_redirect_lines" ]]; then
860
+ _be_redirect_preserved=$'\n'"--- ACTIVE SIGNALS (Colony Guidance) ---"$'\n'
861
+ _be_redirect_preserved+=$'\n'"$_be_redirect_lines"
862
+ _be_redirect_preserved+=$'\n'"--- END COLONY CONTEXT ---"
863
+ fi
864
+ fi
865
+ _be_sec_signals="$_be_redirect_preserved"
866
+ _be_trimmed_list="${_be_trimmed_list:+$_be_trimmed_list,}pheromone-signals"
867
+ _be_final_prompt="$_be_sec_queen_global$_be_sec_queen_local$_be_sec_user_prefs$_be_sec_hive$_be_sec_capsule$_be_sec_learnings$_be_sec_decisions$_be_sec_blockers$_be_sec_rolling$_be_sec_signals"
868
+ _be_budget_len=${#_be_final_prompt}
869
+ fi
870
+ fi
871
+
872
+ # Write back to caller's variables
873
+ eval "${_be_prefix}sec_queen_global=\"\$_be_sec_queen_global\""
874
+ eval "${_be_prefix}sec_queen_local=\"\$_be_sec_queen_local\""
875
+ eval "${_be_prefix}sec_user_prefs=\"\$_be_sec_user_prefs\""
876
+ eval "${_be_prefix}sec_hive=\"\$_be_sec_hive\""
877
+ eval "${_be_prefix}sec_capsule=\"\$_be_sec_capsule\""
878
+ eval "${_be_prefix}sec_learnings=\"\$_be_sec_learnings\""
879
+ eval "${_be_prefix}sec_decisions=\"\$_be_sec_decisions\""
880
+ eval "${_be_prefix}sec_blockers=\"\$_be_sec_blockers\""
881
+ eval "${_be_prefix}sec_rolling=\"\$_be_sec_rolling\""
882
+ eval "${_be_prefix}sec_signals=\"\$_be_sec_signals\""
883
+ eval "${_be_prefix}final_prompt=\"\$_be_final_prompt\""
884
+ eval "${_be_prefix}budget_len=\"\$_be_budget_len\""
885
+ eval "${_be_prefix}budget_trimmed_list=\"\$_be_trimmed_list\""
886
+ }
887
+
733
888
  # ============================================================================
734
889
  # _colony_prime
735
890
  # Unified colony priming: combines wisdom (QUEEN.md) + signals + instincts into single output
@@ -1380,115 +1535,11 @@ fi
1380
1535
  # context-capsule > user-prefs > queen-wisdom-global > queen-wisdom-local > pheromone-signals (NEVER trim REDIRECTs)
1381
1536
  # Blockers are always kept (REDIRECT-priority).
1382
1537
 
1383
- # Assemble all sections in original order (Phase 20: split queen sections)
1384
- cp_final_prompt="$cp_sec_queen_global$cp_sec_queen_local$cp_sec_user_prefs$cp_sec_hive$cp_sec_capsule$cp_sec_learnings$cp_sec_decisions$cp_sec_blockers$cp_sec_rolling$cp_sec_signals"
1385
-
1386
- cp_budget_len=${#cp_final_prompt}
1387
-
1388
- if [[ "$cp_budget_len" -gt "$cp_max_chars" ]]; then
1389
- # Over budget -- trim sections in priority order (first = trimmed first)
1390
- cp_budget_trimmed_list=""
1391
-
1392
- # 1. Trim rolling-summary
1393
- if [[ "$cp_budget_len" -gt "$cp_max_chars" && -n "$cp_sec_rolling" ]]; then
1394
- cp_sec_rolling=""
1395
- cp_budget_trimmed_list="rolling-summary"
1396
- cp_final_prompt="$cp_sec_queen_global$cp_sec_queen_local$cp_sec_user_prefs$cp_sec_hive$cp_sec_capsule$cp_sec_learnings$cp_sec_decisions$cp_sec_blockers$cp_sec_rolling$cp_sec_signals"
1397
- cp_budget_len=${#cp_final_prompt}
1398
- fi
1399
-
1400
- # 2. Trim phase-learnings
1401
- if [[ "$cp_budget_len" -gt "$cp_max_chars" && -n "$cp_sec_learnings" ]]; then
1402
- cp_sec_learnings=""
1403
- cp_budget_trimmed_list="${cp_budget_trimmed_list:+$cp_budget_trimmed_list,}phase-learnings"
1404
- cp_final_prompt="$cp_sec_queen_global$cp_sec_queen_local$cp_sec_user_prefs$cp_sec_hive$cp_sec_capsule$cp_sec_learnings$cp_sec_decisions$cp_sec_blockers$cp_sec_rolling$cp_sec_signals"
1405
- cp_budget_len=${#cp_final_prompt}
1406
- fi
1407
-
1408
- # 3. Trim key-decisions
1409
- if [[ "$cp_budget_len" -gt "$cp_max_chars" && -n "$cp_sec_decisions" ]]; then
1410
- cp_sec_decisions=""
1411
- cp_budget_trimmed_list="${cp_budget_trimmed_list:+$cp_budget_trimmed_list,}key-decisions"
1412
- cp_final_prompt="$cp_sec_queen_global$cp_sec_queen_local$cp_sec_user_prefs$cp_sec_hive$cp_sec_capsule$cp_sec_learnings$cp_sec_decisions$cp_sec_blockers$cp_sec_rolling$cp_sec_signals"
1413
- cp_budget_len=${#cp_final_prompt}
1414
- fi
1415
-
1416
- # 4. Trim hive-wisdom
1417
- if [[ "$cp_budget_len" -gt "$cp_max_chars" && -n "$cp_sec_hive" ]]; then
1418
- cp_sec_hive=""
1419
- cp_budget_trimmed_list="${cp_budget_trimmed_list:+$cp_budget_trimmed_list,}hive-wisdom"
1420
- cp_final_prompt="$cp_sec_queen_global$cp_sec_queen_local$cp_sec_user_prefs$cp_sec_hive$cp_sec_capsule$cp_sec_learnings$cp_sec_decisions$cp_sec_blockers$cp_sec_rolling$cp_sec_signals"
1421
- cp_budget_len=${#cp_final_prompt}
1422
- fi
1423
-
1424
- # 5. Trim context-capsule
1425
- if [[ "$cp_budget_len" -gt "$cp_max_chars" && -n "$cp_sec_capsule" ]]; then
1426
- cp_sec_capsule=""
1427
- cp_budget_trimmed_list="${cp_budget_trimmed_list:+$cp_budget_trimmed_list,}context-capsule"
1428
- cp_final_prompt="$cp_sec_queen_global$cp_sec_queen_local$cp_sec_user_prefs$cp_sec_hive$cp_sec_capsule$cp_sec_learnings$cp_sec_decisions$cp_sec_blockers$cp_sec_rolling$cp_sec_signals"
1429
- cp_budget_len=${#cp_final_prompt}
1430
- fi
1431
-
1432
- # 6. Trim user-prefs
1433
- if [[ "$cp_budget_len" -gt "$cp_max_chars" && -n "$cp_sec_user_prefs" ]]; then
1434
- cp_sec_user_prefs=""
1435
- cp_budget_trimmed_list="${cp_budget_trimmed_list:+$cp_budget_trimmed_list,}user-prefs"
1436
- cp_final_prompt="$cp_sec_queen_global$cp_sec_queen_local$cp_sec_user_prefs$cp_sec_hive$cp_sec_capsule$cp_sec_learnings$cp_sec_decisions$cp_sec_blockers$cp_sec_rolling$cp_sec_signals"
1437
- cp_budget_len=${#cp_final_prompt}
1438
- fi
1439
-
1440
- # 7. Trim queen-wisdom-global (trim global before local -- local is more relevant)
1441
- if [[ "$cp_budget_len" -gt "$cp_max_chars" && -n "$cp_sec_queen_global" ]]; then
1442
- cp_sec_queen_global=""
1443
- cp_budget_trimmed_list="${cp_budget_trimmed_list:+$cp_budget_trimmed_list,}queen-wisdom-global"
1444
- cp_final_prompt="$cp_sec_queen_global$cp_sec_queen_local$cp_sec_user_prefs$cp_sec_hive$cp_sec_capsule$cp_sec_learnings$cp_sec_decisions$cp_sec_blockers$cp_sec_rolling$cp_sec_signals"
1445
- cp_budget_len=${#cp_final_prompt}
1446
- fi
1447
-
1448
- # 8. Trim queen-wisdom-local
1449
- if [[ "$cp_budget_len" -gt "$cp_max_chars" && -n "$cp_sec_queen_local" ]]; then
1450
- cp_sec_queen_local=""
1451
- cp_budget_trimmed_list="${cp_budget_trimmed_list:+$cp_budget_trimmed_list,}queen-wisdom-local"
1452
- cp_final_prompt="$cp_sec_queen_global$cp_sec_queen_local$cp_sec_user_prefs$cp_sec_hive$cp_sec_capsule$cp_sec_learnings$cp_sec_decisions$cp_sec_blockers$cp_sec_rolling$cp_sec_signals"
1453
- cp_budget_len=${#cp_final_prompt}
1454
- fi
1538
+ _budget_enforce "cp_"
1455
1539
 
1456
- # 9. Trim pheromone-signals (preserve REDIRECTs)
1457
- if [[ "$cp_budget_len" -gt "$cp_max_chars" && -n "$cp_sec_signals" ]]; then
1458
- # Extract REDIRECT lines and preserve them
1459
- cp_redirect_preserved=""
1460
- if [[ "$cp_sec_signals" == *"REDIRECT (HARD CONSTRAINTS"* ]]; then
1461
- cp_redirect_lines=""
1462
- cp_in_redirect=false
1463
- while IFS= read -r cp_rl; do
1464
- if [[ "$cp_rl" == *"REDIRECT (HARD CONSTRAINTS"* ]]; then
1465
- cp_in_redirect=true
1466
- cp_redirect_lines+="$cp_rl"$'\n'
1467
- elif [[ "$cp_in_redirect" == "true" ]]; then
1468
- if [[ "$cp_rl" == "FOCUS "* ]] || [[ "$cp_rl" == "FEEDBACK "* ]] || \
1469
- [[ "$cp_rl" == "POSITION "* ]] || [[ "$cp_rl" == "--- "* ]]; then
1470
- cp_in_redirect=false
1471
- else
1472
- cp_redirect_lines+="$cp_rl"$'\n'
1473
- fi
1474
- fi
1475
- done <<< "$cp_sec_signals"
1476
- if [[ -n "$cp_redirect_lines" ]]; then
1477
- cp_redirect_preserved=$'\n'"--- ACTIVE SIGNALS (Colony Guidance) ---"$'\n'
1478
- cp_redirect_preserved+=$'\n'"$cp_redirect_lines"
1479
- cp_redirect_preserved+=$'\n'"--- END COLONY CONTEXT ---"
1480
- fi
1481
- fi
1482
- cp_sec_signals="$cp_redirect_preserved"
1483
- cp_budget_trimmed_list="${cp_budget_trimmed_list:+$cp_budget_trimmed_list,}pheromone-signals"
1484
- cp_final_prompt="$cp_sec_queen_global$cp_sec_queen_local$cp_sec_user_prefs$cp_sec_hive$cp_sec_capsule$cp_sec_learnings$cp_sec_decisions$cp_sec_blockers$cp_sec_rolling$cp_sec_signals"
1485
- cp_budget_len=${#cp_final_prompt}
1486
- fi
1487
-
1488
- # Append truncation note to log line
1489
- if [[ -n "$cp_budget_trimmed_list" ]]; then
1490
- cp_log_line="$cp_log_line, truncated: $cp_budget_trimmed_list (budget: ${cp_max_chars})"
1491
- fi
1540
+ # Append truncation note to log line (post _budget_enforce)
1541
+ if [[ -n "${cp_budget_trimmed_list:-}" ]]; then
1542
+ cp_log_line="$cp_log_line, truncated: $cp_budget_trimmed_list (budget: ${cp_max_chars})"
1492
1543
  fi
1493
1544
  # === END Budget enforcement ===
1494
1545
 
@@ -1552,6 +1603,569 @@ fi
1552
1603
  json_ok "$cp_result"
1553
1604
  }
1554
1605
 
1606
+ # ============================================================================
1607
+ # _cache_read / _cache_write
1608
+ # Cache helpers for pr-context -- TTL-based with mtime validation
1609
+ # ============================================================================
1610
+ _cache_read() {
1611
+ local _cr_name="$1"
1612
+ local _cr_path="$2"
1613
+ local _cr_ttl="$3"
1614
+ local _cr_cache_file="${COLONY_DATA_DIR:-$DATA_DIR}/pr-context-cache.json"
1615
+
1616
+ if [[ ! -f "$_cr_cache_file" ]]; then
1617
+ echo "null"
1618
+ return 0
1619
+ fi
1620
+
1621
+ # Get source file mtime
1622
+ local _cr_mtime
1623
+ _cr_mtime=$(stat -f "%m" "$_cr_path" 2>/dev/null || stat -c "%Y" "$_cr_path" 2>/dev/null || echo "0")
1624
+
1625
+ # Get cached entry
1626
+ local _cr_entry
1627
+ _cr_entry=$(jq -r --arg name "$_cr_name" '.[$name] // null' "$_cr_cache_file" 2>/dev/null)
1628
+
1629
+ if [[ "$_cr_entry" == "null" || -z "$_cr_entry" ]]; then
1630
+ echo "null"
1631
+ return 0
1632
+ fi
1633
+
1634
+ # Check mtime match
1635
+ local _cr_cached_mtime
1636
+ _cr_cached_mtime=$(echo "$_cr_entry" | jq -r '.mtime // 0' 2>/dev/null)
1637
+ if [[ "$_cr_cached_mtime" != "$_cr_mtime" ]]; then
1638
+ echo "null"
1639
+ return 0
1640
+ fi
1641
+
1642
+ # Check TTL
1643
+ local _cr_cached_at
1644
+ _cr_cached_at=$(echo "$_cr_entry" | jq -r '.cached_at // 0' 2>/dev/null)
1645
+ local _cr_now
1646
+ _cr_now=$(date +%s)
1647
+ local _cr_age=$(( _cr_now - _cr_cached_at ))
1648
+ if [[ "$_cr_age" -gt "$_cr_ttl" ]]; then
1649
+ echo "null"
1650
+ return 0
1651
+ fi
1652
+
1653
+ # Cache hit -- return the data
1654
+ echo "$_cr_entry" | jq -r '.data'
1655
+ }
1656
+
1657
+ _cache_write() {
1658
+ local _cw_name="$1"
1659
+ local _cw_path="$2"
1660
+ local _cw_data="$3"
1661
+ local _cw_cache_file="${COLONY_DATA_DIR:-$DATA_DIR}/pr-context-cache.json"
1662
+
1663
+ # Ensure directory exists
1664
+ mkdir -p "$(dirname "$_cw_cache_file")" 2>/dev/null || true
1665
+
1666
+ # Get source file mtime
1667
+ local _cw_mtime
1668
+ _cw_mtime=$(stat -f "%m" "$_cw_path" 2>/dev/null || stat -c "%Y" "$_cw_path" 2>/dev/null || echo "0")
1669
+
1670
+ local _cw_now
1671
+ _cw_now=$(date +%s)
1672
+
1673
+ # Build new entry
1674
+ local _cw_entry
1675
+ _cw_entry=$(jq -n \
1676
+ --arg path "$_cw_path" \
1677
+ --arg mtime "$_cw_mtime" \
1678
+ --argjson cached_at "$_cw_now" \
1679
+ --argjson data "$_cw_data" \
1680
+ '{path: $path, mtime: $mtime, cached_at: $cached_at, data: $data}')
1681
+
1682
+ # Merge into cache file
1683
+ if [[ ! -f "$_cw_cache_file" ]]; then
1684
+ echo "{}" > "$_cw_cache_file"
1685
+ fi
1686
+
1687
+ # Use atomic write pattern
1688
+ local _cw_tmp="${_cw_cache_file}.tmp.$$"
1689
+ jq --arg name "$_cw_name" --argjson entry "$_cw_entry" \
1690
+ '.[$name] = $entry' "$_cw_cache_file" > "$_cw_tmp" 2>/dev/null && \
1691
+ mv "$_cw_tmp" "$_cw_cache_file" 2>/dev/null || \
1692
+ rm -f "$_cw_tmp" 2>/dev/null
1693
+ }
1694
+
1695
+ # ============================================================================
1696
+ # _pr_context
1697
+ # Generate CI-ready colony context as structured JSON.
1698
+ # Soft-fails on every missing source. Uses cache for stable sources.
1699
+ # ============================================================================
1700
+ _pr_context() {
1701
+ pc_compact=false
1702
+ pc_branch=""
1703
+ pc_ci_run_id=""
1704
+
1705
+ # Parse flags
1706
+ while [[ $# -gt 0 ]]; do
1707
+ case "$1" in
1708
+ --compact) pc_compact=true; shift ;;
1709
+ --branch) pc_branch="${2:-}"; shift 2 ;;
1710
+ --ci-run-id) pc_ci_run_id="${2:-}"; shift 2 ;;
1711
+ *) shift ;;
1712
+ esac
1713
+ done
1714
+
1715
+ # Defaults
1716
+ pc_max_chars=6000
1717
+ if [[ "$pc_compact" == "true" ]]; then
1718
+ pc_max_chars=3000
1719
+ fi
1720
+
1721
+ if [[ -z "$pc_branch" ]]; then
1722
+ pc_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
1723
+ fi
1724
+
1725
+ pc_generated_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1726
+ pc_warnings=()
1727
+ pc_fallbacks=()
1728
+ pc_cache_status='{}'
1729
+
1730
+ # === Section: queen (global) ===
1731
+ pc_queen_global_file="$HOME/.aether/QUEEN.md"
1732
+ pc_queen_global_data="{}"
1733
+ if [[ -f "$pc_queen_global_file" ]]; then
1734
+ pc_queen_global_cached=$(_cache_read "queen_global" "$pc_queen_global_file" 3600)
1735
+ if [[ "$pc_queen_global_cached" != "null" && -n "$pc_queen_global_cached" ]]; then
1736
+ pc_queen_global_data="$pc_queen_global_cached"
1737
+ pc_cache_status=$(echo "$pc_cache_status" | jq --arg s "cached" '.queen_global = $s' 2>/dev/null || echo '{}')
1738
+ else
1739
+ pc_queen_global_data=$(_extract_wisdom "$pc_queen_global_file" 2>/dev/null || echo '{}')
1740
+ if [[ -z "$pc_queen_global_data" || "$pc_queen_global_data" == "null" ]]; then
1741
+ pc_queen_global_data="{}"
1742
+ fi
1743
+ _cache_write "queen_global" "$pc_queen_global_file" "$pc_queen_global_data" 2>/dev/null || true
1744
+ pc_cache_status=$(echo "$pc_cache_status" | jq --arg s "fresh" '.queen_global = $s' 2>/dev/null || echo '{}')
1745
+ fi
1746
+ else
1747
+ pc_fallbacks+=("queen_global: no file found")
1748
+ pc_cache_status=$(echo "$pc_cache_status" | jq --arg s "missing" '.queen_global = $s' 2>/dev/null || echo '{}')
1749
+ fi
1750
+
1751
+ # === Section: queen (local) ===
1752
+ pc_queen_local_file="$AETHER_ROOT/.aether/QUEEN.md"
1753
+ pc_queen_local_data="{}"
1754
+ if [[ -f "$pc_queen_local_file" ]]; then
1755
+ pc_queen_local_cached=$(_cache_read "queen_local" "$pc_queen_local_file" 3600)
1756
+ if [[ "$pc_queen_local_cached" != "null" && -n "$pc_queen_local_cached" ]]; then
1757
+ pc_queen_local_data="$pc_queen_local_cached"
1758
+ pc_cache_status=$(echo "$pc_cache_status" | jq --arg s "cached" '.queen_local = $s' 2>/dev/null || echo '{}')
1759
+ else
1760
+ pc_queen_local_data=$(_extract_wisdom "$pc_queen_local_file" 2>/dev/null || echo '{}')
1761
+ if [[ -z "$pc_queen_local_data" || "$pc_queen_local_data" == "null" ]]; then
1762
+ pc_queen_local_data="{}"
1763
+ fi
1764
+ _cache_write "queen_local" "$pc_queen_local_file" "$pc_queen_local_data" 2>/dev/null || true
1765
+ pc_cache_status=$(echo "$pc_cache_status" | jq --arg s "fresh" '.queen_local = $s' 2>/dev/null || echo '{}')
1766
+ fi
1767
+ else
1768
+ pc_fallbacks+=("queen_local: no file found")
1769
+ pc_cache_status=$(echo "$pc_cache_status" | jq --arg s "missing" '.queen_local = $s' 2>/dev/null || echo '{}')
1770
+ fi
1771
+
1772
+ # === Section: user_preferences ===
1773
+ pc_user_prefs='[]'
1774
+ # Extract from global queen wisdom
1775
+ pc_up_raw=$(echo "$pc_queen_global_data" | jq -r '.user_prefs // ""' 2>/dev/null)
1776
+ if [[ -n "$pc_up_raw" && "$pc_up_raw" != "null" ]]; then
1777
+ pc_user_prefs=$(echo "$pc_up_raw" | jq -R -s 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]')
1778
+ fi
1779
+ pc_up_local=$(echo "$pc_queen_local_data" | jq -r '.user_prefs // ""' 2>/dev/null)
1780
+ if [[ -n "$pc_up_local" && "$pc_up_local" != "null" ]]; then
1781
+ pc_user_prefs_local=$(echo "$pc_up_local" | jq -R -s 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]')
1782
+ pc_user_prefs=$(echo "$pc_user_prefs" | jq --argjson local "$pc_user_prefs_local" '. + $local' 2>/dev/null || echo "$pc_user_prefs")
1783
+ fi
1784
+
1785
+ # === Section: signals ===
1786
+ pc_pher_file="${COLONY_DATA_DIR:-$DATA_DIR}/pheromones.json"
1787
+ pc_state_file="${COLONY_DATA_DIR:-$DATA_DIR}/COLONY_STATE.json"
1788
+ pc_signals_count=0
1789
+ pc_redirects='[]'
1790
+ pc_focus='[]'
1791
+ pc_feedback='[]'
1792
+ pc_instincts='[]'
1793
+
1794
+ if [[ -f "$pc_pher_file" ]]; then
1795
+ # Read and classify signals
1796
+ pc_signals_json=$(jq -r '.signals // []' "$pc_pher_file" 2>/dev/null || echo '[]')
1797
+ if [[ -n "$pc_signals_json" && "$pc_signals_json" != "null" ]]; then
1798
+ pc_signals_count=$(echo "$pc_signals_json" | jq 'length' 2>/dev/null || echo 0)
1799
+ pc_redirects=$(echo "$pc_signals_json" | jq '[.[] | select(.type == "REDIRECT")]' 2>/dev/null || echo '[]')
1800
+ pc_focus=$(echo "$pc_signals_json" | jq '[.[] | select(.type == "FOCUS")]' 2>/dev/null || echo '[]')
1801
+ pc_feedback=$(echo "$pc_signals_json" | jq '[.[] | select(.type == "FEEDBACK")]' 2>/dev/null || echo '[]')
1802
+ fi
1803
+ else
1804
+ pc_fallbacks+=("pheromones: no active signals")
1805
+ fi
1806
+
1807
+ # Read instincts from COLONY_STATE.json
1808
+ if [[ -f "$pc_state_file" ]]; then
1809
+ pc_instincts=$(jq -r '.memory.instincts // []' "$pc_state_file" 2>/dev/null || echo '[]')
1810
+ if [[ -z "$pc_instincts" || "$pc_instincts" == "null" ]]; then
1811
+ pc_instincts='[]'
1812
+ fi
1813
+ fi
1814
+
1815
+ # === Section: hive_wisdom ===
1816
+ pc_hive_data='[]'
1817
+ pc_hive_source="empty"
1818
+ pc_hive_file="$HOME/.aether/hive/wisdom.json"
1819
+ pc_eternal_file="$HOME/.aether/eternal/memory.json"
1820
+
1821
+ # Try hive first (via subcommand invocation for proper domain scoping)
1822
+ pc_hive_raw=$(bash "$SCRIPT_DIR/aether-utils.sh" hive-read --limit 5 --min-confidence 0.5 --format json 2>/dev/null || echo '')
1823
+ if [[ -n "$pc_hive_raw" ]]; then
1824
+ pc_hive_entries=$(echo "$pc_hive_raw" | jq -r '.result.entries // []' 2>/dev/null)
1825
+ if [[ -n "$pc_hive_entries" && "$pc_hive_entries" != "null" && "$pc_hive_entries" != "[]" ]]; then
1826
+ pc_hive_data="$pc_hive_entries"
1827
+ pc_hive_source="hive"
1828
+ pc_cache_status=$(echo "$pc_cache_status" | jq --arg s "fresh" '.hive = $s' 2>/dev/null || echo '{}')
1829
+ fi
1830
+ fi
1831
+
1832
+ # Fallback to eternal memory
1833
+ if [[ "$pc_hive_source" == "empty" && -f "$pc_eternal_file" ]]; then
1834
+ pc_eternal_cached=$(_cache_read "eternal" "$pc_eternal_file" 7200)
1835
+ if [[ "$pc_eternal_cached" != "null" && -n "$pc_eternal_cached" ]]; then
1836
+ pc_hive_data="$pc_eternal_cached"
1837
+ pc_hive_source="eternal"
1838
+ pc_cache_status=$(echo "$pc_cache_status" | jq --arg s "cached" '.hive = $s' 2>/dev/null || echo '{}')
1839
+ else
1840
+ pc_eternal_raw=$(jq -r '.entries // []' "$pc_eternal_file" 2>/dev/null || echo '[]')
1841
+ if [[ -n "$pc_eternal_raw" && "$pc_eternal_raw" != "null" && "$pc_eternal_raw" != "[]" ]]; then
1842
+ pc_hive_data="$pc_eternal_raw"
1843
+ pc_hive_source="eternal"
1844
+ _cache_write "eternal" "$pc_eternal_file" "$pc_eternal_raw" 2>/dev/null || true
1845
+ pc_cache_status=$(echo "$pc_cache_status" | jq --arg s "fresh" '.hive = $s' 2>/dev/null || echo '{}')
1846
+ fi
1847
+ fi
1848
+ fi
1849
+
1850
+ if [[ "$pc_hive_source" == "empty" ]]; then
1851
+ pc_fallbacks+=("hive_wisdom: no hive or eternal data")
1852
+ pc_cache_status=$(echo "$pc_cache_status" | jq --arg s "missing" '.hive = $s' 2>/dev/null || echo '{}')
1853
+ fi
1854
+
1855
+ # === Section: colony_state ===
1856
+ pc_cs_exists=false
1857
+ pc_cs_goal="No goal set"
1858
+ pc_cs_state="UNKNOWN"
1859
+ pc_cs_current_phase=0
1860
+ pc_cs_total_phases=0
1861
+ pc_cs_phase_name=""
1862
+ if [[ -f "$pc_state_file" ]]; then
1863
+ pc_cs_parsed=$(jq -r '{goal: .goal, state: .state, current_phase: .current_phase, total_phases: (.total_phases // (.plan.phases | length // 0)), phase_name: .phase_name}' "$pc_state_file" 2>/dev/null)
1864
+ if [[ -n "$pc_cs_parsed" && "$pc_cs_parsed" != "null" ]]; then
1865
+ pc_cs_exists=true
1866
+ pc_cs_goal=$(echo "$pc_cs_parsed" | jq -r '.goal // "No goal set"' 2>/dev/null)
1867
+ pc_cs_state=$(echo "$pc_cs_parsed" | jq -r '.state // "UNKNOWN"' 2>/dev/null)
1868
+ pc_cs_current_phase=$(echo "$pc_cs_parsed" | jq -r '.current_phase // 0' 2>/dev/null)
1869
+ pc_cs_total_phases=$(echo "$pc_cs_parsed" | jq -r '.total_phases // 0' 2>/dev/null)
1870
+ pc_cs_phase_name=$(echo "$pc_cs_parsed" | jq -r '.phase_name // ""' 2>/dev/null)
1871
+ else
1872
+ pc_fallbacks+=("colony_state: COLONY_STATE.json corrupt")
1873
+ fi
1874
+ else
1875
+ pc_fallbacks+=("colony_state: COLONY_STATE.json missing")
1876
+ fi
1877
+
1878
+ # === Section: blockers ===
1879
+ pc_flags_file="${COLONY_DATA_DIR:-$DATA_DIR}/flags.json"
1880
+ pc_blockers_count=0
1881
+ pc_blockers_items='[]'
1882
+ if [[ -f "$pc_flags_file" ]]; then
1883
+ pc_blockers_items=$(jq -r '[.flags // [] | .[] | select((.resolved // false) != true and ((.type // "") == "blocker" or (.severity // "") == "CRITICAL"))]' "$pc_flags_file" 2>/dev/null || echo '[]')
1884
+ pc_blockers_count=$(echo "$pc_blockers_items" | jq 'length' 2>/dev/null || echo 0)
1885
+ else
1886
+ pc_fallbacks+=("blockers: flags.json missing")
1887
+ fi
1888
+
1889
+ # === Section: decisions ===
1890
+ pc_decisions_count=0
1891
+ pc_decisions_items='[]'
1892
+ pc_context_file="$AETHER_ROOT/.aether/CONTEXT.md"
1893
+ if [[ -f "$pc_context_file" ]]; then
1894
+ # Extract decisions from CONTEXT.md if present
1895
+ pc_decisions_items=$(jq -r '.memory.decisions // []' "$pc_state_file" 2>/dev/null || echo '[]')
1896
+ if [[ -n "$pc_decisions_items" && "$pc_decisions_items" != "null" && "$pc_decisions_items" != "[]" ]]; then
1897
+ pc_decisions_count=$(echo "$pc_decisions_items" | jq 'length' 2>/dev/null || echo 0)
1898
+ fi
1899
+ elif [[ -f "$pc_state_file" ]]; then
1900
+ pc_decisions_items=$(jq -r '.memory.decisions // []' "$pc_state_file" 2>/dev/null || echo '[]')
1901
+ if [[ -n "$pc_decisions_items" && "$pc_decisions_items" != "null" ]]; then
1902
+ pc_decisions_count=$(echo "$pc_decisions_items" | jq 'length' 2>/dev/null || echo 0)
1903
+ fi
1904
+ fi
1905
+
1906
+ # === Section: rolling_summary ===
1907
+ pc_roll_file="${COLONY_DATA_DIR:-$DATA_DIR}/rolling-summary.log"
1908
+ pc_rolling=""
1909
+ if [[ -f "$pc_roll_file" ]]; then
1910
+ pc_rolling=$(tail -n 20 "$pc_roll_file" 2>/dev/null | head -20 || echo "")
1911
+ fi
1912
+
1913
+ # === Section: midden ===
1914
+ pc_midden_file="${COLONY_DATA_DIR:-$DATA_DIR}/midden/midden.json"
1915
+ pc_midden_count=0
1916
+ pc_midden_entries='[]'
1917
+ pc_midden_cross_pr='{}'
1918
+
1919
+ if [[ -f "$pc_midden_file" ]]; then
1920
+ # Bound: entries from last 7 days, cap at 10
1921
+ local now_epoch
1922
+ now_epoch=$(date +%s)
1923
+ local seven_days_ago=$(( now_epoch - 604800 ))
1924
+ pc_midden_entries=$(jq -r --argjson cutoff "$seven_days_ago" --argjson max 10 '
1925
+ [.entries // [] | .[] |
1926
+ # Parse occurred_at to epoch (best-effort)
1927
+ (.occurred_at // .timestamp // "") as $ts |
1928
+ ($ts | split("T")) as $parts |
1929
+ if ($parts | length) > 1 then
1930
+ ($parts[0] | split("-")) as $d |
1931
+ ($parts[1] | rtrimstr("Z") | split(":")) as $t |
1932
+ (($d[0] // "0" | tonumber) - 1970) * 365 * 86400 +
1933
+ (($d[1] // "0" | tonumber) - 1) * 30 * 86400 +
1934
+ (($d[2] // "0" | tonumber) - 1) * 86400 +
1935
+ (($t[0] // "0" | tonumber) * 3600) +
1936
+ (($t[1] // "0" | tonumber) * 60) +
1937
+ (($t[2] // "0" | rtrimstr("Z") | tonumber) // 0) as $epoch |
1938
+ . + {_epoch: $epoch}
1939
+ else . + {_epoch: 0} end
1940
+ ] | sort_by(-._epoch) | .[:$max] |
1941
+ map(del(._epoch) | .description = ((.description // "")[0:160]))
1942
+ ' "$pc_midden_file" 2>/dev/null || echo '[]')
1943
+ pc_midden_count=$(echo "$pc_midden_entries" | jq 'length' 2>/dev/null || echo 0)
1944
+ else
1945
+ pc_fallbacks+=("midden: midden.json missing")
1946
+ fi
1947
+
1948
+ # === Section: context_capsule ===
1949
+ pc_capsule_data='{}'
1950
+ pc_capsule_raw=$(bash "$SCRIPT_DIR/aether-utils.sh" context-capsule --json 2>/dev/null || echo '')
1951
+ if [[ -n "$pc_capsule_raw" ]]; then
1952
+ pc_capsule_data=$(echo "$pc_capsule_raw" | jq -r '.result // . // {}' 2>/dev/null || echo '{}')
1953
+ fi
1954
+
1955
+ # === Section: phase_learnings ===
1956
+ pc_learnings=""
1957
+ if [[ -f "$pc_state_file" ]]; then
1958
+ pc_learnings=$(jq -r '.memory.phase_learnings // [] | map(if type == "object" then (.summary // .description // tostring) else tostring end) | .[]' "$pc_state_file" 2>/dev/null | head -20 || echo "")
1959
+ fi
1960
+
1961
+ # === Build prompt_section (text version) ===
1962
+ pc_sec_queen_global=""
1963
+ pc_sec_queen_local=""
1964
+ pc_sec_user_prefs=""
1965
+ pc_sec_hive=""
1966
+ pc_sec_capsule=""
1967
+ pc_sec_learnings=""
1968
+ pc_sec_decisions=""
1969
+ pc_sec_blockers=""
1970
+ pc_sec_rolling=""
1971
+ pc_sec_signals=""
1972
+
1973
+ # QUEEN global section
1974
+ local _pc_qg_raw=""
1975
+ if [[ -f "$pc_queen_global_file" ]]; then
1976
+ _pc_qg_raw=$(echo "$pc_queen_global_data" | jq -r 'to_entries | map("\(.key): \(.value)") | .[]' 2>/dev/null)
1977
+ fi
1978
+ if [[ -n "$_pc_qg_raw" ]]; then
1979
+ pc_sec_queen_global=$'\n'"--- QUEEN WISDOM (Global) ---"$'\n'"$_pc_qg_raw"$'\n'
1980
+ fi
1981
+
1982
+ # QUEEN local section
1983
+ local _pc_ql_raw=""
1984
+ if [[ -f "$pc_queen_local_file" ]]; then
1985
+ _pc_ql_raw=$(echo "$pc_queen_local_data" | jq -r 'to_entries | map("\(.key): \(.value)") | .[]' 2>/dev/null)
1986
+ fi
1987
+ if [[ -n "$_pc_ql_raw" ]]; then
1988
+ pc_sec_queen_local=$'\n'"--- QUEEN WISDOM (Local) ---"$'\n'"$_pc_ql_raw"$'\n'
1989
+ fi
1990
+
1991
+ # User preferences
1992
+ if [[ "$(echo "$pc_user_prefs" | jq 'length' 2>/dev/null)" -gt 0 ]]; then
1993
+ pc_sec_user_prefs=$'\n'"--- USER PREFERENCES ---"$'\n'
1994
+ pc_sec_user_prefs+=$(echo "$pc_user_prefs" | jq -r '.[]' 2>/dev/null | while IFS= read -r line; do echo "- $line"; done)
1995
+ pc_sec_user_prefs+=$'\n'
1996
+ fi
1997
+
1998
+ # Signals section
1999
+ if [[ "$pc_signals_count" -gt 0 ]]; then
2000
+ pc_sec_signals=$'\n'"--- ACTIVE SIGNALS (Colony Guidance) ---"$'\n'
2001
+ local _pc_redirects_text=""
2002
+ _pc_redirects_text=$(echo "$pc_redirects" | jq -r '.[] | "REDIRECT (HARD CONSTRAINT): " + (.content.text // (.content | if type == "string" then . else "" end))' 2>/dev/null)
2003
+ if [[ -n "$_pc_redirects_text" ]]; then
2004
+ pc_sec_signals+=$'\n'"REDIRECT (HARD CONSTRAINTS):"$'\n'
2005
+ while IFS= read -r line; do [[ -n "$line" ]] && pc_sec_signals+="- $line"$'\n'; done <<< "$_pc_redirects_text"
2006
+ fi
2007
+ local _pc_focus_text=""
2008
+ _pc_focus_text=$(echo "$pc_focus" | jq -r '.[] | "FOCUS: " + (.content.text // (.content | if type == "string" then . else "" end))' 2>/dev/null)
2009
+ if [[ -n "$_pc_focus_text" ]]; then
2010
+ pc_sec_signals+=$'\n'"FOCUS (Active Guidance):"$'\n'
2011
+ while IFS= read -r line; do [[ -n "$line" ]] && pc_sec_signals+="- $line"$'\n'; done <<< "$_pc_focus_text"
2012
+ fi
2013
+ local _pc_feedback_text=""
2014
+ _pc_feedback_text=$(echo "$pc_feedback" | jq -r '.[] | "FEEDBACK: " + (.content.text // (.content | if type == "string" then . else "" end))' 2>/dev/null)
2015
+ if [[ -n "$_pc_feedback_text" ]]; then
2016
+ pc_sec_signals+=$'\n'"FEEDBACK (Adjustments):"$'\n'
2017
+ while IFS= read -r line; do [[ -n "$line" ]] && pc_sec_signals+="- $line"$'\n'; done <<< "$_pc_feedback_text"
2018
+ fi
2019
+ pc_sec_signals+=$'\n'"--- END SIGNALS ---"$'\n'
2020
+ fi
2021
+
2022
+ # Hive wisdom
2023
+ local _pc_hive_count=0
2024
+ _pc_hive_count=$(echo "$pc_hive_data" | jq 'length' 2>/dev/null || echo 0)
2025
+ if [[ "$_pc_hive_count" -gt 0 ]]; then
2026
+ pc_sec_hive=$'\n'"--- HIVE WISDOM (Cross-Colony Patterns) ---"$'\n'
2027
+ pc_sec_hive+=$(echo "$pc_hive_data" | jq -r '.[] | "- " + (.wisdom // .text // (. | tostring))' 2>/dev/null | head -10)
2028
+ pc_sec_hive+=$'\n'
2029
+ fi
2030
+
2031
+ # Context capsule
2032
+ local _pc_capsule_text=""
2033
+ _pc_capsule_text=$(echo "$pc_capsule_data" | jq -r '.prompt_section // ""' 2>/dev/null)
2034
+ if [[ -n "$_pc_capsule_text" ]]; then
2035
+ pc_sec_capsule=$'\n'"$_pc_capsule_text"$'\n'
2036
+ fi
2037
+
2038
+ # Phase learnings
2039
+ if [[ -n "$pc_learnings" ]]; then
2040
+ pc_sec_learnings=$'\n'"--- PHASE LEARNINGS ---"$'\n'"$pc_learnings"$'\n'
2041
+ fi
2042
+
2043
+ # Decisions
2044
+ if [[ "$pc_decisions_count" -gt 0 ]]; then
2045
+ pc_sec_decisions=$'\n'"--- KEY DECISIONS ---"$'\n'
2046
+ pc_sec_decisions+=$(echo "$pc_decisions_items" | jq -r '.[] | if type == "object" then "- " + (.decision // .summary // .description // tostring) else "- " + tostring end' 2>/dev/null | head -10)
2047
+ pc_sec_decisions+=$'\n'
2048
+ fi
2049
+
2050
+ # Blockers
2051
+ if [[ "$pc_blockers_count" -gt 0 ]]; then
2052
+ pc_sec_blockers=$'\n'"--- BLOCKERS (CRITICAL) ---"$'\n'
2053
+ pc_sec_blockers+=$(echo "$pc_blockers_items" | jq -r '.[] | "- " + (.title // .description // tostring)' 2>/dev/null | head -10)
2054
+ pc_sec_blockers+=$'\n'
2055
+ fi
2056
+
2057
+ # Rolling summary
2058
+ if [[ -n "$pc_rolling" ]]; then
2059
+ pc_sec_rolling=$'\n'"--- ROLLING SUMMARY ---"$'\n'"$pc_rolling"$'\n'
2060
+ fi
2061
+
2062
+ # === Budget enforcement ===
2063
+ _budget_enforce "pc_"
2064
+
2065
+ # Trim notification
2066
+ local pc_trimmed_sections=""
2067
+ if [[ -n "${pc_budget_trimmed_list:-}" ]]; then
2068
+ pc_trimmed_sections=$(echo "$pc_budget_trimmed_list" | tr ',' ', ')
2069
+ fi
2070
+
2071
+ # === Build output JSON ===
2072
+ # Build fallbacks JSON array
2073
+ local pc_fallbacks_json='[]'
2074
+ local fb
2075
+ for fb in ${pc_fallbacks[@]+"${pc_fallbacks[@]}"}; do
2076
+ pc_fallbacks_json=$(echo "$pc_fallbacks_json" | jq --arg f "$fb" '. + [$f]' 2>/dev/null || echo '[]')
2077
+ done
2078
+
2079
+ # Build warnings JSON array
2080
+ local pc_warnings_json='[]'
2081
+ local w
2082
+ for w in ${pc_warnings[@]+"${pc_warnings[@]}"}; do
2083
+ pc_warnings_json=$(echo "$pc_warnings_json" | jq --arg f "$w" '. + [$f]' 2>/dev/null || echo '[]')
2084
+ done
2085
+
2086
+ # Trimmed sections JSON array
2087
+ local pc_trimmed_json='[]'
2088
+ if [[ -n "${pc_budget_trimmed_list:-}" ]]; then
2089
+ pc_trimmed_json=$(echo "$pc_budget_trimmed_list" | jq -R 'split(",")' 2>/dev/null || echo '[]')
2090
+ fi
2091
+
2092
+ # Escape prompt_section for JSON
2093
+ local pc_prompt_json
2094
+ pc_prompt_json=$(printf '%s' "$pc_final_prompt" | jq -Rs '.' 2>/dev/null || echo '""')
2095
+
2096
+ # Colony state JSON
2097
+ local pc_colony_state_json
2098
+ pc_colony_state_json=$(jq -n \
2099
+ --argjson exists "$pc_cs_exists" \
2100
+ --arg goal "$pc_cs_goal" \
2101
+ --arg state "$pc_cs_state" \
2102
+ --argjson current_phase "$pc_cs_current_phase" \
2103
+ --argjson total_phases "$pc_cs_total_phases" \
2104
+ --arg phase_name "$pc_cs_phase_name" \
2105
+ '{exists: $exists, goal: $goal, state: $state, current_phase: $current_phase, total_phases: $total_phases, phase_name: $phase_name}')
2106
+
2107
+ # Build result
2108
+ local pc_result
2109
+ pc_result=$(jq -n \
2110
+ --arg schema "pr-context-v1" \
2111
+ --arg generated_at "$pc_generated_at" \
2112
+ --arg branch "$pc_branch" \
2113
+ --argjson cache_status "$pc_cache_status" \
2114
+ --argjson queen_global "$pc_queen_global_data" \
2115
+ --argjson queen_local "$pc_queen_local_data" \
2116
+ --argjson combined_prefs "$pc_user_prefs" \
2117
+ --argjson signals_count "$pc_signals_count" \
2118
+ --argjson redirects "$pc_redirects" \
2119
+ --argjson focus "$pc_focus" \
2120
+ --argjson feedback "$pc_feedback" \
2121
+ --argjson instincts "$pc_instincts" \
2122
+ --arg hive_source "$pc_hive_source" \
2123
+ --argjson hive_count "$(echo "$pc_hive_data" | jq 'length' 2>/dev/null || echo 0)" \
2124
+ --argjson hive_entries "$pc_hive_data" \
2125
+ --argjson colony_state "$pc_colony_state_json" \
2126
+ --argjson blockers_count "$pc_blockers_count" \
2127
+ --argjson blockers_items "$pc_blockers_items" \
2128
+ --argjson decisions_count "$pc_decisions_count" \
2129
+ --argjson decisions_items "$pc_decisions_items" \
2130
+ --argjson midden_count "$pc_midden_count" \
2131
+ --argjson midden_entries "$pc_midden_entries" \
2132
+ --argjson midden_cross_pr "$pc_midden_cross_pr" \
2133
+ --argjson prompt_section "$pc_prompt_json" \
2134
+ --argjson char_count "${#pc_final_prompt}" \
2135
+ --argjson budget "$pc_max_chars" \
2136
+ --argjson trimmed_sections "$pc_trimmed_json" \
2137
+ --argjson warnings "$pc_warnings_json" \
2138
+ --argjson fallbacks_used "$pc_fallbacks_json" \
2139
+ '{
2140
+ schema: $schema,
2141
+ generated_at: $generated_at,
2142
+ branch: $branch,
2143
+ cache_status: $cache_status,
2144
+ queen: {global: $queen_global, local: $queen_local, combined_prefs: $combined_prefs},
2145
+ signals: {count: $signals_count, redirects: $redirects, focus: $focus, feedback: $feedback, instincts: $instincts},
2146
+ hive: {source: $hive_source, count: $hive_count, entries: $hive_entries},
2147
+ colony_state: $colony_state,
2148
+ blockers: {count: $blockers_count, items: $blockers_items},
2149
+ decisions: {count: $decisions_count, items: $decisions_items},
2150
+ midden: {count: $midden_count, entries: $midden_entries, cross_pr_analysis: $midden_cross_pr},
2151
+ prompt_section: $prompt_section,
2152
+ char_count: $char_count,
2153
+ budget: $budget,
2154
+ trimmed_sections: $trimmed_sections,
2155
+ warnings: $warnings,
2156
+ fallbacks_used: $fallbacks_used
2157
+ }')
2158
+
2159
+ # Validate result
2160
+ if [[ -z "$pc_result" ]] || ! echo "$pc_result" | jq -e . >/dev/null 2>&1; then
2161
+ json_err "$E_JSON_INVALID" \
2162
+ "Couldn't assemble pr-context output" \
2163
+ '{"error":"assembly_failed"}'
2164
+ fi
2165
+
2166
+ json_ok "$pc_result"
2167
+ }
2168
+
1555
2169
  # ============================================================================
1556
2170
  # _pheromone_expire
1557
2171
  # Archive expired pheromone signals to midden
@@ -2027,3 +2641,657 @@ source "$SCRIPT_DIR/exchange/pheromone-xml.sh"
2027
2641
  xml-pheromone-validate "$pvx_xml" "$pvx_xsd"
2028
2642
  }
2029
2643
 
2644
+ # ============================================================================
2645
+ # _pheromone_snapshot_inject
2646
+ # Inject canonical signals from main into the current branch
2647
+ # ============================================================================
2648
+ _pheromone_snapshot_inject() {
2649
+ # Inject main's injectable signals into the current branch's pheromones.json
2650
+ # Usage: pheromone-snapshot-inject --from-branch BRANCH --from-commit SHA
2651
+ # --from-branch: source branch (typically "main")
2652
+ # --from-commit: commit SHA of the source branch at injection time
2653
+ # Returns: JSON with injected_count, skipped_count, snapshot metadata
2654
+
2655
+ psi_from_branch="main"
2656
+ psi_from_commit=""
2657
+
2658
+ while [[ $# -gt 0 ]]; do
2659
+ case "$1" in
2660
+ --from-branch) shift; psi_from_branch="${1:-main}" ;;
2661
+ --from-commit) shift; psi_from_commit="${1:-}" ;;
2662
+ *) shift ;;
2663
+ esac
2664
+ done
2665
+
2666
+ if [[ -z "$psi_from_commit" ]]; then
2667
+ json_err "$E_VALIDATION_FAILED" "pheromone-snapshot-inject requires --from-commit argument"
2668
+ fi
2669
+
2670
+ psi_file="$COLONY_DATA_DIR/pheromones.json"
2671
+
2672
+ # Edge case: no pheromones.json on main -- no-op
2673
+ if [[ ! -f "$psi_file" ]]; then
2674
+ json_ok "$(jq -n --arg branch "$psi_from_branch" --arg commit "$psi_from_commit" \
2675
+ '{snapshot_from_branch: $branch, snapshot_from_commit: $commit, injected_count: 0, skipped_count: 0}')"
2676
+ return 0
2677
+ fi
2678
+
2679
+ psi_now_iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
2680
+
2681
+ # Read active signals and filter injectable ones
2682
+ # Filter rule: REDIRECT (any source) OR (user source AND type IN (FOCUS, FEEDBACK))
2683
+ psi_filtered=$(jq -c --arg now "$psi_now_iso" '
2684
+ def to_epoch(ts):
2685
+ if ts == null or ts == "" or ts == "phase_end" then null
2686
+ else
2687
+ (ts | split("T")) as $parts |
2688
+ ($parts[0] | split("-")) as $d |
2689
+ ($parts[1] | rtrimstr("Z") | split(":")) as $t |
2690
+ (($d[0] | tonumber) - 1970) * 365 * 86400 +
2691
+ (($d[1] | tonumber) - 1) * 30 * 86400 +
2692
+ (($d[2] | tonumber) - 1) * 86400 +
2693
+ ($t[0] | tonumber) * 3600 +
2694
+ ($t[1] | tonumber) * 60 +
2695
+ ($t[2] | rtrimstr("Z") | tonumber)
2696
+ end;
2697
+
2698
+ (to_epoch($now)) as $now_epoch |
2699
+
2700
+ .signals | map(select(.active == true)) |
2701
+ map(
2702
+ # Check expiry
2703
+ (to_epoch(.expires_at)) as $exp_epoch |
2704
+ select(if $exp_epoch != null then $exp_epoch > $now_epoch else true end) |
2705
+ # Apply injection filter
2706
+ select(
2707
+ .type == "REDIRECT"
2708
+ or
2709
+ (.source == "user" and (.type == "FOCUS" or .type == "FEEDBACK"))
2710
+ )
2711
+ )
2712
+ ' "$psi_file" 2>/dev/null || echo "[]")
2713
+
2714
+ if [[ -z "$psi_filtered" || "$psi_filtered" == "null" ]]; then
2715
+ psi_filtered="[]"
2716
+ fi
2717
+
2718
+ psi_injected_ids=()
2719
+ psi_skipped_ids=()
2720
+ psi_injected_details="[]"
2721
+ psi_skipped_details="[]"
2722
+
2723
+ # Count total active signals for skip tracking
2724
+ psi_total_active=$(jq '[.signals[] | select(.active == true)] | length' "$psi_file" 2>/dev/null || echo "0")
2725
+ psi_inject_count=$(echo "$psi_filtered" | jq 'length')
2726
+ psi_skip_count=$((psi_total_active - psi_inject_count))
2727
+
2728
+ # Build skipped reasons
2729
+ psi_skip_reasons="[]"
2730
+ if [[ "$psi_skip_count" -gt 0 ]]; then
2731
+ psi_skip_reasons=$(jq -c --arg now "$psi_now_iso" '
2732
+ def to_epoch(ts):
2733
+ if ts == null or ts == "" or ts == "phase_end" then null
2734
+ else
2735
+ (ts | split("T")) as $parts |
2736
+ ($parts[0] | split("-")) as $d |
2737
+ ($parts[1] | rtrimstr("Z") | split(":")) as $t |
2738
+ (($d[0] | tonumber) - 1970) * 365 * 86400 +
2739
+ (($d[1] | tonumber) - 1) * 30 * 86400 +
2740
+ (($d[2] | tonumber) - 1) * 86400 +
2741
+ ($t[0] | tonumber) * 3600 +
2742
+ ($t[1] | tonumber) * 60 +
2743
+ ($t[2] | rtrimstr("Z") | tonumber)
2744
+ end;
2745
+
2746
+ (to_epoch($now)) as $now_epoch |
2747
+
2748
+ .signals | map(select(.active == true)) |
2749
+ map(
2750
+ (to_epoch(.expires_at)) as $exp_epoch |
2751
+ select(if $exp_epoch != null then $exp_epoch > $now_epoch else true end) |
2752
+ select(
2753
+ .type != "REDIRECT"
2754
+ and
2755
+ (.source != "user" or (.type != "FOCUS" and .type != "FEEDBACK"))
2756
+ )
2757
+ ) | map({
2758
+ original_id: .id,
2759
+ type: .type,
2760
+ source: .source,
2761
+ reason: (if .type == "FOCUS" or .type == "FEEDBACK" then "worker/system-sourced \(.type) excluded from injection" else "signal type \(.type) excluded from injection" end)
2762
+ })
2763
+ ' "$psi_file" 2>/dev/null || echo "[]")
2764
+ fi
2765
+
2766
+ # Inject each signal via _pheromone_write (reuses content_hash dedup)
2767
+ psi_injected_count=0
2768
+ if [[ "$psi_inject_count" -gt 0 ]]; then
2769
+ psi_injected_details=$(echo "$psi_filtered" | jq -c --arg now "$psi_now_iso" '
2770
+ map({
2771
+ original_id: .id,
2772
+ type: .type,
2773
+ content_hash: .content_hash,
2774
+ strength: .strength,
2775
+ source: .source,
2776
+ action: "injected"
2777
+ })
2778
+ ')
2779
+
2780
+ # Compute TTL from expires_at for each signal
2781
+ echo "$psi_filtered" | jq -c '.[]' | while IFS= read -r sig; do
2782
+ local_sig_type=$(echo "$sig" | jq -r '.type')
2783
+ local_sig_content=$(echo "$sig" | jq -r '.content.text // .content // ""')
2784
+ local_sig_strength=$(echo "$sig" | jq -r '.strength')
2785
+ local_sig_source=$(echo "$sig" | jq -r '.source')
2786
+ local_sig_expires=$(echo "$sig" | jq -r '.expires_at')
2787
+
2788
+ # Compute TTL from remaining time
2789
+ local_sig_ttl="phase_end"
2790
+ if [[ "$local_sig_expires" != "phase_end" && -n "$local_sig_expires" ]]; then
2791
+ # Parse expires_at epoch using jq's to_epoch (same logic as _pheromone_write)
2792
+ local_exp_epoch=$(echo "$sig" | jq --arg now "$psi_now_iso" '
2793
+ def to_epoch(ts):
2794
+ (ts | split("T")) as $parts |
2795
+ ($parts[0] | split("-")) as $d |
2796
+ ($parts[1] | rtrimstr("Z") | split(":")) as $t |
2797
+ (($d[0] | tonumber) - 1970) * 365 * 86400 +
2798
+ (($d[1] | tonumber) - 1) * 30 * 86400 +
2799
+ (($d[2] | tonumber) - 1) * 86400 +
2800
+ ($t[0] | tonumber) * 3600 +
2801
+ ($t[1] | tonumber) * 60 +
2802
+ ($t[2] | rtrimstr("Z") | tonumber)
2803
+ end;
2804
+ to_epoch(.expires_at)
2805
+ ' 2>/dev/null || echo "0")
2806
+
2807
+ local_now_epoch=$(date +%s)
2808
+ local_remaining=$(( local_exp_epoch - local_now_epoch ))
2809
+
2810
+ if [[ "$local_remaining" -gt 0 ]]; then
2811
+ # Convert to hours/days for TTL
2812
+ if [[ "$local_remaining" -ge 86400 ]]; then
2813
+ local_sig_ttl="$(( local_remaining / 86400 ))d"
2814
+ else
2815
+ local_sig_ttl="$(( local_remaining / 3600 ))h"
2816
+ fi
2817
+ fi
2818
+ fi
2819
+
2820
+ _pheromone_write "$local_sig_type" "$local_sig_content" \
2821
+ --strength "$local_sig_strength" \
2822
+ --ttl "$local_sig_ttl" \
2823
+ --source "$local_sig_source" \
2824
+ --reason "Injected from $psi_from_branch branch (snapshot)" \
2825
+ >/dev/null 2>&1 || true
2826
+ done
2827
+
2828
+ psi_injected_count="$psi_inject_count"
2829
+ fi
2830
+
2831
+ # Write snapshot metadata
2832
+ psi_snapshot_file="$COLONY_DATA_DIR/pheromone-snapshot.json"
2833
+ psi_snapshot=$(jq -n \
2834
+ --arg schema "pheromone-snapshot-v1" \
2835
+ --arg branch "$psi_from_branch" \
2836
+ --arg commit "$psi_from_commit" \
2837
+ --arg at "$psi_now_iso" \
2838
+ --argjson injected "$psi_injected_details" \
2839
+ --argjson skipped "$psi_skip_reasons" \
2840
+ --argjson injected_count "$psi_injected_count" \
2841
+ --argjson skipped_count "$psi_skip_count" \
2842
+ '{
2843
+ schema: $schema,
2844
+ snapshot_from_branch: $branch,
2845
+ snapshot_from_commit: $commit,
2846
+ snapshot_at: $at,
2847
+ injected: $injected,
2848
+ skipped: $skipped,
2849
+ injected_count: $injected_count,
2850
+ skipped_count: $skipped_count
2851
+ }')
2852
+
2853
+ atomic_write "$psi_snapshot_file" "$psi_snapshot" 2>/dev/null || {
2854
+ _aether_log_error "Could not write pheromone snapshot metadata"
2855
+ }
2856
+
2857
+ json_ok "$(jq -n \
2858
+ --arg branch "$psi_from_branch" \
2859
+ --arg commit "$psi_from_commit" \
2860
+ --argjson injected_count "$psi_injected_count" \
2861
+ --argjson skipped_count "$psi_skip_count" \
2862
+ '{
2863
+ snapshot_from_branch: $branch,
2864
+ snapshot_from_commit: $commit,
2865
+ injected_count: $injected_count,
2866
+ skipped_count: $skipped_count
2867
+ }')"
2868
+ }
2869
+
2870
+ # ============================================================================
2871
+ # _pheromone_export_branch
2872
+ # Export branch signals for merge-back (pre-merge step)
2873
+ # ============================================================================
2874
+ _pheromone_export_branch() {
2875
+ # Export branch's eligible signals for merge-back
2876
+ # Usage: pheromone-export-branch
2877
+ # Returns: JSON with eligible_count, ineligible_count, total_signals
2878
+ # Side effect: writes .aether/exchange/pheromone-branch-export.json
2879
+
2880
+ peb_file="$COLONY_DATA_DIR/pheromones.json"
2881
+
2882
+ if [[ ! -f "$peb_file" ]]; then
2883
+ json_err "$E_FILE_NOT_FOUND" "pheromones.json not found. No signals to export."
2884
+ fi
2885
+
2886
+ peb_now_iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
2887
+
2888
+ # Get current branch name and commit
2889
+ peb_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
2890
+ peb_commit=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
2891
+
2892
+ # Read all active signals and determine eligibility
2893
+ peb_all_signals=$(jq -c --arg now "$peb_now_iso" '
2894
+ def to_epoch(ts):
2895
+ if ts == null or ts == "" or ts == "phase_end" then null
2896
+ else
2897
+ (ts | split("T")) as $parts |
2898
+ ($parts[0] | split("-")) as $d |
2899
+ ($parts[1] | rtrimstr("Z") | split(":")) as $t |
2900
+ (($d[0] | tonumber) - 1970) * 365 * 86400 +
2901
+ (($d[1] | tonumber) - 1) * 30 * 86400 +
2902
+ (($d[2] | tonumber) - 1) * 86400 +
2903
+ ($t[0] | tonumber) * 3600 +
2904
+ ($t[1] | tonumber) * 60 +
2905
+ ($t[2] | rtrimstr("Z") | tonumber)
2906
+ end;
2907
+
2908
+ (to_epoch($now)) as $now_epoch |
2909
+
2910
+ .signals | map(select(.active == true)) |
2911
+ map(
2912
+ (to_epoch(.expires_at)) as $exp_epoch |
2913
+ select(if $exp_epoch != null then $exp_epoch > $now_epoch else true end) |
2914
+ . + {
2915
+ # Eligibility rules:
2916
+ # REDIRECT from non-user sources: YES (new constraint)
2917
+ # FEEDBACK from non-user sources with reinforcement >= 2: YES
2918
+ # Everything else: NO
2919
+ eligible_for_merge: (
2920
+ if .type == "REDIRECT" and .source != "user" then true
2921
+ elif .type == "FEEDBACK" and .source != "user" and ((.reinforcement_count // 0) >= 2) then true
2922
+ elif .type == "REDIRECT" and .source == "system" then true
2923
+ else false
2924
+ end
2925
+ ),
2926
+ merge_reason: (
2927
+ if .type == "REDIRECT" and .source != "user" then "new \(.source) REDIRECT discovered on branch"
2928
+ elif .type == "FEEDBACK" and .source != "user" and ((.reinforcement_count // 0) >= 2) then "FEEDBACK with reinforcement_count >= 2"
2929
+ elif .type == "FOCUS" then "\(.source)-sourced FOCUS excluded from merge-back"
2930
+ elif .type == "REDIRECT" and .source == "user" then "user signal already on main"
2931
+ elif .type == "FEEDBACK" and .source == "user" then "user signal already on main"
2932
+ elif .type == "FEEDBACK" and ((.reinforcement_count // 0) < 2) then "FEEDBACK reinforcement < 2"
2933
+ else "signal type \(.type) from \(.source) excluded"
2934
+ end
2935
+ )
2936
+ }
2937
+ )
2938
+ ' "$peb_file" 2>/dev/null || echo "[]")
2939
+
2940
+ peb_total=$(echo "$peb_all_signals" | jq 'length' 2>/dev/null || echo "0")
2941
+ peb_eligible=$(echo "$peb_all_signals" | jq '[.[] | select(.eligible_for_merge == true)]' 2>/dev/null || echo "[]")
2942
+ peb_ineligible=$(echo "$peb_all_signals" | jq '[.[] | select(.eligible_for_merge == false)]' 2>/dev/null || echo "[]")
2943
+ peb_eligible_count=$(echo "$peb_eligible" | jq 'length' 2>/dev/null || echo "0")
2944
+ peb_ineligible_count=$(echo "$peb_ineligible" | jq 'length' 2>/dev/null || echo "0")
2945
+
2946
+ # Build export signals array with only needed fields
2947
+ peb_export_signals=$(echo "$peb_all_signals" | jq -c '[
2948
+ .[] | {
2949
+ id: .id,
2950
+ type: .type,
2951
+ source: .source,
2952
+ content_hash: .content_hash,
2953
+ content_text: (.content.text // .content // ""),
2954
+ strength: .strength,
2955
+ created_at: .created_at,
2956
+ expires_at: .expires_at,
2957
+ reinforcement_count: (.reinforcement_count // 0),
2958
+ eligible_for_merge: .eligible_for_merge,
2959
+ merge_reason: .merge_reason
2960
+ }
2961
+ ]')
2962
+
2963
+ # Write export file
2964
+ peb_export=$(jq -n \
2965
+ --arg schema "pheromone-branch-export-v1" \
2966
+ --arg at "$peb_now_iso" \
2967
+ --arg branch "$peb_branch" \
2968
+ --arg commit "$peb_commit" \
2969
+ --argjson signals "$peb_export_signals" \
2970
+ --argjson total "$peb_total" \
2971
+ --argjson eligible "$peb_eligible_count" \
2972
+ --argjson ineligible "$peb_ineligible_count" \
2973
+ '{
2974
+ schema: $schema,
2975
+ exported_at: $at,
2976
+ branch_name: $branch,
2977
+ branch_commit: $commit,
2978
+ signals: $signals,
2979
+ total_signals: $total,
2980
+ eligible_count: $eligible,
2981
+ ineligible_count: $ineligible
2982
+ }')
2983
+
2984
+ peb_export_dir="$AETHER_ROOT/.aether/exchange"
2985
+ mkdir -p "$peb_export_dir" 2>/dev/null || true
2986
+ peb_export_file="$peb_export_dir/pheromone-branch-export.json"
2987
+ atomic_write "$peb_export_file" "$peb_export" 2>/dev/null || {
2988
+ _aether_log_error "Could not write pheromone branch export"
2989
+ }
2990
+
2991
+ json_ok "$(jq -n \
2992
+ --arg branch "$peb_branch" \
2993
+ --arg commit "$peb_commit" \
2994
+ --argjson total "$peb_total" \
2995
+ --argjson eligible "$peb_eligible_count" \
2996
+ --argjson ineligible "$peb_ineligible_count" \
2997
+ '{
2998
+ branch_name: $branch,
2999
+ branch_commit: $commit,
3000
+ total_signals: $total,
3001
+ eligible_count: $eligible,
3002
+ ineligible_count: $ineligible
3003
+ }')"
3004
+ }
3005
+
3006
+ # ============================================================================
3007
+ # _pheromone_merge_back
3008
+ # Merge branch signals into main (post-merge step)
3009
+ # ============================================================================
3010
+ _pheromone_merge_back() {
3011
+ # Merge eligible branch signals into main's pheromones.json
3012
+ # Usage: pheromone-merge-back [--export-file PATH]
3013
+ # --export-file: path to branch export JSON (default: .aether/exchange/pheromone-branch-export.json)
3014
+ # Returns: JSON with new_signals_written, skipped_count, conflicts_resolved
3015
+ # Side effect: appends to .aether/data/pheromone-merge-log.json
3016
+
3017
+ pmb_export_file="${1:-}"
3018
+ pmb_branch=""
3019
+
3020
+ # Parse args
3021
+ while [[ $# -gt 0 ]]; do
3022
+ case "$1" in
3023
+ --export-file) shift; pmb_export_file="${1:-}" ;;
3024
+ *) shift ;;
3025
+ esac
3026
+ done
3027
+
3028
+ # Default export file path (exchange/ is git-tracked for cross-branch propagation)
3029
+ if [[ -z "$pmb_export_file" ]]; then
3030
+ pmb_export_file="$AETHER_ROOT/.aether/exchange/pheromone-branch-export.json"
3031
+ fi
3032
+
3033
+ # Edge case: no export file -- no-op
3034
+ if [[ ! -f "$pmb_export_file" ]]; then
3035
+ json_ok "$(jq -n '{new_signals_written: 0, skipped_count: 0, conflicts_resolved: [], warnings: []}')"
3036
+ return 0
3037
+ fi
3038
+
3039
+ # Validate export schema
3040
+ pmb_schema=$(jq -r '.schema // ""' "$pmb_export_file" 2>/dev/null || echo "")
3041
+ if [[ "$pmb_schema" != "pheromone-branch-export-v1" ]]; then
3042
+ json_err "$E_VALIDATION_FAILED" "Invalid export file schema: expected pheromone-branch-export-v1"
3043
+ fi
3044
+
3045
+ pmb_branch=$(jq -r '.branch_name // "unknown"' "$pmb_export_file" 2>/dev/null || echo "unknown")
3046
+ pmb_branch_commit=$(jq -r '.branch_commit // "unknown"' "$pmb_export_file" 2>/dev/null || echo "unknown")
3047
+ pmb_now_iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3048
+
3049
+ pmb_main_file="$COLONY_DATA_DIR/pheromones.json"
3050
+
3051
+ # Initialize main pheromones.json if missing
3052
+ if [[ ! -f "$pmb_main_file" ]]; then
3053
+ pmb_init_content='{"version":"1.0.0","colony_id":"aether-dev","generated_at":"'"$pmb_now_iso"'","signals":[]}'
3054
+ atomic_write "$pmb_main_file" "$pmb_init_content" 2>/dev/null || true
3055
+ fi
3056
+
3057
+ # Get eligible signals from export
3058
+ pmb_eligible=$(jq -c '[.signals[] | select(.eligible_for_merge == true)]' "$pmb_export_file" 2>/dev/null || echo "[]")
3059
+ pmb_ineligible_count=$(jq '[.signals[] | select(.eligible_for_merge == false)] | length' "$pmb_export_file" 2>/dev/null || echo "0")
3060
+
3061
+ # Lock main pheromones.json for writing
3062
+ pmb_lock_held=false
3063
+ if type acquire_lock &>/dev/null; then
3064
+ acquire_lock "$pmb_main_file" 2>/dev/null && pmb_lock_held=true || true
3065
+ fi
3066
+
3067
+ pmb_new_signals="[]"
3068
+ pmb_conflicts="[]"
3069
+ pmb_warnings="[]"
3070
+ pmb_new_count=0
3071
+
3072
+ pmb_eligible_count=$(echo "$pmb_eligible" | jq 'length')
3073
+
3074
+ if [[ "$pmb_eligible_count" -gt 0 ]]; then
3075
+ # Read main signals for conflict detection
3076
+ pmb_main_hashes=$(jq -c '[.signals[] | select(.active == true) | {type: .type, content_hash: .content_hash, id: .id, strength: .strength, source: .source, reinforcement_count: (.reinforcement_count // 0), expires_at: .expires_at}]' "$pmb_main_file" 2>/dev/null || echo "[]")
3077
+
3078
+ # Process each eligible signal
3079
+ pmb_new_signals="[]"
3080
+ pmb_conflicts="[]"
3081
+
3082
+ while IFS= read -r sig; do
3083
+ [[ -z "$sig" || "$sig" == "null" ]] && continue
3084
+
3085
+ sig_type=$(echo "$sig" | jq -r '.type')
3086
+ sig_hash=$(echo "$sig" | jq -r '.content_hash')
3087
+ sig_text=$(echo "$sig" | jq -r '.content_text')
3088
+ sig_strength=$(echo "$sig" | jq -r '.strength')
3089
+ sig_source=$(echo "$sig" | jq -r '.source')
3090
+ sig_reinforcement=$(echo "$sig" | jq -r '.reinforcement_count')
3091
+ sig_expires=$(echo "$sig" | jq -r '.expires_at')
3092
+ sig_id=$(echo "$sig" | jq -r '.id')
3093
+
3094
+ # Check for conflict: main has same type + content_hash
3095
+ main_match=$(echo "$pmb_main_hashes" | jq -c --arg type "$sig_type" --arg hash "$sig_hash" \
3096
+ '[.[] | select(.type == $type and .content_hash == $hash)][0]' 2>/dev/null || echo "null")
3097
+
3098
+ if [[ -n "$main_match" && "$main_match" != "null" ]]; then
3099
+ # Conflict detected -- resolve
3100
+ main_strength=$(echo "$main_match" | jq -r '.strength')
3101
+ main_source=$(echo "$main_match" | jq -r '.source')
3102
+ main_reinforcement=$(echo "$main_match" | jq -r '.reinforcement_count')
3103
+ main_id=$(echo "$main_match" | jq -r '.id')
3104
+
3105
+ # Resolution logic per design spec
3106
+ resolution="skip"
3107
+
3108
+ if [[ "$sig_type" == "REDIRECT" ]]; then
3109
+ resolution="reinforced"
3110
+ elif [[ "$main_source" == "user" && "$sig_type" == "FOCUS" ]]; then
3111
+ resolution="skip"
3112
+ elif [[ "$sig_type" == "FEEDBACK" ]]; then
3113
+ if [[ "$sig_reinforcement" -ge 2 ]]; then
3114
+ resolution="reinforced"
3115
+ else
3116
+ resolution="skip"
3117
+ fi
3118
+ fi
3119
+
3120
+ if [[ "$resolution" == "reinforced" ]]; then
3121
+ # Reinforce: update main signal with max strength, increment reinforcement
3122
+ new_strength=$(echo "$main_strength $sig_strength" | awk '{if ($1 > $2) print $1; else print $2}')
3123
+ new_reinforcement=$(( main_reinforcement + 1 ))
3124
+
3125
+ # Update the signal in main's pheromones.json
3126
+ pmb_updated=$(jq \
3127
+ --arg id "$main_id" \
3128
+ --argjson new_strength "$new_strength" \
3129
+ --argjson new_reinforcement "$new_reinforcement" \
3130
+ --arg now "$pmb_now_iso" \
3131
+ '
3132
+ .signals = [.signals[] |
3133
+ if .id == $id then
3134
+ .strength = ([.strength, $new_strength] | max) |
3135
+ .reinforcement_count = $new_reinforcement |
3136
+ .created_at = $now
3137
+ else .
3138
+ end
3139
+ ]
3140
+ ' "$pmb_main_file" 2>/dev/null)
3141
+
3142
+ if [[ -n "$pmb_updated" && "$pmb_updated" != "null" ]]; then
3143
+ atomic_write "$pmb_main_file" "$pmb_updated" 2>/dev/null || true
3144
+ fi
3145
+
3146
+ pmb_conflicts=$(echo "$pmb_conflicts" | jq -c --arg hash "$sig_hash" --arg type "$sig_type" \
3147
+ --argjson main_s "$main_strength" --argjson branch_s "$sig_strength" \
3148
+ --argjson new_s "$new_strength" --argjson new_r "$new_reinforcement" \
3149
+ '. += [{
3150
+ content_hash: $hash,
3151
+ type: $type,
3152
+ main_strength: $main_s,
3153
+ branch_strength: $branch_s,
3154
+ resolution: "reinforced",
3155
+ new_strength: $new_s,
3156
+ new_reinforcement_count: $new_r
3157
+ }]')
3158
+ else
3159
+ pmb_conflicts=$(echo "$pmb_conflicts" | jq -c --arg hash "$sig_hash" --arg type "$sig_type" \
3160
+ --argjson main_s "$main_strength" --argjson branch_s "$sig_strength" \
3161
+ '. += [{
3162
+ content_hash: $hash,
3163
+ type: $type,
3164
+ main_strength: $main_s,
3165
+ branch_strength: $branch_s,
3166
+ resolution: "skip"
3167
+ }]')
3168
+ fi
3169
+ else
3170
+ # No conflict -- write new signal to main directly (we already hold the lock,
3171
+ # so calling _pheromone_write would deadlock on re-acquiring it)
3172
+ pmb_new_epoch=$(date +%s)
3173
+ pmb_new_rand=$(( RANDOM % 10000 ))
3174
+ pmb_new_type_lower=$(echo "$sig_type" | tr '[:upper:]' '[:lower:]')
3175
+ pmb_new_id="sig_${pmb_new_type_lower}_${pmb_new_epoch}_${pmb_new_rand}"
3176
+ pmb_new_created="$pmb_now_iso"
3177
+
3178
+ case "$sig_type" in
3179
+ REDIRECT) pmb_new_priority="high" ;;
3180
+ FOCUS) pmb_new_priority="normal" ;;
3181
+ FEEDBACK) pmb_new_priority="low" ;;
3182
+ esac
3183
+
3184
+ pmb_new_signal=$(jq -n \
3185
+ --arg id "$pmb_new_id" \
3186
+ --arg type "$sig_type" \
3187
+ --arg priority "$pmb_new_priority" \
3188
+ --arg source "$sig_source" \
3189
+ --arg created_at "$pmb_new_created" \
3190
+ --arg expires_at "phase_end" \
3191
+ --argjson active true \
3192
+ --argjson strength "$sig_strength" \
3193
+ --arg reason "Merged from branch $pmb_branch" \
3194
+ --arg content "$sig_text" \
3195
+ --arg content_hash "$sig_hash" \
3196
+ --argjson reinforcement_count 0 \
3197
+ '{id: $id, type: $type, priority: $priority, source: $source, created_at: $created_at, expires_at: $expires_at, active: $active, strength: ($strength | tonumber), reason: $reason, content: {text: $content}, content_hash: $content_hash, reinforcement_count: $reinforcement_count}')
3198
+
3199
+ pmb_updated_main=$(jq --argjson sig "$pmb_new_signal" '.signals += [$sig]' "$pmb_main_file" 2>/dev/null)
3200
+ if [[ -n "$pmb_updated_main" && "$pmb_updated_main" != "null" ]]; then
3201
+ atomic_write "$pmb_main_file" "$pmb_updated_main" 2>/dev/null || true
3202
+ fi
3203
+
3204
+ pmb_new_signals=$(echo "$pmb_new_signals" | jq -c --arg id "$sig_id" --arg new_id "$pmb_new_id" --arg type "$sig_type" --arg hash "$sig_hash" \
3205
+ '. += [{original_id: $id, new_id: $new_id, type: $type, content_hash: $hash}]')
3206
+ pmb_new_count=$(( pmb_new_count + 1 ))
3207
+ fi
3208
+ done < <(echo "$pmb_eligible" | jq -c '.[]')
3209
+ fi
3210
+
3211
+ # Release lock
3212
+ [[ "$pmb_lock_held" == "true" ]] && release_lock 2>/dev/null || true
3213
+
3214
+ # Append to merge log
3215
+ pmb_log_file="$COLONY_DATA_DIR/pheromone-merge-log.json"
3216
+ pmb_entries="[]"
3217
+ if [[ -f "$pmb_log_file" ]]; then
3218
+ pmb_entries=$(jq -c '.entries // []' "$pmb_log_file" 2>/dev/null || echo "[]")
3219
+ fi
3220
+
3221
+ pmb_new_entry=$(jq -n \
3222
+ --arg branch "$pmb_branch" \
3223
+ --arg commit "$pmb_branch_commit" \
3224
+ --arg at "$pmb_now_iso" \
3225
+ --argjson new_signals "$pmb_new_signals" \
3226
+ --argjson conflicts "$pmb_conflicts" \
3227
+ --argjson warnings "$pmb_warnings" \
3228
+ --argjson skipped "$pmb_ineligible_count" \
3229
+ '{
3230
+ merged_from_branch: $branch,
3231
+ merged_from_commit: $commit,
3232
+ merged_at: $at,
3233
+ new_signals_written: $new_signals,
3234
+ conflicts_resolved: $conflicts,
3235
+ warnings: $warnings,
3236
+ skipped_count: $skipped
3237
+ }')
3238
+
3239
+ pmb_updated_log=$(jq -n --arg schema "pheromone-merge-log-v1" --argjson entries "$pmb_entries" --argjson new_entry "$pmb_new_entry" \
3240
+ '{schema: $schema, entries: ($entries + [$new_entry])}')
3241
+
3242
+ atomic_write "$pmb_log_file" "$pmb_updated_log" 2>/dev/null || {
3243
+ _aether_log_error "Could not write pheromone merge log"
3244
+ }
3245
+
3246
+ json_ok "$(jq -n \
3247
+ --arg branch "$pmb_branch" \
3248
+ --argjson new_count "$pmb_new_count" \
3249
+ --argjson skipped "$pmb_ineligible_count" \
3250
+ --argjson conflicts "$pmb_conflicts" \
3251
+ '{
3252
+ merged_from_branch: $branch,
3253
+ new_signals_written: $new_count,
3254
+ skipped_count: $skipped,
3255
+ conflicts_resolved: $conflicts
3256
+ }')"
3257
+ }
3258
+
3259
+ # ============================================================================
3260
+ # _pheromone_merge_log
3261
+ # Read merge log entries for debugging/auditing
3262
+ # ============================================================================
3263
+ _pheromone_merge_log() {
3264
+ # Read pheromone merge log entries
3265
+ # Usage: pheromone-merge-log [--last N]
3266
+ # --last N: only return the last N entries (default: all)
3267
+ # Returns: JSON with entries array
3268
+
3269
+ pml_last=""
3270
+ while [[ $# -gt 0 ]]; do
3271
+ case "$1" in
3272
+ --last) shift; pml_last="${1:-}" ;;
3273
+ *) shift ;;
3274
+ esac
3275
+ done
3276
+
3277
+ pml_log_file="$COLONY_DATA_DIR/pheromone-merge-log.json"
3278
+
3279
+ if [[ ! -f "$pml_log_file" ]]; then
3280
+ json_ok "$(jq -n '{schema: "pheromone-merge-log-v1", entries_count: 0, entries: []}')"
3281
+ return 0
3282
+ fi
3283
+
3284
+ pml_entries=$(jq -c '.entries // []' "$pml_log_file" 2>/dev/null || echo "[]")
3285
+
3286
+ if [[ -n "$pml_last" && "$pml_last" =~ ^[0-9]+$ ]]; then
3287
+ pml_entries=$(echo "$pml_entries" | jq -c --argjson n "$pml_last" '.[(-$n):]')
3288
+ fi
3289
+
3290
+ pml_count=$(echo "$pml_entries" | jq 'length' 2>/dev/null || echo "0")
3291
+
3292
+ json_ok "$(jq -n \
3293
+ --argjson count "$pml_count" \
3294
+ --argjson entries "$pml_entries" \
3295
+ '{entries_count: $count, entries: $entries}')"
3296
+ }
3297
+