From a34669e22548ace0df48a978e6b22340376cafd1 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 10 Apr 2026 13:45:39 +0200 Subject: [PATCH 1/3] Prevent accidental in-place agent starts on protected branches The branch start helper now requires explicit --allow-in-place alongside --in-place, keeping the default path isolated in agent worktrees.\n\nAlso added regression tests for default worktree behavior and the in-place guard/override flow, and documented the explicit override in README. Constraint: Preserve existing default branch/worktree flow for current users Rejected: Remove --in-place entirely | some advanced local workflows still need an explicit escape hatch Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep main/dev workflows worktree-first; do not relax in-place guard without adding equivalent safety Tested: npm test; node --check bin/multiagent-safety.js; npm pack --dry-run Not-tested: End-to-end GitHub Actions run for this commit --- README.md | 3 ++ templates/scripts/agent-branch-start.sh | 45 +++++++++++++++-- test/install.test.js | 65 +++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e9c90b7..6c35191 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,9 @@ By default this writes: ![musafety branch start protocol screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/workflow-branch-start.svg) +`agent-branch-start` defaults to isolated worktrees. In-place starts are blocked unless you pass both +`--in-place --allow-in-place` explicitly. + ### 2) Lock claim + deletion guard protocol ![musafety lock and delete guard screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/workflow-lock-guard.svg) diff --git a/templates/scripts/agent-branch-start.sh b/templates/scripts/agent-branch-start.sh index 61ca938..2f5614c 100755 --- a/templates/scripts/agent-branch-start.sh +++ b/templates/scripts/agent-branch-start.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -TASK_NAME="${1:-task}" -AGENT_NAME="${2:-agent}" -BASE_BRANCH="${3:-dev}" +TASK_NAME="task" +AGENT_NAME="agent" +BASE_BRANCH="dev" WORKTREE_MODE=1 +ALLOW_IN_PLACE=0 WORKTREE_ROOT_REL=".omx/agent-worktrees" +POSITIONAL_ARGS=() while [[ $# -gt 0 ]]; do case "$1" in @@ -25,25 +27,52 @@ while [[ $# -gt 0 ]]; do WORKTREE_MODE=0 shift ;; + --allow-in-place) + ALLOW_IN_PLACE=1 + shift + ;; --worktree-root) WORKTREE_ROOT_REL="${2:-.omx/agent-worktrees}" shift 2 ;; --) shift + while [[ $# -gt 0 ]]; do + POSITIONAL_ARGS+=("$1") + shift + done break ;; -*) echo "[agent-branch-start] Unknown option: $1" >&2 - echo "Usage: $0 [task] [agent] [base] [--in-place] [--worktree-root ]" >&2 + echo "Usage: $0 [task] [agent] [base] [--in-place --allow-in-place] [--worktree-root ]" >&2 exit 1 ;; *) - break + POSITIONAL_ARGS+=("$1") + shift ;; esac done +if [[ "${#POSITIONAL_ARGS[@]}" -gt 3 ]]; then + echo "[agent-branch-start] Too many positional arguments." >&2 + echo "Usage: $0 [task] [agent] [base] [--in-place --allow-in-place] [--worktree-root ]" >&2 + exit 1 +fi + +if [[ "${#POSITIONAL_ARGS[@]}" -ge 1 ]]; then + TASK_NAME="${POSITIONAL_ARGS[0]}" +fi + +if [[ "${#POSITIONAL_ARGS[@]}" -ge 2 ]]; then + AGENT_NAME="${POSITIONAL_ARGS[1]}" +fi + +if [[ "${#POSITIONAL_ARGS[@]}" -ge 3 ]]; then + BASE_BRANCH="${POSITIONAL_ARGS[2]}" +fi + sanitize_slug() { local raw="$1" local slug @@ -83,6 +112,12 @@ if git show-ref --verify --quiet "refs/heads/${branch_name}"; then fi if [[ "$WORKTREE_MODE" -eq 0 ]]; then + if [[ "$ALLOW_IN_PLACE" -ne 1 ]]; then + echo "[agent-branch-start] --in-place is blocked by default to prevent accidental edits on protected branches." >&2 + echo "[agent-branch-start] If you really need it, pass both: --in-place --allow-in-place" >&2 + exit 1 + fi + if ! git diff --quiet || ! git diff --cached --quiet; then echo "[agent-branch-start] Working tree is not clean. Commit/stash changes before starting an in-place branch." >&2 exit 1 diff --git a/test/install.test.js b/test/install.test.js index cb94abd..f318fd6 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -310,6 +310,71 @@ test('setup --no-gitignore skips creating managed gitignore block', () => { assert.equal(fs.existsSync(path.join(repoDir, '.gitignore')), false); }); +test('agent-branch-start keeps main worktree branch unchanged by default', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runCmd('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoDir); + assert.equal(result.status, 0, result.stderr); + const beforeBranch = result.stdout.trim(); + assert.equal(beforeBranch, 'dev'); + + result = runCmd( + 'bash', + ['scripts/agent-branch-start.sh', 'verify-default-worktree', 'doctor'], + repoDir, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Created branch: agent\/doctor\//); + assert.match(result.stdout, /Worktree: /); + + const branchMatch = result.stdout.match(/Created branch: ([^\n]+)/); + assert.notEqual(branchMatch, null); + const createdBranch = branchMatch[1].trim(); + + result = runCmd('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoDir); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), beforeBranch, 'current branch should stay unchanged in main worktree'); + + result = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${createdBranch}`], repoDir); + assert.equal(result.status, 0, 'created agent branch should exist'); +}); + +test('agent-branch-start blocks in-place mode unless explicitly allowed', () => { + const repoDir = initRepo(); + seedCommit(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', 'post-setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runCmd( + 'bash', + ['scripts/agent-branch-start.sh', 'verify-in-place-guard', 'doctor', '--in-place'], + repoDir, + ); + assert.equal(result.status, 1, 'in-place should be blocked by default'); + assert.match(result.stderr, /--in-place is blocked by default/); + assert.match(result.stderr, /--in-place --allow-in-place/); + + result = runCmd( + 'bash', + ['scripts/agent-branch-start.sh', 'verify-in-place-override', 'doctor', '--in-place', '--allow-in-place'], + repoDir, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Created in-place branch: agent\/doctor\//); +}); + test('protect command manages configured protected branches', () => { const repoDir = initRepo(); seedCommit(repoDir); From b62fc89e88bf074387550cb7cea4f4eb65b0fc26 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 10 Apr 2026 14:17:10 +0200 Subject: [PATCH 2/3] Auto-enforce Codex agent branches during setup and doctor Pre-commit now blocks Codex/OMX session commits on non-agent branches by default, while allowing human branch workflows to continue.\n\nSetup and doctor now auto-refresh managed safety files ( and ) when templates drift, so rerunning musafety applies the latest branch-safety logic without requiring manual force flags. Added regression coverage for the Codex guard plus auto-refresh behavior. Constraint: Keep existing human VS Code commits on non-protected feature branches working Rejected: Block all non-agent branch commits for everyone | would break normal human trunk/feature workflows Confidence: high Scope-risk: moderate Reversibility: clean Directive: Treat Codex branch guard and template auto-refresh as safety-critical defaults; do not weaken without replacement controls Tested: npm test; node --check bin/multiagent-safety.js; npm pack --dry-run Not-tested: GitHub Actions run for this new commit --- README.md | 7 +++ bin/multiagent-safety.js | 19 ++++++-- templates/githooks/pre-commit | 35 ++++++++++++++ test/install.test.js | 86 +++++++++++++++++++++++++++++++++-- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6c35191..5e0d491 100644 --- a/README.md +++ b/README.md @@ -269,10 +269,17 @@ Configuration is stored in local git config key: multiagent.protectedBranches ``` +Codex/OMX agent branch guard is enabled by default in pre-commit and can be configured with: + +```text +multiagent.codexRequireAgentBranch +``` + ## What is protected - direct commits to protected branches (defaults: `dev`, `main`, `master`; configurable via `musafety protect ...`) - protected-branch commits are blocked regardless of commit client (including VS Code Source Control) +- Codex/OMX session commits are blocked on non-`agent/*` branches by default (keeps human branch untouched while agents use isolated branches) - overlapping file ownership between agents - unapproved deletions of claimed files - risky stale/missing lock state diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 5bbbf87..3861def 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -36,6 +36,11 @@ const TEMPLATE_FILES = [ 'claude/commands/musafety.md', ]; +const AUTO_SYNC_TEMPLATE_FILES = new Set([ + 'scripts/agent-branch-start.sh', + 'githooks/pre-commit', +]); + const EXECUTABLE_RELATIVE_PATHS = new Set([ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', @@ -352,7 +357,7 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) { ensureExecutable(destinationPath, destinationRelativePath, dryRun); return { status: 'unchanged', file: destinationRelativePath }; } - if (!force) { + if (!force && !AUTO_SYNC_TEMPLATE_FILES.has(relativeTemplatePath)) { throw new Error( `Refusing to overwrite existing file without --force: ${destinationRelativePath}`, ); @@ -381,8 +386,16 @@ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) { return { status: 'unchanged', file: destinationRelativePath }; } - // In fix mode, avoid silently replacing local customizations. - return { status: 'skipped-conflict', file: destinationRelativePath }; + if (!AUTO_SYNC_TEMPLATE_FILES.has(relativeTemplatePath)) { + // In fix mode, avoid silently replacing local customizations. + return { status: 'skipped-conflict', file: destinationRelativePath }; + } + + if (!dryRun) { + fs.writeFileSync(destinationPath, sourceContent, 'utf8'); + ensureExecutable(destinationPath, destinationRelativePath, dryRun); + } + return { status: dryRun ? 'would-update' : 'updated', file: destinationRelativePath }; } ensureParentDir(destinationPath, dryRun); diff --git a/templates/githooks/pre-commit b/templates/githooks/pre-commit index 2c77555..f1c28fe 100755 --- a/templates/githooks/pre-commit +++ b/templates/githooks/pre-commit @@ -43,6 +43,41 @@ MSG exit 1 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" +fi +codex_require_agent_branch="$(printf '%s' "$codex_require_agent_branch_raw" | tr '[:upper:]' '[:lower:]')" + +should_require_codex_agent_branch=0 +case "$codex_require_agent_branch" in + 1|true|yes|on) should_require_codex_agent_branch=1 ;; + 0|false|no|off) should_require_codex_agent_branch=0 ;; + *) should_require_codex_agent_branch=1 ;; +esac + +if [[ "$should_require_codex_agent_branch" == "1" && "${MUSAFETY_ALLOW_CODEX_ON_NON_AGENT:-0}" != "1" ]]; then + is_codex_session=0 + if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" == "1" ]]; then + is_codex_session=1 + fi + + if [[ "$is_codex_session" == "1" && "$branch" != agent/* ]]; then + cat >&2 <<'MSG' +[codex-branch-guard] Codex agent commit blocked on non-agent branch. +Use isolated branch/worktree first: + bash scripts/agent-branch-start.sh "" "" +Then commit from the created agent/* branch. + +Temporary bypass (not recommended): + MUSAFETY_ALLOW_CODEX_ON_NON_AGENT=1 git commit ... +Disable this rule for a repo (not recommended): + git config multiagent.codexRequireAgentBranch false +MSG + exit 1 + fi +fi + if [[ "$branch" == agent/* ]]; then if ! python3 scripts/agent-file-locks.py validate --branch "$branch" --staged; then cat >&2 <<'MSG' diff --git a/test/install.test.js b/test/install.test.js index f318fd6..9c40dea 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -84,7 +84,10 @@ function initRepoOnBranch(branchName) { function seedCommit(repoDir) { let result = runCmd('git', ['add', '.'], repoDir); assert.equal(result.status, 0, result.stderr); - result = runCmd('git', ['commit', '-m', 'seed'], repoDir); + result = runCmd('git', ['commit', '-m', 'seed'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + MUSAFETY_ALLOW_CODEX_ON_NON_AGENT: '1', + }); assert.equal(result.status, 0, result.stderr); } @@ -126,7 +129,7 @@ function commitFile(repoDir, relativePath, contents, message) { assert.equal(result.status, 0, result.stderr); const commitEnv = ['dev', 'main', 'master'].includes(branchName) ? { ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1' } - : {}; + : { MUSAFETY_ALLOW_CODEX_ON_NON_AGENT: '1' }; result = runCmd('git', ['commit', '-m', message], repoDir, commitEnv); assert.equal(result.status, 0, result.stderr); } @@ -310,6 +313,36 @@ test('setup --no-gitignore skips creating managed gitignore block', () => { assert.equal(fs.existsSync(path.join(repoDir, '.gitignore')), false); }); +test('setup auto-refreshes managed pre-commit guard when template changed', () => { + const repoDir = initRepo(); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + fs.writeFileSync(path.join(repoDir, '.githooks', 'pre-commit'), '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + + result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const repaired = fs.readFileSync(path.join(repoDir, '.githooks', 'pre-commit'), 'utf8'); + assert.match(repaired, /\[codex-branch-guard\] Codex agent commit blocked on non-agent branch/); +}); + +test('doctor auto-refreshes managed pre-commit guard when template changed', () => { + const repoDir = initRepo(); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + fs.writeFileSync(path.join(repoDir, '.githooks', 'pre-commit'), '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + + result = runNode(['doctor', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const repaired = fs.readFileSync(path.join(repoDir, '.githooks', 'pre-commit'), 'utf8'); + assert.match(repaired, /\[codex-branch-guard\] Codex agent commit blocked on non-agent branch/); +}); + test('agent-branch-start keeps main worktree branch unchanged by default', () => { const repoDir = initRepo(); seedCommit(repoDir); @@ -443,6 +476,50 @@ test('pre-commit blocks protected branch commits even from VS Code Source Contro assert.match(hookResult.stderr, /Direct commits on protected branches are blocked/); }); +test('pre-commit blocks Codex session commits on non-agent branches by default', () => { + const repoDir = initRepoOnBranch('feature/codex-guard'); + seedCommit(repoDir); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + + const hookResult = runCmd( + 'bash', + ['.githooks/pre-commit'], + repoDir, + { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0', + CODEX_THREAD_ID: 'codex-thread-test', + }, + ); + + assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); + assert.match(hookResult.stderr, /Codex agent commit blocked on non-agent branch/); +}); + +test('pre-commit allows non-agent branch commits for Codex when repo guard is disabled', () => { + const repoDir = initRepoOnBranch('feature/codex-guard-optout'); + seedCommit(repoDir); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + + let result = runCmd('git', ['config', 'multiagent.codexRequireAgentBranch', 'false'], repoDir); + assert.equal(result.status, 0, result.stderr); + + const hookResult = runCmd( + 'bash', + ['.githooks/pre-commit'], + repoDir, + { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0', + CODEX_THREAD_ID: 'codex-thread-test', + }, + ); + + assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); +}); + test('sync command rebases current agent branch onto latest origin/dev', () => { const repoDir = initRepo(); seedCommit(repoDir); @@ -671,7 +748,10 @@ test('validate blocks unapproved deletions until allow-delete is set', () => { result = runCmd('git', ['add', '.'], repoDir); assert.equal(result.status, 0, result.stderr); - result = runCmd('git', ['commit', '-m', 'seed'], repoDir); + result = runCmd('git', ['commit', '-m', 'seed'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + MUSAFETY_ALLOW_CODEX_ON_NON_AGENT: '1', + }); assert.equal(result.status, 0, result.stderr); result = runCmd( From 196aa757c00b1e88539b56eb0234844d1afc87b5 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 10 Apr 2026 21:43:10 +0200 Subject: [PATCH 3/3] Harden in-place branch workflows to prevent unsafe finishes This update tightens branch lifecycle guardrails around in-place work while keeping the finish flow ergonomic. It adds managed hook/script updates and test coverage so setup/doctor can auto-heal drift and the finish lifecycle remains deterministic across local/remote cleanup. Constraint: Must preserve existing agent-branch UX while adding safety rails Rejected: Force all flows through extra interactive prompts | would slow scripted and CI usage Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep hook templates and lifecycle tests in sync when changing branch-finish semantics Tested: npm test (55/55 pass) Not-tested: Manual VS Code Source Control merge path after install --- README.md | 109 ++- bin/multiagent-safety.js | 770 +++++++++++++++++----- package-lock.json | 4 +- package.json | 2 +- templates/AGENTS.multiagent-safety.md | 108 +-- templates/githooks/post-commit | 39 ++ templates/githooks/pre-commit | 45 +- templates/scripts/agent-branch-finish.sh | 81 ++- templates/scripts/agent-branch-start.sh | 63 +- templates/scripts/agent-worktree-prune.sh | 62 +- test/install.test.js | 407 ++++++++++-- 11 files changed, 1345 insertions(+), 345 deletions(-) create mode 100644 templates/githooks/post-commit diff --git a/README.md b/README.md index 5e0d491..78b4a33 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # musafety (MULTI AGENTS SAFETY PROTCOL) +## 🚀 Commit with confidence 🚀 + [![npm version](https://img.shields.io/npm/v/musafety?color=cb3837&logo=npm)](https://www.npmjs.com/package/musafety) [![CI](https://github.com/recodeecom/multiagent-safety/actions/workflows/ci.yml/badge.svg)](https://github.com/recodeecom/multiagent-safety/actions/workflows/ci.yml) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/recodeecom/multiagent-safety/badge)](https://securityscorecards.dev/viewer/?uri=github.com/recodeecom/multiagent-safety) @@ -54,11 +56,12 @@ musafety setup That one command runs: 1. detects whether OMX/OpenSpec are already globally installed, -2. asks strict Y/N approval only if something is missing, +2. asks Y/N approval (default `Y`) only if something is missing, 3. installs guardrail scripts/hooks, 4. repairs common safety problems, 5. installs local Codex + Claude musafety helper skill files if missing, -6. scans and reports final status. +6. auto-creates a sibling `main` worktree + VS Code workspace file for dual-branch SCM view (when setup/doctor/install runs from a non-`main` branch), +7. scans and reports final status. ## Setup screenshot @@ -75,6 +78,11 @@ That one command runs: - Codex skill: `.codex/skills/musafety/SKILL.md` - Claude command: `.claude/commands/musafety.md` (use as `/musafety`) +When you run setup/doctor/install from a non-`main` branch, they also maintain a dual-view workspace so Source Control can show your active branch and `main` side-by-side: + +- main view worktree: `-main` +- workspace file: `-branches.code-workspace` + ## Scorecard report generation Create/update markdown reports from OpenSSF Scorecard JSON: @@ -105,27 +113,7 @@ By default this writes: ![musafety source control multi-agent screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/workflow-source-control.svg) -## Copy prompt for your AI (Codex / Claude) - -```sh -musafety copy-prompt -``` - -This prints a ready-to-paste prompt. - -### Prompt preview (SVG) - -![musafety copy prompt screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/copy-prompt-output.svg) - -### Commands-only copy mode - -If you only want executable commands (without explanatory text): - -```sh -musafety copy-commands -``` - -Example output: +## Quick setup checklist ```sh npm i -g musafety @@ -140,80 +128,45 @@ musafety sync --check musafety sync ``` -Full checklist output: - -```text -Use this exact checklist to setup multi-agent safety in this repository for Codex or Claude. - -1) Install (if missing): - npm i -g musafety - -2) Bootstrap safety in this repo: - musafety setup - - - Setup detects global OMX/OpenSpec first. - - If one is missing and setup asks for approval, reply explicitly: - - y = run: npm i -g oh-my-codex @fission-ai/openspec (missing ones only) - - n = skip global installs - -3) If setup reports warnings/errors, repair + re-check: - musafety doctor - -4) Confirm next safe agent workflow commands: - bash scripts/agent-branch-start.sh "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)" - -5) Optional: create OpenSpec planning workspace: - bash scripts/openspec/init-plan-workspace.sh "" - -6) Optional: protect extra branches: - musafety protect add release staging - -7) Optional: sync your current agent branch with latest dev: - musafety sync --check - musafety sync -``` - ## Basic commands ```sh musafety status [--target ] [--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 -musafety copy-commands +musafety setup [--target ] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore] [--no-main-view] +musafety doctor [--target ] [--dry-run] [--json] [--keep-stale-locks] [--no-gitignore] [--no-main-view] musafety protect list [--target ] musafety protect add [--target ] musafety protect remove [--target ] musafety protect set [--target ] musafety protect reset [--target ] +musafety finish [--target ] [--base ] [--branch ] [--no-push] [--keep-remote-branch] musafety sync --check [--target ] [--base ] [--json] musafety sync [--target ] [--base ] [--strategy rebase|merge] [--ff-only] musafety report scorecard [--target ] [--repo github.com//] [--scorecard-json ] [--output-dir ] [--date YYYY-MM-DD] -bash scripts/agent-worktree-prune.sh --base dev # manual stale worktree cleanup +bash scripts/agent-worktree-prune.sh # manual stale worktree cleanup bash scripts/openspec/init-plan-workspace.sh # optional OpenSpec plan scaffold ``` 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. +If Docker markers are detected in the repo (`docker-compose.yml`, `compose.yml`, `Dockerfile*`), +status/doctor also check Docker runtime and print a red warning when Docker needs to be started. When run in an interactive terminal, default `musafety` checks npm for a newer version first and asks `[Y/n]` whether to update immediately (default is `Y`). - Interactive setup: prompts for Y/N approval before global OMX/OpenSpec install. -- Interactive prompt is strict (`[y/n]`) and waits for explicit answer. +- Interactive prompt uses `[Y/n]` (default is `Y`) and waits for Enter. - Non-interactive setup: skips global installs by default; use `--yes-global-install` to force. ## Advanced commands ```sh -musafety install [--target ] [--force] [--skip-agents] [--skip-package-json] [--no-gitignore] [--dry-run] -musafety fix [--target ] [--dry-run] [--keep-stale-locks] [--no-gitignore] +musafety install [--target ] [--force] [--skip-agents] [--skip-package-json] [--no-gitignore] [--no-main-view] [--dry-run] musafety scan [--target ] [--json] musafety report help ``` -## Keep agent branches synced with dev +## Keep agent branches synced with your base branch Use sync checks before finishing agent branches: @@ -224,7 +177,7 @@ musafety sync Defaults: -- base branch: `dev` (or `multiagent.baseBranch`) +- base branch: `multiagent.baseBranch` when configured, otherwise auto-detected from the repo (current non-agent branch, then `origin/HEAD`, then `dev/main/master`) - strategy: `rebase` (or `multiagent.sync.strategy`) Useful variants: @@ -235,6 +188,14 @@ musafety sync --all-agent-branches --check ``` By default, `agent-branch-finish.sh` also blocks finishing when your branch is behind `origin/` and points to `musafety sync`. +You can run the same flow via CLI with `musafety finish`. + +Finish flow defaults: + +1. push source `agent/*` branch to `origin`, +2. merge it into the detected base branch, +3. push base branch update, +4. delete the source branch locally and on remote. Optional pre-commit behind-threshold gate (off by default): @@ -275,17 +236,26 @@ Codex/OMX agent branch guard is enabled by default in pre-commit and can be conf multiagent.codexRequireAgentBranch ``` +Auto-finish on agent commit (isolated worktrees) is enabled by default and can be configured with: + +```text +multiagent.autoFinishOnCommit +``` + ## What is protected - direct commits to protected branches (defaults: `dev`, `main`, `master`; configurable via `musafety protect ...`) - protected-branch commits are blocked regardless of commit client (including VS Code Source Control) - Codex/OMX session commits are blocked on non-`agent/*` branches by default (keeps human branch untouched while agents use isolated branches) +- agent commits on `agent/*` branches auto-finish by default (push -> merge to base -> delete agent branch local+remote) - overlapping file ownership between agents - unapproved deletions of claimed files - risky stale/missing lock state - accidental loss of critical guardrail files - setup also writes a managed `.gitignore` block so generated musafety scripts/hooks stay out of normal git status noise by default - pass `--no-gitignore` if you want to keep tracking these files in git +- setup/doctor/install also auto-maintain `-main` + `-branches.code-workspace` for dual-repo Source Control view when run from a non-`main` branch + - pass `--no-main-view` to skip that automation ## Files it installs @@ -297,6 +267,7 @@ scripts/agent-file-locks.py scripts/install-agent-git-hooks.sh scripts/openspec/init-plan-workspace.sh .githooks/pre-commit +.githooks/post-commit .codex/skills/musafety/SKILL.md .claude/commands/musafety.md .omx/state/agent-file-locks.json @@ -337,7 +308,7 @@ npm pack --dry-run - Setup now detects existing global OMX/OpenSpec installs first. - If tools are already present, setup skips global install automatically. -- Interactive approval is now strict `[y/n]` (waits for explicit answer). +- Interactive approval now uses `[Y/n]` (default is `Y`). - Added setup screenshot to README. - Added 3 additional workflow screenshots (branch start, lock/delete guard, source-control view). diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 3861def..8f82351 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -14,6 +14,7 @@ const MAINTAINER_RELEASE_REPO = path.resolve( process.env.MUSAFETY_RELEASE_REPO || '/tmp/multiagent-safety', ); const NPM_BIN = process.env.MUSAFETY_NPM_BIN || 'npm'; +const DOCKER_BIN = process.env.MUSAFETY_DOCKER_BIN || 'docker'; const SCORECARD_BIN = process.env.MUSAFETY_SCORECARD_BIN || 'scorecard'; const GIT_PROTECTED_BRANCHES_KEY = 'multiagent.protectedBranches'; const GIT_BASE_BRANCH_KEY = 'multiagent.baseBranch'; @@ -21,6 +22,9 @@ const GIT_SYNC_STRATEGY_KEY = 'multiagent.sync.strategy'; const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master']; const DEFAULT_BASE_BRANCH = 'dev'; const DEFAULT_SYNC_STRATEGY = 'rebase'; +const MAIN_VIEW_BRANCH = process.env.MUSAFETY_MAIN_VIEW_BRANCH || 'main'; +const MAIN_VIEW_WORKTREE_SUFFIX = process.env.MUSAFETY_MAIN_VIEW_WORKTREE_SUFFIX || '-main'; +const MAIN_VIEW_WORKSPACE_SUFFIX = process.env.MUSAFETY_MAIN_VIEW_WORKSPACE_SUFFIX || '-branches.code-workspace'; const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates'); @@ -32,6 +36,7 @@ const TEMPLATE_FILES = [ 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', 'githooks/pre-commit', + 'githooks/post-commit', 'codex/skills/musafety/SKILL.md', 'claude/commands/musafety.md', ]; @@ -39,6 +44,7 @@ const TEMPLATE_FILES = [ const AUTO_SYNC_TEMPLATE_FILES = new Set([ 'scripts/agent-branch-start.sh', 'githooks/pre-commit', + 'githooks/post-commit', ]); const EXECUTABLE_RELATIVE_PATHS = new Set([ @@ -49,6 +55,7 @@ const EXECUTABLE_RELATIVE_PATHS = new Set([ 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', + '.githooks/post-commit', ]); const CRITICAL_GUARDRAIL_PATHS = new Set([ @@ -61,6 +68,7 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([ const LOCK_FILE_RELATIVE = '.omx/state/agent-file-locks.json'; const AGENTS_MARKER_START = ''; +const AGENTS_MARKER_END = ''; const GITIGNORE_MARKER_START = '# multiagent-safety:START'; const GITIGNORE_MARKER_END = '# multiagent-safety:END'; const MANAGED_GITIGNORE_PATHS = [ @@ -71,6 +79,7 @@ const MANAGED_GITIGNORE_PATHS = [ 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', + '.githooks/post-commit', '.codex/skills/musafety/SKILL.md', '.claude/commands/musafety.md', LOCK_FILE_RELATIVE, @@ -83,6 +92,7 @@ const COMMAND_TYPO_ALIASES = new Map([ ['intsall', 'install'], ['docter', 'doctor'], ['doctro', 'doctor'], + ['docktor', 'doctor'], ['scna', 'scan'], ]); const SUGGESTIBLE_COMMANDS = [ @@ -90,13 +100,11 @@ const SUGGESTIBLE_COMMANDS = [ 'setup', 'doctor', 'report', - 'copy-prompt', - 'copy-commands', 'protect', + 'finish', 'sync', 'release', 'install', - 'fix', 'scan', 'print-agents-snippet', 'help', @@ -104,15 +112,13 @@ const SUGGESTIBLE_COMMANDS = [ ]; const CLI_COMMAND_DESCRIPTIONS = [ ['status', 'Show musafety CLI + service health without modifying files'], - ['setup', 'Install + repair guardrails in a git repo (supports --no-gitignore)'], - ['doctor', 'Repair safety setup drift, then verify repo safety'], + ['setup', 'Install + repair guardrails in a git repo (supports --no-gitignore, --no-main-view)'], + ['doctor', 'Repair safety setup drift, then verify repo safety (supports --no-main-view)'], ['report', 'Generate security/safety reports (for example: OpenSSF scorecard)'], - ['copy-prompt', 'Print the AI-ready setup checklist'], - ['copy-commands', 'Print setup checklist as executable commands only'], ['protect', 'Manage protected branches (list/add/remove/set/reset)'], + ['finish', 'Run safe branch finish flow (push, merge to base, cleanup agent branch)'], ['sync', 'Check or sync agent branches with origin/'], - ['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'], - ['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'], + ['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore, --no-main-view)'], ['scan', 'Report safety issues and exit non-zero on findings'], ['print-agents-snippet', 'Print the AGENTS.md snippet template'], ['release', 'Publish musafety from maintainer release repo'], @@ -120,50 +126,6 @@ const CLI_COMMAND_DESCRIPTIONS = [ ['version', 'Print musafety version'], ]; -const AI_SETUP_PROMPT = `Use this exact checklist to setup multi-agent safety in this repository for Codex or Claude. - -1) Install (if missing): - npm i -g musafety - -2) Bootstrap safety in this repo: - musafety setup - - - Setup detects global OMX/OpenSpec first. - - If one is missing and setup asks for approval, reply explicitly: - - y = run: npm i -g oh-my-codex @fission-ai/openspec (missing ones only) - - n = skip global installs - -3) If setup reports warnings/errors, repair + re-check: - musafety doctor - -4) Confirm next safe agent workflow commands: - bash scripts/agent-branch-start.sh "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)" - -5) Optional: create OpenSpec planning workspace: - bash scripts/openspec/init-plan-workspace.sh "" - -6) Optional: protect extra branches: - musafety protect add release staging - -7) Optional: sync your current agent branch with latest dev: - musafety sync --check - musafety sync -`; - -const AI_SETUP_COMMANDS = `npm i -g musafety -musafety setup -musafety doctor -bash scripts/agent-branch-start.sh "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 "" -musafety protect add release staging -musafety sync --check -musafety sync -`; - const SCORECARD_RISK_BY_CHECK = { 'Dangerous-Workflow': 'Critical', 'Code-Review': 'High', @@ -182,6 +144,13 @@ const SCORECARD_RISK_BY_CHECK = { License: 'Low', }; +const DOCKER_SIGNAL_FILENAMES = [ + 'docker-compose.yml', + 'docker-compose.yaml', + 'compose.yml', + 'compose.yaml', +]; + function runtimeVersion() { return `${packageJson.name}/${packageJson.version} ${process.platform}-${process.arch} node-${process.version}`; } @@ -207,14 +176,119 @@ function statusDot(status) { return colorize('●', '33'); // yellow for degraded/unknown } +function firstNonEmptyLine(value) { + return String(value || '') + .split('\n') + .map((line) => line.trim()) + .find(Boolean) || ''; +} + +function detectRepoDockerSignals(repoRoot) { + const reasons = []; + + for (const filename of DOCKER_SIGNAL_FILENAMES) { + if (fs.existsSync(path.join(repoRoot, filename))) { + reasons.push(filename); + } + } + + let rootEntries = []; + try { + rootEntries = fs.readdirSync(repoRoot, { withFileTypes: true }); + } catch { + rootEntries = []; + } + + for (const entry of rootEntries) { + if (!entry.isFile()) continue; + if (/^dockerfile(?:\..+)?$/i.test(entry.name)) { + reasons.push(entry.name); + } + } + + return uniquePreserveOrder(reasons); +} + +function inspectDockerRuntime() { + const result = run(DOCKER_BIN, ['info'], { timeout: 5000 }); + + if (result.error) { + if (result.error.code === 'ENOENT') { + return { + status: 'inactive', + reason: `Docker CLI not found (${DOCKER_BIN})`, + }; + } + return { + status: 'unknown', + reason: result.error.message || `Unable to run ${DOCKER_BIN} info`, + }; + } + + if (result.status === 0) { + return { + status: 'active', + reason: '', + }; + } + + const details = firstNonEmptyLine(result.stderr || result.stdout); + return { + status: 'inactive', + reason: details || 'Docker daemon is not available', + }; +} + +function detectRepoDockerStatus(repoRoot) { + const reasons = detectRepoDockerSignals(repoRoot); + if (reasons.length === 0) { + return { + required: false, + status: 'not-required', + needsStart: false, + reasons, + reason: '', + }; + } + + const runtime = inspectDockerRuntime(); + return { + required: true, + status: runtime.status, + needsStart: runtime.status === 'inactive', + reasons, + reason: runtime.reason || '', + }; +} + function commandCatalogLines(indent = ' ') { const maxCommandLength = CLI_COMMAND_DESCRIPTIONS.reduce( (max, [command]) => Math.max(max, command.length), 0, ); - return CLI_COMMAND_DESCRIPTIONS.map( - ([command, description]) => `${indent}${command.padEnd(maxCommandLength + 2)}${description}`, - ); + const lines = []; + + for (const [command, description] of CLI_COMMAND_DESCRIPTIONS) { + const supportMatch = String(description).match(/^(.*)\(supports\s+([^)]+)\)\s*$/i); + const mainDescription = supportMatch ? supportMatch[1].trimEnd() : description; + lines.push(`${indent}${command.padEnd(maxCommandLength + 2)}${mainDescription}`); + + if (!supportMatch) { + continue; + } + + const flags = supportMatch[2] + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + lines.push(`${indent}${' '.repeat(maxCommandLength + 2)}supports:`); + for (const flag of flags) { + lines.push(`${indent}${' '.repeat(maxCommandLength + 4)}${flag}`); + } + } + + return lines; } function printToolLogsSummary() { @@ -253,6 +327,21 @@ function printToolLogsSummary() { console.log(` ${corner}─ ${colorize(`Try '${TOOL_NAME} doctor' for one-step repair + verification.`, '2')}`); } +function printDurExBrandingBanner() { + if (!supportsAnsiColors()) { + console.log('========================================='); + console.log(' COMMIT WITH CONFIDENCE '); + console.log('========================================='); + return; + } + + const border = colorize('█'.repeat(41), '1;35'); + const title = colorize('█ COMMIT WITH CONFIDENCE █', '1;95'); + console.log(border); + console.log(title); + console.log(border); +} + function usage(options = {}) { const { outsideGitRepo = false } = options; @@ -297,6 +386,192 @@ function gitRun(repoRoot, args, { allowFailure = false } = {}) { return result; } +function hasVerifiedHead(repoRoot) { + return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0; +} + +function parseWorktreeListPorcelain(rawOutput) { + const entries = []; + let current = null; + const lines = String(rawOutput || '').split('\n'); + + for (const line of lines) { + if (!line.trim()) { + if (current) { + entries.push(current); + current = null; + } + continue; + } + + if (line.startsWith('worktree ')) { + if (current) { + entries.push(current); + } + current = { + path: line.slice('worktree '.length).trim(), + branchRef: '', + }; + continue; + } + + if (!current) { + continue; + } + + if (line.startsWith('branch ')) { + current.branchRef = line.slice('branch '.length).trim(); + } + } + + if (current) { + entries.push(current); + } + + return entries; +} + +function computeMainViewPaths(repoRoot) { + const parentDir = path.dirname(repoRoot); + const baseName = path.basename(repoRoot); + const worktreePath = path.join(parentDir, `${baseName}${MAIN_VIEW_WORKTREE_SUFFIX}`); + const workspacePath = path.join(parentDir, `${baseName}${MAIN_VIEW_WORKSPACE_SUFFIX}`); + return { worktreePath, workspacePath }; +} + +function ensureMainBranchWorkspace(repoRoot, dryRun) { + const operations = []; + const { worktreePath, workspacePath } = computeMainViewPaths(repoRoot); + const mainBranchRef = `refs/heads/${MAIN_VIEW_BRANCH}`; + const currentBranchResult = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true }); + const currentBranch = (currentBranchResult.stdout || '').trim() || 'current'; + + if (!hasVerifiedHead(repoRoot)) { + operations.push({ + status: 'skipped', + file: path.relative(repoRoot, workspacePath), + note: 'main branch view skipped (repository has no commits yet)', + }); + return operations; + } + + if (!gitRefExists(repoRoot, mainBranchRef)) { + operations.push({ + status: 'skipped', + file: path.relative(repoRoot, workspacePath), + note: `main branch view skipped ('${MAIN_VIEW_BRANCH}' branch not found)`, + }); + return operations; + } + + const worktreeList = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true }); + if (worktreeList.status !== 0) { + operations.push({ + status: 'skipped', + file: path.relative(repoRoot, workspacePath), + note: 'main branch view skipped (unable to read git worktree list)', + }); + return operations; + } + + const entries = parseWorktreeListPorcelain(worktreeList.stdout); + const normalizedRepoRoot = path.resolve(repoRoot); + const existingMainEntry = entries.find((entry) => { + if (entry.branchRef !== mainBranchRef) return false; + return path.resolve(entry.path) !== normalizedRepoRoot; + }); + + let selectedMainWorktreePath = existingMainEntry ? path.resolve(existingMainEntry.path) : ''; + if (!selectedMainWorktreePath) { + if (currentBranch === MAIN_VIEW_BRANCH) { + operations.push({ + status: 'skipped', + file: path.relative(repoRoot, workspacePath), + note: 'main branch view skipped (current branch is main; generate from agent branch)', + }); + return operations; + } + + if (!dryRun) { + if (fs.existsSync(worktreePath)) { + operations.push({ + status: 'skipped', + file: path.relative(repoRoot, worktreePath), + note: `main branch view skipped (${worktreePath} already exists and is not a registered worktree)`, + }); + return operations; + } + + const addResult = gitRun( + repoRoot, + ['worktree', 'add', worktreePath, MAIN_VIEW_BRANCH], + { allowFailure: true }, + ); + if (addResult.status !== 0) { + operations.push({ + status: 'skipped', + file: path.relative(repoRoot, worktreePath), + note: `main branch view skipped (${(addResult.stderr || '').trim() || 'unable to create worktree'})`, + }); + return operations; + } + selectedMainWorktreePath = path.resolve(worktreePath); + operations.push({ + status: 'created', + file: path.relative(repoRoot, worktreePath), + note: `main branch worktree (${MAIN_VIEW_BRANCH})`, + }); + } else { + selectedMainWorktreePath = path.resolve(worktreePath); + operations.push({ + status: 'would-create', + file: path.relative(repoRoot, worktreePath), + note: `main branch worktree (${MAIN_VIEW_BRANCH})`, + }); + } + } else { + operations.push({ + status: 'unchanged', + file: path.relative(repoRoot, selectedMainWorktreePath), + note: `main branch worktree (${MAIN_VIEW_BRANCH})`, + }); + } + + const workspaceContent = `${JSON.stringify({ + folders: [ + { name: currentBranch, path: repoRoot }, + { name: MAIN_VIEW_BRANCH, path: selectedMainWorktreePath }, + ], + settings: { + 'scm.alwaysShowRepositories': true, + }, + }, null, 2)}\n`; + + const existingWorkspace = fs.existsSync(workspacePath) ? fs.readFileSync(workspacePath, 'utf8') : ''; + if (existingWorkspace === workspaceContent) { + operations.push({ + status: 'unchanged', + file: path.relative(repoRoot, workspacePath), + note: 'workspace shows current + main repositories in SCM', + }); + return operations; + } + + const workspaceExisted = fs.existsSync(workspacePath); + + if (!dryRun) { + fs.writeFileSync(workspacePath, workspaceContent, 'utf8'); + } + + operations.push({ + status: workspaceExisted ? (dryRun ? 'would-update' : 'updated') : (dryRun ? 'would-create' : 'created'), + file: path.relative(repoRoot, workspacePath), + note: 'workspace shows current + main repositories in SCM', + }); + + return operations; +} + function resolveRepoRoot(targetPath) { const resolvedTarget = path.resolve(targetPath || process.cwd()); const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']); @@ -477,7 +752,7 @@ function ensurePackageScripts(repoRoot, dryRun) { const wantedScripts = { '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: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:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete', @@ -489,7 +764,6 @@ function ensurePackageScripts(repoRoot, dryRun) { 'agent:branch:sync:check': `${TOOL_NAME} sync --check`, 'agent:safety:setup': `${TOOL_NAME} setup`, 'agent:safety:scan': `${TOOL_NAME} scan`, - 'agent:safety:fix': `${TOOL_NAME} fix`, 'agent:safety:doctor': `${TOOL_NAME} doctor`, }; @@ -516,6 +790,9 @@ function ensurePackageScripts(repoRoot, dryRun) { function ensureAgentsSnippet(repoRoot, dryRun) { const agentsPath = path.join(repoRoot, 'AGENTS.md'); const snippet = fs.readFileSync(path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md'), 'utf8').trimEnd(); + const escapedStart = AGENTS_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedEnd = AGENTS_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const managedRegex = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, 'm'); if (!fs.existsSync(agentsPath)) { if (!dryRun) { @@ -526,7 +803,24 @@ function ensureAgentsSnippet(repoRoot, dryRun) { const existing = fs.readFileSync(agentsPath, 'utf8'); if (existing.includes(AGENTS_MARKER_START)) { - return { status: 'unchanged', file: 'AGENTS.md' }; + if (managedRegex.test(existing)) { + const next = existing.replace(managedRegex, snippet); + if (next === existing) { + return { status: 'unchanged', file: 'AGENTS.md' }; + } + if (!dryRun) { + fs.writeFileSync(agentsPath, next, 'utf8'); + } + return { status: 'updated', file: 'AGENTS.md', note: 'refreshed musafety managed block' }; + } + + const startIndex = existing.indexOf(AGENTS_MARKER_START); + const prefix = existing.slice(0, startIndex).replace(/\s*$/, ''); + const next = `${prefix}${prefix.length > 0 ? '\n\n' : ''}${snippet}\n`; + if (!dryRun) { + fs.writeFileSync(agentsPath, next, 'utf8'); + } + return { status: 'updated', file: 'AGENTS.md', note: 'repaired malformed musafety marker block' }; } const separator = existing.endsWith('\n') ? '\n' : '\n\n'; @@ -634,6 +928,10 @@ function parseCommonArgs(rawArgs, defaults) { options.skipGitignore = true; continue; } + if (arg === '--no-main-view') { + options.skipMainView = true; + continue; + } throw new Error(`Unknown option: ${arg}`); } @@ -911,12 +1209,89 @@ function readGitConfig(repoRoot, key) { return (result.stdout || '').trim(); } +function branchExistsLocalOrRemote(repoRoot, branchName) { + if (!branchName) return false; + const local = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], { + allowFailure: true, + }); + if (local.status === 0) { + return true; + } + const remote = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branchName}`], { + allowFailure: true, + }); + return remote.status === 0; +} + +function inferDefaultBaseBranch(repoRoot) { + const currentBranchResult = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true }); + const currentBranch = currentBranchResult.status === 0 ? (currentBranchResult.stdout || '').trim() : ''; + if (currentBranch && !currentBranch.startsWith('agent/') && branchExistsLocalOrRemote(repoRoot, currentBranch)) { + return { branch: currentBranch, source: 'current-branch' }; + } + + const remoteHead = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { + allowFailure: true, + }); + if (remoteHead.status === 0) { + const remoteDefault = (remoteHead.stdout || '').trim().replace(/^origin\//, ''); + if (remoteDefault && branchExistsLocalOrRemote(repoRoot, remoteDefault)) { + return { branch: remoteDefault, source: 'origin-head' }; + } + } + + for (const candidate of [DEFAULT_BASE_BRANCH, 'main', 'master']) { + if (branchExistsLocalOrRemote(repoRoot, candidate)) { + return { branch: candidate, source: 'fallback-known-branch' }; + } + } + + if (currentBranch && !currentBranch.startsWith('agent/')) { + return { branch: currentBranch, source: 'current-branch-fallback' }; + } + + return { branch: DEFAULT_BASE_BRANCH, source: 'default' }; +} + function resolveBaseBranch(repoRoot, explicitBase) { if (explicitBase) { return explicitBase; } const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY); - return configured || DEFAULT_BASE_BRANCH; + if (configured && branchExistsLocalOrRemote(repoRoot, configured)) { + return configured; + } + return inferDefaultBaseBranch(repoRoot).branch; +} + +function ensureBaseBranchConfig(repoRoot, dryRun) { + const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY); + if (configured && branchExistsLocalOrRemote(repoRoot, configured)) { + return { + status: 'unchanged', + file: `git-config:${GIT_BASE_BRANCH_KEY}`, + note: `base branch '${configured}'`, + }; + } + + const inferred = inferDefaultBaseBranch(repoRoot); + if (!inferred.branch) { + return { + status: 'skipped', + file: `git-config:${GIT_BASE_BRANCH_KEY}`, + note: 'unable to infer base branch', + }; + } + + if (!dryRun) { + gitRun(repoRoot, ['config', GIT_BASE_BRANCH_KEY, inferred.branch]); + } + + return { + status: dryRun ? 'would-set' : 'set', + file: `git-config:${GIT_BASE_BRANCH_KEY}`, + note: `base branch '${inferred.branch}' (${inferred.source})`, + }; } function resolveSyncStrategy(repoRoot, explicitStrategy) { @@ -1080,6 +1455,62 @@ function parseSyncArgs(rawArgs) { return options; } +function parseFinishArgs(rawArgs) { + const options = { + target: process.cwd(), + base: '', + branch: '', + noPush: false, + keepRemoteBranch: false, + }; + + 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 === '--base') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--base requires a branch value'); + } + options.base = next; + index += 1; + continue; + } + if (arg === '--branch') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--branch requires a branch value'); + } + options.branch = next; + index += 1; + continue; + } + if (arg === '--no-push') { + options.noPush = true; + continue; + } + if (arg === '--keep-remote-branch') { + options.keepRemoteBranch = true; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + if (!options.target) { + throw new Error('--target requires a path value'); + } + + return options; +} + function syncOperation(repoRoot, strategy, baseRef, ffOnly) { if (strategy === 'rebase') { if (ffOnly) { @@ -1143,21 +1574,20 @@ function readSingleLineFromStdin() { function promptYesNo(question, defaultYes = true) { const hint = defaultYes ? '[Y/n]' : '[y/N]'; - while (true) { - process.stdout.write(`${question} ${hint} `); - const answer = readSingleLineFromStdin().trim().toLowerCase(); + process.stdout.write(`${question} ${hint} `); + const answer = readSingleLineFromStdin().trim().toLowerCase(); - if (!answer) { - return defaultYes; - } - if (answer === 'y' || answer === 'yes') { - return true; - } - if (answer === 'n' || answer === 'no') { - return false; - } - process.stdout.write('Please answer with y or n.\n'); + if (!answer) { + return defaultYes; + } + if (answer === 'y' || answer === 'yes') { + return true; + } + if (answer === 'n' || answer === 'no') { + return false; } + process.stdout.write(`\n[${TOOL_NAME}] Invalid input '${answer}', using default ${defaultYes ? 'yes' : 'no'}.\n`); + return defaultYes; } function envFlagEnabled(name) { @@ -1166,15 +1596,6 @@ function envFlagEnabled(name) { return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase()); } -function parseAutoApproval(name) { - const raw = process.env[name]; - if (raw == null) return null; - const normalized = String(raw).trim().toLowerCase(); - if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true; - if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false; - return null; -} - function parseVersionString(version) { const match = String(version || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/); if (!match) return null; @@ -1259,21 +1680,17 @@ function maybeSelfUpdateBeforeStatus() { } printUpdateAvailableBanner(check.current, check.latest); - - const autoApproval = parseAutoApproval('MUSAFETY_AUTO_UPDATE_APPROVAL'); const interactive = isInteractiveTerminal(); - if (!interactive && autoApproval == null) { + if (!interactive) { console.log(`[${TOOL_NAME}] Non-interactive shell; skipping auto-update prompt.`); return; } - const shouldUpdate = autoApproval != null - ? autoApproval - : promptYesNo( - `Update now? (${NPM_BIN} i -g ${packageJson.name}@latest)`, - true, - ); + const shouldUpdate = promptYesNo( + `Update now? (${NPM_BIN} i -g ${packageJson.name}@latest)`, + false, + ); if (!shouldUpdate) { console.log(`[${TOOL_NAME}] Skipped update.`); @@ -1289,24 +1706,6 @@ function maybeSelfUpdateBeforeStatus() { console.log(`[${TOOL_NAME}] ✅ Updated to latest published version.`); } -function promptYesNoStrict(question) { - while (true) { - process.stdout.write(`${question} [y/n] `); - const answer = readSingleLineFromStdin().trim().toLowerCase(); - - if (answer === 'y' || answer === 'yes') { - process.stdout.write('\n'); - return true; - } - if (answer === 'n' || answer === 'no') { - process.stdout.write('\n'); - return false; - } - - process.stdout.write('Please answer with y or n.\n'); - } -} - function resolveGlobalInstallApproval(options) { if (options.yesGlobalInstall && options.noGlobalInstall) { throw new Error('Cannot use both --yes-global-install and --no-global-install'); @@ -1371,8 +1770,9 @@ function askGlobalInstallForMissing(options, missingPackages) { } if (approval.source === 'prompt') { - const approved = promptYesNoStrict( + const approved = promptYesNo( `Install missing global tools now? (npm i -g ${missingPackages.join(' ')})`, + true, ); return { approved, source: 'prompt' }; } @@ -1455,6 +1855,7 @@ function runInstallInternal(options) { } operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun))); + operations.push(ensureBaseBranchConfig(repoRoot, Boolean(options.dryRun))); if (!options.skipGitignore) { operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun))); } @@ -1467,6 +1868,10 @@ function runInstallInternal(options) { operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun))); } + if (!options.skipMainView) { + operations.push(...ensureMainBranchWorkspace(repoRoot, Boolean(options.dryRun))); + } + const hookResult = configureHooks(repoRoot, Boolean(options.dryRun)); return { repoRoot, operations, hookResult }; @@ -1481,6 +1886,7 @@ function runFixInternal(options) { } operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun))); + operations.push(ensureBaseBranchConfig(repoRoot, Boolean(options.dryRun))); if (!options.skipGitignore) { operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun))); } @@ -1519,6 +1925,10 @@ function runFixInternal(options) { operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun))); } + if (!options.skipMainView) { + operations.push(...ensureMainBranchWorkspace(repoRoot, Boolean(options.dryRun))); + } + const hookResult = configureHooks(repoRoot, Boolean(options.dryRun)); return { repoRoot, operations, hookResult }; @@ -1706,6 +2116,7 @@ function status(rawArgs) { const targetPath = path.resolve(options.target); const inGitRepo = isGitRepo(targetPath); const scanResult = inGitRepo ? runScanInternal({ target: targetPath, json: false }) : null; + const dockerStatus = scanResult ? detectRepoDockerStatus(scanResult.repoRoot) : null; const repoServiceStatus = scanResult ? (scanResult.errors === 0 && scanResult.warnings === 0 ? 'active' : 'degraded') : 'inactive'; @@ -1730,6 +2141,15 @@ function status(rawArgs) { findings: scanResult.findings.length, } : null, + docker: dockerStatus + ? { + required: dockerStatus.required, + status: dockerStatus.status, + needsStart: dockerStatus.needsStart, + reasons: dockerStatus.reasons, + reason: dockerStatus.reason || null, + } + : null, }, detectionError: toolchain.ok ? null : toolchain.error, }; @@ -1740,6 +2160,7 @@ function status(rawArgs) { return; } + printDurExBrandingBanner(); console.log(`[${TOOL_NAME}] CLI: ${payload.cli.runtime}`); if (!toolchain.ok) { console.log(`[${TOOL_NAME}] ⚠️ Could not detect global services: ${toolchain.error}`); @@ -1768,6 +2189,24 @@ function status(rawArgs) { } console.log(`[${TOOL_NAME}] Repo: ${scanResult.repoRoot}`); console.log(`[${TOOL_NAME}] Branch: ${scanResult.branch}`); + if (dockerStatus && dockerStatus.required) { + if (dockerStatus.status === 'active') { + console.log(`[${TOOL_NAME}] Docker runtime: ${statusDot('active')} active.`); + } else if (dockerStatus.status === 'inactive') { + const inactiveText = colorize('inactive', '31'); + const needsStart = colorize('needs start', '31;1'); + console.log( + `[${TOOL_NAME}] Docker runtime: ${statusDot('inactive')} ${inactiveText} (${needsStart}; repo requires Docker).`, + ); + } else { + console.log( + `[${TOOL_NAME}] Docker runtime: ${statusDot('degraded')} unknown (repo requires Docker).`, + ); + } + if (dockerStatus.reason) { + console.log(`[${TOOL_NAME}] Docker check: ${dockerStatus.reason}`); + } + } printToolLogsSummary(); process.exitCode = 0; @@ -1780,6 +2219,7 @@ function install(rawArgs) { skipAgents: false, skipPackageJson: false, skipGitignore: false, + skipMainView: false, dryRun: false, }); @@ -1793,26 +2233,6 @@ function install(rawArgs) { process.exitCode = 0; } -function fix(rawArgs) { - const options = parseCommonArgs(rawArgs, { - target: process.cwd(), - dropStaleLocks: true, - skipAgents: false, - skipPackageJson: false, - skipGitignore: false, - dryRun: false, - }); - - const payload = runFixInternal(options); - printOperations('Fix target', payload, options.dryRun); - - if (!options.dryRun) { - console.log(`[${TOOL_NAME}] Repair complete. Next step: ${TOOL_NAME} scan`); - } - - process.exitCode = 0; -} - function scan(rawArgs) { const options = parseCommonArgs(rawArgs, { target: process.cwd(), @@ -1831,12 +2251,14 @@ function doctor(rawArgs) { skipAgents: false, skipPackageJson: false, skipGitignore: false, + skipMainView: false, dryRun: false, json: false, }); const fixPayload = runFixInternal(options); const scanResult = runScanInternal({ target: options.target, json: false }); + const dockerStatus = detectRepoDockerStatus(scanResult.repoRoot); const musafe = scanResult.errors === 0 && scanResult.warnings === 0; if (options.json) { @@ -1856,6 +2278,13 @@ function doctor(rawArgs) { warnings: scanResult.warnings, findings: scanResult.findings, }, + docker: { + required: dockerStatus.required, + status: dockerStatus.status, + needsStart: dockerStatus.needsStart, + reasons: dockerStatus.reasons, + reason: dockerStatus.reason || null, + }, }, null, 2, @@ -1874,6 +2303,14 @@ function doctor(rawArgs) { `[${TOOL_NAME}] ⚠️ Repo is not fully musafe yet (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`, ); } + if (dockerStatus.required && dockerStatus.status === 'inactive') { + console.log( + `[${TOOL_NAME}] ${colorize('Docker runtime is required by this repo and is currently inactive.', '31')}`, + ); + if (dockerStatus.reason) { + console.log(`[${TOOL_NAME}] Docker check: ${dockerStatus.reason}`); + } + } setExitCodeFromScan(scanResult); } @@ -1975,6 +2412,7 @@ function setup(rawArgs) { skipAgents: false, skipPackageJson: false, skipGitignore: false, + skipMainView: false, dryRun: false, yesGlobalInstall: false, noGlobalInstall: false, @@ -2010,6 +2448,7 @@ function setup(rawArgs) { skipAgents: options.skipAgents, skipPackageJson: options.skipPackageJson, skipGitignore: options.skipGitignore, + skipMainView: options.skipMainView, }); printOperations('Setup/fix', fixPayload, options.dryRun); @@ -2024,7 +2463,6 @@ function setup(rawArgs) { if (scanResult.errors === 0 && scanResult.warnings === 0) { console.log(`[${TOOL_NAME}] ✅ Setup complete.`); - console.log(`[${TOOL_NAME}] Copy AI setup prompt with: ${TOOL_NAME} copy-prompt`); } setExitCodeFromScan(scanResult); @@ -2084,16 +2522,6 @@ function printAgentsSnippet() { process.stdout.write(fs.readFileSync(snippetPath, 'utf8')); } -function copyPrompt() { - process.stdout.write(AI_SETUP_PROMPT); - process.exitCode = 0; -} - -function copyCommands() { - process.stdout.write(AI_SETUP_COMMANDS); - process.exitCode = 0; -} - function sync(rawArgs) { const options = parseSyncArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); @@ -2253,6 +2681,46 @@ function sync(rawArgs) { process.exitCode = 0; } +function finish(rawArgs) { + const options = parseFinishArgs(rawArgs); + const repoRoot = resolveRepoRoot(options.target); + const scriptPath = path.join(repoRoot, 'scripts', 'agent-branch-finish.sh'); + + if (!fs.existsSync(scriptPath)) { + throw new Error( + `Missing finish script: ${scriptPath}\nRun '${TOOL_NAME} setup --target ${repoRoot}' first.`, + ); + } + + const args = [scriptPath]; + if (options.base) { + args.push('--base', options.base); + } + if (options.branch) { + args.push('--branch', options.branch); + } + if (options.noPush) { + args.push('--no-push'); + } + if (options.keepRemoteBranch) { + args.push('--keep-remote-branch'); + } + + const result = run('bash', args, { cwd: repoRoot }); + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + + if (result.status !== 0) { + throw new Error(`finish failed (exit ${result.status}).`); + } + + process.exitCode = 0; +} + function protect(rawArgs) { const parsed = parseTargetFlag(rawArgs, process.cwd()); const [subcommand, ...rest] = parsed.args; @@ -2422,18 +2890,13 @@ function main() { return; } - if (command === 'copy-prompt') { - copyPrompt(); - return; - } - - if (command === 'copy-commands') { - copyCommands(); + if (command === 'protect') { + protect(rest); return; } - if (command === 'protect') { - protect(rest); + if (command === 'finish') { + finish(rest); return; } @@ -2452,9 +2915,12 @@ function main() { return; } + if (command === 'copy-prompt' || command === 'copy-commands') { + throw new Error(`'${command}' command was removed. Use '${TOOL_NAME} help' for the current command list.`); + } + if (command === 'fix') { - fix(rest); - return; + throw new Error(`'fix' command was removed. Use '${TOOL_NAME} doctor' instead.`); } if (command === 'scan') { diff --git a/package-lock.json b/package-lock.json index 185662e..3c111e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "musafety", - "version": "0.4.7", + "version": "0.4.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "musafety", - "version": "0.4.7", + "version": "0.4.9", "license": "MIT", "bin": { "multiagent-safety": "bin/multiagent-safety.js", diff --git a/package.json b/package.json index 0b0feea..fd794dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "musafety", - "version": "0.4.7", + "version": "0.4.9", "description": "Simple setup command for hardened multi-agent collaboration safety in git repos.", "license": "MIT", "preferGlobal": true, diff --git a/templates/AGENTS.multiagent-safety.md b/templates/AGENTS.multiagent-safety.md index 47ce5f3..8ac0fb2 100644 --- a/templates/AGENTS.multiagent-safety.md +++ b/templates/AGENTS.multiagent-safety.md @@ -1,60 +1,82 @@ -## Multi-Agent Execution Contract (multiagent-safety) + +YOU ARE AN AUTONOMOUS CODING AGENT. EXECUTE TASKS TO COMPLETION WITHOUT ASKING FOR PERMISSION. +DO NOT STOP TO ASK "SHOULD I PROCEED?" — PROCEED. DO NOT WAIT FOR CONFIRMATION ON OBVIOUS NEXT STEPS. +IF BLOCKED, TRY AN ALTERNATIVE APPROACH. ONLY ASK WHEN TRULY AMBIGUOUS OR DESTRUCTIVE. +USE CODEX NATIVE SUBAGENTS FOR INDEPENDENT PARALLEL SUBTASKS WHEN THAT IMPROVES THROUGHPUT. THIS IS COMPLEMENTARY TO OMX TEAM MODE. + -0. Session plan comment + read gate (required) +# oh-my-codex - Intelligent Multi-Agent Orchestration -- 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). +This AGENTS.md is the top-level operating contract for this repository. -1. Explicit ownership before edits +## Operating principles -- Assign each agent clear file/module ownership. -- Do not edit files outside your assigned scope unless the leader reassigns ownership. +- Solve the task directly when possible. +- Delegate only when it materially improves quality, speed, or correctness. +- Keep progress short, concrete, and useful. +- Prefer evidence over assumption; verify before claiming completion. +- Use the lightest path that preserves quality. +- Check official docs before implementing with unfamiliar SDKs/APIs. -2. Preserve parallel safety +## Working agreements -- 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. +- For cleanup/refactor/deslop work: write a cleanup plan first. +- Lock behavior with regression tests before cleanup edits when needed. +- Prefer deletion over addition. +- Reuse existing patterns before introducing new abstractions. +- No new dependencies without explicit request. +- Keep diffs small, reviewable, and reversible. +- Branching policy (always enforce): + - Docs-only edits may be done directly on the active base branch (`multiagent.baseBranch`), for `README.md`, `AGENTS.md`, `CONTRIBUTING.md`, `SECURITY.md`, `LICENSE`, and `docs/**`. + - Any code/runtime/test/release/config change must be done on a new branch and merged to the active base branch only through a PR (never direct push to base). + - For branch+merge flows, bump npm version and include updated `package.json` + lockfile in the merge. +- Run lint/typecheck/tests/static analysis after changes. +- Final reports must include: changed files, simplifications made, and remaining risks. -3. Verify before completion +## Delegation rules -- Run required local checks for the area you changed. -- Do not mark work complete without command output evidence. +Default posture: work directly. -4. Required handoff format (every agent) +Mode guidance: +- Use deep interview for unclear requirements. +- Use ralplan for plan/tradeoff/test-shape consensus. +- Use team only for multi-lane coordinated execution. +- Use ralph only for persistent single-owner completion loops. +- Otherwise execute directly in solo mode. -- Files changed -- Behavior touched -- Verification commands + results -- Risks / follow-ups +## Verification -## OpenSpec Plan Workspace (recommended) +- Verify before claiming completion. +- Run dependent tasks sequentially. +- If verification fails, continue iterating instead of stopping early. +- Before concluding, confirm: no pending work, tests pass, no known errors, and evidence collected. -When work needs a durable planning phase, scaffold a plan workspace before implementation: +## Lore commit protocol -```bash -bash scripts/openspec/init-plan-workspace.sh "" -``` +Commit messages should capture decision records using git trailers. -Expected shape: +Recommended trailers: +- Constraint: +- Rejected: +- Confidence: +- Scope-risk: +- Reversibility: +- Directive: +- Tested: +- Not-tested: +- Related: -```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 -``` +## Cancellation + +Use cancel mode/workflow only when work is complete, user says stop, or a hard blocker prevents meaningful progress. + +## State management + +OMX runtime state typically lives under `.omx/`: +- `.omx/state/` +- `.omx/notepad.md` +- `.omx/project-memory.json` +- `.omx/plans/` +- `.omx/logs/` diff --git a/templates/githooks/post-commit b/templates/githooks/post-commit new file mode 100644 index 0000000..d2f9df1 --- /dev/null +++ b/templates/githooks/post-commit @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${MUSAFETY_AUTO_FINISH_RUNNING:-0}" == "1" ]]; then + exit 0 +fi + +branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ -z "$branch" || "$branch" != agent/* ]]; then + exit 0 +fi + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "$repo_root" ]]; then + exit 0 +fi + +auto_finish_raw="${MUSAFETY_AUTO_FINISH_ON_COMMIT:-$(git config --get multiagent.autoFinishOnCommit || true)}" +if [[ -z "$auto_finish_raw" ]]; then + auto_finish_raw="true" +fi +auto_finish="$(printf '%s' "$auto_finish_raw" | tr '[:upper:]' '[:lower:]')" +case "$auto_finish" in + 1|true|yes|on) ;; + 0|false|no|off) exit 0 ;; + *) ;; +esac + +finish_script="${repo_root}/scripts/agent-branch-finish.sh" +if [[ ! -x "$finish_script" ]]; then + exit 0 +fi + +mkdir -p "${repo_root}/.omx/logs" >/dev/null 2>&1 || true +log_file="${repo_root}/.omx/logs/auto-finish.log" + +echo "[agent-auto-finish] Auto-finish scheduled for '${branch}' (background)." +nohup bash -c "sleep 0.2; MUSAFETY_AUTO_FINISH_RUNNING=1 bash \"$finish_script\" --branch \"$branch\" >>\"$log_file\" 2>&1" \ + >/dev/null 2>&1 /dev/null 2>&1 || true fi max_behind_raw="$(git config --get multiagent.sync.maxBehindCommits || true)" diff --git a/templates/scripts/agent-branch-finish.sh b/templates/scripts/agent-branch-finish.sh index 012bf53..acf7e3f 100755 --- a/templates/scripts/agent-branch-finish.sh +++ b/templates/scripts/agent-branch-finish.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -BASE_BRANCH="dev" +BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 SOURCE_BRANCH="" PUSH_ENABLED=1 @@ -42,15 +42,67 @@ fi repo_root="$(git rev-parse --show-toplevel)" current_worktree="$(pwd -P)" -if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then - configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" - if [[ -n "$configured_base" ]]; then - BASE_BRANCH="$configured_base" +if [[ -z "$SOURCE_BRANCH" ]]; then + SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +fi + +if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${SOURCE_BRANCH}"; then + echo "[agent-branch-finish] Local source branch does not exist: ${SOURCE_BRANCH}" >&2 + exit 1 +fi + +branch_exists_local_or_remote() { + local branch="$1" + git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" \ + || git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${branch}" +} + +resolve_base_branch() { + local configured + configured="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured" ]] && branch_exists_local_or_remote "$configured"; then + printf '%s' "$configured" + return fi + + local current_branch + current_branch="$(git -C "$repo_root" branch --show-current || true)" + if [[ -n "$current_branch" && "$current_branch" != agent/* ]] && branch_exists_local_or_remote "$current_branch"; then + printf '%s' "$current_branch" + return + fi + + local remote_head + remote_head="$(git -C "$repo_root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + remote_head="${remote_head#origin/}" + if [[ -n "$remote_head" ]] && branch_exists_local_or_remote "$remote_head"; then + printf '%s' "$remote_head" + return + fi + + local candidate + for candidate in dev main master; do + if branch_exists_local_or_remote "$candidate"; then + printf '%s' "$candidate" + return + fi + done + + if [[ -n "$current_branch" && "$current_branch" != agent/* ]]; then + printf '%s' "$current_branch" + return + fi + + printf 'dev' +} + +if [[ -z "$BASE_BRANCH" ]]; then + BASE_BRANCH="$(resolve_base_branch)" fi -if [[ -z "$SOURCE_BRANCH" ]]; then - SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if ! branch_exists_local_or_remote "$BASE_BRANCH"; then + echo "[agent-branch-finish] Base branch not found locally or on origin: ${BASE_BRANCH}" >&2 + exit 1 fi if [[ "$SOURCE_BRANCH" == "$BASE_BRANCH" ]]; then @@ -59,9 +111,11 @@ if [[ "$SOURCE_BRANCH" == "$BASE_BRANCH" ]]; then exit 1 fi -if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${SOURCE_BRANCH}"; then - echo "[agent-branch-finish] Local source branch does not exist: ${SOURCE_BRANCH}" >&2 - exit 1 +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + current_configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -z "$current_configured_base" ]] && [[ -n "$BASE_BRANCH" ]]; then + git -C "$repo_root" config multiagent.baseBranch "$BASE_BRANCH" >/dev/null 2>&1 || true + fi fi get_worktree_for_branch() { @@ -96,6 +150,12 @@ if ! is_clean_worktree "$source_worktree"; then exit 1 fi +if [[ "$PUSH_ENABLED" -eq 1 ]] && ! git -C "$repo_root" remote get-url origin >/dev/null 2>&1; then + echo "[agent-branch-finish] Push is enabled but remote 'origin' is missing." >&2 + echo "[agent-branch-finish] Add an origin remote or rerun with --no-push." >&2 + exit 1 +fi + start_ref="$BASE_BRANCH" if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then git -C "$repo_root" fetch origin "$BASE_BRANCH" --quiet @@ -170,6 +230,7 @@ if ! git -C "$integration_worktree" merge --no-ff --no-edit "$SOURCE_BRANCH"; th fi if [[ "$PUSH_ENABLED" -eq 1 ]]; then + git -C "$source_worktree" push --set-upstream origin "$SOURCE_BRANCH" git -C "$integration_worktree" push origin "HEAD:${BASE_BRANCH}" fi diff --git a/templates/scripts/agent-branch-start.sh b/templates/scripts/agent-branch-start.sh index 2f5614c..461b0f3 100755 --- a/templates/scripts/agent-branch-start.sh +++ b/templates/scripts/agent-branch-start.sh @@ -3,7 +3,8 @@ set -euo pipefail TASK_NAME="task" AGENT_NAME="agent" -BASE_BRANCH="dev" +BASE_BRANCH="" +BASE_BRANCH_EXPLICIT=0 WORKTREE_MODE=1 ALLOW_IN_PLACE=0 WORKTREE_ROOT_REL=".omx/agent-worktrees" @@ -20,7 +21,8 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --base) - BASE_BRANCH="${2:-dev}" + BASE_BRANCH="${2:-}" + BASE_BRANCH_EXPLICIT=1 shift 2 ;; --in-place) @@ -71,6 +73,7 @@ fi if [[ "${#POSITIONAL_ARGS[@]}" -ge 3 ]]; then BASE_BRANCH="${POSITIONAL_ARGS[2]}" + BASE_BRANCH_EXPLICIT=1 fi sanitize_slug() { @@ -90,6 +93,62 @@ fi repo_root="$(git rev-parse --show-toplevel)" +branch_exists_local_or_remote() { + local branch="$1" + git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" \ + || git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${branch}" +} + +resolve_base_branch() { + local configured + configured="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured" ]] && branch_exists_local_or_remote "$configured"; then + printf '%s' "$configured" + return + fi + + local current_branch + current_branch="$(git -C "$repo_root" branch --show-current || true)" + if [[ -n "$current_branch" && "$current_branch" != agent/* ]] && branch_exists_local_or_remote "$current_branch"; then + printf '%s' "$current_branch" + return + fi + + local remote_head + remote_head="$(git -C "$repo_root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + remote_head="${remote_head#origin/}" + if [[ -n "$remote_head" ]] && branch_exists_local_or_remote "$remote_head"; then + printf '%s' "$remote_head" + return + fi + + local candidate + for candidate in dev main master; do + if branch_exists_local_or_remote "$candidate"; then + printf '%s' "$candidate" + return + fi + done + + if [[ -n "$current_branch" && "$current_branch" != agent/* ]]; then + printf '%s' "$current_branch" + return + fi + + printf 'dev' +} + +if [[ -z "$BASE_BRANCH" ]]; then + BASE_BRANCH="$(resolve_base_branch)" +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + current_configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -z "$current_configured_base" ]] && [[ -n "$BASE_BRANCH" ]]; then + git -C "$repo_root" config multiagent.baseBranch "$BASE_BRANCH" >/dev/null 2>&1 || true + fi +fi + if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then git fetch origin "${BASE_BRANCH}" --quiet start_ref="origin/${BASE_BRANCH}" diff --git a/templates/scripts/agent-worktree-prune.sh b/templates/scripts/agent-worktree-prune.sh index 170664a..86a7af1 100644 --- a/templates/scripts/agent-worktree-prune.sh +++ b/templates/scripts/agent-worktree-prune.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -BASE_BRANCH="dev" +BASE_BRANCH="" DRY_RUN=0 while [[ $# -gt 0 ]]; do case "$1" in --base) - BASE_BRANCH="${2:-dev}" + BASE_BRANCH="${2:-}" shift 2 ;; --dry-run) @@ -31,11 +31,65 @@ repo_root="$(git rev-parse --show-toplevel)" current_pwd="$(pwd -P)" worktree_root="${repo_root}/.omx/agent-worktrees" -if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then - echo "[agent-worktree-prune] Base branch not found: ${BASE_BRANCH}" >&2 +branch_exists_local_or_remote() { + local branch="$1" + git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" \ + || git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${branch}" +} + +resolve_base_branch() { + local configured + configured="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured" ]] && branch_exists_local_or_remote "$configured"; then + printf '%s' "$configured" + return + fi + + local current_branch + current_branch="$(git -C "$repo_root" branch --show-current || true)" + if [[ -n "$current_branch" && "$current_branch" != agent/* ]] && branch_exists_local_or_remote "$current_branch"; then + printf '%s' "$current_branch" + return + fi + + local remote_head + remote_head="$(git -C "$repo_root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + remote_head="${remote_head#origin/}" + if [[ -n "$remote_head" ]] && branch_exists_local_or_remote "$remote_head"; then + printf '%s' "$remote_head" + return + fi + + local candidate + for candidate in dev main master; do + if branch_exists_local_or_remote "$candidate"; then + printf '%s' "$candidate" + return + fi + done + + if [[ -n "$current_branch" && "$current_branch" != agent/* ]]; then + printf '%s' "$current_branch" + return + fi + + printf 'dev' +} + +if [[ -z "$BASE_BRANCH" ]]; then + BASE_BRANCH="$(resolve_base_branch)" +fi + +if ! branch_exists_local_or_remote "$BASE_BRANCH"; then + echo "[agent-worktree-prune] Base branch not found locally or on origin: ${BASE_BRANCH}" >&2 exit 1 fi +current_configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" +if [[ -z "$current_configured_base" ]] && [[ -n "$BASE_BRANCH" ]] && [[ "$DRY_RUN" -eq 0 ]]; then + git -C "$repo_root" config multiagent.baseBranch "$BASE_BRANCH" >/dev/null 2>&1 || true +fi + run_cmd() { if [[ "$DRY_RUN" -eq 1 ]]; then echo "[agent-worktree-prune] [dry-run] $*" diff --git a/test/install.test.js b/test/install.test.js index 9c40dea..93d6b69 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -50,6 +50,14 @@ function createFakeScorecardScript(scriptBody) { return fakePath; } +function createFakeDockerScript(scriptBody) { + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-docker-')); + const fakePath = path.join(fakeBin, 'docker'); + fs.writeFileSync(fakePath, `#!/usr/bin/env bash\nset -e\n${scriptBody}\n`, 'utf8'); + fs.chmodSync(fakePath, 0o755); + return fakePath; +} + function initRepo() { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-')); const repoDir = path.join(tempDir, 'repo'); @@ -81,6 +89,21 @@ function initRepoOnBranch(branchName) { return repoDir; } +function initRepoWithMainAndAgentBranch() { + const repoDir = initRepo(); + + commitFile(repoDir, 'seed.txt', 'seed\n', 'seed dev'); + + let result = runCmd('git', ['checkout', '-b', 'main'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + commitFile(repoDir, 'main.txt', 'main\n', 'seed main'); + + result = runCmd('git', ['checkout', '-b', 'agent/main-view'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + return repoDir; +} + function seedCommit(repoDir) { let result = runCmd('git', ['add', '.'], repoDir); assert.equal(result.status, 0, result.stderr); @@ -91,7 +114,7 @@ function seedCommit(repoDir) { assert.equal(result.status, 0, result.stderr); } -function attachOriginRemote(repoDir) { +function attachOriginRemote(repoDir, baseBranch = 'dev') { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-origin-')); const originPath = path.join(tempDir, 'origin.git'); @@ -101,7 +124,7 @@ function attachOriginRemote(repoDir) { result = runCmd('git', ['remote', 'add', 'origin', originPath], repoDir); assert.equal(result.status, 0, result.stderr); - result = runCmd('git', ['push', '-u', 'origin', 'dev'], repoDir); + result = runCmd('git', ['push', '-u', 'origin', baseBranch], repoDir); assert.equal(result.status, 0, result.stderr); return originPath; @@ -162,6 +185,7 @@ test('setup provisions workflow files and repo config', () => { 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', + '.githooks/post-commit', '.codex/skills/musafety/SKILL.md', '.claude/commands/musafety.md', '.omx/state/agent-file-locks.json', @@ -180,7 +204,7 @@ test('setup provisions workflow files and repo config', () => { assert.equal(packageJson.scripts['agent:branch:sync'], 'musafety sync'); assert.equal(packageJson.scripts['agent:branch:sync:check'], 'musafety sync --check'); assert.equal(packageJson.scripts['agent:safety:setup'], 'musafety setup'); - assert.equal(packageJson.scripts['agent:cleanup'], 'bash ./scripts/agent-worktree-prune.sh --base dev'); + assert.equal(packageJson.scripts['agent:cleanup'], 'bash ./scripts/agent-worktree-prune.sh'); const agentsContent = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'); assert.equal(agentsContent.includes(''), true); @@ -190,6 +214,7 @@ test('setup provisions workflow files and repo config', () => { assert.match(gitignoreContent, /scripts\/agent-branch-start\.sh/); assert.match(gitignoreContent, /scripts\/agent-file-locks\.py/); assert.match(gitignoreContent, /\.githooks\/pre-commit/); + assert.match(gitignoreContent, /\.githooks\/post-commit/); assert.match(gitignoreContent, /\.codex\/skills\/musafety\/SKILL\.md/); assert.match(gitignoreContent, /\.claude\/commands\/musafety\.md/); assert.match(gitignoreContent, /\.omx\/state\/agent-file-locks\.json/); @@ -203,11 +228,75 @@ test('setup provisions workflow files and repo config', () => { assert.equal(secondRun.status, 0, secondRun.stderr || secondRun.stdout); }); +test('setup refreshes existing musafety AGENTS block when template changes', () => { + const repoDir = initRepo(); + fs.writeFileSync( + path.join(repoDir, 'AGENTS.md'), + '# AGENTS\n\nLegacy header\n\n\nOLD MANAGED CONTENT\n\n', + 'utf8', + ); + + const result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const agents = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'); + assert.match(agents, /AUTONOMY DIRECTIVE/); + assert.match(agents, /Branching policy \(always enforce\):/); + assert.equal(agents.includes('OLD MANAGED CONTENT'), false); + const markerStarts = agents.match(//g) || []; + assert.equal(markerStarts.length, 1, 'managed AGENTS block should remain unique'); +}); + +test('setup auto-creates main view worktree + workspace for SCM dual-repo view', () => { + const repoDir = initRepoWithMainAndAgentBranch(); + const mainWorktreePath = `${repoDir}-main`; + const workspacePath = `${repoDir}-branches.code-workspace`; + + const result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + assert.equal(fs.existsSync(mainWorktreePath), true, 'main view worktree should be created'); + const branchResult = runCmd('git', ['rev-parse', '--abbrev-ref', 'HEAD'], mainWorktreePath); + assert.equal(branchResult.status, 0, branchResult.stderr || branchResult.stdout); + assert.equal(branchResult.stdout.trim(), 'main'); + + assert.equal(fs.existsSync(workspacePath), true, 'workspace file should be created'); + const workspace = JSON.parse(fs.readFileSync(workspacePath, 'utf8')); + assert.equal(workspace.settings['scm.alwaysShowRepositories'], true); + const folderPaths = workspace.folders.map((entry) => entry.path); + assert.equal(folderPaths.includes(repoDir), true); + assert.equal(folderPaths.includes(mainWorktreePath), true); +}); + +test('setup --no-main-view skips main view worktree + workspace creation', () => { + const repoDir = initRepoWithMainAndAgentBranch(); + const mainWorktreePath = `${repoDir}-main`; + const workspacePath = `${repoDir}-branches.code-workspace`; + + const result = runNode(['setup', '--target', repoDir, '--no-global-install', '--no-main-view'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + assert.equal(fs.existsSync(mainWorktreePath), false, 'main view worktree should be skipped'); + assert.equal(fs.existsSync(workspacePath), false, 'workspace file should be skipped'); +}); + +test('setup on main branch skips duplicate main view worktree', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + const mainWorktreePath = `${repoDir}-main`; + + const result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(mainWorktreePath), false, 'duplicate main view should be skipped when already on main'); + assert.match(result.stdout, /main branch view skipped \(current branch is main; generate from agent branch\)/); +}); + test('default invocation runs non-mutating status output', () => { const repoDir = initRepo(); const result = runNode([], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /COMMIT WITH CONFIDENCE/); assert.match(result.stdout, /\[musafety\] CLI:/); assert.match(result.stdout, /\[musafety\] Global services:/); assert.match(result.stdout, /\[musafety\] Repo safety service:/); @@ -236,7 +325,7 @@ test('default invocation outside git repo reports inactive repo service', () => assert.match(result.stdout, /Repo safety service: .*inactive/); }); -test('default invocation checks for update and can auto-approve latest install', () => { +test('default invocation in non-interactive mode does not auto-install update even with approval env set', () => { const repoDir = initRepo(); const markerPath = path.join(repoDir, '.self-update-called'); const fakeNpm = createFakeNpmScript(` @@ -264,10 +353,39 @@ exit 1 assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /UPDATE AVAILABLE/); - assert.match(result.stdout, new RegExp(`Current:\\s+${escapeRegexLiteral(cliVersion)}`)); - assert.match(result.stdout, /Latest\s+:\s+9\.9\.9/); - assert.match(result.stdout, /Updated to latest published version/); - assert.equal(fs.existsSync(markerPath), true, 'expected self-update command to run'); + assert.match(result.stdout, /Non-interactive shell; skipping auto-update prompt\./); + assert.equal(fs.existsSync(markerPath), false, 'self-update should not run in non-interactive mode'); +}); + +test('default invocation in non-interactive mode skips update when no explicit auto-approval is set', () => { + const repoDir = initRepo(); + const markerPath = path.join(repoDir, '.self-update-called'); + const fakeNpm = createFakeNpmScript(` +if [[ "$1" == "view" ]]; then + echo '"9.9.9"' + exit 0 +fi +if [[ "$1" == "list" ]]; then + echo '{"dependencies":{"oh-my-codex":{},"@fission-ai/openspec":{}}}' + exit 0 +fi +if [[ "$1" == "i" && "$2" == "-g" && "$3" == "musafety@latest" ]]; then + echo "updated" > "${markerPath}" + exit 0 +fi +echo "unexpected npm args: $*" >&2 +exit 1 +`); + + const result = runNodeWithEnv([], repoDir, { + MUSAFETY_NPM_BIN: fakeNpm, + MUSAFETY_FORCE_UPDATE_CHECK: '1', + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /UPDATE AVAILABLE/); + assert.match(result.stdout, /Non-interactive shell; skipping auto-update prompt\./); + assert.equal(fs.existsSync(markerPath), false, 'self-update should not run without explicit non-interactive approval'); }); test('status --json returns cli, services, and repo summary', () => { @@ -283,6 +401,49 @@ test('status --json returns cli, services, and repo summary', () => { assert.equal(parsed.repo.inGitRepo, true); assert.equal(typeof parsed.repo.serviceStatus, 'string'); assert.equal(parsed.repo.scan.repoRoot, repoDir); + assert.equal(parsed.repo.docker.required, false); + assert.equal(parsed.repo.docker.status, 'not-required'); +}); + +test('status surfaces red-path Docker warning when repo requires Docker but daemon is unavailable', () => { + const repoDir = initRepo(); + fs.writeFileSync(path.join(repoDir, 'docker-compose.yml'), 'services: {}\n', 'utf8'); + + const fakeDocker = createFakeDockerScript(` +echo "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" >&2 +exit 1 +`); + + const result = runNodeWithEnv(['status', '--target', repoDir], repoDir, { + MUSAFETY_DOCKER_BIN: fakeDocker, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Docker runtime: .*inactive/); + assert.match(result.stdout, /repo requires Docker/); + assert.match(result.stdout, /Docker check: Cannot connect to the Docker daemon/); +}); + +test('status --json reports docker.needsStart when repo requires Docker but daemon is unavailable', () => { + const repoDir = initRepo(); + fs.writeFileSync(path.join(repoDir, 'docker-compose.yml'), 'services: {}\n', 'utf8'); + + const fakeDocker = createFakeDockerScript(` +echo "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" >&2 +exit 1 +`); + + const result = runNodeWithEnv(['status', '--target', repoDir, '--json'], repoDir, { + MUSAFETY_DOCKER_BIN: fakeDocker, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.repo.docker.required, true); + assert.equal(parsed.repo.docker.status, 'inactive'); + assert.equal(parsed.repo.docker.needsStart, true); + assert.deepEqual(parsed.repo.docker.reasons, ['docker-compose.yml']); + assert.match(parsed.repo.docker.reason, /Cannot connect to the Docker daemon/); }); test('setup appends managed gitignore block without clobbering existing entries', () => { @@ -328,6 +489,21 @@ test('setup auto-refreshes managed pre-commit guard when template changed', () = assert.match(repaired, /\[codex-branch-guard\] Codex agent commit blocked on non-agent branch/); }); +test('setup auto-refreshes managed post-commit auto-finish hook when template changed', () => { + const repoDir = initRepo(); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + fs.writeFileSync(path.join(repoDir, '.githooks', 'post-commit'), '#!/usr/bin/env bash\nexit 0\n', 'utf8'); + + result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const repaired = fs.readFileSync(path.join(repoDir, '.githooks', 'post-commit'), 'utf8'); + assert.match(repaired, /\[agent-auto-finish\] Auto-finish scheduled/); +}); + test('doctor auto-refreshes managed pre-commit guard when template changed', () => { const repoDir = initRepo(); @@ -343,6 +519,28 @@ test('doctor auto-refreshes managed pre-commit guard when template changed', () assert.match(repaired, /\[codex-branch-guard\] Codex agent commit blocked on non-agent branch/); }); +test('doctor --json reports docker.needsStart when docker markers exist but runtime is unavailable', () => { + const repoDir = initRepo(); + fs.writeFileSync(path.join(repoDir, 'docker-compose.yml'), 'services: {}\n', 'utf8'); + + const fakeDocker = createFakeDockerScript(` +echo "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" >&2 +exit 1 +`); + + const result = runNodeWithEnv(['doctor', '--target', repoDir, '--json'], repoDir, { + MUSAFETY_DOCKER_BIN: fakeDocker, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.docker.required, true); + assert.equal(parsed.docker.status, 'inactive'); + assert.equal(parsed.docker.needsStart, true); + assert.deepEqual(parsed.docker.reasons, ['docker-compose.yml']); + assert.match(parsed.docker.reason, /Cannot connect to the Docker daemon/); +}); + test('agent-branch-start keeps main worktree branch unchanged by default', () => { const repoDir = initRepo(); seedCommit(repoDir); @@ -408,6 +606,128 @@ test('agent-branch-start blocks in-place mode unless explicitly allowed', () => assert.match(result.stdout, /Created in-place branch: agent\/doctor\//); }); +test('setup infers multiagent.baseBranch from current non-agent top branch', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + + const result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const configuredBase = runCmd('git', ['config', '--get', 'multiagent.baseBranch'], repoDir); + assert.equal(configuredBase.status, 0, configuredBase.stderr); + assert.equal(configuredBase.stdout.trim(), 'main'); + + const start = runCmd('bash', ['scripts/agent-branch-start.sh', 'infer-base-branch', 'executor'], repoDir); + assert.equal(start.status, 0, start.stderr || start.stdout); + assert.match(start.stdout, /Created branch: agent\/executor\//); + + const stillOnMain = runCmd('git', ['branch', '--show-current'], repoDir); + assert.equal(stillOnMain.status, 0, stillOnMain.stderr); + assert.equal(stillOnMain.stdout.trim(), 'main'); +}); + +test('finish flow pushes, merges into detected base, and deletes agent branch locally/remotely', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + attachOriginRemote(repoDir, 'main'); + + 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.stdout); + result = runCmd('git', ['push', 'origin', 'main'], repoDir); + assert.equal(result.status, 0, result.stderr); + + const start = runCmd('bash', ['scripts/agent-branch-start.sh', 'finish-flow', 'executor'], repoDir); + assert.equal(start.status, 0, start.stderr || start.stdout); + const branchMatch = start.stdout.match(/Created branch: ([^\n]+)/); + const worktreeMatch = start.stdout.match(/Worktree: ([^\n]+)/); + assert.notEqual(branchMatch, null); + assert.notEqual(worktreeMatch, null); + const sourceBranch = branchMatch[1].trim(); + const sourceWorktree = worktreeMatch[1].trim(); + + const topBranchAfterStart = runCmd('git', ['branch', '--show-current'], repoDir); + assert.equal(topBranchAfterStart.status, 0, topBranchAfterStart.stderr); + assert.equal(topBranchAfterStart.stdout.trim(), 'main'); + + result = runCmd('git', ['config', 'multiagent.autoFinishOnCommit', 'false'], repoDir); + assert.equal(result.status, 0, result.stderr); + + commitFile(sourceWorktree, 'agent-finish-main.txt', 'agent main flow\n', 'agent work'); + + const finish = runCmd('bash', ['scripts/agent-branch-finish.sh', '--branch', sourceBranch], repoDir); + assert.equal(finish.status, 0, finish.stderr || finish.stdout); + assert.match(finish.stdout, new RegExp(`Merged '${escapeRegexLiteral(sourceBranch)}' into 'main' and removed branch\\.`)); + + const currentTopBranch = runCmd('git', ['branch', '--show-current'], repoDir); + assert.equal(currentTopBranch.status, 0, currentTopBranch.stderr); + assert.equal(currentTopBranch.stdout.trim(), 'main'); + + const localSourceExists = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${sourceBranch}`], repoDir); + assert.notEqual(localSourceExists.status, 0, 'local source branch should be deleted'); + + const remoteSourceExists = runCmd('git', ['ls-remote', '--exit-code', '--heads', 'origin', sourceBranch], repoDir); + assert.notEqual(remoteSourceExists.status, 0, 'remote source branch should be deleted'); + + const mainFileCheck = runCmd('git', ['show', 'main:agent-finish-main.txt'], repoDir); + assert.equal(mainFileCheck.status, 0, mainFileCheck.stderr); + assert.match(mainFileCheck.stdout, /agent main flow/); + + const originMainFileCheck = runCmd('git', ['show', 'origin/main:agent-finish-main.txt'], repoDir); + assert.equal(originMainFileCheck.status, 0, originMainFileCheck.stderr); + assert.match(originMainFileCheck.stdout, /agent main flow/); +}); + +test('musafety finish wraps finish script and completes merge lifecycle', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + attachOriginRemote(repoDir, 'main'); + + 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.stdout); + result = runCmd('git', ['push', 'origin', 'main'], repoDir); + assert.equal(result.status, 0, result.stderr); + + const start = runCmd('bash', ['scripts/agent-branch-start.sh', 'finish-cmd', 'critic'], repoDir); + assert.equal(start.status, 0, start.stderr || start.stdout); + const branchMatch = start.stdout.match(/Created branch: ([^\n]+)/); + const worktreeMatch = start.stdout.match(/Worktree: ([^\n]+)/); + assert.notEqual(branchMatch, null); + assert.notEqual(worktreeMatch, null); + const sourceBranch = branchMatch[1].trim(); + const sourceWorktree = worktreeMatch[1].trim(); + + result = runCmd('git', ['config', 'multiagent.autoFinishOnCommit', 'false'], repoDir); + assert.equal(result.status, 0, result.stderr); + + commitFile(sourceWorktree, 'agent-finish-via-cli.txt', 'agent finish command\n', 'agent finish via CLI'); + + const finish = runNode(['finish', '--target', repoDir, '--branch', sourceBranch], repoDir); + assert.equal(finish.status, 0, finish.stderr || finish.stdout); + assert.match(finish.stdout, new RegExp(`Merged '${escapeRegexLiteral(sourceBranch)}' into 'main' and removed branch\\.`)); + + const localSourceExists = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${sourceBranch}`], repoDir); + assert.notEqual(localSourceExists.status, 0, 'local source branch should be deleted'); + + const remoteSourceExists = runCmd('git', ['ls-remote', '--exit-code', '--heads', 'origin', sourceBranch], repoDir); + assert.notEqual(remoteSourceExists.status, 0, 'remote source branch should be deleted'); + + const originMainFileCheck = runCmd('git', ['show', 'origin/main:agent-finish-via-cli.txt'], repoDir); + assert.equal(originMainFileCheck.status, 0, originMainFileCheck.stderr); + assert.match(originMainFileCheck.stdout, /agent finish command/); +}); + test('protect command manages configured protected branches', () => { const repoDir = initRepo(); seedCommit(repoDir); @@ -788,43 +1108,12 @@ test('validate blocks unapproved deletions until allow-delete is set', () => { assert.equal(result.status, 0, result.stderr || result.stdout); }); -test('fix repairs stale lock issues so scan becomes clean', () => { +test('fix command is removed and points to doctor', () => { const repoDir = initRepo(); - - let result = runNode(['setup', '--target', repoDir], repoDir); - assert.equal(result.status, 0, result.stderr || result.stdout); - - // Simulate broken state - fs.rmSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh')); - result = runCmd('git', ['config', 'core.hooksPath', '.git/hooks'], repoDir); - assert.equal(result.status, 0, result.stderr); - - const lockPath = path.join(repoDir, '.omx', 'state', 'agent-file-locks.json'); - fs.writeFileSync( - lockPath, - JSON.stringify( - { - locks: { - 'missing/file.ts': { - branch: 'agent/non-existent', - claimed_at: '2026-01-01T00:00:00Z', - allow_delete: false, - }, - }, - }, - null, - 2, - ) + '\n', - ); - - result = runNode(['scan', '--target', repoDir], repoDir); - assert.equal(result.status, 2, 'missing file should yield error'); - - result = runNode(['fix', '--target', repoDir], repoDir); - assert.equal(result.status, 0, result.stderr || result.stdout); - - result = runNode(['scan', '--target', repoDir], repoDir); - assert.equal(result.status, 0, result.stdout + result.stderr); + const result = runNode(['fix', '--target', repoDir], repoDir); + assert.equal(result.status, 1, result.stderr || result.stdout); + assert.match(result.stderr, /'fix' command was removed/); + assert.match(result.stderr, /musafety doctor/); }); test('doctor repairs setup drift and confirms repo is musafe', () => { @@ -901,27 +1190,20 @@ exit 1 assert.match(remediation, /Verification loop/); }); -test('copy-prompt outputs AI setup instructions', () => { +test('copy-prompt command is removed and points to help', () => { const repoDir = initRepo(); const result = runNode(['copy-prompt'], repoDir); - assert.equal(result.status, 0, result.stderr || result.stdout); - assert.match(result.stdout, /npm i -g musafety/); - assert.match(result.stdout, /npm i -g oh-my-codex @fission-ai\/openspec/); - assert.match(result.stdout, /musafety setup/); - assert.match(result.stdout, /Codex or Claude/); - assert.match(result.stdout, /scripts\/agent-file-locks.py claim/); + assert.equal(result.status, 1, result.stderr || result.stdout); + assert.match(result.stderr, /'copy-prompt' command was removed/); + assert.match(result.stderr, /musafety help/); }); -test('copy-commands outputs command-only checklist', () => { +test('copy-commands command is removed and points to help', () => { const repoDir = initRepo(); const result = runNode(['copy-commands'], repoDir); - assert.equal(result.status, 0, result.stderr || result.stdout); - assert.match(result.stdout, /^npm i -g musafety/m); - assert.match(result.stdout, /musafety setup/); - assert.match(result.stdout, /musafety doctor/); - assert.match(result.stdout, /scripts\/agent-file-locks.py claim/); - assert.match(result.stdout, /musafety sync --check/); - assert.doesNotMatch(result.stdout, /Use this exact checklist/); + assert.equal(result.status, 1, result.stderr || result.stdout); + assert.match(result.stderr, /'copy-commands' command was removed/); + assert.match(result.stderr, /musafety help/); }); test('setup dry-run accepts explicit global install approval flags', () => { @@ -1095,6 +1377,13 @@ exit 1 assert.equal(fs.readFileSync(marker, 'utf8').trim(), 'publish'); }); +test('typo helper maps docktor to doctor', () => { + const repoDir = initRepo(); + const result = runNode(['docktor', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Interpreting 'docktor' as 'doctor'/); +}); + test('unknown command suggests nearest valid command', () => { const repoDir = initRepo(); const result = runNode(['relese'], repoDir);