From cae3763ca08acbb9e9d555839c1b857ba88e530c Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 13 Apr 2026 22:33:46 +0200 Subject: [PATCH] Keep gx setup/doctor .omx runtime scaffold aligned across repos Setup and doctor now provision a consistent .omx scaffold (state/logs/plans/agent-worktrees plus notepad and project memory files), include .omx in managed gitignore, and scan for scaffold drift. Protected-main doctor sandbox runs now sync the local .omx scaffold back to the base workspace alongside lock-state sync. Constraint: Protected-main doctor runs must avoid direct implementation writes yet still repair local runtime state Rejected: Sync only lock registry on sandbox doctor | left other .omx runtime paths inconsistent in base workspace Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep .omx scaffold list centralized so setup/fix/scan/sandbox-sync stay in lockstep Tested: node --check bin/multiagent-safety.js; node --test test/install.test.js; npm test Not-tested: Real GitHub PR auto-finish path against live protected branch policy --- bin/multiagent-safety.js | 88 ++++++++++++++++++++++++++++++++++++++++ test/install.test.js | 23 +++++++++++ 2 files changed, 111 insertions(+) diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 977b22a..331f550 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -83,6 +83,7 @@ const AGENTS_MARKER_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', @@ -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'], @@ -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)) { @@ -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') { @@ -1264,6 +1335,7 @@ function runDoctorInSandbox(options, blocked) { JSON.stringify( { ...parsed, + sandboxOmxScaffoldSync: omxScaffoldSyncResult, sandboxLockSync: lockSyncResult, sandboxAutoCommit: autoCommitResult, sandboxFinish: finishResult, @@ -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}.`); + } } } @@ -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))); } @@ -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))); } @@ -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, ]; diff --git a/test/install.test.js b/test/install.test.js index d9f69cf..f09937d 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -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', @@ -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/); @@ -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); @@ -2196,6 +2211,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); @@ -2225,6 +2244,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);