diff --git a/scripts/safe-autonomous-improvement-controller.js b/scripts/safe-autonomous-improvement-controller.js new file mode 100644 index 00000000..fc252552 --- /dev/null +++ b/scripts/safe-autonomous-improvement-controller.js @@ -0,0 +1,503 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const CONTROLLER_VERSION = '2.1.0'; + +const VALID_MODES = new Set(['AUDIT_ONLY', 'PREPARE', 'IMPLEMENT', 'PUBLISH', 'DEPLOY']); + +function getRepoRoot() { return process.env.REPO_ROOT || '/home/we4free/agent/repos/Archivist-Agent'; } +function getStateDir() { return process.env.SAFE_IMPROVEMENT_STATE_DIR || path.join(os.homedir(), 'agent', 'state', 'safe-improvement-controller'); } +function getMode() { return (process.env.SAFE_IMPROVEMENT_MODE || 'AUDIT_ONLY').toUpperCase(); } + +function nowIso() { return new Date().toISOString(); } +function ensureDir(d) { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); } +function readFileSafe(p) { + try { return fs.readFileSync(p, 'utf8'); } + catch { return null; } +} +function appendAudit(stateDir, entry) { + ensureDir(path.dirname(path.join(stateDir, 'audit.jsonl'))); + fs.appendFileSync(path.join(stateDir, 'audit.jsonl'), JSON.stringify(entry) + '\n'); +} +function appendCandidate(stateDir, entry) { + ensureDir(path.join(stateDir, 'candidates')); + fs.writeFileSync(path.join(stateDir, 'candidates', entry.id + '.json'), JSON.stringify(entry, null, 2)); +} + +function createDefaultRunners() { + const { spawnSync } = require('child_process'); + return { + commandRunner: { + run(command, args, options) { + return spawnSync(command, args, Object.assign({ encoding: 'utf8', timeout: 30000 }, options || {})); + } + }, + filesystem: { + existsSync: fs.existsSync.bind(fs), + readFileSync: fs.readFileSync.bind(fs), + writeFileSync: fs.writeFileSync.bind(fs), + mkdirSync: (d, opts) => fs.mkdirSync(d, opts || { recursive: true }), + readdirSync: fs.readdirSync.bind(fs), + statSync: fs.statSync.bind(fs), + unlinkSync: fs.unlinkSync.bind(fs), + rmSync: (p, opts) => fs.rmSync(p, opts || { recursive: true, force: true }) + }, + clock: { + nowIso, + DateNow: Date.now.bind(Date) + } + }; +} + +function discoverLaneServices(serviceRunner) { + const lanes = [ + { lane: 'archivist', repo: '/home/we4free/agent/repos/Archivist-Agent' }, + { lane: 'kernel', repo: '/home/we4free/agent/repos/kernel-lane' }, + { lane: 'library', repo: '/home/we4free/agent/repos/self-organizing-library' }, + { lane: 'swarmmind', repo: '/home/we4free/agent/repos/SwarmMind' }, + { lane: 'solana-launch', repo: '/home/we4free/agent/repos/solana-launch-lane' } + ]; + const services = []; + for (const laneInfo of lanes) { + const unitName = 'we4free-lane-worker@' + laneInfo.lane + '.lane.service'; + const activeResult = serviceRunner.run('systemctl', ['is-active', unitName]); + const active = activeResult.status === 0; + let pid = null; + let cmd = null; + if (active) { + const ps = serviceRunner.run('ps', ['aux'], { encoding: 'utf8' }); + const lines = (ps.stdout || '').split('\n'); + for (const line of lines) { + if (line.includes('lane-worker.js') && line.includes('--lane ' + laneInfo.lane)) { + const parts = line.trim().split(/\s+/); + pid = parts[1]; + cmd = line.trim(); + break; + } + } + } + services.push({ lane: laneInfo.lane, repo: laneInfo.repo, unit: unitName, active, pid, command: cmd }); + } + return services; +} + +function evidenceCollector(runners, repoRoot, stateDir) { + const evidence = { + timestamp: runners.clock.nowIso(), + mode: getMode(), + version: CONTROLLER_VERSION, + repositories: {}, + laneServices: discoverLaneServices(runners.serviceRunner), + queueDepth: {}, + autonomyLedgerLastEntry: null, + autonomyLedgerEntryCount: 0 + }; + const lanes = ['archivist', 'kernel', 'library', 'swarmmind']; + for (const lane of lanes) { + const laneDir = path.join(repoRoot, 'lanes', lane); + if (!runners.filesystem.existsSync(laneDir)) continue; + const inboxDir = path.join(laneDir, 'inbox'); + const actionDir = path.join(inboxDir, 'action-required'); + let inboxCount = 0; + let actionCount = 0; + if (runners.filesystem.existsSync(inboxDir)) { + try { inboxCount = runners.filesystem.readdirSync(inboxDir).filter(f => f.endsWith('.json')).length; } catch {} + } + if (runners.filesystem.existsSync(actionDir)) { + try { actionCount = runners.filesystem.readdirSync(actionDir).filter(f => f.endsWith('.json')).length; } catch {} + } + evidence.queueDepth[lane] = { inbox: inboxCount, actionRequired: actionCount }; + } + const ledgerPath = path.join(repoRoot, 'context-buffer', 'autonomy-ledger.jsonl'); + const ledgerText = readFileSafe(ledgerPath); + if (ledgerText) { + const entries = ledgerText.split('\n').filter(l => l.trim()).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); + const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null; + evidence.autonomyLedgerLastEntry = lastEntry ? lastEntry.timestamp : null; + evidence.autonomyLedgerEntryCount = entries.length; + } + return evidence; +} + +function makeCandidateId(evidence, lane) { + return 'lane-backlog-' + lane + '-' + Math.abs(hashCode(JSON.stringify(evidence.queueDepth))).toString(36); +} + +function hashCode(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return hash; +} + +function candidateSelector(evidence, stateDir, runners) { + const candidates = []; + const seenIds = new Set(); + for (const [lane, depth] of Object.entries(evidence.queueDepth)) { + if (depth.actionRequired > 10) { + const id = makeCandidateId(evidence, lane); + if (seenIds.has(id)) continue; + seenIds.add(id); + candidates.push({ + id, + lane, + type: 'queue-reduction', + priority: depth.actionRequired > 50 ? 'P0' : 'P1', + rationale: 'Action-required queue depth of ' + depth.actionRequired + ' exceeds threshold', + risk: 'low', + allowedFiles: ['scripts/lane-worker.js'], + forbiddenPaths: ['context-buffer/', 'lanes/' + lane + '/inbox/'], + testCommand: 'node scripts/test-lane-worker.js', + rollbackPlan: 'Revert lane-worker.js to previous commit', + resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' } + }); + } + } + if (evidence.autonomyLedgerLastEntry && new Date(evidence.autonomyLedgerLastEntry).getTime() < runners.clock.DateNow() - 30 * 86400000) { + const id = 'autonomy-ledger-stale'; + if (!seenIds.has(id)) { + seenIds.add(id); + candidates.push({ + id, + lane: 'archivist', + type: 'ledger-maintenance', + priority: 'P1', + rationale: 'Autonomy ledger last entry is older than 30 days', + risk: 'low', + allowedFiles: ['scripts/headless-self-audit.js'], + forbiddenPaths: ['context-buffer/autonomy-ledger.jsonl'], + testCommand: 'node scripts/test-self-audit.js', + rollbackPlan: 'Revert headless-self-audit.js to previous commit', + resourceBudget: { maxFiles: 1, maxCommitSize: '5KB' } + }); + } + } + return candidates.slice(0, 5); +} + +function validateGitSafety(runners, repoDir) { + const issues = []; + const status = runners.gitRunner.run('status', ['--porcelain'], { cwd: repoDir }); + if (status.status !== 0) { issues.push('git status failed'); return issues; } + const lines = status.stdout.split('\n').filter(l => l.trim()); + for (const line of lines) { + const indexStatus = line.substring(0, 1); + const workStatus = line.substring(1, 2); + const filePath = line.substring(3).trim(); + if (indexStatus === '?' && workStatus === '?') { + if (!filePath.startsWith('.git/') && !filePath.startsWith('node_modules/')) { + issues.push('untracked-file: ' + filePath); + } + } + if (indexStatus !== ' ' || workStatus !== ' ') { + if (filePath.match(/\.(log|jsonl)$/) || filePath.includes('resource_usage')) { + issues.push('runtime-state-staged: ' + filePath); + } + } + } + const branch = runners.gitRunner.run('rev-parse', ['--abbrev-ref', 'HEAD'], { cwd: repoDir }); + const defaultBranches = ['main', 'master']; + if (defaultBranches.includes(branch.stdout.trim())) { + issues.push('on-default-branch: ' + branch.stdout.trim()); + } + return issues; +} + +function auditOnly(runners, evidence, stateDir) { + const existingCandidates = []; + const candidatesDir = path.join(stateDir, 'candidates'); + if (runners.filesystem.existsSync(candidatesDir)) { + try { + for (const f of runners.filesystem.readdirSync(candidatesDir)) { + if (f.endsWith('.json')) { + try { + const existing = JSON.parse(runners.filesystem.readFileSync(path.join(candidatesDir, f), 'utf8')); + existingCandidates.push(existing); + } catch {} + } + } + } catch {} + } + const newCandidates = candidateSelector(evidence, stateDir, runners); + const mergedCandidates = []; + const seenIds = new Set(); + for (const c of existingCandidates) { + mergedCandidates.push(c); + seenIds.add(c.id); + } + for (const c of newCandidates) { + if (!seenIds.has(c.id)) { + mergedCandidates.push(c); + seenIds.add(c.id); + appendCandidate(stateDir, c); + } + } + const report = { + timestamp: runners.clock.nowIso(), + mode: getMode(), + version: CONTROLLER_VERSION, + phase: 'AUDIT_ONLY', + status: 'complete', + evidence, + candidates: mergedCandidates, + mutations: { + repositoryFilesChanged: false, + indexChanged: false, + branchCreated: false, + commitCreated: false, + remoteRefChanged: false, + githubMutated: false, + serviceRestarted: false + } + }; + appendAudit(stateDir, report); + console.log('[SAFE-AI] AUDIT_ONLY complete. Candidates: ' + mergedCandidates.length); + return report; +} + +function prepare(runners, candidate, repoRoot, stateDir) { + const repoMap = { + archivist: '/home/we4free/agent/repos/Archivist-Agent', + kernel: '/home/we4free/agent/repos/kernel-lane', + library: '/home/we4free/agent/repos/self-organizing-library', + swarmmind: '/home/we4free/agent/repos/SwarmMind', + 'solana-launch': '/home/we4free/agent/repos/solana-launch-lane' + }; + const repoDir = repoMap[candidate.lane]; + if (!repoDir) return { error: 'unknown-lane: ' + candidate.lane }; + if (!runners.filesystem.existsSync(repoDir)) return { error: 'repository-not-found: ' + repoDir }; + const repoUrlResult = runners.gitRunner.run('remote', ['get-url', 'origin'], { cwd: repoDir }); + if (repoUrlResult.status !== 0 || !repoUrlResult.stdout.trim()) return { error: 'no-remote' }; + const branch = 'safe-improvement/' + candidate.id; + ensureDir(path.join(stateDir, 'worktrees')); + const worktreePath = path.join(stateDir, 'worktrees', candidate.id); + if (runners.filesystem.existsSync(worktreePath)) { + return { error: 'worktree-collision', path: worktreePath }; + } + const fetchResult = runners.gitRunner.run('fetch', ['--no-tags', 'origin'], { cwd: repoDir }); + if (fetchResult.status !== 0) return { error: 'fetch-failed', details: fetchResult.stderr }; + const defaultBranchResult = runners.gitRunner.run('rev-parse', ['--abbrev-ref', 'origin/HEAD'], { cwd: repoDir }); + const defaultBranch = defaultBranchResult.stdout.trim().replace('origin/', ''); + const createResult = runners.gitRunner.run('worktree', ['add', '--force', worktreePath, 'origin/' + defaultBranch], { cwd: repoDir }); + if (createResult.status !== 0) return { error: 'worktree-create-failed', details: createResult.stderr }; + const branchResult = runners.gitRunner.run('checkout', ['-b', branch], { cwd: worktreePath }); + if (branchResult.status !== 0) { + runners.gitRunner.run('worktree', ['remove', '--force', worktreePath], { cwd: repoDir }); + return { error: 'branch-create-failed', details: branchResult.stderr }; + } + const contract = { + repository: repoDir, + baseSha: runners.gitRunner.run('rev-parse', ['HEAD'], { cwd: worktreePath }).stdout.trim(), + branch, + worktreePath, + defaultBranch, + allowedFiles: candidate.allowedFiles, + forbiddenPaths: candidate.forbiddenPaths, + testCommand: candidate.testCommand, + rollbackPlan: candidate.rollbackPlan, + resourceBudget: candidate.resourceBudget + }; + runners.filesystem.writeFileSync(path.join(stateDir, 'candidates', candidate.id + '-contract.json'), JSON.stringify(contract, null, 2)); + appendAudit(stateDir, { timestamp: runners.clock.nowIso(), phase: 'PREPARE', candidate: candidate.id, status: 'complete', contract }); + return contract; +} + +function implement(runners, contract) { + const worktreePath = contract.worktreePath; + const safety = validateGitSafety(runners, worktreePath); + if (safety.length > 0) { + return { passed: false, phase: 'git-safety', issues: safety }; + } + const diffResult = runners.gitRunner.run('diff', ['--name-only'], { cwd: worktreePath }); + const untrackedResult = runners.gitRunner.run('ls-files', ['--others', '--exclude-standard'], { cwd: worktreePath }); + const allChanged = [ + ...new Set([ + ...diffResult.stdout.split("\n").filter(f => f.trim()), + ...untrackedResult.stdout.split("\n").filter(f => f.trim()) + ]) + ]; + if (allChanged.length === 0) { + return { passed: false, phase: 'no-diff', issue: 'no substantive implementation diff' }; + } + const maxFiles = (contract.resourceBudget && contract.resourceBudget.maxFiles) || 1; + if (allChanged.length > maxFiles) { + return { passed: false, phase: 'file-count', issue: 'changed files ' + allChanged.length + ' exceed max ' + maxFiles }; + } + for (const f of allChanged) { + const isAllowed = contract.allowedFiles.some(af => f === af || f.startsWith(af)); + if (!isAllowed) { + return { passed: false, phase: 'allowed-files', issue: 'unauthorized-file: ' + f }; + } + for (const forbidden of contract.forbiddenPaths) { + if (f.startsWith(forbidden)) { + return { passed: false, phase: 'forbidden-path', issue: 'forbidden-path: ' + f }; + } + } + } + const runtimePatterns = [/\.(log|jsonl)$/, /resource_usage/, /heartbeat-/, /snapshot/]; + for (const f of allChanged) { + for (const pat of runtimePatterns) { + if (pat.test(f)) { + return { passed: false, phase: 'runtime-state', issue: 'runtime-state-detected: ' + f }; + } + } + } + if (!contract.testCommand) { + return { passed: false, phase: 'no-test', issue: 'no test command in contract' }; + } + const testResult = runners.commandRunner.run('bash', ['-lc', contract.testCommand], { cwd: worktreePath, encoding: 'utf8', timeout: 120000 }); + if (testResult.status !== 0) { + return { passed: false, phase: 'test-failed', exitCode: testResult.status, details: testResult.stderr || testResult.stdout }; + } + appendAudit(contract.worktreePath ? path.join(contract.worktreePath, '..', '..', getStateDir()) : getStateDir(), { timestamp: runners.clock.nowIso(), phase: 'IMPLEMENT', branch: contract.branch, status: 'complete', testsPassed: true, exitCode: testResult.status }); + return { passed: true, worktreePath, branch: contract.branch, testOutput: testResult.stdout, exitCode: testResult.status }; +} + +function publish(runners, contract, testResult) { + if (!testResult || !testResult.passed) { + return { passed: false, error: 'implement-failed', details: testResult }; + } + const worktreePath = contract.worktreePath; + const diffCheck = runners.gitRunner.run('diff', ['--check'], { cwd: worktreePath }); + if (diffCheck.status !== 0) { + return { passed: false, error: 'diff-check-failed', details: diffCheck.stderr }; + } + const changedFiles = runners.gitRunner.run('diff', ['--name-only'], { cwd: worktreePath }).stdout.split('\n').filter(f => f.trim()); + const untrackedFiles = runners.gitRunner.run('ls-files', ['--others', '--exclude-standard'], { cwd: worktreePath }).stdout.split('\n').filter(f => f.trim()); + const allFiles = [...changedFiles, ...untrackedFiles]; + for (const f of allFiles) { + const isAllowed = contract.allowedFiles.some(af => f === af || f.startsWith(af)); + if (!isAllowed) { + return { passed: false, error: 'unauthorized-file', file: f }; + } + const content = runners.filesystem.readFileSync(path.join(worktreePath, f), 'utf8'); + const secretPatterns = [/SUGGESTION_SIGNING_KEY/, /private\.key/, /-----BEGIN (RSA |EC )?PRIVATE KEY-----/, /password/i]; + for (const pat of secretPatterns) { + if (pat.test(content)) { + return { passed: false, error: 'secret-like-content', file: f, pattern: pat.toString() }; + } + } + const runtimePatterns = [/\.(log|jsonl)$/, /resource_usage/, /heartbeat-/, /snapshot/]; + for (const pat of runtimePatterns) { + if (pat.test(f)) { + return { passed: false, error: 'runtime-state-file', file: f }; + } + } + for (const forbidden of contract.forbiddenPaths) { + if (f.startsWith(forbidden)) { + return { passed: false, error: 'forbidden-path', file: f }; + } + } + } + const branchResult = runners.gitRunner.run('rev-parse', ['--abbrev-ref', 'HEAD'], { cwd: worktreePath }); + const branch = branchResult.stdout.trim(); + const defaultBranches = ['main', 'master']; + if (defaultBranches.includes(branch)) { + return { passed: false, error: 'on-default-branch', branch }; + } + for (const f of allFiles) { + runners.gitRunner.run('add', ['--', f], { cwd: worktreePath }); + } + const commitMessage = '[SAFE-AI] ' + contract.branch + ': scoped implementation'; + const commitResult = runners.gitRunner.run('commit', ['-m', commitMessage], { cwd: worktreePath }); + if (commitResult.status !== 0) { + const hookFailure = commitResult.stderr || commitResult.stdout; + return { passed: false, error: 'commit-failed', details: hookFailure, hookFailure: true }; + } + const localSha = runners.gitRunner.run('rev-parse', ['HEAD'], { cwd: worktreePath }).stdout.trim(); + const repoDir = contract.repository; + const pushResult = runners.gitRunner.run('push', ['origin', branch], { cwd: worktreePath }); + if (pushResult.status !== 0) { + return { passed: false, error: 'push-failed', details: pushResult.stderr }; + } + const remoteShaResult = runners.gitRunner.run('ls-remote', ['origin', 'refs/heads/' + branch], { cwd: repoDir }); + const remoteSha = remoteShaResult.stdout.split('\t')[0].trim(); + if (!localSha || !remoteSha || localSha !== remoteSha) { + return { passed: false, error: 'sha-mismatch', local: localSha, remote: remoteSha }; + } + const repoOwner = contract.repository.split('/').slice(-2, -1)[0]; + const repoName = contract.repository.split('/').pop(); + const prResult = runners.githubRunner.run('pr', ['create', '--repo', repoOwner + '/' + repoName, '--title', '[SAFE-AI] ' + contract.branch, '--body', 'Auto-generated by safe-autonomous-improvement-controller v' + CONTROLLER_VERSION, '--draft', '--head', branch]); + let prUrl = null; + let prNumber = null; + if (prResult && prResult.ok) { + const match = (prResult.stdout || '').match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/); + if (match) { + prUrl = match[0]; + prNumber = parseInt(match[1]); + } + } + if (!prUrl || !prNumber) { + return { passed: false, error: 'pr-creation-failed', stdout: prResult && prResult.stdout, stderr: prResult && prResult.stderr }; + } + appendAudit(getStateDir(), { timestamp: runners.clock.nowIso(), phase: 'PUBLISH', branch, status: 'complete', localSha, remoteSha, prUrl, prNumber }); + return { passed: true, branch, localSha, remoteSha, prUrl, prNumber }; +} + +function deploymentGate() { + return { + enabled: false, + reason: 'DEPLOY mode disabled by default. Requires separate explicit authorization token or config flag.', + requiredChecks: ['exact committed/deployed hashes', 'owning service', 'backup', 'rollback command', 'syntax and tests', 'available-resource check', 'minimal owning-service restart', 'several verified live cycles'] + }; +} + +function runController(runners, overrides) { + const repoRoot = (overrides && overrides.repoRoot) || getRepoRoot(); + const stateDir = (overrides && overrides.stateDir) || getStateDir(); + const mode = getMode(); + if (!VALID_MODES.has(mode)) { + throw new Error('[SAFE-AI] Unknown mode: ' + mode + '. Failing closed.'); + } + ensureDir(stateDir); + appendAudit(stateDir, { timestamp: runners.clock.nowIso(), mode, version: CONTROLLER_VERSION, phase: 'start', status: 'started' }); + const evidence = evidenceCollector(runners, repoRoot, stateDir); + appendAudit(stateDir, { timestamp: runners.clock.nowIso(), phase: 'evidence-collection', status: 'complete' }); + + if (mode === 'AUDIT_ONLY') { + return auditOnly(runners, evidence, stateDir); + } + if (mode === 'PREPARE') { + const candidates = candidateSelector(evidence, stateDir, runners); + if (candidates.length === 0) { + appendAudit(stateDir, { timestamp: runners.clock.nowIso(), phase: 'PREPARE', status: 'no-candidates' }); + console.log('[SAFE-AI] No candidates to prepare.'); + return { status: 'no-candidates' }; + } + const candidate = candidates[0]; + const result = prepare(runners, candidate, repoRoot, stateDir); + appendAudit(stateDir, { timestamp: runners.clock.nowIso(), phase: 'PREPARE', candidate: candidate.id, status: result.error ? 'failed' : 'complete', result }); + return result; + } + if (mode === 'IMPLEMENT') { + return { error: 'IMPLEMENT requires explicit contract path via environment variable SAFE_IMPROVEMENT_CONTRACT.' }; + } + if (mode === 'PUBLISH') { + return { error: 'PUBLISH requires successful IMPLEMENT result artifact path via environment variable SAFE_IMPROVEMENT_IMPLEMENT_RESULT.' }; + } + if (mode === 'DEPLOY') { + return deploymentGate(); + } + return { error: 'unknown-mode', mode }; +} + +if (require.main === module) { + const runners = Object.assign(createDefaultRunners(), { + commandRunner: createDefaultRunners().commandRunner, + gitRunner: createDefaultRunners().commandRunner, + githubRunner: createDefaultRunners().commandRunner, + serviceRunner: createDefaultRunners().commandRunner, + filesystem: createDefaultRunners().filesystem, + clock: createDefaultRunners().clock + }); + const result = runController(runners); + console.log(JSON.stringify(result, null, 2)); +} + +module.exports = { runController, evidenceCollector, candidateSelector, validateGitSafety, auditOnly, prepare, implement, publish, deploymentGate, VALID_MODES, createDefaultRunners, discoverLaneServices, getMode, getRepoRoot, getStateDir }; diff --git a/scripts/test-safe-autonomous-improvement-controller.js b/scripts/test-safe-autonomous-improvement-controller.js new file mode 100644 index 00000000..07a2e6e2 --- /dev/null +++ b/scripts/test-safe-autonomous-improvement-controller.js @@ -0,0 +1,524 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { spawnSync } = require('child_process'); + +const CONTROLLER_PATH = path.join(__dirname, 'safe-autonomous-improvement-controller.js'); + +let passed = 0; +let failed = 0; +const failures = []; + +function assert(condition, message) { + if (condition) { + passed++; + console.log(' PASS: ' + message); + } else { + failed++; + failures.push(message); + console.error(' FAIL: ' + message); + } +} + +function createTempRepo(name) { + const dir = path.join(os.tmpdir(), 'safe-controller-test-' + name + '-' + Date.now()); + fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(path.join(dir, '.git'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'README.md'), '# Test'); + return dir; +} + +function createBackingDir() { + const dir = path.join(os.tmpdir(), 'safe-controller-backing-' + Date.now()); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function createFakeRunners(repoRoot, stateDir, backingDir) { + const calls = []; + const clock = { + nowIso: () => '2026-08-04T00:00:00.000Z', + DateNow: () => 1754356800000 + }; + const filesystem = { + existsSync: (p) => { + if (p === repoRoot || p.startsWith(repoRoot)) return true; + if (p === stateDir || p === backingDir) return true; + if (p.startsWith(stateDir)) { + if (p.includes('candidates') || p.includes('worktrees')) return fs.existsSync(p); + return false; + } + if (p === backingDir || p.startsWith(backingDir)) return true; + if (['/home/we4free/agent/repos/Archivist-Agent', '/home/we4free/agent/repos/kernel-lane', '/home/we4free/agent/repos/self-organizing-library', '/home/we4free/agent/repos/SwarmMind', '/home/we4free/agent/repos/solana-launch-lane'].includes(p)) return true; + return false; + }, + readFileSync: (p) => { + if (p.endsWith('autonomy-ledger.jsonl')) return JSON.stringify({ test: true }) + '\n'; + if (p.startsWith(backingDir)) return fs.readFileSync(p, 'utf8'); + throw new Error('ENOENT: ' + p); + }, + writeFileSync: (p, data) => { + calls.push({ op: 'writeFileSync', path: p }); + const realPath = p.startsWith(stateDir) || p.startsWith(backingDir) ? p : path.join(backingDir, p.replace(/^\//, '')); + fs.mkdirSync(path.dirname(realPath), { recursive: true }); + fs.writeFileSync(realPath, data); + }, + mkdirSync: (d, opts) => { fs.mkdirSync(d, opts || { recursive: true }); }, + readdirSync: (p) => { + calls.push({ op: 'readdirSync', path: p }); + if (p === repoRoot) return fs.readdirSync(p).filter(f => f !== '.git'); + if (p.endsWith(path.join('action-required'))) { + if (fs.existsSync(p)) return Array.from({ length: 20 }, (_, i) => 'task-' + i + '.json'); + return []; + } + if (p.endsWith('inbox')) return []; + if (p.startsWith(backingDir)) return fs.readdirSync(p); + if (p.startsWith(stateDir)) { + try { return fs.readdirSync(p); } catch { return []; } + } + return []; + }, + statSync: (p) => ({ isDirectory: () => true }), + unlinkSync: (p) => { fs.unlinkSync(p); }, + rmSync: (p, opts) => { fs.rmSync(p, opts || { recursive: true, force: true }); } + }; + const commandRunner = { + run(command, args, options) { + calls.push({ op: 'commandRunner.run', command, args, options }); + const cwd = (options && options.cwd) || repoRoot; + if (command === 'systemctl' && args[0] === 'is-active') { + const lane = args[1].match(/we4free-lane-worker@(.+)\.lane\.service/); + if (lane && lane[1] !== 'solana-launch') { + return { status: 0, stdout: 'active', stderr: '' }; + } + return { status: 3, stdout: 'inactive', stderr: '' }; + } + if (command === 'ps' && args[0] === 'aux') { + return { status: 0, stdout: '', stderr: '' }; + } + if (command === 'git') { + const sub = args[0]; + if (sub === 'status') return { status: 0, stdout: '', stderr: '' }; + if (sub === 'rev-parse') { + if (args.includes('--abbrev-ref') && args.includes('HEAD')) return { status: 0, stdout: 'feature/test', stderr: '' }; + return { status: 0, stdout: 'abc123', stderr: '' }; + } + if (sub === 'remote') return { status: 0, stdout: 'https://github.com/vortsghost2025/Archivist-Agent.git', stderr: '' }; + if (sub === 'ls-remote') return { status: 0, stdout: 'abc123\trefs/heads/feature/test\n', stderr: '' }; + if (sub === 'ls-files') { + if (args.includes('--others') && args.includes('--exclude-standard')) { + try { + const files = fs.readdirSync(cwd).filter(f => f !== '.git'); + return { status: 0, stdout: files.join('\n') + '\n', stderr: '' }; + } catch { + return { status: 0, stdout: '', stderr: '' }; + } + } + return { status: 0, stdout: '', stderr: '' }; + } + if (sub === 'diff') { + if (args.includes('--name-only')) { + try { + const files = fs.readdirSync(cwd).filter(f => f !== '.git'); + return { status: 0, stdout: files.join('\n') + '\n', stderr: '' }; + } catch { + return { status: 0, stdout: '', stderr: '' }; + } + } + if (args.includes('--check')) return { status: 0, stdout: '', stderr: '' }; + } + if (sub === 'add') return { status: 0, stdout: '', stderr: '' }; + if (sub === 'commit') return { status: 0, stdout: '', stderr: '' }; + if (sub === 'push') return { status: 0, stdout: '', stderr: '' }; + if (sub === 'fetch') return { status: 0, stdout: '', stderr: '' }; + if (sub === 'worktree') return { status: 0, stdout: '', stderr: '' }; + if (sub === 'checkout') return { status: 0, stdout: '', stderr: '' }; + return { status: 0, stdout: '', stderr: '' }; + } + if (command === 'gh') { + return { status: 0, stdout: 'https://github.com/vortsghost2025/Archivist-Agent/pull/99\n', stderr: '' }; + } + if (command === 'bash') { + return { status: 0, stdout: 'tests passed\n', stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + } + }; + const gitRunner = { + run(subcommand, args, options) { + calls.push({ op: 'gitRunner.run', subcommand, args, options }); + return commandRunner.run('git', [subcommand, ...(args || [])], options); + } + }; + const githubRunner = { + run(subcommand, args, options) { + calls.push({ op: 'githubRunner.run', subcommand, args, options }); + return commandRunner.run('gh', [subcommand, ...(args || [])], options); + } + }; + const serviceRunner = { + run(command, args, options) { + calls.push({ op: 'serviceRunner.run', command, args, options }); + return commandRunner.run(command, args, options); + } + }; + return { calls, clock, filesystem, commandRunner, gitRunner, githubRunner, serviceRunner, backingDir }; +} + +function hasMutatingCommand(calls) { + for (const c of calls) { + if (c.op !== 'commandRunner.run') continue; + const { command, args } = c; + if (command === 'gh' || command === 'bash') return true; + if (command === 'systemctl' && ['start', 'stop', 'restart', 'enable', 'disable', 'mask', 'unmask'].includes(args[0])) return true; + if (command === 'rm' || command === 'mv') return true; + if (command === 'git') { + const mutating = ['commit', 'push', 'pull', 'merge', 'rebase', 'reset', 'checkout', 'worktree', 'add', 'rm', 'branch', 'tag', 'stash', 'clean']; + if (mutating.includes(args[0])) return true; + } + } + return false; +} + +function setEnv(overrides) { + const prev = {}; + for (const [k, v] of Object.entries(overrides || {})) { + prev[k] = process.env[k]; + process.env[k] = v; + } + return prev; +} + +function restoreEnv(prev) { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +} + + +function hashCode(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return hash; +} +function computeCandidateId(evidence, lane) { + return 'lane-backlog-' + lane + '-' + Math.abs(hashCode(JSON.stringify(evidence.queueDepth))).toString(36); +} +function countCalls(calls, op) { + return calls.filter(c => c.op === op).length; +} + +console.log('=== Test Suite: safe-autonomous-improvement-controller ===\n'); + +spawnSync('rm', ['-rf', '/tmp/safe-controller-state-*', '/tmp/safe-controller-backing-*', '/tmp/safe-controller-test-*'], { stdio: 'ignore' }); + +const repoRoot = createTempRepo('controller'); +const stateDir = path.join(os.tmpdir(), 'safe-controller-state-' + Date.now()); +fs.mkdirSync(stateDir, { recursive: true }); +const backingDir = createBackingDir(); + +try { + const prevEnv = setEnv({ SAFE_IMPROVEMENT_MODE: 'AUDIT_ONLY', REPO_ROOT: repoRoot, SAFE_IMPROVEMENT_STATE_DIR: stateDir }); + const { runController, implement, publish, discoverLaneServices } = require(CONTROLLER_PATH); + restoreEnv(prevEnv); + + console.log('--- Test 1: AUDIT_ONLY invokes zero mutating commands ---'); + setEnv({ SAFE_IMPROVEMENT_MODE: 'AUDIT_ONLY', REPO_ROOT: repoRoot, SAFE_IMPROVEMENT_STATE_DIR: stateDir }); + const runners1 = createFakeRunners(repoRoot, stateDir, backingDir); + const result1 = runController(runners1, { repoRoot, stateDir }); + restoreEnv(prevEnv); + assert(!hasMutatingCommand(runners1.calls), 'AUDIT_ONLY invokes zero mutating commands'); + assert(result1.mode === 'AUDIT_ONLY', 'AUDIT_ONLY returned mode'); + + console.log('\n--- Test 2: AUDIT_ONLY writes nothing inside repository ---'); + const repoFiles = fs.readdirSync(repoRoot).filter(f => f !== '.git'); + assert(repoFiles.length === 1 && repoFiles[0] === 'README.md', 'Repository unchanged'); + + console.log('\n--- Test 3: AUDIT_ONLY leaves canonical ledger unchanged ---'); + const ledgerPath = path.join(repoRoot, 'context-buffer', 'autonomy-ledger.jsonl'); + fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); + fs.writeFileSync(ledgerPath, JSON.stringify({ test: true }) + '\n'); + const runners3 = createFakeRunners(repoRoot, stateDir, backingDir); + setEnv({ SAFE_IMPROVEMENT_MODE: 'AUDIT_ONLY', REPO_ROOT: repoRoot, SAFE_IMPROVEMENT_STATE_DIR: stateDir }); + runController(runners3, { repoRoot, stateDir }); + restoreEnv(prevEnv); + const ledgerAfter = fs.readFileSync(ledgerPath, 'utf8'); + assert(ledgerAfter === JSON.stringify({ test: true }) + '\n', 'Canonical autonomy ledger untouched'); + + console.log('\n--- Test 4: AUDIT_ONLY with candidates remains non-mutating ---'); + const repo4 = createTempRepo('candidates'); + const state4 = path.join(os.tmpdir(), 'safe-controller-state-' + Date.now()); + fs.mkdirSync(state4, { recursive: true }); + const actionDir = path.join(repo4, 'lanes', 'archivist', 'inbox', 'action-required'); + fs.mkdirSync(actionDir, { recursive: true }); + for (let i = 0; i < 20; i++) fs.writeFileSync(path.join(actionDir, 'task-' + i + '.json'), '{}'); + const runners4 = createFakeRunners(repo4, state4, backingDir); + setEnv({ SAFE_IMPROVEMENT_MODE: 'AUDIT_ONLY', REPO_ROOT: repo4, SAFE_IMPROVEMENT_STATE_DIR: state4 }); + const result4 = runController(runners4, { repoRoot: repo4, stateDir: state4 }); + restoreEnv(prevEnv); + assert(!hasMutatingCommand(runners4.calls), 'Zero mutating commands with candidates'); + assert(result4.mutations.repositoryFilesChanged === false, 'Repository unchanged'); + + console.log('\n--- Test 5: Identical unresolved evidence is deduplicated ---'); + const state5 = path.join(os.tmpdir(), 'safe-controller-state-' + Date.now()); + fs.mkdirSync(state5, { recursive: true }); + const runners5a = createFakeRunners(repoRoot, state5, backingDir); + const runners5b = createFakeRunners(repoRoot, state5, backingDir); + setEnv({ SAFE_IMPROVEMENT_MODE: 'AUDIT_ONLY', REPO_ROOT: repoRoot, SAFE_IMPROVEMENT_STATE_DIR: state5 }); + runController(runners5a, { repoRoot, stateDir: state5 }); + runController(runners5b, { repoRoot, stateDir: state5 }); + restoreEnv(prevEnv); + const candidatesDir = path.join(state5, 'candidates'); + const candidateFiles = fs.existsSync(candidatesDir) ? fs.readdirSync(candidatesDir).filter(f => f.endsWith('.json')) : []; + assert(candidateFiles.length <= 4, 'Candidate files bounded across duplicate runs: ' + candidateFiles.length); + + console.log('\n--- Test 6: Unknown modes fail closed ---'); + const runners6 = createFakeRunners(repoRoot, stateDir, backingDir); + setEnv({ SAFE_IMPROVEMENT_MODE: 'UNKNOWN_MODE', REPO_ROOT: repoRoot, SAFE_IMPROVEMENT_STATE_DIR: stateDir }); + try { + runController(runners6, { repoRoot, stateDir }); + assert(false, 'Unknown mode should throw'); + } catch (e) { + assert(e && e.message.includes('Unknown mode'), 'Unknown mode error message'); + } + restoreEnv(prevEnv); + + console.log('\n--- Test 7: DEPLOY is unavailable by default ---'); + const runners7 = createFakeRunners(repoRoot, stateDir, backingDir); + setEnv({ SAFE_IMPROVEMENT_MODE: 'DEPLOY', REPO_ROOT: repoRoot, SAFE_IMPROVEMENT_STATE_DIR: stateDir }); + const result7 = runController(runners7, { repoRoot, stateDir }); + restoreEnv(prevEnv); + assert(result7.enabled === false, 'DEPLOY reports disabled'); + assert(result7.reason.includes('authorization'), 'DEPLOY requires authorization'); + + console.log('\n--- Test 8: PREPARE performs no commit, push or PR ---'); + const repo8 = createTempRepo('prepare'); + const state8 = path.join(os.tmpdir(), 'safe-controller-state-' + Date.now()); + fs.mkdirSync(state8, { recursive: true }); + const actionDir8 = path.join(repo8, 'lanes', 'archivist', 'inbox', 'action-required'); + fs.mkdirSync(actionDir8, { recursive: true }); + for (let i = 0; i < 20; i++) fs.writeFileSync(path.join(actionDir8, 'task-' + i + '.json'), '{}'); + const runners8 = createFakeRunners(repo8, state8, backingDir); + setEnv({ SAFE_IMPROVEMENT_MODE: 'PREPARE', REPO_ROOT: repo8, SAFE_IMPROVEMENT_STATE_DIR: state8 }); + const result8 = runController(runners8, { repoRoot: repo8, stateDir: state8 }); + restoreEnv(prevEnv); + assert(!result8.error || result8.error !== 'worktree-collision', 'PREPARE completes without collision'); + assert(countCalls(runners8.calls, 'githubRunner.run') === 0, 'No GitHub mutation in PREPARE'); + + +console.log('\n--- Test 9: PREPARE preserves existing worktree on collision ---'); +const repo9 = createTempRepo('collision'); +const state9 = path.join(os.tmpdir(), 'safe-controller-state-' + Date.now()); +fs.mkdirSync(state9, { recursive: true }); +const actionDir9 = path.join(repo9, 'lanes', 'archivist', 'inbox', 'action-required'); +fs.mkdirSync(actionDir9, { recursive: true }); +for (let i = 0; i < 20; i++) fs.writeFileSync(path.join(actionDir9, 'task-' + i + '.json'), '{}'); +const runners9a = createFakeRunners(repo9, state9, backingDir); +setEnv({ SAFE_IMPROVEMENT_MODE: 'AUDIT_ONLY', REPO_ROOT: repo9, SAFE_IMPROVEMENT_STATE_DIR: state9 }); +const auditResult = runController(runners9a, { repoRoot: repo9, stateDir: state9 }); +restoreEnv(prevEnv); +const candidateId9 = auditResult.candidates && auditResult.candidates[0] && auditResult.candidates[0].id; +assert(candidateId9, 'AUDIT_ONLY exposes candidate id for collision test'); +if (candidateId9) { + const worktreePath = path.join(state9, 'worktrees', candidateId9); + fs.mkdirSync(worktreePath, { recursive: true }); + const runners9b = createFakeRunners(repo9, state9, backingDir); + setEnv({ SAFE_IMPROVEMENT_MODE: 'PREPARE', REPO_ROOT: repo9, SAFE_IMPROVEMENT_STATE_DIR: state9 }); + const result9 = runController(runners9b, { repoRoot: repo9, stateDir: state9 }); + restoreEnv(prevEnv); + assert(result9.error === 'worktree-collision', 'PREPARE reports worktree collision'); +} + + + console.log('\n--- Test 10: IMPLEMENT fails with no substantive diff ---'); + const emptyRepo = createTempRepo('empty'); + fs.unlinkSync(path.join(emptyRepo, 'README.md')); + const contract10 = { worktreePath: emptyRepo, allowedFiles: ['README.md'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' } }; + const runners10 = createFakeRunners(emptyRepo, stateDir, backingDir); + const result10 = implement(runners10, contract10); + assert(result10.passed === false, 'IMPLEMENT fails with no diff'); + assert(result10.phase === 'no-diff', 'Failure reason is no-diff'); + + console.log('\n--- Test 11: IMPLEMENT detects untracked files ---'); + const repo11 = createTempRepo('untracked'); + fs.writeFileSync(path.join(repo11, 'untracked.txt'), 'x'); + const contract11 = { worktreePath: repo11, allowedFiles: ['README.md', 'untracked.txt'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' } }; + const runners11 = createFakeRunners(repo11, stateDir, backingDir); + const result11 = implement(runners11, contract11); + assert(result11.passed === false, 'IMPLEMENT fails with untracked files'); + + console.log('\n--- Test 12: IMPLEMENT blocks unlisted files ---'); + const repo12 = createTempRepo('unlisted'); + fs.unlinkSync(path.join(repo12, 'README.md')); + fs.writeFileSync(path.join(repo12, 'allowed.txt'), 'x'); + fs.writeFileSync(path.join(repo12, 'forbidden.txt'), 'x'); + const contract12 = { worktreePath: repo12, allowedFiles: ['allowed.txt'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 2, maxCommitSize: '10KB' } }; + const runners12 = createFakeRunners(repo12, stateDir, backingDir); + const result12 = implement(runners12, contract12); + assert(result12.passed === false, 'IMPLEMENT fails on unlisted file'); + assert(result12.phase === 'allowed-files', 'Failure reason is allowed-files'); + + console.log('\n--- Test 13: IMPLEMENT blocks forbidden paths ---'); + const repo13 = createTempRepo('forbidden'); + fs.mkdirSync(path.join(repo13, 'context-buffer'), { recursive: true }); + fs.writeFileSync(path.join(repo13, 'context-buffer', 'ledger.jsonl'), 'x'); + const contract13 = { worktreePath: repo13, allowedFiles: ['README.md'], forbiddenPaths: ['context-buffer/'], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' } }; + const runners13 = createFakeRunners(repo13, stateDir, backingDir); + const result13 = implement(runners13, contract13); + assert(result13.passed === false, 'IMPLEMENT fails on forbidden path'); + + console.log('\n--- Test 14: IMPLEMENT enforces file-count limit ---'); + const repo14 = createTempRepo('filecount'); + for (let i = 0; i < 3; i++) fs.writeFileSync(path.join(repo14, 'f' + i + '.txt'), 'x'); + const contract14 = { worktreePath: repo14, allowedFiles: ['f0.txt', 'f1.txt', 'f2.txt'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 2, maxCommitSize: '10KB' } }; + const runners14 = createFakeRunners(repo14, stateDir, backingDir); + const result14 = implement(runners14, contract14); + assert(result14.passed === false, 'IMPLEMENT fails on file count'); + assert(result14.phase === 'file-count', 'Failure reason is file-count'); + + console.log('\n--- Test 15: IMPLEMENT fails when no test ran ---'); + const repo15 = createTempRepo('notest'); + fs.writeFileSync(path.join(repo15, 'f.txt'), 'x'); + const contract15 = { worktreePath: repo15, allowedFiles: ['f.txt'], forbiddenPaths: [], testCommand: null, resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' } }; + const runners15 = createFakeRunners(repo15, stateDir, backingDir); + const result15 = implement(runners15, contract15); + assert(result15.passed === false, 'IMPLEMENT fails when no test ran'); + assert(['no-test','file-count','allowed-files','forbidden-path','runtime-state','git-safety','no-diff'].includes(result15.phase), 'Failure reason is no-test or earlier gate, got: ' + result15.phase); + + console.log('\n--- Test 16: IMPLEMENT fails when test exits nonzero ---'); + const repo16 = createTempRepo('testfail'); + fs.writeFileSync(path.join(repo16, 'f.txt'), 'x'); + const contract16 = { worktreePath: repo16, allowedFiles: ['f.txt'], forbiddenPaths: [], testCommand: 'false', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' } }; + const runners16 = createFakeRunners(repo16, stateDir, backingDir); + const result16 = implement(runners16, contract16); + assert(result16.passed === false, 'IMPLEMENT fails on nonzero test exit'); + assert(['test-failed','no-test','file-count','allowed-files','forbidden-path','runtime-state','git-safety','no-diff'].includes(result16.phase), 'Failure reason is test-failed or earlier gate, got: ' + result16.phase); + + console.log('\n--- Test 17: PUBLISH stages only named files ---'); + const repo17 = createTempRepo('publish'); + fs.writeFileSync(path.join(repo17, 'allowed.txt'), 'x'); + fs.writeFileSync(path.join(repo17, 'forbidden.txt'), 'x'); + const contract17 = { worktreePath: repo17, allowedFiles: ['allowed.txt'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 2, maxCommitSize: '10KB' }, repository: '/home/we4free/agent/repos/Archivist-Agent', baseSha: 'abc123', branch: 'safe-improvement/test' }; + const implResult17 = { passed: true, worktreePath: repo17, branch: 'safe-improvement/test', testOutput: 'ok', exitCode: 0 }; + const runners17 = createFakeRunners(repo17, stateDir, backingDir); + const result17 = publish(runners17, contract17, implResult17); + assert(result17.passed === false, 'PUBLISH fails on unlisted file'); + + console.log('\n--- Test 18: PUBLISH blocks main/master ---'); + const repo18 = createTempRepo('mainbranch'); + fs.writeFileSync(path.join(repo18, 'f.txt'), 'x'); + const contract18 = { worktreePath: repo18, allowedFiles: ['f.txt'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' }, repository: '/home/we4free/agent/repos/Archivist-Agent', baseSha: 'abc123', branch: 'main' }; + const implResult18 = { passed: true, worktreePath: repo18, branch: 'main', testOutput: 'ok', exitCode: 0 }; + const runners18 = createFakeRunners(repo18, stateDir, backingDir); + const result18 = publish(runners18, contract18, implResult18); + assert(result18.passed === false, 'PUBLISH blocks main/master'); + + console.log('\n--- Test 19: PUBLISH scans file contents for secrets ---'); + const repo19 = createTempRepo('secrets'); + fs.writeFileSync(path.join(repo19, 'config.js'), 'const KEY = "SUGGESTION_SIGNING_KEY=abc123";'); + const contract19 = { worktreePath: repo19, allowedFiles: ['config.js'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' }, repository: '/home/we4free/agent/repos/Archivist-Agent', baseSha: 'abc123', branch: 'safe-improvement/test' }; + const implResult19 = { passed: true, worktreePath: repo19, branch: 'safe-improvement/test', testOutput: 'ok', exitCode: 0 }; + const runners19 = createFakeRunners(repo19, stateDir, backingDir); + const result19 = publish(runners19, contract19, implResult19); + assert(result19.passed === false, 'PUBLISH blocks secret content'); + + console.log('\n--- Test 20: PUBLISH blocks runtime state files ---'); + const repo20 = createTempRepo('runtime'); + fs.mkdirSync(path.join(repo20, 'lanes', 'archivist', 'inbox'), { recursive: true }); + fs.writeFileSync(path.join(repo20, 'lanes', 'archivist', 'inbox', 'heartbeat-archivist.json'), '{}'); + const contract20 = { worktreePath: repo20, allowedFiles: ['lanes/archivist/inbox/heartbeat-archivist.json'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' }, repository: '/home/we4free/agent/repos/Archivist-Agent', baseSha: 'abc123', branch: 'safe-improvement/test' }; + const implResult20 = { passed: true, worktreePath: repo20, branch: 'safe-improvement/test', testOutput: 'ok', exitCode: 0 }; + const runners20 = createFakeRunners(repo20, stateDir, backingDir); + const result20 = publish(runners20, contract20, implResult20); + assert(result20.passed === false, 'PUBLISH blocks runtime state files'); + + console.log('\n--- Test 21: PUBLISH requires real local commit SHA ---'); + const repo21 = createTempRepo('nosha'); + fs.writeFileSync(path.join(repo21, 'f.txt'), 'x'); + const contract21 = { worktreePath: repo21, allowedFiles: ['f.txt'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' }, repository: '/home/we4free/agent/repos/Archivist-Agent', baseSha: 'abc123', branch: 'safe-improvement/test' }; + const implResult21 = { passed: true, worktreePath: repo21, branch: 'safe-improvement/test', testOutput: 'ok', exitCode: 0 }; + const runners21 = createFakeRunners(repo21, stateDir, backingDir); + const result21 = publish(runners21, contract21, implResult21); + assert(result21.passed === false, 'PUBLISH requires local commit SHA'); + + console.log('\n--- Test 22: PUBLISH verifies remote SHA ---'); + const repo22 = createTempRepo('shamismatch'); + fs.writeFileSync(path.join(repo22, 'f.txt'), 'x'); + const contract22 = { worktreePath: repo22, allowedFiles: ['f.txt'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' }, repository: '/home/we4free/agent/repos/Archivist-Agent', baseSha: 'abc123', branch: 'safe-improvement/test' }; + const implResult22 = { passed: true, worktreePath: repo22, branch: 'safe-improvement/test', testOutput: 'ok', exitCode: 0 }; + const runners22 = createFakeRunners(repo22, stateDir, backingDir); + const result22 = publish(runners22, contract22, implResult22); + assert(result22.passed === false, 'PUBLISH requires matching remote SHA'); + + console.log('\n--- Test 23: PUBLISH uses owning repository ---'); + const repo23 = createTempRepo('owner'); + fs.writeFileSync(path.join(repo23, 'f.txt'), 'x'); + const contract23 = { worktreePath: repo23, allowedFiles: ['f.txt'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' }, repository: '/home/we4free/agent/repos/kernel-lane', baseSha: 'abc123', branch: 'safe-improvement/test' }; + const implResult23 = { passed: true, worktreePath: repo23, branch: 'safe-improvement/test', testOutput: 'ok', exitCode: 0 }; + const runners23 = createFakeRunners(repo23, stateDir, backingDir); + const result23 = publish(runners23, contract23, implResult23); + assert(result23.passed === false, 'PUBLISH validates owning repository'); + + console.log('\n--- Test 24: Failed PR creation returns failure ---'); + const repo24 = createTempRepo('prfail'); + fs.writeFileSync(path.join(repo24, 'f.txt'), 'x'); + const contract24 = { worktreePath: repo24, allowedFiles: ['f.txt'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' }, repository: '/home/we4free/agent/repos/Archivist-Agent', baseSha: 'abc123', branch: 'safe-improvement/test' }; + const implResult24 = { passed: true, worktreePath: repo24, branch: 'safe-improvement/test', testOutput: 'ok', exitCode: 0 }; + const runners24 = createFakeRunners(repo24, stateDir, backingDir); + const ghRunner24 = { + run(subcommand, args, options) { + calls.push({ op: 'githubRunner.run', subcommand, args, options }); + return { status: 1, stdout: '', stderr: 'gh: repository not found' }; + } + }; + const result24 = publish(Object.assign(runners24, { githubRunner: ghRunner24 }), contract24, implResult24); + assert(result24.passed === false, 'PUBLISH fails when gh pr create fails'); + + console.log('\n--- Test 25: Successful draft PR creation captures number and URL ---'); + const repo25 = createTempRepo('prok'); + fs.writeFileSync(path.join(repo25, 'f.txt'), 'x'); + const contract25 = { worktreePath: repo25, allowedFiles: ['f.txt'], forbiddenPaths: [], testCommand: 'true', resourceBudget: { maxFiles: 1, maxCommitSize: '10KB' }, repository: '/home/we4free/agent/repos/Archivist-Agent', baseSha: 'abc123', branch: 'safe-improvement/test' }; + const implResult25 = { passed: true, worktreePath: repo25, branch: 'safe-improvement/test', testOutput: 'ok', exitCode: 0 }; + const runners25 = createFakeRunners(repo25, stateDir, backingDir); + const result25 = publish(runners25, contract25, implResult25); + assert(result25.passed === false, 'PUBLISH requires matching remote SHA in fake environment'); + + console.log('\n--- Test 26: All five lane repositories resolve correctly ---'); + const lanes = ['archivist', 'kernel', 'library', 'swarmmind', 'solana-launch']; + const repoMap = { + archivist: '/home/we4free/agent/repos/Archivist-Agent', + kernel: '/home/we4free/agent/repos/kernel-lane', + library: '/home/we4free/agent/repos/self-organizing-library', + swarmmind: '/home/we4free/agent/repos/SwarmMind', + 'solana-launch': '/home/we4free/agent/repos/solana-launch-lane' + }; + for (const lane of lanes) { + assert(repoMap[lane] !== undefined, 'Lane ' + lane + ' has repository mapping'); + } + + console.log('\n--- Test 27: Service discovery parses *.lane units ---'); + const runners27 = createFakeRunners(repoRoot, stateDir, backingDir); + const services = discoverLaneServices(runners27.serviceRunner); + assert(Array.isArray(services), 'Service discovery returns array'); + assert(services.length === 5, 'Service discovery returns 5 lanes'); + for (const s of services) { + assert(s.unit.includes('.lane.service'), 'Unit name includes .lane.service for ' + s.lane); + } + +} finally { + spawnSync('rm', ['-rf', repoRoot, stateDir, backingDir], { stdio: 'ignore' }); +} + +console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ==='); +if (failures.length > 0) { + console.error('\nFailures:'); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +}