diff --git a/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/proposal.md b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/proposal.md
new file mode 100644
index 0000000..bf27f68
--- /dev/null
+++ b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/proposal.md
@@ -0,0 +1,17 @@
+## Why
+
+- `src/cli/main.js` still mirrors constants and helper implementations that already exist in `src/context.js`, `src/output/index.js`, and `src/scaffold/index.js`.
+- Those mirrors already drifted: `MANAGED_GITIGNORE_PATHS`, `CLI_COMMAND_DESCRIPTIONS`, and `MAINTAINER_RELEASE_REPO` disagree across modules, so whichever copy a caller reaches changes behavior.
+- The current follow-up should stay mechanical: delete dead/duplicate helpers, make the extracted modules the only source of truth, and lock the drift cases with focused tests.
+
+## What Changes
+
+- Import shared constants/session helpers from `src/context.js` instead of redefining them in `src/cli/main.js`.
+- Import presentation helpers from `src/output/index.js` and scaffold/file-install helpers from `src/scaffold/index.js`, then delete the local mirrors in `src/cli/main.js`.
+- Remove dead or duplicated helpers in `src/cli/main.js`, including the duplicate `gitRefExists`, duplicate auto-finish failure log, unused command handlers, unused prompt helper, and redundant truthy-flag wrapper.
+- Add focused regression coverage for the shared-source ownership and the concrete drift cases (`--current` help text, `.vscode` gitignore exceptions, and maintainer release repo resolution).
+
+## Impact
+
+- Primary files: `src/cli/main.js`, `src/context.js`, `src/output/index.js`, `src/scaffold/index.js`, and `test/cli-args-dispatch.test.js`.
+- Main risk is behavior drift while deleting local copies, so verification stays focused on syntax plus targeted CLI regression suites before broader checks.
diff --git a/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/specs/cli-modularization/spec.md b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/specs/cli-modularization/spec.md
new file mode 100644
index 0000000..3acfa7a
--- /dev/null
+++ b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/specs/cli-modularization/spec.md
@@ -0,0 +1,21 @@
+## MODIFIED Requirements
+
+### Requirement: Module seams mirror operational responsibility
+The CLI SHALL keep shared helper ownership in the extracted `src/` modules instead of duplicating the same constants or helper implementations in `src/cli/main.js`.
+
+#### Scenario: Shared context/output/scaffold seams stay single-source
+- **WHEN** maintainers inspect `src/cli/main.js`
+- **THEN** shared constants and session helpers are imported from `src/context.js`
+- **AND** presentation helpers are imported from `src/output/index.js`
+- **AND** scaffold/file-install helpers are imported from `src/scaffold/index.js`
+- **AND** `src/cli/main.js` does not redefine those helpers locally.
+
+### Requirement: Refactor preserves targeted CLI behavior
+The modularization SHALL preserve the current command surface for targeted verified flows while deleting the local duplicate helpers.
+
+#### Scenario: Shared-source drift cases remain stable after cleanup
+- **WHEN** focused CLI regression suites are run after the helper cleanup
+- **THEN** setup and doctor help continue advertising `--current`
+- **AND** managed gitignore handling preserves the `.vscode` exceptions required for shared settings
+- **AND** release gating resolves the default maintainer repo to the package root instead of the `src/` directory
+- **AND** syntax/require-time failures do not occur from duplicate helper definitions.
diff --git a/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/tasks.md b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/tasks.md
new file mode 100644
index 0000000..ddf7f31
--- /dev/null
+++ b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/tasks.md
@@ -0,0 +1,40 @@
+## 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, add a `BLOCKED:` line under section 4 and stop.
+
+## Handoff
+
+- Handoff: change=`agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29`; branch=`agent/codex/dedupe-cli-main-context-output-scaffold-2026-04-22-14-29`; scope=`src/cli/main.js`, `src/context.js`, `src/output/index.js`, `src/scaffold/index.js`, `test/cli-args-dispatch.test.js`; action=`delete remaining shared-helper duplication from src/cli/main.js and keep context/output/scaffold as the only live source of truth`.
+
+## 1. Specification
+
+- [x] 1.1 Capture the mechanical cleanup scope and acceptance criteria for the remaining shared helper duplication.
+- [x] 1.2 Add a `cli-modularization` spec delta covering context/output/scaffold single-source ownership and the known drift cases.
+
+## 2. Implementation
+
+- [x] 2.1 Add focused regression coverage for shared-source ownership and the known drift cases before editing the cleanup targets.
+- [x] 2.2 Move duplicated constants/session helpers in `src/cli/main.js` to `src/context.js` imports and reconcile `MANAGED_GITIGNORE_PATHS`.
+- [x] 2.3 Move duplicated presentation helpers in `src/cli/main.js` to `src/output/index.js` imports.
+- [x] 2.4 Move duplicated scaffold/file-install helpers in `src/cli/main.js` to `src/scaffold/index.js` imports and make scaffold reuse `context`'s `toDestinationPath`.
+- [x] 2.5 Remove dead or duplicate helpers from `src/cli/main.js` (`installMany`, `initWorkspace`, `doctorAudit`, `syncDoctorLocalSupportFiles`, `promptYesNo`, `envFlagEnabled`, one `gitRefExists`, and the duplicate auto-finish failure log).
+
+## 3. Verification
+
+- [x] 3.1 Run `node --check src/cli/main.js src/context.js src/output/index.js src/scaffold/index.js`.
+- [x] 3.2 Run `node --test test/cli-args-dispatch.test.js`.
+- [x] 3.3 Run focused CLI regression suites that cover setup/doctor/install surfaces touched by the cleanup.
+- [x] 3.4 Run `openspec validate agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29 --type change --strict`.
+- [x] 3.5 Run `openspec validate --specs`.
+
+Verification note: `node --check src/cli/main.js src/context.js src/output/index.js src/scaffold/index.js`, `node --test test/cli-args-dispatch.test.js`, `node --test test/cli-args-dispatch.test.js test/setup.test.js test/doctor.test.js test/install.test.js test/metadata.test.js`, and `npm test` all passed after the cleanup. `openspec validate agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29 --type change --strict` exited `0`, and `openspec validate --specs` exited `0` with `No items found to validate`.
+
+## 4. Cleanup
+
+- [ ] 4.1 Run `gx branch finish --branch agent/codex/dedupe-cli-main-context-output-scaffold-2026-04-22-14-29 --base main --via-pr --wait-for-merge --cleanup`.
+- [ ] 4.2 Record PR URL and final merge state (`MERGED`) in the completion handoff.
+- [ ] 4.3 Confirm the sandbox worktree is removed and no local/remote refs remain for the branch.
diff --git a/src/cli/main.js b/src/cli/main.js
index 2801de4..3836c67 100755
--- a/src/cli/main.js
+++ b/src/cli/main.js
@@ -1,13 +1,65 @@
#!/usr/bin/env node
-const fs = require('node:fs');
-const os = require('node:os');
-const path = require('node:path');
-const cp = require('node:child_process');
const hooksModule = require('../hooks');
const sandboxModule = require('../sandbox');
const toolchainModule = require('../toolchain');
const finishModule = require('../finish');
+const {
+ fs,
+ path,
+ cp,
+ packageJson,
+ TOOL_NAME,
+ SHORT_TOOL_NAME,
+ OPENSPEC_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,
+ COMPOSE_HINT_FILES,
+ TEMPLATE_ROOT,
+ HOOK_NAMES,
+ TEMPLATE_FILES,
+ LEGACY_WORKFLOW_SHIM_SPECS,
+ 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,
+ AGENT_WORKTREE_RELATIVE_DIRS,
+ MANAGED_GITIGNORE_PATHS,
+ REPO_SCAFFOLD_DIRECTORIES,
+ OMX_SCAFFOLD_DIRECTORIES,
+ OMX_SCAFFOLD_FILES,
+ TARGETED_FORCEABLE_MANAGED_PATHS,
+ DEPRECATED_COMMAND_ALIASES,
+ defaultAgentWorktreeRelativeDir,
+ AI_SETUP_PROMPT,
+ AI_SETUP_COMMANDS,
+ SCORECARD_RISK_BY_CHECK,
+} = require('../context');
const {
gitRun,
resolveRepoRoot,
@@ -42,348 +94,43 @@ const {
warnDeprecatedAlias,
extractFlag,
} = require('./dispatch');
+const {
+ runtimeVersion,
+ colorize,
+ colorizeDoctorOutput,
+ statusDot,
+ printToolLogsSummary,
+ usage,
+ formatElapsedDuration,
+ compactAutoFinishPathSegments,
+ detectRecoverableAutoFinishConflict,
+ printAutoFinishSummary,
+} = require('../output');
+const {
+ toDestinationPath,
+ ensureParentDir,
+ ensureExecutable,
+ isCriticalGuardrailPath,
+ shellSingleQuote,
+ renderShellDispatchShim,
+ renderPythonDispatchShim,
+ managedForceConflictMessage,
+ printOperations,
+ printStandaloneOperations,
+} = require('../scaffold');
let sandboxApi;
let toolchainApi;
let finishApi;
-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(__dirname, '..'),
-);
-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 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'];
-
-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 SHARED_VSCODE_SETTINGS_RELATIVE = path.posix.join('.vscode', 'settings.json');
const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'git.repositoryScanIgnoredFolders';
-const AGENT_WORKTREE_RELATIVE_DIRS = [
- CODEX_WORKTREE_RELATIVE_DIR,
- CLAUDE_WORKTREE_RELATIVE_DIR,
-];
const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [
'.omx/agent-worktrees',
'**/.omx/agent-worktrees',
'.omc/agent-worktrees',
'**/.omc/agent-worktrees',
];
-const MANAGED_GITIGNORE_PATHS = [
- '.omx/',
- '.omc/',
- '!.vscode/',
- '.vscode/*',
- '!.vscode/settings.json',
- '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 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',
- // deprecated aliases still routable with a warning
- '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 getSandboxApi() {
if (!sandboxApi) {
@@ -465,114 +212,6 @@ function getFinishApi() {
return finishApi;
}
-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',
-};
-
-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;
-}
-
/**
* @typedef {Object} AutoFinishSummary
* @property {boolean} [enabled]
@@ -627,456 +266,6 @@ function colorizeDoctorOutput(text, status) {
* @property {AutoFinishSummary} autoFinish
* @property {string | null} sandboxLockContent
*/
-
-/**
- * @param {string | null | undefined} detail
- * @returns {string | null}
- */
-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;
-}
-
-/**
- * @param {AutoFinishSummary | null | undefined} summary
- * @returns {string | 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'); // green
- }
- if (status === 'inactive') {
- return colorize('●', '31'); // red
- }
- if (status === 'disabled') {
- return colorize('●', '36'); // cyan
- }
- return colorize('●', '33'); // yellow for degraded/unknown
-}
-
-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)}`;
-}
-
-/**
- * @param {AutoFinishSummary | null | undefined} summary
- * @param {{ baseBranch?: string, verbose?: boolean, detailLimit?: number }} [options]
- */
-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 > detailLimit) {
- console.log(
- colorizeDoctorOutput(
- `[${TOOL_NAME}] … ${details.length - detailLimit} 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)));
- }
-}
-
-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 renderManagedFile(repoRoot, relativePath, content, options = {}) {
const destinationPath = path.join(repoRoot, relativePath);
const destinationExists = fs.existsSync(destinationPath);
@@ -1891,10 +1080,6 @@ function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
}
-function gitRefExists(repoRoot, ref) {
- return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
-}
-
function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
@@ -2532,10 +1717,6 @@ function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata
};
}
-function syncDoctorLocalSupportFiles(repoRoot, dryRun) {
- return [];
-}
-
/**
* @param {string} [note]
* @returns {OperationResult}
@@ -2732,8 +1913,6 @@ function executeDoctorSandboxLifecycle(options, blocked, metadata) {
execution.finish,
);
- syncDoctorLocalSupportFiles(blocked.repoRoot, dryRun);
-
execution.omxScaffoldSync = summarizeDoctorOmxScaffoldSync(blocked.repoRoot, dryRun);
execution.lockSync = syncDoctorLockRegistryAfterMerge(
blocked.repoRoot,
@@ -2850,7 +2029,6 @@ function emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult,
if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
} else if (execution.finish.status === 'failed') {
- console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
@@ -3968,31 +3146,6 @@ 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();
-
- 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');
- }
-}
-
-function envFlagEnabled(name) {
- const raw = process.env[name];
- if (raw == null) return false;
- return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase());
-}
-
function parseAutoApproval(name) {
const raw = process.env[name];
if (raw == null) return null;
@@ -4120,11 +3273,11 @@ function parseNpmVersionOutput(stdout) {
}
function checkForGuardexUpdate() {
- if (envFlagEnabled('GUARDEX_SKIP_UPDATE_CHECK')) {
+ if (parseBooleanLike(process.env.GUARDEX_SKIP_UPDATE_CHECK) === true) {
return { checked: false, reason: 'disabled' };
}
- const forceCheck = envFlagEnabled('GUARDEX_FORCE_UPDATE_CHECK');
+ const forceCheck = parseBooleanLike(process.env.GUARDEX_FORCE_UPDATE_CHECK) === true;
if (!forceCheck && !isInteractiveTerminal()) {
return { checked: false, reason: 'non-interactive' };
}
@@ -4244,11 +3397,12 @@ function restartIntoUpdatedGuardex(expectedVersion) {
}
function checkForOpenSpecPackageUpdate() {
- if (envFlagEnabled('GUARDEX_SKIP_OPENSPEC_UPDATE_CHECK')) {
+ if (parseBooleanLike(process.env.GUARDEX_SKIP_OPENSPEC_UPDATE_CHECK) === true) {
return { checked: false, reason: 'disabled' };
}
- const forceCheck = envFlagEnabled('GUARDEX_FORCE_OPENSPEC_UPDATE_CHECK');
+ const forceCheck =
+ parseBooleanLike(process.env.GUARDEX_FORCE_OPENSPEC_UPDATE_CHECK) === true;
if (!forceCheck && !isInteractiveTerminal()) {
return { checked: false, reason: 'non-interactive' };
}
@@ -4757,21 +3911,6 @@ function runScanInternal(options) {
};
}
-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 printScanResult(scan, json = false) {
if (json) {
process.stdout.write(
@@ -6082,247 +5221,6 @@ function release(rawArgs) {
process.exitCode = 0;
}
-function installMany(rawArgs) {
- const options = parseInstallManyArgs(rawArgs);
- const targets = collectInstallManyTargets(options);
-
- if (!targets.length) {
- throw new Error('install-many did not find any targets to process.');
- }
-
- if (options.usedImplicitWorkspaceDefault) {
- console.log(
- `[multiagent-safety] No explicit targets provided. Defaulting to workspace scan: ${path.resolve(
- options.workspace,
- )} (max depth ${options.maxDepth})`,
- );
- }
-
- console.log(
- `[multiagent-safety] install-many starting for ${targets.length} target path(s)${
- options.dryRun ? ' [dry-run]' : ''
- }`,
- );
-
- let installed = 0;
- let duplicateRepos = 0;
- const seenRepoRoots = new Set();
- const failures = [];
-
- for (const targetPath of targets) {
- let repoRoot;
- try {
- repoRoot = resolveRepoRoot(targetPath);
- } catch (error) {
- failures.push({ target: targetPath, message: error.message });
- if (options.failFast) {
- break;
- }
- continue;
- }
-
- if (seenRepoRoots.has(repoRoot)) {
- duplicateRepos += 1;
- console.log(`[multiagent-safety] Skipping duplicate repo target: ${targetPath} -> ${repoRoot}`);
- continue;
- }
-
- seenRepoRoots.add(repoRoot);
-
- try {
- const report = installIntoRepoRoot(repoRoot, options);
- printInstallReport(report);
- installed += 1;
- } catch (error) {
- failures.push({ target: repoRoot, message: error.message });
- if (options.failFast) {
- break;
- }
- }
- }
-
- console.log(
- `[multiagent-safety] install-many summary: installed=${installed}, failures=${failures.length}, duplicate-targets=${duplicateRepos}`,
- );
-
- if (failures.length > 0) {
- console.error('[multiagent-safety] Failed targets:');
- for (const failure of failures) {
- console.error(` - ${failure.target}`);
- console.error(` ${failure.message}`);
- }
- throw new Error(`install-many completed with ${failures.length} failure(s)`);
- }
-
- if (options.dryRun) {
- console.log('[multiagent-safety] Dry run complete. No files were modified.');
- } else {
- console.log('[multiagent-safety] Installed multi-agent safety workflow across all targets.');
- }
-}
-
-function initWorkspace(rawArgs) {
- const options = parseInitWorkspaceArgs(rawArgs);
- const resolvedWorkspace = path.resolve(options.workspace);
- const repos = discoverGitRepos(resolvedWorkspace, options.maxDepth)
- .map((repoPath) => path.resolve(repoPath))
- .sort();
-
- const outputPath = options.output
- ? path.resolve(options.output)
- : path.join(resolvedWorkspace, DEFAULT_WORKSPACE_TARGETS_FILE);
-
- if (fs.existsSync(outputPath) && !options.force) {
- throw new Error(`Refusing to overwrite existing file without --force: ${outputPath}`);
- }
-
- const headerLines = [
- '# multiagent-safety workspace targets',
- `# generated: ${new Date().toISOString()}`,
- `# workspace: ${resolvedWorkspace}`,
- `# max-depth: ${options.maxDepth}`,
- '#',
- '# Run:',
- `# multiagent-safety install-many --targets-file "${outputPath}"`,
- '',
- ];
- const content = `${headerLines.join('\n')}${repos.join('\n')}${repos.length ? '\n' : ''}`;
-
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
- fs.writeFileSync(outputPath, content, 'utf8');
-
- console.log(`[multiagent-safety] Workspace target file written: ${outputPath}`);
- console.log(`[multiagent-safety] Repos discovered: ${repos.length}`);
- if (repos.length === 0) {
- console.log('[multiagent-safety] No git repos found. You can add target paths manually to the file.');
- } else {
- console.log(`[multiagent-safety] Next step: multiagent-safety install-many --targets-file "${outputPath}"`);
- }
-}
-
-function doctorAudit(rawArgs) {
- const options = parseDoctorArgs(rawArgs);
- const repoRoot = resolveRepoRoot(options.target);
- const guardexToggle = resolveGuardexRepoToggle(repoRoot);
- const failures = [];
- const warnings = [];
-
- function ok(message) {
- console.log(` [ok] ${message}`);
- }
- function warn(message) {
- warnings.push(message);
- console.log(` [warn] ${message}`);
- }
- function fail(message) {
- failures.push(message);
- console.log(` [fail] ${message}`);
- }
-
- console.log(`[multiagent-safety] doctor target: ${repoRoot}`);
- if (!guardexToggle.enabled) {
- console.log(
- `[multiagent-safety] Guardex is disabled for this repo (${describeGuardexRepoToggle(guardexToggle)}).`,
- );
- console.log('[multiagent-safety] doctor passed.');
- return;
- }
-
- const hooksPath = run('git', ['-C', repoRoot, 'config', '--get', 'core.hooksPath']);
- if (hooksPath.status !== 0) {
- fail('git core.hooksPath is not configured');
- } else if (hooksPath.stdout.trim() !== '.githooks') {
- fail(`git core.hooksPath is "${hooksPath.stdout.trim()}" (expected ".githooks")`);
- } else {
- ok('git core.hooksPath is .githooks');
- }
-
- for (const relativePath of REQUIRED_MANAGED_REPO_FILES) {
- const absolutePath = path.join(repoRoot, relativePath);
- if (!fs.existsSync(absolutePath)) {
- fail(`missing ${relativePath}`);
- continue;
- }
- ok(`found ${relativePath}`);
-
- if (EXECUTABLE_RELATIVE_PATHS.has(relativePath)) {
- try {
- fs.accessSync(absolutePath, fs.constants.X_OK);
- } catch {
- fail(`${relativePath} exists but is not executable`);
- }
- }
- }
-
- const lockFilePath = path.join(repoRoot, '.omx/state/agent-file-locks.json');
- if (fs.existsSync(lockFilePath)) {
- try {
- const parsed = JSON.parse(fs.readFileSync(lockFilePath, 'utf8'));
- if (!parsed || typeof parsed !== 'object' || typeof parsed.locks !== 'object') {
- fail('.omx/state/agent-file-locks.json does not contain a valid { locks: {} } object');
- } else {
- ok('lock registry JSON is valid');
- }
- } catch (error) {
- fail(`lock registry JSON is invalid: ${error.message}`);
- }
- }
-
- const packagePath = path.join(repoRoot, 'package.json');
- if (!fs.existsSync(packagePath)) {
- warn('package.json not found (legacy agent:* script drift cannot be checked)');
- } else {
- try {
- const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
- const scripts = pkg.scripts || {};
- const legacyAgentScripts = Object.entries(LEGACY_MANAGED_PACKAGE_SCRIPTS)
- .filter(([name, expectedValue]) => scripts[name] === expectedValue)
- .map(([name]) => name);
- if (legacyAgentScripts.length > 0) {
- warn(`legacy agent:* package.json scripts remain (${legacyAgentScripts.join(', ')}); run '${SHORT_TOOL_NAME} migrate' to remove them`);
- } else {
- ok('package.json does not contain Guardex-managed agent:* helper scripts');
- }
- } catch (error) {
- fail(`package.json is invalid JSON: ${error.message}`);
- }
- }
-
- const agentsPath = path.join(repoRoot, 'AGENTS.md');
- if (!fs.existsSync(agentsPath)) {
- warn('AGENTS.md not found (multi-agent contract snippet not present)');
- } else {
- const agentsContent = fs.readFileSync(agentsPath, 'utf8');
- if (!agentsContent.includes(AGENTS_MARKER_START)) {
- warn('AGENTS.md exists but multiagent-safety snippet marker is missing');
- } else {
- ok('AGENTS.md contains multiagent-safety snippet marker');
- }
- }
-
- if (warnings.length) {
- console.log(`[multiagent-safety] warnings: ${warnings.length}`);
- }
- if (failures.length) {
- console.log(`[multiagent-safety] failures: ${failures.length}`);
- }
-
- if (failures.length === 0 && (!options.strict || warnings.length === 0)) {
- console.log('[multiagent-safety] doctor passed.');
- if (warnings.length > 0) {
- console.log('[multiagent-safety] tip: run with --strict to treat warnings as failures.');
- }
- return;
- }
-
- if (options.strict && warnings.length > 0 && failures.length === 0) {
- console.log('[multiagent-safety] strict mode failed due to warnings.');
- } else {
- console.log('[multiagent-safety] doctor failed.');
- }
- throw new Error('doctor detected configuration issues');
-}
-
function printAgentsSnippet() {
const snippetPath = path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md');
process.stdout.write(fs.readFileSync(snippetPath, 'utf8'));
@@ -6363,17 +5261,6 @@ function prompt(rawArgs) {
return copyPrompt();
}
-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.`);
- }
-}
-
function branch(rawArgs) {
const [subcommand, ...rest] = rawArgs;
if (subcommand === 'start') {
diff --git a/src/context.js b/src/context.js
index 623dc8c..0471617 100644
--- a/src/context.js
+++ b/src/context.js
@@ -249,6 +249,9 @@ const AGENT_WORKTREE_RELATIVE_DIRS = [
const MANAGED_GITIGNORE_PATHS = [
'.omx/',
'.omc/',
+ '!.vscode/',
+ '.vscode/*',
+ '!.vscode/settings.json',
'scripts/agent-session-state.js',
'scripts/guardex-docker-loader.sh',
'scripts/guardex-env.sh',
diff --git a/src/scaffold/index.js b/src/scaffold/index.js
index d6b3b08..e1f3424 100644
--- a/src/scaffold/index.js
+++ b/src/scaffold/index.js
@@ -3,32 +3,11 @@ const {
path,
TOOL_NAME,
SHORT_TOOL_NAME,
+ toDestinationPath,
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;
diff --git a/test/cli-args-dispatch.test.js b/test/cli-args-dispatch.test.js
index 348da08..5c5dd1c 100644
--- a/test/cli-args-dispatch.test.js
+++ b/test/cli-args-dispatch.test.js
@@ -5,7 +5,12 @@ const path = require('node:path');
const {
DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES,
+ MANAGED_GITIGNORE_PATHS,
+ CLI_COMMAND_DESCRIPTIONS,
+ MAINTAINER_RELEASE_REPO,
+ toDestinationPath,
} = require('../src/context');
+const scaffold = require('../src/scaffold');
const {
parseSetupArgs,
parseDoctorArgs,
@@ -164,12 +169,36 @@ test('dispatch helpers preserve suggestion, alias, deprecation, and flag extract
);
});
-test('cli main no longer keeps local copies of extracted parser and dispatch helpers', () => {
+test('shared context keeps the drift-prone help text, gitignore paths, and release repo root', () => {
+ const descriptions = new Map(CLI_COMMAND_DESCRIPTIONS);
+
+ assert.match(descriptions.get('setup'), /--current/);
+ assert.match(descriptions.get('doctor'), /--current/);
+ assert.ok(MANAGED_GITIGNORE_PATHS.includes('!.vscode/'));
+ assert.ok(MANAGED_GITIGNORE_PATHS.includes('.vscode/*'));
+ assert.ok(MANAGED_GITIGNORE_PATHS.includes('!.vscode/settings.json'));
+ assert.equal(MAINTAINER_RELEASE_REPO, repoRoot);
+});
+
+test('scaffold reuses the shared destination-path helper from context', () => {
+ assert.equal(scaffold.toDestinationPath, toDestinationPath);
+ assert.equal(scaffold.toDestinationPath('github/pull.yml.example'), '.github/pull.yml.example');
+});
+
+test('cli main no longer keeps local copies of extracted shared helpers or dead cleanup code', () => {
const source = fs.readFileSync(path.join(repoRoot, 'src', 'cli', 'main.js'), 'utf8');
+ assert.match(source, /require\('\.\.\/context'\)/);
+ assert.match(source, /require\('\.\.\/output'\)/);
+ assert.match(source, /require\('\.\.\/scaffold'\)/);
assert.match(source, /require\('\.\/args'\)/);
assert.match(source, /require\('\.\/dispatch'\)/);
assert.match(source, /require\('\.\.\/git'\)/);
+ assert.doesNotMatch(source, /const TOOL_NAME = 'gitguardex';/);
+ assert.doesNotMatch(source, /const MAINTAINER_RELEASE_REPO = path\.resolve\(/);
+ assert.doesNotMatch(source, /function envFlagIsTruthy\(raw\)/);
+ assert.doesNotMatch(source, /function isClaudeCodeSession\(env = process\.env\)/);
+ assert.doesNotMatch(source, /function defaultAgentWorktreeRelativeDir\(env = process\.env\)/);
assert.doesNotMatch(source, /function parseDoctorArgs\(rawArgs\)/);
assert.doesNotMatch(source, /function parseSetupArgs\(rawArgs, defaults\)/);
assert.doesNotMatch(source, /function parseCleanupArgs\(rawArgs\)/);
@@ -182,4 +211,17 @@ test('cli main no longer keeps local copies of extracted parser and dispatch hel
assert.doesNotMatch(source, /function normalizeCommandOrThrow\(command\)/);
assert.doesNotMatch(source, /function warnDeprecatedAlias\(aliasName\)/);
assert.doesNotMatch(source, /function extractFlag\(args, \.\.\.names\)/);
+ assert.doesNotMatch(source, /function runtimeVersion\(\)/);
+ assert.doesNotMatch(source, /function usage\(options = \{\}\)/);
+ assert.doesNotMatch(source, /function toDestinationPath\(relativeTemplatePath\)/);
+ assert.doesNotMatch(source, /function printOperations\(title, payload, dryRun = false\)/);
+ assert.doesNotMatch(source, /function printStandaloneOperations\(title, rootLabel, operations, dryRun = false\)/);
+ assert.doesNotMatch(source, /function promptYesNo\(question, defaultYes = true\)/);
+ assert.doesNotMatch(source, /function envFlagEnabled\(name\)/);
+ assert.doesNotMatch(source, /function installMany\(rawArgs\)/);
+ assert.doesNotMatch(source, /function initWorkspace\(rawArgs\)/);
+ assert.doesNotMatch(source, /function doctorAudit\(rawArgs\)/);
+ assert.doesNotMatch(source, /function syncDoctorLocalSupportFiles\(repoRoot, dryRun\)/);
+ assert.equal((source.match(/function gitRefExists\(/g) || []).length, 1);
+ assert.equal((source.match(/Auto-finish flow failed for sandbox branch/g) || []).length, 1);
});
diff --git a/test/metadata.test.js b/test/metadata.test.js
index 1f7e73c..18340da 100644
--- a/test/metadata.test.js
+++ b/test/metadata.test.js
@@ -156,11 +156,13 @@ test('package manifest ships the extracted src runtime', () => {
assert.match(pkg.files.join('\n'), /^src$/m);
});
-test('doctor CLI parser exists in src/cli args and main runtime to prevent ReferenceError regressions', () => {
+test('doctor CLI parser stays in src/cli args while dead legacy audit stubs stay removed from main runtime', () => {
const argsSource = fs.readFileSync(path.join(repoRoot, 'src', 'cli', 'args.js'), 'utf8');
const cliSource = fs.readFileSync(path.join(repoRoot, 'src', 'cli', 'main.js'), 'utf8');
assert.match(argsSource, /function parseDoctorArgs\(rawArgs(?:, options = \{\})?\)/);
- assert.match(cliSource, /function doctorAudit\(rawArgs\)/);
+ assert.doesNotMatch(cliSource, /function doctorAudit\(rawArgs\)/);
+ assert.doesNotMatch(cliSource, /function installMany\(rawArgs\)/);
+ assert.doesNotMatch(cliSource, /function initWorkspace\(rawArgs\)/);
});
test('cli main delegates extracted seams and keeps doctor single-source', () => {