diff --git a/README.md b/README.md index eae172f..f1d33c8 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,8 @@ forgecell ui # 3. Set up your Lab in the browser, then pick an Issue. ``` You need Git, the GitHub CLI signed in (`gh auth login`), and one coding agent signed in -with its own CLI: Claude Code, Codex or Cursor. The UI walks you through the rest: +with its own CLI: Claude Code, Codex or Cursor. Cursor codes only, so setup pairs it +with Claude Code or Codex for planning. The UI walks you through the rest: - **Set up your Lab**: your repository and `gh` sign-in status, pick a coding agent, create the Lab, then review and approve its first Formula, with or without the review @@ -129,6 +130,8 @@ authentication, and proposes a Formula for review. It does not silently activate Binding support and read-only ticket-analysis support are different: Codex analysis is verified; other bindings have [documented limits](docs/ticket-readiness.md#provider-and-policy-support). +Cursor codes only: it can't do read-only ticket analysis, so setup pairs it with Claude +Code, or else Codex, for planning (`harnesses.analysis`), and refuses if neither is ready. Your harness supplies coding and reasoning. Forgecell keeps the workflow and its evidence. A learn binding is separate and is not silently configured during onboarding. diff --git a/docs/install.md b/docs/install.md index 1ef2d76..3a2cee9 100644 --- a/docs/install.md +++ b/docs/install.md @@ -43,7 +43,12 @@ credentials. GitLab and Jira ship in the v0.4.0 release as bundled status. This step only reads; sign-in stays in `gh`. 2. **Coding agent**: pick claude-code, codex or cursor. Each one shows whether it is ready and signed in. An agent that is not ready can't be picked: install it, sign - in with its own CLI, then press Check Again. + in with its own CLI, then press Check Again. Cursor codes only: it can't plan + (read-only ticket analysis), so setup pairs it with Claude Code, or else Codex, + for planning, as `harnesses.analysis` in the proposed Formula. If neither is + ready, setup refuses and saves nothing: install or sign in to Claude Code or Codex + first. To plan with another agent later, run + `forgecell init --harness ID --role analysis`. 3. **Create the Lab**: press Create Lab. Forgecell saves the Lab and proposes its first Formula, as `forgecell init --harness` does. Nothing is approved yet. 4. **Approve the Formula**: review the Atoms in order, the baseline checks taken from diff --git a/lab/internal/cli/allowlist_label_test.go b/lab/internal/cli/allowlist_label_test.go new file mode 100644 index 0000000..838c870 --- /dev/null +++ b/lab/internal/cli/allowlist_label_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "github.com/DanielNetzer/forgecell/lab/internal/readiness" +) + +// TestPlanAllowlistLabel: the plan view names the coding binding's harness. Only +// the claude-code adapter applies the coding allowlist, so Codex and Cursor get +// one line saying it is not applied instead of entries shown as if enforced. +func TestPlanAllowlistLabel(t *testing.T) { + snapshot := func(defaultAdapter, coding string) string { + text := "kind: formula\nid: f\nharness: {binding: " + defaultAdapter + ", command: [/forgecell, __adapter, " + defaultAdapter + ", /provider]}\n" + if coding != "" { + text += "harnesses:\n coding: {binding: " + coding + ", command: [/forgecell, __adapter, " + coding + ", /provider]}\n" + } + return text + roleAtoms + } + allowlist := []string{"Read", "Bash(go -C lab test ./...)"} + for name, tc := range map[string]struct { + yaml string + allowlist []string + want, deny []string + }{ + "claude-code": {snapshot("claude-code", ""), allowlist, []string{"Claude Code coding allowlist (approved with this plan", " Bash(go -C lab test ./...)"}, []string{"not applied"}}, + "codex": {snapshot("codex", ""), allowlist, []string{"Coding allowlist: not applied — coding is bound to Codex"}, []string{"Claude Code coding allowlist", " Bash(go -C lab test ./...)"}}, + "cursor": {snapshot("cursor", ""), allowlist, []string{"Coding allowlist: not applied — coding is bound to Cursor"}, []string{"Claude Code coding allowlist", " Bash(go -C lab test ./...)"}}, + "coding role": {snapshot("claude-code", "codex"), allowlist, []string{"coding is bound to Codex"}, []string{"Claude Code coding allowlist"}}, + "claude role": {snapshot("cursor", "claude-code"), allowlist, []string{"Claude Code coding allowlist (approved with this plan", " Read"}, []string{"not applied"}}, + "cursor none": {snapshot("cursor", ""), nil, []string{"coding is bound to Cursor"}, []string{"none stored"}}, + "custom": {"kind: formula\nid: f\nharness: {binding: codex, command: [/custom/wrapper]}\n" + roleAtoms, allowlist, []string{"Claude Code coding allowlist (approved with this plan"}, []string{"not applied"}}, + "no snapshot": {"", allowlist, []string{"Claude Code coding allowlist (approved with this plan", " Read"}, []string{"not applied"}}, + "unparsable": {"harness: [", nil, []string{"Claude Code coding allowlist: none stored"}, []string{"not applied"}}, + } { + t.Run(name, func(t *testing.T) { + p := readiness.Plan{CodingAllowlist: tc.allowlist} + r := molecule.Record{ID: "mol-1", FormulaSnapshot: molecule.Snapshot{YAML: tc.yaml}, Readiness: &readiness.State{Phase: "scope-waiting", Plans: []readiness.Proposal{{Plan: p, Digest: "d"}}}} + var out bytes.Buffer + presentRun(&out, r) + for _, want := range tc.want { + if !strings.Contains(out.String(), want) { + t.Fatalf("missing %q in:\n%s", want, out.String()) + } + } + for _, deny := range tc.deny { + if strings.Contains(out.String(), deny) { + t.Fatalf("unexpected %q in:\n%s", deny, out.String()) + } + } + }) + } +} diff --git a/lab/internal/cli/cli.go b/lab/internal/cli/cli.go index 4dcd690..9378738 100644 --- a/lab/internal/cli/cli.go +++ b/lab/internal/cli/cli.go @@ -629,7 +629,11 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer source = "saved observation; not current verification" } if result.Binding != nil { - renderCapabilities(out, result.Binding.Candidate, result.Binding.LearnBound, source) + text := result.BeforeYAML + if result.Proposal != nil { + text = result.Proposal.YAML + } + renderCapabilitiesWorkflow(out, result.Binding.Candidate, result.Binding.LearnBound, source, bindingWorkflow(*result.Binding, text)) } if result.Meta != nil { renderMeta(out, *result.Meta, source) @@ -785,6 +789,7 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer firstRoleCommand = roles[i].NextCommand } } + workflow = analysisRoleWorkflow(probed, roles, workflow) if len(active.Formula.Harnesses) > 0 { ready = !unapproved && git.OK && ghReady && rolesReady } @@ -1391,7 +1396,9 @@ func presentRun(out io.Writer, r molecule.Record) { } if p.Continuation == "" { fmt.Fprintln(out, codingTimeoutLine(r.FormulaSnapshot.YAML, p.HarnessTimeoutMS)) - if len(p.CodingAllowlist) == 0 { + if line := codingAllowlistNotApplied(r.FormulaSnapshot.YAML); line != "" { + fmt.Fprintln(out, line) + } else if len(p.CodingAllowlist) == 0 { fmt.Fprintln(out, "Claude Code coding allowlist: none stored (plan predates derivation); the earlier fixed Node allowance applies.") } else { fmt.Fprintln(out, "Claude Code coding allowlist (approved with this plan; each Bash entry runs repository code):") @@ -1568,6 +1575,28 @@ func protectedFailures(r molecule.Record) []readiness.FailingTest { return failing } +// codingAllowlistNotApplied is the one plan-view line for a snapshot whose coding +// binding runs a native adapter that ignores the coding allowlist: only the +// claude-code adapter passes it to the harness. It is "" for a claude-code, custom +// or unreadable binding, which keep the Claude Code allowlist wording. +func codingAllowlistNotApplied(snapshot string) string { + f, err := formula.Parse([]byte(snapshot)) + if err != nil { + return "" + } + b, _ := f.RoleBinding(formula.CodingHarness) + if len(b.Command) != 4 || b.Command[1] != "__adapter" { + return "" + } + switch b.Command[2] { + case "codex": + return "Coding allowlist: not applied — coding is bound to Codex, whose adapter does not pass the Claude Code tool allowlist; Codex runs under its own sandbox." + case "cursor": + return "Coding allowlist: not applied — coding is bound to Cursor, whose adapter does not pass the Claude Code tool allowlist; Cursor runs under its own sandbox." + } + return "" +} + // codingTimeoutLine states the timeout the approved plan will use, so a reviewed // per-Molecule value is visible at the gate next to the Formula default and maximum. func codingTimeoutLine(snapshot string, planMS int) string { @@ -1809,7 +1838,7 @@ func renderProposal(out, stderr io.Writer, p onboarding.Proposal, dir string, js } } report := proposalCapabilities(p) - renderCapabilities(out, report.Candidate, report.LearnBound, "saved observation; not current verification") + renderCapabilitiesWorkflow(out, report.Candidate, report.LearnBound, "saved observation; not current verification", bindingWorkflow(report, p.YAML)) renderRoleCapabilities(out, report.Roles, "saved observation; not current verification") meta := proposalMeta(p) renderMeta(out, meta, "saved observation; not current verification") @@ -1845,6 +1874,35 @@ func selectedWorkflow(candidates []onboarding.Candidate, id string) harness.Capa return harness.Capability{State: "unknown", Reason: "No binding selected."} } +// bindingWorkflow is the GitHub Issue workflow line of a binding report for +// Formula YAML text: the report's own workflow, which Prepare takes from the +// analysis binding, when the Formula declares harnesses.analysis, else the +// default candidate's as before. +func bindingWorkflow(report onboarding.BindingReport, text string) harness.Capability { + if f, err := formula.Parse([]byte(text)); err == nil && report.Workflow.State != "" { + if _, declared := f.Harnesses[string(formula.AnalysisHarness)]; declared { + return report.Workflow + } + } + return report.Candidate.WorkflowCapability() +} + +// analysisRoleWorkflow is the GitHub Issue workflow capability of a Formula that +// declares harnesses.analysis: intake runs that role's binding, so its probed +// capability replaces the default binding's. Any other Formula, or one whose +// roles were not probed, keeps fallback. +func analysisRoleWorkflow(f formula.Formula, roles []onboarding.RoleReadiness, fallback harness.Capability) harness.Capability { + if _, declared := f.Harnesses[string(formula.AnalysisHarness)]; !declared { + return fallback + } + for _, r := range roles { + if r.Role == string(formula.AnalysisHarness) && r.Source == "override" { + return r.Capability + } + } + return fallback +} + // runIssue is the engine call behind forgecell run; tests may replace it. var runIssue = molecule.RunIntake @@ -2229,11 +2287,18 @@ func renderDoctorCandidate(out io.Writer, title string, c doctorCandidate) { } func renderCapabilities(out io.Writer, c onboarding.Candidate, learn bool, source string) { + renderCapabilitiesWorkflow(out, c, learn, source, c.WorkflowCapability()) +} + +// renderCapabilitiesWorkflow is renderCapabilities with the GitHub Issue workflow +// line taken from workflow, which differs from the default binding's only when +// the Formula declares harnesses.analysis (see bindingWorkflow). +func renderCapabilitiesWorkflow(out io.Writer, c onboarding.Candidate, learn bool, source string, workflow harness.Capability) { fmt.Fprintf(out, "Capabilities (%s):\nCLI probe: %s — %s\n", source, c.Readiness, c.Detail) for _, item := range []struct { name string value harness.Capability - }{{"Installed CLI", c.Installed}, {"Authentication", c.Authentication}, {"Coding adapter", c.Capabilities.Coding}, {"Read-only ticket analysis adapter", c.Capabilities.Analysis}, {"Installed analysis controls", c.Capabilities.AnalysisControls}, {"Meta-learning adapter", c.Capabilities.Meta}, {"Model access", c.Capabilities.ModelAccess}, {"GitHub Issue workflow", c.WorkflowCapability()}} { + }{{"Installed CLI", c.Installed}, {"Authentication", c.Authentication}, {"Coding adapter", c.Capabilities.Coding}, {"Read-only ticket analysis adapter", c.Capabilities.Analysis}, {"Installed analysis controls", c.Capabilities.AnalysisControls}, {"Meta-learning adapter", c.Capabilities.Meta}, {"Model access", c.Capabilities.ModelAccess}, {"GitHub Issue workflow", workflow}} { if item.value.State == "" { item.value = harness.Capability{State: "unknown", Reason: "Not recorded."} } diff --git a/lab/internal/cli/workflow_role_test.go b/lab/internal/cli/workflow_role_test.go new file mode 100644 index 0000000..6c33cec --- /dev/null +++ b/lab/internal/cli/workflow_role_test.go @@ -0,0 +1,86 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DanielNetzer/forgecell/lab/internal/harness" + "github.com/DanielNetzer/forgecell/lab/internal/onboarding" +) + +// saveCursorFormula writes and approves an active Formula that codes with Cursor, +// with optional per-role bindings from the caller. +func saveCursorFormula(t *testing.T, lab, cursorCommand, harnesses string) { + t.Helper() + os.MkdirAll(filepath.Join(lab, "formulas"), 0700) + os.WriteFile(filepath.Join(lab, "lab.json"), []byte(`{"schemaVersion":"v0","activeFormulaId":"f"}`), 0600) + recipe := "kind: formula\nid: f\nintake: {repo: acme/widgets}\nharness:\n binding: cursor\n command: " + cursorCommand + "\n" + harnesses + roleAtoms + if err := os.WriteFile(filepath.Join(lab, "formulas", "f.yaml"), []byte(recipe), 0600); err != nil { + t.Fatal(err) + } + approveSavedFormula(t, lab) +} + +// TestDoctorWorkflowAnalysisRole: a Formula that codes with Cursor and plans with +// a ready Codex through harnesses.analysis reports the GitHub Issue workflow from +// the analysis binding; without the override it stays unsupported, as before. +func TestDoctorWorkflowAnalysisRole(t *testing.T) { + bin, lab := doctorFixture(t) + launcher, err := os.Executable() + if err != nil { + t.Fatal(err) + } + codex := writeTool(t, bin, "codex", strings.Replace(codexTool, "LOGIN", "Logged in using ChatGPT", 1)) + cursor := writeTool(t, bin, "cursor-agent", strings.NewReplacer("LOGGED", "true", "HELPFLAGS", "--sandbox --mode --trust").Replace(cursorTool)) + cursorCommand := argv(t, launcher, "__adapter", "cursor", cursor) + codexCommand := argv(t, launcher, "__adapter", "codex", codex) + + saveCursorFormula(t, lab, cursorCommand, "harnesses:\n analysis: {binding: codex, command: "+codexCommand+"}\n") + _, out := runDoctor(t, lab) + report := decodeDoctor(t, out) + workflow := report["workflow"].(map[string]any) + analysis := rolesByName(t, report)["analysis"] + if workflow["state"] != "conditional" || workflow["reason"] != analysis["capability"].(map[string]any)["reason"] { + t.Fatalf("workflow must come from the analysis binding: %v (analysis role %v)", workflow, analysis) + } + if coding := rolesByName(t, report)["coding"]; coding["binding"] != "cursor" || coding["ready"] != true { + t.Fatalf("coding readiness must come from the coding binding: %v", coding) + } + for _, key := range []string{"schemaVersion", "roles", "workflow", "savedBinding", "candidates", "selection", "gitReady", "githubAuthenticated"} { + if _, ok := report[key]; !ok { + t.Fatalf("doctor JSON lost %s: %s", key, out) + } + } + if _, text := runDoctor(t, lab, "--verbose"); !strings.Contains(text, "GitHub Issue workflow: conditional") { + t.Fatalf("human doctor output did not read the analysis binding:\n%s", text) + } + + saveCursorFormula(t, lab, cursorCommand, "") + _, out = runDoctor(t, lab) + workflow = decodeDoctor(t, out)["workflow"].(map[string]any) + if workflow["state"] != "unsupported" || workflow["reason"] != harness.AdapterCapabilities("cursor").Analysis.Reason { + t.Fatalf("a Formula without harnesses.analysis must keep the default binding's workflow: %v", workflow) + } +} + +// TestDoctorWorkflowAnalysisRoleSummary: the proposal summary's GitHub Issue +// workflow line reads the saved analysis-binding workflow only when the Formula +// declares harnesses.analysis. +func TestDoctorWorkflowAnalysisRoleSummary(t *testing.T) { + cursor := onboarding.Candidate{ID: "cursor", Readiness: "ready", Capabilities: harness.AdapterCapabilities("cursor")}.CompleteUnknowns() + planned := harness.Capability{State: "conditional", Reason: "planner ready"} + report := onboarding.BindingReport{Candidate: cursor, Workflow: planned} + declared := "kind: formula\nid: f\nharness: {binding: cursor, command: [/forgecell, __adapter, cursor, /cursor]}\nharnesses:\n analysis: {binding: codex, command: [/forgecell, __adapter, codex, /codex]}\n" + roleAtoms + if got := bindingWorkflow(report, declared); got != planned { + t.Fatalf("declared analysis: %v", got) + } + plain := "kind: formula\nid: f\nharness: {binding: cursor, command: [/forgecell, __adapter, cursor, /cursor]}\n" + roleAtoms + if got := bindingWorkflow(report, plain); got != cursor.WorkflowCapability() { + t.Fatalf("no analysis override must keep the default's workflow: %v", got) + } + if got := bindingWorkflow(report, "not: [a formula"); got != cursor.WorkflowCapability() { + t.Fatalf("unparsable YAML must keep the default's workflow: %v", got) + } +} diff --git a/lab/internal/onboarding/binding.go b/lab/internal/onboarding/binding.go index adca63f..14956e0 100644 --- a/lab/internal/onboarding/binding.go +++ b/lab/internal/onboarding/binding.go @@ -161,6 +161,18 @@ func (c Candidate) WorkflowCapability() harness.Capability { return harness.Capability{State: "conditional", Reason: "Ticket analysis can be attempted with verified controls; model access and end-to-end workflow remain unverified."} } +// FormulaWorkflowCapability describes whether intake can start for Formula f: +// when f declares harnesses.analysis, intake runs that binding, so its probed +// workflow capability is reported; otherwise the default candidate's, unchanged. +// The probe runs only the fixed read-only adapter checks of ProbeBinding. +func FormulaWorkflowCapability(ctx context.Context, f formula.Formula, def Candidate, cwd string, run Runner) harness.Capability { + b, declared := f.Harnesses[string(formula.AnalysisHarness)] + if !declared { + return def.WorkflowCapability() + } + return ProbeBinding(ctx, b, cwd, run).CompleteUnknowns().WorkflowCapability() +} + func nativeAdapter(b formula.Binding) bool { return len(b.Command) == 4 && b.Command[1] == "__adapter" } diff --git a/lab/internal/onboarding/bootstrap_analysis_test.go b/lab/internal/onboarding/bootstrap_analysis_test.go new file mode 100644 index 0000000..61e2b85 --- /dev/null +++ b/lab/internal/onboarding/bootstrap_analysis_test.go @@ -0,0 +1,164 @@ +package onboarding + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/DanielNetzer/forgecell/lab/internal/formula" + "github.com/DanielNetzer/forgecell/lab/internal/harness" + "github.com/DanielNetzer/forgecell/lab/internal/resources" +) + +func bootstrapCandidate(id, executable, readiness string) Candidate { + c := Candidate{ID: id, Executable: executable, Readiness: readiness, Detail: id + " is " + readiness + ".", Capabilities: harness.AdapterCapabilities(id)} + if c.Capabilities.Analysis.State == "supported" { + c.Capabilities.AnalysisControls = harness.Capability{State: "supported", Reason: "controls verified"} + } + return c +} + +// prepareCursorBootstrap runs a fresh-Lab init --harness cursor with the given +// planner candidates. +func prepareCursorBootstrap(t *testing.T, planners ...Candidate) (Outcome, string, error) { + t.Helper() + root, run := repoFixture(t) + dir := filepath.Join(t.TempDir(), "lab") + candidates := append([]Candidate{bootstrapCandidate("cursor", "/bin/cursor-agent", "ready")}, planners...) + out, err := Prepare(context.Background(), InitOptions{Cwd: root, LabDir: dir, Launcher: "/installed/forgecell", Preferred: "cursor", Candidates: candidates, Runner: run}) + return out, dir, err +} + +// assertPlannedBy checks a pending Cursor bootstrap declares harnesses.analysis on +// planner, saves it verifiably, and after approval intake resolves to planner. +func assertPlannedBy(t *testing.T, out Outcome, dir, planner, executable string) { + t.Helper() + if out.Status != "pending" || out.Proposal == nil { + t.Fatalf("no pending proposal: %+v", out) + } + f, err := formula.Parse([]byte(out.Proposal.YAML)) + if err != nil { + t.Fatal(err) + } + if f.Harness.Binding != "cursor" || !slices.Equal(f.Harness.Command, []string{"/installed/forgecell", "__adapter", "cursor", "/bin/cursor-agent"}) { + t.Fatalf("coding binding changed: %+v", f.Harness) + } + analysis, declared := f.Harnesses[string(formula.AnalysisHarness)] + if !declared || len(f.Harnesses) != 1 || analysis.Binding != planner || !slices.Equal(analysis.Command, []string{"/installed/forgecell", "__adapter", planner, executable}) { + t.Fatalf("harnesses = %+v, want only analysis on %s", f.Harnesses, planner) + } + for _, want := range []string{"cursor cannot do read-only ticket analysis", "binds planning to " + planner, "forgecell init --harness ID --role analysis"} { + if !strings.Contains(out.Note, want) { + t.Fatalf("note lacks %q: %s", want, out.Note) + } + } + if err = ValidateRoleBindings(f); err != nil { + t.Fatal(err) + } + saved, err := InspectProposal(dir, out.Proposal.ID) + if err != nil { + t.Fatal(err) + } + evidence := saved.CapabilityEvidence + if evidence == nil || evidence.Candidate.ID != "cursor" || evidence.Workflow.State != "conditional" { + t.Fatalf("saved evidence must keep the coding candidate and the planner's workflow: %+v", evidence) + } + if _, err = Review(dir, out.Proposal.ID, "approve"); err != nil { + t.Fatal(err) + } + approved, err := formula.Load(dir, "") + if err != nil || !approved.Approved { + t.Fatalf("approval: %+v %v", approved, err) + } + if b, _ := approved.Formula.ExecutionBinding(formula.IntakeRole); b.Binding != planner { + t.Fatalf("intake resolves to %q, want %s", b.Binding, planner) + } + if b, _ := approved.Formula.RoleBinding(formula.CodingHarness); b.Binding != "cursor" { + t.Fatalf("coding resolves to %q, want cursor", b.Binding) + } +} + +func TestBootstrapCursorPairsClaudeCode(t *testing.T) { + out, dir, err := prepareCursorBootstrap(t, bootstrapCandidate("codex", "/bin/codex", "ready"), bootstrapCandidate("claude-code", "/bin/claude", "ready")) + if err != nil { + t.Fatal(err) + } + assertPlannedBy(t, out, dir, "claude-code", "/bin/claude") +} + +func TestBootstrapCursorPairsCodexWhenClaudeCodeNotReady(t *testing.T) { + for name, claude := range map[string]Candidate{ + "login required": bootstrapCandidate("claude-code", "/bin/claude", "login-required"), + "not installed": bootstrapCandidate("claude-code", "", "missing"), + } { + t.Run(name, func(t *testing.T) { + out, dir, err := prepareCursorBootstrap(t, claude, bootstrapCandidate("codex", "/bin/codex", "ready")) + if err != nil { + t.Fatal(err) + } + assertPlannedBy(t, out, dir, "codex", "/bin/codex") + }) + } +} + +func TestBootstrapCursorRefusesWithoutPlanner(t *testing.T) { + for name, planners := range map[string][]Candidate{ + "none discovered": nil, + "none ready": {bootstrapCandidate("claude-code", "", "missing"), bootstrapCandidate("codex", "/bin/codex", "login-required")}, + "no executable": {bootstrapCandidate("codex", "", "ready")}, + } { + t.Run(name, func(t *testing.T) { + out, dir, err := prepareCursorBootstrap(t, planners...) + if err == nil { + t.Fatalf("Cursor bootstrap without a planner was proposed: %+v", out) + } + for _, want := range []string{harness.AdapterCapabilities("cursor").Analysis.Reason, "install or sign in to Claude Code or Codex", "no proposal saved"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("refusal lacks %q: %v", want, err) + } + } + if strings.Contains(err.Error(), dir) || strings.Contains(err.Error(), "/bin/") || strings.Contains(err.Error(), "/installed/") { + t.Fatalf("refusal leaks a local path: %v", err) + } + if out.Proposal != nil { + t.Fatalf("refusal returned a proposal: %+v", out.Proposal) + } + if entries, e := os.ReadDir(filepath.Join(dir, "assays")); e == nil && len(entries) > 0 || e != nil && !os.IsNotExist(e) { + t.Fatalf("refusal wrote assays: %v %v", entries, e) + } + if _, e := os.Stat(filepath.Join(dir, resources.ConfigFile)); !os.IsNotExist(e) { + t.Fatalf("refusal wrote %s: %v", resources.ConfigFile, e) + } + }) + } +} + +func TestBootstrapNativeAnalysisUnchanged(t *testing.T) { + for _, id := range []string{"claude-code", "codex"} { + t.Run(id, func(t *testing.T) { + root, run := repoFixture(t) + dir := filepath.Join(t.TempDir(), "lab") + candidates := []Candidate{bootstrapCandidate("claude-code", "/bin/claude", "ready"), bootstrapCandidate("codex", "/bin/codex", "ready"), bootstrapCandidate("cursor", "/bin/cursor-agent", "ready")} + out, err := Prepare(context.Background(), InitOptions{Cwd: root, LabDir: dir, Launcher: "/installed/forgecell", Preferred: id, Candidates: candidates, Runner: run}) + if err != nil { + t.Fatal(err) + } + f, err := formula.Parse([]byte(out.Proposal.YAML)) + if err != nil { + t.Fatal(err) + } + if f.Harness.Binding != id || len(f.Harnesses) != 0 || strings.Contains(out.Proposal.YAML, "harnesses") { + t.Fatalf("%s bootstrap gained role bindings:\n%s", id, out.Proposal.YAML) + } + if strings.Contains(out.Note, "harnesses.analysis") || !strings.HasPrefix(out.Note, "Bootstrap Assay is read-only.") { + t.Fatalf("%s bootstrap note changed: %s", id, out.Note) + } + if out.Binding == nil || out.Binding.Workflow != out.Binding.Candidate.WorkflowCapability() { + t.Fatalf("%s workflow must come from the coding candidate: %+v", id, out.Binding) + } + }) + } +} diff --git a/lab/internal/onboarding/init.go b/lab/internal/onboarding/init.go index ec70117..ee24162 100644 --- a/lab/internal/onboarding/init.go +++ b/lab/internal/onboarding/init.go @@ -434,6 +434,32 @@ func learnCommand(f formula.Formula) []string { return b.Command } +// bootstrapPlanners are the harnesses a bootstrap pairs with a coding harness +// that cannot do read-only ticket analysis, in order of preference. +var bootstrapPlanners = []string{"claude-code", "codex"} + +// bootstrapPlanner returns nil when the coding binding can do read-only ticket +// analysis itself. Otherwise it returns the first ready, discovered planner whose +// adapter can, with the native command harnesses.analysis would run, or an error +// quoting the coding adapter's reason when there is none. It executes nothing. +func bootstrapPlanner(candidates []Candidate, coding string, command []string, launcher, lab string) (*Candidate, []string, error) { + analysis := roleCapability(formula.AnalysisHarness, formula.Binding{Binding: coding, Command: command}) + if analysis.State != "unsupported" { + return nil, nil, nil + } + for _, id := range bootstrapPlanners { + for i := range candidates { + if candidates[i].ID != id || candidates[i].Readiness != "ready" || candidates[i].Executable == "" { + continue + } + if planner, err := roleHarnessTarget(candidates, id, launcher, formula.AnalysisHarness, lab); err == nil { + return &candidates[i], planner, nil + } + } + } + return nil, nil, fmt.Errorf("cannot propose a bootstrap Formula that codes with %s: it cannot be used for read-only ticket analysis: %s; planning needs a ready Claude Code or Codex harness and neither is ready; install or sign in to Claude Code or Codex, then rerun forgecell init; no proposal saved", coding, analysis.Reason) +} + func Prepare(ctx context.Context, o InitOptions) (out Outcome, err error) { if o.pluginInitRequested() { return preparePluginInit(ctx, o) @@ -627,7 +653,7 @@ func Prepare(ctx context.Context, o InitOptions) (out Outcome, err error) { } if active.Approved && o.Preferred == "" && (o.MetaHarness == "" && o.MetaCommand == nil || LearnBound(active.Formula)) { checked := ProbeBinding(ctx, active.Formula.Harness, out.Repository.Root, o.Runner) - out.Binding = &BindingReport{Command: active.Formula.Harness.Command, Candidate: checked, Workflow: checked.WorkflowCapability(), LearnBound: LearnBound(active.Formula)} + out.Binding = &BindingReport{Command: active.Formula.Harness.Command, Candidate: checked, Workflow: FormulaWorkflowCapability(ctx, active.Formula, checked, out.Repository.Root, o.Runner), LearnBound: LearnBound(active.Formula)} out.Status = "unchanged" out.BeforeYAML = active.Formula.YAML out.Note = "Preserved existing approved Formula and exact command bindings. Explicitly choose --harness to propose a rebind." @@ -661,6 +687,15 @@ func Prepare(ctx context.Context, o InitOptions) (out Outcome, err error) { return out, err } } + // A bootstrap whose coding harness cannot do read-only ticket analysis pairs it + // with a planning harness, or refuses before anything is saved. + var planner *Candidate + var plannerCommand []string + if !active.Approved { + if planner, plannerCommand, err = bootstrapPlanner(out.Candidates, selected.ID, command, o.Launcher, o.LabDir); err != nil { + return out, err + } + } var recipe map[string]any var rewrites []launcherRewrite if active.Approved { @@ -718,6 +753,10 @@ func Prepare(ctx context.Context, o InitOptions) (out Outcome, err error) { return out, err } out.Note = "Bootstrap Assay is read-only. Proposed checks are evidence, not executed commands. Approving binds the coding harness; runs still stop at human review." + if planner != nil { + recipe["harnesses"] = map[string]any{string(formula.AnalysisHarness): map[string]any{"binding": planner.ID, "command": plannerCommand}} + out.Note += fmt.Sprintf(" %s cannot do read-only ticket analysis, so harnesses.analysis binds planning to %s, the first ready harness that can (claude-code, then codex); %s still codes. To plan with another harness after approval, run forgecell init --harness ID --role analysis.", selected.ID, planner.ID, selected.ID) + } if len(baseline) > 0 { recipe["requiredChecks"] = baseline ids := make([]string, len(baseline)) @@ -807,6 +846,9 @@ func Prepare(ctx context.Context, o InitOptions) (out Outcome, err error) { return out, err } out.Binding = &BindingReport{Command: command, Candidate: selected, Workflow: selected.WorkflowCapability(), LearnBound: LearnBound(active.Formula)} + if planner != nil { + out.Binding.Workflow = planner.WorkflowCapability() + } if o.MetaHarness != "" || o.MetaCommand != nil || LearnBound(active.Formula) { out.Binding.Meta = out.Meta out.Binding.LearnBound = true diff --git a/lab/internal/onboarding/init_test.go b/lab/internal/onboarding/init_test.go index eea0a0a..6f4f224 100644 --- a/lab/internal/onboarding/init_test.go +++ b/lab/internal/onboarding/init_test.go @@ -2,6 +2,7 @@ package onboarding import ( "context" + "github.com/DanielNetzer/forgecell/lab/internal/formula" "github.com/DanielNetzer/forgecell/lab/internal/harness" "github.com/DanielNetzer/forgecell/lab/internal/process" "github.com/DanielNetzer/forgecell/lab/internal/resources" @@ -219,8 +220,12 @@ func TestInitReportsCursorBeforeApprovalWithoutFallback(t *testing.T) { if out.Selection.ID != "cursor" || out.Binding == nil || out.Binding.Candidate.WorkflowCapability().State != "unsupported" || out.Proposal.CapabilityEvidence == nil { t.Fatalf("%+v", out) } - if strings.Contains(out.Proposal.YAML, "binding: codex") || strings.Contains(out.Proposal.YAML, "type: learn") { - t.Fatal("changed selection or added learning") + f, err := formula.Parse([]byte(out.Proposal.YAML)) + if err != nil { + t.Fatal(err) + } + if f.Harness.Binding != "cursor" || f.Harnesses[string(formula.AnalysisHarness)].Binding != "codex" || strings.Contains(out.Proposal.YAML, "type: learn") { + t.Fatal("changed selection, missed the codex planner or added learning") } saved, err := InspectProposal(dir, out.Proposal.ID) if err != nil || saved.CapabilityEvidence.Candidate.Capabilities.Analysis.Reason != c.Capabilities.Analysis.Reason { diff --git a/lab/internal/onboarding/proposal.go b/lab/internal/onboarding/proposal.go index 905b622..a9c93da 100644 --- a/lab/internal/onboarding/proposal.go +++ b/lab/internal/onboarding/proposal.go @@ -235,7 +235,11 @@ func saveWithEvidence(dir, yaml string, evidence *Repository, reports ...*Bindin p.CapabilityVersion = "v2" } reports[0].Candidate = reports[0].Candidate.CompleteUnknowns() - reports[0].Workflow = reports[0].Candidate.WorkflowCapability() + // Intake runs a declared harnesses.analysis binding, so the caller's workflow + // observation of that binding is kept; otherwise it is the default's. + if _, declared := f.Harnesses[string(formula.AnalysisHarness)]; !declared || reports[0].Workflow.State == "" { + reports[0].Workflow = reports[0].Candidate.WorkflowCapability() + } reports[0].Roles = RoleCapabilities(f) p.CapabilityEvidence = withoutProbeDetail(*reports[0]) if !reflect.DeepEqual(p.CapabilityEvidence.Command, f.Harness.Command) || p.CapabilityEvidence.Candidate.ID != f.Harness.Binding {