create-egregore 0.21.0-runtime-mvp.24 → 0.21.0-runtime-mvp.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/assets/egregore-launcher.js +37 -12
  2. package/package.json +1 -1
  3. package/runtime/claude/.claude/skills/save/SKILL.md +4 -11
  4. package/runtime/claude/bin/agent.sh +8 -0
  5. package/runtime/claude/bin/branch-guard.cjs +477 -0
  6. package/runtime/claude/bin/runtime-upgrade.sh +3 -1
  7. package/runtime/claude/egregore_runtime/runtime.py +5 -8
  8. package/runtime/claude/manifest.json +1 -0
  9. package/runtime/codex/.codex/hooks/branch-guard.js +2 -458
  10. package/runtime/codex/.codex/hooks.json +1 -1
  11. package/runtime/codex/.codex/skills/save/SKILL.md +4 -7
  12. package/runtime/codex/bin/agent.sh +8 -0
  13. package/runtime/codex/bin/branch-guard.cjs +477 -0
  14. package/runtime/codex/bin/runtime-upgrade.sh +3 -1
  15. package/runtime/codex/egregore_runtime/runtime.py +5 -8
  16. package/runtime/codex/manifest.json +1 -0
  17. package/runtime/pi/.codex/hooks/branch-guard.js +2 -458
  18. package/runtime/pi/.codex/hooks.json +1 -1
  19. package/runtime/pi/.codex/skills/save/SKILL.md +4 -7
  20. package/runtime/pi/bin/agent.sh +8 -0
  21. package/runtime/pi/bin/branch-guard.cjs +477 -0
  22. package/runtime/pi/bin/runtime-upgrade.sh +3 -1
  23. package/runtime/pi/egregore_runtime/runtime.py +5 -8
  24. package/runtime/pi/manifest.json +1 -0
  25. package/runtime/prime/.codex/hooks/branch-guard.js +2 -458
  26. package/runtime/prime/.codex/hooks.json +1 -1
  27. package/runtime/prime/.codex/skills/save/SKILL.md +4 -7
  28. package/runtime/prime/bin/agent.sh +8 -0
  29. package/runtime/prime/bin/branch-guard.cjs +477 -0
  30. package/runtime/prime/bin/runtime-upgrade.sh +3 -1
  31. package/runtime/prime/egregore_runtime/runtime.py +5 -8
  32. package/runtime/prime/manifest.json +1 -0
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.21.0-runtime-mvp.24
2
+ // egregore-launcher-version: 0.21.0-runtime-mvp.26
3
3
  //
4
4
  // egregore — launch and browse Egregore instances from your terminal.
5
5
  // Installed by create-egregore. Reads ~/.egregore/instances.json.
@@ -35,7 +35,7 @@ const crypto = require("node:crypto");
35
35
  const { spawnSync, spawn } = require("node:child_process");
36
36
  const readline = require("node:readline");
37
37
 
38
- const VERSION = "0.21.0-runtime-mvp.24";
38
+ const VERSION = "0.21.0-runtime-mvp.26";
39
39
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
40
40
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
41
41
  const UPDATE_CACHE_FILE = path.join(os.homedir(), ".egregore", "update-check.json");
@@ -1288,7 +1288,19 @@ function compareReleaseVersions(a, b) {
1288
1288
  if (left.prerelease === right.prerelease) return 0;
1289
1289
  if (!left.prerelease) return 1;
1290
1290
  if (!right.prerelease) return -1;
1291
- return left.prerelease.localeCompare(right.prerelease);
1291
+ const leftIds = left.prerelease.split(".");
1292
+ const rightIds = right.prerelease.split(".");
1293
+ for (let i = 0; i < Math.max(leftIds.length, rightIds.length); i++) {
1294
+ if (leftIds[i] === undefined) return -1;
1295
+ if (rightIds[i] === undefined) return 1;
1296
+ if (leftIds[i] === rightIds[i]) continue;
1297
+ const leftNumeric = /^\d+$/.test(leftIds[i]);
1298
+ const rightNumeric = /^\d+$/.test(rightIds[i]);
1299
+ if (leftNumeric && rightNumeric) return BigInt(leftIds[i]) > BigInt(rightIds[i]) ? 1 : -1;
1300
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
1301
+ return leftIds[i] > rightIds[i] ? 1 : -1;
1302
+ }
1303
+ return 0;
1292
1304
  }
1293
1305
 
1294
1306
  function cliUpdateState(latest) {
@@ -2353,13 +2365,20 @@ function resolveUpgradeBridge(inst) {
2353
2365
  return null;
2354
2366
  }
2355
2367
 
2368
+ // Snapshot the bridge before starting Bash: older instances still have a
2369
+ // non-exec bridge that activation overwrites while its shell is running.
2370
+ // Keep $0 and all arguments intact for root resolution and exact forwarding.
2371
+ function upgradeBridgeArgs(bridge, args) {
2372
+ return ["-c", fs.readFileSync(bridge.script, "utf-8"), bridge.script, ...args];
2373
+ }
2374
+
2356
2375
  function runUpgrade(inst, args, timeout = 60000) {
2357
2376
  const bridge = resolveUpgradeBridge(inst);
2358
2377
  if (!bridge) {
2359
2378
  return { status: 127, stdout: "", stderr: "Runtime update is unavailable — no upgrade engine in this egregore or the installed launcher package.\n", missing: true };
2360
2379
  }
2361
2380
  try {
2362
- return spawnSync("bash", [bridge.script, ...args], { cwd: inst.path, stdio: "pipe", encoding: "utf-8", timeout, env: { ...process.env, ...bridge.env } });
2381
+ return spawnSync("bash", upgradeBridgeArgs(bridge, args), { cwd: inst.path, stdio: "pipe", encoding: "utf-8", timeout, env: { ...process.env, ...bridge.env } });
2363
2382
  } catch (err) {
2364
2383
  return { status: 1, stdout: "", stderr: String(err) };
2365
2384
  }
@@ -2502,7 +2521,7 @@ function upgradeStatusAsync(inst, cb) {
2502
2521
  if (!bridge) return cb({ status: "unavailable", failure: { message: "No Runtime update engine is installed" } });
2503
2522
  let buf = ""; let err = "";
2504
2523
  try {
2505
- const child = spawn("bash", [bridge.script, "status"], { cwd: inst.path, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...bridge.env } });
2524
+ const child = spawn("bash", upgradeBridgeArgs(bridge, ["status"]), { cwd: inst.path, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...bridge.env } });
2506
2525
  child.stdout.on("data", (d) => { buf += d; });
2507
2526
  child.stderr.on("data", (d) => { err += d; });
2508
2527
  child.on("close", (code) => {
@@ -2639,12 +2658,18 @@ const UPGRADE_STATUS_LABEL = {
2639
2658
  // Settings-row truth for the Runtime update line. The row must say outright
2640
2659
  // whether this egregore runs the new Runtime — "no update staged" is
2641
2660
  // transaction state, not the condition the user needs to see.
2661
+ function runtimeUpdateAvailable(st, candidate) {
2662
+ return st?.status === "active"
2663
+ && RUNTIME_MVP_VERSION_RE.test(candidate || "")
2664
+ && compareReleaseVersions(candidate, st.active_version) > 0;
2665
+ }
2666
+
2642
2667
  function runtimeUpdateRowValue(st, candidate) {
2643
2668
  if (st === undefined) return `${DIM}checking…${RESET}`;
2644
2669
  if (st === null) return `${DIM}unavailable${RESET}`;
2645
2670
  if (st.status === "active") {
2646
2671
  const v = st.active_version || "?";
2647
- if (candidate && candidate !== v) return `${v} active ${YELLOW}· update available ${candidate}${RESET}`;
2672
+ if (runtimeUpdateAvailable(st, candidate)) return `${v} active ${YELLOW}· update available ${candidate}${RESET}`;
2648
2673
  return `${GREEN}${v} active${RESET}${candidate ? `${DIM} · up to date${RESET}` : ""}`;
2649
2674
  }
2650
2675
  if (st.status === "unavailable") return `${YELLOW}update unavailable${RESET}`;
@@ -2682,8 +2707,10 @@ function formatUpgradeLines(inst, st, { candidate = "", retrieval = null } = {})
2682
2707
  lines.push(`Location: ${inst.path}`);
2683
2708
  lines.push("");
2684
2709
  const activeLabel = d.active_version ? `${d.active_version} active` : "not yet activated here";
2685
- lines.push(`Runtime: ${activeLabel}`
2686
- + (d.candidate_version ? ` → candidate ${d.candidate_version}` : candidate ? ` → candidate ${candidate} available` : ""));
2710
+ const candidateLabel = d.status === "active"
2711
+ ? (runtimeUpdateAvailable(d, candidate) ? ` → candidate ${candidate} available` : "")
2712
+ : (d.candidate_version ? ` → candidate ${d.candidate_version}` : candidate ? ` → candidate ${candidate} available` : "");
2713
+ lines.push(`Runtime: ${activeLabel}${candidateLabel}`);
2687
2714
  lines.push(`Status: ${UPGRADE_STATUS_LABEL[d.status] || d.status}`);
2688
2715
  if (d.candidate_digest) lines.push(`Package digest: sha256 ${String(d.candidate_digest).slice(0, 16)}…`);
2689
2716
  // A recorded failure only needs attention while the transaction is stuck
@@ -3161,7 +3188,7 @@ function renderUpgradePanel({ inst, data, candidate, retrieval, mode, notice, wi
3161
3188
  if (notice) lines.push(boxLine(notice, innerWidth, margin));
3162
3189
  const keys = [];
3163
3190
  if (!status || status === "none" || ["pending", "failed", "interrupted", "cancelled", "rolled-back"].includes(status)) keys.push(`${BOLD}p${RESET}${DIM} prepare${RESET}`);
3164
- if (status === "active" && candidate && candidate !== (data && data.active_version)) keys.push(`${BOLD}p${RESET}${DIM} prepare update${RESET}`);
3191
+ if (runtimeUpdateAvailable(data, candidate)) keys.push(`${BOLD}p${RESET}${DIM} prepare update${RESET}`);
3165
3192
  if (status === "ready-to-activate") keys.push(`${BOLD}a${RESET}${DIM} activate${RESET}`);
3166
3193
  if (status === "preparing") keys.push(`${BOLD}c${RESET}${DIM} cancel${RESET}`);
3167
3194
  if (status === "active") keys.push(`${BOLD}r${RESET}${DIM} roll back${RESET}`);
@@ -3924,9 +3951,7 @@ async function browser(args = {}) {
3924
3951
  render(); return;
3925
3952
  }
3926
3953
  const status = state.upgradeData && state.upgradeData.status;
3927
- const activeUpdatable = status === "active"
3928
- && state.upgradeCandidate
3929
- && state.upgradeCandidate !== (state.upgradeData && state.upgradeData.active_version);
3954
+ const activeUpdatable = runtimeUpdateAvailable(state.upgradeData, state.upgradeCandidate);
3930
3955
  if (chunk === "p" && (!status || activeUpdatable || ["none", "pending", "failed", "interrupted", "cancelled", "rolled-back"].includes(status))) {
3931
3956
  const staged = state.upgradeData
3932
3957
  && !["none", "cancelled", "rolled-back", "active"].includes(status)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.21.0-runtime-mvp.24",
3
+ "version": "0.21.0-runtime-mvp.26",
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": {
@@ -36,17 +36,10 @@ BASE=$(bash -c 'SCRIPT_DIR="$PWD"; CONFIG="$PWD/egregore.json"; . "$PWD/bin/lib/
36
36
 
37
37
  Stop before mutation if it cannot be resolved.
38
38
 
39
- 3. Run the product-distribution checkpoint when its registry exists — the
40
- guard is real: installed instances do not ship the distribution engine,
41
- and its absence there is correct, not an error to chase:
42
-
43
- ```bash
44
- if [ -f bin/capability-distribution.mjs ] && [ -f capability-distribution.json ]; then
45
- bash bin/node-run.sh bin/capability-distribution.mjs validate --root .
46
- bash bin/node-run.sh bin/capability-distribution.mjs changes --root . --base "origin/$BASE" --json
47
- bash bin/node-run.sh bin/capability-distribution.mjs runtime-skill-audit --root . --strict-contract
48
- fi
49
- ```
39
+ 3. The shared `bin/agent.sh save` preflight runs distribution validation,
40
+ the change report, and the strict Runtime-skill audit before any memory or
41
+ project mutation. Both engine and registry must exist; installed public
42
+ instances intentionally omit the development engine.
50
43
 
51
44
  Ask one placement question only for a genuinely new/unclassified component or
52
45
  an explicit placement change. Ordinary edits to an existing component do not
@@ -397,6 +397,14 @@ cmd_save() {
397
397
  [ -n "$message" ] || message="chore(save): $topic"
398
398
  base="$(resolve_base_branch)" || die "save stopped before Git changes"
399
399
 
400
+ # Shared invariant across harnesses, before memory or project mutations.
401
+ # Installed public instances intentionally omit the development engine.
402
+ if [ -f "$SCRIPT_DIR/bin/capability-distribution.mjs" ] && [ -f "$SCRIPT_DIR/capability-distribution.json" ]; then
403
+ bash "$SCRIPT_DIR/bin/node-run.sh" "$SCRIPT_DIR/bin/capability-distribution.mjs" validate --root "$SCRIPT_DIR" || die "distribution validation failed"
404
+ bash "$SCRIPT_DIR/bin/node-run.sh" "$SCRIPT_DIR/bin/capability-distribution.mjs" changes --root "$SCRIPT_DIR" --base "origin/$base" --json || die "distribution change check failed"
405
+ bash "$SCRIPT_DIR/bin/node-run.sh" "$SCRIPT_DIR/bin/capability-distribution.mjs" runtime-skill-audit --root "$SCRIPT_DIR" --strict-contract || die "runtime skill contract failed"
406
+ fi
407
+
400
408
  if [ -d "$MEMORY_DIR" ]; then
401
409
  save_memory "$message" "."
402
410
  echo "memory: synced"
@@ -0,0 +1,477 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const { spawnSync } = require("node:child_process");
7
+
8
+ function readStdin() {
9
+ try {
10
+ return fs.readFileSync(0, "utf-8");
11
+ } catch {
12
+ return "";
13
+ }
14
+ }
15
+
16
+ function parseJson(raw) {
17
+ try {
18
+ return JSON.parse(raw || "{}");
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ const commandCache = new Map();
25
+
26
+ function run(cmd, args, cwd) {
27
+ const key = JSON.stringify([cmd, args, cwd]);
28
+ if (commandCache.has(key)) return commandCache.get(key);
29
+ const result = spawnSync(cmd, args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
30
+ if (result.status !== 0) return "";
31
+ const output = String(result.stdout || "").trim();
32
+ commandCache.set(key, output);
33
+ return output;
34
+ }
35
+
36
+ function realish(filePath) {
37
+ const absolute = path.resolve(filePath);
38
+ let cursor = absolute;
39
+ while (!fs.existsSync(cursor)) {
40
+ const parent = path.dirname(cursor);
41
+ if (parent === cursor) return absolute;
42
+ cursor = parent;
43
+ }
44
+ try {
45
+ const resolved = fs.realpathSync(cursor);
46
+ return path.join(resolved, path.relative(cursor, absolute));
47
+ } catch {
48
+ return absolute;
49
+ }
50
+ }
51
+
52
+ function nearestExistingDirectory(candidate) {
53
+ let cursor = path.resolve(candidate);
54
+ try {
55
+ if (fs.existsSync(cursor) && !fs.statSync(cursor).isDirectory()) cursor = path.dirname(cursor);
56
+ } catch {}
57
+ while (!fs.existsSync(cursor)) {
58
+ const parent = path.dirname(cursor);
59
+ if (parent === cursor) break;
60
+ cursor = parent;
61
+ }
62
+ return cursor;
63
+ }
64
+
65
+ function gitContext(candidate) {
66
+ if (!candidate) return null;
67
+ const probe = nearestExistingDirectory(candidate);
68
+ const top = run("git", ["-C", probe, "rev-parse", "--show-toplevel"], probe);
69
+ if (!top) return null;
70
+ const commonRaw = run("git", ["-C", top, "rev-parse", "--git-common-dir"], top);
71
+ const common = commonRaw
72
+ ? realish(path.isAbsolute(commonRaw) ? commonRaw : path.join(top, commonRaw))
73
+ : realish(path.join(top, ".git"));
74
+ return { top: realish(top), common };
75
+ }
76
+
77
+ function currentBranch(projectDir) {
78
+ return run("git", ["-C", projectDir, "rev-parse", "--abbrev-ref", "HEAD"], projectDir);
79
+ }
80
+
81
+ function currentAuthor(projectDir) {
82
+ try {
83
+ const raw = fs.readFileSync(path.join(projectDir, ".egregore-state.json"), "utf-8");
84
+ const state = JSON.parse(raw);
85
+ const value = state.handle || state.display_name || state.name || "dev";
86
+ return String(value).trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "dev";
87
+ } catch {
88
+ return "dev";
89
+ }
90
+ }
91
+
92
+ function configuredBaseBranch(projectDir) {
93
+ try {
94
+ const raw = fs.readFileSync(path.join(projectDir, "egregore.json"), "utf-8");
95
+ const value = JSON.parse(raw).base_branch;
96
+ return typeof value === "string" && value.trim() ? value.trim() : "develop";
97
+ } catch {
98
+ return "develop";
99
+ }
100
+ }
101
+
102
+ function isProtectedBranch(branch, projectDir) {
103
+ return branch === configuredBaseBranch(projectDir) ||
104
+ branch === "develop" || branch === "main" || branch === "master";
105
+ }
106
+
107
+ function isRuntimePath(candidate, projectDir) {
108
+ const rel = path.relative(projectDir, candidate);
109
+ // A nested task checkout is project code, not disposable runtime state.
110
+ if (rel === ".claude/worktrees" || rel.startsWith(".claude/worktrees/")) return false;
111
+ return [".claude", ".codex", ".pi", ".prime", "memory"].some(
112
+ (dir) => rel === dir || rel.startsWith(`${dir}/`)
113
+ ) || [".egregore-state.json", ".egregore-branch-consent", ".env"].includes(rel);
114
+ }
115
+
116
+ function protectedProject(candidate, hub) {
117
+ const operation = gitContext(candidate);
118
+ if (!operation || (hub && operation.common !== hub.common)) return null;
119
+ const branch = currentBranch(operation.top);
120
+ if (!isProtectedBranch(branch, operation.top)) return null;
121
+ try {
122
+ if (fs.readFileSync(path.join(operation.top, ".egregore-branch-consent"), "utf8").trim() === branch) return null;
123
+ } catch {}
124
+ return { projectDir: operation.top, branch };
125
+ }
126
+
127
+ function guardedTarget(target, baseDir, hub) {
128
+ const candidate = realish(path.resolve(baseDir, target));
129
+ const project = protectedProject(candidate, hub);
130
+ if (!project || isRuntimePath(candidate, project.projectDir)) return null;
131
+ return project;
132
+ }
133
+
134
+ function normalizeInput(input) {
135
+ const raw = input.tool_input;
136
+ const toolInput = typeof raw === "string" ? { command: raw } : { ...raw };
137
+ toolInput.command = toolInput.command ?? toolInput.cmd ?? toolInput.patch ?? toolInput.input ?? "";
138
+ const toolName = input.tool_name === "exec_command" ? "Bash" : input.tool_name;
139
+ return { ...input, tool_name: toolName, tool_input: toolInput };
140
+ }
141
+
142
+ function extractApplyPatchPaths(command) {
143
+ const paths = [];
144
+ for (const line of String(command || "").split(/\r?\n/)) {
145
+ const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) || line.match(/^\*\*\* Move to: (.+)$/);
146
+ if (match && match[1]) paths.push(match[1].trim());
147
+ }
148
+ return paths;
149
+ }
150
+
151
+ function isBranchSetupCommand(command) {
152
+ if (shellSegments(command).length !== 1) return false;
153
+ return (
154
+ /^\s*bin\/agent\.sh\s+branch\b/.test(command) ||
155
+ /^\s*git\s+(checkout|switch)\s+(-b|-c)\b/.test(command) ||
156
+ /^\s*git\s+branch\s+(dev|feature|bugfix)\//.test(command) ||
157
+ /^\s*git\s+worktree\s+add\b/.test(command) ||
158
+ /^\s*bin\/worktree\.sh\s+setup\b/.test(command)
159
+ );
160
+ }
161
+
162
+ function stagedOnlyFrameworkPaths(projectDir) {
163
+ const files = run("git", ["-C", projectDir, "diff", "--cached", "--name-only"], projectDir)
164
+ .split(/\r?\n/)
165
+ .filter(Boolean);
166
+ return files.length > 0 && files.every((file) => /^(bin\/|\.claude\/|\.codex\/|CLAUDE\.md$|AGENTS\.md$|skills\/)/.test(file));
167
+ }
168
+
169
+ function stripQuotedSegments(command) {
170
+ let out = "";
171
+ let quote = null;
172
+ for (let i = 0; i < String(command || "").length; i++) {
173
+ const ch = command[i];
174
+ if (quote) {
175
+ if (quote === '"' && ch === "\\") i++;
176
+ else if (ch === quote) quote = null;
177
+ continue;
178
+ }
179
+ if (ch === "'" || ch === '"') {
180
+ quote = ch;
181
+ out += "x";
182
+ continue;
183
+ }
184
+ out += ch;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ function maskQuotedOperators(command) {
190
+ let out = "";
191
+ let quote = null;
192
+ for (let i = 0; i < String(command || "").length; i++) {
193
+ const ch = command[i];
194
+ if (quote) {
195
+ if (quote === '"' && ch === "\\") {
196
+ out += ch;
197
+ if (i + 1 < command.length) out += command[++i];
198
+ continue;
199
+ }
200
+ if (ch === quote) quote = null;
201
+ out += /[><|;&]/.test(ch) ? " " : ch;
202
+ continue;
203
+ }
204
+ if (ch === "'" || ch === '"') quote = ch;
205
+ out += ch;
206
+ }
207
+ return out;
208
+ }
209
+
210
+ function withoutHeredocBodies(command) {
211
+ // Script/data bodies are stdin, not shell commands. Keep each opener so
212
+ // its real output redirects still participate in destination checks.
213
+ const pending = [];
214
+ const visible = [];
215
+ let quote = null;
216
+ for (const line of String(command).split("\n")) {
217
+ if (pending.length) {
218
+ const { delimiter, tabs } = pending[0];
219
+ if ((tabs ? line.replace(/^\t+/, "") : line) === delimiter) pending.shift();
220
+ continue;
221
+ }
222
+ visible.push(line);
223
+ for (let i = 0; i < line.length; i++) {
224
+ const ch = line[i];
225
+ if (ch === "\\" && quote !== "'") { i++; continue; }
226
+ if (quote) {
227
+ if (ch === quote) quote = null;
228
+ continue;
229
+ }
230
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
231
+ if (line.slice(i, i + 3) === "<<<") { i += 2; continue; }
232
+ if (line.slice(i, i + 2) !== "<<") continue;
233
+ const match = line.slice(i).match(/^<<(-)?[ \t]*(?:'([^']+)'|"([^"]+)"|([^\s;&|<>]+))/);
234
+ if (match) {
235
+ pending.push({ delimiter: match[2] || match[3] || match[4], tabs: Boolean(match[1]) });
236
+ i += match[0].length - 1;
237
+ }
238
+ }
239
+ }
240
+ return visible.join("\n");
241
+ }
242
+
243
+ function shellSegments(command) {
244
+ command = withoutHeredocBodies(command);
245
+ const segments = [];
246
+ let current = "";
247
+ let quote = null;
248
+ for (let i = 0; i < String(command || "").length; i++) {
249
+ const ch = command[i];
250
+ if (quote) {
251
+ current += ch;
252
+ if (quote === '"' && ch === "\\" && i + 1 < command.length) current += command[++i];
253
+ else if (ch === quote) quote = null;
254
+ continue;
255
+ }
256
+ if (ch === "'" || ch === '"') {
257
+ quote = ch;
258
+ current += ch;
259
+ continue;
260
+ }
261
+ if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
262
+ if (current.trim()) segments.push(current.trim());
263
+ current = "";
264
+ continue;
265
+ }
266
+ current += ch;
267
+ }
268
+ if (current.trim()) segments.push(current.trim());
269
+ return segments;
270
+ }
271
+
272
+ function shellWords(segment) {
273
+ const words = [];
274
+ let current = "";
275
+ let quote = null;
276
+ for (let i = 0; i < String(segment || "").length; i++) {
277
+ const ch = segment[i];
278
+ if (quote) {
279
+ if (quote === '"' && ch === "\\" && i + 1 < segment.length) current += segment[++i];
280
+ else if (ch === quote) quote = null;
281
+ else current += ch;
282
+ continue;
283
+ }
284
+ if (ch === "'" || ch === '"') {
285
+ quote = ch;
286
+ continue;
287
+ }
288
+ if (/\s/.test(ch)) {
289
+ if (current) words.push(current);
290
+ current = "";
291
+ continue;
292
+ }
293
+ if (ch === "\\") {
294
+ if (i + 1 < segment.length) current += segment[++i];
295
+ continue;
296
+ }
297
+ current += ch;
298
+ }
299
+ if (current) words.push(current);
300
+ return words;
301
+ }
302
+
303
+ function filesystemMutationStatus(command, isExemptPath) {
304
+ const commands = new Set(["rm", "mv", "cp", "mkdir", "rmdir", "touch", "chmod", "chown", "ln", "tee"]);
305
+ let unsafe = false;
306
+
307
+ for (const segment of shellSegments(command)) {
308
+ const words = shellWords(segment);
309
+ while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
310
+ while (words[0] === "sudo" || words[0] === "command" || words[0] === "env") words.shift();
311
+ if (!words.length) continue;
312
+
313
+ const name = path.basename(words.shift());
314
+ if (!commands.has(name)) continue;
315
+
316
+ const args = words.filter((word) => !word.startsWith("-"));
317
+ let targets = args;
318
+ if (name === "cp" || name === "ln") targets = args.length ? [args[args.length - 1]] : [];
319
+ if (name === "chmod" || name === "chown") targets = args.slice(1);
320
+
321
+ if (!targets.length) unsafe = true;
322
+ for (const target of targets) {
323
+ if (/[$`*?{}]/.test(target) || !isExemptPath(target)) unsafe = true;
324
+ }
325
+ }
326
+
327
+ return { unsafe };
328
+ }
329
+
330
+ function redirectLooksMutating(command, isExemptPath) {
331
+ const visible = maskQuotedOperators(command);
332
+ const redirectPatterns = [
333
+ /(?:^|[\s;|&])(?:\d*)>>?\s*([^&\s;|]+)/g,
334
+ /\|\s*tee\s+(?:-a\s+)?([^&\s;|]+)/g,
335
+ ];
336
+
337
+ for (const pattern of redirectPatterns) {
338
+ let match;
339
+ while ((match = pattern.exec(visible))) {
340
+ const target = String(match[1] || "").replace(/^["']|["']$/g, "");
341
+ if (!target || target === "/dev/null" || target.startsWith("$")) continue;
342
+ if (!isExemptPath(target)) return true;
343
+ }
344
+ }
345
+ return false;
346
+ }
347
+
348
+ function shellLooksMutating(command, projectDir, isExemptPath) {
349
+ if (!command.trim()) return false;
350
+ const visible = stripQuotedSegments(command);
351
+
352
+ const mutatingPatterns = [
353
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?git(?:\s+-C\s+\S+)?\s+(add|commit|push|rm|mv|reset|restore|checkout\s+--|clean|rebase|merge|cherry-pick)\b/,
354
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?apply_patch\b/,
355
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?(sed|perl)\b[^;&|]*\s-i(\s|$)/,
356
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?(npm|pnpm|yarn|bun)\s+(install|add|remove|update|dedupe|ci)\b/,
357
+ /(?:^|[;&|(\n]\s*)(?:sudo\s+)?(go\s+mod\s+tidy|cargo\s+(fmt|fix|update)|rustfmt|prettier\s+--write|eslint\s+--fix)\b/,
358
+ ];
359
+
360
+ const gitCommit = /(?:^|[;&|(\n]\s*)(?:sudo\s+)?git(?:\s+-C\s+\S+)?\s+commit\b/.test(visible);
361
+ const exemptOperation = isBranchSetupCommand(visible) || (gitCommit && stagedOnlyFrameworkPaths(projectDir));
362
+ // Inspect destinations even when the command itself is already classified
363
+ // as a mutation (or exempt). A task-worktree git command can redirect into
364
+ // the protected hub, and that destination must not be short-circuited.
365
+ const filesystem = filesystemMutationStatus(command, isExemptPath);
366
+ const redirects = redirectLooksMutating(command, isExemptPath);
367
+ return filesystem.unsafe || redirects ||
368
+ (!exemptOperation && mutatingPatterns.some((pattern) => pattern.test(visible)));
369
+ }
370
+
371
+ function isConsentCommand(command, branch) {
372
+ const escaped = branch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
373
+ return new RegExp(
374
+ `^\\s*echo\\s+['"]?${escaped}['"]?\\s*>\\s*(?:\\./)?\\.egregore-branch-consent\\s*$`
375
+ ).test(String(command || ""));
376
+ }
377
+
378
+ function deny(reason) {
379
+ process.stdout.write(JSON.stringify({
380
+ hookSpecificOutput: {
381
+ hookEventName: "PreToolUse",
382
+ permissionDecision: "deny",
383
+ permissionDecisionReason: reason,
384
+ },
385
+ }));
386
+ }
387
+
388
+ function reasonFor({ projectDir, branch }) {
389
+ const author = currentAuthor(projectDir);
390
+ return [
391
+ `Protected branch (${branch}). Move project work to a task branch before writing.`,
392
+ `If the work topic is clear, run bin/agent.sh branch --topic "<work topic>" automatically (prefix dev/${author}/...), continue in the printed worktree, and briefly tell the user.`,
393
+ "Ask only when the work topic is genuinely ambiguous; ask for the topic, not for routine Git permission.",
394
+ `Only when the user explicitly requested writing on ${branch}, record consent with: echo '${branch}' > .egregore-branch-consent`,
395
+ "Memory, framework-runtime, and managed-repo writes are exempt and should not trigger this guard.",
396
+ ].join("\n");
397
+ }
398
+
399
+ function evaluate(input) {
400
+ commandCache.clear();
401
+ input = normalizeInput(input);
402
+ const { tool_name: toolName, tool_input: toolInput } = input;
403
+ const cwd = input.cwd || process.cwd();
404
+ const baseDir = path.resolve(cwd, toolInput.workdir || toolInput.cwd || ".");
405
+ const hub = gitContext(process.env.EGREGORE_CODEX_PROJECT_DIR || cwd);
406
+
407
+ if (toolName === "apply_patch") {
408
+ const targets = extractApplyPatchPaths(toolInput.command);
409
+ // Check every patch target, including rename destinations. The launch
410
+ // checkout and the first file say nothing about the remaining targets.
411
+ for (const target of targets) {
412
+ const project = guardedTarget(target, baseDir, hub);
413
+ if (project) return project;
414
+ }
415
+ return targets.length ? null : protectedProject(baseDir, hub);
416
+ }
417
+
418
+ if (toolName === "Edit" || toolName === "Write") {
419
+ const target = toolInput.file_path || toolInput.path;
420
+ return target ? guardedTarget(target, baseDir, hub) : protectedProject(baseDir, hub);
421
+ }
422
+
423
+ if (toolName !== "Bash") return null;
424
+ let shellDir = baseDir;
425
+ // Some Codex hosts normalize exec_command to Bash and discard workdir.
426
+ // Their event.cwd is the launch checkout, not evidence of execution cwd.
427
+ // Keep ambiguous relative mutations advisory; concrete destinations still
428
+ // meet the hard guard. Never guess a worktree or redirect a command.
429
+ let cwdKnown = Boolean(toolInput.workdir || toolInput.cwd);
430
+ let advisory = null;
431
+ for (const segment of shellSegments(String(toolInput.command))) {
432
+ const words = shellWords(segment);
433
+ // cd changes subsequent shell operations; git -C changes only that git
434
+ // operation. Never exempt a whole chain because one command is external.
435
+ if (words[0] === "cd" && words.length === 2 && !/[$`*?~]/.test(words[1])) {
436
+ cwdKnown = path.isAbsolute(words[1]) || cwdKnown;
437
+ shellDir = path.resolve(shellDir, words[1]);
438
+ continue;
439
+ }
440
+ let operationDir = shellDir;
441
+ let operationKnown = cwdKnown;
442
+ if (words[0] === "git" && words[1] === "-C" && words[2] && !/[$`*?~]/.test(words[2])) {
443
+ operationDir = path.resolve(shellDir, words[2]);
444
+ operationKnown = path.isAbsolute(words[2]) || cwdKnown;
445
+ }
446
+ const project = protectedProject(operationDir, hub);
447
+ if (project && isConsentCommand(segment, project.branch)) continue;
448
+ let blockedTarget = null;
449
+ const exempt = (target) => {
450
+ const blocked = guardedTarget(target, shellDir, hub);
451
+ if (blocked && (cwdKnown || path.isAbsolute(target))) blockedTarget = blocked;
452
+ return !blocked;
453
+ };
454
+ if (shellLooksMutating(segment, operationDir, exempt)) {
455
+ if (blockedTarget) return blockedTarget;
456
+ if (project && operationKnown) return project;
457
+ if (project) advisory = { ...project, advisory: true };
458
+ }
459
+ }
460
+ return advisory;
461
+ }
462
+
463
+ function main() {
464
+ const project = evaluate(parseJson(readStdin()));
465
+ if (!project) return;
466
+ if (project.advisory) {
467
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: {
468
+ hookEventName: "PreToolUse",
469
+ additionalContext: "The shell hook did not receive the execution directory, so the launch checkout cannot establish the target branch. Keep project writes in the task workspace. Use absolute destinations or git -C with the task workspace path. If no task workspace exists, run bin/agent.sh branch --topic \"<work topic>\" automatically and continue there.",
470
+ } }));
471
+ return;
472
+ }
473
+ deny(reasonFor(project));
474
+ }
475
+
476
+ module.exports = { evaluate, main };
477
+ if (require.main === module) main();
@@ -22,10 +22,12 @@ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
22
22
  # tree, while the target instance is set explicitly — never inferred.
23
23
  INSTANCE_ROOT="${EGREGORE_INSTANCE_ROOT:-$SCRIPT_DIR}"
24
24
 
25
+ # Activation and rollback can replace this script. Hand off the process so
26
+ # Bash never resumes reading changed bytes after the Runtime returns.
25
27
  _runtime() {
26
28
  EGREGORE_ROOT="$INSTANCE_ROOT" \
27
29
  PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \
28
- python3 -m egregore_runtime.harness_cli "$@"
30
+ exec python3 -m egregore_runtime.harness_cli "$@"
29
31
  }
30
32
 
31
33
  _ensure_identity() {