create-egregore 0.19.11 → 0.19.13

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.19.13
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,25 @@ 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.19.13";
35
37
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
36
38
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
39
+ const UPDATE_CACHE_FILE = path.join(os.homedir(), ".egregore", "update-check.json");
37
40
  const PUBLIC_NPX_PACKAGE = "create-egregore";
41
+ const UPDATE_REGISTRY_URL =
42
+ process.env.EGREGORE_UPDATE_REGISTRY_URL ||
43
+ `https://registry.npmjs.org/${PUBLIC_NPX_PACKAGE}/latest`;
44
+ const UPDATE_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
45
+ const UPDATE_FAILURE_TTL_MS = 30 * 60 * 1000;
46
+ const UPDATE_CHECK_TIMEOUT_MS = 1500;
47
+ const CONNECT_API_URL = (
48
+ process.env.EGREGORE_CONNECT_API_URL ||
49
+ "https://egregore-production-55f2.up.railway.app"
50
+ ).replace(/\/+$/, "");
38
51
 
39
52
  const NATIVE_CODEX_SKILLS = [
40
53
  "activity",
@@ -864,6 +877,7 @@ function cmdHelp() {
864
877
  ` ${CYAN}egregore browse${RESET} force the full-screen browser`,
865
878
  ` ${CYAN}egregore ls${RESET} ${DIM}| list${RESET} list installed instances`,
866
879
  ` ${CYAN}egregore use${RESET} ${DIM}<slug>${RESET} set the default for bare ${BOLD}egregore${RESET}`,
880
+ ` ${CYAN}egregore connect${RESET} ${DIM}[slug]${RESET} activate Connected for an existing instance`,
867
881
  ` ${CYAN}egregore /invite${RESET} ${DIM}<user> [slug]${RESET} invite a GitHub user`,
868
882
  ` ${CYAN}egregore settings${RESET} ${DIM}[slug]${RESET} hosting on/off, repos, admins`,
869
883
  ` ${CYAN}egregore prune${RESET} ${DIM}[--dry-run]${RESET} remove entries with missing paths`,
@@ -874,6 +888,7 @@ function cmdHelp() {
874
888
  ` ${DIM}enter${RESET} choose selected instance`,
875
889
  ` ${DIM}/${RESET} filter (type to narrow; esc to clear)`,
876
890
  ` ${DIM}n / +${RESET} create a new Egregore`,
891
+ ` ${DIM}c${RESET} connect selected instance`,
877
892
  ` ${DIM}u${RESET} set selected as default`,
878
893
  ` ${DIM}i${RESET} invite a GitHub user to selected instance`,
879
894
  ` ${DIM}s${RESET} settings (hosting on/off, repos, admins)`,
@@ -968,11 +983,145 @@ function requestJson(options, body) {
968
983
  });
969
984
  });
970
985
  req.on("error", (err) => resolve({ status: 0, data: { message: err.message } }));
986
+ if (options.unref) {
987
+ req.on("socket", (socket) => socket.unref());
988
+ }
989
+ if (options.timeoutMs) {
990
+ req.setTimeout(options.timeoutMs, () => req.destroy(new Error("request timed out")));
991
+ }
971
992
  if (payload) req.write(payload);
972
993
  req.end();
973
994
  });
974
995
  }
975
996
 
997
+ function parseReleaseVersion(value) {
998
+ const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
999
+ if (!match) return null;
1000
+ return {
1001
+ parts: match.slice(1, 4).map(Number),
1002
+ prerelease: match[4] || "",
1003
+ };
1004
+ }
1005
+
1006
+ function compareReleaseVersions(a, b) {
1007
+ const left = parseReleaseVersion(a);
1008
+ const right = parseReleaseVersion(b);
1009
+ if (!left || !right) return 0;
1010
+ for (let i = 0; i < 3; i++) {
1011
+ if (left.parts[i] !== right.parts[i]) return left.parts[i] > right.parts[i] ? 1 : -1;
1012
+ }
1013
+ if (left.prerelease === right.prerelease) return 0;
1014
+ if (!left.prerelease) return 1;
1015
+ if (!right.prerelease) return -1;
1016
+ return left.prerelease.localeCompare(right.prerelease);
1017
+ }
1018
+
1019
+ function cliUpdateState(latest) {
1020
+ if (!parseReleaseVersion(latest)) return null;
1021
+ return {
1022
+ current: VERSION,
1023
+ latest,
1024
+ available: compareReleaseVersions(latest, VERSION) > 0,
1025
+ };
1026
+ }
1027
+
1028
+ function readUpdateCache() {
1029
+ try {
1030
+ const cache = JSON.parse(fs.readFileSync(UPDATE_CACHE_FILE, "utf-8"));
1031
+ if (!cache || !cache.checked_at) return null;
1032
+ const age = Date.now() - new Date(cache.checked_at).getTime();
1033
+ const ttl = cache.latest ? UPDATE_CACHE_TTL_MS : UPDATE_FAILURE_TTL_MS;
1034
+ if (!Number.isFinite(age) || age < 0 || age > ttl) return null;
1035
+ if (!cache.latest) return { current: VERSION, latest: null, available: false };
1036
+ return cliUpdateState(cache.latest);
1037
+ } catch {
1038
+ return null;
1039
+ }
1040
+ }
1041
+
1042
+ function writeUpdateCache(latest) {
1043
+ try {
1044
+ fs.mkdirSync(path.dirname(UPDATE_CACHE_FILE), { recursive: true });
1045
+ atomicWrite(
1046
+ UPDATE_CACHE_FILE,
1047
+ JSON.stringify({ checked_at: new Date().toISOString(), latest: latest || null }, null, 2) + "\n",
1048
+ 0o600,
1049
+ );
1050
+ } catch {}
1051
+ }
1052
+
1053
+ async function checkForCliUpdate() {
1054
+ if (process.env.EGREGORE_DISABLE_UPDATE_CHECK === "1") return null;
1055
+ if (process.env.EGREGORE_UPDATE_LATEST_VERSION) {
1056
+ return cliUpdateState(process.env.EGREGORE_UPDATE_LATEST_VERSION);
1057
+ }
1058
+
1059
+ const cached = readUpdateCache();
1060
+ if (cached) return cached;
1061
+
1062
+ try {
1063
+ const url = new URL(UPDATE_REGISTRY_URL);
1064
+ const result = await requestJson({
1065
+ hostname: url.hostname,
1066
+ port: url.port || undefined,
1067
+ path: `${url.pathname}${url.search}`,
1068
+ method: "GET",
1069
+ protocol: url.protocol,
1070
+ timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
1071
+ unref: true,
1072
+ headers: { Accept: "application/json" },
1073
+ });
1074
+ const latest = result.status === 200 && result.data && result.data.version;
1075
+ const update = cliUpdateState(latest);
1076
+ writeUpdateCache(update?.latest || null);
1077
+ return update;
1078
+ } catch {
1079
+ return null;
1080
+ }
1081
+ }
1082
+
1083
+ function cmdCliUpdate(latest) {
1084
+ if (!parseReleaseVersion(latest)) {
1085
+ console.error(`\n ${YELLOW}!${RESET} Could not verify the CLI release version.\n`);
1086
+ process.exitCode = 1;
1087
+ return;
1088
+ }
1089
+
1090
+ console.log("");
1091
+ console.log(` ${BOLD}${CYAN}↑ CLI UPDATE${RESET}`);
1092
+ console.log(` ${DIM}${VERSION} → ${latest}${RESET}`);
1093
+ console.log("");
1094
+ process.stdout.write(` [1/2] Installing ${PUBLIC_NPX_PACKAGE}@${latest}…`);
1095
+ const result = spawnSync(
1096
+ "npx",
1097
+ ["-y", `${PUBLIC_NPX_PACKAGE}@${latest}`, "--refresh-launcher"],
1098
+ { encoding: "utf-8", stdio: ["inherit", "pipe", "pipe"], env: { ...process.env } },
1099
+ );
1100
+ if (result.status !== 0) {
1101
+ console.log(`\r [1/2] ${YELLOW}!${RESET} Install failed`);
1102
+ const detail = String(result.stderr || result.stdout || "").trim().split("\n").filter(Boolean).pop();
1103
+ if (detail) console.error(` ${DIM}${detail}${RESET}`);
1104
+ console.error(` Retry: npx -y ${PUBLIC_NPX_PACKAGE}@${latest} --refresh-launcher\n`);
1105
+ process.exitCode = 1;
1106
+ return;
1107
+ }
1108
+ console.log(`\r [1/2] ${GREEN}✓${RESET} CLI ${latest} installed`);
1109
+
1110
+ if (process.env.EGREGORE_UPDATE_NO_RELAUNCH === "1") {
1111
+ console.log(` [2/2] ${GREEN}✓${RESET} Ready for the next launch\n`);
1112
+ return;
1113
+ }
1114
+
1115
+ console.log(` [2/2] ${GREEN}✓${RESET} Reopening Egregore\n`);
1116
+ const relaunched = spawnSync(process.execPath, [process.argv[1], "browse"], {
1117
+ stdio: "inherit",
1118
+ env: { ...process.env },
1119
+ });
1120
+ if (relaunched.error) {
1121
+ console.error(` ${YELLOW}!${RESET} Updated successfully. Run ${BOLD}egregore${RESET} to reopen.\n`);
1122
+ }
1123
+ }
1124
+
976
1125
  function githubRequest(token, method, apiPath, body) {
977
1126
  return requestJson({
978
1127
  hostname: "api.github.com",
@@ -994,6 +1143,252 @@ function apiRequest(apiUrl, apiKey, apiPath, body) {
994
1143
  }, body);
995
1144
  }
996
1145
 
1146
+ function connectedApiRequest(githubToken, method, apiPath, body, headers = {}) {
1147
+ const url = new URL(apiPath, CONNECT_API_URL + "/");
1148
+ return requestJson({
1149
+ hostname: url.hostname,
1150
+ port: url.port || undefined,
1151
+ path: `${url.pathname}${url.search}`,
1152
+ method,
1153
+ protocol: url.protocol,
1154
+ headers: {
1155
+ Authorization: `Bearer ${githubToken}`,
1156
+ ...headers,
1157
+ },
1158
+ }, body);
1159
+ }
1160
+
1161
+ function openExternalUrl(url) {
1162
+ try {
1163
+ if (process.platform === "darwin") {
1164
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
1165
+ } else if (process.platform === "win32") {
1166
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
1167
+ } else {
1168
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
1169
+ }
1170
+ return true;
1171
+ } catch {
1172
+ return false;
1173
+ }
1174
+ }
1175
+
1176
+ function atomicWrite(file, content, mode) {
1177
+ const temp = path.join(
1178
+ path.dirname(file),
1179
+ `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`,
1180
+ );
1181
+ fs.writeFileSync(temp, content, { mode });
1182
+ fs.renameSync(temp, file);
1183
+ }
1184
+
1185
+ function writeJsonFileAtomic(file, value) {
1186
+ atomicWrite(file, JSON.stringify(value, null, 2) + "\n", 0o644);
1187
+ }
1188
+
1189
+ function writeEnvValueAtomic(file, key, value) {
1190
+ const lines = fs.existsSync(file)
1191
+ ? fs.readFileSync(file, "utf-8").split(/\r?\n/)
1192
+ : [];
1193
+ let replaced = false;
1194
+ const next = lines
1195
+ .filter((line, index) => !(index === lines.length - 1 && line === ""))
1196
+ .map((line) => {
1197
+ if (!line.startsWith(`${key}=`)) return line;
1198
+ replaced = true;
1199
+ return `${key}=${value}`;
1200
+ });
1201
+ if (!replaced) next.push(`${key}=${value}`);
1202
+ atomicWrite(file, next.join("\n") + "\n", 0o600);
1203
+ }
1204
+
1205
+ function persistConnectState(inst, fields) {
1206
+ const file = path.join(inst.path, ".egregore-state.json");
1207
+ const state = readJsonFile(file);
1208
+ state.connected_activation = {
1209
+ ...(state.connected_activation || {}),
1210
+ ...fields,
1211
+ updated_at: new Date().toISOString(),
1212
+ };
1213
+ writeJsonFileAtomic(file, state);
1214
+ }
1215
+
1216
+ function connectError(result, fallback) {
1217
+ if (!result) return fallback;
1218
+ const data = result.data || {};
1219
+ return data.detail || data.message || (typeof data === "string" ? data : fallback);
1220
+ }
1221
+
1222
+ function sleep(ms) {
1223
+ return new Promise((resolve) => setTimeout(resolve, ms));
1224
+ }
1225
+
1226
+ async function cmdConnect(args) {
1227
+ const registry = readRegistry();
1228
+ if (registry.length === 0) die("no egregores installed yet.\n run: npx -y create-egregore@latest");
1229
+ const inst = resolveInviteInstance(registry, args[0] || "");
1230
+ if (!inst) {
1231
+ die("multiple egregores installed; choose one:\n egregore connect <slug>\n run: egregore ls");
1232
+ }
1233
+ if (!fs.existsSync(inst.path)) die(`instance path is missing: ${inst.path}`);
1234
+
1235
+ const { cfg, state, env } = configForInstance(inst);
1236
+ const githubToken = env.GITHUB_TOKEN || "";
1237
+ if (!githubToken) {
1238
+ die(`GITHUB_TOKEN is missing for ${inst.slug}.\n Add it to ${path.join(inst.path, ".env")} and retry.`);
1239
+ }
1240
+ const activation = state.connected_activation || {};
1241
+ if (cfg.mode === "connected" && activation.status === "connected") {
1242
+ console.log(`\n ${GREEN}✓${RESET} ${BOLD}${inst.slug}${RESET} is already connected.\n`);
1243
+ return;
1244
+ }
1245
+
1246
+ console.log("");
1247
+ console.log(` ${BOLD}CONNECT${RESET} ${DIM}${inst.slug}${RESET}`);
1248
+ console.log(` ${DIM}€500/month · Stripe checkout · existing Markdown and Git stay in place${RESET}`);
1249
+ console.log("");
1250
+
1251
+ let intentId = activation.intent_id || "";
1252
+ let connectUrl = activation.connect_url || "";
1253
+ if (!intentId || !["checkout_pending", "entitled", "backfilling"].includes(activation.status)) {
1254
+ console.log(` [1/5] ${DIM}Creating connection intent…${RESET}`);
1255
+ const checkout = await connectedApiRequest(
1256
+ githubToken,
1257
+ "POST",
1258
+ "/api/billing/checkout",
1259
+ {
1260
+ org_slug: cfg.slug || inst.slug,
1261
+ plan: "connect",
1262
+ instance_metadata: {
1263
+ launcher_version: VERSION,
1264
+ mode: cfg.mode || "local",
1265
+ repo_name: cfg.repo_name || "",
1266
+ managed_repo_count: Array.isArray(cfg.repos) ? cfg.repos.length : 0,
1267
+ },
1268
+ },
1269
+ { "X-Idempotency-Key": `cli:${inst.slug}:${crypto.randomUUID()}` },
1270
+ );
1271
+ if (checkout.status !== 200) {
1272
+ die(`could not start Connected checkout: ${connectError(checkout, `HTTP ${checkout.status}`)}`);
1273
+ }
1274
+ intentId = checkout.data.connection_intent_id;
1275
+ connectUrl = checkout.data.connect_url;
1276
+ persistConnectState(inst, {
1277
+ intent_id: intentId,
1278
+ checkout_session_id: checkout.data.checkout_session_id,
1279
+ connect_url: connectUrl,
1280
+ status: "checkout_pending",
1281
+ });
1282
+ console.log(` [1/5] ${GREEN}✓${RESET} Connection intent`);
1283
+ } else {
1284
+ console.log(` [1/5] ${GREEN}✓${RESET} Resuming connection intent`);
1285
+ }
1286
+
1287
+ if (activation.status !== "backfilling") {
1288
+ if (connectUrl) {
1289
+ const opened = openExternalUrl(connectUrl);
1290
+ const mark = opened ? `${GREEN}✓${RESET}` : `${YELLOW}!${RESET}`;
1291
+ console.log(` [2/5] ${mark} ${opened ? "Opened" : "Open"} Egregore Connect`);
1292
+ console.log(` ${CYAN}${connectUrl}${RESET}`);
1293
+ }
1294
+ console.log(` ${DIM}Complete checkout in the browser. Promotion codes are entered in Stripe.${RESET}`);
1295
+ } else {
1296
+ console.log(` [2/5] ${GREEN}✓${RESET} Subscription already confirmed`);
1297
+ }
1298
+
1299
+ const pollMs = Math.max(100, Number(process.env.EGREGORE_CONNECT_POLL_INTERVAL_MS || 2000));
1300
+ const timeoutMs = Math.max(pollMs, Number(process.env.EGREGORE_CONNECT_TIMEOUT_MS || 30 * 60 * 1000));
1301
+ const deadline = Date.now() + timeoutMs;
1302
+ let status;
1303
+ while (Date.now() < deadline) {
1304
+ const result = await connectedApiRequest(
1305
+ githubToken,
1306
+ "GET",
1307
+ `/api/billing/connect/${encodeURIComponent(intentId)}`,
1308
+ );
1309
+ if (result.status === 200) {
1310
+ status = result.data;
1311
+ if (
1312
+ status.entitled ||
1313
+ ["entitled", "provisioning", "backfilling", "connected"].includes(status.status)
1314
+ ) break;
1315
+ if (["canceled", "failed"].includes(status.status)) {
1316
+ persistConnectState(inst, { status: status.status });
1317
+ die(`Connected checkout ${status.status}. Your local Egregore was not changed.`);
1318
+ }
1319
+ } else if (result.status !== 0) {
1320
+ die(`could not read Connected status: ${connectError(result, `HTTP ${result.status}`)}`);
1321
+ }
1322
+ await sleep(pollMs);
1323
+ }
1324
+ if (!status || !(status.entitled || ["entitled", "provisioning", "backfilling", "connected"].includes(status.status))) {
1325
+ die("timed out waiting for checkout. Run the same command to resume.");
1326
+ }
1327
+ console.log(` [3/5] ${GREEN}✓${RESET} Subscription confirmed`);
1328
+ persistConnectState(inst, { status: "entitled" });
1329
+
1330
+ const claim = await connectedApiRequest(
1331
+ githubToken,
1332
+ "POST",
1333
+ `/api/billing/connect/${encodeURIComponent(intentId)}/claim`,
1334
+ {},
1335
+ );
1336
+ if (claim.status !== 200) {
1337
+ die(`could not activate Connected infrastructure: ${connectError(claim, `HTTP ${claim.status}`)}`);
1338
+ }
1339
+
1340
+ const configFile = path.join(inst.path, "egregore.json");
1341
+ const envFile = path.join(inst.path, ".env");
1342
+ const currentConfig = readJsonFile(configFile);
1343
+ writeJsonFileAtomic(configFile, {
1344
+ ...currentConfig,
1345
+ mode: "connected",
1346
+ api_url: claim.data.api_url,
1347
+ });
1348
+ writeEnvValueAtomic(envFile, "EGREGORE_API_KEY", claim.data.api_key);
1349
+ persistConnectState(inst, {
1350
+ status: "backfilling",
1351
+ activation_protocol: claim.data.activation_protocol,
1352
+ });
1353
+ console.log(` [4/5] ${GREEN}✓${RESET} Connected credential and graph node`);
1354
+
1355
+ const syncScript = path.join(inst.path, "bin", "sync-graph.sh");
1356
+ if (!fs.existsSync(syncScript)) {
1357
+ die("bin/sync-graph.sh is missing. Update the installed Egregore version, then run egregore connect again.");
1358
+ }
1359
+ const sync = spawnSync("bash", [syncScript], {
1360
+ cwd: inst.path,
1361
+ encoding: "utf-8",
1362
+ stdio: "pipe",
1363
+ timeout: 10 * 60 * 1000,
1364
+ });
1365
+ if (sync.status !== 0) {
1366
+ const detail = (sync.stderr || sync.stdout || "graph replay failed").trim();
1367
+ die(`Connected is configured, but graph replay did not finish.\n ${detail}\n Run egregore connect ${inst.slug} to retry; existing data is unchanged.`);
1368
+ }
1369
+ let backfillSummary = { replayed: true };
1370
+ try { backfillSummary = JSON.parse((sync.stdout || "").trim()); } catch {}
1371
+
1372
+ const complete = await connectedApiRequest(
1373
+ githubToken,
1374
+ "POST",
1375
+ `/api/billing/connect/${encodeURIComponent(intentId)}/complete`,
1376
+ { backfill_summary: backfillSummary },
1377
+ );
1378
+ if (complete.status !== 200) {
1379
+ die(`graph replay finished, but activation could not be finalized: ${connectError(complete, `HTTP ${complete.status}`)}`);
1380
+ }
1381
+ persistConnectState(inst, {
1382
+ status: "connected",
1383
+ connected_at: new Date().toISOString(),
1384
+ backfill_summary: backfillSummary,
1385
+ });
1386
+ console.log(` [5/5] ${GREEN}✓${RESET} Existing memory projected into the graph`);
1387
+ console.log("");
1388
+ console.log(` ${GREEN}✓${RESET} ${BOLD}${inst.slug}${RESET} is connected. Local files remain the source of truth.`);
1389
+ console.log("");
1390
+ }
1391
+
997
1392
  function accessLabel(status) {
998
1393
  if (status === 201) return `${GREEN}sent${RESET}`;
999
1394
  if (status === 204) return `${GREEN}already added${RESET}`;
@@ -1435,7 +1830,7 @@ function selectedWorkflowLine(pv, width) {
1435
1830
  return joinShort(available.map(skillToken), Math.max(12, width));
1436
1831
  }
1437
1832
 
1438
- function renderHeroPanel({ selected, pv, width, height, countSuffix, filter, filterMode }) {
1833
+ function renderHeroPanel({ selected, pv, width, height, countSuffix, filter, filterMode, cliUpdate }) {
1439
1834
  const outerWidth = Math.min(Math.max(50, width - 2), 126);
1440
1835
  const innerWidth = Math.max(48, outerWidth - 2);
1441
1836
  const bodyWidth = Math.max(8, innerWidth - 2);
@@ -1487,7 +1882,17 @@ function renderHeroPanel({ selected, pv, width, height, countSuffix, filter, fil
1487
1882
  lines.push(mid);
1488
1883
  lines.push(boxLine(`${BOLD}${title}${RESET} ${DIM}· ${org} · ${runtime} · ${countSuffix} · ${filterText}${RESET}`, innerWidth, margin));
1489
1884
  lines.push(boxLine(`${DIM}skills${RESET} ${skillStatus}`, innerWidth, margin));
1490
- lines.push(emptyBoxLine(innerWidth, margin));
1885
+ if (cliUpdate?.available) {
1886
+ lines.push(boxLine(
1887
+ `${YELLOW}→ CLI update ${cliUpdate.latest} available${RESET} ${DIM}· installed ${cliUpdate.current} · ${RESET}${BOLD}U${RESET}${DIM} install${RESET}`,
1888
+ innerWidth,
1889
+ margin,
1890
+ ));
1891
+ } else {
1892
+ // Reserve the status row so the list never jumps when the async version
1893
+ // check resolves and reveals an available release.
1894
+ lines.push(emptyBoxLine(innerWidth, margin));
1895
+ }
1491
1896
 
1492
1897
  const sigilWidth = Math.max(...EGREGORE_SIGIL.map((line) => line.length));
1493
1898
  const twoColumn = bodyWidth >= 68;
@@ -1684,6 +2089,7 @@ async function browser(args = {}) {
1684
2089
  message: null, // ephemeral info line (e.g. "default set to X")
1685
2090
  messageKind: "info", // "info" | "success" | "warn"
1686
2091
  messageUntil: 0,
2092
+ cliUpdate: null,
1687
2093
  };
1688
2094
 
1689
2095
  const newAction = {
@@ -1787,6 +2193,7 @@ async function browser(args = {}) {
1787
2193
  countSuffix,
1788
2194
  filter: state.filter,
1789
2195
  filterMode: state.filterMode,
2196
+ cliUpdate: state.cliUpdate,
1790
2197
  });
1791
2198
  for (const line of heroLines) out.push(line + ANSI.clearLine + "\n");
1792
2199
  out.push(ANSI.clearLine + "\n");
@@ -1952,8 +2359,8 @@ async function browser(args = {}) {
1952
2359
  footer = band(`● ${stripAnsi(state.message)}`, bg);
1953
2360
  } else {
1954
2361
  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);
2362
+ ? band(`${key("↑↓", "nav")} ${key("enter", "select")} ${key("n/+", "new")} ${key("c", "connect")} ${key("i", "invite")} ${key("d", "delete")} ${key("?", "help")} ${key("q", "quit")}`, 46)
2363
+ : 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
2364
  }
1958
2365
  out.push(M + footer + "\n");
1959
2366
  if (state.showHelp) {
@@ -2310,6 +2717,19 @@ async function browser(args = {}) {
2310
2717
  resolve(setupNewEgregore(args.runtimeOverride));
2311
2718
  return;
2312
2719
  }
2720
+ if (chunk === "c") {
2721
+ const pick = visible()[state.cursor]?.e;
2722
+ if (!pick) return;
2723
+ cleanup();
2724
+ resolve(cmdConnect([pick.slug]));
2725
+ return;
2726
+ }
2727
+ if (chunk === "U" && state.cliUpdate?.available) {
2728
+ const latest = state.cliUpdate.latest;
2729
+ cleanup();
2730
+ resolve(cmdCliUpdate(latest));
2731
+ return;
2732
+ }
2313
2733
  if (chunk === "i") {
2314
2734
  const pick = visible()[state.cursor]?.e;
2315
2735
  if (!pick) return;
@@ -2396,6 +2816,11 @@ async function browser(args = {}) {
2396
2816
  });
2397
2817
 
2398
2818
  render();
2819
+ checkForCliUpdate().then((update) => {
2820
+ if (done || !update) return;
2821
+ state.cliUpdate = update;
2822
+ if (update.available) render();
2823
+ });
2399
2824
  });
2400
2825
  }
2401
2826
 
@@ -2451,6 +2876,7 @@ function main() {
2451
2876
  // Non-interactive subcommands
2452
2877
  if (cmd === "ls" || cmd === "list") return cmdLs();
2453
2878
  if (cmd === "use") return cmdUse(args[1]);
2879
+ if (cmd === "connect") return cmdConnect(args.slice(1));
2454
2880
  if (cmd === "/invite" || cmd === "invite") return cmdInvite(args.slice(1));
2455
2881
  if (cmd === "settings" || cmd === "config") return cmdSettings(args.slice(1));
2456
2882
  if (cmd === "prune") return cmdPruneNonInteractive(args[1] === "--dry-run" || args[1] === "-n");
package/bin/cli.js CHANGED
@@ -223,6 +223,8 @@ function parseArgs(argv) {
223
223
  // already served as the entry screen — a second alt-screen welcome
224
224
  // would otherwise demand a redundant Enter and churn the alt buffer.
225
225
  args.noWelcome = true;
226
+ } else if (argv[i] === "--refresh-launcher") {
227
+ args.refreshLauncher = true;
226
228
  } else if (argv[i] === "--codex") {
227
229
  args.runtime = "codex";
228
230
  } else if (argv[i] === "--pi") {
@@ -395,6 +397,7 @@ function showHelp() {
395
397
  ui.info(" --force Skip the in-repo detection check");
396
398
  ui.info(" --no-autolaunch Don't launch the harness at the end (for CI)");
397
399
  ui.info(" --no-screens Skip the full-screen welcome/ready bookends");
400
+ ui.info(" --refresh-launcher Refresh only the Egregore CLI, then exit");
398
401
  ui.info(" -h, --help Show this help");
399
402
  ui.info("");
400
403
  }
@@ -443,6 +446,21 @@ async function main() {
443
446
  }
444
447
  }
445
448
 
449
+ if (args.refreshLauncher) {
450
+ const instances = readLocalInstances();
451
+ if (instances.length === 0) {
452
+ ui.error("No installed Egregore instance was found. Run create-egregore without --refresh-launcher.");
453
+ process.exit(1);
454
+ }
455
+ const alias = await installLauncherBinary(instances[0].path, ui);
456
+ if (!alias || !alias.binaryPath) {
457
+ ui.error("Could not refresh the Egregore CLI.");
458
+ process.exit(1);
459
+ }
460
+ ui.success(`Egregore CLI is current at ${alias.binaryPath}`);
461
+ return;
462
+ }
463
+
446
464
  // Local-first product surface: if the user already has egregores installed
447
465
  // and didn't pass an explicit mode flag, refresh the launcher and open the
448
466
  // same TUI as bare `egregore`. With no installed instances, make bare
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.19.11",
3
+ "version": "0.19.13",
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 test/launcher-update.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