Skip to content

MCP engine: deterministic workflow enforcement via tool scoping + hooks - #52

Merged
5uck1ess merged 28 commits into
mainfrom
feat/mcp-engine
Apr 10, 2026
Merged

MCP engine: deterministic workflow enforcement via tool scoping + hooks#52
5uck1ess merged 28 commits into
mainfrom
feat/mcp-engine

Conversation

@5uck1ess

Copy link
Copy Markdown
Owner

Summary

Replaces the broken subprocess-spawning engine with an MCP server that enforces deterministic workflow execution inside Claude Code.

  • MCP server (src/mcp/) — Go binary speaks JSON-RPC over stdio, exposes 4 tools: devkit_start, devkit_advance, devkit_status, devkit_list. Registered in plugin.json via mcpServers.
  • PreToolUse guard hook — reads session.json, hard-blocks (exit 2) out-of-step actions during command steps
  • Stop guard hook — blocks session end if workflow is incomplete
  • Condensed principles (skills/_principles.yml) — ~120 tokens of DRY/YAGNI/clean-code rules injected per step instead of full ~800 token skill files
  • Session JSON hot state — atomic write for hook reads (<50ms), SQLite for cold history
  • Skills/commands updated — all 8 entry points now use MCP tools instead of CLI bootstrap

Why

The old engine spawned claude -p as a subprocess. Claude Code's OAuth token doesn't work for subprocess calls, so the primary runner was broken. All "deterministic" workflows were running via markdown fallback — Claude followed them voluntarily. This PR makes step skipping structurally impossible.

Enforcement layers

Layer Mechanism Prevents
MCP tool scoping Server controls which step is current Skipping/reordering steps
PreToolUse hook (exit 2) Blocks tools during command steps Manual command execution
Stop hook Blocks session end during workflow Abandoning workflows

Token budget

~17k tokens for an 8-step workflow vs ~50k+ with the old monolithic approach (~65% reduction).

Stats

  • 17 commits, 31 files changed
  • +2,008 / -265 lines
  • New src/mcp/ package with full test coverage (integration + unit)
  • Removed scripts/ensure-engine.sh and scripts/install-engine.sh (binary ships in bin/, auto-PATH)

Test plan

  • cd src && go test ./... -race -count=1 — all passing
  • make install-plugin builds binary to bin/devkit
  • echo '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"capabilities":{}}}' | bin/devkit mcp returns valid JSON-RPC
  • Smoke test hook: echo '{"tool_name":"Bash"}' | CLAUDE_PLUGIN_DATA=/tmp/test bash hooks/devkit-guard.sh exits 0 (no session)
  • Integration tests cover: full lifecycle, loops with gates, principle injection, expect-failure

5uck1ess added 28 commits April 9, 2026 22:07
Extends Workflow struct with Enforce (hard/soft), BranchMode, and
Principles fields; adds per-step Principles override to WfStep.
Default for Enforce is "hard", applied in validate() so directly-
constructed Workflow values also normalize correctly.
Introduces SessionState struct with atomic write (tmp+rename) for race-free reads by PreToolUse hooks on every tool call.
Move all tool methods from server.go stubs into tools.go. Replace
workflow_list/workflow_status stubs with real devkit_list and
devkit_status implementations. Add tests for both tools covering
happy path, missing dir, parse errors, and no-session state.
Replaces startTool stub with full implementation: checks for running
session, finds/parses workflow YAML, writes session.json, creates SQLite
record, optionally creates git branch, and returns the first step prompt
with interpolated input and injected principles. Adds path traversal
guard on workflow name. Adds TestStart and TestStartAlreadyRunning.
Replaces handleLoopAdvance stub with full loop tracking: per-iteration
state (LoopIteration/LoopMax on SessionState), gate command evaluation,
until-string detection, and max-iteration enforcement. Fixes path
traversal gap in advanceTool where state.Workflow (from YAML name field)
was used in filepath.Join without the abs-path guard already applied at
start time. Three new tests cover max-iteration rollover, gate pass, and
gate fail cases.
Add devkit-guard.sh that reads session.json on every tool call and hard-blocks
Bash/Edit/Write/Read/Glob/Grep/Agent during active command steps (exit 2).
Register as matcher:"*" PreToolUse hook with 2s timeout in hooks.json.
Adds make install-plugin to build natively and place the binary in
bin/ (plugin root), so Claude Code auto-adds it to PATH. Also adds
bin/ to .gitignore to prevent committing platform binaries.
Replace ensure-engine.sh bootstrap + devkit workflow run with devkit_start/devkit_advance MCP tool calls in all 8 skill/command files. Remove scripts/ensure-engine.sh and scripts/install-engine.sh — binary now ships in bin/ via plugin.
Critical fixes:
- Shell hooks: use sys.argv[1] instead of string interpolation (injection fix)
- Shell hooks: fail closed (exit 2) when python3 unavailable
- Shell hooks: single python3 call instead of 4 per invocation
- Guard hook: block all standard tools during command steps (not just 7)
- Check all WriteSessionJSON/ClearSessionJSON errors (5 unchecked calls)
- Check ReadSessionJSON error in startTool (was swallowed with _)
- Check DB CreateSession/UpdateSessionStatus errors

Important fixes:
- Add Server.Close() for DB resource cleanup, defer in cmd/mcp.go
- Use StdioServer.Listen(ctx) for graceful shutdown (was ignoring ctx)
- Store validated filename in state.Workflow (not YAML name field)
- Add 5-minute command timeout via context.WithTimeout
- Add bounds check on state.CurrentIndex before array access
- Extract completeWorkflow() to deduplicate advance/advancePastLoop
- Validate non-empty paths in NewServer
- Fix stale CONTRIBUTING.md references
- Stop hook: output valid JSON via python3 json.dumps

New tests:
- Path traversal rejection (4 cases)
- Nonexistent workflow name
- Advance with no active session
- expect:success with failing command
- expect:failure with passing command
- Loop until condition (stay + exit)
- README: rewrite hook table (10→12), architecture diagram, add How It Works section
- ROADMAP: add MCP engine entry at top of implemented list
- CHANGELOG: full 2.1.0 entry covering PR #52 architectural shift
- GitHub about section: updated description, added mcp/mcp-server topics
Removes pre-MCP-conversion duplicate code. The YAML engine + MCP server
already implement all loop capabilities (max, gate, until) via the engine
and mcp.handleLoopAdvance. These Go implementations were unreachable
legacy from before the deterministic workflow conversion.

Deleted:
- src/loops/ entire package (improve, feature, bugfix, refactor, review,
  testgen, dispatch) — 2,651 lines
- src/cmd/{improve,feature,bugfix,refactor,review,testgen,dispatch,resume}.go
  — 631 lines of CLI shims that called loops.*
- Total: 3,282 lines

Kept:
- src/engine/ — YAML workflow engine (loops live here)
- src/mcp/ — MCP server with handleLoopAdvance
- src/runners/ — still used by engine for Codex/Gemini terminal fallback
- 'devkit workflow run <name>' — single entry point for all workflows
- 'devkit mcp' — MCP server mode
- 'devkit status' — session history
The old command described a pattern where Claude manually walked YAML
files step-by-step. Now the devkit engine controls execution via MCP
tools (devkit_start, devkit_advance, devkit_list). The command is now
the generic entry point for all 18 YAML workflows that don't have
dedicated skills (feature, bugfix, refactor, self-*, audit, etc.).

Verified all commands still work:
- /tri:{review,debug,security} → devkit_start(tri-*)
- /devkit:pr-ready → devkit_start(pr-ready)
- /devkit:pr-monitor → standalone (no engine dep)
- /devkit:status → devkit_status + shell
- /devkit:setup-rules → pure shell
- /devkit:workflow → generic entry for any YAML workflow
- research, deep-research, autoloop skills → devkit_start
- test-gen, doc-gen, changelog, onboard, scrape, adr → self-contained
pr-monitor was a manual-only command — nothing chained to it after
pr-ready, so users had to explicitly invoke it (and rarely did).

Changes:
- Add 'monitor' step to workflows/pr-ready.yml as the final loop step
  (max 10 iterations, until 'all resolved'). Handles CI waiting, comment
  classification, fix/reply cycles, re-review requests.
- Delete commands/pr-monitor.md (standalone command removed)
- Delete commands/pr-ready.md (command form removed)
- Add skills/pr-ready/SKILL.md — auto-activates on 'submit a PR',
  'create a pull request', 'ship this', 'open a PR', etc.
- Update README: 6 commands (was 7), 20 skills (was 19)
- Update ROADMAP to reflect new counts and rationale

Commands are now reserved for things needing explicit invocation:
tri-* (CLI detection), setup-rules (one-time), status (manual check),
workflow (generic runner). Everything else is a skill.
- README: remove /devkit:pr-ready slash command reference in Quick Start
  (contradicted line 134 which says it's now a skill), fix ast-grep
  comment (no such workflow 'repo-map'), update pr-gate hook description,
  remove stale src/loops/ entry in repo tree
- CONTRIBUTING.md: 8→6 command count
- commands/status.md: update example table row to say 'pr-ready skill'
- hooks/pr-gate.sh: update prompt text to reference pr-ready skill (not
  slash command) since /devkit:pr-ready no longer exists
- Delete docs/superpowers/specs/ — policy violation (specs belong in
  homebase, not public repo); the file is already in homebase
- Delete orphan lib files (similarity, metric) — only used by removed
  loops/ package. Remove HandoffPath/WriteHandoff from state.go and their
  tests for the same reason.
- Delete stale src/TODO.md — described architecture from before MCP engine
  rewrite (loops/, devkit improve/review/dispatch).
- Update cmd/status.go wording to match current command surface.
- pr-ready.yml monitor step — fix silent failures from review:
  - create-pr step now emits `PR: <number>` or `PR: FAILED <reason>` on
    last line; monitor parses it in STEP 0 and bails cleanly if missing.
  - gh API failures retry once, then halt with a terminal `all resolved
    (gh api unreachable ...)` message instead of misreading empty fetches
    as success.
  - Check state classification is explicit: PENDING/QUEUED is never
    treated as resolved; completion requires PENDING_CHECKS=0 AND
    FAILED_CHECKS=0 AND REMAINING=0.
  - Stuck detection now emits the engine's terminal `all resolved`
    string (so loop actually stops) with a diagnostic suffix.
  - Loop-exhausted observability note — engine halts with last iter
    output preserved in session log.
# Conflicts:
#	.claude-plugin/plugin.json
@5uck1ess
5uck1ess merged commit bfea8fb into main Apr 10, 2026
3 checks passed
@5uck1ess
5uck1ess deleted the feat/mcp-engine branch April 10, 2026 04:07
5uck1ess added a commit that referenced this pull request Apr 10, 2026
Ships the critical fixes a full mega-pr review on #52 uncovered, beyond
the binary-shipping bug already addressed in the first commit on this
branch. All findings from silent-failure-hunter and code-reviewer
marked CRITICAL or HIGH are addressed here.

CRITICAL — shell injection in command steps

runCommand interpolated {{input}} and {{step-id}} into `sh -c <cmd>`.
Any LLM-chosen input or contaminated prior-step output executed shell.
Command and gate strings are now literal YAML; values are passed via
$DEVKIT_INPUT and $DEVKIT_OUT_<step_id> env vars. The engine validator
rejects {{...}} in command/gate strings at parse time with an explicit
"shell injection mitigation" error. Both engine.runCommand and
mcp.runCommand were affected; both fixed. The 5 self-* workflows
(self-test, self-lint, self-improve, self-perf, self-migrate) were
updated to use $DEVKIT_INPUT.

Regression tests: TestRunWorkflowCommandRejectsInterpolation,
TestRunWorkflowCommandEnvInput, TestRunWorkflowCommandEnvPriorOutput.

CRITICAL — PreToolUse guard bypassable

hooks/devkit-guard.sh blocklist matched 11 hardcoded tool names and
missed Task, SlashCommand, TodoWrite, BashOutput, any mcp__* tool, and
every future tool Claude Code adds. Claude could trivially drive a
command step by calling Task (subagent) or any non-listed tool. Fixed
by inverting to an allowlist: during command steps only mcp__devkit*
and TodoWrite are permitted; everything else returns exit 2 with a
clear diagnostic including the attempted tool name.

CRITICAL — stop-guard and guard had opposite fail-closed policies

devkit-stop-guard approved on corrupt session.json; devkit-guard
blocked. A corrupt state file mid-workflow would silently end the
session. Both now fail closed with a diagnostic and handle the TOCTOU
race between the -f test and python open() by treating
FileNotFoundError as "no session" (matching pre-check intent).

HIGH — CLAUDE_PLUGIN_DATA unset silent no-op

Both guard hooks exited 0 with no output when the env var was missing.
Combined with the binary-shipping bug, enforcement was an invisible
no-op. Added stderr warnings so the degraded state is observable.

HIGH — loop until / branch when substring false-positives

strings.Contains("no failures found", "fail") returned true, silently
terminating loops or routing branches incorrectly. Same bug in
EvalBranch. Both now use word-boundary matching (grep -w semantics) via
a new containsWord helper: the sentinel must be bounded on both sides
by a non-alphanumeric/underscore character. Accepts idiomatic forms
like "TINY: short fix" and "attempt 2: ALL_PASSING" while rejecting
accidental substrings. Regression tests cover both directions.

HIGH — completeWorkflow swallowed git.CommitAll errors

A failed final commit was logged to stderr only, so the user saw
"WORKFLOW COMPLETE" with uncommitted branch work. Now collected into
a warnings list and surfaced in the MCP response body, alongside any
DB update or state-clear failures.

HIGH — runCommand discarded stdout/stderr on non-ExitError failures

When the error wasn't an *exec.ExitError (missing binary, permission
denied, ctx cancelled), the captured combined stream was thrown away.
Now returned alongside the wrapped error so users see the actual cause.
Also distinguishes context.DeadlineExceeded (returns exit 124 + "timed
out after 5m") from generic execution failure.

MEDIUM — YAML typos silently ignored

yaml.Unmarshal accepted unknown fields, so "commnd: foo" (missing e)
parsed as a no-op step. Now uses NewDecoder(bytes).KnownFields(true);
typos fail loudly at parse time.

MEDIUM — make sync-version clobbered manual bumps

Triggering any make target on a feature branch rewrote plugin.json to
the latest git tag, silently undoing manual version bumps. Now only
writes when the git tag is strictly higher than the current
plugin.json version (semver ordering via sort -V).

MEDIUM — pr-gate.sh violated project shell rules

Missing `set -euo pipefail`, used /tmp/devkit-pr-gate-done shared
across concurrent projects, and arithmetic fallback that silently
skipped the gate on any failure. Now strict mode, per-user cooldown
file under $XDG_CACHE_HOME/devkit, and explicit numeric-regex guard
on the timestamp comparison.

Stale docs

- src/cmd/status.go, README.md, CONTRIBUTING.md, CHANGELOG.md —
  replaced `devkit workflow run <name>` with the correct
  `devkit workflow <name> "<description>"`. There is no `run`
  subcommand; src/cmd/workflow.go defines `Use: "workflow [name] ..."`.
- README.md architecture block — reframed the "binary ships in bin/"
  claim to describe the wrapper + download flow introduced by this PR.
- workflows/pr-ready.yml description — updated to reflect all 8 steps
  (validate, necessity, lint, test, security, changelog, create-pr,
  monitor) rather than the stale 5-step list from before pr-monitor
  was folded in.
- hooks/hooks.json top-level description — now mentions the workflow
  guard and completion guard hooks that were silently added in 2.1.0.
- ROADMAP.md — 19 → 22 skills (stale count from before the Playwright
  skills were added in PR #53).
- CHANGELOG.md — new 2.1.2 section documenting every fix in this PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant