diff --git a/lab/README.md b/lab/README.md index 9ca0de9..dc1fc43 100644 --- a/lab/README.md +++ b/lab/README.md @@ -99,6 +99,26 @@ approved Formula with only the target binding keys and those recorded launcher rewrites applied, and `--rebind` refuses with "nothing to rebind" when no key would change. +`forgecell init --lab DIR --harness ID --role analysis|coding|meta` proposes a reviewed, +binding-only change to one role override through the same flow (pending proposal, +`--review`, `--approve`). If `harnesses.ROLE` is declared it rebinds it to `ID` with the +native command `[launcher, "__adapter", ID, provider]` on the running launcher, keeping its +other keys; otherwise it declares it with that binding and command. `ID` must be a +discovered, ready harness whose adapter can perform the role (for example Cursor is refused +for `analysis`, with Cursor's own reason). A request that leaves the role's effective +binding unchanged, such as `--role coding` with the default harness, is refused with +"nothing to change"; there is no remove mode. The proposal is verified to equal the approved +Formula with only `harnesses.ROLE` binding and command changed, each change is listed as +`path: old -> new` (or `path: (none) -> new`), and the result must still pass Formula +validation, so for example a coding override must match the harness Atom's binding and a +meta override must match the learn Atom's command. It needs a current exact approval and no +pending proposal, and cannot be combined with `--rebind`, `--model`, `--meta-harness`, +`--meta-command`, the timeout flags, `--review`, `--approve` or `--dismiss`. `--role` +without `--harness` or `--model` is refused. When `init --harness ID` on an approved Formula +is refused because `ID` cannot do read-only ticket analysis and no `harnesses.analysis` is +declared, declare one first, for example with +`forgecell init --lab DIR --harness claude-code --role analysis`. + ## Review a ticket (development build) Ticket readiness on this branch is **not in the published `0.2.0-preview.1`**. @@ -385,8 +405,8 @@ rewritten. `ID` is validated with the same rules as `harness.model`. Atom's command, which has no model, and does not inherit it. The proposal summary states this for each such role, derived from the resolution code. - With `--role ROLE` it changes only `harnesses.ROLE`. If that override is not declared the - proposal is refused, naming the role; run without `--role` or declare the override first. - Init never creates an override. + proposal is refused, naming the role; run without `--role` or declare the override first + with `init --harness ID --role ROLE` (below). `--model --role` never creates an override. - Setting a pin on a binding that is not native Claude Code is refused: the message names each Codex, Cursor or custom command binding (Forgecell cannot enforce a pin on a custom command), and without `--role` the whole proposal is refused. A Formula that declares no diff --git a/lab/internal/cli/cli.go b/lab/internal/cli/cli.go index 9819b43..4dcd690 100644 --- a/lab/internal/cli/cli.go +++ b/lab/internal/cli/cli.go @@ -48,7 +48,7 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer fail := func(err error) int { fmt.Fprintln(stderr, "forgecell:", err); return 1 } harness.SetRunningVersion(version) if len(args) == 0 || args[0] == "--help" || args[0] == "help" { - fmt.Fprintln(out, "Forgecell Lab — Go migration preview\n\n"+demoUsage+"\ninit [--lab DIR] [--harness ID | --rebind ID] [--meta-harness ID | --meta-command JSON_ARGV] [--harness-timeout-ms MS] [--max-timeout-ms MS] [--json] [--review ID | --approve ID | --dismiss ID]\ninit --model ID [--role analysis|coding|meta|review] [--lab DIR] [--json]\ninit --refresh-baseline [--lab DIR] [--json]\ninit --enable-review [--lab DIR] [--json]\ndoctor [--lab DIR] [--harness ID] [--json] [--verbose]\ndoctor --clean [--yes] [--lab DIR]\nrun [--lab DIR] [--formula ID] [--base COMMIT] [--target BRANCH] [--approve DIGEST] [--json]\namend [--add-scope PATH --reason TEXT]... [--add-acceptance TEXT]... [--note TEXT] [--continuation checks-only] [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --accept-protected-failures [--add-acceptance TEXT]... [--note TEXT] [--lab DIR] (declare the tests that failed by design in the latest protected regression run as expectedProtectedDeltas; continues checks-only; takes no --run: review the printed list, then approve the digest with run --approve DIGEST)\namend --parent DIGEST --plan FILE [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --parent DIGEST --rebase-formula [--lab DIR]\nrecover --plan DIGEST --confirm-stopped [--lab DIR]\nretire --reason TEXT [--lab DIR] (mark a stale Molecule retired: one-line reason of at most 200 characters; every record is kept, the loop never resumes it and may start a fresh Molecule for its Issue)\nloop [--lab DIR] [--select oldest|label:L|milestone:M] [--max-concurrent N] [--once]\nloop [--lab DIR] --watch [--interval DURATION] [--learn]\nledger [--lab DIR]\nissues --repo OWNER/REPO [--page N --page-size N | --select ISSUE_REF] [--lab DIR] [--json]\nlearn [--lab DIR] [--json]\nlearn --pending [--lab DIR] [--json]\nsuggestion [--lab DIR] [--approve | --dismiss | --link-evaluation DIR | --baseline-ledger ID --candidate-ledger ID --comparability-basis TEXT] [--evaluation-parent DIR] [--json]\nrollback\ndev install [--from CHECKOUT] [--activate] [--allow-dirty]\nevaluate --inputs PLAN --out NEW_DIRECTORY [--source REPO] [--approve DIGEST]\nevaluate --cohort FILE --out DIRECTORY [--source REPO] [--approve DIGEST] [--parallel N] [--recheck]\nevaluate replay --repo OWNER/REPO --pr N [--pr M ...] --source CHECKOUT --out NEW_DIRECTORY\ndeliver --lab DIR --base BRANCH [--approve DIGEST --title TITLE] -- FILE...\nchecks --lab DIR --molecule ID --repo OWNER/REPO --pr NUMBER --commit SHA --required NAME [--required NAME]\n"+findingsUsage+"\nlabs [--json]\n"+pluginHelp+"\nui [--lab DIR] [--lab-name NAME] [--port N] [--no-open] [--demo]\n--version\n--protocol\n\nThe default Lab is keyed by the checkout's GitHub origin (OWNER/REPO), not its path; --lab DIR always wins and --lab-name NAME selects a named Lab for the repository on any command that takes a Lab. labs lists every Lab, read-only.\n\ndev install is developer-only: it builds the checkout's lab/cmd/forgecell with the local Go toolchain and then runs the result, so the checkout is trusted code. Without --activate the build is only staged under releases/ and current is unchanged. A tree with uncommitted, untracked or ignored Go source changes is refused unless --allow-dirty is given. --protocol prints the adapter protocol version.\n\nui serves the Lab UI on 127.0.0.1 behind a per-launch token and opens it in the browser unless --no-open is given. Its writes go through the engine and are bound to the digest you saw; it never merges or deploys. With no ready Lab it serves Set up your Lab; --demo shows synthetic sample data. It stops on SIGINT or SIGTERM.\n\n"+demoHelp+"\n\nRuns analyze a ticket before coding and wait for exact scope approval. Required checks run independently before final review.\n\nloop starts intake for eligible open Issues of the Formula's repository, oldest first, and approves a scope gate only where the Formula declares a policy that approves that exact plan. It stops each Molecule at any human gate, open question or failure and reports why; it never delivers, merges or approves a human gate. --once makes a single pass. --watch repeats single passes every --interval (default 5m, at least 1m) until SIGINT or SIGTERM, which stop it between passes after in-flight Molecules finish; it cannot be combined with --once. After its first pass, a watch prints only what changed, plus a one-line summary. --learn, which is off by default, runs the pending learn requests after each pass like learn --pending; suggestions stay pending for your decision. For an interrupted Molecule whose owner is gone, the loop saves a recovery proposal (the exact forgecell recover command) under the Lab's recovery-proposals directory and shows it in its report and in doctor; it never runs recover or amend.\n\nTicket readiness is a development preview; existing Formulas require an explicitly reviewed scope gate.") + fmt.Fprintln(out, "Forgecell Lab — Go migration preview\n\n"+demoUsage+"\ninit [--lab DIR] [--harness ID | --rebind ID] [--meta-harness ID | --meta-command JSON_ARGV] [--harness-timeout-ms MS] [--max-timeout-ms MS] [--json] [--review ID | --approve ID | --dismiss ID]\ninit --model ID [--role analysis|coding|meta|review] [--lab DIR] [--json]\ninit --harness ID --role analysis|coding|meta [--lab DIR] [--json]\ninit --refresh-baseline [--lab DIR] [--json]\ninit --enable-review [--lab DIR] [--json]\ndoctor [--lab DIR] [--harness ID] [--json] [--verbose]\ndoctor --clean [--yes] [--lab DIR]\nrun [--lab DIR] [--formula ID] [--base COMMIT] [--target BRANCH] [--approve DIGEST] [--json]\namend [--add-scope PATH --reason TEXT]... [--add-acceptance TEXT]... [--note TEXT] [--continuation checks-only] [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --accept-protected-failures [--add-acceptance TEXT]... [--note TEXT] [--lab DIR] (declare the tests that failed by design in the latest protected regression run as expectedProtectedDeltas; continues checks-only; takes no --run: review the printed list, then approve the digest with run --approve DIGEST)\namend --parent DIGEST --plan FILE [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --parent DIGEST --rebase-formula [--lab DIR]\nrecover --plan DIGEST --confirm-stopped [--lab DIR]\nretire --reason TEXT [--lab DIR] (mark a stale Molecule retired: one-line reason of at most 200 characters; every record is kept, the loop never resumes it and may start a fresh Molecule for its Issue)\nloop [--lab DIR] [--select oldest|label:L|milestone:M] [--max-concurrent N] [--once]\nloop [--lab DIR] --watch [--interval DURATION] [--learn]\nledger [--lab DIR]\nissues --repo OWNER/REPO [--page N --page-size N | --select ISSUE_REF] [--lab DIR] [--json]\nlearn [--lab DIR] [--json]\nlearn --pending [--lab DIR] [--json]\nsuggestion [--lab DIR] [--approve | --dismiss | --link-evaluation DIR | --baseline-ledger ID --candidate-ledger ID --comparability-basis TEXT] [--evaluation-parent DIR] [--json]\nrollback\ndev install [--from CHECKOUT] [--activate] [--allow-dirty]\nevaluate --inputs PLAN --out NEW_DIRECTORY [--source REPO] [--approve DIGEST]\nevaluate --cohort FILE --out DIRECTORY [--source REPO] [--approve DIGEST] [--parallel N] [--recheck]\nevaluate replay --repo OWNER/REPO --pr N [--pr M ...] --source CHECKOUT --out NEW_DIRECTORY\ndeliver --lab DIR --base BRANCH [--approve DIGEST --title TITLE] -- FILE...\nchecks --lab DIR --molecule ID --repo OWNER/REPO --pr NUMBER --commit SHA --required NAME [--required NAME]\n"+findingsUsage+"\nlabs [--json]\n"+pluginHelp+"\nui [--lab DIR] [--lab-name NAME] [--port N] [--no-open] [--demo]\n--version\n--protocol\n\nThe default Lab is keyed by the checkout's GitHub origin (OWNER/REPO), not its path; --lab DIR always wins and --lab-name NAME selects a named Lab for the repository on any command that takes a Lab. labs lists every Lab, read-only.\n\ndev install is developer-only: it builds the checkout's lab/cmd/forgecell with the local Go toolchain and then runs the result, so the checkout is trusted code. Without --activate the build is only staged under releases/ and current is unchanged. A tree with uncommitted, untracked or ignored Go source changes is refused unless --allow-dirty is given. --protocol prints the adapter protocol version.\n\nui serves the Lab UI on 127.0.0.1 behind a per-launch token and opens it in the browser unless --no-open is given. Its writes go through the engine and are bound to the digest you saw; it never merges or deploys. With no ready Lab it serves Set up your Lab; --demo shows synthetic sample data. It stops on SIGINT or SIGTERM.\n\n"+demoHelp+"\n\nRuns analyze a ticket before coding and wait for exact scope approval. Required checks run independently before final review.\n\nloop starts intake for eligible open Issues of the Formula's repository, oldest first, and approves a scope gate only where the Formula declares a policy that approves that exact plan. It stops each Molecule at any human gate, open question or failure and reports why; it never delivers, merges or approves a human gate. --once makes a single pass. --watch repeats single passes every --interval (default 5m, at least 1m) until SIGINT or SIGTERM, which stop it between passes after in-flight Molecules finish; it cannot be combined with --once. After its first pass, a watch prints only what changed, plus a one-line summary. --learn, which is off by default, runs the pending learn requests after each pass like learn --pending; suggestions stay pending for your decision. For an interrupted Molecule whose owner is gone, the loop saves a recovery proposal (the exact forgecell recover command) under the Lab's recovery-proposals directory and shows it in its report and in doctor; it never runs recover or amend.\n\nTicket readiness is a development preview; existing Formulas require an explicitly reviewed scope gate.") return 0 } if args[0] == "--version" || args[0] == "-v" { @@ -411,7 +411,7 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer timeoutMS := flags.Int("harness-timeout-ms", 0, "Reviewed coding attempt budget in ms (default 900000)") maxTimeoutMS := flags.Int("max-timeout-ms", 0, "Ceiling for reviewed per-Molecule timeouts in ms (default 3600000)") model := flags.String("model", "", "Propose a model pin on the approved Formula's native Claude bindings; an empty value removes the pin") - role := flags.String("role", "", "With --model, limit the pin to one declared harnesses.ROLE override (analysis, coding, meta or review)") + role := flags.String("role", "", "With --model, limit the pin to one declared harnesses.ROLE override (analysis, coding, meta or review); with --harness, propose declaring or rebinding the harnesses.ROLE override (analysis, coding or meta) for review") refreshBaseline := flags.Bool("refresh-baseline", false, "Propose a baseline-only requiredChecks refresh of the approved Formula, derived from the current CI workflows, for review") enableReview := flags.Bool("enable-review", false, "Propose inserting the adversarial review Atom after the check Atom of the approved Formula, for review") forgePlugin := flags.String("forge-plugin", "", "Lab-relative manifest of a forge plugin; with --project, --tracker-plugin and --tracker-config, propose a bootstrap Formula whose forge and tracker are plugins") @@ -487,10 +487,24 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer return fail(fmt.Errorf("--refresh-baseline proposes a change; review, approve or dismiss the saved proposal in a separate command")) } } - if modelRequested || *role != "" { + roleHarness := *role != "" && !modelRequested && *preferred != "" + if roleHarness { switch { + case rebindRequested: + return fail(fmt.Errorf("--role cannot be combined with --rebind; --rebind changes the default binding, while --harness ID --role ROLE changes only harnesses.ROLE")) + case metaRequested: + return fail(fmt.Errorf("--harness ID --role ROLE cannot be combined with --meta-harness or --meta-command; it changes only harnesses.ROLE")) + case *timeoutMS != 0 || *maxTimeoutMS != 0: + return fail(fmt.Errorf("--harness ID --role ROLE cannot be combined with the timeout flags; it changes only harnesses.ROLE")) + case *review != "" || *approve != "" || *dismiss != "": + return fail(fmt.Errorf("--harness ID --role ROLE proposes a change; review, approve or dismiss the saved proposal in a separate command")) + } + } else if modelRequested || *role != "" { + switch { + case !modelRequested && rebindRequested: + return fail(fmt.Errorf("--role cannot be combined with --rebind; use --harness ID --role ROLE to change one role, or --model ID --role ROLE to pin its model")) case !modelRequested: - return fail(fmt.Errorf("--role needs --model")) + return fail(fmt.Errorf("--role needs --model ID to pin a model, or --harness ID to propose a role harness override")) case *preferred != "" || rebindRequested || metaRequested || *timeoutMS != 0 || *maxTimeoutMS != 0: return fail(fmt.Errorf("--model changes only the model pin; do not combine it with --harness, --rebind, --meta-harness, --meta-command or the timeout flags")) case *review != "" || *approve != "" || *dismiss != "": @@ -600,7 +614,7 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer for _, key := range []string{"CODEX_THREAD_ID", "CLAUDECODE", "CURSOR_AGENT", "TERM_PROGRAM"} { env[key] = os.Getenv(key) } - result, err := onboarding.Prepare(ctx, onboarding.InitOptions{Cwd: cwd, LabDir: dir, Launcher: launcher, Preferred: *preferred, Rebind: *rebind, Env: env, MetaHarness: *metaHarness, MetaCommand: metaCommand, HarnessTimeoutMS: *timeoutMS, MaxTimeoutMS: *maxTimeoutMS, Model: *model, ModelSet: modelRequested, Role: *role, RefreshBaseline: *refreshBaseline, EnableReview: *enableReview, ForgePlugin: *forgePlugin, Project: *project, TrackerPlugin: *trackerPlugin, TrackerConfig: *trackerConfig}) + result, err := onboarding.Prepare(ctx, onboarding.InitOptions{Cwd: cwd, LabDir: dir, Launcher: launcher, Preferred: *preferred, Rebind: *rebind, Env: env, MetaHarness: *metaHarness, MetaCommand: metaCommand, HarnessTimeoutMS: *timeoutMS, MaxTimeoutMS: *maxTimeoutMS, Model: *model, ModelSet: modelRequested, Role: *role, RoleHarness: roleHarness, RefreshBaseline: *refreshBaseline, EnableReview: *enableReview, ForgePlugin: *forgePlugin, Project: *project, TrackerPlugin: *trackerPlugin, TrackerConfig: *trackerConfig}) if err != nil { return fail(err) } diff --git a/lab/internal/cli/role_harness_test.go b/lab/internal/cli/role_harness_test.go new file mode 100644 index 0000000..d3260c9 --- /dev/null +++ b/lab/internal/cli/role_harness_test.go @@ -0,0 +1,108 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// roleHarnessRepo is a hermetic checkout of acme/widgets the CLI runs init in. +func roleHarnessRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + repo := filepath.Join(root, "repo") + gsWrite(t, filepath.Join(repo, "code.txt"), "base", 0600) + gsEnv(t, "\"repo view\"*) printf '%s' '{\"nameWithOwner\":\"acme/widgets\",\"defaultBranchRef\":{\"name\":\"main\"}}'; exit 0;;\n") + for _, args := range [][]string{{"init", "-q", "-b", "main"}, {"config", "user.name", "Test"}, {"config", "user.email", "test@example.test"}, {"add", "."}, {"commit", "-qm", "base"}, {"remote", "add", "origin", "https://github.com/acme/widgets.git"}} { + gsGit(t, repo, args...) + } + t.Chdir(repo) + return root +} + +func TestHarnessRoleFlagConflictsWriteNothing(t *testing.T) { + lab := t.TempDir() + cases := map[string]struct { + args []string + want string + }{ + "role alone": {[]string{"--role", "analysis"}, "--role needs --model ID to pin a model, or --harness ID"}, + "role with rebind": {[]string{"--role", "analysis", "--rebind", "codex"}, "--role cannot be combined with --rebind"}, + "harness role rebind": {[]string{"--harness", "claude-code", "--role", "analysis", "--rebind", "codex"}, "--role cannot be combined with --rebind"}, + "harness role meta": {[]string{"--harness", "claude-code", "--role", "analysis", "--meta-harness", "codex"}, "cannot be combined with --meta-harness or --meta-command"}, + "harness role command": {[]string{"--harness", "claude-code", "--role", "analysis", "--meta-command", `["/bin/x"]`}, "cannot be combined with --meta-harness or --meta-command"}, + "harness role timeout": {[]string{"--harness", "claude-code", "--role", "analysis", "--harness-timeout-ms", "1000"}, "cannot be combined with the timeout flags"}, + "harness role max": {[]string{"--harness", "claude-code", "--role", "analysis", "--max-timeout-ms", "1000"}, "cannot be combined with the timeout flags"}, + "harness role review": {[]string{"--harness", "claude-code", "--role", "analysis", "--review", "assay-x"}, "review, approve or dismiss the saved proposal in a separate command"}, + "harness role approve": {[]string{"--harness", "claude-code", "--role", "analysis", "--approve", "assay-x"}, "review, approve or dismiss the saved proposal in a separate command"}, + "harness role dismiss": {[]string{"--harness", "claude-code", "--role", "analysis", "--dismiss", "assay-x"}, "review, approve or dismiss the saved proposal in a separate command"}, + "model role harness": {[]string{"--model", "m", "--role", "coding", "--harness", "claude-code"}, "--model changes only the model pin"}, + "model role still pins": {[]string{"--model", "--bad", "--role", "coding"}, "--model"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + var out, stderr bytes.Buffer + args := append([]string{"init", "--lab", lab}, c.args...) + if code := Run(context.Background(), args, strings.NewReader(""), &out, &stderr, "dev"); code != 1 || !strings.Contains(stderr.String(), c.want) { + t.Fatalf("exit %d stderr %q, want %q", code, stderr.String(), c.want) + } + if entries, _ := os.ReadDir(lab); len(entries) != 0 { + t.Fatalf("a refused init wrote to the Lab: %v", entries) + } + }) + } +} + +func TestHarnessRoleReachesTheRoleProposal(t *testing.T) { + root := roleHarnessRepo(t) + + // Without an approved Formula the request is refused as a role harness change, + // not as a --role without --model, and nothing is written. + empty := filepath.Join(root, "empty-lab") + if err := os.Mkdir(empty, 0700); err != nil { + t.Fatal(err) + } + code, out, errOut := gsRun([]string{"init", "--lab", empty, "--harness", "claude-code", "--role", "analysis"}) + if code != 1 || !strings.Contains(errOut, "--harness --role needs an approved Formula") { + t.Fatalf("exit %d: %s %s", code, out, errOut) + } + if entries, _ := os.ReadDir(empty); len(entries) != 0 { + t.Fatalf("a refused init wrote to the Lab: %v", entries) + } + + // With a pending proposal the request is refused and names that proposal. + lab := filepath.Join(root, "lab") + gsApproveFormula(t, lab, modelPinFormula) + code, out, errOut = gsRun([]string{"init", "--lab", lab, "--model", "claude-opus-4-8"}) + if code != 0 { + t.Fatalf("init --model exit %d: %s %s", code, out, errOut) + } + id := regexp.MustCompile(`assay-[a-f0-9]{64}`).FindString(out) + if id == "" { + t.Fatalf("no proposal id:\n%s", out) + } + before, _ := os.ReadDir(filepath.Join(lab, "assays")) + code, out, errOut = gsRun([]string{"init", "--lab", lab, "--harness", "claude-code", "--role", "coding"}) + if code != 1 || !strings.Contains(errOut, "already pending") || !strings.Contains(errOut, id) || !strings.Contains(errOut, "before --harness --role") { + t.Fatalf("exit %d: %s %s", code, out, errOut) + } + if after, _ := os.ReadDir(filepath.Join(lab, "assays")); len(after) != len(before) { + t.Fatal("a refused init saved a proposal") + } +} + +func TestHarnessRoleIsDocumentedInHelp(t *testing.T) { + var out, stderr bytes.Buffer + if code := Run(context.Background(), []string{"help"}, strings.NewReader(""), &out, &stderr, "dev"); code != 0 { + t.Fatalf("exit %d", code) + } + for _, want := range []string{"init --harness ID --role analysis|coding|meta", "init --model ID [--role analysis|coding|meta|review]"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("help omits %q", want) + } + } +} diff --git a/lab/internal/onboarding/binding.go b/lab/internal/onboarding/binding.go index 372ca56..adca63f 100644 --- a/lab/internal/onboarding/binding.go +++ b/lab/internal/onboarding/binding.go @@ -365,7 +365,7 @@ func CheckAnalysisFallback(f formula.Formula, target formula.Binding) error { return nil } if c := roleCapability(formula.AnalysisHarness, target); c.State == "unsupported" { - return fmt.Errorf("cannot rebind to %s: harnesses.analysis is not declared, so read-only ticket analysis would fall back to the default binding, which cannot be used for it: %s; declare harnesses.analysis with a supported harness first", target.Binding, c.Reason) + return fmt.Errorf("cannot rebind to %s: harnesses.analysis is not declared, so read-only ticket analysis would fall back to the default binding, which cannot be used for it: %s; declare harnesses.analysis with a supported harness first, for example with forgecell init --lab DIR --harness claude-code --role analysis", target.Binding, c.Reason) } return nil } diff --git a/lab/internal/onboarding/init.go b/lab/internal/onboarding/init.go index eea502c..ec70117 100644 --- a/lab/internal/onboarding/init.go +++ b/lab/internal/onboarding/init.go @@ -30,6 +30,9 @@ type InitOptions struct { // pin. Role limits the proposal to the named harnesses. override. Model, Role string ModelSet bool + // RoleHarness marks an explicit --harness ID --role ROLE: Preferred is proposed + // as the harnesses. override only, declaring the role when it is absent. + RoleHarness bool // RefreshBaseline proposes a baseline-only change: requiredChecks re-derived // from the current pull-request workflows of the approved Formula's repository. RefreshBaseline bool @@ -447,6 +450,11 @@ func Prepare(ctx context.Context, o InitOptions) (out Outcome, err error) { if o.RefreshBaseline && (o.Preferred != "" || o.Rebind != "" || o.MetaHarness != "" || o.MetaCommand != nil || o.ModelSet || o.Role != "" || budgetRequested) { return out, fmt.Errorf("--refresh-baseline changes only requiredChecks; do not combine it with --harness, --rebind, --meta-harness, --meta-command, --model or the timeout flags") } + if o.RoleHarness { + if err = checkRoleHarnessOptions(o, budgetRequested); err != nil { + return out, err + } + } if o.Rebind != "" { if o.Preferred != "" || o.MetaHarness != "" || o.MetaCommand != nil || budgetRequested { return out, fmt.Errorf("--rebind changes only the binding; do not combine it with --harness, --meta-harness, --meta-command or the timeout flags") @@ -509,6 +517,9 @@ func Prepare(ctx context.Context, o InitOptions) (out Outcome, err error) { if o.ModelSet { return out, fmt.Errorf("a proposal is already pending; review, approve or dismiss it with init --lab %s --review/--approve/--dismiss %s before --model", formula.ShellWord(o.LabDir), pending.ID) } + if o.RoleHarness { + return out, fmt.Errorf("a proposal is already pending; review, approve or dismiss it with init --lab %s --review/--approve/--dismiss %s before --harness --role", formula.ShellWord(o.LabDir), pending.ID) + } f, e := formula.Parse([]byte(pending.YAML)) if e != nil { return out, e @@ -547,6 +558,9 @@ func Prepare(ctx context.Context, o InitOptions) (out Outcome, err error) { if o.ModelSet && active.Formula.ID == "" { return out, fmt.Errorf("--model needs an approved Formula; run init --lab %s and approve the bootstrap proposal first", formula.ShellWord(o.LabDir)) } + if o.RoleHarness && active.Formula.ID == "" { + return out, fmt.Errorf("--harness --role needs an approved Formula; run init --lab %s and approve the bootstrap proposal first", formula.ShellWord(o.LabDir)) + } if active.Formula.ID != "" { approved, e := formula.Load(o.LabDir, "") if e != nil || !approved.Approved { @@ -562,6 +576,9 @@ func Prepare(ctx context.Context, o InitOptions) (out Outcome, err error) { if o.ModelSet { return out, fmt.Errorf("--model needs a current exact approval of the active Formula; run init --lab %s to review it, then init --lab %s --approve PROPOSAL_ID", formula.ShellWord(o.LabDir), formula.ShellWord(o.LabDir)) } + if o.RoleHarness { + return out, fmt.Errorf("--harness --role needs a current exact approval of the active Formula; run init --lab %s to review it, then init --lab %s --approve PROPOSAL_ID", formula.ShellWord(o.LabDir), formula.ShellWord(o.LabDir)) + } 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)} p, e := saveWithEvidence(o.LabDir, active.Formula.YAML, nil, out.Binding) @@ -585,6 +602,9 @@ func Prepare(ctx context.Context, o InitOptions) (out Outcome, err error) { if o.ModelSet { return proposeModelPin(ctx, o, out, active) } + if o.RoleHarness { + return proposeRoleHarness(ctx, o, out, active) + } out.Candidates = o.Candidates if out.Candidates == nil { out.Candidates = Discover(ctx, out.Repository.Root, o.Runner) diff --git a/lab/internal/onboarding/model_pin.go b/lab/internal/onboarding/model_pin.go index 6a82a02..cb0cf9c 100644 --- a/lab/internal/onboarding/model_pin.go +++ b/lab/internal/onboarding/model_pin.go @@ -20,8 +20,11 @@ var pinRoles = append(slices.Clone(formula.HarnessRoles), formula.ReviewHarness) // read or written. func checkModelPinOptions(o InitOptions, budgetRequested bool) error { if !o.ModelSet { + if o.Role != "" && o.RoleHarness { + return nil + } if o.Role != "" || o.Model != "" { - return fmt.Errorf("--role needs --model") + return fmt.Errorf("--role needs --model ID to pin a model, or --harness ID to propose a role harness override") } return nil } @@ -500,17 +503,9 @@ func proposeModelPin(ctx context.Context, o InitOptions, out Outcome, active for out.BeforeYAML = f.YAML out.Binding = &BindingReport{Command: f.Harness.Command, Candidate: probed, Workflow: probed.WorkflowCapability(), LearnBound: LearnBound(f), BeforeYAML: f.YAML} // Saved meta evidence must describe the one learn Atom that carries a command. - learns := 0 - for _, a := range f.Atoms { - if a.Type == "learn" { - learns++ - } - } - if learn := metaLearnAtom(f); learns == 1 && learn != nil && len(learn.Command) > 0 { - r := DescribeMeta(learn.Command, learn.Binding, learn.TimeoutMS, Candidate{}) - r.BeforeYAML = f.YAML - out.Meta = &r - out.Binding.Meta = &r + if r := learnMetaReport(f); r != nil { + out.Meta = r + out.Binding.Meta = r } p, err := saveWithEvidence(o.LabDir, proposedText, evidence, out.Binding) if err != nil { diff --git a/lab/internal/onboarding/role_harness.go b/lab/internal/onboarding/role_harness.go new file mode 100644 index 0000000..6673dec --- /dev/null +++ b/lab/internal/onboarding/role_harness.go @@ -0,0 +1,264 @@ +package onboarding + +import ( + "context" + "encoding/json" + "fmt" + "github.com/DanielNetzer/forgecell/lab/internal/formula" + "go.yaml.in/yaml/v4" + "maps" + "reflect" + "slices" + "strings" +) + +// checkRoleHarnessOptions refuses --harness ID --role ROLE combinations before +// anything is read or written. +func checkRoleHarnessOptions(o InitOptions, budgetRequested bool) error { + if o.Preferred == "" { + return fmt.Errorf("--role needs --harness ID to propose a role harness override") + } + if o.Rebind != "" || o.ModelSet || o.Model != "" || o.MetaHarness != "" || o.MetaCommand != nil || budgetRequested { + return fmt.Errorf("--harness ID --role ROLE changes only harnesses.ROLE; do not combine it with --rebind, --model, --meta-harness, --meta-command or the timeout flags") + } + if !slices.Contains(formula.HarnessRoles, formula.HarnessRole(o.Role)) { + return fmt.Errorf("--role %q cannot take a harness override; use analysis, coding or meta", o.Role) + } + return nil +} + +// roleHarnessTarget requires a ready, discovered harness with an executable and a +// launcher whose adapter can perform the role's operation, and returns the native +// command the role override would run. +func roleHarnessTarget(candidates []Candidate, id, launcher string, role formula.HarnessRole, lab string) ([]string, error) { + path := "harnesses." + string(role) + for _, c := range candidates { + if c.ID != id { + continue + } + if c.Readiness != "ready" || c.Executable == "" { + return nil, fmt.Errorf("cannot bind %s to %s: %s; run forgecell doctor --lab %s to see what is missing; no proposal saved", path, id, c.Detail, formula.ShellWord(lab)) + } + if launcher == "" { + return nil, fmt.Errorf("cannot bind %s to %s: the Forgecell launcher is unknown; set FORGECELL_LAUNCHER to its path; no proposal saved", path, id) + } + command := []string{launcher, "__adapter", id, c.Executable} + if err := requireAbsoluteNative(path+".command", command); err != nil { + return nil, err + } + if capability := roleCapability(role, formula.Binding{Binding: id, Command: command}); capability.State == "unsupported" { + return nil, fmt.Errorf("cannot bind %s to %s: it cannot be used for %s: %s; no proposal saved", path, id, roleOperation(role), capability.Reason) + } + return command, nil + } + return nil, fmt.Errorf("cannot bind %s to %q: no such harness was discovered; run forgecell doctor --lab %s to list the available harnesses; no proposal saved", path, id, formula.ShellWord(lab)) +} + +// effectiveRoleBinding is the binding a role runs before the change: its own +// override, else the learn Atom binding for meta, else the default harness. +func effectiveRoleBinding(f formula.Formula, role formula.HarnessRole) (b formula.Binding, source string) { + if b, declared := f.Harnesses[string(role)]; declared { + return b, "its own override" + } + if role == formula.MetaHarness { + if b, _, bound := MetaBinding(f); bound { + return b, "the learn Atom" + } + } + return f.Harness, "the default binding" +} + +func anyArgv(command []string) []any { + items := make([]any, len(command)) + for i, item := range command { + items[i] = item + } + return items +} + +func jsonText(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +// roleHolders returns the harnesses mapping of a decoded Formula and the role's +// binding mapping in it; either is nil when absent. +func roleHolders(doc map[string]any, role formula.HarnessRole) (roles, holder map[string]any, err error) { + value, present := doc["harnesses"] + if !present { + return nil, nil, nil + } + if roles, _ = value.(map[string]any); roles == nil { + return nil, nil, fmt.Errorf("harnesses is not a mapping; no proposal saved") + } + value, present = roles[string(role)] + if !present { + return roles, nil, nil + } + if holder, _ = value.(map[string]any); holder == nil { + return nil, nil, fmt.Errorf("harnesses.%s is not a mapping; no proposal saved", role) + } + return roles, holder, nil +} + +// roleHarnessRecipe sets the binding and command of harnesses.ROLE, declaring the +// role when it is absent, and returns each change as `path: old -> new`. +func roleHarnessRecipe(recipe map[string]any, role formula.HarnessRole, target string, command []string) ([]string, error) { + roles, holder, err := roleHolders(recipe, role) + if err != nil { + return nil, err + } + path := "harnesses." + string(role) + if holder == nil { + if roles == nil { + roles = map[string]any{} + recipe["harnesses"] = roles + } + declared := map[string]any{"binding": target, "command": anyArgv(command)} + roles[string(role)] = declared + return []string{path + ": (none) -> " + jsonText(declared)}, nil + } + var changes []string + for key, value := range map[string]any{"binding": target, "command": anyArgv(command)} { + if old, ok := holder[key]; !ok { + changes = append(changes, path+"."+key+": (none) -> "+jsonText(value)) + } else if !reflect.DeepEqual(old, value) { + changes = append(changes, path+"."+key+": "+jsonText(old)+" -> "+jsonText(value)) + } + holder[key] = value + } + slices.Sort(changes) + return changes, nil +} + +// takeRole removes harnesses.ROLE from a decoded Formula, and harnesses itself +// when nothing else is left in it, and returns the removed binding mapping. +func takeRole(doc map[string]any, role formula.HarnessRole) (map[string]any, error) { + roles, holder, err := roleHolders(doc, role) + if err != nil || roles == nil { + return nil, err + } + delete(roles, string(role)) + if len(roles) == 0 { + delete(doc, "harnesses") + } + return holder, nil +} + +// verifyRoleHarnessExact proves the proposal is the approved Formula with only the +// binding and command of harnesses.ROLE set or added: everything outside the role, +// and every other key of the role, is unchanged. +func verifyRoleHarnessExact(before, after string, role formula.HarnessRole, target string, command []string) error { + var expected, proposed map[string]any + if err := yaml.Unmarshal([]byte(before), &expected); err != nil { + return err + } + if err := yaml.Unmarshal([]byte(after), &proposed); err != nil { + return err + } + was, err := takeRole(expected, role) + if err != nil { + return err + } + now, err := takeRole(proposed, role) + if err != nil { + return err + } + if now == nil || !reflect.DeepEqual(expected, proposed) { + return fmt.Errorf("the role harness change would change more than harnesses.%s; no proposal saved", role) + } + want := maps.Clone(was) + if want == nil { + want = map[string]any{} + } + want["binding"], want["command"] = target, anyArgv(command) + if !reflect.DeepEqual(want, now) { + return fmt.Errorf("the role harness change would change more than the binding and command of harnesses.%s; no proposal saved", role) + } + return nil +} + +// learnMetaReport describes the one learn Atom that carries a command, which is +// what saved meta evidence must name; nil when there is no such single Atom. +func learnMetaReport(f formula.Formula) *MetaReport { + learns := 0 + for _, a := range f.Atoms { + if a.Type == "learn" { + learns++ + } + } + learn := metaLearnAtom(f) + if learns != 1 || learn == nil || len(learn.Command) == 0 { + return nil + } + r := DescribeMeta(learn.Command, learn.Binding, learn.TimeoutMS, Candidate{}) + r.BeforeYAML = f.YAML + return &r +} + +// proposeRoleHarness saves a reviewed proposal that declares harnesses.ROLE, or +// rebinds it when declared, to the native command of a discovered harness on the +// running launcher. Nothing else in the approved Formula changes. +func proposeRoleHarness(ctx context.Context, o InitOptions, out Outcome, active formula.Loaded) (Outcome, error) { + f := active.Formula + role := formula.HarnessRole(o.Role) + path := "harnesses." + o.Role + out.Candidates = o.Candidates + if out.Candidates == nil { + out.Candidates = Discover(ctx, out.Repository.Root, o.Runner) + } + for i := range out.Candidates { + out.Candidates[i] = out.Candidates[i].CompleteUnknowns() + } + command, err := roleHarnessTarget(out.Candidates, o.Preferred, o.Launcher, role, o.LabDir) + if err != nil { + return out, err + } + if current, source := effectiveRoleBinding(f, role); current.Binding == o.Preferred && slices.Equal(current.Command, command) { + return out, fmt.Errorf("%s already runs %s with this launcher and executable through %s; nothing to change; no proposal saved", path, o.Preferred, source) + } + + var recipe map[string]any + if err = yaml.Unmarshal([]byte(f.YAML), &recipe); err != nil { + return out, err + } + changes, err := roleHarnessRecipe(recipe, role, o.Preferred, command) + if err != nil { + return out, err + } + encoded, err := yaml.Marshal(recipe) + if err != nil { + return out, err + } + if err = verifyRoleHarnessExact(f.YAML, string(encoded), role, o.Preferred, command); err != nil { + return out, err + } + proposed, err := formula.Parse(encoded) + if err != nil { + return out, err + } + if err = ValidateRoleBindings(proposed); err != nil { + return out, fmt.Errorf("binding %s to %s would not be a valid Formula: %w; no proposal saved", path, o.Preferred, err) + } + + probed := ProbeBinding(ctx, f.Harness, out.Repository.Root, o.Runner) + evidence, err := proposalEvidence(o.LabDir, active, nil) + if err != nil { + return out, err + } + out.BeforeYAML = f.YAML + out.Binding = &BindingReport{Command: f.Harness.Command, Candidate: probed, Workflow: probed.WorkflowCapability(), LearnBound: LearnBound(f), BeforeYAML: f.YAML} + if r := learnMetaReport(f); r != nil { + out.Meta = r + out.Binding.Meta = r + } + p, err := saveWithEvidence(o.LabDir, string(encoded), evidence, out.Binding) + if err != nil { + return out, err + } + out.Status = "pending" + out.Proposal = &p + out.Selection = Selection{ID: o.Preferred, Status: "ready", Reason: "Explicit role harness override for " + o.Role + "."} + out.Note = "Proposes only a " + o.Role + " role harness change to " + o.Preferred + ". Rewritten keys (old -> new): " + strings.Join(changes, "; ") + ". The default harness, atoms, checks, instructions, timeouts, learned guidance and every other binding are preserved. Changing the binding changes the binding fingerprint (#36) of the " + o.Role + " role; in-flight plans for that role need a fresh intake. Review the exact YAML before approval." + return out, nil +} diff --git a/lab/internal/onboarding/role_harness_test.go b/lab/internal/onboarding/role_harness_test.go new file mode 100644 index 0000000..c305184 --- /dev/null +++ b/lab/internal/onboarding/role_harness_test.go @@ -0,0 +1,310 @@ +package onboarding + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/DanielNetzer/forgecell/lab/internal/formula" + "github.com/DanielNetzer/forgecell/lab/internal/harness" + "go.yaml.in/yaml/v4" +) + +func roleHarness(root string, runner Runner, dir, id, role string, mutate ...func(*InitOptions)) (Outcome, error) { + o := InitOptions{Cwd: root, LabDir: dir, Launcher: "/cli", Candidates: rebindCandidates(), Preferred: id, Role: role, RoleHarness: true, Runner: runner} + for _, m := range mutate { + m(&o) + } + return Prepare(context.Background(), o) +} + +func roleOf(t *testing.T, doc map[string]any, role string) map[string]any { + t.Helper() + roles, _ := doc["harnesses"].(map[string]any) + m, _ := roles[role].(map[string]any) + return m +} + +func TestHarnessRoleDeclaresAnalysisOverride(t *testing.T) { + root, runner, dir, boot := approvedLab(t) + active, err := formula.Load(dir, "") + if err != nil || len(active.Formula.Harnesses) != 0 { + t.Fatalf("fixture already declares a role: %v %v", err, active.Formula.Harnesses) + } + next, err := roleHarness(root, runner, dir, "claude-code", "analysis") + if err != nil || next.Status != "pending" || next.Proposal == nil { + t.Fatalf("%+v %v", next, err) + } + command := []string{"/cli", "__adapter", "claude-code", "/claude-code"} + got := roleOf(t, decodeYAML(t, next.Proposal.YAML), "analysis") + if want := map[string]any{"binding": "claude-code", "command": anyArgv(command)}; !reflect.DeepEqual(got, want) { + t.Fatalf("harnesses.analysis = %v", got) + } + if err = verifyRoleHarnessExact(active.Formula.YAML, next.Proposal.YAML, formula.AnalysisHarness, "claude-code", command); err != nil { + t.Fatal(err) + } + if want := `harnesses.analysis: (none) -> {"binding":"claude-code","command":["/cli","__adapter","claude-code","/claude-code"]}`; !strings.Contains(next.Note, want) { + t.Fatalf("the note omits %q:\n%s", want, next.Note) + } + if next.BeforeYAML != active.Formula.YAML { + t.Fatal("the outcome does not show the approved YAML") + } + saved, err := InspectProposal(dir, next.Proposal.ID) + if err != nil { + t.Fatal(err) + } + if saved.EvidenceHash != boot.Proposal.EvidenceHash || saved.EvidencePath != boot.Proposal.EvidencePath { + t.Fatal("detached repository evidence changed") + } + if now, _ := formula.Load(dir, ""); now.Formula.SHA256 != active.Formula.SHA256 { + t.Fatal("proposing activated the change") + } + if _, err = Review(dir, next.Proposal.ID, "approve"); err != nil { + t.Fatal(err) + } + now, err := formula.Load(dir, "") + if err != nil || !now.Approved { + t.Fatalf("approval failed: %v", err) + } + if b, declared := now.Formula.RoleBinding(formula.AnalysisHarness); !declared || b.Binding != "claude-code" || !reflect.DeepEqual(b.Command, command) { + t.Fatalf("approved analysis binding = %+v %v", b, declared) + } + if now.Formula.Harness.Binding != "codex" || !reflect.DeepEqual(now.Formula.Harness.Command, active.Formula.Harness.Command) { + t.Fatalf("the default harness changed: %+v", now.Formula.Harness) + } +} + +func TestHarnessRoleRebindsDeclaredOverride(t *testing.T) { + root, runner, dir, _ := approvedLab(t) + approved := approveEdited(t, dir, func(doc map[string]any) { + doc["harnesses"] = map[string]any{"analysis": map[string]any{"binding": "claude-code", "command": nativeCommand("/old-cli", "claude-code"), "timeoutMs": 1234}} + }) + next, err := roleHarness(root, runner, dir, "codex", "analysis") + if err != nil || next.Proposal == nil { + t.Fatalf("%+v %v", next, err) + } + command := []string{"/cli", "__adapter", "codex", "/codex"} + got := roleOf(t, decodeYAML(t, next.Proposal.YAML), "analysis") + if want := map[string]any{"binding": "codex", "command": anyArgv(command), "timeoutMs": 1234}; !reflect.DeepEqual(got, want) { + t.Fatalf("harnesses.analysis = %v", got) + } + if err = verifyRoleHarnessExact(approved.Formula.YAML, next.Proposal.YAML, formula.AnalysisHarness, "codex", command); err != nil { + t.Fatal(err) + } + for _, want := range []string{`harnesses.analysis.binding: "claude-code" -> "codex"`, `harnesses.analysis.command: ["/old-cli","__adapter","claude-code","/claude-code"] -> ["/cli","__adapter","codex","/codex"]`} { + if !strings.Contains(next.Note, want) { + t.Fatalf("the note omits %q:\n%s", want, next.Note) + } + } + if strings.Contains(next.Note, "timeoutMs") { + t.Fatalf("an unchanged key is listed: %s", next.Note) + } +} + +func TestHarnessRoleRefusesCursorForAnalysis(t *testing.T) { + root, runner, dir, _ := approvedLab(t) + before := assayCount(dir) + reason := harness.AdapterCapabilities("cursor").Analysis.Reason + if reason == "" || harness.AdapterCapabilities("cursor").Analysis.State != "unsupported" { + t.Fatalf("fixture expects Cursor to refuse analysis: %+v", harness.AdapterCapabilities("cursor").Analysis) + } + _, err := roleHarness(root, runner, dir, "cursor", "analysis") + if err == nil || !strings.Contains(err.Error(), reason) || !strings.Contains(err.Error(), "harnesses.analysis") { + t.Fatalf("Cursor was not refused with its own reason: %v", err) + } + if assayCount(dir) != before { + t.Fatal("a refused proposal was saved") + } +} + +func TestHarnessRoleRefusesUnreadyOrUnknownHarness(t *testing.T) { + root, runner, dir, _ := approvedLab(t) + for _, c := range []struct { + name, id, want string + mutate func(*InitOptions) + }{ + {"not ready", "claude-code", "cannot bind harnesses.analysis to claude-code", func(o *InitOptions) { + o.Candidates = []Candidate{{ID: "claude-code", Executable: "/claude-code", Readiness: "blocked", Detail: "not logged in"}} + }}, + {"no executable", "claude-code", "cannot bind harnesses.analysis to claude-code", func(o *InitOptions) { + o.Candidates = []Candidate{{ID: "claude-code", Readiness: "ready"}} + }}, + {"no launcher", "claude-code", "launcher is unknown", func(o *InitOptions) { o.Launcher = "" }}, + {"relative launcher", "claude-code", "must be an absolute path", func(o *InitOptions) { o.Launcher = "forgecell" }}, + {"unknown", "nonesuch", "no such harness was discovered", nil}, + } { + t.Run(c.name, func(t *testing.T) { + before := assayCount(dir) + var mutate []func(*InitOptions) + if c.mutate != nil { + mutate = append(mutate, c.mutate) + } + _, err := roleHarness(root, runner, dir, c.id, "analysis", mutate...) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("want %q, got %v", c.want, err) + } + if assayCount(dir) != before { + t.Fatal("a refused proposal was saved") + } + }) + } +} + +func TestHarnessRoleNothingToChange(t *testing.T) { + root, runner, dir, _ := approvedLab(t) + before := assayCount(dir) + if _, err := roleHarness(root, runner, dir, "codex", "coding"); err == nil || !strings.Contains(err.Error(), "nothing to change") || !strings.Contains(err.Error(), "default binding") { + t.Fatalf("an unchanged coding role was proposed: %v", err) + } + if _, err := roleHarness(root, runner, dir, "codex", "analysis"); err == nil || !strings.Contains(err.Error(), "nothing to change") { + t.Fatalf("an unchanged analysis role was proposed: %v", err) + } + if _, err := roleHarness(root, runner, dir, "codex", "meta"); err == nil || !strings.Contains(err.Error(), "nothing to change") || !strings.Contains(err.Error(), "learn Atom") { + t.Fatalf("an unchanged meta role was proposed: %v", err) + } + approveEdited(t, dir, func(doc map[string]any) { + doc["harnesses"] = map[string]any{"analysis": map[string]any{"binding": "claude-code", "command": nativeCommand("/cli", "claude-code")}} + }) + before = assayCount(dir) + if _, err := roleHarness(root, runner, dir, "claude-code", "analysis"); err == nil || !strings.Contains(err.Error(), "nothing to change") || !strings.Contains(err.Error(), "its own override") { + t.Fatalf("an unchanged declared role was proposed: %v", err) + } + if assayCount(dir) != before { + t.Fatal("a refused proposal was saved") + } +} + +func TestHarnessRoleRefusesAnInvalidResult(t *testing.T) { + root, runner, dir, _ := approvedLab(t) + before := assayCount(dir) + // The harness Atom is bound to codex, so a claude-code coding override conflicts. + if _, err := roleHarness(root, runner, dir, "claude-code", "coding"); err == nil || !strings.Contains(err.Error(), "would not be a valid Formula") || !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("a conflicting coding override was proposed: %v", err) + } + // The learn Atom carries the codex command, so another meta command conflicts. + if _, err := roleHarness(root, runner, dir, "claude-code", "meta"); err == nil || !strings.Contains(err.Error(), "would not be a valid Formula") { + t.Fatalf("a conflicting meta override was proposed: %v", err) + } + if assayCount(dir) != before { + t.Fatal("a refused proposal was saved") + } +} + +func TestHarnessRoleVerifyExact(t *testing.T) { + before := "schemaVersion: v0\nkind: formula\nid: sample\nharness:\n binding: codex\n command: [/cli, __adapter, codex, /codex]\nharnesses:\n meta: {binding: codex, command: [/cli, __adapter, codex, /codex]}\natoms:\n - {id: intake, type: intake}\n" + command := []string{"/cli", "__adapter", "claude-code", "/claude-code"} + var doc map[string]any + if err := yaml.Unmarshal([]byte(before), &doc); err != nil { + t.Fatal(err) + } + changes, err := roleHarnessRecipe(doc, formula.AnalysisHarness, "claude-code", command) + if err != nil || len(changes) != 1 { + t.Fatalf("%v %v", changes, err) + } + encode := func(doc map[string]any) string { + b, err := yaml.Marshal(doc) + if err != nil { + t.Fatal(err) + } + return string(b) + } + exact := encode(doc) + if err = verifyRoleHarnessExact(before, exact, formula.AnalysisHarness, "claude-code", command); err != nil { + t.Fatalf("the exact proposal was refused: %v", err) + } + for name, tamper := range map[string]func(doc map[string]any){ + "other top-level key": func(doc map[string]any) { doc["id"] = "other" }, + "default harness": func(doc map[string]any) { doc["harness"].(map[string]any)["binding"] = "claude-code" }, + "other role": func(doc map[string]any) { roleOf(t, doc, "meta")["binding"] = "claude-code" }, + "extra role key": func(doc map[string]any) { roleOf(t, doc, "analysis")["timeoutMs"] = 1 }, + "wrong command": func(doc map[string]any) { + roleOf(t, doc, "analysis")["command"] = nativeCommand("/elsewhere", "claude-code") + }, + "role missing": func(doc map[string]any) { delete(doc["harnesses"].(map[string]any), "analysis") }, + } { + t.Run(name, func(t *testing.T) { + var proposed map[string]any + if err := yaml.Unmarshal([]byte(exact), &proposed); err != nil { + t.Fatal(err) + } + tamper(proposed) + if err := verifyRoleHarnessExact(before, encode(proposed), formula.AnalysisHarness, "claude-code", command); err == nil || !strings.Contains(err.Error(), "no proposal saved") { + t.Fatalf("a tampered proposal was accepted: %v", err) + } + }) + } +} + +func TestHarnessRoleOptionConflicts(t *testing.T) { + root, runner, dir, _ := approvedLab(t) + for name, c := range map[string]struct { + mutate func(*InitOptions) + want string + }{ + "review role": {func(o *InitOptions) { o.Role = "review" }, "use analysis, coding or meta"}, + "unknown role": {func(o *InitOptions) { o.Role = "ship" }, "use analysis, coding or meta"}, + "no harness": {func(o *InitOptions) { o.Preferred = "" }, "--role needs --harness"}, + "rebind": {func(o *InitOptions) { o.Rebind = "codex" }, "changes only harnesses.ROLE"}, + "model": {func(o *InitOptions) { o.Model, o.ModelSet = "m", true }, "changes only harnesses.ROLE"}, + "meta harness": {func(o *InitOptions) { o.MetaHarness = "codex" }, "changes only harnesses.ROLE"}, + "meta command": {func(o *InitOptions) { o.MetaCommand = []string{"/bin/x"} }, "changes only harnesses.ROLE"}, + "timeout": {func(o *InitOptions) { o.HarnessTimeoutMS = 1000 }, "changes only harnesses.ROLE"}, + } { + t.Run(name, func(t *testing.T) { + before := assayCount(dir) + if _, err := roleHarness(root, runner, dir, "claude-code", "analysis", c.mutate); err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("want %q, got %v", c.want, err) + } + if assayCount(dir) != before { + t.Fatal("a refused proposal was saved") + } + }) + } + t.Run("role without harness or model", func(t *testing.T) { + _, err := Prepare(context.Background(), InitOptions{Cwd: root, LabDir: dir, Launcher: "/cli", Candidates: rebindCandidates(), Runner: runner, Role: "analysis", Preferred: "claude-code"}) + if err == nil || !strings.Contains(err.Error(), "--role needs --model") || !strings.Contains(err.Error(), "--harness") { + t.Fatal(err) + } + }) +} + +func TestHarnessRoleRefusedWithoutApprovalOrWhilePending(t *testing.T) { + root, runner := repoFixture(t) + empty := t.TempDir() + if _, err := roleHarness(root, runner, empty, "claude-code", "analysis"); err == nil || !strings.Contains(err.Error(), "needs an approved Formula") { + t.Fatalf("no Formula: %v", err) + } + if assayCount(empty) != 0 { + t.Fatal("a refused proposal was saved") + } + root, runner, dir, _ := approvedLab(t) + first, err := roleHarness(root, runner, dir, "claude-code", "analysis") + if err != nil || first.Proposal == nil { + t.Fatalf("%+v %v", first, err) + } + before := assayCount(dir) + if _, err = roleHarness(root, runner, dir, "claude-code", "analysis"); err == nil || !strings.Contains(err.Error(), "already pending") || !strings.Contains(err.Error(), first.Proposal.ID) { + t.Fatalf("a second proposal was not refused: %v", err) + } + if assayCount(dir) != before { + t.Fatal("a refused proposal was saved") + } +} + +func TestHarnessRoleAnalysisFallbackNamesTheCommand(t *testing.T) { + root, runner, dir, _ := approvedLab(t) + before := assayCount(dir) + _, err := Prepare(context.Background(), InitOptions{Cwd: root, LabDir: dir, Launcher: "/cli", Candidates: rebindCandidates(), Preferred: "cursor", Runner: runner}) + if err == nil { + t.Fatal("cursor became the analysis binding by fallback") + } + for _, want := range []string{harness.AdapterCapabilities("cursor").Analysis.Reason, "harnesses.analysis", "forgecell init --lab DIR --harness claude-code --role analysis"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal omits %q: %v", want, err) + } + } + if assayCount(dir) != before { + t.Fatal("a refused proposal was saved") + } +}