From eacb637606bc3c67dee197cc7384ce9dd4cfb26d Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 10 Apr 2026 22:45:18 +0200 Subject: [PATCH] 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 --- .gitignore | 15 ++- AGENTS.md | 61 ++++++++++++ README.md | 7 +- bin/multiagent-safety.js | 201 ++++++++++++++++++++++++++++++++++++++- package.json | 19 +++- test/install.test.js | 64 +++++++++++++ 6 files changed, 361 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 8be440a..63b6ffc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,15 @@ .omx/ -node_modules \ No newline at end of file +node_modules + +# multiagent-safety:START +scripts/agent-branch-start.sh +scripts/agent-branch-finish.sh +scripts/agent-worktree-prune.sh +scripts/agent-file-locks.py +scripts/install-agent-git-hooks.sh +scripts/openspec/init-plan-workspace.sh +.githooks/pre-commit +.codex/skills/musafety/SKILL.md +.claude/commands/musafety.md +.omx/state/agent-file-locks.json +# multiagent-safety:END diff --git a/AGENTS.md b/AGENTS.md index 7c9cb3f..b753ab3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,3 +74,64 @@ OMX runtime state typically lives under `.omx/`: - `.omx/project-memory.json` - `.omx/plans/` - `.omx/logs/` + + +## Multi-Agent Execution Contract (multiagent-safety) + +0. Session plan comment + read gate (required) + +- Before editing, each agent must post a short session comment/handoff note that includes: + - plan/change name (or checkpoint id), + - owned files/scope, + - intended action. +- Before deleting/replacing code, each agent must read the latest session comments/handoffs first and confirm the target code is in their owned scope. +- If ownership is unclear or overlaps, stop that edit, post a blocker comment, and let the leader/integrator reassign scope. +- For git isolation, each agent must start on a dedicated branch via `scripts/agent-branch-start.sh "" ""`. +- Agent completion must use `scripts/agent-branch-finish.sh` (merge into `dev`, push, delete agent branch). + +1. Explicit ownership before edits + +- Assign each agent clear file/module ownership. +- Do not edit files outside your assigned scope unless the leader reassigns ownership. + +2. Preserve parallel safety + +- Assume other agents are editing nearby code concurrently. +- Never revert unrelated changes authored by others. +- If another change conflicts with your approach, adapt and report the conflict in handoff. + +3. Verify before completion + +- Run required local checks for the area you changed. +- Do not mark work complete without command output evidence. + +4. Required handoff format (every agent) + +- Files changed +- Behavior touched +- Verification commands + results +- Risks / follow-ups + +## OpenSpec Plan Workspace (recommended) + +When work needs a durable planning phase, scaffold a plan workspace before implementation: + +```bash +bash scripts/openspec/init-plan-workspace.sh "" +``` + +Expected shape: + +```text +openspec/plan// + summary.md + checkpoints.md + planner/plan.md + planner/tasks.md + architect/tasks.md + critic/tasks.md + executor/tasks.md + writer/tasks.md + verifier/tasks.md +``` + diff --git a/README.md b/README.md index e9c90b7..76a1f84 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Example output: npm i -g musafety musafety setup musafety doctor -bash scripts/agent-branch-start.sh "task" "agent-name" +musafety sandbox "task" "agent-name" python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/openspec/init-plan-workspace.sh "" @@ -157,7 +157,7 @@ Use this exact checklist to setup multi-agent safety in this repository for Code musafety doctor 4) Confirm next safe agent workflow commands: - bash scripts/agent-branch-start.sh "task" "agent-name" + musafety sandbox "task" "agent-name" python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" @@ -176,6 +176,7 @@ Use this exact checklist to setup multi-agent safety in this repository for Code ```sh musafety status [--target ] [--json] +musafety sandbox [task] [agent] [--target ] [--base ] [--worktree-root ] [--allow-non-base] [--json] musafety setup [--target ] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore] musafety doctor [--target ] [--dry-run] [--json] [--keep-stale-locks] [--no-gitignore] musafety copy-prompt @@ -192,6 +193,8 @@ bash scripts/agent-worktree-prune.sh --base dev # manual stale worktree cleanu bash scripts/openspec/init-plan-workspace.sh # optional OpenSpec plan scaffold ``` +`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. + No command defaults to `musafety status` (non-mutating health/status view). `musafety status` reports CLI/runtime info, global OMX/OpenSpec service status, and repo safety service state. When run in an interactive terminal, default `musafety` checks npm for a newer version first diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 5bbbf87..6e7a359 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -82,6 +82,7 @@ const COMMAND_TYPO_ALIASES = new Map([ ]); const SUGGESTIBLE_COMMANDS = [ 'status', + 'sandbox', 'setup', 'doctor', 'report', @@ -99,6 +100,7 @@ const SUGGESTIBLE_COMMANDS = [ ]; const CLI_COMMAND_DESCRIPTIONS = [ ['status', 'Show musafety CLI + service health without modifying files'], + ['sandbox', 'Create an isolated agent worktree sandbox while keeping visible repo branch unchanged'], ['setup', 'Install + repair guardrails in a git repo (supports --no-gitignore)'], ['doctor', 'Repair safety setup drift, then verify repo safety'], ['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 musafety doctor 4) Confirm next safe agent workflow commands: - bash scripts/agent-branch-start.sh "task" "agent-name" + musafety sandbox "task" "agent-name" python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" @@ -150,7 +152,7 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup multi-agent safety in const AI_SETUP_COMMANDS = `npm i -g musafety musafety setup musafety doctor -bash scripts/agent-branch-start.sh "task" "agent-name" +musafety sandbox "task" "agent-name" python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/openspec/init-plan-workspace.sh "" @@ -462,6 +464,7 @@ function ensurePackageScripts(repoRoot, dryRun) { } const wantedScripts = { + 'agent:sandbox': `${TOOL_NAME} sandbox`, 'agent:branch:start': 'bash ./scripts/agent-branch-start.sh', 'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh', 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh --base dev', @@ -1067,6 +1070,131 @@ function parseSyncArgs(rawArgs) { return options; } +function parseSandboxArgs(rawArgs) { + const options = { + target: process.cwd(), + task: 'task', + agent: 'agent', + base: '', + worktreeRoot: '.omx/agent-worktrees', + allowNonBase: false, + json: false, + }; + + const positional = []; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--target requires a path value'); + } + options.target = next; + index += 1; + continue; + } + if (arg === '--task') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--task requires a value'); + } + options.task = next; + index += 1; + continue; + } + if (arg === '--agent') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--agent requires a value'); + } + options.agent = next; + index += 1; + continue; + } + if (arg === '--base') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--base requires a branch value'); + } + options.base = next; + index += 1; + continue; + } + if (arg === '--worktree-root') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--worktree-root requires a path value'); + } + options.worktreeRoot = next; + index += 1; + continue; + } + if (arg === '--allow-non-base') { + options.allowNonBase = true; + continue; + } + if (arg === '--json') { + options.json = true; + continue; + } + if (arg.startsWith('-')) { + throw new Error(`Unknown option: ${arg}`); + } + positional.push(arg); + } + + if (positional.length > 2) { + throw new Error(`Unexpected argument: ${positional[2]}`); + } + if (positional[0] && options.task === 'task') { + options.task = positional[0]; + } + if (positional[1] && options.agent === 'agent') { + options.agent = positional[1]; + } + if (!options.target) { + throw new Error('--target requires a path value'); + } + + return options; +} + +function resolveSandboxBaseBranch(repoRoot, explicitBase) { + if (explicitBase) { + return explicitBase.trim(); + } + + const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY); + if (configured) { + return configured; + } + + const current = currentBranchName(repoRoot); + if (current && current !== 'HEAD' && !current.startsWith('agent/')) { + return current; + } + + if (gitRefExists(repoRoot, 'refs/heads/main') || gitRefExists(repoRoot, 'refs/remotes/origin/main')) { + return 'main'; + } + + return DEFAULT_BASE_BRANCH; +} + +function parseSandboxStartOutput(stdout) { + const out = String(stdout || ''); + const branchMatch = out.match(/^\[agent-branch-start\] Created branch:\s*(.+)$/m); + const worktreeMatch = out.match(/^\[agent-branch-start\] Worktree:\s*(.+)$/m); + if (!branchMatch || !worktreeMatch) { + throw new Error(`Unable to parse agent sandbox output:\n${out.trim()}`); + } + return { + branch: branchMatch[1].trim(), + worktreePath: worktreeMatch[1].trim(), + }; +} + function syncOperation(repoRoot, strategy, baseRef, ffOnly) { if (strategy === 'rebase') { if (ffOnly) { @@ -2081,6 +2209,70 @@ function copyCommands() { process.exitCode = 0; } +function sandbox(rawArgs) { + const options = parseSandboxArgs(rawArgs); + const repoRoot = resolveRepoRoot(options.target); + const startScript = path.join(repoRoot, 'scripts', 'agent-branch-start.sh'); + if (!fs.existsSync(startScript)) { + throw new Error(`Missing scripts/agent-branch-start.sh in target repo. Run '${TOOL_NAME} setup' first.`); + } + + const baseBranch = resolveSandboxBaseBranch(repoRoot, options.base); + const visibleBranchBefore = currentBranchName(repoRoot); + + if (!options.allowNonBase && visibleBranchBefore !== baseBranch) { + throw new Error( + `Sandbox expects visible repo branch '${baseBranch}' but current branch is '${visibleBranchBefore}'. ` + + `Switch first, or pass --allow-non-base to override.`, + ); + } + + const startArgs = [ + 'scripts/agent-branch-start.sh', + '--task', options.task, + '--agent', options.agent, + '--base', baseBranch, + '--worktree-root', options.worktreeRoot, + ]; + + const started = run('bash', startArgs, { cwd: repoRoot }); + if (started.status !== 0) { + throw new Error((started.stderr || started.stdout || 'Sandbox start failed').trim()); + } + + const parsed = parseSandboxStartOutput(started.stdout || ''); + const visibleBranchAfter = currentBranchName(repoRoot); + if (visibleBranchAfter !== visibleBranchBefore) { + throw new Error( + `Sandbox changed visible repo branch from '${visibleBranchBefore}' to '${visibleBranchAfter}', which is not allowed.`, + ); + } + + const payload = { + repoRoot, + baseBranch, + visibleBranch: visibleBranchAfter, + branch: parsed.branch, + worktreePath: parsed.worktreePath, + }; + + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } else { + console.log(`[${TOOL_NAME}] Sandbox ready.`); + console.log(`[${TOOL_NAME}] Visible repo branch: ${visibleBranchAfter}`); + console.log(`[${TOOL_NAME}] Base branch: ${baseBranch}`); + console.log(`[${TOOL_NAME}] Agent branch: ${parsed.branch}`); + console.log(`[${TOOL_NAME}] Sandbox worktree: ${parsed.worktreePath}`); + console.log(`[${TOOL_NAME}] Open a sandbox terminal:`); + console.log(` cd "${parsed.worktreePath}"`); + console.log(` # commit + push from sandbox, then finish:`); + console.log(` bash scripts/agent-branch-finish.sh --branch "${parsed.branch}"`); + } + + process.exitCode = 0; +} + function sync(rawArgs) { const options = parseSyncArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); @@ -2394,6 +2586,11 @@ function main() { return; } + if (command === 'sandbox') { + sandbox(rest); + return; + } + if (command === 'setup') { setup(rest); return; diff --git a/package.json b/package.json index 0b0feea..7fef6a7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,24 @@ "multiagent-safety": "bin/multiagent-safety.js" }, "scripts": { - "test": "node --test test/*.test.js" + "test": "node --test test/*.test.js", + "agent:sandbox": "musafety sandbox", + "agent:branch:start": "bash ./scripts/agent-branch-start.sh", + "agent:branch:finish": "bash ./scripts/agent-branch-finish.sh", + "agent:cleanup": "bash ./scripts/agent-worktree-prune.sh --base dev", + "agent:hooks:install": "bash ./scripts/install-agent-git-hooks.sh", + "agent:locks:claim": "python3 ./scripts/agent-file-locks.py claim", + "agent:locks:allow-delete": "python3 ./scripts/agent-file-locks.py allow-delete", + "agent:locks:release": "python3 ./scripts/agent-file-locks.py release", + "agent:locks:status": "python3 ./scripts/agent-file-locks.py status", + "agent:plan:init": "bash ./scripts/openspec/init-plan-workspace.sh", + "agent:protect:list": "musafety protect list", + "agent:branch:sync": "musafety sync", + "agent:branch:sync:check": "musafety sync --check", + "agent:safety:setup": "musafety setup", + "agent:safety:scan": "musafety scan", + "agent:safety:fix": "musafety fix", + "agent:safety:doctor": "musafety doctor" }, "engines": { "node": ">=18" diff --git a/test/install.test.js b/test/install.test.js index cb94abd..265422d 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -171,6 +171,7 @@ test('setup provisions workflow files and repo config', () => { } const packageJson = JSON.parse(fs.readFileSync(path.join(repoDir, 'package.json'), 'utf8')); + assert.equal(packageJson.scripts['agent:sandbox'], 'musafety sandbox'); assert.equal(packageJson.scripts['agent:branch:start'], 'bash ./scripts/agent-branch-start.sh'); assert.equal(packageJson.scripts['agent:plan:init'], 'bash ./scripts/openspec/init-plan-workspace.sh'); assert.equal(packageJson.scripts['agent:protect:list'], 'musafety protect list'); @@ -338,6 +339,69 @@ test('protect command manages configured protected branches', () => { assert.match(result.stdout, /reset to defaults/); }); +test('sandbox command creates isolated agent worktree and keeps visible checkout on base branch', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + attachOriginRemote(repoDir); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['add', '.'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['commit', '-m', 'apply musafety setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['push', 'origin', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr); + + const sandbox = runNode(['sandbox', 'terminal-flow', 'planner', '--target', repoDir], repoDir); + assert.equal(sandbox.status, 0, sandbox.stderr || sandbox.stdout); + assert.match(sandbox.stdout, /\[musafety\] Sandbox ready\./); + assert.match(sandbox.stdout, /Visible repo branch: dev/); + assert.match(sandbox.stdout, /Agent branch: (agent\/[^\s]+)/); + assert.match(sandbox.stdout, /Sandbox worktree: (.+)/); + + const branchMatch = sandbox.stdout.match(/Agent branch: (agent\/[^\s]+)/); + assert.ok(branchMatch, sandbox.stdout); + const worktreeMatch = sandbox.stdout.match(/Sandbox worktree: (.+)/); + assert.ok(worktreeMatch, sandbox.stdout); + const branchName = branchMatch[1]; + const worktreePath = worktreeMatch[1].trim(); + + assert.equal(fs.existsSync(worktreePath), true, `sandbox worktree missing: ${worktreePath}`); + const branchResult = runCmd('git', ['branch', '--show-current'], repoDir); + assert.equal(branchResult.status, 0, branchResult.stderr); + assert.equal(branchResult.stdout.trim(), 'dev'); + const branchRef = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], repoDir); + assert.equal(branchRef.status, 0, `missing created branch: ${branchName}`); +}); + +test('sandbox command blocks when visible checkout is not on base branch', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + attachOriginRemote(repoDir); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['add', '.'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['commit', '-m', 'apply musafety setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['push', 'origin', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr); + + result = runCmd('git', ['checkout', '-b', 'feature/local-experiment'], repoDir); + assert.equal(result.status, 0, result.stderr); + + const sandbox = runNode(['sandbox', 'terminal-flow', 'planner', '--target', repoDir, '--base', 'dev'], repoDir); + assert.equal(sandbox.status, 1, sandbox.stderr || sandbox.stdout); + assert.match(sandbox.stderr, /Sandbox expects visible repo branch 'dev'/); + assert.match(sandbox.stderr, /--allow-non-base/); +}); + test('pre-commit blocks custom protected branches configured via musafety protect', () => { const repoDir = initRepoOnBranch('release'); seedCommit(repoDir);