Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 4 additions & 16 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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:]')"

Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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 ...
Expand Down
6 changes: 3 additions & 3 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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:]')"

Expand Down Expand Up @@ -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 ..."
Expand Down
262 changes: 255 additions & 7 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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'));
Expand Down
1 change: 1 addition & 0 deletions codex-action
Submodule codex-action added at 48c421
Loading