graph TB
subgraph "Plugin (opencode-semantic-anchors)"
ENTRY[index.ts<br/>Plugin Entry Point]
subgraph "Config Layer"
LOADER[config/loader.ts<br/>YAML Loader]
SCHEMA[config/schema.ts<br/>Zod Schema]
end
subgraph "Rule Engine"
ENGINE[rules/engine.ts<br/>RuleEngine]
MATCHER[rules/matcher.ts<br/>AnchorMatcher]
PRESETS[rules/presets.ts<br/>Role Presets]
STATE[(Session State)]
end
subgraph "Hook Handlers"
TEB[hooks/toolExecute.ts<br/>tool.execute.before]
CM[hooks/chatMessage.ts<br/>chat.message]
AA[hooks/agentActivate.ts<br/>agent.activate]
end
subgraph "Custom Tools"
BYPASS[tools/bypass.ts<br/>anchor-bypass]
STATUS[tools/status.ts<br/>anchor-status]
RELOAD[tools/configReload.ts<br/>anchor-config-reload]
end
end
ENTRY --> LOADER
ENTRY --> ENGINE
ENTRY --> TEB
ENTRY --> CM
ENTRY --> AA
ENTRY --> BYPASS
ENTRY --> STATUS
ENTRY --> RELOAD
LOADER --> SCHEMA
LOADER --> PRESETS
LOADER --> ENGINE
ENGINE --> MATCHER
ENGINE --> STATE
TEB --> ENGINE
CM --> ENGINE
AA --> ENGINE
BYPASS --> STATE
STATUS --> STATE
RELOAD --> LOADER
5. Building Block View
Whitebox Overview
Block: Plugin Entry Point (plugin/opencode-semantic-anchors/src/index.ts)
Responsibility: Registers hooks and tools with opencode. Initialises config and Rule Engine at load time.
Interface:
import type { Plugin, tool } from '@opencode-ai/plugin'
export const opencodeSemanticAnchors: Plugin = async ({ client, $, directory, worktree }) => {
// Config + RuleEngine are initialised here
const config = await loadConfig()
const engine = new RuleEngine(config)
return {
'tool.execute.before': async (input, output) => {
const verdict = engine.evaluate({
type: 'tool',
toolName: input.tool,
args: output.args,
})
if (!verdict.allow) {
throw new Error(`🚫 ${verdict.message}`)
}
if (verdict.message) {
await client.app.log({
body: { service: 'opencode-semantic-anchors', level: 'warn', message: verdict.message }
})
}
},
tool: {
'anchor-bypass': anchorBypassTool(engine),
'anchor-status': anchorStatusTool(engine),
'anchor-config-reload': anchorConfigReloadTool(config),
},
}
}
Source Anchor (source): opencode Plugin SDK — Basic Structure (function-based API). https://opencode.ai/docs/plugins#basic-structure. "A plugin is a JavaScript/TypeScript module that exports one or more plugin functions. Each function receives a context object and returns a hooks object."
Note: The
chat.messageandagent.activatehooks are not yet documented as standard hooks in the current plugin docs. They can be connected via the genericeventsystem if needed. See https://opencode.ai/docs/plugins#events.
Block: Config Layer
config/loader.ts – YAML Loader
Responsibility: Liest opencode-semantic-anchors.yaml aus dem opencode Config-Verzeichnis, validiert gegen Schema, merged mit Role-based Presets.
Interface:
interface ConfigLoader {
load(): LoadedConfig // Loads config + validates
reload(): LoadedConfig // Reloads (for /anchor config-reload)
getActiveContracts(role: string): StructuralCouplingContract[]
}
interface LoadedConfig {
contracts: StructuralCouplingContract[]
presets: Record<string, string[]> // role → contractIds[]
settings: {
maxOverrides: number
stepConfirmationInterval: number
}
}
config/schema.ts – Zod Schema (Contract-Typisierung)
Responsibility: Validiert die YAML-Config gegen ein Zod-Schema. Stellt sicher, dass Contract-IDs existieren, Modes gültig sind, Trigger-Spezifikationen vollständig sind.
import { z } from 'zod'
export const TriggerSpecSchema = z.object({
type: z.enum(['tool', 'message', 'state']),
pattern: z.string(), // tool name pattern oder keyword regex
count: z.number().optional(), // für step confirmation
})
export const StructuralCouplingContractSchema = z.object({
id: z.string().min(1),
mode: z.enum(['BLOCK', 'WARN']),
description: z.string(),
anchorRefs: z.array(z.string()).optional(), // optional: referenzierte Semantic Anchor-IDs
triggers: z.array(TriggerSpecSchema).min(1),
maxOverrides: z.number().default(3),
})
export const ConfigSchema = z.object({
version: z.string().default('1'),
contracts: z.array(StructuralCouplingContractSchema),
presets: z.record(z.array(z.string())).optional(), // role → contractIds[]
settings: z.object({
maxOverrides: z.number().default(3),
stepConfirmationInterval: z.number().default(3),
}).default({}),
})
// Derived types
export type StructuralCouplingContract = z.infer<typeof StructuralCouplingContractSchema>
Block: Rule Engine
rules/engine.ts – RuleEngine
Responsibility: Zentraler Evaluator. Nimmt Events (toolName, message, role) entgegen, matched gegen aktive Contracts, gibt Verdikt zurück.
Interface:
class RuleEngine {
constructor(config: LoadedConfig)
evaluate(event: ToolEvent | MessageEvent): Verdict
setRole(role: string): void
getState(): SessionState
getActiveContracts(): StructuralCouplingContract[]
incrementOverride(): number // returns new count
reset(): void
}
interface Verdict {
allow: boolean
contract: StructuralCouplingContract | null
message: string
overrideTool?: string // Tool-Name für Bypass
}
interface SessionState {
role: string
toolCallCount: number
overrideCount: number
maxOverrides: number
lastConfirmation: Date | null
}
Evaluation order: 1. Check Step Confirmation Contract (toolCallCount % interval === 0 && no confirmation?) 2. Check active contracts against toolName/pattern 3. First match wins (priority order) 4. No match → ALLOW
rules/matcher.ts – AnchorMatcher
Responsibility: Matcht Tool-Namen und Message-Keywords gegen Trigger-Patterns.
class AnchorMatcher {
match(toolName: string, triggers: TriggerSpec[]): TriggerSpec | null
matchMessage(content: string, triggers: TriggerSpec[]): TriggerSpec | null
}
rules/presets.ts – Role Presets
Responsibility: Definiert Default-Contracts pro Rolle. 12 Presets entsprechend der 12 Semantic-Anchors-Rollen.
Source Anchor (source): Role definitions from the LLM-Coding/Semantic-Anchors repository. https://github.com/LLM-Coding/Semantic-Anchors/blob/main/docs/metadata/roles.yml. 12 roles: Software Developer, Architect, QA Engineer, DevOps Engineer, Product Owner, Business Analyst, Technical Writer, UX Designer, Data Scientist, Consultant, Team Lead, Educator.
const ROLE_PRESETS: Record<string, StructuralCouplingContract[]> = {
'software-developer': [
// Step Confirmation, Source Anchor, Intent Anchor
],
'software-architect': [
// + Boundary Anchor, Emergence Anchor
],
// ... 10 weitere Rollen
}
Block: Hook Handlers
hooks/toolExecute.ts
Responsibility: Triggert vor jeder Tool-Ausführung. Ruft RuleEngine.evaluate() auf. Bei BLOCK: throw new Error(). Bei WARN: loggt Warnung via client.app.log().
// tool.execute.before — wird im Plugin Entry Point als Hook registriert
export function createToolExecuteHandler(engine: RuleEngine) {
return async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
const verdict = engine.evaluate({
type: 'tool',
toolName: input.tool,
args: output.args,
})
if (!verdict.allow) {
// BLOCK: opencode fängt den Throw und zeigt die Message
throw new Error(`🚫 ${verdict.message}`)
}
if (verdict.message) {
// WARN: loggen, Ausführung läuft weiter
// (Logging via client.app.log — client ist im Plugin-Context verfügbar)
}
}
}
Source Anchor (Quelle): opencode Plugin SDK — .env protection example. https://opencode.ai/docs/plugins#env-protection. "throw new Error()" in
tool.execute.beforeblockt die Tool-Ausführung.
hooks/chatMessage.ts (via Event-System)
Responsibility: Observiert Chat-Nachrichten. Erkennt fehlende Intent-Deklarationen, fehlende Quellenangaben, oder narrative statt BLUF-Struktur. Gibt sanfte Vorschläge (blockt nicht).
Note: The current opencode plugin docs do not list a dedicated
chat.messagehook. Chat observation is instead done via the generic event system (eventhook). See https://opencode.ai/docs/plugins#events → Message Events.
// Als generisches Event registriert (anstatt dediziertem hook)
export function createMessageHandler(engine: RuleEngine) {
return async ({ event }: { event: { type: string; content?: string } }) => {
if (event.type !== 'message.updated') return
// Prüfe auf fehlenden Intent bei Task-Beginn
if (isNewTaskRequest(event) && !containsIntent(event.content)) {
// Logging via client.app.log oder append to prompt
}
// Prüfe auf faktische Behauptungen ohne Quelle
if (containsFactualClaim(event.content) && !containsSourceCitation(event.content)) {
// Logging via client.app.log
}
}
}
hooks/agentRoleChange.ts (via Session Events)
Responsibility: Lädt Role-based Preset beim Agent-Start oder Rollenwechsel.
Note: The current opencode plugin docs do not list a dedicated
agent.activatehook. Role changes can be detected viasession.updatedevents. See https://opencode.ai/docs/plugins#events → Session Events.
// agent.activate — via session Events
export function createRoleHandler(engine: RuleEngine, config: ConfigLoader) {
return async ({ event }: { event: { type: string; session?: { role?: string } } }) => {
if (event.type === 'session.created' || event.type === 'session.updated') {
const role = event.session?.role || 'default'
engine.setRole(role)
}
}
}
Block: Custom Tools
tools/bypass.ts – /anchor bypass [reason]
Responsibility: Erlaubt temporären Override eines aktiven BLOCKs. Zählt mit, loggt den Grund.
// tools/bypass.ts — erzeugt eine Tool-Definition für den Plugin-Export
import { tool } from '@opencode-ai/plugin'
export function anchorBypassTool(engine: RuleEngine) {
return tool({
description: 'Temporarily bypass an active contract block',
args: { reason: tool.schema.string() },
async execute(args) {
const state = engine.getState()
if (state.overrideCount >= state.maxOverrides) {
return `Error: Max overrides (${state.maxOverrides}) reached. Cannot bypass.`
}
const newCount = engine.incrementOverride()
return `Override ${newCount}/${state.maxOverrides}: "${args.reason}"`
},
})
}
tools/status.ts – /anchor status
Responsibility: Zeigt aktive Contracts, Zählerstände, Rolle.
// tools/status.ts
import { tool } from '@opencode-ai/plugin'
export function anchorStatusTool(engine: RuleEngine) {
return tool({
description: 'Show active contracts, counters, role',
args: {},
async execute() {
const state = engine.getState()
return JSON.stringify(state, null, 2)
},
})
}
tools/configReload.ts – /anchor config-reload
Responsibility: Lädt Config neu ohne Plugin-Neustart. Nur mit edit-Permission.
// tools/configReload.ts
import { tool } from '@opencode-ai/plugin'
export function anchorConfigReloadTool(config: ConfigLoader) {
return tool({
description: 'Reload config without plugin restart (requires edit permission)',
args: {},
async execute() {
config.reload()
return 'Config reloaded'
},
})
}
Datenfluss: Tool Execution (Blocking)
sequenceDiagram
participant User
participant OC as opencode
participant Plugin as opencode-semantic-anchors
participant Engine as RuleEngine
User->>OC: Type "edit file"
OC->>Plugin: tool.execute.before(edit, ...)
Plugin->>Engine: evaluate({toolName: "edit"})
Engine->>Engine: Match triggers, check counts
Engine-->>Plugin: Verdict {allow: false, contract: { id: "step-confirmation" }}
Plugin->>Plugin: throw new Error("🚫 ...")
Plugin--xOC: Error: "🚫 Step Confirmation block"
OC->>User: Show block message
User->>OC: "/anchor bypass 'test first'"
OC->>Plugin: anchor-bypass.execute({ reason: "test first" })
Plugin->>Engine: incrementOverride()
Engine-->>Plugin: overrideCount: 1/3
Plugin-->>OC: "Override 1/3: 'test first'"
OC->>Plugin: tool.execute.before(edit, ...) [retry by LLM]
Plugin->>Engine: evaluate({toolName: "edit"})
Engine-->>Plugin: Verdict {allow: true}
Plugin-->>OC: proceed
OC->>User: Execute edit
Take before Buy before Make: This data flow follows the opencode Plugin SDK pattern:
throw new Error()blocks the tool execution (see .env protection example, https://opencode.ai/docs/plugins#env-protection). The bypass mechanism uses a custom tool call that increments the override counter before the actual tool execution is retried.
Error Handling
Fail-Open Prinzip
On any error in a hook, the plugin returns { allow: true } so that the agent is not blocked. This principle applies to all layers:
Error in Config Layer → DefaultConfig + Warning Log Error in RuleEngine → allow: true + Error Log Error in Hook Handler → allow: true + Error Log Error in Custom Tool → Error Response to User
Rationale: The plugin is a runtime steering tool, not a security layer. A blocking error would crash the entire opencode session. Fail-Open ensures Workflow Continuity (Quality Goal #3).
Source Anchor (source): opencode Plugin SDK — .env protection example demonstrates that
throw new Error()intool.execute.beforeinterrupts tool execution. Our Fail-Open catches this throw and returns{ allow: true }. See https://opencode.ai/docs/plugins#env-protection.
Error scenarios per layer
1. Config Layer — YAML Parse Error
sequenceDiagram
participant Plugin as Plugin Entry Point
participant Loader as ConfigLoader
participant Schema as Zod Schema
Plugin->>Loader: load("opencode-semantic-anchors.yaml")
Loader->>Loader: read YAML file
alt YAML parse error
Loader-->>Loader: log error (level: error)
Loader-->>Plugin: DefaultConfig (built-in)
Plugin->>Plugin: continue with defaults
else invalid YAML structure
Loader->>Schema: validate(config)
Schema-->>Loader: ZodError[]
Loader->>Loader: log each validation error
Loader-->>Plugin: DefaultConfig + Warning
end
Behaviour:
- Invalid YAML → Default configuration (Step Confirmation with 3-call interval)
- Error details are logged via client.app.log({ level: "error" })
- Plugin still starts — no blockade
Affected interfaces:
// config/loader.ts
interface ConfigLoader {
load(): LoadedConfig // does not throw — always returns config
// On error: DefaultConfig with log warning
}
2. RuleEngine — Evaluation Error
sequenceDiagram
participant Hook as Hook Handler
participant Engine as RuleEngine
Hook->>Engine: evaluate(event)
alt State corrupt
Engine-->>Hook: throws Error
Hook->>Hook: log error (level: error)
Hook-->>OC: { allow: true } // fail-open
else Matcher crash
Engine->>Engine: match() throws
Engine-->>Hook: throws Error
Hook->>Hook: log error
Hook-->>OC: { allow: true }
else Memory overflow (toolCallCount > MAX_SAFE_INTEGER)
Engine->>Engine: reset toolCallCount to 0
Engine-->>Hook: Verdict { allow: true }
end
Behaviour:
- Every throw in evaluate() is caught by the hook handler
- RuleEngine has no external dependencies (no HTTP calls, no DB) — error sources are only programming errors or state corruption
- SessionState is not reset on error (only toolCallCount on overflow)
Affected interfaces:
// rules/engine.ts
class RuleEngine {
evaluate(event: ToolEvent | MessageEvent): Verdict
// Guarantee: evaluate() does not throw externally.
// Internal errors are logged, return value is always Verdict.
}
3. Hook Handler — Execution Error
sequenceDiagram
participant OC as opencode
participant Hook as Hook Handler
participant Engine as RuleEngine
OC->>Hook: tool.execute.before(edit, ...)
Hook->>Engine: evaluate(...)
Engine-->>Hook: Verdict { allow: true }
Hook->>Hook: format message for response
Hook-->>OC: { allow: true, message: "..." }
What happens when there is a bug in the hook handler itself: - opencode catches the throw and treats the hook as failed - Tool execution still proceeds (opencode’s own fail-open) - Plugin has no way to log the error (since the handler itself crashes)
Source Anchor (source): The opencode Plugin SDK does not explicitly document how opencode handles throwing hooks. The
.env protectioncode (https://opencode.ai/docs/plugins#env-protection) showsthrow new Error()as a legitimate way to block — so opencode catches the throw and interprets it as a block. Our Fail-Open ensures we do NOT accidentally block.
4. Custom Tools — Bypass Max Overrides
// tools/bypass.ts
handler: async (args) => {
const state = ruleEngine.getState()
if (state.overrideCount >= state.maxOverrides) {
return { error: `Max overrides (${state.maxOverrides}) reached. Cannot bypass.` }
}
const newCount = ruleEngine.incrementOverride()
return { message: `Override ${newCount}/${state.maxOverrides}: "${args.reason}"` }
}
Only defined error path in Custom Tools. The maxOverrides check prevents infinite bypasses.
Logging-Strategie
| Layer | Method | Details | PII |
|---|---|---|---|
Config Layer |
`client.app.log({ level: "warn" |
"error" })` |
Parse errors, validation errors |
No |
RuleEngine |
|
State corruption, matcher errors |
No |
Hook Handler |
|
Verdict results (allow/block) |
No (toolName only) |
Custom Tools |
Return value to user |
Override counters, error messages |
Source Anchor (source): opencode Plugin SDK — Logging API. https://opencode.ai/docs/plugins#logging. "Use
client.app.log()instead ofconsole.logfor structured logging."
Graceful Degradation per component
| Component | Total failure | Partial failure |
|---|---|---|
Config Layer |
DefaultConfig (built-in) |
Settings field missing → default value |
RuleEngine |
allow: true + Error Log |
One contract broken → other contracts run |
Hook Handler |
allow: true (opencode catches throw) |
Message formatting broken → allow: true without message |
Custom Tools |
Error response to user |
One tool broken → other tools run |
Error Handling Architecture Summary
┌─────────────────┐
│ opencode │
│ (ruft Hook auf) │
└────────┬────────┘
│ tool.execute.before
▼
┌─────────────────┐
│ Hook Handler │
│ try { │
│ evaluate() │
│ } catch(e) { │
│ log.error(e) │
│ return ALLOW │ ← Fail-Open
│ } │
└────────┬────────┘
│ evaluate()
▼
┌─────────────────┐
│ RuleEngine │
│ try { │
│ match() │
│ } catch(e) { │
│ log.error(e) │
│ return ALLOW │ ← Fail-Open
│ } │
└─────────────────┘
Fail-Open auf zwei Ebenen: Hook Handler fängt RuleEngine-Fehler, und opencode selbst fängt Hook-Fehler. Double layer of protection gegen Session-Blockade.
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.