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.
- package/assets/egregore-launcher.js +37 -12
- package/package.json +1 -1
- package/runtime/claude/.claude/skills/save/SKILL.md +4 -11
- package/runtime/claude/bin/agent.sh +8 -0
- package/runtime/claude/bin/branch-guard.cjs +477 -0
- package/runtime/claude/bin/runtime-upgrade.sh +3 -1
- package/runtime/claude/egregore_runtime/runtime.py +5 -8
- package/runtime/claude/manifest.json +1 -0
- package/runtime/codex/.codex/hooks/branch-guard.js +2 -458
- package/runtime/codex/.codex/hooks.json +1 -1
- package/runtime/codex/.codex/skills/save/SKILL.md +4 -7
- package/runtime/codex/bin/agent.sh +8 -0
- package/runtime/codex/bin/branch-guard.cjs +477 -0
- package/runtime/codex/bin/runtime-upgrade.sh +3 -1
- package/runtime/codex/egregore_runtime/runtime.py +5 -8
- package/runtime/codex/manifest.json +1 -0
- package/runtime/pi/.codex/hooks/branch-guard.js +2 -458
- package/runtime/pi/.codex/hooks.json +1 -1
- package/runtime/pi/.codex/skills/save/SKILL.md +4 -7
- package/runtime/pi/bin/agent.sh +8 -0
- package/runtime/pi/bin/branch-guard.cjs +477 -0
- package/runtime/pi/bin/runtime-upgrade.sh +3 -1
- package/runtime/pi/egregore_runtime/runtime.py +5 -8
- package/runtime/pi/manifest.json +1 -0
- package/runtime/prime/.codex/hooks/branch-guard.js +2 -458
- package/runtime/prime/.codex/hooks.json +1 -1
- package/runtime/prime/.codex/skills/save/SKILL.md +4 -7
- package/runtime/prime/bin/agent.sh +8 -0
- package/runtime/prime/bin/branch-guard.cjs +477 -0
- package/runtime/prime/bin/runtime-upgrade.sh +3 -1
- package/runtime/prime/egregore_runtime/runtime.py +5 -8
- 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.
|
|
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.
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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 (
|
|
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
|
-
|
|
2686
|
-
|
|
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 (
|
|
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 =
|
|
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.
|
|
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.
|
|
40
|
-
|
|
41
|
-
|
|
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() {
|