create-egregore 0.21.0-runtime-mvp.25 → 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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.21.0-runtime-mvp.25
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.25";
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.21.0-runtime-mvp.25",
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": {
@@ -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();
@@ -135,6 +135,7 @@
135
135
  "bin/attendant.sh",
136
136
  "bin/autosave.sh",
137
137
  "bin/boundary.sh",
138
+ "bin/branch-guard.cjs",
138
139
  "bin/build-sealed-dataroom.mjs",
139
140
  "bin/capture-reconcile.sh",
140
141
  "bin/capture-run.sh",