7 Commits

Author SHA1 Message Date
vikingowl 352cab4a94 docs(todo): extend config-migration plan with project registry
Release / release (push) Has been cancelled
Adds item #5 to the config write/merge corruption entry:
~/.config/gnoma/projects.json tracking which directories gnoma has
been launched in. Enables doctor --all-projects, cross-project
session listing, and one-shot upgrade-config across all known
projects.

Documents the design constraints: must use the same omitempty /
atomic-write discipline as the encoder fix to avoid recreating the
class of bug it exists to help solve. Privacy footprint flagged
(local-only directory log; opt-out toggle). Stale-entry handling
gated through doctor, not auto-prune.
2026-05-24 22:29:56 +02:00
vikingowl 58f4001917 docs(todo): track config write/merge corruption + doctor/upgrade design
setConfig() serializes the entire Config struct on every key change,
which writes zero-valued fields into the file. On the next load those
explicit zeros override higher-priority layers via toml.Decode's
present-beats-absent semantics. Concrete symptom today: a global
prefer = 'cloud' was silently shadowed by a project prefer = ''.

Captures the multi-part fix surface so it doesn't get half-done:
- Stop generating zero-spam (omitempty hybrid or pelletier swap).
- gnoma doctor: read-only diagnostic (zero-spam, invalid enums,
  removed keys, effective-merged values).
- gnoma upgrade-config: active migration with .bak backup + diff.
- Auto-migrate project-level on startup with TUI banner notice;
  global stays explicit.
2026-05-24 22:24:59 +02:00
vikingowl 6c5e969217 feat(tui): add /router command for runtime routing-preference switch
Mirrors the pattern of /permission: bare command shows the current
value plus a help line; with an argument (auto/local/cloud) it calls
Router.SetPreferPolicy and emits a system message. Session-only — does
not write back to config.toml, matching /permission and Ctrl+X
incognito-toggle conventions.

Tab completion on the value via routerPreferModes alongside the
existing permissionModes pattern. Help text updated. Status-bar
indicator deferred (separate concern if it turns out to be wanted).
2026-05-24 22:13:27 +02:00
vikingowl 74bd570438 fix(tui): de-dupe /init in command picker; skill names shadow builtins
/init appeared twice in the completion picker — once from the static
builtinCommands list and once from the bundled init skill at
internal/skill/skills/init.md (registered via skills.All()).

Two changes:

- Remove /init from builtinCommands. The skill provides the canonical
  entry, and its description ('Generate or update AGENTS.md project
  documentation') is more accurate than the static one ('initialize
  project — create AGENTS.md') because the skill handles both create
  and update.
- Refactor completionSource() so a skill name silently shadows any
  builtin with the same name. Prevents this from recurring if a
  future builtin migrates to a skill, and lets users override a
  builtin's description by dropping a skill of the same name into
  .gnoma/skills/.
2026-05-24 22:08:46 +02:00
vikingowl d38d7daf25 fix(subprocess/agy): disable ToolUse until stream-json lands
agy is registered with FormatAgyText and the agyParser emits every
stdout line as a plain EventTextDelta. There is no path for a
structured ToolCall event to come back. With ToolUse=true the router
would dispatch tool-needing tasks (security_review, spawn_elfs, file
edit) to agy; the underlying Gemini model would describe calling the
tool in prose — invented UUIDs and 'I will pause now'-style stubs —
the engine would receive only text, and the turn would hang waiting
for a tool call that never arrives.

Surfaced when /init routed to agy for a security_review task and
elf spawning visibly hallucinated in the TUI. Capability flag
flipped to false; agy stays usable for tool-free prompts (explain,
summarize, simple chat). TODO entry for native stream-json updated
to flag that the capability flip is part of that same change.
2026-05-24 21:58:22 +02:00
vikingowl 06d4069076 ci: pin GoReleaser to the triggering tag, fix tag-collision regression
Release / release (push) Has been cancelled
When v0.3.1 was tagged on the same commit as v0.3.1-rc2, the release
workflow built and tried to publish rc2 artifacts instead of v0.3.1,
failing with 'already_exists' on every asset upload.

Root cause: goreleaser-action@v6 + 'version: latest' (locked to v2.x)
falls back to 'git describe --tags' for the current tag, which picked
v0.3.1-rc2 over v0.3.1 when both refs pointed at HEAD. Explicitly
setting GORELEASER_CURRENT_TAG = github.ref_name forces the workflow
to use the tag that triggered it, regardless of other refs at the same
commit.
2026-05-24 17:36:01 +02:00
vikingowl f641bd4971 docs(todo): track bandit selector design questions
Two related items surfaced from the r/coolgithubprojects v0.3.1
launch thread. Bundled because they share the selector code:

1. Whether to keep numeric EMA at all post-SLM dispatcher (open
   strategic question from the 2026-05-07 roadmap — not a
   must-implement).
2. Surfacing hardcoded selector knobs (qualityAlpha, blend ratio,
   strength bonus, quality floor) as [router.bandit] config keys —
   ships independently of #1.
2026-05-24 17:34:13 +02:00
5 changed files with 224 additions and 8 deletions
+5
View File
@@ -61,3 +61,8 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Force GoReleaser to use the triggering tag rather than fall
# back to `git describe` — which can resolve to an older tag
# (e.g., a vX.Y.Z-rc tag) when multiple tags point at the same
# commit. Surfaced as the v0.3.1 release failure on 2026-05-24.
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
+149 -1
View File
@@ -4,6 +4,148 @@ Active work, newest first.
## In flight
- **Config write/merge — silent corruption of layered configs.**
`internal/config/write.go:setConfig` reads the existing TOML into a
zero-valued `Config` struct, sets one field, and writes the entire
struct back out — so every untouched field gets serialized at its
Go zero value (empty strings, zero ints, `false` bools). On the
next load, those explicit zeros overwrite higher-priority layers
via `toml.Decode`'s "present field beats absent field" semantics.
Concrete symptom (2026-05-24): user's `~/.config/gnoma/config.toml`
had `[router].prefer = "cloud"` but the project-level
`.gnoma/config.toml` had `prefer = ""` (generated by an earlier
`gnoma config set ...` call), which silently downgraded the
effective policy to `auto` — visible only via the new `/router`
TUI command, with no warning.
Same root cause is responsible for the zero-spammed global config
the same user has (`max_tokens = 0`, `permission.mode = ""`,
`bash_timeout = 0`, etc.) — all overwriting sensible defaults.
**Fix surface (multi-part, plan-worthy):**
1. **Stop generating zero-spam.** Two options:
- Tag struct fields with `,omitempty` so the BurntSushi encoder
skips zero values. Caveat: conflates "unset" with "explicitly
zero" for primitive types (a user who wants `max_keep = 0`
loses it). Safe for strings/maps/slices where empty is never
user-intent; lossy for numeric fields.
- Switch to `pelletier/go-toml/v2` and use its document model
to edit only the targeted key, preserving everything else
byte-for-byte. Cleaner semantics, bigger refactor.
- Hybrid: omitempty on string/map/slice fields, document-level
edit for numerics. Fastest path that doesn't lose intent.
2. **`gnoma doctor` — read-only diagnostic.** Scans both global
and project configs and reports:
- Zero-spam fields that would silently shadow defaults or
upstream layers.
- Invalid enum values (e.g. `permission.mode = ""`).
- Unknown / removed keys from older schema versions.
- Effective-merged values (so the user sees what gnoma will
actually use after layering). No writes. Exits non-zero on
findings so it's CI-friendly.
3. **`gnoma upgrade-config` — active migration.** For each config
file (global, profiles, project):
- Compute the cleaned form (only fields the user actually set,
dropping zeros that match defaults).
- Write the original to `<path>.bak` with timestamp suffix.
- Write the cleaned form to the original path.
- Print a diff of what changed so the user can verify.
4. **Project-level auto-migration on startup.** If gnoma detects
a zero-spammed project `.gnoma/config.toml` at launch:
- Auto-run the upgrade (project-only, never auto-touch the
global config).
- Write `.gnoma/config.toml.bak-YYYY-MM-DD-HHMMSS`.
- Surface a one-line notice in the startup safety banner:
`config: migrated .gnoma/config.toml (see .bak)`.
- The auto-migration is non-destructive (`.bak` preserves
original) but still gated behind a `[config].auto_migrate`
toggle, defaulting to `true`. Global configs require
explicit `gnoma upgrade-config`.
5. **Project registry** (`~/.config/gnoma/projects.json`). Today
there is no record of which directories gnoma has been launched
in — items #2 and #3 can work with a filesystem scan
(`find ~ -type d -name .gnoma`), but a registry makes them
significantly faster and unlocks cross-project features.
Sketch:
```json
{
"projects": [
{
"path": "/home/.../my-repo",
"first_seen": "2026-04-15T10:30:00Z",
"last_seen": "2026-05-24T19:23:00Z",
"session_count": 47
}
]
}
```
Update on every successful startup (record project root,
bump `last_seen` + increment `session_count`). Enables:
- Fast `gnoma doctor --all-projects` without a filesystem walk.
- Cross-project session listing (`gnoma sessions --all`
picker; surface most-recent sessions across the registry).
- `gnoma upgrade-config` that can migrate every known project
in one invocation.
- Future local-only aggregate stats (`gnoma stats`) — still
no-phone-home, just a sum across the registry.
**Caveats and design constraints:**
- The registry file becomes another silent-corruption surface
— must use the same `omitempty` / atomic-write discipline
as the encoder fix in #1, or it'll exhibit the same class
of bug.
- Stale entries (deleted projects). `gnoma doctor` should
detect and offer to prune; do not auto-delete.
- Privacy: this is literally a log of directories the user
has worked in. Local-only, never sent off-machine (per the
no-phone-home positioning), but worth a one-line note in
the Security section of the README so users know it exists.
- Opt-out: `[config].project_registry = false` for users who
don't want this tracked. Default `true`.
- Atomic writes (temp file + rename) so a crash mid-write
doesn't corrupt the file.
Surfaced from the v0.3.1 launch wave (2026-05-24).
- **Bandit selector — design decisions deferred.** The current
selector (`internal/router/selector.go:scoreArm`) is greedy
quality-weighted: per-(arm × task-type) EMA scores blended 70/30
with heuristic defaults, divided by CostWeight-adjusted cost. It
is **not** a true multi-armed bandit — no UCB-style exploration
bonus, no Thompson sampling. Tracked as a design question rather
than a must-implement item because of two open dependencies:
1. **Whether to keep numeric EMA at all.** The 2026-05-07 roadmap
(Phase 4) puts re-evaluating bandit learning on hold until the
SLM-driven dispatcher is in production. Three options on the
table: keep bandit as feedback for the SLM, retire EMA in
favour of qualitative outcome summaries fed to the SLM, or
split responsibilities (SLM = intent routing, bandit =
cost/quality within a tier). See
[`docs/superpowers/plans/2026-05-07-gnoma-roadmap.md`](docs/superpowers/plans/2026-05-07-gnoma-roadmap.md)
§Phase 4.
2. **User-tunable selector knobs.** Several constants are
hardcoded today: `qualityAlpha` (EMA smoothing, ~3-sample
memory), the 70/30 observed/heuristic blend,
`strengthScoreBonus` for tagged task types, and the
`DefaultThresholds.Minimum` quality floor. Surfacing these as
`[router.bandit]` config keys would let users tune for their
workloads (faster alpha for shifting model performance, longer
memory for stable fleets) without waiting for the strategic
decision in #1.
Surfaced from the r/coolgithubprojects v0.3.1 launch thread
(2026-05-24, `u/Ha_Deal_5079`).
- **Security boundary — egress controls + session audit log.** The
current `Firewall` is a content boundary only (scans messages and
tool results for secrets via regex + Shannon entropy, redacts or
@@ -76,7 +218,13 @@ Active work, newest first.
- **Structured output** with JSON schema validation — M12.
- **Native agy JSON output** — switch the subprocess provider to
`--output-format stream-json` once the agy CLI supports it,
replacing the current prompt-augmentation fallback.
replacing the current prompt-augmentation fallback. Until then,
agy's `ToolUse` capability is set to `false` (see
`internal/provider/subprocess/agent.go` agy entry) — without
structured tool-call output, the router would otherwise dispatch
tool-needing tasks to agy and the turn would hang on prose
hallucinations of tool calls. Flip the capability back to `true`
in the same change that lands stream-json parsing.
- **SQLite session persistence** + serve mode — M10.
- **Task learning** (pattern recognition, persistent tasks) — M11.
- **Web UI** (`gnoma web`) — M15.
+12 -1
View File
@@ -109,8 +109,19 @@ var knownAgents = []CLIAgent{
// structured-output flag and no image-input mechanism. JSON support
// is faked via PromptResponseFormat (best-effort, model-dependent);
// see TODO.md for tracking native stream-json support.
//
// ToolUse is false on purpose. agy streams plain text and the
// agyParser turns every line into an EventTextDelta — there is
// no path for a structured ToolCall event to come back. With
// ToolUse=true the router would dispatch tool-needing tasks
// (security_review, spawn_elfs, file edit) to agy; the
// underlying Gemini model would describe calling the tool in
// prose (invented UUIDs and "I will pause now"-style stubs),
// the engine would receive only text, and the turn would hang
// waiting for a tool call that never arrives. Flip back to
// true when native stream-json lands.
Capabilities: provider.Capabilities{
ToolUse: true,
ToolUse: false,
ContextWindow: 200000,
},
PromptResponseFormat: true,
+23 -1
View File
@@ -1403,6 +1403,28 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
m.injectSystemContext(msg)
return m, nil
case "/router":
if m.config.Router == nil {
m.messages = append(m.messages, chatMessage{role: "error", content: "router not configured"})
return m, nil
}
if args == "" || args == "help" {
current := m.config.Router.PreferPolicy().String()
m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("router.prefer = %s\nUsage: /router <auto|local|cloud>\n auto — no bias; tier order + Strengths decide\n local — cloud arms demoted; locals win when feasible\n cloud — local arms demoted; cloud arms win (except tier-0 SLM)", current)})
return m, nil
}
policy, err := router.ParsePreferPolicy(args)
if err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
return m, nil
}
m.config.Router.SetPreferPolicy(policy)
msg := fmt.Sprintf("router.prefer = %s (runtime override; not written to config)", policy.String())
m.messages = append(m.messages, chatMessage{role: "system", content: msg})
m.injectSystemContext(msg)
return m, nil
case "/profile":
if args == "" {
m = m.closeAllPickers()
@@ -1532,7 +1554,7 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
return m, nil
}
m.messages = append(m.messages, chatMessage{role: "system",
content: "Commands:\n /init generate or update AGENTS.md project docs\n /clear, /new clear chat and start new conversation\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /keys show keyboard shortcuts\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /plugins list installed plugins\n /profile [name] list profiles / switch (re-execs gnoma)\n /provider show current provider\n /replay scroll to top to re-read conversation\n /resume [id] list or restore saved sessions\n /shell [cmd] open interactive shell (or run cmd in shell)\n /skills list loaded skills\n /usage show token usage and cost\n /help show this help\n /quit exit gnoma\n\nSkills (use /<name> [args] to invoke):\n Add .md files with YAML front matter to .gnoma/skills/ or ~/.config/gnoma/skills/"})
content: "Commands:\n /init generate or update AGENTS.md project docs\n /clear, /new clear chat and start new conversation\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /keys show keyboard shortcuts\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /plugins list installed plugins\n /profile [name] list profiles / switch (re-execs gnoma)\n /provider show current provider\n /replay scroll to top to re-read conversation\n /resume [id] list or restore saved sessions\n /router [mode] show or set routing preference (auto/local/cloud)\n /shell [cmd] open interactive shell (or run cmd in shell)\n /skills list loaded skills\n /usage show token usage and cost\n /help show this help\n /quit exit gnoma\n\nSkills (use /<name> [args] to invoke):\n Add .md files with YAML front matter to .gnoma/skills/ or ~/.config/gnoma/skills/"})
return m, nil
case "/keys":
+35 -5
View File
@@ -22,7 +22,10 @@ var builtinCommands = []cmdEntry{
{"/exit", "exit gnoma"},
{"/help", "show available commands and shortcuts"},
{"/incognito", "toggle incognito mode (no persistence, local-only routing)"},
{"/init", "initialize project — create AGENTS.md"},
// /init is provided by the bundled skill at
// internal/skill/skills/init.md; do not duplicate it here. The dedup
// in completionSource() would skip a duplicate entry anyway, but
// omitting it keeps the source-of-truth single.
{"/keys", "show keyboard shortcuts"},
{"/model", "list or switch active model"},
{"/new", "start a new conversation"},
@@ -34,6 +37,7 @@ var builtinCommands = []cmdEntry{
{"/quit", "quit gnoma"},
{"/replay", "replay last assistant response"},
{"/resume", "browse and resume a saved session"},
{"/router", "show or set routing preference (auto/local/cloud)"},
{"/shell", "open interactive shell"},
{"/theme", "list themes or set active theme"},
{"/skills", "list available skills"},
@@ -46,11 +50,27 @@ var permissionModes = []string{
"auto", "default", "accept_edits", "bypass", "deny", "plan",
}
// completionSource builds a sorted command list from builtins + skills.
func completionSource(skills *skill.Registry) []cmdEntry {
entries := make([]cmdEntry, len(builtinCommands))
copy(entries, builtinCommands)
// routerPreferModes lists valid values for /router completion.
var routerPreferModes = []string{"auto", "local", "cloud"}
// completionSource builds a sorted command list from builtins + skills.
// Skill names shadow builtin names so a skill (bundled or user-defined)
// can replace a static entry without producing a duplicate in the picker.
func completionSource(skills *skill.Registry) []cmdEntry {
skillNames := make(map[string]struct{})
if skills != nil {
for _, s := range skills.All() {
skillNames["/"+s.Frontmatter.Name] = struct{}{}
}
}
entries := make([]cmdEntry, 0, len(builtinCommands)+len(skillNames))
for _, c := range builtinCommands {
if _, shadowed := skillNames[c.name]; shadowed {
continue
}
entries = append(entries, c)
}
if skills != nil {
for _, s := range skills.All() {
desc := s.Frontmatter.Description
@@ -150,6 +170,16 @@ func matchArgCompletion(input string, profileNames []string, providerNames []str
return cmd + " " + mode
}
}
case "/router":
if arg == "" {
return ""
}
lower := strings.ToLower(arg)
for _, mode := range routerPreferModes {
if strings.HasPrefix(mode, lower) && mode != arg {
return cmd + " " + mode
}
}
case "/profile":
if arg == "" || len(profileNames) == 0 {
return ""