create-egregore 0.19.32 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/assets/egregore-launcher.js +118 -13
  2. package/bin/cli.js +48 -3
  3. package/lib/connect-runtime.js +4 -2
  4. package/lib/local.js +15 -1
  5. package/lib/prime-runtime.js +208 -0
  6. package/lib/setup.js +45 -3
  7. package/package.json +5 -4
  8. package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +29 -0
  9. package/runtime/codex/.codex/skills/notion-connect/SKILL.md +29 -0
  10. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
  11. package/runtime/codex/.codex/spec-manifest.json +3 -3
  12. package/runtime/codex/AGENTS.md +11 -4
  13. package/runtime/codex/bin/activity-data.sh +4 -8
  14. package/runtime/codex/bin/attendant.sh +13 -0
  15. package/runtime/codex/bin/autosave.sh +27 -1
  16. package/runtime/codex/bin/codex-render-spec.mjs +6 -0
  17. package/runtime/codex/bin/codex-session-start.sh +8 -1
  18. package/runtime/codex/bin/connect-refresh.sh +33 -0
  19. package/runtime/codex/bin/graph-batch.sh +25 -6
  20. package/runtime/codex/bin/graph-op.sh +5 -4
  21. package/runtime/codex/bin/graph.sh +31 -5
  22. package/runtime/codex/bin/ingest.py +81 -5
  23. package/runtime/codex/bin/lib/context.sh +26 -1
  24. package/runtime/codex/bin/lib/greeting.sh +4 -1
  25. package/runtime/codex/bin/lib/worktree-links.sh +25 -0
  26. package/runtime/codex/manifest.json +8 -1
  27. package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +29 -0
  28. package/runtime/pi/.codex/skills/notion-connect/SKILL.md +29 -0
  29. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
  30. package/runtime/pi/.codex/spec-manifest.json +3 -3
  31. package/runtime/pi/.pi/APPEND_SYSTEM.md +3 -2
  32. package/runtime/pi/.pi/extensions/egregore.ts +17 -6
  33. package/runtime/pi/.pi/spec-manifest.json +2 -2
  34. package/runtime/pi/AGENTS.md +11 -4
  35. package/runtime/pi/bin/activity-data.sh +4 -8
  36. package/runtime/pi/bin/attendant.sh +13 -0
  37. package/runtime/pi/bin/autosave.sh +27 -1
  38. package/runtime/pi/bin/codex-render-spec.mjs +6 -0
  39. package/runtime/pi/bin/codex-session-start.sh +8 -1
  40. package/runtime/pi/bin/connect-refresh.sh +33 -0
  41. package/runtime/pi/bin/graph-batch.sh +25 -6
  42. package/runtime/pi/bin/graph-op.sh +5 -4
  43. package/runtime/pi/bin/graph.sh +31 -5
  44. package/runtime/pi/bin/ingest.py +81 -5
  45. package/runtime/pi/bin/lib/context.sh +26 -1
  46. package/runtime/pi/bin/lib/greeting.sh +4 -1
  47. package/runtime/pi/bin/lib/worktree-links.sh +25 -0
  48. package/runtime/pi/bin/pi-branch-policy.mjs +138 -1
  49. package/runtime/pi/bin/pi-product-workflows.mjs +35 -7
  50. package/runtime/pi/manifest.json +8 -1
  51. package/runtime/prime/.claude/context/notification-consent.md +66 -0
  52. package/runtime/prime/.codex/config.toml +17 -0
  53. package/runtime/prime/.codex/hooks/branch-guard.js +461 -0
  54. package/runtime/prime/.codex/hooks/search-hint.js +67 -0
  55. package/runtime/prime/.codex/hooks.json +30 -0
  56. package/runtime/prime/.codex/skills/activity/SKILL.md +82 -0
  57. package/runtime/prime/.codex/skills/announce/SKILL.md +67 -0
  58. package/runtime/prime/.codex/skills/archive/SKILL.md +45 -0
  59. package/runtime/prime/.codex/skills/branch/SKILL.md +29 -0
  60. package/runtime/prime/.codex/skills/checkup/SKILL.md +45 -0
  61. package/runtime/prime/.codex/skills/commit/SKILL.md +29 -0
  62. package/runtime/prime/.codex/skills/contribute/SKILL.md +29 -0
  63. package/runtime/prime/.codex/skills/create-skill/SKILL.md +29 -0
  64. package/runtime/prime/.codex/skills/dark-mode/SKILL.md +29 -0
  65. package/runtime/prime/.codex/skills/dashboard/SKILL.md +59 -0
  66. package/runtime/prime/.codex/skills/deep-reflect/SKILL.md +109 -0
  67. package/runtime/prime/.codex/skills/delete-user/SKILL.md +29 -0
  68. package/runtime/prime/.codex/skills/emissary/SKILL.md +45 -0
  69. package/runtime/prime/.codex/skills/handoff/SKILL.md +166 -0
  70. package/runtime/prime/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
  71. package/runtime/prime/.codex/skills/harvest/SKILL.md +131 -0
  72. package/runtime/prime/.codex/skills/ingest/SKILL.md +29 -0
  73. package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +29 -0
  74. package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +45 -0
  75. package/runtime/prime/.codex/skills/invite/SKILL.md +44 -0
  76. package/runtime/prime/.codex/skills/issue/SKILL.md +45 -0
  77. package/runtime/prime/.codex/skills/me/SKILL.md +29 -0
  78. package/runtime/prime/.codex/skills/meeting/SKILL.md +45 -0
  79. package/runtime/prime/.codex/skills/note/SKILL.md +29 -0
  80. package/runtime/prime/.codex/skills/notion-connect/SKILL.md +29 -0
  81. package/runtime/prime/.codex/skills/onboarding/SKILL.md +45 -0
  82. package/runtime/prime/.codex/skills/pr/SKILL.md +29 -0
  83. package/runtime/prime/.codex/skills/pull/SKILL.md +29 -0
  84. package/runtime/prime/.codex/skills/push/SKILL.md +29 -0
  85. package/runtime/prime/.codex/skills/quest/SKILL.md +80 -0
  86. package/runtime/prime/.codex/skills/reflect/SKILL.md +45 -0
  87. package/runtime/prime/.codex/skills/review-pr/SKILL.md +45 -0
  88. package/runtime/prime/.codex/skills/save/SKILL.md +125 -0
  89. package/runtime/prime/.codex/skills/slack-connect/SKILL.md +29 -0
  90. package/runtime/prime/.codex/skills/summon/SKILL.md +45 -0
  91. package/runtime/prime/.codex/skills/sync-repos/SKILL.md +29 -0
  92. package/runtime/prime/.codex/skills/teams-connect/SKILL.md +29 -0
  93. package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +29 -0
  94. package/runtime/prime/.codex/skills/telemetry/SKILL.md +29 -0
  95. package/runtime/prime/.codex/skills/test/SKILL.md +45 -0
  96. package/runtime/prime/.codex/skills/the-spiral/SKILL.md +84 -0
  97. package/runtime/prime/.codex/skills/tui-design/SKILL.md +45 -0
  98. package/runtime/prime/.codex/skills/tutorial/SKILL.md +45 -0
  99. package/runtime/prime/.codex/skills/update/SKILL.md +29 -0
  100. package/runtime/prime/.codex/skills/view/SKILL.md +156 -0
  101. package/runtime/prime/.codex/skills/view/agents/openai.yaml +4 -0
  102. package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +221 -0
  103. package/runtime/prime/.codex/skills/visual-explain/SKILL.md +45 -0
  104. package/runtime/prime/.codex/skills/wrap/SKILL.md +58 -0
  105. package/runtime/prime/.codex/spec-manifest.json +132 -0
  106. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +393 -0
  107. package/runtime/prime/.prime/agent/extensions/egregore.ts +460 -0
  108. package/runtime/prime/.prime/agent/settings.json +4 -0
  109. package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +34 -0
  110. package/runtime/prime/.prime/agent/skills/egregore/pyproject.toml +13 -0
  111. package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +156 -0
  112. package/runtime/prime/.prime/agent/spec-manifest.json +12 -0
  113. package/runtime/prime/AGENTS.md +489 -0
  114. package/runtime/prime/bin/activity-data.sh +238 -0
  115. package/runtime/prime/bin/agent.sh +775 -0
  116. package/runtime/prime/bin/attendant.sh +707 -0
  117. package/runtime/prime/bin/autosave.sh +112 -0
  118. package/runtime/prime/bin/capture-reconcile.sh +69 -0
  119. package/runtime/prime/bin/capture-run.sh +341 -0
  120. package/runtime/prime/bin/codex-install-skills.sh +78 -0
  121. package/runtime/prime/bin/codex-render-spec.mjs +440 -0
  122. package/runtime/prime/bin/codex-session-start.sh +265 -0
  123. package/runtime/prime/bin/codex-skill-render.mjs +347 -0
  124. package/runtime/prime/bin/codex-sync-skills.sh +232 -0
  125. package/runtime/prime/bin/connect-refresh.sh +33 -0
  126. package/runtime/prime/bin/dashboard-data.sh +178 -0
  127. package/runtime/prime/bin/graph-batch.sh +79 -0
  128. package/runtime/prime/bin/graph-op.sh +1102 -0
  129. package/runtime/prime/bin/graph-wal.sh +229 -0
  130. package/runtime/prime/bin/graph.sh +223 -0
  131. package/runtime/prime/bin/handoff-pr-backfill.sh +86 -0
  132. package/runtime/prime/bin/handoff-run.sh +671 -0
  133. package/runtime/prime/bin/handoff-save-egregore.sh +212 -0
  134. package/runtime/prime/bin/index-handoff.sh +322 -0
  135. package/runtime/prime/bin/ingest-graph.sh +80 -0
  136. package/runtime/prime/bin/ingest-surface.html +495 -0
  137. package/runtime/prime/bin/ingest.py +756 -0
  138. package/runtime/prime/bin/ingest.sh +279 -0
  139. package/runtime/prime/bin/ingest_graph.py +856 -0
  140. package/runtime/prime/bin/ingest_surface.py +544 -0
  141. package/runtime/prime/bin/lib/artifact-id.sh +46 -0
  142. package/runtime/prime/bin/lib/config.sh +116 -0
  143. package/runtime/prime/bin/lib/context.sh +621 -0
  144. package/runtime/prime/bin/lib/dashboard-artifact.sh +242 -0
  145. package/runtime/prime/bin/lib/ensure-develop.sh +183 -0
  146. package/runtime/prime/bin/lib/git-sync.sh +406 -0
  147. package/runtime/prime/bin/lib/greeting.sh +519 -0
  148. package/runtime/prime/bin/lib/hash.sh +13 -0
  149. package/runtime/prime/bin/lib/identity.sh +213 -0
  150. package/runtime/prime/bin/lib/metrics.sh +374 -0
  151. package/runtime/prime/bin/lib/noncode.sh +31 -0
  152. package/runtime/prime/bin/lib/permissions.sh +106 -0
  153. package/runtime/prime/bin/lib/time.sh +56 -0
  154. package/runtime/prime/bin/lib/worktree-links.sh +79 -0
  155. package/runtime/prime/bin/notify.sh +636 -0
  156. package/runtime/prime/bin/person.py +630 -0
  157. package/runtime/prime/bin/person.sh +448 -0
  158. package/runtime/prime/bin/pi-branch-policy.mjs +312 -0
  159. package/runtime/prime/bin/pi-product-workflows.mjs +394 -0
  160. package/runtime/prime/bin/prime-render-spec.mjs +123 -0
  161. package/runtime/prime/bin/prime-session-start.sh +24 -0
  162. package/runtime/prime/bin/publish-artifact.sh +271 -0
  163. package/runtime/prime/bin/publish-references.sh +144 -0
  164. package/runtime/prime/bin/repo-state.sh +96 -0
  165. package/runtime/prime/bin/search.sh +240 -0
  166. package/runtime/prime/bin/session-autosave.sh +209 -0
  167. package/runtime/prime/bin/session-log.sh +199 -0
  168. package/runtime/prime/bin/settings.sh +304 -0
  169. package/runtime/prime/bin/telemetry.sh +391 -0
  170. package/runtime/prime/bin/worktree-create.sh +128 -0
  171. package/runtime/prime/bin/worktree-remove.sh +50 -0
  172. package/runtime/prime/bin/worktree.sh +198 -0
  173. package/runtime/prime/manifest.json +178 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  existsSync,
5
+ mkdirSync,
5
6
  mkdtempSync,
6
7
  readFileSync,
7
8
  readdirSync,
@@ -141,12 +142,37 @@ export async function renderView(pi, root, request, signal) {
141
142
  return { text, file, type: request.type };
142
143
  }
143
144
 
144
- export async function renderGreeting(pi, root, signal) {
145
- const script = join(root, "bin", "pi-session-start.sh");
146
- if (!existsSync(script)) throw new Error("Pi startup renderer is missing");
145
+ export async function renderGreeting(pi, root, signal, startScript = "pi-session-start.sh") {
146
+ const script = join(root, "bin", startScript);
147
+ if (!existsSync(script)) throw new Error("Egregore startup renderer is missing");
147
148
  return checkedExec(pi, "bash", [script, "--card"], { signal, timeout: 45000 }, "Egregore startup");
148
149
  }
149
150
 
151
+ // Raw JSON from the most recent activity-data.sh run, so follow-up focus
152
+ // turns can receive the data inline instead of re-running the script inside
153
+ // a harness surface that may cap command time (Prime's kernel kills execs at
154
+ // ~5s, truncating the 10-20s data script to a partial fragment).
155
+ let lastActivityData = "";
156
+ export function lastActivityJson() {
157
+ return lastActivityData;
158
+ }
159
+
160
+ // Stable on-disk copy of the same data. Focus turns point the model at this
161
+ // path instead of an inline excerpt: a file read is one fast kernel call,
162
+ // never truncated, and removes any reason to re-run the data script.
163
+ export function activityDataPath(root) {
164
+ return join(root, ".egregore", "activity-data.json");
165
+ }
166
+
167
+ function persistActivityData(root, json) {
168
+ try {
169
+ mkdirSync(join(root, ".egregore"), { recursive: true });
170
+ writeFileSync(activityDataPath(root), json);
171
+ } catch {
172
+ // Cache only — rendering proceeds from memory when the disk write fails.
173
+ }
174
+ }
175
+
150
176
  export async function renderActivity(pi, root, signal, options = {}) {
151
177
  if (options.sync !== false) {
152
178
  await checkedExec(
@@ -177,6 +203,8 @@ export async function renderActivity(pi, root, signal, options = {}) {
177
203
  { signal, timeout: 120000 },
178
204
  "Activity refresh",
179
205
  );
206
+ lastActivityData = String(json || "");
207
+ persistActivityData(root, lastActivityData);
180
208
  const temp = mkdtempSync(join(tmpdir(), "egregore-pi-activity-"));
181
209
  const input = join(temp, "activity.json");
182
210
  try {
@@ -276,7 +304,7 @@ export async function createHandoff(pi, root, params, signal) {
276
304
  }
277
305
  }
278
306
 
279
- export async function captureSessionEnd(pi, root, sessionFile, signal) {
307
+ export async function captureSessionEnd(pi, root, sessionFile, signal, runtime = "pi") {
280
308
  const sessionIdFile = join(root, ".egregore-session-id");
281
309
  if (!existsSync(sessionIdFile)) return false;
282
310
  const sessionId = cleanArgs(readFileSync(sessionIdFile, "utf8"));
@@ -287,7 +315,7 @@ export async function captureSessionEnd(pi, root, sessionFile, signal) {
287
315
  writeFileSync(input, JSON.stringify({
288
316
  session_id: sessionId,
289
317
  transcript_path: cleanArgs(sessionFile),
290
- runtime: "pi",
318
+ runtime,
291
319
  }));
292
320
 
293
321
  try {
@@ -353,11 +381,11 @@ export async function publishScroll(pi, root, params, signal) {
353
381
  };
354
382
  }
355
383
 
356
- export function workflowPrompt(name, args, extra = "") {
384
+ export function workflowPrompt(name, args, extra = "", spec = { runtimeName: "Pi", specPath: ".pi/APPEND_SYSTEM.md" }) {
357
385
  const userArgs = cleanArgs(args);
358
386
  return [
359
387
  `Run the Egregore /${name} workflow now.`,
360
- `Read .codex/skills/${name}/SKILL.md completely and adapt Codex-specific wording to Pi using .pi/APPEND_SYSTEM.md.`,
388
+ `Read .codex/skills/${name}/SKILL.md completely and adapt Codex-specific wording to ${spec.runtimeName} using ${spec.specPath}.`,
361
389
  "Work quietly: do not narrate progress, expose raw command output, or repeat tool results.",
362
390
  "Use the fewest targeted reads and commands that preserve the workflow contract.",
363
391
  userArgs ? `User arguments: ${userArgs}` : "",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "source": "egregore-pi-meta-harness",
3
3
  "policy": "Pi-native lifecycle layer over the available OSS Codex skill corpus",
4
- "skillCount": 43,
4
+ "skillCount": 46,
5
5
  "skills": [
6
6
  "activity",
7
7
  "announce",
@@ -19,12 +19,14 @@
19
19
  "handoff",
20
20
  "harvest",
21
21
  "ingest",
22
+ "ingest-notion",
22
23
  "ingest-user-interview",
23
24
  "invite",
24
25
  "issue",
25
26
  "me",
26
27
  "meeting",
27
28
  "note",
29
+ "notion-connect",
28
30
  "onboarding",
29
31
  "pr",
30
32
  "pull",
@@ -36,6 +38,7 @@
36
38
  "slack-connect",
37
39
  "summon",
38
40
  "sync-repos",
41
+ "teams-connect",
39
42
  "telegram-connect",
40
43
  "telemetry",
41
44
  "test",
@@ -70,6 +73,7 @@
70
73
  ".codex/skills/handoff/SKILL.md",
71
74
  ".codex/skills/harvest/QUESTION_PALETTE.md",
72
75
  ".codex/skills/harvest/SKILL.md",
76
+ ".codex/skills/ingest-notion/SKILL.md",
73
77
  ".codex/skills/ingest-user-interview/SKILL.md",
74
78
  ".codex/skills/ingest/SKILL.md",
75
79
  ".codex/skills/invite/SKILL.md",
@@ -77,6 +81,7 @@
77
81
  ".codex/skills/me/SKILL.md",
78
82
  ".codex/skills/meeting/SKILL.md",
79
83
  ".codex/skills/note/SKILL.md",
84
+ ".codex/skills/notion-connect/SKILL.md",
80
85
  ".codex/skills/onboarding/SKILL.md",
81
86
  ".codex/skills/pr/SKILL.md",
82
87
  ".codex/skills/pull/SKILL.md",
@@ -88,6 +93,7 @@
88
93
  ".codex/skills/slack-connect/SKILL.md",
89
94
  ".codex/skills/summon/SKILL.md",
90
95
  ".codex/skills/sync-repos/SKILL.md",
96
+ ".codex/skills/teams-connect/SKILL.md",
91
97
  ".codex/skills/telegram-connect/SKILL.md",
92
98
  ".codex/skills/telemetry/SKILL.md",
93
99
  ".codex/skills/test/SKILL.md",
@@ -117,6 +123,7 @@
117
123
  "bin/codex-session-start.sh",
118
124
  "bin/codex-skill-render.mjs",
119
125
  "bin/codex-sync-skills.sh",
126
+ "bin/connect-refresh.sh",
120
127
  "bin/dashboard-data.sh",
121
128
  "bin/graph-batch.sh",
122
129
  "bin/graph-op.sh",
@@ -0,0 +1,66 @@
1
+ # External notification consent
2
+
3
+ This protocol applies to every Telegram, Teams, or other external notification
4
+ in every harness. It applies even when the user asked to announce, notify,
5
+ handoff, invite, or message someone, and even when the harness is running with
6
+ broad tool permissions.
7
+
8
+ ## Invariant
9
+
10
+ Never dispatch an external notification until the human has approved that one
11
+ exact delivery in a dedicated checkpoint. The checkpoint must show:
12
+
13
+ - the organization;
14
+ - the final recipient or group;
15
+ - every channel that will receive it;
16
+ - the exact final message, including links.
17
+
18
+ A workflow request, plan approval, batch approval, prior approval, broad
19
+ permission mode, or approval of a related action is not notification consent.
20
+ There is no standing "always allow" choice. One approval authorizes one attempt
21
+ to send one immutable message.
22
+
23
+ ## Required flow
24
+
25
+ 1. Finish the message first. Publish any artifact and insert its real URL
26
+ before planning; never change the message after preview.
27
+ 2. Resolve the exact delivery without sending:
28
+
29
+ ```bash
30
+ PLAN_JSON=$(bash bin/notify.sh plan send "$RECIPIENT" "$MESSAGE")
31
+ # or
32
+ PLAN_JSON=$(bash bin/notify.sh plan group "$MESSAGE")
33
+ ```
34
+
35
+ 3. Read `plan_id` and `digest` from `PLAN_JSON`. Show the organization,
36
+ recipient, all `channels`/`deliveries`, and exact `message` in a dedicated
37
+ Send / Edit / Cancel question. Use the harness's structured question UI
38
+ when available. Otherwise show the same fields in plain text and stop for
39
+ the user's answer.
40
+ 4. Only if the user selects Send for that exact preview:
41
+
42
+ ```bash
43
+ APPROVAL_JSON=$(bash bin/notify.sh approve \
44
+ "$PLAN_ID" "$DIGEST" APPROVE_EXACT_NOTIFICATION)
45
+ APPROVAL_TOKEN=$(printf '%s' "$APPROVAL_JSON" | jq -r '.approval_token')
46
+ bash bin/notify.sh dispatch "$PLAN_ID" "$APPROVAL_TOKEN"
47
+ ```
48
+
49
+ 5. If the user chooses Edit, cancel the old plan, edit, create a new plan, and
50
+ preview again. If they choose Cancel, cancel it and do not send. If the plan
51
+ expires or dispatch fails, create a new plan and obtain new consent; never
52
+ retry from the old approval.
53
+
54
+ ## Hard rules
55
+
56
+ - Never call notification API endpoints directly. Use `bin/notify.sh`.
57
+ - Never use legacy `notify.sh send` or `notify.sh group`; they only create a
58
+ proposal and intentionally cannot dispatch.
59
+ - Never substitute a group when a direct message cannot be delivered.
60
+ - Never add a channel after preview.
61
+ - Never approve or dispatch from a detached/background process, hook, cron
62
+ job, batch execution, or unattended agent. Those paths may create a plan and
63
+ report that human approval is pending.
64
+ - Multiple direct recipients require a separate preview and approval for each
65
+ exact message. A group fanout may use one approval only when every receiving
66
+ channel is listed in the preview.
@@ -0,0 +1,17 @@
1
+ [features]
2
+ hooks = true
3
+
4
+ [tui]
5
+ # Codex currently accepts built-in footer item IDs only. Egregore's dynamic
6
+ # ◆ CONNECTED / ◇ LOCAL identity is rendered by bin/codex-session-start.sh.
7
+ status_line = [
8
+ "model-with-reasoning",
9
+ "git-branch",
10
+ "current-dir",
11
+ "context-remaining",
12
+ "five-hour-limit",
13
+ "weekly-limit",
14
+ "permissions",
15
+ "approval-mode",
16
+ ]
17
+ status_line_use_colors = true
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const { spawnSync } = require("node:child_process");
7
+
8
+ function readStdin() {
9
+ try {
10
+ return fs.readFileSync(0, "utf-8");
11
+ } catch {
12
+ return "";
13
+ }
14
+ }
15
+
16
+ function parseJson(raw) {
17
+ try {
18
+ return JSON.parse(raw || "{}");
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ function run(cmd, args, cwd) {
25
+ const result = spawnSync(cmd, args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
26
+ if (result.status !== 0) return "";
27
+ return String(result.stdout || "").trim();
28
+ }
29
+
30
+ function realish(filePath) {
31
+ const absolute = path.resolve(filePath);
32
+ let cursor = absolute;
33
+ while (!fs.existsSync(cursor)) {
34
+ const parent = path.dirname(cursor);
35
+ if (parent === cursor) return absolute;
36
+ cursor = parent;
37
+ }
38
+ try {
39
+ const resolved = fs.realpathSync(cursor);
40
+ return path.join(resolved, path.relative(cursor, absolute));
41
+ } catch {
42
+ return absolute;
43
+ }
44
+ }
45
+
46
+ function inside(child, parent) {
47
+ const rel = path.relative(parent, child);
48
+ return rel === "" || (!!rel && !rel.startsWith("..") && !path.isAbsolute(rel));
49
+ }
50
+
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;
94
+
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 };
124
+ }
125
+
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 };
131
+ }
132
+
133
+ function currentBranch(projectDir) {
134
+ return run("git", ["-C", projectDir, "rev-parse", "--abbrev-ref", "HEAD"], projectDir);
135
+ }
136
+
137
+ function currentAuthor(projectDir) {
138
+ try {
139
+ const raw = fs.readFileSync(path.join(projectDir, ".egregore-state.json"), "utf-8");
140
+ const state = JSON.parse(raw);
141
+ const value = state.handle || state.display_name || state.name || "dev";
142
+ return String(value).trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "dev";
143
+ } catch {
144
+ return "dev";
145
+ }
146
+ }
147
+
148
+ function configuredBaseBranch(projectDir) {
149
+ try {
150
+ const raw = fs.readFileSync(path.join(projectDir, "egregore.json"), "utf-8");
151
+ const value = JSON.parse(raw).base_branch;
152
+ return typeof value === "string" && value.trim() ? value.trim() : "develop";
153
+ } catch {
154
+ return "develop";
155
+ }
156
+ }
157
+
158
+ function isProtectedBranch(branch, projectDir) {
159
+ return branch === configuredBaseBranch(projectDir) ||
160
+ branch === "develop" || branch === "main" || branch === "master";
161
+ }
162
+
163
+ function isExemptPath(rawPath, projectDir, memoryDir) {
164
+ if (!rawPath) return false;
165
+ const absolute = path.isAbsolute(rawPath) ? rawPath : path.join(projectDir, rawPath);
166
+ const resolved = realish(absolute);
167
+
168
+ for (const dir of [".claude", ".codex", "memory"]) {
169
+ const target = realish(path.join(projectDir, dir));
170
+ if (inside(resolved, target)) return true;
171
+ }
172
+
173
+ for (const file of [".egregore-state.json", ".egregore-branch-consent", ".env"]) {
174
+ if (resolved === realish(path.join(projectDir, file))) return true;
175
+ }
176
+
177
+ if (memoryDir && inside(resolved, memoryDir)) return true;
178
+
179
+ return !inside(resolved, projectDir);
180
+ }
181
+
182
+ function extractApplyPatchPaths(command) {
183
+ const paths = [];
184
+ for (const line of String(command || "").split(/\r?\n/)) {
185
+ const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) || line.match(/^\*\*\* Move to: (.+)$/);
186
+ if (match && match[1]) paths.push(match[1].trim());
187
+ }
188
+ return paths;
189
+ }
190
+
191
+ function commandTargetsOtherRepo(command, projectDir, memoryDir) {
192
+ const paths = [];
193
+ const patterns = [
194
+ /\bcd\s+["']?([^"';&|]+)["']?/g,
195
+ /\bgit\s+-C\s+["']?([^"';&|]+)["']?/g,
196
+ ];
197
+ for (const pattern of patterns) {
198
+ let match;
199
+ while ((match = pattern.exec(command))) paths.push(match[1]);
200
+ }
201
+
202
+ for (const raw of paths) {
203
+ const resolved = realish(path.isAbsolute(raw) ? raw : path.join(projectDir, raw));
204
+ if (memoryDir && inside(resolved, memoryDir)) return true;
205
+ if (!inside(resolved, projectDir)) return true;
206
+ }
207
+ return false;
208
+ }
209
+
210
+ function isBranchSetupCommand(command) {
211
+ if (shellSegments(command).length !== 1) return false;
212
+ return (
213
+ /^\s*bin\/agent\.sh\s+branch\b/.test(command) ||
214
+ /^\s*git\s+(checkout|switch)\s+(-b|-c)\b/.test(command) ||
215
+ /^\s*git\s+branch\s+(dev|feature|bugfix)\//.test(command) ||
216
+ /^\s*git\s+worktree\s+add\b/.test(command) ||
217
+ /^\s*bin\/worktree\.sh\s+setup\b/.test(command)
218
+ );
219
+ }
220
+
221
+ function stagedOnlyFrameworkPaths(projectDir) {
222
+ const files = run("git", ["-C", projectDir, "diff", "--cached", "--name-only"], projectDir)
223
+ .split(/\r?\n/)
224
+ .filter(Boolean);
225
+ return files.length > 0 && files.every((file) => /^(bin\/|\.claude\/|\.codex\/|CLAUDE\.md$|AGENTS\.md$|skills\/)/.test(file));
226
+ }
227
+
228
+ function stripQuotedSegments(command) {
229
+ let out = "";
230
+ let quote = null;
231
+ for (let i = 0; i < String(command || "").length; i++) {
232
+ const ch = command[i];
233
+ if (quote) {
234
+ if (quote === '"' && ch === "\\") i++;
235
+ else if (ch === quote) quote = null;
236
+ continue;
237
+ }
238
+ if (ch === "'" || ch === '"') {
239
+ quote = ch;
240
+ out += "x";
241
+ continue;
242
+ }
243
+ out += ch;
244
+ }
245
+ return out;
246
+ }
247
+
248
+ function maskQuotedOperators(command) {
249
+ let out = "";
250
+ let quote = null;
251
+ for (let i = 0; i < String(command || "").length; i++) {
252
+ const ch = command[i];
253
+ if (quote) {
254
+ if (quote === '"' && ch === "\\") {
255
+ out += ch;
256
+ if (i + 1 < command.length) out += command[++i];
257
+ continue;
258
+ }
259
+ if (ch === quote) quote = null;
260
+ out += /[><|;&]/.test(ch) ? " " : ch;
261
+ continue;
262
+ }
263
+ if (ch === "'" || ch === '"') quote = ch;
264
+ out += ch;
265
+ }
266
+ return out;
267
+ }
268
+
269
+ function shellSegments(command) {
270
+ const segments = [];
271
+ let current = "";
272
+ let quote = null;
273
+ for (let i = 0; i < String(command || "").length; i++) {
274
+ const ch = command[i];
275
+ if (quote) {
276
+ current += ch;
277
+ if (quote === '"' && ch === "\\" && i + 1 < command.length) current += command[++i];
278
+ else if (ch === quote) quote = null;
279
+ continue;
280
+ }
281
+ if (ch === "'" || ch === '"') {
282
+ quote = ch;
283
+ current += ch;
284
+ continue;
285
+ }
286
+ if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
287
+ if (current.trim()) segments.push(current.trim());
288
+ current = "";
289
+ continue;
290
+ }
291
+ current += ch;
292
+ }
293
+ if (current.trim()) segments.push(current.trim());
294
+ return segments;
295
+ }
296
+
297
+ function shellWords(segment) {
298
+ const words = [];
299
+ let current = "";
300
+ let quote = null;
301
+ for (let i = 0; i < String(segment || "").length; i++) {
302
+ const ch = segment[i];
303
+ if (quote) {
304
+ if (quote === '"' && ch === "\\" && i + 1 < segment.length) current += segment[++i];
305
+ else if (ch === quote) quote = null;
306
+ else current += ch;
307
+ continue;
308
+ }
309
+ if (ch === "'" || ch === '"') {
310
+ quote = ch;
311
+ continue;
312
+ }
313
+ if (/\s/.test(ch)) {
314
+ if (current) words.push(current);
315
+ current = "";
316
+ continue;
317
+ }
318
+ if (ch === "\\") {
319
+ if (i + 1 < segment.length) current += segment[++i];
320
+ continue;
321
+ }
322
+ current += ch;
323
+ }
324
+ if (current) words.push(current);
325
+ return words;
326
+ }
327
+
328
+ function filesystemMutationStatus(command, projectDir, memoryDir) {
329
+ const commands = new Set(["rm", "mv", "cp", "mkdir", "rmdir", "touch", "chmod", "chown", "ln", "tee"]);
330
+ let unsafe = false;
331
+
332
+ for (const segment of shellSegments(command)) {
333
+ const words = shellWords(segment);
334
+ while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
335
+ while (words[0] === "sudo" || words[0] === "command" || words[0] === "env") words.shift();
336
+ if (!words.length) continue;
337
+
338
+ const name = path.basename(words.shift());
339
+ if (!commands.has(name)) continue;
340
+
341
+ const args = words.filter((word) => !word.startsWith("-"));
342
+ let targets = args;
343
+ if (name === "cp" || name === "ln") targets = args.length ? [args[args.length - 1]] : [];
344
+ if (name === "chmod" || name === "chown") targets = args.slice(1);
345
+
346
+ if (!targets.length || targets.some((target) =>
347
+ /[$`*?{}]/.test(target) || !isExemptPath(target, projectDir, memoryDir)
348
+ )) unsafe = true;
349
+ }
350
+
351
+ return { unsafe };
352
+ }
353
+
354
+ function redirectLooksMutating(command, projectDir, memoryDir) {
355
+ const visible = maskQuotedOperators(command);
356
+ const redirectPatterns = [
357
+ /(?:^|[\s;|&])(?:\d*)>>?\s*([^&\s;|]+)/g,
358
+ /\|\s*tee\s+(?:-a\s+)?([^&\s;|]+)/g,
359
+ ];
360
+
361
+ for (const pattern of redirectPatterns) {
362
+ let match;
363
+ while ((match = pattern.exec(visible))) {
364
+ const target = String(match[1] || "").replace(/^["']|["']$/g, "");
365
+ if (!target || target === "/dev/null" || target.startsWith("$")) continue;
366
+ if (!isExemptPath(target, projectDir, memoryDir)) return true;
367
+ }
368
+ }
369
+ return false;
370
+ }
371
+
372
+ function shellLooksMutating(command, projectDir, memoryDir) {
373
+ if (!command.trim()) return false;
374
+ const visible = stripQuotedSegments(command);
375
+ if (isBranchSetupCommand(visible)) return false;
376
+
377
+ const mutatingPatterns = [
378
+ /(?:^|[;&|(\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/,
379
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?apply_patch\b/,
380
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?(sed|perl)\b[^;&|]*\s-i(\s|$)/,
381
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?(npm|pnpm|yarn|bun)\s+(install|add|remove|update|dedupe|ci)\b/,
382
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?(go\s+mod\s+tidy|cargo\s+(fmt|fix|update)|rustfmt|prettier\s+--write|eslint\s+--fix)\b/,
383
+ ];
384
+
385
+ const gitCommit = /(?:^|[;&|(\n]\s*)(?:sudo\s+)?git(?:\s+-C\s+\S+)?\s+commit\b/.test(visible);
386
+ if (gitCommit && stagedOnlyFrameworkPaths(projectDir)) return false;
387
+
388
+ const filesystem = filesystemMutationStatus(command, projectDir, memoryDir);
389
+ return mutatingPatterns.some((pattern) => pattern.test(visible)) ||
390
+ filesystem.unsafe ||
391
+ redirectLooksMutating(command, projectDir, memoryDir);
392
+ }
393
+
394
+ function isConsentCommand(command, branch) {
395
+ const escaped = branch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
396
+ return new RegExp(
397
+ `^\\s*echo\\s+['"]?${escaped}['"]?\\s*>\\s*(?:\\./)?\\.egregore-branch-consent\\s*$`
398
+ ).test(String(command || ""));
399
+ }
400
+
401
+ function deny(reason) {
402
+ process.stdout.write(JSON.stringify({
403
+ hookSpecificOutput: {
404
+ hookEventName: "PreToolUse",
405
+ permissionDecision: "deny",
406
+ permissionDecisionReason: reason,
407
+ },
408
+ }));
409
+ }
410
+
411
+ function main() {
412
+ const input = parseJson(readStdin());
413
+ const context = findProjectContext(input);
414
+ if (context.external) return;
415
+ const projectDir = context.projectDir;
416
+ const branch = currentBranch(projectDir);
417
+ if (!isProtectedBranch(branch, projectDir)) return;
418
+
419
+ // Consent bypass: the user explicitly chose to work on this protected branch.
420
+ // Written only after they pick "Proceed on <branch>"; cleared on session start,
421
+ // so consent is asked once per session, not per write.
422
+ try {
423
+ const consent = fs.readFileSync(path.join(projectDir, ".egregore-branch-consent"), "utf-8").trim();
424
+ if (consent === branch) return;
425
+ } catch {}
426
+
427
+ const toolName = input.tool_name || "";
428
+ const toolInput = input.tool_input || {};
429
+ const memoryDir = realish(path.join(projectDir, "memory"));
430
+ const author = currentAuthor(projectDir);
431
+ const reason = [
432
+ `Protected branch (${branch}). Move project work to a task branch before writing.`,
433
+ `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.`,
434
+ "Ask only when the work topic is genuinely ambiguous; ask for the topic, not for routine Git permission.",
435
+ `Only when the user explicitly requested writing on ${branch}, record consent with: echo '${branch}' > .egregore-branch-consent`,
436
+ "Memory, framework-runtime, and managed-repo writes are exempt and should not trigger this guard.",
437
+ ].join("\n");
438
+
439
+ if (toolName === "apply_patch") {
440
+ const changedPaths = extractApplyPatchPaths(toolInput.command || "");
441
+ if (changedPaths.length === 0 || changedPaths.some((p) => !isExemptPath(p, projectDir, memoryDir))) {
442
+ deny(reason);
443
+ }
444
+ return;
445
+ }
446
+
447
+ if (toolName === "Edit" || toolName === "Write") {
448
+ const target = toolInput.file_path || toolInput.path || "";
449
+ if (!target || !isExemptPath(target, projectDir, memoryDir)) deny(reason);
450
+ return;
451
+ }
452
+
453
+ if (toolName === "Bash") {
454
+ const command = String(toolInput.command || "");
455
+ if (isConsentCommand(command, branch)) return;
456
+ if (commandTargetsOtherRepo(command, projectDir, memoryDir)) return;
457
+ if (shellLooksMutating(command, projectDir, memoryDir)) deny(reason);
458
+ }
459
+ }
460
+
461
+ main();