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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
const GITIGNORE_MARKER_START = '# multiagent-safety:START';
const GITIGNORE_MARKER_END = '# multiagent-safety:END';
const MANAGED_GITIGNORE_PATHS = [
'.omx/',
'scripts/agent-branch-start.sh',
'scripts/agent-branch-finish.sh',
'scripts/codex-agent.sh',
Expand All @@ -98,6 +99,17 @@ const MANAGED_GITIGNORE_PATHS = [
'.claude/commands/guardex.md',
LOCK_FILE_RELATIVE,
];
const OMX_SCAFFOLD_DIRECTORIES = [
'.omx',
'.omx/state',
'.omx/logs',
'.omx/plans',
'.omx/agent-worktrees',
];
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'],
Expand Down Expand Up @@ -502,6 +514,45 @@ function lockFilePath(repoRoot) {
return path.join(repoRoot, LOCK_FILE_RELATIVE);
}

function ensureOmxScaffold(repoRoot, dryRun) {
const operations = [];

for (const relativeDir of OMX_SCAFFOLD_DIRECTORIES) {
const absoluteDir = path.join(repoRoot, relativeDir);
if (fs.existsSync(absoluteDir)) {
if (!fs.statSync(absoluteDir).isDirectory()) {
throw new Error(`Expected directory at ${relativeDir} but found a file.`);
}
operations.push({ status: 'unchanged', file: relativeDir });
continue;
}

if (!dryRun) {
fs.mkdirSync(absoluteDir, { recursive: true });
}
operations.push({ status: 'created', file: relativeDir });
}

for (const [relativeFile, defaultContent] of OMX_SCAFFOLD_FILES.entries()) {
const absoluteFile = path.join(repoRoot, relativeFile);
if (fs.existsSync(absoluteFile)) {
if (!fs.statSync(absoluteFile).isFile()) {
throw new Error(`Expected file at ${relativeFile} but found a directory.`);
}
operations.push({ status: 'unchanged', file: relativeFile });
continue;
}

if (!dryRun) {
fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
fs.writeFileSync(absoluteFile, defaultContent, 'utf8');
}
operations.push({ status: 'created', file: relativeFile });
}

return operations;
}

function ensureLockRegistry(repoRoot, dryRun) {
const absolutePath = lockFilePath(repoRoot);
if (fs.existsSync(absolutePath)) {
Expand Down Expand Up @@ -1197,7 +1248,27 @@ function runDoctorInSandbox(options, blocked) {
status: 'skipped',
note: 'sandbox doctor did not complete successfully',
};
let omxScaffoldSyncResult = {
status: 'skipped',
note: 'sandbox doctor did not complete successfully',
};
if (nestedResult.status === 0) {
const omxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
if (changedOmxPaths.length === 0) {
omxScaffoldSyncResult = {
status: 'unchanged',
note: '.omx scaffold already in sync',
operations: omxScaffoldOps,
};
} else {
omxScaffoldSyncResult = {
status: options.dryRun ? 'would-sync' : 'synced',
note: `${options.dryRun ? 'would sync' : 'synced'} ${changedOmxPaths.length} .omx path(s)`,
operations: omxScaffoldOps,
};
}

if (!options.dryRun) {
autoCommitResult = autoCommitDoctorSandboxChanges(metadata);
if (autoCommitResult.status === 'committed') {
Expand Down Expand Up @@ -1264,6 +1335,7 @@ function runDoctorInSandbox(options, blocked) {
JSON.stringify(
{
...parsed,
sandboxOmxScaffoldSync: omxScaffoldSyncResult,
sandboxLockSync: lockSyncResult,
sandboxAutoCommit: autoCommitResult,
sandboxFinish: finishResult,
Expand Down Expand Up @@ -1332,6 +1404,16 @@ function runDoctorInSandbox(options, blocked) {
} else {
console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
}

if (omxScaffoldSyncResult.status === 'synced') {
console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`);
} else if (omxScaffoldSyncResult.status === 'unchanged') {
console.log(`[${TOOL_NAME}] .omx scaffold already aligned in protected branch workspace.`);
} else if (omxScaffoldSyncResult.status === 'would-sync') {
console.log(`[${TOOL_NAME}] Dry run: would sync .omx scaffold back to protected branch workspace.`);
} else {
console.log(`[${TOOL_NAME}] .omx scaffold sync skipped: ${omxScaffoldSyncResult.note}.`);
}
}
}

Expand Down Expand Up @@ -2325,6 +2407,8 @@ function runInstallInternal(options) {
const repoRoot = resolveRepoRoot(options.target);
const operations = [];

operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));

for (const templateFile of TEMPLATE_FILES) {
operations.push(copyTemplateFile(repoRoot, templateFile, Boolean(options.force), Boolean(options.dryRun)));
}
Expand All @@ -2351,6 +2435,8 @@ function runFixInternal(options) {
const repoRoot = resolveRepoRoot(options.target);
const operations = [];

operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));

for (const templateFile of TEMPLATE_FILES) {
operations.push(ensureTemplateFilePresent(repoRoot, templateFile, Boolean(options.dryRun)));
}
Expand Down Expand Up @@ -2404,6 +2490,8 @@ function runScanInternal(options) {
const findings = [];

const requiredPaths = [
...OMX_SCAFFOLD_DIRECTORIES,
...Array.from(OMX_SCAFFOLD_FILES.keys()),
...TEMPLATE_FILES.map((entry) => toDestinationPath(entry)),
LOCK_FILE_RELATIVE,
];
Expand Down
23 changes: 23 additions & 0 deletions test/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,13 @@ test('setup provisions workflow files and repo config', () => {
assert.equal(result.status, 0, result.stderr || result.stdout);

const requiredFiles = [
'.omx',
'.omx/state',
'.omx/logs',
'.omx/plans',
'.omx/agent-worktrees',
'.omx/notepad.md',
'.omx/project-memory.json',
'scripts/agent-branch-start.sh',
'scripts/agent-branch-finish.sh',
'scripts/codex-agent.sh',
Expand Down Expand Up @@ -257,6 +264,7 @@ test('setup provisions workflow files and repo config', () => {
assert.match(gitignoreContent, /scripts\/agent-file-locks\.py/);
assert.match(gitignoreContent, /\.githooks\/pre-commit/);
assert.match(gitignoreContent, /\.githooks\/pre-push/);
assert.match(gitignoreContent, /\.omx\//);
assert.match(gitignoreContent, /oh-my-codex\//);
assert.match(gitignoreContent, /\.codex\/skills\/guardex\/SKILL\.md/);
assert.match(gitignoreContent, /\.claude\/commands\/guardex\.md/);
Expand Down Expand Up @@ -484,10 +492,17 @@ test('doctor on protected main bootstraps sandbox branch even before setup exist
const result = runNode(['doctor', '--target', repoDir], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /doctor detected protected branch 'main'/);
assert.match(result.stdout, /\.omx scaffold/);
const createdBranch = extractCreatedBranch(result.stdout);
const createdWorktree = extractCreatedWorktree(result.stdout);
assert.match(createdBranch, /^agent\/gx\/.+-gx-doctor$/);
assert.equal(fs.existsSync(path.join(createdWorktree, 'scripts', 'agent-branch-start.sh')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'state')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'logs')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'plans')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'agent-worktrees')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'notepad.md')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'project-memory.json')), true);

const rootStatus = runCmd('git', ['status', '--short', '--untracked-files=no'], repoDir);
assert.equal(rootStatus.status, 0, rootStatus.stderr || rootStatus.stdout);
Expand Down Expand Up @@ -2264,6 +2279,10 @@ test('doctor repairs setup drift and confirms repo is safe', () => {

// Simulate broken setup + stale lock.
fs.rmSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh'));
fs.rmSync(path.join(repoDir, '.omx', 'notepad.md'));
fs.rmSync(path.join(repoDir, '.omx', 'project-memory.json'));
fs.rmSync(path.join(repoDir, '.omx', 'logs'), { recursive: true, force: true });
fs.rmSync(path.join(repoDir, '.omx', 'plans'), { recursive: true, force: true });
fs.writeFileSync(path.join(repoDir, '.githooks', 'pre-commit'), '#!/usr/bin/env bash\necho broken hook >&2\nexit 1\n', 'utf8');
result = runCmd('git', ['config', 'core.hooksPath', '.git/hooks'], repoDir);
assert.equal(result.status, 0, result.stderr);
Expand Down Expand Up @@ -2293,6 +2312,10 @@ test('doctor repairs setup drift and confirms repo is safe', () => {

const repairedHook = fs.readFileSync(path.join(repoDir, '.githooks', 'pre-commit'), 'utf8');
assert.match(repairedHook, /AGENTS\.md\|\.gitignore/);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'notepad.md')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'project-memory.json')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'logs')), true);
assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'plans')), true);

const scanAfter = runNode(['scan', '--target', repoDir], repoDir);
assert.equal(scanAfter.status, 0, scanAfter.stderr || scanAfter.stdout);
Expand Down
Loading