Skip to content

Commit eacb637

Browse files
author
NagyVikt
committed
Keep the primary checkout stable while creating agent sandboxes
Introducing a first-class `musafety sandbox` command makes the safer workflow explicit: keep the visible repo on the base branch while creating isolated agent worktrees for branch-local edits. Documentation, setup scaffolding, and tests now reinforce this path and prevent accidental off-base sandbox starts. Constraint: Sandbox creation must preserve current branch expectations for interactive maintainers Rejected: Keep telling users to call agent-branch-start directly | it flips active branches and is easier to misuse in UI-driven workflows Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep README/AI setup prompts aligned with actual CLI commands whenever workflow commands change Tested: npm test (38/38 passing) Not-tested: Manual end-to-end run in a live VS Code multi-terminal session
1 parent 8b511fa commit eacb637

6 files changed

Lines changed: 361 additions & 6 deletions

File tree

.gitignore

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,15 @@
11
.omx/
2-
node_modules
2+
node_modules
3+
4+
# multiagent-safety:START
5+
scripts/agent-branch-start.sh
6+
scripts/agent-branch-finish.sh
7+
scripts/agent-worktree-prune.sh
8+
scripts/agent-file-locks.py
9+
scripts/install-agent-git-hooks.sh
10+
scripts/openspec/init-plan-workspace.sh
11+
.githooks/pre-commit
12+
.codex/skills/musafety/SKILL.md
13+
.claude/commands/musafety.md
14+
.omx/state/agent-file-locks.json
15+
# multiagent-safety:END

AGENTS.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,64 @@ OMX runtime state typically lives under `.omx/`:
7474
- `.omx/project-memory.json`
7575
- `.omx/plans/`
7676
- `.omx/logs/`
77+
78+
<!-- multiagent-safety:START -->
79+
## Multi-Agent Execution Contract (multiagent-safety)
80+
81+
0. Session plan comment + read gate (required)
82+
83+
- Before editing, each agent must post a short session comment/handoff note that includes:
84+
- plan/change name (or checkpoint id),
85+
- owned files/scope,
86+
- intended action.
87+
- Before deleting/replacing code, each agent must read the latest session comments/handoffs first and confirm the target code is in their owned scope.
88+
- If ownership is unclear or overlaps, stop that edit, post a blocker comment, and let the leader/integrator reassign scope.
89+
- For git isolation, each agent must start on a dedicated branch via `scripts/agent-branch-start.sh "<task-or-plan>" "<agent-name>"`.
90+
- Agent completion must use `scripts/agent-branch-finish.sh` (merge into `dev`, push, delete agent branch).
91+
92+
1. Explicit ownership before edits
93+
94+
- Assign each agent clear file/module ownership.
95+
- Do not edit files outside your assigned scope unless the leader reassigns ownership.
96+
97+
2. Preserve parallel safety
98+
99+
- Assume other agents are editing nearby code concurrently.
100+
- Never revert unrelated changes authored by others.
101+
- If another change conflicts with your approach, adapt and report the conflict in handoff.
102+
103+
3. Verify before completion
104+
105+
- Run required local checks for the area you changed.
106+
- Do not mark work complete without command output evidence.
107+
108+
4. Required handoff format (every agent)
109+
110+
- Files changed
111+
- Behavior touched
112+
- Verification commands + results
113+
- Risks / follow-ups
114+
115+
## OpenSpec Plan Workspace (recommended)
116+
117+
When work needs a durable planning phase, scaffold a plan workspace before implementation:
118+
119+
```bash
120+
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
121+
```
122+
123+
Expected shape:
124+
125+
```text
126+
openspec/plan/<plan-slug>/
127+
summary.md
128+
checkpoints.md
129+
planner/plan.md
130+
planner/tasks.md
131+
architect/tasks.md
132+
critic/tasks.md
133+
executor/tasks.md
134+
writer/tasks.md
135+
verifier/tasks.md
136+
```
137+
<!-- multiagent-safety:END -->

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ Example output:
128128
npm i -g musafety
129129
musafety setup
130130
musafety doctor
131-
bash scripts/agent-branch-start.sh "task" "agent-name"
131+
musafety sandbox "task" "agent-name"
132132
python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
133133
bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
134134
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
@@ -157,7 +157,7 @@ Use this exact checklist to setup multi-agent safety in this repository for Code
157157
musafety doctor
158158
159159
4) Confirm next safe agent workflow commands:
160-
bash scripts/agent-branch-start.sh "task" "agent-name"
160+
musafety sandbox "task" "agent-name"
161161
python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
162162
bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
163163
@@ -176,6 +176,7 @@ Use this exact checklist to setup multi-agent safety in this repository for Code
176176

177177
```sh
178178
musafety status [--target <path>] [--json]
179+
musafety sandbox [task] [agent] [--target <path>] [--base <branch>] [--worktree-root <path>] [--allow-non-base] [--json]
179180
musafety setup [--target <path>] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore]
180181
musafety doctor [--target <path>] [--dry-run] [--json] [--keep-stale-locks] [--no-gitignore]
181182
musafety copy-prompt
@@ -192,6 +193,8 @@ bash scripts/agent-worktree-prune.sh --base dev # manual stale worktree cleanu
192193
bash scripts/openspec/init-plan-workspace.sh <plan-slug> # optional OpenSpec plan scaffold
193194
```
194195

196+
`musafety sandbox` keeps your visible checkout on the base branch (for example `main`) and creates an isolated agent worktree under `.omx/agent-worktrees/`, so sandbox terminals can use dedicated `agent/*` branches without flipping your primary Source Control branch.
197+
195198
No command defaults to `musafety status` (non-mutating health/status view).
196199
`musafety status` reports CLI/runtime info, global OMX/OpenSpec service status, and repo safety service state.
197200
When run in an interactive terminal, default `musafety` checks npm for a newer version first

bin/multiagent-safety.js

Lines changed: 199 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const COMMAND_TYPO_ALIASES = new Map([
8282
]);
8383
const SUGGESTIBLE_COMMANDS = [
8484
'status',
85+
'sandbox',
8586
'setup',
8687
'doctor',
8788
'report',
@@ -99,6 +100,7 @@ const SUGGESTIBLE_COMMANDS = [
99100
];
100101
const CLI_COMMAND_DESCRIPTIONS = [
101102
['status', 'Show musafety CLI + service health without modifying files'],
103+
['sandbox', 'Create an isolated agent worktree sandbox while keeping visible repo branch unchanged'],
102104
['setup', 'Install + repair guardrails in a git repo (supports --no-gitignore)'],
103105
['doctor', 'Repair safety setup drift, then verify repo safety'],
104106
['report', 'Generate security/safety reports (for example: OpenSSF scorecard)'],
@@ -132,7 +134,7 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup multi-agent safety in
132134
musafety doctor
133135
134136
4) Confirm next safe agent workflow commands:
135-
bash scripts/agent-branch-start.sh "task" "agent-name"
137+
musafety sandbox "task" "agent-name"
136138
python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
137139
bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
138140
@@ -150,7 +152,7 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup multi-agent safety in
150152
const AI_SETUP_COMMANDS = `npm i -g musafety
151153
musafety setup
152154
musafety doctor
153-
bash scripts/agent-branch-start.sh "task" "agent-name"
155+
musafety sandbox "task" "agent-name"
154156
python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
155157
bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
156158
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
@@ -462,6 +464,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
462464
}
463465

464466
const wantedScripts = {
467+
'agent:sandbox': `${TOOL_NAME} sandbox`,
465468
'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
466469
'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
467470
'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh --base dev',
@@ -1067,6 +1070,131 @@ function parseSyncArgs(rawArgs) {
10671070
return options;
10681071
}
10691072

1073+
function parseSandboxArgs(rawArgs) {
1074+
const options = {
1075+
target: process.cwd(),
1076+
task: 'task',
1077+
agent: 'agent',
1078+
base: '',
1079+
worktreeRoot: '.omx/agent-worktrees',
1080+
allowNonBase: false,
1081+
json: false,
1082+
};
1083+
1084+
const positional = [];
1085+
1086+
for (let index = 0; index < rawArgs.length; index += 1) {
1087+
const arg = rawArgs[index];
1088+
if (arg === '--target') {
1089+
const next = rawArgs[index + 1];
1090+
if (!next) {
1091+
throw new Error('--target requires a path value');
1092+
}
1093+
options.target = next;
1094+
index += 1;
1095+
continue;
1096+
}
1097+
if (arg === '--task') {
1098+
const next = rawArgs[index + 1];
1099+
if (!next) {
1100+
throw new Error('--task requires a value');
1101+
}
1102+
options.task = next;
1103+
index += 1;
1104+
continue;
1105+
}
1106+
if (arg === '--agent') {
1107+
const next = rawArgs[index + 1];
1108+
if (!next) {
1109+
throw new Error('--agent requires a value');
1110+
}
1111+
options.agent = next;
1112+
index += 1;
1113+
continue;
1114+
}
1115+
if (arg === '--base') {
1116+
const next = rawArgs[index + 1];
1117+
if (!next) {
1118+
throw new Error('--base requires a branch value');
1119+
}
1120+
options.base = next;
1121+
index += 1;
1122+
continue;
1123+
}
1124+
if (arg === '--worktree-root') {
1125+
const next = rawArgs[index + 1];
1126+
if (!next) {
1127+
throw new Error('--worktree-root requires a path value');
1128+
}
1129+
options.worktreeRoot = next;
1130+
index += 1;
1131+
continue;
1132+
}
1133+
if (arg === '--allow-non-base') {
1134+
options.allowNonBase = true;
1135+
continue;
1136+
}
1137+
if (arg === '--json') {
1138+
options.json = true;
1139+
continue;
1140+
}
1141+
if (arg.startsWith('-')) {
1142+
throw new Error(`Unknown option: ${arg}`);
1143+
}
1144+
positional.push(arg);
1145+
}
1146+
1147+
if (positional.length > 2) {
1148+
throw new Error(`Unexpected argument: ${positional[2]}`);
1149+
}
1150+
if (positional[0] && options.task === 'task') {
1151+
options.task = positional[0];
1152+
}
1153+
if (positional[1] && options.agent === 'agent') {
1154+
options.agent = positional[1];
1155+
}
1156+
if (!options.target) {
1157+
throw new Error('--target requires a path value');
1158+
}
1159+
1160+
return options;
1161+
}
1162+
1163+
function resolveSandboxBaseBranch(repoRoot, explicitBase) {
1164+
if (explicitBase) {
1165+
return explicitBase.trim();
1166+
}
1167+
1168+
const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
1169+
if (configured) {
1170+
return configured;
1171+
}
1172+
1173+
const current = currentBranchName(repoRoot);
1174+
if (current && current !== 'HEAD' && !current.startsWith('agent/')) {
1175+
return current;
1176+
}
1177+
1178+
if (gitRefExists(repoRoot, 'refs/heads/main') || gitRefExists(repoRoot, 'refs/remotes/origin/main')) {
1179+
return 'main';
1180+
}
1181+
1182+
return DEFAULT_BASE_BRANCH;
1183+
}
1184+
1185+
function parseSandboxStartOutput(stdout) {
1186+
const out = String(stdout || '');
1187+
const branchMatch = out.match(/^\[agent-branch-start\] Created branch:\s*(.+)$/m);
1188+
const worktreeMatch = out.match(/^\[agent-branch-start\] Worktree:\s*(.+)$/m);
1189+
if (!branchMatch || !worktreeMatch) {
1190+
throw new Error(`Unable to parse agent sandbox output:\n${out.trim()}`);
1191+
}
1192+
return {
1193+
branch: branchMatch[1].trim(),
1194+
worktreePath: worktreeMatch[1].trim(),
1195+
};
1196+
}
1197+
10701198
function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
10711199
if (strategy === 'rebase') {
10721200
if (ffOnly) {
@@ -2081,6 +2209,70 @@ function copyCommands() {
20812209
process.exitCode = 0;
20822210
}
20832211

2212+
function sandbox(rawArgs) {
2213+
const options = parseSandboxArgs(rawArgs);
2214+
const repoRoot = resolveRepoRoot(options.target);
2215+
const startScript = path.join(repoRoot, 'scripts', 'agent-branch-start.sh');
2216+
if (!fs.existsSync(startScript)) {
2217+
throw new Error(`Missing scripts/agent-branch-start.sh in target repo. Run '${TOOL_NAME} setup' first.`);
2218+
}
2219+
2220+
const baseBranch = resolveSandboxBaseBranch(repoRoot, options.base);
2221+
const visibleBranchBefore = currentBranchName(repoRoot);
2222+
2223+
if (!options.allowNonBase && visibleBranchBefore !== baseBranch) {
2224+
throw new Error(
2225+
`Sandbox expects visible repo branch '${baseBranch}' but current branch is '${visibleBranchBefore}'. ` +
2226+
`Switch first, or pass --allow-non-base to override.`,
2227+
);
2228+
}
2229+
2230+
const startArgs = [
2231+
'scripts/agent-branch-start.sh',
2232+
'--task', options.task,
2233+
'--agent', options.agent,
2234+
'--base', baseBranch,
2235+
'--worktree-root', options.worktreeRoot,
2236+
];
2237+
2238+
const started = run('bash', startArgs, { cwd: repoRoot });
2239+
if (started.status !== 0) {
2240+
throw new Error((started.stderr || started.stdout || 'Sandbox start failed').trim());
2241+
}
2242+
2243+
const parsed = parseSandboxStartOutput(started.stdout || '');
2244+
const visibleBranchAfter = currentBranchName(repoRoot);
2245+
if (visibleBranchAfter !== visibleBranchBefore) {
2246+
throw new Error(
2247+
`Sandbox changed visible repo branch from '${visibleBranchBefore}' to '${visibleBranchAfter}', which is not allowed.`,
2248+
);
2249+
}
2250+
2251+
const payload = {
2252+
repoRoot,
2253+
baseBranch,
2254+
visibleBranch: visibleBranchAfter,
2255+
branch: parsed.branch,
2256+
worktreePath: parsed.worktreePath,
2257+
};
2258+
2259+
if (options.json) {
2260+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
2261+
} else {
2262+
console.log(`[${TOOL_NAME}] Sandbox ready.`);
2263+
console.log(`[${TOOL_NAME}] Visible repo branch: ${visibleBranchAfter}`);
2264+
console.log(`[${TOOL_NAME}] Base branch: ${baseBranch}`);
2265+
console.log(`[${TOOL_NAME}] Agent branch: ${parsed.branch}`);
2266+
console.log(`[${TOOL_NAME}] Sandbox worktree: ${parsed.worktreePath}`);
2267+
console.log(`[${TOOL_NAME}] Open a sandbox terminal:`);
2268+
console.log(` cd "${parsed.worktreePath}"`);
2269+
console.log(` # commit + push from sandbox, then finish:`);
2270+
console.log(` bash scripts/agent-branch-finish.sh --branch "${parsed.branch}"`);
2271+
}
2272+
2273+
process.exitCode = 0;
2274+
}
2275+
20842276
function sync(rawArgs) {
20852277
const options = parseSyncArgs(rawArgs);
20862278
const repoRoot = resolveRepoRoot(options.target);
@@ -2394,6 +2586,11 @@ function main() {
23942586
return;
23952587
}
23962588

2589+
if (command === 'sandbox') {
2590+
sandbox(rest);
2591+
return;
2592+
}
2593+
23972594
if (command === 'setup') {
23982595
setup(rest);
23992596
return;

0 commit comments

Comments
 (0)