create-egregore 0.19.11 → 0.19.12

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.7.3
2
+ // egregore-launcher-version: 0.8.0
3
3
  //
4
4
  // egregore — launch and browse Egregore instances from your terminal.
5
5
  // Installed by create-egregore. Reads ~/.egregore/instances.json.
@@ -13,6 +13,7 @@
13
13
  // egregore <slug|name> pick a harness for the matching instance
14
14
  // egregore ls | list list instances (non-interactive table)
15
15
  // egregore use <slug> set the default for bare `egregore`
16
+ // egregore connect [slug] activate Connected for an existing instance
16
17
  // egregore /invite <user> invite a GitHub user to an instance
17
18
  // egregore settings [slug] hosting on/off, managed repos, admins (s in browser)
18
19
  // egregore prune [--dry-run] remove entries whose path is gone
@@ -28,13 +29,18 @@ const path = require("node:path");
28
29
  const os = require("node:os");
29
30
  const http = require("node:http");
30
31
  const https = require("node:https");
32
+ const crypto = require("node:crypto");
31
33
  const { spawnSync, spawn } = require("node:child_process");
32
34
  const readline = require("node:readline");
33
35
 
34
- const VERSION = "0.7.3";
36
+ const VERSION = "0.8.0";
35
37
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
36
38
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
37
39
  const PUBLIC_NPX_PACKAGE = "create-egregore";
40
+ const CONNECT_API_URL = (
41
+ process.env.EGREGORE_CONNECT_API_URL ||
42
+ "https://egregore-production-55f2.up.railway.app"
43
+ ).replace(/\/+$/, "");
38
44
 
39
45
  const NATIVE_CODEX_SKILLS = [
40
46
  "activity",
@@ -864,6 +870,7 @@ function cmdHelp() {
864
870
  ` ${CYAN}egregore browse${RESET} force the full-screen browser`,
865
871
  ` ${CYAN}egregore ls${RESET} ${DIM}| list${RESET} list installed instances`,
866
872
  ` ${CYAN}egregore use${RESET} ${DIM}<slug>${RESET} set the default for bare ${BOLD}egregore${RESET}`,
873
+ ` ${CYAN}egregore connect${RESET} ${DIM}[slug]${RESET} activate Connected for an existing instance`,
867
874
  ` ${CYAN}egregore /invite${RESET} ${DIM}<user> [slug]${RESET} invite a GitHub user`,
868
875
  ` ${CYAN}egregore settings${RESET} ${DIM}[slug]${RESET} hosting on/off, repos, admins`,
869
876
  ` ${CYAN}egregore prune${RESET} ${DIM}[--dry-run]${RESET} remove entries with missing paths`,
@@ -874,6 +881,7 @@ function cmdHelp() {
874
881
  ` ${DIM}enter${RESET} choose selected instance`,
875
882
  ` ${DIM}/${RESET} filter (type to narrow; esc to clear)`,
876
883
  ` ${DIM}n / +${RESET} create a new Egregore`,
884
+ ` ${DIM}c${RESET} connect selected instance`,
877
885
  ` ${DIM}u${RESET} set selected as default`,
878
886
  ` ${DIM}i${RESET} invite a GitHub user to selected instance`,
879
887
  ` ${DIM}s${RESET} settings (hosting on/off, repos, admins)`,
@@ -994,6 +1002,252 @@ function apiRequest(apiUrl, apiKey, apiPath, body) {
994
1002
  }, body);
995
1003
  }
996
1004
 
1005
+ function connectedApiRequest(githubToken, method, apiPath, body, headers = {}) {
1006
+ const url = new URL(apiPath, CONNECT_API_URL + "/");
1007
+ return requestJson({
1008
+ hostname: url.hostname,
1009
+ port: url.port || undefined,
1010
+ path: `${url.pathname}${url.search}`,
1011
+ method,
1012
+ protocol: url.protocol,
1013
+ headers: {
1014
+ Authorization: `Bearer ${githubToken}`,
1015
+ ...headers,
1016
+ },
1017
+ }, body);
1018
+ }
1019
+
1020
+ function openExternalUrl(url) {
1021
+ try {
1022
+ if (process.platform === "darwin") {
1023
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
1024
+ } else if (process.platform === "win32") {
1025
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
1026
+ } else {
1027
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
1028
+ }
1029
+ return true;
1030
+ } catch {
1031
+ return false;
1032
+ }
1033
+ }
1034
+
1035
+ function atomicWrite(file, content, mode) {
1036
+ const temp = path.join(
1037
+ path.dirname(file),
1038
+ `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`,
1039
+ );
1040
+ fs.writeFileSync(temp, content, { mode });
1041
+ fs.renameSync(temp, file);
1042
+ }
1043
+
1044
+ function writeJsonFileAtomic(file, value) {
1045
+ atomicWrite(file, JSON.stringify(value, null, 2) + "\n", 0o644);
1046
+ }
1047
+
1048
+ function writeEnvValueAtomic(file, key, value) {
1049
+ const lines = fs.existsSync(file)
1050
+ ? fs.readFileSync(file, "utf-8").split(/\r?\n/)
1051
+ : [];
1052
+ let replaced = false;
1053
+ const next = lines
1054
+ .filter((line, index) => !(index === lines.length - 1 && line === ""))
1055
+ .map((line) => {
1056
+ if (!line.startsWith(`${key}=`)) return line;
1057
+ replaced = true;
1058
+ return `${key}=${value}`;
1059
+ });
1060
+ if (!replaced) next.push(`${key}=${value}`);
1061
+ atomicWrite(file, next.join("\n") + "\n", 0o600);
1062
+ }
1063
+
1064
+ function persistConnectState(inst, fields) {
1065
+ const file = path.join(inst.path, ".egregore-state.json");
1066
+ const state = readJsonFile(file);
1067
+ state.connected_activation = {
1068
+ ...(state.connected_activation || {}),
1069
+ ...fields,
1070
+ updated_at: new Date().toISOString(),
1071
+ };
1072
+ writeJsonFileAtomic(file, state);
1073
+ }
1074
+
1075
+ function connectError(result, fallback) {
1076
+ if (!result) return fallback;
1077
+ const data = result.data || {};
1078
+ return data.detail || data.message || (typeof data === "string" ? data : fallback);
1079
+ }
1080
+
1081
+ function sleep(ms) {
1082
+ return new Promise((resolve) => setTimeout(resolve, ms));
1083
+ }
1084
+
1085
+ async function cmdConnect(args) {
1086
+ const registry = readRegistry();
1087
+ if (registry.length === 0) die("no egregores installed yet.\n run: npx -y create-egregore@latest");
1088
+ const inst = resolveInviteInstance(registry, args[0] || "");
1089
+ if (!inst) {
1090
+ die("multiple egregores installed; choose one:\n egregore connect <slug>\n run: egregore ls");
1091
+ }
1092
+ if (!fs.existsSync(inst.path)) die(`instance path is missing: ${inst.path}`);
1093
+
1094
+ const { cfg, state, env } = configForInstance(inst);
1095
+ const githubToken = env.GITHUB_TOKEN || "";
1096
+ if (!githubToken) {
1097
+ die(`GITHUB_TOKEN is missing for ${inst.slug}.\n Add it to ${path.join(inst.path, ".env")} and retry.`);
1098
+ }
1099
+ const activation = state.connected_activation || {};
1100
+ if (cfg.mode === "connected" && activation.status === "connected") {
1101
+ console.log(`\n ${GREEN}✓${RESET} ${BOLD}${inst.slug}${RESET} is already connected.\n`);
1102
+ return;
1103
+ }
1104
+
1105
+ console.log("");
1106
+ console.log(` ${BOLD}CONNECT${RESET} ${DIM}${inst.slug}${RESET}`);
1107
+ console.log(` ${DIM}€500/month · Stripe checkout · existing Markdown and Git stay in place${RESET}`);
1108
+ console.log("");
1109
+
1110
+ let intentId = activation.intent_id || "";
1111
+ let connectUrl = activation.connect_url || "";
1112
+ if (!intentId || !["checkout_pending", "entitled", "backfilling"].includes(activation.status)) {
1113
+ console.log(` [1/5] ${DIM}Creating connection intent…${RESET}`);
1114
+ const checkout = await connectedApiRequest(
1115
+ githubToken,
1116
+ "POST",
1117
+ "/api/billing/checkout",
1118
+ {
1119
+ org_slug: cfg.slug || inst.slug,
1120
+ plan: "connect",
1121
+ instance_metadata: {
1122
+ launcher_version: VERSION,
1123
+ mode: cfg.mode || "local",
1124
+ repo_name: cfg.repo_name || "",
1125
+ managed_repo_count: Array.isArray(cfg.repos) ? cfg.repos.length : 0,
1126
+ },
1127
+ },
1128
+ { "X-Idempotency-Key": `cli:${inst.slug}:${crypto.randomUUID()}` },
1129
+ );
1130
+ if (checkout.status !== 200) {
1131
+ die(`could not start Connected checkout: ${connectError(checkout, `HTTP ${checkout.status}`)}`);
1132
+ }
1133
+ intentId = checkout.data.connection_intent_id;
1134
+ connectUrl = checkout.data.connect_url;
1135
+ persistConnectState(inst, {
1136
+ intent_id: intentId,
1137
+ checkout_session_id: checkout.data.checkout_session_id,
1138
+ connect_url: connectUrl,
1139
+ status: "checkout_pending",
1140
+ });
1141
+ console.log(` [1/5] ${GREEN}✓${RESET} Connection intent`);
1142
+ } else {
1143
+ console.log(` [1/5] ${GREEN}✓${RESET} Resuming connection intent`);
1144
+ }
1145
+
1146
+ if (activation.status !== "backfilling") {
1147
+ if (connectUrl) {
1148
+ const opened = openExternalUrl(connectUrl);
1149
+ const mark = opened ? `${GREEN}✓${RESET}` : `${YELLOW}!${RESET}`;
1150
+ console.log(` [2/5] ${mark} ${opened ? "Opened" : "Open"} Egregore Connect`);
1151
+ console.log(` ${CYAN}${connectUrl}${RESET}`);
1152
+ }
1153
+ console.log(` ${DIM}Complete checkout in the browser. Promotion codes are entered in Stripe.${RESET}`);
1154
+ } else {
1155
+ console.log(` [2/5] ${GREEN}✓${RESET} Subscription already confirmed`);
1156
+ }
1157
+
1158
+ const pollMs = Math.max(100, Number(process.env.EGREGORE_CONNECT_POLL_INTERVAL_MS || 2000));
1159
+ const timeoutMs = Math.max(pollMs, Number(process.env.EGREGORE_CONNECT_TIMEOUT_MS || 30 * 60 * 1000));
1160
+ const deadline = Date.now() + timeoutMs;
1161
+ let status;
1162
+ while (Date.now() < deadline) {
1163
+ const result = await connectedApiRequest(
1164
+ githubToken,
1165
+ "GET",
1166
+ `/api/billing/connect/${encodeURIComponent(intentId)}`,
1167
+ );
1168
+ if (result.status === 200) {
1169
+ status = result.data;
1170
+ if (
1171
+ status.entitled ||
1172
+ ["entitled", "provisioning", "backfilling", "connected"].includes(status.status)
1173
+ ) break;
1174
+ if (["canceled", "failed"].includes(status.status)) {
1175
+ persistConnectState(inst, { status: status.status });
1176
+ die(`Connected checkout ${status.status}. Your local Egregore was not changed.`);
1177
+ }
1178
+ } else if (result.status !== 0) {
1179
+ die(`could not read Connected status: ${connectError(result, `HTTP ${result.status}`)}`);
1180
+ }
1181
+ await sleep(pollMs);
1182
+ }
1183
+ if (!status || !(status.entitled || ["entitled", "provisioning", "backfilling", "connected"].includes(status.status))) {
1184
+ die("timed out waiting for checkout. Run the same command to resume.");
1185
+ }
1186
+ console.log(` [3/5] ${GREEN}✓${RESET} Subscription confirmed`);
1187
+ persistConnectState(inst, { status: "entitled" });
1188
+
1189
+ const claim = await connectedApiRequest(
1190
+ githubToken,
1191
+ "POST",
1192
+ `/api/billing/connect/${encodeURIComponent(intentId)}/claim`,
1193
+ {},
1194
+ );
1195
+ if (claim.status !== 200) {
1196
+ die(`could not activate Connected infrastructure: ${connectError(claim, `HTTP ${claim.status}`)}`);
1197
+ }
1198
+
1199
+ const configFile = path.join(inst.path, "egregore.json");
1200
+ const envFile = path.join(inst.path, ".env");
1201
+ const currentConfig = readJsonFile(configFile);
1202
+ writeJsonFileAtomic(configFile, {
1203
+ ...currentConfig,
1204
+ mode: "connected",
1205
+ api_url: claim.data.api_url,
1206
+ });
1207
+ writeEnvValueAtomic(envFile, "EGREGORE_API_KEY", claim.data.api_key);
1208
+ persistConnectState(inst, {
1209
+ status: "backfilling",
1210
+ activation_protocol: claim.data.activation_protocol,
1211
+ });
1212
+ console.log(` [4/5] ${GREEN}✓${RESET} Connected credential and graph node`);
1213
+
1214
+ const syncScript = path.join(inst.path, "bin", "sync-graph.sh");
1215
+ if (!fs.existsSync(syncScript)) {
1216
+ die("bin/sync-graph.sh is missing. Update the installed Egregore version, then run egregore connect again.");
1217
+ }
1218
+ const sync = spawnSync("bash", [syncScript], {
1219
+ cwd: inst.path,
1220
+ encoding: "utf-8",
1221
+ stdio: "pipe",
1222
+ timeout: 10 * 60 * 1000,
1223
+ });
1224
+ if (sync.status !== 0) {
1225
+ const detail = (sync.stderr || sync.stdout || "graph replay failed").trim();
1226
+ die(`Connected is configured, but graph replay did not finish.\n ${detail}\n Run egregore connect ${inst.slug} to retry; existing data is unchanged.`);
1227
+ }
1228
+ let backfillSummary = { replayed: true };
1229
+ try { backfillSummary = JSON.parse((sync.stdout || "").trim()); } catch {}
1230
+
1231
+ const complete = await connectedApiRequest(
1232
+ githubToken,
1233
+ "POST",
1234
+ `/api/billing/connect/${encodeURIComponent(intentId)}/complete`,
1235
+ { backfill_summary: backfillSummary },
1236
+ );
1237
+ if (complete.status !== 200) {
1238
+ die(`graph replay finished, but activation could not be finalized: ${connectError(complete, `HTTP ${complete.status}`)}`);
1239
+ }
1240
+ persistConnectState(inst, {
1241
+ status: "connected",
1242
+ connected_at: new Date().toISOString(),
1243
+ backfill_summary: backfillSummary,
1244
+ });
1245
+ console.log(` [5/5] ${GREEN}✓${RESET} Existing memory projected into the graph`);
1246
+ console.log("");
1247
+ console.log(` ${GREEN}✓${RESET} ${BOLD}${inst.slug}${RESET} is connected. Local files remain the source of truth.`);
1248
+ console.log("");
1249
+ }
1250
+
997
1251
  function accessLabel(status) {
998
1252
  if (status === 201) return `${GREEN}sent${RESET}`;
999
1253
  if (status === 204) return `${GREEN}already added${RESET}`;
@@ -1952,8 +2206,8 @@ async function browser(args = {}) {
1952
2206
  footer = band(`● ${stripAnsi(state.message)}`, bg);
1953
2207
  } else {
1954
2208
  footer = blockWidth < 96
1955
- ? band(`${key("↑↓", "nav")} ${key("enter", "select")} ${key("n/+", "new")} ${key("i", "invite")} ${key("s", "settings")} ${key("d", "delete")} ${key("?", "help")} ${key("q", "quit")}`, 46)
1956
- : band(`${key("↑↓", "nav")} ${key("enter", "select")} ${key("n/+", "new")} ${key("/", "filter")} ${key("i", "invite")} ${key("s", "settings")} ${key("u", "default")} ${key("d", "delete")} ${key("?", "help")} ${key("q", "quit")}`, 46);
2209
+ ? band(`${key("↑↓", "nav")} ${key("enter", "select")} ${key("n/+", "new")} ${key("c", "connect")} ${key("i", "invite")} ${key("d", "delete")} ${key("?", "help")} ${key("q", "quit")}`, 46)
2210
+ : band(`${key("↑↓", "nav")} ${key("enter", "select")} ${key("n/+", "new")} ${key("/", "filter")} ${key("c", "connect")} ${key("i", "invite")} ${key("s", "settings")} ${key("u", "default")} ${key("d", "delete")} ${key("?", "help")} ${key("q", "quit")}`, 46);
1957
2211
  }
1958
2212
  out.push(M + footer + "\n");
1959
2213
  if (state.showHelp) {
@@ -2310,6 +2564,13 @@ async function browser(args = {}) {
2310
2564
  resolve(setupNewEgregore(args.runtimeOverride));
2311
2565
  return;
2312
2566
  }
2567
+ if (chunk === "c") {
2568
+ const pick = visible()[state.cursor]?.e;
2569
+ if (!pick) return;
2570
+ cleanup();
2571
+ resolve(cmdConnect([pick.slug]));
2572
+ return;
2573
+ }
2313
2574
  if (chunk === "i") {
2314
2575
  const pick = visible()[state.cursor]?.e;
2315
2576
  if (!pick) return;
@@ -2451,6 +2712,7 @@ function main() {
2451
2712
  // Non-interactive subcommands
2452
2713
  if (cmd === "ls" || cmd === "list") return cmdLs();
2453
2714
  if (cmd === "use") return cmdUse(args[1]);
2715
+ if (cmd === "connect") return cmdConnect(args.slice(1));
2454
2716
  if (cmd === "/invite" || cmd === "invite") return cmdInvite(args.slice(1));
2455
2717
  if (cmd === "settings" || cmd === "config") return cmdSettings(args.slice(1));
2456
2718
  if (cmd === "prune") return cmdPruneNonInteractive(args[1] === "--dry-run" || args[1] === "-n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.19.11",
3
+ "version": "0.19.12",
4
4
  "description": "Set up Egregore for your team in one command — installs the egregore launcher and your choice of Claude Code, Codex, or Pi.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "scripts": {
13
13
  "build:runtime": "node scripts/build-runtime-bundle.mjs && node scripts/build-pi-runtime-bundle.mjs",
14
14
  "build:pi-runtime": "node scripts/build-pi-runtime-bundle.mjs",
15
- "test": "node scripts/build-runtime-bundle.mjs --check && node scripts/build-pi-runtime-bundle.mjs --check && node --test test/context.test.js test/parse-join-target.test.js test/skip-repos.test.js test/onboarding-shortcircuit.test.js test/welcome-screen.test.js test/runtime-selection.test.js test/command-generation.test.js test/auth-gh.test.js test/gauntlet-fixes.test.js test/codex-runtime.test.js test/pi-runtime.test.js test/pi-product-workflows.test.js test/installer-runtime-source.test.js test/scroll-repro.test.js test/launcher-refocus-redraw.test.js test/multiselect-remote-search.test.js test/back-navigation.test.js test/manifest-form.test.js test/launcher-delete.test.js",
15
+ "test": "node scripts/build-runtime-bundle.mjs --check && node scripts/build-pi-runtime-bundle.mjs --check && node --test test/context.test.js test/parse-join-target.test.js test/skip-repos.test.js test/onboarding-shortcircuit.test.js test/welcome-screen.test.js test/runtime-selection.test.js test/command-generation.test.js test/auth-gh.test.js test/gauntlet-fixes.test.js test/codex-runtime.test.js test/pi-runtime.test.js test/pi-product-workflows.test.js test/installer-runtime-source.test.js test/scroll-repro.test.js test/launcher-refocus-redraw.test.js test/multiselect-remote-search.test.js test/back-navigation.test.js test/manifest-form.test.js test/launcher-delete.test.js test/launcher-connect.test.js",
16
16
  "test:e2e": "node --test test/e2e-local.test.js",
17
17
  "test:all": "node scripts/build-runtime-bundle.mjs --check && node scripts/build-pi-runtime-bundle.mjs --check && node --test test/"
18
18
  },
@@ -43,7 +43,11 @@ esac
43
43
  4. Synthesize:
44
44
  - a short topic from the work,
45
45
  - a clear commit message,
46
- - a one-line user-facing scope summary.
46
+ - a one-line user-facing scope summary,
47
+ - a PR description following `.claude/context/pr-format.md`:
48
+ `## What` (1–4 bullets), `## Why` (1–3 sentences), and
49
+ `## Verification` (how the change was checked — required when the
50
+ diff touches non-markdown files; be honest if unverified).
47
51
  5. If the scope is ambiguous or includes unrelated changes, ask for one compact
48
52
  confirmation. Use structured Codex question tooling when available;
49
53
  otherwise render:
@@ -58,11 +62,13 @@ Other:
58
62
  6. Run the bridge command:
59
63
 
60
64
  ```bash
61
- bin/agent.sh save --message "$MESSAGE" --topic "$TOPIC"
65
+ bin/agent.sh save --message "$MESSAGE" --topic "$TOPIC" --pr-body "$PR_BODY"
62
66
  ```
63
67
 
64
68
  The bridge owns the mechanical workflow: sync memory, ensure a task branch,
65
69
  commit repo changes, push, and create or reuse a pull request when available.
70
+ Always pass the synthesized `--pr-body` — the bridge only auto-generates a
71
+ skeleton body as a last resort, and the CI `pr-format` check gates every PR.
66
72
 
67
73
  7. Parse the output and report only useful user information:
68
74
  - branch name,
@@ -41,7 +41,7 @@
41
41
  {
42
42
  "section": "Config Files",
43
43
  "action": "keep",
44
- "sourceHash": "81b7bf09901b",
44
+ "sourceHash": "8d7f1bd0f2d9",
45
45
  "note": "Kept verbatim — shell facts, runtime-neutral."
46
46
  },
47
47
  {
@@ -83,7 +83,7 @@
83
83
  {
84
84
  "section": "Git Workflow",
85
85
  "action": "keep",
86
- "sourceHash": "d058d1f8da11",
86
+ "sourceHash": "49fd4625fa1e",
87
87
  "note": "Kept; slash tokens rendered as $skill tokens. Branch diagram unchanged (code fence)."
88
88
  },
89
89
  {
@@ -119,8 +119,8 @@
119
119
  {
120
120
  "section": "Environment Isolation",
121
121
  "action": "replace",
122
- "sourceHash": "ed47e96248bd",
123
- "note": "boundary-check is a Claude PreToolUse hook with no .codex equivalent — behavior stated as a standing instruction; AskUserQuestion remediation -> numbered options."
122
+ "sourceHash": "086e2c1d9019",
123
+ "note": "boundary-check is a Claude PreToolUse hook with no .codex equivalent — two-tier model stated as a standing instruction; AskUserQuestion remediation -> numbered options, now including the two consent-grant options (session grant -> .egregore-boundary-consent, instance grant -> .egregore-boundary.local.json read[])."
124
124
  }
125
125
  ]
126
126
  }
@@ -35,7 +35,8 @@ Use the runtime-neutral bridge instead of Claude Code slash commands:
35
35
 
36
36
  ```bash
37
37
  bin/agent.sh branch --topic "auth review"
38
- bin/agent.sh save --message "Save: auth review" --topic "auth review"
38
+ bin/agent.sh save --message "Save: auth review" --topic "auth review" \
39
+ --pr-body "$PR_BODY" # body per .claude/context/pr-format.md (auto-skeleton if omitted)
39
40
  bin/agent.sh wrap --from alice --topic "auth review" \
40
41
  --summary "Implemented the OAuth callback parser and documented follow-ups." \
41
42
  --body "Open threads: review error cases and browser redirects."
@@ -191,7 +192,7 @@ If the startup card output contains `onboarding_needed`, invoke the `$onboarding
191
192
 
192
193
  ## Config Files
193
194
 
194
- - **`egregore.json`** — committed. Non-secret org config: `org_name`, `github_org`, `memory_repo`, `slug`, `mode`. `api_url` is connected-mode only. **Never put secrets here.**
195
+ - **`egregore.json`** — committed. Non-secret org config: `org_name`, `github_org`, `memory_repo`, `slug`, `mode`. `api_url` is connected-mode only. Optional `boundary { posture, read[], locked }` sets the org's isolation posture (see Environment Isolation). **Never put secrets here.**
195
196
  - **`.env`** — gitignored. Personal secrets. Local mode: `GITHUB_TOKEN` only. Connected mode: `GITHUB_TOKEN` + `EGREGORE_API_KEY`. **Never use `source .env`** — use `grep '^KEY=' .env | cut -d'=' -f2-`.
196
197
 
197
198
  In connected mode, infrastructure credentials (Neo4j, Telegram) live on the API server only — `bin/graph.sh` and `bin/notify.sh` route through the API gateway.
@@ -280,6 +281,10 @@ main ← stable (/release)
280
281
  - **Memory repo**: stays on main (separate repo, auto-merge).
281
282
  - **Never push directly to main or develop.** All changes flow through PRs.
282
283
 
284
+ ### Pull request format (all harnesses)
285
+
286
+ Every PR body follows `.claude/context/pr-format.md`, enforced by the `pr-format` CI check regardless of which harness opened it: `## What` (1–4 bullets) + `## Why` (1–3 sentences) always; `## Verification` when the diff touches non-markdown files (how it was checked, or an honest `Not verified — <reason>`); `## Risk`/`## Links` when real; title `type(scope): imperative summary` (advisory). **Never create a PR with an empty body or `--fill`** — write the body and pass it explicitly (`gh pr create --body`, or `bin/agent.sh save --pr-body` for shell agents; the bridge auto-generates a compliant skeleton only as a last resort).
287
+
283
288
  ### Managed Repos
284
289
 
285
290
  Repos in `egregore.json` → `repos[]` are cloned as siblings (`../{repo}/`). Each entry can be a string or `{"name": "...", "description": "..."}`. Match user intent to the right repo using `description`. Same branching strategy. Use `git -C` with absolute paths — never `cd` into repos. `$save` scans all managed repos for uncommitted changes.
@@ -368,19 +373,22 @@ Egregore runs in one of two configurations, set by `mode` in `egregore.json`. De
368
373
 
369
374
  ## Environment Isolation
370
375
 
371
- Sessions are confined to this project + memory + managed repos. On Codex this boundary is a standing instruction, not an enforced hook — hold it yourself.
376
+ Sessions are confined to this project + memory + managed repos, with a **two-tier boundary** — a hard wall between Egregore instances, a consent gate for everything else. On Codex the boundary is a standing instruction, not an enforced hook — hold it yourself.
372
377
 
373
- - **Never modify `~/.egregore/instances.json`** — managed by session-start.sh
374
- - **Never access another instance's files** — refuse even if asked
375
- - When a request points outside the boundary, do not improvise a workaround. Ask in plain text with numbered options:
378
+ - **Hard tier — other Egregore instances.** Denied for every tool, always. There is no consent path. Never access another instance's files — refuse even if asked. Never modify `~/.egregore/instances.json` (managed by session-start.sh). When a request points at another instance's files there is nothing to ask — refuse and explain.
379
+ - **Soft tier — paths outside the boundary.** Consent-gated. Inbox dirs (`~/Downloads`, `~/Desktop`) are readable without consent under the default posture; writes outside the project always need consent. Posture (`strict | standard | open`) and extra read roots come from `egregore.json` -> `boundary { posture, read[], locked }` (org, committed) merged with `.egregore-boundary.local.json` (personal, gitignored). `locked: true` removes the consent path entirely.
380
+ - When a request needs soft-tier consent, do not improvise a workaround. Ask in plain text with numbered options:
376
381
 
377
382
  ```text
378
383
  That path is outside this Egregore's boundary. How should we proceed?
379
- 1. Paste the contents inline
380
- 2. Move the file into the repo and point me at the new path
381
- 3. Cancel
384
+ 1. Allow its directory for this session (recorded in .egregore-boundary-consent)
385
+ 2. Always allow on this instance (added to read[] in .egregore-boundary.local.json)
386
+ 3. Paste the contents inline
387
+ 4. Cancel
382
388
  ```
383
389
 
384
- See DEVELOPMENT.md §3 for boundary details.
390
+ Never record a consent grant without the user's explicit choice of option 1 or 2 in that exchange. Session grants are cleared on session start; `locked: true` removes options 1 and 2.
391
+
392
+ See DEVELOPMENT.md §3 for boundary details and `memory/knowledge/decisions/2026-07-08-boundary-hook-consent-design.md` for the design decisions.
385
393
 
386
394
  <!-- END GENERATED EGREGORE CODEX SPEC -->
@@ -32,7 +32,10 @@ Runtime-neutral commands:
32
32
  Hybrid recall over shared memory (qmd)
33
33
  people List known people from memory/people
34
34
  branch --topic TEXT Create or reuse a task branch/worktree
35
- save [--message TEXT] Commit and push current repo + memory changes
35
+ save [--message TEXT] [--pr-body TEXT|--pr-body-file PATH]
36
+ Commit and push current repo + memory changes;
37
+ PR body follows .claude/context/pr-format.md
38
+ (auto-generated skeleton if not provided)
36
39
  wrap --topic T --summary TEXT Write a session wrap to memory/wraps
37
40
  handoff --from A --to B --topic T [--intent action|feedback|fyi]
38
41
  [--body TEXT|--body-file PATH] [--no-push] [--no-publish] [--no-notify] [--json]
@@ -332,13 +335,26 @@ ensure_working_branch() {
332
335
  echo "$branch"
333
336
  }
334
337
 
338
+ build_pr_body() {
339
+ # Compliant fallback PR body (spec: .claude/context/pr-format.md) built
340
+ # from the commit log + diff when the calling agent supplies none.
341
+ local topic="${1:-portable save}" commits stat
342
+ commits="$(git -C "$SCRIPT_DIR" log --pretty='- %s' "origin/develop..HEAD" 2>/dev/null | head -8)"
343
+ [ -n "$commits" ] || commits="- (commit list unavailable — see the Files tab)"
344
+ stat="$(git -C "$SCRIPT_DIR" diff --shortstat "origin/develop...HEAD" 2>/dev/null | sed 's/^ *//')"
345
+ printf '## What\n%s\n\n## Why\nShell-agent session save — topic: %s. Body auto-generated by bin/agent.sh; pass --pr-body for a hand-written description.\n\n## Verification\nNot verified — auto-generated skeleton; review the diff before merging.%s\n\n🤖 Saved via bin/agent.sh\n' \
346
+ "$commits" "$topic" "${stat:+ ($stat)}"
347
+ }
348
+
335
349
  cmd_save() {
336
- local message="" topic="" no_pr=0 branch pushed="false" committed="false" pr_url="" existing_pr="" pr_number="" non_md="" merged="false"
350
+ local message="" topic="" no_pr=0 branch pushed="false" committed="false" pr_url="" existing_pr="" pr_number="" non_md="" merged="false" pr_body="" pr_body_file=""
337
351
 
338
352
  while [ $# -gt 0 ]; do
339
353
  case "$1" in
340
354
  --message|-m) message="${2:-}"; shift 2 ;;
341
355
  --topic) topic="${2:-}"; shift 2 ;;
356
+ --pr-body) pr_body="${2:-}"; shift 2 ;;
357
+ --pr-body-file) pr_body_file="${2:-}"; shift 2 ;;
342
358
  --no-pr) no_pr=1; shift ;;
343
359
  *) die "unknown save option: $1" ;;
344
360
  esac
@@ -376,7 +392,11 @@ cmd_save() {
376
392
  if [ -n "$existing_pr" ]; then
377
393
  pr_url="$existing_pr"
378
394
  else
379
- pr_url="$(gh pr create --base develop --head "$branch" --title "$message" --body "Saved via bin/agent.sh save." 2>/dev/null || true)"
395
+ if [ -z "$pr_body" ] && [ -n "$pr_body_file" ] && [ -f "$pr_body_file" ]; then
396
+ pr_body="$(cat "$pr_body_file")"
397
+ fi
398
+ [ -n "$pr_body" ] || pr_body="$(build_pr_body "$topic")"
399
+ pr_url="$(gh pr create --base develop --head "$branch" --title "$message" --body "$pr_body" 2>/dev/null || true)"
380
400
  fi
381
401
  fi
382
402
 
@@ -79,7 +79,8 @@ Use the runtime-neutral bridge instead of Claude Code slash commands:
79
79
 
80
80
  \`\`\`bash
81
81
  bin/agent.sh branch --topic "auth review"
82
- bin/agent.sh save --message "Save: auth review" --topic "auth review"
82
+ bin/agent.sh save --message "Save: auth review" --topic "auth review" \\
83
+ --pr-body "$PR_BODY" # body per .claude/context/pr-format.md (auto-skeleton if omitted)
83
84
  bin/agent.sh wrap --from alice --topic "auth review" \\
84
85
  --summary "Implemented the OAuth callback parser and documented follow-ups." \\
85
86
  --body "Open threads: review error cases and browser redirects."
@@ -251,20 +252,23 @@ Codex has no structured question tool — render each batch as compact numbered
251
252
 
252
253
  const LOOM_ROUTING_BODY = `Loom routes commands across model tiers on the Claude Code runtime (\`loom/routes.json\` + \`bin/loom.sh\` + a model-pinned executor subagent). Codex has no subagent delegation — every command runs inline in the current session. Ignore "Loom routing" preambles if you encounter them in a skill spec, and skip \`bin/loom.sh\` calls; \`loom/\` and \`.claude/agents/\` are framework files synced for the Claude runtime.`;
253
254
 
254
- const ISOLATION_BODY = `Sessions are confined to this project + memory + managed repos. On Codex this boundary is a standing instruction, not an enforced hook — hold it yourself.
255
+ const ISOLATION_BODY = `Sessions are confined to this project + memory + managed repos, with a **two-tier boundary** — a hard wall between Egregore instances, a consent gate for everything else. On Codex the boundary is a standing instruction, not an enforced hook — hold it yourself.
255
256
 
256
- - **Never modify \`~/.egregore/instances.json\`** — managed by session-start.sh
257
- - **Never access another instance's files** — refuse even if asked
258
- - When a request points outside the boundary, do not improvise a workaround. Ask in plain text with numbered options:
257
+ - **Hard tier — other Egregore instances.** Denied for every tool, always. There is no consent path. Never access another instance's files — refuse even if asked. Never modify \`~/.egregore/instances.json\` (managed by session-start.sh). When a request points at another instance's files there is nothing to ask — refuse and explain.
258
+ - **Soft tier — paths outside the boundary.** Consent-gated. Inbox dirs (\`~/Downloads\`, \`~/Desktop\`) are readable without consent under the default posture; writes outside the project always need consent. Posture (\`strict | standard | open\`) and extra read roots come from \`egregore.json\` -> \`boundary { posture, read[], locked }\` (org, committed) merged with \`.egregore-boundary.local.json\` (personal, gitignored). \`locked: true\` removes the consent path entirely.
259
+ - When a request needs soft-tier consent, do not improvise a workaround. Ask in plain text with numbered options:
259
260
 
260
261
  \`\`\`text
261
262
  That path is outside this Egregore's boundary. How should we proceed?
262
- 1. Paste the contents inline
263
- 2. Move the file into the repo and point me at the new path
264
- 3. Cancel
263
+ 1. Allow its directory for this session (recorded in .egregore-boundary-consent)
264
+ 2. Always allow on this instance (added to read[] in .egregore-boundary.local.json)
265
+ 3. Paste the contents inline
266
+ 4. Cancel
265
267
  \`\`\`
266
268
 
267
- See DEVELOPMENT.md §3 for boundary details.`;
269
+ Never record a consent grant without the user's explicit choice of option 1 or 2 in that exchange. Session grants are cleared on session start; \`locked: true\` removes options 1 and 2.
270
+
271
+ See DEVELOPMENT.md §3 for boundary details and \`memory/knowledge/decisions/2026-07-08-boundary-hook-consent-design.md\` for the design decisions.`;
268
272
 
269
273
  // ── per-section rules ────────────────────────────────────────────────────
270
274
  const RULES = {
@@ -326,7 +330,7 @@ const RULES = {
326
330
  'Environment Isolation': {
327
331
  action: 'replace',
328
332
  body: ISOLATION_BODY,
329
- note: 'boundary-check is a Claude PreToolUse hook with no .codex equivalent — behavior stated as a standing instruction; AskUserQuestion remediation -> numbered options.',
333
+ note: 'boundary-check is a Claude PreToolUse hook with no .codex equivalent — two-tier model stated as a standing instruction; AskUserQuestion remediation -> numbered options, now including the two consent-grant options (session grant -> .egregore-boundary-consent, instance grant -> .egregore-boundary.local.json read[]).',
330
334
  },
331
335
  };
332
336
 
@@ -6,7 +6,7 @@ set -o pipefail
6
6
  # libraries as Claude's SessionStart, then renders a terminal greeting before
7
7
  # Codex itself starts.
8
8
 
9
- FRAMEWORK_VERSION="6"
9
+ FRAMEWORK_VERSION="7"
10
10
  SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
11
11
  cd "$SCRIPT_DIR" || exit 1
12
12
 
@@ -21,6 +21,9 @@ fi
21
21
  # Clear any branch-guard consent from a previous session — consent to write on
22
22
  # a protected branch is asked fresh each session (see AGENTS.md branch-guard protocol).
23
23
  rm -f "$SCRIPT_DIR/.egregore-branch-consent" "$MAIN_PROJECT_DIR/.egregore-branch-consent" 2>/dev/null
24
+ # Same for boundary-crossing consent — grants are session-scoped (AGENTS.md
25
+ # environment-isolation protocol); durable grants belong in .egregore-boundary.local.json.
26
+ rm -f "$SCRIPT_DIR/.egregore-boundary-consent" "$MAIN_PROJECT_DIR/.egregore-boundary-consent" 2>/dev/null
24
27
 
25
28
  if [ -f "$SCRIPT_DIR/bin/lib/worktree-links.sh" ]; then
26
29
  source "$SCRIPT_DIR/bin/lib/worktree-links.sh" >/dev/null 2>/dev/null || true
@@ -43,7 +43,11 @@ esac
43
43
  4. Synthesize:
44
44
  - a short topic from the work,
45
45
  - a clear commit message,
46
- - a one-line user-facing scope summary.
46
+ - a one-line user-facing scope summary,
47
+ - a PR description following `.claude/context/pr-format.md`:
48
+ `## What` (1–4 bullets), `## Why` (1–3 sentences), and
49
+ `## Verification` (how the change was checked — required when the
50
+ diff touches non-markdown files; be honest if unverified).
47
51
  5. If the scope is ambiguous or includes unrelated changes, ask for one compact
48
52
  confirmation. Use structured Codex question tooling when available;
49
53
  otherwise render:
@@ -58,11 +62,13 @@ Other:
58
62
  6. Run the bridge command:
59
63
 
60
64
  ```bash
61
- bin/agent.sh save --message "$MESSAGE" --topic "$TOPIC"
65
+ bin/agent.sh save --message "$MESSAGE" --topic "$TOPIC" --pr-body "$PR_BODY"
62
66
  ```
63
67
 
64
68
  The bridge owns the mechanical workflow: sync memory, ensure a task branch,
65
69
  commit repo changes, push, and create or reuse a pull request when available.
70
+ Always pass the synthesized `--pr-body` — the bridge only auto-generates a
71
+ skeleton body as a last resort, and the CI `pr-format` check gates every PR.
66
72
 
67
73
  7. Parse the output and report only useful user information:
68
74
  - branch name,
@@ -41,7 +41,7 @@
41
41
  {
42
42
  "section": "Config Files",
43
43
  "action": "keep",
44
- "sourceHash": "81b7bf09901b",
44
+ "sourceHash": "8d7f1bd0f2d9",
45
45
  "note": "Kept verbatim — shell facts, runtime-neutral."
46
46
  },
47
47
  {
@@ -83,7 +83,7 @@
83
83
  {
84
84
  "section": "Git Workflow",
85
85
  "action": "keep",
86
- "sourceHash": "d058d1f8da11",
86
+ "sourceHash": "49fd4625fa1e",
87
87
  "note": "Kept; slash tokens rendered as $skill tokens. Branch diagram unchanged (code fence)."
88
88
  },
89
89
  {
@@ -119,8 +119,8 @@
119
119
  {
120
120
  "section": "Environment Isolation",
121
121
  "action": "replace",
122
- "sourceHash": "ed47e96248bd",
123
- "note": "boundary-check is a Claude PreToolUse hook with no .codex equivalent — behavior stated as a standing instruction; AskUserQuestion remediation -> numbered options."
122
+ "sourceHash": "086e2c1d9019",
123
+ "note": "boundary-check is a Claude PreToolUse hook with no .codex equivalent — two-tier model stated as a standing instruction; AskUserQuestion remediation -> numbered options, now including the two consent-grant options (session grant -> .egregore-boundary-consent, instance grant -> .egregore-boundary.local.json read[])."
124
124
  }
125
125
  ]
126
126
  }
@@ -118,7 +118,7 @@ If the startup card output contains `onboarding_needed`, invoke the `/onboarding
118
118
 
119
119
  ## Config Files
120
120
 
121
- - **`egregore.json`** — committed. Non-secret org config: `org_name`, `github_org`, `memory_repo`, `slug`, `mode`. `api_url` is connected-mode only. **Never put secrets here.**
121
+ - **`egregore.json`** — committed. Non-secret org config: `org_name`, `github_org`, `memory_repo`, `slug`, `mode`. `api_url` is connected-mode only. Optional `boundary { posture, read[], locked }` sets the org's isolation posture (see Environment Isolation). **Never put secrets here.**
122
122
  - **`.env`** — gitignored. Personal secrets. Local mode: `GITHUB_TOKEN` only. Connected mode: `GITHUB_TOKEN` + `EGREGORE_API_KEY`. **Never use `source .env`** — use `grep '^KEY=' .env | cut -d'=' -f2-`.
123
123
 
124
124
  In connected mode, infrastructure credentials (Neo4j, Telegram) live on the API server only — `bin/graph.sh` and `bin/notify.sh` route through the API gateway.
@@ -207,6 +207,10 @@ main ← stable (/release)
207
207
  - **Memory repo**: stays on main (separate repo, auto-merge).
208
208
  - **Never push directly to main or develop.** All changes flow through PRs.
209
209
 
210
+ ### Pull request format (all harnesses)
211
+
212
+ Every PR body follows `.claude/context/pr-format.md`, enforced by the `pr-format` CI check regardless of which harness opened it: `## What` (1–4 bullets) + `## Why` (1–3 sentences) always; `## Verification` when the diff touches non-markdown files (how it was checked, or an honest `Not verified — <reason>`); `## Risk`/`## Links` when real; title `type(scope): imperative summary` (advisory). **Never create a PR with an empty body or `--fill`** — write the body and pass it explicitly (`gh pr create --body`, or `bin/agent.sh save --pr-body` for shell agents; the bridge auto-generates a compliant skeleton only as a last resort).
213
+
210
214
  ### Managed Repos
211
215
 
212
216
  Repos in `egregore.json` → `repos[]` are cloned as siblings (`../{repo}/`). Each entry can be a string or `{"name": "...", "description": "..."}`. Match user intent to the right repo using `description`. Same branching strategy. Use `git -C` with absolute paths — never `cd` into repos. `/save` scans all managed repos for uncommitted changes.
@@ -275,17 +279,20 @@ Egregore runs in one of two configurations, set by `mode` in `egregore.json`. De
275
279
 
276
280
  ## Environment Isolation
277
281
 
278
- Sessions are confined to this project + memory + managed repos. On Pi this boundary is a standing instruction, not an enforced hook — hold it yourself.
282
+ Sessions are confined to this project + memory + managed repos, with a **two-tier boundary** — a hard wall between Egregore instances, a consent gate for everything else. On Pi the boundary is a standing instruction, not an enforced hook — hold it yourself.
279
283
 
280
- - **Never modify `~/.egregore/instances.json`** — managed by session-start.sh
281
- - **Never access another instance's files** — refuse even if asked
282
- - When a request points outside the boundary, do not improvise a workaround. Ask in plain text with numbered options:
284
+ - **Hard tier — other Egregore instances.** Denied for every tool, always. There is no consent path. Never access another instance's files — refuse even if asked. Never modify `~/.egregore/instances.json` (managed by session-start.sh). When a request points at another instance's files there is nothing to ask — refuse and explain.
285
+ - **Soft tier — paths outside the boundary.** Consent-gated. Inbox dirs (`~/Downloads`, `~/Desktop`) are readable without consent under the default posture; writes outside the project always need consent. Posture (`strict | standard | open`) and extra read roots come from `egregore.json` -> `boundary { posture, read[], locked }` (org, committed) merged with `.egregore-boundary.local.json` (personal, gitignored). `locked: true` removes the consent path entirely.
286
+ - When a request needs soft-tier consent, do not improvise a workaround. Ask in plain text with numbered options:
283
287
 
284
288
  ```text
285
289
  That path is outside this Egregore's boundary. How should we proceed?
286
- 1. Paste the contents inline
287
- 2. Move the file into the repo and point me at the new path
288
- 3. Cancel
290
+ 1. Allow its directory for this session (recorded in .egregore-boundary-consent)
291
+ 2. Always allow on this instance (added to read[] in .egregore-boundary.local.json)
292
+ 3. Paste the contents inline
293
+ 4. Cancel
289
294
  ```
290
295
 
291
- See DEVELOPMENT.md §3 for boundary details.
296
+ Never record a consent grant without the user's explicit choice of option 1 or 2 in that exchange. Session grants are cleared on session start; `locked: true` removes options 1 and 2.
297
+
298
+ See DEVELOPMENT.md §3 for boundary details and `memory/knowledge/decisions/2026-07-08-boundary-hook-consent-design.md` for the design decisions.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "generatedBy": "bin/pi-render-spec.mjs",
3
3
  "source": "AGENTS.md generated Codex spec",
4
- "sourceHash": "e0336f66e9b4",
5
- "outputHash": "da44541a50fb",
4
+ "sourceHash": "6ad94ccc38d8",
5
+ "outputHash": "7a3db92a611e",
6
6
  "adaptations": [
7
7
  "Pi project trust and APPEND_SYSTEM delivery",
8
8
  "Pi tool_call branch gate",
@@ -35,7 +35,8 @@ Use the runtime-neutral bridge instead of Claude Code slash commands:
35
35
 
36
36
  ```bash
37
37
  bin/agent.sh branch --topic "auth review"
38
- bin/agent.sh save --message "Save: auth review" --topic "auth review"
38
+ bin/agent.sh save --message "Save: auth review" --topic "auth review" \
39
+ --pr-body "$PR_BODY" # body per .claude/context/pr-format.md (auto-skeleton if omitted)
39
40
  bin/agent.sh wrap --from alice --topic "auth review" \
40
41
  --summary "Implemented the OAuth callback parser and documented follow-ups." \
41
42
  --body "Open threads: review error cases and browser redirects."
@@ -191,7 +192,7 @@ If the startup card output contains `onboarding_needed`, invoke the `$onboarding
191
192
 
192
193
  ## Config Files
193
194
 
194
- - **`egregore.json`** — committed. Non-secret org config: `org_name`, `github_org`, `memory_repo`, `slug`, `mode`. `api_url` is connected-mode only. **Never put secrets here.**
195
+ - **`egregore.json`** — committed. Non-secret org config: `org_name`, `github_org`, `memory_repo`, `slug`, `mode`. `api_url` is connected-mode only. Optional `boundary { posture, read[], locked }` sets the org's isolation posture (see Environment Isolation). **Never put secrets here.**
195
196
  - **`.env`** — gitignored. Personal secrets. Local mode: `GITHUB_TOKEN` only. Connected mode: `GITHUB_TOKEN` + `EGREGORE_API_KEY`. **Never use `source .env`** — use `grep '^KEY=' .env | cut -d'=' -f2-`.
196
197
 
197
198
  In connected mode, infrastructure credentials (Neo4j, Telegram) live on the API server only — `bin/graph.sh` and `bin/notify.sh` route through the API gateway.
@@ -280,6 +281,10 @@ main ← stable (/release)
280
281
  - **Memory repo**: stays on main (separate repo, auto-merge).
281
282
  - **Never push directly to main or develop.** All changes flow through PRs.
282
283
 
284
+ ### Pull request format (all harnesses)
285
+
286
+ Every PR body follows `.claude/context/pr-format.md`, enforced by the `pr-format` CI check regardless of which harness opened it: `## What` (1–4 bullets) + `## Why` (1–3 sentences) always; `## Verification` when the diff touches non-markdown files (how it was checked, or an honest `Not verified — <reason>`); `## Risk`/`## Links` when real; title `type(scope): imperative summary` (advisory). **Never create a PR with an empty body or `--fill`** — write the body and pass it explicitly (`gh pr create --body`, or `bin/agent.sh save --pr-body` for shell agents; the bridge auto-generates a compliant skeleton only as a last resort).
287
+
283
288
  ### Managed Repos
284
289
 
285
290
  Repos in `egregore.json` → `repos[]` are cloned as siblings (`../{repo}/`). Each entry can be a string or `{"name": "...", "description": "..."}`. Match user intent to the right repo using `description`. Same branching strategy. Use `git -C` with absolute paths — never `cd` into repos. `$save` scans all managed repos for uncommitted changes.
@@ -368,19 +373,22 @@ Egregore runs in one of two configurations, set by `mode` in `egregore.json`. De
368
373
 
369
374
  ## Environment Isolation
370
375
 
371
- Sessions are confined to this project + memory + managed repos. On Codex this boundary is a standing instruction, not an enforced hook — hold it yourself.
376
+ Sessions are confined to this project + memory + managed repos, with a **two-tier boundary** — a hard wall between Egregore instances, a consent gate for everything else. On Codex the boundary is a standing instruction, not an enforced hook — hold it yourself.
372
377
 
373
- - **Never modify `~/.egregore/instances.json`** — managed by session-start.sh
374
- - **Never access another instance's files** — refuse even if asked
375
- - When a request points outside the boundary, do not improvise a workaround. Ask in plain text with numbered options:
378
+ - **Hard tier — other Egregore instances.** Denied for every tool, always. There is no consent path. Never access another instance's files — refuse even if asked. Never modify `~/.egregore/instances.json` (managed by session-start.sh). When a request points at another instance's files there is nothing to ask — refuse and explain.
379
+ - **Soft tier — paths outside the boundary.** Consent-gated. Inbox dirs (`~/Downloads`, `~/Desktop`) are readable without consent under the default posture; writes outside the project always need consent. Posture (`strict | standard | open`) and extra read roots come from `egregore.json` -> `boundary { posture, read[], locked }` (org, committed) merged with `.egregore-boundary.local.json` (personal, gitignored). `locked: true` removes the consent path entirely.
380
+ - When a request needs soft-tier consent, do not improvise a workaround. Ask in plain text with numbered options:
376
381
 
377
382
  ```text
378
383
  That path is outside this Egregore's boundary. How should we proceed?
379
- 1. Paste the contents inline
380
- 2. Move the file into the repo and point me at the new path
381
- 3. Cancel
384
+ 1. Allow its directory for this session (recorded in .egregore-boundary-consent)
385
+ 2. Always allow on this instance (added to read[] in .egregore-boundary.local.json)
386
+ 3. Paste the contents inline
387
+ 4. Cancel
382
388
  ```
383
389
 
384
- See DEVELOPMENT.md §3 for boundary details.
390
+ Never record a consent grant without the user's explicit choice of option 1 or 2 in that exchange. Session grants are cleared on session start; `locked: true` removes options 1 and 2.
391
+
392
+ See DEVELOPMENT.md §3 for boundary details and `memory/knowledge/decisions/2026-07-08-boundary-hook-consent-design.md` for the design decisions.
385
393
 
386
394
  <!-- END GENERATED EGREGORE CODEX SPEC -->
@@ -32,7 +32,10 @@ Runtime-neutral commands:
32
32
  Hybrid recall over shared memory (qmd)
33
33
  people List known people from memory/people
34
34
  branch --topic TEXT Create or reuse a task branch/worktree
35
- save [--message TEXT] Commit and push current repo + memory changes
35
+ save [--message TEXT] [--pr-body TEXT|--pr-body-file PATH]
36
+ Commit and push current repo + memory changes;
37
+ PR body follows .claude/context/pr-format.md
38
+ (auto-generated skeleton if not provided)
36
39
  wrap --topic T --summary TEXT Write a session wrap to memory/wraps
37
40
  handoff --from A --to B --topic T [--intent action|feedback|fyi]
38
41
  [--body TEXT|--body-file PATH] [--no-push] [--no-publish] [--no-notify] [--json]
@@ -332,13 +335,26 @@ ensure_working_branch() {
332
335
  echo "$branch"
333
336
  }
334
337
 
338
+ build_pr_body() {
339
+ # Compliant fallback PR body (spec: .claude/context/pr-format.md) built
340
+ # from the commit log + diff when the calling agent supplies none.
341
+ local topic="${1:-portable save}" commits stat
342
+ commits="$(git -C "$SCRIPT_DIR" log --pretty='- %s' "origin/develop..HEAD" 2>/dev/null | head -8)"
343
+ [ -n "$commits" ] || commits="- (commit list unavailable — see the Files tab)"
344
+ stat="$(git -C "$SCRIPT_DIR" diff --shortstat "origin/develop...HEAD" 2>/dev/null | sed 's/^ *//')"
345
+ printf '## What\n%s\n\n## Why\nShell-agent session save — topic: %s. Body auto-generated by bin/agent.sh; pass --pr-body for a hand-written description.\n\n## Verification\nNot verified — auto-generated skeleton; review the diff before merging.%s\n\n🤖 Saved via bin/agent.sh\n' \
346
+ "$commits" "$topic" "${stat:+ ($stat)}"
347
+ }
348
+
335
349
  cmd_save() {
336
- local message="" topic="" no_pr=0 branch pushed="false" committed="false" pr_url="" existing_pr="" pr_number="" non_md="" merged="false"
350
+ local message="" topic="" no_pr=0 branch pushed="false" committed="false" pr_url="" existing_pr="" pr_number="" non_md="" merged="false" pr_body="" pr_body_file=""
337
351
 
338
352
  while [ $# -gt 0 ]; do
339
353
  case "$1" in
340
354
  --message|-m) message="${2:-}"; shift 2 ;;
341
355
  --topic) topic="${2:-}"; shift 2 ;;
356
+ --pr-body) pr_body="${2:-}"; shift 2 ;;
357
+ --pr-body-file) pr_body_file="${2:-}"; shift 2 ;;
342
358
  --no-pr) no_pr=1; shift ;;
343
359
  *) die "unknown save option: $1" ;;
344
360
  esac
@@ -376,7 +392,11 @@ cmd_save() {
376
392
  if [ -n "$existing_pr" ]; then
377
393
  pr_url="$existing_pr"
378
394
  else
379
- pr_url="$(gh pr create --base develop --head "$branch" --title "$message" --body "Saved via bin/agent.sh save." 2>/dev/null || true)"
395
+ if [ -z "$pr_body" ] && [ -n "$pr_body_file" ] && [ -f "$pr_body_file" ]; then
396
+ pr_body="$(cat "$pr_body_file")"
397
+ fi
398
+ [ -n "$pr_body" ] || pr_body="$(build_pr_body "$topic")"
399
+ pr_url="$(gh pr create --base develop --head "$branch" --title "$message" --body "$pr_body" 2>/dev/null || true)"
380
400
  fi
381
401
  fi
382
402
 
@@ -79,7 +79,8 @@ Use the runtime-neutral bridge instead of Claude Code slash commands:
79
79
 
80
80
  \`\`\`bash
81
81
  bin/agent.sh branch --topic "auth review"
82
- bin/agent.sh save --message "Save: auth review" --topic "auth review"
82
+ bin/agent.sh save --message "Save: auth review" --topic "auth review" \\
83
+ --pr-body "$PR_BODY" # body per .claude/context/pr-format.md (auto-skeleton if omitted)
83
84
  bin/agent.sh wrap --from alice --topic "auth review" \\
84
85
  --summary "Implemented the OAuth callback parser and documented follow-ups." \\
85
86
  --body "Open threads: review error cases and browser redirects."
@@ -251,20 +252,23 @@ Codex has no structured question tool — render each batch as compact numbered
251
252
 
252
253
  const LOOM_ROUTING_BODY = `Loom routes commands across model tiers on the Claude Code runtime (\`loom/routes.json\` + \`bin/loom.sh\` + a model-pinned executor subagent). Codex has no subagent delegation — every command runs inline in the current session. Ignore "Loom routing" preambles if you encounter them in a skill spec, and skip \`bin/loom.sh\` calls; \`loom/\` and \`.claude/agents/\` are framework files synced for the Claude runtime.`;
253
254
 
254
- const ISOLATION_BODY = `Sessions are confined to this project + memory + managed repos. On Codex this boundary is a standing instruction, not an enforced hook — hold it yourself.
255
+ const ISOLATION_BODY = `Sessions are confined to this project + memory + managed repos, with a **two-tier boundary** — a hard wall between Egregore instances, a consent gate for everything else. On Codex the boundary is a standing instruction, not an enforced hook — hold it yourself.
255
256
 
256
- - **Never modify \`~/.egregore/instances.json\`** — managed by session-start.sh
257
- - **Never access another instance's files** — refuse even if asked
258
- - When a request points outside the boundary, do not improvise a workaround. Ask in plain text with numbered options:
257
+ - **Hard tier — other Egregore instances.** Denied for every tool, always. There is no consent path. Never access another instance's files — refuse even if asked. Never modify \`~/.egregore/instances.json\` (managed by session-start.sh). When a request points at another instance's files there is nothing to ask — refuse and explain.
258
+ - **Soft tier — paths outside the boundary.** Consent-gated. Inbox dirs (\`~/Downloads\`, \`~/Desktop\`) are readable without consent under the default posture; writes outside the project always need consent. Posture (\`strict | standard | open\`) and extra read roots come from \`egregore.json\` -> \`boundary { posture, read[], locked }\` (org, committed) merged with \`.egregore-boundary.local.json\` (personal, gitignored). \`locked: true\` removes the consent path entirely.
259
+ - When a request needs soft-tier consent, do not improvise a workaround. Ask in plain text with numbered options:
259
260
 
260
261
  \`\`\`text
261
262
  That path is outside this Egregore's boundary. How should we proceed?
262
- 1. Paste the contents inline
263
- 2. Move the file into the repo and point me at the new path
264
- 3. Cancel
263
+ 1. Allow its directory for this session (recorded in .egregore-boundary-consent)
264
+ 2. Always allow on this instance (added to read[] in .egregore-boundary.local.json)
265
+ 3. Paste the contents inline
266
+ 4. Cancel
265
267
  \`\`\`
266
268
 
267
- See DEVELOPMENT.md §3 for boundary details.`;
269
+ Never record a consent grant without the user's explicit choice of option 1 or 2 in that exchange. Session grants are cleared on session start; \`locked: true\` removes options 1 and 2.
270
+
271
+ See DEVELOPMENT.md §3 for boundary details and \`memory/knowledge/decisions/2026-07-08-boundary-hook-consent-design.md\` for the design decisions.`;
268
272
 
269
273
  // ── per-section rules ────────────────────────────────────────────────────
270
274
  const RULES = {
@@ -326,7 +330,7 @@ const RULES = {
326
330
  'Environment Isolation': {
327
331
  action: 'replace',
328
332
  body: ISOLATION_BODY,
329
- note: 'boundary-check is a Claude PreToolUse hook with no .codex equivalent — behavior stated as a standing instruction; AskUserQuestion remediation -> numbered options.',
333
+ note: 'boundary-check is a Claude PreToolUse hook with no .codex equivalent — two-tier model stated as a standing instruction; AskUserQuestion remediation -> numbered options, now including the two consent-grant options (session grant -> .egregore-boundary-consent, instance grant -> .egregore-boundary.local.json read[]).',
330
334
  },
331
335
  };
332
336
 
@@ -6,7 +6,7 @@ set -o pipefail
6
6
  # libraries as Claude's SessionStart, then renders a terminal greeting before
7
7
  # Codex itself starts.
8
8
 
9
- FRAMEWORK_VERSION="6"
9
+ FRAMEWORK_VERSION="7"
10
10
  SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
11
11
  cd "$SCRIPT_DIR" || exit 1
12
12
 
@@ -21,6 +21,9 @@ fi
21
21
  # Clear any branch-guard consent from a previous session — consent to write on
22
22
  # a protected branch is asked fresh each session (see AGENTS.md branch-guard protocol).
23
23
  rm -f "$SCRIPT_DIR/.egregore-branch-consent" "$MAIN_PROJECT_DIR/.egregore-branch-consent" 2>/dev/null
24
+ # Same for boundary-crossing consent — grants are session-scoped (AGENTS.md
25
+ # environment-isolation protocol); durable grants belong in .egregore-boundary.local.json.
26
+ rm -f "$SCRIPT_DIR/.egregore-boundary-consent" "$MAIN_PROJECT_DIR/.egregore-boundary-consent" 2>/dev/null
24
27
 
25
28
  if [ -f "$SCRIPT_DIR/bin/lib/worktree-links.sh" ]; then
26
29
  source "$SCRIPT_DIR/bin/lib/worktree-links.sh" >/dev/null 2>/dev/null || true