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,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
@@ -198,7 +198,7 @@ _people_file() {
198
198
  if [ ! -f "$f" ]; then
199
199
  inviter="$(jq -r '.display_name // .github_username // "admin"' "$SCRIPT_DIR/.egregore-state.json" 2>/dev/null || echo admin)"
200
200
  today="$(date -u +%Y-%m-%d)"
201
- printf -- '---\nname: %s\ngithub: %s\ninvited_by: %s\njoined: %s\n---\n' "$user" "$user" "$inviter" "$today" > "$f"
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
202
  fi
203
203
  else
204
204
  rm -f "$f"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "source": "egregore-runtime",
3
3
  "policy": "public-safe subset derived from .syncignore; built by scripts/build-runtime-bundle.mjs",
4
- "skillCount": 56,
4
+ "skillCount": 58,
5
5
  "skills": [
6
6
  "activity",
7
7
  "add",
@@ -17,6 +17,7 @@
17
17
  "dashboard",
18
18
  "deep-reflect",
19
19
  "delete-user",
20
+ "deploy-bridge",
20
21
  "egregore-voice",
21
22
  "emissary",
22
23
  "env",
@@ -47,6 +48,7 @@
47
48
  "setup",
48
49
  "summon",
49
50
  "sync-repos",
51
+ "task",
50
52
  "telegram-connect",
51
53
  "telemetry",
52
54
  "test",
@@ -80,6 +82,7 @@
80
82
  ".codex/skills/dashboard/SKILL.md",
81
83
  ".codex/skills/deep-reflect/SKILL.md",
82
84
  ".codex/skills/delete-user/SKILL.md",
85
+ ".codex/skills/deploy-bridge/SKILL.md",
83
86
  ".codex/skills/egregore-voice/SKILL.md",
84
87
  ".codex/skills/emissary/SKILL.md",
85
88
  ".codex/skills/env/SKILL.md",
@@ -112,6 +115,7 @@
112
115
  ".codex/skills/setup/SKILL.md",
113
116
  ".codex/skills/summon/SKILL.md",
114
117
  ".codex/skills/sync-repos/SKILL.md",
118
+ ".codex/skills/task/SKILL.md",
115
119
  ".codex/skills/telegram-connect/SKILL.md",
116
120
  ".codex/skills/telemetry/SKILL.md",
117
121
  ".codex/skills/test/SKILL.md",
@@ -130,6 +134,9 @@
130
134
  "AGENTS.md",
131
135
  "bin/activity-data.sh",
132
136
  "bin/agent.sh",
137
+ "bin/attendant.sh",
138
+ "bin/capture-reconcile.sh",
139
+ "bin/capture-run.sh",
133
140
  "bin/codex-install-skills.sh",
134
141
  "bin/codex-render-spec.mjs",
135
142
  "bin/codex-session-start.sh",
@@ -144,6 +151,12 @@
144
151
  "bin/handoff-run.sh",
145
152
  "bin/handoff-save-egregore.sh",
146
153
  "bin/index-handoff.sh",
154
+ "bin/ingest-graph.sh",
155
+ "bin/ingest-surface.html",
156
+ "bin/ingest.py",
157
+ "bin/ingest.sh",
158
+ "bin/ingest_graph.py",
159
+ "bin/ingest_surface.py",
147
160
  "bin/lib/artifact-id.sh",
148
161
  "bin/lib/config.sh",
149
162
  "bin/lib/context.sh",
@@ -154,13 +167,18 @@
154
167
  "bin/lib/hash.sh",
155
168
  "bin/lib/identity.sh",
156
169
  "bin/lib/metrics.sh",
170
+ "bin/lib/noncode.sh",
157
171
  "bin/lib/permissions.sh",
158
172
  "bin/lib/time.sh",
159
173
  "bin/lib/worktree-links.sh",
160
174
  "bin/notify.sh",
175
+ "bin/person.py",
176
+ "bin/person.sh",
161
177
  "bin/publish-artifact.sh",
162
178
  "bin/publish-references.sh",
163
179
  "bin/repo-state.sh",
180
+ "bin/session-autosave.sh",
181
+ "bin/session-log.sh",
164
182
  "bin/settings.sh",
165
183
  "bin/telemetry.sh",
166
184
  "bin/worktree-create.sh",
@@ -48,22 +48,86 @@ function inside(child, parent) {
48
48
  return rel === "" || (!!rel && !rel.startsWith("..") && !path.isAbsolute(rel));
49
49
  }
50
50
 
51
- function findProjectDir(input) {
52
- // Tool payload cwd is the operation location. Codex launchers may keep
53
- // EGREGORE_CODEX_PROJECT_DIR pointed at the main checkout even after the
54
- // model continues in a task worktree, so using the env var first can make
55
- // legitimate worktree writes look like writes on protected develop.
56
- const cwd = input.cwd || process.cwd() || process.env.EGREGORE_CODEX_PROJECT_DIR;
57
- const top = run("git", ["-C", cwd, "rev-parse", "--show-toplevel"], cwd);
58
- if (top) return realish(top);
51
+ function nearestExistingDirectory(candidate) {
52
+ let cursor = path.resolve(candidate);
53
+ try {
54
+ if (fs.existsSync(cursor) && !fs.statSync(cursor).isDirectory()) cursor = path.dirname(cursor);
55
+ } catch {}
56
+ while (!fs.existsSync(cursor)) {
57
+ const parent = path.dirname(cursor);
58
+ if (parent === cursor) break;
59
+ cursor = parent;
60
+ }
61
+ return cursor;
62
+ }
63
+
64
+ function gitContext(candidate) {
65
+ if (!candidate) return null;
66
+ const probe = nearestExistingDirectory(candidate);
67
+ const top = run("git", ["-C", probe, "rev-parse", "--show-toplevel"], probe);
68
+ if (!top) return null;
69
+ const commonRaw = run("git", ["-C", top, "rev-parse", "--git-common-dir"], top);
70
+ const common = commonRaw
71
+ ? realish(path.isAbsolute(commonRaw) ? commonRaw : path.join(top, commonRaw))
72
+ : realish(path.join(top, ".git"));
73
+ return { top: realish(top), common };
74
+ }
75
+
76
+ function shellRetarget(command) {
77
+ // Only retarget a single operation. Mixed command chains are evaluated from
78
+ // the hub context so one external `git -C` cannot mask a later project write.
79
+ if (shellSegments(command).length !== 1) return "";
80
+ const gitC = String(command || "").match(/(?:^|[;&|]\s*)git\s+-C\s+("[^"]*"|'[^']*'|[^\s;&|]+)/);
81
+ const cdAnd = String(command || "").match(/^\s*cd\s+("[^"]*"|'[^']*'|[^\s;&|]+)\s*&&/);
82
+ const raw = (gitC && gitC[1]) || (cdAnd && cdAnd[1]) || "";
83
+ const target = raw.replace(/^['"]|['"]$/g, "");
84
+ return /[$`*?]/.test(target) ? "" : target;
85
+ }
86
+
87
+ function operationCandidate(input, baseDir) {
88
+ const toolName = input.tool_name || "";
89
+ const toolInput = input.tool_input || {};
90
+ const explicitWorkdir = toolInput.workdir || toolInput.cwd || "";
91
+ let candidate = explicitWorkdir
92
+ ? (path.isAbsolute(explicitWorkdir) ? explicitWorkdir : path.join(baseDir, explicitWorkdir))
93
+ : baseDir;
59
94
 
60
- const envDir = process.env.EGREGORE_CODEX_PROJECT_DIR || "";
61
- if (envDir) {
62
- const envTop = run("git", ["-C", envDir, "rev-parse", "--show-toplevel"], envDir);
63
- return realish(envTop || envDir);
95
+ if (toolName === "Edit" || toolName === "Write") {
96
+ const target = toolInput.file_path || toolInput.path || "";
97
+ if (target) candidate = path.isAbsolute(target) ? target : path.join(candidate, target);
98
+ } else if (toolName === "Bash") {
99
+ const target = shellRetarget(toolInput.command || "");
100
+ if (target) candidate = path.isAbsolute(target) ? target : path.join(candidate, target);
101
+ }
102
+
103
+ return candidate;
104
+ }
105
+
106
+ function findProjectContext(input) {
107
+ // The launcher keeps EGREGORE_CODEX_PROJECT_DIR on the hub checkout while
108
+ // individual tool calls can target a task worktree, memory, or a managed
109
+ // repo. Compare Git common dirs: a task worktree shares the hub common dir
110
+ // and must use its own branch; memory/managed repos are separate and exempt.
111
+ const fallback = input.cwd || process.cwd();
112
+ const hubSeed = process.env.EGREGORE_CODEX_PROJECT_DIR || fallback;
113
+ const hub = gitContext(hubSeed);
114
+ const baseDir = input.cwd || hub?.top || fallback;
115
+ const candidate = operationCandidate(input, baseDir);
116
+ const operation = gitContext(candidate);
117
+
118
+ if (!hub) return { projectDir: operation?.top || realish(baseDir), external: false };
119
+ if (operation && operation.common === hub.common) {
120
+ return { projectDir: operation.top, external: false };
121
+ }
122
+ if (operation && operation.common !== hub.common) {
123
+ return { projectDir: hub.top, external: true };
64
124
  }
65
125
 
66
- return realish(cwd);
126
+ const resolvedCandidate = realish(candidate);
127
+ if (!inside(resolvedCandidate, hub.top)) {
128
+ return { projectDir: hub.top, external: true };
129
+ }
130
+ return { projectDir: hub.top, external: false };
67
131
  }
68
132
 
69
133
  function currentBranch(projectDir) {
@@ -95,7 +159,7 @@ function isExemptPath(rawPath, projectDir, memoryDir) {
95
159
  if (inside(resolved, target)) return true;
96
160
  }
97
161
 
98
- for (const file of [".egregore-state.json", ".env"]) {
162
+ for (const file of [".egregore-state.json", ".egregore-branch-consent", ".env"]) {
99
163
  if (resolved === realish(path.join(projectDir, file))) return true;
100
164
  }
101
165
 
@@ -133,12 +197,13 @@ function commandTargetsOtherRepo(command, projectDir, memoryDir) {
133
197
  }
134
198
 
135
199
  function isBranchSetupCommand(command) {
200
+ if (shellSegments(command).length !== 1) return false;
136
201
  return (
137
- /\bbin\/agent\.sh\s+branch\b/.test(command) ||
138
- /\bgit\s+(checkout|switch)\s+(-b|-c)\b/.test(command) ||
139
- /\bgit\s+branch\s+(dev|feature|bugfix)\//.test(command) ||
140
- /\bgit\s+worktree\s+add\b/.test(command) ||
141
- /\bbin\/worktree\.sh\s+setup\b/.test(command)
202
+ /^\s*bin\/agent\.sh\s+branch\b/.test(command) ||
203
+ /^\s*git\s+(checkout|switch)\s+(-b|-c)\b/.test(command) ||
204
+ /^\s*git\s+branch\s+(dev|feature|bugfix)\//.test(command) ||
205
+ /^\s*git\s+worktree\s+add\b/.test(command) ||
206
+ /^\s*bin\/worktree\.sh\s+setup\b/.test(command)
142
207
  );
143
208
  }
144
209
 
@@ -149,7 +214,134 @@ function stagedOnlyFrameworkPaths(projectDir) {
149
214
  return files.length > 0 && files.every((file) => /^(bin\/|\.claude\/|\.codex\/|CLAUDE\.md$|AGENTS\.md$|skills\/)/.test(file));
150
215
  }
151
216
 
217
+ function stripQuotedSegments(command) {
218
+ let out = "";
219
+ let quote = null;
220
+ for (let i = 0; i < String(command || "").length; i++) {
221
+ const ch = command[i];
222
+ if (quote) {
223
+ if (quote === '"' && ch === "\\") i++;
224
+ else if (ch === quote) quote = null;
225
+ continue;
226
+ }
227
+ if (ch === "'" || ch === '"') {
228
+ quote = ch;
229
+ out += "x";
230
+ continue;
231
+ }
232
+ out += ch;
233
+ }
234
+ return out;
235
+ }
236
+
237
+ function maskQuotedOperators(command) {
238
+ let out = "";
239
+ let quote = null;
240
+ for (let i = 0; i < String(command || "").length; i++) {
241
+ const ch = command[i];
242
+ if (quote) {
243
+ if (quote === '"' && ch === "\\") {
244
+ out += ch;
245
+ if (i + 1 < command.length) out += command[++i];
246
+ continue;
247
+ }
248
+ if (ch === quote) quote = null;
249
+ out += /[><|;&]/.test(ch) ? " " : ch;
250
+ continue;
251
+ }
252
+ if (ch === "'" || ch === '"') quote = ch;
253
+ out += ch;
254
+ }
255
+ return out;
256
+ }
257
+
258
+ function shellSegments(command) {
259
+ const segments = [];
260
+ let current = "";
261
+ let quote = null;
262
+ for (let i = 0; i < String(command || "").length; i++) {
263
+ const ch = command[i];
264
+ if (quote) {
265
+ current += ch;
266
+ if (quote === '"' && ch === "\\" && i + 1 < command.length) current += command[++i];
267
+ else if (ch === quote) quote = null;
268
+ continue;
269
+ }
270
+ if (ch === "'" || ch === '"') {
271
+ quote = ch;
272
+ current += ch;
273
+ continue;
274
+ }
275
+ if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
276
+ if (current.trim()) segments.push(current.trim());
277
+ current = "";
278
+ continue;
279
+ }
280
+ current += ch;
281
+ }
282
+ if (current.trim()) segments.push(current.trim());
283
+ return segments;
284
+ }
285
+
286
+ function shellWords(segment) {
287
+ const words = [];
288
+ let current = "";
289
+ let quote = null;
290
+ for (let i = 0; i < String(segment || "").length; i++) {
291
+ const ch = segment[i];
292
+ if (quote) {
293
+ if (quote === '"' && ch === "\\" && i + 1 < segment.length) current += segment[++i];
294
+ else if (ch === quote) quote = null;
295
+ else current += ch;
296
+ continue;
297
+ }
298
+ if (ch === "'" || ch === '"') {
299
+ quote = ch;
300
+ continue;
301
+ }
302
+ if (/\s/.test(ch)) {
303
+ if (current) words.push(current);
304
+ current = "";
305
+ continue;
306
+ }
307
+ if (ch === "\\") {
308
+ if (i + 1 < segment.length) current += segment[++i];
309
+ continue;
310
+ }
311
+ current += ch;
312
+ }
313
+ if (current) words.push(current);
314
+ return words;
315
+ }
316
+
317
+ function filesystemMutationStatus(command, projectDir, memoryDir) {
318
+ const commands = new Set(["rm", "mv", "cp", "mkdir", "rmdir", "touch", "chmod", "chown", "ln", "tee"]);
319
+ let unsafe = false;
320
+
321
+ for (const segment of shellSegments(command)) {
322
+ const words = shellWords(segment);
323
+ while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
324
+ while (words[0] === "sudo" || words[0] === "command" || words[0] === "env") words.shift();
325
+ if (!words.length) continue;
326
+
327
+ const name = path.basename(words.shift());
328
+ if (!commands.has(name)) continue;
329
+
330
+ const args = words.filter((word) => !word.startsWith("-"));
331
+ let targets = args;
332
+ if (name === "cp" || name === "ln") targets = args.length ? [args[args.length - 1]] : [];
333
+ if (name === "chmod" || name === "chown") targets = args.slice(1);
334
+
335
+ if (!targets.length || targets.some((target) =>
336
+ /[$`*?{}]/.test(target) || !isExemptPath(target, projectDir, memoryDir)
337
+ )) unsafe = true;
338
+ }
339
+
340
+ return { unsafe };
341
+ }
342
+
152
343
  function redirectLooksMutating(command, projectDir, memoryDir) {
344
+ const visible = maskQuotedOperators(command);
153
345
  const redirectPatterns = [
154
346
  /(?:^|[\s;|&])(?:\d*)>>?\s*([^&\s;|]+)/g,
155
347
  /\|\s*tee\s+(?:-a\s+)?([^&\s;|]+)/g,
@@ -157,7 +349,7 @@ function redirectLooksMutating(command, projectDir, memoryDir) {
157
349
 
158
350
  for (const pattern of redirectPatterns) {
159
351
  let match;
160
- while ((match = pattern.exec(command))) {
352
+ while ((match = pattern.exec(visible))) {
161
353
  const target = String(match[1] || "").replace(/^["']|["']$/g, "");
162
354
  if (!target || target === "/dev/null" || target.startsWith("$")) continue;
163
355
  if (!isExemptPath(target, projectDir, memoryDir)) return true;
@@ -168,18 +360,31 @@ function redirectLooksMutating(command, projectDir, memoryDir) {
168
360
 
169
361
  function shellLooksMutating(command, projectDir, memoryDir) {
170
362
  if (!command.trim()) return false;
171
- if (isBranchSetupCommand(command)) return false;
363
+ const visible = stripQuotedSegments(command);
364
+ if (isBranchSetupCommand(visible)) return false;
172
365
 
173
366
  const mutatingPatterns = [
174
- /\bgit\s+(add|commit|push|rm|mv|reset|restore|checkout\s+--|clean|rebase|merge|cherry-pick)\b/,
175
- /\b(apply_patch|touch|mkdir|rm|mv|cp|chmod|chown|ln)\b/,
176
- /\b(sed|perl)\b[^;&|]*\s-i(\s|$)/,
177
- /\b(npm|pnpm|yarn|bun)\s+(install|add|remove|update|dedupe|ci)\b/,
178
- /\b(go\s+mod\s+tidy|cargo\s+(fmt|fix|update)|rustfmt|prettier\s+--write|eslint\s+--fix)\b/,
367
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?git(?:\s+-C\s+\S+)?\s+(add|commit|push|rm|mv|reset|restore|checkout\s+--|clean|rebase|merge|cherry-pick)\b/,
368
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?apply_patch\b/,
369
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?(sed|perl)\b[^;&|]*\s-i(\s|$)/,
370
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?(npm|pnpm|yarn|bun)\s+(install|add|remove|update|dedupe|ci)\b/,
371
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?(go\s+mod\s+tidy|cargo\s+(fmt|fix|update)|rustfmt|prettier\s+--write|eslint\s+--fix)\b/,
179
372
  ];
180
373
 
181
- if (/\bgit\s+commit\b/.test(command) && stagedOnlyFrameworkPaths(projectDir)) return false;
182
- return mutatingPatterns.some((pattern) => pattern.test(command)) || redirectLooksMutating(command, projectDir, memoryDir);
374
+ const gitCommit = /(?:^|[;&|(\n]\s*)(?:sudo\s+)?git(?:\s+-C\s+\S+)?\s+commit\b/.test(visible);
375
+ if (gitCommit && stagedOnlyFrameworkPaths(projectDir)) return false;
376
+
377
+ const filesystem = filesystemMutationStatus(command, projectDir, memoryDir);
378
+ return mutatingPatterns.some((pattern) => pattern.test(visible)) ||
379
+ filesystem.unsafe ||
380
+ redirectLooksMutating(command, projectDir, memoryDir);
381
+ }
382
+
383
+ function isConsentCommand(command, branch) {
384
+ const escaped = branch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
385
+ return new RegExp(
386
+ `^\\s*echo\\s+['"]?${escaped}['"]?\\s*>\\s*(?:\\./)?\\.egregore-branch-consent\\s*$`
387
+ ).test(String(command || ""));
183
388
  }
184
389
 
185
390
  function deny(reason) {
@@ -194,7 +399,9 @@ function deny(reason) {
194
399
 
195
400
  function main() {
196
401
  const input = parseJson(readStdin());
197
- const projectDir = findProjectDir(input);
402
+ const context = findProjectContext(input);
403
+ if (context.external) return;
404
+ const projectDir = context.projectDir;
198
405
  const branch = currentBranch(projectDir);
199
406
  if (!isProtectedBranch(branch)) return;
200
407
 
@@ -211,11 +418,11 @@ function main() {
211
418
  const memoryDir = realish(path.join(projectDir, "memory"));
212
419
  const author = currentAuthor(projectDir);
213
420
  const reason = [
214
- `Protected branch (${branch}). Ask the user how to proceed — do NOT auto-branch silently.`,
215
- "Offer a choice (numbered or structured): Branch off (default) / Proceed on this branch / Cancel.",
216
- ` • Branch off: bin/agent.sh branch --topic "<work topic>" (prefix dev/${author}/...)`,
217
- ` • Proceed on ${branch}: echo '${branch}' > .egregore-branch-consent then retry`,
218
- "Proceeding records consent for this branch for the rest of the session (cleared on next session start).",
421
+ `Protected branch (${branch}). Move project work to a task branch before writing.`,
422
+ `If the work topic is clear, run bin/agent.sh branch --topic "<work topic>" automatically (prefix dev/${author}/...), continue in the printed worktree, and briefly tell the user.`,
423
+ "Ask only when the work topic is genuinely ambiguous; ask for the topic, not for routine Git permission.",
424
+ `Only when the user explicitly requested writing on ${branch}, record consent with: echo '${branch}' > .egregore-branch-consent`,
425
+ "Memory, framework-runtime, and managed-repo writes are exempt and should not trigger this guard.",
219
426
  ].join("\n");
220
427
 
221
428
  if (toolName === "apply_patch") {
@@ -234,6 +441,7 @@ function main() {
234
441
 
235
442
  if (toolName === "Bash") {
236
443
  const command = String(toolInput.command || "");
444
+ if (isConsentCommand(command, branch)) return;
237
445
  if (commandTargetsOtherRepo(command, projectDir, memoryDir)) return;
238
446
  if (shellLooksMutating(command, projectDir, memoryDir)) deny(reason);
239
447
  }