create-egregore 0.19.20 → 0.19.22

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 (226) hide show
  1. package/assets/egregore-launcher.js +124 -12
  2. package/bin/cli.js +3 -4
  3. package/lib/auth.js +3 -4
  4. package/lib/path-lookup.js +59 -0
  5. package/lib/setup.js +52 -6
  6. package/package.json +2 -2
  7. package/runtime/codex/.claude/context/notification-consent.md +66 -0
  8. package/runtime/codex/.codex/hooks/branch-guard.js +14 -3
  9. package/runtime/codex/.codex/skills/add/SKILL.md +8 -4
  10. package/runtime/codex/.codex/skills/announce/SKILL.md +21 -8
  11. package/runtime/codex/.codex/skills/answer/SKILL.md +8 -4
  12. package/runtime/codex/.codex/skills/archive/SKILL.md +8 -4
  13. package/runtime/codex/.codex/skills/ask/SKILL.md +14 -6
  14. package/runtime/codex/.codex/skills/audit/SKILL.md +29 -0
  15. package/runtime/codex/.codex/skills/autosave/SKILL.md +29 -0
  16. package/runtime/codex/.codex/skills/branch/SKILL.md +8 -4
  17. package/runtime/codex/.codex/skills/checkup/SKILL.md +8 -4
  18. package/runtime/codex/.codex/skills/commit/SKILL.md +8 -4
  19. package/runtime/codex/.codex/skills/contribute/SKILL.md +8 -4
  20. package/runtime/codex/.codex/skills/dark-mode/SKILL.md +8 -4
  21. package/runtime/codex/.codex/skills/delete-user/SKILL.md +8 -4
  22. package/runtime/codex/.codex/skills/demo-mode/SKILL.md +29 -0
  23. package/runtime/codex/.codex/skills/deploy-bridge/SKILL.md +8 -4
  24. package/runtime/codex/.codex/skills/design/SKILL.md +29 -0
  25. package/runtime/codex/.codex/skills/egregore-voice/SKILL.md +8 -4
  26. package/runtime/codex/.codex/skills/emissary/SKILL.md +8 -4
  27. package/runtime/codex/.codex/skills/env/SKILL.md +8 -4
  28. package/runtime/codex/.codex/skills/handoff/SKILL.md +15 -6
  29. package/runtime/codex/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
  30. package/runtime/codex/.codex/skills/harvest/SKILL.md +30 -6
  31. package/runtime/codex/.codex/skills/ingest/SKILL.md +8 -4
  32. package/runtime/codex/.codex/skills/ingest-google/SKILL.md +8 -4
  33. package/runtime/codex/.codex/skills/ingest-user-interview/SKILL.md +8 -4
  34. package/runtime/codex/.codex/skills/invite/SKILL.md +6 -2
  35. package/runtime/codex/.codex/skills/issue/SKILL.md +8 -4
  36. package/runtime/codex/.codex/skills/me/SKILL.md +8 -4
  37. package/runtime/codex/.codex/skills/meeting/SKILL.md +8 -4
  38. package/runtime/codex/.codex/skills/minion/SKILL.md +8 -4
  39. package/runtime/codex/.codex/skills/mock/SKILL.md +29 -0
  40. package/runtime/codex/.codex/skills/note/SKILL.md +8 -4
  41. package/runtime/codex/.codex/skills/onboarding/SKILL.md +8 -4
  42. package/runtime/codex/.codex/skills/pr/SKILL.md +8 -4
  43. package/runtime/codex/.codex/skills/product-voice/SKILL.md +29 -0
  44. package/runtime/codex/.codex/skills/project/SKILL.md +8 -4
  45. package/runtime/codex/.codex/skills/pull/SKILL.md +8 -4
  46. package/runtime/codex/.codex/skills/push/SKILL.md +8 -4
  47. package/runtime/codex/.codex/skills/quest/SKILL.md +6 -3
  48. package/runtime/codex/.codex/skills/quest-suggest/SKILL.md +8 -4
  49. package/runtime/codex/.codex/skills/reflect/SKILL.md +8 -4
  50. package/runtime/codex/.codex/skills/review-pr/SKILL.md +8 -4
  51. package/runtime/codex/.codex/skills/save/SKILL.md +6 -4
  52. package/runtime/codex/.codex/skills/scroll/SKILL.md +92 -23
  53. package/runtime/codex/.codex/skills/scroll/assets/shell.html +381 -52
  54. package/runtime/codex/.codex/skills/session-view/SKILL.md +8 -4
  55. package/runtime/codex/.codex/skills/setup/SKILL.md +8 -4
  56. package/runtime/codex/.codex/skills/summon/SKILL.md +8 -4
  57. package/runtime/codex/.codex/skills/sync-repos/SKILL.md +8 -4
  58. package/runtime/codex/.codex/skills/task/SKILL.md +9 -5
  59. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
  60. package/runtime/codex/.codex/skills/telegram-connect/SKILL.md +8 -4
  61. package/runtime/codex/.codex/skills/telemetry/SKILL.md +8 -4
  62. package/runtime/codex/.codex/skills/test/SKILL.md +8 -4
  63. package/runtime/codex/.codex/skills/thread/SKILL.md +8 -4
  64. package/runtime/codex/.codex/skills/todo/SKILL.md +8 -4
  65. package/runtime/codex/.codex/skills/tui-design/SKILL.md +8 -4
  66. package/runtime/codex/.codex/skills/tutorial/SKILL.md +8 -4
  67. package/runtime/codex/.codex/skills/update/SKILL.md +8 -4
  68. package/runtime/codex/.codex/skills/visual-explain/SKILL.md +8 -4
  69. package/runtime/codex/.codex/spec-manifest.json +4 -4
  70. package/runtime/codex/AGENTS.md +36 -14
  71. package/runtime/codex/bin/activity-data.sh +1 -1
  72. package/runtime/codex/bin/agent.sh +38 -19
  73. package/runtime/codex/bin/attendant.sh +8 -2
  74. package/runtime/codex/bin/autosave.sh +1 -1
  75. package/runtime/codex/bin/capture-reconcile.sh +8 -6
  76. package/runtime/codex/bin/capture-run.sh +1 -1
  77. package/runtime/codex/bin/codex-install-skills.sh +1 -1
  78. package/runtime/codex/bin/codex-render-spec.mjs +12 -5
  79. package/runtime/codex/bin/codex-session-start.sh +1 -1
  80. package/runtime/codex/bin/codex-sync-skills.sh +9 -5
  81. package/runtime/codex/bin/dashboard-data.sh +1 -1
  82. package/runtime/codex/bin/graph-batch.sh +1 -1
  83. package/runtime/codex/bin/graph-op.sh +1 -1
  84. package/runtime/codex/bin/graph-wal.sh +1 -1
  85. package/runtime/codex/bin/graph.sh +1 -1
  86. package/runtime/codex/bin/handoff-pr-backfill.sh +1 -1
  87. package/runtime/codex/bin/handoff-run.sh +53 -39
  88. package/runtime/codex/bin/handoff-save-egregore.sh +62 -32
  89. package/runtime/codex/bin/index-handoff.sh +1 -1
  90. package/runtime/codex/bin/ingest-graph.sh +1 -1
  91. package/runtime/codex/bin/ingest.sh +1 -1
  92. package/runtime/codex/bin/lib/artifact-id.sh +1 -1
  93. package/runtime/codex/bin/lib/config.sh +56 -20
  94. package/runtime/codex/bin/lib/context.sh +7 -1
  95. package/runtime/codex/bin/lib/ensure-develop.sh +1 -1
  96. package/runtime/codex/bin/lib/git-sync.sh +145 -59
  97. package/runtime/codex/bin/lib/greeting.sh +14 -0
  98. package/runtime/codex/bin/lib/hash.sh +1 -1
  99. package/runtime/codex/bin/lib/noncode.sh +1 -1
  100. package/runtime/codex/bin/lib/time.sh +1 -1
  101. package/runtime/codex/bin/notify.sh +492 -153
  102. package/runtime/codex/bin/person.sh +1 -1
  103. package/runtime/codex/bin/publish-artifact.sh +39 -4
  104. package/runtime/codex/bin/publish-references.sh +1 -1
  105. package/runtime/codex/bin/repo-state.sh +7 -10
  106. package/runtime/codex/bin/search.sh +1 -1
  107. package/runtime/codex/bin/session-autosave.sh +1 -1
  108. package/runtime/codex/bin/session-log.sh +1 -1
  109. package/runtime/codex/bin/settings.sh +32 -2
  110. package/runtime/codex/bin/telemetry.sh +1 -1
  111. package/runtime/codex/bin/worktree-create.sh +14 -4
  112. package/runtime/codex/bin/worktree-remove.sh +1 -1
  113. package/runtime/codex/bin/worktree.sh +1 -1
  114. package/runtime/codex/manifest.json +18 -1
  115. package/runtime/pi/.claude/context/notification-consent.md +66 -0
  116. package/runtime/pi/.codex/hooks/branch-guard.js +14 -3
  117. package/runtime/pi/.codex/skills/add/SKILL.md +8 -4
  118. package/runtime/pi/.codex/skills/announce/SKILL.md +21 -8
  119. package/runtime/pi/.codex/skills/answer/SKILL.md +8 -4
  120. package/runtime/pi/.codex/skills/archive/SKILL.md +8 -4
  121. package/runtime/pi/.codex/skills/ask/SKILL.md +14 -6
  122. package/runtime/pi/.codex/skills/audit/SKILL.md +29 -0
  123. package/runtime/pi/.codex/skills/autosave/SKILL.md +29 -0
  124. package/runtime/pi/.codex/skills/branch/SKILL.md +8 -4
  125. package/runtime/pi/.codex/skills/checkup/SKILL.md +8 -4
  126. package/runtime/pi/.codex/skills/commit/SKILL.md +8 -4
  127. package/runtime/pi/.codex/skills/contribute/SKILL.md +8 -4
  128. package/runtime/pi/.codex/skills/dark-mode/SKILL.md +8 -4
  129. package/runtime/pi/.codex/skills/delete-user/SKILL.md +8 -4
  130. package/runtime/pi/.codex/skills/demo-mode/SKILL.md +29 -0
  131. package/runtime/pi/.codex/skills/deploy-bridge/SKILL.md +8 -4
  132. package/runtime/pi/.codex/skills/design/SKILL.md +29 -0
  133. package/runtime/pi/.codex/skills/egregore-voice/SKILL.md +8 -4
  134. package/runtime/pi/.codex/skills/emissary/SKILL.md +8 -4
  135. package/runtime/pi/.codex/skills/env/SKILL.md +8 -4
  136. package/runtime/pi/.codex/skills/handoff/SKILL.md +15 -6
  137. package/runtime/pi/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
  138. package/runtime/pi/.codex/skills/harvest/SKILL.md +30 -6
  139. package/runtime/pi/.codex/skills/ingest/SKILL.md +8 -4
  140. package/runtime/pi/.codex/skills/ingest-google/SKILL.md +8 -4
  141. package/runtime/pi/.codex/skills/ingest-user-interview/SKILL.md +8 -4
  142. package/runtime/pi/.codex/skills/invite/SKILL.md +6 -2
  143. package/runtime/pi/.codex/skills/issue/SKILL.md +8 -4
  144. package/runtime/pi/.codex/skills/me/SKILL.md +8 -4
  145. package/runtime/pi/.codex/skills/meeting/SKILL.md +8 -4
  146. package/runtime/pi/.codex/skills/minion/SKILL.md +8 -4
  147. package/runtime/pi/.codex/skills/mock/SKILL.md +29 -0
  148. package/runtime/pi/.codex/skills/note/SKILL.md +8 -4
  149. package/runtime/pi/.codex/skills/onboarding/SKILL.md +8 -4
  150. package/runtime/pi/.codex/skills/pr/SKILL.md +8 -4
  151. package/runtime/pi/.codex/skills/product-voice/SKILL.md +29 -0
  152. package/runtime/pi/.codex/skills/project/SKILL.md +8 -4
  153. package/runtime/pi/.codex/skills/pull/SKILL.md +8 -4
  154. package/runtime/pi/.codex/skills/push/SKILL.md +8 -4
  155. package/runtime/pi/.codex/skills/quest/SKILL.md +6 -3
  156. package/runtime/pi/.codex/skills/quest-suggest/SKILL.md +8 -4
  157. package/runtime/pi/.codex/skills/reflect/SKILL.md +8 -4
  158. package/runtime/pi/.codex/skills/review-pr/SKILL.md +8 -4
  159. package/runtime/pi/.codex/skills/save/SKILL.md +6 -4
  160. package/runtime/pi/.codex/skills/scroll/SKILL.md +92 -23
  161. package/runtime/pi/.codex/skills/scroll/assets/shell.html +381 -52
  162. package/runtime/pi/.codex/skills/session-view/SKILL.md +8 -4
  163. package/runtime/pi/.codex/skills/setup/SKILL.md +8 -4
  164. package/runtime/pi/.codex/skills/summon/SKILL.md +8 -4
  165. package/runtime/pi/.codex/skills/sync-repos/SKILL.md +8 -4
  166. package/runtime/pi/.codex/skills/task/SKILL.md +9 -5
  167. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
  168. package/runtime/pi/.codex/skills/telegram-connect/SKILL.md +8 -4
  169. package/runtime/pi/.codex/skills/telemetry/SKILL.md +8 -4
  170. package/runtime/pi/.codex/skills/test/SKILL.md +8 -4
  171. package/runtime/pi/.codex/skills/thread/SKILL.md +8 -4
  172. package/runtime/pi/.codex/skills/todo/SKILL.md +8 -4
  173. package/runtime/pi/.codex/skills/tui-design/SKILL.md +8 -4
  174. package/runtime/pi/.codex/skills/tutorial/SKILL.md +8 -4
  175. package/runtime/pi/.codex/skills/update/SKILL.md +8 -4
  176. package/runtime/pi/.codex/skills/visual-explain/SKILL.md +8 -4
  177. package/runtime/pi/.codex/spec-manifest.json +4 -4
  178. package/runtime/pi/.pi/APPEND_SYSTEM.md +34 -12
  179. package/runtime/pi/.pi/extensions/egregore.ts +8 -1
  180. package/runtime/pi/.pi/spec-manifest.json +2 -2
  181. package/runtime/pi/AGENTS.md +36 -14
  182. package/runtime/pi/bin/activity-data.sh +1 -1
  183. package/runtime/pi/bin/agent.sh +38 -19
  184. package/runtime/pi/bin/attendant.sh +8 -2
  185. package/runtime/pi/bin/autosave.sh +1 -1
  186. package/runtime/pi/bin/capture-reconcile.sh +8 -6
  187. package/runtime/pi/bin/capture-run.sh +1 -1
  188. package/runtime/pi/bin/codex-install-skills.sh +1 -1
  189. package/runtime/pi/bin/codex-render-spec.mjs +12 -5
  190. package/runtime/pi/bin/codex-session-start.sh +1 -1
  191. package/runtime/pi/bin/codex-sync-skills.sh +9 -5
  192. package/runtime/pi/bin/dashboard-data.sh +1 -1
  193. package/runtime/pi/bin/graph-batch.sh +1 -1
  194. package/runtime/pi/bin/graph-op.sh +1 -1
  195. package/runtime/pi/bin/graph-wal.sh +1 -1
  196. package/runtime/pi/bin/graph.sh +1 -1
  197. package/runtime/pi/bin/handoff-pr-backfill.sh +1 -1
  198. package/runtime/pi/bin/handoff-run.sh +53 -39
  199. package/runtime/pi/bin/handoff-save-egregore.sh +62 -32
  200. package/runtime/pi/bin/index-handoff.sh +1 -1
  201. package/runtime/pi/bin/ingest-graph.sh +1 -1
  202. package/runtime/pi/bin/ingest.sh +1 -1
  203. package/runtime/pi/bin/lib/artifact-id.sh +1 -1
  204. package/runtime/pi/bin/lib/config.sh +56 -20
  205. package/runtime/pi/bin/lib/context.sh +7 -1
  206. package/runtime/pi/bin/lib/ensure-develop.sh +1 -1
  207. package/runtime/pi/bin/lib/git-sync.sh +145 -59
  208. package/runtime/pi/bin/lib/greeting.sh +14 -0
  209. package/runtime/pi/bin/lib/hash.sh +1 -1
  210. package/runtime/pi/bin/lib/noncode.sh +1 -1
  211. package/runtime/pi/bin/lib/time.sh +1 -1
  212. package/runtime/pi/bin/notify.sh +492 -153
  213. package/runtime/pi/bin/person.sh +1 -1
  214. package/runtime/pi/bin/pi-session-start.sh +1 -1
  215. package/runtime/pi/bin/publish-artifact.sh +39 -4
  216. package/runtime/pi/bin/publish-references.sh +1 -1
  217. package/runtime/pi/bin/repo-state.sh +7 -10
  218. package/runtime/pi/bin/search.sh +1 -1
  219. package/runtime/pi/bin/session-autosave.sh +1 -1
  220. package/runtime/pi/bin/session-log.sh +1 -1
  221. package/runtime/pi/bin/settings.sh +32 -2
  222. package/runtime/pi/bin/telemetry.sh +1 -1
  223. package/runtime/pi/bin/worktree-create.sh +14 -4
  224. package/runtime/pi/bin/worktree-remove.sh +1 -1
  225. package/runtime/pi/bin/worktree.sh +1 -1
  226. package/runtime/pi/manifest.json +18 -1
@@ -1,196 +1,530 @@
1
- #!/bin/bash
1
+ #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
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)}"
5
15
  CONFIG="$SCRIPT_DIR/egregore.json"
6
16
 
7
- if [ ! -f "$CONFIG" ]; then
17
+ [ -f "$CONFIG" ] || {
8
18
  echo "Error: egregore.json not found." >&2
9
19
  exit 1
10
- fi
20
+ }
11
21
 
12
- # --- Mode detection ---
13
- _MODE=$(jq -r '.mode // "connected"' "$CONFIG" 2>/dev/null)
22
+ umask 077
14
23
 
15
- # Public relay for local mode group messages.
16
- # No API key needed — rate-limited, group messages only.
17
- RELAY_URL="https://egregore-production-55f2.up.railway.app"
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
18
34
 
19
- # Load specific variables from .env if it exists (safe extraction, no arbitrary code execution)
20
35
  if [ -f "$SCRIPT_DIR/.env" ]; then
21
36
  EGREGORE_API_URL="${EGREGORE_API_URL:-$(grep '^EGREGORE_API_URL=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
22
37
  EGREGORE_API_KEY="${EGREGORE_API_KEY:-$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
23
38
  fi
24
-
25
- # Check if API mode or direct mode
26
- # api_url comes from egregore.json (committed, non-secret)
27
- # api_key comes from .env only (EGREGORE_API_KEY) — never from egregore.json
28
- API_URL="${EGREGORE_API_URL:-$(jq -r '.api_url // empty' "$CONFIG")}"
39
+ API_URL="${EGREGORE_API_URL:-$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)}"
29
40
  API_KEY="${EGREGORE_API_KEY:-}"
30
41
 
31
- if [ -n "$API_URL" ] && [ -n "$API_KEY" ]; then
32
- # === API MODE: Call Egregore API gateway ===
42
+ die() {
43
+ echo "Error: $*" >&2
44
+ exit 1
45
+ }
33
46
 
34
- send_to_person() {
35
- local name="$1"
36
- local message="$2"
47
+ hash_text() {
48
+ if command -v shasum >/dev/null 2>&1; then
49
+ shasum -a 256 | awk '{print $1}'
50
+ else
51
+ sha256sum | awk '{print $1}'
52
+ fi
53
+ }
37
54
 
38
- local response
39
- response=$(curl -s -X POST "${API_URL}/api/notify/send" \
40
- -H "Authorization: Bearer $API_KEY" \
41
- -H "Content-Type: application/json" \
42
- -d "$(jq -n --arg to "$name" --arg message "$message" \
43
- '{to: $to, message: $message}')" \
44
- --max-time 10)
45
-
46
- if echo "$response" | jq -e '.status == "sent"' >/dev/null 2>&1; then
47
- echo "Sent"
48
- bash "$SCRIPT_DIR/bin/telemetry.sh" emit "notification" \
49
- '{"type":"send","status":"sent"}' 2>/dev/null &
50
- else
51
- local detail
52
- detail=$(echo "$response" | jq -r '.detail // .status // "unknown error"')
53
- if echo "$detail" | grep -qi "forbidden\|can't initiate\|bot was blocked"; then
54
- echo "Failed: DM not available. Tell $name to message the Egregore bot first (/start)."
55
- else
56
- echo "Failed: $detail"
57
- fi
58
- bash "$SCRIPT_DIR/bin/telemetry.sh" emit "notification" \
59
- '{"type":"send","status":"failed"}' 2>/dev/null &
60
- fi
61
- }
55
+ random_token() {
56
+ openssl rand -hex 16
57
+ }
62
58
 
63
- send_to_group() {
64
- local message="$1"
59
+ plan_path() {
60
+ local id="$1"
61
+ case "$id" in
62
+ ""|*[!a-f0-9]*) die "invalid notification plan id" ;;
63
+ esac
64
+ printf '%s/%s.json\n' "$STATE_DIR" "$id"
65
+ }
65
66
 
66
- local response
67
- response=$(curl -s -X POST "${API_URL}/api/notify/group" \
68
- -H "Authorization: Bearer $API_KEY" \
69
- -H "Content-Type: application/json" \
70
- -d "$(jq -n --arg message "$message" '{message: $message}')" \
71
- --max-time 10)
67
+ read_plan() {
68
+ local id="$1"
69
+ local path
70
+ path="$(plan_path "$id")"
71
+ [ -f "$path" ] || die "notification plan not found: $id"
72
+ jq -e . "$path" >/dev/null 2>&1 || die "notification plan is unreadable: $id"
73
+ printf '%s\n' "$path"
74
+ }
72
75
 
73
- if echo "$response" | jq -e '.status == "sent"' >/dev/null 2>&1; then
74
- echo "Sent"
75
- bash "$SCRIPT_DIR/bin/telemetry.sh" emit "notification" \
76
- '{"type":"group","status":"sent"}' 2>/dev/null &
77
- else
78
- local detail
79
- detail=$(echo "$response" | jq -r '.detail // .status // "unknown error"')
80
- echo "Failed: $detail"
81
- bash "$SCRIPT_DIR/bin/telemetry.sh" emit "notification" \
82
- '{"type":"group","status":"failed"}' 2>/dev/null &
83
- fi
84
- }
76
+ canonical_plan() {
77
+ jq -cS '{
78
+ version,
79
+ id,
80
+ session_id,
81
+ mode,
82
+ org_slug,
83
+ org_name,
84
+ kind,
85
+ recipient,
86
+ message,
87
+ channels,
88
+ deliveries,
89
+ no_fallback,
90
+ expires_at,
91
+ server_plan_token
92
+ }' "$1"
93
+ }
94
+
95
+ verify_digest() {
96
+ local path="$1"
97
+ local stored actual
98
+ stored="$(jq -r '.digest' "$path")"
99
+ actual="$(canonical_plan "$path" | hash_text)"
100
+ [ "$stored" = "$actual" ] || die "notification plan changed after preview"
101
+ }
102
+
103
+ verify_live_plan() {
104
+ local path="$1"
105
+ local plan_session plan_mode expires now
106
+ plan_session="$(jq -r '.session_id' "$path")"
107
+ [ "$plan_session" = "$SESSION_ID" ] ||
108
+ die "notification approval belongs to another session"
109
+ plan_mode="$(jq -r '.mode' "$path")"
110
+ [ "$plan_mode" = "$MODE" ] ||
111
+ die "notification configuration changed; prepare it again"
112
+ expires="$(jq -r '.expires_at' "$path")"
113
+ now="$(date +%s)"
114
+ [ "$expires" -ge "$now" ] || die "notification plan expired; prepare it again"
115
+ verify_digest "$path"
116
+ }
117
+
118
+ write_plan() {
119
+ local plan_response="$1"
120
+ local kind="$2"
121
+ local recipient="$3"
122
+ local message="$4"
123
+ local id created expires path tmp
124
+ id="$(random_token | cut -c1-24)"
125
+ created="$(date +%s)"
126
+ expires="$(printf '%s' "$plan_response" | jq -r '.expires_at // empty')"
127
+ expires="${expires:-$((created + 600))}"
128
+ path="$(plan_path "$id")"
129
+ tmp="$path.tmp"
130
+
131
+ printf '%s' "$plan_response" | jq \
132
+ --arg id "$id" \
133
+ --arg session "$SESSION_ID" \
134
+ --arg mode "$MODE" \
135
+ --arg slug "$ORG_SLUG" \
136
+ --arg orgName "$ORG_NAME" \
137
+ --arg kind "$kind" \
138
+ --arg recipient "$recipient" \
139
+ --arg message "$message" \
140
+ --argjson created "$created" \
141
+ --argjson expires "$expires" \
142
+ '{
143
+ version: 1,
144
+ id: $id,
145
+ status: "proposed",
146
+ session_id: $session,
147
+ mode: $mode,
148
+ org_slug: (.org_slug // $slug),
149
+ org_name: (.org_name // $orgName),
150
+ kind: $kind,
151
+ recipient: (if $recipient == "" then null else $recipient end),
152
+ message: $message,
153
+ channels: (.channels // []),
154
+ deliveries: (.deliveries // []),
155
+ no_fallback: true,
156
+ created_at: $created,
157
+ expires_at: $expires,
158
+ server_plan_token: (.plan_token // ""),
159
+ digest: ""
160
+ }' > "$tmp"
161
+
162
+ local digest
163
+ digest="$(canonical_plan "$tmp" | hash_text)"
164
+ jq --arg digest "$digest" '.digest = $digest' "$tmp" > "$path"
165
+ rm -f "$tmp"
166
+ chmod 600 "$path" 2>/dev/null || true
167
+ jq '{
168
+ status: "approval_required",
169
+ plan_id: .id,
170
+ digest,
171
+ org: .org_name,
172
+ recipient,
173
+ channels,
174
+ deliveries,
175
+ no_fallback,
176
+ message,
177
+ expires_at
178
+ }' "$path"
179
+ }
180
+
181
+ read_local_telegram_config() {
182
+ local field="$1"
183
+ local state_file="$SCRIPT_DIR/.egregore-state.json"
184
+ local value
185
+ value="$(jq -r ".$field // empty" "$state_file" 2>/dev/null || true)"
186
+ [ -n "$value" ] ||
187
+ value="$(jq -r ".$field // empty" "$CONFIG" 2>/dev/null || true)"
188
+ printf '%s\n' "$value"
189
+ }
190
+
191
+ plan_connected() {
192
+ local kind="$1"
193
+ local recipient="$2"
194
+ local message="$3"
195
+ [ -n "$API_URL" ] && [ -n "$API_KEY" ] ||
196
+ die "notification service is not connected"
197
+
198
+ local response
199
+ response="$(curl -sS -X POST "${API_URL}/api/notify/plan" \
200
+ -H "Authorization: Bearer $API_KEY" \
201
+ -H "Content-Type: application/json" \
202
+ -d "$(jq -nc \
203
+ --arg kind "$kind" \
204
+ --arg to "$recipient" \
205
+ --arg message "$message" \
206
+ '{kind:$kind, message:$message}
207
+ + (if $to == "" then {} else {to:$to} end)')" \
208
+ --max-time 10)" || die "notification planning failed"
209
+
210
+ printf '%s' "$response" | jq -e '.status == "planned"' >/dev/null 2>&1 ||
211
+ die "$(printf '%s' "$response" | jq -r '.detail // "notification planning failed"' 2>/dev/null)"
212
+ write_plan "$response" "$kind" "$recipient" "$message"
213
+ }
214
+
215
+ plan_local() {
216
+ local kind="$1"
217
+ local recipient="$2"
218
+ local message="$3"
219
+ if [ "$kind" = "send" ]; then
220
+ die "direct messages are unavailable in local mode; no group fallback was sent"
221
+ fi
222
+
223
+ local chat_id group_link destination
224
+ chat_id="$(read_local_telegram_config telegram_chat_id)"
225
+ group_link="$(read_local_telegram_config telegram_group_link)"
226
+ [ -n "$chat_id" ] || [ -n "$group_link" ] ||
227
+ die "no Telegram group is configured"
228
+ destination="${group_link:-$chat_id}"
229
+
230
+ local response
231
+ response="$(jq -nc \
232
+ --arg slug "$ORG_SLUG" \
233
+ --arg org "$ORG_NAME" \
234
+ --arg destination "$destination" \
235
+ '{
236
+ status:"planned",
237
+ org_slug:$slug,
238
+ org_name:$org,
239
+ channels:["telegram"],
240
+ deliveries:[{channel:"telegram", destination:$destination, kind:"group"}],
241
+ no_fallback:true
242
+ }')"
243
+ write_plan "$response" "$kind" "$recipient" "$message"
244
+ }
245
+
246
+ plan_notification() {
247
+ local kind="$1"
248
+ local recipient="$2"
249
+ local message="$3"
250
+ [ -n "$message" ] || die "notification message is empty"
251
+ case "$MODE" in
252
+ connected) plan_connected "$kind" "$recipient" "$message" ;;
253
+ local) plan_local "$kind" "$recipient" "$message" ;;
254
+ *) die "notifications are unavailable in this configuration" ;;
255
+ esac
256
+ }
257
+
258
+ approve_plan() {
259
+ local id="$1"
260
+ local supplied_digest="$2"
261
+ local confirmation="$3"
262
+ [ "$confirmation" = "APPROVE_EXACT_NOTIFICATION" ] ||
263
+ die "explicit notification confirmation is required"
264
+
265
+ local path status digest token token_hash tmp
266
+ path="$(read_plan "$id")"
267
+ verify_live_plan "$path"
268
+ status="$(jq -r '.status' "$path")"
269
+ [ "$status" = "proposed" ] || die "notification plan is not awaiting approval"
270
+ digest="$(jq -r '.digest' "$path")"
271
+ [ "$supplied_digest" = "$digest" ] ||
272
+ die "approval does not match the previewed notification"
273
+
274
+ token="$(random_token)"
275
+ token_hash="$(printf '%s' "$token" | hash_text)"
276
+ tmp="$path.tmp"
277
+ jq \
278
+ --arg tokenHash "$token_hash" \
279
+ --argjson approvedAt "$(date +%s)" \
280
+ '.status = "approved"
281
+ | .approval_token_hash = $tokenHash
282
+ | .approved_at = $approvedAt' "$path" > "$tmp"
283
+ mv "$tmp" "$path"
284
+ jq -nc --arg id "$id" --arg token "$token" \
285
+ '{status:"approved", plan_id:$id, approval_token:$token}'
286
+ }
287
+
288
+ mark_plan() {
289
+ local path="$1"
290
+ local status="$2"
291
+ local detail="${3:-}"
292
+ local tmp="$path.tmp"
293
+ jq \
294
+ --arg status "$status" \
295
+ --arg detail "$detail" \
296
+ --argjson changedAt "$(date +%s)" \
297
+ '.status = $status
298
+ | .status_detail = (if $detail == "" then null else $detail end)
299
+ | .status_changed_at = $changedAt' "$path" > "$tmp"
300
+ mv "$tmp" "$path"
301
+ }
85
302
 
86
- test_connection() {
87
- local response
88
- response=$(curl -s -X GET "${API_URL}/api/notify/test" \
303
+ dispatch_connected() {
304
+ local path="$1"
305
+ local kind message plan_token response
306
+ kind="$(jq -r '.kind' "$path")"
307
+ message="$(jq -r '.message' "$path")"
308
+ plan_token="$(jq -r '.server_plan_token' "$path")"
309
+
310
+ if [ "$kind" = "send" ]; then
311
+ local recipient channel
312
+ recipient="$(jq -r '.recipient' "$path")"
313
+ channel="$(jq -r '.channels[0]' "$path")"
314
+ response="$(curl -sS -X POST "${API_URL}/api/notify/send" \
315
+ -H "Authorization: Bearer $API_KEY" \
316
+ -H "Content-Type: application/json" \
317
+ -d "$(jq -nc \
318
+ --arg to "$recipient" \
319
+ --arg message "$message" \
320
+ --arg channel "$channel" \
321
+ --arg planToken "$plan_token" \
322
+ '{to:$to,message:$message,channel:$channel,plan_token:$planToken}')" \
323
+ --max-time 10)" || return 1
324
+ else
325
+ local channels
326
+ channels="$(jq -c '.channels' "$path")"
327
+ response="$(curl -sS -X POST "${API_URL}/api/notify/group" \
89
328
  -H "Authorization: Bearer $API_KEY" \
90
- --max-time 10)
329
+ -H "Content-Type: application/json" \
330
+ -d "$(jq -nc \
331
+ --arg message "$message" \
332
+ --arg planToken "$plan_token" \
333
+ --argjson channels "$channels" \
334
+ '{message:$message,channels:$channels,plan_token:$planToken}')" \
335
+ --max-time 10)" || return 1
336
+ fi
91
337
 
92
- if echo "$response" | jq -e '.status == "ok"' >/dev/null 2>&1; then
93
- echo "Telegram connected via Egregore API"
94
- else
95
- echo "Failed to connect" >&2
96
- echo "$response" >&2
97
- exit 1
98
- fi
338
+ printf '%s' "$response" | jq -e '.status == "sent"' >/dev/null 2>&1 || {
339
+ printf '%s\n' "$response"
340
+ return 1
99
341
  }
342
+ printf '%s\n' "$response"
343
+ }
100
344
 
101
- elif [ "$_MODE" = "local" ]; then
102
- # === LOCAL MODE: Group messages via public relay, DMs not available ===
103
- # Telegram config: prefer .egregore-state.json (symlinked across worktrees),
104
- # fall back to egregore.json for backward compatibility.
105
- STATE_FILE="$SCRIPT_DIR/.egregore-state.json"
106
-
107
- _read_telegram_config() {
108
- local field="$1"
109
- local val
110
- val=$(jq -r ".$field // empty" "$STATE_FILE" 2>/dev/null)
111
- [ -z "$val" ] && val=$(jq -r ".$field // empty" "$CONFIG" 2>/dev/null)
112
- echo "$val"
345
+ dispatch_local() {
346
+ local path="$1"
347
+ local message destination chat_id group_link plan_slug plan_org response
348
+ message="$(jq -r '.message' "$path")"
349
+ destination="$(jq -r '.deliveries[0].destination' "$path")"
350
+ case "$destination" in
351
+ https://t.me/*)
352
+ chat_id=""
353
+ group_link="$destination"
354
+ ;;
355
+ *)
356
+ chat_id="$destination"
357
+ group_link=""
358
+ ;;
359
+ esac
360
+ plan_slug="$(jq -r '.org_slug' "$path")"
361
+ plan_org="$(jq -r '.org_name' "$path")"
362
+ response="$(curl -sS -X POST "${RELAY_URL}/api/notify/relay" \
363
+ -H "Content-Type: application/json" \
364
+ -d "$(jq -nc \
365
+ --arg chat_id "$chat_id" \
366
+ --arg group_link "$group_link" \
367
+ --arg message "$message" \
368
+ --arg slug "$plan_slug" \
369
+ --arg org_name "$plan_org" \
370
+ '{chat_id:$chat_id,group_link:$group_link,message:$message,slug:$slug,org_name:$org_name}')" \
371
+ --max-time 10)" || return 1
372
+ printf '%s' "$response" | jq -e '.status == "sent"' >/dev/null 2>&1 || {
373
+ printf '%s\n' "$response"
374
+ return 1
113
375
  }
376
+ printf '%s\n' "$response"
377
+ }
114
378
 
115
- send_to_person() {
116
- echo "DMs require managed hosting. Group message sent instead."
117
- send_to_group "@${1}: ${2}"
118
- }
379
+ dispatch_plan() {
380
+ local id="$1"
381
+ local approval_token="$2"
382
+ local path status expected_hash actual_hash lock response
383
+ path="$(read_plan "$id")"
384
+ verify_live_plan "$path"
385
+ status="$(jq -r '.status' "$path")"
386
+ [ "$status" = "approved" ] || die "notification plan has not been explicitly approved"
387
+ expected_hash="$(jq -r '.approval_token_hash' "$path")"
388
+ actual_hash="$(printf '%s' "$approval_token" | hash_text)"
389
+ [ "$expected_hash" = "$actual_hash" ] || die "notification approval token is invalid"
119
390
 
120
- send_to_group() {
121
- local message="$1"
122
- local chat_id
123
- chat_id=$(_read_telegram_config telegram_chat_id)
124
- local group_link
125
- group_link=$(_read_telegram_config telegram_group_link)
391
+ lock="$path.lock"
392
+ mkdir "$lock" 2>/dev/null || die "notification dispatch is already in progress"
393
+ NOTIFY_LOCK="$lock"
394
+ trap '[ -z "${NOTIFY_LOCK:-}" ] || rmdir "$NOTIFY_LOCK" 2>/dev/null || true' EXIT
126
395
 
127
- if [ -z "$chat_id" ] && [ -z "$group_link" ]; then
128
- echo '{"status":"offline","reason":"no_telegram_group"}'
129
- return
396
+ # Consume approval before the network call. An uncertain failure requires a
397
+ # new plan rather than risking a duplicate external message.
398
+ mark_plan "$path" "dispatching"
399
+ if [ "$MODE" = "connected" ]; then
400
+ if ! response="$(dispatch_connected "$path")"; then
401
+ mark_plan "$path" "failed" "dispatch failed; prepare and approve a new plan"
402
+ die "notification dispatch failed; no fallback was attempted"
403
+ fi
404
+ else
405
+ if ! response="$(dispatch_local "$path")"; then
406
+ mark_plan "$path" "failed" "dispatch failed; prepare and approve a new plan"
407
+ die "notification dispatch failed; no fallback was attempted"
130
408
  fi
409
+ fi
131
410
 
132
- local slug
133
- slug=$(jq -r '.slug // empty' "$CONFIG" 2>/dev/null)
134
- local org_name
135
- org_name=$(jq -r '.org_name // empty' "$CONFIG" 2>/dev/null)
411
+ mark_plan "$path" "sent"
412
+ rmdir "$lock" 2>/dev/null || true
413
+ NOTIFY_LOCK=""
414
+ trap - EXIT
415
+ bash "$SCRIPT_DIR/bin/telemetry.sh" emit "notification" \
416
+ "$(jq -nc --arg kind "$(jq -r '.kind' "$path")" \
417
+ '{type:$kind,status:"sent",consent:"explicit"}')" 2>/dev/null &
418
+ jq -nc \
419
+ --arg id "$id" \
420
+ --argjson result "$response" \
421
+ '{status:"sent",plan_id:$id,result:$result}'
422
+ }
136
423
 
137
- local response
138
- response=$(curl -s -X POST "${RELAY_URL}/api/notify/relay" \
139
- -H "Content-Type: application/json" \
140
- -d "$(jq -n \
141
- --arg chat_id "$chat_id" \
142
- --arg group_link "$group_link" \
143
- --arg message "$message" \
144
- --arg slug "$slug" \
145
- --arg org_name "$org_name" \
146
- '{chat_id: $chat_id, group_link: $group_link, message: $message, slug: $slug, org_name: $org_name}')" \
147
- --max-time 10 2>/dev/null)
424
+ show_plan() {
425
+ local path
426
+ path="$(read_plan "$1")"
427
+ jq '{
428
+ status,
429
+ plan_id:.id,
430
+ digest,
431
+ org:.org_name,
432
+ recipient,
433
+ channels,
434
+ deliveries,
435
+ no_fallback,
436
+ message,
437
+ expires_at
438
+ }' "$path"
439
+ }
148
440
 
149
- if echo "$response" | jq -e '.status == "sent"' >/dev/null 2>&1; then
150
- echo "Sent"
151
- else
152
- local detail
153
- detail=$(echo "$response" | jq -r '.detail // .status // "relay unavailable"' 2>/dev/null)
154
- echo "Failed: $detail"
441
+ cancel_plan() {
442
+ local path status
443
+ path="$(read_plan "$1")"
444
+ status="$(jq -r '.status' "$path")"
445
+ case "$status" in
446
+ proposed|approved|failed) mark_plan "$path" "cancelled" ;;
447
+ *) die "notification plan cannot be cancelled from status: $status" ;;
448
+ esac
449
+ jq -nc --arg id "$1" '{status:"cancelled",plan_id:$id}'
450
+ }
451
+
452
+ list_pending() {
453
+ local found="false"
454
+ for path in "$STATE_DIR"/*.json; do
455
+ [ -f "$path" ] || continue
456
+ if jq -e '.status == "proposed" or .status == "approved"' "$path" >/dev/null 2>&1; then
457
+ jq -c '{plan_id:.id,status,org:.org_name,recipient,channels,message,expires_at}' "$path"
458
+ found="true"
155
459
  fi
156
- }
460
+ done
461
+ [ "$found" = "true" ] || echo '{"status":"empty"}'
462
+ }
157
463
 
158
- test_connection() {
464
+ test_connection() {
465
+ if [ "$MODE" = "connected" ]; then
466
+ [ -n "$API_URL" ] && [ -n "$API_KEY" ] ||
467
+ die "notification service is not connected"
468
+ curl -sS -X GET "${API_URL}/api/notify/test" \
469
+ -H "Authorization: Bearer $API_KEY" \
470
+ --max-time 10
471
+ elif [ "$MODE" = "local" ]; then
159
472
  local group_link
160
- group_link=$(_read_telegram_config telegram_group_link)
473
+ group_link="$(read_local_telegram_config telegram_group_link)"
161
474
  if [ -n "$group_link" ]; then
162
- echo "Telegram group configured (local mode — group messages via relay)"
475
+ echo '{"status":"configured","mode":"local"}'
163
476
  else
164
- echo "No Telegram group configured. Run /telegram-connect."
477
+ echo '{"status":"offline","reason":"no_telegram_group"}'
165
478
  fi
166
- }
167
-
168
- else
169
- # === OFFLINE MODE: No API key, not local — skip notifications ===
170
- send_to_person() { echo '{"status":"offline","reason":"no_api_key"}'; }
171
- send_to_group() { echo '{"status":"offline","reason":"no_api_key"}'; }
172
- test_connection() { echo '{"status":"offline","reason":"no_api_key"}'; }
173
- fi
479
+ else
480
+ echo '{"status":"offline","reason":"no_api_key"}'
481
+ fi
482
+ }
174
483
 
175
484
  case "${1:-help}" in
485
+ plan)
486
+ case "${2:-}" in
487
+ send)
488
+ [ "$#" -eq 4 ] || die "Usage: notify.sh plan send <recipient> <message>"
489
+ plan_notification "send" "$3" "$4"
490
+ ;;
491
+ group)
492
+ [ "$#" -eq 3 ] || die "Usage: notify.sh plan group <message>"
493
+ plan_notification "group" "" "$3"
494
+ ;;
495
+ *) die "Usage: notify.sh plan send|group ..." ;;
496
+ esac
497
+ ;;
498
+ approve)
499
+ [ "$#" -eq 4 ] || die "Usage: notify.sh approve <plan-id> <digest> APPROVE_EXACT_NOTIFICATION"
500
+ approve_plan "$2" "$3" "$4"
501
+ ;;
502
+ dispatch)
503
+ [ "$#" -eq 3 ] || die "Usage: notify.sh dispatch <plan-id> <approval-token>"
504
+ dispatch_plan "$2" "$3"
505
+ ;;
506
+ show)
507
+ [ "$#" -eq 2 ] || die "Usage: notify.sh show <plan-id>"
508
+ show_plan "$2"
509
+ ;;
510
+ cancel)
511
+ [ "$#" -eq 2 ] || die "Usage: notify.sh cancel <plan-id>"
512
+ cancel_plan "$2"
513
+ ;;
514
+ pending)
515
+ list_pending
516
+ ;;
176
517
  send)
177
- recipient="${2:?Usage: notify.sh send <name> <message>}"
178
- message="${3:?Usage: notify.sh send <name> <message>}"
179
- result=$(send_to_person "$recipient" "$message")
180
- if [[ "$result" == Failed* ]]; then
181
- send_to_group "@${recipient}: ${message}"
182
- echo "DM failed, sent to group instead. ${result}"
183
- else
184
- echo "$result"
185
- fi
518
+ [ "$#" -eq 3 ] || die "Usage: notify.sh send <recipient> <message>"
519
+ plan_notification "send" "$2" "$3"
520
+ echo "Approval required — no notification was sent." >&2
521
+ exit 4
186
522
  ;;
187
523
  group)
188
- message="${2:?Usage: notify.sh group <message>}"
189
- send_to_group "$message"
190
- ;;
191
- file)
192
- echo "File upload not yet supported via API. Use direct mode." >&2
193
- exit 1
524
+ [ "$#" -eq 2 ] || die "Usage: notify.sh group <message>"
525
+ plan_notification "group" "" "$2"
526
+ echo "Approval required — no notification was sent." >&2
527
+ exit 4
194
528
  ;;
195
529
  test)
196
530
  test_connection
@@ -199,9 +533,14 @@ case "${1:-help}" in
199
533
  echo "Usage: notify.sh <command>"
200
534
  echo ""
201
535
  echo "Commands:"
202
- echo " send <name> <message> Send DM to a person (auto-fallback to group on failure)"
203
- echo " group <message> Send to the group chat"
204
- echo " file <path> [caption] Send a file to the group chat"
205
- echo " test Test connection"
536
+ echo " plan send <name> <message> Resolve a DM without sending"
537
+ echo " plan group <message> Resolve group channels without sending"
538
+ echo " approve <id> <digest> APPROVE_EXACT_NOTIFICATION"
539
+ echo " Record one exact human approval"
540
+ echo " dispatch <id> <token> Consume approval and send stored content"
541
+ echo " show <id> Show exact pending content"
542
+ echo " cancel <id> Cancel a pending plan"
543
+ echo " pending List pending plans"
544
+ echo " test Test configuration without sending"
206
545
  ;;
207
546
  esac