diff --git a/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/.openspec.yaml b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/.openspec.yaml
new file mode 100644
index 0000000..25345f4
--- /dev/null
+++ b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-22
diff --git a/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/proposal.md b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/proposal.md
new file mode 100644
index 0000000..acebb3b
--- /dev/null
+++ b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/proposal.md
@@ -0,0 +1,18 @@
+## Why
+
+- `bin/multiagent-safety.js` is roughly 7,864 lines long and currently mixes CLI parsing, template rendering, git/worktree plumbing, protected-base sandboxing, finish/merge logic, toolchain self-update, and output/report formatting.
+- That shared module scope makes even small changes hard to review and easy to regress because unrelated helpers are tightly coupled and tests have to exercise one monolith.
+- The requested outcome is a seam-based decomposition so future Guardex CLI changes can land in smaller diffs with clearer ownership and lower regression risk.
+
+## What Changes
+
+- Introduce a `src/` runtime layout that separates `cli`, `output`, `git`, `scaffold`, `hooks`, `toolchain`, `sandbox`, and `finish`, with only small shared helpers left in `src/context.js` and `src/core/runtime.js`.
+- Reduce `bin/multiagent-safety.js` to a thin entrypoint that boots `src/cli/main.js`.
+- Preserve the current command surface, aliases, and targeted behavior while moving the existing logic wholesale into the new modules.
+- Update package shipping and regression coverage so installed CLIs still include `src/**` and the extracted runtime stays exercised by install/metadata tests.
+
+## Impact
+
+- Primary surfaces: `bin/multiagent-safety.js`, new `src/**` modules, `package.json`, and CLI regression tests.
+- Main refactor risk is hidden cross-module coupling in `doctor`, protected-main sandbox flows, and finish/cleanup helpers, so extraction should move lower-risk seams first and verify after each pass.
+- This is an internal architecture cleanup only; it must not intentionally change command names, output contracts, or the zero-copy install surface.
diff --git a/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/specs/cli-modularization/spec.md b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/specs/cli-modularization/spec.md
new file mode 100644
index 0000000..f009646
--- /dev/null
+++ b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/specs/cli-modularization/spec.md
@@ -0,0 +1,29 @@
+## ADDED Requirements
+
+### Requirement: Thin CLI entrypoint
+The CLI SHALL keep `bin/multiagent-safety.js` as a thin bootstrap surface that delegates command execution into `src/cli`.
+
+#### Scenario: Entrypoint delegates into src/cli
+- **WHEN** the published CLI binary is executed
+- **THEN** `bin/multiagent-safety.js` loads the modular runtime from `src/cli/main.js`
+- **AND** command dispatch logic no longer depends on the monolithic file body.
+
+### Requirement: Module seams mirror operational responsibility
+The CLI SHALL separate major operational seams into dedicated modules under `src/` instead of keeping them in one file.
+
+#### Scenario: Responsibilities live under dedicated src modules
+- **WHEN** a maintainer inspects the refactored CLI
+- **THEN** argument parsing and dispatch live under `src/cli`
+- **AND** output formatting lives under `src/output`
+- **AND** git/worktree helpers live under `src/git`
+- **AND** managed-file and template logic live under `src/scaffold` and `src/hooks`
+- **AND** toolchain and self-update logic live under `src/toolchain`
+- **AND** protected-base sandbox and finish flows live under `src/sandbox` and `src/finish`.
+
+### Requirement: Refactor preserves targeted CLI behavior
+The modularization SHALL preserve the current command surface for targeted verified flows.
+
+#### Scenario: Targeted CLI regressions stay green after extraction
+- **WHEN** the focused install/metadata/command regression suites and packaging checks are run after the extraction
+- **THEN** they pass without command-name regressions
+- **AND** the published package still contains the runtime files required by the extracted `src/**` modules.
diff --git a/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/tasks.md b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/tasks.md
new file mode 100644
index 0000000..4667bba
--- /dev/null
+++ b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/tasks.md
@@ -0,0 +1,42 @@
+## Definition of Done
+
+This change is complete only when **all** of the following are true:
+
+- Every checkbox below is checked.
+- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
+- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.
+
+## Handoff
+
+- Handoff: change=`agent-codex-decompose-cli-monolith-2026-04-22-11-06`; branch=`agent/codex/decompose-cli-monolith-2026-04-22-11-06`; scope=`bin/multiagent-safety.js`, new `src/**` runtime modules, packaging metadata, and targeted CLI regression tests; action=`decompose the monolithic CLI into seam-owned modules while preserving the command surface`.
+- Copy prompt: Continue `agent-codex-decompose-cli-monolith-2026-04-22-11-06` on branch `agent/codex/decompose-cli-monolith-2026-04-22-11-06`. Work inside the existing sandbox, review `openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/decompose-cli-monolith-2026-04-22-11-06 --base dev --via-pr --wait-for-merge --cleanup`.
+
+## 1. Specification
+
+- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-decompose-cli-monolith-2026-04-22-11-06`.
+- [x] 1.2 Define normative requirements in `specs/cli-modularization/spec.md`.
+
+## 2. Implementation
+
+- [x] 2.1 Add shared `src/context.js` / `src/core/runtime.js` foundations for constants, process helpers, and low-level utilities.
+- [x] 2.2 Extract low-risk seams into `src/output`, `src/git`, `src/scaffold`, `src/hooks`, and `src/toolchain`.
+- [x] 2.3 Extract higher-coupling seams into `src/sandbox`, `src/finish`, and `src/cli`.
+- [x] 2.4 Reduce `bin/multiagent-safety.js` to a thin launcher that boots `src/cli/main.js`.
+- [x] 2.5 Update publish packaging / metadata so installed CLIs ship the new `src/**` runtime.
+
+## 3. Verification
+
+- [x] 3.1 Add/update targeted regression coverage for the thin entrypoint, representative command routes, and package shipping of `src/**`.
+- [x] 3.2 Run syntax checks for the entrypoint and extracted modules (`node --check bin/multiagent-safety.js` plus `node --check` on `src/**`).
+- [x] 3.3 Run focused install/metadata/command regression suites.
+- [x] 3.4 Run `npm pack --dry-run` to confirm `src/**` ships in the package.
+- [x] 3.5 Run `openspec validate agent-codex-decompose-cli-monolith-2026-04-22-11-06 --type change --strict`.
+- [x] 3.6 Run `openspec validate --specs`.
+
+## 4. Cleanup (mandatory; run before claiming completion)
+
+- [x] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/decompose-cli-monolith-2026-04-22-11-06 --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
+- [x] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
+- [x] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).
+
+Completion handoff: PR https://github.com/recodeee/gitguardex/pull/294 state=`MERGED` merged_at=`2026-04-22T10:38:31Z`; `git worktree list` no longer shows `.omx/agent-worktrees/agent__codex__decompose-cli-monolith-2026-04-22-11-06`; `git branch -a --list 'agent/codex/decompose-cli-monolith-2026-04-22-11-06' 'origin/agent/codex/decompose-cli-monolith-2026-04-22-11-06'` returns no refs after `git fetch --prune origin`.
diff --git a/src/cli/dispatch.js b/src/cli/dispatch.js
new file mode 100644
index 0000000..63ec94e
--- /dev/null
+++ b/src/cli/dispatch.js
@@ -0,0 +1,86 @@
+const {
+ TOOL_NAME,
+ COMMAND_TYPO_ALIASES,
+ DEPRECATED_COMMAND_ALIASES,
+ SUGGESTIBLE_COMMANDS,
+} = require('../context');
+
+function levenshteinDistance(a, b) {
+ const rows = a.length + 1;
+ const cols = b.length + 1;
+ const matrix = Array.from({ length: rows }, () => Array(cols).fill(0));
+
+ for (let i = 0; i < rows; i += 1) matrix[i][0] = i;
+ for (let j = 0; j < cols; j += 1) matrix[0][j] = j;
+
+ for (let i = 1; i < rows; i += 1) {
+ for (let j = 1; j < cols; j += 1) {
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
+ matrix[i][j] = Math.min(
+ matrix[i - 1][j] + 1,
+ matrix[i][j - 1] + 1,
+ matrix[i - 1][j - 1] + cost,
+ );
+ }
+ }
+ return matrix[a.length][b.length];
+}
+
+function maybeSuggestCommand(command) {
+ let best = null;
+ let bestDistance = Number.POSITIVE_INFINITY;
+
+ for (const candidate of SUGGESTIBLE_COMMANDS) {
+ const dist = levenshteinDistance(command, candidate);
+ if (dist < bestDistance) {
+ bestDistance = dist;
+ best = candidate;
+ }
+ }
+
+ if (best && bestDistance <= 2) {
+ return best;
+ }
+
+ return null;
+}
+
+function normalizeCommandOrThrow(command) {
+ if (COMMAND_TYPO_ALIASES.has(command)) {
+ const mapped = COMMAND_TYPO_ALIASES.get(command);
+ console.log(`[${TOOL_NAME}] Interpreting '${command}' as '${mapped}'.`);
+ return mapped;
+ }
+ return command;
+}
+
+function warnDeprecatedAlias(aliasName) {
+ const entry = DEPRECATED_COMMAND_ALIASES.get(aliasName);
+ if (!entry) return;
+ console.error(
+ `[${TOOL_NAME}] '${aliasName}' is deprecated and will be removed in a future major release. ` +
+ `Use: ${entry.hint}`,
+ );
+}
+
+function extractFlag(args, ...names) {
+ const flagSet = new Set(names);
+ let found = false;
+ const remaining = [];
+ for (const arg of args) {
+ if (flagSet.has(arg)) {
+ found = true;
+ } else {
+ remaining.push(arg);
+ }
+ }
+ return { found, remaining };
+}
+
+module.exports = {
+ levenshteinDistance,
+ maybeSuggestCommand,
+ normalizeCommandOrThrow,
+ warnDeprecatedAlias,
+ extractFlag,
+};
diff --git a/src/context.js b/src/context.js
new file mode 100644
index 0000000..f64dbf3
--- /dev/null
+++ b/src/context.js
@@ -0,0 +1,503 @@
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const cp = require('node:child_process');
+
+const PACKAGE_ROOT = path.resolve(__dirname, '..');
+const CLI_ENTRY_PATH = path.join(PACKAGE_ROOT, 'bin', 'multiagent-safety.js');
+const packageJsonPath = path.join(PACKAGE_ROOT, 'package.json');
+const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
+
+const TOOL_NAME = 'gitguardex';
+const SHORT_TOOL_NAME = 'gx';
+if (!process.env.GUARDEX_CLI_ENTRY) {
+ process.env.GUARDEX_CLI_ENTRY = CLI_ENTRY_PATH;
+}
+if (!process.env.GUARDEX_NODE_BIN) {
+ process.env.GUARDEX_NODE_BIN = process.execPath;
+}
+const LEGACY_NAMES = ['guardex', 'multiagent-safety'];
+const GLOBAL_INSTALL_COMMAND = `npm i -g ${packageJson.name}`;
+const OPENSPEC_PACKAGE = '@fission-ai/openspec';
+const OMC_PACKAGE = 'oh-my-claude-sisyphus';
+const OMC_REPO_URL = 'https://github.com/Yeachan-Heo/oh-my-claudecode';
+const CAVEMEM_PACKAGE = 'cavemem';
+const NPX_BIN = process.env.GUARDEX_NPX_BIN || 'npx';
+const GUARDEX_HOME_DIR = path.resolve(process.env.GUARDEX_HOME_DIR || os.homedir());
+const GLOBAL_TOOLCHAIN_SERVICES = [
+ { name: 'oh-my-codex', packageName: 'oh-my-codex' },
+ {
+ name: 'oh-my-claudecode',
+ packageName: OMC_PACKAGE,
+ dependencyUrl: OMC_REPO_URL,
+ },
+ { name: OPENSPEC_PACKAGE, packageName: OPENSPEC_PACKAGE },
+ { name: CAVEMEM_PACKAGE, packageName: CAVEMEM_PACKAGE },
+ {
+ name: '@imdeadpool/codex-account-switcher',
+ packageName: '@imdeadpool/codex-account-switcher',
+ },
+];
+const GLOBAL_TOOLCHAIN_PACKAGES = [
+ ...GLOBAL_TOOLCHAIN_SERVICES.map((service) => service.packageName),
+];
+const OPTIONAL_LOCAL_COMPANION_TOOLS = [
+ {
+ name: 'cavekit',
+ candidatePaths: [
+ '.cavekit/plugin.json',
+ '.codex/local-marketplaces/cavekit/.agents/plugins/marketplace.json',
+ ],
+ installCommand: `${NPX_BIN} skills add JuliusBrussee/cavekit`,
+ installArgs: ['skills', 'add', 'JuliusBrussee/cavekit'],
+ },
+ {
+ name: 'caveman',
+ candidatePaths: [
+ '.config/caveman/config.json',
+ '.cavekit/skills/caveman/SKILL.md',
+ ],
+ installCommand: `${NPX_BIN} skills add JuliusBrussee/caveman`,
+ installArgs: ['skills', 'add', 'JuliusBrussee/caveman'],
+ },
+];
+const GH_BIN = process.env.GUARDEX_GH_BIN || 'gh';
+const REQUIRED_SYSTEM_TOOLS = [
+ {
+ name: 'gh',
+ displayName: 'GitHub (gh)',
+ command: GH_BIN,
+ installHint: 'https://cli.github.com/',
+ },
+];
+const MAINTAINER_RELEASE_REPO = path.resolve(
+ process.env.GUARDEX_RELEASE_REPO || path.resolve(PACKAGE_ROOT),
+);
+const NPM_BIN = process.env.GUARDEX_NPM_BIN || 'npm';
+const OPENSPEC_BIN = process.env.GUARDEX_OPENSPEC_BIN || 'openspec';
+const SCORECARD_BIN = process.env.GUARDEX_SCORECARD_BIN || 'scorecard';
+const GIT_PROTECTED_BRANCHES_KEY = 'multiagent.protectedBranches';
+const GIT_BASE_BRANCH_KEY = 'multiagent.baseBranch';
+const GIT_SYNC_STRATEGY_KEY = 'multiagent.sync.strategy';
+const GUARDEX_REPO_TOGGLE_ENV = 'GUARDEX_ON';
+const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master'];
+const DEFAULT_BASE_BRANCH = 'dev';
+const DEFAULT_SYNC_STRATEGY = 'rebase';
+const DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES = 60;
+const COMPOSE_HINT_FILES = [
+ 'docker-compose.yml',
+ 'docker-compose.yaml',
+ 'compose.yml',
+ 'compose.yaml',
+];
+
+const TEMPLATE_ROOT = path.join(PACKAGE_ROOT, 'templates');
+
+const HOOK_NAMES = ['pre-commit', 'pre-push', 'post-merge', 'post-checkout'];
+
+function toDestinationPath(relativeTemplatePath) {
+ if (relativeTemplatePath.startsWith('scripts/')) {
+ return relativeTemplatePath;
+ }
+ if (relativeTemplatePath.startsWith('githooks/')) {
+ return `.${relativeTemplatePath}`;
+ }
+ if (relativeTemplatePath.startsWith('codex/')) {
+ return `.${relativeTemplatePath}`;
+ }
+ if (relativeTemplatePath.startsWith('claude/')) {
+ return `.${relativeTemplatePath}`;
+ }
+ if (relativeTemplatePath.startsWith('github/')) {
+ return `.${relativeTemplatePath}`;
+ }
+ if (relativeTemplatePath.startsWith('vscode/')) {
+ return relativeTemplatePath;
+ }
+ throw new Error(`Unsupported template path: ${relativeTemplatePath}`);
+}
+
+const TEMPLATE_FILES = [
+ 'scripts/agent-session-state.js',
+ 'scripts/guardex-docker-loader.sh',
+ 'scripts/guardex-env.sh',
+ 'scripts/install-vscode-active-agents-extension.js',
+ 'github/pull.yml.example',
+ 'github/workflows/cr.yml',
+ 'vscode/guardex-active-agents/package.json',
+ 'vscode/guardex-active-agents/extension.js',
+ 'vscode/guardex-active-agents/session-schema.js',
+ 'vscode/guardex-active-agents/README.md',
+];
+
+const LEGACY_WORKFLOW_SHIM_SPECS = [
+ { relativePath: 'scripts/agent-branch-start.sh', kind: 'shell', command: ['branch', 'start'] },
+ { relativePath: 'scripts/agent-branch-finish.sh', kind: 'shell', command: ['branch', 'finish'] },
+ { relativePath: 'scripts/agent-branch-merge.sh', kind: 'shell', command: ['branch', 'merge'] },
+ { relativePath: 'scripts/codex-agent.sh', kind: 'shell', command: ['internal', 'run-shell', 'codexAgent'] },
+ { relativePath: 'scripts/review-bot-watch.sh', kind: 'shell', command: ['internal', 'run-shell', 'reviewBot'] },
+ { relativePath: 'scripts/agent-worktree-prune.sh', kind: 'shell', command: ['worktree', 'prune'] },
+ { relativePath: 'scripts/agent-file-locks.py', kind: 'python', command: ['locks'] },
+ { relativePath: 'scripts/openspec/init-plan-workspace.sh', kind: 'shell', command: ['internal', 'run-shell', 'planInit'] },
+ { relativePath: 'scripts/openspec/init-change-workspace.sh', kind: 'shell', command: ['internal', 'run-shell', 'changeInit'] },
+];
+
+const LEGACY_WORKFLOW_SHIMS = LEGACY_WORKFLOW_SHIM_SPECS.map((entry) => entry.relativePath);
+
+const MANAGED_TEMPLATE_DESTINATIONS = TEMPLATE_FILES.map((entry) => toDestinationPath(entry));
+const MANAGED_TEMPLATE_SCRIPT_FILES = MANAGED_TEMPLATE_DESTINATIONS.filter((entry) =>
+ entry.startsWith('scripts/'),
+);
+
+const LEGACY_MANAGED_REPO_FILES = [
+ ...LEGACY_WORKFLOW_SHIMS,
+ 'scripts/agent-session-state.js',
+ 'scripts/guardex-docker-loader.sh',
+ 'scripts/install-vscode-active-agents-extension.js',
+ 'scripts/guardex-env.sh',
+ 'scripts/install-agent-git-hooks.sh',
+ '.githooks/pre-commit',
+ '.githooks/pre-push',
+ '.githooks/post-merge',
+ '.githooks/post-checkout',
+ '.codex/skills/gitguardex/SKILL.md',
+ '.codex/skills/guardex-merge-skills-to-dev/SKILL.md',
+ '.claude/commands/gitguardex.md',
+];
+
+const REQUIRED_MANAGED_REPO_FILES = [
+ ...MANAGED_TEMPLATE_DESTINATIONS,
+ ...HOOK_NAMES.map((entry) => path.posix.join('.githooks', entry)),
+ '.omx/state/agent-file-locks.json',
+];
+
+const LEGACY_MANAGED_PACKAGE_SCRIPTS = {
+ 'agent:codex': 'bash ./scripts/codex-agent.sh',
+ 'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
+ 'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
+ 'agent:branch:merge': 'bash ./scripts/agent-branch-merge.sh',
+ 'agent:cleanup': 'gx cleanup',
+ 'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
+ 'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
+ 'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete',
+ 'agent:locks:release': 'python3 ./scripts/agent-file-locks.py release',
+ 'agent:locks:status': 'python3 ./scripts/agent-file-locks.py status',
+ 'agent:plan:init': 'bash ./scripts/openspec/init-plan-workspace.sh',
+ 'agent:change:init': 'bash ./scripts/openspec/init-change-workspace.sh',
+ 'agent:protect:list': 'gx protect list',
+ 'agent:branch:sync': 'gx sync',
+ 'agent:branch:sync:check': 'gx sync --check',
+ 'agent:safety:setup': 'gx setup',
+ 'agent:safety:scan': 'gx status --strict',
+ 'agent:safety:fix': 'gx setup --repair',
+ 'agent:safety:doctor': 'gx doctor',
+ 'agent:docker:load': 'bash ./scripts/guardex-docker-loader.sh',
+ 'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
+ 'agent:finish': 'gx finish --all',
+};
+
+const PACKAGE_SCRIPT_ASSETS = {
+ branchStart: path.join(TEMPLATE_ROOT, 'scripts', 'agent-branch-start.sh'),
+ branchFinish: path.join(TEMPLATE_ROOT, 'scripts', 'agent-branch-finish.sh'),
+ branchMerge: path.join(TEMPLATE_ROOT, 'scripts', 'agent-branch-merge.sh'),
+ codexAgent: path.join(TEMPLATE_ROOT, 'scripts', 'codex-agent.sh'),
+ reviewBot: path.join(TEMPLATE_ROOT, 'scripts', 'review-bot-watch.sh'),
+ worktreePrune: path.join(TEMPLATE_ROOT, 'scripts', 'agent-worktree-prune.sh'),
+ lockTool: path.join(TEMPLATE_ROOT, 'scripts', 'agent-file-locks.py'),
+ planInit: path.join(TEMPLATE_ROOT, 'scripts', 'openspec', 'init-plan-workspace.sh'),
+ changeInit: path.join(TEMPLATE_ROOT, 'scripts', 'openspec', 'init-change-workspace.sh'),
+};
+
+const USER_LEVEL_SKILL_ASSETS = [
+ {
+ source: path.join(TEMPLATE_ROOT, 'codex', 'skills', 'gitguardex', 'SKILL.md'),
+ destination: path.join('.codex', 'skills', 'gitguardex', 'SKILL.md'),
+ },
+ {
+ source: path.join(TEMPLATE_ROOT, 'codex', 'skills', 'guardex-merge-skills-to-dev', 'SKILL.md'),
+ destination: path.join('.codex', 'skills', 'guardex-merge-skills-to-dev', 'SKILL.md'),
+ },
+ {
+ source: path.join(TEMPLATE_ROOT, 'claude', 'commands', 'gitguardex.md'),
+ destination: path.join('.claude', 'commands', 'gitguardex.md'),
+ },
+];
+
+const EXECUTABLE_RELATIVE_PATHS = new Set([
+ ...MANAGED_TEMPLATE_SCRIPT_FILES,
+ ...HOOK_NAMES.map((entry) => path.posix.join('.githooks', entry)),
+]);
+
+const CRITICAL_GUARDRAIL_PATHS = new Set([
+ 'AGENTS.md',
+ ...HOOK_NAMES.map((entry) => path.posix.join('.githooks', entry)),
+ 'scripts/guardex-env.sh',
+]);
+
+const LOCK_FILE_RELATIVE = '.omx/state/agent-file-locks.json';
+const AGENTS_BOTS_STATE_RELATIVE = '.omx/state/agents-bots.json';
+const AGENTS_MARKER_START = '';
+const AGENTS_MARKER_END = '';
+const GITIGNORE_MARKER_START = '# multiagent-safety:START';
+const GITIGNORE_MARKER_END = '# multiagent-safety:END';
+const CODEX_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees');
+const CLAUDE_WORKTREE_RELATIVE_DIR = path.join('.omc', 'agent-worktrees');
+const AGENT_WORKTREE_RELATIVE_DIRS = [
+ CODEX_WORKTREE_RELATIVE_DIR,
+ CLAUDE_WORKTREE_RELATIVE_DIR,
+];
+const MANAGED_GITIGNORE_PATHS = [
+ '.omx/',
+ '.omc/',
+ 'scripts/agent-session-state.js',
+ 'scripts/guardex-docker-loader.sh',
+ 'scripts/guardex-env.sh',
+ 'scripts/install-vscode-active-agents-extension.js',
+ '.githooks',
+ 'oh-my-codex/',
+ LOCK_FILE_RELATIVE,
+];
+const REPO_SCAFFOLD_DIRECTORIES = ['bin'];
+const OMX_SCAFFOLD_DIRECTORIES = [
+ '.omx',
+ '.omx/state',
+ '.omx/logs',
+ '.omx/plans',
+ CODEX_WORKTREE_RELATIVE_DIR,
+ '.omc',
+ CLAUDE_WORKTREE_RELATIVE_DIR,
+];
+const OMX_SCAFFOLD_FILES = new Map([
+ ['.omx/notepad.md', '\n\n## WORKING MEMORY\n'],
+ ['.omx/project-memory.json', '{}\n'],
+]);
+const TARGETED_FORCEABLE_MANAGED_PATHS = new Set([
+ 'AGENTS.md',
+ '.gitignore',
+ ...Array.from(OMX_SCAFFOLD_FILES.keys()),
+ ...REQUIRED_MANAGED_REPO_FILES,
+ ...LEGACY_WORKFLOW_SHIMS,
+]);
+const COMMAND_TYPO_ALIASES = new Map([
+ ['relaese', 'release'],
+ ['realaese', 'release'],
+ ['relase', 'release'],
+ ['setpu', 'setup'],
+ ['inti', 'init'],
+ ['intsall', 'install'],
+ ['docter', 'doctor'],
+ ['doctro', 'doctor'],
+ ['cleunup', 'cleanup'],
+ ['scna', 'scan'],
+]);
+const SUGGESTIBLE_COMMANDS = [
+ 'status',
+ 'setup',
+ 'doctor',
+ 'branch',
+ 'locks',
+ 'worktree',
+ 'hook',
+ 'migrate',
+ 'install-agent-skills',
+ 'agents',
+ 'merge',
+ 'finish',
+ 'report',
+ 'protect',
+ 'sync',
+ 'cleanup',
+ 'prompt',
+ 'help',
+ 'version',
+ 'init',
+ 'install',
+ 'fix',
+ 'scan',
+ 'review',
+ 'copy-prompt',
+ 'copy-commands',
+ 'print-agents-snippet',
+ 'release',
+];
+const CLI_COMMAND_DESCRIPTIONS = [
+ ['status', 'Show GitGuardex CLI + service health without modifying files'],
+ ['setup', 'Install, repair, and verify guardrails (flags: --repair, --install-only, --target)'],
+ ['doctor', 'Repair drift + verify (auto-sandboxes on protected main)'],
+ ['branch', 'CLI-owned branch workflow surface (start/finish/merge)'],
+ ['locks', 'CLI-owned file lock surface (claim/allow-delete/release/status/validate)'],
+ ['worktree', 'CLI-owned worktree cleanup surface (prune)'],
+ ['hook', 'Hook dispatch/install surface used by managed shims'],
+ ['migrate', 'Convert legacy repo-local installs to the zero-copy CLI-owned surface'],
+ ['install-agent-skills', 'Install Guardex Codex/Claude skills into the user home'],
+ ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
+ ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'],
+ ['sync', 'Sync agent branches with origin/'],
+ ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
+ ['cleanup', 'Prune merged/stale agent branches and worktrees'],
+ ['release', 'Create or update the current GitHub release with README-generated notes'],
+ ['agents', 'Start/stop repo-scoped review + cleanup bots'],
+ ['prompt', 'Print AI setup checklist (--exec, --snippet)'],
+ ['report', 'Security/safety reports (e.g. OpenSSF scorecard)'],
+ ['help', 'Show this help output'],
+ ['version', 'Print GitGuardex version'],
+];
+const DEPRECATED_COMMAND_ALIASES = new Map([
+ ['init', { target: 'setup', hint: 'gx setup' }],
+ ['install', { target: 'setup', hint: 'gx setup --install-only' }],
+ ['fix', { target: 'setup', hint: 'gx setup --repair' }],
+ ['scan', { target: 'status', hint: 'gx status --strict' }],
+ ['copy-prompt', { target: 'prompt', hint: 'gx prompt' }],
+ ['copy-commands', { target: 'prompt', hint: 'gx prompt --exec' }],
+ ['print-agents-snippet', { target: 'prompt', hint: 'gx prompt --snippet' }],
+ ['review', { target: 'agents', hint: 'gx agents start (runs review + cleanup)' }],
+]);
+const AGENT_BOT_DESCRIPTIONS = [
+ ['agents', 'Start/stop review + cleanup bots for this repo'],
+];
+const DOCTOR_AUTO_FINISH_DETAIL_LIMIT = 6;
+const DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX = 72;
+const DOCTOR_AUTO_FINISH_MESSAGE_MAX = 160;
+
+function envFlagIsTruthy(raw) {
+ const lowered = String(raw || '').trim().toLowerCase();
+ return lowered === '1' || lowered === 'true' || lowered === 'yes' || lowered === 'on';
+}
+
+function isClaudeCodeSession(env = process.env) {
+ return envFlagIsTruthy(env.CLAUDECODE) || Boolean(env.CLAUDE_CODE_SESSION_ID);
+}
+
+function defaultAgentWorktreeRelativeDir(env = process.env) {
+ return isClaudeCodeSession(env) ? CLAUDE_WORKTREE_RELATIVE_DIR : CODEX_WORKTREE_RELATIVE_DIR;
+}
+
+const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in this repo.
+
+1) Install: ${GLOBAL_INSTALL_COMMAND} && gh --version
+2) Bootstrap: gx setup
+3) Repair: gx doctor
+4) Task loop: gx branch start "" ""
+ then gx locks claim --branch "" -> gx branch finish
+5) Integrate: gx merge --branch --branch
+6) Finish: gx finish --all
+7) Cleanup: gx cleanup
+8) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive
+9) Optional: gx protect add release staging
+10) Optional: gx sync --check && gx sync
+11) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY
+12) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml
+`;
+
+const AI_SETUP_COMMANDS = `${GLOBAL_INSTALL_COMMAND}
+gh --version
+gx setup
+gx doctor
+gx branch start "" ""
+gx locks claim --branch ""
+gx merge --branch "" --branch ""
+gx finish --all
+gx cleanup
+gx protect add release staging
+gx sync --check && gx sync
+`;
+
+const SCORECARD_RISK_BY_CHECK = {
+ 'Dangerous-Workflow': 'Critical',
+ 'Code-Review': 'High',
+ Maintained: 'High',
+ 'Binary-Artifacts': 'High',
+ 'Dependency-Update-Tool': 'High',
+ 'Token-Permissions': 'High',
+ Vulnerabilities: 'High',
+ 'Branch-Protection': 'High',
+ Fuzzing: 'Medium',
+ 'Pinned-Dependencies': 'Medium',
+ SAST: 'Medium',
+ 'Security-Policy': 'Medium',
+ 'CII-Best-Practices': 'Low',
+ Contributors: 'Low',
+ License: 'Low',
+};
+
+module.exports = {
+ fs,
+ os,
+ path,
+ cp,
+ PACKAGE_ROOT,
+ CLI_ENTRY_PATH,
+ packageJsonPath,
+ packageJson,
+ TOOL_NAME,
+ SHORT_TOOL_NAME,
+ LEGACY_NAMES,
+ GLOBAL_INSTALL_COMMAND,
+ OPENSPEC_PACKAGE,
+ OMC_PACKAGE,
+ OMC_REPO_URL,
+ CAVEMEM_PACKAGE,
+ NPX_BIN,
+ GUARDEX_HOME_DIR,
+ GLOBAL_TOOLCHAIN_SERVICES,
+ GLOBAL_TOOLCHAIN_PACKAGES,
+ OPTIONAL_LOCAL_COMPANION_TOOLS,
+ GH_BIN,
+ REQUIRED_SYSTEM_TOOLS,
+ MAINTAINER_RELEASE_REPO,
+ NPM_BIN,
+ OPENSPEC_BIN,
+ SCORECARD_BIN,
+ GIT_PROTECTED_BRANCHES_KEY,
+ GIT_BASE_BRANCH_KEY,
+ GIT_SYNC_STRATEGY_KEY,
+ GUARDEX_REPO_TOGGLE_ENV,
+ DEFAULT_PROTECTED_BRANCHES,
+ DEFAULT_BASE_BRANCH,
+ DEFAULT_SYNC_STRATEGY,
+ DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES,
+ COMPOSE_HINT_FILES,
+ TEMPLATE_ROOT,
+ HOOK_NAMES,
+ toDestinationPath,
+ TEMPLATE_FILES,
+ LEGACY_WORKFLOW_SHIM_SPECS,
+ LEGACY_WORKFLOW_SHIMS,
+ MANAGED_TEMPLATE_DESTINATIONS,
+ MANAGED_TEMPLATE_SCRIPT_FILES,
+ LEGACY_MANAGED_REPO_FILES,
+ REQUIRED_MANAGED_REPO_FILES,
+ LEGACY_MANAGED_PACKAGE_SCRIPTS,
+ PACKAGE_SCRIPT_ASSETS,
+ USER_LEVEL_SKILL_ASSETS,
+ EXECUTABLE_RELATIVE_PATHS,
+ CRITICAL_GUARDRAIL_PATHS,
+ LOCK_FILE_RELATIVE,
+ AGENTS_BOTS_STATE_RELATIVE,
+ AGENTS_MARKER_START,
+ AGENTS_MARKER_END,
+ GITIGNORE_MARKER_START,
+ GITIGNORE_MARKER_END,
+ CODEX_WORKTREE_RELATIVE_DIR,
+ CLAUDE_WORKTREE_RELATIVE_DIR,
+ AGENT_WORKTREE_RELATIVE_DIRS,
+ MANAGED_GITIGNORE_PATHS,
+ REPO_SCAFFOLD_DIRECTORIES,
+ OMX_SCAFFOLD_DIRECTORIES,
+ OMX_SCAFFOLD_FILES,
+ TARGETED_FORCEABLE_MANAGED_PATHS,
+ COMMAND_TYPO_ALIASES,
+ SUGGESTIBLE_COMMANDS,
+ CLI_COMMAND_DESCRIPTIONS,
+ DEPRECATED_COMMAND_ALIASES,
+ AGENT_BOT_DESCRIPTIONS,
+ DOCTOR_AUTO_FINISH_DETAIL_LIMIT,
+ DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX,
+ DOCTOR_AUTO_FINISH_MESSAGE_MAX,
+ envFlagIsTruthy,
+ isClaudeCodeSession,
+ defaultAgentWorktreeRelativeDir,
+ AI_SETUP_PROMPT,
+ AI_SETUP_COMMANDS,
+ SCORECARD_RISK_BY_CHECK,
+};
diff --git a/src/core/runtime.js b/src/core/runtime.js
new file mode 100644
index 0000000..c5044ca
--- /dev/null
+++ b/src/core/runtime.js
@@ -0,0 +1,119 @@
+const {
+ fs,
+ path,
+ CLI_ENTRY_PATH,
+ PACKAGE_SCRIPT_ASSETS,
+} = require('../context');
+
+function requireValue(rawArgs, index, flagName) {
+ const value = rawArgs[index + 1];
+ if (!value || value.startsWith('-')) {
+ throw new Error(`${flagName} requires a value`);
+ }
+ return value;
+}
+
+function run(cmd, args, options = {}) {
+ return require('node:child_process').spawnSync(cmd, args, {
+ encoding: 'utf8',
+ stdio: options.stdio || 'pipe',
+ cwd: options.cwd,
+ env: options.env ? { ...process.env, ...options.env } : process.env,
+ timeout: options.timeout,
+ });
+}
+
+function extractTargetedArgs(rawArgs, defaultTarget = process.cwd()) {
+ const passthrough = [];
+ let target = defaultTarget;
+
+ for (let index = 0; index < rawArgs.length; index += 1) {
+ const arg = rawArgs[index];
+ if (arg === '--target' || arg === '-t') {
+ target = requireValue(rawArgs, index, '--target');
+ index += 1;
+ continue;
+ }
+ passthrough.push(arg);
+ }
+
+ return { target, passthrough };
+}
+
+function packageAssetEnv(extraEnv = {}) {
+ return {
+ GUARDEX_CLI_ENTRY: CLI_ENTRY_PATH,
+ GUARDEX_NODE_BIN: process.execPath,
+ ...extraEnv,
+ };
+}
+
+function packageAssetPath(assetKey) {
+ const assetPath = PACKAGE_SCRIPT_ASSETS[assetKey];
+ if (!assetPath) {
+ throw new Error(`Unknown package asset: ${assetKey}`);
+ }
+ if (!fs.existsSync(assetPath)) {
+ throw new Error(`Missing package asset: ${assetPath}`);
+ }
+ return assetPath;
+}
+
+function runPackageAsset(assetKey, rawArgs, options = {}) {
+ const assetPath = packageAssetPath(assetKey);
+ let cmd = 'bash';
+ if (assetPath.endsWith('.py')) {
+ cmd = 'python3';
+ } else if (assetPath.endsWith('.js')) {
+ cmd = process.execPath;
+ }
+ return run(cmd, [assetPath, ...rawArgs], {
+ cwd: options.cwd || process.cwd(),
+ stdio: options.stdio || 'pipe',
+ timeout: options.timeout,
+ env: packageAssetEnv(options.env),
+ });
+}
+
+function repoLocalLegacyScriptPath(repoRoot, relativePath) {
+ const assetPath = path.join(repoRoot, relativePath);
+ return fs.existsSync(assetPath) ? assetPath : null;
+}
+
+function runReviewBotCommand(repoRoot, rawArgs, options = {}) {
+ const legacyScript = repoLocalLegacyScriptPath(repoRoot, 'scripts/review-bot-watch.sh');
+ if (legacyScript) {
+ return run('bash', [legacyScript, ...rawArgs], {
+ cwd: repoRoot,
+ stdio: options.stdio || 'pipe',
+ timeout: options.timeout,
+ env: packageAssetEnv(options.env),
+ });
+ }
+ return runPackageAsset('reviewBot', rawArgs, {
+ ...options,
+ cwd: repoRoot,
+ });
+}
+
+function invokePackageAsset(assetKey, rawArgs, options = {}) {
+ const result = runPackageAsset(assetKey, rawArgs, options);
+ if (result.stdout) process.stdout.write(result.stdout);
+ if (result.stderr) process.stderr.write(result.stderr);
+ if (result.status !== 0) {
+ throw new Error(`${assetKey} command failed with status ${result.status}`);
+ }
+ process.exitCode = 0;
+ return result;
+}
+
+module.exports = {
+ run,
+ extractTargetedArgs,
+ packageAssetEnv,
+ packageAssetPath,
+ runPackageAsset,
+ repoLocalLegacyScriptPath,
+ runReviewBotCommand,
+ invokePackageAsset,
+};
diff --git a/src/git/index.js b/src/git/index.js
new file mode 100644
index 0000000..45edb88
--- /dev/null
+++ b/src/git/index.js
@@ -0,0 +1,112 @@
+const { path } = require('../context');
+const { run } = require('../core/runtime');
+
+function gitRun(repoRoot, args, { allowFailure = false } = {}) {
+ const result = run('git', ['-C', repoRoot, ...args]);
+ if (!allowFailure && result.status !== 0) {
+ throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
+ }
+ return result;
+}
+
+function resolveRepoRoot(targetPath) {
+ const resolvedTarget = path.resolve(targetPath || process.cwd());
+ const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']);
+ if (result.status !== 0) {
+ const stderr = (result.stderr || '').trim();
+ throw new Error(
+ `Target is not inside a git repository: ${resolvedTarget}${stderr ? `\n${stderr}` : ''}`,
+ );
+ }
+ return result.stdout.trim();
+}
+
+function isGitRepo(targetPath) {
+ const resolvedTarget = path.resolve(targetPath || process.cwd());
+ const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']);
+ return result.status === 0;
+}
+
+const NESTED_REPO_DEFAULT_MAX_DEPTH = 6;
+const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([
+ 'node_modules',
+ '.git',
+ 'dist',
+ 'build',
+ '.next',
+ '.cache',
+ 'target',
+ 'vendor',
+ '.venv',
+ '.pnpm-store',
+]);
+
+function discoverNestedGitRepos(rootPath, opts = {}) {
+ const maxDepth = Number.isFinite(opts.maxDepth)
+ ? Math.max(1, opts.maxDepth)
+ : NESTED_REPO_DEFAULT_MAX_DEPTH;
+ const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []);
+ const includeSubmodules = Boolean(opts.includeSubmodules);
+ const resolvedRoot = path.resolve(rootPath);
+
+ if (!isGitRepo(resolvedRoot)) {
+ throw new Error(`Target is not inside a git repository: ${resolvedRoot}`);
+ }
+
+ const results = [];
+ const seen = new Set();
+
+ function visit(directoryPath, depth) {
+ const repoRoot = resolveRepoRoot(directoryPath);
+ if (!seen.has(repoRoot)) {
+ seen.add(repoRoot);
+ results.push(repoRoot);
+ }
+
+ if (depth >= maxDepth) {
+ return;
+ }
+
+ let entries = [];
+ try {
+ entries = require('node:fs').readdirSync(directoryPath, { withFileTypes: true });
+ } catch {
+ return;
+ }
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) {
+ continue;
+ }
+ if (NESTED_REPO_DEFAULT_SKIP_DIRS.has(entry.name) || extraSkip.has(entry.name)) {
+ continue;
+ }
+
+ const childPath = path.join(directoryPath, entry.name);
+ const gitDir = path.join(childPath, '.git');
+ if (require('node:fs').existsSync(gitDir)) {
+ if (!includeSubmodules) {
+ const gitInfo = require('node:fs').lstatSync(gitDir);
+ if (gitInfo.isFile()) {
+ continue;
+ }
+ }
+ visit(childPath, depth + 1);
+ continue;
+ }
+
+ visit(childPath, depth + 1);
+ }
+ }
+
+ visit(resolvedRoot, 0);
+ return results;
+}
+
+module.exports = {
+ DEFAULT_NESTED_REPO_MAX_DEPTH: NESTED_REPO_DEFAULT_MAX_DEPTH,
+ gitRun,
+ resolveRepoRoot,
+ isGitRepo,
+ discoverNestedGitRepos,
+};
diff --git a/src/output/index.js b/src/output/index.js
new file mode 100644
index 0000000..8d43f57
--- /dev/null
+++ b/src/output/index.js
@@ -0,0 +1,398 @@
+const {
+ path,
+ packageJson,
+ TOOL_NAME,
+ SHORT_TOOL_NAME,
+ LEGACY_NAMES,
+ GUARDEX_REPO_TOGGLE_ENV,
+ CLI_COMMAND_DESCRIPTIONS,
+ AGENT_BOT_DESCRIPTIONS,
+ DOCTOR_AUTO_FINISH_DETAIL_LIMIT,
+ DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX,
+ DOCTOR_AUTO_FINISH_MESSAGE_MAX,
+} = require('../context');
+
+function runtimeVersion() {
+ return `${packageJson.name}/${packageJson.version} ${process.platform}-${process.arch} node-${process.version}`;
+}
+
+function supportsAnsiColors() {
+ const forced = String(process.env.FORCE_COLOR || '').trim().toLowerCase();
+ if (['0', 'false', 'no', 'off'].includes(forced)) {
+ return false;
+ }
+ if (forced.length > 0) {
+ return true;
+ }
+ if (process.env.NO_COLOR) {
+ return false;
+ }
+ return Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb';
+}
+
+function colorize(text, colorCode) {
+ if (!supportsAnsiColors()) {
+ return text;
+ }
+ return `\u001B[${colorCode}m${text}\u001B[0m`;
+}
+
+function doctorOutputColorCode(status) {
+ const normalized = String(status || '').trim().toLowerCase();
+ if (['active', 'done', 'ok', 'safe', 'success'].includes(normalized)) {
+ return '32';
+ }
+ if (normalized === 'disabled') {
+ return '36';
+ }
+ if (['degraded', 'pending', 'skip', 'warn', 'warning'].includes(normalized)) {
+ return '33';
+ }
+ if (['error', 'fail', 'inactive', 'unsafe'].includes(normalized)) {
+ return '31';
+ }
+ return null;
+}
+
+function colorizeDoctorOutput(text, status) {
+ const colorCode = doctorOutputColorCode(status);
+ return colorCode ? colorize(text, colorCode) : text;
+}
+
+function detectAutoFinishDetailStatus(detail) {
+ const trimmed = String(detail || '').trim();
+ const match = trimmed.match(/^\[(\w+)\]/);
+ if (match) {
+ return match[1].toLowerCase();
+ }
+ if (/^Skipped\b/i.test(trimmed) || /^No local agent branches found\b/i.test(trimmed)) {
+ return 'skip';
+ }
+ return null;
+}
+
+function detectAutoFinishSummaryStatus(summary) {
+ if (!summary || summary.enabled === false) {
+ return detectAutoFinishDetailStatus(summary?.details?.[0]);
+ }
+ if ((summary.failed || 0) > 0) {
+ return 'fail';
+ }
+ if ((summary.completed || 0) > 0) {
+ return 'done';
+ }
+ if ((summary.skipped || 0) > 0) {
+ return 'skip';
+ }
+ return null;
+}
+
+function statusDot(status) {
+ if (status === 'active') {
+ return colorize('●', '32');
+ }
+ if (status === 'inactive') {
+ return colorize('●', '31');
+ }
+ if (status === 'disabled') {
+ return colorize('●', '36');
+ }
+ return colorize('●', '33');
+}
+
+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}`,
+ );
+}
+
+function agentBotCatalogLines(indent = ' ') {
+ const maxCommandLength = AGENT_BOT_DESCRIPTIONS.reduce(
+ (max, [command]) => Math.max(max, command.length),
+ 0,
+ );
+ return AGENT_BOT_DESCRIPTIONS.map(
+ ([command, description]) => `${indent}${command.padEnd(maxCommandLength + 2)}${description}`,
+ );
+}
+
+function repoToggleLines(indent = ' ') {
+ return [
+ `${indent}Set repo-root .env: ${GUARDEX_REPO_TOGGLE_ENV}=0 disables Guardex, ${GUARDEX_REPO_TOGGLE_ENV}=1 enables it again`,
+ ];
+}
+
+function printToolLogsSummary() {
+ const usageLine = ` $ ${SHORT_TOOL_NAME} [options]`;
+ const commandDetails = commandCatalogLines(' ');
+ const agentBotDetails = agentBotCatalogLines(' ');
+ const repoToggleDetails = repoToggleLines(' ');
+
+ if (!supportsAnsiColors()) {
+ console.log(`${TOOL_NAME}-tools logs:`);
+ console.log(' USAGE');
+ console.log(usageLine);
+ console.log(' COMMANDS');
+ for (const line of commandDetails) {
+ console.log(line);
+ }
+ console.log(' AGENT BOT');
+ for (const line of agentBotDetails) {
+ console.log(line);
+ }
+ console.log(' REPO TOGGLE');
+ for (const line of repoToggleDetails) {
+ console.log(line);
+ }
+ return;
+ }
+
+ const title = colorize(`${TOOL_NAME}-tools logs`, '1;36');
+ const usageHeader = colorize('USAGE', '1');
+ const commandsHeader = colorize('COMMANDS', '1');
+ const agentBotHeader = colorize('AGENT BOT', '1');
+ const repoToggleHeader = colorize('REPO TOGGLE', '1');
+ const pipe = colorize('│', '90');
+ const tee = colorize('├', '90');
+ const corner = colorize('└', '90');
+
+ console.log(`${title}:`);
+ console.log(` ${tee}─ ${usageHeader}`);
+ console.log(` ${pipe}${usageLine}`);
+ console.log(` ${tee}─ ${commandsHeader}`);
+ for (const line of commandDetails) {
+ if (!line) {
+ console.log(` ${pipe}`);
+ continue;
+ }
+ console.log(` ${pipe}${line.slice(2)}`);
+ }
+ console.log(` ${tee}─ ${agentBotHeader}`);
+ for (const line of agentBotDetails) {
+ if (!line) {
+ console.log(` ${pipe}`);
+ continue;
+ }
+ console.log(` ${pipe}${line.slice(2)}`);
+ }
+ console.log(` ${tee}─ ${repoToggleHeader}`);
+ for (const line of repoToggleDetails) {
+ if (!line) {
+ console.log(` ${pipe}`);
+ continue;
+ }
+ console.log(` ${pipe}${line.slice(2)}`);
+ }
+ console.log(` ${corner}─ ${colorize(`Try '${TOOL_NAME} doctor' for one-step repair + verification.`, '2')}`);
+}
+
+function usage(options = {}) {
+ const { outsideGitRepo = false } = options;
+
+ console.log(`A command-line tool that sets up hardened multi-agent safety for git repositories.
+
+VERSION
+ ${runtimeVersion()}
+
+USAGE
+ $ ${SHORT_TOOL_NAME} [options]
+
+COMMANDS
+${commandCatalogLines().join('\n')}
+
+AGENT BOT
+${agentBotCatalogLines().join('\n')}
+
+REPO TOGGLE
+${repoToggleLines().join('\n')}
+
+NOTES
+ - No command = ${SHORT_TOOL_NAME} status. ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup.
+ - Global installs need Y/N approval; GitHub CLI (gh) is required for PR automation.
+ - Target another repo: ${SHORT_TOOL_NAME} --target .
+ - On protected main, setup/install/fix/doctor auto-sandbox via agent branch + PR flow.
+ - Run '${SHORT_TOOL_NAME} cleanup' to prune merged agent branches/worktrees.
+ - Legacy aliases: ${LEGACY_NAMES.join(', ')}.`);
+
+ if (outsideGitRepo) {
+ console.log(`
+[${TOOL_NAME}] No git repository detected in current directory.
+[${TOOL_NAME}] Start from a repo root, or pass an explicit target:
+ ${TOOL_NAME} setup --target
+ ${TOOL_NAME} doctor --target `);
+ }
+}
+
+function formatElapsedDuration(ms) {
+ const durationMs = Number.isFinite(ms) ? Math.max(0, ms) : 0;
+ if (durationMs < 1000) {
+ return `${Math.round(durationMs)}ms`;
+ }
+ if (durationMs < 10_000) {
+ return `${(durationMs / 1000).toFixed(1)}s`;
+ }
+ return `${Math.round(durationMs / 1000)}s`;
+}
+
+function truncateMiddle(value, maxLength) {
+ const text = String(value || '');
+ const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0;
+ if (!limit || text.length <= limit) {
+ return text;
+ }
+
+ const visible = limit - 1;
+ const headLength = Math.ceil(visible / 2);
+ const tailLength = Math.floor(visible / 2);
+ return `${text.slice(0, headLength)}…${text.slice(text.length - tailLength)}`;
+}
+
+function truncateTail(value, maxLength) {
+ const text = String(value || '');
+ const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0;
+ if (!limit || text.length <= limit) {
+ return text;
+ }
+ return `${text.slice(0, limit - 1)}…`;
+}
+
+function compactAutoFinishPathSegments(message) {
+ return String(message || '').replace(/\((\/[^)]+)\)/g, (_, rawPath) => {
+ if (
+ rawPath.includes(`${path.sep}.omx${path.sep}agent-worktrees${path.sep}`) ||
+ rawPath.includes(`${path.sep}.omc${path.sep}agent-worktrees${path.sep}`)
+ ) {
+ return `(${path.basename(rawPath)})`;
+ }
+ return `(${truncateMiddle(rawPath, 72)})`;
+ });
+}
+
+function detectRecoverableAutoFinishConflict(message) {
+ const text = String(message || '').trim();
+ if (!text) {
+ return null;
+ }
+
+ if (/rebase --continue/i.test(text) && /rebase --abort/i.test(text)) {
+ return {
+ rawLabel: 'auto-finish requires manual rebase.',
+ summary: 'manual rebase required in the source-probe worktree; run rebase --continue or rebase --abort',
+ };
+ }
+
+ if (/Rebase\/merge '.+' into '.+' and resolve conflicts before finishing\./i.test(text)) {
+ return {
+ rawLabel: 'auto-finish requires manual rebase or merge.',
+ summary: 'manual rebase or merge required before auto-finish can continue',
+ };
+ }
+
+ if (/Merge conflict detected while merging/i.test(text)) {
+ return {
+ rawLabel: 'auto-finish requires manual merge resolution.',
+ summary: 'manual merge resolution required before auto-finish can continue',
+ };
+ }
+
+ return null;
+}
+
+function summarizeAutoFinishDetail(detail) {
+ const trimmed = String(detail || '').trim();
+ const match = trimmed.match(/^\[(\w+)\]\s+([^:]+):\s*(.*)$/);
+ if (!match) {
+ return truncateTail(compactAutoFinishPathSegments(trimmed), DOCTOR_AUTO_FINISH_MESSAGE_MAX);
+ }
+
+ const [, status, rawBranch, rawMessage] = match;
+ const branch = truncateMiddle(rawBranch, DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX);
+ let message = String(rawMessage || '').trim();
+ const recoverableConflict = status === 'skip' ? detectRecoverableAutoFinishConflict(message) : null;
+
+ if (recoverableConflict) {
+ message = recoverableConflict.summary;
+ } else if (status === 'fail') {
+ message = message.replace(/^auto-finish failed\.?\s*/i, '');
+ if (/\[agent-sync-guard\]/.test(message) && /Resolve conflicts/i.test(message)) {
+ message = 'rebase conflict in finish flow; run rebase --continue or rebase --abort in the source-probe worktree';
+ } else if (/unable to compute ahead\/behind/i.test(message)) {
+ const aheadBehindMatch = message.match(/unable to compute ahead\/behind(?: \([^)]+\))?/i);
+ if (aheadBehindMatch) {
+ message = aheadBehindMatch[0];
+ }
+ } else if (/remote ref does not exist/i.test(message)) {
+ message = 'branch merged, but the remote ref was already removed during cleanup';
+ }
+ }
+
+ message = compactAutoFinishPathSegments(message)
+ .replace(/\s+\|\s+/g, '; ')
+ .trim();
+
+ return `[${status}] ${branch}: ${truncateTail(message, DOCTOR_AUTO_FINISH_MESSAGE_MAX)}`;
+}
+
+function printAutoFinishSummary(summary, options = {}) {
+ const enabled = Boolean(summary && summary.enabled);
+ const details = Array.isArray(summary && summary.details) ? summary.details : [];
+ const baseBranch = String(options.baseBranch || summary?.baseBranch || '').trim();
+ const verbose = Boolean(options.verbose);
+ const detailLimit = Number.isFinite(options.detailLimit)
+ ? Math.max(0, options.detailLimit)
+ : DOCTOR_AUTO_FINISH_DETAIL_LIMIT;
+
+ if (enabled) {
+ console.log(
+ colorizeDoctorOutput(
+ `[${TOOL_NAME}] Auto-finish sweep (base=${baseBranch}): attempted=${summary.attempted}, completed=${summary.completed}, skipped=${summary.skipped}, failed=${summary.failed}`,
+ detectAutoFinishSummaryStatus(summary),
+ ),
+ );
+ const visibleDetails = verbose ? details : details.slice(0, detailLimit).map(summarizeAutoFinishDetail);
+ for (const detail of visibleDetails) {
+ console.log(colorizeDoctorOutput(`[${TOOL_NAME}] ${detail}`, detectAutoFinishDetailStatus(detail)));
+ }
+ if (!verbose && details.length > visibleDetails.length) {
+ console.log(
+ colorizeDoctorOutput(
+ `[${TOOL_NAME}] … ${details.length - visibleDetails.length} more branch result(s). Re-run with --verbose-auto-finish for full details.`,
+ 'warn',
+ ),
+ );
+ }
+ return;
+ }
+
+ if (details.length > 0) {
+ const detail = verbose ? details[0] : summarizeAutoFinishDetail(details[0]);
+ console.log(colorizeDoctorOutput(`[${TOOL_NAME}] ${detail}`, detectAutoFinishDetailStatus(detail)));
+ }
+}
+
+module.exports = {
+ runtimeVersion,
+ supportsAnsiColors,
+ colorize,
+ doctorOutputColorCode,
+ colorizeDoctorOutput,
+ detectAutoFinishDetailStatus,
+ detectAutoFinishSummaryStatus,
+ statusDot,
+ commandCatalogLines,
+ agentBotCatalogLines,
+ repoToggleLines,
+ printToolLogsSummary,
+ usage,
+ formatElapsedDuration,
+ truncateMiddle,
+ truncateTail,
+ compactAutoFinishPathSegments,
+ detectRecoverableAutoFinishConflict,
+ summarizeAutoFinishDetail,
+ printAutoFinishSummary,
+};
diff --git a/src/scaffold/index.js b/src/scaffold/index.js
new file mode 100644
index 0000000..d6b3b08
--- /dev/null
+++ b/src/scaffold/index.js
@@ -0,0 +1,169 @@
+const {
+ fs,
+ path,
+ TOOL_NAME,
+ SHORT_TOOL_NAME,
+ EXECUTABLE_RELATIVE_PATHS,
+ CRITICAL_GUARDRAIL_PATHS,
+} = require('../context');
+
+function toDestinationPath(relativeTemplatePath) {
+ if (relativeTemplatePath.startsWith('scripts/')) {
+ return relativeTemplatePath;
+ }
+ if (relativeTemplatePath.startsWith('githooks/')) {
+ return `.${relativeTemplatePath}`;
+ }
+ if (relativeTemplatePath.startsWith('codex/')) {
+ return `.${relativeTemplatePath}`;
+ }
+ if (relativeTemplatePath.startsWith('claude/')) {
+ return `.${relativeTemplatePath}`;
+ }
+ if (relativeTemplatePath.startsWith('github/')) {
+ return `.${relativeTemplatePath}`;
+ }
+ if (relativeTemplatePath.startsWith('vscode/')) {
+ return relativeTemplatePath;
+ }
+ throw new Error(`Unsupported template path: ${relativeTemplatePath}`);
+}
+
+function ensureParentDir(repoRoot, filePath, dryRun) {
+ if (dryRun) return;
+
+ const parentDir = path.dirname(filePath);
+ const relativeParentDir = path.relative(repoRoot, parentDir);
+ const segments = relativeParentDir.split(path.sep).filter(Boolean);
+ let currentPath = repoRoot;
+
+ for (const segment of segments) {
+ currentPath = path.join(currentPath, segment);
+ if (fs.existsSync(currentPath) && !fs.statSync(currentPath).isDirectory()) {
+ const blockingPath = path.relative(repoRoot, currentPath) || path.basename(currentPath);
+ const targetPath = path.relative(repoRoot, filePath) || path.basename(filePath);
+ throw new Error(
+ `Path conflict: ${blockingPath} exists as a file, but ${targetPath} needs it to be a directory. ` +
+ `Remove or rename ${blockingPath} and rerun '${SHORT_TOOL_NAME} setup'.`,
+ );
+ }
+ }
+
+ fs.mkdirSync(parentDir, { recursive: true });
+}
+
+function ensureExecutable(destinationPath, relativePath, dryRun) {
+ if (dryRun) return;
+ if (EXECUTABLE_RELATIVE_PATHS.has(relativePath)) {
+ fs.chmodSync(destinationPath, 0o755);
+ }
+}
+
+function isCriticalGuardrailPath(relativePath) {
+ return CRITICAL_GUARDRAIL_PATHS.has(relativePath);
+}
+
+function shellSingleQuote(value) {
+ return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
+}
+
+function renderShellDispatchShim(commandParts) {
+ const rendered = commandParts.map((part) => shellSingleQuote(part)).join(' ');
+ return (
+ '#!/usr/bin/env bash\n' +
+ 'set -euo pipefail\n' +
+ '\n' +
+ 'if [[ -n "${GUARDEX_CLI_ENTRY:-}" ]]; then\n' +
+ ' node_bin="${GUARDEX_NODE_BIN:-node}"\n' +
+ ` exec "$node_bin" "$GUARDEX_CLI_ENTRY" ${rendered} "$@"\n` +
+ 'fi\n' +
+ '\n' +
+ 'resolve_guardex_cli() {\n' +
+ ' if [[ -n "${GUARDEX_CLI_BIN:-}" ]]; then\n' +
+ ' printf \'%s\' "$GUARDEX_CLI_BIN"\n' +
+ ' return 0\n' +
+ ' fi\n' +
+ ' if command -v gx >/dev/null 2>&1; then\n' +
+ ' printf \'%s\' "gx"\n' +
+ ' return 0\n' +
+ ' fi\n' +
+ ' if command -v gitguardex >/dev/null 2>&1; then\n' +
+ ' printf \'%s\' "gitguardex"\n' +
+ ' return 0\n' +
+ ' fi\n' +
+ ' echo "[gitguardex-shim] Missing gx CLI in PATH." >&2\n' +
+ ' exit 1\n' +
+ '}\n' +
+ '\n' +
+ 'cli_bin="$(resolve_guardex_cli)"\n' +
+ `exec "$cli_bin" ${rendered} "$@"\n`
+ );
+}
+
+function renderPythonDispatchShim(commandParts) {
+ return (
+ '#!/usr/bin/env python3\n' +
+ 'import os\n' +
+ 'import shutil\n' +
+ 'import subprocess\n' +
+ 'import sys\n' +
+ '\n' +
+ `COMMAND = ${JSON.stringify(commandParts)}\n` +
+ '\n' +
+ 'entry = os.environ.get("GUARDEX_CLI_ENTRY")\n' +
+ 'if entry:\n' +
+ ' node_bin = os.environ.get("GUARDEX_NODE_BIN") or shutil.which("node") or "node"\n' +
+ ' raise SystemExit(subprocess.call([node_bin, entry, *COMMAND, *sys.argv[1:]]))\n' +
+ 'cli = os.environ.get("GUARDEX_CLI_BIN") or shutil.which("gx") or shutil.which("gitguardex")\n' +
+ 'if not cli:\n' +
+ ' sys.stderr.write("[gitguardex-shim] Missing gx CLI in PATH.\\n")\n' +
+ ' raise SystemExit(1)\n' +
+ 'raise SystemExit(subprocess.call([cli, *COMMAND, *sys.argv[1:]]))\n'
+ );
+}
+
+function managedForceConflictMessage(relativePath) {
+ return (
+ `Refusing to overwrite existing file without --force: ${relativePath}\n` +
+ `Use '--force ${relativePath}' to rewrite only this managed file, or '--force' to rewrite all managed files.`
+ );
+}
+
+function printOperations(title, payload, dryRun = false) {
+ console.log(`[${TOOL_NAME}] ${title}: ${payload.repoRoot}`);
+ for (const operation of payload.operations) {
+ const note = operation.note ? ` (${operation.note})` : '';
+ console.log(` - ${operation.status.padEnd(12)} ${operation.file}${note}`);
+ }
+ console.log(
+ ` - hooksPath ${payload.hookResult.status} ${payload.hookResult.key}=${payload.hookResult.value}`,
+ );
+
+ if (dryRun) {
+ console.log(`[${TOOL_NAME}] Dry run complete. No files were modified.`);
+ }
+}
+
+function printStandaloneOperations(title, rootLabel, operations, dryRun = false) {
+ console.log(`[${TOOL_NAME}] ${title}: ${rootLabel}`);
+ for (const operation of operations) {
+ const note = operation.note ? ` (${operation.note})` : '';
+ console.log(` - ${operation.status.padEnd(12)} ${operation.file}${note}`);
+ }
+ if (dryRun) {
+ console.log(`[${TOOL_NAME}] Dry run complete. No files were modified.`);
+ }
+}
+
+module.exports = {
+ toDestinationPath,
+ ensureParentDir,
+ ensureExecutable,
+ isCriticalGuardrailPath,
+ shellSingleQuote,
+ renderShellDispatchShim,
+ renderPythonDispatchShim,
+ managedForceConflictMessage,
+ printOperations,
+ printStandaloneOperations,
+};
diff --git a/test/metadata.test.js b/test/metadata.test.js
index c67cb2c..e163695 100644
--- a/test/metadata.test.js
+++ b/test/metadata.test.js
@@ -146,6 +146,7 @@ test('thin CLI entrypoint delegates to src/cli runtime', () => {
const entrySource = fs.readFileSync(entryPath, 'utf8');
assert.match(entrySource, /require\('\.\.\/src\/cli\/main'\)/);
assert.match(entrySource, /runFromBin\(\)/);
+ assert.ok((fs.statSync(entryPath).mode & 0o111) !== 0, 'bin/multiagent-safety.js must stay executable');
});
test('package manifest ships the extracted src runtime', () => {