diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a72e3f8..b43824d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,8 @@ jobs: id: version run: | PLUGIN_VERSION=$(jq -r '.version' .claude-plugin/plugin.json) - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "0.0.0") + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + LATEST_TAG=${LATEST_TAG#v} echo "plugin=$PLUGIN_VERSION latest_tag=$LATEST_TAG" diff --git a/.gitignore b/.gitignore index cc2ad9b..bbbe10e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ dist/ *.log *.tmp .worktrees +docs* +codex/docs/ +codex/*.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0b60d36 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Devkit For Codex + +devkit is a deterministic workflow engine. In this repo, treat the YAML workflow and MCP state as the source of truth: start a workflow, execute only the current step, report the step result, then advance. + +## Workflow Discipline + +- Use `devkit_start` to begin a workflow and `devkit_advance` to move to the next step. +- Do not skip, reorder, or merge workflow steps unless the engine returns a branch/loop transition that does so. +- For command steps, do not run the command yourself. Call `devkit_advance`; the engine owns command execution. +- For prompt steps, do the requested work, summarize the result as the step output, then call `devkit_advance`. +- For loop steps, keep the iteration small and use `.devkit/scratchpads/current.md` when the step asks for it. +- If an active workflow exists, finish or explicitly stop it before starting a different workflow. + +## Tool Mapping + +- Claude `Bash` maps to Codex shell execution. +- Claude `Read`, `Grep`, and `Glob` map to shell reads, `rg`, and file inspection. +- Claude `Edit` and `Write` map to `apply_patch` for manual edits. +- Claude `Agent` and `Task` map to Codex subagents when the current environment exposes them; otherwise use external CLI runners or stop and report that model-diverse dispatch is unavailable. +- Claude `WebFetch` and `WebSearch` map to Codex web tools only when browsing is available and allowed. + +## Enforcement Notes + +Claude Code installs lifecycle hooks from `hooks/hooks.json` and can hard-block out-of-step tools. Codex-specific lifecycle hooks, prompt bridges, and config are planned for the separate `devkit-codex` adapter, which points Codex at this repo's MCP engine once released. Follow the same workflow policy voluntarily here, and rely on Codex sandbox/approval boundaries plus the MCP engine for stateful workflow control. + +Hard guarantees that still hold under Codex: + +- The MCP engine controls step order. +- Command steps execute inside the engine. +- Session state, branches, loops, gates, and reports remain engine-owned. + +Guarantees that are adapter-owned under Codex: + +- Blocking arbitrary shell/edit tool use during hard prompt steps. +- Running post-tool validation automatically after every edit or command. +- Blocking assistant stop while a workflow is incomplete. +- Validating subagent completion through a host lifecycle event. + +## Repo Boundaries + +- Keep Claude packaging in `.claude-plugin/`, `mcpb/`, and `hooks/` working. +- Keep Codex-specific integration in the separate `devkit-codex` repository once it is released, unless the change is shared engine behavior. +- Do not hand-edit generated binaries or bundled release artifacts. diff --git a/CLAUDE.md b/CLAUDE.md index 3396df2..287da8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,16 +2,16 @@ Navigation map for Claude Code working in this repo. Anchor-style, not narrative — keep it short. `pr-ready`'s doc-check targets this file, so stale entries get caught at PR time. -devkit is a Claude Code plugin: deterministic YAML workflow engine, thin-dispatcher skills, enforcement hooks, multi-agent consensus. +devkit is a Claude Code plugin: deterministic YAML workflow engine, dispatcher and capability skills, enforcement hooks, multi-agent consensus. ## Layout | Path | What | Grep here for | |---|---|---| -| `skills/` | 38 SKILL.md dispatchers | Skill descriptions; what triggers each workflow | +| `skills/` | 38 SKILL.md skills (engine dispatchers + capability tools) | Skill descriptions; what each skill triggers (workflow or direct exec) | | `skills/_principles.yml` | Shared cross-cutting principle config | Rules applied to every skill | | `skills/creating-workflows/` | Workflow YAML schema reference | Step types, `parallel:`/`branch:`/`loop:`/`expect:` semantics | -| `workflows/` | 21 YAML workflow definitions | What each skill actually runs | +| `workflows/` | 22 YAML workflow definitions | What each skill actually runs | | `src/engine/engine.go` | Go workflow executor | How `parallel: [ids]` skips-then-fans-out (`parallelChildren`), branch eval, loop gates | | `src/engine/workflow.go` | Workflow struct + YAML parsing | Step field definitions | | `src/cmd/guard.go` | Hook gatekeeper | What Bash/Edit/etc. is allowed per step type + enforce level | @@ -40,7 +40,8 @@ devkit is a Claude Code plugin: deterministic YAML workflow engine, thin-dispatc ## Architectural invariants -- **Workflows are deterministic.** The engine controls step sequencing, loops, branches, and gates — not the model. Skills are thin dispatchers that call `devkit_start` then loop on `devkit_advance` until done. +- **Workflows are deterministic.** The engine controls step sequencing, loops, branches, and gates — not the model. YAML is parsed once, the graph is fixed, steps run with state persistence and hook enforcement. +- **Skills come in two flavors.** *Engine-shim skills* (most) are thin dispatchers that call `devkit_start` then loop on `devkit_advance`, delegating to the workflow engine. *Capability skills* (e.g., `skills/scrape/`) are runtime-interpreted prompt templates where the model is the executor — it picks a backend from what's installed, generates shell/script via heredoc, judges output, falls through on failure. Use the capability form when work needs on-the-spot adaptation that pre-compiled YAML can't express (anti-bot arms races, heterogeneous backend outputs, content-judgment branching). - **Parallel step pattern.** Steps listed in another step's `parallel: [ids]` are skipped during the sequential walk (see `parallelChildren` in `src/engine/engine.go`) and dispatched concurrently via `runParallel`. Never expect a step that appears in a parallel list to also execute in its sequential position. - **Enforce levels gate hook permissions.** `enforce: soft` lets gather/setup steps run `git diff` and other shell work during dispatch; `enforce: hard` blocks them. The gatekeeper logic lives in `src/cmd/guard.go`; see `guard_test.go` for the allow/deny matrix. - **Engine binary is separate from the CLI wrapper.** `bin/devkit` is the committed shell wrapper; it execs `devkit-engine` (compiled Go, gitignored). The wrapper handles install/version checks; the engine handles workflow execution. diff --git a/README.md b/README.md index acbd08f..64f4d9c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # Devkit -A deterministic development harness for AI agents. The MCP engine controls workflow execution (step ordering, gates, loops, branches). The agent handles creativity. Every step is enforced, measured, and auditable. +A deterministic development harness for AI agents. The MCP engine controls workflow execution (step ordering, gates, loops, branches). The agent handles creativity. Every step is tracked, measured, and auditable. -Works with just Claude. Optionally adds Codex and Gemini for multi-agent consensus. +Works with Claude Code as the original full-enforcement host. Other host adapters can use the same MCP engine and workflows by registering `bin/devkit mcp`; the Codex adapter is distributed separately by `devkit-codex`. Optionally adds Codex and Gemini for multi-agent consensus. --- ## Install -### 1. Devkit (required) +### Claude Code + +#### 1. Devkit (required) ```bash /plugin marketplace add 5uck1ess/marketplace @@ -17,7 +19,7 @@ Works with just Claude. Optionally adds Codex and Gemini for multi-agent consens Auto-updates are enabled by default. Devkit updates itself when you restart Claude Code. -### 2. Multi-agent plugins (optional) +#### 2. Multi-agent plugins (optional) These enable `tri:*` commands (tri-review, tri-debug, tri-security, etc.) to run Claude + Codex + Gemini in parallel. @@ -36,7 +38,7 @@ If plugins aren't installed, the CLI fallbacks work too: brew install codex gemini-cli ``` -### 3. Companion plugins (optional) +#### 3. Companion plugins (optional) These handle concerns devkit doesn't — methodology, specialized reviews, and context management. No overlap. @@ -64,7 +66,19 @@ These handle concerns devkit doesn't — methodology, specialized reviews, and c /plugin install context-mode@context-mode ``` -### 4. Optional tools +### Codex + +Codex support lives in the separate `devkit-codex` adapter. That adapter installs Codex-specific hooks/config and points Codex at this repo's MCP server (`bin/devkit mcp`). This repo remains the core workflow engine and shared workflow/skill source of truth. + +Manual MCP registration shape for host adapters: + +```toml +[mcp_servers.devkit] +command = "/absolute/path/to/devkit/bin/devkit" +args = ["mcp"] +``` + +### Optional tools ```bash brew install rtk # Token optimization (60-90% savings on Bash output) @@ -165,22 +179,25 @@ devkit-engine probe-local ## How It Works -Devkit runs as an **MCP server** inside Claude Code. When a workflow starts, the engine takes control: +Devkit runs as an **MCP server** inside the host agent. Under Claude Code it is packaged as a plugin; other adapters register `bin/devkit mcp` through their host-specific MCP config. When a workflow starts, the engine takes control: ``` devkit_start("research", "best Go testing frameworks") → Engine creates session, returns Step 1 + condensed principles - → Claude executes the step using standard tools - → Claude calls devkit_advance(session_id) + → Host agent executes the step using standard tools + → Host agent calls devkit_advance(session_id) → Engine validates, records output, returns Step 2 → ...repeat until WORKFLOW COMPLETE -Enforcement (runs automatically): +Claude Code enforcement (runs automatically): PreToolUse hook → blocks out-of-step actions during command steps Stop hook → prevents session end during active workflows + +External adapters: + Register the same MCP server and provide host-specific hooks/guardrails. ``` -**Why MCP?** Claude can't skip steps because the engine controls what comes next. Claude can't call tools that aren't valid for the current step. The engine holds state — Claude doesn't self-report. +**Why MCP?** The host agent can't skip steps because the engine controls what comes next. The engine holds state — the agent doesn't self-report workflow position. Claude Code additionally blocks invalid tools through hooks; other hosts can add equivalent adapter-side guardrails while still relying on engine-owned command steps. --- @@ -300,7 +317,7 @@ All agents run in worktree isolation. ## Coding Rules -Language-specific rules that auto-activate when Claude reads matching files. Installed to `~/.claude/rules/` — rules guide how to write, hooks catch what you missed. +Language-specific rules that auto-activate when Claude reads matching files. Installed to `~/.claude/rules/` — rules guide how to write, hooks catch what you missed. Other host adapters should load the matching files from `resources/rules/`. ```bash /setup-rules @@ -337,16 +354,17 @@ MCP Server (bin/devkit mcp — auto-started by plugin) │ ├── Command steps → engine executes shell directly ($0 cost) │ │ Values passed via $DEVKIT_INPUT / $DEVKIT_OUT_ │ │ env vars — never interpolated into the command string. - │ ├── Prompt steps → Claude works, calls devkit_advance when done + │ ├── Prompt steps → host agent works, calls devkit_advance when done │ ├── Loop with gate → run, verify, keep or revert │ ├── Branch → case-insensitive word-boundary match → goto - │ └── Parallel → Agent tool dispatch (Claude/Codex/Gemini) + │ └── Parallel → host subagent/external runner dispatch └── Principles injected per step (~120 tokens, not full skill files) Enforcement: - ├── MCP tool scoping — Claude can only call devkit_advance to progress - ├── PreToolUse hook — exit 2 blocks tools during command steps - └── Stop hook — blocks session end during active workflows + ├── MCP state — host agent can only progress by calling devkit_advance + ├── Claude Code: PreToolUse hook exits 2 to block invalid tools + ├── Claude Code: Stop hook blocks session end during active workflows + └── External adapters: host-specific hooks/proxies enforce what their host exposes Terminal usage (devkit workflow ""): ├── Subprocess runners for Codex/Gemini CLI diff --git a/devkit.mcpb b/devkit.mcpb index 7b20007..840580b 100644 Binary files a/devkit.mcpb and b/devkit.mcpb differ diff --git a/devkit.mcpb.sources.json b/devkit.mcpb.sources.json index b98c79a..5bcadd7 100644 --- a/devkit.mcpb.sources.json +++ b/devkit.mcpb.sources.json @@ -2,7 +2,7 @@ "mcpb/launcher/main.go": "ad2ce60992bcff323663ac31a63dfae93e782c8556043be8b77ece3b874ade36", "mcpb/launcher/go.mod": "7f2e8c3f695fe8cbe8cafa7eb9a269e8d0e5141023f2ea20ef5fc7cbd2f22bc2", "mcpb/launcher/main_test.go": "b723d82d0ce371a6c46eb062a1a312cf6aaaf306ef7edf40f3e35972cc4b153f", - "mcpb/manifest.json": "b7d68aed66f89e2c2f7b78faa8ea43bcccfe771a47d9c7c3d267095459ceaab5", + "mcpb/manifest.json": "e1cf2fb068b915f51bfe179349b2a10f0079445f0ca37c0e0a96e8476f3994c9", "mcpb/server/devkit": "3833a48a67dfb8d9db7e1513617c0b1df5d7ebb6bf0c3023c60ab048ed63002f", - "mcpb/server/devkit.exe": "92d489e463ea2779d5b7e8008812319ba4e06cc2a7694df926e060236ee2384e" + "mcpb/server/devkit.exe": "13e8f43d946ff43dd28d85a4c06d55814a8d534e2628e7721d6e3b6d716d3026" } diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 02bc45d..9a03bef 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -1,19 +1,25 @@ { "manifest_version": "0.3", "name": "devkit-engine", - "version": "2.1.19", + "version": "2.1.29", "description": "devkit MCP engine launcher — POSIX proxies to bin/devkit, Windows spawns a native PE stub to bypass CreateProcess + CVE-2024-27980 constraints", - "author": { "name": "5uck1ess" }, + "author": { + "name": "5uck1ess" + }, "server": { "type": "binary", "entry_point": "server/devkit", "mcp_config": { "command": "${__dirname}/server/devkit", - "args": ["mcp"], + "args": [ + "mcp" + ], "platform_overrides": { "win32": { "command": "${__dirname}/server/devkit.exe", - "args": ["mcp"] + "args": [ + "mcp" + ] } } } diff --git a/mcpb/server/devkit.exe b/mcpb/server/devkit.exe index 5f79423..2b8db08 100755 Binary files a/mcpb/server/devkit.exe and b/mcpb/server/devkit.exe differ diff --git a/skills/adr/SKILL.md b/skills/adr/SKILL.md index 806781b..bbe959d 100644 --- a/skills/adr/SKILL.md +++ b/skills/adr/SKILL.md @@ -20,15 +20,15 @@ If the user hasn't specified, ask one question: "What decision are you documenti ## Step 2: Check Existing ADRs ```bash -mkdir -p docs/adr -ls docs/adr/ 2>/dev/null | grep -oE '^[0-9]+' | sort -n | tail -1 +mkdir -p .devkit/docs/adr +ls .devkit/docs/adr/ 2>/dev/null | grep -oE '^[0-9]+' | sort -n | tail -1 ``` Add 1 to the highest number found, zero-padded to 4 digits. If no output, the directory is empty — start at `0001`. ## Step 3: Write the ADR -Create `docs/adr/NNNN-short-title.md`: +Create `.devkit/docs/adr/NNNN-short-title.md`: ```markdown # NNNN. Short Decision Title diff --git a/skills/doc-gen/SKILL.md b/skills/doc-gen/SKILL.md index b23decd..3235723 100644 --- a/skills/doc-gen/SKILL.md +++ b/skills/doc-gen/SKILL.md @@ -5,7 +5,7 @@ description: Generate documentation for code — use when asked to document a mo # Documentation Generation -Deterministic doc generation. Analyze target → generate via the documenter subagent → write to docs/ or specified path. +Deterministic doc generation. Analyze target → generate via the documenter subagent → write to a requested non-repo path or `.devkit/docs/`. ## Invoke diff --git a/skills/health/SKILL.md b/skills/health/SKILL.md index f200995..3e55f4a 100644 --- a/skills/health/SKILL.md +++ b/skills/health/SKILL.md @@ -11,12 +11,16 @@ Report on devkit installation health and available capabilities. ### Plugins +Claude Code plugin checks: + ```bash echo "=== Plugin Status ===" echo -n "codex plugin: " && (/codex:status >/dev/null 2>&1 && echo "installed" || echo "not installed") echo -n "gemini plugin: " && (/gemini:status >/dev/null 2>&1 && echo "installed" || echo "not installed") ``` +Under non-Claude hosts, report Claude plugin status as "not applicable" and check MCP config plus external CLIs instead. + ### Devkit Engine Use the `devkit_status` tool to check workflow progress. @@ -109,6 +113,7 @@ List all skills from `skills/` directory, marking which ones need external CLIs: ## Rules - Check actual CLI availability with `command -v`, not just PATH +- If running under Codex, do not call Claude slash commands such as `/codex:status`; mark Claude plugin checks as not applicable - Report versions where possible - Clearly indicate which features work without external CLIs - Show tri:* as "partial" if some but not all CLIs are present diff --git a/skills/mega-pr/SKILL.md b/skills/mega-pr/SKILL.md index 87d7ccc..8b1ec39 100644 --- a/skills/mega-pr/SKILL.md +++ b/skills/mega-pr/SKILL.md @@ -20,14 +20,14 @@ description: >- # Mega PR Review -Run `/devkit:tri-review` and `/pr-review-toolkit:review-pr` simultaneously for maximum review coverage. +Run `/devkit:tri-review` and `/pr-review-toolkit:review-pr` simultaneously for maximum review coverage when those skills/plugins are available. Under non-Claude hosts, run devkit's tri-review MCP workflow and any available external review command; if `pr-review-toolkit` is not installed in the host, report that part as unavailable instead of fabricating results. ## Step 1: Launch Both Reviews **[PARALLEL]** — invoke both in a single message using the Skill tool: -1. `Skill: devkit:tri-review` — dispatches Claude + Codex + Gemini reviewers -2. `Skill: pr-review-toolkit:review-pr` — dispatches specialized aspect reviewers (silent-failure-hunter, type-design-analyzer, test-analyzer, code-reviewer, etc.) +1. `Skill: devkit:tri-review` — dispatches available model-diverse reviewers +2. `Skill: pr-review-toolkit:review-pr` — dispatches specialized aspect reviewers when the Claude plugin is installed (silent-failure-hunter, type-design-analyzer, test-analyzer, code-reviewer, etc.) Pass any user-provided arguments (custom prompt, specific files) to both skills unchanged. diff --git a/skills/onboard/SKILL.md b/skills/onboard/SKILL.md index 896c049..ef00bc0 100644 --- a/skills/onboard/SKILL.md +++ b/skills/onboard/SKILL.md @@ -5,7 +5,7 @@ description: Generate a codebase onboarding guide — use when asked to explain # Codebase Onboarding -Deterministic onboarding guide generation. Analyze structure → architect via the researcher subagent → write guide to `docs/ONBOARDING.md`. +Deterministic onboarding guide generation. Analyze structure → architect via the researcher subagent → write guide to `.devkit/docs/ONBOARDING.md`. ## Invoke diff --git a/skills/pr-ready/SKILL.md b/skills/pr-ready/SKILL.md index 7614ee1..097aa7e 100644 --- a/skills/pr-ready/SKILL.md +++ b/skills/pr-ready/SKILL.md @@ -38,7 +38,7 @@ Then follow each step the engine returns. Call `devkit_advance` after completing 3. **lint** — runs linter, fixes violations, loops until clean 4. **test** — runs test suite, fixes failures, loops until passing 5. **security** — scans for hardcoded secrets, injection, XSS, traversal, insecure deps -6. **doc-check** — classifies the diff (feature/bugfix/breaking/internal/docs-only) and decides per-file whether README, ROADMAP, CLAUDE.md, docs/, `skills/*/SKILL.md`, plugin.json, or `workflows/*.yml` need updates. Applies mechanical edits directly (moving roadmap bullets, adding command rows, syncing SKILL.md step lists); flags ambiguous updates as `[!]` in the output checklist. Commits applied edits with `docs: update ...` so they land in the PR alongside the code. CHANGELOG.md is intentionally skipped — it is managed by the release pipeline. Runs **before** `changelog` so any doc commit it creates is captured in the PR description. +6. **doc-check** — classifies the diff (feature/bugfix/breaking/internal/docs-only) and decides per-file whether README, ROADMAP, CLAUDE.md, `skills/*/SKILL.md`, plugin.json, or `workflows/*.yml` need updates. Applies mechanical edits directly (moving roadmap bullets, adding command rows, syncing SKILL.md step lists); flags ambiguous updates as `[!]` in the output checklist. Commits applied edits with `docs: update ...` so they land in the PR alongside the code. CHANGELOG.md is intentionally skipped — it is managed by the release pipeline. Runs **before** `changelog` so any doc commit it creates is captured in the PR description. 7. **changelog** — generates entry from git diff (now includes any doc-check commits) 8. **create-pr** — pushes branch, creates PR via gh pr create with title/summary/changelog/test plan 9. **monitor** — waits for CI, classifies reviewer comments (code_fix/style_nit/question/false_positive/out_of_scope), applies fixes, replies, pushes, loops until all resolved diff --git a/skills/setup-rules/SKILL.md b/skills/setup-rules/SKILL.md index 12c3696..d946d5d 100644 --- a/skills/setup-rules/SKILL.md +++ b/skills/setup-rules/SKILL.md @@ -1,12 +1,12 @@ --- name: setup-rules -description: Install devkit coding rules to ~/.claude/rules/ for language-specific auto-activation. Use when the user explicitly asks to set up devkit rules, install language rules, or run /devkit:setup-rules. One-shot installer with side effects (writes to ~/.claude/rules/) — user-only invocation, never auto-triggered. +description: Install devkit coding rules to ~/.claude/rules/ for Claude Code language-specific auto-activation. Use when the user explicitly asks to set up devkit rules, install language rules, or run /devkit:setup-rules. One-shot installer with side effects (writes to ~/.claude/rules/) — user-only invocation, never auto-triggered. Under non-Claude hosts, use AGENTS.md and resources/rules/ as reference instead of installing Claude rules. disable-model-invocation: true --- # Setup Coding Rules -Install language-specific coding rules to `~/.claude/rules/`. These auto-activate when Claude reads matching files and complement devkit's hooks — rules guide *how to write*, hooks catch *what you missed*. +Install language-specific coding rules to `~/.claude/rules/`. These auto-activate when Claude reads matching files and complement devkit's hooks — rules guide *how to write*, hooks catch *what you missed*. Non-Claude hosts do not read `~/.claude/rules/`; use `AGENTS.md` and the files in `resources/rules/` as project guidance. ## What it does diff --git a/skills/tri-review/SKILL.md b/skills/tri-review/SKILL.md index f46d34b..3d249b8 100644 --- a/skills/tri-review/SKILL.md +++ b/skills/tri-review/SKILL.md @@ -7,6 +7,8 @@ description: Three independent code reviews (smart + general + fast model tiers Deterministic three-tier model review. Gather → review-smart → review-general → review-fast (in parallel) → consolidate. Runs under enforce: soft so the gather step can call git diff. +Host note: Claude Code gets hard workflow/tool enforcement from lifecycle hooks. Codex can run the same MCP workflow, but hook-equivalent tool blocking is advisory unless a Codex shim is installed. + ## Invoke Use the `devkit_start` tool with workflow: "tri-review" and input: "{input}". diff --git a/src/engine/engine.go b/src/engine/engine.go index 8eec7c0..39d0258 100644 --- a/src/engine/engine.go +++ b/src/engine/engine.go @@ -336,6 +336,12 @@ func (e *Engine) runStep(ctx context.Context, step *WfStep, session *lib.Session // Include exit code in output so downstream steps can check it fullOutput := fmt.Sprintf("%s\nexit code: %d", strings.TrimRight(output, "\n"), exitCode) + if err := ValidateRequiredOutput(*step, output); err != nil { + dbStep.Status = "failed" + dbStep.ChangeSummary = err.Error() + e.db.UpdateStep(dbStep) + return 0, "", err + } dbStep.Status = "kept" dbStep.Kept = true @@ -365,6 +371,13 @@ func (e *Engine) runStep(ctx context.Context, step *WfStep, session *lib.Session e.db.UpdateStep(dbStep) return 0, "", fmt.Errorf("step %s failed: %w", step.ID, err) } + if err := ValidateRequiredOutput(*step, result.Output); err != nil { + dbStep.Status = "failed" + dbStep.CostUSD = result.CostUSD + dbStep.ChangeSummary = err.Error() + e.db.UpdateStep(dbStep) + return 0, "", err + } dbStep.Status = "kept" dbStep.Kept = true @@ -420,6 +433,15 @@ func (e *Engine) runLoop(ctx context.Context, step *WfStep, session *lib.Session fmt.Printf(" failed, retrying\n\n") continue } + if err := ValidateRequiredOutput(*step, result.Output); err != nil { + dbStep.Status = "failed" + dbStep.CostUSD = result.CostUSD + dbStep.ChangeSummary = err.Error() + e.db.UpdateStep(dbStep) + consecutiveFailures++ + fmt.Printf(" failed required output check, retrying\n\n") + continue + } iterCost := result.CostUSD @@ -575,6 +597,14 @@ func (e *Engine) runParallel(ctx context.Context, dispatcher *WfStep, allSteps [ results[j] = parallelResult{id: pid, err: err} return } + if err := ValidateRequiredOutput(*step, result.Output); err != nil { + dbStep.Status = "failed" + dbStep.CostUSD = result.CostUSD + dbStep.ChangeSummary = err.Error() + e.db.UpdateStep(dbStep) + results[j] = parallelResult{id: pid, err: err} + return + } dbStep.Status = "kept" dbStep.Kept = true diff --git a/src/engine/engine_test.go b/src/engine/engine_test.go index 54b9871..5b13ce3 100644 --- a/src/engine/engine_test.go +++ b/src/engine/engine_test.go @@ -270,6 +270,23 @@ steps: - id: a command: "echo hi" enforce: soft`, "enforce on a command step"}, + {"invalid require regex", `name: T +steps: + - id: a + prompt: x + require: + last_line_regex: "["`, "require.last_line_regex is invalid"}, + {"blank require contains", `name: T +steps: + - id: a + prompt: x + require: + contains: [""]`, "require.contains must not include blank strings"}, + {"empty require", `name: T +steps: + - id: a + prompt: x + require: {}`, "require must declare at least one check"}, } for _, tt := range tests { @@ -285,6 +302,89 @@ steps: } } +func TestValidateRequiredOutput(t *testing.T) { + tests := []struct { + name string + require *Require + output string + wantErr string + }{ + { + name: "nil require passes", + require: nil, + output: "", + }, + { + name: "non empty passes", + require: &Require{NonEmpty: true}, + output: "done", + }, + { + name: "non empty rejects whitespace", + require: &Require{NonEmpty: true}, + output: " \n\t", + wantErr: "require.non_empty", + }, + { + name: "contains matches multiline output", + require: &Require{Contains: []string{"needle"}}, + output: "first line\nneedle here\nlast line", + }, + { + name: "contains rejects missing text", + require: &Require{Contains: []string{"needle"}}, + output: "first line\nlast line", + wantErr: "require.contains", + }, + { + name: "until uses line anchored sentinel matching", + require: &Require{Until: "DONE"}, + output: "working\nDONE\n", + }, + { + name: "until rejects substring", + require: &Require{Until: "DONE"}, + output: "UNDONE", + wantErr: "require.until", + }, + { + name: "last line regex passes", + require: &Require{LastLineRegex: `^PR: [0-9]+$`}, + output: "created\nPR: 123\n", + }, + { + name: "last line regex rejects earlier match", + require: &Require{LastLineRegex: `^PR: [0-9]+$`}, + output: "PR: 123\nextra", + wantErr: "require.last_line_regex", + }, + { + name: "error includes attempted output", + require: &Require{LastLineRegex: `^PR: [0-9]+$`}, + output: "created PR at https://example.test/pull/123", + wantErr: "attempted output", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRequiredOutput(WfStep{ID: "step", Require: tt.require}, tt.output) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("ValidateRequiredOutput returned error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr) + } + }) + } +} + func TestParseBudget(t *testing.T) { yaml := ` name: Budgeted @@ -872,6 +972,38 @@ func TestRunWorkflowCommandStep(t *testing.T) { } } +func TestRunWorkflowCommandRequireLastLineUsesRawOutput(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := newMockRunner(nil, nil) + eng := mustEngine(t, db, git, runner, dir) + + wf := &Workflow{ + Name: "test", + Steps: []WfStep{ + { + ID: "check", + Command: "printf 'ready\\nRESULT: ok\\n'", + Require: &Require{ + LastLineRegex: `^RESULT: ok$`, + }, + }, + }, + } + + res, err := eng.RunWorkflow(context.Background(), wf, RunConfig{Input: "test"}) + if err != nil { + t.Fatalf("RunWorkflow: %v", err) + } + if !strings.Contains(res.Outputs["check"], "exit code: 0") { + t.Errorf("stored command output should still include exit code, got %q", res.Outputs["check"]) + } + if runner.callIdx != 0 { + t.Errorf("runner called %d times, want 0 for command step", runner.callIdx) + } +} + func TestRunWorkflowCommandEnvInput(t *testing.T) { // Regression: command steps must NOT interpolate {{input}} — values // come via $DEVKIT_INPUT to prevent shell injection. @@ -1330,6 +1462,87 @@ func TestRunWorkflowLoopGateRecovery(t *testing.T) { } } +func TestRunWorkflowLoopRequireBeforeGateRecovery(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := newMockRunner([]runners.RunResult{ + result("missing required marker\nALL_DONE"), + result("READY but gate fails"), + result("READY\nALL_DONE"), + }, nil) + eng := mustEngine(t, db, git, runner, dir) + + counterDir := t.TempDir() + counterFile := filepath.Join(counterDir, "gate-counter") + if err := os.WriteFile(counterFile, []byte("0"), 0o644); err != nil { + t.Fatal(err) + } + gateScript := fmt.Sprintf( + `count=$(cat %q); count=$((count + 1)); printf '%%s' "$count" > %q; test "$count" -ge 2`, + counterFile, counterFile, + ) + + wf := &Workflow{ + Name: "test", + Steps: []WfStep{ + { + ID: "fix", + Model: "smart", + Prompt: "Fix", + Require: &Require{Contains: []string{"READY"}}, + Loop: &Loop{ + Max: 5, + Until: "ALL_DONE", + Gate: gateScript, + }, + }, + }, + } + + res, err := eng.RunWorkflow(context.Background(), wf, RunConfig{Input: "test"}) + if err != nil { + t.Fatalf("RunWorkflow: %v", err) + } + if runner.callIdx != 3 { + t.Errorf("runner called %d times, want 3", runner.callIdx) + } + counterBytes, err := os.ReadFile(counterFile) + if err != nil { + t.Fatal(err) + } + if got := string(counterBytes); got != "2" { + t.Errorf("gate ran %s times, want 2; require failure should not run gate", got) + } + + var failed, reverted, kept int + for _, s := range res.Steps { + switch s.Status { + case "failed": + failed++ + if !strings.Contains(s.ChangeSummary, "attempted output") { + t.Errorf("failed require step summary = %q, want attempted output", s.ChangeSummary) + } + case "reverted": + reverted++ + case "kept": + kept++ + } + } + if failed != 1 { + t.Errorf("failed steps = %d, want 1", failed) + } + if reverted != 1 { + t.Errorf("reverted steps = %d, want 1", reverted) + } + if kept != 1 { + t.Errorf("kept steps = %d, want 1", kept) + } + if got := res.Outputs["fix"]; !strings.Contains(got, "READY") || !strings.Contains(got, "ALL_DONE") { + t.Errorf("final output = %q, want kept passing output", got) + } +} + func TestParseCommandStep(t *testing.T) { yaml := ` name: CmdTest diff --git a/src/engine/workflow.go b/src/engine/workflow.go index 05f50d1..b403b5c 100644 --- a/src/engine/workflow.go +++ b/src/engine/workflow.go @@ -7,6 +7,7 @@ import ( "bytes" "fmt" "os" + "regexp" "sort" "strings" @@ -54,6 +55,7 @@ type WfStep struct { Loop *Loop `yaml:"loop"` Branch []Branch `yaml:"branch"` Principles []string `yaml:"principles"` // per-step override + Require *Require `yaml:"require,omitempty"` // Enforce overrides the workflow-level enforce for this step only. // Empty inherits from Workflow.Enforce. Lets a workflow keep most // prompt steps under hard (mid-step tool block) while allowing @@ -63,6 +65,21 @@ type WfStep struct { Enforce EnforceMode `yaml:"enforce,omitempty"` } +// Require declares host-neutral output contracts checked by the engine +// before a step can advance. It is intentionally small: these are not a +// replacement for tests or review, just deterministic shape checks for +// sentinel values that later workflow steps depend on. +type Require struct { + NonEmpty bool `yaml:"non_empty"` + Contains []string `yaml:"contains"` + Until string `yaml:"until"` + LastLineRegex string `yaml:"last_line_regex"` +} + +func (r Require) HasChecks() bool { + return r.NonEmpty || len(r.Contains) > 0 || r.Until != "" || r.LastLineRegex != "" +} + // EffectiveEnforce returns the enforcement mode for a step, falling back // to the workflow-level setting when the step does not override it, and // to EnforceHard when neither is set. Callers must use this instead of @@ -222,6 +239,24 @@ func validate(wf *Workflow) error { if s.Loop != nil && s.Loop.Gate != "" && strings.Contains(s.Loop.Gate, "{{") { return fmt.Errorf("step %q loop.gate must not use {{...}} — pass values via $DEVKIT_INPUT or $DEVKIT_OUT_ instead (shell injection mitigation)", s.ID) } + if s.Require != nil { + if !s.Require.HasChecks() { + return fmt.Errorf("step %q require must declare at least one check", s.ID) + } + if s.Require.LastLineRegex != "" { + if _, err := regexp.Compile(s.Require.LastLineRegex); err != nil { + return fmt.Errorf("step %q require.last_line_regex is invalid: %w", s.ID, err) + } + } + if s.Require.Until != "" && strings.TrimSpace(s.Require.Until) == "" { + return fmt.Errorf("step %q require.until must not be blank", s.ID) + } + for _, want := range s.Require.Contains { + if strings.TrimSpace(want) == "" { + return fmt.Errorf("step %q require.contains must not include blank strings", s.ID) + } + } + } } // Validate branch targets exist @@ -293,6 +328,65 @@ func Interpolate(prompt string, input string, outputs map[string]string) string return result } +// ValidateRequiredOutput checks a step's optional require: contract against +// its output. It is used by both the MCP engine and terminal workflow runner +// so host-specific adapters cannot accidentally bypass deterministic +// downstream shape checks. +func ValidateRequiredOutput(step WfStep, output string) error { + if step.Require == nil { + return nil + } + req := step.Require + if req.NonEmpty && strings.TrimSpace(output) == "" { + return requiredOutputError(step.ID, output, "output failed require.non_empty") + } + for _, want := range req.Contains { + if !strings.Contains(output, want) { + return requiredOutputError(step.ID, output, "output failed require.contains %q", want) + } + } + if req.Until != "" && !MatchUntil(output, req.Until) { + return requiredOutputError(step.ID, output, "output failed require.until %q", req.Until) + } + if req.LastLineRegex != "" { + last := lastNonEmptyLine(output) + re, err := regexp.Compile(req.LastLineRegex) + if err != nil { + return fmt.Errorf("step %q has invalid require.last_line_regex: %w", step.ID, err) + } + if !re.MatchString(last) { + return requiredOutputError(step.ID, output, "output last line %q failed require.last_line_regex %q", last, req.LastLineRegex) + } + } + return nil +} + +func requiredOutputError(stepID, output, format string, args ...any) error { + detail := fmt.Sprintf(format, args...) + return fmt.Errorf("step %q %s; attempted output: %q", stepID, detail, truncateRequiredOutput(output, 300)) +} + +func truncateRequiredOutput(output string, limit int) string { + output = strings.TrimSpace(output) + if len(output) <= limit { + return output + } + if limit <= 3 { + return output[:limit] + } + return output[:limit-3] + "..." +} + +func lastNonEmptyLine(output string) string { + lines := strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if trimmed := strings.TrimSpace(lines[i]); trimmed != "" { + return trimmed + } + } + return "" +} + // EvalBranch checks step output against branch conditions. // Returns the goto target step ID, or "" if no match. // diff --git a/src/mcp/tools.go b/src/mcp/tools.go index 633ed09..e0aa926 100644 --- a/src/mcp/tools.go +++ b/src/mcp/tools.go @@ -290,7 +290,8 @@ func (s *Server) formatStepResponse(wf *engine.Workflow, state *lib.SessionState } else if len(step.Parallel) > 0 { fmt.Fprintf(&b, "TYPE: parallel dispatch\n") fmt.Fprintf(&b, "DISPATCH: %s\n", strings.Join(step.Parallel, ", ")) - fmt.Fprintf(&b, "Use the Agent tool and plugins to run these in parallel, then call devkit_advance.\n") + fmt.Fprintf(&b, "Run each listed step concurrently through the host subagent facility, then call devkit_advance with a consolidated result.\n") + fmt.Fprintf(&b, "Use host-native subagents or external runners when available; give each subagent a bounded scope, separate write ownership, and require files changed, verification run, and remaining risks before completion.\n") } else { prompt := engine.Interpolate(step.Prompt, input, state.Outputs) fmt.Fprintf(&b, "PROMPT: %s\n", prompt) @@ -326,6 +327,21 @@ func (s *Server) formatStepResponse(wf *engine.Workflow, state *lib.SessionState fmt.Fprintf(&b, "[stuck] %s\n", strings.Join(rules, "; ")) } } + if step.Require != nil { + fmt.Fprintf(&b, "\nREQUIRE:\n") + if step.Require.NonEmpty { + fmt.Fprintf(&b, "- non_empty\n") + } + for _, want := range step.Require.Contains { + fmt.Fprintf(&b, "- contains: %s\n", want) + } + if step.Require.Until != "" { + fmt.Fprintf(&b, "- until: %s\n", step.Require.Until) + } + if step.Require.LastLineRegex != "" { + fmt.Fprintf(&b, "- last_line_regex: %s\n", step.Require.LastLineRegex) + } + } fmt.Fprintf(&b, "\nCall devkit_advance when this step is complete.\n") return b.String() @@ -424,15 +440,27 @@ func (s *Server) advanceTool() (mcpmcp.Tool, mcpgo.ToolHandlerFunc) { return mcpmcp.NewToolResultError(fmt.Sprintf("step %s: expected success but got exit %d\n%s", currentStep.ID, exitCode, output)), nil } + if err := engine.ValidateRequiredOutput(currentStep, output); err != nil { + return mcpmcp.NewToolResultError(err.Error()), nil + } state.Outputs[currentStep.ID] = output } else { // Prompt/parallel step — record output from Claude args := req.GetArguments() + var output string + outputProvided := false if outputArg, ok := args["output"]; ok && outputArg != nil { if outputStr, ok := outputArg.(string); ok { - state.Outputs[currentStep.ID] = outputStr + output = outputStr + outputProvided = true } } + if err := engine.ValidateRequiredOutput(currentStep, output); err != nil { + return mcpmcp.NewToolResultError(err.Error()), nil + } + if outputProvided || (currentStep.Require != nil && currentStep.Require.HasChecks()) { + state.Outputs[currentStep.ID] = output + } } // Handle loop steps. Do NOT pre-release the claim here — if diff --git a/src/mcp/tools_test.go b/src/mcp/tools_test.go index 1f7006f..d99e24f 100644 --- a/src/mcp/tools_test.go +++ b/src/mcp/tools_test.go @@ -244,6 +244,56 @@ steps: } } +func TestStartParallelStepIncludesHostSubagentGuidance(t *testing.T) { + wfDir := t.TempDir() + dataDir := t.TempDir() + + writeFile(t, filepath.Join(wfDir, "parallel.yml"), `name: parallel +description: Parallel dispatch workflow +steps: + - id: dispatch + parallel: [review-smart, review-fast] + - id: review-smart + prompt: Smart review {{input}} + - id: review-fast + prompt: Fast review {{input}} +`) + + srv := newTestServer(t, dataDir, wfDir) + _, handler := srv.startTool() + + req := mcpmcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "workflow": "parallel", + "input": "current diff", + } + + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if result.IsError { + tc, _ := result.Content[0].(mcpmcp.TextContent) + t.Fatalf("handler returned tool error: %s", tc.Text) + } + tc, ok := result.Content[0].(mcpmcp.TextContent) + if !ok { + t.Fatalf("unexpected content type: %T", result.Content[0]) + } + + out := tc.Text + for _, want := range []string{ + "TYPE: parallel dispatch", + "host subagent facility", + "host-native subagents or external runners", + "files changed, verification run, and remaining risks", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in parallel response, got:\n%s", want, out) + } + } +} + func TestStartAlreadyRunning(t *testing.T) { wfDir := t.TempDir() dataDir := t.TempDir() @@ -564,6 +614,91 @@ steps: } } +func TestAdvanceRejectsRequiredOutput(t *testing.T) { + wfDir := t.TempDir() + dataDir := t.TempDir() + + writeFile(t, filepath.Join(wfDir, "pr.yml"), `name: pr +description: PR workflow +steps: + - id: create-pr + prompt: Create the PR and report the number. + require: + last_line_regex: "^PR: ([0-9]+|FAILED .+)$" + - id: monitor + prompt: Monitor the PR. +`) + + srv := newTestServer(t, dataDir, wfDir) + + _, startHandler := srv.startTool() + startReq := mcpmcp.CallToolRequest{} + startReq.Params.Arguments = map[string]interface{}{ + "workflow": "pr", + "input": "ship it", + } + startResult, err := startHandler(context.Background(), startReq) + if err != nil { + t.Fatalf("start: %v", err) + } + if startResult.IsError { + tc, _ := startResult.Content[0].(mcpmcp.TextContent) + t.Fatalf("start error: %s", tc.Text) + } + + state, err := lib.ReadSessionJSON(dataDir) + if err != nil || state == nil { + t.Fatalf("read session after start: %v", err) + } + + _, advHandler := srv.advanceTool() + badReq := mcpmcp.CallToolRequest{} + badReq.Params.Arguments = map[string]interface{}{ + "session": state.ID, + "output": "created the pull request successfully", + } + badResult, err := advHandler(context.Background(), badReq) + if err != nil { + t.Fatalf("advance bad: %v", err) + } + if !badResult.IsError { + t.Fatal("expected required-output error") + } + tc, _ := badResult.Content[0].(mcpmcp.TextContent) + if !strings.Contains(tc.Text, "require.last_line_regex") { + t.Fatalf("expected require.last_line_regex error, got: %s", tc.Text) + } + + state, err = lib.ReadSessionJSON(dataDir) + if err != nil || state == nil { + t.Fatalf("read session after bad advance: %v", err) + } + if state.CurrentStep != "create-pr" { + t.Fatalf("bad output advanced session to %q", state.CurrentStep) + } + if state.Busy { + t.Fatal("bad output left session Busy=true") + } + + goodReq := mcpmcp.CallToolRequest{} + goodReq.Params.Arguments = map[string]interface{}{ + "session": state.ID, + "output": "created\nPR: 123", + } + goodResult, err := advHandler(context.Background(), goodReq) + if err != nil { + t.Fatalf("advance good: %v", err) + } + if goodResult.IsError { + tc, _ := goodResult.Content[0].(mcpmcp.TextContent) + t.Fatalf("good output rejected: %s", tc.Text) + } + tc, _ = goodResult.Content[0].(mcpmcp.TextContent) + if !strings.Contains(tc.Text, "monitor") { + t.Fatalf("expected monitor step after good output, got:\n%s", tc.Text) + } +} + // TestAdvancePropagatesStepEnforce verifies that SessionState.StepEnforce is // re-derived from the *current* step on every transition so that a // workflow with mixed per-step enforce correctly flips the hook's diff --git a/src/runners/claude_test.go b/src/runners/claude_test.go index d6339f8..46c2a27 100644 --- a/src/runners/claude_test.go +++ b/src/runners/claude_test.go @@ -19,6 +19,38 @@ func TestCodexRunnerName(t *testing.T) { } } +func TestCodexExecArgs(t *testing.T) { + tests := []struct { + name string + workDir string + want []string + }{ + { + name: "no workdir", + want: []string{"exec", "--sandbox", "workspace-write", "-"}, + }, + { + name: "with workdir", + workDir: "/tmp/repo", + want: []string{"exec", "--sandbox", "workspace-write", "-C", "/tmp/repo", "-"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := codexExecArgs(tt.workDir) + if len(got) != len(tt.want) { + t.Fatalf("len = %d, want %d (%v)", len(got), len(tt.want), got) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("arg[%d] = %q, want %q (all args: %v)", i, got[i], tt.want[i], got) + } + } + }) + } +} + func TestGeminiRunnerName(t *testing.T) { r := &GeminiRunner{} if r.Name() != "gemini" { diff --git a/src/runners/codex.go b/src/runners/codex.go index 194c581..0b296ae 100644 --- a/src/runners/codex.go +++ b/src/runners/codex.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os/exec" + "strings" ) type CodexRunner struct{} @@ -16,15 +17,22 @@ func (r *CodexRunner) Available() bool { return err == nil } +func codexExecArgs(workDir string) []string { + args := []string{"exec", "--sandbox", "workspace-write"} + if workDir != "" { + args = append(args, "-C", workDir) + } + return append(args, "-") +} + func (r *CodexRunner) Run(ctx context.Context, prompt string, opts RunOpts) (RunResult, error) { - // Codex reads stdin and appends to the prompt argument. + // `codex exec -` reads initial instructions from stdin. // Pipe the full prompt via stdin to handle large diffs safely. - args := []string{"exec", "--full-auto", prompt} - - cmd := exec.CommandContext(ctx, "codex", args...) + cmd := exec.CommandContext(ctx, "codex", codexExecArgs(opts.WorkDir)...) if opts.WorkDir != "" { cmd.Dir = opts.WorkDir } + cmd.Stdin = strings.NewReader(prompt) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/workflows/doc-gen.yml b/workflows/doc-gen.yml index 45d9e01..de33d9b 100644 --- a/workflows/doc-gen.yml +++ b/workflows/doc-gen.yml @@ -23,12 +23,12 @@ steps: - id: generate model: smart - enforce: soft # spawns documenter agent via Task tool + enforce: soft # spawns documenter agent via host subagent facility prompt: | Target: {{input}} Analysis: {{analyze}} - Spawn the `documenter` agent via the Task tool with the analysis as context. + Spawn the `documenter` agent via the host subagent facility with the analysis as context. The agent must produce: 1. Overview — what the module/package does 2. API Reference — every public export with signature, params, return type, description @@ -45,13 +45,13 @@ steps: - id: write model: fast - enforce: soft # writes doc files to disk + enforce: soft # writes local-only doc files to disk prompt: | Generated docs: {{generate}} Write to the appropriate location: - - If a `docs/` directory exists, write there - - If a specific output path was requested in {{input}}, use that + - If a specific output path was requested in {{input}}, use that only if it is not under a git-tracked docs directory + - If a file is needed and no safe output path was requested, write under `.devkit/docs/` - Otherwise, output inline and tell the user where they want it saved Report the final file path(s). diff --git a/workflows/onboard.yml b/workflows/onboard.yml index fdd66e2..8f5e530 100644 --- a/workflows/onboard.yml +++ b/workflows/onboard.yml @@ -24,12 +24,12 @@ steps: - id: architect model: smart - enforce: soft # spawns researcher agent via Task tool + enforce: soft # spawns researcher agent via host subagent facility prompt: | Target codebase: {{input}} Initial analysis: {{analyze}} - Spawn the `researcher` agent via the Task tool with the analysis as context. + Spawn the `researcher` agent via the host subagent facility with the analysis as context. The agent must identify: 1. Architecture pattern (monolith, microservices, MVC, layered, etc.) 2. Key directories and what lives in each @@ -45,7 +45,7 @@ steps: - id: guide model: smart - enforce: soft # writes docs/ONBOARDING.md + enforce: soft # writes local-only onboarding notes prompt: | Initial analysis: {{analyze}} Architecture: {{architect}} @@ -90,5 +90,5 @@ steps: | Add a DB migration | ... | | Run specific tests | ... | - Write the guide to `docs/ONBOARDING.md` (create the directory if needed). + Write the guide to `.devkit/docs/ONBOARDING.md` (create the directory if needed). Report the final file path. diff --git a/workflows/pr-ready.yml b/workflows/pr-ready.yml index 6019cab..2aaab4a 100644 --- a/workflows/pr-ready.yml +++ b/workflows/pr-ready.yml @@ -108,11 +108,6 @@ steps: selection rules, or workflow patterns changed. * bugfix/internal: not affected. - - docs/ or *.md under docs/ - * feature: update if an existing doc describes the subsystem - you touched. - * breaking: always update. - - skills/*/SKILL.md * feature: update the affected skill's SKILL.md if a new step, capability, or contract is added. @@ -167,6 +162,8 @@ steps: - id: create-pr model: smart enforce: soft # git push + gh pr create + require: + last_line_regex: "^PR: ([0-9]+|FAILED .+)$" prompt: | Create the PR: 1. Push the branch to remote diff --git a/workflows/test-gen.yml b/workflows/test-gen.yml index 47accac..2d3ae33 100644 --- a/workflows/test-gen.yml +++ b/workflows/test-gen.yml @@ -23,12 +23,12 @@ steps: - id: generate model: smart - enforce: soft # spawns test-writer agent via Task tool + enforce: soft # spawns test-writer agent via host subagent facility prompt: | Target: {{input}} Analysis: {{analyze}} - Spawn the `test-writer` agent via the Task tool with the analysis as context. + Spawn the `test-writer` agent via the host subagent facility with the analysis as context. The agent must: 1. Create test files matching project conventions (naming, location, style) 2. Cover happy paths, edge cases, error conditions @@ -48,7 +48,7 @@ steps: Run the project's test command and check the result. If any tests fail: - - Send the failures back to the test-writer agent via Task with the failing output + - Send the failures back to the test-writer agent via the host subagent facility with the failing output - Re-run after the agent's fix - Loop until all pass or you hit the iteration cap diff --git a/workflows/tri-debug.yml b/workflows/tri-debug.yml index b3c3b7a..4579e3c 100644 --- a/workflows/tri-debug.yml +++ b/workflows/tri-debug.yml @@ -6,9 +6,10 @@ steps: model: smart prompt: | DISPATCH, DO NOT ANSWER. Tri-debug requires three DIFFERENT models - diagnose independently. Invoke the Agent tool with a non-Claude - smart-tier subagent (`codex:codex-rescue` is the standard external - diagnostician). Pass the issue and report verbatim. If no external + diagnose independently. Invoke the Agent tool with + a non-Claude smart-tier subagent (`codex:codex-rescue` is the standard external + diagnostician). In non-Claude hosts, use a delegated subagent or external + CLI diagnostician if available. Pass the issue and report verbatim. If no external subagent is available, stop and tell the user — do NOT answer as yourself. Subagent prompt: "Debug this issue: {{input}}. Trace the root cause. Read the relevant code, check assumptions, and propose @@ -17,17 +18,21 @@ steps: - id: general-diagnosis model: general prompt: | - DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a non-Claude - general-tier subagent (prefer a Gemini-backed agent, or a different - external than smart-diagnosis used). Report verbatim. Stop if none + DISPATCH, DO NOT ANSWER. Invoke the Agent tool + with a non-Claude general-tier subagent (prefer a Gemini-backed agent, or a + different external than smart-diagnosis used). In non-Claude hosts, use a + distinct delegated subagent or external CLI diagnostician when + available. Report verbatim. Stop if none available. Subagent prompt: "Debug this issue: {{input}}. What's the most likely cause? Suggest a fix." - id: fast-diagnosis model: fast prompt: | - DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a fast-tier - external subagent (distinct from the other two tiers). Report + DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a + fast-tier external subagent (distinct from the other two tiers). In + non-Claude hosts, use a distinct delegated subagent or external CLI diagnostician + when available. Report verbatim. Stop if none available. Subagent prompt: "Debug this issue: {{input}}. Quick diagnosis — what's probably wrong and how to fix it." diff --git a/workflows/tri-dispatch.yml b/workflows/tri-dispatch.yml index 31329e5..373b5aa 100644 --- a/workflows/tri-dispatch.yml +++ b/workflows/tri-dispatch.yml @@ -8,7 +8,8 @@ steps: DISPATCH, DO NOT ANSWER. Tri-dispatch requires three DIFFERENT models answer independently. Invoke the Agent tool with a non-Claude smart-tier subagent (`codex:codex-rescue` or equivalent - external). Report verbatim. Stop if no external subagent is + external). In non-Claude hosts, use a delegated subagent or external CLI + respondent if available. Report verbatim. Stop if no external subagent is available — do NOT answer as yourself. Subagent prompt: "{{input}} Give your best, most thorough answer." @@ -17,14 +18,17 @@ steps: prompt: | DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a non-Claude general-tier subagent (prefer Gemini; at minimum distinct from - smart-take's subagent). Report verbatim. Stop if none available. + smart-take's subagent). In non-Claude hosts, use a distinct delegated + subagent or external CLI respondent when available. Report verbatim. Stop if none available. Subagent prompt: "{{input}} Give a clear, balanced answer." - id: fast-take model: fast prompt: | - DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a fast-tier - external subagent (distinct from the other two). Report verbatim. + DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a + fast-tier external subagent (distinct from the other two). In non-Claude + hosts, use a distinct delegated subagent or external CLI respondent when + available. Report verbatim. Stop if none available. Subagent prompt: "{{input}} Give a quick, direct answer." diff --git a/workflows/tri-review.yml b/workflows/tri-review.yml index 0a5ed91..532fc8c 100644 --- a/workflows/tri-review.yml +++ b/workflows/tri-review.yml @@ -16,8 +16,10 @@ steps: DISPATCH, DO NOT ANSWER. This is tri-review — the whole point is model diversity. You (the calling model) must NOT write this review yourself. Invoke the Agent tool with a non-Claude subagent - (`codex:codex-rescue` is the standard smart-tier external reviewer; - any available Gemini or external senior reviewer also qualifies). + (`codex:codex-rescue` is the standard smart-tier + external reviewer; any available Gemini or external senior reviewer + also qualifies). In non-Claude hosts, use the equivalent delegated + subagent or external CLI reviewer if available. Pass the diff from {{gather}} and report the subagent's verdict verbatim. If no external-model subagent is available, stop and tell the user — do NOT answer as yourself and advance. The subagent @@ -29,10 +31,12 @@ steps: model: general prompt: | DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a non-Claude - general-tier subagent (e.g. a Gemini-backed reviewer plugin, or + general-tier reviewer (e.g. a Gemini-backed reviewer plugin, or `codex:codex-rescue` if no Gemini is available — but prefer a different model than review-smart used, or the tri-* property is - lost). Report verbatim. Stop if no external subagent is available. + lost). In non-Claude hosts, use a distinct delegated subagent or + external CLI reviewer when available. Report verbatim. Stop if no external + subagent is available. The subagent prompt should be: "Review this code for quality and maintainability — readability, naming, duplication, test coverage. Diff: {{gather}}" @@ -41,10 +45,11 @@ steps: model: fast prompt: | DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a fast-tier - external subagent (Haiku-backed, Gemini-Flash-backed, or the fast - tier of whatever is available — distinct from review-smart and - review-general). Report verbatim. Stop if no external subagent is - available. The subagent prompt should be: "Quick review — flag + external subagent (Haiku-backed, Gemini-Flash-backed, or + the fast tier of whatever is available — distinct from review-smart + and review-general). In non-Claude hosts, use a distinct delegated + subagent or external CLI reviewer when available. Report verbatim. Stop if no + external subagent is available. The subagent prompt should be: "Quick review — flag anything obviously wrong: typos, missing error handling, dead code. Diff: {{gather}}" diff --git a/workflows/tri-security.yml b/workflows/tri-security.yml index ad0cf76..a976e8e 100644 --- a/workflows/tri-security.yml +++ b/workflows/tri-security.yml @@ -14,8 +14,10 @@ steps: model: smart prompt: | DISPATCH, DO NOT ANSWER. Tri-security requires three DIFFERENT - models audit in parallel. Invoke the Agent tool with a non-Claude - smart-tier subagent (`codex:codex-rescue` or equivalent external). + models audit in parallel. Invoke the Agent tool with + a non-Claude smart-tier subagent (`codex:codex-rescue` or equivalent external). + In non-Claude hosts, use a delegated subagent or external CLI reviewer if + available. Report verbatim. Stop if no external subagent is available — do NOT audit as yourself. Subagent prompt: "Security audit — injection and input validation. Diff: {{gather}}. Check for: SQL injection, @@ -26,9 +28,10 @@ steps: - id: audit-auth model: general prompt: | - DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a non-Claude - general-tier subagent (prefer Gemini; at minimum distinct from - audit-injection's subagent). Report verbatim. Stop if none + DISPATCH, DO NOT ANSWER. Invoke the Agent tool + with a non-Claude general-tier subagent (prefer Gemini; at minimum distinct from + audit-injection's subagent). In non-Claude hosts, use a distinct delegated + subagent or external CLI reviewer when available. Report verbatim. Stop if none available. Subagent prompt: "Security audit — authentication and authorization. Diff: {{gather}}. Check for: broken auth, missing access controls, insecure session handling, privilege escalation, @@ -38,8 +41,10 @@ steps: - id: audit-config model: fast prompt: | - DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a fast-tier - external subagent (distinct from the other two). Report verbatim. + DISPATCH, DO NOT ANSWER. Invoke the Agent tool with a + fast-tier external subagent (distinct from the other two). In non-Claude + hosts, use a distinct delegated subagent or external CLI reviewer when + available. Report verbatim. Stop if none available. Subagent prompt: "Security audit — configuration and dependencies. Diff: {{gather}}. Check for: exposed secrets, debug mode in prod, permissive CORS, missing