create-egregore 0.19.33 → 0.20.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 (171) hide show
  1. package/assets/egregore-launcher.js +118 -13
  2. package/bin/cli.js +19 -3
  3. package/lib/connect-runtime.js +4 -2
  4. package/lib/local.js +15 -1
  5. package/lib/prime-runtime.js +208 -0
  6. package/lib/setup.js +45 -3
  7. package/package.json +4 -3
  8. package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +29 -0
  9. package/runtime/codex/.codex/skills/notion-connect/SKILL.md +29 -0
  10. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
  11. package/runtime/codex/.codex/spec-manifest.json +3 -3
  12. package/runtime/codex/AGENTS.md +11 -4
  13. package/runtime/codex/bin/activity-data.sh +4 -8
  14. package/runtime/codex/bin/attendant.sh +13 -0
  15. package/runtime/codex/bin/autosave.sh +27 -1
  16. package/runtime/codex/bin/codex-render-spec.mjs +6 -0
  17. package/runtime/codex/bin/codex-session-start.sh +3 -1
  18. package/runtime/codex/bin/graph-batch.sh +25 -6
  19. package/runtime/codex/bin/graph-op.sh +5 -4
  20. package/runtime/codex/bin/graph.sh +31 -5
  21. package/runtime/codex/bin/ingest.py +81 -5
  22. package/runtime/codex/bin/lib/context.sh +26 -1
  23. package/runtime/codex/bin/lib/greeting.sh +4 -1
  24. package/runtime/codex/bin/lib/worktree-links.sh +25 -0
  25. package/runtime/codex/manifest.json +7 -1
  26. package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +29 -0
  27. package/runtime/pi/.codex/skills/notion-connect/SKILL.md +29 -0
  28. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
  29. package/runtime/pi/.codex/spec-manifest.json +3 -3
  30. package/runtime/pi/.pi/APPEND_SYSTEM.md +3 -2
  31. package/runtime/pi/.pi/extensions/egregore.ts +17 -6
  32. package/runtime/pi/.pi/spec-manifest.json +2 -2
  33. package/runtime/pi/AGENTS.md +11 -4
  34. package/runtime/pi/bin/activity-data.sh +4 -8
  35. package/runtime/pi/bin/attendant.sh +13 -0
  36. package/runtime/pi/bin/autosave.sh +27 -1
  37. package/runtime/pi/bin/codex-render-spec.mjs +6 -0
  38. package/runtime/pi/bin/codex-session-start.sh +3 -1
  39. package/runtime/pi/bin/graph-batch.sh +25 -6
  40. package/runtime/pi/bin/graph-op.sh +5 -4
  41. package/runtime/pi/bin/graph.sh +31 -5
  42. package/runtime/pi/bin/ingest.py +81 -5
  43. package/runtime/pi/bin/lib/context.sh +26 -1
  44. package/runtime/pi/bin/lib/greeting.sh +4 -1
  45. package/runtime/pi/bin/lib/worktree-links.sh +25 -0
  46. package/runtime/pi/bin/pi-branch-policy.mjs +138 -1
  47. package/runtime/pi/bin/pi-product-workflows.mjs +35 -7
  48. package/runtime/pi/manifest.json +7 -1
  49. package/runtime/prime/.claude/context/notification-consent.md +66 -0
  50. package/runtime/prime/.codex/config.toml +17 -0
  51. package/runtime/prime/.codex/hooks/branch-guard.js +461 -0
  52. package/runtime/prime/.codex/hooks/search-hint.js +67 -0
  53. package/runtime/prime/.codex/hooks.json +30 -0
  54. package/runtime/prime/.codex/skills/activity/SKILL.md +82 -0
  55. package/runtime/prime/.codex/skills/announce/SKILL.md +67 -0
  56. package/runtime/prime/.codex/skills/archive/SKILL.md +45 -0
  57. package/runtime/prime/.codex/skills/branch/SKILL.md +29 -0
  58. package/runtime/prime/.codex/skills/checkup/SKILL.md +45 -0
  59. package/runtime/prime/.codex/skills/commit/SKILL.md +29 -0
  60. package/runtime/prime/.codex/skills/contribute/SKILL.md +29 -0
  61. package/runtime/prime/.codex/skills/create-skill/SKILL.md +29 -0
  62. package/runtime/prime/.codex/skills/dark-mode/SKILL.md +29 -0
  63. package/runtime/prime/.codex/skills/dashboard/SKILL.md +59 -0
  64. package/runtime/prime/.codex/skills/deep-reflect/SKILL.md +109 -0
  65. package/runtime/prime/.codex/skills/delete-user/SKILL.md +29 -0
  66. package/runtime/prime/.codex/skills/emissary/SKILL.md +45 -0
  67. package/runtime/prime/.codex/skills/handoff/SKILL.md +166 -0
  68. package/runtime/prime/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
  69. package/runtime/prime/.codex/skills/harvest/SKILL.md +131 -0
  70. package/runtime/prime/.codex/skills/ingest/SKILL.md +29 -0
  71. package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +29 -0
  72. package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +45 -0
  73. package/runtime/prime/.codex/skills/invite/SKILL.md +44 -0
  74. package/runtime/prime/.codex/skills/issue/SKILL.md +45 -0
  75. package/runtime/prime/.codex/skills/me/SKILL.md +29 -0
  76. package/runtime/prime/.codex/skills/meeting/SKILL.md +45 -0
  77. package/runtime/prime/.codex/skills/note/SKILL.md +29 -0
  78. package/runtime/prime/.codex/skills/notion-connect/SKILL.md +29 -0
  79. package/runtime/prime/.codex/skills/onboarding/SKILL.md +45 -0
  80. package/runtime/prime/.codex/skills/pr/SKILL.md +29 -0
  81. package/runtime/prime/.codex/skills/pull/SKILL.md +29 -0
  82. package/runtime/prime/.codex/skills/push/SKILL.md +29 -0
  83. package/runtime/prime/.codex/skills/quest/SKILL.md +80 -0
  84. package/runtime/prime/.codex/skills/reflect/SKILL.md +45 -0
  85. package/runtime/prime/.codex/skills/review-pr/SKILL.md +45 -0
  86. package/runtime/prime/.codex/skills/save/SKILL.md +125 -0
  87. package/runtime/prime/.codex/skills/slack-connect/SKILL.md +29 -0
  88. package/runtime/prime/.codex/skills/summon/SKILL.md +45 -0
  89. package/runtime/prime/.codex/skills/sync-repos/SKILL.md +29 -0
  90. package/runtime/prime/.codex/skills/teams-connect/SKILL.md +29 -0
  91. package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +29 -0
  92. package/runtime/prime/.codex/skills/telemetry/SKILL.md +29 -0
  93. package/runtime/prime/.codex/skills/test/SKILL.md +45 -0
  94. package/runtime/prime/.codex/skills/the-spiral/SKILL.md +84 -0
  95. package/runtime/prime/.codex/skills/tui-design/SKILL.md +45 -0
  96. package/runtime/prime/.codex/skills/tutorial/SKILL.md +45 -0
  97. package/runtime/prime/.codex/skills/update/SKILL.md +29 -0
  98. package/runtime/prime/.codex/skills/view/SKILL.md +156 -0
  99. package/runtime/prime/.codex/skills/view/agents/openai.yaml +4 -0
  100. package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +221 -0
  101. package/runtime/prime/.codex/skills/visual-explain/SKILL.md +45 -0
  102. package/runtime/prime/.codex/skills/wrap/SKILL.md +58 -0
  103. package/runtime/prime/.codex/spec-manifest.json +132 -0
  104. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +393 -0
  105. package/runtime/prime/.prime/agent/extensions/egregore.ts +460 -0
  106. package/runtime/prime/.prime/agent/settings.json +4 -0
  107. package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +34 -0
  108. package/runtime/prime/.prime/agent/skills/egregore/pyproject.toml +13 -0
  109. package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +156 -0
  110. package/runtime/prime/.prime/agent/spec-manifest.json +12 -0
  111. package/runtime/prime/AGENTS.md +489 -0
  112. package/runtime/prime/bin/activity-data.sh +238 -0
  113. package/runtime/prime/bin/agent.sh +775 -0
  114. package/runtime/prime/bin/attendant.sh +707 -0
  115. package/runtime/prime/bin/autosave.sh +112 -0
  116. package/runtime/prime/bin/capture-reconcile.sh +69 -0
  117. package/runtime/prime/bin/capture-run.sh +341 -0
  118. package/runtime/prime/bin/codex-install-skills.sh +78 -0
  119. package/runtime/prime/bin/codex-render-spec.mjs +440 -0
  120. package/runtime/prime/bin/codex-session-start.sh +265 -0
  121. package/runtime/prime/bin/codex-skill-render.mjs +347 -0
  122. package/runtime/prime/bin/codex-sync-skills.sh +232 -0
  123. package/runtime/prime/bin/connect-refresh.sh +33 -0
  124. package/runtime/prime/bin/dashboard-data.sh +178 -0
  125. package/runtime/prime/bin/graph-batch.sh +79 -0
  126. package/runtime/prime/bin/graph-op.sh +1102 -0
  127. package/runtime/prime/bin/graph-wal.sh +229 -0
  128. package/runtime/prime/bin/graph.sh +223 -0
  129. package/runtime/prime/bin/handoff-pr-backfill.sh +86 -0
  130. package/runtime/prime/bin/handoff-run.sh +671 -0
  131. package/runtime/prime/bin/handoff-save-egregore.sh +212 -0
  132. package/runtime/prime/bin/index-handoff.sh +322 -0
  133. package/runtime/prime/bin/ingest-graph.sh +80 -0
  134. package/runtime/prime/bin/ingest-surface.html +495 -0
  135. package/runtime/prime/bin/ingest.py +756 -0
  136. package/runtime/prime/bin/ingest.sh +279 -0
  137. package/runtime/prime/bin/ingest_graph.py +856 -0
  138. package/runtime/prime/bin/ingest_surface.py +544 -0
  139. package/runtime/prime/bin/lib/artifact-id.sh +46 -0
  140. package/runtime/prime/bin/lib/config.sh +116 -0
  141. package/runtime/prime/bin/lib/context.sh +621 -0
  142. package/runtime/prime/bin/lib/dashboard-artifact.sh +242 -0
  143. package/runtime/prime/bin/lib/ensure-develop.sh +183 -0
  144. package/runtime/prime/bin/lib/git-sync.sh +406 -0
  145. package/runtime/prime/bin/lib/greeting.sh +519 -0
  146. package/runtime/prime/bin/lib/hash.sh +13 -0
  147. package/runtime/prime/bin/lib/identity.sh +213 -0
  148. package/runtime/prime/bin/lib/metrics.sh +374 -0
  149. package/runtime/prime/bin/lib/noncode.sh +31 -0
  150. package/runtime/prime/bin/lib/permissions.sh +106 -0
  151. package/runtime/prime/bin/lib/time.sh +56 -0
  152. package/runtime/prime/bin/lib/worktree-links.sh +79 -0
  153. package/runtime/prime/bin/notify.sh +636 -0
  154. package/runtime/prime/bin/person.py +630 -0
  155. package/runtime/prime/bin/person.sh +448 -0
  156. package/runtime/prime/bin/pi-branch-policy.mjs +312 -0
  157. package/runtime/prime/bin/pi-product-workflows.mjs +394 -0
  158. package/runtime/prime/bin/prime-render-spec.mjs +123 -0
  159. package/runtime/prime/bin/prime-session-start.sh +24 -0
  160. package/runtime/prime/bin/publish-artifact.sh +271 -0
  161. package/runtime/prime/bin/publish-references.sh +144 -0
  162. package/runtime/prime/bin/repo-state.sh +96 -0
  163. package/runtime/prime/bin/search.sh +240 -0
  164. package/runtime/prime/bin/session-autosave.sh +209 -0
  165. package/runtime/prime/bin/session-log.sh +199 -0
  166. package/runtime/prime/bin/settings.sh +304 -0
  167. package/runtime/prime/bin/telemetry.sh +391 -0
  168. package/runtime/prime/bin/worktree-create.sh +128 -0
  169. package/runtime/prime/bin/worktree-remove.sh +50 -0
  170. package/runtime/prime/bin/worktree.sh +198 -0
  171. package/runtime/prime/manifest.json +178 -0
@@ -0,0 +1,636 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Human-consented notification transport.
5
+ #
6
+ # No command in this file can plan and dispatch in one step:
7
+ # 1. plan — resolve exact destinations and persist immutable content
8
+ # 2. approve — record one explicit human approval for that exact digest
9
+ # 3. dispatch — consume the one-use approval and send the stored payload
10
+ #
11
+ # Legacy `send` / `group` commands only create a plan and exit 4. They never
12
+ # dispatch. This makes the invariant fail closed for stale skills and jobs.
13
+
14
+ SCRIPT_DIR="${EGREGORE_NOTIFY_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
15
+ CONFIG="$SCRIPT_DIR/egregore.json"
16
+
17
+ [ -f "$CONFIG" ] || {
18
+ echo "Error: egregore.json not found." >&2
19
+ exit 1
20
+ }
21
+
22
+ umask 077
23
+
24
+ MODE="$(jq -r '.mode // "connected"' "$CONFIG" 2>/dev/null || echo connected)"
25
+ ORG_SLUG="$(jq -r '.slug // empty' "$CONFIG" 2>/dev/null || true)"
26
+ ORG_NAME="$(jq -r '.org_name // .slug // empty' "$CONFIG" 2>/dev/null || true)"
27
+ SESSION_ID="$(sed -n '1p' "$SCRIPT_DIR/.egregore-session-id" 2>/dev/null || true)"
28
+ SESSION_ID="${SESSION_ID:-no-session}"
29
+ PROJECT_HASH="$(printf '%s' "$SCRIPT_DIR" | cksum | awk '{print $1}')"
30
+ STATE_DIR="${EGREGORE_NOTIFY_STATE_DIR:-$HOME/.egregore/notifications/$PROJECT_HASH}"
31
+ RELAY_URL="${EGREGORE_NOTIFY_RELAY_URL:-https://egregore-production-55f2.up.railway.app}"
32
+ mkdir -p "$STATE_DIR"
33
+ chmod 700 "$STATE_DIR" 2>/dev/null || true
34
+
35
+ if [ -f "$SCRIPT_DIR/.env" ]; then
36
+ EGREGORE_API_URL="${EGREGORE_API_URL:-$(grep '^EGREGORE_API_URL=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
37
+ EGREGORE_API_KEY="${EGREGORE_API_KEY:-$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
38
+ SLACK_BOT_TOKEN="${SLACK_BOT_TOKEN:-$(grep '^SLACK_BOT_TOKEN=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
39
+ fi
40
+ API_URL="${EGREGORE_API_URL:-$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)}"
41
+ API_KEY="${EGREGORE_API_KEY:-}"
42
+
43
+ die() {
44
+ echo "Error: $*" >&2
45
+ exit 1
46
+ }
47
+
48
+ hash_text() {
49
+ if command -v shasum >/dev/null 2>&1; then
50
+ shasum -a 256 | awk '{print $1}'
51
+ else
52
+ sha256sum | awk '{print $1}'
53
+ fi
54
+ }
55
+
56
+ random_token() {
57
+ openssl rand -hex 16
58
+ }
59
+
60
+ plan_path() {
61
+ local id="$1"
62
+ case "$id" in
63
+ ""|*[!a-f0-9]*) die "invalid notification plan id" ;;
64
+ esac
65
+ printf '%s/%s.json\n' "$STATE_DIR" "$id"
66
+ }
67
+
68
+ read_plan() {
69
+ local id="$1"
70
+ local path
71
+ path="$(plan_path "$id")"
72
+ [ -f "$path" ] || die "notification plan not found: $id"
73
+ jq -e . "$path" >/dev/null 2>&1 || die "notification plan is unreadable: $id"
74
+ printf '%s\n' "$path"
75
+ }
76
+
77
+ canonical_plan() {
78
+ jq -cS '{
79
+ version,
80
+ id,
81
+ session_id,
82
+ mode,
83
+ org_slug,
84
+ org_name,
85
+ kind,
86
+ recipient,
87
+ message,
88
+ channels,
89
+ deliveries,
90
+ no_fallback,
91
+ expires_at,
92
+ server_plan_token
93
+ }' "$1"
94
+ }
95
+
96
+ verify_digest() {
97
+ local path="$1"
98
+ local stored actual
99
+ stored="$(jq -r '.digest' "$path")"
100
+ actual="$(canonical_plan "$path" | hash_text)"
101
+ [ "$stored" = "$actual" ] || die "notification plan changed after preview"
102
+ }
103
+
104
+ verify_live_plan() {
105
+ local path="$1"
106
+ local plan_session plan_mode expires now
107
+ plan_session="$(jq -r '.session_id' "$path")"
108
+ [ "$plan_session" = "$SESSION_ID" ] ||
109
+ die "notification approval belongs to another session"
110
+ plan_mode="$(jq -r '.mode' "$path")"
111
+ [ "$plan_mode" = "$MODE" ] ||
112
+ die "notification configuration changed; prepare it again"
113
+ expires="$(jq -r '.expires_at' "$path")"
114
+ now="$(date +%s)"
115
+ [ "$expires" -ge "$now" ] || die "notification plan expired; prepare it again"
116
+ verify_digest "$path"
117
+ }
118
+
119
+ write_plan() {
120
+ local plan_response="$1"
121
+ local kind="$2"
122
+ local recipient="$3"
123
+ local message="$4"
124
+ local id created expires path tmp
125
+ id="$(random_token | cut -c1-24)"
126
+ created="$(date +%s)"
127
+ expires="$(printf '%s' "$plan_response" | jq -r '.expires_at // empty')"
128
+ expires="${expires:-$((created + 600))}"
129
+ path="$(plan_path "$id")"
130
+ tmp="$path.tmp"
131
+
132
+ printf '%s' "$plan_response" | jq \
133
+ --arg id "$id" \
134
+ --arg session "$SESSION_ID" \
135
+ --arg mode "$MODE" \
136
+ --arg slug "$ORG_SLUG" \
137
+ --arg orgName "$ORG_NAME" \
138
+ --arg kind "$kind" \
139
+ --arg recipient "$recipient" \
140
+ --arg message "$message" \
141
+ --argjson created "$created" \
142
+ --argjson expires "$expires" \
143
+ '{
144
+ version: 1,
145
+ id: $id,
146
+ status: "proposed",
147
+ session_id: $session,
148
+ mode: $mode,
149
+ org_slug: (.org_slug // $slug),
150
+ org_name: (.org_name // $orgName),
151
+ kind: $kind,
152
+ recipient: (if $recipient == "" then null else $recipient end),
153
+ message: $message,
154
+ channels: (.channels // []),
155
+ deliveries: (.deliveries // []),
156
+ no_fallback: true,
157
+ created_at: $created,
158
+ expires_at: $expires,
159
+ server_plan_token: (.plan_token // ""),
160
+ digest: ""
161
+ }' > "$tmp"
162
+
163
+ local digest
164
+ digest="$(canonical_plan "$tmp" | hash_text)"
165
+ jq --arg digest "$digest" '.digest = $digest' "$tmp" > "$path"
166
+ rm -f "$tmp"
167
+ chmod 600 "$path" 2>/dev/null || true
168
+ jq '{
169
+ status: "approval_required",
170
+ plan_id: .id,
171
+ digest,
172
+ org: .org_name,
173
+ recipient,
174
+ channels,
175
+ deliveries,
176
+ no_fallback,
177
+ message,
178
+ expires_at
179
+ }' "$path"
180
+ }
181
+
182
+ # Channel-generic despite the name: reads any field from .egregore-state.json
183
+ # with egregore.json as the legacy fallback (telegram_*, slack_*, …).
184
+ read_local_telegram_config() {
185
+ local field="$1"
186
+ local state_file="$SCRIPT_DIR/.egregore-state.json"
187
+ local value
188
+ value="$(jq -r ".$field // empty" "$state_file" 2>/dev/null || true)"
189
+ [ -n "$value" ] ||
190
+ value="$(jq -r ".$field // empty" "$CONFIG" 2>/dev/null || true)"
191
+ printf '%s\n' "$value"
192
+ }
193
+
194
+ plan_connected() {
195
+ local kind="$1"
196
+ local recipient="$2"
197
+ local message="$3"
198
+ [ -n "$API_URL" ] && [ -n "$API_KEY" ] ||
199
+ die "notification service is not connected"
200
+
201
+ local response
202
+ response="$(curl -sS -X POST "${API_URL}/api/notify/plan" \
203
+ -H "Authorization: Bearer $API_KEY" \
204
+ -H "Content-Type: application/json" \
205
+ -d "$(jq -nc \
206
+ --arg kind "$kind" \
207
+ --arg to "$recipient" \
208
+ --arg message "$message" \
209
+ '{kind:$kind, message:$message}
210
+ + (if $to == "" then {} else {to:$to} end)')" \
211
+ --max-time 10)" || die "notification planning failed"
212
+
213
+ printf '%s' "$response" | jq -e '.status == "planned"' >/dev/null 2>&1 ||
214
+ die "$(printf '%s' "$response" | jq -r '.detail // "notification planning failed"' 2>/dev/null)"
215
+ write_plan "$response" "$kind" "$recipient" "$message"
216
+ }
217
+
218
+ plan_local() {
219
+ local kind="$1"
220
+ local recipient="$2"
221
+ local message="$3"
222
+ if [ "$kind" = "send" ]; then
223
+ die "direct messages are unavailable in local mode; no group fallback was sent"
224
+ fi
225
+
226
+ local chat_id group_link slack_channel deliveries
227
+ chat_id="$(read_local_telegram_config telegram_chat_id)"
228
+ group_link="$(read_local_telegram_config telegram_group_link)"
229
+ slack_channel="$(read_local_telegram_config slack_channel_id)"
230
+
231
+ deliveries="[]"
232
+ if [ -n "$chat_id" ] || [ -n "$group_link" ]; then
233
+ deliveries="$(printf '%s' "$deliveries" | jq -c \
234
+ --arg destination "${group_link:-$chat_id}" \
235
+ '. + [{channel:"telegram", destination:$destination, kind:"group"}]')"
236
+ fi
237
+ # Slack is local-direct (the org owns the app and token): plan it only when
238
+ # both halves of the config exist, so a half-configured Slack fails soft.
239
+ if [ -n "$slack_channel" ] && [ -n "${SLACK_BOT_TOKEN:-}" ]; then
240
+ deliveries="$(printf '%s' "$deliveries" | jq -c \
241
+ --arg destination "$slack_channel" \
242
+ '. + [{channel:"slack", destination:$destination, kind:"group"}]')"
243
+ fi
244
+ [ "$deliveries" != "[]" ] ||
245
+ die "no group notification channel is configured"
246
+
247
+ local response
248
+ response="$(jq -nc \
249
+ --arg slug "$ORG_SLUG" \
250
+ --arg org "$ORG_NAME" \
251
+ --argjson deliveries "$deliveries" \
252
+ '{
253
+ status:"planned",
254
+ org_slug:$slug,
255
+ org_name:$org,
256
+ channels:($deliveries | map(.channel)),
257
+ deliveries:$deliveries,
258
+ no_fallback:true
259
+ }')"
260
+ write_plan "$response" "$kind" "$recipient" "$message"
261
+ }
262
+
263
+ plan_notification() {
264
+ local kind="$1"
265
+ local recipient="$2"
266
+ local message="$3"
267
+ [ -n "$message" ] || die "notification message is empty"
268
+ case "$MODE" in
269
+ connected) plan_connected "$kind" "$recipient" "$message" ;;
270
+ local) plan_local "$kind" "$recipient" "$message" ;;
271
+ *) die "notifications are unavailable in this configuration" ;;
272
+ esac
273
+ }
274
+
275
+ approve_plan() {
276
+ local id="$1"
277
+ local supplied_digest="$2"
278
+ local confirmation="$3"
279
+ [ "$confirmation" = "APPROVE_EXACT_NOTIFICATION" ] ||
280
+ die "explicit notification confirmation is required"
281
+
282
+ local path status digest token token_hash tmp
283
+ path="$(read_plan "$id")"
284
+ verify_live_plan "$path"
285
+ status="$(jq -r '.status' "$path")"
286
+ [ "$status" = "proposed" ] || die "notification plan is not awaiting approval"
287
+ digest="$(jq -r '.digest' "$path")"
288
+ [ "$supplied_digest" = "$digest" ] ||
289
+ die "approval does not match the previewed notification"
290
+
291
+ token="$(random_token)"
292
+ token_hash="$(printf '%s' "$token" | hash_text)"
293
+ tmp="$path.tmp"
294
+ jq \
295
+ --arg tokenHash "$token_hash" \
296
+ --argjson approvedAt "$(date +%s)" \
297
+ '.status = "approved"
298
+ | .approval_token_hash = $tokenHash
299
+ | .approved_at = $approvedAt' "$path" > "$tmp"
300
+ mv "$tmp" "$path"
301
+ jq -nc --arg id "$id" --arg token "$token" \
302
+ '{status:"approved", plan_id:$id, approval_token:$token}'
303
+ }
304
+
305
+ mark_plan() {
306
+ local path="$1"
307
+ local status="$2"
308
+ local detail="${3:-}"
309
+ local tmp="$path.tmp"
310
+ jq \
311
+ --arg status "$status" \
312
+ --arg detail "$detail" \
313
+ --argjson changedAt "$(date +%s)" \
314
+ '.status = $status
315
+ | .status_detail = (if $detail == "" then null else $detail end)
316
+ | .status_changed_at = $changedAt' "$path" > "$tmp"
317
+ mv "$tmp" "$path"
318
+ }
319
+
320
+ dispatch_connected() {
321
+ local path="$1"
322
+ local kind message plan_token response
323
+ kind="$(jq -r '.kind' "$path")"
324
+ message="$(jq -r '.message' "$path")"
325
+ plan_token="$(jq -r '.server_plan_token' "$path")"
326
+
327
+ if [ "$kind" = "send" ]; then
328
+ local recipient channel
329
+ recipient="$(jq -r '.recipient' "$path")"
330
+ channel="$(jq -r '.channels[0]' "$path")"
331
+ response="$(curl -sS -X POST "${API_URL}/api/notify/send" \
332
+ -H "Authorization: Bearer $API_KEY" \
333
+ -H "Content-Type: application/json" \
334
+ -d "$(jq -nc \
335
+ --arg to "$recipient" \
336
+ --arg message "$message" \
337
+ --arg channel "$channel" \
338
+ --arg planToken "$plan_token" \
339
+ '{to:$to,message:$message,channel:$channel,plan_token:$planToken}')" \
340
+ --max-time 10)" || return 1
341
+ else
342
+ local channels
343
+ channels="$(jq -c '.channels' "$path")"
344
+ response="$(curl -sS -X POST "${API_URL}/api/notify/group" \
345
+ -H "Authorization: Bearer $API_KEY" \
346
+ -H "Content-Type: application/json" \
347
+ -d "$(jq -nc \
348
+ --arg message "$message" \
349
+ --arg planToken "$plan_token" \
350
+ --argjson channels "$channels" \
351
+ '{message:$message,channels:$channels,plan_token:$planToken}')" \
352
+ --max-time 10)" || return 1
353
+ fi
354
+
355
+ printf '%s' "$response" | jq -e '.status == "sent"' >/dev/null 2>&1 || {
356
+ printf '%s\n' "$response"
357
+ return 1
358
+ }
359
+ printf '%s\n' "$response"
360
+ }
361
+
362
+ dispatch_local_telegram() {
363
+ local destination="$1"
364
+ local message="$2"
365
+ local plan_slug="$3"
366
+ local plan_org="$4"
367
+ local chat_id group_link
368
+ case "$destination" in
369
+ https://t.me/*)
370
+ chat_id=""
371
+ group_link="$destination"
372
+ ;;
373
+ *)
374
+ chat_id="$destination"
375
+ group_link=""
376
+ ;;
377
+ esac
378
+ curl -sS -X POST "${RELAY_URL}/api/notify/relay" \
379
+ -H "Content-Type: application/json" \
380
+ -d "$(jq -nc \
381
+ --arg chat_id "$chat_id" \
382
+ --arg group_link "$group_link" \
383
+ --arg message "$message" \
384
+ --arg slug "$plan_slug" \
385
+ --arg org_name "$plan_org" \
386
+ '{chat_id:$chat_id,group_link:$group_link,message:$message,slug:$slug,org_name:$org_name}')" \
387
+ --max-time 10
388
+ }
389
+
390
+ # Local-direct: the org owns the Slack app and token, so the message goes
391
+ # straight from this machine to Slack — never through the Egregore relay.
392
+ dispatch_local_slack() {
393
+ local destination="$1"
394
+ local message="$2"
395
+ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
396
+ jq -nc '{status:"error",detail:"SLACK_BOT_TOKEN is missing from .env"}'
397
+ return 0
398
+ fi
399
+ local response
400
+ response="$(curl -sS -X POST "https://slack.com/api/chat.postMessage" \
401
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
402
+ -H "Content-Type: application/json; charset=utf-8" \
403
+ -d "$(jq -nc \
404
+ --arg channel "$destination" \
405
+ --arg text "$message" \
406
+ '{channel:$channel,text:$text,unfurl_links:false}')" \
407
+ --max-time 10)" || {
408
+ jq -nc '{status:"error",detail:"Slack request failed"}'
409
+ return 0
410
+ }
411
+ if printf '%s' "$response" | jq -e '.ok == true' >/dev/null 2>&1; then
412
+ jq -nc '{status:"sent"}'
413
+ else
414
+ jq -nc \
415
+ --arg detail "$(printf '%s' "$response" | jq -r '.error // "unknown"' 2>/dev/null || echo unknown)" \
416
+ '{status:"error",detail:$detail}'
417
+ fi
418
+ }
419
+
420
+ dispatch_local() {
421
+ local path="$1"
422
+ local message plan_slug plan_org count i channel destination result
423
+ message="$(jq -r '.message' "$path")"
424
+ plan_slug="$(jq -r '.org_slug' "$path")"
425
+ plan_org="$(jq -r '.org_name' "$path")"
426
+ count="$(jq -r '.deliveries | length' "$path")"
427
+
428
+ # One platform failing must not mask another succeeding (mirror of the
429
+ # server-side send_group semantics).
430
+ local results="{}"
431
+ i=0
432
+ while [ "$i" -lt "$count" ]; do
433
+ channel="$(jq -r ".deliveries[$i].channel" "$path")"
434
+ destination="$(jq -r ".deliveries[$i].destination" "$path")"
435
+ case "$channel" in
436
+ telegram)
437
+ result="$(dispatch_local_telegram "$destination" "$message" "$plan_slug" "$plan_org")" ||
438
+ result='{"status":"error","detail":"relay request failed"}'
439
+ printf '%s' "$result" | jq -e . >/dev/null 2>&1 ||
440
+ result='{"status":"error","detail":"relay returned an unreadable response"}'
441
+ ;;
442
+ slack)
443
+ result="$(dispatch_local_slack "$destination" "$message")"
444
+ ;;
445
+ *)
446
+ result='{"status":"error","detail":"Unsupported channel"}'
447
+ ;;
448
+ esac
449
+ results="$(printf '%s' "$results" | jq -c --arg ch "$channel" --argjson r "$result" '. + {($ch): $r}')"
450
+ i=$((i + 1))
451
+ done
452
+
453
+ local sent
454
+ sent="$(printf '%s' "$results" | jq -c '[to_entries[] | select(.value.status == "sent") | .key]')"
455
+ if [ "$sent" = "[]" ]; then
456
+ printf '%s\n' "$results"
457
+ return 1
458
+ fi
459
+ printf '%s' "$results" | jq -c --argjson sent "$sent" \
460
+ '{status:"sent", platforms:$sent}
461
+ + ([to_entries[] | select(.value.status != "sent")] as $f
462
+ | if ($f | length) > 0
463
+ then {failed: ($f | map({(.key): (.value.detail // "unknown")}) | add)}
464
+ else {} end)'
465
+ }
466
+
467
+ dispatch_plan() {
468
+ local id="$1"
469
+ local approval_token="$2"
470
+ local path status expected_hash actual_hash lock response
471
+ path="$(read_plan "$id")"
472
+ verify_live_plan "$path"
473
+ status="$(jq -r '.status' "$path")"
474
+ [ "$status" = "approved" ] || die "notification plan has not been explicitly approved"
475
+ expected_hash="$(jq -r '.approval_token_hash' "$path")"
476
+ actual_hash="$(printf '%s' "$approval_token" | hash_text)"
477
+ [ "$expected_hash" = "$actual_hash" ] || die "notification approval token is invalid"
478
+
479
+ lock="$path.lock"
480
+ mkdir "$lock" 2>/dev/null || die "notification dispatch is already in progress"
481
+ NOTIFY_LOCK="$lock"
482
+ trap '[ -z "${NOTIFY_LOCK:-}" ] || rmdir "$NOTIFY_LOCK" 2>/dev/null || true' EXIT
483
+
484
+ # Consume approval before the network call. An uncertain failure requires a
485
+ # new plan rather than risking a duplicate external message.
486
+ mark_plan "$path" "dispatching"
487
+ if [ "$MODE" = "connected" ]; then
488
+ if ! response="$(dispatch_connected "$path")"; then
489
+ mark_plan "$path" "failed" "dispatch failed; prepare and approve a new plan"
490
+ die "notification dispatch failed; no fallback was attempted"
491
+ fi
492
+ else
493
+ if ! response="$(dispatch_local "$path")"; then
494
+ mark_plan "$path" "failed" "dispatch failed; prepare and approve a new plan"
495
+ die "notification dispatch failed; no fallback was attempted"
496
+ fi
497
+ fi
498
+
499
+ mark_plan "$path" "sent"
500
+ rmdir "$lock" 2>/dev/null || true
501
+ NOTIFY_LOCK=""
502
+ trap - EXIT
503
+ bash "$SCRIPT_DIR/bin/telemetry.sh" emit "notification" \
504
+ "$(jq -nc --arg kind "$(jq -r '.kind' "$path")" \
505
+ '{type:$kind,status:"sent",consent:"explicit"}')" 2>/dev/null &
506
+ jq -nc \
507
+ --arg id "$id" \
508
+ --argjson result "$response" \
509
+ '{status:"sent",plan_id:$id,result:$result}'
510
+ }
511
+
512
+ show_plan() {
513
+ local path
514
+ path="$(read_plan "$1")"
515
+ jq '{
516
+ status,
517
+ plan_id:.id,
518
+ digest,
519
+ org:.org_name,
520
+ recipient,
521
+ channels,
522
+ deliveries,
523
+ no_fallback,
524
+ message,
525
+ expires_at
526
+ }' "$path"
527
+ }
528
+
529
+ cancel_plan() {
530
+ local path status
531
+ path="$(read_plan "$1")"
532
+ status="$(jq -r '.status' "$path")"
533
+ case "$status" in
534
+ proposed|approved|failed) mark_plan "$path" "cancelled" ;;
535
+ *) die "notification plan cannot be cancelled from status: $status" ;;
536
+ esac
537
+ jq -nc --arg id "$1" '{status:"cancelled",plan_id:$id}'
538
+ }
539
+
540
+ list_pending() {
541
+ local found="false"
542
+ for path in "$STATE_DIR"/*.json; do
543
+ [ -f "$path" ] || continue
544
+ if jq -e '.status == "proposed" or .status == "approved"' "$path" >/dev/null 2>&1; then
545
+ jq -c '{plan_id:.id,status,org:.org_name,recipient,channels,message,expires_at}' "$path"
546
+ found="true"
547
+ fi
548
+ done
549
+ [ "$found" = "true" ] || echo '{"status":"empty"}'
550
+ }
551
+
552
+ test_connection() {
553
+ if [ "$MODE" = "connected" ]; then
554
+ [ -n "$API_URL" ] && [ -n "$API_KEY" ] ||
555
+ die "notification service is not connected"
556
+ curl -sS -X GET "${API_URL}/api/notify/test" \
557
+ -H "Authorization: Bearer $API_KEY" \
558
+ --max-time 10
559
+ elif [ "$MODE" = "local" ]; then
560
+ local group_link slack_channel
561
+ group_link="$(read_local_telegram_config telegram_group_link)"
562
+ slack_channel="$(read_local_telegram_config slack_channel_id)"
563
+ if [ -n "$group_link" ] ||
564
+ { [ -n "$slack_channel" ] && [ -n "${SLACK_BOT_TOKEN:-}" ]; }; then
565
+ echo '{"status":"configured","mode":"local"}'
566
+ else
567
+ echo '{"status":"offline","reason":"no_notification_channel"}'
568
+ fi
569
+ else
570
+ echo '{"status":"offline","reason":"no_api_key"}'
571
+ fi
572
+ }
573
+
574
+ case "${1:-help}" in
575
+ plan)
576
+ case "${2:-}" in
577
+ send)
578
+ [ "$#" -eq 4 ] || die "Usage: notify.sh plan send <recipient> <message>"
579
+ plan_notification "send" "$3" "$4"
580
+ ;;
581
+ group)
582
+ [ "$#" -eq 3 ] || die "Usage: notify.sh plan group <message>"
583
+ plan_notification "group" "" "$3"
584
+ ;;
585
+ *) die "Usage: notify.sh plan send|group ..." ;;
586
+ esac
587
+ ;;
588
+ approve)
589
+ [ "$#" -eq 4 ] || die "Usage: notify.sh approve <plan-id> <digest> APPROVE_EXACT_NOTIFICATION"
590
+ approve_plan "$2" "$3" "$4"
591
+ ;;
592
+ dispatch)
593
+ [ "$#" -eq 3 ] || die "Usage: notify.sh dispatch <plan-id> <approval-token>"
594
+ dispatch_plan "$2" "$3"
595
+ ;;
596
+ show)
597
+ [ "$#" -eq 2 ] || die "Usage: notify.sh show <plan-id>"
598
+ show_plan "$2"
599
+ ;;
600
+ cancel)
601
+ [ "$#" -eq 2 ] || die "Usage: notify.sh cancel <plan-id>"
602
+ cancel_plan "$2"
603
+ ;;
604
+ pending)
605
+ list_pending
606
+ ;;
607
+ send)
608
+ [ "$#" -eq 3 ] || die "Usage: notify.sh send <recipient> <message>"
609
+ plan_notification "send" "$2" "$3"
610
+ echo "Approval required — no notification was sent." >&2
611
+ exit 4
612
+ ;;
613
+ group)
614
+ [ "$#" -eq 2 ] || die "Usage: notify.sh group <message>"
615
+ plan_notification "group" "" "$2"
616
+ echo "Approval required — no notification was sent." >&2
617
+ exit 4
618
+ ;;
619
+ test)
620
+ test_connection
621
+ ;;
622
+ help|*)
623
+ echo "Usage: notify.sh <command>"
624
+ echo ""
625
+ echo "Commands:"
626
+ echo " plan send <name> <message> Resolve a DM without sending"
627
+ echo " plan group <message> Resolve group channels without sending"
628
+ echo " approve <id> <digest> APPROVE_EXACT_NOTIFICATION"
629
+ echo " Record one exact human approval"
630
+ echo " dispatch <id> <token> Consume approval and send stored content"
631
+ echo " show <id> Show exact pending content"
632
+ echo " cancel <id> Cancel a pending plan"
633
+ echo " pending List pending plans"
634
+ echo " test Test configuration without sending"
635
+ ;;
636
+ esac