Compare commits
7 Commits
v0.3.1-rc2
...
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 352cab4a94 | |||
| 58f4001917 | |||
| 6c5e969217 | |||
| 74bd570438 | |||
| d38d7daf25 | |||
| 06d4069076 | |||
| f641bd4971 |
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user