aether-colony 5.1.0 → 5.2.1

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 (52) hide show
  1. package/.aether/aether-utils.sh +122 -42
  2. package/.aether/commands/colonize.yaml +4 -0
  3. package/.aether/commands/council.yaml +205 -0
  4. package/.aether/commands/init.yaml +46 -13
  5. package/.aether/commands/insert-phase.yaml +4 -0
  6. package/.aether/commands/plan.yaml +53 -2
  7. package/.aether/commands/quick.yaml +104 -0
  8. package/.aether/commands/resume-colony.yaml +6 -4
  9. package/.aether/commands/resume.yaml +9 -0
  10. package/.aether/commands/run.yaml +37 -1
  11. package/.aether/commands/seal.yaml +9 -0
  12. package/.aether/commands/status.yaml +45 -1
  13. package/.aether/docs/command-playbooks/build-full.md +2 -1
  14. package/.aether/docs/command-playbooks/build-prep.md +2 -1
  15. package/.aether/docs/command-playbooks/continue-full.md +1 -0
  16. package/.aether/docs/command-playbooks/continue-verify.md +1 -0
  17. package/.aether/utils/council.sh +425 -0
  18. package/.aether/utils/error-handler.sh +3 -3
  19. package/.aether/utils/flag.sh +23 -12
  20. package/.aether/utils/hive.sh +2 -2
  21. package/.aether/utils/immune.sh +508 -0
  22. package/.aether/utils/learning.sh +2 -2
  23. package/.aether/utils/midden.sh +178 -0
  24. package/.aether/utils/queen.sh +29 -17
  25. package/.aether/utils/session.sh +264 -0
  26. package/.aether/utils/spawn-tree.sh +7 -7
  27. package/.aether/utils/spawn.sh +2 -2
  28. package/.aether/utils/state-api.sh +191 -1
  29. package/.claude/commands/ant/colonize.md +2 -0
  30. package/.claude/commands/ant/council.md +205 -0
  31. package/.claude/commands/ant/init.md +46 -13
  32. package/.claude/commands/ant/insert-phase.md +4 -0
  33. package/.claude/commands/ant/plan.md +27 -1
  34. package/.claude/commands/ant/quick.md +100 -0
  35. package/.claude/commands/ant/resume-colony.md +3 -2
  36. package/.claude/commands/ant/resume.md +9 -0
  37. package/.claude/commands/ant/run.md +37 -1
  38. package/.claude/commands/ant/seal.md +9 -0
  39. package/.claude/commands/ant/status.md +45 -1
  40. package/.opencode/commands/ant/colonize.md +2 -0
  41. package/.opencode/commands/ant/council.md +143 -0
  42. package/.opencode/commands/ant/init.md +46 -13
  43. package/.opencode/commands/ant/insert-phase.md +4 -0
  44. package/.opencode/commands/ant/plan.md +26 -1
  45. package/.opencode/commands/ant/quick.md +91 -0
  46. package/.opencode/commands/ant/resume-colony.md +3 -2
  47. package/.opencode/commands/ant/resume.md +9 -0
  48. package/.opencode/commands/ant/run.md +37 -1
  49. package/.opencode/commands/ant/status.md +2 -0
  50. package/CHANGELOG.md +90 -0
  51. package/README.md +23 -0
  52. package/package.json +10 -2
@@ -0,0 +1,508 @@
1
+ #!/bin/bash
2
+ # Immune response system — trophallaxis repair and scarification
3
+ # Provides: _trophallaxis_diagnose, _trophallaxis_retry, _scar_add, _scar_list,
4
+ # _scar_check, _immune_auto_scar
5
+ #
6
+ # These functions are sourced by aether-utils.sh at startup.
7
+ # All shared infrastructure (json_ok, json_err, atomic_write, acquire_lock,
8
+ # release_lock, LOCK_DIR, COLONY_DATA_DIR, error constants) is available.
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Internal helpers
12
+ # ---------------------------------------------------------------------------
13
+
14
+ _immune_data_dir() {
15
+ echo "$COLONY_DATA_DIR/immune"
16
+ }
17
+
18
+ _immune_scars_file() {
19
+ echo "$COLONY_DATA_DIR/immune/scars.json"
20
+ }
21
+
22
+ _immune_retry_log_file() {
23
+ echo "$COLONY_DATA_DIR/immune/retry-log.json"
24
+ }
25
+
26
+ _immune_ensure_dir() {
27
+ mkdir -p "$(_immune_data_dir)"
28
+ }
29
+
30
+ _immune_ensure_scars_file() {
31
+ local sf
32
+ sf="$(_immune_scars_file)"
33
+ _immune_ensure_dir
34
+ if [[ ! -f "$sf" ]]; then
35
+ printf '%s\n' '{"version":"1.0","scars":[]}' > "$sf"
36
+ fi
37
+ }
38
+
39
+ _immune_ensure_retry_log() {
40
+ local rf
41
+ rf="$(_immune_retry_log_file)"
42
+ _immune_ensure_dir
43
+ if [[ ! -f "$rf" ]]; then
44
+ printf '%s\n' '{"version":"1.0","tasks":{}}' > "$rf"
45
+ fi
46
+ }
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # _trophallaxis_diagnose
50
+ # Usage: trophallaxis-diagnose --task-id <id> --failure <desc> [--phase N]
51
+ # ---------------------------------------------------------------------------
52
+ _trophallaxis_diagnose() {
53
+ local td_task_id=""
54
+ local td_failure=""
55
+ local td_phase=""
56
+
57
+ while [[ $# -gt 0 ]]; do
58
+ case "$1" in
59
+ --task-id) td_task_id="${2:-}"; shift 2 ;;
60
+ --failure) td_failure="${2:-}"; shift 2 ;;
61
+ --phase) td_phase="${2:-}"; shift 2 ;;
62
+ *) shift ;;
63
+ esac
64
+ done
65
+
66
+ if [[ -z "$td_task_id" ]]; then
67
+ json_err "$E_VALIDATION_FAILED" "trophallaxis-diagnose requires --task-id"
68
+ return
69
+ fi
70
+
71
+ if [[ -z "$td_failure" ]]; then
72
+ json_err "$E_VALIDATION_FAILED" "trophallaxis-diagnose requires --failure"
73
+ return
74
+ fi
75
+
76
+ local td_midden_file="$COLONY_DATA_DIR/midden/midden.json"
77
+ local td_related=0
78
+ local td_related_entries="[]"
79
+ local td_approach=""
80
+
81
+ # Search midden for related entries using keywords from failure description
82
+ if [[ -f "$td_midden_file" ]]; then
83
+ # Extract first keyword (longest word >= 4 chars) for search
84
+ local td_keyword
85
+ td_keyword=$(echo "$td_failure" | tr '[:upper:]' '[:lower:]' | \
86
+ grep -oE '[a-z]{4,}' | sort -rn -k1,1 | head -1 || echo "")
87
+
88
+ if [[ -n "$td_keyword" ]]; then
89
+ td_related=$(jq \
90
+ --arg q "$td_keyword" \
91
+ '[.entries // [] | .[] |
92
+ select(.acknowledged != true) |
93
+ select(.message | ascii_downcase | contains($q))
94
+ ] | length' "$td_midden_file" 2>/dev/null || echo "0")
95
+
96
+ td_related_entries=$(jq \
97
+ --arg q "$td_keyword" \
98
+ '[.entries // [] | .[] |
99
+ select(.acknowledged != true) |
100
+ select(.message | ascii_downcase | contains($q)) |
101
+ {id, timestamp, category, source, message}
102
+ ] | .[:5]' "$td_midden_file" 2>/dev/null || echo "[]")
103
+ else
104
+ # No usable keyword — scan all recent entries
105
+ td_related=$(jq '[.entries // [] | .[] | select(.acknowledged != true)] | length' \
106
+ "$td_midden_file" 2>/dev/null || echo "0")
107
+ fi
108
+ fi
109
+
110
+ # Build diagnosis text
111
+ local td_diagnosis
112
+ if [[ "$td_related" -gt 0 ]]; then
113
+ td_diagnosis="Found $td_related related failure(s) in midden matching: $td_failure"
114
+ td_approach="Review related midden entries for patterns. Address root cause before retrying."
115
+ else
116
+ td_diagnosis="No related failures found in midden for: $td_failure"
117
+ td_approach="Investigate failure from first principles. Check logs and dependencies."
118
+ fi
119
+
120
+ # Confidence: higher when related failures exist
121
+ local td_confidence
122
+ if [[ "$td_related" -ge 3 ]]; then
123
+ td_confidence="0.9"
124
+ elif [[ "$td_related" -ge 1 ]]; then
125
+ td_confidence="0.7"
126
+ else
127
+ td_confidence="0.4"
128
+ fi
129
+
130
+ json_ok "$(jq -n \
131
+ --arg task_id "$td_task_id" \
132
+ --arg failure "$td_failure" \
133
+ --arg diagnosis "$td_diagnosis" \
134
+ --argjson related_failures "$td_related" \
135
+ --arg suggested_approach "$td_approach" \
136
+ --argjson confidence "$td_confidence" \
137
+ --argjson related_entries "$td_related_entries" \
138
+ '{
139
+ task_id: $task_id,
140
+ failure: $failure,
141
+ diagnosis: $diagnosis,
142
+ related_failures: $related_failures,
143
+ suggested_approach: $suggested_approach,
144
+ confidence: $confidence,
145
+ related_entries: $related_entries
146
+ }')"
147
+ }
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # _trophallaxis_retry
151
+ # Usage: trophallaxis-retry --task-id <id> --diagnosis <json>
152
+ # ---------------------------------------------------------------------------
153
+ _trophallaxis_retry() {
154
+ local tr_task_id=""
155
+ local tr_diagnosis=""
156
+
157
+ while [[ $# -gt 0 ]]; do
158
+ case "$1" in
159
+ --task-id) tr_task_id="${2:-}"; shift 2 ;;
160
+ --diagnosis) tr_diagnosis="${2:-}"; shift 2 ;;
161
+ *) shift ;;
162
+ esac
163
+ done
164
+
165
+ if [[ -z "$tr_task_id" ]]; then
166
+ json_err "$E_VALIDATION_FAILED" "trophallaxis-retry requires --task-id"
167
+ return
168
+ fi
169
+
170
+ _immune_ensure_retry_log
171
+
172
+ local tr_log_file
173
+ tr_log_file="$(_immune_retry_log_file)"
174
+ local tr_timestamp
175
+ tr_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
176
+
177
+ # Read current retry count for this task
178
+ local tr_current_count
179
+ tr_current_count=$(jq -r --arg tid "$tr_task_id" \
180
+ '.tasks[$tid].retry_count // 0' "$tr_log_file" 2>/dev/null || echo "0")
181
+ local tr_new_count=$(( tr_current_count + 1 ))
182
+
183
+ # Validate diagnosis JSON (graceful: use empty object if invalid)
184
+ local tr_diag_json
185
+ if echo "$tr_diagnosis" | jq empty 2>/dev/null; then
186
+ tr_diag_json="$tr_diagnosis"
187
+ else
188
+ tr_diag_json="{}"
189
+ fi
190
+
191
+ # Build the retry entry
192
+ local tr_entry
193
+ tr_entry=$(jq -n \
194
+ --arg ts "$tr_timestamp" \
195
+ --argjson retry_count "$tr_new_count" \
196
+ --argjson diagnosis "$tr_diag_json" \
197
+ '{timestamp: $ts, retry_count: $retry_count, diagnosis: $diagnosis}')
198
+
199
+ # Update log with locking
200
+ local tr_updated
201
+ if acquire_lock "$tr_log_file" 2>/dev/null; then
202
+ tr_updated=$(jq \
203
+ --arg tid "$tr_task_id" \
204
+ --argjson entry "$tr_entry" \
205
+ --argjson new_count "$tr_new_count" \
206
+ '.tasks[$tid] = {
207
+ retry_count: $new_count,
208
+ last_attempt: $entry.timestamp,
209
+ last_diagnosis: $entry.diagnosis,
210
+ history: ((.tasks[$tid].history // []) + [$entry])
211
+ }' "$tr_log_file" 2>/dev/null)
212
+
213
+ if [[ -n "$tr_updated" ]]; then
214
+ atomic_write "$tr_log_file" "$tr_updated"
215
+ fi
216
+ release_lock 2>/dev/null || true
217
+ else
218
+ # Lockless fallback
219
+ tr_updated=$(jq \
220
+ --arg tid "$tr_task_id" \
221
+ --argjson entry "$tr_entry" \
222
+ --argjson new_count "$tr_new_count" \
223
+ '.tasks[$tid] = {
224
+ retry_count: $new_count,
225
+ last_attempt: $entry.timestamp,
226
+ last_diagnosis: $entry.diagnosis,
227
+ history: ((.tasks[$tid].history // []) + [$entry])
228
+ }' "$tr_log_file" 2>/dev/null)
229
+
230
+ if [[ -n "$tr_updated" ]]; then
231
+ atomic_write "$tr_log_file" "$tr_updated"
232
+ fi
233
+ fi
234
+
235
+ json_ok "$(jq -n \
236
+ --arg task_id "$tr_task_id" \
237
+ --argjson retry_count "$tr_new_count" \
238
+ '{task_id: $task_id, retry_count: $retry_count, diagnosis_injected: true}')"
239
+ }
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # _scar_add
243
+ # Usage: scar-add --pattern <desc> --severity <low|medium|high> [--phase N] [--source <src>]
244
+ # ---------------------------------------------------------------------------
245
+ _scar_add() {
246
+ local sa_pattern=""
247
+ local sa_severity=""
248
+ local sa_phase=""
249
+ local sa_source="unknown"
250
+
251
+ while [[ $# -gt 0 ]]; do
252
+ case "$1" in
253
+ --pattern) sa_pattern="${2:-}"; shift 2 ;;
254
+ --severity) sa_severity="${2:-}"; shift 2 ;;
255
+ --phase) sa_phase="${2:-}"; shift 2 ;;
256
+ --source) sa_source="${2:-}"; shift 2 ;;
257
+ *) shift ;;
258
+ esac
259
+ done
260
+
261
+ if [[ -z "$sa_pattern" ]]; then
262
+ json_err "$E_VALIDATION_FAILED" "scar-add requires --pattern"
263
+ return
264
+ fi
265
+
266
+ # Default severity if not provided
267
+ if [[ -z "$sa_severity" ]]; then
268
+ sa_severity="medium"
269
+ fi
270
+
271
+ # Validate severity
272
+ case "$sa_severity" in
273
+ low|medium|high) ;;
274
+ *)
275
+ json_err "$E_VALIDATION_FAILED" "scar-add --severity must be low, medium, or high"
276
+ return
277
+ ;;
278
+ esac
279
+
280
+ _immune_ensure_scars_file
281
+
282
+ local sa_scars_file
283
+ sa_scars_file="$(_immune_scars_file)"
284
+ local sa_timestamp
285
+ sa_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
286
+ local sa_id
287
+ sa_id="scar_$(date +%s)_$$"
288
+
289
+ # Build phase value for JSON
290
+ local sa_phase_val
291
+ if [[ -n "$sa_phase" ]]; then
292
+ sa_phase_val="$sa_phase"
293
+ else
294
+ sa_phase_val="null"
295
+ fi
296
+
297
+ local sa_new_scar
298
+ sa_new_scar=$(jq -n \
299
+ --arg id "$sa_id" \
300
+ --arg pattern "$sa_pattern" \
301
+ --arg severity "$sa_severity" \
302
+ --arg phase "$sa_phase_val" \
303
+ --arg source "$sa_source" \
304
+ --arg created_at "$sa_timestamp" \
305
+ '{
306
+ id: $id,
307
+ pattern: $pattern,
308
+ severity: $severity,
309
+ phase: (if $phase == "null" then null else ($phase | tonumber? // $phase) end),
310
+ source: $source,
311
+ created_at: $created_at,
312
+ retry_count: 0,
313
+ active: true
314
+ }')
315
+
316
+ local sa_updated
317
+ if acquire_lock "$sa_scars_file" 2>/dev/null; then
318
+ sa_updated=$(jq \
319
+ --argjson scar "$sa_new_scar" \
320
+ '.scars += [$scar]' "$sa_scars_file" 2>/dev/null)
321
+ if [[ -n "$sa_updated" ]]; then
322
+ atomic_write "$sa_scars_file" "$sa_updated"
323
+ fi
324
+ release_lock 2>/dev/null || true
325
+ else
326
+ sa_updated=$(jq \
327
+ --argjson scar "$sa_new_scar" \
328
+ '.scars += [$scar]' "$sa_scars_file" 2>/dev/null)
329
+ if [[ -n "$sa_updated" ]]; then
330
+ atomic_write "$sa_scars_file" "$sa_updated"
331
+ fi
332
+ fi
333
+
334
+ local sa_count
335
+ sa_count=$(jq '[.scars[]] | length' "$sa_scars_file" 2>/dev/null || echo "1")
336
+
337
+ json_ok "$(jq -n \
338
+ --arg id "$sa_id" \
339
+ --argjson scar_count "$sa_count" \
340
+ '{id: $id, scar_count: $scar_count}')"
341
+ }
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # _scar_list
345
+ # Usage: scar-list [--active] [--severity <level>]
346
+ # ---------------------------------------------------------------------------
347
+ _scar_list() {
348
+ local sl_active_only=false
349
+ local sl_severity=""
350
+
351
+ while [[ $# -gt 0 ]]; do
352
+ case "$1" in
353
+ --active) sl_active_only=true; shift ;;
354
+ --severity) sl_severity="${2:-}"; shift 2 ;;
355
+ *) shift ;;
356
+ esac
357
+ done
358
+
359
+ local sl_scars_file
360
+ sl_scars_file="$(_immune_scars_file)"
361
+
362
+ if [[ ! -f "$sl_scars_file" ]]; then
363
+ json_ok '{"total":0,"active":0,"scars":[]}'
364
+ return 0
365
+ fi
366
+
367
+ local sl_result
368
+ sl_result=$(jq \
369
+ --argjson active_only "$sl_active_only" \
370
+ --arg severity "$sl_severity" \
371
+ '
372
+ [.scars // [] | .[] |
373
+ if $active_only then select(.active == true) else . end |
374
+ if ($severity | length) > 0 then select(.severity == $severity) else . end
375
+ ] |
376
+ . as $filtered |
377
+ {
378
+ total: ($filtered | length),
379
+ active: ($filtered | map(select(.active == true)) | length),
380
+ scars: $filtered
381
+ }
382
+ ' "$sl_scars_file" 2>/dev/null)
383
+
384
+ if [[ -z "$sl_result" ]]; then
385
+ json_ok '{"total":0,"active":0,"scars":[]}'
386
+ return 0
387
+ fi
388
+
389
+ json_ok "$sl_result"
390
+ }
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # _scar_check
394
+ # Usage: scar-check --task <desc>
395
+ # ---------------------------------------------------------------------------
396
+ _scar_check() {
397
+ local sc_task=""
398
+
399
+ while [[ $# -gt 0 ]]; do
400
+ case "$1" in
401
+ --task) sc_task="${2:-}"; shift 2 ;;
402
+ *) shift ;;
403
+ esac
404
+ done
405
+
406
+ if [[ -z "$sc_task" ]]; then
407
+ json_err "$E_VALIDATION_FAILED" "scar-check requires --task"
408
+ return
409
+ fi
410
+
411
+ local sc_scars_file
412
+ sc_scars_file="$(_immune_scars_file)"
413
+
414
+ if [[ ! -f "$sc_scars_file" ]]; then
415
+ json_ok '{"matches":0,"scars":[]}'
416
+ return 0
417
+ fi
418
+
419
+ local sc_result
420
+ sc_result=$(jq \
421
+ --arg task "$sc_task" \
422
+ '
423
+ [.scars // [] | .[] |
424
+ select(.active == true) |
425
+ # Split pattern into words, check if any word from pattern appears in task
426
+ select(
427
+ (.pattern | ascii_downcase) as $pat |
428
+ ($pat | split(" ") | .[] | select(length >= 3)) as $word |
429
+ ($task | ascii_downcase) | contains($word)
430
+ )
431
+ ] |
432
+ . as $matches |
433
+ {
434
+ matches: ($matches | length),
435
+ scars: $matches
436
+ }
437
+ ' "$sc_scars_file" 2>/dev/null)
438
+
439
+ if [[ -z "$sc_result" ]]; then
440
+ json_ok '{"matches":0,"scars":[]}'
441
+ return 0
442
+ fi
443
+
444
+ json_ok "$sc_result"
445
+ }
446
+
447
+ # ---------------------------------------------------------------------------
448
+ # _immune_auto_scar
449
+ # Usage: immune-auto-scar --task-id <id>
450
+ # ---------------------------------------------------------------------------
451
+ _immune_auto_scar() {
452
+ local ias_task_id=""
453
+
454
+ while [[ $# -gt 0 ]]; do
455
+ case "$1" in
456
+ --task-id) ias_task_id="${2:-}"; shift 2 ;;
457
+ *) shift ;;
458
+ esac
459
+ done
460
+
461
+ if [[ -z "$ias_task_id" ]]; then
462
+ json_err "$E_VALIDATION_FAILED" "immune-auto-scar requires --task-id"
463
+ return
464
+ fi
465
+
466
+ local ias_log_file
467
+ ias_log_file="$(_immune_retry_log_file)"
468
+
469
+ if [[ ! -f "$ias_log_file" ]]; then
470
+ json_ok '{"auto_scarred":false,"retry_count":0}'
471
+ return 0
472
+ fi
473
+
474
+ local ias_retry_count
475
+ ias_retry_count=$(jq -r --arg tid "$ias_task_id" \
476
+ '.tasks[$tid].retry_count // 0' "$ias_log_file" 2>/dev/null || echo "0")
477
+
478
+ if [[ "$ias_retry_count" -lt 3 ]]; then
479
+ json_ok "$(jq -n \
480
+ --argjson retry_count "$ias_retry_count" \
481
+ '{auto_scarred: false, retry_count: $retry_count}')"
482
+ return 0
483
+ fi
484
+
485
+ # Auto-create a scar for this task
486
+ local ias_last_diagnosis
487
+ ias_last_diagnosis=$(jq -r --arg tid "$ias_task_id" \
488
+ '.tasks[$tid].last_diagnosis.failure // ""' "$ias_log_file" 2>/dev/null || echo "")
489
+
490
+ local ias_pattern
491
+ if [[ -n "$ias_last_diagnosis" ]]; then
492
+ ias_pattern="$ias_last_diagnosis"
493
+ else
494
+ ias_pattern="task $ias_task_id failed persistently (auto-scarred after $ias_retry_count retries)"
495
+ fi
496
+
497
+ # Determine severity based on retry count
498
+ local ias_severity="medium"
499
+ if [[ "$ias_retry_count" -ge 5 ]]; then
500
+ ias_severity="high"
501
+ fi
502
+
503
+ _scar_add --pattern "$ias_pattern" --severity "$ias_severity" --source "immune-auto-scar" >/dev/null
504
+
505
+ json_ok "$(jq -n \
506
+ --argjson retry_count "$ias_retry_count" \
507
+ '{auto_scarred: true, retry_count: $retry_count}')"
508
+ }
@@ -431,11 +431,11 @@ _learning_promote_auto() {
431
431
  # SUPPRESS:OK -- read-default: returns fallback on failure
432
432
  --evidence "Auto-promoted after $observation_count observations (confidence: $lp_confidence)" 2>/dev/null \
433
433
  || _aether_log_error "Could not create instinct from promoted learning"
434
- json_ok "$(jq -n --argjson pt "$policy_threshold" --argjson oc "$observation_count" --argjson cc "$colony_count" --arg et "$event_type" '{promoted: true, mode: "auto", policy_threshold: $pt, observation_count: $oc, colony_count: $cc, event_type: $et}')"
434
+ json_ok "$(jq -nc --argjson pt "$policy_threshold" --argjson oc "$observation_count" --argjson cc "$colony_count" --arg et "$event_type" '{promoted: true, mode: "auto", policy_threshold: $pt, observation_count: $oc, colony_count: $cc, event_type: $et}')"
435
435
  else
436
436
  # SUPPRESS:OK -- read-default: query may return empty
437
437
  promote_msg=$(echo "$promote_result" | jq -r '.error.message // "promotion_failed"' 2>/dev/null || echo "promotion_failed")
438
- result=$(jq -n \
438
+ result=$(jq -nc \
439
439
  --arg reason "promotion_failed" \
440
440
  --arg message "$promote_msg" \
441
441
  --argjson policy_threshold "$policy_threshold" \
@@ -244,6 +244,184 @@ _midden_ingest_errors() {
244
244
  return 0
245
245
  }
246
246
 
247
+ _midden_search() {
248
+ # Search midden entries by keyword match in message field
249
+ # Usage: midden-search <query> [--category <cat>] [--source <src>] [--limit N] [--include-acknowledged]
250
+ # Returns: JSON with query, match_count, and entries array
251
+
252
+ ms_query=""
253
+ ms_category=""
254
+ ms_source=""
255
+ ms_limit=10
256
+ ms_include_ack=false
257
+
258
+ # First positional arg is the query
259
+ if [[ $# -gt 0 && "$1" != --* ]]; then
260
+ ms_query="$1"
261
+ shift
262
+ fi
263
+
264
+ while [[ $# -gt 0 ]]; do
265
+ case "$1" in
266
+ --category) ms_category="${2:-}"; shift 2 ;;
267
+ --source) ms_source="${2:-}"; shift 2 ;;
268
+ --limit) ms_limit="${2:-10}"; shift 2 ;;
269
+ --include-acknowledged) ms_include_ack=true; shift ;;
270
+ *) shift ;;
271
+ esac
272
+ done
273
+
274
+ ms_midden_file="$COLONY_DATA_DIR/midden/midden.json"
275
+
276
+ if [[ ! -f "$ms_midden_file" ]]; then
277
+ json_ok "{\"query\":$(printf '%s' "$ms_query" | jq -Rs .),\"match_count\":0,\"entries\":[]}"
278
+ return 0
279
+ fi
280
+
281
+ ms_result=$(jq \
282
+ --arg query "$ms_query" \
283
+ --arg category "$ms_category" \
284
+ --arg source "$ms_source" \
285
+ --argjson limit "$ms_limit" \
286
+ --argjson include_ack "$ms_include_ack" \
287
+ '
288
+ [.entries // [] | .[] |
289
+ # Filter acknowledged unless --include-acknowledged
290
+ if $include_ack then . else select(.acknowledged != true) end |
291
+ # Filter by category if specified
292
+ if ($category | length) > 0 then select(.category == $category) else . end |
293
+ # Filter by source if specified
294
+ if ($source | length) > 0 then select(.source == $source) else . end |
295
+ # Filter by keyword match in message (case-insensitive)
296
+ if ($query | length) > 0 then
297
+ select(.message | ascii_downcase | contains($query | ascii_downcase))
298
+ else
299
+ .
300
+ end
301
+ ] |
302
+ sort_by(.timestamp) | reverse |
303
+ . as $all |
304
+ {
305
+ query: $query,
306
+ match_count: ($all | length),
307
+ entries: ($all | .[:$limit])
308
+ }
309
+ ' "$ms_midden_file" 2>/dev/null)
310
+
311
+ if [[ -z "$ms_result" ]]; then
312
+ json_ok "{\"query\":$(printf '%s' "$ms_query" | jq -Rs .),\"match_count\":0,\"entries\":[]}"
313
+ else
314
+ json_ok "$ms_result"
315
+ fi
316
+ return 0
317
+ }
318
+
319
+ _midden_tag() {
320
+ # Add or remove a tag from a midden entry's tags array
321
+ # Usage: midden-tag --id <entry_id> --tag <tag_name>
322
+ # OR: midden-tag --id <entry_id> --untag <tag_name>
323
+ # Returns: JSON with entry_id, tags array, and action
324
+
325
+ mt_id=""
326
+ mt_tag=""
327
+ mt_untag=""
328
+
329
+ while [[ $# -gt 0 ]]; do
330
+ case "$1" in
331
+ --id) mt_id="${2:-}"; shift 2 ;;
332
+ --tag) mt_tag="${2:-}"; shift 2 ;;
333
+ --untag) mt_untag="${2:-}"; shift 2 ;;
334
+ *) shift ;;
335
+ esac
336
+ done
337
+
338
+ # Validate: need --id
339
+ if [[ -z "$mt_id" ]]; then
340
+ json_err "$E_VALIDATION_FAILED" "midden-tag requires --id"
341
+ fi
342
+
343
+ # Validate: need --tag or --untag (but not both)
344
+ if [[ -z "$mt_tag" && -z "$mt_untag" ]]; then
345
+ json_err "$E_VALIDATION_FAILED" "midden-tag requires --tag or --untag"
346
+ fi
347
+
348
+ if [[ -n "$mt_tag" && -n "$mt_untag" ]]; then
349
+ json_err "$E_VALIDATION_FAILED" "midden-tag requires --tag or --untag, not both"
350
+ fi
351
+
352
+ mt_midden_file="$COLONY_DATA_DIR/midden/midden.json"
353
+
354
+ if [[ ! -f "$mt_midden_file" ]]; then
355
+ json_err "$E_FILE_NOT_FOUND" "midden.json not found"
356
+ fi
357
+
358
+ # Check entry exists
359
+ mt_exists=$(jq --arg id "$mt_id" '[.entries[]? | select(.id == $id)] | length > 0' "$mt_midden_file" 2>/dev/null || echo "false")
360
+ if [[ "$mt_exists" != "true" ]]; then
361
+ json_err "$E_RESOURCE_NOT_FOUND" "Midden entry '$mt_id' not found"
362
+ fi
363
+
364
+ # Acquire lock with trap-based cleanup
365
+ acquire_lock "$mt_midden_file" || {
366
+ json_err "$E_LOCK_FAILED" "Failed to acquire lock on midden.json"
367
+ }
368
+ trap 'release_lock 2>/dev/null || true' EXIT
369
+
370
+ if [[ -n "$mt_tag" ]]; then
371
+ # Add tag — create tags array if absent, append if tag not already present
372
+ mt_updated=$(jq \
373
+ --arg id "$mt_id" \
374
+ --arg tag "$mt_tag" \
375
+ '
376
+ .entries = [.entries[] |
377
+ if .id == $id then
378
+ . + {tags: ((.tags // []) | if contains([$tag]) then . else . + [$tag] end)}
379
+ else
380
+ .
381
+ end
382
+ ]
383
+ ' "$mt_midden_file" 2>/dev/null)
384
+ mt_action="added"
385
+ else
386
+ # Remove tag — remove from tags array if present
387
+ mt_updated=$(jq \
388
+ --arg id "$mt_id" \
389
+ --arg tag "$mt_untag" \
390
+ '
391
+ .entries = [.entries[] |
392
+ if .id == $id then
393
+ . + {tags: ((.tags // []) | map(select(. != $tag)))}
394
+ else
395
+ .
396
+ end
397
+ ]
398
+ ' "$mt_midden_file" 2>/dev/null)
399
+ mt_action="removed"
400
+ mt_tag="$mt_untag"
401
+ fi
402
+
403
+ if [[ -z "$mt_updated" ]]; then
404
+ trap - EXIT
405
+ release_lock 2>/dev/null || true
406
+ json_err "$E_INTERNAL" "Failed to update midden.json"
407
+ fi
408
+
409
+ atomic_write "$mt_midden_file" "$mt_updated"
410
+
411
+ trap - EXIT
412
+ release_lock 2>/dev/null || true
413
+
414
+ # Read back the updated tags for the entry
415
+ mt_tags=$(jq --arg id "$mt_id" '[.entries[]? | select(.id == $id) | .tags // []] | .[0] // []' "$mt_midden_file" 2>/dev/null || echo "[]")
416
+
417
+ json_ok "$(jq -n \
418
+ --arg entry_id "$mt_id" \
419
+ --argjson tags "$mt_tags" \
420
+ --arg action "$mt_action" \
421
+ '{entry_id: $entry_id, tags: $tags, action: $action}')"
422
+ return 0
423
+ }
424
+
247
425
  _midden_acknowledge() {
248
426
  # Acknowledge midden entries by id or by category
249
427
  # Usage: midden-acknowledge --id <entry_id> [--reason <reason>]