Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
3a3bab4
docs/config: finish packv2 prompt and agent-defaults cleanup
julianknutsen Apr 18, 2026
1bf1c8e
config: wire pack agent-defaults through imported packs
julianknutsen Apr 18, 2026
e71661e
cmd: preserve imported pack defaults in pool clones
julianknutsen Apr 18, 2026
c715052
cmd: align import and prime pack defaults behavior
julianknutsen Apr 18, 2026
fd4dbb1
config: preserve nested pack defaults and emit warnings
julianknutsen Apr 18, 2026
909f88b
pack: scope defaults to includes and preserve import tombstones
julianknutsen Apr 18, 2026
719fd5f
pack: stop inherited defaults at import boundaries
julianknutsen Apr 18, 2026
bff7df9
import: harden warning guidance and defaults parity
julianknutsen Apr 18, 2026
8a45f07
fix: harden pack defaults alias cleanup
julianknutsen Apr 18, 2026
e3cd351
fix: align mixed agent defaults alias semantics
julianknutsen Apr 18, 2026
f5bf78a
fix: preserve unsupported import defaults
julianknutsen Apr 18, 2026
ba20357
fix: narrow migration warning emission
julianknutsen Apr 18, 2026
2582588
fix: keep legacy migration warnings visible
julianknutsen Apr 18, 2026
d5d2d61
fix: unify migration warning behavior
julianknutsen Apr 18, 2026
c7c87d7
fix: align direct config warning paths
julianknutsen Apr 18, 2026
ffe8862
fix: align direct config warning paths
julianknutsen Apr 18, 2026
23ca96d
fix: thread warning writers through command helpers
julianknutsen Apr 19, 2026
879ff41
fix: route mail config warnings through command stderr
julianknutsen Apr 19, 2026
2f6e288
test: silence unused install mode in import upgrade mock
julianknutsen Apr 19, 2026
1933577
fix: allow pack descriptions in strict config validation
julianknutsen Apr 19, 2026
f3d8ee4
fix: suppress repeated supervisor config warnings
julianknutsen Apr 19, 2026
4d9b78b
fix: harden config warning routing
julianknutsen Apr 19, 2026
4b6a49f
fix: keep migration warnings out of strict startup
julianknutsen Apr 19, 2026
d558331
fix: route attachment deprecations through provenance
julianknutsen Apr 19, 2026
1d4470c
fix: keep mixed-table config warnings strict-fatal
julianknutsen Apr 19, 2026
9321d7a
fix: emit config warnings in setup reloads
julianknutsen Apr 19, 2026
0e1f746
fix: keep transitive pack warnings scoped
julianknutsen Apr 19, 2026
45e966d
fix: block transitive named sessions from imports
julianknutsen Apr 19, 2026
10e7e00
fix: only warn on overlapping agent-default aliases
julianknutsen Apr 19, 2026
55d7012
docs: refresh generated packv2 defaults reference
julianknutsen Apr 19, 2026
e999bf3
fix: tighten pack transitive and warning contracts
julianknutsen Apr 19, 2026
ffdb559
fix: filter nested rig imports when transitive is off
julianknutsen Apr 19, 2026
4b1e6f4
fix: keep dashboard context warning writer optional
julianknutsen Apr 19, 2026
4b95c83
fix: block nested artifacts from non-transitive imports
julianknutsen Apr 19, 2026
05edca8
docs: add v0.15.0 rollout caveat banners
julianknutsen Apr 19, 2026
7d58fe0
fix: thread warning writers through rebased config loads
julianknutsen Apr 19, 2026
c6a04ab
fix: keep init pack schema mirrors in sync
julianknutsen Apr 19, 2026
fcff393
fix: keep gc init pack output stable
julianknutsen Apr 19, 2026
019754d
fix rest-full integration regressions
julianknutsen Apr 19, 2026
3954971
fix rest-full follow-up test failures
julianknutsen Apr 19, 2026
f60b3e4
fix huma integration dolt identity setup
julianknutsen Apr 19, 2026
c219048
fix huma integration env parity
julianknutsen Apr 19, 2026
d688437
fix: preserve pack validation during warning migration
julianknutsen Apr 19, 2026
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
2 changes: 1 addition & 1 deletion cmd/gc/agent_build_params.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type agentBuildParams struct {
packOverlayDirs []string
rigOverlayDirs map[string][]string
globalFragments []string
appendFragments []string // V2: from [agents].append_fragments / [agent_defaults].append_fragments
appendFragments []string // V2: city-level [agents].append_fragments / [agent_defaults].append_fragments
stderr io.Writer

// beadStore is the city-level bead store for session bead lookups.
Expand Down
3 changes: 2 additions & 1 deletion cmd/gc/apiroute.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"fmt"
"io"
"net"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -62,7 +63,7 @@ func standaloneControllerCityName(cfg *config.City, cityPath string) string {
// the API server can find the agent. If already qualified or resolution
// fails, the original name is returned.
func resolveAgentForAPI(cityPath, name string) string {
cfg, err := loadCityConfig(cityPath)
cfg, err := loadCityConfig(cityPath, io.Discard)
if err != nil {
return name
}
Expand Down
6 changes: 3 additions & 3 deletions cmd/gc/beads_provider_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func desiredScopeDoltConfigStateForInit(cityPath, dir, prefix string) (contract.
return contract.ConfigState{}, false, nil
}
cityDolt := config.DoltConfig{}
if cfg, err := loadCityConfig(cityPath); err == nil {
if cfg, err := loadCityConfig(cityPath, io.Discard); err == nil {
resolveRigPaths(cityPath, cfg.Rigs)
cityPrefix := config.EffectiveHQPrefix(cfg)
cityDolt = cfg.Dolt
Expand Down Expand Up @@ -499,7 +499,7 @@ func forcedScopeDoltConfigStateForInit(cityPath, dir, prefix string) (contract.C
return contract.ConfigState{}, false, nil
}
cityDolt := config.DoltConfig{}
if cfg, err := loadCityConfig(cityPath); err == nil {
if cfg, err := loadCityConfig(cityPath, io.Discard); err == nil {
resolveRigPaths(cityPath, cfg.Rigs)
cityState := desiredCityDoltConfigState(cityPath, cfg.Dolt, config.EffectiveHQPrefix(cfg))
if samePath(cityPath, dir) {
Expand Down Expand Up @@ -581,7 +581,7 @@ func waitForAllBeadsScopesReadyAfterRecovery(cityPath string, timeout time.Durat
// migrated rigs (rig.path only in .gc/site.toml) are still waited
// for. A raw config.Load here would silently skip every migrated
// rig — the site binding wouldn't populate rig.Path.
cfg, err := loadCityConfig(cityPath)
cfg, err := loadCityConfig(cityPath, io.Discard)
if err != nil {
return nil
}
Expand Down
148 changes: 148 additions & 0 deletions cmd/gc/build_desired_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/gastownhall/gascity/internal/beads"
"github.com/gastownhall/gascity/internal/beads/contract"
"github.com/gastownhall/gascity/internal/config"
"github.com/gastownhall/gascity/internal/fsys"
"github.com/gastownhall/gascity/internal/runtime"
)

Expand Down Expand Up @@ -243,6 +244,153 @@ func TestBuildDesiredState_InstallsGeminiHooksBeforeFingerprinting(t *testing.T)
}
}

func TestBuildDesiredState_IncludesImportedAlwaysNamedSessions(t *testing.T) {
cityPath := t.TempDir()
rigPath := filepath.Join(cityPath, "repo")
for path, contents := range map[string]string{
filepath.Join(cityPath, "pack.toml"): `
[pack]
name = "import-regression"
schema = 2

[imports.gs]
source = "./assets/sidecar"
`,
filepath.Join(cityPath, "city.toml"): `
[workspace]
name = "import-regression"
provider = "claude"

[[rigs]]
name = "repo"
path = "./repo"

[rigs.imports.gs]
source = "./assets/sidecar"
`,
filepath.Join(cityPath, "assets", "sidecar", "pack.toml"): `
[pack]
name = "sidecar"
schema = 2

[[named_session]]
template = "captain"
scope = "city"
mode = "always"

[[named_session]]
template = "watcher"
scope = "rig"
mode = "always"
`,
filepath.Join(cityPath, "assets", "sidecar", "agents", "captain", "agent.toml"): "scope = \"city\"\n",
filepath.Join(cityPath, "assets", "sidecar", "agents", "captain", "prompt.md"): "You are the imported captain.\n",
filepath.Join(cityPath, "assets", "sidecar", "agents", "watcher", "agent.toml"): "scope = \"rig\"\n",
filepath.Join(cityPath, "assets", "sidecar", "agents", "watcher", "prompt.md"): "You are the imported watcher.\n",
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
}
if err := os.MkdirAll(rigPath, 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", rigPath, err)
}

cfg, _, err := config.LoadWithIncludes(fsys.OSFS{}, filepath.Join(cityPath, "city.toml"))
if err != nil {
t.Fatalf("LoadWithIncludes: %v", err)
}

dsResult := buildDesiredState(cfg.EffectiveCityName(), cityPath, time.Now().UTC(), cfg, runtime.NewFake(), beads.NewMemStore(), io.Discard)

captain, ok := dsResult.State["gs__captain"]
if !ok {
t.Fatalf("desired state missing gs__captain; keys=%v", mapKeys(dsResult.State))
}
if captain.TemplateName != "gs.captain" {
t.Fatalf("gs__captain TemplateName = %q, want %q", captain.TemplateName, "gs.captain")
}
if captain.ConfiguredNamedIdentity != "gs.captain" {
t.Fatalf("gs__captain ConfiguredNamedIdentity = %q, want %q", captain.ConfiguredNamedIdentity, "gs.captain")
}

watcher, ok := dsResult.State["repo--gs__watcher"]
if !ok {
t.Fatalf("desired state missing repo--gs__watcher; keys=%v", mapKeys(dsResult.State))
}
if watcher.TemplateName != "repo/gs.watcher" {
t.Fatalf("repo--gs__watcher TemplateName = %q, want %q", watcher.TemplateName, "repo/gs.watcher")
}
if watcher.ConfiguredNamedIdentity != "repo/gs.watcher" {
t.Fatalf("repo--gs__watcher ConfiguredNamedIdentity = %q, want %q", watcher.ConfiguredNamedIdentity, "repo/gs.watcher")
}
}

func TestBuildDesiredState_TransitiveFalseSkipsNestedImportedNamedSessions(t *testing.T) {
cityPath := t.TempDir()
for path, contents := range map[string]string{
filepath.Join(cityPath, "city.toml"): `
[workspace]
name = "import-regression"
provider = "claude"

[imports.outer]
source = "./assets/outer"
transitive = false
`,
filepath.Join(cityPath, "assets", "outer", "pack.toml"): `
[pack]
name = "outer"
schema = 2

[imports.inner]
source = "../inner"

[[named_session]]
template = "captain"
scope = "city"
mode = "always"
`,
filepath.Join(cityPath, "assets", "outer", "agents", "captain", "agent.toml"): "scope = \"city\"\n",
filepath.Join(cityPath, "assets", "outer", "agents", "captain", "prompt.md"): "You are the outer captain.\n",
filepath.Join(cityPath, "assets", "inner", "pack.toml"): `
[pack]
name = "inner"
schema = 2

[[named_session]]
template = "watcher"
scope = "city"
mode = "always"
`,
filepath.Join(cityPath, "assets", "inner", "agents", "watcher", "agent.toml"): "scope = \"city\"\n",
filepath.Join(cityPath, "assets", "inner", "agents", "watcher", "prompt.md"): "You are the inner watcher.\n",
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
}

cfg, _, err := config.LoadWithIncludes(fsys.OSFS{}, filepath.Join(cityPath, "city.toml"))
if err != nil {
t.Fatalf("LoadWithIncludes: %v", err)
}

dsResult := buildDesiredState(cfg.EffectiveCityName(), cityPath, time.Now().UTC(), cfg, runtime.NewFake(), beads.NewMemStore(), io.Discard)
if _, ok := dsResult.State["outer__captain"]; !ok {
t.Fatalf("desired state missing outer__captain; keys=%v", mapKeys(dsResult.State))
}
if _, ok := dsResult.State["outer__watcher"]; ok {
t.Fatalf("desired state should not include nested named session when transitive=false; keys=%v", mapKeys(dsResult.State))
}
}

func TestBuildDesiredState_RoutedQueueDoesNotCreateOneSessionPerBead(t *testing.T) {
cityPath := t.TempDir()
if err := os.MkdirAll(filepath.Join(cityPath, ".beads"), 0o700); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/gc/chat_autosuspend.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func autoSuspendChatSessions(store beads.Store, sp runtime.Provider, idleTimeout
cityPath, _ := resolveCity()
var cfg *config.City
if cityPath != "" {
cfg, _ = loadCityConfig(cityPath)
cfg, _ = loadCityConfig(cityPath, stderr)
}
catalog, err := workerSessionCatalogWithConfig(cityPath, store, sp, cfg)
if err != nil {
Expand Down
102 changes: 94 additions & 8 deletions cmd/gc/cmd_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,22 @@ Describe what this agent should do here.
// via packs are visible. The only exceptions are quick pre-fetch checks
// in cmd_config.go and cmd_start.go that intentionally use config.Load to
// discover remote packs before fetching them.
func loadCityConfig(cityPath string) (*config.City, error) {
func loadCityConfig(cityPath string, warningWriter ...io.Writer) (*config.City, error) {
extras := builtinPackIncludes(cityPath)
cfg, _, err := config.LoadWithIncludes(fsys.OSFS{}, filepath.Join(cityPath, "city.toml"), extras...)
cfg, prov, err := config.LoadWithIncludes(fsys.OSFS{}, filepath.Join(cityPath, "city.toml"), extras...)
if err != nil {
return nil, err
}
emitLoadCityConfigWarnings(resolveLoadCityConfigWarningWriter(warningWriter...), prov)
applyFeatureFlags(cfg)
return cfg, nil
}

// loadCityConfigSuppressDeprecatedOrderWarnings performs a full config load
// while suppressing only legacy order-path migration warnings.
func loadCityConfigSuppressDeprecatedOrderWarnings(cityPath string) (*config.City, error) {
func loadCityConfigSuppressDeprecatedOrderWarnings(cityPath string, warningWriter ...io.Writer) (*config.City, error) {
extras := builtinPackIncludes(cityPath)
cfg, _, err := config.LoadWithIncludesOptions(
cfg, prov, err := config.LoadWithIncludesOptions(
fsys.OSFS{},
filepath.Join(cityPath, "city.toml"),
config.LoadOptions{SuppressDeprecatedOrderWarnings: true},
Expand All @@ -50,22 +51,107 @@ func loadCityConfigSuppressDeprecatedOrderWarnings(cityPath string) (*config.Cit
if err != nil {
return nil, err
}
if len(warningWriter) > 0 {
emitLoadCityConfigWarnings(resolveLoadCityConfigWarningWriter(warningWriter...), prov)
}
applyFeatureFlags(cfg)
return cfg, nil
}

// loadCityConfigFS is the testable variant of loadCityConfig that accepts a
// filesystem implementation. Used by functions that take an fsys.FS parameter
// for unit testing.
func loadCityConfigFS(fs fsys.FS, tomlPath string) (*config.City, error) {
cfg, _, err := config.LoadWithIncludes(fs, tomlPath)
func loadCityConfigFS(fs fsys.FS, tomlPath string, warningWriter ...io.Writer) (*config.City, error) {
cfg, prov, err := config.LoadWithIncludes(fs, tomlPath)
if err != nil {
return nil, err
}
emitLoadCityConfigWarnings(resolveLoadCityConfigWarningWriter(warningWriter...), prov)
applyFeatureFlags(cfg)
return cfg, nil
}

func resolveLoadCityConfigWarningWriter(warningWriter ...io.Writer) io.Writer {
for _, w := range warningWriter {
if w != nil {
return w
}
}
return os.Stderr
}

func emitLoadCityConfigWarnings(w io.Writer, prov *config.Provenance) {
if w == nil || prov == nil || len(prov.Warnings) == 0 {
return
}
seen := make(map[string]struct{}, len(prov.Warnings))
for _, warning := range prov.Warnings {
if !shouldEmitLoadCityConfigWarning(warning) {
continue
}
if _, dup := seen[warning]; dup {
continue
}
seen[warning] = struct{}{}
fmt.Fprintln(w, warning) //nolint:errcheck // best-effort warning emission
}
}

// Alias-only warnings, deferred future-surface keys, and tombstone attachment
// deprecations stay soft so legacy configs keep booting. A mixed
// [agent_defaults]/[agents] config remains strict-fatal because overlapping
// default tables are ambiguous even after normalization.
func isNonFatalLoadConfigWarning(warning string) bool {
if strings.Contains(warning, "[agents] is a deprecated compatibility alias for [agent_defaults]") {
return true
}
if strings.Contains(warning, "attachment-list fields") {
return true
}
if !strings.Contains(warning, `" is not supported`) {
return false
}
return strings.Contains(warning, `"agent_defaults.`) || strings.Contains(warning, `"agents.`)
}

func shouldEmitLoadCityConfigWarning(warning string) bool {
if strings.Contains(warning, "both [agent_defaults] and [agents] are present") {
return true
}
return isNonFatalLoadConfigWarning(warning)
}

func strictFatalLoadConfigWarnings(warnings []string) []string {
if len(warnings) == 0 {
return nil
}
var fatal []string
for _, warning := range warnings {
if isNonFatalLoadConfigWarning(warning) {
continue
}
fatal = append(fatal, warning)
}
return fatal
}

func emitNonFatalLoadConfigWarnings(w io.Writer, prov *config.Provenance) {
if w == nil || prov == nil || len(prov.Warnings) == 0 {
return
}
seen := make(map[string]struct{}, len(prov.Warnings))
for _, warning := range prov.Warnings {
if !isNonFatalLoadConfigWarning(warning) {
continue
}
if _, dup := seen[warning]; dup {
continue
}
seen[warning] = struct{}{}
fmt.Fprintln(w, warning) //nolint:errcheck // best-effort warning emission
}
}

// loadCityConfigForEditFS loads the raw city config WITHOUT pack/include
// expansion. Use for commands that modify city.toml and write it back —
// preserves include directives, pack references, and patches.
Expand Down Expand Up @@ -385,7 +471,7 @@ func doAgentAdd(fs fsys.FS, cityPath, name, promptTemplate, dir string, suspende
return 1
}

cfg, err := loadCityConfigFS(fs, tomlPath)
cfg, err := loadCityConfigFS(fs, tomlPath, stderr)
if err != nil {
fmt.Fprintf(stderr, "gc agent add: %v\n", err) //nolint:errcheck // best-effort stderr
return 1
Expand Down Expand Up @@ -606,7 +692,7 @@ func doAgentSuspendOrResume(fs fsys.FS, cityPath, name string, suspended bool, s
}

// Phase 2: not in raw config — check expanded config for provenance.
expanded, err := loadCityConfigFS(fs, tomlPath)
expanded, err := loadCityConfigFS(fs, tomlPath, stderr)
if err != nil {
fmt.Fprintln(stderr, agentNotFoundMsg("gc agent "+verb, name, cfg)) //nolint:errcheck // best-effort stderr
return 1
Expand Down
Loading
Loading