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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/gc/api_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion cmd/gc/city_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion cmd/gc/city_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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{
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions cmd/gc/cmd_gendoc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
107 changes: 98 additions & 9 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
Loading