From b2a1f17807e036f38ed1734399dc10ff2453f82f Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 06:28:05 +0700 Subject: [PATCH 1/9] refactor(harness): keep one list of supported harnesses --- internal/harness/harness.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/harness/harness.go b/internal/harness/harness.go index e635c4e..44784eb 100644 --- a/internal/harness/harness.go +++ b/internal/harness/harness.go @@ -4,6 +4,7 @@ package harness import ( "fmt" "regexp" + "slices" "strings" "github.com/atqamz/secondhand/internal/agentsmd" @@ -17,16 +18,16 @@ const ( OpenCode = "opencode" ) -var supported = map[string]bool{ - Claude: true, - Codex: true, - Grok: true, - Pi: true, - OpenCode: true, +// The one list of supported harnesses. Anything that offers a choice of harness derives it from here +// rather than repeating the names, so a harness added below is offered everywhere at once. +var names = []string{Claude, Codex, Grok, Pi, OpenCode} + +func Names() []string { + return slices.Clone(names) } func IsSupported(name string) bool { - return supported[name] + return slices.Contains(names, name) } // One interactive dialog a harness may show before it starts reading the brief; Match is checked From 6e15161a0b817091406a039194d57a3a4bab0908 Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 06:28:21 +0700 Subject: [PATCH 2/9] feat(cmd): own worker defaults with hand config hand config reports the fleet's worker defaults and which of them are still missing; hand config set validates one value and writes it atomically, refusing a model or effort the configured harness takes no launch flag for. Model and effort are stored keyed by that harness (config/model.claude), so switching harnesses re-asks instead of handing a worker an identifier chosen for a different tool. An older home's unkeyed value is moved under the harness it belonged to on init and update. --- cmd/config.go | 263 ++++++++++++++++++++++++++++ cmd/config_test.go | 321 +++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + cmd/tier.go | 4 +- cmd/tier_test.go | 45 ++--- cmd/update.go | 10 +- tests/e2e/brief_tier_test.go | 8 +- tests/e2e/e2e_test.go | 9 + 8 files changed, 628 insertions(+), 33 deletions(-) create mode 100644 cmd/config.go create mode 100644 cmd/config_test.go diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..de3cb49 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,263 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/atqamz/secondhand/internal/atomicfile" + "github.com/atqamz/secondhand/internal/axi" + "github.com/atqamz/secondhand/internal/harness" + "github.com/atqamz/secondhand/internal/home" + "github.com/spf13/cobra" +) + +const ( + settingHarness = "harness" + settingModel = "model" + settingEffort = "effort" +) + +// In the order configuration asks for them: the harness decides which of the other two apply at all. +var workerSettingKeys = []string{settingHarness, settingModel, settingEffort} + +const ( + stateConfigured = "configured" + stateMissing = "missing" + // The selected harness takes no such launch flag, so there is nothing to configure - distinct from + // missing, which is a question still owed an answer. + stateUnsupported = "unsupported" + // Applicability is a property of the harness, so it is unknown until one is chosen. + statePendingHarness = "pending-harness" +) + +type workerSetting struct { + key string + state string + value string +} + +var workerSettingFields = []axi.Column[workerSetting]{ + {Name: "key", Value: func(s workerSetting) string { return s.key }}, + {Name: "state", Value: func(s workerSetting) string { return s.state }}, + {Name: "value", Value: func(s workerSetting) string { return orNone(s.value) }}, +} + +// Every capability column is read off internal/harness rather than restated here: a second table that +// claims to know which harness takes a model flag is one that can disagree with the launch command. +var harnessFields = []axi.Column[string]{ + {Name: "name", Value: func(name string) string { return name }}, + {Name: "installed", Value: func(name string) string { return strconv.FormatBool(onPath(name)) }}, + {Name: "model", Value: func(name string) string { return strconv.FormatBool(harness.SupportsModel(name)) }}, + {Name: "effort", Value: func(name string) string { return strconv.FormatBool(harness.SupportsEffort(name)) }}, +} + +type workerConfig struct { + harness string + settings []workerSetting +} + +func newConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Report the fleet's worker defaults and which of them are still missing", + Args: usageArgs(cobra.NoArgs), + RunE: func(cmd *cobra.Command, args []string) error { + fleetHome, err := home.Resolve() + if err != nil { + return asPrecondition(err) + } + cfg := readWorkerConfig(fleetHome) + + var doc axi.Doc + doc.Field("home", fleetHome) + doc.Field("harness", orNone(cfg.harness)) + appendWorkerConfig(&doc, cfg) + axi.Table(&doc, "harnesses", harness.Names(), harnessFields) + doc.Help(workerConfigHelp(cfg)...) + return doc.Render(cmd.OutOrStdout()) + }, + } + cmd.AddCommand(newConfigSetCmd()) + return cmd +} + +func newConfigSetCmd() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Validate and persist one worker default", + Args: usageArgs(cobra.ExactArgs(2)), + RunE: func(cmd *cobra.Command, args []string) error { + key, value := args[0], args[1] + fleetHome, err := home.Resolve() + if err != nil { + return asPrecondition(err) + } + rel, err := writeWorkerSetting(fleetHome, key, value) + if err != nil { + return err + } + cfg := readWorkerConfig(fleetHome) + + var doc axi.Doc + doc.Field("result", "set") + doc.Field("home", fleetHome) + doc.Field("key", key) + doc.Field("value", value) + doc.Field("file", rel) + doc.Field("harness", orNone(cfg.harness)) + appendWorkerConfig(&doc, cfg) + help := workerConfigHelp(cfg) + if len(help) == 0 { + help = append(help, "Every applicable worker default is configured; run `hand project add ` to register a project") + } + doc.Help(help...) + return doc.Render(cmd.OutOrStdout()) + }, + } +} + +// The report the session hook and `hand config` both render, so a supervisor rechecking after an answer +// reads the same shape it read at session start. +func appendWorkerConfig(doc *axi.Doc, cfg workerConfig) { + missing := 0 + for _, s := range cfg.settings { + if s.state == stateMissing { + missing++ + } + } + doc.Int("config_missing", missing) + axi.Table(doc, "config", cfg.settings, workerSettingFields) +} + +// One line per missing setting, in ask order. Each names the operator as the one who answers: a +// supervisor that resolves the question itself has configured the fleet with its own guess. +func workerConfigHelp(cfg workerConfig) []string { + var help []string + for _, s := range cfg.settings { + if s.state != stateMissing { + continue + } + if s.key == settingHarness { + help = append(help, "Ask the operator which harness this fleet's workers should default to, then run `hand config set harness `; `hand config` lists the supported ones and which are installed") + continue + } + help = append(help, fmt.Sprintf("Ask the operator for the default %s for %s workers, then run `hand config set %s `", s.key, cfg.harness, s.key)) + } + return help +} + +// Model and effort are read from the file keyed to the configured harness, never from a bare +// config/model: a value chosen for one harness is not a default for the next one. +func readWorkerConfig(fleetHome string) workerConfig { + cfg := workerConfig{harness: configDefault(fleetHome, settingHarness, "")} + cfg.settings = []workerSetting{{key: settingHarness, state: valueState(cfg.harness), value: cfg.harness}} + for _, key := range []string{settingModel, settingEffort} { + s := workerSetting{key: key} + switch { + case cfg.harness == "": + s.state = statePendingHarness + case !harnessCarries(key, cfg.harness): + s.state = stateUnsupported + default: + s.value = configDefault(fleetHome, harnessSettingKey(key, cfg.harness), "") + s.state = valueState(s.value) + } + cfg.settings = append(cfg.settings, s) + } + return cfg +} + +func workerDefault(fleetHome, key, harnessName string) string { + return configDefault(fleetHome, harnessSettingKey(key, harnessName), "") +} + +func harnessSettingKey(key, harnessName string) string { + return key + "." + harnessName +} + +func harnessCarries(key, harnessName string) bool { + if key == settingEffort { + return harness.SupportsEffort(harnessName) + } + return harness.SupportsModel(harnessName) +} + +func valueState(value string) string { + if value == "" { + return stateMissing + } + return stateConfigured +} + +func onPath(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// Harness names are validated against internal/harness, and effort and model are not: hand knows which +// launch flags a harness takes, and a model identifier belongs to the harness's own catalog, which a +// release of hand cannot keep up with. +func writeWorkerSetting(fleetHome, key, value string) (string, error) { + if !slices.Contains(workerSettingKeys, key) { + return "", &ExitError{Err: fmt.Errorf("unknown setting %q: want one of %s", key, strings.Join(workerSettingKeys, ", ")), Code: 2} + } + if value != strings.TrimSpace(value) || len(strings.Fields(value)) != 1 { + return "", &ExitError{Err: fmt.Errorf("%s value %q must be one word with no surrounding whitespace", key, value), Code: 2} + } + + name := key + if key == settingHarness { + if !harness.IsSupported(value) { + return "", &ExitError{Err: fmt.Errorf("harness %q not recognized: want one of %s", value, strings.Join(harness.Names(), ", ")), Code: 2} + } + } else { + current := configDefault(fleetHome, settingHarness, "") + if current == "" { + return "", &ExitError{Err: fmt.Errorf("no worker harness configured, so %s does not apply to anything yet; set the harness first: hand config set harness ", key), Code: 3} + } + if !harnessCarries(key, current) { + return "", &ExitError{Err: fmt.Errorf("harness %q takes no %s, so there is nothing to configure", current, key), Code: 2} + } + name = harnessSettingKey(key, current) + } + + dir := filepath.Join(fleetHome, "config") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create config: %w", err) + } + if err := atomicfile.Write(filepath.Join(dir, name), ".config-", []byte(value+"\n"), 0o644); err != nil { + return "", fmt.Errorf("write config/%s: %w", name, err) + } + return filepath.Join("config", name), nil +} + +// Moves a bare config/model or config/effort under the harness it was written for, and reports which keys +// moved. Left unkeyed, that value would become the default for whatever harness the home switches to +// next, which is how a claude model identifier reaches an opencode worker. +func migrateWorkerSettings(fleetHome string) ([]string, error) { + harnessName := configDefault(fleetHome, settingHarness, harness.Claude) + var moved []string + var errs []error + for _, key := range []string{settingModel, settingEffort} { + unkeyed := filepath.Join(fleetHome, "config", key) + if _, err := os.Stat(unkeyed); err != nil { + continue + } + keyed := filepath.Join(fleetHome, "config", harnessSettingKey(key, harnessName)) + if _, err := os.Stat(keyed); err == nil { + continue + } + if err := os.Rename(unkeyed, keyed); err != nil { + errs = append(errs, fmt.Errorf("move config/%s under %s: %w", key, harnessName, err)) + continue + } + moved = append(moved, key) + } + return moved, errors.Join(errs...) +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..894c047 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,321 @@ +package cmd + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/atqamz/secondhand/internal/harness" +) + +func setupConfigHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + mkFleetDirs(t, home) + t.Chdir(home) + return home +} + +func runConfigSet(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := newConfigSetCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +func mustConfigSet(t *testing.T, args ...string) string { + t.Helper() + out, err := runConfigSet(t, args...) + if err != nil { + t.Fatalf("hand config set %v: %v", args, err) + } + return out +} + +func assertExitCode(t *testing.T, err error, want int) { + t.Helper() + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != want { + t.Fatalf("got %v, want ExitError code %d", err, want) + } +} + +func settingState(t *testing.T, home, key string) string { + t.Helper() + for _, s := range readWorkerConfig(home).settings { + if s.key == key { + return s.state + } + } + t.Fatalf("no %q setting in the config report", key) + return "" +} + +// The state an unconfigured home reports is the whole first-run flow in one document: one question to +// answer, and two that are not questions yet because applicability follows the harness. +func TestConfigOnAnEmptyHomeAsksForTheHarnessFirst(t *testing.T) { + setupConfigHome(t) + + cmd := newConfigCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + + got := out.String() + for _, want := range []string{ + "config_missing: 1\n", + "harness,missing,none", + "model,pending-harness,none", + "effort,pending-harness,none", + "hand config set harness ", + } { + if !strings.Contains(got, want) { + t.Fatalf("config output = %q, want it to contain %q", got, want) + } + } + if strings.Contains(got, "hand config set model") { + t.Fatalf("config output = %q, want no model question before a harness is chosen", got) + } +} + +// The matrix is read off internal/harness rather than restated, so this is the assertion that the +// reported applicability is the same fact hand launches with. +func TestConfigApplicabilityFollowsTheHarnessContract(t *testing.T) { + home := setupConfigHome(t) + + for _, tc := range []struct { + harness string + model string + effort string + }{ + {harness.Claude, stateMissing, stateMissing}, + {harness.OpenCode, stateMissing, stateUnsupported}, + {harness.Codex, stateUnsupported, stateUnsupported}, + {harness.Grok, stateUnsupported, stateUnsupported}, + {harness.Pi, stateUnsupported, stateUnsupported}, + } { + mustConfigSet(t, settingHarness, tc.harness) + if got := settingState(t, home, settingModel); got != tc.model { + t.Fatalf("%s model state = %q, want %q", tc.harness, got, tc.model) + } + if got := settingState(t, home, settingEffort); got != tc.effort { + t.Fatalf("%s effort state = %q, want %q", tc.harness, got, tc.effort) + } + if harness.SupportsModel(tc.harness) != (tc.model != stateUnsupported) { + t.Fatalf("%s: the expectation above disagrees with harness.SupportsModel", tc.harness) + } + if harness.SupportsEffort(tc.harness) != (tc.effort != stateUnsupported) { + t.Fatalf("%s: the expectation above disagrees with harness.SupportsEffort", tc.harness) + } + } +} + +func TestConfigSetPersistsUnderTheConfiguredHarness(t *testing.T) { + home := setupConfigHome(t) + + mustConfigSet(t, settingHarness, harness.Claude) + out := mustConfigSet(t, settingModel, "claude-opus-5") + + got, err := os.ReadFile(filepath.Join(home, "config", "model.claude")) + if err != nil { + t.Fatalf("config/model.claude missing after set: %v", err) + } + if string(got) != "claude-opus-5\n" { + t.Fatalf("config/model.claude = %q, want %q", got, "claude-opus-5\n") + } + if _, err := os.Stat(filepath.Join(home, "config", "model")); err == nil { + t.Fatal("an unkeyed config/model was written, which the next harness would inherit") + } + // The answer's own document carries the recheck, so a supervisor never has to guess what is left. + for _, want := range []string{"file: config/model.claude", "model,configured,claude-opus-5", "config_missing: 1\n"} { + if !strings.Contains(out, want) { + t.Fatalf("set output = %q, want it to contain %q", out, want) + } + } +} + +// The launch path has to read what `hand config set` wrote, or the fleet is configured with a value +// nothing dispatches. +func TestConfigSetFeedsTheResolvedTier(t *testing.T) { + home := setupConfigHome(t) + briefAbs := writeTierBrief(t, home, "# Title\n") + + mustConfigSet(t, settingHarness, harness.Claude) + mustConfigSet(t, settingModel, "claude-opus-5") + mustConfigSet(t, settingEffort, "high") + + cmd, _ := newTierTestCmd() + model, effort, _, err := resolveTier(cmd, home, briefAbs, harness.Claude, "", "") + if err != nil { + t.Fatal(err) + } + if model != "claude-opus-5" || effort != "high" { + t.Fatalf("got model=%q effort=%q, want the persisted defaults", model, effort) + } +} + +func TestConfigSetRefusesModelBeforeAHarnessIsChosen(t *testing.T) { + home := setupConfigHome(t) + + _, err := runConfigSet(t, settingModel, "claude-opus-5") + assertExitCode(t, err, 3) + if _, err := os.Stat(filepath.Join(home, "config", "model")); err == nil { + t.Fatal("config/model was written despite the refusal") + } +} + +// Codex takes no model and no effort flag, so neither is a value to hold: writing one anyway produces a +// configured-looking fleet whose defaults can only ever warn at dispatch. +func TestConfigSetRefusesWhatTheHarnessCannotCarry(t *testing.T) { + home := setupConfigHome(t) + mustConfigSet(t, settingHarness, harness.Codex) + + for _, key := range []string{settingModel, settingEffort} { + _, err := runConfigSet(t, key, "whatever") + assertExitCode(t, err, 2) + entries, dirErr := os.ReadDir(filepath.Join(home, "config")) + if dirErr != nil { + t.Fatal(dirErr) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), key) { + t.Fatalf("%s was written for codex, which cannot use it", e.Name()) + } + } + } +} + +// Keying is what makes a harness switch re-evaluate rather than inherit: an opencode worker must never be +// launched with the model identifier chosen for claude. +func TestConfigSwitchingHarnessNeverInheritsAnotherHarnessDefault(t *testing.T) { + home := setupConfigHome(t) + briefAbs := writeTierBrief(t, home, "# Title\n") + + mustConfigSet(t, settingHarness, harness.Claude) + mustConfigSet(t, settingModel, "claude-opus-5") + mustConfigSet(t, settingHarness, harness.OpenCode) + + if got := settingState(t, home, settingModel); got != stateMissing { + t.Fatalf("opencode model state = %q, want %q", got, stateMissing) + } + cmd, _ := newTierTestCmd() + model, _, _, err := resolveTier(cmd, home, briefAbs, harness.OpenCode, "", "") + if err != nil { + t.Fatal(err) + } + if model != "" { + t.Fatalf("opencode resolved model = %q, want claude's default not to reach it", model) + } + + // Switching back finds the earlier answer again rather than having lost it. + mustConfigSet(t, settingHarness, harness.Claude) + if got := settingState(t, home, settingModel); got != stateConfigured { + t.Fatalf("claude model state = %q, want %q", got, stateConfigured) + } +} + +// A configured value is not asked about again, and one the operator never answered stays visible without +// blocking anything else the session does. +func TestConfigRepeatSessionsOnlyAskForWhatIsStillMissing(t *testing.T) { + home := setupConfigHome(t) + mustConfigSet(t, settingHarness, harness.Claude) + mustConfigSet(t, settingModel, "claude-opus-5") + + help := workerConfigHelp(readWorkerConfig(home)) + if len(help) != 1 || !strings.Contains(help[0], "hand config set effort") { + t.Fatalf("help = %v, want only the declined effort question", help) + } + + mustConfigSet(t, settingEffort, "high") + if help := workerConfigHelp(readWorkerConfig(home)); len(help) != 0 { + t.Fatalf("help = %v, want no questions once every applicable value is configured", help) + } +} + +func TestConfigSetRejectsUnknownKeysAndUnusableValues(t *testing.T) { + setupConfigHome(t) + + for _, args := range [][]string{ + {"notify", "say hi"}, + {settingHarness, "sol"}, + {settingHarness, ""}, + } { + _, err := runConfigSet(t, args...) + assertExitCode(t, err, 2) + } + + mustConfigSet(t, settingHarness, harness.Claude) + for _, value := range []string{"", " ", "two words", " padded"} { + _, err := runConfigSet(t, settingModel, value) + assertExitCode(t, err, 2) + } +} + +// A pre-0.2.0 home carried config/model with no record of the harness it was chosen for. Read as-is it +// would become the default for whatever harness the home switches to next. +func TestMigrateWorkerSettingsKeysAnOlderHomesDefaults(t *testing.T) { + home := setupConfigHome(t) + if err := os.MkdirAll(filepath.Join(home, "config"), 0o755); err != nil { + t.Fatal(err) + } + write := func(name, value string) { + if err := os.WriteFile(filepath.Join(home, "config", name), []byte(value+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + write("harness", harness.Claude) + write("model", "claude-sonnet-5") + write("effort", "high") + + moved, err := migrateWorkerSettings(home) + if err != nil { + t.Fatal(err) + } + if strings.Join(moved, ",") != "model,effort" { + t.Fatalf("moved = %v, want model and effort", moved) + } + if got := settingState(t, home, settingModel); got != stateConfigured { + t.Fatalf("model state = %q, want the migrated value to be read", got) + } + for _, name := range []string{"model", "effort"} { + if _, err := os.Stat(filepath.Join(home, "config", name)); err == nil { + t.Fatalf("config/%s is still unkeyed after the migration", name) + } + } + if again, err := migrateWorkerSettings(home); err != nil || len(again) != 0 { + t.Fatalf("second run moved %v (%v), want nothing left to move", again, err) + } +} + +// A home with no harness file still dispatches claude (hand spawn's fallback), so that is the harness its +// unkeyed value belonged to. +func TestMigrateWorkerSettingsKeysToClaudeWhenNoHarnessIsConfigured(t *testing.T) { + home := setupConfigHome(t) + if err := os.MkdirAll(filepath.Join(home, "config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, "config", "model"), []byte("claude-sonnet-5\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := migrateWorkerSettings(home); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(home, "config", "model.claude")) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(got)) != "claude-sonnet-5" { + t.Fatalf("config/model.claude = %q", got) + } +} diff --git a/cmd/root.go b/cmd/root.go index 258b2c4..2588e54 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -43,6 +43,7 @@ func newRootCmd(version string) *cobra.Command { return &ExitError{Err: err, Code: 2} }) root.AddCommand(newInitCmd()) + root.AddCommand(newConfigCmd()) root.AddCommand(newProjectCmd()) root.AddCommand(newSpawnCmd()) root.AddCommand(newStatusCmd()) diff --git a/cmd/tier.go b/cmd/tier.go index d6ea96f..245e3fe 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -16,8 +16,8 @@ func resolveTier(cmd *cobra.Command, home, briefAbs, harnessName, model, effort return "", "", false, fmt.Errorf("parse brief %s: %w", briefAbs, err) } - resolvedModel = cmp.Or(model, decl.Model, configDefault(home, "model", "")) - resolvedEffort = cmp.Or(effort, decl.Effort, configDefault(home, "effort", "")) + resolvedModel = cmp.Or(model, decl.Model, workerDefault(home, settingModel, harnessName)) + resolvedEffort = cmp.Or(effort, decl.Effort, workerDefault(home, settingEffort, harnessName)) var dropped []string if resolvedModel != "" && !harness.SupportsModel(harnessName) { diff --git a/cmd/tier_test.go b/cmd/tier_test.go index 492d671..50dc573 100644 --- a/cmd/tier_test.go +++ b/cmd/tier_test.go @@ -21,6 +21,21 @@ func writeTierBrief(t *testing.T, home, content string) string { return path } +// Worker defaults are keyed by the harness they were chosen for, so a fixture that writes the bare +// config/model a pre-0.2.0 home carried would be read by nothing. +func writeTierConfig(t *testing.T, home, harnessName string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(home, "config"), 0o755); err != nil { + t.Fatal(err) + } + for key, value := range map[string]string{"model": "config-model", "effort": "config-effort"} { + path := filepath.Join(home, "config", key+"."+harnessName) + if err := os.WriteFile(path, []byte(value+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } +} + func newTierTestCmd() (*cobra.Command, *bytes.Buffer) { cmd := &cobra.Command{} stderr := &bytes.Buffer{} @@ -33,15 +48,7 @@ const declaredBrief = "---\nmodel: brief-model\neffort: brief-effort\n---\n# Tit func TestResolveTierFlagOverridesEverything(t *testing.T) { home := t.TempDir() briefAbs := writeTierBrief(t, home, declaredBrief) - if err := os.MkdirAll(filepath.Join(home, "config"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(home, "config", "model"), []byte("config-model\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(home, "config", "effort"), []byte("config-effort\n"), 0o644); err != nil { - t.Fatal(err) - } + writeTierConfig(t, home, harness.Claude) cmd, _ := newTierTestCmd() model, effort, frontMatter, err := resolveTier(cmd, home, briefAbs, harness.Claude, "flag-model", "flag-effort") @@ -59,15 +66,7 @@ func TestResolveTierFlagOverridesEverything(t *testing.T) { func TestResolveTierBriefOverridesConfig(t *testing.T) { home := t.TempDir() briefAbs := writeTierBrief(t, home, declaredBrief) - if err := os.MkdirAll(filepath.Join(home, "config"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(home, "config", "model"), []byte("config-model\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(home, "config", "effort"), []byte("config-effort\n"), 0o644); err != nil { - t.Fatal(err) - } + writeTierConfig(t, home, harness.Claude) cmd, _ := newTierTestCmd() model, effort, frontMatter, err := resolveTier(cmd, home, briefAbs, harness.Claude, "", "") @@ -85,15 +84,7 @@ func TestResolveTierBriefOverridesConfig(t *testing.T) { func TestResolveTierConfigAlone(t *testing.T) { home := t.TempDir() briefAbs := writeTierBrief(t, home, "# Title\n\nno declaration here\n") - if err := os.MkdirAll(filepath.Join(home, "config"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(home, "config", "model"), []byte("config-model\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(home, "config", "effort"), []byte("config-effort\n"), 0o644); err != nil { - t.Fatal(err) - } + writeTierConfig(t, home, harness.Claude) cmd, _ := newTierTestCmd() model, effort, frontMatter, err := resolveTier(cmd, home, briefAbs, harness.Claude, "", "") diff --git a/cmd/update.go b/cmd/update.go index 0d1524a..f5837ad 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -54,7 +54,7 @@ func newUpdateCmd(version string) *cobra.Command { // reported as a warning rather than an error: exiting nonzero here reads as "the update failed" and // invites a pointless re-run. var refreshed, hooked bool - var seedErr, hookErr error + var seedErr, hookErr, migrateErr error fleetHome, refreshErr := home.Resolve() switch { case refreshErr == nil: @@ -63,6 +63,9 @@ func newUpdateCmd(version string) *cobra.Command { // that installs it also leaves those files in place - directories included, since a home resolves // as one on its state/hand.db marker alone. seedErr = initLayout(fleetHome) + // An older home's unkeyed worker defaults would otherwise stop being read at all by the binary + // this command just installed. + _, migrateErr = migrateWorkerSettings(fleetHome) // An install that moved leaves the session hook pointing at a // path with no binary behind it any more. var exe string @@ -88,6 +91,11 @@ func newUpdateCmd(version string) *cobra.Command { return err } } + if migrateErr != nil { + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "warning: key worker defaults by harness: %v\n", migrateErr); err != nil { + return err + } + } notes, _ := selfupdate.ReleaseNotes(selfupdate.Repo, latest) diff --git a/tests/e2e/brief_tier_test.go b/tests/e2e/brief_tier_test.go index 96f608d..6358e73 100644 --- a/tests/e2e/brief_tier_test.go +++ b/tests/e2e/brief_tier_test.go @@ -54,7 +54,8 @@ func readLaunchLog(t *testing.T, path string) []string { func TestSpawnHonorsBriefDeclaredTier(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "local-only") - writeConfig(t, home, "model", "claude-sonnet-5\n") + handConfigSet(t, home, "harness", "claude") + handConfigSet(t, home, "model", "claude-sonnet-5") clonePath := filepath.Join(home, "projects", "demo") initGitRepo(t, clonePath) @@ -154,7 +155,8 @@ func TestSpawnHonorsBriefDeclaredTier(t *testing.T) { func TestPromoteHonorsBriefDeclaredTier(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") - writeConfig(t, home, "model", "claude-sonnet-5\n") + handConfigSet(t, home, "harness", "claude") + handConfigSet(t, home, "model", "claude-sonnet-5") writeBriefWith(t, home, "task-1", "---\nmodel: claude-opus-5\neffort: max\n---\n\n# promoted scout\n") if err := os.WriteFile(filepath.Join(home, "data", "task-1", "report.md"), []byte("# report\n"), 0o644); err != nil { t.Fatal(err) @@ -214,7 +216,7 @@ func TestPromoteHonorsBriefDeclaredTier(t *testing.T) { func TestSpawnWarnsOnEffortIncapableHarness(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "local-only") - writeConfig(t, home, "harness", "opencode\n") + handConfigSet(t, home, "harness", "opencode") writeBriefWith(t, home, "task-1", "---\nmodel: grok-code\neffort: high\n---\n\n# opencode task\n") clonePath := filepath.Join(home, "projects", "demo") diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 560be92..a887e81 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -284,6 +284,15 @@ func writeConfig(t *testing.T, home, name, value string) { } } +// A worker default is keyed by the harness it was chosen for, so a test declares one the way an operator +// does rather than writing config/ by hand and getting a file nothing reads. +func handConfigSet(t *testing.T, home, key, value string) { + t.Helper() + if got := runHand(t, home, "config", "set", key, value); got.code != 0 { + t.Fatalf("hand config set %s %s: exit %d, stderr %q", key, value, got.code, got.stderr) + } +} + func writeBrief(t *testing.T, home, id string) { t.Helper() if err := os.MkdirAll(filepath.Join(home, "data", id), 0o755); err != nil { From 2c95c7657fe0eb3e91817191ecf3b8cb6ab3b77a Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 06:28:27 +0700 Subject: [PATCH 3/9] feat(cmd): report unset worker defaults at session start The bare command is the session hook, so it is where a supervising agent learns the fleet is not configured yet. It now carries the same config block hand config prints, and leads its help with one question per missing value for the agent to put to the operator. --- cmd/root.go | 7 ++++++- cmd/root_test.go | 38 ++++++++++++++++++++++++++++++++++++++ cmd/status.go | 6 ++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 2588e54..5023546 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -104,6 +104,11 @@ func runRootOverview(cmd *cobra.Command, version string) error { } doc.Field("home", tildePath(fleetHome)) + // This command is the session hook, so the fleet's configuration state reaches a supervising agent + // here or not at all: the questions it asks the operator are the ones this block reports missing. + cfg := readWorkerConfig(fleetHome) + appendWorkerConfig(&doc, cfg) + cols, err := pickFields(taskFields, nil, fleetDefaultFields) if err != nil { return err @@ -112,7 +117,7 @@ func runRootOverview(cmd *cobra.Command, version string) error { if err != nil { return err } - appendFleet(&doc, views, holds, cols) + appendFleet(&doc, views, holds, cols, workerConfigHelp(cfg)...) return doc.Render(cmd.OutOrStdout()) } diff --git a/cmd/root_test.go b/cmd/root_test.go index 886f4ac..a158ecb 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -146,6 +146,44 @@ func TestBareInvocationLeadsWithTheFleetItManages(t *testing.T) { } } +// The bare command is the session hook, so it is the only place a supervising agent learns that the +// fleet is not configured yet, and the only place the question can be put in front of the operator. +func TestBareInvocationReportsConfigurationStateAndAsksTheOperator(t *testing.T) { + home := t.TempDir() + t.Chdir(home) + mkFleetDirs(t, home) + + out := runBareRoot(t) + for _, want := range []string{ + "config_missing: 1\n", + "config[3]{key,state,value}:\n", + "harness,missing,none", + "Ask the operator which harness", + } { + if !strings.Contains(out, want) { + t.Fatalf("out = %q, want it to contain %q", out, want) + } + } + + if _, err := runConfigSet(t, settingHarness, "codex"); err != nil { + t.Fatal(err) + } + out = runBareRoot(t) + for _, want := range []string{ + "config_missing: 0\n", + "harness,configured,codex", + "model,unsupported,none", + "effort,unsupported,none", + } { + if !strings.Contains(out, want) { + t.Fatalf("out = %q, want it to contain %q", out, want) + } + } + if strings.Contains(out, "Ask the operator") { + t.Fatalf("out = %q, want no configuration question once nothing applicable is missing", out) + } +} + func TestBareInvocationOutsideAFleetHomeSaysSoAndNamesTheWayIn(t *testing.T) { t.Chdir(t.TempDir()) t.Setenv("HAND_HOME", "") diff --git a/cmd/status.go b/cmd/status.go index a6b7333..73f4c93 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -290,7 +290,7 @@ func runStatusFleet(cmd *cobra.Command, home string, client *herdr.Client, asJSO // Writes the fleet blocks onto doc rather than a writer, so the bare command can put its identity fields // above the same overview. -func appendFleet(doc *axi.Doc, views []taskView, holds []state.Hold, cols []axi.Column[taskView]) { +func appendFleet(doc *axi.Doc, views []taskView, holds []state.Hold, cols []axi.Column[taskView], leadHelp ...string) { attention := 0 for _, v := range views { if needsAttention(v) { @@ -303,7 +303,9 @@ func appendFleet(doc *axi.Doc, views []taskView, holds []state.Hold, cols []axi. doc.Int("held", len(holds)) axi.Table(doc, "tasks", views, cols) axi.Table(doc, "holds", holds, holdFields) - doc.Help(fleetHelp(views, attention)...) + // An unanswered configuration question leads: it is the one thing here the fleet cannot proceed + // without, and doc.Help renders a single list, so it cannot be a second block. + doc.Help(append(slices.Clone(leadHelp), fleetHelp(views, attention)...)...) } func fleetViews(cmd *cobra.Command, home string, client *herdr.Client) ([]taskView, []state.Hold, error) { From 33a5dc912c0ba009586150254b05eefcba3dff53 Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 06:28:33 +0700 Subject: [PATCH 4/9] feat(cmd): make hand init non-interactive init now only bootstraps: it never reads stdin, asks nothing, and writes no worker default, so it runs unchanged in a script, in CI, or with stdin closed. What the fleet should dispatch is settled by the operator in the first supervising session instead, because a value invented at bootstrap time is indistinguishable from one the operator chose. The --setup flag is gone rather than deprecated: nothing in the tool, the tests or the session integration invoked it, and an unknown flag already refuses with a usage document naming it. --- cmd/init.go | 143 ++++++------------------------- cmd/init_test.go | 91 ++++++++++++++++++++ cmd/project_test.go | 10 --- tests/e2e/init_config_test.go | 154 ++++++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 127 deletions(-) create mode 100644 tests/e2e/init_config_test.go diff --git a/cmd/init.go b/cmd/init.go index b9ab865..5cc7f34 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -5,9 +5,7 @@ import ( "fmt" "io" "os" - "os/exec" "path/filepath" - "strconv" "strings" "github.com/atqamz/secondhand/internal/agentsmd" @@ -17,7 +15,6 @@ import ( "github.com/spf13/cobra" ) -var harnessCandidates = []string{"claude", "codex", "pi", "grok", "opencode"} var toolCandidates = []string{"treehouse", "herdr", "no-mistakes", "gh"} const backlogSkeleton = `# Backlog @@ -52,12 +49,13 @@ const doneArchiveSkeleton = "# Done archive\n" const noteArchiveSkeleton = "# Note archive\n" +// Bootstrap only: it asks nothing, reads no stdin, and writes no worker default. What the fleet should +// dispatch is settled by the operator in the first supervising session (`hand config`), because a value +// invented at bootstrap time is indistinguishable from one the operator chose. func newInitCmd() *cobra.Command { - var setup bool - - cmd := &cobra.Command{ + return &cobra.Command{ Use: "init [path]", - Short: "Initialize secondhand runtime directories", + Short: "Create or refresh a fleet home; asks no questions", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { cwd, err := os.Getwd() @@ -75,6 +73,10 @@ func newInitCmd() *cobra.Command { if err := initMarker(home); err != nil { return err } + migrated, err := migrateWorkerSettings(home) + if err != nil { + return err + } refreshed, err := agentsmd.Refresh(home) if err != nil { return err @@ -88,14 +90,6 @@ func newInitCmd() *cobra.Command { return err } - var chosen setupChoice - if setup { - chosen, err = runInteractiveSetup(cmd, home) - if err != nil { - return err - } - } - if err := warnHandHomeMismatch(cmd.ErrOrStderr(), home); err != nil { return err } @@ -105,18 +99,28 @@ func newInitCmd() *cobra.Command { doc.Field("home", home) doc.Field("agents_md", writtenOrUnchanged(refreshed)) doc.Field("session_hook", writtenOrUnchanged(hooked)) - doc.Field("harness", orNone(chosen.harness)) - doc.Field("model", orNone(chosen.model)) - doc.Field("effort", orNone(chosen.effort)) - doc.Help("Run `hand project add ` to register the first project", + doc.List("migrated", migrated) + appendWorkerConfig(&doc, readWorkerConfig(home)) + doc.List("missing_tools", missingTools()) + doc.Help("Start a supervising session in this home; it reports the worker defaults still missing and asks you for each one (`hand config set `)", "Read AGENTS.md in this home for how a supervising agent is meant to drive it", - "A Claude Code session started in this home now opens with the fleet already in context") + "Run `hand project add ` to register the first project", + "The session integration installed here is a Claude Code `SessionStart` hook, so a session opened with another harness reads AGENTS.md itself") return doc.Render(cmd.OutOrStdout()) }, } +} - cmd.Flags().BoolVar(&setup, "setup", false, "run interactive first-time setup") - return cmd +// Reported rather than resolved: a missing tool is a diagnostic the first session explains in context, +// and turning bootstrap into a prerequisite wizard is what this command exists not to be. +func missingTools() []string { + var missing []string + for _, t := range toolCandidates { + if !onPath(t) { + missing = append(missing, t) + } + } + return missing } func writtenOrUnchanged(changed bool) string { @@ -186,78 +190,6 @@ func initMarker(home string) error { return db.Close() } -// The prompts stay plain lines: they are a dialog with whoever is at the -// terminal, and only the answers are a result worth rendering. -type setupChoice struct { - harness string - model string - effort string -} - -func runInteractiveSetup(cmd *cobra.Command, home string) (setupChoice, error) { - out := cmd.OutOrStdout() - - var foundHarnesses []string - for _, h := range harnessCandidates { - if _, err := exec.LookPath(h); err == nil { - foundHarnesses = append(foundHarnesses, h) - } - } - - var foundTools []string - for _, t := range toolCandidates { - if _, err := exec.LookPath(t); err == nil { - foundTools = append(foundTools, t) - } - } - - if _, err := fmt.Fprintf(out, "found harnesses: %s\n", strings.Join(foundHarnesses, " ")); err != nil { - return setupChoice{}, err - } - if _, err := fmt.Fprintf(out, "found tools: %s\n", strings.Join(foundTools, " ")); err != nil { - return setupChoice{}, err - } - - if len(foundHarnesses) == 0 { - return setupChoice{}, fmt.Errorf("no supported harnesses found on PATH") - } - - in := cmd.InOrStdin() - if _, err := fmt.Fprintln(out, "select default worker harness:"); err != nil { - return setupChoice{}, err - } - for i, h := range foundHarnesses { - if _, err := fmt.Fprintf(out, "%d) %s\n", i+1, h); err != nil { - return setupChoice{}, err - } - } - harness, err := readSetupChoice(in, foundHarnesses, "harness") - if err != nil { - return setupChoice{}, err - } - - model, err := readSetupValue(in, out, "default worker model") - if err != nil { - return setupChoice{}, err - } - effort, err := readSetupValue(in, out, "worker effort") - if err != nil { - return setupChoice{}, err - } - - if err := os.WriteFile(filepath.Join(home, "config", "harness"), []byte(harness+"\n"), 0o644); err != nil { - return setupChoice{}, fmt.Errorf("write config/harness: %w", err) - } - if err := os.WriteFile(filepath.Join(home, "config", "model"), []byte(model+"\n"), 0o644); err != nil { - return setupChoice{}, fmt.Errorf("write config/model: %w", err) - } - if err := os.WriteFile(filepath.Join(home, "config", "effort"), []byte(effort+"\n"), 0o644); err != nil { - return setupChoice{}, fmt.Errorf("write config/effort: %w", err) - } - - return setupChoice{harness: harness, model: model, effort: effort}, nil -} - func resolveInitHome(cwd string, args []string) (string, error) { if len(args) > 1 { return "", &ExitError{Err: fmt.Errorf("init accepts at most one target path"), Code: 2} @@ -293,26 +225,3 @@ func warnHandHomeMismatch(w io.Writer, home string) error { _, err := fmt.Fprintf(w, "warning: HAND_HOME is set to %s, so every other hand command will use that home, not %s\n", display, home) return err } - -func readSetupChoice(in io.Reader, choices []string, label string) (string, error) { - var input string - if _, err := fmt.Fscan(in, &input); err != nil { - return "", fmt.Errorf("read %s choice: %w", label, err) - } - choice, err := strconv.Atoi(input) - if err != nil || choice < 1 || choice > len(choices) { - return "", fmt.Errorf("invalid %s choice", label) - } - return choices[choice-1], nil -} - -func readSetupValue(in io.Reader, out io.Writer, label string) (string, error) { - if _, err := fmt.Fprintf(out, "%s: ", label); err != nil { - return "", err - } - var value string - if _, err := fmt.Fscan(in, &value); err != nil { - return "", fmt.Errorf("read %s: %w", label, err) - } - return value, nil -} diff --git a/cmd/init_test.go b/cmd/init_test.go index 22617e6..dcaf8a3 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/atqamz/secondhand/internal/home" ) @@ -138,6 +139,96 @@ func TestInitIsIdempotentAboutTheHandDbMarker(t *testing.T) { } } +// Bootstrap answers nothing on the operator's behalf, so a fresh home leaves every worker default +// unwritten and hands the questions to the session that reads this document. +func TestInitWritesNoWorkerDefaultAndReportsWhatIsMissing(t *testing.T) { + t.Setenv("HAND_HOME", "") + dir := t.TempDir() + t.Chdir(dir) + + cmd := newInitCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + + entries, err := os.ReadDir(filepath.Join(dir, "config")) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + for _, e := range entries { + t.Fatalf("init wrote config/%s, want no worker default chosen for the operator", e.Name()) + } + for _, want := range []string{ + "config_missing: 1\n", + "harness,missing,none", + "model,pending-harness,none", + "hand config set ", + } { + if !strings.Contains(out.String(), want) { + t.Fatalf("init output = %q, want it to contain %q", out.String(), want) + } + } +} + +// The retired flag has to fail loudly rather than be silently accepted, so a script still passing it +// is told the configuration moved instead of appearing to have set something. +func TestInitRefusesTheRetiredSetupFlag(t *testing.T) { + t.Setenv("HAND_HOME", "") + t.Chdir(t.TempDir()) + + root := newRootCmd("test") + root.SetOut(new(bytes.Buffer)) + root.SetErr(new(bytes.Buffer)) + root.SetArgs([]string{"init", "--setup"}) + _, err := root.ExecuteC() + if code := exitCodeFor(t, err); code != 2 { + t.Fatalf("code = %d, want 2 (err = %v)", code, err) + } + if !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("err = %v, want it to name the flag as unknown", err) + } +} + +// Init runs in scripts and in CI, where stdin is closed or never written to. An open pipe nothing +// writes to is the shape that hangs, so the read has to be absent rather than merely unreached. +func TestInitNeverReadsStdin(t *testing.T) { + t.Setenv("HAND_HOME", "") + t.Chdir(t.TempDir()) + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = reader.Close() + _ = writer.Close() + }) + stdin := os.Stdin + os.Stdin = reader + t.Cleanup(func() { os.Stdin = stdin }) + + done := make(chan error, 1) + go func() { + cmd := newInitCmd() + cmd.SetIn(reader) + cmd.SetOut(new(bytes.Buffer)) + cmd.SetArgs([]string{}) + done <- cmd.Execute() + }() + + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(10 * time.Second): + t.Fatal("init did not finish with nothing to read, want it never to block on stdin") + } +} + func TestInitInstallsTheSessionHookAndSaysSoOnlyWhenItWroteIt(t *testing.T) { t.Setenv("HAND_HOME", "") dir := t.TempDir() diff --git a/cmd/project_test.go b/cmd/project_test.go index 6329105..07a9940 100644 --- a/cmd/project_test.go +++ b/cmd/project_test.go @@ -555,13 +555,3 @@ func TestWarnHandHomeMismatch(t *testing.T) { t.Fatalf("got %q, want %q", relative.String(), want) } } - -func TestReadSetupChoice(t *testing.T) { - choice, err := readSetupChoice(strings.NewReader("2\n"), []string{"claude", "codex"}, "harness") - if err != nil || choice != "codex" { - t.Fatalf("readSetupChoice = %q, %v", choice, err) - } - if _, err := readSetupChoice(strings.NewReader("3\n"), []string{"claude", "codex"}, "harness"); err == nil { - t.Fatal("expected out-of-range setup choice to fail") - } -} diff --git a/tests/e2e/init_config_test.go b/tests/e2e/init_config_test.go new file mode 100644 index 0000000..70da841 --- /dev/null +++ b/tests/e2e/init_config_test.go @@ -0,0 +1,154 @@ +//go:build e2e + +package e2e + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Runs hand with stdin the caller chooses and a deadline, because the failure being tested for is a +// process that never returns rather than one that returns the wrong thing. +func runHandStdin(t *testing.T, home string, stdin *os.File, args ...string) invocation { + t.Helper() + cmd := exec.Command(handBin, args...) + cmd.Dir = home + cmd.Stdin = stdin + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatalf("start hand %v: %v", args, err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + code := 0 + if err != nil { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("wait hand %v: %v", args, err) + } + code = exitErr.ExitCode() + } + t.Logf("$ hand %s\n exit %d\n stdout: %s\n stderr: %s", + strings.Join(args, " "), code, strings.TrimSpace(stdout.String()), strings.TrimSpace(stderr.String())) + return invocation{code: code, stdout: stdout.String(), stderr: stderr.String()} + case <-time.After(30 * time.Second): + _ = cmd.Process.Kill() + <-done + t.Fatalf("hand %v never returned with nothing to read on stdin; stdout=%q stderr=%q", + args, stdout.String(), stderr.String()) + return invocation{} + } +} + +// An open pipe nothing ever writes to, which is the stdin a read blocks forever on. /dev/null returns EOF +// immediately, so it catches a read that fails but not one that waits. +func openPipeStdin(t *testing.T) *os.File { + t.Helper() + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = reader.Close() + _ = writer.Close() + }) + return reader +} + +func devNull(t *testing.T) *os.File { + t.Helper() + f, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = f.Close() }) + return f +} + +// Bootstrap runs in scripts and in CI, and the session hook runs on every session start, so neither may +// ever depend on something being on stdin. +func TestInitAndSessionStartNeverBlockOnStdin(t *testing.T) { + for _, tc := range []struct { + name string + stdin func(*testing.T) *os.File + }{ + {"dev-null", devNull}, + {"unwritten-pipe", openPipeStdin}, + } { + t.Run(tc.name, func(t *testing.T) { + isolateGitConfig(t) + home := t.TempDir() + + initialized := runHandStdin(t, home, tc.stdin(t), "init") + if initialized.code != 0 { + t.Fatalf("hand init: exit %d, stderr %q", initialized.code, initialized.stderr) + } + if !strings.Contains(initialized.stdout, "config_missing: 1") { + t.Fatalf("init stdout = %q, want the unanswered harness reported", initialized.stdout) + } + + session := runHandStdin(t, home, tc.stdin(t), "config") + if session.code != 0 { + t.Fatalf("hand config: exit %d, stderr %q", session.code, session.stderr) + } + if bare := runHandStdin(t, home, tc.stdin(t)); bare.code != 0 { + t.Fatalf("bare hand: exit %d, stderr %q", bare.code, bare.stderr) + } + }) + } +} + +// The whole first run through the built binary: bootstrap chooses nothing, the session document asks, the +// answer is persisted, and what the chosen harness cannot carry is never asked about. +func TestFirstRunConfigurationHappensAfterBootstrap(t *testing.T) { + home := newHome(t) + + before := runHand(t, home) + for _, want := range []string{"config_missing: 1", "harness,missing,none", "Ask the operator which harness"} { + if !strings.Contains(before.stdout, want) { + t.Fatalf("session document = %q, want it to contain %q", before.stdout, want) + } + } + + handConfigSet(t, home, "harness", "codex") + after := runHand(t, home) + for _, want := range []string{"config_missing: 0", "harness,configured,codex", "model,unsupported,none", "effort,unsupported,none"} { + if !strings.Contains(after.stdout, want) { + t.Fatalf("session document = %q, want it to contain %q", after.stdout, want) + } + } + if strings.Contains(after.stdout, "Ask the operator") { + t.Fatalf("session document = %q, want nothing left to ask about under codex", after.stdout) + } + + refused := runHand(t, home, "config", "set", "model", "gpt-5") + if refused.code != 2 { + t.Fatalf("config set model under codex: exit %d, want 2", refused.code) + } + if _, err := os.Stat(filepath.Join(home, "config", "model.codex")); err == nil { + t.Fatal("config/model.codex was written for a harness that takes no model flag") + } +} + +// The retired flag has to refuse rather than be ignored, so a script still passing it is told the +// configuration moved instead of appearing to have set something. +func TestInitRefusesTheRetiredSetupFlag(t *testing.T) { + isolateGitConfig(t) + got := runHand(t, t.TempDir(), "init", "--setup") + if got.code != 2 { + t.Fatalf("hand init --setup: exit %d, want 2", got.code) + } + if !strings.Contains(got.stderr, "unknown flag") { + t.Fatalf("stderr = %q, want it to name the flag as unknown", got.stderr) + } +} From 062128b3b18def4114e88e6217081549858dddce Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 06:28:40 +0700 Subject: [PATCH 5/9] feat(agentsmd): tell the supervisor to ask the operator for worker defaults The generated block now makes settling a missing worker default a step of the workflow, and says who answers: the supervisor asks the operator in conversation and persists the reply with hand config set. Answering its own harness dialog would configure the fleet with the agent's guess. --- internal/agentsmd/agentsmd.go | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/internal/agentsmd/agentsmd.go b/internal/agentsmd/agentsmd.go index aaeb6a7..ec665be 100644 --- a/internal/agentsmd/agentsmd.go +++ b/internal/agentsmd/agentsmd.go @@ -41,15 +41,26 @@ Run ` + "`hand --help`" + ` for the full command reference. ## Workflow 1. Read ` + "`data/operator.md`" + ` before anything else. Its constraints outrank your own judgment. -2. Run ` + "`hand status`" + ` for current fleet state. A row marked ` + "`unacknowledged`" + ` is a worker that reached done or failed while nothing was watching; treat it as news that just arrived. -3. Match the request to a project in ` + "`data/projects.md`" + `. -4. Edit ` + "`data/backlog.md`" + ` to record the task with a unique ID. -5. Write a brief at ` + "`data//brief.md`" + `, including the absolute path to ` + "`state/.status`" + ` and the report vocabulary the worker should append to it. The brief may open with a ` + "`---`" + ` fenced block declaring ` + "`model`" + ` and ` + "`effort`" + ` for the task, which spawn and promote apply unless a flag overrides them. -6. ` + "`hand spawn `" + ` to start a worker. -7. ` + "`hand watch --until-event`" + ` as a background task to monitor the fleet. It exits on the first fleet event and that exit is what reaches you, so re-arm it every time you act on one. Bound the wait with ` + "`--timeout `" + `; exit 4 means the window passed with nothing happening, exit 5 means a worker named on stderr couldn't be reached before it even started waiting, exit 3 means another watcher is already attached to this home - one per home is the limit, so replace it with ` + "`--takeover`" + ` rather than starting a second. -8. Act on watch output: steer blocked workers with ` + "`hand send`" + `, relay results. -9. When told to merge: ` + "`hand merge `" + `. -10. ` + "`hand teardown `" + ` after work is landed. Work that is handed off but whose landing is someone else's call - a PR offered to an upstream repo, a deliverable that is a report - is recorded with ` + "`hand deliver --reason `" + ` first; teardown then accepts it without ` + "`--force`" + `, and the completion record says delivered rather than merged. +2. Settle any worker default the session overview's ` + "`config`" + ` block reports ` + "`missing`" + `, before dispatching anything: see "First-run configuration" below. +3. Run ` + "`hand status`" + ` for current fleet state. A row marked ` + "`unacknowledged`" + ` is a worker that reached done or failed while nothing was watching; treat it as news that just arrived. +4. Match the request to a project in ` + "`data/projects.md`" + `. +5. Edit ` + "`data/backlog.md`" + ` to record the task with a unique ID. +6. Write a brief at ` + "`data//brief.md`" + `, including the absolute path to ` + "`state/.status`" + ` and the report vocabulary the worker should append to it. The brief may open with a ` + "`---`" + ` fenced block declaring ` + "`model`" + ` and ` + "`effort`" + ` for the task, which spawn and promote apply unless a flag overrides them. +7. ` + "`hand spawn `" + ` to start a worker. +8. ` + "`hand watch --until-event`" + ` as a background task to monitor the fleet. It exits on the first fleet event and that exit is what reaches you, so re-arm it every time you act on one. Bound the wait with ` + "`--timeout `" + `; exit 4 means the window passed with nothing happening, exit 5 means a worker named on stderr couldn't be reached before it even started waiting, exit 3 means another watcher is already attached to this home - one per home is the limit, so replace it with ` + "`--takeover`" + ` rather than starting a second. +9. Act on watch output: steer blocked workers with ` + "`hand send`" + `, relay results. +10. When told to merge: ` + "`hand merge `" + `. +11. ` + "`hand teardown `" + ` after work is landed. Work that is handed off but whose landing is someone else's call - a PR offered to an upstream repo, a deliverable that is a report - is recorded with ` + "`hand deliver --reason `" + ` first; teardown then accepts it without ` + "`--force`" + `, and the completion record says delivered rather than merged. + +## First-run configuration + +The session overview's ` + "`config`" + ` block is what this fleet dispatches with, and every value in it is the operator's to choose. + +- A setting reported ` + "`missing`" + ` is a question you ask the operator in this conversation, in plain words, offering what ` + "`hand config`" + ` lists as supported and installed. Persist their answer with ` + "`hand config set `" + `, which validates it and writes it atomically; never write anything under ` + "`config/`" + ` yourself. +- Never answer one of these for the operator. A value you picked, or one your own harness dialog accepted, configures the fleet with a guess that afterwards looks exactly like their decision. +- ` + "`hand config set`" + ` reprints the block, so read what it returns and keep asking until nothing is ` + "`missing`" + `. ` + "`hand config`" + ` re-reads it at any time. +- ` + "`unsupported`" + ` means the selected harness takes no such launch flag, so it is not a question and not a gap. ` + "`pending-harness`" + ` means applicability is unknown until the harness is chosen, so choose that first. +- An operator who declines to answer has answered: leave it missing, say so, and carry on with everything that does not need it. The next session asks again. ## Rules From 7a471d31170bbcc178d316f61a106b4af6449836 Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 06:28:40 +0700 Subject: [PATCH 6/9] docs: install hand before initializing a fleet home The quick start built the tool from a checkout, which is the maintainers' setup rather than an install, and pointed at hand init --setup for configuration. It now installs a release binary, initializes a home that asks nothing, and explains the worker defaults the first session asks for. Building a checkout moves to CONTRIBUTING.md. --- CONTRIBUTING.md | 8 ++++ README.md | 98 +++++++++++++++++++++++++++++++------------------ 2 files changed, 70 insertions(+), 36 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b3d457..efa31d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,14 @@ make test `nix develop` is optional and provides the full toolchain: Go, golangci-lint, gopls, gotools, and gcc (gcc is required because `make test` runs with `-race`, which needs CGO). Without Nix, install those yourself. +To dogfood the tool from its own checkout, initialize a fleet home there and run the binary you just built: + + ./hand init + ./hand project add https://github.com/org/repo + +Every directory `hand init` creates at the checkout root is gitignored, so the fleet home lives alongside the source without ever being committed. +`hand init` asks nothing; open a supervising session in the checkout and answer the worker defaults it reports missing. + ## Making changes 1. Open an issue describing the intent, design, or proposal, and get agreement there before writing code. This applies to any contribution, no matter the size. See "Reporting issues" below for what to include. diff --git a/README.md b/README.md index aed9d44..3463d9a 100644 --- a/README.md +++ b/README.md @@ -9,30 +9,28 @@ It was born from [firstmate](https://github.com/kunchenguid/firstmate), an agent ## Quick start -```sh -git clone https://github.com/atqamz/secondhand -cd secondhand -make build -./hand init --setup -./hand project add https://github.com/org/repo -``` - -The worker lifecycle commands are available, including `hand spawn`, `hand status`, `hand send`, and `hand teardown`. - -## Set up a fleet home - -The quick start above dogfoods a fleet home inside the secondhand repo checkout itself, which is how the maintainers run it. -Most users instead want a standalone fleet home: a plain directory, anywhere on disk, unrelated to any project's own repo. +Install `hand` (see "Installation" below for every option), then create a fleet home and register a project: ```sh mkdir ~/fleet cd ~/fleet -hand init --setup +hand init hand project add https://github.com/org/repo ``` -`hand init` only writes runtime directories, skeleton files and a `.claude/settings.json` session hook under the current directory; it never places a `hand` binary there. -Install `hand` from one of the options under "Installation" below and make sure it is on `PATH` before running any command. +`hand init` asks nothing. +It creates runtime directories, skeleton files, `AGENTS.md` and a `.claude/settings.json` session hook under the current directory, and reports which worker defaults are still unset. +Those defaults are settled in the first supervising session you open in the home: it reads the same report at session start and asks you for each missing value, then persists your answer with `hand config set `. +Nothing is guessed on your behalf, so a fleet home is never configured with a value you did not choose. + +A fleet home is a plain directory, anywhere on disk, unrelated to any project's own repo. +`hand init` never places a `hand` binary in it, so install `hand` on `PATH` first. + +The worker lifecycle commands are available, including `hand spawn`, `hand status`, `hand send`, and `hand teardown`. + +## Set up a fleet home + +The maintainers dogfood a fleet home inside the secondhand checkout itself; see [CONTRIBUTING.md](CONTRIBUTING.md) for that setup. Every `hand` command resolves its fleet home the same way: the `HAND_HOME` environment variable if set, otherwise the current directory or the nearest ancestor holding `state/hand.db`. `state/hand.db` is the marker because only `hand` ever writes it, so a project clone under `projects/` carrying its own generic top-level `data/` and `state/` never captures the walk up. @@ -57,7 +55,8 @@ Set `HAND_HOME` to run `hand` from outside the fleet home, for example from a sc | Command | Description | Status | | --- | --- | --- | | `hand` | With no subcommand: name the binary that answered, its version and the fleet home it resolved, followed by the fleet overview `hand status` prints | Available | -| `hand init` | Initialize runtime directories, skeleton files and the session hook; `--setup` runs interactive first-time configuration | Available | +| `hand init` | Initialize runtime directories, skeleton files and the session hook; asks nothing and chooses no worker default | Available | +| `hand config` | Report the fleet's worker defaults and which of them are still missing; `hand config set ` validates and persists one | Available | | `hand project add` | Clone and register a repository | Available | | `hand project list` | List registered projects | Available | | `hand project remove` | Unregister a project, keeping its clone | Available | @@ -98,15 +97,19 @@ flowchart TD ## Requirements -- Go 1.26.5 or newer (build only) -- [herdr](https://github.com/ogulcancelik/herdr) - terminal multiplexer with semantic agent state -- [treehouse](https://github.com/kunchenguid/treehouse) v2.1.0 or newer - git worktree pool manager -- [gh](https://github.com/cli/cli) - GitHub CLI, used for PR and release operations +`hand` itself is a static binary with no runtime dependencies. +It shells out to these tools, and reports the ones it cannot find on `PATH` when you run `hand init` or `hand doctor`: + +- [herdr](https://github.com/ogulcancelik/herdr) - terminal multiplexer with semantic agent state; every worker runs in a herdr pane, so spawning needs it +- [treehouse](https://github.com/kunchenguid/treehouse) v2.1.0 or newer - git worktree pool manager; workers are given worktrees leased from it +- [gh](https://github.com/cli/cli) - GitHub CLI, used for every PR and release operation +- [no-mistakes](https://github.com/yes2games/no-mistakes) - validation pipeline, needed only by projects registered in `no-mistakes` mode +- [qmd](https://github.com/tobi/qmd) - semantic search over historical task data, beyond `hand search`'s keyword matching; optional -Optional: +A worker also needs its own agent harness installed - `claude`, `codex`, `grok`, `pi` or `opencode`. +`hand config` lists the supported ones, which of them are on `PATH`, and which accept a model or effort at launch. -- [no-mistakes](https://github.com/yes2games/no-mistakes) - validation pipeline for projects in `no-mistakes` mode -- [qmd](https://github.com/tobi/qmd) - semantic search over historical task data, beyond `hand search`'s keyword matching +Building from source additionally needs Go 1.26.5 or newer. `hand` never installs or configures qmd, and every command works without it. To point it at a fleet home's corpus by hand: @@ -122,28 +125,37 @@ qmd vsearch "how did we handle the deploy failure" -c secondhand ## Installation -From source: +From a release, the way most installs should go: ```sh -make build +curl -fsSLO https://github.com/atqamz/secondhand/releases/latest/download/hand-linux-amd64.tar.gz +tar xzf hand-linux-amd64.tar.gz +install -m755 hand ~/.local/bin/hand ``` -Set `VERSION` to embed a release version in the binary: +Releases carry `hand-linux-amd64`, `hand-linux-arm64`, `hand-darwin-amd64` and `hand-darwin-arm64` as `.tar.gz`, alongside a `checksums.txt` to verify against. +The [releases page](https://github.com/atqamz/secondhand/releases) lists every asset. + +From nix, either into your profile or for a single command: ```sh -make build VERSION=0.1.0 +nix profile install github:atqamz/secondhand +nix shell github:atqamz/secondhand -c hand --version ``` -From nix: +The flake covers `aarch64-darwin`, `aarch64-linux` and `x86_64-linux`. +On Intel macOS, use a release binary or `go install`. + +From Go: ```sh -nix build +go install github.com/atqamz/secondhand@latest ``` -The flake covers `aarch64-darwin`, `aarch64-linux`, and `x86_64-linux`. -On Intel macOS, use `make build` or a release binary. +That produces a binary named `secondhand`, not `hand`, and embeds no version, so it prints `dev` and never reports available updates. +Rename it or prefer a release asset. -From releases: download the binary for your platform from the [releases page](https://github.com/atqamz/secondhand/releases). +To build a checkout - the path for working on secondhand itself - see [CONTRIBUTING.md](CONTRIBUTING.md). To update an installed binary, run `hand update`. It downloads the release asset for the current OS and architecture, verifies its SHA256 checksum, and replaces the running binary in place. @@ -154,8 +166,22 @@ Builds without an embedded version never print the notice. ## Configuration -Preferences live as plain files under `config/`, one value per file - default worker harness, model, and effort among them; SPECS.md's "Directory layout" section lists every key `hand` reads. -Run `hand init --setup` to discover installed harnesses and tools and write the defaults interactively. +Preferences live as plain files under `config/`, one value per file; SPECS.md's "Directory layout" section lists every key `hand` reads. + +The three worker defaults - `harness`, `model` and `effort` - are owned by `hand config`, which validates a value and writes it atomically: + +```sh +hand config # what is set, what is missing, what each harness can carry +hand config set harness claude +hand config set model claude-opus-5 +``` + +The harness comes first because it decides whether the other two exist at all: `hand config` reports `model` and `effort` as `pending-harness` until one is chosen, then as `unsupported` for a harness that takes no such launch flag, and `hand config set` refuses to write one rather than storing a value nothing can dispatch. +`model` and `effort` are stored per harness (`config/model.claude`), so switching harnesses re-asks instead of handing a worker an identifier chosen for a different tool. + +Nothing sets these for you. +`hand init` reports them as missing, every supervising session's opening document repeats the report, and the answer is yours to give in that session - the fleet home's own `AGENTS.md` carries the instructions the agent follows to ask. + A brief can declare its own `model` and `effort` for one task, which win over these defaults and lose only to a `hand spawn`/`hand promote` flag - see SPECS.md's "Brief format" section. Workers run their harness interactively so they can be steered and watched. From 637371a6708b6de774cdbec092a0f7830e28ae65 Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 08:46:11 +0700 Subject: [PATCH 7/9] docs(specs): reconcile first-run contract PR #165 made Codex model and effort capable before this rebase. Configuration tests follow the merged harness contract instead of the provisional matrix. Refs #161 --- SPECS.md | 166 +++++++++++++++++++++++++++------- cmd/config_test.go | 8 +- cmd/root_test.go | 4 +- tests/e2e/init_config_test.go | 12 +-- 4 files changed, 143 insertions(+), 47 deletions(-) diff --git a/SPECS.md b/SPECS.md index 58eeae1..bfa55a0 100644 --- a/SPECS.md +++ b/SPECS.md @@ -204,8 +204,8 @@ secondhand/ # maintainer's in-repo fleet home = repo checkout projects/ # git clones, read-only to supervisory agent config/ # local user preferences (optional) harness # default harness for workers (default: claude) - model # default model for workers (optional) - effort # default effort for workers (optional) + model. # default model for workers under that harness (optional) + effort. # default effort for workers under that harness (optional) notify # notification command template (optional) stale-threshold # seconds before a task is considered stale (default: 300) watch-interval # poll interval for `hand watch` (default: 5s) @@ -242,6 +242,19 @@ operator added to it alone. Installing is confined to a fleet home. A directory with no `state/hand.db` gets no `.claude/` directory at all. +### First-run configuration + +The generated AGENTS.md workflow settles any worker default the session overview's `config` block reports +`missing` before dispatching anything. Its first-run contract is: + +The session overview's `config` block is what this fleet dispatches with, and every value in it is the operator's to choose. + +- A setting reported `missing` is a question the supervisor asks the operator in conversation, in plain words, offering what `hand config` lists as supported and installed. It persists the answer with `hand config set `, which validates and writes atomically; it never writes under `config/` itself. +- The supervisor never answers one of these for the operator. A value it picked, or one its own harness dialog accepted, configures the fleet with a guess that afterwards looks exactly like the operator's decision. +- `hand config set` reprints the block, so the supervisor reads what it returns and keeps asking until nothing is `missing`. `hand config` re-reads it at any time. +- `unsupported` means the selected harness takes no such launch flag, so it is not a question and not a gap. `pending-harness` means applicability is unknown until the harness is chosen, so that is chosen first. +- An operator who declines to answer has answered: the supervisor leaves it missing, says so, and carries on with everything that does not need it. The next session asks again. + Why: `docs/adr/ambient-context-is-a-session-hook-not-a-file.md`. ## Output shape @@ -283,6 +296,11 @@ purpose: manages a fleet of coding agents - one worker per task in its own workt version: 0.1.4 exec: ~/.local/bin/hand home: ~/secondhand +config_missing: 1 +config[3]{key,state,value}: + harness,configured,claude + model,missing,none + effort,missing,none count: 2 attention: 1 held: 0 @@ -290,13 +308,16 @@ tasks[2]{id,state,reported,age,flags}: fix-login,working,working,2h ago,none audit-deps,done,done,20m ago,unacknowledged holds[0]{id,kind,detail,age}: -help[3]: +help[5]: + - Ask the operator for the default model for claude workers, then run `hand config set model ` + - Ask the operator for the default effort for claude workers, then run `hand config set effort ` - Run `hand status ` for one task's detail and report history - A flagged row is waiting on you: `hand send ` to steer it, `hand hold set --kind operator --reason ` to park it - Run `hand status --fields ` to pick columns, `hand status --help` for every field name ``` `exec` names the executable that answered, with the user's home abbreviated to `~`. +The configuration block precedes the fleet overview and leads `help` with one question per applicable missing value. Everything from `count` down is `hand status`'s fleet overview with its default fields, built by the same code, so the two can never disagree. Outside a fleet home the identity fields still print, `home` is `none`, and the help block names the way in: @@ -328,41 +349,108 @@ When `HAND_HOME` is set and names some other directory it still initializes the ``` hand init -hand init --setup ``` -Flags: -- `--setup`: run interactive first-time setup. Discovers available harnesses on PATH (claude, codex, pi, grok, opencode) and available tools (treehouse, herdr, no-mistakes, gh), then asks the user for the default worker harness, model, and effort and writes `config/harness`, `config/model`, and `config/effort`. +Flags: none. + +`hand init` asks nothing and reads no stdin, so it behaves the same in a terminal, in a script, and with +stdin closed. It writes no worker default either: what the fleet dispatches with is the operator's +choice, and a value invented at bootstrap time is indistinguishable afterwards from one they made. The +document reports which defaults are still missing, and the first supervising session asks for them +(see "`hand config`"). Output: ``` result: initialized -home: /path/to/secondhand +home: /path/to/fleet agents_md: written session_hook: written -harness: none -model: none -effort: none -help[3]: - - Run `hand project add ` to register the first project +migrated[0]: +config_missing: 1 +config[3]{key,state,value}: + harness,missing,none + model,pending-harness,none + effort,pending-harness,none +missing_tools[4]: + - treehouse + - herdr + - no-mistakes + - gh +help[4]: + - Start a supervising session in this home; it reports the worker defaults still missing and asks you for each one (`hand config set `) - Read AGENTS.md in this home for how a supervising agent is meant to drive it - - A Claude Code session started in this home now opens with the fleet already in context + - Run `hand project add ` to register the first project + - The session integration installed here is a Claude Code `SessionStart` hook, so a session opened with another harness reads AGENTS.md itself ``` `agents_md` and `session_hook` are each `written` or `unchanged`, so a re-run says which of the two it had to touch rather than going silent about both. -`--setup` is a dialog with whoever is at the terminal, so its discovery lines and prompts stay plain -lines (`found harnesses: ...`, `found tools: ...`, a numbered harness menu, then a prompt per value). -Only the answers reach the document, which follows the dialog with `harness: claude`, `model: sonnet` -and `effort: low` in place of the three `none`s above. Without `--setup` nothing was chosen, so all -three read `none` rather than being dropped: the schema is the same either way. +`missing_tools` lists the tools from `treehouse`, `herdr`, `no-mistakes` and `gh` that are not on PATH. +It is a diagnostic, not a gate: init reports them and succeeds, because which of them a home needs +depends on the delivery mode of projects it does not have yet. + +`migrated` names the worker defaults an older home carried unkeyed (`config/model`) that were moved under +the harness they were chosen for (`config/model.claude`); it is empty for a new home and for one already +keyed. + +The `config` block is the same one `hand config` and the bare command print (see "`hand config`"). Errors: - Filesystem permission errors. --- +### `hand config [set ]` + +Report the fleet's worker defaults and which of them are still missing, or validate and persist one. + +``` +hand config +hand config set harness claude +hand config set model claude-opus-5 +``` + +Bare output: +``` +home: /path/to/fleet +harness: none +config_missing: 1 +config[3]{key,state,value}: + harness,missing,none + model,pending-harness,none + effort,pending-harness,none +harnesses[5]{name,installed,model,effort}: + claude,true,true,true + codex,true,true,true + grok,false,false,false + pi,false,false,false + opencode,true,true,false +help[1]: + - Ask the operator which harness this fleet's workers should default to, then run `hand config set harness `; `hand config` lists the supported ones and which are installed +``` + +`set` output adds `result: set`, `key`, `value` and `file` (the repo-relative path written), then reprints +the same block, so the answer's own document carries the recheck. + +States: `configured`, `missing` (a question still owed an answer), `unsupported` (the selected harness +takes no such launch flag, so there is nothing to configure), `pending-harness` (applicability is unknown +until a harness is chosen). + +The `harnesses` block's `model` and `effort` columns are read from the harness contract the launch path +uses, never restated, so a second table can never claim a capability `hand spawn` disagrees with. + +`help` carries one line per `missing` setting and names the operator as the one who answers: a supervising +agent that resolves the question itself has configured the fleet with its own guess. + +Errors: +- Unknown key, unrecognized harness name, a value that is not a single word, or a `model`/`effort` the + configured harness cannot carry: exit 2. +- `model` or `effort` before any harness is configured: exit 3, since the setting does not apply to + anything yet. + +--- + ### `hand project add [flags]` Clone a git repository into `projects/` and register it in the store. @@ -537,8 +625,8 @@ hand spawn investigate-crash nsr --scout Flags: - `--scout`: mark as scout task (deliverable is a report, not a PR). - `--harness `: agent harness to launch. Default: value from `config/harness`, or `claude`. -- `--model `: model override for harnesses that support it. Default: the brief's declared `model`, else `config/model`. -- `--effort `: effort level for harnesses that support it. Default: the brief's declared `effort`, else `config/effort`. +- `--model `: model override for harnesses that support it. Default: the brief's declared `model`, else `config/model.`. +- `--effort `: effort level for harnesses that support it. Default: the brief's declared `effort`, else `config/effort.`. - `--skip-gate-check`: dispatch into a `no-mistakes` project even if its gate is not initialized, its clone path is missing from disk, or that path is not a git repository (see "Gate preflight"). @@ -1512,8 +1600,8 @@ hand promote investigate-crash --harness codex Flags: - `--harness `: harness for the new ship worker. Default: value from `config/harness`. -- `--model `: model override. Default: the brief's declared `model`, else `config/model`. -- `--effort `: effort override. Default: the brief's declared `effort`, else `config/effort`. +- `--model `: model override. Default: the brief's declared `model`, else `config/model.`. +- `--effort `: effort override. Default: the brief's declared `effort`, else `config/effort.`. - `--skip-gate-check`: dispatch into a `no-mistakes` project even if its gate is not initialized, its clone path is missing from disk, or that path is not a git repository (see "Gate preflight"). @@ -1590,7 +1678,7 @@ Or for macOS: osascript -e "display notification \"$HAND_MESSAGE\" with title \"secondhand\"" ``` -`hand init --setup` does not write `config/notify` - it covers `harness`, `model` and `effort` only - so a fresh fleet +`hand config` does not cover `config/notify` - it owns `harness`, `model` and `effort` only - so a fresh fleet home leaves the channel unconfigured. That absence is quiet in the watcher's hook (see "Notifying a supervisory agent with no session watching") and loud here, per the exit code below. @@ -1713,7 +1801,7 @@ A missing `AGENTS.md` is not an error: `hand doctor` reports its zero count and ### Optional: qmd for semantic search [qmd](https://github.com/tobi/qmd) adds what `hand search` deliberately does not do: semantic and hybrid search over embeddings. -It is never a dependency. `hand init --setup` does not require or configure it, nothing in `hand` reads it, and every command works without it. +It is never a dependency. `hand init` and `hand config` do not require or configure it, nothing in `hand` reads it, and every command works without it. `generatedBody` names `qmd search` alongside `hand search` as a way to find historical context, and README carries the indexing commands. ## Harness launch templates @@ -2072,7 +2160,7 @@ Both keys are optional, and the block is only recognized when `---` is the brief line. `hand spawn` and `hand promote` read it (`internal/brief`) and resolve model and effort as flag, then declaration, then config default, then unset. The brief is the durable statement of scope, so a respawn or a promote picks the declaration up again instead of falling back to -`config/model`. +`config/model.`. The parser is deliberately forgiving, unlike `data/projects.md`'s registry parser: unknown keys inside the block are ignored, a value written as a YAML quoted scalar (`model: "claude-opus-5"`) @@ -2416,25 +2504,33 @@ Why: `docs/adr/one-stateful-fake-per-external-tool.md`. **Pre-built binary (recommended):** ```sh -# installer script (detects OS/arch, downloads from GitHub Releases) -curl -fsSL https://secondhand.dev/install.sh | bash - -# or via Go -go install github.com/atqamz/secondhand@latest +# release tarball for this OS/arch, from GitHub Releases +curl -fsSLO https://github.com/atqamz/secondhand/releases/latest/download/hand-linux-amd64.tar.gz +tar xzf hand-linux-amd64.tar.gz +install -m755 hand ~/.local/bin/hand # or via Nix nix profile install github:atqamz/secondhand -# or one-shot: nix shell github:atqamz/secondhand -c hand init --setup +# or one-shot: nix shell github:atqamz/secondhand -c hand --version + +# or via Go: builds a binary named secondhand, with no embedded version +go install github.com/atqamz/secondhand@latest ``` +Releases carry `hand-linux-amd64`, `hand-linux-arm64`, `hand-darwin-amd64` and `hand-darwin-arm64` as `.tar.gz`, alongside `checksums.txt`. +The flake covers `aarch64-darwin`, `aarch64-linux` and `x86_64-linux`; on Intel macOS use a release binary or `go install`. +`go install` names the binary after the module (`secondhand`) and embeds no version, so it reports `dev` and never sees an available update. + **From source (contributors):** ```sh git clone https://github.com/atqamz/secondhand cd secondhand -go build -o hand . +make build # optionally: cp hand ~/.local/bin/ ``` +`make build` rather than `go build -o hand .`: the Makefile is what CONTRIBUTING.md documents and what carries the `VERSION` ldflag. + The source repo is a development repo. End users install the binary and create fleet homes anywhere. ### Fleet home creation @@ -2442,15 +2538,17 @@ The source repo is a development repo. End users install the binary and create f ```sh # create a fleet home anywhere mkdir ~/fleet && cd ~/fleet -hand init --setup +hand init # or in one shot -hand init ~/fleet --setup +hand init ~/fleet ``` `hand init` writes the runtime dirs (`data/`, `state/`, `config/`, `projects/`), creates whichever of the `data/` skeleton files are missing (see "Directory layout"), and creates `state/hand.db` if it is not already there. It also writes the generated AGENTS.md template and its CLAUDE.md symlink (`internal/agentsmd`'s `generatedBody` is the template). Other existing files are left unchanged, and an optional target path is accepted. +`hand init` chooses no worker default. Open a supervising session in the home and answer the questions +its opening document asks (see "`hand config`"). ### Self-update: `hand update` diff --git a/cmd/config_test.go b/cmd/config_test.go index 894c047..68b1c45 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -100,7 +100,7 @@ func TestConfigApplicabilityFollowsTheHarnessContract(t *testing.T) { }{ {harness.Claude, stateMissing, stateMissing}, {harness.OpenCode, stateMissing, stateUnsupported}, - {harness.Codex, stateUnsupported, stateUnsupported}, + {harness.Codex, stateMissing, stateMissing}, {harness.Grok, stateUnsupported, stateUnsupported}, {harness.Pi, stateUnsupported, stateUnsupported}, } { @@ -174,11 +174,9 @@ func TestConfigSetRefusesModelBeforeAHarnessIsChosen(t *testing.T) { } } -// Codex takes no model and no effort flag, so neither is a value to hold: writing one anyway produces a -// configured-looking fleet whose defaults can only ever warn at dispatch. func TestConfigSetRefusesWhatTheHarnessCannotCarry(t *testing.T) { home := setupConfigHome(t) - mustConfigSet(t, settingHarness, harness.Codex) + mustConfigSet(t, settingHarness, harness.Grok) for _, key := range []string{settingModel, settingEffort} { _, err := runConfigSet(t, key, "whatever") @@ -189,7 +187,7 @@ func TestConfigSetRefusesWhatTheHarnessCannotCarry(t *testing.T) { } for _, e := range entries { if strings.HasPrefix(e.Name(), key) { - t.Fatalf("%s was written for codex, which cannot use it", e.Name()) + t.Fatalf("%s was written for grok, which cannot use it", e.Name()) } } } diff --git a/cmd/root_test.go b/cmd/root_test.go index a158ecb..8f97d02 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -165,13 +165,13 @@ func TestBareInvocationReportsConfigurationStateAndAsksTheOperator(t *testing.T) } } - if _, err := runConfigSet(t, settingHarness, "codex"); err != nil { + if _, err := runConfigSet(t, settingHarness, "grok"); err != nil { t.Fatal(err) } out = runBareRoot(t) for _, want := range []string{ "config_missing: 0\n", - "harness,configured,codex", + "harness,configured,grok", "model,unsupported,none", "effort,unsupported,none", } { diff --git a/tests/e2e/init_config_test.go b/tests/e2e/init_config_test.go index 70da841..c7de8ba 100644 --- a/tests/e2e/init_config_test.go +++ b/tests/e2e/init_config_test.go @@ -120,23 +120,23 @@ func TestFirstRunConfigurationHappensAfterBootstrap(t *testing.T) { } } - handConfigSet(t, home, "harness", "codex") + handConfigSet(t, home, "harness", "grok") after := runHand(t, home) - for _, want := range []string{"config_missing: 0", "harness,configured,codex", "model,unsupported,none", "effort,unsupported,none"} { + for _, want := range []string{"config_missing: 0", "harness,configured,grok", "model,unsupported,none", "effort,unsupported,none"} { if !strings.Contains(after.stdout, want) { t.Fatalf("session document = %q, want it to contain %q", after.stdout, want) } } if strings.Contains(after.stdout, "Ask the operator") { - t.Fatalf("session document = %q, want nothing left to ask about under codex", after.stdout) + t.Fatalf("session document = %q, want nothing left to ask about under grok", after.stdout) } refused := runHand(t, home, "config", "set", "model", "gpt-5") if refused.code != 2 { - t.Fatalf("config set model under codex: exit %d, want 2", refused.code) + t.Fatalf("config set model under grok: exit %d, want 2", refused.code) } - if _, err := os.Stat(filepath.Join(home, "config", "model.codex")); err == nil { - t.Fatal("config/model.codex was written for a harness that takes no model flag") + if _, err := os.Stat(filepath.Join(home, "config", "model.grok")); err == nil { + t.Fatal("config/model.grok was written for a harness that takes no model flag") } } From 17fcd9ededa5bd62056083347c552ba13622f6c9 Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 08:57:35 +0700 Subject: [PATCH 8/9] no-mistakes(review): Migrate legacy worker defaults at command startup --- cmd/config_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 16 ++++++++++------ cmd/update.go | 11 +---------- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/cmd/config_test.go b/cmd/config_test.go index 68b1c45..8425ea3 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -317,3 +317,45 @@ func TestMigrateWorkerSettingsKeysToClaudeWhenNoHarnessIsConfigured(t *testing.T t.Fatalf("config/model.claude = %q", got) } } + +func TestCommandStartupMigratesOlderWorkerDefaultsBeforeReportingConfig(t *testing.T) { + home := setupConfigHome(t) + if err := os.MkdirAll(filepath.Join(home, "config"), 0o755); err != nil { + t.Fatal(err) + } + for name, value := range map[string]string{ + "harness": harness.Claude, + "model": "claude-sonnet-5", + "effort": "high", + } { + if err := os.WriteFile(filepath.Join(home, "config", name), []byte(value+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + root := newRootCmd("test") + root.SetArgs([]string{"config"}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if _, err := root.ExecuteC(); err != nil { + t.Fatal(err) + } + + for _, want := range []string{ + "model,configured,claude-sonnet-5", + "effort,configured,high", + } { + if !strings.Contains(out.String(), want) { + t.Fatalf("config output = %q, want it to contain %q", out.String(), want) + } + } + for _, name := range []string{"model", "effort"} { + if _, err := os.Stat(filepath.Join(home, "config", name+".claude")); err != nil { + t.Fatalf("migrated config/%s.claude: %v", name, err) + } + if _, err := os.Stat(filepath.Join(home, "config", name)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("legacy config/%s still exists: %v", name, err) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index 5023546..4d29bdf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,12 +20,16 @@ func newRootCmd(version string) *cobra.Command { Short: "Talk to one agent. Ship with a crew.", Version: version, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if cmd.Name() == "update" { - return nil - } - if home, err := home.Resolve(); err == nil { - if notice := selfupdate.CheckNotice(home, selfupdate.Repo, version); notice != "" { - _, _ = fmt.Fprintln(cmd.ErrOrStderr(), notice) + if fleetHome, err := home.Resolve(); err == nil { + if cmd.Name() != "init" { + if _, err := migrateWorkerSettings(fleetHome); err != nil { + return err + } + } + if cmd.Name() != "update" { + if notice := selfupdate.CheckNotice(fleetHome, selfupdate.Repo, version); notice != "" { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), notice) + } } } return nil diff --git a/cmd/update.go b/cmd/update.go index f5837ad..b1062be 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -54,7 +54,7 @@ func newUpdateCmd(version string) *cobra.Command { // reported as a warning rather than an error: exiting nonzero here reads as "the update failed" and // invites a pointless re-run. var refreshed, hooked bool - var seedErr, hookErr, migrateErr error + var seedErr, hookErr error fleetHome, refreshErr := home.Resolve() switch { case refreshErr == nil: @@ -63,9 +63,6 @@ func newUpdateCmd(version string) *cobra.Command { // that installs it also leaves those files in place - directories included, since a home resolves // as one on its state/hand.db marker alone. seedErr = initLayout(fleetHome) - // An older home's unkeyed worker defaults would otherwise stop being read at all by the binary - // this command just installed. - _, migrateErr = migrateWorkerSettings(fleetHome) // An install that moved leaves the session hook pointing at a // path with no binary behind it any more. var exe string @@ -91,12 +88,6 @@ func newUpdateCmd(version string) *cobra.Command { return err } } - if migrateErr != nil { - if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "warning: key worker defaults by harness: %v\n", migrateErr); err != nil { - return err - } - } - notes, _ := selfupdate.ReleaseNotes(selfupdate.Repo, latest) var doc axi.Doc From 9a4cd9a71cc46061f38395875d708423c0f28a97 Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 09:09:09 +0700 Subject: [PATCH 9/9] no-mistakes(document): Align Codex and init documentation --- README.md | 2 +- SPECS.md | 42 +++++++++++++++++++----------------------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 3463d9a..6dd17d0 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ The harness comes first because it decides whether the other two exist at all: ` `model` and `effort` are stored per harness (`config/model.claude`), so switching harnesses re-asks instead of handing a worker an identifier chosen for a different tool. Nothing sets these for you. -`hand init` reports them as missing, every supervising session's opening document repeats the report, and the answer is yours to give in that session - the fleet home's own `AGENTS.md` carries the instructions the agent follows to ask. +`hand init` reports their state, every supervising session's opening document repeats the report, and the answer is yours to give in that session - the fleet home's own `AGENTS.md` carries the instructions the agent follows to ask. A brief can declare its own `model` and `effort` for one task, which win over these defaults and lose only to a `hand spawn`/`hand promote` flag - see SPECS.md's "Brief format" section. diff --git a/SPECS.md b/SPECS.md index bfa55a0..cc0f53e 100644 --- a/SPECS.md +++ b/SPECS.md @@ -335,7 +335,7 @@ help[2]: Exit `0`, unlike every other command's `3` for an unresolvable home. -### `hand init [path] [flags]` +### `hand init [path]` Initialize secondhand runtime directories in the current working directory. Creates `state/`, `data/`, `projects/`, `config/` if they don't exist. @@ -637,12 +637,12 @@ Anything the chosen harness cannot carry is a warning on stderr, not a failure: with a resolved model or effort recorded in state and ignored by the launch command. Everything a launch drops is named on one line rather than one line each. What can be dropped: -- a resolved effort under anything but claude (`harness.SupportsEffort`) -- a resolved model under `codex`, `grok` or `pi` (`harness.SupportsModel`) +- a resolved effort under `grok`, `pi` or `opencode` (`harness.SupportsEffort`) +- a resolved model under `grok` or `pi` (`harness.SupportsModel`) - the operator-decision rule, and the front-matter disclaimer when the brief has front matter, - under `codex`, `grok` or `pi` (`harness.CarriesPrompt`, see "Harness launch templates") + under `grok` or `pi` (`harness.CarriesPrompt`, see "Harness launch templates") -The line reads `warning: harness "codex" cannot carry model "opus", effort "high", the +The line reads `warning: harness "grok" cannot carry model "opus", effort "high", the operator-decision rule, the front-matter disclaimer; launching anyway`, listing only what that launch actually drops. @@ -1827,8 +1827,8 @@ cd && CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously The brief path is included in the prompt because Claude Code takes prompt text, not a file path. `` is `agentsmd.OperatorDecisionRule` verbatim, one exported constant rather than two copies that drift, appended to every prompt-carrying template because the worktree is outside the fleet home and the worker never reads the home's `AGENTS.md`. When configured, `--model ` and `--effort ` are inserted before the prompt. -Claude is the only harness with an effort flag: `opencode` takes `--model` but no effort, and -`codex`, `grok` and `pi` take neither (`harness.SupportsEffort`, `harness.SupportsModel`). +Claude and Codex take both model and effort; OpenCode takes a model but no effort; Grok and Pi take +neither (`harness.SupportsEffort`, `harness.SupportsModel`). A declared value a harness has no flag for is warned about on stderr rather than dropped in silence (see `hand spawn`). When the brief carries a `---` declaration, the prompt gains a sentence disclaiming it as dispatch @@ -1869,12 +1869,12 @@ the reason the catalogue matters for every harness added rather than only for cl ### Codex ```sh -cd && codex --file "" +cd && codex --dangerously-bypass-approvals-and-sandbox -c 'disable_paste_burst=true' --model -c 'model_reasoning_effort=""' "Read the brief at and carry out the task it describes. " ``` -Unverified: no `codex` binary was available to check `--help` against. -Confirm this launches interactively (not one-shot) before relying on it; the template above -predates that requirement and may need an autonomy flag and a different invocation shape. +The template was verified against Codex CLI 0.146.0. `--model` and the reasoning-effort override +are omitted when unset; effort `auto` also omits the override so Codex inherits its own default. +Disabling paste-burst buffering keeps an immediate Enter from `hand send` from being absorbed. ### Grok @@ -1882,16 +1882,12 @@ predates that requirement and may need an autonomy flag and a different invocati cd && grok --trust --file "" ``` -Unverified, same caveat as Codex above. - ### Pi ```sh cd && pi "" ``` -Unverified, same caveat as Codex above. - ### OpenCode ```sh @@ -1907,11 +1903,11 @@ When configured, `--model ` is inserted; the bare command has no effort/va The bare command also has no `--file` flag, so the brief path is embedded in the prompt text instead of attached. -The Claude and OpenCode forms above were verified against the installed CLI versions. -Codex, Grok, and Pi retain unverified templates until those binaries are installable; whoever +The Claude, Codex and OpenCode forms above were verified against the installed CLI versions. +Grok and Pi retain unverified templates until those binaries are installable; whoever verifies them must confirm interactive (not headless) launch, not just flag names. `internal/harness` is the single place that constructs these commands. -A template that hands the brief over as a file rather than as prompt text (Codex, Grok, Pi) has no +A template that hands the brief over as a file rather than as prompt text (Grok, Pi) has no prompt to append to, so `agentsmd.OperatorDecisionRule` and the front-matter disclaimer never reach those workers: the brief is all they read. `harness.CarriesPrompt` reports this, and `hand spawn` warns on stderr rather than dropping it in silence. @@ -2170,13 +2166,13 @@ are not validated against a list, which would rot the first time a model ships. The declaration is dispatch metadata, not task content. The launch prompt gains one sentence marking the block's `model` and `effort` keys as such when a block is present; anything else the block -carries is left to the worker to read, and the brief on disk is never rewritten or stripped. Only -the prompt-bearing harnesses carry that sentence: `codex`, `grok` and `pi` are handed the brief as a -file with no prompt at all, so a declaring brief reaches them undisclaimed. +carries is left to the worker to read, and the brief on disk is never rewritten or stripped. `grok` +and `pi` are handed the brief as a file with no prompt at all, so a declaring brief reaches them +undisclaimed; every other harness carries the sentence in its launch prompt. A declared effort under a harness that cannot apply one warns on stderr, as does a declared model -under `codex`, `grok` or `pi`, and so does the operator-decision rule and the front-matter -disclaimer those same three cannot carry. Whatever a given launch drops is named on one combined +under `grok` or `pi`, and so does the operator-decision rule and the front-matter disclaimer those +same two cannot carry. Whatever a given launch drops is named on one combined line, never one line per dropped value (see `hand spawn`). ## Backlog format