diff --git a/cmd/gc/api_state.go b/cmd/gc/api_state.go index 0931b26ca..9a15a7354 100644 --- a/cmd/gc/api_state.go +++ b/cmd/gc/api_state.go @@ -64,6 +64,11 @@ type controllerState struct { var controllerStateInitRigDirIfReady = initDirIfReady +// newControllerStateOpenCityStore opens the city-level bead store for +// newControllerState. Test code can swap this to return an in-memory store +// and skip spawning managed dolt (~12s per call). +var newControllerStateOpenCityStore = openCityStoreAt + type configMutationSnapshot struct { cityPath string files map[string][]byte @@ -98,7 +103,7 @@ func newControllerState( } cs.beadStores = cs.buildStores(cfg) // Open city-level store for session beads and mail (best-effort). - if store, err := openCityStoreAt(cityPath); err != nil { + if store, err := newControllerStateOpenCityStore(cityPath); err != nil { fmt.Fprintf(os.Stderr, "api: city bead store: %v (session/mail endpoints disabled)\n", err) } else { cs.cityBeadStore = wrapWithCachingStore(ctx, store, ep) diff --git a/cmd/gc/city_runtime.go b/cmd/gc/city_runtime.go index c3078c4ed..b97773e6b 100644 --- a/cmd/gc/city_runtime.go +++ b/cmd/gc/city_runtime.go @@ -27,6 +27,11 @@ import ( "github.com/gastownhall/gascity/internal/workspacesvc" ) +// newCityRuntimeOpenSweepStore opens the city store used for the orphaned +// order-tracking sweep in newCityRuntime. Test code can swap this to return +// an in-memory store and skip spawning managed dolt. +var newCityRuntimeOpenSweepStore = openStoreAtForCity + // reloadOrderDrainTimeout bounds how long config reload will wait for // the outgoing order dispatcher's in-flight goroutines before replacing // it. Reload runs on the tick loop, so a larger budget would stall all @@ -210,7 +215,7 @@ func newCityRuntime(p CityRuntimeParams) *CityRuntime { // (goroutines killed on restart, or silent Close failures). // Retry with backoff as defense-in-depth against transient store // errors immediately after ensureBeadsProvider returns (#753). - if sweepStore, err := openStoreAtForCity(p.CityPath, p.CityPath); err != nil { + if sweepStore, err := newCityRuntimeOpenSweepStore(p.CityPath, p.CityPath); err != nil { fmt.Fprintf(p.Stderr, "gc start: order tracking sweep: %v\n", err) //nolint:errcheck // best-effort stderr } else if n, err := sweepOrphanedOrderTrackingRetry(sweepStore, 3, time.Second); err != nil { fmt.Fprintf(p.Stderr, "gc start: order tracking sweep (closed %d): %v\n", n, err) //nolint:errcheck // best-effort stderr diff --git a/cmd/gc/city_runtime_test.go b/cmd/gc/city_runtime_test.go index 4c8f598e1..873d22357 100644 --- a/cmd/gc/city_runtime_test.go +++ b/cmd/gc/city_runtime_test.go @@ -71,6 +71,31 @@ func TestSweepUndesiredPoolSessionBeads_KeepsRunningSessionsOpen(t *testing.T) { } } +// stubManagedDoltStoreOpeners replaces the two package-level store openers +// used during newCityRuntime + newControllerState startup with in-memory +// stubs. This prevents tests from spawning real managed dolt servers (~12s +// each). The original openers are restored via t.Cleanup. +// +// Tests that verify the managed-dolt preflight ordering invariant still +// install their own fake managedDoltHealth/Owned/Port hooks to record events; +// this helper only handles the side-effects from sweepStore / city store +// opening which otherwise force a real dolt spawn. +func stubManagedDoltStoreOpeners(t *testing.T) { + t.Helper() + prevCityStore := newControllerStateOpenCityStore + prevSweepStore := newCityRuntimeOpenSweepStore + newControllerStateOpenCityStore = func(string) (beads.Store, error) { + return beads.NewMemStore(), nil + } + newCityRuntimeOpenSweepStore = func(string, string) (beads.Store, error) { + return beads.NewMemStore(), nil + } + t.Cleanup(func() { + newControllerStateOpenCityStore = prevCityStore + newCityRuntimeOpenSweepStore = prevSweepStore + }) +} + // newTestCityRuntime builds a CityRuntime and registers a cleanup that // cancels in-flight dispatched orders before invoking shutdown. Do NOT // add a duplicate t.Cleanup(cr.shutdown) in callers — t.Cleanup is LIFO, @@ -413,6 +438,10 @@ func TestCityRuntimeEnsureManagedDoltPublishedForTickLogsOwnershipError(t *testi func TestCityRuntimeTickPreflightsManagedDoltBeforeSessionSnapshot(t *testing.T) { t.Setenv("GC_BEADS", "bd") + stubManagedDoltStoreOpeners(t) + + cityPath := t.TempDir() + cleanupManagedDoltTestCity(t, cityPath) orderEvents := &orderedRuntimeEvents{} store := &managedDoltPreflightOrderStore{ @@ -421,7 +450,7 @@ func TestCityRuntimeTickPreflightsManagedDoltBeforeSessionSnapshot(t *testing.T) } sp := runtime.NewFake() cr := &CityRuntime{ - cityPath: t.TempDir(), + cityPath: cityPath, cityName: "test-city", cfg: &config.City{}, sp: sp, @@ -466,6 +495,7 @@ func TestCityRuntimeRunStartupPreflightsManagedDoltBeforeSessionSnapshot(t *test tomlPath := filepath.Join(cityPath, "city.toml") writeCityRuntimeConfig(t, tomlPath, "fake") t.Setenv("GC_BEADS", "bd") + stubManagedDoltStoreOpeners(t) cleanupManagedDoltTestCity(t, cityPath) cfg, err := config.Load(osFS{}, tomlPath) @@ -572,9 +602,11 @@ func TestCityRuntimeControlDispatcherPreflightsManagedDoltBeforeSessionSnapshot( func TestNewCityRuntimePreflightsManagedDoltPublicationBeforeStartupStoreWork(t *testing.T) { t.Setenv("GC_BEADS", "bd") + stubManagedDoltStoreOpeners(t) healthCalls := 0 cityPath := t.TempDir() + cleanupManagedDoltTestCity(t, cityPath) sp := runtime.NewFake() _ = newCityRuntime(CityRuntimeParams{ CityPath: cityPath, diff --git a/cmd/gc/cmd_gendoc_test.go b/cmd/gc/cmd_gendoc_test.go index de67c0de6..b6a7422c3 100644 --- a/cmd/gc/cmd_gendoc_test.go +++ b/cmd/gc/cmd_gendoc_test.go @@ -2,10 +2,14 @@ package main import ( "bytes" + "os" + "path/filepath" + "runtime" "strings" "testing" "github.com/gastownhall/gascity/internal/docgen" + "github.com/spf13/cobra" ) func TestGenDocProducesMarkdown(t *testing.T) { @@ -44,3 +48,46 @@ func TestGenDocProducesMarkdown(t *testing.T) { t.Error("missing auto-generated note") } } + +// TestCLIDocsFreshness verifies every non-hidden command in the live cobra +// tree has a section in docs/reference/cli.md. Catches "added or renamed a +// command without running go run ./cmd/genschema". Avoids strict byte-equal +// comparison because cobra lazily registers `completion`/`help` only on +// Execute, which the in-test render path does not trigger. +func TestCLIDocsFreshness(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Join(filepath.Dir(thisFile), "..", "..") + + committedPath := filepath.Join(repoRoot, "docs", "reference", "cli.md") + committed, err := os.ReadFile(committedPath) + if err != nil { + t.Fatalf("reading %s: %v\nRun: go run ./cmd/genschema", committedPath, err) + } + doc := string(committed) + + var buf bytes.Buffer + root := newRootCmd(&buf, &buf) + + var missing []string + var walk func(cmd *cobra.Command) + walk = func(cmd *cobra.Command) { + if cmd.Hidden || cmd.Annotations["gc.docgen.skip"] == "true" { + return + } + heading := "## " + cmd.CommandPath() + "\n" + if !strings.Contains(doc, heading) { + missing = append(missing, cmd.CommandPath()) + } + for _, c := range cmd.Commands() { + walk(c) + } + } + walk(root) + + if len(missing) > 0 { + t.Errorf("docs/reference/cli.md is stale — missing sections for %d commands. Run: go run ./cmd/genschema\nMissing: %v", len(missing), missing) + } +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d83402b13..8eea9eae2 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -46,6 +46,7 @@ gc [flags] | [gc order](#gc-order) | Manage orders (scheduled and event-driven dispatch) | | [gc pack](#gc-pack) | Manage remote pack sources | | [gc prime](#gc-prime) | Output the behavioral prompt for an agent | +| [gc prompt](#gc-prompt) | Author and inspect agent prompt templates | | [gc register](#gc-register) | Register a city with the machine-wide supervisor | | [gc reload](#gc-reload) | Reload the current city's config without restarting the city/controller | | [gc restart](#gc-restart) | Restart all agent sessions in the city | @@ -1788,6 +1789,95 @@ gc prime [agent-name] [flags] | `--hook-format` | string | | format hook output for a provider | | `--strict` | bool | | fail on missing city, missing or unknown agent, or unreadable prompt_template instead of falling back to the default prompt | +## gc prompt + +Subcommands for authoring agent prompt templates. + +Currently the only subcommand is 'synth', which invokes the configured +provider in one-shot mode to generate a prompt template for a given role. + +``` +gc prompt +``` + +| Subcommand | Description | +|------------|-------------| +| [gc prompt synth](#gc-prompt-synth) | Generate an agent prompt template by invoking the LLM | + +## gc prompt synth + +Renders a meta-prompt with the given parameters, invokes the configured +provider in one-shot mode, and emits the generated prompt template. + +The default behavior prints the generated prompt to stdout. Pass --write +to save it directly to <city>/agents/<role>/prompt.template.md (use --force +to overwrite an existing file). + +Context type is determined by --rig: + + (no --rig) City context. The agent is HQ-only and operates at + the city level (e.g. mayor, deacon). The meta-prompt + emphasizes coordination, dispatch, monitoring. + --rig <name> Rig context. The agent is attached to the named rig + (looked up in city.toml). The meta-prompt includes + the rig path, default branch, and project-aware + guidance (git operations, branch management, etc.). + +Auto-detection: + --provider defaults to workspace.provider in city.toml + +Baseline: + The synth pulls in an existing prompt template as a refinement + baseline so the LLM iterates on a known-good shape rather than + designing from scratch. Resolution priority: + 1. <city>/agents/<role>/prompt.template.md (user customization) + 2. <city>/.gc/system/packs/*/agents/<role>/ (pack default) + 3. embedded prompts/<role>.md (built-in fallback) + 4. embedded prompts/mayor.md (structural reference, + used only when no + role-specific source + exists) + +Two execution modes: + + --writer-agent "" Direct mode (default). Spawns a one-shot + subprocess of the configured provider; no + Gas City agent is involved. Useful for + bootstrap and offline-friendly invocations. + + --writer-agent <name> Slingued mode. Creates a bead and slings the + synth as work to the named agent via the + mol-prompt-synth formula; the agent's + session reads the meta-prompt, generates the + prompt, and writes it to the destination. + + Async by default — the CLI prints the bead + ID + destination and returns immediately; + use 'gc bd show <id>' to track progress. + Pass --wait to block until the agent closes + the bead (or --wait-timeout fires). + +The output is LLM-generated. Review it carefully before relying on it. +When --write is used, a comment header records the inputs and generation +date for traceability. + +``` +gc prompt synth [flags] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--city` | string | | city path (default: auto-resolve) | +| `--force` | bool | | with --write, overwrite the destination if it exists | +| `--meta-prompt` | string | | override the embedded meta-prompt with a file path | +| `--provider` | string | | target AI provider key (default: city.toml workspace.provider) | +| `--rig` | string | | rig name from city.toml (default: empty = city/HQ context, no rig) | +| `--role` | string | | agent role to design (required, e.g. mayor, polecat, witness) | +| `--wait` | bool | | in slingued mode, block until the agent closes the bead | +| `--wait-timeout` | duration | `10m0s` | in slingued mode with --wait, abort after this duration | +| `--write` | bool | | write to <city>/agents/<role>/prompt.template.md instead of stdout (direct mode only; slingued mode always writes) | +| `--writer-agent` | string | | Gas City agent to delegate the synth to via mol-prompt-synth (default: empty = direct mode, no agent) | + ## gc register Register a city directory with the machine-wide supervisor. @@ -1818,16 +1908,15 @@ Reload may fetch configured remote packs before recomputing effective config. By default, per-session restarts may still happen if normal config drift rules require them. -With `--soft`, the controller accepts any detected per-session config +With --soft, the controller accepts any detected per-session config drift instead of draining the drifted sessions: each open session's -recorded config hash is updated to the hash the freshly reloaded config -produces for it, so the immediately-following reconcile tick sees no -drift and no config-drift drains fire. Useful when editing a running -city's `.gc/settings.json` without disrupting in-flight work. Sessions -whose template no longer maps to a configured agent are NOT updated; -normal orphan/suspended drain handles them on the next tick. See -[Soft Reload](../guides/gc-reload-design.md#soft-reload) for the full -semantics and when to use it. +recorded config hash is updated to the hash the freshly reloaded +config produces for it, so the immediately-following reconcile tick +sees no drift and no config-drift drains fire. Useful when editing a +running city's .gc/settings.json without disrupting in-flight work. +Sessions whose template no longer maps to a configured agent are +NOT updated; normal orphan/suspended drain handles them on the next +tick. ``` gc reload [path] [flags]