Skill Phase Enforcement¶
Status: Shipped. 3 tools, 4 skills with phase configs, 17 unit tests.
Tools: activate_skill, deactivate_skill, get_skill_phase
Skills with enforcement: lsp-rename (3 phases), lsp-refactor (5 phases), lsp-safe-edit (4 phases), lsp-verify (5 phases)
The Problem¶
Skills encode correct multi-step workflows ("analyze impact before editing", "simulate before writing to disk"). But encoding is not enforcement. An agent following /lsp-refactor can call apply_edit in the blast-radius phase, bypassing the safety gate that exists to prevent uninformed edits. The skill prose says "do not apply yet"; the runtime does nothing to stop it.
This matters because the most dangerous agent failures are ordering violations: applying edits before understanding blast radius, writing to disk before simulating, running tests before building. These produce correct-looking output that silently skips safety steps.
How It Works¶
Phase enforcement is a runtime state machine that sits in front of every tool handler. When an agent activates a skill, the tracker monitors incoming tool calls and enforces the phase ordering defined in the skill's tool_permissions metadata.
Agent calls activate_skill("lsp-rename", "block")
-> tracker enters phase 1: "prerequisites"
Agent calls start_lsp(...)
-> allowed (in phase 1 allowed list)
Agent calls go_to_symbol(...)
-> auto-advance to phase 2: "preview" (go_to_symbol is in phase 2's allowed list)
Agent calls prepare_rename(...)
-> allowed (in current phase)
Agent calls apply_edit(...)
-> BLOCKED: "apply_edit is forbidden in the preview phase"
-> recovery: "Complete the preview phase first. Allowed tools: [go_to_symbol, prepare_rename, find_references, rename_symbol]"
Agent calls get_diagnostics(...)
-> auto-advance to phase 3: "execute" (get_diagnostics is in phase 3's allowed list)
Agent calls apply_edit(...)
-> now allowed
Quick Start¶
Activate enforcement at the start of any supported skill workflow:
Check your current phase at any time:
get_skill_phase()
-> {
"active": true,
"skill_name": "lsp-rename",
"current_phase": "preview",
"phase_index": 1,
"total_phases": 3,
"mode": "warn",
"allowed_tools": ["go_to_symbol", "prepare_rename", "find_references", "rename_symbol"],
"forbidden_tools": ["apply_edit", "Edit", "Write", "format_document", "run_tests"],
"tool_history": ["start_lsp", "go_to_symbol"]
}
Deactivate when the workflow is complete:
Enforcement Modes¶
| Mode | Behavior | When to use |
|---|---|---|
warn |
Logs the violation, allows the call to proceed | Default. Learning mode; see violations in logs without breaking the workflow. |
block |
Returns an error with recovery guidance; tool call does not execute | Production safety. Prevents ordering violations from reaching the tool handler. |
In block mode, the error response includes structured JSON with the violation details and recovery guidance:
{
"error": "phase_violation",
"tool": "apply_edit",
"skill": "lsp-rename",
"current_phase": "preview",
"reason": "apply_edit is forbidden in the \"preview\" phase",
"recovery": "Complete the \"preview\" phase first. Allowed tools: [go_to_symbol, prepare_rename, find_references, rename_symbol]"
}
Phase Advancement¶
Phases advance automatically. There are no explicit "next phase" calls.
How it works: When a tool call matches a later phase's allowed list, the tracker advances to that phase. Tools in the current phase's allowed list stay allowed. Tools not mentioned in any phase (e.g., inspect_symbol, get_completions) pass through without restriction.
Rules:
- A tool matching the current phase's forbidden list: BLOCKED (or warned).
- A tool matching the global_forbidden list: BLOCKED regardless of phase.
- A tool matching the current phase's allowed list: allowed, no phase change.
- A tool matching a later phase's allowed list: allowed, phase advances to that phase.
- A tool not in any phase's allowed or forbidden list: allowed (pass-through for tools outside the skill's scope).
Phase skipping: If an agent calls a tool from phase 3 while in phase 1, the tracker skips phase 2 and advances directly to phase 3. This handles cases where some phases are optional (e.g., skipping start_lsp when the server is already running).
Supported Skills¶
lsp-rename (3 phases)¶
| Phase | Allowed | Forbidden | Purpose |
|---|---|---|---|
| prerequisites | start_lsp | (none) | Initialize LSP if needed |
| preview | go_to_symbol, prepare_rename, find_references, rename_symbol | apply_edit, Edit, Write | Locate symbol, validate, enumerate references |
| execute | get_diagnostics, rename_symbol, apply_edit | simulate_*, run_build | Apply the rename and verify |
Global forbidden: format_document, run_tests (rename does not format or test)
lsp-refactor (5 phases)¶
| Phase | Allowed | Forbidden | Purpose |
|---|---|---|---|
| blast_radius | blast_radius, go_to_symbol, find_references | apply_edit, simulate_*, Edit, Write | Analyze impact before any edits |
| speculative_preview | open_document, get_diagnostics, preview_edit, simulate_chain | apply_edit, Edit, Write | Simulate edits in memory |
| apply | apply_edit, format_document, Edit, Write | simulate_*, rename_symbol | Write changes to disk |
| build_verification | get_diagnostics, run_build | apply_edit, Edit, Write | Check the build |
| test_execution | get_tests_for_file, run_tests | apply_edit, Edit, Write | Run affected tests |
Global forbidden: rename_symbol (refactor uses direct edits, not rename)
Key safety property: apply_edit is forbidden in blast_radius and speculative_preview. The agent cannot write to disk until it has both analyzed impact and simulated the change.
lsp-safe-edit (4 phases)¶
| Phase | Allowed | Forbidden | Purpose |
|---|---|---|---|
| setup | start_lsp, open_document, get_diagnostics | apply_edit, Edit, Write | Capture baseline diagnostics |
| speculative_preview | preview_edit, simulate_chain | apply_edit, Edit, Write | Simulate before touching disk |
| apply | apply_edit, Edit, Write | simulate_* | Write the change |
| verify_and_fix | get_diagnostics, suggest_fixes, apply_edit, format_document | simulate_*, run_build, run_tests | Post-edit verification and fixes |
Global forbidden: rename_symbol, blast_radius
lsp-verify (5 phases)¶
| Phase | Allowed | Forbidden | Purpose |
|---|---|---|---|
| test_correlation | get_tests_for_file | apply_edit, Edit, Write | Map source to test files |
| diagnostics | start_lsp, get_diagnostics | apply_edit, Edit, Write | Layer 1: LSP diagnostics |
| build | run_build | apply_edit, Edit, Write | Layer 2: compiler build |
| tests | run_tests, Bash | apply_edit, Edit, Write | Layer 3: test suite |
| fix_and_format | suggest_fixes, apply_edit, format_document, get_diagnostics | simulate_*, run_build, run_tests | Apply fixes and format |
Global forbidden: simulate_* (verify is post-edit, not speculative), rename_symbol
External Tool Limitations¶
Phase configs include external tools like Edit, Write, and Bash in their forbidden lists. These are tools provided by the AI agent runtime (e.g., Claude Code), not by agent-lsp. Since those tools bypass MCP entirely, agent-lsp cannot enforce them at runtime.
These entries serve two purposes:
1. Agent guidance: get_skill_phase() includes them in the forbidden list, so agents see the full picture of what they should not call.
2. Documentation: The SKILL.md YAML and this reference document the complete set of ordering constraints.
For full enforcement of external tools, the agent must self-enforce based on the get_skill_phase() output.
Audit Trail¶
Phase events are logged to the JSONL audit trail (when --audit-log is configured):
| Event | Logged when |
|---|---|
activate_skill |
Agent activates enforcement |
deactivate_skill |
Agent deactivates enforcement |
phase_advance |
Tracker advances to a new phase |
phase_violation |
Tool call violates phase rules (both warn and block modes) |
Each record includes the skill name, current phase, and the tool that triggered the event.
Architecture¶
Phase enforcement lives in internal/phase/:
| File | Purpose |
|---|---|
types.go |
EnforcementMode, PhaseDefinition, SkillPhaseConfig, PhaseViolation, PhaseStatus |
matcher.go |
Glob matching for tool name patterns (trailing * wildcard) |
tracker.go |
Thread-safe Tracker state machine: activate, deactivate, check+record, status |
skills.go |
Built-in phase configs for the 4 supported skills |
The tracker is initialized in cmd/agent-lsp/server.go and injected into toolDeps. Every tool handler is wrapped via the addToolWithPhaseCheck generic function, which checks permissions before delegating to the real handler. This wrapper replaced direct mcp.AddTool calls so phase enforcement is automatic for all agent-lsp tools.
Adding Phase Enforcement to a New Skill¶
- Add
tool_permissionsto the skill's SKILL.md frontmatter (see existing skills for the YAML format). - Add a corresponding
SkillPhaseConfigininternal/phase/skills.go. - The new skill is automatically available via
activate_skill.
The tool names in skills.go use the unprefixed form (apply_edit, not mcp__lsp__apply_edit) because that is what agent-lsp receives in tool call requests. The SKILL.md YAML uses the prefixed form because that is what agents see.