diff --git a/src/engine/engine_test.go b/src/engine/engine_test.go index 87950a5..0e5eafd 100644 --- a/src/engine/engine_test.go +++ b/src/engine/engine_test.go @@ -260,6 +260,16 @@ steps: prompt: x - id: fetch_data prompt: y`, "collide under env key"}, + {"invalid step enforce", `name: T +steps: + - id: a + prompt: x + enforce: maybe`, `invalid enforce "maybe"`}, + {"enforce on command step", `name: T +steps: + - id: a + command: "echo hi" + enforce: soft`, "enforce on a command step"}, } for _, tt := range tests { @@ -1415,6 +1425,64 @@ steps: } } +func TestParseStepLevelEnforceOverride(t *testing.T) { + yaml := []byte(` +name: mixed-enforce +steps: + - id: review + prompt: Pure reasoning, stays hard. + - id: fix + prompt: Writes files, needs soft. + enforce: soft + - id: summary + prompt: Pure reasoning again. + enforce: hard +`) + wf, err := Parse(yaml) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if wf.Enforce != "hard" { + t.Errorf("workflow enforce = %q, want default hard", wf.Enforce) + } + if wf.Steps[0].Enforce != "" { + t.Errorf("step 0 enforce = %q, want empty (inherit)", wf.Steps[0].Enforce) + } + if wf.Steps[1].Enforce != "soft" { + t.Errorf("step 1 enforce = %q, want soft", wf.Steps[1].Enforce) + } + if wf.Steps[2].Enforce != "hard" { + t.Errorf("step 2 enforce = %q, want hard", wf.Steps[2].Enforce) + } +} + +func TestEffectiveEnforce(t *testing.T) { + tests := []struct { + name string + wfField string + stepField string + want string + }{ + {"step soft overrides wf hard", "hard", "soft", "soft"}, + {"step hard overrides wf soft", "soft", "hard", "hard"}, + {"empty step inherits wf soft", "soft", "", "soft"}, + {"empty step inherits wf hard", "hard", "", "hard"}, + {"both zero → default hard", "", "", "hard"}, + {"zero wf + soft step → soft", "", "soft", "soft"}, + {"zero wf + hard step → hard", "", "hard", "hard"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wf := Workflow{Enforce: tt.wfField} + step := WfStep{Enforce: tt.stepField} + got := EffectiveEnforce(wf, step) + if got != tt.want { + t.Errorf("EffectiveEnforce = %q, want %q", got, tt.want) + } + }) + } +} + func TestInterpolateDeterministic(t *testing.T) { // Regression: map iteration order is randomized in Go; Interpolate // must sort keys so a step output containing {{another-id}} renders diff --git a/src/engine/workflow.go b/src/engine/workflow.go index 33c1bbe..5202660 100644 --- a/src/engine/workflow.go +++ b/src/engine/workflow.go @@ -41,6 +41,30 @@ type WfStep struct { Loop *Loop `yaml:"loop"` Branch []Branch `yaml:"branch"` Principles []string `yaml:"principles"` // per-step override + // 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 + // specific steps whose body needs tools the hard mode blocks to + // run under soft. The Stop-hook still blocks session end on soft + // steps, so end-of-turn drift is still caught. + Enforce string `yaml:"enforce,omitempty"` +} + +// EffectiveEnforce returns the enforcement mode for a step, falling back +// to the workflow-level setting when the step does not override it, and +// to "hard" when neither is set. Callers must use this instead of +// reading step.Enforce directly so the fall-through is consistent at +// every state transition. Takes values (not pointers) so the compiler +// enforces that both fields exist at the call site — every current +// caller owns concrete structs by the time they reach a transition. +func EffectiveEnforce(wf Workflow, step WfStep) string { + if step.Enforce != "" { + return step.Enforce + } + if wf.Enforce != "" { + return wf.Enforce + } + return "hard" } // Loop controls step repetition. @@ -151,6 +175,22 @@ func validate(wf *Workflow) error { if s.Expect != "" && s.Expect != "success" && s.Expect != "failure" { return fmt.Errorf("step %q has invalid expect %q — must be \"success\" or \"failure\"", s.ID, s.Expect) } + // Step-level enforce override: empty inherits from workflow, + // otherwise must be hard|soft. Reject on command steps — the + // guard honors SessionState.Enforce uniformly (see guard.go's + // command branch), so marking a command step `soft` would let + // arbitrary agent tool calls slip through while the engine is + // executing that step. Since command steps are engine-owned + // and never need per-step overrides, fail loudly at parse time + // instead of producing a sharp edge at runtime. + if s.Enforce != "" { + if s.Enforce != "hard" && s.Enforce != "soft" { + return fmt.Errorf("step %q has invalid enforce %q — must be \"hard\" or \"soft\"", s.ID, s.Enforce) + } + if s.Command != "" { + return fmt.Errorf("step %q has enforce on a command step — enforce is only meaningful for prompt steps (the engine executes command steps directly)", s.ID) + } + } if s.Command != "" && s.Loop != nil { return fmt.Errorf("step %q has both command and loop — these are mutually exclusive", s.ID) } diff --git a/src/mcp/tools.go b/src/mcp/tools.go index 487e9c3..157b31b 100644 --- a/src/mcp/tools.go +++ b/src/mcp/tools.go @@ -160,11 +160,14 @@ func (s *Server) startTool() (mcpmcp.Tool, mcpgo.ToolHandlerFunc) { CurrentIndex: 0, TotalSteps: len(wf.Steps), StepType: stepType(firstStep), - Enforce: wf.Enforce, - Branch: wf.BranchMode, - Status: "starting", - StartedAt: time.Now(), - Outputs: map[string]string{}, + // SessionState.Enforce is the current step's effective + // enforce, re-derived on every transition so the hook + // always reads the mode that matches state.CurrentStep. + Enforce: engine.EffectiveEnforce(*wf, firstStep), + Branch: wf.BranchMode, + Status: "starting", + StartedAt: time.Now(), + Outputs: map[string]string{}, }, nil }) if err != nil { @@ -478,6 +481,7 @@ func (s *Server) advanceTool() (mcpmcp.Tool, mcpgo.ToolHandlerFunc) { state.CurrentStep = nextStep.ID state.CurrentIndex = nextIndex state.StepType = stepType(nextStep) + state.Enforce = engine.EffectiveEnforce(*wf, nextStep) state.Busy = false if err := lib.WriteSessionJSON(s.dataDir, state); err != nil { return mcpmcp.NewToolResultError(fmt.Sprintf("write state: %v", err)), nil @@ -642,6 +646,10 @@ func (s *Server) handleLoopAdvance(ctx context.Context, wf *engine.Workflow, sta // Continue loop — return same step for another iteration. // Clear the advance claim as part of this write (see advanceTool). + // state.Enforce is NOT re-derived: loop iterations stay on the same + // step, so the step's effective enforce does not change. If that + // invariant ever breaks (e.g. enforce becomes iteration-dependent), + // recompute here like the other transition sites do. state.Busy = false if err := lib.WriteSessionJSON(s.dataDir, state); err != nil { return mcpmcp.NewToolResultError(fmt.Sprintf("write loop state: %v", err)), nil @@ -665,6 +673,7 @@ func (s *Server) advancePastLoop(wf *engine.Workflow, state *lib.SessionState) ( state.CurrentStep = nextStep.ID state.CurrentIndex = nextIndex state.StepType = stepType(nextStep) + state.Enforce = engine.EffectiveEnforce(*wf, nextStep) state.Busy = false if err := lib.WriteSessionJSON(s.dataDir, state); err != nil { return mcpmcp.NewToolResultError(fmt.Sprintf("write state: %v", err)), nil diff --git a/src/mcp/tools_test.go b/src/mcp/tools_test.go index f30702a..f543e22 100644 --- a/src/mcp/tools_test.go +++ b/src/mcp/tools_test.go @@ -563,6 +563,301 @@ steps: } } +// TestAdvancePropagatesStepEnforce verifies that SessionState.Enforce is +// re-derived from the *current* step on every transition so that a +// workflow with mixed per-step enforce correctly flips the hook's +// enforcement mode as the workflow walks from step to step. +// +// This is the structural guarantee that backs per-step `enforce:` as a +// meaningful override: the hot-state file the hook reads must reflect +// the step the agent is currently on, not the workflow default. +func TestAdvancePropagatesStepEnforce(t *testing.T) { + wfDir := t.TempDir() + dataDir := t.TempDir() + + // Workflow-level default is hard. Middle step overrides to soft. + // Third step inherits (stays hard). Verifies all three transition + // paths: start (seeds first step), advance (normal), advance + // (back to inherited hard). + writeFile(t, filepath.Join(wfDir, "mixed.yml"), `name: mixed +description: Mixed per-step enforce test +steps: + - id: review + prompt: Read-only review. + - id: apply + prompt: Apply the fix. + enforce: soft + - id: summarize + prompt: Summarize what happened. +`) + + srv := newTestServer(t, dataDir, wfDir) + _, startHandler := srv.startTool() + startReq := mcpmcp.CallToolRequest{} + startReq.Params.Arguments = map[string]interface{}{ + "workflow": "mixed", + "input": "demo", + } + if _, err := startHandler(context.Background(), startReq); err != nil { + t.Fatalf("start: %v", err) + } + + state, err := lib.ReadSessionJSON(dataDir) + if err != nil || state == nil { + t.Fatalf("read session after start: %v", err) + } + if state.CurrentStep != "review" { + t.Fatalf("expected starting step review, got %s", state.CurrentStep) + } + if state.Enforce != "hard" { + t.Errorf("step 1 (review) enforce = %q, want hard (inherited from workflow default)", state.Enforce) + } + sessionID := state.ID + + _, advHandler := srv.advanceTool() + advance := func(output string) { + t.Helper() + req := mcpmcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "session": sessionID, + "output": output, + } + res, err := advHandler(context.Background(), req) + if err != nil { + t.Fatalf("advance: %v", err) + } + if res.IsError { + tc, _ := res.Content[0].(mcpmcp.TextContent) + t.Fatalf("advance error: %s", tc.Text) + } + } + + advance("review output") + state, _ = lib.ReadSessionJSON(dataDir) + if state == nil { + t.Fatal("session gone after advance 1") + } + if state.CurrentStep != "apply" { + t.Fatalf("expected step apply, got %s", state.CurrentStep) + } + if state.Enforce != "soft" { + t.Errorf("step 2 (apply) enforce = %q, want soft (per-step override)", state.Enforce) + } + + advance("apply output") + state, _ = lib.ReadSessionJSON(dataDir) + if state == nil { + t.Fatal("session gone after advance 2") + } + if state.CurrentStep != "summarize" { + t.Fatalf("expected step summarize, got %s", state.CurrentStep) + } + if state.Enforce != "hard" { + t.Errorf("step 3 (summarize) enforce = %q, want hard (back to inherited)", state.Enforce) + } +} + +// TestAdvancePropagatesStepEnforceReverseOverride verifies the symmetric +// case: a workflow that defaults to soft but has an individual step +// marked hard. Ensures the per-step override works both directions +// through the advance path, and that a soft-default workflow's hard +// step still triggers the mid-step tool block from PR #64. +func TestAdvancePropagatesStepEnforceReverseOverride(t *testing.T) { + wfDir := t.TempDir() + dataDir := t.TempDir() + + writeFile(t, filepath.Join(wfDir, "reverse.yml"), `name: reverse +description: Soft default with hard override +enforce: soft +steps: + - id: collect + prompt: Collect evidence, can run shell. + - id: review + prompt: Pure reasoning, no shell. + enforce: hard + - id: write + prompt: Write the result. +`) + + srv := newTestServer(t, dataDir, wfDir) + _, startHandler := srv.startTool() + startReq := mcpmcp.CallToolRequest{} + startReq.Params.Arguments = map[string]interface{}{ + "workflow": "reverse", + "input": "demo", + } + if _, err := startHandler(context.Background(), startReq); err != nil { + t.Fatalf("start: %v", err) + } + + state, _ := lib.ReadSessionJSON(dataDir) + if state == nil { + t.Fatal("no session after start") + } + if state.Enforce != "soft" { + t.Errorf("step 1 (collect) enforce = %q, want soft (inherited from soft default)", state.Enforce) + } + sessionID := state.ID + + _, advHandler := srv.advanceTool() + advance := func(output string) { + t.Helper() + req := mcpmcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"session": sessionID, "output": output} + res, err := advHandler(context.Background(), req) + if err != nil || res.IsError { + t.Fatalf("advance failed: %v", err) + } + } + + advance("collected") + state, _ = lib.ReadSessionJSON(dataDir) + if state.CurrentStep != "review" { + t.Fatalf("expected review, got %s", state.CurrentStep) + } + if state.Enforce != "hard" { + t.Errorf("step 2 (review) enforce = %q, want hard (per-step override flips soft→hard)", state.Enforce) + } + + advance("reviewed") + state, _ = lib.ReadSessionJSON(dataDir) + if state.CurrentStep != "write" { + t.Fatalf("expected write, got %s", state.CurrentStep) + } + if state.Enforce != "soft" { + t.Errorf("step 3 (write) enforce = %q, want soft (back to inherited default)", state.Enforce) + } +} + +// TestAdvancePropagatesStepEnforceAfterLoop exercises the +// advancePastLoop path: a loop step exits (via max iterations), and the +// following step has an explicit per-step enforce override. Without +// state.Enforce re-derivation in advancePastLoop, the post-loop step +// would carry the loop step's enforce and the hook would apply the +// wrong mode. +func TestAdvancePropagatesStepEnforceAfterLoop(t *testing.T) { + wfDir := t.TempDir() + dataDir := t.TempDir() + + writeFile(t, filepath.Join(wfDir, "loop-enforce.yml"), `name: loop-enforce +description: Loop step followed by a step with explicit enforce override +steps: + - id: iterate + prompt: Run one iteration. + enforce: soft + loop: + max: 2 + - id: wrapup + prompt: Wrap up after the loop. +`) + + srv := newTestServer(t, dataDir, wfDir) + _, startHandler := srv.startTool() + startReq := mcpmcp.CallToolRequest{} + startReq.Params.Arguments = map[string]interface{}{ + "workflow": "loop-enforce", + "input": "demo", + } + if _, err := startHandler(context.Background(), startReq); err != nil { + t.Fatalf("start: %v", err) + } + + state, _ := lib.ReadSessionJSON(dataDir) + if state == nil { + t.Fatal("no session after start") + } + if state.CurrentStep != "iterate" || state.Enforce != "soft" { + t.Errorf("expected iterate/soft, got %s/%s", state.CurrentStep, state.Enforce) + } + sessionID := state.ID + + _, advHandler := srv.advanceTool() + advance := func() { + t.Helper() + req := mcpmcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"session": sessionID, "output": "one iteration done"} + if _, err := advHandler(context.Background(), req); err != nil { + t.Fatalf("advance: %v", err) + } + } + + advance() // iteration 1/2 + state, _ = lib.ReadSessionJSON(dataDir) + if state.CurrentStep != "iterate" || state.Enforce != "soft" { + t.Errorf("mid-loop iter1: got %s/%s, want iterate/soft", state.CurrentStep, state.Enforce) + } + + advance() // iteration 2/2 — loop hits max, advancePastLoop fires + state, _ = lib.ReadSessionJSON(dataDir) + if state == nil { + t.Fatal("session gone after loop exit") + } + if state.CurrentStep != "wrapup" { + t.Fatalf("expected wrapup after loop exit, got %s", state.CurrentStep) + } + if state.Enforce != "hard" { + t.Errorf("wrapup enforce = %q, want hard (wrapup has no override, workflow default is hard — advancePastLoop must re-derive)", state.Enforce) + } +} + +// TestAdvancePropagatesStepEnforceAcrossBranch exercises the branch +// jump path in advanceTool: a step with a `branch:` clause that routes +// past sequential steps to a target with a different per-step enforce. +// Guards against a future refactor that computes enforce from +// CurrentIndex+1 instead of the branch target. +func TestAdvancePropagatesStepEnforceAcrossBranch(t *testing.T) { + wfDir := t.TempDir() + dataDir := t.TempDir() + + writeFile(t, filepath.Join(wfDir, "branch-enforce.yml"), `name: branch-enforce +description: Branch skipping a sequential step with a different enforce +steps: + - id: classify + prompt: Output TARGET exactly. + branch: + - when: TARGET + goto: jump-target + - id: skipped + prompt: Should be skipped. + enforce: soft + - id: jump-target + prompt: Reached via branch. + enforce: soft +`) + + srv := newTestServer(t, dataDir, wfDir) + _, startHandler := srv.startTool() + startReq := mcpmcp.CallToolRequest{} + startReq.Params.Arguments = map[string]interface{}{ + "workflow": "branch-enforce", + "input": "demo", + } + if _, err := startHandler(context.Background(), startReq); err != nil { + t.Fatalf("start: %v", err) + } + + state, _ := lib.ReadSessionJSON(dataDir) + if state.Enforce != "hard" { + t.Errorf("classify enforce = %q, want hard (inherited)", state.Enforce) + } + sessionID := state.ID + + _, advHandler := srv.advanceTool() + req := mcpmcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"session": sessionID, "output": "TARGET"} + if _, err := advHandler(context.Background(), req); err != nil { + t.Fatalf("advance: %v", err) + } + + state, _ = lib.ReadSessionJSON(dataDir) + if state.CurrentStep != "jump-target" { + t.Fatalf("expected branch to jump-target, got %s", state.CurrentStep) + } + if state.Enforce != "soft" { + t.Errorf("jump-target enforce = %q, want soft (per-step override on branch target, not on skipped step)", state.Enforce) + } +} + func TestAdvanceCommandStep(t *testing.T) { wfDir := t.TempDir() dataDir := t.TempDir() diff --git a/workflows/audit.yml b/workflows/audit.yml index e314dd6..6921d6d 100644 --- a/workflows/audit.yml +++ b/workflows/audit.yml @@ -11,6 +11,7 @@ steps: - id: deps model: smart + enforce: soft prompt: | Detected ecosystems: {{detect}} @@ -24,6 +25,7 @@ steps: - id: lint model: smart + enforce: soft prompt: | Detected ecosystems: {{detect}} diff --git a/workflows/autoloop.yml b/workflows/autoloop.yml index ec47236..74f3b6e 100644 --- a/workflows/autoloop.yml +++ b/workflows/autoloop.yml @@ -8,6 +8,7 @@ budget: steps: - id: baseline model: fast + enforce: soft # runs user-supplied metric command via Bash prompt: | Run the user's metric command to establish a baseline measurement. @@ -56,6 +57,7 @@ steps: - id: fix model: smart + enforce: soft # edits source to apply hypothesis prompt: | Make the change recommended by the audit. @@ -76,6 +78,7 @@ steps: - id: measure model: fast + enforce: soft prompt: | Run the EXACT same metric command from the baseline to measure the result. @@ -117,6 +120,7 @@ steps: - id: keep model: fast + enforce: soft # git add + git commit + scratchpad edit prompt: | The change IMPROVED the metric. Keep it. @@ -145,6 +149,7 @@ steps: - id: revert model: fast + enforce: soft # git checkout + scratchpad edit prompt: | The change REGRESSED or had no effect. Revert it. @@ -173,6 +178,7 @@ steps: - id: report model: smart + enforce: soft # deletes scratchpad at the end prompt: | The autoloop session is complete. Write a final report. diff --git a/workflows/bugfix.yml b/workflows/bugfix.yml index c07c354..ce495a1 100644 --- a/workflows/bugfix.yml +++ b/workflows/bugfix.yml @@ -26,6 +26,7 @@ steps: - id: reproduce model: smart + enforce: soft # may run the failing case to confirm the bug prompt: | Bug report: {{input}} @@ -37,6 +38,7 @@ steps: - id: diagnose model: smart + enforce: soft # appends to scratchpad prompt: | Reproduction: @@ -54,6 +56,7 @@ steps: - id: fix model: smart + enforce: soft # edits source to implement the fix prompt: | Diagnosis: @@ -64,6 +67,7 @@ steps: - id: regression-test model: smart + enforce: soft # writes a new test file prompt: | A bug was fixed: @@ -77,6 +81,7 @@ steps: - id: run-tests model: fast + enforce: soft # runs the full test suite prompt: | Run the full test suite including the new regression test. Report results. @@ -85,6 +90,7 @@ steps: - id: fix-tests model: smart + enforce: soft # edits code/tests + re-runs prompt: | Test results: @@ -104,6 +110,7 @@ steps: - id: summary model: fast + enforce: soft # deletes scratchpad file prompt: | Bug fix complete. Summary: @@ -126,6 +133,7 @@ steps: - id: quick-fix model: smart + enforce: soft # edits + writes test + runs tests prompt: | This is a trivial bug — no deep investigation needed. diff --git a/workflows/deep-research.yml b/workflows/deep-research.yml index 09d7224..38310d4 100644 --- a/workflows/deep-research.yml +++ b/workflows/deep-research.yml @@ -8,6 +8,7 @@ budget: steps: - id: clarify model: smart + enforce: soft # AskUserQuestion prompt: | The user wants to deep-research: {{input}} @@ -21,6 +22,7 @@ steps: - id: perspectives model: smart + enforce: soft # WebSearch + WebFetch via Jina prompt: | Research question: {{clarify}} @@ -58,6 +60,7 @@ steps: - id: search-1 model: general + enforce: soft prompt: | Execute web search for sub-questions 1-2 from this decomposition: {{decompose}} @@ -66,6 +69,7 @@ steps: - id: search-2 model: general + enforce: soft prompt: | Execute web search for sub-questions 3-4 from this decomposition: {{decompose}} @@ -74,6 +78,7 @@ steps: - id: search-3 model: general + enforce: soft prompt: | Execute web search for sub-questions 5+ from this decomposition: {{decompose}} @@ -86,6 +91,7 @@ steps: - id: extract-claims model: smart + enforce: soft # WebFetch via Jina Reader prompt: | Research question: {{clarify}} @@ -127,6 +133,7 @@ steps: - id: disconfirm model: smart + enforce: soft # WebSearch + WebFetch for disconfirming evidence prompt: | Research question: {{clarify}} Hypotheses: {{hypotheses}} @@ -176,6 +183,7 @@ steps: - id: self-critique model: smart + enforce: soft # may do ONE targeted WebSearch to fill gaps prompt: | Research question: {{clarify}} Evidence matrix and sensitivity: {{evidence-matrix}} diff --git a/workflows/doc-gen.yml b/workflows/doc-gen.yml index 1fe3ea8..45d9e01 100644 --- a/workflows/doc-gen.yml +++ b/workflows/doc-gen.yml @@ -4,6 +4,7 @@ description: Generate code documentation via the documenter agent — analyze co steps: - id: analyze model: general + # analyze stays hard: Read-only surface extraction. prompt: | Target: {{input}} @@ -22,6 +23,7 @@ steps: - id: generate model: smart + enforce: soft # spawns documenter agent via Task tool prompt: | Target: {{input}} Analysis: {{analyze}} @@ -43,6 +45,7 @@ steps: - id: write model: fast + enforce: soft # writes doc files to disk prompt: | Generated docs: {{generate}} diff --git a/workflows/feature.yml b/workflows/feature.yml index d47d131..a79dc0d 100644 --- a/workflows/feature.yml +++ b/workflows/feature.yml @@ -55,6 +55,7 @@ steps: - id: implement model: smart + enforce: soft # writes code + appends to scratchpad prompt: | Implementation plan: @@ -76,6 +77,7 @@ steps: - id: gen-tests model: smart + enforce: soft # creates test files prompt: | Implementation complete. The feature: @@ -91,6 +93,7 @@ steps: - id: run-tests model: fast + enforce: soft # runs project test suite prompt: | Run the full test suite (not just the new tests). Report which pass, which fail, and the error messages. @@ -99,6 +102,7 @@ steps: - id: fix-tests model: smart + enforce: soft # edits source + test files, re-runs tests prompt: | Test results: @@ -118,6 +122,7 @@ steps: - id: lint model: fast + enforce: soft # runs project linter prompt: | Run the project's linter on the changed files. Report any violations. @@ -126,6 +131,7 @@ steps: - id: fix-lint model: smart + enforce: soft # edits violations, re-runs linter prompt: | Lint violations: @@ -162,6 +168,7 @@ steps: - id: final-report model: fast + enforce: soft # deletes scratchpad file prompt: | Feature session complete. Produce a summary: @@ -188,6 +195,7 @@ steps: - id: quick-fix model: smart + enforce: soft # edits + runs linter + runs tests prompt: | This is a tiny change — no design or planning needed. diff --git a/workflows/onboard.yml b/workflows/onboard.yml index 1182eda..fdd66e2 100644 --- a/workflows/onboard.yml +++ b/workflows/onboard.yml @@ -4,6 +4,7 @@ description: Generate an onboarding guide for new contributors — analyze struc steps: - id: analyze model: general + # analyze stays hard: Read/Glob-only structural scan. prompt: | Target codebase: {{input}} @@ -23,6 +24,7 @@ steps: - id: architect model: smart + enforce: soft # spawns researcher agent via Task tool prompt: | Target codebase: {{input}} Initial analysis: {{analyze}} @@ -43,6 +45,7 @@ steps: - id: guide model: smart + enforce: soft # writes docs/ONBOARDING.md prompt: | Initial analysis: {{analyze}} Architecture: {{architect}} diff --git a/workflows/pr-ready.yml b/workflows/pr-ready.yml index ff8b9f1..6019cab 100644 --- a/workflows/pr-ready.yml +++ b/workflows/pr-ready.yml @@ -4,6 +4,7 @@ description: Full PR preparation pipeline — validate, drop unrelated changes, steps: - id: validate model: fast + enforce: soft # inspects branch state via git prompt: | Validate the current branch is ready for PR preparation: - Not on main/master @@ -14,6 +15,7 @@ steps: - id: necessity model: smart + enforce: soft # may Edit files to remove debug artifacts prompt: | Review the diff (git diff main...HEAD) for unnecessary changes: - Debug artifacts (console.log, print statements) @@ -24,6 +26,7 @@ steps: - id: lint model: smart + enforce: soft # runs linter + edits violations prompt: | Run the project's linter on changed files. Fix any violations. @@ -35,6 +38,7 @@ steps: - id: test model: smart + enforce: soft # runs test suite + edits failing tests prompt: | Run the full test suite. Fix any failures. @@ -46,6 +50,7 @@ steps: - id: security model: smart + # security stays hard: read-only review, no file writes. prompt: | Review changed files for security issues: - Hardcoded secrets or API keys @@ -59,6 +64,7 @@ steps: - id: doc-check model: smart + enforce: soft # edits doc files (and commits them) prompt: | Classify the diff (git diff main...HEAD) and decide which project docs need updating so the user does not have to ask every PR. @@ -151,6 +157,7 @@ steps: - id: changelog model: fast + enforce: soft # runs git diff main...HEAD prompt: | Generate a changelog entry from git diff main...HEAD. Summarize what changed and why. Any `docs: update ...` commit @@ -159,6 +166,7 @@ steps: - id: create-pr model: smart + enforce: soft # git push + gh pr create prompt: | Create the PR: 1. Push the branch to remote @@ -174,6 +182,7 @@ steps: - id: monitor model: smart + enforce: soft # gh pr checks, gh api, git push for review fixes prompt: | The PR was just created. Watch it until merge-ready. diff --git a/workflows/refactor.yml b/workflows/refactor.yml index 65a1cd2..b897f32 100644 --- a/workflows/refactor.yml +++ b/workflows/refactor.yml @@ -34,6 +34,7 @@ steps: - id: refactor model: smart + enforce: soft # edits source + runs compiler to verify prompt: | Refactoring plan: @@ -49,6 +50,7 @@ steps: - id: run-tests model: fast + enforce: soft # runs test suite prompt: | Run the full test suite to verify the refactoring didn't break anything. Report results. @@ -57,6 +59,7 @@ steps: - id: fix-tests model: smart + enforce: soft # edits code/tests + re-runs prompt: | Test results after refactoring: diff --git a/workflows/research.yml b/workflows/research.yml index b21011f..4c00f80 100644 --- a/workflows/research.yml +++ b/workflows/research.yml @@ -8,6 +8,7 @@ budget: steps: - id: clarify model: smart + enforce: soft # uses AskUserQuestion prompt: | The user wants to research: {{input}} @@ -36,6 +37,7 @@ steps: - id: search-1 model: general + enforce: soft prompt: | Execute web search for the FIRST sub-question from this decomposition: {{decompose}} @@ -44,6 +46,7 @@ steps: - id: search-2 model: general + enforce: soft prompt: | Execute web search for the SECOND sub-question from this decomposition: {{decompose}} @@ -52,6 +55,7 @@ steps: - id: search-3 model: fast + enforce: soft prompt: | Execute web search for the THIRD sub-question from this decomposition: {{decompose}} @@ -63,6 +67,7 @@ steps: - id: summarize model: smart + enforce: soft # WebFetch via Jina Reader prompt: | Research question: {{clarify}} @@ -85,6 +90,7 @@ steps: - id: follow-up model: general + enforce: soft # WebSearch + WebFetch prompt: | Research question: {{clarify}} diff --git a/workflows/self-audit.yml b/workflows/self-audit.yml index ce0ef0f..1cb8edb 100644 --- a/workflows/self-audit.yml +++ b/workflows/self-audit.yml @@ -20,6 +20,7 @@ steps: - id: measure-quality model: general + enforce: soft # runs go vet, tsc, eslint, ruff, clippy prompt: | Stack detected: {{detect}} @@ -33,6 +34,7 @@ steps: - id: measure-security model: general + enforce: soft # runs govulncheck, npm audit, grep for secrets prompt: | Stack detected: {{detect}} @@ -45,6 +47,7 @@ steps: - id: measure-git model: fast + enforce: soft # runs git log / git ls-tree prompt: | Measure git health: - 30-day commit count diff --git a/workflows/self-improve.yml b/workflows/self-improve.yml index 6e5817d..550958f 100644 --- a/workflows/self-improve.yml +++ b/workflows/self-improve.yml @@ -7,6 +7,7 @@ steps: - id: improve model: smart + enforce: soft # edits source to apply fixes prompt: | Current metric output: diff --git a/workflows/self-lint.yml b/workflows/self-lint.yml index d9ef97c..6eda503 100644 --- a/workflows/self-lint.yml +++ b/workflows/self-lint.yml @@ -7,6 +7,7 @@ steps: - id: fix model: smart + enforce: soft # edits source to fix violations prompt: | Current lint output: diff --git a/workflows/self-migrate.yml b/workflows/self-migrate.yml index 679fecc..cdeaf47 100644 --- a/workflows/self-migrate.yml +++ b/workflows/self-migrate.yml @@ -7,6 +7,7 @@ steps: - id: migrate model: smart + enforce: soft # edits source + updates imports/references prompt: | Test baseline: diff --git a/workflows/self-perf.yml b/workflows/self-perf.yml index be3a765..1ce7a33 100644 --- a/workflows/self-perf.yml +++ b/workflows/self-perf.yml @@ -7,6 +7,7 @@ steps: - id: optimize model: smart + enforce: soft # edits source to apply optimization prompt: | Performance baseline: diff --git a/workflows/self-test.yml b/workflows/self-test.yml index 76f23af..60c0ce8 100644 --- a/workflows/self-test.yml +++ b/workflows/self-test.yml @@ -10,6 +10,7 @@ steps: - id: fix model: smart + enforce: soft # edits code/tests to fix failures prompt: | Test output: diff --git a/workflows/test-gen.yml b/workflows/test-gen.yml index 9b74e44..47accac 100644 --- a/workflows/test-gen.yml +++ b/workflows/test-gen.yml @@ -4,6 +4,7 @@ description: Generate tests for code, run them, iterate until they pass — anal steps: - id: analyze model: general + # analyze stays hard: Read-only framework detection. prompt: | Target: {{input}} @@ -22,6 +23,7 @@ steps: - id: generate model: smart + enforce: soft # spawns test-writer agent via Task tool prompt: | Target: {{input}} Analysis: {{analyze}} @@ -39,6 +41,7 @@ steps: - id: run-fix model: general + enforce: soft # runs tests + dispatches fix agents prompt: | Test files just created: {{generate}} diff --git a/workflows/tri-review.yml b/workflows/tri-review.yml index 4f6de33..eb561fd 100644 --- a/workflows/tri-review.yml +++ b/workflows/tri-review.yml @@ -1,10 +1,10 @@ name: Tri-Review description: Three-tier model review — reviews run in parallel, then consolidated -enforce: soft steps: - id: gather model: fast + enforce: soft # runs git diff / file reads to collect the diff prompt: | Collect the code or diff to review: {{input}} diff --git a/workflows/tri-security.yml b/workflows/tri-security.yml index ce4e872..833e507 100644 --- a/workflows/tri-security.yml +++ b/workflows/tri-security.yml @@ -1,10 +1,10 @@ name: Tri-Security description: Three-tier security audit — parallel review focused on vulnerabilities -enforce: soft steps: - id: gather model: fast + enforce: soft # runs git diff / file reads to collect the code prompt: | Collect the code to audit: {{input}}