Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions lab/internal/cli/allowlist_label_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
})
}
}
73 changes: 69 additions & 4 deletions lab/internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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):")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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."}
}
Expand Down
86 changes: 86 additions & 0 deletions lab/internal/cli/workflow_role_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
12 changes: 12 additions & 0 deletions lab/internal/onboarding/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading
Loading