create-egregore 0.19.7 → 0.19.11

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 (92) hide show
  1. package/assets/egregore-launcher.js +188 -295
  2. package/lib/local.js +11 -1
  3. package/lib/setup.js +33 -0
  4. package/package.json +1 -1
  5. package/runtime/codex/.codex/hooks/branch-guard.js +242 -34
  6. package/runtime/codex/.codex/skills/activity/SKILL.md +18 -6
  7. package/runtime/codex/.codex/skills/deep-reflect/SKILL.md +90 -55
  8. package/runtime/codex/.codex/skills/deploy-bridge/SKILL.md +25 -0
  9. package/runtime/codex/.codex/skills/handoff/SKILL.md +6 -2
  10. package/runtime/codex/.codex/skills/save/SKILL.md +21 -3
  11. package/runtime/codex/.codex/skills/scroll/SKILL.md +54 -3
  12. package/runtime/codex/.codex/skills/task/SKILL.md +25 -0
  13. package/runtime/codex/.codex/skills/tui-design/SKILL.md +1 -1
  14. package/runtime/codex/.codex/skills/wrap/SKILL.md +4 -1
  15. package/runtime/codex/.codex/spec-manifest.json +15 -3
  16. package/runtime/codex/AGENTS.md +45 -9
  17. package/runtime/codex/bin/activity-data.sh +1 -1
  18. package/runtime/codex/bin/agent.sh +66 -23
  19. package/runtime/codex/bin/attendant.sh +525 -0
  20. package/runtime/codex/bin/capture-reconcile.sh +67 -0
  21. package/runtime/codex/bin/capture-run.sh +341 -0
  22. package/runtime/codex/bin/codex-render-spec.mjs +15 -4
  23. package/runtime/codex/bin/codex-session-start.sh +45 -3
  24. package/runtime/codex/bin/codex-skill-render.mjs +23 -4
  25. package/runtime/codex/bin/graph-op.sh +719 -49
  26. package/runtime/codex/bin/graph-wal.sh +3 -2
  27. package/runtime/codex/bin/handoff-run.sh +72 -0
  28. package/runtime/codex/bin/handoff-save-egregore.sh +51 -11
  29. package/runtime/codex/bin/index-handoff.sh +26 -17
  30. package/runtime/codex/bin/ingest-graph.sh +80 -0
  31. package/runtime/codex/bin/ingest-surface.html +429 -0
  32. package/runtime/codex/bin/ingest.py +614 -0
  33. package/runtime/codex/bin/ingest.sh +231 -0
  34. package/runtime/codex/bin/ingest_graph.py +776 -0
  35. package/runtime/codex/bin/ingest_surface.py +464 -0
  36. package/runtime/codex/bin/lib/git-sync.sh +2 -6
  37. package/runtime/codex/bin/lib/greeting.sh +157 -22
  38. package/runtime/codex/bin/lib/identity.sh +50 -29
  39. package/runtime/codex/bin/lib/noncode.sh +31 -0
  40. package/runtime/codex/bin/person.py +610 -0
  41. package/runtime/codex/bin/person.sh +448 -0
  42. package/runtime/codex/bin/session-autosave.sh +184 -0
  43. package/runtime/codex/bin/session-log.sh +199 -0
  44. package/runtime/codex/bin/settings.sh +1 -1
  45. package/runtime/codex/manifest.json +19 -1
  46. package/runtime/pi/.codex/hooks/branch-guard.js +242 -34
  47. package/runtime/pi/.codex/skills/activity/SKILL.md +18 -6
  48. package/runtime/pi/.codex/skills/deep-reflect/SKILL.md +90 -55
  49. package/runtime/pi/.codex/skills/deploy-bridge/SKILL.md +25 -0
  50. package/runtime/pi/.codex/skills/handoff/SKILL.md +6 -2
  51. package/runtime/pi/.codex/skills/save/SKILL.md +21 -3
  52. package/runtime/pi/.codex/skills/scroll/SKILL.md +54 -3
  53. package/runtime/pi/.codex/skills/task/SKILL.md +25 -0
  54. package/runtime/pi/.codex/skills/tui-design/SKILL.md +1 -1
  55. package/runtime/pi/.codex/skills/wrap/SKILL.md +4 -1
  56. package/runtime/pi/.codex/spec-manifest.json +15 -3
  57. package/runtime/pi/.pi/APPEND_SYSTEM.md +41 -5
  58. package/runtime/pi/.pi/extensions/egregore.ts +98 -8
  59. package/runtime/pi/.pi/spec-manifest.json +2 -2
  60. package/runtime/pi/AGENTS.md +45 -9
  61. package/runtime/pi/bin/activity-data.sh +1 -1
  62. package/runtime/pi/bin/agent.sh +66 -23
  63. package/runtime/pi/bin/attendant.sh +525 -0
  64. package/runtime/pi/bin/capture-reconcile.sh +67 -0
  65. package/runtime/pi/bin/capture-run.sh +341 -0
  66. package/runtime/pi/bin/codex-render-spec.mjs +15 -4
  67. package/runtime/pi/bin/codex-session-start.sh +45 -3
  68. package/runtime/pi/bin/codex-skill-render.mjs +23 -4
  69. package/runtime/pi/bin/graph-op.sh +719 -49
  70. package/runtime/pi/bin/graph-wal.sh +3 -2
  71. package/runtime/pi/bin/handoff-run.sh +72 -0
  72. package/runtime/pi/bin/handoff-save-egregore.sh +51 -11
  73. package/runtime/pi/bin/index-handoff.sh +26 -17
  74. package/runtime/pi/bin/ingest-graph.sh +80 -0
  75. package/runtime/pi/bin/ingest-surface.html +429 -0
  76. package/runtime/pi/bin/ingest.py +614 -0
  77. package/runtime/pi/bin/ingest.sh +231 -0
  78. package/runtime/pi/bin/ingest_graph.py +776 -0
  79. package/runtime/pi/bin/ingest_surface.py +464 -0
  80. package/runtime/pi/bin/lib/git-sync.sh +2 -6
  81. package/runtime/pi/bin/lib/greeting.sh +157 -22
  82. package/runtime/pi/bin/lib/identity.sh +50 -29
  83. package/runtime/pi/bin/lib/noncode.sh +31 -0
  84. package/runtime/pi/bin/person.py +610 -0
  85. package/runtime/pi/bin/person.sh +448 -0
  86. package/runtime/pi/bin/pi-branch-policy.mjs +164 -4
  87. package/runtime/pi/bin/pi-product-workflows.mjs +31 -2
  88. package/runtime/pi/bin/publish-artifact.sh +14 -0
  89. package/runtime/pi/bin/session-autosave.sh +184 -0
  90. package/runtime/pi/bin/session-log.sh +199 -0
  91. package/runtime/pi/bin/settings.sh +274 -0
  92. package/runtime/pi/manifest.json +20 -1
@@ -95,6 +95,20 @@ fi
95
95
  # Public relay for OSS share endpoint
96
96
  RELAY_URL="https://egregore-production-55f2.up.railway.app"
97
97
 
98
+ # --- Artifact hosting off-switch (compliance / privacy) ---
99
+ # An instance can disable ALL egregore.xyz publishing via `bin/settings.sh hosting off`
100
+ # (sets features.publishing=false). Absent/true means ON, so existing instances are
101
+ # unaffected. We bail BEFORE any render or upload so "off" truly does nothing and costs
102
+ # nothing. Exit 3 is distinct from usage errors; fire-and-forget callers ignore it.
103
+ # NOTE: test `!= false`, not jq's `// "true"` — `//` treats an explicit `false` as empty
104
+ # and would return the default, silently defeating the off-switch.
105
+ PUBLISHING_ENABLED="$(jq -r 'if .features.publishing == false then "false" else "true" end' "$CONFIG" 2>/dev/null || echo "true")"
106
+ if [ "$PUBLISHING_ENABLED" = "false" ]; then
107
+ echo "Artifact hosting is off for this egregore (features.publishing=false); nothing uploaded." >&2
108
+ echo "Turn it on with: bin/settings.sh hosting on (or: egregore settings → Hosting)." >&2
109
+ exit 3
110
+ fi
111
+
98
112
  # --- Prepare HTML ---
99
113
  TMP_HTML="/tmp/egregore-artifacts/publish-$$.html"
100
114
  mkdir -p /tmp/egregore-artifacts
@@ -0,0 +1,184 @@
1
+ #!/bin/bash
2
+ # session-autosave.sh — save non-coding session work without an explicit /save.
3
+ #
4
+ # Terminal-close is the design constraint: no single "session end" event is
5
+ # reliable (people just close the window), so durability comes from three
6
+ # overlapping triggers:
7
+ # - Stop hook, debounced (Claude Code): saves DURING the session, after a
8
+ # turn completes — a killed terminal loses at most the debounce window.
9
+ # - Session-start sweep (--sweep, all runtimes): the next launch on this
10
+ # machine rescues leftovers in the main checkout and lingering worktrees.
11
+ # - SessionEnd hook / agent.sh wrap (Codex, Pi): immediate save on clean exit.
12
+ #
13
+ # Gate: acts ONLY when every pending change — uncommitted files AND commits
14
+ # ahead of origin/develop — is non-coding per bin/lib/noncode.sh. Any code in
15
+ # the pending set → exit silently; code always goes through explicit /save and
16
+ # review. Never auto-commits half-finished code.
17
+ #
18
+ # When the gate passes, delegates commit→push→PR→auto-merge to
19
+ # handoff-save-egregore.sh (--kind autosave), and pushes the memory repo.
20
+ #
21
+ # Usage:
22
+ # session-autosave.sh [--dir <repo-dir>] [--author <name>]
23
+ # [--debounce <sec>] [--min-idle <sec>] [--sweep]
24
+ # --debounce skip if this dir was autosaved less than <sec> ago (Stop hook)
25
+ # --min-idle skip if any dirty file was modified less than <sec> ago
26
+ # (don't commit prose someone is actively typing)
27
+ # --sweep iterate main checkout + .claude/worktrees/* (session start)
28
+ # (Hook invocations pass nothing; stdin JSON supplies cwd.)
29
+
30
+ set -uo pipefail
31
+
32
+ DIR=""
33
+ AUTHOR=""
34
+ DEBOUNCE=0
35
+ MIN_IDLE=0
36
+ SWEEP=0
37
+ while [ $# -gt 0 ]; do
38
+ case "$1" in
39
+ --dir) DIR="${2:-}"; shift 2 ;;
40
+ --author) AUTHOR="${2:-}"; shift 2 ;;
41
+ --debounce) DEBOUNCE="${2:-0}"; shift 2 ;;
42
+ --min-idle) MIN_IDLE="${2:-0}"; shift 2 ;;
43
+ --sweep) SWEEP=1; shift ;;
44
+ *) shift ;;
45
+ esac
46
+ done
47
+
48
+ SCRIPT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
49
+
50
+ # --- Sweep mode: rescue abandoned checkouts, then exit -------------------
51
+ if [ "$SWEEP" = "1" ]; then
52
+ # Let session-start's own git syncs settle before touching the checkout.
53
+ sleep 15
54
+ for d in "$SCRIPT_ROOT" "$SCRIPT_ROOT"/.claude/worktrees/*/; do
55
+ d="${d%/}"
56
+ [ -d "$d" ] || continue
57
+ # min-idle protects live sessions in other terminals: never commit files
58
+ # someone touched in the last 10 minutes. debounce dedupes concurrent
59
+ # session launches sweeping the same dirs.
60
+ bash "$0" --dir "$d" --min-idle 600 --debounce 120 >/dev/null 2>&1 || true
61
+ done
62
+ exit 0
63
+ fi
64
+
65
+ # Hook invocations deliver JSON on stdin (cwd = where the session worked).
66
+ if [ -z "$DIR" ] && [ ! -t 0 ]; then
67
+ HOOK_INPUT=$(cat 2>/dev/null || true)
68
+ DIR=$(echo "$HOOK_INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
69
+ fi
70
+
71
+ [ -n "$DIR" ] && [ -d "$DIR" ] || DIR="$SCRIPT_ROOT"
72
+ cd "$DIR" 2>/dev/null || exit 0
73
+ git rev-parse --git-dir >/dev/null 2>&1 || exit 0
74
+
75
+ # --- Debounce (per-dir stamp) --------------------------------------------
76
+ # NB: `cmd | cut || fallback` doesn't work — cut succeeds on empty input, so
77
+ # the fallback never fires and every dir collapses to one stamp (macOS has
78
+ # md5, not md5sum). Group the hash commands, then cut.
79
+ DIR_HASH=$( { md5sum <<<"$DIR" 2>/dev/null || md5 -q -s "$DIR" 2>/dev/null || echo default; } | cut -c1-8 )
80
+ STAMP="/tmp/.egregore-autosave-${DIR_HASH}"
81
+ if [ "$DEBOUNCE" -gt 0 ] && [ -f "$STAMP" ]; then
82
+ LAST=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
83
+ [ $(( $(date +%s) - LAST )) -lt "$DEBOUNCE" ] && exit 0
84
+ fi
85
+ touch "$STAMP" 2>/dev/null || true
86
+
87
+ # shellcheck source=bin/lib/noncode.sh
88
+ . "$SCRIPT_ROOT/bin/lib/noncode.sh" 2>/dev/null || exit 0
89
+
90
+ if [ -z "$AUTHOR" ]; then
91
+ AUTHOR=$(jq -r '.github_username // .name // empty' "$DIR/.egregore-state.json" 2>/dev/null || true)
92
+ [ -n "$AUTHOR" ] || AUTHOR=$(jq -r '.github_username // .name // empty' "$SCRIPT_ROOT/.egregore-state.json" 2>/dev/null || true)
93
+ [ -n "$AUTHOR" ] || AUTHOR=$(git config user.name 2>/dev/null | tr '[:upper:] ' '[:lower:]-' || true)
94
+ [ -n "$AUTHOR" ] || AUTHOR="egregore"
95
+ fi
96
+
97
+ # --- Memory repo: markdown by definition, push straight to main ----------
98
+ if git -C "$DIR/memory" rev-parse --git-dir >/dev/null 2>&1; then
99
+ if [ -n "$(git -C "$DIR/memory" status --porcelain 2>/dev/null)" ]; then
100
+ git -C "$DIR/memory" add -A >/dev/null 2>&1 || true
101
+ git -C "$DIR/memory" commit -m "Auto-save: session content" --quiet 2>/dev/null || true
102
+ fi
103
+ if [ -n "$(git -C "$DIR/memory" log origin/main..HEAD --oneline 2>/dev/null)" ]; then
104
+ for _try in 1 2 3; do
105
+ if git -C "$DIR/memory" pull --rebase origin main --quiet 2>/dev/null \
106
+ && git -C "$DIR/memory" push origin main --quiet 2>/dev/null; then
107
+ break
108
+ fi
109
+ sleep 1
110
+ done
111
+ fi
112
+ fi
113
+
114
+ # --- Core repo gate: everything pending must be non-coding ---------------
115
+ # -uall expands untracked dirs to individual files: a collapsed "docs/" entry
116
+ # would defeat both the classifier (code nested under a content dir) and the
117
+ # idle guard (directory mtime, not file mtime).
118
+ DIRTY_PATHS=$(git status --porcelain -uall 2>/dev/null | cut -c4- | sed 's/.* -> //' | sed '/^$/d')
119
+ PENDING=$(
120
+ {
121
+ printf '%s\n' "$DIRTY_PATHS"
122
+ git diff origin/develop...HEAD --name-only 2>/dev/null
123
+ } | sed '/^$/d'
124
+ )
125
+ [ -n "$PENDING" ] || exit 0
126
+
127
+ BLOCKER=$(printf '%s\n' "$PENDING" | noncode_filter)
128
+ if [ -n "$BLOCKER" ]; then
129
+ # Code in the pending set — leave it for an explicit /save + review.
130
+ exit 0
131
+ fi
132
+
133
+ # --- Idle guard: don't commit files someone is actively editing ----------
134
+ if [ "$MIN_IDLE" -gt 0 ] && [ -n "$DIRTY_PATHS" ]; then
135
+ NOW=$(date +%s)
136
+ while IFS= read -r f; do
137
+ [ -e "$f" ] || continue
138
+ M=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo 0)
139
+ [ $(( NOW - M )) -lt "$MIN_IDLE" ] && exit 0
140
+ done <<< "$DIRTY_PATHS"
141
+ fi
142
+
143
+ ORIG_BRANCH=$(git branch --show-current 2>/dev/null || echo "")
144
+
145
+ bash "$SCRIPT_ROOT/bin/handoff-save-egregore.sh" "$AUTHOR" \
146
+ "session content $(date +%Y-%m-%d)" --kind autosave --repo-dir "$DIR" \
147
+ --branch-suffix "$DIR_HASH" \
148
+ >/dev/null 2>&1 || true
149
+
150
+ # Tell the user what just happened. Under the Stop hook this line surfaces in
151
+ # the Claude Code UI; sweep/SessionEnd invocations discard stdout (their trail
152
+ # is the ledger, reported by the next greeting).
153
+ LEDGER="${EGREGORE_AUTOSAVE_LOG:-$HOME/.egregore/autosave.log}"
154
+ LAST=$(grep -F "|autosave|$DIR|" "$LEDGER" 2>/dev/null | tail -1 || true)
155
+ if [ -n "$LAST" ]; then
156
+ TS=$(echo "$LAST" | cut -d'|' -f1)
157
+ PR=$(echo "$LAST" | cut -d'|' -f5)
158
+ RESULT=$(echo "$LAST" | cut -d'|' -f6)
159
+ NF=$(echo "$LAST" | cut -d'|' -f7)
160
+ NOW_EPOCH=$(date +%s)
161
+ TS_EPOCH=$(date -u -j -f %Y-%m-%dT%H:%M:%SZ "$TS" +%s 2>/dev/null || date -u -d "$TS" +%s 2>/dev/null || echo 0)
162
+ if [ $(( NOW_EPOCH - TS_EPOCH )) -lt 120 ]; then
163
+ case "$RESULT" in
164
+ merged) echo "⟲ auto-saved: $NF non-coding file(s) → develop (PR #$PR merged)" ;;
165
+ open) echo "⟲ auto-saved: $NF non-coding file(s) → PR #$PR (merge pending)" ;;
166
+ *) echo "⟲ auto-saved: $NF non-coding file(s) → branch pushed (PR pending)" ;;
167
+ esac
168
+ fi
169
+ fi
170
+
171
+ # The helper branches off protected branches before committing. If this
172
+ # checkout was on develop/main (e.g. the main checkout during a sweep),
173
+ # restore it so the next session doesn't land on a stray autosave branch.
174
+ case "$ORIG_BRANCH" in
175
+ develop|main|master)
176
+ CUR=$(git branch --show-current 2>/dev/null || echo "")
177
+ if [ "$CUR" != "$ORIG_BRANCH" ]; then
178
+ git checkout "$ORIG_BRANCH" --quiet 2>/dev/null || true
179
+ git pull --ff-only origin "$ORIG_BRANCH" --quiet 2>/dev/null || true
180
+ fi
181
+ ;;
182
+ esac
183
+
184
+ exit 0
@@ -0,0 +1,199 @@
1
+ #!/bin/bash
2
+ # Graceful session-end adapter: write a structured session log to memory/sessions/.
3
+ # Receives runtime-neutral JSON on stdin from Claude Code or Pi.
4
+ # Runs BEFORE transcript-archive.sh (needs obs buffer + worktree intact).
5
+ # Makes sessions visible in local-mode without requiring /handoff or /wrap.
6
+ set -euo pipefail
7
+
8
+ # Suppress all output — hook scripts must not leak to terminal
9
+ exec >/dev/null 2>&1
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)" || SCRIPT_DIR=""
12
+ if [ -z "$SCRIPT_DIR" ] || [ ! -d "$SCRIPT_DIR" ]; then
13
+ SCRIPT_DIR="${CLAUDE_PROJECT_DIR:-}"
14
+ fi
15
+ if [ -z "$SCRIPT_DIR" ] || [ ! -d "$SCRIPT_DIR" ]; then
16
+ exit 0
17
+ fi
18
+
19
+ # --- Read hook input from stdin ---
20
+ INPUT=$(cat /dev/stdin 2>/dev/null || echo "{}")
21
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || true)
22
+ TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || true)
23
+
24
+ if [ -z "$SESSION_ID" ]; then
25
+ exit 0
26
+ fi
27
+
28
+ # Sanitize SESSION_ID
29
+ SESSION_ID=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-')
30
+ if [ -z "$SESSION_ID" ] || [ ${#SESSION_ID} -gt 128 ]; then
31
+ exit 0
32
+ fi
33
+
34
+ # --- Gate: memory must exist ---
35
+ if [ ! -L "$SCRIPT_DIR/memory" ] && [ ! -d "$SCRIPT_DIR/memory" ]; then
36
+ exit 0
37
+ fi
38
+
39
+ # --- Read identity ---
40
+ STATE_FILE="$SCRIPT_DIR/.egregore-state.json"
41
+ AUTHOR=""
42
+ DISPLAY_NAME=""
43
+ if [ -f "$STATE_FILE" ]; then
44
+ AUTHOR=$(jq -r '.github_username // empty' "$STATE_FILE" 2>/dev/null || true)
45
+ DISPLAY_NAME=$(jq -r '.display_name // empty' "$STATE_FILE" 2>/dev/null || true)
46
+ fi
47
+ AUTHOR=$(echo "$AUTHOR" | tr -cd 'a-zA-Z0-9_-')
48
+ if [ -z "$AUTHOR" ]; then
49
+ exit 0
50
+ fi
51
+
52
+ # --- Git metadata ---
53
+ BRANCH=$(git -C "$SCRIPT_DIR" branch --show-current 2>/dev/null || echo "")
54
+
55
+ # --- Derive topic from branch name ---
56
+ TOPIC=""
57
+ if [ -n "$BRANCH" ] && [ "$BRANCH" != "develop" ] && [ "$BRANCH" != "main" ]; then
58
+ TOPIC=$(echo "$BRANCH" | sed 's|^dev/[^/]*/||; s|^feature/||; s|^bugfix/||' | tr '-' ' ')
59
+ fi
60
+ [ -z "$TOPIC" ] && TOPIC="session"
61
+
62
+ # --- Read baseline (written by session-start.sh) ---
63
+ BASELINE_FILE="/tmp/egregore-baseline-${SESSION_ID}.json"
64
+ BASELINE_COMMIT=""
65
+ BASELINE_STARTED=""
66
+ if [ -f "$BASELINE_FILE" ]; then
67
+ BASELINE_COMMIT=$(jq -r '.commit // empty' "$BASELINE_FILE" 2>/dev/null || true)
68
+ BASELINE_STARTED=$(jq -r '.started_at // empty' "$BASELINE_FILE" 2>/dev/null || true)
69
+ fi
70
+
71
+ # --- Extract timestamps from transcript ---
72
+ STARTED_AT=""
73
+ ENDED_AT=""
74
+ if [ -n "$TRANSCRIPT_PATH" ]; then
75
+ TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
76
+ TRANSCRIPT_PATH=$(realpath "$TRANSCRIPT_PATH" 2>/dev/null || echo "")
77
+ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
78
+ STARTED_AT=$(head -1 "$TRANSCRIPT_PATH" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || echo "")
79
+ ENDED_AT=$(tail -1 "$TRANSCRIPT_PATH" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || echo "")
80
+ fi
81
+ fi
82
+
83
+ # Use baseline start time if transcript didn't provide one
84
+ [ -z "$STARTED_AT" ] && STARTED_AT="$BASELINE_STARTED"
85
+ # Fallback to now
86
+ [ -z "$ENDED_AT" ] && ENDED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
87
+ [ -z "$STARTED_AT" ] && STARTED_AT="$ENDED_AT"
88
+
89
+ # --- Compute duration ---
90
+ DURATION=""
91
+ DURATION_MIN=0
92
+ if [ -n "$STARTED_AT" ] && [ -n "$ENDED_AT" ]; then
93
+ S_EPOCH=$(python3 -c "from datetime import datetime; print(int(datetime.fromisoformat('$STARTED_AT'.replace('Z','+00:00')).timestamp()))" 2>/dev/null || echo "0")
94
+ E_EPOCH=$(python3 -c "from datetime import datetime; print(int(datetime.fromisoformat('$ENDED_AT'.replace('Z','+00:00')).timestamp()))" 2>/dev/null || echo "0")
95
+ if [ "$S_EPOCH" -gt 0 ] 2>/dev/null && [ "$E_EPOCH" -gt 0 ] 2>/dev/null; then
96
+ DURATION_MIN=$(( (E_EPOCH - S_EPOCH) / 60 ))
97
+ if [ "$DURATION_MIN" -lt 1 ] 2>/dev/null; then
98
+ DURATION="<1min"
99
+ elif [ "$DURATION_MIN" -lt 60 ] 2>/dev/null; then
100
+ DURATION="${DURATION_MIN}min"
101
+ else
102
+ HOURS=$(( DURATION_MIN / 60 ))
103
+ MINS=$(( DURATION_MIN % 60 ))
104
+ if [ "$MINS" -gt 0 ] 2>/dev/null; then
105
+ DURATION="${HOURS}h ${MINS}min"
106
+ else
107
+ DURATION="${HOURS}h"
108
+ fi
109
+ fi
110
+ fi
111
+ fi
112
+ [ -z "$DURATION" ] && DURATION="unknown"
113
+
114
+ # --- Read obs buffer for files touched ---
115
+ OBS_BUFFER="/tmp/egregore-obs-${SESSION_ID}.jsonl"
116
+ OBS_FILES=""
117
+ OBS_COUNT=0
118
+ if [ -f "$OBS_BUFFER" ] && [ -s "$OBS_BUFFER" ]; then
119
+ OBS_COUNT=$(wc -l < "$OBS_BUFFER" 2>/dev/null | tr -d ' ')
120
+ OBS_FILES=$(jq -r '.path // empty' "$OBS_BUFFER" 2>/dev/null | grep -v '^$' | grep -v '^unknown$' | sort -u | head -10 || true)
121
+ fi
122
+
123
+ # --- Count commits since baseline ---
124
+ COMMIT_COUNT=0
125
+ if [ -n "$BASELINE_COMMIT" ] && git -C "$SCRIPT_DIR" rev-parse "$BASELINE_COMMIT" >/dev/null 2>&1; then
126
+ COMMIT_COUNT=$(git -C "$SCRIPT_DIR" rev-list "${BASELINE_COMMIT}..HEAD" --count 2>/dev/null || echo "0")
127
+ fi
128
+
129
+ # --- Empty session guard ---
130
+ # Skip if: duration < 2 min AND no commits AND no obs buffer activity
131
+ if [ "$DURATION_MIN" -lt 2 ] 2>/dev/null && [ "$COMMIT_COUNT" -eq 0 ] 2>/dev/null && [ "$OBS_COUNT" -eq 0 ] 2>/dev/null; then
132
+ rm -f "$BASELINE_FILE" 2>/dev/null
133
+ exit 0
134
+ fi
135
+
136
+ # --- Deduplication: explicit /handoff or /wrap wins over baseline capture ---
137
+ TODAY=$(date +%d)
138
+ MONTH=$(date +%Y-%m)
139
+ if [ -n "$STARTED_AT" ]; then
140
+ MONTH=$(echo "$STARTED_AT" | grep -oE '^[0-9]{4}-[0-9]{2}' || date +%Y-%m)
141
+ TODAY=$(echo "$STARTED_AT" | grep -oE '^[0-9]{4}-[0-9]{2}-([0-9]{2})' | tail -c3 | sed 's/^-//' || date +%d)
142
+ fi
143
+
144
+ AUTHOR_LC=$(echo "$AUTHOR" | tr '[:upper:]' '[:lower:]')
145
+ DISPLAY_LC=$(echo "$DISPLAY_NAME" | tr '[:upper:]' '[:lower:]')
146
+ AUTHOR_PATTERN="-${AUTHOR_LC}-"
147
+ [ -n "$DISPLAY_LC" ] && AUTHOR_PATTERN="${AUTHOR_PATTERN}|-${DISPLAY_LC}-"
148
+ for CAPTURE_DIR in \
149
+ "$SCRIPT_DIR/memory/handoffs/$MONTH" \
150
+ "$SCRIPT_DIR/memory/wraps/$MONTH"; do
151
+ [ -d "$CAPTURE_DIR" ] || continue
152
+ # Match the canonical handle or display name in today's filename.
153
+ EXISTING=""
154
+ for CANDIDATE in "$CAPTURE_DIR/${TODAY}-"*.md; do
155
+ [ -e "$CANDIDATE" ] || continue
156
+ CANDIDATE_NAME="$(basename "$CANDIDATE" | tr '[:upper:]' '[:lower:]')"
157
+ if printf '%s\n' "$CANDIDATE_NAME" | grep -Eq -- "$AUTHOR_PATTERN"; then
158
+ EXISTING="$CANDIDATE_NAME"
159
+ break
160
+ fi
161
+ done
162
+ if [ -n "$EXISTING" ]; then
163
+ rm -f "$BASELINE_FILE" 2>/dev/null
164
+ exit 0
165
+ fi
166
+ done
167
+
168
+ # --- Write through the shared capture engine -----------------------------
169
+ SUMMARY="${DURATION} session on ${TOPIC}; ${COMMIT_COUNT} commit(s), ${OBS_COUNT} observed action(s)."
170
+ {
171
+ if [ -n "$OBS_FILES" ]; then
172
+ echo "## Files"
173
+ echo "$OBS_FILES" | while read -r F; do
174
+ [ -n "$F" ] && echo "- $F"
175
+ done
176
+ echo ""
177
+ fi
178
+
179
+ if [ "$COMMIT_COUNT" -gt 0 ] 2>/dev/null; then
180
+ echo "## Commits"
181
+ echo "$COMMIT_COUNT commits on ${BRANCH:-develop}"
182
+ echo ""
183
+ fi
184
+ } | bash "$SCRIPT_DIR/bin/capture-run.sh" \
185
+ --mode baseline \
186
+ --author "$AUTHOR" \
187
+ --display-name "$DISPLAY_NAME" \
188
+ --topic "$TOPIC" \
189
+ --summary "$SUMMARY" \
190
+ --session-id "$SESSION_ID" \
191
+ --branch "${BRANCH:-develop}" \
192
+ --date "$STARTED_AT" \
193
+ --duration "$DURATION" \
194
+ --async-push >/dev/null 2>&1 || true
195
+
196
+ # --- Clean up baseline ---
197
+ rm -f "$BASELINE_FILE" 2>/dev/null
198
+
199
+ exit 0
@@ -0,0 +1,274 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # settings.sh — deterministic config verbs for an Egregore instance.
5
+ #
6
+ # Edits egregore.json atomically. No agent, no network, no session. Idempotent:
7
+ # re-adding an existing item is a no-op success; removing a missing one is too.
8
+ # This is the deterministic core the launcher settings screen (and slash
9
+ # commands) call — the reliability lives here, the surfaces are thin callers.
10
+ #
11
+ # Usage:
12
+ # settings.sh hosting status|on|off
13
+ # settings.sh repo list | add <name> [description] | remove <name>
14
+ # settings.sh admin list | add <github-handle> | remove <github-handle>
15
+ # settings.sh dump # full settings snapshot as JSON (for the launcher)
16
+ #
17
+ # Add --json to a read verb (status/list) for machine-readable output.
18
+ #
19
+ # Exit codes: 0 ok · 1 usage/validation error · 2 config not found.
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
22
+ CONFIG="$SCRIPT_DIR/egregore.json"
23
+
24
+ # ── helpers ──────────────────────────────────────────────────────────────
25
+
26
+ _die() { echo "$1" >&2; exit "${2:-1}"; }
27
+
28
+ _valid_token() {
29
+ # non-empty, GitHub-handle / repo-name safe charset
30
+ [ -n "${1:-}" ] && printf '%s' "$1" | grep -qE '^[A-Za-z0-9._-]+$'
31
+ }
32
+
33
+ _save() {
34
+ # _save '<jq-filter>' [jq-args...] — apply filter to CONFIG, write atomically.
35
+ local filter="$1"; shift
36
+ local tmp
37
+ tmp="$(mktemp "$CONFIG.XXXXXX")" || _die "cannot create temp file" 1
38
+ if jq "$@" "$filter" "$CONFIG" > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then
39
+ mv "$tmp" "$CONFIG"
40
+ else
41
+ rm -f "$tmp"
42
+ _die "failed to update $CONFIG (invalid jq filter or unwritable file)" 1
43
+ fi
44
+ }
45
+
46
+ _has_repo() { jq -e --arg n "$1" 'any(.repos[]?; (if type=="object" then .name else . end) == $n)' "$CONFIG" >/dev/null; }
47
+ _has_admin() { jq -e --arg h "$1" 'any(.admins[]?; . == $h)' "$CONFIG" >/dev/null; }
48
+
49
+ usage() {
50
+ cat >&2 <<'EOF'
51
+ settings.sh — Egregore instance settings
52
+
53
+ settings.sh hosting status|on|off
54
+ settings.sh repo list | add <name> [description] | remove <name>
55
+ settings.sh admin list | add <github-handle> | remove <github-handle>
56
+ settings.sh people list | add <github-handle> | remove <github-handle>
57
+ settings.sh dump full snapshot (JSON)
58
+
59
+ --json machine-readable output for status/list
60
+ EOF
61
+ exit "${1:-1}"
62
+ }
63
+
64
+ # ── verbs ────────────────────────────────────────────────────────────────
65
+
66
+ cmd_hosting() {
67
+ local action="${1:-status}"
68
+ # NEVER use jq's `// "true"` here: `//` treats an explicit `false` as empty and
69
+ # would return the default, silently defeating the off-switch. Test `!= false`
70
+ # so absent/true => enabled, explicit false => disabled. `enabled` is the
71
+ # resolved boolean string ("true"/"false").
72
+ local enabled; enabled="$(jq -r '.features.publishing != false' "$CONFIG" 2>/dev/null || echo "true")"
73
+ case "$action" in
74
+ status)
75
+ if [ "$JSON" = 1 ]; then
76
+ jq -n --argjson on "$enabled" '{domain:"hosting", publishing:$on}'
77
+ elif [ "$enabled" = "false" ]; then echo "Hosting (egregore.xyz): OFF"
78
+ else echo "Hosting (egregore.xyz): ON"; fi ;;
79
+ on)
80
+ _save '.features.publishing = true'
81
+ echo "Hosting (egregore.xyz): ON" ;;
82
+ off)
83
+ _save '.features.publishing = false'
84
+ echo "Hosting (egregore.xyz): OFF — artifacts will no longer upload to egregore.xyz" ;;
85
+ *) _die "usage: settings.sh hosting status|on|off" 1 ;;
86
+ esac
87
+ }
88
+
89
+ cmd_repo() {
90
+ local action="${1:-list}"; shift || true
91
+ case "$action" in
92
+ list)
93
+ if [ "$JSON" = 1 ]; then
94
+ jq '[.repos[]? | if type=="object" then . else {name:.} end]' "$CONFIG"
95
+ else
96
+ jq -r '.repos[]? | if type=="object" then " " + .name + (if .description then " — " + .description else "" end) else " " + . end' "$CONFIG"
97
+ fi ;;
98
+ add)
99
+ local name="${1:-}"; shift || true
100
+ local desc="${*:-}"
101
+ _valid_token "$name" || _die "usage: settings.sh repo add <name> [description]" 1
102
+ if _has_repo "$name"; then echo "repo '$name' already managed"; return 0; fi
103
+ if [ -n "$desc" ]; then
104
+ _save '.repos = ((.repos // []) + [{name:$n, description:$d}])' --arg n "$name" --arg d "$desc"
105
+ else
106
+ _save '.repos = ((.repos // []) + [{name:$n}])' --arg n "$name"
107
+ fi
108
+ echo "added repo '$name'"
109
+ echo " clone it with: /sync-repos (or reopen the egregore)" ;;
110
+ remove)
111
+ local name="${1:-}"
112
+ _valid_token "$name" || _die "usage: settings.sh repo remove <name>" 1
113
+ if ! _has_repo "$name"; then echo "repo '$name' is not managed"; return 0; fi
114
+ _save '.repos = [.repos[]? | select((if type=="object" then .name else . end) != $n)]' --arg n "$name"
115
+ echo "removed repo '$name' from config"
116
+ echo " note: the local ../$name checkout is left untouched" ;;
117
+ *) _die "usage: settings.sh repo list|add|remove" 1 ;;
118
+ esac
119
+ }
120
+
121
+ cmd_admin() {
122
+ local action="${1:-list}"; shift || true
123
+ case "$action" in
124
+ list)
125
+ if [ "$JSON" = 1 ]; then jq '{admins:(.admins // [])}' "$CONFIG"
126
+ else jq -r '.admins[]? | " " + .' "$CONFIG"; fi ;;
127
+ add)
128
+ local h="${1:-}"
129
+ _valid_token "$h" || _die "usage: settings.sh admin add <github-handle>" 1
130
+ if _has_admin "$h"; then echo "'$h' is already an admin"; return 0; fi
131
+ _save '.admins = ((.admins // []) + [$h])' --arg h "$h"
132
+ echo "added admin '$h'" ;;
133
+ remove)
134
+ local h="${1:-}"
135
+ _valid_token "$h" || _die "usage: settings.sh admin remove <github-handle>" 1
136
+ if ! _has_admin "$h"; then echo "'$h' is not an admin"; return 0; fi
137
+ local count; count="$(jq '(.admins // []) | length' "$CONFIG")"
138
+ [ "$count" -le 1 ] && _die "refusing to remove the last admin ('$h') — add another admin first" 1
139
+ _save '.admins = [.admins[]? | select(. != $h)]' --arg h "$h"
140
+ echo "removed admin '$h'" ;;
141
+ *) _die "usage: settings.sh admin list|add|remove" 1 ;;
142
+ esac
143
+ }
144
+
145
+ # ── people ───────────────────────────────────────────────────────────────
146
+ # People live as files in memory/people/. add/remove manage that directory AND
147
+ # GitHub repo access (the meaningful local grant). Connected-mode extras — the
148
+ # invite link + Telegram (/invite) and Supabase/Neo4j teardown (/delete-user) —
149
+ # are pointed to, not duplicated here.
150
+
151
+ _people_dir() { echo "$SCRIPT_DIR/memory/people"; }
152
+
153
+ _people_names() {
154
+ local pdir; pdir="$(_people_dir)"
155
+ [ -d "$pdir" ] || return 0
156
+ ls "$pdir"/*.md 2>/dev/null | while IFS= read -r f; do basename "$f" .md; done \
157
+ | grep -viE '^(index|readme)$' || true
158
+ }
159
+
160
+ _people_json() {
161
+ local names; names="$(_people_names)"
162
+ if [ -z "$names" ]; then echo '[]'; return; fi
163
+ printf '%s\n' "$names" | jq -R . | jq -sc 'map(select(length>0))'
164
+ }
165
+
166
+ _has_person() { [ -f "$(_people_dir)/$1.md" ]; }
167
+
168
+ # add|remove a GitHub push-collaborator across the instance's repos. Best-effort:
169
+ # no token/org => skip with a note; per-repo failures never abort.
170
+ _people_github() {
171
+ local user="$1" op="$2" token org repos r
172
+ token="$(grep '^GITHUB_TOKEN=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2- || true)"
173
+ org="$(jq -r '.github_org // empty' "$CONFIG" 2>/dev/null)"
174
+ if [ -z "$token" ] || [ -z "$org" ]; then
175
+ echo " (no GitHub token/org — skipped repo access; run bin/github-auth.sh)" >&2
176
+ return 0
177
+ fi
178
+ repos="$(jq -r '[.repo_name, (.memory_repo // "" | split("/") | last | sub("\\.git$";"")), (.repos[]? | if type=="object" then .name else . end)] | map(select(. != null and . != "")) | unique | .[]' "$CONFIG" 2>/dev/null)"
179
+ for r in $repos; do
180
+ if [ "$op" = "add" ]; then
181
+ curl -s -o /dev/null -X PUT -H "Authorization: Bearer $token" -H "Accept: application/vnd.github+json" \
182
+ "https://api.github.com/repos/$org/$r/collaborators/$user" -d '{"permission":"push"}' 2>/dev/null || true
183
+ else
184
+ curl -s -o /dev/null -X DELETE -H "Authorization: Bearer $token" -H "Accept: application/vnd.github+json" \
185
+ "https://api.github.com/repos/$org/$r/collaborators/$user" 2>/dev/null || true
186
+ fi
187
+ done
188
+ }
189
+
190
+ # create|remove memory/people/<user>.md, committing ONLY the people dir.
191
+ _people_file() {
192
+ local user="$1" op="$2" pdir f inviter today
193
+ pdir="$(_people_dir)"
194
+ [ -d "$SCRIPT_DIR/memory" ] || { echo " (no memory dir — skipped person file)" >&2; return 0; }
195
+ mkdir -p "$pdir"
196
+ f="$pdir/$user.md"
197
+ if [ "$op" = "add" ]; then
198
+ if [ ! -f "$f" ]; then
199
+ inviter="$(jq -r '.display_name // .github_username // "admin"' "$SCRIPT_DIR/.egregore-state.json" 2>/dev/null || echo admin)"
200
+ today="$(date -u +%Y-%m-%d)"
201
+ printf -- '---\nname: %s\nperson_id: github-login:%s\ngithub: %s\ninvited_by: %s\njoined: %s\n---\n' "$user" "$(printf '%s' "$user" | tr '[:upper:]' '[:lower:]')" "$user" "$inviter" "$today" > "$f"
202
+ fi
203
+ else
204
+ rm -f "$f"
205
+ fi
206
+ ( cd "$SCRIPT_DIR/memory" 2>/dev/null && git add -A people/ 2>/dev/null \
207
+ && git commit -q -m "settings: people $op $user" 2>/dev/null \
208
+ && git push -q 2>/dev/null ) || true
209
+ }
210
+
211
+ cmd_people() {
212
+ local action="${1:-list}"; shift || true
213
+ case "$action" in
214
+ list)
215
+ if [ "$JSON" = 1 ]; then _people_json
216
+ else
217
+ local names; names="$(_people_names)"
218
+ if [ -n "$names" ]; then printf ' %s\n' $names; else echo " (none)"; fi
219
+ fi ;;
220
+ add)
221
+ local h="${1:-}"
222
+ _valid_token "$h" || _die "usage: settings.sh people add <github-handle>" 1
223
+ if _has_person "$h"; then echo "'$h' is already in people"; return 0; fi
224
+ _people_github "$h" "add"
225
+ _people_file "$h" "add"
226
+ echo "added '$h' (repo access + person file)"
227
+ [ -n "$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)" ] && echo " connected org: for the invite link + Telegram, run /invite $h" ;;
228
+ remove)
229
+ local h="${1:-}"
230
+ _valid_token "$h" || _die "usage: settings.sh people remove <github-handle>" 1
231
+ if ! _has_person "$h"; then echo "'$h' is not in people"; return 0; fi
232
+ _people_github "$h" "remove"
233
+ _people_file "$h" "remove"
234
+ echo "removed '$h' (repo access + person file)"
235
+ [ -n "$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)" ] && echo " connected org: for full deprovision (Supabase/Neo4j), run /delete-user $h" ;;
236
+ *) _die "usage: settings.sh people list|add|remove" 1 ;;
237
+ esac
238
+ }
239
+
240
+ cmd_dump() {
241
+ # Always JSON — the launcher calls this once to render the whole settings screen.
242
+ local people; people="$(_people_json)"
243
+ jq --argjson people "$people" '{
244
+ org: .org_name,
245
+ slug: .slug,
246
+ hosting: (.features.publishing != false),
247
+ repos: [.repos[]? | if type=="object" then . else {name:.} end],
248
+ admins: (.admins // []),
249
+ people: $people
250
+ }' "$CONFIG"
251
+ }
252
+
253
+ # ── dispatch ─────────────────────────────────────────────────────────────
254
+
255
+ [ -f "$CONFIG" ] || _die "egregore.json not found at $CONFIG" 2
256
+
257
+ # Pull out --json (bash 3.2-safe array handling), leave the rest as positionals.
258
+ JSON=0
259
+ _args=()
260
+ for _a in "$@"; do
261
+ if [ "$_a" = "--json" ]; then JSON=1; else _args+=("$_a"); fi
262
+ done
263
+ set -- "${_args[@]+"${_args[@]}"}"
264
+
265
+ DOMAIN="${1:-}"; shift || true
266
+ case "$DOMAIN" in
267
+ hosting) cmd_hosting "$@" ;;
268
+ repo) cmd_repo "$@" ;;
269
+ admin) cmd_admin "$@" ;;
270
+ people) cmd_people "$@" ;;
271
+ dump) cmd_dump ;;
272
+ ""|-h|--help|help) usage 0 ;;
273
+ *) _die "unknown settings domain: '$DOMAIN' (try: hosting, repo, admin, people, dump)" 1 ;;
274
+ esac