diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 44f36ff..6db0df4 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -30,7 +30,7 @@ fi allow_vscode_protected_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH_WRITES:-$(git config --get multiagent.allowVscodeProtectedBranchWrites || true)}" if [[ -z "$allow_vscode_protected_raw" ]]; then - allow_vscode_protected_raw="true" + allow_vscode_protected_raw="false" fi allow_vscode_protected="$(printf '%s' "$allow_vscode_protected_raw" | tr '[:upper:]' '[:lower:]')" @@ -55,15 +55,6 @@ for protected_branch in $protected_branches_raw; do fi done -is_local_only_branch=0 -if [[ "$is_protected_branch" == "1" ]]; then - upstream_ref="$(git for-each-ref --format='%(upstream:short)' "refs/heads/${branch}" | head -n 1)" - remote_branch_ref="$(git for-each-ref --format='%(refname:short)' "refs/remotes/*/${branch}" | head -n 1)" - if [[ -z "$upstream_ref" && -z "$remote_branch_ref" ]]; then - is_local_only_branch=1 - fi -fi - codex_require_agent_branch_raw="${MUSAFETY_CODEX_REQUIRE_AGENT_BRANCH:-$(git config --get multiagent.codexRequireAgentBranch || true)}" if [[ -z "$codex_require_agent_branch_raw" ]]; then codex_require_agent_branch_raw="true" @@ -134,7 +125,7 @@ fi if [[ "$is_protected_branch" == "1" ]]; then if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then - if [[ "$allow_vscode_protected_branch_writes" == "1" || "$is_local_only_branch" == "1" ]]; then + if [[ "$allow_vscode_protected_branch_writes" == "1" ]]; then exit 0 fi fi @@ -155,11 +146,8 @@ Use an agent branch first: After finishing work: bash scripts/agent-branch-finish.sh -Optional repo hard-block for VS Code protected-branch commits: - git config multiagent.allowVscodeProtectedBranchWrites false - -VS Code Source Control commits on protected local-only branches -(no upstream and no remote branch) are allowed automatically. +Optional repo opt-in for VS Code protected-branch commits: + git config multiagent.allowVscodeProtectedBranchWrites true Temporary bypass (not recommended): ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... diff --git a/.githooks/pre-push b/.githooks/pre-push index 80a3240..4063cf3 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -12,7 +12,7 @@ fi allow_vscode_protected_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH_WRITES:-$(git config --get multiagent.allowVscodeProtectedBranchWrites || true)}" if [[ -z "$allow_vscode_protected_raw" ]]; then - allow_vscode_protected_raw="true" + allow_vscode_protected_raw="false" fi allow_vscode_protected="$(printf '%s' "$allow_vscode_protected_raw" | tr '[:upper:]' '[:lower:]')" @@ -77,8 +77,8 @@ if [[ "${#blocked_refs[@]}" -gt 0 ]]; then echo "[agent-branch-guard] Push to protected branch blocked." echo "[agent-branch-guard] Protected target(s): ${blocked_refs[*]}" echo "[agent-branch-guard] Use an agent branch and merge via PR." - echo "[agent-branch-guard] Optional repo hard-block for VS Code protected-branch push:" - echo " git config multiagent.allowVscodeProtectedBranchWrites false" + echo "[agent-branch-guard] Optional repo opt-in for VS Code protected-branch push:" + echo " git config multiagent.allowVscodeProtectedBranchWrites true" echo echo "Temporary bypass (not recommended):" echo " ALLOW_PUSH_ON_PROTECTED_BRANCH=1 git push ..." diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index d449a2e..5acfaac 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -56,6 +56,26 @@ const TEMPLATE_FILES = [ 'github/workflows/cr.yml', ]; +const REQUIRED_WORKFLOW_FILES = [ + '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', + '.githooks/pre-commit', + '.omx/state/agent-file-locks.json', +]; + +const REQUIRED_PACKAGE_SCRIPTS = { + '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', + 'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh', + 'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim', + 'agent:locks:release': 'python3 ./scripts/agent-file-locks.py release', + 'agent:locks:status': 'python3 ./scripts/agent-file-locks.py status', +}; + const EXECUTABLE_RELATIVE_PATHS = new Set([ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', @@ -696,7 +716,7 @@ function ensurePackageScripts(repoRoot, dryRun) { pkg.scripts = pkg.scripts || {}; let changed = false; - for (const [key, value] of Object.entries(wantedScripts)) { + for (const [key, value] of Object.entries(REQUIRED_PACKAGE_SCRIPTS)) { if (pkg.scripts[key] !== value) { pkg.scripts[key] = value; changed = true; @@ -809,8 +829,8 @@ function parseCommonArgs(rawArgs, defaults) { for (let index = 0; index < rawArgs.length; index += 1) { const arg = rawArgs[index]; - if (arg === '--target') { - options.target = rawArgs[index + 1]; + if (arg === '--target' || arg === '-t') { + options.target = requireValue(rawArgs, index, '--target'); index += 1; continue; } @@ -2367,10 +2387,6 @@ function parseSyncArgs(rawArgs) { throw new Error(`Unknown option: ${arg}`); } - if (!options.target) { - throw new Error('--target requires a path value'); - } - return options; } @@ -4272,6 +4288,238 @@ function release(rawArgs) { process.exitCode = 0; } +function installMany(rawArgs) { + const options = parseInstallManyArgs(rawArgs); + const targets = collectInstallManyTargets(options); + + if (!targets.length) { + throw new Error('install-many did not find any targets to process.'); + } + + if (options.usedImplicitWorkspaceDefault) { + console.log( + `[multiagent-safety] No explicit targets provided. Defaulting to workspace scan: ${path.resolve( + options.workspace, + )} (max depth ${options.maxDepth})`, + ); + } + + console.log( + `[multiagent-safety] install-many starting for ${targets.length} target path(s)${ + options.dryRun ? ' [dry-run]' : '' + }`, + ); + + let installed = 0; + let duplicateRepos = 0; + const seenRepoRoots = new Set(); + const failures = []; + + for (const targetPath of targets) { + let repoRoot; + try { + repoRoot = resolveRepoRoot(targetPath); + } catch (error) { + failures.push({ target: targetPath, message: error.message }); + if (options.failFast) { + break; + } + continue; + } + + if (seenRepoRoots.has(repoRoot)) { + duplicateRepos += 1; + console.log(`[multiagent-safety] Skipping duplicate repo target: ${targetPath} -> ${repoRoot}`); + continue; + } + + seenRepoRoots.add(repoRoot); + + try { + const report = installIntoRepoRoot(repoRoot, options); + printInstallReport(report); + installed += 1; + } catch (error) { + failures.push({ target: repoRoot, message: error.message }); + if (options.failFast) { + break; + } + } + } + + console.log( + `[multiagent-safety] install-many summary: installed=${installed}, failures=${failures.length}, duplicate-targets=${duplicateRepos}`, + ); + + if (failures.length > 0) { + console.error('[multiagent-safety] Failed targets:'); + for (const failure of failures) { + console.error(` - ${failure.target}`); + console.error(` ${failure.message}`); + } + throw new Error(`install-many completed with ${failures.length} failure(s)`); + } + + if (options.dryRun) { + console.log('[multiagent-safety] Dry run complete. No files were modified.'); + } else { + console.log('[multiagent-safety] Installed multi-agent safety workflow across all targets.'); + } +} + +function initWorkspace(rawArgs) { + const options = parseInitWorkspaceArgs(rawArgs); + const resolvedWorkspace = path.resolve(options.workspace); + const repos = discoverGitRepos(resolvedWorkspace, options.maxDepth) + .map((repoPath) => path.resolve(repoPath)) + .sort(); + + const outputPath = options.output + ? path.resolve(options.output) + : path.join(resolvedWorkspace, DEFAULT_WORKSPACE_TARGETS_FILE); + + if (fs.existsSync(outputPath) && !options.force) { + throw new Error(`Refusing to overwrite existing file without --force: ${outputPath}`); + } + + const headerLines = [ + '# multiagent-safety workspace targets', + `# generated: ${new Date().toISOString()}`, + `# workspace: ${resolvedWorkspace}`, + `# max-depth: ${options.maxDepth}`, + '#', + '# Run:', + `# multiagent-safety install-many --targets-file "${outputPath}"`, + '', + ]; + const content = `${headerLines.join('\n')}${repos.join('\n')}${repos.length ? '\n' : ''}`; + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, content, 'utf8'); + + console.log(`[multiagent-safety] Workspace target file written: ${outputPath}`); + console.log(`[multiagent-safety] Repos discovered: ${repos.length}`); + if (repos.length === 0) { + console.log('[multiagent-safety] No git repos found. You can add target paths manually to the file.'); + } else { + console.log(`[multiagent-safety] Next step: multiagent-safety install-many --targets-file "${outputPath}"`); + } +} + +function doctor(rawArgs) { + const options = parseDoctorArgs(rawArgs); + const repoRoot = resolveRepoRoot(options.target); + const failures = []; + const warnings = []; + + function ok(message) { + console.log(` [ok] ${message}`); + } + function warn(message) { + warnings.push(message); + console.log(` [warn] ${message}`); + } + function fail(message) { + failures.push(message); + console.log(` [fail] ${message}`); + } + + console.log(`[multiagent-safety] doctor target: ${repoRoot}`); + + const hooksPath = run('git', ['-C', repoRoot, 'config', '--get', 'core.hooksPath']); + if (hooksPath.status !== 0) { + fail('git core.hooksPath is not configured'); + } else if (hooksPath.stdout.trim() !== '.githooks') { + fail(`git core.hooksPath is "${hooksPath.stdout.trim()}" (expected ".githooks")`); + } else { + ok('git core.hooksPath is .githooks'); + } + + for (const relativePath of REQUIRED_WORKFLOW_FILES) { + const absolutePath = path.join(repoRoot, relativePath); + if (!fs.existsSync(absolutePath)) { + fail(`missing ${relativePath}`); + continue; + } + ok(`found ${relativePath}`); + + if (EXECUTABLE_RELATIVE_PATHS.has(relativePath)) { + try { + fs.accessSync(absolutePath, fs.constants.X_OK); + } catch { + fail(`${relativePath} exists but is not executable`); + } + } + } + + const lockFilePath = path.join(repoRoot, '.omx/state/agent-file-locks.json'); + if (fs.existsSync(lockFilePath)) { + try { + const parsed = JSON.parse(fs.readFileSync(lockFilePath, 'utf8')); + if (!parsed || typeof parsed !== 'object' || typeof parsed.locks !== 'object') { + fail('.omx/state/agent-file-locks.json does not contain a valid { locks: {} } object'); + } else { + ok('lock registry JSON is valid'); + } + } catch (error) { + fail(`lock registry JSON is invalid: ${error.message}`); + } + } + + const packagePath = path.join(repoRoot, 'package.json'); + if (!fs.existsSync(packagePath)) { + warn('package.json not found (npm helper scripts cannot be verified)'); + } else { + try { + const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + const scripts = pkg.scripts || {}; + for (const [name, expectedValue] of Object.entries(REQUIRED_PACKAGE_SCRIPTS)) { + if (scripts[name] !== expectedValue) { + fail(`package.json script mismatch for "${name}"`); + } else { + ok(`package.json script "${name}" is configured`); + } + } + } catch (error) { + fail(`package.json is invalid JSON: ${error.message}`); + } + } + + const agentsPath = path.join(repoRoot, 'AGENTS.md'); + if (!fs.existsSync(agentsPath)) { + warn('AGENTS.md not found (multi-agent contract snippet not present)'); + } else { + const agentsContent = fs.readFileSync(agentsPath, 'utf8'); + if (!agentsContent.includes(AGENTS_MARKER_START)) { + warn('AGENTS.md exists but multiagent-safety snippet marker is missing'); + } else { + ok('AGENTS.md contains multiagent-safety snippet marker'); + } + } + + if (warnings.length) { + console.log(`[multiagent-safety] warnings: ${warnings.length}`); + } + if (failures.length) { + console.log(`[multiagent-safety] failures: ${failures.length}`); + } + + if (failures.length === 0 && (!options.strict || warnings.length === 0)) { + console.log('[multiagent-safety] doctor passed.'); + if (warnings.length > 0) { + console.log('[multiagent-safety] tip: run with --strict to treat warnings as failures.'); + } + return; + } + + if (options.strict && warnings.length > 0 && failures.length === 0) { + console.log('[multiagent-safety] strict mode failed due to warnings.'); + } else { + console.log('[multiagent-safety] doctor failed.'); + } + throw new Error('doctor detected configuration issues'); +} + function printAgentsSnippet() { const snippetPath = path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md'); process.stdout.write(fs.readFileSync(snippetPath, 'utf8')); diff --git a/codex-action b/codex-action new file mode 160000 index 0000000..48c4212 --- /dev/null +++ b/codex-action @@ -0,0 +1 @@ +Subproject commit 48c4212272635ce5c50529ae1f6516040f84dc35 diff --git a/templates/githooks/pre-commit b/templates/githooks/pre-commit index 44f36ff..3a78018 100755 --- a/templates/githooks/pre-commit +++ b/templates/githooks/pre-commit @@ -30,7 +30,7 @@ fi allow_vscode_protected_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH_WRITES:-$(git config --get multiagent.allowVscodeProtectedBranchWrites || true)}" if [[ -z "$allow_vscode_protected_raw" ]]; then - allow_vscode_protected_raw="true" + allow_vscode_protected_raw="false" fi allow_vscode_protected="$(printf '%s' "$allow_vscode_protected_raw" | tr '[:upper:]' '[:lower:]')" @@ -55,15 +55,6 @@ for protected_branch in $protected_branches_raw; do fi done -is_local_only_branch=0 -if [[ "$is_protected_branch" == "1" ]]; then - upstream_ref="$(git for-each-ref --format='%(upstream:short)' "refs/heads/${branch}" | head -n 1)" - remote_branch_ref="$(git for-each-ref --format='%(refname:short)' "refs/remotes/*/${branch}" | head -n 1)" - if [[ -z "$upstream_ref" && -z "$remote_branch_ref" ]]; then - is_local_only_branch=1 - fi -fi - codex_require_agent_branch_raw="${MUSAFETY_CODEX_REQUIRE_AGENT_BRANCH:-$(git config --get multiagent.codexRequireAgentBranch || true)}" if [[ -z "$codex_require_agent_branch_raw" ]]; then codex_require_agent_branch_raw="true" @@ -134,7 +125,7 @@ fi if [[ "$is_protected_branch" == "1" ]]; then if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then - if [[ "$allow_vscode_protected_branch_writes" == "1" || "$is_local_only_branch" == "1" ]]; then + if [[ "$allow_vscode_protected_branch_writes" == "1" ]]; then exit 0 fi fi @@ -155,11 +146,21 @@ Use an agent branch first: After finishing work: bash scripts/agent-branch-finish.sh -Optional repo hard-block for VS Code protected-branch commits: - git config multiagent.allowVscodeProtectedBranchWrites false +Optional repo opt-in for VS Code protected-branch commits: + git config multiagent.allowVscodeProtectedBranchWrites true + +Temporary bypass (not recommended): + ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... +MSG + exit 1 +fi -VS Code Source Control commits on protected local-only branches -(no upstream and no remote branch) are allowed automatically. +if [[ "$is_agent_context" == "1" && "$branch" != agent/* ]]; then + cat >&2 <<'MSG' +[agent-branch-guard] Agent commits must run on dedicated agent/* branches. +Start an agent branch first: + bash scripts/agent-branch-start.sh "" "" +Then commit on that branch. Temporary bypass (not recommended): ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... @@ -168,6 +169,14 @@ MSG fi if [[ "$branch" == agent/* ]]; then + if [[ "${MUSAFETY_AUTOCLAIM_STAGED_LOCKS:-1}" == "1" ]]; then + while IFS= read -r staged_file; do + [[ -z "$staged_file" ]] && continue + [[ "$staged_file" == ".omx/state/agent-file-locks.json" ]] && continue + python3 scripts/agent-file-locks.py claim --branch "$branch" "$staged_file" >/dev/null 2>&1 || true + done < <(git diff --cached --name-only --diff-filter=ACMRDTUXB) + fi + if ! python3 scripts/agent-file-locks.py validate --branch "$branch" --staged; then cat >&2 <<'MSG' [agent-branch-guard] Agent branch commits require file ownership locks. diff --git a/templates/githooks/pre-push b/templates/githooks/pre-push index 80a3240..4063cf3 100644 --- a/templates/githooks/pre-push +++ b/templates/githooks/pre-push @@ -12,7 +12,7 @@ fi allow_vscode_protected_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH_WRITES:-$(git config --get multiagent.allowVscodeProtectedBranchWrites || true)}" if [[ -z "$allow_vscode_protected_raw" ]]; then - allow_vscode_protected_raw="true" + allow_vscode_protected_raw="false" fi allow_vscode_protected="$(printf '%s' "$allow_vscode_protected_raw" | tr '[:upper:]' '[:lower:]')" @@ -77,8 +77,8 @@ if [[ "${#blocked_refs[@]}" -gt 0 ]]; then echo "[agent-branch-guard] Push to protected branch blocked." echo "[agent-branch-guard] Protected target(s): ${blocked_refs[*]}" echo "[agent-branch-guard] Use an agent branch and merge via PR." - echo "[agent-branch-guard] Optional repo hard-block for VS Code protected-branch push:" - echo " git config multiagent.allowVscodeProtectedBranchWrites false" + echo "[agent-branch-guard] Optional repo opt-in for VS Code protected-branch push:" + echo " git config multiagent.allowVscodeProtectedBranchWrites true" echo echo "Temporary bypass (not recommended):" echo " ALLOW_PUSH_ON_PROTECTED_BRANCH=1 git push ..." diff --git a/test/install.test.js b/test/install.test.js index 8c65937..fa6b2ce 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -90,10 +90,12 @@ function initRepo() { result = runCmd('git', ['config', 'user.name', 'Bot'], repoDir); assert.equal(result.status, 0, result.stderr); - fs.writeFileSync( - path.join(repoDir, 'package.json'), - JSON.stringify({ name: 'demo', private: true, scripts: {} }, null, 2) + '\n', - ); + if (withPackageJson) { + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ name: path.basename(repoDir), private: true, scripts: {} }, null, 2) + '\n', + ); + } return repoDir; } @@ -1613,10 +1615,11 @@ test('pre-commit blocks non-codex VS Code commits on custom protected branches b ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0', VSCODE_GIT_IPC_HANDLE: '1', }); - assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); + assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); + assert.match(hookResult.stderr, /\[agent-branch-guard\] Direct commits on protected branches are blocked\./); }); -test('pre-commit allows non-codex protected branch commits from VS Code Source Control env by default', () => { +test('pre-commit blocks non-codex protected branch commits from VS Code Source Control env by default', () => { const repoDir = initRepo(); seedCommit(repoDir); attachOriginRemote(repoDir); @@ -1635,10 +1638,11 @@ test('pre-commit allows non-codex protected branch commits from VS Code Source C VSCODE_IPC_HOOK_CLI: '1', }, ); - assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); + assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); + assert.match(hookResult.stderr, /\[agent-branch-guard\] Direct commits on protected branches are blocked\./); }); -test('pre-commit allows non-codex VS Code commits on protected local-only branches', () => { +test('pre-commit blocks non-codex VS Code commits on protected local-only branches by default', () => { const repoDir = initRepo(); seedCommit(repoDir); @@ -1656,7 +1660,8 @@ test('pre-commit allows non-codex VS Code commits on protected local-only branch VSCODE_IPC_HOOK_CLI: '1', }, ); - assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); + assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); + assert.match(hookResult.stderr, /\[agent-branch-guard\] Direct commits on protected branches are blocked\./); }); test('pre-commit blocks codex commits on protected local-only branches even from VS Code Source Control env', () => { @@ -1702,7 +1707,8 @@ test('pre-push blocks non-codex protected branch pushes from VS Code Source Cont VSCODE_IPC_HOOK_CLI: '1', }, ); - assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); + assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); + assert.match(hookResult.stderr, /\[agent-branch-guard\] Push to protected branch blocked\./); }); test('pre-commit blocks non-codex protected branch commits from VS Code Source Control env when explicitly disabled', () => { @@ -1772,7 +1778,7 @@ test('pre-push allows non-codex protected branch pushes from VS Code Source Cont let configResult = runCmd( 'git', - ['config', 'multiagent.allowVscodeProtectedBranchWrites', 'false'], + ['config', 'multiagent.allowVscodeProtectedBranchWrites', 'true'], repoDir, ); assert.equal(configResult.status, 0, configResult.stderr || configResult.stdout); @@ -1790,8 +1796,7 @@ test('pre-push allows non-codex protected branch pushes from VS Code Source Cont VSCODE_IPC_HOOK_CLI: '1', }, ); - assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); - assert.match(hookResult.stderr, /\[agent-branch-guard\] Push to protected branch blocked\./); + assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); }); test('pre-push blocks codex protected branch pushes even from VS Code Source Control env', () => {