create-egregore 0.19.8 → 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 (91) hide show
  1. package/lib/local.js +11 -1
  2. package/lib/setup.js +33 -0
  3. package/package.json +1 -1
  4. package/runtime/codex/.codex/hooks/branch-guard.js +242 -34
  5. package/runtime/codex/.codex/skills/activity/SKILL.md +18 -6
  6. package/runtime/codex/.codex/skills/deep-reflect/SKILL.md +90 -55
  7. package/runtime/codex/.codex/skills/deploy-bridge/SKILL.md +25 -0
  8. package/runtime/codex/.codex/skills/handoff/SKILL.md +6 -2
  9. package/runtime/codex/.codex/skills/save/SKILL.md +21 -3
  10. package/runtime/codex/.codex/skills/scroll/SKILL.md +54 -3
  11. package/runtime/codex/.codex/skills/task/SKILL.md +25 -0
  12. package/runtime/codex/.codex/skills/tui-design/SKILL.md +1 -1
  13. package/runtime/codex/.codex/skills/wrap/SKILL.md +4 -1
  14. package/runtime/codex/.codex/spec-manifest.json +15 -3
  15. package/runtime/codex/AGENTS.md +45 -9
  16. package/runtime/codex/bin/activity-data.sh +1 -1
  17. package/runtime/codex/bin/agent.sh +66 -23
  18. package/runtime/codex/bin/attendant.sh +525 -0
  19. package/runtime/codex/bin/capture-reconcile.sh +67 -0
  20. package/runtime/codex/bin/capture-run.sh +341 -0
  21. package/runtime/codex/bin/codex-render-spec.mjs +15 -4
  22. package/runtime/codex/bin/codex-session-start.sh +45 -3
  23. package/runtime/codex/bin/codex-skill-render.mjs +23 -4
  24. package/runtime/codex/bin/graph-op.sh +719 -49
  25. package/runtime/codex/bin/graph-wal.sh +3 -2
  26. package/runtime/codex/bin/handoff-run.sh +72 -0
  27. package/runtime/codex/bin/handoff-save-egregore.sh +51 -11
  28. package/runtime/codex/bin/index-handoff.sh +26 -17
  29. package/runtime/codex/bin/ingest-graph.sh +80 -0
  30. package/runtime/codex/bin/ingest-surface.html +429 -0
  31. package/runtime/codex/bin/ingest.py +614 -0
  32. package/runtime/codex/bin/ingest.sh +231 -0
  33. package/runtime/codex/bin/ingest_graph.py +776 -0
  34. package/runtime/codex/bin/ingest_surface.py +464 -0
  35. package/runtime/codex/bin/lib/git-sync.sh +2 -6
  36. package/runtime/codex/bin/lib/greeting.sh +157 -22
  37. package/runtime/codex/bin/lib/identity.sh +50 -29
  38. package/runtime/codex/bin/lib/noncode.sh +31 -0
  39. package/runtime/codex/bin/person.py +610 -0
  40. package/runtime/codex/bin/person.sh +448 -0
  41. package/runtime/codex/bin/session-autosave.sh +184 -0
  42. package/runtime/codex/bin/session-log.sh +199 -0
  43. package/runtime/codex/bin/settings.sh +1 -1
  44. package/runtime/codex/manifest.json +19 -1
  45. package/runtime/pi/.codex/hooks/branch-guard.js +242 -34
  46. package/runtime/pi/.codex/skills/activity/SKILL.md +18 -6
  47. package/runtime/pi/.codex/skills/deep-reflect/SKILL.md +90 -55
  48. package/runtime/pi/.codex/skills/deploy-bridge/SKILL.md +25 -0
  49. package/runtime/pi/.codex/skills/handoff/SKILL.md +6 -2
  50. package/runtime/pi/.codex/skills/save/SKILL.md +21 -3
  51. package/runtime/pi/.codex/skills/scroll/SKILL.md +54 -3
  52. package/runtime/pi/.codex/skills/task/SKILL.md +25 -0
  53. package/runtime/pi/.codex/skills/tui-design/SKILL.md +1 -1
  54. package/runtime/pi/.codex/skills/wrap/SKILL.md +4 -1
  55. package/runtime/pi/.codex/spec-manifest.json +15 -3
  56. package/runtime/pi/.pi/APPEND_SYSTEM.md +41 -5
  57. package/runtime/pi/.pi/extensions/egregore.ts +98 -8
  58. package/runtime/pi/.pi/spec-manifest.json +2 -2
  59. package/runtime/pi/AGENTS.md +45 -9
  60. package/runtime/pi/bin/activity-data.sh +1 -1
  61. package/runtime/pi/bin/agent.sh +66 -23
  62. package/runtime/pi/bin/attendant.sh +525 -0
  63. package/runtime/pi/bin/capture-reconcile.sh +67 -0
  64. package/runtime/pi/bin/capture-run.sh +341 -0
  65. package/runtime/pi/bin/codex-render-spec.mjs +15 -4
  66. package/runtime/pi/bin/codex-session-start.sh +45 -3
  67. package/runtime/pi/bin/codex-skill-render.mjs +23 -4
  68. package/runtime/pi/bin/graph-op.sh +719 -49
  69. package/runtime/pi/bin/graph-wal.sh +3 -2
  70. package/runtime/pi/bin/handoff-run.sh +72 -0
  71. package/runtime/pi/bin/handoff-save-egregore.sh +51 -11
  72. package/runtime/pi/bin/index-handoff.sh +26 -17
  73. package/runtime/pi/bin/ingest-graph.sh +80 -0
  74. package/runtime/pi/bin/ingest-surface.html +429 -0
  75. package/runtime/pi/bin/ingest.py +614 -0
  76. package/runtime/pi/bin/ingest.sh +231 -0
  77. package/runtime/pi/bin/ingest_graph.py +776 -0
  78. package/runtime/pi/bin/ingest_surface.py +464 -0
  79. package/runtime/pi/bin/lib/git-sync.sh +2 -6
  80. package/runtime/pi/bin/lib/greeting.sh +157 -22
  81. package/runtime/pi/bin/lib/identity.sh +50 -29
  82. package/runtime/pi/bin/lib/noncode.sh +31 -0
  83. package/runtime/pi/bin/person.py +610 -0
  84. package/runtime/pi/bin/person.sh +448 -0
  85. package/runtime/pi/bin/pi-branch-policy.mjs +164 -4
  86. package/runtime/pi/bin/pi-product-workflows.mjs +31 -2
  87. package/runtime/pi/bin/publish-artifact.sh +14 -0
  88. package/runtime/pi/bin/session-autosave.sh +184 -0
  89. package/runtime/pi/bin/session-log.sh +199 -0
  90. package/runtime/pi/bin/settings.sh +274 -0
  91. package/runtime/pi/manifest.json +20 -1
@@ -0,0 +1,448 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # One identity spine for .egregore-state.json, memory/people, Supabase, and
5
+ # Neo4j. GitHub's numeric id is the durable key when available.
6
+
7
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
8
+ SCRIPT_DIR="$ROOT"
9
+ CONFIG="$ROOT/egregore.json"
10
+ STATE="$ROOT/.egregore-state.json"
11
+ PERSON_PY="$ROOT/bin/person.py"
12
+
13
+ # shellcheck source=bin/lib/config.sh
14
+ source "$ROOT/bin/lib/config.sh"
15
+
16
+ usage() {
17
+ echo 'usage: bash bin/person.sh {sync|onboard|set-name NAME|set-email EMAIL|show|backfill [--dry-run|--apply] [--include-removed]}' >&2
18
+ exit 2
19
+ }
20
+
21
+ remote_config() {
22
+ api_url="${EGREGORE_API_URL:-$(_config_val api_url)}"
23
+ api_key="${EGREGORE_API_KEY:-$(_load_env_var EGREGORE_API_KEY)}"
24
+ }
25
+
26
+ sync_remote_identity() {
27
+ identity="$1"
28
+ remote_config
29
+
30
+ api_status="not-configured"
31
+ platform_user_id=""
32
+ if [ -n "$api_url" ] && [ -n "$api_key" ]; then
33
+ api_payload="$(printf '%s' "$identity" | jq '{
34
+ github_username,
35
+ github_id,
36
+ github_name,
37
+ github_aliases,
38
+ display_name,
39
+ email,
40
+ emails
41
+ }')"
42
+ api_result="$(curl -sf "${api_url}/api/user/ensure" \
43
+ -H "Authorization: Bearer ${api_key}" \
44
+ -H "Content-Type: application/json" \
45
+ -d "$api_payload" \
46
+ --max-time 10 2>/dev/null || true)"
47
+ api_status="$(printf '%s' "$api_result" | jq -r '.status // "failed"' 2>/dev/null || echo failed)"
48
+ platform_user_id="$(printf '%s' "$api_result" | jq -r '.user_id // empty' 2>/dev/null || true)"
49
+ fi
50
+
51
+ # A removed membership is terminal until an admin explicitly re-invites
52
+ # the person. Never let startup recreate their graph membership.
53
+ if [ "$api_status" = "removed" ]; then
54
+ printf '%s' "$identity" | jq \
55
+ --arg platform_user_id "$platform_user_id" \
56
+ '. + {
57
+ status:"removed",
58
+ supabase:"removed",
59
+ graph:"skipped-removed",
60
+ platform_user_id:$platform_user_id,
61
+ merged_duplicates:0
62
+ }'
63
+ return 0
64
+ fi
65
+
66
+ params="$(printf '%s' "$identity" | jq --arg platformUserId "$platform_user_id" '{
67
+ personId:.person_id,
68
+ github:.github_username,
69
+ githubId:(if .github_id == null then "" else (.github_id|tostring) end),
70
+ fullName:.github_name,
71
+ displayName:.display_name,
72
+ primaryEmail:(.email // ""),
73
+ emails,
74
+ githubAliases:.github_aliases,
75
+ previousNames:.previous_names,
76
+ profilePath:.profile_path,
77
+ allowNameMatch:(
78
+ (.allow_name_match // false)
79
+ or (.source_profile // "") != ""
80
+ or ((.aliases_reconciled // []) | length) > 0
81
+ ),
82
+ platformUserId:$platformUserId
83
+ }')"
84
+
85
+ graph_status="failed"
86
+ if graph_result="$(bash "$ROOT/bin/graph.sh" query "
87
+ OPTIONAL MATCH (legacy:Person)
88
+ WHERE NOT coalesce(legacy.kind, '') IN ['external', 'identity_alias']
89
+ AND coalesce(legacy.status, 'active') = 'active'
90
+ AND legacy.ingestRef IS NULL
91
+ AND (
92
+ legacy.personId = \$personId
93
+ OR (\$githubId <> '' AND coalesce(toString(legacy.githubId), '') = \$githubId)
94
+ OR toLower(coalesce(legacy.github, '')) = toLower(\$github)
95
+ OR toLower(coalesce(legacy.github, '')) IN
96
+ [alias IN \$githubAliases | toLower(alias)]
97
+ OR (
98
+ \$allowNameMatch
99
+ AND legacy.personId IS NULL
100
+ AND legacy.githubId IS NULL
101
+ AND (legacy.github IS NULL OR legacy.github = '')
102
+ AND toLower(coalesce(legacy.name, '')) = toLower(\$displayName)
103
+ )
104
+ )
105
+ WITH legacy
106
+ ORDER BY CASE
107
+ WHEN legacy.personId = \$personId THEN 0
108
+ WHEN \$githubId <> '' AND coalesce(toString(legacy.githubId), '') = \$githubId THEN 1
109
+ WHEN toLower(coalesce(legacy.github, '')) = toLower(\$github) THEN 2
110
+ ELSE 3
111
+ END
112
+ WITH head(collect(legacy)) AS legacy
113
+ FOREACH (_ IN CASE WHEN legacy IS NULL THEN [] ELSE [1] END |
114
+ SET legacy.personId = \$personId
115
+ )
116
+ MERGE (p:Person {personId: \$personId})
117
+ ON CREATE SET p.joined = date()
118
+ SET p.github = \$github,
119
+ p.githubId = CASE WHEN \$githubId = '' THEN p.githubId ELSE \$githubId END,
120
+ p.kind = 'member',
121
+ p.org = \$_org,
122
+ p.status = 'active',
123
+ p.removedAt = null,
124
+ p.identityStatus = CASE WHEN \$githubId = '' THEN 'provisional' ELSE 'verified' END,
125
+ p.platformUserId = CASE WHEN \$platformUserId = '' THEN p.platformUserId ELSE \$platformUserId END,
126
+ p.name = \$displayName,
127
+ p.displayName = \$displayName,
128
+ p.fullName = \$fullName,
129
+ p.email = CASE WHEN \$primaryEmail = '' THEN p.email ELSE \$primaryEmail END,
130
+ p.emails = reduce(acc = coalesce(p.emails, []), value IN \$emails |
131
+ CASE WHEN value IN acc THEN acc ELSE acc + value END),
132
+ p.githubAliases = reduce(acc = coalesce(p.githubAliases, []), value IN \$githubAliases |
133
+ CASE WHEN value IN acc THEN acc ELSE acc + value END),
134
+ p.previousNames = reduce(acc = coalesce(p.previousNames, []), value IN \$previousNames |
135
+ CASE WHEN value IN acc THEN acc ELSE acc + value END),
136
+ p.profilePath = \$profilePath,
137
+ p.identityVersion = 1,
138
+ p.identityUpdatedAt = datetime()
139
+ WITH p
140
+ MATCH (o:Org {id: \$_org})
141
+ MERGE (p)-[:MEMBER_OF]->(o)
142
+ RETURN p.personId AS personId
143
+ " "$params" 2>/dev/null)"; then
144
+ if printf '%s' "$graph_result" | jq -e \
145
+ --arg personId "$(printf '%s' "$identity" | jq -r .person_id)" \
146
+ '.values[0][0] == $personId' >/dev/null 2>&1; then
147
+ graph_status="synced"
148
+ fi
149
+ fi
150
+
151
+ merged_duplicates=0
152
+ if [ "$graph_status" = "synced" ]; then
153
+ duplicates="$(bash "$ROOT/bin/graph.sh" query "
154
+ MATCH (real:Person {personId: \$personId})
155
+ MATCH (duplicate:Person)
156
+ WHERE duplicate <> real AND (
157
+ duplicate.personId = \$personId
158
+ OR (\$githubId <> '' AND coalesce(toString(duplicate.githubId), '') = \$githubId)
159
+ OR toLower(coalesce(duplicate.github, '')) = toLower(\$github)
160
+ OR toLower(coalesce(duplicate.github, '')) IN \$githubAliases
161
+ OR (
162
+ duplicate.githubId IS NULL
163
+ AND duplicate.personId IS NULL
164
+ AND (duplicate.github IS NULL OR duplicate.github = '')
165
+ AND any(email IN coalesce(duplicate.emails, []) WHERE email IN \$emails)
166
+ )
167
+ OR (
168
+ \$allowNameMatch
169
+ AND
170
+ (duplicate.github IS NULL OR duplicate.github = '')
171
+ AND duplicate.personId IS NULL
172
+ AND toLower(coalesce(duplicate.name, '')) = toLower(\$displayName)
173
+ )
174
+ )
175
+ AND NOT coalesce(duplicate.kind, '') IN ['external', 'identity_alias']
176
+ AND coalesce(duplicate.status, 'active') = 'active'
177
+ AND duplicate.ingestRef IS NULL
178
+ RETURN DISTINCT coalesce(duplicate.github, duplicate.name) AS ref
179
+ " "$params" 2>/dev/null || echo '{"values":[]}')"
180
+ while IFS= read -r duplicate; do
181
+ [ -n "$duplicate" ] || continue
182
+ if bash "$ROOT/bin/graph-op.sh" merge-person "$(printf '%s' "$identity" | jq -r .person_id)" \
183
+ "$duplicate" >/dev/null 2>&1; then
184
+ merged_duplicates=$((merged_duplicates + 1))
185
+ fi
186
+ done < <(printf '%s' "$duplicates" | jq -r '.values[]?[0] // empty')
187
+ fi
188
+
189
+ status="synced"
190
+ [ "$api_status" = "ok" ] || status="partial"
191
+ [ "$graph_status" = "synced" ] || status="partial"
192
+ printf '%s' "$identity" | jq \
193
+ --arg status "$status" \
194
+ --arg supabase "$api_status" \
195
+ --arg graph "$graph_status" \
196
+ --arg platform_user_id "$platform_user_id" \
197
+ --argjson merged_duplicates "$merged_duplicates" \
198
+ '. + {
199
+ status:$status,
200
+ supabase:$supabase,
201
+ graph:$graph,
202
+ platform_user_id:$platform_user_id,
203
+ merged_duplicates:$merged_duplicates
204
+ }'
205
+ }
206
+
207
+ backfill_people() {
208
+ mode="dry-run"
209
+ include_removed="false"
210
+ shift
211
+ while [ "$#" -gt 0 ]; do
212
+ case "$1" in
213
+ --dry-run) mode="dry-run" ;;
214
+ --apply) mode="apply" ;;
215
+ --include-removed) include_removed="true" ;;
216
+ *) usage ;;
217
+ esac
218
+ shift
219
+ done
220
+
221
+ if [ "$(_detect_mode)" = "local" ]; then
222
+ jq -n '{status:"unavailable",reason:"person backfill requires the hosted member roster in this configuration"}'
223
+ return 1
224
+ fi
225
+ command -v gh >/dev/null 2>&1 || {
226
+ jq -n '{status:"error",error:"GitHub CLI is required to resolve stable user ids"}'
227
+ return 1
228
+ }
229
+
230
+ remote_config
231
+ if [ -z "$api_url" ] || [ -z "$api_key" ]; then
232
+ jq -n '{status:"error",error:"connected API configuration is incomplete"}'
233
+ return 1
234
+ fi
235
+ org_slug="$(_config_val slug)"
236
+ members_result="$(curl -sf "${api_url}/api/org/${org_slug}/members?include_removed=true" \
237
+ -H "Authorization: Bearer ${api_key}" \
238
+ --max-time 20 2>/dev/null || true)"
239
+ if ! printf '%s' "$members_result" | jq -e '.members | type == "array"' >/dev/null 2>&1; then
240
+ jq -n '{status:"error",error:"could not load the hosted member roster"}'
241
+ return 1
242
+ fi
243
+ if ! printf '%s' "$members_result" | jq -e \
244
+ '.members | length == 0 or all(has("github_id") and has("github_aliases"))' \
245
+ >/dev/null 2>&1; then
246
+ jq -n '{status:"blocked",error:"the hosted API is still using the pre-identity member contract; deploy it before backfilling"}'
247
+ return 1
248
+ fi
249
+
250
+ results='[]'
251
+ excluded_removed=0
252
+ while IFS= read -r member; do
253
+ [ -n "$member" ] || continue
254
+ member_status="$(printf '%s' "$member" | jq -r '.status // "active"')"
255
+ if [ "$member_status" != "active" ] && [ "$include_removed" != "true" ]; then
256
+ excluded_removed=$((excluded_removed + 1))
257
+ continue
258
+ fi
259
+
260
+ previous_github="$(printf '%s' "$member" | jq -r '.github_username // empty')"
261
+ if ! printf '%s' "$previous_github" | grep -Eq '^[A-Za-z0-9-]{1,39}$'; then
262
+ item="$(jq -n --arg github "$previous_github" '{
263
+ github_username:$github,status:"skipped",reason:"invalid GitHub login"
264
+ }')"
265
+ results="$(printf '%s' "$results" | jq --argjson item "$item" '. + [$item]')"
266
+ continue
267
+ fi
268
+
269
+ github_result="$(gh api "users/${previous_github}" \
270
+ --jq '{login,id,name,email}' 2>/dev/null || true)"
271
+ if ! printf '%s' "$github_result" | jq -e \
272
+ '.login and (.id | type == "number")' >/dev/null 2>&1; then
273
+ item="$(jq -n --arg github "$previous_github" '{
274
+ github_username:$github,status:"unresolved",reason:"GitHub identity could not be resolved"
275
+ }')"
276
+ results="$(printf '%s' "$results" | jq --argjson item "$item" '. + [$item]')"
277
+ continue
278
+ fi
279
+
280
+ github="$(printf '%s' "$github_result" | jq -r .login)"
281
+ github_id="$(printf '%s' "$github_result" | jq -r .id)"
282
+ github_name="$(printf '%s' "$github_result" | jq -r '.name // empty')"
283
+ public_email="$(printf '%s' "$github_result" | jq -r '.email // empty')"
284
+ display_name="$(printf '%s' "$member" | jq -r \
285
+ --arg githubName "$github_name" --arg github "$github" \
286
+ '.display_name // (if $githubName != "" then $githubName else $github end)')"
287
+ role="$(printf '%s' "$member" | jq -r '.member_role // .role // "Member"')"
288
+ joined="$(printf '%s' "$member" | jq -r '.joined_at // empty' | cut -c1-10)"
289
+ aliases="$(printf '%s' "$member" | jq -r \
290
+ --arg old "$previous_github" --arg current "$github" '
291
+ ((.github_aliases // []) + (if ($old|ascii_downcase) != ($current|ascii_downcase) then [$old] else [] end))
292
+ | map(select(type == "string" and length > 0)) | unique | join(",")
293
+ ')"
294
+ emails="$(printf '%s' "$member" | jq -r \
295
+ --arg public "$public_email" '
296
+ ((.emails // []) + [(.primary_email // ""), $public])
297
+ | map(select(type == "string" and length > 0)) | unique | join(",")
298
+ ')"
299
+
300
+ profile_args=(
301
+ sync-profile
302
+ --github "$github"
303
+ --github-id "$github_id"
304
+ --github-name "$github_name"
305
+ --display-name "$display_name"
306
+ --previous-github "$previous_github"
307
+ --github-aliases "$aliases"
308
+ --emails "$emails"
309
+ --role "$role"
310
+ --joined "$joined"
311
+ )
312
+ [ "$mode" = "apply" ] || profile_args+=(--dry-run)
313
+ if ! identity="$(python3 "$PERSON_PY" "${profile_args[@]}")"; then
314
+ item="$(jq -n --arg github "$github" '{
315
+ github_username:$github,status:"skipped",reason:"profile reconciliation failed"
316
+ }')"
317
+ results="$(printf '%s' "$results" | jq --argjson item "$item" '. + [$item]')"
318
+ continue
319
+ fi
320
+ roster_name_matches="$(printf '%s' "$members_result" | jq \
321
+ --arg displayName "$display_name" '
322
+ [.members[]
323
+ | (.display_name // "")
324
+ | select(length > 0)
325
+ | ascii_downcase
326
+ | select(. == ($displayName | ascii_downcase))]
327
+ | length
328
+ ')"
329
+ allow_name_match="false"
330
+ [ "$roster_name_matches" -eq 1 ] && allow_name_match="true"
331
+ identity="$(printf '%s' "$identity" | jq \
332
+ --argjson allowNameMatch "$allow_name_match" \
333
+ '.allow_name_match = $allowNameMatch')"
334
+
335
+ if [ "$mode" = "apply" ]; then
336
+ synced="$(sync_remote_identity "$identity")"
337
+ report="$(printf '%s' "$synced" | jq 'del(.email,.emails,.allow_name_match)')"
338
+ else
339
+ report="$(printf '%s' "$identity" | jq \
340
+ '. + {status:"planned"} | del(.email,.emails,.allow_name_match)')"
341
+ fi
342
+ results="$(printf '%s' "$results" | jq --argjson item "$report" '. + [$item]')"
343
+ done < <(printf '%s' "$members_result" | jq -c '.members[]')
344
+
345
+ printf '%s' "$results" | jq \
346
+ --arg mode "$mode" \
347
+ --arg org "$org_slug" \
348
+ --argjson excluded_removed "$excluded_removed" '
349
+ {
350
+ status:(if any(.[]; .status == "partial") then "partial" else "ok" end),
351
+ mode:$mode,
352
+ org:$org,
353
+ summary:{
354
+ considered:length,
355
+ planned_or_synced:[.[] | select(.status == "planned" or .status == "synced")]|length,
356
+ partial:[.[] | select(.status == "partial")]|length,
357
+ unresolved:[.[] | select(.status == "unresolved")]|length,
358
+ skipped:[.[] | select(.status == "skipped")]|length,
359
+ excluded_removed:$excluded_removed
360
+ },
361
+ people:.
362
+ }'
363
+ }
364
+
365
+ command="${1:-}"
366
+ [ -n "$command" ] || usage
367
+
368
+ if [ "$command" = "show" ]; then
369
+ jq '{
370
+ person_id,
371
+ display_name,
372
+ github_username,
373
+ github_id,
374
+ github_name,
375
+ email,
376
+ emails,
377
+ github_aliases,
378
+ previous_names,
379
+ identity_version
380
+ }' "$STATE"
381
+ exit 0
382
+ fi
383
+
384
+ if [ "$command" = "backfill" ]; then
385
+ backfill_people "$@"
386
+ exit $?
387
+ fi
388
+
389
+ display_args=()
390
+ email_args=()
391
+ onboard_args=()
392
+ case "$command" in
393
+ sync) ;;
394
+ onboard) onboard_args=(--onboarded) ;;
395
+ set-name)
396
+ name="${2:-}"
397
+ [ -n "$name" ] || usage
398
+ if [ "${#name}" -gt 60 ] || ! printf '%s' "$name" | grep -Eq "^[[:alnum:]À-ž .'-]+$"; then
399
+ echo '{"status":"error","error":"display name must be 1-60 name characters"}'
400
+ exit 2
401
+ fi
402
+ display_args=(--display-name "$name")
403
+ ;;
404
+ set-email)
405
+ email="${2:-}"
406
+ [ -n "$email" ] || usage
407
+ if [ "${#email}" -gt 254 ] || ! printf '%s' "$email" | grep -Eq '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$'; then
408
+ echo '{"status":"error","error":"email must be a valid address"}'
409
+ exit 2
410
+ fi
411
+ email_args=(--email "$email")
412
+ ;;
413
+ *) usage ;;
414
+ esac
415
+
416
+ github_args=()
417
+ state_github="$(jq -r '.github_username // empty' "$STATE" 2>/dev/null)"
418
+ if command -v gh >/dev/null 2>&1; then
419
+ gh_identity="$(gh api user --jq '{login,id,name,email}' 2>/dev/null || true)"
420
+ gh_login="$(printf '%s' "$gh_identity" | jq -r '.login // empty' 2>/dev/null)"
421
+ gh_id="$(printf '%s' "$gh_identity" | jq -r '.id // empty' 2>/dev/null)"
422
+ state_github_id="$(jq -r '.github_id // empty' "$STATE" 2>/dev/null)"
423
+ gh_login_lc="$(printf '%s' "$gh_login" | tr '[:upper:]' '[:lower:]')"
424
+ state_github_lc="$(printf '%s' "$state_github" | tr '[:upper:]' '[:lower:]')"
425
+ if [ -n "$gh_login" ] && {
426
+ [ -z "$state_github" ] ||
427
+ [ "$gh_login_lc" = "$state_github_lc" ] ||
428
+ { [ -n "$gh_id" ] && [ -n "$state_github_id" ] && [ "$gh_id" = "$state_github_id" ]; }
429
+ }; then
430
+ github_args=(--github "$gh_login")
431
+ gh_email="$(printf '%s' "$gh_identity" | jq -r '.email // empty')"
432
+ [ -z "$gh_id" ] || github_args+=(--github-id "$gh_id")
433
+ [ -z "$gh_email" ] || github_args+=(--email "$gh_email")
434
+ fi
435
+ fi
436
+
437
+ if [ "$(jq -r '.onboarding_complete // false' "$STATE" 2>/dev/null)" = "true" ]; then
438
+ onboard_args=(--onboarded)
439
+ fi
440
+ identity="$(python3 "$PERSON_PY" sync-local \
441
+ "${display_args[@]}" "${github_args[@]}" "${email_args[@]}" "${onboard_args[@]}")"
442
+
443
+ if [ "$(_detect_mode)" = "local" ]; then
444
+ printf '%s' "$identity" | jq '. + {status:"synced-local",supabase:"unavailable",graph:"unavailable"}'
445
+ exit 0
446
+ fi
447
+
448
+ sync_remote_identity "$identity"
@@ -1,10 +1,170 @@
1
- const MUTATING_COMMANDS = /(^|[;&|]\s*)(rm|mv|cp|mkdir|rmdir|touch|chmod|chown|install)\b/;
2
- const MUTATING_GIT = /(^|[;&|]\s*)git\s+(add|commit|push|merge|rebase|cherry-pick|tag|rm|mv)\b/;
3
- const MUTATING_PACKAGES = /(^|[;&|]\s*)(npm|pnpm|yarn|bun)\s+(install|add|remove|uninstall|update)\b/;
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+
3
+ const MUTATING_COMMANDS = /(^|[;&|]\s*)(?:sudo\s+)?(rm|mv|cp|mkdir|rmdir|touch|chmod|chown|install|tee)\b/;
4
+ const MUTATING_GIT = /(^|[;&|]\s*)(?:sudo\s+)?git\s+(add|commit|push|merge|rebase|cherry-pick|tag|rm|mv)\b/;
5
+ const MUTATING_PACKAGES = /(^|[;&|]\s*)(?:sudo\s+)?(npm|pnpm|yarn|bun)\s+(install|add|remove|uninstall|update)\b/;
6
+
7
+ // A `>` only acts as a shell redirect when it sits OUTSIDE quotes. Quoted
8
+ // `>` shows up constantly in read-only commands — Cypher comparisons
9
+ // (`WHERE x >= y`), JS/Python formatters (`f"{a} -> {b}"`), jq filters —
10
+ // so the redirect test runs on a quote-stripped copy of the command.
11
+ // Quoted segments collapse to a placeholder char so a redirect with a
12
+ // quoted TARGET (`echo hi > "/tmp/x"`) is still detected.
13
+ function stripQuotedSegments(command) {
14
+ let out = "";
15
+ let quote = null;
16
+ for (let i = 0; i < command.length; i++) {
17
+ const ch = command[i];
18
+ if (quote) {
19
+ if (quote === '"' && ch === "\\") i++; // skip escaped char inside double quotes
20
+ else if (ch === quote) quote = null;
21
+ continue;
22
+ }
23
+ if (ch === "'" || ch === '"') { quote = ch; out += "x"; continue; }
24
+ out += ch;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ function maskQuotedOperators(command) {
30
+ let out = "";
31
+ let quote = null;
32
+ for (let i = 0; i < command.length; i++) {
33
+ const ch = command[i];
34
+ if (quote) {
35
+ if (quote === '"' && ch === "\\" && i + 1 < command.length) {
36
+ out += ch + command[++i];
37
+ continue;
38
+ }
39
+ if (ch === quote) quote = null;
40
+ out += /[><|;&]/.test(ch) ? " " : ch;
41
+ continue;
42
+ }
43
+ if (ch === "'" || ch === '"') quote = ch;
44
+ out += ch;
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function shellSegments(command) {
50
+ const segments = [];
51
+ let current = "";
52
+ let quote = null;
53
+ for (let i = 0; i < command.length; i++) {
54
+ const ch = command[i];
55
+ if (quote) {
56
+ current += ch;
57
+ if (quote === '"' && ch === "\\" && i + 1 < command.length) current += command[++i];
58
+ else if (ch === quote) quote = null;
59
+ continue;
60
+ }
61
+ if (ch === "'" || ch === '"') {
62
+ quote = ch;
63
+ current += ch;
64
+ continue;
65
+ }
66
+ if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
67
+ if (current.trim()) segments.push(current.trim());
68
+ current = "";
69
+ continue;
70
+ }
71
+ current += ch;
72
+ }
73
+ if (current.trim()) segments.push(current.trim());
74
+ return segments;
75
+ }
76
+
77
+ function shellWords(segment) {
78
+ const words = [];
79
+ let current = "";
80
+ let quote = null;
81
+ for (let i = 0; i < segment.length; i++) {
82
+ const ch = segment[i];
83
+ if (quote) {
84
+ if (quote === '"' && ch === "\\" && i + 1 < segment.length) current += segment[++i];
85
+ else if (ch === quote) quote = null;
86
+ else current += ch;
87
+ continue;
88
+ }
89
+ if (ch === "'" || ch === '"') {
90
+ quote = ch;
91
+ continue;
92
+ }
93
+ if (/\s/.test(ch)) {
94
+ if (current) words.push(current);
95
+ current = "";
96
+ continue;
97
+ }
98
+ if (ch === "\\" && i + 1 < segment.length) current += segment[++i];
99
+ else current += ch;
100
+ }
101
+ if (current) words.push(current);
102
+ return words;
103
+ }
104
+
105
+ export function isRuntimeStatePath(target, projectDir) {
106
+ if (!target || /[$`*?{}]/.test(target)) return false;
107
+ const absolute = isAbsolute(target) ? resolve(target) : resolve(projectDir, target);
108
+ const rel = relative(resolve(projectDir), absolute);
109
+ if (rel.startsWith("..") || isAbsolute(rel)) return true;
110
+ if (rel === ".claude/worktrees" || rel.startsWith(".claude/worktrees/")) return false;
111
+ return rel === "memory" || rel.startsWith("memory/") ||
112
+ rel === ".claude" || rel.startsWith(".claude/") ||
113
+ rel === ".codex" || rel.startsWith(".codex/") ||
114
+ rel === ".pi" || rel.startsWith(".pi/") ||
115
+ rel === ".egregore-state.json" ||
116
+ rel === ".egregore-branch-consent" ||
117
+ rel === ".env";
118
+ }
119
+
120
+ export function mutatesOnlyRuntimeState(command, projectDir) {
121
+ if (!isMutatingBash(command)) return false;
122
+ const visible = stripQuotedSegments(String(command));
123
+ if (MUTATING_GIT.test(visible) || MUTATING_PACKAGES.test(visible) ||
124
+ /\bsed\s+[^\n]*\s-i(?:\s|$)/.test(visible)) return false;
125
+
126
+ let foundTarget = false;
127
+ const pathCommands = new Set(["rm", "mv", "cp", "mkdir", "rmdir", "touch", "chmod", "chown", "install", "tee"]);
128
+ for (const segment of shellSegments(String(command))) {
129
+ const words = shellWords(segment);
130
+ while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
131
+ while (words[0] === "sudo" || words[0] === "command" || words[0] === "env") words.shift();
132
+ if (!words.length) continue;
133
+ const name = words.shift().split("/").pop();
134
+ if (!pathCommands.has(name)) continue;
135
+
136
+ const args = words.filter((word) => !word.startsWith("-"));
137
+ let targets = args;
138
+ if (name === "cp" || name === "install") targets = args.length ? [args[args.length - 1]] : [];
139
+ if (name === "chmod" || name === "chown") targets = args.slice(1);
140
+ if (!targets.length || targets.some((target) => !isRuntimeStatePath(target, projectDir))) return false;
141
+ foundTarget = true;
142
+ }
143
+
144
+ const redirectSource = maskQuotedOperators(String(command));
145
+ const redirects = /(?:^|[\s;|&])(?:\d*)>>?\s*([^&\s;|]+)/g;
146
+ let redirect;
147
+ while ((redirect = redirects.exec(redirectSource))) {
148
+ const target = String(redirect[1] || "").replace(/^["']|["']$/g, "");
149
+ if (!target || target === "/dev/null") continue;
150
+ if (!isRuntimeStatePath(target, projectDir)) return false;
151
+ foundTarget = true;
152
+ }
153
+
154
+ const tees = /\|\s*tee\s+(?:-a\s+)?([^&\s;|]+)/g;
155
+ let tee;
156
+ while ((tee = tees.exec(redirectSource))) {
157
+ const target = String(tee[1] || "").replace(/^["']|["']$/g, "");
158
+ if (!isRuntimeStatePath(target, projectDir)) return false;
159
+ foundTarget = true;
160
+ }
161
+
162
+ return foundTarget;
163
+ }
4
164
 
5
165
  export function isMutatingBash(command) {
6
166
  if (!command) return false;
7
- const meaningfulRedirects = String(command)
167
+ const meaningfulRedirects = stripQuotedSegments(String(command))
8
168
  .replace(/(?:^|\s)\d*>>?\s*\/dev\/null\b/g, " ")
9
169
  .replace(/(?:^|\s)\d*>\s*&\d+\b/g, " ");
10
170
  return MUTATING_COMMANDS.test(command) ||
@@ -235,11 +235,11 @@ export async function createHandoff(pi, root, params, signal) {
235
235
  writeFileSync(body, buildHandoffDocument(params, author));
236
236
 
237
237
  try {
238
- const shell = 'tmp="$1"; body="$2"; shift 2; TMPDIR="$tmp" bash bin/handoff-run.sh "$@" < "$body"';
238
+ const shell = 'tmp="$1"; body="$2"; runner="$3"; shift 3; TMPDIR="$tmp" bash "$runner" --mode addressed "$@" < "$body"';
239
239
  await checkedExec(
240
240
  pi,
241
241
  "bash",
242
- ["-c", shell, "egregore-pi-handoff", temp, body, ...runnerArgs],
242
+ ["-c", shell, "egregore-pi-handoff", temp, body, join(root, "bin", "capture-run.sh"), ...runnerArgs],
243
243
  { signal, timeout: 180000 },
244
244
  "Handoff",
245
245
  );
@@ -263,6 +263,35 @@ export async function createHandoff(pi, root, params, signal) {
263
263
  }
264
264
  }
265
265
 
266
+ export async function captureSessionEnd(pi, root, sessionFile, signal) {
267
+ const sessionIdFile = join(root, ".egregore-session-id");
268
+ if (!existsSync(sessionIdFile)) return false;
269
+ const sessionId = cleanArgs(readFileSync(sessionIdFile, "utf8"));
270
+ if (!sessionId) return false;
271
+
272
+ const temp = mkdtempSync(join(tmpdir(), "egregore-pi-session-end-"));
273
+ const input = join(temp, "session-end.json");
274
+ writeFileSync(input, JSON.stringify({
275
+ session_id: sessionId,
276
+ transcript_path: cleanArgs(sessionFile),
277
+ runtime: "pi",
278
+ }));
279
+
280
+ try {
281
+ const shell = 'bash "$1" < "$2"';
282
+ await checkedExec(
283
+ pi,
284
+ "bash",
285
+ ["-c", shell, "egregore-pi-session-end", join(root, "bin", "session-log.sh"), input],
286
+ { signal, timeout: 10000 },
287
+ "Session capture",
288
+ );
289
+ return true;
290
+ } finally {
291
+ rmSync(temp, { recursive: true, force: true });
292
+ }
293
+ }
294
+
266
295
  export async function publishScroll(pi, root, params, signal) {
267
296
  const source = resolve(root, String(params.source || "").replace(/^@/, ""));
268
297
  if (!existsSync(source)) throw new Error(`Scroll source not found: ${relative(root, source)}`);