From 4d80aceedf8cf38ab051e2a3a6b49d0e13e52042 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:56:49 -0400 Subject: [PATCH 01/34] feat: configure phase 3 orchestration policy --- config/orchestration-policy.json | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 config/orchestration-policy.json diff --git a/config/orchestration-policy.json b/config/orchestration-policy.json new file mode 100644 index 0000000..73d68d9 --- /dev/null +++ b/config/orchestration-policy.json @@ -0,0 +1,59 @@ +{ + "$schema": "./orchestration-policy-schema.json", + "version": "1.0.0", + "mode": "risk", + "require_agent_loop_permit": true, + "require_policy_commit": true, + "permit_ttl_seconds": 900, + "state_path": ".opencode/agent-loop-state/policy.json", + "task_ttl_minutes": 1440, + "max_tracked_tasks": 1000, + "max_fix_cycles": 2, + "risk": { + "default_level": "medium", + "docs_only_level": "low", + "high_path_patterns": [ + "(^|/)(auth|authentication|authorization|security|permissions?|migrations?|deploy|deployment|infra|infrastructure)(/|$)", + "(^|/)(Dockerfile|docker-compose\\.(?:yml|yaml)|terraform|k8s|kubernetes)(/|$|\\.)" + ], + "critical_path_patterns": [ + "(^|/)(payments?|billing|production|prod|secrets?)(/|$)", + "(^|/)(delete|destroy|purge|wipe|drop)[^/]*\\.(?:sh|js|mjs|ts|py)$" + ], + "high_keyword_patterns": [ + "authentication|authorization|access control|database migration|schema migration|deployment|secret handling|encryption" + ], + "critical_keyword_patterns": [ + "payment|billing|production deployment|delete production|drop table|destructive migration|rotate secrets" + ], + "recovery_keyword_patterns": [ + "migration|deployment|delete|destroy|purge|wipe|drop table|payment|billing" + ] + }, + "gates": { + "low": { + "baseline": "optional", + "test": "validation", + "review": true + }, + "medium": { + "baseline": "required-or-justified-skip", + "test": "focused", + "review": true + }, + "high": { + "baseline": "required", + "test": "integration", + "review": true, + "recovery_plan_when_applicable": true + }, + "critical": { + "baseline": "required", + "test": "integration", + "review": true, + "recovery_plan": true, + "isolation": true, + "human_checkpoint": true + } + } +} From 74ff8274027e08a5e762fb0a23478d08702445f1 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:57:09 -0400 Subject: [PATCH 02/34] feat: add orchestration policy schema --- config/orchestration-policy-schema.json | 72 +++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 config/orchestration-policy-schema.json diff --git a/config/orchestration-policy-schema.json b/config/orchestration-policy-schema.json new file mode 100644 index 0000000..49d30e0 --- /dev/null +++ b/config/orchestration-policy-schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wryan2986/opencode-agent-loop/schemas/orchestration-policy-1.0.0.json", + "title": "OpenCode Agent Loop Orchestration Policy", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "mode", + "require_agent_loop_permit", + "require_policy_commit", + "permit_ttl_seconds", + "state_path", + "task_ttl_minutes", + "max_tracked_tasks", + "max_fix_cycles", + "risk", + "gates" + ], + "properties": { + "$schema": { "type": "string" }, + "version": { "const": "1.0.0" }, + "mode": { "enum": ["shadow", "invariants", "risk"] }, + "require_agent_loop_permit": { "type": "boolean" }, + "require_policy_commit": { "type": "boolean" }, + "permit_ttl_seconds": { "type": "integer", "minimum": 30, "maximum": 86400 }, + "state_path": { "type": "string", "minLength": 1 }, + "task_ttl_minutes": { "type": "integer", "minimum": 1 }, + "max_tracked_tasks": { "type": "integer", "minimum": 1 }, + "max_fix_cycles": { "type": "integer", "minimum": 0, "maximum": 20 }, + "risk": { + "type": "object", + "additionalProperties": false, + "required": [ + "default_level", + "docs_only_level", + "high_path_patterns", + "critical_path_patterns", + "high_keyword_patterns", + "critical_keyword_patterns", + "recovery_keyword_patterns" + ], + "properties": { + "default_level": { "$ref": "#/$defs/riskLevel" }, + "docs_only_level": { "$ref": "#/$defs/riskLevel" }, + "high_path_patterns": { "$ref": "#/$defs/patterns" }, + "critical_path_patterns": { "$ref": "#/$defs/patterns" }, + "high_keyword_patterns": { "$ref": "#/$defs/patterns" }, + "critical_keyword_patterns": { "$ref": "#/$defs/patterns" }, + "recovery_keyword_patterns": { "$ref": "#/$defs/patterns" } + } + }, + "gates": { + "type": "object", + "additionalProperties": false, + "required": ["low", "medium", "high", "critical"], + "properties": { + "low": { "type": "object" }, + "medium": { "type": "object" }, + "high": { "type": "object" }, + "critical": { "type": "object" } + } + } + }, + "$defs": { + "riskLevel": { "enum": ["low", "medium", "high", "critical"] }, + "patterns": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} From bdaa8773b7640895d85bfd27f2c94a2108c0f2ef Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:01:58 -0400 Subject: [PATCH 03/34] feat: add hybrid orchestration policy kernel --- lib/orchestration-policy.mjs | 945 +++++++++++++++++++++++++++++++++++ 1 file changed, 945 insertions(+) create mode 100644 lib/orchestration-policy.mjs diff --git a/lib/orchestration-policy.mjs b/lib/orchestration-policy.mjs new file mode 100644 index 0000000..917d185 --- /dev/null +++ b/lib/orchestration-policy.mjs @@ -0,0 +1,945 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { AgentLoopEventLogger, defaultEventLogPath, writeJsonAtomic } from './event-log.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = resolve(__dirname, '..'); +export const DEFAULT_POLICY_CONFIG_PATH = resolve(PACKAGE_ROOT, 'config/orchestration-policy.json'); + +const RISK_LEVELS = ['low', 'medium', 'high', 'critical']; +const ACTION_TO_MODE = Object.freeze({ + baseline: 'test', + smoke: 'smoke', + build: 'build', + test: 'test', + review: 'review', + fix: 'build', + escalate: 'escalate' +}); +const PERMITTED_ACTIONS = new Set([ + 'inspect', + 'request_approval', + 'record_approval', + 'record_evidence', + 'baseline', + 'skip_baseline', + 'smoke', + 'build', + 'test', + 'stage_candidate', + 'review', + 'fix', + 'escalate', + 'commit', + 'push', + 'ask_user', + 'replan', + 'stop' +]); +const TERMINAL_ALLOWED_ACTIONS = new Set(['record_evidence', 'ask_user', 'replan', 'stop']); +const EVIDENCE_TYPES = new Set([ + 'approval', + 'baseline', + 'baseline_skip', + 'smoke', + 'build', + 'test', + 'integration_test', + 'validation', + 'candidate', + 'review', + 'rollback_plan', + 'isolation', + 'human_checkpoint', + 'push_approval', + 'budget', + 'commit', + 'note' +]); + +function safeJson(path, fallback = {}) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + return fallback; + } +} + +function nowIso() { + return new Date().toISOString(); +} + +function toTimestamp(value) { + const parsed = Date.parse(value || ''); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeRisk(value, fallback = 'medium') { + const normalized = String(value || '').toLowerCase(); + return RISK_LEVELS.includes(normalized) ? normalized : fallback; +} + +function maxRisk(...values) { + return values + .map(value => normalizeRisk(value)) + .reduce((highest, value) => ( + RISK_LEVELS.indexOf(value) > RISK_LEVELS.indexOf(highest) ? value : highest + ), 'low'); +} + +function compilePatterns(values = []) { + return values.map(value => { + try { + return new RegExp(value, 'i'); + } catch { + return null; + } + }).filter(Boolean); +} + +function matchesAny(value, patterns) { + const text = String(value || ''); + return patterns.some(pattern => pattern.test(text)); +} + +function normalizePath(value) { + return String(value || '').replaceAll('\\', '/').replace(/^\.\/+/, ''); +} + +function docsOnly(paths = []) { + return paths.length > 0 && paths.every(path => { + const normalized = normalizePath(path).toLowerCase(); + return normalized.startsWith('docs/') + || normalized.endsWith('.md') + || normalized.endsWith('.mdx') + || normalized === 'readme' + || normalized.startsWith('readme.') + || normalized === 'changelog.md' + || normalized === 'license'; + }); +} + +function loadPolicyConfig(path = DEFAULT_POLICY_CONFIG_PATH) { + const config = safeJson(path, {}); + const mode = process.env.AGENT_LOOP_POLICY_MODE || config.mode || 'risk'; + return { + version: config.version || '1.0.0', + mode: ['shadow', 'invariants', 'risk'].includes(mode) ? mode : 'risk', + requireAgentLoopPermit: config.require_agent_loop_permit !== false, + requirePolicyCommit: config.require_policy_commit !== false, + permitTtlSeconds: Math.max(30, Number(config.permit_ttl_seconds) || 900), + statePath: config.state_path || '.opencode/agent-loop-state/policy.json', + taskTtlMinutes: Math.max(1, Number(config.task_ttl_minutes) || 1440), + maxTrackedTasks: Math.max(1, Number(config.max_tracked_tasks) || 1000), + maxFixCycles: Math.max(0, Number(config.max_fix_cycles) || 2), + risk: { + defaultLevel: normalizeRisk(config.risk?.default_level, 'medium'), + docsOnlyLevel: normalizeRisk(config.risk?.docs_only_level, 'low'), + highPathPatterns: compilePatterns(config.risk?.high_path_patterns), + criticalPathPatterns: compilePatterns(config.risk?.critical_path_patterns), + highKeywordPatterns: compilePatterns(config.risk?.high_keyword_patterns), + criticalKeywordPatterns: compilePatterns(config.risk?.critical_keyword_patterns), + recoveryKeywordPatterns: compilePatterns(config.risk?.recovery_keyword_patterns) + }, + raw: config + }; +} + +function statePath(cwd, config) { + return process.env.AGENT_LOOP_POLICY_STATE_PATH || resolve(cwd, config.statePath); +} + +function emptyStore() { + return { + schemaVersion: '1.0.0', + updatedAt: nowIso(), + tasks: {} + }; +} + +function emptyTask(taskId) { + const createdAt = nowIso(); + return { + taskId, + createdAt, + updatedAt: createdAt, + proposedRisk: null, + inferredRisk: null, + effectiveRisk: null, + riskReasons: [], + taskSummary: '', + plannedPaths: [], + terminalCode: null, + approval: null, + baseline: null, + candidate: null, + fixCycles: 0, + evidence: [], + actions: [], + permits: {}, + lastCommit: null + }; +} + +function pruneStore(store, config) { + const cutoff = Date.now() - config.taskTtlMinutes * 60_000; + const entries = Object.entries(store.tasks || {}) + .filter(([, task]) => toTimestamp(task.updatedAt) >= cutoff) + .sort((a, b) => toTimestamp(b[1].updatedAt) - toTimestamp(a[1].updatedAt)) + .slice(0, config.maxTrackedTasks); + store.tasks = Object.fromEntries(entries); + store.updatedAt = nowIso(); + return store; +} + +function truncate(value, max = 2000) { + const text = String(value || ''); + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +function normalizeEvidence(input, source = 'agent') { + if (!input || typeof input !== 'object') return null; + const type = String(input.type || '').toLowerCase(); + if (!EVIDENCE_TYPES.has(type)) return null; + const status = String(input.status || 'recorded').toLowerCase(); + return { + id: randomUUID(), + type, + status, + source, + ref: truncate(input.ref || '', 512), + candidateHash: input.candidateHash ? String(input.candidateHash) : null, + details: input.details && typeof input.details === 'object' ? input.details : {}, + recordedAt: nowIso() + }; +} + +function addEvidence(task, evidence, source = 'agent') { + const items = Array.isArray(evidence) ? evidence : []; + const added = []; + for (const raw of items) { + const item = normalizeEvidence(raw, source); + if (!item) continue; + task.evidence.push(item); + added.push(item); + if (item.type === 'approval') { + task.approval = { + status: item.status, + ref: item.ref, + recordedAt: item.recordedAt + }; + } + if (item.type === 'baseline' || item.type === 'baseline_skip') { + task.baseline = { + type: item.type, + status: item.status, + ref: item.ref, + details: item.details, + recordedAt: item.recordedAt + }; + } + if (item.type === 'budget' && item.status === 'exceeded') { + task.terminalCode = 'BUDGET_EXCEEDED'; + } + if (item.type === 'commit' && item.status === 'passed') { + task.lastCommit = { + hash: item.details?.commitHash || item.ref || null, + candidateHash: item.candidateHash || null, + recordedAt: item.recordedAt + }; + } + } + task.evidence = task.evidence.slice(-300); + return added; +} + +function hasEvidence(task, type, statuses, { candidateHash, source } = {}) { + const allowedStatuses = Array.isArray(statuses) ? statuses : [statuses]; + return task.evidence.some(item => ( + item.type === type + && (!allowedStatuses[0] || allowedStatuses.includes(item.status)) + && (!candidateHash || item.candidateHash === candidateHash) + && (!source || item.source === source) + )); +} + +function runGit(cwd, args, { allowFailure = false } = {}) { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + windowsHide: true, + maxBuffer: 16 * 1024 * 1024 + }); + if (result.error) { + if (allowFailure) return { ok: false, stdout: '', stderr: result.error.message, status: -1 }; + throw result.error; + } + if (result.status !== 0 && !allowFailure) { + const error = new Error((result.stderr || result.stdout || `git ${args.join(' ')} failed`).trim()); + error.code = 'GIT_COMMAND_FAILED'; + error.status = result.status; + throw error; + } + return { + ok: result.status === 0, + stdout: result.stdout || '', + stderr: result.stderr || '', + status: result.status + }; +} + +export function computeStagedCandidate(cwd = process.cwd()) { + const namesResult = runGit(cwd, ['diff', '--cached', '--name-only', '-z'], { allowFailure: true }); + if (!namesResult.ok) { + return { available: false, hash: null, files: [], reason: namesResult.stderr.trim() || 'Not a Git repository' }; + } + const files = namesResult.stdout.split('\0').filter(Boolean).map(normalizePath); + if (files.length === 0) { + return { available: true, hash: null, files: [], reason: 'No staged files' }; + } + const diff = runGit(cwd, ['diff', '--cached', '--binary', '--no-ext-diff']).stdout; + const hash = createHash('sha256').update(diff).digest('hex'); + return { + available: true, + hash: `sha256:${hash}`, + files, + reason: null + }; +} + +function inferRisk({ proposedRisk, taskText, plannedPaths, config }) { + const paths = [...new Set((plannedPaths || []).map(normalizePath).filter(Boolean))]; + const pathText = paths.join('\n'); + const combined = `${taskText || ''}\n${pathText}`; + let inferred = config.risk.defaultLevel; + const reasons = []; + + if (docsOnly(paths)) { + inferred = config.risk.docsOnlyLevel; + reasons.push('planned or staged paths are documentation-only'); + } + if (matchesAny(pathText, config.risk.highPathPatterns) || matchesAny(taskText, config.risk.highKeywordPatterns)) { + inferred = maxRisk(inferred, 'high'); + reasons.push('authentication, security, migration, deployment, or infrastructure risk signal'); + } + if (matchesAny(pathText, config.risk.criticalPathPatterns) || matchesAny(taskText, config.risk.criticalKeywordPatterns)) { + inferred = 'critical'; + reasons.push('payment, production, secret, or destructive-operation risk signal'); + } + const effective = maxRisk(proposedRisk || config.risk.defaultLevel, inferred); + const needsRecovery = matchesAny(combined, config.risk.recoveryKeywordPatterns) + || effective === 'critical'; + + return { + proposed: normalizeRisk(proposedRisk, config.risk.defaultLevel), + inferred, + effective, + reasons: [...new Set(reasons)], + needsRecovery, + docsOnly: docsOnly(paths) + }; +} + +function decisionForIssues({ deny = [], missing = [] } = {}) { + if (deny.length > 0) return { decision: 'deny', reasons: deny, missingEvidence: [] }; + if (missing.length > 0) return { decision: 'needs_evidence', reasons: [], missingEvidence: missing }; + return { decision: 'allow', reasons: [], missingEvidence: [] }; +} + +function applyMode(mode, invariantDecision, riskDecision) { + const combined = decisionForIssues({ + deny: [...invariantDecision.reasons, ...riskDecision.reasons], + missing: [...invariantDecision.missingEvidence, ...riskDecision.missingEvidence] + }); + + if (mode === 'shadow') { + return { + decision: 'allow', + enforced: false, + observedDecision: combined.decision, + observedReasons: combined.reasons, + advisoryMissingEvidence: combined.missingEvidence + }; + } + + if (mode === 'invariants') { + return { + decision: invariantDecision.decision, + enforced: true, + observedDecision: combined.decision, + observedReasons: combined.reasons, + advisoryMissingEvidence: riskDecision.missingEvidence, + reasons: invariantDecision.reasons, + missingEvidence: invariantDecision.missingEvidence + }; + } + + return { + decision: combined.decision, + enforced: true, + observedDecision: combined.decision, + observedReasons: combined.reasons, + advisoryMissingEvidence: [], + reasons: combined.reasons, + missingEvidence: combined.missingEvidence + }; +} + +function isDelegatedAction(action) { + return Boolean(ACTION_TO_MODE[action]); +} + +function validateTaskId(taskId) { + return typeof taskId === 'string' + && taskId.length >= 1 + && taskId.length <= 128 + && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(taskId); +} + +function invariantRequirements({ task, action, candidate, config, proposalEvidence }) { + const deny = []; + const missing = []; + const delegatedOrIrreversible = isDelegatedAction(action) || ['stage_candidate', 'commit', 'push'].includes(action); + + if (task.terminalCode && delegatedOrIrreversible && !TERMINAL_ALLOWED_ACTIONS.has(action)) { + deny.push(`Task is terminal with ${task.terminalCode}`); + } + + if (['baseline', 'smoke', 'build', 'test', 'stage_candidate', 'review', 'fix', 'escalate', 'commit'].includes(action)) { + if (!task.approval || task.approval.status !== 'granted') { + missing.push('approval: explicit user approval for the implementation plan'); + } + } + + if (action === 'record_approval') { + const granted = proposalEvidence.some(item => item.type === 'approval' && item.status === 'granted'); + if (!granted) missing.push('approval evidence with status granted'); + } + + if (action === 'skip_baseline') { + const justified = proposalEvidence.some(item => ( + item.type === 'baseline_skip' + && ['justified', 'passed', 'recorded'].includes(item.status) + && String(item.details?.justification || item.ref || '').trim().length >= 8 + )); + if (!justified) missing.push('baseline_skip: a concrete justification'); + } + + if (action === 'stage_candidate') { + if (!candidate.available) deny.push(candidate.reason || 'Unable to inspect staged candidate'); + else if (!candidate.hash || candidate.files.length === 0) missing.push('candidate: stage at least one intended file'); + } + + if (action === 'review') { + if (!candidate.hash || candidate.files.length === 0) missing.push('candidate: a non-empty staged candidate'); + } + + if (action === 'fix' && task.fixCycles >= config.maxFixCycles) { + deny.push(`Maximum fix cycles reached (${config.maxFixCycles})`); + } + + if (action === 'commit') { + if (!candidate.hash || candidate.files.length === 0) { + missing.push('candidate: a non-empty staged candidate'); + } else { + if (!task.candidate || task.candidate.hash !== candidate.hash) { + missing.push('candidate: re-authorize the current staged candidate'); + } + if (!hasEvidence(task, 'review', 'passed', { candidateHash: candidate.hash, source: 'runtime' })) { + missing.push('review: runtime PASS for the current staged candidate'); + } + } + } + + if (action === 'push' && !hasEvidence(task, 'push_approval', 'granted')) { + missing.push('push_approval: explicit user authorization to push'); + } + + return decisionForIssues({ deny, missing }); +} + +function riskRequirements({ task, action, risk, candidate }) { + const deny = []; + const missing = []; + const level = risk.effective; + const candidateHash = candidate.hash || task.candidate?.hash || null; + const baselinePassed = hasEvidence(task, 'baseline', ['passed', 'reproduced'], { source: 'runtime' }) + || hasEvidence(task, 'baseline', ['passed', 'reproduced']); + const baselineSkipped = hasEvidence(task, 'baseline_skip', ['justified', 'passed', 'recorded']); + const currentTestPassed = candidateHash + ? hasEvidence(task, 'test', 'passed', { candidateHash, source: 'runtime' }) + : false; + const currentReviewPassed = candidateHash + ? hasEvidence(task, 'review', 'passed', { candidateHash, source: 'runtime' }) + : false; + const currentValidation = candidateHash + ? hasEvidence(task, 'validation', 'passed', { candidateHash }) + : hasEvidence(task, 'validation', 'passed'); + const currentIntegration = candidateHash + ? hasEvidence(task, 'integration_test', 'passed', { candidateHash }) + : hasEvidence(task, 'integration_test', 'passed'); + + if (action === 'skip_baseline' && ['high', 'critical'].includes(level)) { + deny.push(`${level}-risk work cannot skip baseline evidence`); + } + + if (['build', 'fix'].includes(action)) { + if (level === 'medium' && !baselinePassed && !baselineSkipped) { + missing.push('baseline: run it or record a justified skip'); + } + if (['high', 'critical'].includes(level) && !baselinePassed) { + missing.push('baseline: runtime baseline evidence is required for high-risk work'); + } + } + + if (action === 'commit') { + if (!currentReviewPassed) { + missing.push('review: PASS bound to the current candidate'); + } + if (level === 'low') { + if (!currentValidation && !currentTestPassed) { + missing.push('validation: documentation/link validation or a test PASS bound to the current candidate'); + } + } + if (level === 'medium') { + if (!baselinePassed && !baselineSkipped) { + missing.push('baseline: run it or record a justified skip'); + } + if (!currentTestPassed) { + missing.push('test: focused runtime PASS bound to the current candidate'); + } + } + if (['high', 'critical'].includes(level)) { + if (!baselinePassed) missing.push('baseline: runtime baseline evidence'); + if (!currentTestPassed) missing.push('test: runtime PASS bound to the current candidate'); + if (!currentIntegration) missing.push('integration_test: representative integration or end-to-end evidence bound to the current candidate'); + if (risk.needsRecovery && !hasEvidence(task, 'rollback_plan', ['passed', 'recorded'])) { + missing.push('rollback_plan: recovery or forward-fix plan'); + } + } + if (level === 'critical') { + if (!hasEvidence(task, 'isolation', ['passed', 'recorded'])) { + missing.push('isolation: container, VM, worktree, or equivalent isolation evidence'); + } + if (!hasEvidence(task, 'human_checkpoint', 'granted')) { + missing.push('human_checkpoint: explicit approval after final evidence'); + } + } + } + + return decisionForIssues({ deny, missing }); +} + +function publicTask(task) { + return { + taskId: task.taskId, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + proposedRisk: task.proposedRisk, + inferredRisk: task.inferredRisk, + effectiveRisk: task.effectiveRisk, + riskReasons: task.riskReasons, + terminalCode: task.terminalCode, + approval: task.approval, + baseline: task.baseline, + candidate: task.candidate, + fixCycles: task.fixCycles, + evidence: task.evidence.slice(-40), + actions: task.actions.slice(-40), + pendingPermits: Object.values(task.permits).filter(permit => !permit.consumedAt && !permit.revokedAt), + lastCommit: task.lastCommit + }; +} + +export class OrchestrationPolicyKernel { + constructor({ + cwd = process.cwd(), + configPath = DEFAULT_POLICY_CONFIG_PATH, + config, + eventLogPath + } = {}) { + this.cwd = resolve(cwd); + const loaded = loadPolicyConfig(configPath); + this.config = config ? { + ...loaded, + ...config, + risk: { ...loaded.risk, ...(config.risk || {}) } + } : loaded; + this.path = statePath(this.cwd, this.config); + this.store = pruneStore(safeJson(this.path, emptyStore()), this.config); + this.eventLogPath = eventLogPath || defaultEventLogPath(this.cwd); + } + + getTask(taskId) { + this.store.tasks ||= {}; + this.store.tasks[taskId] ||= emptyTask(taskId); + return this.store.tasks[taskId]; + } + + save() { + pruneStore(this.store, this.config); + writeJsonAtomic(this.path, this.store); + } + + logger(taskId) { + return new AgentLoopEventLogger({ + taskId, + cwd: this.cwd, + path: this.eventLogPath, + enabled: true + }); + } + + snapshot(taskId) { + const task = this.getTask(taskId); + return { + mode: this.config.mode, + statePath: this.path, + requireAgentLoopPermit: this.config.requireAgentLoopPermit, + requirePolicyCommit: this.config.requirePolicyCommit, + task: publicTask(task) + }; + } + + propose({ + taskId, + action, + reason = '', + task = '', + riskLevel, + riskReasons = [], + plannedPaths = [], + evidence = [] + } = {}) { + if (!validateTaskId(taskId)) { + return { + decision: 'deny', + enforced: true, + code: 'INVALID_TASK_ID', + reasons: ['taskId must be 1-128 characters and contain only letters, numbers, dot, underscore, colon, or dash'] + }; + } + if (!PERMITTED_ACTIONS.has(action)) { + return { + decision: 'deny', + enforced: true, + code: 'INVALID_POLICY_ACTION', + reasons: [`Unsupported policy action: ${action}`] + }; + } + + const taskState = this.getTask(taskId); + const proposalEvidence = addEvidence(taskState, evidence, 'agent'); + if (task) taskState.taskSummary = truncate(task, 4000); + if (plannedPaths.length > 0) { + taskState.plannedPaths = [...new Set([...taskState.plannedPaths, ...plannedPaths.map(normalizePath)])].slice(0, 500); + } + + const candidate = computeStagedCandidate(this.cwd); + const riskPaths = [...new Set([ + ...taskState.plannedPaths, + ...(candidate.files || []) + ])]; + const risk = inferRisk({ + proposedRisk: riskLevel || taskState.proposedRisk || this.config.risk.defaultLevel, + taskText: task || taskState.taskSummary || reason, + plannedPaths: riskPaths, + config: this.config + }); + taskState.proposedRisk = risk.proposed; + taskState.inferredRisk = risk.inferred; + taskState.effectiveRisk = risk.effective; + taskState.riskReasons = [...new Set([ + ...taskState.riskReasons, + ...risk.reasons, + ...(riskReasons || []).map(value => truncate(value, 300)) + ])].slice(0, 100); + + if (action === 'stage_candidate' && candidate.hash) { + taskState.candidate = { + hash: candidate.hash, + files: candidate.files, + authorizedAt: nowIso() + }; + addEvidence(taskState, [{ + type: 'candidate', + status: 'recorded', + ref: 'git staged diff', + candidateHash: candidate.hash, + details: { files: candidate.files } + }], 'runtime'); + } + + const invariantDecision = invariantRequirements({ + task: taskState, + action, + candidate, + config: this.config, + proposalEvidence + }); + const riskDecision = riskRequirements({ + task: taskState, + action, + risk, + candidate + }); + const applied = applyMode(this.config.mode, invariantDecision, riskDecision); + + let permit = null; + if (applied.decision === 'allow' && (isDelegatedAction(action) || action === 'commit')) { + permit = { + id: randomUUID(), + taskId, + action, + mode: ACTION_TO_MODE[action] || null, + candidateHash: candidate.hash || taskState.candidate?.hash || null, + issuedAt: nowIso(), + expiresAt: new Date(Date.now() + this.config.permitTtlSeconds * 1000).toISOString(), + consumedAt: null, + revokedAt: null + }; + taskState.permits[permit.id] = permit; + } + + const entry = { + id: randomUUID(), + action, + reason: truncate(reason, 2000), + decision: applied.decision, + observedDecision: applied.observedDecision, + enforced: applied.enforced, + effectiveRisk: risk.effective, + candidateHash: candidate.hash || taskState.candidate?.hash || null, + permitId: permit?.id || null, + createdAt: nowIso() + }; + taskState.actions.push(entry); + taskState.actions = taskState.actions.slice(-300); + taskState.updatedAt = nowIso(); + this.save(); + + const logger = this.logger(taskId); + logger.emit('policy.proposed', { + stage: action, + data: { + reason, + proposedRisk: risk.proposed, + inferredRisk: risk.inferred, + effectiveRisk: risk.effective, + riskReasons: taskState.riskReasons, + candidateHash: entry.candidateHash + } + }); + logger.emit('policy.decision', { + stage: action, + data: { + mode: this.config.mode, + decision: applied.decision, + observedDecision: applied.observedDecision, + enforced: applied.enforced, + reasons: applied.reasons || [], + missingEvidence: applied.missingEvidence || [], + advisoryMissingEvidence: applied.advisoryMissingEvidence || [], + permitId: permit?.id || null + } + }); + + return { + code: applied.decision === 'allow' + ? 'POLICY_ALLOWED' + : applied.decision === 'needs_evidence' + ? 'POLICY_NEEDS_EVIDENCE' + : 'POLICY_DENIED', + mode: this.config.mode, + decision: applied.decision, + enforced: applied.enforced, + observedDecision: applied.observedDecision, + reasons: applied.reasons || [], + missingEvidence: applied.missingEvidence || [], + advisoryMissingEvidence: applied.advisoryMissingEvidence || [], + effectiveRisk: risk.effective, + inferredRisk: risk.inferred, + riskReasons: taskState.riskReasons, + permit: permit ? { + id: permit.id, + action: permit.action, + mode: permit.mode, + candidateHash: permit.candidateHash, + expiresAt: permit.expiresAt + } : null, + state: publicTask(taskState) + }; + } + + consumePermit({ taskId, permitId, mode, action } = {}) { + if (!validateTaskId(taskId)) { + const error = new Error('A valid taskId is required'); + error.code = 'INVALID_TASK_ID'; + throw error; + } + if (!permitId) { + const error = new Error('A policy permit is required'); + error.code = 'POLICY_PERMIT_REQUIRED'; + throw error; + } + const task = this.getTask(taskId); + const permit = task.permits?.[permitId]; + if (!permit) { + const error = new Error('Policy permit was not found for this task'); + error.code = 'POLICY_PERMIT_INVALID'; + throw error; + } + if (permit.consumedAt) { + const error = new Error('Policy permit has already been consumed'); + error.code = 'POLICY_PERMIT_CONSUMED'; + throw error; + } + if (permit.revokedAt || toTimestamp(permit.expiresAt) <= Date.now()) { + const error = new Error('Policy permit is expired or revoked'); + error.code = 'POLICY_PERMIT_EXPIRED'; + throw error; + } + if (mode && permit.mode !== mode) { + const error = new Error(`Policy permit authorizes mode ${permit.mode}, not ${mode}`); + error.code = 'POLICY_PERMIT_MODE_MISMATCH'; + throw error; + } + if (action && permit.action !== action) { + const error = new Error(`Policy permit authorizes action ${permit.action}, not ${action}`); + error.code = 'POLICY_PERMIT_ACTION_MISMATCH'; + throw error; + } + + if (permit.action === 'commit') { + const candidate = computeStagedCandidate(this.cwd); + if (!candidate.hash || candidate.hash !== permit.candidateHash) { + const error = new Error('The staged candidate changed after commit authorization'); + error.code = 'POLICY_CANDIDATE_CHANGED'; + throw error; + } + } + + permit.consumedAt = nowIso(); + if (permit.action === 'fix') task.fixCycles += 1; + task.updatedAt = nowIso(); + this.save(); + this.logger(taskId).emit('policy.permit-consumed', { + stage: permit.action, + data: { + permitId, + mode: permit.mode, + candidateHash: permit.candidateHash + } + }); + return { ...permit }; + } + + recordAgentLoopResult({ taskId, permitId, mode, result } = {}) { + const task = this.getTask(taskId); + const permit = task.permits?.[permitId]; + if (!permit) return this.snapshot(taskId); + + const completed = result?.status === 'completed'; + const status = completed ? 'passed' : result?.status === 'blocked' ? 'blocked' : 'failed'; + const evidenceType = permit.action === 'baseline' + ? 'baseline' + : permit.action === 'fix' + ? 'build' + : permit.action; + const specializedStatus = evidenceType === 'baseline' && completed + ? (result?.tests?.status === 'failed' ? 'reproduced' : 'passed') + : status; + + addEvidence(task, [{ + type: EVIDENCE_TYPES.has(evidenceType) ? evidenceType : 'note', + status: specializedStatus, + ref: result?.eventLogPath || result?.logPath || `agent_loop:${mode}`, + candidateHash: permit.candidateHash || null, + details: { + code: result?.code || null, + successfulModel: result?.successfulModel || null, + attemptedModels: result?.attemptedModels || [], + summary: result?.summary || '' + } + }], 'runtime'); + + if (result?.code === 'BUDGET_EXCEEDED' || result?.budget?.exceeded === true) { + task.terminalCode = 'BUDGET_EXCEEDED'; + addEvidence(task, [{ + type: 'budget', + status: 'exceeded', + ref: result?.eventLogPath || result?.logPath || 'agent_loop', + details: { budget: result?.budget || null } + }], 'runtime'); + } + + task.updatedAt = nowIso(); + this.save(); + this.logger(taskId).emit('policy.execution-recorded', { + stage: permit.action, + data: { + permitId, + mode, + status: specializedStatus, + code: result?.code || null, + candidateHash: permit.candidateHash || null + } + }); + return this.snapshot(taskId); + } + + commit({ taskId, permitId, message } = {}) { + if (!this.config.requirePolicyCommit) { + const error = new Error('Policy-controlled commit is disabled'); + error.code = 'POLICY_COMMIT_DISABLED'; + throw error; + } + const commitMessage = String(message || '').trim(); + if (!commitMessage || commitMessage.length > 500) { + const error = new Error('Commit message must be between 1 and 500 characters'); + error.code = 'INVALID_COMMIT_MESSAGE'; + throw error; + } + const permit = this.consumePermit({ taskId, permitId, action: 'commit' }); + const result = runGit(this.cwd, ['commit', '-m', commitMessage], { allowFailure: true }); + if (!result.ok) { + const error = new Error((result.stderr || result.stdout || 'git commit failed').trim()); + error.code = 'POLICY_COMMIT_FAILED'; + throw error; + } + const commitHash = runGit(this.cwd, ['rev-parse', 'HEAD']).stdout.trim(); + const task = this.getTask(taskId); + addEvidence(task, [{ + type: 'commit', + status: 'passed', + ref: commitHash, + candidateHash: permit.candidateHash, + details: { commitHash, message: commitMessage } + }], 'runtime'); + task.updatedAt = nowIso(); + this.save(); + this.logger(taskId).emit('policy.commit-completed', { + stage: 'commit', + data: { + permitId, + commitHash, + candidateHash: permit.candidateHash + } + }); + return { + status: 'completed', + code: 'POLICY_COMMIT_COMPLETED', + taskId, + commitHash, + candidateHash: permit.candidateHash, + stdout: result.stdout.trim() + }; + } +} + +export function loadOrchestrationPolicyConfig(path = DEFAULT_POLICY_CONFIG_PATH) { + return loadPolicyConfig(path); +} From 1a7b445f4a91a03d369011f68b3369ef62a785bb Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:04:22 -0400 Subject: [PATCH 04/34] feat: gate delegated actions through policy permits --- .opencode/plugins/agent-loop.js | 238 +++++++++++++++++++++++++++++--- 1 file changed, 215 insertions(+), 23 deletions(-) diff --git a/.opencode/plugins/agent-loop.js b/.opencode/plugins/agent-loop.js index 8b4e55d..8c36e4c 100644 --- a/.opencode/plugins/agent-loop.js +++ b/.opencode/plugins/agent-loop.js @@ -1,5 +1,6 @@ import { tool } from '@opencode-ai/plugin'; import { runAgentLoop } from '../../runtime/agent-loop-controller.mjs'; +import { OrchestrationPolicyKernel } from '../../lib/orchestration-policy.mjs'; function concise(result) { const steps = (result.steps || []).map(s => ({ @@ -29,6 +30,7 @@ function concise(result) { return { status: result.status, + code: result.code || undefined, taskId: result.taskId, summary: result.summary, successfulModel: result.successfulModel, @@ -42,33 +44,159 @@ function concise(result) { logPath: result.logPath, eventLogPath: result.eventLogPath || undefined, budget: result.budget || undefined, + policy: result.policy || undefined, steps }; } +function blocked(title, code, summary, extra = {}) { + const structured = { + status: 'blocked', + code, + summary, + requiresUserInput: false, + ...extra + }; + return { + title, + output: JSON.stringify(structured, null, 2), + metadata: structured + }; +} + +function failed(title, error, fallbackCode) { + const structured = error?.structured || { + status: 'failed', + code: error?.code || fallbackCode, + summary: error?.message || String(error), + requiresUserInput: false + }; + return { + title, + output: JSON.stringify(structured, null, 2), + metadata: structured + }; +} + +function policyKernel(context) { + return new OrchestrationPolicyKernel({ cwd: context.directory }); +} + export default async function AgentLoopPlugin() { + const evidenceSchema = tool.schema.object({ + type: tool.schema.enum([ + 'approval', + 'baseline', + 'baseline_skip', + 'smoke', + 'build', + 'test', + 'integration_test', + 'validation', + 'candidate', + 'review', + 'rollback_plan', + 'isolation', + 'human_checkpoint', + 'push_approval', + 'budget', + 'commit', + 'note' + ]), + status: tool.schema.string().min(1).max(64), + ref: tool.schema.string().max(512).optional(), + candidateHash: tool.schema.string().max(128).optional(), + details: tool.schema.object({ + justification: tool.schema.string().max(2000).optional(), + notes: tool.schema.string().max(4000).optional(), + commands: tool.schema.array(tool.schema.string().max(1000)).max(50).optional(), + files: tool.schema.array(tool.schema.string().max(1000)).max(500).optional() + }).optional() + }); + return { tool: { + orchestration_policy: tool({ + description: 'Propose the orchestrator next action to the hybrid policy kernel. The model chooses the action and rationale; the kernel enforces invariants, risk gates, durable evidence, and one-time permits.', + args: { + taskId: tool.schema.string().min(1).max(128).describe('Stable task ID reused for the entire feature.'), + action: tool.schema.enum([ + 'inspect', + 'request_approval', + 'record_approval', + 'record_evidence', + 'baseline', + 'skip_baseline', + 'smoke', + 'build', + 'test', + 'stage_candidate', + 'review', + 'fix', + 'escalate', + 'commit', + 'push', + 'ask_user', + 'replan', + 'stop' + ]), + reason: tool.schema.string().min(1).max(4000).describe('Why this is the appropriate next action.'), + task: tool.schema.string().max(12000).optional().describe('Task summary used for semantic risk inference.'), + riskLevel: tool.schema.enum(['low', 'medium', 'high', 'critical']).optional().describe('The orchestrator proposed risk level. The kernel may elevate but never lower it.'), + riskReasons: tool.schema.array(tool.schema.string().max(500)).max(30).optional(), + plannedPaths: tool.schema.array(tool.schema.string().max(1000)).max(500).optional(), + evidence: tool.schema.array(evidenceSchema).max(100).optional() + }, + async execute(args, context) { + if (process.env.AGENT_LOOP_CHILD === '1') { + return blocked( + 'orchestration_policy recursion blocked', + 'POLICY_RECURSION_BLOCKED', + 'Worker processes cannot authorize orchestration actions.' + ); + } + try { + const decision = policyKernel(context).propose(args); + const title = `${decision.decision} risk:${decision.effectiveRisk || 'unknown'} action:${args.action}`; + context.metadata?.({ + title, + metadata: { + action: args.action, + decision: decision.decision, + observedDecision: decision.observedDecision, + risk: decision.effectiveRisk, + missingEvidence: decision.missingEvidence, + permitId: decision.permit?.id + } + }); + return { + title, + output: JSON.stringify(decision, null, 2), + metadata: decision + }; + } catch (error) { + return failed('orchestration_policy failed', error, 'POLICY_ERROR'); + } + } + }), + agent_loop: tool({ - description: 'Run the OpenCode agent-loop runtime for a development task. Use for non-trivial build/test/review work; do not use for simple questions.', + description: 'Run one delegated OpenCode role after orchestration_policy authorizes it. A matching one-time policy permit is required when policy enforcement is enabled.', args: { task: tool.schema.string().min(1).describe('The complete user request to run through the agent loop.'), mode: tool.schema.enum(['build', 'test', 'review', 'smoke', 'escalate']).optional().describe('Which role to run: build, test, review, smoke (model test), or escalate (GPT-5.6 diagnosis)'), maxRetries: tool.schema.number().int().min(0).max(5).optional().describe('Maximum same-model retries for transient failures. Provider failover begins after these retries are exhausted.'), models: tool.schema.array(tool.schema.string()).optional().describe('Pre-verified model IDs from a prior smoke call to restrict which models are used.'), - taskId: tool.schema.string().min(1).max(128).optional().describe('Stable task ID used to share token and cost budgets across smoke, build, test, review, fix, and escalation calls.') + taskId: tool.schema.string().min(1).max(128).optional().describe('Stable task ID shared by the policy kernel, budgets, and all delegated stages.'), + policyPermit: tool.schema.string().min(1).max(128).optional().describe('One-time permit ID returned by orchestration_policy for this exact action and mode.') }, async execute(args, context) { if (process.env.AGENT_LOOP_CHILD === '1') { - return { - title: 'agent_loop recursion blocked', - output: JSON.stringify({ - status: 'blocked', - code: 'AGENT_LOOP_RECURSION_BLOCKED', - summary: 'Worker processes are not allowed to start a complete agent loop.', - requiresUserInput: false - }, null, 2) - }; + return blocked( + 'agent_loop recursion blocked', + 'AGENT_LOOP_RECURSION_BLOCKED', + 'Worker processes are not allowed to start a complete agent loop.' + ); } const task = String(args.task || '').trim(); @@ -79,17 +207,37 @@ export default async function AgentLoopPlugin() { }; } - context.metadata?.({ title: 'Running agent_loop', metadata: { mode: args.mode || 'build' } }); + const mode = args.mode || 'build'; + const kernel = policyKernel(context); + let consumedPermit = null; + if (kernel.config.requireAgentLoopPermit) { + try { + consumedPermit = kernel.consumePermit({ + taskId: args.taskId, + permitId: args.policyPermit, + mode + }); + } catch (error) { + return blocked( + 'agent_loop policy blocked', + error.code || 'POLICY_PERMIT_REQUIRED', + error.message, + { taskId: args.taskId || null, mode } + ); + } + } + + context.metadata?.({ title: 'Running agent_loop', metadata: { mode, policyAction: consumedPermit?.action } }); try { const progressCallback = (msg) => { try { context.metadata?.({ title: msg?.title || 'agent_loop', metadata: msg?.metadata || {} }); } catch {} }; - progressCallback({ title: 'Running agent_loop', metadata: { mode: args.mode || 'build', status: 'starting' } }); + progressCallback({ title: 'Running agent_loop', metadata: { mode, status: 'starting', policyAction: consumedPermit?.action } }); const result = await runAgentLoop({ task, - mode: args.mode || 'build', + mode, maxRetries: args.maxRetries, cwd: context.directory, parentSessionId: context.sessionID, @@ -102,6 +250,14 @@ export default async function AgentLoopPlugin() { forceModels: args.models || undefined, taskId: args.taskId || undefined }); + if (consumedPermit) { + result.policy = kernel.recordAgentLoopResult({ + taskId: args.taskId, + permitId: args.policyPermit, + mode, + result + }); + } const cr = concise(result); const status = result.status; const modelSummary = result.successfulModel ? result.successfulModel.split('/').pop() : 'none'; @@ -113,17 +269,53 @@ export default async function AgentLoopPlugin() { metadata: cr }; } catch (error) { - const structured = error.structured || { - status: 'failed', - code: error.code || 'AGENT_LOOP_ERROR', - summary: error.message, - requiresUserInput: false - }; + if (consumedPermit) { + try { + kernel.recordAgentLoopResult({ + taskId: args.taskId, + permitId: args.policyPermit, + mode, + result: { + status: 'failed', + code: error.code || 'AGENT_LOOP_ERROR', + summary: error.message + } + }); + } catch {} + } + return failed('agent_loop failed', error, 'AGENT_LOOP_ERROR'); + } + } + }), + + orchestration_commit: tool({ + description: 'Create the final local Git commit from the staged candidate after orchestration_policy grants a commit permit. The tool rechecks the candidate hash before committing.', + args: { + taskId: tool.schema.string().min(1).max(128), + policyPermit: tool.schema.string().min(1).max(128), + message: tool.schema.string().min(1).max(500) + }, + async execute(args, context) { + if (process.env.AGENT_LOOP_CHILD === '1') { + return blocked( + 'orchestration_commit recursion blocked', + 'POLICY_RECURSION_BLOCKED', + 'Worker processes cannot create the final orchestration commit.' + ); + } + try { + const result = policyKernel(context).commit({ + taskId: args.taskId, + permitId: args.policyPermit, + message: args.message + }); return { - title: `agent_loop ${structured.status}`, - output: JSON.stringify(structured, null, 2), - metadata: structured + title: `committed ${result.commitHash.slice(0, 12)}`, + output: JSON.stringify(result, null, 2), + metadata: result }; + } catch (error) { + return failed('orchestration_commit blocked', error, 'POLICY_COMMIT_FAILED'); } } }) From d932f97d3fff5506a88ba9a468393268736d5ec7 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:06:26 -0400 Subject: [PATCH 05/34] feat: teach orchestrator hybrid policy action loop --- agents/orchestrator.md | 277 ++++++++++++++++++++++++++--------------- 1 file changed, 180 insertions(+), 97 deletions(-) diff --git a/agents/orchestrator.md b/agents/orchestrator.md index 423907d..fa21a73 100644 --- a/agents/orchestrator.md +++ b/agents/orchestrator.md @@ -5,13 +5,14 @@ temperature: 0.1 reasoning_effort: medium steps: 100 description: > - Orchestrates a complete feature lifecycle. It inspects and plans the work, - obtains approval, then runs baseline, smoke, build, test, review, and - escalation stages through the budget-enforced agent_loop tool before - creating the final commit. + Orchestrates a complete feature lifecycle. It reasons about the appropriate + next action, proposes that action to the hybrid orchestration policy kernel, + and uses one-time permits for delegated work and the final local commit. permission: edit: deny webfetch: deny + orchestration_policy: allow + orchestration_commit: allow agent_loop: allow task: deny todo: allow @@ -24,10 +25,9 @@ permission: git show: allow git stash list: allow git add: allow - git commit: allow ls: allow mkdir: allow - "git commit*": allow + "git commit*": deny "git push*": deny "git reset*": deny "git clean*": deny @@ -39,16 +39,21 @@ permission: You drive one complete feature request from inspection through a verified local commit. -## Non-negotiable execution contract +You retain semantic control over planning, decomposition, validation strategy, risk assessment, replanning, and communication. The deterministic kernel controls authorization, durable evidence, budgets, permits, candidate identity, and irreversible actions. -- Use the `agent_loop` custom tool for every delegated model call. -- Do not use the built-in `task` tool. Direct task delegation bypasses routing, failover, and budget enforcement. -- Create one stable task ID after approval and pass the exact same `taskId` to every `agent_loop` call for that request. -- Never retry, switch models, or escalate after `code: "BUDGET_EXCEEDED"`. Stop and report the budget snapshot. +## Core operating model + +**You propose; the kernel validates; an authorized tool executes.** + +- Use `orchestration_policy` before every delegated action and before the final commit. +- Use `agent_loop` only with the matching one-time `policyPermit` returned by the policy decision. +- Use `orchestration_commit` for the final commit. Direct `git commit` is prohibited. +- Do not use the built-in `task` tool. It bypasses routing, budgets, evidence recording, and policy permits. +- Create one stable `taskId` at the beginning and reuse it for every policy and agent-loop call. +- The kernel may elevate your proposed risk level but must not lower it. +- Treat `BUDGET_EXCEEDED` and other terminal policy denials as final for delegated work. - Never push, merge, rewrite history, discard unrelated changes, or expose secrets. -- Only create the final commit after both test and independent review pass. -- Use one delegated role at a time in a shared working tree. Do not parallelize editing, testing, or review agents unless the runtime provides isolated worktrees and explicit reconciliation. -- The review agent evaluates the staged candidate. Stage only intended final files immediately before review, and update that staged candidate after every fix cycle. +- Use one delegated role at a time while agents share one working tree. A suitable stable ID is: @@ -56,145 +61,223 @@ A suitable stable ID is: feature-- ``` -Keep it under 128 characters. Record it in the todo list so it is not accidentally regenerated between stages. +Keep it under 128 characters and record it in the todo list. -## Workflow +## Policy decisions -### 1. Inspect +Every `orchestration_policy` result has one of three decisions: -1. Read `AGENTS.md` when present. -2. Inspect the affected code and repository status. -3. Discover build, test, lint, type-check, and documentation commands from repository configuration. -4. Identify security, privacy, migration, data-loss, and compatibility risks. -5. Preserve unrelated working-tree changes. -6. Record any pre-existing staged files. If the index already contains unrelated staged changes, stop and ask the user to isolate them before implementation; do not mix them into this review or commit. +- `allow` — proceed. For delegated actions or commit, use the returned permit exactly once. +- `needs_evidence` — gather the listed evidence, record it, or choose a different legitimate action. +- `deny` — do not repeat the same proposal. Stop, replan, ask the user, or choose another permitted action. -### 2. Plan and obtain approval +In `shadow` mode, the kernel returns `allow` while reporting the decision it would have made. In `invariants` mode it enforces hard safeguards and reports risk gates as advisory. In `risk` mode it enforces both. The configured default is `risk`. -Present: +Do not argue with the kernel in a loop or fabricate evidence. When it requests semantic evidence, obtain it from a worker, repository command, or user and record a concrete reference. -- concise implementation steps -- explicit acceptance criteria -- files or subsystems likely to change -- tests that will prove completion -- material risks or ambiguities +## Risk assessment -Wait for explicit approval before implementation. +Propose one of: -### 3. Create the task ID and establish a baseline +- `low` — documentation, comments, metadata, or similarly low-impact work +- `medium` — ordinary source-code changes +- `high` — authentication, authorization, security, migrations, deployment, infrastructure, encryption, or sensitive external integration +- `critical` — payments, production operations, secrets, destructive changes, or similarly irreversible work -Create the stable task ID only after approval. Call `agent_loop` with `mode: "test"` and instruct the test agent to establish the pre-change baseline without modifying production code. +Include the reasons and likely paths. The kernel also inspects staged paths and task text and may elevate the level. -Record: +Risk changes the minimum evidence, not the implementation approach: -- discovered validation commands -- current pass/fail counts -- reproduced target behavior -- pre-existing failures and how they are distinguished from the requested change +- low: relevant validation and independent review +- medium: baseline or justified skip, focused test, independent review +- high: baseline, runtime test, representative integration evidence, review, and recovery evidence when applicable +- critical: high-risk gates plus isolation and a final human checkpoint -A baseline `FAIL` may be expected when it reproduces the approved bug. Continue only when the failure is clearly attributable to the pre-change state and the expected post-change result is explicit. A blocked or ambiguous baseline requires user input. +## Flexible action loop -### 4. Smoke test +The workflow is not a universal fixed pipeline. At each point, choose the next useful action and propose it to `orchestration_policy`. -Call `agent_loop` once with: +Available actions include: -```json -{ - "mode": "smoke", - "task": "", - "taskId": "" -} -``` +- `inspect` +- `request_approval` +- `record_approval` +- `baseline` +- `skip_baseline` +- `smoke` +- `build` +- `test` +- `stage_candidate` +- `review` +- `fix` +- `escalate` +- `record_evidence` +- `ask_user` +- `replan` +- `commit` +- `stop` + +### 1. Inspect and assess -Save the responsive model IDs returned by the tool. If smoke testing returns `BUDGET_EXCEEDED`, stop immediately. +1. Read `AGENTS.md` when present. +2. Inspect repository status, affected code, configuration, tests, and project conventions. +3. Discover build, test, lint, type-check, documentation, UI, and integration commands. +4. Identify security, privacy, migration, data-loss, compatibility, and operational risks. +5. Preserve unrelated changes. +6. If unrelated files are already staged, stop and ask the user to isolate them. -### 5. Build +Register the task with an `inspect` proposal containing the task summary, proposed risk, reasons, and likely paths. + +### 2. Plan and obtain approval + +Present: + +- concise implementation plan +- explicit acceptance criteria +- likely files or subsystems +- validation strategy +- proposed risk and reasons +- material ambiguities -Call `agent_loop` with: +Propose `request_approval`, then wait for explicit user approval. + +After approval, call `orchestration_policy` with: ```json { - "mode": "build", - "task": "", - "taskId": "", - "models": [""] + "action": "record_approval", + "evidence": [ + { + "type": "approval", + "status": "granted", + "ref": "user approval in the current conversation" + } + ] } ``` -Inspect the structured result and `git diff`. Transient retries and provider failover are controlled by runtime configuration. A task-quality failure may receive one corrected build request before the normal fix-cycle limit applies. Do not retry budget exhaustion. +Do not begin implementation before approval is recorded. + +### 3. Choose baseline behavior + +Decide whether baseline evidence is useful. + +- Propose `baseline` when reproducing a bug, comparing existing behavior, or establishing pre-change test state is valuable. +- Propose `skip_baseline` only when it would add little value, and include a concrete `baseline_skip` justification. +- High and critical risk cannot skip baseline. + +When `baseline` is allowed, pass its permit to `agent_loop` with `mode: "test"` and clearly label the worker request as pre-change baseline work. A reproduced target failure can be valid baseline evidence; explain it explicitly if the runtime result alone cannot distinguish reproduction from regression. + +### 4. Smoke and implementation + +Smoke testing is optional when the orchestrator already has reliable responsive-model evidence, but normally propose `smoke` before implementation. + +For any delegated action: + +1. Propose the semantic action to `orchestration_policy`. +2. Read the decision. +3. On `allow`, call `agent_loop` with: + - the same `taskId` + - the matching runtime mode + - `policyPermit` set to the returned permit ID +4. Inspect the structured result and repository state. + +Action-to-mode mapping: + +- `baseline` → `test` +- `smoke` → `smoke` +- `build` → `build` +- `test` → `test` +- `review` → `review` +- `fix` → `build` +- `escalate` → `escalate` + +The kernel records runtime outcomes automatically. Use `record_evidence` for additional semantic facts such as a justified baseline reproduction, integration coverage, recovery plan, isolation, or human checkpoint. + +### 5. Candidate and verification + +After implementation: + +1. Inspect all changes. +2. Stage only intended files with explicit pathspecs. +3. Run `git diff --cached --name-only` and `git diff --cached --check`. +4. Propose `stage_candidate`. + +The kernel calculates and records the staged candidate hash. + +Final test and review evidence must be bound to the current staged candidate: -### 6. Test the implementation +- Propose `test` after `stage_candidate`, then run the permitted test call. +- Propose `review`, then run the permitted independent review call. +- If a test worker changes files, restage, propose `stage_candidate` again, and rerun final test and review. +- The review agent must inspect the staged diff and return `BLOCKED` for empty, incomplete, stale, or unrelated candidates. -Call `agent_loop` with the same `taskId` and `mode: "test"`. Include the discovered commands, baseline evidence, and acceptance criteria in the task text. Testing must cover the changed behavior, not merely confirm that a command exits successfully. +For documentation-only work, record the relevant link, schema, or documentation validation as `validation` evidence with the candidate hash shown by the policy state. -The test agent may add or update tests but must not modify production code. Inspect all resulting changes before staging. +For high-risk work, record representative integration or end-to-end evidence as `integration_test`. When migration, deployment, payment, billing, deletion, or another recovery-sensitive operation is involved, record a `rollback_plan`. -### 7. Stage the review candidate +For critical work, also record `isolation` and obtain a final `human_checkpoint` after presenting the completed evidence. -1. Run `git status --short`. -2. Identify the exact files belonging to the approved request, including test and documentation changes. -3. Stage only those files with explicit pathspecs: `git add -- ...`. -4. Run `git diff --cached --name-only` and `git diff --cached --check`. -5. Confirm the staged candidate contains no unrelated files, secrets, environment files, generated runtime state, or unresolved conflict markers. +### 6. Fix, replan, or escalate -Do not use `git add -A` or `git add .` when unrelated working-tree changes exist. +When test or review finds a defect: -### 8. Review +- propose `fix` +- run the permitted build call +- restage the complete candidate +- propose `stage_candidate` +- rerun final test and review against the new hash -Call `agent_loop` with the same `taskId` and `mode: "review"`. Include the acceptance criteria, baseline evidence, builder handoff, test evidence, and intended staged-file list. +The kernel enforces the configured fix-cycle maximum. -The reviewer must inspect the staged diff and return `BLOCKED` rather than `PASS` when the staged candidate is empty, incomplete, or contains unrelated files. +Use `replan` when discoveries invalidate the approved approach. Obtain revised approval when the scope or material risk changes. -### 9. Fix or escalate +Use `escalate` only for a non-budget blocker that benefits from deeper diagnosis. Never escalate budget exhaustion. -When test or review finds a correctable defect: +### 7. Commit -1. combine the findings into one bounded fix request -2. call `agent_loop` with `mode: "build"` and the same `taskId` -3. rerun the implementation test with that same ID -4. restage the complete intended candidate with explicit pathspecs -5. rerun independent review against the updated staged diff +Before commit: -Allow at most two fix cycles. If the work remains blocked for a non-budget reason, call `agent_loop` with `mode: "escalate"` and the same task ID. +1. Confirm the staged candidate is complete and contains no unrelated, secret, environment, runtime-state, or conflict files. +2. Confirm required test, review, and risk evidence applies to the current candidate hash. +3. Confirm test-owned background processes are stopped or explicitly accounted for. +4. Propose `commit`. -`BUDGET_EXCEEDED` is terminal for the request. Report: +On `allow`, pass the commit permit to `orchestration_commit` with a focused message. The commit tool recomputes the staged candidate hash and refuses the commit if anything changed after authorization. -- limits -- usage and estimated/reported cost -- remaining allowance -- exceeded reasons -- per-step and per-model breakdowns +Never run `git commit` directly and never push automatically. -Do not ask the runtime to continue under a new task ID, because that would bypass the configured limit. +## Evidence integrity -### 10. Commit and clean up +Runtime-generated test, review, candidate, budget, and commit evidence is marked as runtime evidence. Do not try to replace it with unsupported prose. -Before committing: +Agent-recorded evidence is appropriate for semantic facts the runtime cannot infer mechanically, including: -1. run `git status --short` -2. review the complete staged diff -3. confirm test status is PASS -4. confirm review status is PASS for the current staged candidate -5. confirm no file changed after the final review -6. confirm no secret, environment, runtime-state, or unrelated file is staged -7. confirm any background process started by the test agent has been stopped, or explicitly report why it remains running and where its ownership/PID record is stored +- why a baseline skip is justified +- why a failing baseline reproduces the target bug +- what integration scenario was exercised +- recovery or forward-fix plans +- isolation method +- explicit user checkpoints -Create one focused local commit from the reviewed staged candidate. Never push automatically. +Evidence references should identify commands, event logs, artifacts, screenshots, messages, or files rather than merely saying “done.” ## Budget scope -The `agent_loop` budget covers delegated worker calls made through baseline, smoke, build, test, review, escalation, and provider failover. The parent orchestrator model's own conversation usage is not included in that worker ledger. State this limitation when reporting precise cost totals. +The delegated-worker budget covers baseline, smoke, build, test, review, escalation, retries, and provider failover. The parent orchestrator model's own conversation usage is not included in that worker ledger. State this limitation when reporting precise totals. ## Completion report Return: - implementation summary -- baseline, test, and review evidence +- actions proposed and any kernel denials or evidence requests +- effective risk and why it was selected or elevated +- baseline, test, integration, review, and recovery evidence as applicable - final commit hash - files changed - budget snapshot and scope -- cleanup status for test-owned background processes +- cleanup status for background processes - remaining risks or pre-existing issues Keep the report factual and concise. From 96a92b094fd08bbee6847e7a409f060a3ec6d7f1 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:07:04 -0400 Subject: [PATCH 06/34] feat: route feature workflow through hybrid policy kernel --- commands/feature.md | 101 +++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 52 deletions(-) diff --git a/commands/feature.md b/commands/feature.md index 5b38747..aee6977 100644 --- a/commands/feature.md +++ b/commands/feature.md @@ -1,75 +1,72 @@ --- agent: orchestrator description: > - Run the full agent workflow for a feature, fix, refactor, migration, - documentation, or UI change. The orchestrator inspects, plans, obtains - approval, then drives baseline, smoke, build, test, review, and escalation - through budget-enforced agent_loop calls before creating a local commit. + Run a feature, fix, refactor, migration, documentation, or UI change through + the hybrid policy kernel, budgeted agent_loop workers, independent review, + and a policy-controlled local commit. --- -# /feature — Autonomous feature workflow +# /feature — Policy-constrained autonomous workflow -Run the complete OpenCode agent lifecycle for a single unit of work. +Run one unit of development work while preserving the orchestration model's judgment about planning, decomposition, validation strategy, and replanning. -## Usage +## Required operating pattern -```text -/feature -``` +1. Create one stable `taskId` at the beginning and reuse it for every policy and worker call. +2. Use `orchestration_policy` to propose each next action. +3. Read the decision: + - `allow` — proceed and use the returned one-time permit when present. + - `needs_evidence` — gather or record the requested evidence, or choose another legitimate action. + - `deny` — stop, replan, ask the user, or choose a different permitted action. +4. Use `agent_loop` only with a matching `policyPermit`. +5. Use `orchestration_commit` for the final local commit. Never run `git commit` directly. +6. Never use the built-in `task` tool for delegated work. -## Required execution pattern +The configured kernel mode is recorded in every decision: -The orchestrator must create one stable `taskId` after approval and reuse it for every `agent_loop` call in this feature. This makes token, cost, and workflow-call limits cumulative across stages and failover attempts. +- `shadow` observes without blocking. +- `invariants` enforces non-negotiable safeguards and reports risk gates as advisory. +- `risk` enforces safeguards and risk-based minimum evidence. This is the default. -Example: +## Flexible action loop -```text -feature-auth-refresh-20260722T210000Z -``` +The workflow is not a mandatory linear pipeline. The orchestrator may propose: -The orchestrator must not use the built-in `task` tool for delegated work because that bypasses the agent-loop router and budget ledger. +- inspection, replanning, asking the user, or stopping +- approval recording +- baseline testing or a justified baseline skip +- smoke testing +- build, test-only, review-only, fix, or escalation work +- staged-candidate registration +- semantic evidence such as integration coverage, recovery plans, isolation, or human checkpoints +- final commit authorization -All delegated roles run sequentially in the shared working tree. Do not parallelize workers until isolated worktrees and deterministic reconciliation are implemented. +The kernel may elevate risk based on task text or actual staged paths, but it never lowers the orchestrator's proposed risk. -## Workflow +Minimum final evidence generally scales as follows: -1. **Read project instructions** — inspect `AGENTS.md` and repository-specific guidance. -2. **Inspect** — read affected code, repository status, and relevant configuration. Stop for user direction when unrelated staged changes already exist. -3. **Discover validation commands** — identify build, test, lint, type-check, and documentation checks. -4. **Plan** — produce explicit acceptance criteria and a concise implementation plan. -5. **Obtain approval** — wait for explicit user approval. -6. **Create the stable task ID** — retain it for the entire feature. -7. **Baseline test** — call `agent_loop` with `mode: "test"` to record pre-change behavior, current failures, and validation commands. A reproduced target bug may be an expected baseline failure; ambiguous failures block implementation. -8. **Smoke test** — call `agent_loop` with `mode: "smoke"` and the stable `taskId`; save responsive model IDs. -9. **Build** — call `agent_loop` with `mode: "build"`, the same `taskId`, and responsive model IDs. -10. **Test** — call `agent_loop` with `mode: "test"` and the same `taskId`; compare results with the baseline. -11. **Stage the review candidate** — stage only intended implementation, test, and documentation files with explicit pathspecs. Verify `git diff --cached --name-only` and `git diff --cached --check`. -12. **Review** — call `agent_loop` with `mode: "review"` and the same `taskId`. Provide acceptance criteria, baseline/test evidence, and the intended staged-file list. An empty or incomplete staged candidate is `BLOCKED`, never `PASS`. -13. **Fix** — combine findings into a bounded build request, rerun test, restage the complete candidate, and rerun review with the same ID. Maximum two fix cycles. -14. **Escalate** — use `mode: "escalate"` only for non-budget blockers and retain the same ID. -15. **Commit and clean up** — commit only the exact candidate that received final test and review PASS. Stop or explicitly account for test-owned background processes. -16. **Report** — include baseline, test and review evidence, commit hash, changed files, cleanup status, and the budget snapshot. +- low: relevant validation and independent review +- medium: baseline or justified skip, focused test, independent review +- high: baseline, runtime test, representative integration evidence, review, and recovery evidence when applicable +- critical: high-risk evidence plus isolation and a final human checkpoint -## Budget exhaustion +## Candidate integrity -`code: "BUDGET_EXCEEDED"` is terminal for the feature request. +Stage only intended files with explicit pathspecs, then propose `stage_candidate`. The kernel hashes the staged diff. Final test and review results are bound to that hash, and the commit tool refuses to commit if the staged candidate changes after authorization. -When it occurs: +After any fix or test-generated file change: -- stop all retries and escalation -- do not generate a replacement task ID -- do not bypass the limit with direct task delegation -- report limits, usage, cost, exceeded reasons, and the per-step/per-model breakdown - -The budget covers delegated worker calls made through `agent_loop`. It does not include the parent orchestrator model's own conversation usage. +1. restage the complete intended candidate +2. propose `stage_candidate` again +3. rerun final test and review against the new hash ## Hard rules -- Reject an empty task with: "Please describe the work to be done." -- Preserve unrelated working-tree changes. -- Never mix pre-existing staged changes into the feature review or commit. -- Never use `git add .` or `git add -A` when unrelated changes exist. -- Never push automatically. -- Never rewrite history or run destructive cleanup commands. -- Only the orchestrator may create the final commit. -- See `agents/orchestrator.md` for the complete execution contract. +- Wait for explicit approval before implementation. +- Preserve unrelated working-tree and staged changes. +- Never parallelize delegated roles in one shared working tree. +- Never fabricate evidence or repeatedly argue with a denial. +- `BUDGET_EXCEEDED` is terminal for delegated work. +- Never push, merge, rewrite history, or run destructive cleanup automatically. + +See `agents/orchestrator.md` for the complete action, evidence, permit, and risk contract. From bf8c33190d7b2e41ddd95a859894430b1dcd16b6 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:07:20 -0400 Subject: [PATCH 07/34] feat: align installed feature command with policy kernel --- .opencode/command/feature.md | 53 +++++++++++++----------------------- 1 file changed, 19 insertions(+), 34 deletions(-) diff --git a/.opencode/command/feature.md b/.opencode/command/feature.md index 40beabf..e9a1ef8 100644 --- a/.opencode/command/feature.md +++ b/.opencode/command/feature.md @@ -1,48 +1,33 @@ --- agent: orchestrator description: > - Run the full agent workflow for a feature, fix, refactor, migration, - documentation, or UI change. The orchestrator inspects, plans, obtains - approval, then drives baseline, smoke, build, test, review, and escalation - through budget-enforced agent_loop calls before creating a local commit. + Run a feature, fix, refactor, migration, documentation, or UI change through + the hybrid policy kernel, budgeted agent_loop workers, independent review, + and a policy-controlled local commit. --- -# /feature — Autonomous feature workflow +# /feature — Policy-constrained autonomous workflow -Run the complete OpenCode agent lifecycle for a single unit of work. +Create one stable `taskId`, inspect and plan the work, and retain semantic control over the next useful action. -## Required execution pattern +Before every delegated action and before commit: -Create one stable `taskId` after approval and reuse it for every `agent_loop` call. Do not use the built-in `task` tool, because it bypasses routing, failover, and budget enforcement. +1. Call `orchestration_policy` with the proposed action, reason, risk, paths, and available evidence. +2. On `allow`, pass its one-time permit to `agent_loop` or `orchestration_commit`. +3. On `needs_evidence`, gather the requested evidence, record it, replan, or ask the user. +4. On `deny`, do not repeat the same proposal or bypass the kernel. -All delegated roles run sequentially in the shared working tree. Do not parallelize workers until isolated worktrees and deterministic reconciliation are implemented. +The orchestrator may choose baseline, justified baseline skip, smoke, build, test-only, review, fix, escalation, replanning, user clarification, or stop. The kernel enforces approval, stable identity, budgets, retry/fix limits, risk gates, staged-candidate identity, and final commit authorization. -## Workflow +Risk-based final evidence: -1. **PLANNING** — Read `AGENTS.md`, inspect repository status and affected code, discover validation commands, define acceptance criteria, and produce a concise plan. -2. **AWAITING_APPROVAL** — Present the plan and wait for explicit approval. -3. **BASELINE_TESTING** — Create the stable task ID and delegate a pre-change test pass. Record current failures and the behavior the implementation must change. -4. **SMOKE_TESTING** — Test the relevant free model pool and retain responsive model IDs. -5. **IMPLEMENTING** — Delegate one build role at a time. Do not edit code yourself. -6. **VERIFYING** — Delegate the test role and compare results with the baseline. -7. **STAGING_FOR_REVIEW** — Stage only intended implementation, test, and documentation files with explicit pathspecs. Verify the staged file list and `git diff --cached --check`. -8. **REVIEWING** — Delegate the independent read-only reviewer against the current staged candidate. Empty, incomplete, or unrelated staged changes are `BLOCKED`, never `PASS`. -9. **FIXING** — Combine findings into one bounded build request. After each fix, rerun tests, restage the complete candidate, and rerun review. Maximum two fix cycles. -10. **ESCALATING** — Escalate only non-budget blockers and retain the same task ID. -11. **READY_TO_COMMIT** — Confirm the exact staged candidate received final test and review PASS, contains no secrets or unrelated files, and has not changed since review. -12. **COMPLETED** — Create one focused local commit, account for test-owned background processes, and produce the final evidence and budget report. +- low — relevant validation and independent review +- medium — baseline or justified skip, focused test, review +- high — baseline, runtime and integration evidence, review, recovery evidence when applicable +- critical — high-risk evidence plus isolation and a final human checkpoint -## Hard rules +Stage only intended files, propose `stage_candidate`, and bind final test and review evidence to the returned candidate hash. After any candidate change, restage and repeat final verification. -- Test before and after implementation. -- Never allow the builder to approve its own implementation. -- Never commit unless tests and independent review pass on the current staged candidate. -- Never push without asking the user first. -- Never mix pre-existing staged changes into the feature review or commit. -- Never use `git add .` or `git add -A` when unrelated changes exist. -- One active delegated role at a time. -- The orchestrator does not write code; delegate implementation. -- After every fix cycle, rerun VERIFYING, STAGING_FOR_REVIEW, and REVIEWING. -- `BUDGET_EXCEEDED` is terminal. Do not retry, escalate, or generate a replacement task ID. +Never use the built-in `task` tool, direct `git commit`, automatic push, parallel workers in one shared worktree, fabricated evidence, or a replacement task ID after budget exhaustion. -See `agents/orchestrator.md` for the complete execution contract. +See `agents/orchestrator.md` for the complete contract. From 668a468e2f383b5e9d0fe83cc4328a453fb7174b Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:07:40 -0400 Subject: [PATCH 08/34] feat: require policy permits for direct loop command --- commands/loop.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/commands/loop.md b/commands/loop.md index 461a4e2..43e2a34 100644 --- a/commands/loop.md +++ b/commands/loop.md @@ -1,15 +1,25 @@ --- agent: orchestrator -description: Run a task through the agent_loop custom tool. Starts with smoke test then build. +description: Run a compact task through the hybrid policy kernel and agent_loop workers. --- -Call the `agent_loop` tool one role at a time. +# /loop — Compact policy-controlled workflow -1. First call `agent_loop` with `mode: "smoke"` and this task: - ``` - $ARGUMENTS - ``` -2. If smoke test succeeds, call `agent_loop` with `mode: "build"` + `models` from smoke results. -3. Then `mode: "test"`, then `mode: "review"`. +Treat the user's direct `/loop` invocation as approval for the stated scope, but do not assume approval for material scope expansion. -Report the tool result honestly, including partial completion, failed tests, model attempts, and log paths. +1. Create one stable `taskId`. +2. Call `orchestration_policy` with `action: "inspect"`, the task, proposed risk, and likely paths. +3. Record approval with `action: "record_approval"` and approval evidence referencing the `/loop` request. +4. Decide whether baseline evidence is useful. Propose `baseline` or a justified `skip_baseline`. +5. Propose `smoke`; on `allow`, pass its permit to `agent_loop` with `mode: "smoke"`. +6. Propose each needed worker action. Pass the returned permit to the matching `agent_loop` call. +7. For changes, stage only intended files, propose `stage_candidate`, and run final policy-authorized test and review against that candidate. +8. Propose `commit` only when the requested work includes a local commit and all required evidence exists; use `orchestration_commit`, never direct `git commit`. + +On `needs_evidence`, gather it or choose another legitimate action. On `deny`, stop, replan, or ask the user. Report policy decisions, worker results, budget state, and partial completion honestly. + +Task: + +```text +$ARGUMENTS +``` From a89a77e16978ef27a8cc33cfcdbabf9554e4a62a Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:08:01 -0400 Subject: [PATCH 09/34] feat: require hybrid policy authorization in global instructions --- opencode.json | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/opencode.json b/opencode.json index aec92e3..5539a0d 100644 --- a/opencode.json +++ b/opencode.json @@ -2,19 +2,26 @@ "$schema": "https://opencode.ai/config.json", "instructions": [ "This configuration provides reusable OpenCode agents for autonomous feature development.", - "Use /feature to start the full staged workflow.", - "For /feature work, the orchestrator must delegate through the agent_loop custom tool, never the built-in task tool.", - "Create one stable taskId per feature and reuse it for smoke, build, test, review, fix, escalation, and failover calls so token and cost budgets remain cumulative.", - "Treat BUDGET_EXCEEDED as terminal: stop retries and do not create a new task ID to bypass the limit.", + "Use /feature to start the full policy-constrained workflow.", + "For /feature and /loop work, the orchestrator proposes each next action to the orchestration_policy custom tool.", + "The orchestration model owns planning, decomposition, semantic risk assessment, validation strategy, replanning, and user communication.", + "The deterministic kernel owns approval records, stable task identity, budgets, permits, risk minimums, staged-candidate identity, and irreversible action authorization.", + "Use agent_loop only with a matching one-time policyPermit returned by orchestration_policy.", + "Use orchestration_commit for the final local commit; direct git commit is prohibited for the orchestrator.", + "Never use the built-in task tool for feature delegation because it bypasses policy, routing, failover, and budget enforcement.", + "Create one stable taskId per request and reuse it for policy, baseline, smoke, build, test, review, fix, escalation, and failover calls.", + "Treat BUDGET_EXCEEDED and terminal policy denials as final: stop delegated work and do not create a new task ID to bypass the limit.", + "On POLICY_NEEDS_EVIDENCE, gather or record concrete evidence, replan, ask the user, or select another legitimate action; never fabricate evidence.", + "The kernel may elevate the orchestrator's proposed risk level but must not lower it.", "The delegated-worker budget does not include the parent orchestrator model's own conversation usage.", "Do not edit .env, .env.*, credentials, secrets, tokens, or production data.", "Do not push, merge, rewrite history, install dependencies, or run destructive shell commands.", "Never print credentials, secrets, tokens, API keys, or private keys.", "Agents should read the project's AGENTS.md for project-specific rules and commands.", "Free-first routing and budget policy are configured in config/free-first-config.json.", + "Hybrid orchestration policy is configured in config/orchestration-policy.json.", "Model capability and pricing metadata are in config/model-registry.json.", "Role-based model pools are in config/free-first-pools.json.", - "The agent-loop runtime manages model selection, provider failover, cooldowns, paid fallback controls, and budget enforcement.", "Ollama is excluded from primary orchestration and final security review.", "Ollama failure must not trigger paid fallback while suitable free cloud models remain." ], From 4ae7b0be0d6f0689dd26a05f4bc9c45d3fad93a2 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:09:15 -0400 Subject: [PATCH 10/34] test: validate hybrid policy orchestration contract --- scripts/check-feature-contract.mjs | 85 ++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/scripts/check-feature-contract.mjs b/scripts/check-feature-contract.mjs index 012f39e..2ef999e 100644 --- a/scripts/check-feature-contract.mjs +++ b/scripts/check-feature-contract.mjs @@ -5,13 +5,19 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const orchestrator = readFileSync(resolve(root, 'agents/orchestrator.md'), 'utf8'); -const feature = readFileSync(resolve(root, 'commands/feature.md'), 'utf8'); -const projectFeature = readFileSync(resolve(root, '.opencode/command/feature.md'), 'utf8'); -const reviewer = readFileSync(resolve(root, 'agents/review.md'), 'utf8'); -const tester = readFileSync(resolve(root, 'agents/test.md'), 'utf8'); -const builder = readFileSync(resolve(root, 'agents/build-worker.md'), 'utf8'); -const opencode = JSON.parse(readFileSync(resolve(root, 'opencode.json'), 'utf8')); +const read = path => readFileSync(resolve(root, path), 'utf8'); + +const orchestrator = read('agents/orchestrator.md'); +const feature = read('commands/feature.md'); +const projectFeature = read('.opencode/command/feature.md'); +const loop = read('commands/loop.md'); +const reviewer = read('agents/review.md'); +const tester = read('agents/test.md'); +const builder = read('agents/build-worker.md'); +const plugin = read('.opencode/plugins/agent-loop.js'); +const kernel = read('lib/orchestration-policy.mjs'); +const policyConfig = JSON.parse(read('config/orchestration-policy.json')); +const opencode = JSON.parse(read('opencode.json')); const failures = []; function requireMatch(text, pattern, message) { @@ -22,22 +28,33 @@ function rejectMatch(text, pattern, message) { } requireMatch(orchestrator, /^steps:\s*(?:[1-9][0-9]?|100)\s*$/m, 'orchestrator steps must be capped at 100'); +requireMatch(orchestrator, /^\s*orchestration_policy:\s*allow\s*$/m, 'orchestrator must allow orchestration_policy'); +requireMatch(orchestrator, /^\s*orchestration_commit:\s*allow\s*$/m, 'orchestrator must allow orchestration_commit'); requireMatch(orchestrator, /^\s*agent_loop:\s*allow\s*$/m, 'orchestrator must allow agent_loop'); requireMatch(orchestrator, /^\s*task:\s*deny\s*$/m, 'orchestrator must deny direct task delegation'); -requireMatch(orchestrator, /same stable task ID|same `taskId`/i, 'orchestrator must require one stable task ID'); -requireMatch(orchestrator, /BUDGET_EXCEEDED[\s\S]{0,240}(terminal|stop)/i, 'orchestrator must stop on BUDGET_EXCEEDED'); -requireMatch(orchestrator, /baseline/i, 'orchestrator must establish a pre-change baseline'); -requireMatch(orchestrator, /git diff --cached --name-only/i, 'orchestrator must verify the staged review candidate'); -requireMatch(orchestrator, /do not parallelize/i, 'orchestrator must prohibit shared-worktree parallel agents'); -requireMatch(orchestrator, /background process/i, 'orchestrator must account for test-owned background processes'); +requireMatch(orchestrator, /^\s*"git commit\*":\s*deny\s*$/m, 'orchestrator must deny direct git commit'); +requireMatch(orchestrator, /one stable `taskId`|one stable task ID/i, 'orchestrator must require one stable task ID'); +requireMatch(orchestrator, /You propose; the kernel validates/i, 'orchestrator must describe the hybrid authority boundary'); +requireMatch(orchestrator, /policyPermit/i, 'orchestrator must pass one-time policy permits'); +requireMatch(orchestrator, /needs_evidence/i, 'orchestrator must handle missing-evidence decisions'); +requireMatch(orchestrator, /kernel may elevate[\s\S]{0,80}must not lower/i, 'orchestrator must preserve asymmetric risk elevation'); +requireMatch(orchestrator, /workflow is not a universal fixed pipeline/i, 'orchestrator must retain flexible action selection'); +requireMatch(orchestrator, /stage_candidate/i, 'orchestrator must register staged candidates'); +requireMatch(orchestrator, /orchestration_commit/i, 'orchestrator must use policy-controlled commit'); +requireMatch(orchestrator, /BUDGET_EXCEEDED[\s\S]{0,240}(terminal|final)/i, 'orchestrator must stop on BUDGET_EXCEEDED'); +requireMatch(orchestrator, /do not parallelize|one delegated role at a time/i, 'orchestrator must prohibit shared-worktree parallel agents'); -for (const [name, text] of [['commands/feature.md', feature], ['.opencode/command/feature.md', projectFeature]]) { +for (const [name, text] of [ + ['commands/feature.md', feature], + ['.opencode/command/feature.md', projectFeature], + ['commands/loop.md', loop] +]) { requireMatch(text, /stable `taskId`|stable task ID/i, `${name} must require taskId reuse`); - requireMatch(text, /do not use|must not use[\s\S]{0,80}(built-in `task` tool|built-in task tool)/i, `${name} must prohibit direct task delegation`); - requireMatch(text, /BUDGET_EXCEEDED/i, `${name} must define budget-exhaustion behavior`); - requireMatch(text, /baseline/i, `${name} must define baseline testing`); - requireMatch(text, /staged|staging/i, `${name} must define a staged review candidate`); - requireMatch(text, /do not parallelize|one active delegated role/i, `${name} must prohibit unsafe shared-worktree parallelism`); + requireMatch(text, /orchestration_policy/i, `${name} must use orchestration_policy`); + requireMatch(text, /policyPermit|policy permit/i, `${name} must pass policy permits`); + requireMatch(text, /needs_evidence/i, `${name} must handle missing evidence`); + requireMatch(text, /stage_candidate|candidate hash/i, `${name} must bind verification to a candidate`); + requireMatch(text, /built-in `task` tool|built-in task tool/i, `${name} must prohibit direct task delegation`); } requireMatch(reviewer, /staged diff is empty|staged candidate is empty/i, 'reviewer must fail closed on an empty staged candidate'); @@ -51,10 +68,36 @@ requireMatch(builder, /\.opencode\/agent-loop-state\/handoffs/i, 'builder must u rejectMatch(tester, /\/tmp\//i, 'test agent must not prescribe global /tmp paths'); rejectMatch(builder, /\/tmp\//i, 'builder must not prescribe global /tmp paths'); +requireMatch(plugin, /orchestration_policy:\s*tool\(/, 'plugin must register orchestration_policy'); +requireMatch(plugin, /orchestration_commit:\s*tool\(/, 'plugin must register orchestration_commit'); +requireMatch(plugin, /policyPermit/, 'agent_loop tool must accept a policy permit'); +requireMatch(plugin, /consumePermit/, 'agent_loop and commit tools must consume permits'); +requireMatch(kernel, /mode === 'shadow'/, 'kernel must implement phase 1 shadow mode'); +requireMatch(kernel, /mode === 'invariants'/, 'kernel must implement phase 2 invariant enforcement'); +requireMatch(kernel, /riskRequirements/, 'kernel must implement phase 3 risk gates'); +requireMatch(kernel, /computeStagedCandidate/, 'kernel must bind policy to the staged candidate'); +requireMatch(kernel, /recordAgentLoopResult/, 'kernel must record runtime evidence'); +requireMatch(kernel, /POLICY_CANDIDATE_CHANGED/, 'commit authorization must fail on candidate drift'); + +if (!['shadow', 'invariants', 'risk'].includes(policyConfig.mode)) { + failures.push('orchestration policy mode must be shadow, invariants, or risk'); +} +if (policyConfig.require_agent_loop_permit !== true) { + failures.push('orchestration policy must require agent-loop permits'); +} +if (policyConfig.require_policy_commit !== true) { + failures.push('orchestration policy must require policy-controlled commit'); +} +for (const level of ['low', 'medium', 'high', 'critical']) { + if (!policyConfig.gates?.[level]) failures.push(`orchestration policy must define ${level} risk gates`); +} + const instructions = Array.isArray(opencode.instructions) ? opencode.instructions.join('\n') : ''; -requireMatch(instructions, /delegate through the agent_loop custom tool/i, 'opencode.json must direct /feature through agent_loop'); +requireMatch(instructions, /orchestration_policy/i, 'opencode.json must direct feature work through orchestration_policy'); +requireMatch(instructions, /policyPermit/i, 'opencode.json must require policy permits'); +requireMatch(instructions, /orchestration_commit/i, 'opencode.json must require policy-controlled commits'); requireMatch(instructions, /stable taskId/i, 'opencode.json must require a stable taskId'); -requireMatch(instructions, /BUDGET_EXCEEDED as terminal/i, 'opencode.json must make budget exhaustion terminal'); +requireMatch(instructions, /BUDGET_EXCEEDED[\s\S]{0,80}terminal/i, 'opencode.json must make budget exhaustion terminal'); if (failures.length > 0) { for (const failure of failures) console.error(`feature-contract: ${failure}`); From 9ef61ecb626d99e3d10226c457d52d9359ecfad8 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:11:56 -0400 Subject: [PATCH 11/34] test: cover policy kernel phases and risk gates --- tests/policy-kernel-tests.mjs | 453 ++++++++++++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 tests/policy-kernel-tests.mjs diff --git a/tests/policy-kernel-tests.mjs b/tests/policy-kernel-tests.mjs new file mode 100644 index 0000000..adf365b --- /dev/null +++ b/tests/policy-kernel-tests.mjs @@ -0,0 +1,453 @@ +import { strict as assert } from 'node:assert'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { OrchestrationPolicyKernel } from '../lib/orchestration-policy.mjs'; + +function git(cwd, args) { + const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout || `git ${args.join(' ')} failed`); + return result.stdout.trim(); +} + +function createRepo() { + const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-policy-')); + git(dir, ['init']); + git(dir, ['config', 'user.name', 'Policy Test']); + git(dir, ['config', 'user.email', 'policy@example.invalid']); + writeFileSync(resolve(dir, 'README.md'), '# Test\n', 'utf8'); + git(dir, ['add', 'README.md']); + git(dir, ['commit', '-m', 'initial']); + return dir; +} + +function kernel(dir, mode = 'risk') { + return new OrchestrationPolicyKernel({ + cwd: dir, + eventLogPath: resolve(dir, 'events.jsonl'), + config: { + mode, + statePath: '.policy-state.json', + permitTtlSeconds: 600, + taskTtlMinutes: 60, + maxTrackedTasks: 100, + maxFixCycles: 2 + } + }); +} + +function approve(instance, taskId, riskLevel = 'medium', plannedPaths = ['src/app.js']) { + const decision = instance.propose({ + taskId, + action: 'record_approval', + reason: 'The user explicitly approved the plan.', + task: 'Implement the approved change.', + riskLevel, + plannedPaths, + evidence: [{ type: 'approval', status: 'granted', ref: 'user-message-1' }] + }); + assert.equal(decision.decision, 'allow'); +} + +function justifyBaselineSkip(instance, taskId, riskLevel = 'medium') { + return instance.propose({ + taskId, + action: 'skip_baseline', + reason: 'A baseline run would not add useful evidence.', + riskLevel, + evidence: [{ + type: 'baseline_skip', + status: 'justified', + ref: 'documentation-only or non-reproducible setup', + details: { justification: 'The change has no executable pre-change behavior to reproduce.' } + }] + }); +} + +function stageFile(dir, path, content) { + writeFileSync(resolve(dir, path), content, 'utf8'); + git(dir, ['add', '--', path]); +} + +function runPermitted(instance, proposal, result) { + assert.equal(proposal.decision, 'allow'); + assert.ok(proposal.permit?.id); + instance.consumePermit({ + taskId: proposal.state.taskId, + permitId: proposal.permit.id, + mode: proposal.permit.mode + }); + instance.recordAgentLoopResult({ + taskId: proposal.state.taskId, + permitId: proposal.permit.id, + mode: proposal.permit.mode, + result + }); +} + +async function testShadowModeObservesWithoutBlocking() { + const dir = createRepo(); + try { + const instance = kernel(dir, 'shadow'); + const decision = instance.propose({ + taskId: 'shadow-task', + action: 'build', + reason: 'Try implementation before approval.', + riskLevel: 'medium', + plannedPaths: ['src/app.js'] + }); + assert.equal(decision.decision, 'allow'); + assert.equal(decision.enforced, false); + assert.equal(decision.observedDecision, 'needs_evidence'); + assert.ok(decision.permit?.id); + assert.ok(decision.advisoryMissingEvidence.some(item => item.includes('approval'))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +async function testInvariantModeBlocksMissingApprovalButAdvisesRisk() { + const dir = createRepo(); + try { + const instance = kernel(dir, 'invariants'); + const blocked = instance.propose({ + taskId: 'invariant-task', + action: 'build', + reason: 'Implement code.', + riskLevel: 'medium', + plannedPaths: ['src/app.js'] + }); + assert.equal(blocked.decision, 'needs_evidence'); + assert.equal(blocked.permit, null); + assert.ok(blocked.missingEvidence.some(item => item.includes('approval'))); + + approve(instance, 'invariant-task'); + const allowed = instance.propose({ + taskId: 'invariant-task', + action: 'build', + reason: 'Implement after approval.', + riskLevel: 'medium', + plannedPaths: ['src/app.js'] + }); + assert.equal(allowed.decision, 'allow'); + assert.ok(allowed.advisoryMissingEvidence.some(item => item.includes('baseline'))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +async function testRiskModeRequiresMediumBaselineOrJustification() { + const dir = createRepo(); + try { + const instance = kernel(dir, 'risk'); + approve(instance, 'medium-task'); + const blocked = instance.propose({ + taskId: 'medium-task', + action: 'build', + reason: 'Implement ordinary source change.', + riskLevel: 'medium', + plannedPaths: ['src/app.js'] + }); + assert.equal(blocked.decision, 'needs_evidence'); + assert.ok(blocked.missingEvidence.some(item => item.includes('baseline'))); + + const skipped = justifyBaselineSkip(instance, 'medium-task'); + assert.equal(skipped.decision, 'allow'); + + const allowed = instance.propose({ + taskId: 'medium-task', + action: 'build', + reason: 'Implement after justified baseline skip.', + riskLevel: 'medium', + plannedPaths: ['src/app.js'] + }); + assert.equal(allowed.decision, 'allow'); + assert.ok(allowed.permit?.id); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +async function testKernelElevatesRiskAndRejectsHighRiskSkip() { + const dir = createRepo(); + try { + const instance = kernel(dir, 'risk'); + const inspected = instance.propose({ + taskId: 'auth-task', + action: 'inspect', + reason: 'Inspect login behavior.', + task: 'Change authentication session handling.', + riskLevel: 'low', + plannedPaths: ['src/auth/login.js'] + }); + assert.equal(inspected.effectiveRisk, 'high'); + approve(instance, 'auth-task', 'low', ['src/auth/login.js']); + + const denied = justifyBaselineSkip(instance, 'auth-task', 'low'); + assert.equal(denied.decision, 'deny'); + assert.ok(denied.reasons.some(item => item.includes('cannot skip baseline'))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +async function testPermitsAreModeBoundAndSingleUse() { + const dir = createRepo(); + try { + const instance = kernel(dir, 'risk'); + approve(instance, 'permit-task'); + const proposal = instance.propose({ + taskId: 'permit-task', + action: 'baseline', + reason: 'Establish baseline.', + riskLevel: 'medium' + }); + assert.equal(proposal.decision, 'allow'); + assert.equal(proposal.permit.mode, 'test'); + + assert.throws(() => instance.consumePermit({ + taskId: 'permit-task', + permitId: proposal.permit.id, + mode: 'build' + }), error => error.code === 'POLICY_PERMIT_MODE_MISMATCH'); + + instance.consumePermit({ + taskId: 'permit-task', + permitId: proposal.permit.id, + mode: 'test' + }); + assert.throws(() => instance.consumePermit({ + taskId: 'permit-task', + permitId: proposal.permit.id, + mode: 'test' + }), error => error.code === 'POLICY_PERMIT_CONSUMED'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +async function testCandidateBoundTestReviewAndCommit() { + const dir = createRepo(); + try { + const instance = kernel(dir, 'risk'); + approve(instance, 'candidate-task'); + justifyBaselineSkip(instance, 'candidate-task'); + stageFile(dir, 'README.md', '# Updated\n'); + + const staged = instance.propose({ + taskId: 'candidate-task', + action: 'stage_candidate', + reason: 'Register the intended staged candidate.', + riskLevel: 'medium', + plannedPaths: ['README.md'] + }); + assert.equal(staged.decision, 'allow'); + const candidateHash = staged.state.candidate.hash; + assert.match(candidateHash, /^sha256:/); + + const testProposal = instance.propose({ + taskId: 'candidate-task', + action: 'test', + reason: 'Verify the staged candidate.', + riskLevel: 'medium' + }); + runPermitted(instance, testProposal, { + status: 'completed', + tests: { status: 'passed' }, + summary: 'Tests passed.' + }); + + const reviewProposal = instance.propose({ + taskId: 'candidate-task', + action: 'review', + reason: 'Review the staged candidate.', + riskLevel: 'medium' + }); + runPermitted(instance, reviewProposal, { + status: 'completed', + review: { status: 'passed' }, + summary: 'Review passed.' + }); + + const commitProposal = instance.propose({ + taskId: 'candidate-task', + action: 'commit', + reason: 'Commit the verified candidate.', + riskLevel: 'medium' + }); + assert.equal(commitProposal.decision, 'allow'); + assert.equal(commitProposal.permit.candidateHash, candidateHash); + + stageFile(dir, 'README.md', '# Changed after authorization\n'); + assert.throws(() => instance.consumePermit({ + taskId: 'candidate-task', + permitId: commitProposal.permit.id, + action: 'commit' + }), error => error.code === 'POLICY_CANDIDATE_CHANGED'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +async function testHighRiskCommitNeedsIntegrationEvidence() { + const dir = createRepo(); + try { + const instance = kernel(dir, 'risk'); + approve(instance, 'high-task', 'high', ['src/auth/login.js']); + instance.propose({ + taskId: 'high-task', + action: 'record_evidence', + reason: 'Record reproduced baseline.', + riskLevel: 'high', + evidence: [{ type: 'baseline', status: 'reproduced', ref: 'baseline-log' }] + }); + stageFile(dir, 'README.md', '# Auth documentation update\n'); + const staged = instance.propose({ + taskId: 'high-task', + action: 'stage_candidate', + reason: 'Register candidate.', + riskLevel: 'high', + plannedPaths: ['src/auth/login.js'] + }); + const hash = staged.state.candidate.hash; + + const testProposal = instance.propose({ + taskId: 'high-task', + action: 'test', + reason: 'Run tests.', + riskLevel: 'high' + }); + runPermitted(instance, testProposal, { + status: 'completed', + tests: { status: 'passed' }, + summary: 'Tests passed.' + }); + const reviewProposal = instance.propose({ + taskId: 'high-task', + action: 'review', + reason: 'Run review.', + riskLevel: 'high' + }); + runPermitted(instance, reviewProposal, { + status: 'completed', + review: { status: 'passed' }, + summary: 'Review passed.' + }); + + const blocked = instance.propose({ + taskId: 'high-task', + action: 'commit', + reason: 'Try commit without integration evidence.', + riskLevel: 'high' + }); + assert.equal(blocked.decision, 'needs_evidence'); + assert.ok(blocked.missingEvidence.some(item => item.includes('integration_test'))); + + instance.propose({ + taskId: 'high-task', + action: 'record_evidence', + reason: 'Record representative integration scenario.', + riskLevel: 'high', + evidence: [{ + type: 'integration_test', + status: 'passed', + ref: 'integration-command-output', + candidateHash: hash + }] + }); + const allowed = instance.propose({ + taskId: 'high-task', + action: 'commit', + reason: 'Commit after integration evidence.', + riskLevel: 'high' + }); + assert.equal(allowed.decision, 'allow'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +async function testBudgetTerminalStopsDelegation() { + const dir = createRepo(); + try { + const instance = kernel(dir, 'risk'); + approve(instance, 'budget-task'); + justifyBaselineSkip(instance, 'budget-task'); + instance.propose({ + taskId: 'budget-task', + action: 'record_evidence', + reason: 'Record exhausted budget.', + riskLevel: 'medium', + evidence: [{ type: 'budget', status: 'exceeded', ref: 'budget-ledger' }] + }); + const denied = instance.propose({ + taskId: 'budget-task', + action: 'build', + reason: 'Attempt to continue.', + riskLevel: 'medium' + }); + assert.equal(denied.decision, 'deny'); + assert.ok(denied.reasons.some(item => item.includes('BUDGET_EXCEEDED'))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +async function testFixCycleLimitPersists() { + const dir = createRepo(); + try { + let instance = kernel(dir, 'risk'); + approve(instance, 'fix-task'); + justifyBaselineSkip(instance, 'fix-task'); + + for (let index = 0; index < 2; index += 1) { + const proposal = instance.propose({ + taskId: 'fix-task', + action: 'fix', + reason: `Fix cycle ${index + 1}.`, + riskLevel: 'medium' + }); + assert.equal(proposal.decision, 'allow'); + instance.consumePermit({ + taskId: 'fix-task', + permitId: proposal.permit.id, + mode: 'build' + }); + } + + instance = kernel(dir, 'risk'); + const denied = instance.propose({ + taskId: 'fix-task', + action: 'fix', + reason: 'Third fix cycle.', + riskLevel: 'medium' + }); + assert.equal(denied.decision, 'deny'); + assert.ok(denied.reasons.some(item => item.includes('Maximum fix cycles'))); + const persisted = JSON.parse(readFileSync(resolve(dir, '.policy-state.json'), 'utf8')); + assert.equal(persisted.tasks['fix-task'].fixCycles, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const tests = [ + testShadowModeObservesWithoutBlocking, + testInvariantModeBlocksMissingApprovalButAdvisesRisk, + testRiskModeRequiresMediumBaselineOrJustification, + testKernelElevatesRiskAndRejectsHighRiskSkip, + testPermitsAreModeBoundAndSingleUse, + testCandidateBoundTestReviewAndCommit, + testHighRiskCommitNeedsIntegrationEvidence, + testBudgetTerminalStopsDelegation, + testFixCycleLimitPersists +]; + +let passed = 0; +for (const test of tests) { + await test(); + passed += 1; + console.log(`OK: ${test.name}`); +} +console.log(`policy-kernel-tests: ${passed}/${tests.length} passed`); From 52f2da7b83e00b50f3462483f17fb73edea03b02 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:12:12 -0400 Subject: [PATCH 12/34] test: include policy kernel coverage --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7361216..ba8ea1a 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "node": ">=18" }, "scripts": { - "test": "node tests/reliability-v020-tests.mjs && node tests/paid-fallback-state-tests.mjs && node tests/budget-tests.mjs && node tests/budget-audit-tests.mjs && node tests/routing-tests.mjs && node tests/runtime-tests.mjs && node tests/tool-integration-tests.mjs && node tests/bypass-detection.mjs", + "test": "node tests/policy-kernel-tests.mjs && node tests/reliability-v020-tests.mjs && node tests/paid-fallback-state-tests.mjs && node tests/budget-tests.mjs && node tests/budget-audit-tests.mjs && node tests/routing-tests.mjs && node tests/runtime-tests.mjs && node tests/tool-integration-tests.mjs && node tests/bypass-detection.mjs", "validate:agents": "bash scripts/validate-agent-configs.sh", "validate:routing": "node scripts/check-routing-defaults.mjs", "validate:budget": "node scripts/check-budget-config.mjs", From c1ff8a81eaffe6eadfffc4a83818302c1aef9ff5 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:13:15 -0400 Subject: [PATCH 13/34] test: exercise policy-permitted agent loop calls --- tests/tool-integration-tests.mjs | 104 +++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/tests/tool-integration-tests.mjs b/tests/tool-integration-tests.mjs index 4ddd5cd..e1ebd89 100644 --- a/tests/tool-integration-tests.mjs +++ b/tests/tool-integration-tests.mjs @@ -11,7 +11,7 @@ writeFileSync(fakeScript, `const fs = require('fs'); fs.appendFileSync(process.env.AGENT_LOOP_FAKE_LOG, JSON.stringify({ args: process.argv.slice(2), child: process.env.AGENT_LOOP_CHILD, taskId: process.env.AGENT_LOOP_TASK_ID, smokeTest: process.env.AGENT_LOOP_SMOKE_TEST || '' }) + '\\n'); console.log(JSON.stringify({ type: 'step_start', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p1', type: 'step-start' } })); console.log(JSON.stringify({ type: 'text', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p2', type: 'text', text: 'ok' } })); -console.log(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p3', type: 'step-finish', reason: 'stop', tokens: { input: 1, output: 1 } } })); +console.log(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p3', type: 'step-finish', reason: 'stop', tokens: { input: 1, output: 1 } })); console.log('RESULT: PASS'); process.exit(0); `, 'utf8'); @@ -21,12 +21,15 @@ process.env.AGENT_LOOP_WORKER_EXECUTABLE_ARGS = JSON.stringify([fakeScript]); process.env.AGENT_LOOP_FAKE_LOG = fakeLog; process.env.AGENT_LOOP_FORK_DISABLED = '1'; process.env.AGENT_LOOP_BUDGET_STATE_PATH = resolve(dir, 'budgets.json'); +process.env.AGENT_LOOP_POLICY_STATE_PATH = resolve(dir, 'policy.json'); process.env.AGENT_LOOP_EVENT_LOG_PATH = resolve(dir, 'events.jsonl'); const plugin = await AgentLoopPlugin(); assert.ok(plugin.tool.agent_loop, 'agent_loop tool should be registered'); +assert.ok(plugin.tool.orchestration_policy, 'orchestration_policy tool should be registered'); +assert.ok(plugin.tool.orchestration_commit, 'orchestration_commit tool should be registered'); -const result = await plugin.tool.agent_loop.execute({ task: 'harmless smoke task', mode: 'build', maxRetries: 0 }, { +const context = { directory: dir, worktree: dir, sessionID: 'parent-session', @@ -34,10 +37,65 @@ const result = await plugin.tool.agent_loop.execute({ task: 'harmless smoke task agent: 'orchestrator', abort: new AbortController().signal, metadata: () => {} -}); +}; +const taskId = 'tool-integration-task'; + +const approvalResult = await plugin.tool.orchestration_policy.execute({ + taskId, + action: 'record_approval', + reason: 'The direct integration test request is approved.', + task: 'Run a harmless build integration test.', + riskLevel: 'medium', + plannedPaths: ['src/example.js'], + evidence: [{ type: 'approval', status: 'granted', ref: 'integration-test' }] +}, context); +assert.equal(JSON.parse(approvalResult.output).decision, 'allow'); + +const skipResult = await plugin.tool.orchestration_policy.execute({ + taskId, + action: 'skip_baseline', + reason: 'The fake worker integration test has no meaningful pre-change behavior.', + riskLevel: 'medium', + evidence: [{ + type: 'baseline_skip', + status: 'justified', + ref: 'fake worker harness', + details: { justification: 'This test validates tool wiring rather than repository behavior.' } + }] +}, context); +assert.equal(JSON.parse(skipResult.output).decision, 'allow'); + +const buildPolicy = await plugin.tool.orchestration_policy.execute({ + taskId, + action: 'build', + reason: 'Exercise the permitted worker invocation.', + riskLevel: 'medium', + plannedPaths: ['src/example.js'] +}, context); +const buildDecision = JSON.parse(buildPolicy.output); +assert.equal(buildDecision.decision, 'allow'); +assert.ok(buildDecision.permit?.id); + +const missingPermit = await plugin.tool.agent_loop.execute({ + task: 'harmless smoke task', + mode: 'build', + taskId +}, context); +assert.equal(JSON.parse(missingPermit.output).code, 'POLICY_PERMIT_REQUIRED'); + +const result = await plugin.tool.agent_loop.execute({ + task: 'harmless smoke task', + mode: 'build', + maxRetries: 0, + taskId, + policyPermit: buildDecision.permit.id +}, context); const parsed = JSON.parse(result.output); assert.equal(parsed.status, 'completed'); assert.match(parsed.successfulModel, /.+/); +assert.equal(parsed.policy.task.taskId, taskId); +assert.ok(parsed.policy.task.evidence.some(item => item.type === 'build' && item.source === 'runtime')); + const calls = readFileSync(fakeLog, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line)); const buildCalls = calls.filter(call => !call.smokeTest); assert.ok(buildCalls.length >= 1, `Expected at least 1 build call, got ${buildCalls.length}`); @@ -50,19 +108,39 @@ assert.equal(mainCall.args[mainCall.args.indexOf('--model') + 1], parsed.success assert.equal(parsed.budget.persistent, true); assert.match(parsed.eventLogPath, /events\.jsonl$/); +const reused = await plugin.tool.agent_loop.execute({ + task: 'reuse permit', + mode: 'build', + taskId, + policyPermit: buildDecision.permit.id +}, context); +assert.equal(JSON.parse(reused.output).code, 'POLICY_PERMIT_CONSUMED'); + process.env.AGENT_LOOP_CHILD = '1'; -const blocked = await plugin.tool.agent_loop.execute({ task: 'nested', mode: 'build' }, { - directory: dir, - worktree: dir, - sessionID: 'parent-session', - messageID: 'message-2', - agent: 'build-worker', - abort: new AbortController().signal, - metadata: () => {} -}); +const blockedPolicy = await plugin.tool.orchestration_policy.execute({ + taskId: 'nested-task', + action: 'inspect', + reason: 'nested' +}, { ...context, agent: 'build-worker' }); +assert.equal(JSON.parse(blockedPolicy.output).code, 'POLICY_RECURSION_BLOCKED'); + +const blocked = await plugin.tool.agent_loop.execute({ + task: 'nested', + mode: 'build', + taskId: 'nested-task', + policyPermit: 'not-valid' +}, { ...context, agent: 'build-worker' }); delete process.env.AGENT_LOOP_CHILD; assert.equal(JSON.parse(blocked.output).code, 'AGENT_LOOP_RECURSION_BLOCKED'); -for (const key of ['AGENT_LOOP_WORKER_EXECUTABLE', 'AGENT_LOOP_WORKER_EXECUTABLE_ARGS', 'AGENT_LOOP_FAKE_LOG', 'AGENT_LOOP_FORK_DISABLED', 'AGENT_LOOP_BUDGET_STATE_PATH', 'AGENT_LOOP_EVENT_LOG_PATH']) delete process.env[key]; +for (const key of [ + 'AGENT_LOOP_WORKER_EXECUTABLE', + 'AGENT_LOOP_WORKER_EXECUTABLE_ARGS', + 'AGENT_LOOP_FAKE_LOG', + 'AGENT_LOOP_FORK_DISABLED', + 'AGENT_LOOP_BUDGET_STATE_PATH', + 'AGENT_LOOP_POLICY_STATE_PATH', + 'AGENT_LOOP_EVENT_LOG_PATH' +]) delete process.env[key]; rmSync(dir, { recursive: true, force: true }); console.log('tool-integration-tests: passed'); From 17a2fb7ffe1b6d4c4d937064dfd0f367b0b6fbc2 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:13:59 -0400 Subject: [PATCH 14/34] docs: explain hybrid orchestration policy phases --- docs/orchestration-policy.md | 193 +++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/orchestration-policy.md diff --git a/docs/orchestration-policy.md b/docs/orchestration-policy.md new file mode 100644 index 0000000..955f19f --- /dev/null +++ b/docs/orchestration-policy.md @@ -0,0 +1,193 @@ +# Hybrid orchestration policy + +The orchestration policy kernel keeps semantic development decisions with the orchestration model while enforcing objective safety, evidence, budget, and candidate-integrity rules in code. + +> **Authority boundary:** the model proposes; the kernel validates; an authorized tool executes. + +## What remains flexible + +The orchestration model decides: + +- how to understand and decompose the task +- which architecture or implementation approach is appropriate +- whether a baseline is useful +- which validation methods fit the repository and risk +- whether to build, test existing behavior, review, replan, ask the user, escalate, or stop +- what semantic risk applies and why +- whether a discovered issue belongs in scope + +The kernel does not choose files to edit, design the solution, select test commands, or force one universal stage sequence. + +## What is deterministic + +The kernel controls: + +- stable task identity and durable task state +- explicit approval records +- terminal budget state +- one-time action permits +- retry and fix-cycle ceilings +- risk-level minimum evidence +- staged candidate hashing +- test and review evidence bound to the candidate hash +- final commit authorization and candidate rechecking + +`agent_loop` requires a matching permit. The orchestrator cannot directly run `git commit`; `orchestration_commit` consumes a commit permit and rechecks the staged hash. + +## Phases + +Set `mode` in `config/orchestration-policy.json` or temporarily override it with `AGENT_LOOP_POLICY_MODE`. + +### Phase 1: `shadow` + +The kernel evaluates every proposal and records the decision it would make, but returns `allow` and issues permits. Results include: + +- `decision: "allow"` +- `enforced: false` +- `observedDecision` +- `advisoryMissingEvidence` + +Use this mode to measure disagreements and false positives without blocking work. + +### Phase 2: `invariants` + +The kernel enforces non-negotiable safeguards while reporting risk gates as advisory. It blocks or requests evidence for conditions such as: + +- implementation before approval +- missing or invalid permits +- permit reuse or mode mismatch +- terminal budget exhaustion +- too many fix cycles +- empty staged candidate +- review missing for the current candidate +- candidate changes after commit authorization + +Risk-specific minimums appear in `advisoryMissingEvidence` but do not block yet. + +### Phase 3: `risk` + +This is the default. It enforces invariants plus risk-based minimum evidence. + +| Effective risk | Minimum final evidence | +|---|---| +| Low | Relevant validation or test plus independent review | +| Medium | Baseline or justified skip, focused test, review | +| High | Baseline, runtime test, representative integration evidence, review, recovery evidence when applicable | +| Critical | High-risk evidence plus isolation and a final human checkpoint | + +The orchestrator proposes a risk level. The kernel independently evaluates task text, planned paths, and actual staged paths. It may elevate the level but never lower the model's proposal. + +## Decisions + +`orchestration_policy` returns: + +- `allow` — proceed; delegated and commit actions include a one-time permit +- `needs_evidence` — obtain or record the listed evidence, replan, ask the user, or select another legitimate action +- `deny` — the action is prohibited or the task is terminal; do not repeat or bypass it + +The model should treat policy feedback as machine-readable requirements, not as a prompt to argue with the kernel. + +## Actions + +Supported proposals include: + +- `inspect` +- `request_approval` +- `record_approval` +- `record_evidence` +- `baseline` +- `skip_baseline` +- `smoke` +- `build` +- `test` +- `stage_candidate` +- `review` +- `fix` +- `escalate` +- `commit` +- `push` +- `ask_user` +- `replan` +- `stop` + +Delegated action mapping: + +| Policy action | `agent_loop` mode | +|---|---| +| baseline | test | +| smoke | smoke | +| build | build | +| test | test | +| review | review | +| fix | build | +| escalate | escalate | + +## Evidence + +The runtime automatically records: + +- worker outcomes +- budget exhaustion +- staged candidate identity +- test and review status +- final commit hash + +The model may record semantic evidence that cannot be inferred reliably from process state, including: + +- baseline-skip justification +- why a failing baseline reproduces the target bug +- representative integration or end-to-end coverage +- rollback or forward-recovery plan +- isolation method +- explicit human checkpoints + +Evidence references should point to commands, event logs, screenshots, artifacts, files, or user messages. Unsupported statements such as `done` are not meaningful evidence. + +## Candidate binding + +`stage_candidate` calculates a SHA-256 digest of the staged binary diff and records the staged file list. Test and review permits capture that digest. Final evidence must match the current digest. + +Before committing, the kernel recalculates the digest. A changed candidate fails with `POLICY_CANDIDATE_CHANGED` and must be restaged, retested, and rereviewed. + +## Persistent state and observation + +Policy state is stored at: + +```text +.opencode/agent-loop-state/policy.json +``` + +Policy proposals, decisions, permit consumption, execution recording, and commit completion are also appended to the structured event log. This allows comparison of: + +- the action the model proposed +- the risk the model proposed +- any kernel risk elevation +- the enforced decision +- the decision that would have been made in shadow mode +- missing or advisory evidence +- how the model adapted after feedback + +Use the existing event query utility to inspect policy events: + +```bash +npm run events -- --task --type policy.decision +``` + +## Configuration + +`config/orchestration-policy.json` controls: + +- phase mode +- permit requirement and lifetime +- policy-controlled commit requirement +- task-state retention +- maximum fix cycles +- risk path and keyword signals +- documented risk gates + +The environment variables below are useful for evaluation and tests: + +- `AGENT_LOOP_POLICY_MODE=shadow|invariants|risk` +- `AGENT_LOOP_POLICY_STATE_PATH=/custom/path/policy.json` + +Do not use environment overrides to bypass a production repository's approved policy. From 2c4392b60d4d52f9fd07e1a42a28bbdc695a6352 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:17:38 -0400 Subject: [PATCH 15/34] fix: make compact loop policy contract explicit --- commands/loop.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/commands/loop.md b/commands/loop.md index 43e2a34..6fea259 100644 --- a/commands/loop.md +++ b/commands/loop.md @@ -11,10 +11,12 @@ Treat the user's direct `/loop` invocation as approval for the stated scope, but 2. Call `orchestration_policy` with `action: "inspect"`, the task, proposed risk, and likely paths. 3. Record approval with `action: "record_approval"` and approval evidence referencing the `/loop` request. 4. Decide whether baseline evidence is useful. Propose `baseline` or a justified `skip_baseline`. -5. Propose `smoke`; on `allow`, pass its permit to `agent_loop` with `mode: "smoke"`. -6. Propose each needed worker action. Pass the returned permit to the matching `agent_loop` call. -7. For changes, stage only intended files, propose `stage_candidate`, and run final policy-authorized test and review against that candidate. -8. Propose `commit` only when the requested work includes a local commit and all required evidence exists; use `orchestration_commit`, never direct `git commit`. +5. Propose `smoke`; on `allow`, pass the returned `policyPermit` to `agent_loop` with `mode: "smoke"`. +6. Propose each needed worker action. Pass the returned `policyPermit` to the matching `agent_loop` call. +7. For changes, stage only intended files, propose `stage_candidate`, and run final policy-authorized test and review against that candidate hash. +8. Propose `commit` only when the requested work includes a local commit and all required evidence exists; pass its `policyPermit` to `orchestration_commit`, never direct `git commit`. + +Never use the built-in `task` tool for delegated work because it bypasses policy, routing, failover, and budget enforcement. On `needs_evidence`, gather it or choose another legitimate action. On `deny`, stop, replan, or ask the user. Report policy decisions, worker results, budget state, and partial completion honestly. From 6e6ada27aeb93bd97a674d8c4322d92b5d32afd2 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:18:48 -0400 Subject: [PATCH 16/34] docs: describe hybrid policy-constrained orchestration --- README.md | 110 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 70 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 25b7826..a970ec4 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ [![Project Status](https://img.shields.io/badge/status-v0.2.0--pre--release-yellow?style=for-the-badge)](https://github.com/wryan2986/opencode-agent-loop) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -A reusable OpenCode agent-loop package for structured feature development with specialized planning, building, testing, independent review, recovery, and local agents. +A reusable OpenCode agent-loop package for policy-constrained feature development with specialized planning, building, testing, independent review, recovery, and local agents. > **Independent project:** OpenCode Agent Loop is a community project. It is not built, maintained, or endorsed by the OpenCode team. -`plan → approve → smoke → build → test → review → fix/escalate → commit` +`model proposes → policy validates → permitted worker executes → evidence is recorded` -The parent orchestrator uses paid DeepSeek for coordination. Delegated workers use free-first model pools with same-model retries, provider failover, local-model support, persistent budgets, structured events, and controlled paid fallback. +The paid DeepSeek parent orchestrator retains judgment over planning, decomposition, validation strategy, risk, replanning, and user communication. Delegated workers use free-first model pools with retries, provider failover, local-model support, persistent budgets, structured events, controlled paid fallback, and one-time policy permits. ## Important prerequisite @@ -52,42 +52,66 @@ opencode /feature Implement user authentication ``` -## Reliability architecture +## Hybrid orchestration architecture ```text - User request - | - v -+--------------------------------------+ -| Parent orchestrator | -| Plans and reuses one stable task ID | -+--------------------------------------+ - | - v -+--------------------------------------+ -| agent_loop runtime | -| Retry | Route | Failover | Budget | -+--------------------------------------+ - | - v -+--------------------------------------+ -| Provider adapters and worker pools | -| Free/local models -> paid fallback | -+--------------------------------------+ - | - v -+--------------------------------------+ -| Persistent state and events | -| Budget ledger | JSONL audit stream | -+--------------------------------------+ + User request + | + v ++--------------------------------------------+ +| Parent orchestrator | +| Plans, reasons, proposes risk + next action| ++--------------------------------------------+ + | + v ++--------------------------------------------+ +| Orchestration policy kernel | +| Approval | Evidence | Risk | Permit | Hash | ++--------------------------------------------+ + | + one-time permit + | + v ++--------------------------------------------+ +| agent_loop runtime | +| Retry | Route | Failover | Worker Budget | ++--------------------------------------------+ + | + v ++--------------------------------------------+ +| Provider adapters and worker pools | +| Free/local models -> controlled paid use | ++--------------------------------------------+ + | + v ++--------------------------------------------+ +| Persistent state and structured events | +| Policy | Budgets | Candidate | JSONL audit | ++--------------------------------------------+ ``` +The kernel is a referee, not the primary conductor. It does not choose the architecture, files, test commands, or next semantic step. It validates the model's proposed action and returns: + +- `allow` — proceed, using the returned permit when applicable +- `needs_evidence` — gather evidence, replan, ask the user, or choose another legitimate action +- `deny` — stop or select a different legal action; do not bypass the decision + +Policy modes support staged evaluation: + +- `shadow` — observe what would be blocked without blocking it +- `invariants` — enforce non-negotiable safeguards; risk gates remain advisory +- `risk` — enforce invariants and risk-based minimum evidence; this is the default + +See [Hybrid Orchestration Policy](docs/orchestration-policy.md). + Stable configuration lives in: +- `config/orchestration-policy.json` — policy mode, permits, risk signals, and minimum evidence - `config/free-first-config.json` — routing, retry, timeout, budget, and event policy - `config/free-first-pools.json` — ordered model pools by role - `config/model-registry.json` — capabilities, privacy, retirement, and pricing metadata -- `config/free-first-config-schema.json` — supported policy schema +- `config/orchestration-policy-schema.json` — orchestration policy schema +- `config/free-first-config-schema.json` — runtime policy schema - `config/agent-loop-event.schema.json` — versioned event schema See [Architecture](docs/architecture.md) and [Configuration](docs/configuration.md). @@ -96,22 +120,27 @@ See [Architecture](docs/architecture.md) and [Configuration](docs/configuration. | Command | Description | |---------|-------------| -| `/feature ` | Run the complete approved workflow through the orchestrator | -| `/loop ` | Run one `agent_loop` role call directly | +| `/feature ` | Run the full flexible policy-constrained workflow | +| `/loop ` | Run a compact policy-controlled workflow | | `/loop-init` | Install project-specific agent-loop files | | `npm run events -- --task ` | Query the local structured event log | -## v0.2 safeguards +## Safeguards -- one stable task ID across all feature stages -- persistent token, cost, and workflow-call budgets +- one stable task ID across policy, workers, failover, and budgets +- explicit approval recorded before implementation +- one-time action- and mode-bound worker permits +- low, medium, high, and critical risk evidence gates +- model-proposed risk that the kernel may elevate but never lower +- persistent token, cost, workflow-call, policy, and paid-use state - terminal budget exhaustion with no replacement-ID bypass - bounded paid parent orchestration turns - same-model transient retries with exponential backoff and jitter - provider adapters for identity, timeout, and error normalization -- local/Ollama timeout handling even for model IDs without `/` - append-only, versioned, recursively redacted event logs -- portable checkpoint paths and cross-platform Node CI +- staged-candidate SHA-256 binding for final test, review, and commit +- policy-controlled commit with a final candidate-drift check +- portable state paths and cross-platform Node CI - scheduled patched-OpenCode compatibility builds - independent test and review gates before the final local commit @@ -124,7 +153,7 @@ opencode-agent-loop/ ├── agents/ Agent definitions ├── commands/ OpenCode slash commands ├── config/ Policy, pools, registry, and schemas -├── lib/ Routing, adapters, budgets, events, and failover +├── lib/ Policy, routing, adapters, budgets, events, and failover ├── runtime/ Controller and worker execution ├── .opencode/ Plugin and project-local commands ├── skills/ Reusable project-analysis skills @@ -138,9 +167,9 @@ opencode-agent-loop/ ## Safety model -The package requires explicit approval before implementation, routes workers through a budget-enforced runtime, runs independent testing and review, blocks automatic pushes, denies destructive Git commands, guards against recursion, and filters providers by privacy policy. +The package requires explicit approval before implementation, routes workers through a policy- and budget-enforced runtime, runs independent testing and review, blocks automatic pushes, denies destructive Git commands, guards against recursion, and filters providers by privacy policy. -Prompt permissions and redaction are not an operating-system sandbox. Use a container or VM for untrusted repositories. See [Safety Model](docs/safety-model.md). +Prompt permissions, policy checks, and redaction are not an operating-system sandbox. Use a container or VM for untrusted repositories. See [Safety Model](docs/safety-model.md). ## Validation @@ -157,6 +186,7 @@ The full command includes Bash permission checks. The portable command is exerci - [Architecture](docs/architecture.md) - [Agent Roles](docs/agent-roles.md) - [Configuration](docs/configuration.md) +- [Hybrid Orchestration Policy](docs/orchestration-policy.md) - [Provider Adapters](docs/provider-adapters.md) - [Structured Event Logging](docs/event-logging.md) - [Platform Support](docs/platforms.md) From 7bbfa1ddd1cf348283afed2afc05b23add206e95 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:19:31 -0400 Subject: [PATCH 17/34] docs: document orchestration policy configuration --- docs/configuration.md | 90 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 9 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 4fce312..38605ae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,9 +1,34 @@ # Configuration -OpenCode Agent Loop separates stable model metadata, role ordering, runtime policy, and generated state. +OpenCode Agent Loop separates orchestration policy, stable model metadata, role ordering, runtime policy, and generated state. ## Stable configuration +### `config/orchestration-policy.json` + +Controls the hybrid model/kernel boundary: + +- enforcement mode: `shadow`, `invariants`, or `risk` +- whether delegated calls require one-time permits +- whether the final commit must use `orchestration_commit` +- permit lifetime +- persistent policy-state retention +- maximum fix cycles +- path and keyword risk signals +- documented low, medium, high, and critical evidence gates + +The default is `risk`, which enforces hard invariants and risk-based minimum evidence. See [Hybrid Orchestration Policy](orchestration-policy.md). + +Temporary evaluation overrides: + +```bash +AGENT_LOOP_POLICY_MODE=shadow opencode +AGENT_LOOP_POLICY_MODE=invariants opencode +AGENT_LOOP_POLICY_MODE=risk opencode +``` + +Managed deployments and tests may override the state file with `AGENT_LOOP_POLICY_STATE_PATH`. Do not use overrides to evade a repository's approved policy. + ### `config/free-first-config.json` Controls free-first and paid-fallback policy, retries, cooldowns, provider timeouts, privacy classifications, budgets, state retention, and structured event logging. Treat it as policy; never store credentials in it. @@ -16,13 +41,56 @@ Defines ordered model candidates for every role. A pool normally contains free m Stores stable model identity, capability, privacy, retirement, and pricing metadata. -### `config/free-first-config-schema.json` +### Configuration schemas + +- `config/orchestration-policy-schema.json` defines the hybrid orchestration policy. +- `config/free-first-config-schema.json` defines the routing and runtime policy. +- `config/agent-loop-event.schema.json` defines structured events. + +CI also runs semantic checks that JSON Schema alone cannot express. + +## Hybrid orchestration strategy + +The authority boundary is: + +> The model proposes; the kernel validates; an authorized tool executes. + +The orchestration model decides planning, decomposition, semantic risk, validation strategy, next action, replanning, escalation, and user communication. The kernel controls approval records, stable task identity, budgets, one-time permits, minimum risk evidence, candidate hashes, fix limits, and final commit authorization. -Defines the supported policy structure. CI also runs semantic checks that JSON Schema alone cannot express. +A delegated call follows this pattern: + +1. The orchestrator proposes an action through `orchestration_policy`. +2. The kernel returns `allow`, `needs_evidence`, or `deny`. +3. An allowed delegated action includes a one-time `policyPermit`. +4. The orchestrator passes that permit to the matching `agent_loop` mode. +5. The kernel consumes the permit and records the runtime result. + +Direct use of OpenCode's built-in `task` tool bypasses this package's router, budgets, evidence, and policy controls and is prohibited for feature work. + +## Risk policy + +The model proposes `low`, `medium`, `high`, or `critical` risk and supplies reasons and likely paths. The kernel independently examines task text, planned paths, and actual staged paths. It may elevate the effective risk level but never lower the model's proposal. + +Default final gates: + +| Risk | Minimum evidence | +|---|---| +| Low | Relevant validation or test plus independent review | +| Medium | Baseline or justified skip, focused runtime test, independent review | +| High | Baseline, runtime test, representative integration evidence, review, recovery evidence when applicable | +| Critical | High-risk evidence plus isolation and a final human checkpoint | + +These are minimums. The model still chooses the repository-appropriate commands and implementation strategy. + +## Candidate identity and commit policy + +After staging intended files, the orchestrator proposes `stage_candidate`. The kernel computes a SHA-256 digest of the staged binary diff and records the file list. + +Final test and review evidence is bound to that candidate. A later file change invalidates the previous evidence. The final `commit` proposal produces a one-time commit permit, and `orchestration_commit` recalculates the hash before running Git. Candidate drift fails closed with `POLICY_CANDIDATE_CHANGED`. ## Routing strategy -The default strategy is: +The default model strategy is: - paid DeepSeek V4 Flash for the parent orchestrator - free-first pools for delegated build, test, review, exploration, and reconciliation work @@ -30,19 +98,17 @@ The default strategy is: - controlled paid fallback after suitable free/local choices fail or are unavailable - GPT-5.6 Luna for explicit escalation and difficult diagnosis -The `/feature` command must make delegated calls through `agent_loop`. Direct use of OpenCode's built-in `task` tool bypasses this package's router and safety controls. - Provider adapters normalize identity, timeout selection, and provider-specific errors. See [Provider Adapters](provider-adapters.md). ## Retry policy `maxRetries` controls retries of the same model after transient provider or network failures. Retries use exponential backoff plus jitter and count toward the task budget. Provider failover begins only after same-model retries are exhausted. -Authentication, billing, safety, invalid-request, task-quality, cancellation, and budget failures are terminal. +Authentication, billing, safety, invalid-request, task-quality, cancellation, policy, and budget failures are terminal or require a new authorized action rather than blind retries. ## Task budgets -One stable task ID shares a ledger across smoke, build, test, review, fixes, escalation, and provider failover. +One stable task ID shares a ledger across baseline, smoke, build, test, review, fixes, escalation, and provider failover. Default limits are: @@ -86,7 +152,13 @@ The default append-only stream is: /.opencode/agent-loop-state/events.jsonl ``` -Events conform to `config/agent-loop-event.schema.json` and cover stages, model attempts, retries, provider cooldowns, budget updates, and completion. See [Structured Event Logging](event-logging.md). +Events conform to `config/agent-loop-event.schema.json` and cover policy proposals and decisions, permits, stages, model attempts, retries, provider cooldowns, budget updates, candidate state, and completion. See [Structured Event Logging](event-logging.md). + +Policy state is stored separately at: + +```text +/.opencode/agent-loop-state/policy.json +``` ## Provider timeouts From 480729dc420be2e9437a3a0724dcbb36a1f83511 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:20:12 -0400 Subject: [PATCH 18/34] docs: describe policy-constrained action architecture --- docs/architecture.md | 157 ++++++++++++++++++++++++++++++++----------- 1 file changed, 118 insertions(+), 39 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 66fc48a..288b867 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,48 +2,115 @@ ## Overview -OpenCode Agent Loop coordinates a gated development lifecycle: +OpenCode Agent Loop uses a policy-constrained action graph rather than one universal fixed pipeline. -`plan → approve → smoke → build → test → review → fix or escalate → commit` +> **The model proposes; the kernel validates; an authorized tool executes.** -The paid parent orchestrator handles inspection, planning, approval, stage order, and the final commit. Every delegated model call goes through `agent_loop`, which centralizes provider adapters, free-first routing, retries, failover, budget enforcement, and structured events. +The paid parent orchestrator handles semantic work: inspection, planning, decomposition, risk reasoning, validation strategy, next-action selection, replanning, and user communication. The orchestration policy kernel handles objective controls: approval records, stable task identity, budgets, one-time permits, risk minimums, staged-candidate identity, fix limits, and final commit authorization. + +Every delegated model call then goes through `agent_loop`, which centralizes provider adapters, free-first routing, retries, failover, worker-budget enforcement, and structured events. ```text - User request - | - v -+--------------------------------------+ -| Parent orchestrator | -| Plans and reuses one stable task ID | -| Turn cap + workflow-call budget | -+--------------------------------------+ - | - v -+--------------------------------------+ -| agent_loop runtime | -| Smoke | Build | Test | Review | -| Retry | Failover | Escalate | -+--------------------------------------+ - | - v -+--------------------------------------+ -| Reliability services | -| Provider adapters | Persistent budget| -| Structured events | Checkpoints | -+--------------------------------------+ - | - v -+--------------------------------------+ -| OpenCode worker processes | -| Free/local pools -> paid fallback | -+--------------------------------------+ + User request + | + v ++--------------------------------------------+ +| Parent orchestrator | +| Understands task and proposes next action | ++--------------------------------------------+ + | + v ++--------------------------------------------+ +| Orchestration policy kernel | +| Approval | Risk | Evidence | Permit | Hash | ++--------------------------------------------+ + | + one-time permit + | + v ++--------------------------------------------+ +| agent_loop runtime | +| Retry | Route | Failover | Worker Budget | ++--------------------------------------------+ + | + v ++--------------------------------------------+ +| Provider adapters and worker processes | +| Free/local pools -> controlled paid use | ++--------------------------------------------+ + | + v ++--------------------------------------------+ +| Persistent state and structured events | +| Policy | Budget | Candidate | JSONL audit | ++--------------------------------------------+ ``` -## Workflow contract +## Flexible reasoning and deterministic policy + +The orchestration model decides: + +- how to decompose and implement the request +- whether a baseline is useful +- which tests and evidence fit the repository +- whether to build, test existing behavior, review, replan, ask the user, escalate, or stop +- what semantic risk applies +- whether newly discovered work belongs in scope + +The kernel decides: + +- whether approval is recorded +- whether a task or budget is terminal +- whether an action is authorized +- whether a permit is valid, unused, unexpired, and mode-matched +- whether minimum evidence for the effective risk exists +- whether the staged candidate matches tested and reviewed evidence +- whether a commit can proceed without candidate drift + +This keeps the kernel narrow. It verifies objective properties instead of trying to make architecture or engineering judgments. + +## Policy phases + +The same kernel supports three operating modes: + +1. **Shadow** — record the decision the kernel would make while allowing the action. +2. **Invariants** — enforce hard safeguards while reporting risk gates as advisory. +3. **Risk** — enforce safeguards and low/medium/high/critical minimum evidence. + +The default is `risk`. See [Hybrid Orchestration Policy](orchestration-policy.md). + +## Action and permit contract -A feature uses one stable task ID across every stage. `BUDGET_EXCEEDED` is terminal: the orchestrator must not continue under a replacement ID or bypass `agent_loop` with direct task delegation. +A typical delegated action is: -Test and review are independent gates. Every fix cycle returns through both. Only the parent orchestrator may create the final local commit, and it never pushes automatically. +1. The orchestrator proposes an action to `orchestration_policy`. +2. The kernel returns `allow`, `needs_evidence`, or `deny`. +3. An allowed delegated action receives a one-time permit bound to the task, action, and runtime mode. +4. `agent_loop` consumes the permit before launching a worker. +5. The worker result is stored as runtime evidence. + +The action graph includes inspection, approval, baseline or justified skip, smoke, build, test, staging, review, fix, escalation, replanning, user clarification, commit, and stop. The model chooses among legal actions; the kernel does not force every task through every node. + +## Candidate identity + +`stage_candidate` computes a SHA-256 digest of the staged binary diff and records its files. Final test and review permits capture that digest. A changed candidate invalidates earlier final evidence. + +The final local commit is made through `orchestration_commit`, not direct Git. The commit tool consumes a one-time commit permit, recalculates the staged digest, and fails with `POLICY_CANDIDATE_CHANGED` when anything changed after authorization. + +## Risk gates + +The model proposes risk and explains why. The kernel examines task text, planned paths, and actual staged paths. It can elevate risk but never lower the model's proposal. + +Default minimum final evidence: + +| Risk | Minimum evidence | +|---|---| +| Low | Relevant validation or test and independent review | +| Medium | Baseline or justified skip, focused test, review | +| High | Baseline, runtime test, representative integration evidence, review, recovery evidence when applicable | +| Critical | High-risk evidence plus isolation and a final human checkpoint | + +The model still chooses the specific tests, integration scenarios, recovery approach, and implementation design. ## Agent roles @@ -51,24 +118,35 @@ The exact number of roles may change. Current capabilities include orchestration ## Routing and retries -Provider adapters identify models, select timeout keys, and normalize provider errors. Transient failures can retry the same model using exponential backoff and jitter. After retries are exhausted, failover moves to another eligible provider. Non-retryable task, auth, billing, safety, cancellation, and budget failures stop immediately. +Provider adapters identify models, select timeout keys, and normalize provider errors. Transient failures can retry the same model using exponential backoff and jitter. After retries are exhausted, failover moves to another eligible provider. Non-retryable task, auth, billing, safety, cancellation, policy, and budget failures stop or require a newly authorized action. Free-first worker pools may include local models and controlled paid fallback. GPT-5.6 Luna is reserved for explicit escalation. ## Budgets -Budget state is persisted atomically per project under `.opencode/agent-loop-state/`. The ledger covers delegated tokens and cost plus the number of parent workflow calls. The parent orchestrator also has a bounded turn count. +Budget state is persisted atomically per project under `.opencode/agent-loop-state/`. The ledger covers delegated tokens and cost plus workflow calls. The parent orchestrator also has a bounded turn count. OpenCode does not currently expose parent-session token and cost events to this plugin, so snapshots state `parentModelUsageIncluded: false`. They must not be described as complete end-to-end dollar totals. ## Structured events -The runtime emits versioned JSON Lines events for workflow calls, stages, attempts, retries, model selection, cooldowns, budget changes, and completion. Events are recursively redacted and queryable with `scripts/query-events.mjs`. See [Structured Event Logging](event-logging.md). +The runtime emits versioned JSON Lines events for policy proposals and decisions, permits, workflow calls, stages, attempts, retries, model selection, cooldowns, budgets, candidate state, and completion. Events are recursively redacted and queryable with `scripts/query-events.mjs`. See [Structured Event Logging](event-logging.md). + +Policy events make the model/kernel interaction observable: + +- action and risk proposed by the model +- risk elevation by the kernel +- enforced or shadow decision +- missing or advisory evidence +- permit issuance and consumption +- how the model adapts after feedback ## State and recovery Stable configuration lives in: +- `config/orchestration-policy.json` +- `config/orchestration-policy-schema.json` - `config/free-first-config.json` - `config/free-first-config-schema.json` - `config/free-first-pools.json` @@ -76,16 +154,17 @@ Stable configuration lives in: Ignored runtime state includes: +- persistent orchestration policy state - persistent budgets - structured events - provider cooldowns - portable task checkpoints - attempt and progress logs -The v0.2 foundations make interrupted-state reconstruction possible; full automatic stage resume remains planned for v0.3. +The durable policy and evidence foundations make interrupted-state reconstruction possible; automatic semantic resume remains future work because the orchestrator must still evaluate whether old evidence remains applicable. ## Compatibility and trust boundaries The portable Node runtime is tested on Linux, macOS, and Windows. Bash installation and permission validation require Linux, WSL, macOS, or Git Bash. A scheduled workflow builds the patched OpenCode revision and verifies required source contracts. -Agent permissions and redaction reduce risk but are not an operating-system sandbox. Run untrusted repositories in a container or VM, keep credentials out of prompts, and review provider data policies. +Agent permissions, policy checks, and redaction reduce risk but are not an operating-system sandbox. Run untrusted repositories in a container or VM, keep credentials out of prompts, and review provider data policies. From a59602c44182754cd26f9eb63e780914b5eea89d Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:20:41 -0400 Subject: [PATCH 19/34] docs: record hybrid policy kernel changes --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index afca94b..1693d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +### Added + +- Added a durable hybrid orchestration policy kernel with `shadow`, `invariants`, and `risk` enforcement modes. +- Added the `orchestration_policy` tool for model-proposed actions, risk, evidence, and one-time permits. +- Added the `orchestration_commit` tool, which rechecks the staged candidate hash before creating the final local commit. +- Added persistent policy state, policy events, fix-cycle accounting, and staged-candidate SHA-256 binding. +- Added low, medium, high, and critical risk gates while preserving agent choice over planning, decomposition, validation strategy, replanning, and user communication. +- Added deterministic policy-kernel and permit-integration tests plus configuration schemas and documentation. + +### Changed + +- Changed `/feature` and `/loop` from fixed pipeline instructions to a flexible policy-constrained action graph. +- Required every delegated worker call to consume a matching one-time policy permit. +- Prohibited direct orchestrator commits in favor of policy-controlled commit authorization. +- Updated orchestration contracts so the kernel may elevate but never lower the model's proposed risk. + ## [0.2.0] - 2026-07-22 ### Added From 3304e56c2b77968f348da19fa50e2e030e575776 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:21:01 -0400 Subject: [PATCH 20/34] docs: update roadmap for hybrid policy kernel --- docs/roadmap.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ffa13a..6206a34 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,15 +33,24 @@ A reliable, safe, and extensible open-source framework for autonomous software-d - [x] Scheduled OpenCode patch compatibility workflow - [x] Runtime configuration schema and integration-contract validation -## Version 0.3 — Recovery and evaluation - -**Focus:** Deeper resilience, performance measurement, and operational visibility. - -- [ ] Resumable workflow controller with durable stage checkpoints -- [ ] Automatic recovery from interrupted build/test/review stages -- [ ] Reusable evaluation harness for agent quality, latency, and cost +## Version 0.3 — Policy, recovery, and evaluation + +**Focus:** Preserve flexible model reasoning while strengthening durable authorization, recovery, and measurable performance. + +- [x] Hybrid orchestration policy kernel with model-proposed actions +- [x] Shadow-mode policy observation +- [x] Hard-invariant enforcement with risk gates advisory +- [x] Risk-based low, medium, high, and critical evidence enforcement +- [x] Durable one-time action permits and fix-cycle state +- [x] Staged-candidate hashing and policy-controlled final commits +- [ ] Evaluation report for model/kernel disagreements, false blocks, skipped stages, cost, and completion quality +- [ ] Resumable workflow recovery using durable policy, budget, candidate, and event state +- [ ] Automatic recovery from interrupted build/test/review stages when evidence remains valid +- [ ] Strict versioned worker-result envelopes with fail-closed parsing +- [ ] First-class baseline result semantics for clean, reproduced, pre-existing-failure, and blocked outcomes - [ ] Optional task-progress dashboard backed by structured events - [ ] Container-based execution profiles for stronger isolation +- [ ] Isolated Git worktrees and deterministic reconciliation before enabling parallel writers - [ ] Community-contributed role and provider packs - [ ] Expanded local-model benchmarking and hardware guidance From 6e4992a36ae5d4bca8bb53a5d8815447d49d3c12 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:21:30 -0400 Subject: [PATCH 21/34] fix: verify policy kernel installation files --- scripts/install.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 1b078b2..bf4e5ef 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -30,6 +30,10 @@ REQUIRED=( "$PACKAGE_DIR/commands/loop.md" "$PACKAGE_DIR/commands/loop-init.md" "$PACKAGE_DIR/.opencode/plugins/agent-loop.js" + "$PACKAGE_DIR/lib/orchestration-policy.mjs" + "$PACKAGE_DIR/config/orchestration-policy.json" + "$PACKAGE_DIR/config/orchestration-policy-schema.json" + "$PACKAGE_DIR/config/free-first-config.json" "$PACKAGE_DIR/opencode.json" ) From 84fa2aa324fdf0fcbe507d7be4fd1212bfeeb14b Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:23:17 -0400 Subject: [PATCH 22/34] ci: preserve feature contract failure diagnostics --- .github/workflows/ci.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e2fc7..f1dfa56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,21 @@ jobs: run: npm run validate:budget - name: Check feature integration contract - run: npm run validate:feature + id: feature-contract + shell: bash + run: | + set +e + npm run validate:feature 2>&1 | tee feature-contract.log + status=${PIPESTATUS[0]} + set -e + exit "$status" + + - name: Upload feature-contract diagnostics + if: failure() && matrix.node-version == 22 + uses: actions/upload-artifact@v4 + with: + name: feature-contract-node-22 + path: feature-contract.log - name: Check documentation links run: node scripts/check-doc-links.mjs From d45781fa1c0cd8184c9b93ae477020482afab579 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:24:22 -0400 Subject: [PATCH 23/34] feat: summarize orchestrator reactions to policy decisions --- lib/policy-report.mjs | 211 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 lib/policy-report.mjs diff --git a/lib/policy-report.mjs b/lib/policy-report.mjs new file mode 100644 index 0000000..dd60fe2 --- /dev/null +++ b/lib/policy-report.mjs @@ -0,0 +1,211 @@ +function increment(target, key) { + const normalized = String(key || 'unknown'); + target[normalized] = (target[normalized] || 0) + 1; +} + +function toMillis(value) { + const parsed = Date.parse(value || ''); + return Number.isFinite(parsed) ? parsed : 0; +} + +function sortedCounts(value) { + return Object.fromEntries( + Object.entries(value).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + ); +} + +export function buildPolicyReactionReport(events = [], { taskId } = {}) { + const selected = events + .filter(event => !taskId || event.taskId === taskId) + .filter(event => event.type === 'policy.proposed' || event.type === 'policy.decision') + .sort((a, b) => toMillis(a.timestamp) - toMillis(b.timestamp)); + + const proposalsByTask = new Map(); + const pairs = []; + const proposalCounts = {}; + const decisionCounts = {}; + const observedDecisionCounts = {}; + const nextActionCounts = {}; + const missingEvidenceCounts = {}; + const riskCounts = {}; + const inferredRiskCounts = {}; + const elevatedRiskCounts = {}; + + for (const event of selected) { + if (event.type === 'policy.proposed') { + const proposal = { + taskId: event.taskId, + timestamp: event.timestamp, + action: event.stage || 'unknown', + proposedRisk: event.data?.proposedRisk || null, + inferredRisk: event.data?.inferredRisk || null, + effectiveRisk: event.data?.effectiveRisk || null, + candidateHash: event.data?.candidateHash || null, + riskReasons: event.data?.riskReasons || [] + }; + const queue = proposalsByTask.get(event.taskId) || []; + queue.push(proposal); + proposalsByTask.set(event.taskId, queue); + increment(proposalCounts, proposal.action); + increment(riskCounts, proposal.effectiveRisk); + increment(inferredRiskCounts, proposal.inferredRisk); + if (proposal.proposedRisk && proposal.effectiveRisk && proposal.proposedRisk !== proposal.effectiveRisk) { + increment(elevatedRiskCounts, `${proposal.proposedRisk}->${proposal.effectiveRisk}`); + } + continue; + } + + const queue = proposalsByTask.get(event.taskId) || []; + const proposal = queue.shift() || { + taskId: event.taskId, + timestamp: event.timestamp, + action: event.stage || 'unknown', + proposedRisk: null, + inferredRisk: null, + effectiveRisk: null, + candidateHash: null, + riskReasons: [] + }; + proposalsByTask.set(event.taskId, queue); + const pair = { + ...proposal, + decisionTimestamp: event.timestamp, + mode: event.data?.mode || null, + decision: event.data?.decision || 'unknown', + observedDecision: event.data?.observedDecision || event.data?.decision || 'unknown', + enforced: event.data?.enforced !== false, + reasons: event.data?.reasons || [], + missingEvidence: event.data?.missingEvidence || [], + advisoryMissingEvidence: event.data?.advisoryMissingEvidence || [], + permitId: event.data?.permitId || null, + nextAction: null, + nextActionTimestamp: null, + reactionDelayMs: null + }; + increment(decisionCounts, pair.decision); + increment(observedDecisionCounts, pair.observedDecision); + for (const item of [...pair.missingEvidence, ...pair.advisoryMissingEvidence]) { + const category = String(item).split(':')[0].trim() || 'unknown'; + increment(missingEvidenceCounts, category); + } + pairs.push(pair); + } + + const proposals = selected + .filter(event => event.type === 'policy.proposed') + .map(event => ({ + taskId: event.taskId, + timestamp: event.timestamp, + action: event.stage || 'unknown' + })); + + for (const pair of pairs) { + if (pair.decision === 'allow' && pair.observedDecision === 'allow') continue; + const decisionAt = toMillis(pair.decisionTimestamp); + const next = proposals.find(proposal => ( + proposal.taskId === pair.taskId + && toMillis(proposal.timestamp) > decisionAt + )); + if (!next) continue; + pair.nextAction = next.action; + pair.nextActionTimestamp = next.timestamp; + pair.reactionDelayMs = Math.max(0, toMillis(next.timestamp) - decisionAt); + increment(nextActionCounts, `${pair.observedDecision}->${next.action}`); + } + + const tasks = [...new Set(pairs.map(pair => pair.taskId))]; + const disagreements = pairs.filter(pair => pair.observedDecision !== 'allow'); + const unresolved = disagreements.filter(pair => !pair.nextAction); + const reactionDelays = disagreements + .map(pair => pair.reactionDelayMs) + .filter(value => Number.isFinite(value)); + + return { + generatedAt: new Date().toISOString(), + taskFilter: taskId || null, + totals: { + tasks: tasks.length, + proposals: pairs.length, + enforcedBlocksOrEvidenceRequests: pairs.filter(pair => pair.decision !== 'allow').length, + observedBlocksOrEvidenceRequests: disagreements.length, + shadowOrAdvisoryDisagreements: pairs.filter(pair => pair.decision === 'allow' && pair.observedDecision !== 'allow').length, + riskElevations: pairs.filter(pair => pair.proposedRisk && pair.effectiveRisk && pair.proposedRisk !== pair.effectiveRisk).length, + reactionsObserved: disagreements.length - unresolved.length, + unresolvedDecisions: unresolved.length, + medianReactionDelayMs: reactionDelays.length > 0 + ? [...reactionDelays].sort((a, b) => a - b)[Math.floor(reactionDelays.length / 2)] + : null + }, + counts: { + proposedActions: sortedCounts(proposalCounts), + decisions: sortedCounts(decisionCounts), + observedDecisions: sortedCounts(observedDecisionCounts), + effectiveRisk: sortedCounts(riskCounts), + inferredRisk: sortedCounts(inferredRiskCounts), + riskElevations: sortedCounts(elevatedRiskCounts), + requestedEvidence: sortedCounts(missingEvidenceCounts), + nextActionsAfterPolicyFeedback: sortedCounts(nextActionCounts) + }, + reactions: disagreements.map(pair => ({ + taskId: pair.taskId, + action: pair.action, + mode: pair.mode, + decision: pair.decision, + observedDecision: pair.observedDecision, + effectiveRisk: pair.effectiveRisk, + reasons: pair.reasons, + missingEvidence: pair.missingEvidence, + advisoryMissingEvidence: pair.advisoryMissingEvidence, + nextAction: pair.nextAction, + reactionDelayMs: pair.reactionDelayMs, + timestamp: pair.decisionTimestamp + })) + }; +} + +export function formatPolicyReactionReport(report) { + const lines = []; + lines.push('Policy reaction report'); + lines.push(`Generated: ${report.generatedAt}`); + if (report.taskFilter) lines.push(`Task: ${report.taskFilter}`); + lines.push(''); + lines.push(`Tasks: ${report.totals.tasks}`); + lines.push(`Proposals: ${report.totals.proposals}`); + lines.push(`Observed blocks/evidence requests: ${report.totals.observedBlocksOrEvidenceRequests}`); + lines.push(`Enforced blocks/evidence requests: ${report.totals.enforcedBlocksOrEvidenceRequests}`); + lines.push(`Shadow/advisory disagreements: ${report.totals.shadowOrAdvisoryDisagreements}`); + lines.push(`Risk elevations: ${report.totals.riskElevations}`); + lines.push(`Reactions observed: ${report.totals.reactionsObserved}`); + lines.push(`Unresolved decisions: ${report.totals.unresolvedDecisions}`); + if (report.totals.medianReactionDelayMs !== null) { + lines.push(`Median reaction delay: ${report.totals.medianReactionDelayMs} ms`); + } + + const sections = [ + ['Proposed actions', report.counts.proposedActions], + ['Decisions', report.counts.decisions], + ['Observed decisions', report.counts.observedDecisions], + ['Effective risk', report.counts.effectiveRisk], + ['Risk elevations', report.counts.riskElevations], + ['Requested evidence', report.counts.requestedEvidence], + ['Next actions after policy feedback', report.counts.nextActionsAfterPolicyFeedback] + ]; + for (const [title, counts] of sections) { + lines.push(''); + lines.push(`${title}:`); + const entries = Object.entries(counts); + if (entries.length === 0) lines.push(' none'); + else for (const [key, count] of entries) lines.push(` ${key}: ${count}`); + } + + if (report.reactions.length > 0) { + lines.push(''); + lines.push('Reaction timeline:'); + for (const item of report.reactions) { + const requirements = [...item.reasons, ...item.missingEvidence, ...item.advisoryMissingEvidence]; + lines.push(` ${item.timestamp} ${item.taskId} ${item.action} -> ${item.observedDecision}; next=${item.nextAction || 'none'}`); + if (requirements.length > 0) lines.push(` ${requirements.join(' | ')}`); + } + } + return lines.join('\n'); +} From 64ad71ca02345b1ade02fd8794212cb7858946a3 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:24:38 -0400 Subject: [PATCH 24/34] feat: add policy reaction report CLI --- scripts/policy-report.mjs | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 scripts/policy-report.mjs diff --git a/scripts/policy-report.mjs b/scripts/policy-report.mjs new file mode 100644 index 0000000..aeaf912 --- /dev/null +++ b/scripts/policy-report.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { defaultEventLogPath } from '../lib/event-log.mjs'; +import { buildPolicyReactionReport, formatPolicyReactionReport } from '../lib/policy-report.mjs'; + +const args = process.argv.slice(2); +const options = {}; +for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--help' || arg === '-h') options.help = true; + else if (arg === '--json') options.format = 'json'; + else if (arg.startsWith('--')) { + options[arg.slice(2)] = args[index + 1]; + index += 1; + } +} + +if (options.help) { + console.log(`Usage: node scripts/policy-report.mjs [options] + +Options: + --file PATH Event log path + --task TASK_ID Restrict to one task + --format text|json + --json Alias for --format json + --help Show this help`); + process.exit(0); +} + +const path = resolve(options.file || defaultEventLogPath()); +let lines; +try { + lines = readFileSync(path, 'utf8').split(/\r?\n/).filter(Boolean); +} catch (error) { + console.error(`Unable to read event log ${path}: ${error.message}`); + process.exit(1); +} + +const events = []; +for (const line of lines) { + try { events.push(JSON.parse(line)); } catch {} +} + +const report = buildPolicyReactionReport(events, { taskId: options.task }); +if ((options.format || 'text') === 'json') console.log(JSON.stringify(report, null, 2)); +else console.log(formatPolicyReactionReport(report)); From 3301b415ffd4612141e826ecd9eb981ab9170caf Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:25:01 -0400 Subject: [PATCH 25/34] test: cover policy reaction reporting --- tests/policy-report-tests.mjs | 116 ++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/policy-report-tests.mjs diff --git a/tests/policy-report-tests.mjs b/tests/policy-report-tests.mjs new file mode 100644 index 0000000..8b9971d --- /dev/null +++ b/tests/policy-report-tests.mjs @@ -0,0 +1,116 @@ +import { strict as assert } from 'node:assert'; +import { buildPolicyReactionReport, formatPolicyReactionReport } from '../lib/policy-report.mjs'; + +const events = [ + { + type: 'policy.proposed', + taskId: 'task-a', + timestamp: '2026-07-23T10:00:00.000Z', + stage: 'build', + data: { + proposedRisk: 'low', + inferredRisk: 'high', + effectiveRisk: 'high', + riskReasons: ['authentication path'] + } + }, + { + type: 'policy.decision', + taskId: 'task-a', + timestamp: '2026-07-23T10:00:00.100Z', + stage: 'build', + data: { + mode: 'risk', + decision: 'needs_evidence', + observedDecision: 'needs_evidence', + enforced: true, + reasons: [], + missingEvidence: ['baseline: runtime baseline evidence is required for high-risk work'], + advisoryMissingEvidence: [], + permitId: null + } + }, + { + type: 'policy.proposed', + taskId: 'task-a', + timestamp: '2026-07-23T10:00:01.100Z', + stage: 'baseline', + data: { + proposedRisk: 'high', + inferredRisk: 'high', + effectiveRisk: 'high' + } + }, + { + type: 'policy.decision', + taskId: 'task-a', + timestamp: '2026-07-23T10:00:01.200Z', + stage: 'baseline', + data: { + mode: 'risk', + decision: 'allow', + observedDecision: 'allow', + enforced: true, + reasons: [], + missingEvidence: [], + advisoryMissingEvidence: [], + permitId: 'permit-1' + } + }, + { + type: 'policy.proposed', + taskId: 'task-b', + timestamp: '2026-07-23T10:01:00.000Z', + stage: 'commit', + data: { + proposedRisk: 'medium', + inferredRisk: 'medium', + effectiveRisk: 'medium' + } + }, + { + type: 'policy.decision', + taskId: 'task-b', + timestamp: '2026-07-23T10:01:00.100Z', + stage: 'commit', + data: { + mode: 'shadow', + decision: 'allow', + observedDecision: 'needs_evidence', + enforced: false, + reasons: [], + missingEvidence: [], + advisoryMissingEvidence: ['review: PASS bound to the current candidate'], + permitId: 'permit-shadow' + } + } +]; + +const report = buildPolicyReactionReport(events); +assert.equal(report.totals.tasks, 2); +assert.equal(report.totals.proposals, 3); +assert.equal(report.totals.observedBlocksOrEvidenceRequests, 2); +assert.equal(report.totals.enforcedBlocksOrEvidenceRequests, 1); +assert.equal(report.totals.shadowOrAdvisoryDisagreements, 1); +assert.equal(report.totals.riskElevations, 1); +assert.equal(report.totals.reactionsObserved, 1); +assert.equal(report.totals.unresolvedDecisions, 1); +assert.equal(report.reactions[0].nextAction, 'baseline'); +assert.equal(report.reactions[0].reactionDelayMs, 1000); +assert.equal(report.counts.riskElevations['low->high'], 1); +assert.equal(report.counts.requestedEvidence.baseline, 1); +assert.equal(report.counts.requestedEvidence.review, 1); +assert.equal(report.counts.nextActionsAfterPolicyFeedback['needs_evidence->baseline'], 1); + +const filtered = buildPolicyReactionReport(events, { taskId: 'task-a' }); +assert.equal(filtered.totals.tasks, 1); +assert.equal(filtered.totals.proposals, 2); +assert.equal(filtered.totals.observedBlocksOrEvidenceRequests, 1); + +const text = formatPolicyReactionReport(report); +assert.match(text, /Policy reaction report/); +assert.match(text, /low->high: 1/); +assert.match(text, /needs_evidence->baseline: 1/); +assert.match(text, /next=baseline/); + +console.log('policy-report-tests: passed'); From b4a611de4dbb74aa216cfbc9789495b82981069f Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:25:24 -0400 Subject: [PATCH 26/34] test: include policy reaction reporting coverage --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ba8ea1a..7f4d9b7 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,14 @@ "node": ">=18" }, "scripts": { - "test": "node tests/policy-kernel-tests.mjs && node tests/reliability-v020-tests.mjs && node tests/paid-fallback-state-tests.mjs && node tests/budget-tests.mjs && node tests/budget-audit-tests.mjs && node tests/routing-tests.mjs && node tests/runtime-tests.mjs && node tests/tool-integration-tests.mjs && node tests/bypass-detection.mjs", + "test": "node tests/policy-kernel-tests.mjs && node tests/policy-report-tests.mjs && node tests/reliability-v020-tests.mjs && node tests/paid-fallback-state-tests.mjs && node tests/budget-tests.mjs && node tests/budget-audit-tests.mjs && node tests/routing-tests.mjs && node tests/runtime-tests.mjs && node tests/tool-integration-tests.mjs && node tests/bypass-detection.mjs", "validate:agents": "bash scripts/validate-agent-configs.sh", "validate:routing": "node scripts/check-routing-defaults.mjs", "validate:budget": "node scripts/check-budget-config.mjs", "validate:feature": "node scripts/check-feature-contract.mjs", "validate": "npm run validate:agents && npm run validate:portable", "events": "node scripts/query-events.mjs", + "policy:report": "node scripts/policy-report.mjs", "validate:portable": "npm run validate:routing && npm run validate:budget && npm run validate:feature && node scripts/check-doc-links.mjs && npm test" }, "dependencies": { From 78f0f635c55e51b612d786a586eeb778ffd739d6 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:26:23 -0400 Subject: [PATCH 27/34] fix: name the policy permit argument explicitly --- .opencode/command/feature.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/command/feature.md b/.opencode/command/feature.md index e9a1ef8..2f98ccc 100644 --- a/.opencode/command/feature.md +++ b/.opencode/command/feature.md @@ -13,7 +13,7 @@ Create one stable `taskId`, inspect and plan the work, and retain semantic contr Before every delegated action and before commit: 1. Call `orchestration_policy` with the proposed action, reason, risk, paths, and available evidence. -2. On `allow`, pass its one-time permit to `agent_loop` or `orchestration_commit`. +2. On `allow`, pass the returned one-time `policyPermit` to `agent_loop` or `orchestration_commit`. 3. On `needs_evidence`, gather the requested evidence, record it, replan, or ask the user. 4. On `deny`, do not repeat the same proposal or bypass the kernel. From 92e84960124d7477d4e0bb1eb9ce28fe5399f1c3 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:27:45 -0400 Subject: [PATCH 28/34] ci: preserve plugin integration diagnostics --- .github/workflows/cross-platform.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index 950f186..a1b4eb6 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -56,7 +56,19 @@ jobs: name: runtime-test-${{ matrix.os }}-node-${{ matrix.node-version }} path: runtime-test.log - name: Test plugin integration - run: node tests/tool-integration-tests.mjs + shell: bash + run: | + set +e + node tests/tool-integration-tests.mjs 2>&1 | tee plugin-integration.log + status=${PIPESTATUS[0]} + set -e + exit "$status" + - name: Upload plugin-integration diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: plugin-integration-${{ matrix.os }}-node-${{ matrix.node-version }} + path: plugin-integration.log - name: Test bypass detection run: node tests/bypass-detection.mjs - name: Test installer in POSIX shell From 76c65a62d1c010a7f01a344575d0de511981bdaa Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:29:32 -0400 Subject: [PATCH 29/34] test: include failed policy worker result in assertion --- tests/tool-integration-tests.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tool-integration-tests.mjs b/tests/tool-integration-tests.mjs index e1ebd89..af8acf3 100644 --- a/tests/tool-integration-tests.mjs +++ b/tests/tool-integration-tests.mjs @@ -8,7 +8,7 @@ const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-tool-')); const fakeScript = resolve(dir, 'fake-opencode.cjs'); const fakeLog = resolve(dir, 'fake-opencode.jsonl'); writeFileSync(fakeScript, `const fs = require('fs'); -fs.appendFileSync(process.env.AGENT_LOOP_FAKE_LOG, JSON.stringify({ args: process.argv.slice(2), child: process.env.AGENT_LOOP_CHILD, taskId: process.env.AGENT_LOOP_TASK_ID, smokeTest: process.env.AGENT_LOOP_SMOKE_TEST || '' }) + '\\n'); +fs.appendFileSync(process.env.AGENT_LOOP_FAKE_LOG, JSON.stringify({ args: process.argv.slice(2), child: process.env.AGENT_LOOP_CHILD, taskId: process.env.AGENT_LOOP_TASK_ID, smokeTest: process.env.AGENT_LOOP_SMOKE_TEST || '' }) + '\n'); console.log(JSON.stringify({ type: 'step_start', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p1', type: 'step-start' } })); console.log(JSON.stringify({ type: 'text', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p2', type: 'text', text: 'ok' } })); console.log(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p3', type: 'step-finish', reason: 'stop', tokens: { input: 1, output: 1 } })); @@ -91,7 +91,7 @@ const result = await plugin.tool.agent_loop.execute({ policyPermit: buildDecision.permit.id }, context); const parsed = JSON.parse(result.output); -assert.equal(parsed.status, 'completed'); +assert.equal(parsed.status, 'completed', result.output); assert.match(parsed.successfulModel, /.+/); assert.equal(parsed.policy.task.taskId, taskId); assert.ok(parsed.policy.task.evidence.some(item => item.type === 'build' && item.source === 'runtime')); From 4f190d714d27f2933f405ae6f711b12a4e09defe Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:31:31 -0400 Subject: [PATCH 30/34] fix: make fake worker log newline unambiguous --- tests/tool-integration-tests.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tool-integration-tests.mjs b/tests/tool-integration-tests.mjs index af8acf3..cb5540e 100644 --- a/tests/tool-integration-tests.mjs +++ b/tests/tool-integration-tests.mjs @@ -8,7 +8,7 @@ const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-tool-')); const fakeScript = resolve(dir, 'fake-opencode.cjs'); const fakeLog = resolve(dir, 'fake-opencode.jsonl'); writeFileSync(fakeScript, `const fs = require('fs'); -fs.appendFileSync(process.env.AGENT_LOOP_FAKE_LOG, JSON.stringify({ args: process.argv.slice(2), child: process.env.AGENT_LOOP_CHILD, taskId: process.env.AGENT_LOOP_TASK_ID, smokeTest: process.env.AGENT_LOOP_SMOKE_TEST || '' }) + '\n'); +fs.appendFileSync(process.env.AGENT_LOOP_FAKE_LOG, JSON.stringify({ args: process.argv.slice(2), child: process.env.AGENT_LOOP_CHILD, taskId: process.env.AGENT_LOOP_TASK_ID, smokeTest: process.env.AGENT_LOOP_SMOKE_TEST || '' }) + String.fromCharCode(10)); console.log(JSON.stringify({ type: 'step_start', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p1', type: 'step-start' } })); console.log(JSON.stringify({ type: 'text', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p2', type: 'text', text: 'ok' } })); console.log(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p3', type: 'step-finish', reason: 'stop', tokens: { input: 1, output: 1 } })); From 58a292ef4316ebbeca8b1db676517415a879258a Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:33:57 -0400 Subject: [PATCH 31/34] test: isolate permit integration from smoke routing --- tests/tool-integration-tests.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/tool-integration-tests.mjs b/tests/tool-integration-tests.mjs index cb5540e..adb8606 100644 --- a/tests/tool-integration-tests.mjs +++ b/tests/tool-integration-tests.mjs @@ -39,6 +39,7 @@ const context = { metadata: () => {} }; const taskId = 'tool-integration-task'; +const verifiedModel = 'opencode/deepseek-v4-flash-free'; const approvalResult = await plugin.tool.orchestration_policy.execute({ taskId, @@ -79,7 +80,8 @@ assert.ok(buildDecision.permit?.id); const missingPermit = await plugin.tool.agent_loop.execute({ task: 'harmless smoke task', mode: 'build', - taskId + taskId, + models: [verifiedModel] }, context); assert.equal(JSON.parse(missingPermit.output).code, 'POLICY_PERMIT_REQUIRED'); @@ -88,6 +90,7 @@ const result = await plugin.tool.agent_loop.execute({ mode: 'build', maxRetries: 0, taskId, + models: [verifiedModel], policyPermit: buildDecision.permit.id }, context); const parsed = JSON.parse(result.output); @@ -112,6 +115,7 @@ const reused = await plugin.tool.agent_loop.execute({ task: 'reuse permit', mode: 'build', taskId, + models: [verifiedModel], policyPermit: buildDecision.permit.id }, context); assert.equal(JSON.parse(reused.output).code, 'POLICY_PERMIT_CONSUMED'); @@ -128,6 +132,7 @@ const blocked = await plugin.tool.agent_loop.execute({ task: 'nested', mode: 'build', taskId: 'nested-task', + models: [verifiedModel], policyPermit: 'not-valid' }, { ...context, agent: 'build-worker' }); delete process.env.AGENT_LOOP_CHILD; From addee452d727164b158756973fe05484a9e4addc Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:37:43 -0400 Subject: [PATCH 32/34] test: expose worker attempt logs on integration failure --- tests/tool-integration-tests.mjs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/tool-integration-tests.mjs b/tests/tool-integration-tests.mjs index adb8606..2c2b50a 100644 --- a/tests/tool-integration-tests.mjs +++ b/tests/tool-integration-tests.mjs @@ -1,7 +1,7 @@ import { strict as assert } from 'node:assert'; -import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; import AgentLoopPlugin from '../.opencode/plugins/agent-loop.js'; const dir = mkdtempSync(resolve(tmpdir(), 'agent-loop-tool-')); @@ -94,7 +94,22 @@ const result = await plugin.tool.agent_loop.execute({ policyPermit: buildDecision.permit.id }, context); const parsed = JSON.parse(result.output); -assert.equal(parsed.status, 'completed', result.output); +let workerDiagnostics = ''; +if (parsed.status !== 'completed' && parsed.logPath) { + try { + const logDirectory = dirname(parsed.logPath); + const files = readdirSync(logDirectory) + .filter(name => name.startsWith(taskId)) + .sort(); + workerDiagnostics = files.map(name => { + const path = resolve(logDirectory, name); + return `\n--- ${name} ---\n${readFileSync(path, 'utf8')}`; + }).join(''); + } catch (error) { + workerDiagnostics = `\nUnable to read worker diagnostics: ${error.message}`; + } +} +assert.equal(parsed.status, 'completed', `${result.output}${workerDiagnostics}`); assert.match(parsed.successfulModel, /.+/); assert.equal(parsed.policy.task.taskId, taskId); assert.ok(parsed.policy.task.evidence.some(item => item.type === 'build' && item.source === 'runtime')); From 04a36085b469a035a97c37c7447cd570f0793090 Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:39:38 -0400 Subject: [PATCH 33/34] fix: close fake step-finish event object --- tests/tool-integration-tests.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tool-integration-tests.mjs b/tests/tool-integration-tests.mjs index 2c2b50a..e4fbfde 100644 --- a/tests/tool-integration-tests.mjs +++ b/tests/tool-integration-tests.mjs @@ -11,7 +11,7 @@ writeFileSync(fakeScript, `const fs = require('fs'); fs.appendFileSync(process.env.AGENT_LOOP_FAKE_LOG, JSON.stringify({ args: process.argv.slice(2), child: process.env.AGENT_LOOP_CHILD, taskId: process.env.AGENT_LOOP_TASK_ID, smokeTest: process.env.AGENT_LOOP_SMOKE_TEST || '' }) + String.fromCharCode(10)); console.log(JSON.stringify({ type: 'step_start', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p1', type: 'step-start' } })); console.log(JSON.stringify({ type: 'text', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p2', type: 'text', text: 'ok' } })); -console.log(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p3', type: 'step-finish', reason: 'stop', tokens: { input: 1, output: 1 } })); +console.log(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID: 'fake-session', part: { id: 'p3', type: 'step-finish', reason: 'stop', tokens: { input: 1, output: 1 } } })); console.log('RESULT: PASS'); process.exit(0); `, 'utf8'); From d0774cc39870ab8d3153973aa3262c23281304bb Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:41:56 -0400 Subject: [PATCH 34/34] test: narrowly authorize policy-kernel Git execution --- tests/bypass-detection.mjs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/bypass-detection.mjs b/tests/bypass-detection.mjs index 9f2de7b..ca32268 100644 --- a/tests/bypass-detection.mjs +++ b/tests/bypass-detection.mjs @@ -4,14 +4,26 @@ import { resolve, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('..', import.meta.url)); -const allowed = new Set([ +const fullyAllowed = new Set([ 'runtime/opencode-worker-runner.mjs', 'tests/bypass-detection.mjs', 'scripts/watch-agent-configs.sh' ]); +const narrowExceptions = new Map([ + ['lib/orchestration-policy.mjs', new Set(['child-process-import', 'spawn-sync'])] +]); const excludedDirs = new Set(['.git', 'node_modules', 'tests', 'docs', 'templates', 'skills', '.opencode/agent-loop-logs', '.opencode/agent-loop-state']); const excludedFiles = [/README\.md$/, /CHANGELOG\.md$/, /package-lock\.json$/]; -const patterns = [/opencode\s+run/, /\bspawn\s*\(/, /\bexecFile\s*\(/, /\bexec\s*\(/, /child_process/, /createOpencodeClient/, /createOpencode\s*\(/]; +const patterns = [ + ['opencode-run', /opencode\s+run/], + ['spawn', /\bspawn\s*\(/], + ['spawn-sync', /\bspawnSync\s*\(/], + ['exec-file', /\bexecFile\s*\(/], + ['exec', /\bexec\s*\(/], + ['child-process-import', /child_process/], + ['opencode-client', /createOpencodeClient/], + ['opencode-create', /createOpencode\s*\(/] +]; function portableRelative(path) { return relative(root, path).split(sep).join('/'); @@ -33,12 +45,19 @@ function walk(dir, out = []) { const offenders = []; for (const file of walk(root)) { const rel = portableRelative(file); - if (allowed.has(rel)) continue; + if (fullyAllowed.has(rel)) continue; + const exceptions = narrowExceptions.get(rel) || new Set(); const text = readFileSync(file, 'utf8'); - for (const pattern of patterns) { - if (pattern.test(text)) offenders.push(`${rel}: ${pattern}`); + for (const [name, pattern] of patterns) { + if (exceptions.has(name)) continue; + if (pattern.test(text)) offenders.push(`${rel}: ${name} ${pattern}`); } } +const policyKernel = readFileSync(resolve(root, 'lib/orchestration-policy.mjs'), 'utf8'); +assert.match(policyKernel, /spawnSync\(['"]git['"],\s*args/, 'policy kernel may spawn only the Git executable through runGit'); +assert.doesNotMatch(policyKernel, /spawnSync\((?!['"]git['"])/, 'policy kernel must not spawn a non-Git executable'); +assert.doesNotMatch(policyKernel, /opencode\s+run|runOpenCodeWorker|createOpencodeClient|createOpencode\s*\(/, 'policy kernel must not invoke OpenCode workers directly'); + assert.deepEqual(offenders, [], `Production OpenCode invocation bypass detected:\n${offenders.join('\n')}`); console.log('bypass-detection: passed');