aether-colony 5.3.1 → 5.3.3

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 (165) hide show
  1. package/.aether/aether-utils.sh +181 -5
  2. package/.aether/commands/build.yaml +35 -0
  3. package/.aether/commands/entomb.yaml +1 -1
  4. package/.aether/commands/init.yaml +29 -12
  5. package/.aether/commands/oracle.yaml +70 -0
  6. package/.aether/commands/patrol.yaml +2 -2
  7. package/.aether/commands/run.yaml +3 -3
  8. package/.aether/commands/swarm.yaml +1 -1
  9. package/.aether/docs/command-playbooks/build-complete.md +41 -8
  10. package/.aether/docs/command-playbooks/build-full.md +7 -7
  11. package/.aether/docs/command-playbooks/build-prep.md +1 -1
  12. package/.aether/docs/command-playbooks/continue-advance.md +33 -0
  13. package/.aether/docs/command-playbooks/continue-finalize.md +15 -1
  14. package/.aether/docs/command-playbooks/continue-full.md +15 -1
  15. package/.aether/docs/source-of-truth-map.md +10 -10
  16. package/.aether/docs/structural-learning-stack.md +283 -0
  17. package/.aether/utils/consolidation-seal.sh +196 -0
  18. package/.aether/utils/consolidation.sh +127 -0
  19. package/.aether/utils/curation-ants/archivist.sh +97 -0
  20. package/.aether/utils/curation-ants/critic.sh +214 -0
  21. package/.aether/utils/curation-ants/herald.sh +102 -0
  22. package/.aether/utils/curation-ants/janitor.sh +121 -0
  23. package/.aether/utils/curation-ants/librarian.sh +99 -0
  24. package/.aether/utils/curation-ants/nurse.sh +153 -0
  25. package/.aether/utils/curation-ants/orchestrator.sh +181 -0
  26. package/.aether/utils/curation-ants/scribe.sh +164 -0
  27. package/.aether/utils/curation-ants/sentinel.sh +119 -0
  28. package/.aether/utils/event-bus.sh +301 -0
  29. package/.aether/utils/graph.sh +559 -0
  30. package/.aether/utils/instinct-store.sh +401 -0
  31. package/.aether/utils/learning.sh +79 -7
  32. package/.aether/utils/session.sh +13 -0
  33. package/.aether/utils/state-api.sh +1 -1
  34. package/.aether/utils/trust-scoring.sh +347 -0
  35. package/.aether/utils/worktree.sh +97 -0
  36. package/.claude/commands/ant/entomb.md +1 -1
  37. package/.claude/commands/ant/init.md +29 -12
  38. package/.claude/commands/ant/oracle.md +35 -0
  39. package/.claude/commands/ant/patrol.md +2 -2
  40. package/.claude/commands/ant/run.md +3 -3
  41. package/.claude/commands/ant/swarm.md +1 -1
  42. package/.opencode/commands/ant/build.md +35 -0
  43. package/.opencode/commands/ant/init.md +29 -12
  44. package/.opencode/commands/ant/oracle.md +35 -0
  45. package/.opencode/commands/ant/patrol.md +2 -2
  46. package/.opencode/commands/ant/run.md +3 -3
  47. package/CHANGELOG.md +83 -0
  48. package/README.md +34 -37
  49. package/bin/lib/update-transaction.js +8 -3
  50. package/bin/npx-entry.js +0 -0
  51. package/package.json +1 -1
  52. package/.aether/agents/aether-ambassador.md +0 -140
  53. package/.aether/agents/aether-archaeologist.md +0 -108
  54. package/.aether/agents/aether-architect.md +0 -133
  55. package/.aether/agents/aether-auditor.md +0 -144
  56. package/.aether/agents/aether-builder.md +0 -184
  57. package/.aether/agents/aether-chaos.md +0 -115
  58. package/.aether/agents/aether-chronicler.md +0 -122
  59. package/.aether/agents/aether-gatekeeper.md +0 -116
  60. package/.aether/agents/aether-includer.md +0 -117
  61. package/.aether/agents/aether-keeper.md +0 -177
  62. package/.aether/agents/aether-measurer.md +0 -128
  63. package/.aether/agents/aether-oracle.md +0 -137
  64. package/.aether/agents/aether-probe.md +0 -133
  65. package/.aether/agents/aether-queen.md +0 -286
  66. package/.aether/agents/aether-route-setter.md +0 -130
  67. package/.aether/agents/aether-sage.md +0 -106
  68. package/.aether/agents/aether-scout.md +0 -101
  69. package/.aether/agents/aether-surveyor-disciplines.md +0 -391
  70. package/.aether/agents/aether-surveyor-nest.md +0 -329
  71. package/.aether/agents/aether-surveyor-pathogens.md +0 -264
  72. package/.aether/agents/aether-surveyor-provisions.md +0 -334
  73. package/.aether/agents/aether-tracker.md +0 -137
  74. package/.aether/agents/aether-watcher.md +0 -174
  75. package/.aether/agents/aether-weaver.md +0 -130
  76. package/.aether/commands/claude/archaeology.md +0 -334
  77. package/.aether/commands/claude/build.md +0 -65
  78. package/.aether/commands/claude/chaos.md +0 -336
  79. package/.aether/commands/claude/colonize.md +0 -259
  80. package/.aether/commands/claude/continue.md +0 -60
  81. package/.aether/commands/claude/council.md +0 -507
  82. package/.aether/commands/claude/data-clean.md +0 -81
  83. package/.aether/commands/claude/dream.md +0 -268
  84. package/.aether/commands/claude/entomb.md +0 -498
  85. package/.aether/commands/claude/export-signals.md +0 -57
  86. package/.aether/commands/claude/feedback.md +0 -96
  87. package/.aether/commands/claude/flag.md +0 -151
  88. package/.aether/commands/claude/flags.md +0 -169
  89. package/.aether/commands/claude/focus.md +0 -76
  90. package/.aether/commands/claude/help.md +0 -154
  91. package/.aether/commands/claude/history.md +0 -140
  92. package/.aether/commands/claude/import-signals.md +0 -71
  93. package/.aether/commands/claude/init.md +0 -505
  94. package/.aether/commands/claude/insert-phase.md +0 -105
  95. package/.aether/commands/claude/interpret.md +0 -278
  96. package/.aether/commands/claude/lay-eggs.md +0 -210
  97. package/.aether/commands/claude/maturity.md +0 -113
  98. package/.aether/commands/claude/memory-details.md +0 -77
  99. package/.aether/commands/claude/migrate-state.md +0 -171
  100. package/.aether/commands/claude/oracle.md +0 -642
  101. package/.aether/commands/claude/organize.md +0 -232
  102. package/.aether/commands/claude/patrol.md +0 -620
  103. package/.aether/commands/claude/pause-colony.md +0 -233
  104. package/.aether/commands/claude/phase.md +0 -115
  105. package/.aether/commands/claude/pheromones.md +0 -156
  106. package/.aether/commands/claude/plan.md +0 -693
  107. package/.aether/commands/claude/preferences.md +0 -65
  108. package/.aether/commands/claude/quick.md +0 -100
  109. package/.aether/commands/claude/redirect.md +0 -76
  110. package/.aether/commands/claude/resume-colony.md +0 -197
  111. package/.aether/commands/claude/resume.md +0 -388
  112. package/.aether/commands/claude/run.md +0 -231
  113. package/.aether/commands/claude/seal.md +0 -774
  114. package/.aether/commands/claude/skill-create.md +0 -286
  115. package/.aether/commands/claude/status.md +0 -410
  116. package/.aether/commands/claude/swarm.md +0 -349
  117. package/.aether/commands/claude/tunnels.md +0 -426
  118. package/.aether/commands/claude/update.md +0 -132
  119. package/.aether/commands/claude/verify-castes.md +0 -143
  120. package/.aether/commands/claude/watch.md +0 -239
  121. package/.aether/commands/opencode/archaeology.md +0 -331
  122. package/.aether/commands/opencode/build.md +0 -1168
  123. package/.aether/commands/opencode/chaos.md +0 -329
  124. package/.aether/commands/opencode/colonize.md +0 -195
  125. package/.aether/commands/opencode/continue.md +0 -1436
  126. package/.aether/commands/opencode/council.md +0 -437
  127. package/.aether/commands/opencode/data-clean.md +0 -77
  128. package/.aether/commands/opencode/dream.md +0 -260
  129. package/.aether/commands/opencode/entomb.md +0 -377
  130. package/.aether/commands/opencode/export-signals.md +0 -54
  131. package/.aether/commands/opencode/feedback.md +0 -99
  132. package/.aether/commands/opencode/flag.md +0 -149
  133. package/.aether/commands/opencode/flags.md +0 -167
  134. package/.aether/commands/opencode/focus.md +0 -73
  135. package/.aether/commands/opencode/help.md +0 -157
  136. package/.aether/commands/opencode/history.md +0 -136
  137. package/.aether/commands/opencode/import-signals.md +0 -68
  138. package/.aether/commands/opencode/init.md +0 -518
  139. package/.aether/commands/opencode/insert-phase.md +0 -111
  140. package/.aether/commands/opencode/interpret.md +0 -272
  141. package/.aether/commands/opencode/lay-eggs.md +0 -213
  142. package/.aether/commands/opencode/maturity.md +0 -108
  143. package/.aether/commands/opencode/memory-details.md +0 -83
  144. package/.aether/commands/opencode/migrate-state.md +0 -165
  145. package/.aether/commands/opencode/oracle.md +0 -593
  146. package/.aether/commands/opencode/organize.md +0 -226
  147. package/.aether/commands/opencode/patrol.md +0 -626
  148. package/.aether/commands/opencode/pause-colony.md +0 -203
  149. package/.aether/commands/opencode/phase.md +0 -113
  150. package/.aether/commands/opencode/pheromones.md +0 -162
  151. package/.aether/commands/opencode/plan.md +0 -684
  152. package/.aether/commands/opencode/preferences.md +0 -71
  153. package/.aether/commands/opencode/quick.md +0 -91
  154. package/.aether/commands/opencode/redirect.md +0 -84
  155. package/.aether/commands/opencode/resume-colony.md +0 -190
  156. package/.aether/commands/opencode/resume.md +0 -394
  157. package/.aether/commands/opencode/run.md +0 -237
  158. package/.aether/commands/opencode/seal.md +0 -452
  159. package/.aether/commands/opencode/skill-create.md +0 -63
  160. package/.aether/commands/opencode/status.md +0 -307
  161. package/.aether/commands/opencode/swarm.md +0 -15
  162. package/.aether/commands/opencode/tunnels.md +0 -400
  163. package/.aether/commands/opencode/update.md +0 -127
  164. package/.aether/commands/opencode/verify-castes.md +0 -139
  165. package/.aether/commands/opencode/watch.md +0 -227
@@ -0,0 +1,559 @@
1
+ #!/bin/bash
2
+ # Graph traversal layer for instinct relationships — Aether Structural Learning Stack
3
+ # Provides: _graph_link, _graph_neighbors, _graph_reach, _graph_cluster
4
+ #
5
+ # These functions are sourced by aether-utils.sh at startup.
6
+ # All shared infrastructure (json_ok, json_err, atomic_write,
7
+ # COLONY_DATA_DIR, SCRIPT_DIR, error constants) is available.
8
+ #
9
+ # Graph is stored as JSON at $COLONY_DATA_DIR/instinct-graph.json:
10
+ # {
11
+ # "version": "1.0",
12
+ # "edges": [
13
+ # { "source": "id", "target": "id", "relationship": "type",
14
+ # "weight": 0.5, "created_at": "ISO8601" }
15
+ # ]
16
+ # }
17
+ #
18
+ # Relationship types: reinforces, contradicts, extends, supersedes, related
19
+
20
+ # ============================================================================
21
+ # _graph_init_file
22
+ # Ensure the graph file exists with an empty structure.
23
+ # Internal helper.
24
+ # ============================================================================
25
+ _graph_init_file() {
26
+ local graph_file="$1"
27
+ if [[ ! -f "$graph_file" ]]; then
28
+ local dir
29
+ dir="$(dirname "$graph_file")"
30
+ mkdir -p "$dir"
31
+ atomic_write "$graph_file" '{"version":"1.0","edges":[]}'
32
+ fi
33
+ }
34
+
35
+ # ============================================================================
36
+ # _graph_link
37
+ # Create a directed edge between two instinct IDs. If the same
38
+ # source+target+relationship already exists, update the weight instead.
39
+ #
40
+ # Usage: graph-link --source <id> --target <id> --relationship <type>
41
+ # [--weight <float>]
42
+ #
43
+ # Relationship types: reinforces, contradicts, extends, supersedes, related
44
+ # Default weight: 0.5
45
+ #
46
+ # Output: {edge_id, source, target, relationship, weight, action}
47
+ # ============================================================================
48
+ _graph_link() {
49
+ local source_id=""
50
+ local target_id=""
51
+ local relationship=""
52
+ local weight="0.5"
53
+
54
+ while [[ $# -gt 0 ]]; do
55
+ case "$1" in
56
+ --source)
57
+ source_id="${2:-}"
58
+ shift 2
59
+ ;;
60
+ --target)
61
+ target_id="${2:-}"
62
+ shift 2
63
+ ;;
64
+ --relationship)
65
+ relationship="${2:-}"
66
+ shift 2
67
+ ;;
68
+ --weight)
69
+ weight="${2:-0.5}"
70
+ shift 2
71
+ ;;
72
+ *)
73
+ json_err "$E_VALIDATION_FAILED" "Usage: graph-link --source <id> --target <id> --relationship <type> [--weight <float>]"
74
+ return
75
+ ;;
76
+ esac
77
+ done
78
+
79
+ [[ -z "$source_id" || -z "$target_id" || -z "$relationship" ]] && \
80
+ json_err "$E_VALIDATION_FAILED" "Usage: graph-link --source <id> --target <id> --relationship <type> [--weight <float>]"
81
+
82
+ # Validate relationship type
83
+ case "$relationship" in
84
+ reinforces|contradicts|extends|supersedes|related) ;;
85
+ *)
86
+ json_err "$E_VALIDATION_FAILED" "Unknown relationship: $relationship. Valid: reinforces, contradicts, extends, supersedes, related"
87
+ return
88
+ ;;
89
+ esac
90
+
91
+ # Validate weight is a non-negative number
92
+ if ! [[ "$weight" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
93
+ json_err "$E_VALIDATION_FAILED" "--weight must be a non-negative number, got: $weight"
94
+ return
95
+ fi
96
+
97
+ local graph_file="$COLONY_DATA_DIR/instinct-graph.json"
98
+ _graph_init_file "$graph_file"
99
+
100
+ local ts
101
+ ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
102
+
103
+ # Check if a matching edge (same source+target+relationship) exists
104
+ local existing_edge_id
105
+ existing_edge_id=$(jq -r \
106
+ --arg src "$source_id" \
107
+ --arg tgt "$target_id" \
108
+ --arg rel "$relationship" \
109
+ '.edges[] | select(.source == $src and .target == $tgt and .relationship == $rel) | .edge_id' \
110
+ "$graph_file" | head -1)
111
+
112
+ local action
113
+ local updated
114
+ if [[ -n "$existing_edge_id" ]]; then
115
+ # Update existing edge weight
116
+ action="updated"
117
+ updated=$(jq \
118
+ --arg src "$source_id" \
119
+ --arg tgt "$target_id" \
120
+ --arg rel "$relationship" \
121
+ --argjson w "$weight" \
122
+ '.edges = [.edges[] | if (.source == $src and .target == $tgt and .relationship == $rel) then .weight = $w else . end]' \
123
+ "$graph_file")
124
+ local edge_id="$existing_edge_id"
125
+ else
126
+ # Create new edge
127
+ action="created"
128
+ local edge_id
129
+ edge_id="edge_$(date -u +%s)_$(head -c 2 /dev/urandom | od -An -tx1 | tr -d ' \n')"
130
+ updated=$(jq \
131
+ --arg eid "$edge_id" \
132
+ --arg src "$source_id" \
133
+ --arg tgt "$target_id" \
134
+ --arg rel "$relationship" \
135
+ --argjson w "$weight" \
136
+ --arg ts "$ts" \
137
+ '.edges += [{edge_id: $eid, source: $src, target: $tgt, relationship: $rel, weight: $w, created_at: $ts}]' \
138
+ "$graph_file")
139
+ fi
140
+
141
+ atomic_write "$graph_file" "$updated"
142
+
143
+ # Re-read the final edge_id for the output (handles both create and update paths)
144
+ local final_edge_id
145
+ final_edge_id=$(jq -r \
146
+ --arg src "$source_id" \
147
+ --arg tgt "$target_id" \
148
+ --arg rel "$relationship" \
149
+ '.edges[] | select(.source == $src and .target == $tgt and .relationship == $rel) | .edge_id' \
150
+ "$graph_file" | head -1)
151
+
152
+ json_ok "$(jq -n \
153
+ --arg edge_id "$final_edge_id" \
154
+ --arg source "$source_id" \
155
+ --arg target "$target_id" \
156
+ --arg relationship "$relationship" \
157
+ --argjson weight "$weight" \
158
+ --arg action "$action" \
159
+ '{
160
+ edge_id: $edge_id,
161
+ source: $source,
162
+ target: $target,
163
+ relationship: $relationship,
164
+ weight: $weight,
165
+ action: $action
166
+ }')"
167
+ }
168
+
169
+ # ============================================================================
170
+ # _graph_neighbors
171
+ # Find all nodes connected to a given instinct (1-hop).
172
+ #
173
+ # Usage: graph-neighbors --id <instinct_id> [--direction out|in|both]
174
+ # [--relationship <type>]
175
+ #
176
+ # Default direction: both
177
+ #
178
+ # Output: {neighbors: [{id, relationship, weight, direction}], count}
179
+ # ============================================================================
180
+ _graph_neighbors() {
181
+ local instinct_id=""
182
+ local direction="both"
183
+ local filter_rel=""
184
+
185
+ while [[ $# -gt 0 ]]; do
186
+ case "$1" in
187
+ --id)
188
+ instinct_id="${2:-}"
189
+ shift 2
190
+ ;;
191
+ --direction)
192
+ direction="${2:-both}"
193
+ shift 2
194
+ ;;
195
+ --relationship)
196
+ filter_rel="${2:-}"
197
+ shift 2
198
+ ;;
199
+ *)
200
+ json_err "$E_VALIDATION_FAILED" "Usage: graph-neighbors --id <instinct_id> [--direction out|in|both] [--relationship <type>]"
201
+ return
202
+ ;;
203
+ esac
204
+ done
205
+
206
+ [[ -z "$instinct_id" ]] && \
207
+ json_err "$E_VALIDATION_FAILED" "Usage: graph-neighbors --id <instinct_id> [--direction out|in|both] [--relationship <type>]"
208
+
209
+ case "$direction" in
210
+ out|in|both) ;;
211
+ *)
212
+ json_err "$E_VALIDATION_FAILED" "Invalid direction: $direction. Valid: out, in, both"
213
+ return
214
+ ;;
215
+ esac
216
+
217
+ local graph_file="$COLONY_DATA_DIR/instinct-graph.json"
218
+
219
+ # Return empty if graph file doesn't exist yet
220
+ if [[ ! -f "$graph_file" ]]; then
221
+ json_ok '{"neighbors":[],"count":0}'
222
+ return
223
+ fi
224
+
225
+ local neighbors
226
+ neighbors=$(jq -c \
227
+ --arg id "$instinct_id" \
228
+ --arg dir "$direction" \
229
+ --arg rel "$filter_rel" \
230
+ '
231
+ [
232
+ # Outbound edges: id is the source
233
+ if ($dir == "out" or $dir == "both") then
234
+ .edges[]
235
+ | select(.source == $id)
236
+ | select($rel == "" or .relationship == $rel)
237
+ | {id: .target, relationship: .relationship, weight: .weight, direction: "out"}
238
+ else empty end
239
+ ,
240
+ # Inbound edges: id is the target
241
+ if ($dir == "in" or $dir == "both") then
242
+ .edges[]
243
+ | select(.target == $id)
244
+ | select($rel == "" or .relationship == $rel)
245
+ | {id: .source, relationship: .relationship, weight: .weight, direction: "in"}
246
+ else empty end
247
+ ]
248
+ ' \
249
+ "$graph_file")
250
+
251
+ local count
252
+ count=$(echo "$neighbors" | jq 'length')
253
+
254
+ json_ok "$(jq -n \
255
+ --argjson neighbors "$neighbors" \
256
+ --argjson count "$count" \
257
+ '{neighbors: $neighbors, count: $count}')"
258
+ }
259
+
260
+ # ============================================================================
261
+ # _graph_reach
262
+ # Find all nodes reachable within N hops using iterative BFS.
263
+ # One jq call per hop level to avoid complex recursive expressions.
264
+ #
265
+ # Usage: graph-reach --id <instinct_id> --hops <N> [--min-weight <float>]
266
+ #
267
+ # Max hops enforced at 3 to prevent expensive traversals.
268
+ # Default min-weight: 0.0
269
+ #
270
+ # Output: {reachable: [{id, hop, path}], count, hops_searched}
271
+ # ============================================================================
272
+ _graph_reach() {
273
+ local instinct_id=""
274
+ local hops=""
275
+ local min_weight="0.0"
276
+
277
+ while [[ $# -gt 0 ]]; do
278
+ case "$1" in
279
+ --id)
280
+ instinct_id="${2:-}"
281
+ shift 2
282
+ ;;
283
+ --hops)
284
+ hops="${2:-}"
285
+ shift 2
286
+ ;;
287
+ --min-weight)
288
+ min_weight="${2:-0.0}"
289
+ shift 2
290
+ ;;
291
+ *)
292
+ json_err "$E_VALIDATION_FAILED" "Usage: graph-reach --id <instinct_id> --hops <N> [--min-weight <float>]"
293
+ return
294
+ ;;
295
+ esac
296
+ done
297
+
298
+ [[ -z "$instinct_id" || -z "$hops" ]] && \
299
+ json_err "$E_VALIDATION_FAILED" "Usage: graph-reach --id <instinct_id> --hops <N> [--min-weight <float>]"
300
+
301
+ # Validate hops is a positive integer
302
+ if ! [[ "$hops" =~ ^[0-9]+$ ]] || [[ "$hops" -lt 1 ]]; then
303
+ json_err "$E_VALIDATION_FAILED" "--hops must be a positive integer, got: $hops"
304
+ return
305
+ fi
306
+
307
+ # Clamp hops to max 3
308
+ local MAX_HOPS=3
309
+ local hops_searched="$hops"
310
+ if [[ "$hops" -gt "$MAX_HOPS" ]]; then
311
+ hops_searched="$MAX_HOPS"
312
+ fi
313
+
314
+ # Validate min_weight
315
+ if ! [[ "$min_weight" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
316
+ json_err "$E_VALIDATION_FAILED" "--min-weight must be a non-negative number, got: $min_weight"
317
+ return
318
+ fi
319
+
320
+ local graph_file="$COLONY_DATA_DIR/instinct-graph.json"
321
+
322
+ if [[ ! -f "$graph_file" ]]; then
323
+ json_ok "{\"reachable\":[],\"count\":0,\"hops_searched\":$hops_searched}"
324
+ return
325
+ fi
326
+
327
+ # Read all edges once
328
+ local all_edges
329
+ all_edges=$(jq -c '.edges' "$graph_file")
330
+
331
+ # BFS: frontier is the set of IDs at the current hop level
332
+ # reachable accumulates {id, hop, path} for all visited nodes
333
+ # visited tracks IDs seen to avoid cycles
334
+
335
+ # frontier: JSON array of {id, path} objects
336
+ local frontier
337
+ frontier="[{\"id\":\"$instinct_id\",\"path\":[\"$instinct_id\"]}]"
338
+ local visited
339
+ visited="[\"$instinct_id\"]"
340
+ local reachable="[]"
341
+
342
+ local current_hop=0
343
+ while [[ "$current_hop" -lt "$hops_searched" ]]; do
344
+ current_hop=$((current_hop + 1))
345
+
346
+ # Expand frontier: find all outbound neighbors not yet visited
347
+ local new_nodes
348
+ new_nodes=$(jq -c \
349
+ --argjson edges "$all_edges" \
350
+ --argjson frontier "$frontier" \
351
+ --argjson visited "$visited" \
352
+ --argjson hop "$current_hop" \
353
+ --argjson mw "$min_weight" \
354
+ '
355
+ [
356
+ $frontier[] as $f |
357
+ $edges[] |
358
+ select(.source == $f.id) |
359
+ select(.weight >= $mw) |
360
+ select(.target as $t | ($visited | index($t)) == null) |
361
+ {id: .target, hop: $hop, path: ($f.path + [.target])}
362
+ ] | unique_by(.id)
363
+ ' \
364
+ <<< "null")
365
+
366
+ # If no new nodes found, stop early
367
+ local new_count
368
+ new_count=$(echo "$new_nodes" | jq 'length')
369
+ if [[ "$new_count" -eq 0 ]]; then
370
+ break
371
+ fi
372
+
373
+ # Append new nodes to reachable
374
+ reachable=$(jq -c \
375
+ --argjson existing "$reachable" \
376
+ --argjson new "$new_nodes" \
377
+ '$existing + $new' \
378
+ <<< "null")
379
+
380
+ # Update visited set
381
+ visited=$(jq -c \
382
+ --argjson visited "$visited" \
383
+ --argjson new "$new_nodes" \
384
+ '$visited + [$new[].id]' \
385
+ <<< "null")
386
+
387
+ # Update frontier for next hop
388
+ frontier=$(jq -c \
389
+ --argjson new "$new_nodes" \
390
+ '[.[] | {id: .id, path: .path}]' \
391
+ <<< "$new_nodes")
392
+ done
393
+
394
+ local count
395
+ count=$(echo "$reachable" | jq 'length')
396
+
397
+ json_ok "$(jq -n \
398
+ --argjson reachable "$reachable" \
399
+ --argjson count "$count" \
400
+ --argjson hops_searched "$hops_searched" \
401
+ '{reachable: $reachable, count: $count, hops_searched: $hops_searched}')"
402
+ }
403
+
404
+ # ============================================================================
405
+ # _graph_cluster
406
+ # Find clusters of strongly connected instincts.
407
+ # A cluster is a group of nodes that share >= min-edges connections
408
+ # all with weight >= min-weight.
409
+ #
410
+ # Usage: graph-cluster [--min-edges <N>] [--min-weight <float>]
411
+ #
412
+ # Default min-edges: 2
413
+ # Default min-weight: 0.3
414
+ #
415
+ # Output: {clusters: [{nodes, edge_count, avg_weight}], count}
416
+ # ============================================================================
417
+ _graph_cluster() {
418
+ local min_edges=2
419
+ local min_weight="0.3"
420
+
421
+ while [[ $# -gt 0 ]]; do
422
+ case "$1" in
423
+ --min-edges)
424
+ min_edges="${2:-2}"
425
+ shift 2
426
+ ;;
427
+ --min-weight)
428
+ min_weight="${2:-0.3}"
429
+ shift 2
430
+ ;;
431
+ *)
432
+ json_err "$E_VALIDATION_FAILED" "Usage: graph-cluster [--min-edges <N>] [--min-weight <float>]"
433
+ return
434
+ ;;
435
+ esac
436
+ done
437
+
438
+ # Validate min_edges
439
+ if ! [[ "$min_edges" =~ ^[0-9]+$ ]]; then
440
+ json_err "$E_VALIDATION_FAILED" "--min-edges must be a non-negative integer, got: $min_edges"
441
+ return
442
+ fi
443
+
444
+ # Validate min_weight
445
+ if ! [[ "$min_weight" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
446
+ json_err "$E_VALIDATION_FAILED" "--min-weight must be a non-negative number, got: $min_weight"
447
+ return
448
+ fi
449
+
450
+ local graph_file="$COLONY_DATA_DIR/instinct-graph.json"
451
+
452
+ if [[ ! -f "$graph_file" ]]; then
453
+ json_ok '{"clusters":[],"count":0}'
454
+ return
455
+ fi
456
+
457
+ # Strategy:
458
+ # 1. Filter edges by min-weight
459
+ # 2. For each node, count qualifying edges (in + out)
460
+ # 3. Nodes with >= min-edges form candidates
461
+ # 4. Group connected candidate nodes into clusters via union-find in jq
462
+
463
+ local clusters
464
+ clusters=$(jq -c \
465
+ --argjson min_edges "$min_edges" \
466
+ --argjson min_weight "$min_weight" \
467
+ '
468
+ # Step 1: filter qualifying edges
469
+ (.edges | map(select(.weight >= $min_weight))) as $qual_edges |
470
+
471
+ # Step 2: count edges per node (source + target)
472
+ (
473
+ $qual_edges |
474
+ group_by(.source) |
475
+ map({key: .[0].source, value: length}) |
476
+ from_entries
477
+ ) as $out_counts |
478
+ (
479
+ $qual_edges |
480
+ group_by(.target) |
481
+ map({key: .[0].target, value: length}) |
482
+ from_entries
483
+ ) as $in_counts |
484
+
485
+ # Step 3: build total edge counts per node
486
+ (
487
+ [$qual_edges[] | .source, .target] | unique |
488
+ map({
489
+ id: .,
490
+ edge_count: ((($out_counts[.] // 0) + ($in_counts[.] // 0)))
491
+ }) |
492
+ map(select(.edge_count >= $min_edges))
493
+ ) as $candidates |
494
+
495
+ # Step 4: group candidates into clusters via connected components
496
+ # Build adjacency: pairs of candidate nodes that share a qualifying edge
497
+ (
498
+ $candidates | map(.id)
499
+ ) as $candidate_ids |
500
+
501
+ (
502
+ $qual_edges |
503
+ map(select(
504
+ (.source as $s | ($candidate_ids | index($s)) != null) and
505
+ (.target as $t | ($candidate_ids | index($t)) != null)
506
+ )) |
507
+ map({a: .source, b: .target, w: .weight})
508
+ ) as $adj |
509
+
510
+ # Build clusters: use greedy union approach
511
+ # Start each candidate in its own group, merge groups with shared edges
512
+ reduce $adj[] as $edge (
513
+ ($candidate_ids | map({id: ., group: .}));
514
+ . as $groups |
515
+ ($groups | map(select(.id == $edge.a)) | first.group) as $ga |
516
+ ($groups | map(select(.id == $edge.b)) | first.group) as $gb |
517
+ if $ga == $gb then $groups
518
+ else
519
+ # Merge all nodes with group $gb into group $ga
520
+ [ .[] | if .group == $gb then .group = $ga else . end ]
521
+ end
522
+ ) |
523
+ # Group by cluster label
524
+ group_by(.group) |
525
+ map({
526
+ nodes: map(.id),
527
+ edge_count: (
528
+ . as $cluster_nodes |
529
+ ($cluster_nodes | map(.id)) as $node_ids |
530
+ $adj | map(select(
531
+ (.a as $a | ($node_ids | index($a)) != null) and
532
+ (.b as $b | ($node_ids | index($b)) != null)
533
+ )) | length
534
+ ),
535
+ avg_weight: (
536
+ . as $cluster_nodes |
537
+ ($cluster_nodes | map(.id)) as $node_ids |
538
+ ($adj | map(select(
539
+ (.a as $a | ($node_ids | index($a)) != null) and
540
+ (.b as $b | ($node_ids | index($b)) != null)
541
+ )) | map(.w)) as $weights |
542
+ if ($weights | length) > 0 then
543
+ ($weights | add) / ($weights | length)
544
+ else 0 end
545
+ )
546
+ }) |
547
+ # Only keep clusters with more than 1 node
548
+ map(select(.nodes | length > 1))
549
+ ' \
550
+ "$graph_file")
551
+
552
+ local count
553
+ count=$(echo "$clusters" | jq 'length')
554
+
555
+ json_ok "$(jq -n \
556
+ --argjson clusters "$clusters" \
557
+ --argjson count "$count" \
558
+ '{clusters: $clusters, count: $count}')"
559
+ }