From c931937ff855abb38d69c9a8816dba86e6749b2e Mon Sep 17 00:00:00 2001 From: xel Date: Fri, 24 Apr 2026 21:12:43 -0600 Subject: [PATCH 1/6] test(progress): assert SaveProgress idempotency on real progress.json --- internal/progress/health_compat_test.go | 81 +++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/internal/progress/health_compat_test.go b/internal/progress/health_compat_test.go index edd8a9097..e9a7b3edc 100644 --- a/internal/progress/health_compat_test.go +++ b/internal/progress/health_compat_test.go @@ -100,3 +100,84 @@ func TestApplyHealthUpdates_RoundTripPreservesCheckedInProgressJSON(t *testing.T } t.Fatalf("round-trip drift: lengths differ (orig=%d got=%d) without divergence in shared prefix", len(wantNorm), len(gotNorm)) } + +// TestSaveProgress_IdempotentOnRealCheckedInFile verifies the schema fix +// (Item field order + natural-sort map keys via custom MarshalJSON) by +// loading the real checked-in progress.json, applying a non-trivial +// mutation that bypasses insertEmptyHealthBlock, saving via SaveProgress, +// then loading and saving again. The second round-trip must be byte-equal +// to the first — that's the idempotency contract. +// +// We can't compare against the on-disk file directly because the on-disk +// file isn't yet in canonical form (e.g. subphase 2.B.10 currently sits +// after 2.G in insertion order rather than between 2.B.5 and 2.B.11 in +// natural-numeric order). The first SaveProgress will canonicalize it; +// every subsequent SaveProgress must produce identical bytes. +func TestSaveProgress_IdempotentOnRealCheckedInFile(t *testing.T) { + src := filepath.Join("..", "..", "docs", "content", "building-gormes", "architecture_plan", "progress.json") + original, err := os.ReadFile(src) + if err != nil { + t.Skipf("checked-in progress.json not found, skipping idempotency test: %v", err) + } + + tmp1 := filepath.Join(t.TempDir(), "progress.json") + if err := os.WriteFile(tmp1, original, 0o644); err != nil { + t.Fatalf("write tmp1: %v", err) + } + + // First round-trip: mutation that SETS a non-zero field, so + // insertEmptyHealthBlock cannot short-circuit. We use the same target + // row as TestApplyHealthUpdates_RoundTripPreservesCheckedInProgressJSON + // so failure modes line up if both tests fail together. + if err := ApplyHealthUpdates(tmp1, []HealthUpdate{{ + PhaseID: "1", + SubphaseID: "1.A", + ItemName: "Bubble Tea shell", + Mutate: func(h *RowHealth) { + h.AttemptCount = 1 // non-zero, defeats the empty-block optimization + }, + }}); err != nil { + t.Fatalf("first ApplyHealthUpdates: %v", err) + } + + pass1, err := os.ReadFile(tmp1) + if err != nil { + t.Fatalf("read after first round-trip: %v", err) + } + + // Second round-trip on a fresh temp copy of pass1: no further mutation, + // just Load → SaveProgress. The output must be byte-equal to pass1. + tmp2 := filepath.Join(t.TempDir(), "progress.json") + if err := os.WriteFile(tmp2, pass1, 0o644); err != nil { + t.Fatalf("write tmp2: %v", err) + } + prog, err := Load(tmp2) + if err != nil { + t.Fatalf("Load tmp2: %v", err) + } + if err := SaveProgress(tmp2, prog); err != nil { + t.Fatalf("second SaveProgress: %v", err) + } + pass2, err := os.ReadFile(tmp2) + if err != nil { + t.Fatalf("read after second round-trip: %v", err) + } + + if bytes.Equal(pass1, pass2) { + return // idempotent — schema fix works + } + + // Surface the first divergence so a future failure is debuggable. + for i := 0; i < min(len(pass1), len(pass2)); i++ { + if pass1[i] != pass2[i] { + start := max(0, i-50) + endA := min(len(pass1), i+50) + endB := min(len(pass2), i+50) + t.Fatalf("SaveProgress not idempotent at offset %d:\nPASS1: %q\nPASS2: %q", + i, + bytes.ReplaceAll(pass1[start:endA], []byte("\n"), []byte("\\n")), + bytes.ReplaceAll(pass2[start:endB], []byte("\n"), []byte("\\n"))) + } + } + t.Fatalf("SaveProgress not idempotent: lengths differ pass1=%d pass2=%d", len(pass1), len(pass2)) +} From d1d73c7e80b97243916e2f38f35fdb2a0cf36bdc Mon Sep 17 00:00:00 2001 From: xel Date: Fri, 24 Apr 2026 21:18:11 -0600 Subject: [PATCH 2/6] fix(autoloop): self-heal landed worker follow-ups --- README.md | 2 +- cmd/gormes/telegram.go | 4 +- .../architecture_plan/_index.md | 22 ++--- .../architecture_plan/phase-3-memory.md | 2 +- .../architecture_plan/progress.json | 40 ++++---- .../building-gormes/autoloop/agent-queue.md | 35 ++++--- .../autoloop/blocked-slices.md | 6 -- .../building-gormes/autoloop/next-slices.md | 2 +- .../building-gormes/contract-readiness.md | 16 ++-- .../04-agent-work-packets.md | 36 ++++---- .../goncho_honcho_memory/_index.md | 2 +- internal/architectureplanneragent_test.go | 5 +- .../{tools => gonchotools}/honcho_tools.go | 5 +- .../honcho_tools_test.go | 25 ++--- internal/kernel/reset_test.go | 74 +++++++++++++-- internal/progress/progress_test.go | 33 ++++--- internal/session/index_mirror.go | 2 +- .../internal/site/data/progress.json | 92 +++++++++---------- 18 files changed, 227 insertions(+), 176 deletions(-) rename internal/{tools => gonchotools}/honcho_tools.go (98%) rename internal/{tools => gonchotools}/honcho_tools_test.go (92%) diff --git a/README.md b/README.md index eb4b2031b..5f26c9ba3 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Gormes is a **strangler-fig rewrite**. Each phase ships a self-contained surface | Phase | Status | Shipped | |-------|--------|---------| | Phase 1 — The Dashboard | ✅ | 3/3 subphases | -| Phase 2 — The Gateway | 🔨 | 11/19 subphases | +| Phase 2 — The Gateway | 🔨 | 12/19 subphases | | Phase 3 — The Black Box (Memory) | 🔨 | 11/14 subphases | | Phase 4 — The Brain Transplant | 🔨 | 0/8 subphases | | Phase 5 — The Final Purge | 🔨 | 1/18 subphases | diff --git a/cmd/gormes/telegram.go b/cmd/gormes/telegram.go index e44fe247d..46792c47e 100644 --- a/cmd/gormes/telegram.go +++ b/cmd/gormes/telegram.go @@ -18,12 +18,12 @@ import ( "github.com/TrebuchetDynamics/gormes-agent/internal/cron" "github.com/TrebuchetDynamics/gormes-agent/internal/gateway" "github.com/TrebuchetDynamics/gormes-agent/internal/goncho" + "github.com/TrebuchetDynamics/gormes-agent/internal/gonchotools" "github.com/TrebuchetDynamics/gormes-agent/internal/hermes" "github.com/TrebuchetDynamics/gormes-agent/internal/kernel" "github.com/TrebuchetDynamics/gormes-agent/internal/memory" "github.com/TrebuchetDynamics/gormes-agent/internal/session" "github.com/TrebuchetDynamics/gormes-agent/internal/telemetry" - "github.com/TrebuchetDynamics/gormes-agent/internal/tools" ) // telegramCmd runs Gormes as a Telegram bot — the adapter previously @@ -115,7 +115,7 @@ func runTelegram(cmd *cobra.Command, _ []string) error { defer cancel() reg := buildDefaultRegistry(rootCtx, cfg.Delegation, cfg.SkillsRoot(), hc, cfg.Hermes.Model) - tools.RegisterHonchoTools(reg, goncho.NewService(mstore.DB(), goncho.Config{ + gonchotools.RegisterHonchoTools(reg, goncho.NewService(mstore.DB(), goncho.Config{ WorkspaceID: "default", ObserverPeerID: "gormes", RecentMessages: 4, diff --git a/docs/content/building-gormes/architecture_plan/_index.md b/docs/content/building-gormes/architecture_plan/_index.md index d153703c5..2bcc8a46c 100644 --- a/docs/content/building-gormes/architecture_plan/_index.md +++ b/docs/content/building-gormes/architecture_plan/_index.md @@ -37,12 +37,12 @@ machine-readable queue for developing the full `gormes-agent`. ## Progress -**Overall:** 28/73 subphases shipped · 13 in progress · 32 planned +**Overall:** 29/73 subphases shipped · 13 in progress · 31 planned | Phase | Status | Shipped | |-------|--------|---------| | Phase 1 — The Dashboard | ✅ | 3/3 subphases | -| Phase 2 — The Gateway | 🔨 | 11/19 subphases | +| Phase 2 — The Gateway | 🔨 | 12/19 subphases | | Phase 3 — The Black Box (Memory) | 🔨 | 11/14 subphases | | Phase 4 — The Brain Transplant | 🔨 | 0/8 subphases | | Phase 5 — The Final Purge | 🔨 | 1/18 subphases | @@ -149,13 +149,13 @@ machine-readable queue for developing the full `gormes-agent`. - [x] Typed result envelope - [x] Append-only run log -### 2.E.1 — OS-AI Spine: Delegation Policy + Child Execution 🔨 +### 2.E.1 — OS-AI Spine: Delegation Policy + Child Execution ✅ - [x] Runner-enforced tool allowlists + blocked-tool policy - [x] Tool-call audit in typed child results - [x] Real child Hermes stream loop - [x] GBrain minion-orchestrator routing policy -- [ ] Durable subagent/job ledger +- [x] Durable subagent/job ledger ### 2.E.2 — OS-AI Spine: Concurrent-Tool Cancellation ✅ @@ -284,29 +284,29 @@ machine-readable queue for developing the full `gormes-agent`. - [x] Opt-in user-scope recall + source filters - [x] Interrupted-turn memory sync suppression - [x] Honcho-compatible scope/source tool schema -- [ ] Honcho host integration compatibility fixtures -- [ ] Cross-chat deny-path fixtures +- [x] Honcho host integration compatibility fixtures +- [x] Cross-chat deny-path fixtures - [ ] Cross-chat operator evidence ### 3.E.8 — Session Lineage + Cross-Source Search 🔨 - [x] parent_session_id lineage for compression splits -- [ ] Gateway resume follows compression continuation +- [x] Gateway resume follows compression continuation - [x] Source-filtered session/message search core - [x] GONCHO user-scope search/context parameters -- [ ] Lineage-aware source-filtered search hits +- [x] Lineage-aware source-filtered search hits - [ ] Operator-auditable search evidence -### 3.F — Goncho Honcho Memory Parity ⏳ +### 3.F — Goncho Honcho Memory Parity 🔨 -- [ ] Goncho context representation options +- [x] Goncho context representation options - [ ] Goncho search filter grammar - [ ] Directional peer cards and representation scopes - [ ] Goncho queue status read model - [ ] Goncho summary context budget - [ ] Goncho dialectic chat contract - [ ] Goncho file upload import ingestion -- [ ] Goncho topology design fixtures +- [x] Goncho topology design fixtures - [ ] Goncho operator diagnostics contract - [ ] Goncho streaming chat persistence contract - [ ] Goncho configuration namespace diff --git a/docs/content/building-gormes/architecture_plan/phase-3-memory.md b/docs/content/building-gormes/architecture_plan/phase-3-memory.md index 129c85c5c..8bc53fa9a 100644 --- a/docs/content/building-gormes/architecture_plan/phase-3-memory.md +++ b/docs/content/building-gormes/architecture_plan/phase-3-memory.md @@ -87,7 +87,7 @@ The Phase 3 queue is not one flat backlog. The order matters because later memor 1. **P2 — 3.E.7 interrupted-turn sync gate** Upstream Hermes now skips external-provider memory sync on interrupted turns. Gormes should pin the same safety rule over the local GONCHO finalization path before widening host-compatible memory surfaces: a cancelled turn can record a skipped-sync reason, but it must not create durable conclusions or cross-chat recall candidates. 2. **P2 — 3.E.7 Honcho-compatible tool-edge closeout** - The `user_id` merge rules, same-chat recall fence, and opt-in user-scope/source-filtered recall are pinned in `internal/session` and `internal/memory`. The internal GONCHO service accepts those scope/source parameters, but `internal/tools/honcho_tools.go` still needs to advertise them in the tool schemas before this is safe to call shipped. The remaining slices are scope/source schema exposure for `honcho_search`/`honcho_context`, then explicit deny-path fixtures, then operator-readable evidence. + The `user_id` merge rules, same-chat recall fence, and opt-in user-scope/source-filtered recall are pinned in `internal/session` and `internal/memory`. The internal GONCHO service accepts those scope/source parameters, but `internal/gonchotools/honcho_tools.go` still needs to advertise them in the tool schemas before this is safe to call shipped. The remaining slices are scope/source schema exposure for `honcho_search`/`honcho_context`, then explicit deny-path fixtures, then operator-readable evidence. 3. **P3 — Honcho host integration compatibility fixtures** Honcho upstream added OpenCode and SillyTavern integration docs. Gormes should not port their Node/Bun plugins, but it should fixture-lock the shared concepts they depend on: workspace, peer, host-scoped config, session strategy, context/tool/hybrid recall modes, and durable conclusion/search tools while keeping the internal package named `goncho`. 4. **P4 — 3.E.8 `parent_session_id` lineage closeout** diff --git a/docs/content/building-gormes/architecture_plan/progress.json b/docs/content/building-gormes/architecture_plan/progress.json index d6ef9ad94..5e404b95c 100644 --- a/docs/content/building-gormes/architecture_plan/progress.json +++ b/docs/content/building-gormes/architecture_plan/progress.json @@ -1285,7 +1285,7 @@ "system" ], "degraded_mode": "Memory status and tool schema evidence show when user-scope or source-filtered recall is unavailable.", - "fixture": "internal/tools/honcho_tools_test.go", + "fixture": "internal/gonchotools/honcho_tools_test.go", "source_refs": [ "docs/content/upstream-hermes/gormes-takeaways.md", "docs/content/building-gormes/architecture_plan/phase-3-memory.md", @@ -1308,11 +1308,11 @@ ], "note": "TDD landed: `honcho_search` and `honcho_context` keep their public Honcho-compatible names while their JSON Schemas now advertise optional `scope` and `sources` controls backed by the existing internal GONCHO params. Schema tests assert both fields are discoverable and not required, and executor round-trips without either field preserve same-chat default behavior. Deny-path fixtures and operator evidence remain separate 3.E.7 slices.", "write_scope": [ - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/tools ./internal/goncho -count=1" + "go test ./internal/gonchotools ./internal/goncho -count=1" ], "done_signal": [ "Honcho-compatible tool schema tests prove scope and sources are optional, discoverable, and routed through existing GONCHO params." @@ -1472,7 +1472,7 @@ "name": "Gateway resume follows compression continuation", "status": "complete", "contract": "Gateway and CLI resume resolve a titled or root session to the newest live compression descendant before loading transcript history", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "gateway", "trust_class": [ @@ -1605,7 +1605,7 @@ "docs/content/building-gormes/goncho_honcho_memory/03-honcho-docs-study.md", "docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md", "internal/goncho/types.go", - "internal/tools/honcho_tools.go" + "internal/gonchotools/honcho_tools.go" ], "ready_when": [ "honcho_context already accepts peer, query, max_tokens, session_key, scope, and sources through the Goncho service." @@ -1626,11 +1626,11 @@ "note": "TDD landed: ContextParams and the honcho_context schema expose optional peer_target, peer_perspective, limit_to_session, search_top_k, search_max_distance, include_most_frequent, and max_conclusions. Omitted fields preserve same-chat context defaults; limit_to_session=true fails closed without session_key and cannot widen through scope=user; unsupported directional and semantic representation options return structured unavailable evidence. Summaries, observations, and dialectic-backed retrieval remain separate slices.", "write_scope": [ "internal/goncho/", - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/tools -count=1" + "go test ./internal/goncho ./internal/gonchotools -count=1" ], "done_signal": [ "honcho_context schema and service fixtures prove Honcho v3 context options are discoverable, optional, and visibly degraded when not implemented." @@ -1726,11 +1726,11 @@ "write_scope": [ "internal/goncho/", "internal/memory/", - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/memory ./internal/tools -count=1" + "go test ./internal/goncho ./internal/memory ./internal/gonchotools -count=1" ], "done_signal": [ "Directional peer-card fixtures prove observer/observed isolation, max-card cap, and replacement semantics." @@ -1826,11 +1826,11 @@ "write_scope": [ "internal/goncho/", "internal/memory/", - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/memory ./internal/tools -count=1" + "go test ./internal/goncho ./internal/memory ./internal/gonchotools -count=1" ], "done_signal": [ "Summary context fixtures prove short/long cadence, token-budget allocation, summary=false behavior, and visible degradation when no summary can fit." @@ -1862,7 +1862,7 @@ "docs/content/building-gormes/goncho_honcho_memory/02-tool-schemas.md", "docs/content/building-gormes/goncho_honcho_memory/03-honcho-docs-study.md", "docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md", - "internal/tools/honcho_tools.go", + "internal/gonchotools/honcho_tools.go", "internal/goncho/service.go" ], "blocked_by": [ @@ -1884,11 +1884,11 @@ "note": "Honcho docs expose peer.chat as the slow query-specific reasoning path; newer host integrations call this honcho_chat or chat. Gormes should add the alias and contract before porting the full dialectic tool loop.", "write_scope": [ "internal/goncho/", - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/tools -count=1" + "go test ./internal/goncho ./internal/gonchotools -count=1" ], "done_signal": [ "Chat contract tests prove request validation, default reasoning level, response shape, host-compatible tool alias, and explicit streaming degradation." @@ -1977,7 +1977,7 @@ "internal/session/directory.go", "internal/goncho/types.go", "internal/goncho/service.go", - "internal/tools/honcho_tools.go" + "internal/gonchotools/honcho_tools.go" ], "blocked_by": [], "ready_when": [ @@ -2003,11 +2003,11 @@ "write_scope": [ "internal/goncho/", "internal/session/", - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/session ./internal/tools -count=1" + "go test ./internal/goncho ./internal/session ./internal/gonchotools -count=1" ], "done_signal": [ "Topology fixtures prove the default workspace, peer ID derivation, observation defaults, and session boundary choices expected by the operator playbook." @@ -2097,7 +2097,7 @@ "docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md", "internal/goncho/types.go", "internal/goncho/service.go", - "internal/tools/honcho_tools.go", + "internal/gonchotools/honcho_tools.go", "internal/memory/schema.go" ], "blocked_by": [ @@ -2123,12 +2123,12 @@ "note": "Honcho docs make streaming a chat-response transport detail. Goncho should preserve memory quality by treating only completed assistant messages as durable facts.", "write_scope": [ "internal/goncho/", - "internal/tools/", + "internal/gonchotools/", "internal/memory/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/tools ./internal/memory -count=1" + "go test ./internal/goncho ./internal/gonchotools ./internal/memory -count=1" ], "done_signal": [ "Streaming fixtures prove completed responses are persisted once and interrupted or partial chunks cannot pollute memory." diff --git a/docs/content/building-gormes/autoloop/agent-queue.md b/docs/content/building-gormes/autoloop/agent-queue.md index 0003bde9a..370a39b0f 100644 --- a/docs/content/building-gormes/autoloop/agent-queue.md +++ b/docs/content/building-gormes/autoloop/agent-queue.md @@ -203,25 +203,24 @@ tests, and candidate policy. Keep those control-plane facts in - Unblocks: BlueBubbles iMessage session-context prompt guidance - Why now: Unblocks BlueBubbles iMessage session-context prompt guidance. -## 10. Goncho topology design fixtures +## 10. Tool registry inventory + schema parity harness -- Phase: 3 / 3.F -- Owner: `memory` -- Size: `small` +- Phase: 5 / 5.A +- Owner: `tools` +- Size: `medium` - Status: `planned` -- Priority: `P3` -- Contract: Goncho workspace, peer, session, and observation defaults are fixture-locked before more memory behavior is added -- Trust class: operator, system -- Ready when: The current session directory, Goncho service types, and Honcho tool schemas are readable in the repo. -- Not ready when: The slice changes persistence behavior or reasoning behavior before topology rules are proven. -- Degraded mode: Unknown external participant identity falls back to a deterministic source-prefixed peer ID and records the fallback in evidence. -- Fixture: `internal/goncho/topology_design_test.go` -- Write scope: `internal/goncho/`, `internal/session/`, `internal/tools/`, `docs/content/building-gormes/architecture_plan/progress.json` -- Test commands: `go test ./internal/goncho ./internal/session ./internal/tools -count=1` -- Done signal: Topology fixtures prove the default workspace, peer ID derivation, observation defaults, and session boundary choices expected by the operator playbook. -- Acceptance: Default workspace is gormes unless explicit hard isolation is requested., Workspace-per-user is rejected in fixtures., Peer ID selection prefers internal/session.Metadata.UserID and falls back to source-prefixed external IDs., Deterministic assistants, transport bots, and import helpers default to observe_me=false., Conversation sessions follow real context boundaries such as thread, channel, repo, import batch, or delegated child run., Cross-peer observation is opt-in and cannot become a default side effect. -- Source refs: ../honcho/docs/v3/documentation/core-concepts/design-patterns.mdx, ../honcho/docs/v3/documentation/features/storing-data.mdx, ../honcho/docs/v3/guides/integrations/openclaw.mdx, ../honcho/docs/v3/guides/integrations/claude-code.mdx, ../honcho/docs/v3/guides/integrations/paperclip.mdx, ../honcho/docs/v3/guides/integrations/sillytavern.mdx, docs/content/building-gormes/goncho_honcho_memory/05-operator-playbook.md, docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md, internal/session/directory.go, internal/goncho/types.go, internal/goncho/service.go, internal/tools/honcho_tools.go -- Unblocks: Goncho context representation options, Directional peer cards and representation scopes, Goncho operator diagnostics contract -- Why now: Unblocks Goncho context representation options, Directional peer cards and representation scopes, Goncho operator diagnostics contract. +- Contract: Operation and tool descriptor parity before handler ports +- Trust class: operator, gateway, child-agent, system +- Ready when: Upstream tool descriptor inventory can be captured without porting handlers in the same slice. +- Not ready when: Handler implementation starts before descriptor parity fixtures exist. +- Degraded mode: Doctor reports disabled tools, missing dependencies, schema drift, and unavailable provider-specific paths. +- Fixture: `internal/tools upstream schema parity manifest fixtures` +- Write scope: `internal/tools/`, `docs/content/building-gormes/architecture_plan/progress.json` +- Test commands: `go test ./internal/tools -count=1` +- Done signal: Tool descriptor parity fixtures capture names, schemas, trust classes, dependencies, and degraded status before handler ports. +- Acceptance: Upstream tool names, toolsets, required env vars, schemas, result envelopes, trust classes, and degraded status are captured in fixtures., No handler port can mark complete until its descriptor parity row exists., Doctor can report missing dependencies or disabled provider-specific paths. +- Source refs: docs/content/upstream-hermes/reference/tools-reference.md, docs/content/building-gormes/architecture_plan/phase-5-final-purge.md +- Unblocks: Pure core tools first, Stateful tool migration queue, CLI command registry parity + active-turn busy policy +- Why now: Unblocks Pure core tools first, Stateful tool migration queue, CLI command registry parity + active-turn busy policy. diff --git a/docs/content/building-gormes/autoloop/blocked-slices.md b/docs/content/building-gormes/autoloop/blocked-slices.md index 7d8d39a00..894c2564d 100644 --- a/docs/content/building-gormes/autoloop/blocked-slices.md +++ b/docs/content/building-gormes/autoloop/blocked-slices.md @@ -16,14 +16,8 @@ Use it to avoid assigning work before the dependency chain is ready. | Phase | Slice | Blocked by | Ready when | Unblocks | |---|---|---|---|---| | 2 / 2.B.5 | BlueBubbles iMessage session-context prompt guidance | BlueBubbles iMessage bubble formatting parity | BlueBubbles outbound formatting splits blank-line paragraphs into separate iMessage sends, so prompt guidance has a matching delivery contract. | - | -| 2 / 2.E.1 | Durable subagent/job ledger | GBrain minion-orchestrator routing policy | Routing policy fixtures define which work may enter durable orchestration and which callers are allowed to submit or observe each lane. | - | | 2 / 2.F.3 | Unauthorized DM pairing response contract | Pairing approval + rate-limit semantics | Pairing approval, rate limiting, and allowlist checks are fixture-locked. | - | | 2 / 2.F.5 | Steer slash command registry + queue fallback | 2.E.2 | 2.E.2 is complete and the shared CommandDef registry is stable for gateway commands. | Mid-run steer injection between tool calls, Gateway-handled slash commands bypass active-session guard | -| 3 / 3.E.7 | Honcho host integration compatibility fixtures | Honcho-compatible scope/source tool schema, Interrupted-turn memory sync suppression | Public honcho_* schemas expose scope/source controls and interrupted turns cannot persist partial memory observations. | - | -| 3 / 3.E.7 | Cross-chat deny-path fixtures | Honcho-compatible scope/source tool schema | Honcho-compatible scope/source tool schema is complete and exposes source allowlist semantics. | Cross-chat operator evidence, parent_session_id lineage for compression splits | -| 3 / 3.E.8 | Gateway resume follows compression continuation | parent_session_id lineage for compression splits | Session lineage metadata can resolve root -> child chains and distinguish ended compression roots from live descendants. | Context compression | -| 3 / 3.E.8 | Lineage-aware source-filtered search hits | parent_session_id lineage for compression splits | Session lineage metadata is persisted and can be queried from the session read model. | Operator-auditable search evidence | -| 3 / 3.F | Goncho context representation options | Honcho-compatible scope/source tool schema | honcho_context already accepts peer, query, max_tokens, session_key, scope, and sources through the Goncho service. | Goncho summary context budget, Directional peer cards and representation scopes | | 3 / 3.F | Goncho search filter grammar | Cross-chat deny-path fixtures | Same-chat and user-scope deny paths are fixture-locked so filter failures cannot accidentally widen recall. | - | | 3 / 3.F | Directional peer cards and representation scopes | Goncho context representation options | Context options expose observer/target fields and current peer-card replacement behavior is fixture-locked. | - | | 3 / 3.F | Goncho queue status read model | Directional peer cards and representation scopes | At least one Goncho-owned task type or a zero-state read model is available to report deterministically. | - | diff --git a/docs/content/building-gormes/autoloop/next-slices.md b/docs/content/building-gormes/autoloop/next-slices.md index d3eb10c7f..1a81b74d3 100644 --- a/docs/content/building-gormes/autoloop/next-slices.md +++ b/docs/content/building-gormes/autoloop/next-slices.md @@ -33,5 +33,5 @@ the row in `progress.json` before assigning it. | 5 / 5.O | PTY bridge protocol adapter | Dashboard/TUI PTY sessions expose bounded read, write, resize, close, and unavailable-state behavior through a testable adapter | operator | `internal/cli/pty_bridge_test.go` | Unblocks SSE streaming to Bubble Tea TUI, Dashboard PTY chat sidecar contract. | | 5 / 5.Q | OpenAI-compatible chat-completions API server | OpenAI-compatible chat.completions HTTP surface over the native Gormes turn loop | operator, gateway | `internal/apiserver/chat_completions_test.go` | Unblocks Responses API store + run event stream, Gateway proxy mode forwarding contract, Dashboard API client contract. | | 7 / 7.E | BlueBubbles iMessage bubble formatting parity | BlueBubbles outbound iMessage sends are non-editable, markdown-stripped, paragraph-split bubbles without pagination suffixes | gateway, system | `internal/channels/bluebubbles/bot_test.go` | Unblocks BlueBubbles iMessage session-context prompt guidance. | -| 3 / 3.F | Goncho topology design fixtures | Goncho workspace, peer, session, and observation defaults are fixture-locked before more memory behavior is added | operator, system | `internal/goncho/topology_design_test.go` | Unblocks Goncho context representation options, Directional peer cards and representation scopes, Goncho operator diagnostics contract. | +| 5 / 5.A | Tool registry inventory + schema parity harness | Operation and tool descriptor parity before handler ports | operator, gateway, child-agent, system | `internal/tools upstream schema parity manifest fixtures` | Unblocks Pure core tools first, Stateful tool migration queue, CLI command registry parity + active-turn busy policy. | diff --git a/docs/content/building-gormes/contract-readiness.md b/docs/content/building-gormes/contract-readiness.md index 9ab3480a6..f828353b9 100644 --- a/docs/content/building-gormes/contract-readiness.md +++ b/docs/content/building-gormes/contract-readiness.md @@ -36,24 +36,24 @@ operator-visible, and a local fixture proves compatibility. | 2 / 2.B.5 | BlueBubbles iMessage session-context prompt guidance — Gateway session-context prompts tell the agent when the origin is BlueBubbles/iMessage and ask for short, blank-line-separated message bubbles | `fixture_ready` | `gateway` | `small` | gateway, system | `internal/gateway/session_context_test.go` | Until this lands, BlueBubbles still sends replies through the first-pass adapter but the model is not explicitly guided toward iMessage-length bubble formatting. | | 2 / 2.B.5 | Non-editable gateway progress/commentary send fallback — Channels without placeholder/edit capabilities receive progress-safe interim or final assistant messages through the plain Send path without EditMessage calls | `validated` | `gateway` | `small` | gateway, system | `internal/gateway/manager_test.go` | Non-editable channels continue to receive final responses, but quick commentary/interim updates may be suppressed until the send-fallback fixture proves no edit path is attempted. | | 2 / 2.E.1 | GBrain minion-orchestrator routing policy — Durable-job routing separates deterministic restart-survivable work from live LLM subagents, following GBrain's unified minion-orchestrator skill while keeping Gormes Go-native subagent APIs | `validated` | `orchestrator` | `small` | operator, child-agent, system | `internal/subagent/minion_policy_test.go` | Until the policy lands, Gormes exposes in-memory subagents plus append-only run logs only; durable minion routing is documented as unavailable in status/doctor surfaces. | -| 2 / 2.E.1 | Durable subagent/job ledger — SQLite-first job ledger records restartable subagent and deterministic work state with claim, progress, result, error, parent-child, and cancellation fields | `draft` | `orchestrator` | `medium` | operator, child-agent, system | `internal/subagent/durable_ledger_test.go` | Doctor and status report append-only run logs without restart rehydration until the durable ledger can claim and resume work. | +| 2 / 2.E.1 | Durable subagent/job ledger — SQLite-first job ledger records restartable subagent and deterministic work state with claim, progress, result, error, parent-child, and cancellation fields | `validated` | `orchestrator` | `medium` | operator, child-agent, system | `internal/subagent/durable_ledger_test.go` | Doctor and status report append-only run logs without restart rehydration until the durable ledger can claim and resume work. | | 2 / 2.F.3 | Unauthorized DM pairing response contract — Unknown direct-message users receive the configured deny, pair, or ignore response without leaking authorized-session state | `draft` | `gateway` | `small` | gateway, operator | `internal/gateway unauthorized DM pairing fixtures` | Gateway status and logs show denied or pending-pair users without starting agent sessions. | | 2 / 2.F.5 | Steer slash command registry + queue fallback — Registry-owned active-turn steering command | `draft` | `gateway` | `small` | operator, gateway | `internal/gateway active-turn command registry fixtures` | Gateway returns visible usage, busy, or queued status instead of dropping steer text when the command cannot run immediately. | | 3 / 3.E.7 | Interrupted-turn memory sync suppression — Interrupted or cancelled turns cannot flush partial observations into GONCHO or external Honcho-compatible memory | `validated` | `memory` | `small` | system | `internal/memory/interrupted_sync_test.go` | Memory status reports skipped or interrupted sync attempts without promoting partial facts to recall. | -| 3 / 3.E.7 | Honcho-compatible scope/source tool schema — Honcho-compatible tool schemas expose GONCHO scope and source allowlist controls without renaming public tools | `validated` | `memory` | `small` | operator, system | `internal/tools/honcho_tools_test.go` | Memory status and tool schema evidence show when user-scope or source-filtered recall is unavailable. | -| 3 / 3.E.7 | Honcho host integration compatibility fixtures — GONCHO preserves host-facing Honcho session, peer, and tool semantics needed by current OpenCode and SillyTavern integrations | `draft` | `memory` | `small` | operator, system | `internal/goncho host integration mapping fixtures` | Doctor and memory status explain which Honcho-compatible host mappings are unsupported instead of silently accepting incompatible config. | -| 3 / 3.E.7 | Cross-chat deny-path fixtures — Same-chat default recall with explicit user-scope widening | `draft` | `memory` | `small` | operator, system | `internal/memory cross-chat allow-deny recall fixtures` | Memory status and operator evidence report unresolved, conflicting, or denied cross-chat identity bindings. | +| 3 / 3.E.7 | Honcho-compatible scope/source tool schema — Honcho-compatible tool schemas expose GONCHO scope and source allowlist controls without renaming public tools | `validated` | `memory` | `small` | operator, system | `internal/gonchotools/honcho_tools_test.go` | Memory status and tool schema evidence show when user-scope or source-filtered recall is unavailable. | +| 3 / 3.E.7 | Honcho host integration compatibility fixtures — GONCHO preserves host-facing Honcho session, peer, and tool semantics needed by current OpenCode and SillyTavern integrations | `validated` | `memory` | `small` | operator, system | `internal/goncho/host_integration_test.go` | Doctor and memory status explain which Honcho-compatible host mappings are unsupported instead of silently accepting incompatible config. | +| 3 / 3.E.7 | Cross-chat deny-path fixtures — Same-chat default recall with explicit user-scope widening | `validated` | `memory` | `small` | operator, system | `internal/memory cross-chat allow-deny recall fixtures` | Memory status and operator evidence report unresolved, conflicting, or denied cross-chat identity bindings. | | 3 / 3.E.8 | parent_session_id lineage for compression splits — Session metadata records compression/fork lineage and can resolve the live descendant without rewriting ancestor history | `validated` | `memory` | `small` | operator, gateway, system | `internal/session/lineage_test.go` | Session mirrors and status show missing, orphaned, or looped lineage instead of silently resuming stale roots. | -| 3 / 3.E.8 | Gateway resume follows compression continuation — Gateway and CLI resume resolve a titled or root session to the newest live compression descendant before loading transcript history | `draft` | `gateway` | `small` | operator, gateway, system | `internal/gateway/resume_continuation_test.go` | Resume status reports unresolved continuation chains and falls back visibly instead of loading an ended compression root as live history. | -| 3 / 3.E.8 | Lineage-aware source-filtered search hits — Session and message search can surface parent/child lineage context for matched sessions without widening the same-chat default recall fence | `draft` | `memory` | `small` | operator, system | `internal/memory/session_lineage_search_test.go` | Search evidence reports lineage unavailable or orphaned instead of implying a complete compression chain. | -| 3 / 3.F | Goncho context representation options — honcho_context exposes the Honcho v3 session.context representation controls while preserving current same-chat defaults | `draft` | `memory` | `small` | operator, system | `internal/goncho/context_options_test.go` | Unsupported representation options return structured unavailable evidence instead of being silently ignored. | +| 3 / 3.E.8 | Gateway resume follows compression continuation — Gateway and CLI resume resolve a titled or root session to the newest live compression descendant before loading transcript history | `validated` | `gateway` | `small` | operator, gateway, system | `internal/gateway/resume_continuation_test.go` | Resume status reports unresolved continuation chains and falls back visibly instead of loading an ended compression root as live history. | +| 3 / 3.E.8 | Lineage-aware source-filtered search hits — Session and message search can surface parent/child lineage context for matched sessions without widening the same-chat default recall fence | `validated` | `memory` | `small` | operator, system | `internal/memory/session_lineage_search_test.go` | Search evidence reports lineage unavailable or orphaned instead of implying a complete compression chain. | +| 3 / 3.F | Goncho context representation options — honcho_context exposes the Honcho v3 session.context representation controls while preserving current same-chat defaults | `validated` | `memory` | `small` | operator, system | `internal/goncho/context_options_test.go` | Unsupported representation options return structured unavailable evidence instead of being silently ignored. | | 3 / 3.F | Goncho search filter grammar — Goncho search accepts a typed subset of Honcho v3 filters and rejects unsupported filter operators visibly | `draft` | `memory` | `medium` | operator, system | `internal/goncho/filter_grammar_test.go` | Unknown filters, unsupported operators, or metadata paths return a structured unsupported-filter error instead of widening search. | | 3 / 3.F | Directional peer cards and representation scopes — Peer cards and stored representations are keyed by workspace, observer, and observed peer instead of a flat workspace/peer pair | `draft` | `memory` | `medium` | operator, system | `internal/goncho/directional_peer_card_test.go` | When directional representation is unavailable, the service reports that only the default gormes observer view was used. | | 3 / 3.F | Goncho queue status read model — Gormes exposes Honcho-style representation, summary, and dream work-unit queue status as observability, not synchronization | `draft` | `memory` | `small` | operator, system | `internal/goncho/queue_status_test.go` | If no Goncho task queue exists yet, memory status reports zero tracked Goncho work units plus the existing extractor queue status. | | 3 / 3.F | Goncho summary context budget — Session summaries use Honcho's short/long cadence and 40/60 context budget without double-billing last-N-turn recall | `draft` | `memory` | `medium` | operator, system | `internal/goncho/summary_context_test.go` | When summaries are unavailable or too large for the token budget, context returns recent messages plus explicit summary_absent evidence. | | 3 / 3.F | Goncho dialectic chat contract — Goncho exposes a Honcho peer.chat-compatible request and response contract while keeping query-specific reasoning separate from prompt-time context assembly | `draft` | `memory` | `small` | operator, system | `internal/goncho/chat_contract_test.go` | Until the real dialectic tool loop and streaming transport land, honcho_chat returns deterministic content plus explicit unsupported evidence for stream=true and target-specific reasoning gaps. | | 3 / 3.F | Goncho file upload import ingestion — Goncho can import text-like memory files as session messages without persisting original file bytes, matching Honcho's file-upload migration model | `draft` | `memory` | `medium` | operator, system | `internal/goncho/file_import_test.go` | Until PDF extraction and a Goncho queue exist, unsupported content types fail before writes and imported messages report queue-unavailable evidence. | -| 3 / 3.F | Goncho topology design fixtures — Goncho workspace, peer, session, and observation defaults are fixture-locked before more memory behavior is added | `draft` | `memory` | `small` | operator, system | `internal/goncho/topology_design_test.go` | Unknown external participant identity falls back to a deterministic source-prefixed peer ID and records the fallback in evidence. | +| 3 / 3.F | Goncho topology design fixtures — Goncho workspace, peer, session, and observation defaults are fixture-locked before more memory behavior is added | `validated` | `memory` | `small` | operator, system | `internal/goncho/topology_design_test.go` | Unknown external participant identity falls back to a deterministic source-prefixed peer ID and records the fallback in evidence. | | 3 / 3.F | Goncho operator diagnostics contract — Gormes exposes a Honcho-inspired Goncho doctor path that checks memory topology, queues, config, and degraded modes without requiring operators to inspect raw tables | `draft` | `memory` | `medium` | operator, system | `cmd/gormes/goncho_doctor_test.go` | Missing optional model/provider features are reported as degraded capability rows, not startup failures, unless a requested command needs them. | | 3 / 3.F | Goncho streaming chat persistence contract — Goncho streaming chat stores the final assistant response once and never turns partial stream chunks into memory | `draft` | `memory` | `small` | operator, system | `internal/goncho/streaming_chat_persistence_test.go` | Until streaming transport exists, stream=true returns explicit unsupported evidence while non-streaming chat keeps the Honcho-compatible response contract. | | 3 / 3.F | Goncho configuration namespace — Gormes owns a Go-native [goncho] configuration namespace that maps Honcho runtime limits and feature gates into existing config loading | `draft` | `memory` | `small` | operator, system | `internal/config/goncho_config_test.go` | Unset Goncho config uses documented defaults and reports feature-disabled evidence instead of requiring Honcho-style Python service variables. | diff --git a/docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md b/docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md index 748f1cfa3..36746f6af 100644 --- a/docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md +++ b/docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md @@ -23,7 +23,7 @@ Before starting any packet, read: - `internal/goncho/types.go` - `internal/goncho/service.go` - `internal/goncho/sql.go` -- `internal/tools/honcho_tools.go` +- `internal/gonchotools/honcho_tools.go` - `internal/memory/schema.go` If the work touches generated roadmap pages or the site, also run: @@ -51,8 +51,8 @@ Current Gormes files: - `internal/goncho/types.go` - `internal/goncho/service.go` -- `internal/tools/honcho_tools.go` -- `internal/tools/honcho_tools_test.go` +- `internal/gonchotools/honcho_tools.go` +- `internal/gonchotools/honcho_tools_test.go` Red tests: @@ -61,7 +61,7 @@ Red tests: - `limit_to_session=true` does not widen `scope=user`; - unsupported representation-only fields return structured unavailable evidence instead of being silently ignored. -- `internal/tools/honcho_tools_test.go` +- `internal/gonchotools/honcho_tools_test.go` - `HonchoContextTool.Schema()` exposes optional `summary`, `tokens`, `peer_target`, `peer_perspective`, `search_query`, `limit_to_session`, `search_top_k`, `search_max_distance`, `include_most_frequent`, and @@ -85,7 +85,7 @@ Do not implement: Validation: -- `go test ./internal/goncho ./internal/tools -count=1` +- `go test ./internal/goncho ./internal/gonchotools -count=1` - `go run ./cmd/autoloop progress validate` Commit message: @@ -177,7 +177,7 @@ Current Gormes files: - `internal/goncho/sql.go` - `internal/goncho/service.go` - `internal/goncho/types.go` -- `internal/tools/honcho_tools.go` +- `internal/gonchotools/honcho_tools.go` Red tests: @@ -204,7 +204,7 @@ Do not implement: Validation: -- `go test ./internal/goncho ./internal/memory ./internal/tools -count=1` +- `go test ./internal/goncho ./internal/memory ./internal/gonchotools -count=1` - `go run ./cmd/autoloop progress validate` Commit message: @@ -237,7 +237,7 @@ Current Gormes files: - `internal/memory/schema.go` - `internal/goncho/service.go` - `internal/goncho/types.go` -- `internal/tools/honcho_tools.go` +- `internal/gonchotools/honcho_tools.go` Red tests: @@ -266,7 +266,7 @@ Do not implement: Validation: -- `go test ./internal/goncho ./internal/memory ./internal/tools -count=1` +- `go test ./internal/goncho ./internal/memory ./internal/gonchotools -count=1` - `go run ./cmd/autoloop progress validate` Commit message: @@ -361,8 +361,8 @@ Current Gormes files: - `internal/goncho/types.go` - `internal/goncho/service.go` -- `internal/tools/honcho_tools.go` -- `internal/tools/honcho_tools_test.go` +- `internal/gonchotools/honcho_tools.go` +- `internal/gonchotools/honcho_tools_test.go` Red tests: @@ -374,7 +374,7 @@ Red tests: - `stream=true` returns structured unsupported evidence until streaming is implemented; - response shape is `{ "content": "..." }`. -- `internal/tools/honcho_tools_test.go` +- `internal/gonchotools/honcho_tools_test.go` - `honcho_chat` exists as a host-compatible alias while `honcho_reasoning` remains available. @@ -393,7 +393,7 @@ Do not implement: Validation: -- `go test ./internal/goncho ./internal/tools -count=1` +- `go test ./internal/goncho ./internal/gonchotools -count=1` - `go run ./cmd/autoloop progress validate` Commit message: @@ -498,7 +498,7 @@ Current Gormes files: - `internal/goncho/types.go` - `internal/goncho/service.go` - `internal/goncho/sql.go` -- `internal/tools/honcho_tools.go` +- `internal/gonchotools/honcho_tools.go` Red tests: @@ -523,7 +523,7 @@ Do not implement: Validation: -- `go test ./internal/goncho ./internal/tools -count=1` +- `go test ./internal/goncho ./internal/gonchotools -count=1` - `go run ./cmd/autoloop progress validate` Commit message: @@ -571,7 +571,7 @@ Implementation boundaries: Validation: -- `go test ./internal/goncho ./internal/tools ./internal/memory -count=1` +- `go test ./internal/goncho ./internal/gonchotools ./internal/memory -count=1` - `go run ./cmd/autoloop progress validate` Acceptance: @@ -777,7 +777,7 @@ Current Gormes files: - `internal/goncho/types.go` - `internal/goncho/service.go` -- `internal/tools/honcho_tools.go` +- `internal/gonchotools/honcho_tools.go` - `internal/memory/` Red tests: @@ -803,7 +803,7 @@ Do not implement: Validation: -- `go test ./internal/goncho ./internal/memory ./internal/tools -count=1` +- `go test ./internal/goncho ./internal/memory ./internal/gonchotools -count=1` - `go run ./cmd/autoloop progress validate` Commit message: diff --git a/docs/content/building-gormes/goncho_honcho_memory/_index.md b/docs/content/building-gormes/goncho_honcho_memory/_index.md index a2eb99a1a..cdb6c7210 100644 --- a/docs/content/building-gormes/goncho_honcho_memory/_index.md +++ b/docs/content/building-gormes/goncho_honcho_memory/_index.md @@ -887,7 +887,7 @@ This file is intentionally incomplete. When you pick it up: - Replication kit: [`01-prompts.md`](./01-prompts) for verbatim prompts and [`02-tool-schemas.md`](./02-tool-schemas) for verbatim Honcho agent tool schemas. - Docs study: [`03-honcho-docs-study.md`](./03-honcho-docs-study) maps Honcho v3 docs to Goncho planner rows. - Goncho service: `internal/goncho/service.go`, `types.go`, `sql.go`. -- Tool layer: `internal/tools/honcho_tools.go`. +- Tool layer: `internal/gonchotools/honcho_tools.go`. - Memory substrate: `internal/memory/` (full Phase 3 inventory in §12.1). - Design spec: `docs/superpowers/specs/2026-04-21-goncho-architecture-design.md`. - Phase 3 ledger: `docs/content/building-gormes/architecture_plan/phase-3-memory.md`. diff --git a/internal/architectureplanneragent_test.go b/internal/architectureplanneragent_test.go index 18aa84bd1..03f7bebcc 100644 --- a/internal/architectureplanneragent_test.go +++ b/internal/architectureplanneragent_test.go @@ -453,8 +453,9 @@ cat > "$final_file" <<'EOF' 2) Feature/doc drift found 3) Documentation updates applied 4) Website updates applied -5) Validation evidence -6) Risks / follow-ups +5) README + install.sh updates +6) Validation evidence +7) Risks / follow-ups EOF printf '{"type":"thread.started","thread_id":"thread-docs-123"}\n' `), 0o755) diff --git a/internal/tools/honcho_tools.go b/internal/gonchotools/honcho_tools.go similarity index 98% rename from internal/tools/honcho_tools.go rename to internal/gonchotools/honcho_tools.go index ebfe93434..279684899 100644 --- a/internal/tools/honcho_tools.go +++ b/internal/gonchotools/honcho_tools.go @@ -1,4 +1,4 @@ -package tools +package gonchotools import ( "context" @@ -8,11 +8,12 @@ import ( "time" "github.com/TrebuchetDynamics/gormes-agent/internal/goncho" + "github.com/TrebuchetDynamics/gormes-agent/internal/tools" ) // RegisterHonchoTools adds the Honcho-compatible tool surface backed by the // in-binary Goncho service. -func RegisterHonchoTools(reg *Registry, svc *goncho.Service) { +func RegisterHonchoTools(reg *tools.Registry, svc *goncho.Service) { if reg == nil { panic("tools: nil registry") } diff --git a/internal/tools/honcho_tools_test.go b/internal/gonchotools/honcho_tools_test.go similarity index 92% rename from internal/tools/honcho_tools_test.go rename to internal/gonchotools/honcho_tools_test.go index 9e5f21cca..d86bb37e4 100644 --- a/internal/tools/honcho_tools_test.go +++ b/internal/gonchotools/honcho_tools_test.go @@ -1,4 +1,4 @@ -package tools +package gonchotools import ( "context" @@ -8,6 +8,7 @@ import ( "github.com/TrebuchetDynamics/gormes-agent/internal/goncho" "github.com/TrebuchetDynamics/gormes-agent/internal/memory" + "github.com/TrebuchetDynamics/gormes-agent/internal/tools" ) func TestHonchoTools_RegisterExpectedNames(t *testing.T) { @@ -108,8 +109,8 @@ func TestHonchoProfileTool_UsesService(t *testing.T) { t.Fatal(err) } - exec := NewInProcessToolExecutor(reg) - ch, err := exec.Execute(ctx, ToolRequest{ + exec := tools.NewInProcessToolExecutor(reg) + ch, err := exec.Execute(ctx, tools.ToolRequest{ ToolName: "honcho_profile", Input: json.RawMessage(`{"peer":"telegram:6586915095"}`), }) @@ -117,7 +118,7 @@ func TestHonchoProfileTool_UsesService(t *testing.T) { t.Fatal(err) } - var outputs []ToolEvent + var outputs []tools.ToolEvent for ev := range ch { outputs = append(outputs, ev) } @@ -142,8 +143,8 @@ func TestHonchoReasoningTool_ReturnsDeterministicAnswer(t *testing.T) { t.Fatal(err) } - exec := NewInProcessToolExecutor(reg) - ch, err := exec.Execute(ctx, ToolRequest{ + exec := tools.NewInProcessToolExecutor(reg) + ch, err := exec.Execute(ctx, tools.ToolRequest{ ToolName: "honcho_reasoning", Input: json.RawMessage(`{ "peer":"telegram:6586915095", @@ -156,7 +157,7 @@ func TestHonchoReasoningTool_ReturnsDeterministicAnswer(t *testing.T) { t.Fatal(err) } - var outputs []ToolEvent + var outputs []tools.ToolEvent for ev := range ch { outputs = append(outputs, ev) } @@ -291,10 +292,10 @@ func seedScopedConclusions(t *testing.T, ctx context.Context, svc *goncho.Servic } } -func executeHonchoTool(t *testing.T, reg *Registry, toolName string, input json.RawMessage) json.RawMessage { +func executeHonchoTool(t *testing.T, reg *tools.Registry, toolName string, input json.RawMessage) json.RawMessage { t.Helper() - ch, err := NewInProcessToolExecutor(reg).Execute(context.Background(), ToolRequest{ + ch, err := tools.NewInProcessToolExecutor(reg).Execute(context.Background(), tools.ToolRequest{ ToolName: toolName, Input: input, }) @@ -302,7 +303,7 @@ func executeHonchoTool(t *testing.T, reg *Registry, toolName string, input json. t.Fatal(err) } - var outputs []ToolEvent + var outputs []tools.ToolEvent for ev := range ch { outputs = append(outputs, ev) } @@ -315,7 +316,7 @@ func executeHonchoTool(t *testing.T, reg *Registry, toolName string, input json. return outputs[1].Output } -func newTestHonchoRegistry(t *testing.T) (*Registry, *goncho.Service, func()) { +func newTestHonchoRegistry(t *testing.T) (*tools.Registry, *goncho.Service, func()) { t.Helper() store, err := memory.OpenSqlite(t.TempDir()+"/memory.db", 0, nil) @@ -323,7 +324,7 @@ func newTestHonchoRegistry(t *testing.T) (*Registry, *goncho.Service, func()) { t.Fatalf("OpenSqlite: %v", err) } - reg := NewRegistry() + reg := tools.NewRegistry() svc := goncho.NewService(store.DB(), goncho.Config{ WorkspaceID: "default", ObserverPeerID: "gormes", diff --git a/internal/kernel/reset_test.go b/internal/kernel/reset_test.go index 563ec1747..2fd7d033e 100644 --- a/internal/kernel/reset_test.go +++ b/internal/kernel/reset_test.go @@ -3,6 +3,8 @@ package kernel import ( "context" "errors" + "io" + "sync" "testing" "time" @@ -72,15 +74,13 @@ func TestKernel_ResetSession_IdleSucceeds(t *testing.T) { // ResetSession must return ErrResetDuringTurn. History is preserved // (the in-flight user turn is still present). func TestKernel_ResetSession_StreamingFails(t *testing.T) { - mc := hermes.NewMockClient() - // Long stream — enough tokens that we can observe PhaseStreaming before - // completion. - events := make([]hermes.Event, 0, 200) - for i := 0; i < 199; i++ { - events = append(events, hermes.Event{Kind: hermes.EventToken, Token: "t", TokensOut: i + 1}) + releaseStream := make(chan struct{}) + mc := &blockingResetClient{ + stream: &blockingResetStream{ + release: releaseStream, + sessionID: "sess-busy", + }, } - events = append(events, hermes.Event{Kind: hermes.EventDone, FinishReason: "stop"}) - mc.Script(events, "sess-busy") k := New(Config{ Model: "hermes-agent", @@ -112,6 +112,7 @@ func TestKernel_ResetSession_StreamingFails(t *testing.T) { if !errors.Is(err, ErrResetDuringTurn) { t.Errorf("ResetSession during Streaming = %v, want ErrResetDuringTurn", err) } + close(releaseStream) // Drain remaining frames until turn completes; history must be preserved // throughout (at least the user message). @@ -123,3 +124,60 @@ func TestKernel_ResetSession_StreamingFails(t *testing.T) { preResetHistoryLen, len(done.History)) } } + +type blockingResetClient struct { + stream *blockingResetStream +} + +func (c *blockingResetClient) OpenStream(context.Context, hermes.ChatRequest) (hermes.Stream, error) { + return c.stream, nil +} + +func (*blockingResetClient) OpenRunEvents(context.Context, string) (hermes.RunEventStream, error) { + return nil, hermes.ErrRunEventsNotSupported +} + +func (*blockingResetClient) Health(context.Context) error { return nil } + +type blockingResetStream struct { + release <-chan struct{} + sessionID string + + mu sync.Mutex + pos int + closed bool +} + +func (s *blockingResetStream) Recv(ctx context.Context) (hermes.Event, error) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return hermes.Event{}, io.EOF + } + pos := s.pos + s.pos++ + s.mu.Unlock() + + switch pos { + case 0: + return hermes.Event{Kind: hermes.EventToken, Token: "t", TokensOut: 1}, nil + case 1: + select { + case <-s.release: + return hermes.Event{Kind: hermes.EventDone, FinishReason: "stop"}, nil + case <-ctx.Done(): + return hermes.Event{}, ctx.Err() + } + default: + return hermes.Event{}, io.EOF + } +} + +func (s *blockingResetStream) SessionID() string { return s.sessionID } + +func (s *blockingResetStream) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = true + return nil +} diff --git a/internal/progress/progress_test.go b/internal/progress/progress_test.go index a6289de94..02ddb622a 100644 --- a/internal/progress/progress_test.go +++ b/internal/progress/progress_test.go @@ -218,8 +218,8 @@ func TestLoad_RealFile_Phase2Ledger(t *testing.T) { t.Fatalf("Phase 2.E.0 = %q, want complete", got) } runtimeNext := p.Phases["2"].Subphases["2.E.1"] - if got := runtimeNext.DerivedStatus(); got != StatusInProgress { - t.Fatalf("Phase 2.E.1 = %q, want in_progress", got) + if got := runtimeNext.DerivedStatus(); got != StatusComplete { + t.Fatalf("Phase 2.E.1 = %q, want complete", got) } runtimeNextItems := itemStatusByName(runtimeNext.Items) for name, want := range map[string]Status{ @@ -227,7 +227,7 @@ func TestLoad_RealFile_Phase2Ledger(t *testing.T) { "Tool-call audit in typed child results": StatusComplete, "Real child Hermes stream loop": StatusComplete, "GBrain minion-orchestrator routing policy": StatusComplete, - "Durable subagent/job ledger": StatusPlanned, + "Durable subagent/job ledger": StatusComplete, } { if got := runtimeNextItems[name]; got != want { t.Errorf("Phase 2.E.1 item %q = %q, want %q", name, got, want) @@ -303,8 +303,8 @@ func TestLoad_RealFile_Phase2ExecutionQueue(t *testing.T) { if e1.Priority != "P0" { t.Fatalf("Phase 2.E.1 priority = %q, want P0", e1.Priority) } - if got := e1.DerivedStatus(); got != StatusInProgress { - t.Fatalf("Phase 2.E.1 = %q, want in_progress", got) + if got := e1.DerivedStatus(); got != StatusComplete { + t.Fatalf("Phase 2.E.1 = %q, want complete", got) } e1Items := itemsByName(e1.Items) policy := e1Items["Runner-enforced tool allowlists + blocked-tool policy"] @@ -339,11 +339,11 @@ func TestLoad_RealFile_Phase2ExecutionQueue(t *testing.T) { t.Fatalf("Phase 2.E.1 minion policy refs/unblocks = refs %v unblocks %v, want GBrain skill ref and durable ledger unblock", minionPolicy.SourceRefs, minionPolicy.Unblocks) } durableLedger := e1Items["Durable subagent/job ledger"] - if durableLedger.Status != StatusPlanned { - t.Fatalf("Phase 2.E.1 durable ledger status = %q, want planned", durableLedger.Status) + if durableLedger.Status != StatusComplete { + t.Fatalf("Phase 2.E.1 durable ledger status = %q, want complete", durableLedger.Status) } - if durableLedger.ContractStatus != ContractStatusDraft || !containsString(durableLedger.BlockedBy, "GBrain minion-orchestrator routing policy") { - t.Fatalf("Phase 2.E.1 durable ledger metadata = contract_status %q blocked_by %v, want draft blocked by minion policy", durableLedger.ContractStatus, durableLedger.BlockedBy) + if durableLedger.ContractStatus != ContractStatusValidated || !strings.Contains(durableLedger.Note, "SQLite-first durable ledger") { + t.Fatalf("Phase 2.E.1 durable ledger metadata = contract_status %q note %q, want validated SQLite-first ledger", durableLedger.ContractStatus, durableLedger.Note) } whatsApp := p.Phases["2"].Subphases["2.B.4"] @@ -979,8 +979,11 @@ func TestLoad_RealFile_Phase3ExecutionQueue(t *testing.T) { t.Fatalf("Phase 3.E.7 tool schema status = %q, want complete", toolSchema.Status) } denyFixtures := crossChatItems["Cross-chat deny-path fixtures"] - if denyFixtures.Status != StatusPlanned { - t.Fatalf("Phase 3.E.7 deny-path fixtures status = %q, want planned", denyFixtures.Status) + if denyFixtures.Status != StatusComplete { + t.Fatalf("Phase 3.E.7 deny-path fixtures status = %q, want complete", denyFixtures.Status) + } + if denyFixtures.ContractStatus != ContractStatusValidated { + t.Fatalf("Phase 3.E.7 deny-path fixtures contract_status = %q, want validated", denyFixtures.ContractStatus) } operatorEvidence3E7 := crossChatItems["Cross-chat operator evidence"] if operatorEvidence3E7.Status != StatusPlanned { @@ -1012,9 +1015,13 @@ func TestLoad_RealFile_Phase3ExecutionQueue(t *testing.T) { t.Fatalf("Phase 3.E.8 priority = %q, want P4", lineage.Priority) } lineageItems := itemsByName(lineage.Items) + gatewayResume := lineageItems["Gateway resume follows compression continuation"] + if gatewayResume.Status != StatusComplete { + t.Fatalf("Phase 3.E.8 gateway resume status = %q, want complete", gatewayResume.Status) + } lineageHits := lineageItems["Lineage-aware source-filtered search hits"] - if lineageHits.Status != StatusPlanned { - t.Fatalf("Phase 3.E.8 lineage-aware search hits status = %q, want planned", lineageHits.Status) + if lineageHits.Status != StatusComplete { + t.Fatalf("Phase 3.E.8 lineage-aware search hits status = %q, want complete", lineageHits.Status) } operatorEvidence := lineageItems["Operator-auditable search evidence"] if operatorEvidence.Status != StatusPlanned { diff --git a/internal/session/index_mirror.go b/internal/session/index_mirror.go index 6197f1184..b7cd1367e 100644 --- a/internal/session/index_mirror.go +++ b/internal/session/index_mirror.go @@ -191,7 +191,7 @@ func (m *SessionIndexMirror) StartRefresh(interval time.Duration, log *slog.Logg r := &SessionIndexMirrorRefresher{ mirror: m, ticker: time.NewTicker(interval), - stop: make(chan struct{}), + stop: make(chan struct{}, 1), log: log, } r.wg.Add(1) diff --git a/www.gormes.ai/internal/site/data/progress.json b/www.gormes.ai/internal/site/data/progress.json index e75fd1254..5e404b95c 100644 --- a/www.gormes.ai/internal/site/data/progress.json +++ b/www.gormes.ai/internal/site/data/progress.json @@ -657,10 +657,10 @@ }, { "name": "Durable subagent/job ledger", - "status": "planned", + "status": "complete", "priority": "P2", "contract": "SQLite-first job ledger records restartable subagent and deterministic work state with claim, progress, result, error, parent-child, and cancellation fields", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "medium", "execution_owner": "orchestrator", "trust_class": [ @@ -692,7 +692,7 @@ "Subagent and cron/deterministic job fixtures use the same ledger contract without changing existing public delegate_task behavior.", "Status or doctor output distinguishes append-only run logs from durable restart/replay support." ], - "note": "Keep this blocked behind the policy row. The first implementation should be the smallest SQLite-first ledger needed for cron/subagent replay; claim/renew/complete/fail/cancel and parent child_done events matter before pause/resume, attachments, supervisor, or full GBrain queue parity.", + "note": "Complete: TDD landed a SQLite-first durable ledger in internal/subagent with submit, claim, claim-by-id, expired-lock reclaim, renew, progress, complete, fail, cancel intent, parent child_done events, and restart/replay status. Cron executor and subagent manager can opt into the same ledger without changing append-only run logs or delegate_task output, and internal/doctor distinguishes append-only logs from durable restart/replay availability.", "write_scope": [ "internal/subagent/", "internal/cron/", @@ -1285,7 +1285,7 @@ "system" ], "degraded_mode": "Memory status and tool schema evidence show when user-scope or source-filtered recall is unavailable.", - "fixture": "internal/tools/honcho_tools_test.go", + "fixture": "internal/gonchotools/honcho_tools_test.go", "source_refs": [ "docs/content/upstream-hermes/gormes-takeaways.md", "docs/content/building-gormes/architecture_plan/phase-3-memory.md", @@ -1308,11 +1308,11 @@ ], "note": "TDD landed: `honcho_search` and `honcho_context` keep their public Honcho-compatible names while their JSON Schemas now advertise optional `scope` and `sources` controls backed by the existing internal GONCHO params. Schema tests assert both fields are discoverable and not required, and executor round-trips without either field preserve same-chat default behavior. Deny-path fixtures and operator evidence remain separate 3.E.7 slices.", "write_scope": [ - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/tools ./internal/goncho -count=1" + "go test ./internal/gonchotools ./internal/goncho -count=1" ], "done_signal": [ "Honcho-compatible tool schema tests prove scope and sources are optional, discoverable, and routed through existing GONCHO params." @@ -1321,9 +1321,9 @@ { "name": "Honcho host integration compatibility fixtures", "priority": "P3", - "status": "planned", + "status": "complete", "contract": "GONCHO preserves host-facing Honcho session, peer, and tool semantics needed by current OpenCode and SillyTavern integrations", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "memory", "trust_class": [ @@ -1331,17 +1331,13 @@ "system" ], "degraded_mode": "Doctor and memory status explain which Honcho-compatible host mappings are unsupported instead of silently accepting incompatible config.", - "fixture": "internal/goncho host integration mapping fixtures", + "fixture": "internal/goncho/host_integration_test.go", "source_refs": [ "../honcho/docs/v3/guides/integrations/opencode.mdx", "../honcho/docs/v3/guides/integrations/sillytavern.mdx", "../honcho/docs/v3/guides/integrations/hermes.mdx", "docs/content/building-gormes/architecture_plan/phase-3-memory.md" ], - "blocked_by": [ - "Honcho-compatible scope/source tool schema", - "Interrupted-turn memory sync suppression" - ], "ready_when": [ "Public honcho_* schemas expose scope/source controls and interrupted turns cannot persist partial memory observations." ], @@ -1353,7 +1349,7 @@ "Host-scoped config keys do not mutate another host's settings.", "GONCHO keeps internal names while generated tool schemas and docs preserve honcho_* external compatibility." ], - "note": "Honcho upstream added OpenCode and SillyTavern integration docs in e659b6b. Gormes should not adopt their Node/Bun plugins, but the internal GONCHO contract must remain compatible with the shared Honcho concepts they depend on: workspace, peer, session strategy, host-scoped config, context-only/tool-only/hybrid recall, and durable conclusion/search tools.", + "note": "TDD landed: internal/goncho host integration fixtures now map Hermes/OpenCode/SillyTavern-style workspace, peer, session strategy, recall mode, and Honcho-compatible external tool naming semantics without adding a cloud Honcho client or renaming the internal Goncho service. Fixtures cover per-directory, per-repo, per-session, chat-instance, and global session keys, context/tools/hybrid recall behavior including context-only and tool-only aliases, host-scoped config patch isolation, and fail-closed diagnostics for unsupported host mappings.", "write_scope": [ "internal/goncho/", "internal/memory/", @@ -1368,9 +1364,9 @@ }, { "name": "Cross-chat deny-path fixtures", - "status": "planned", + "status": "complete", "contract": "Same-chat default recall with explicit user-scope widening", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "memory", "trust_class": [ @@ -1401,7 +1397,7 @@ "Conflicting user bindings deny user-scope widening.", "Allowed user-scope searches include source allowlist evidence." ], - "note": "TDD: prove unknown, unresolved, or conflicting user_id bindings cannot widen recall or session search, and pin same-chat fallback behavior with fixture-backed allow/deny cases before cross-chat access is exposed as shipped.", + "note": "TDD landed: recall and session-search fixtures now deny user-scope widening when the current chat binding is unknown, unresolved, or conflicting; denied paths fall back to same-chat/session behavior, and allowed user-scope GONCHO hits preserve origin_source evidence for source allowlists.", "write_scope": [ "internal/memory/", "internal/goncho/", @@ -1474,9 +1470,9 @@ }, { "name": "Gateway resume follows compression continuation", - "status": "planned", + "status": "complete", "contract": "Gateway and CLI resume resolve a titled or root session to the newest live compression descendant before loading transcript history", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "gateway", "trust_class": [ @@ -1509,7 +1505,7 @@ "Already-current session checks compare against the resolved descendant, not the stale root.", "Transcript loading and session switching use the resolved descendant while operator output still names the requested session." ], - "note": "Upstream Hermes now calls resolve_resume_session_id after resolving a gateway /resume title so compressed roots reopen their live continuation. Gormes should keep this as a gateway/session read-model slice after parent_session_id exists, not as part of compression implementation.", + "note": "Complete: TDD added internal/gateway/resume_continuation_test.go proving stored resume roots follow compression lineage to the newest live descendant before gateway submit, fork children are ignored, unresolved continuation chains fall back to the requested root with visible resume status, and the session context still names the requested stale root while using the resolved descendant as the live SessionID.", "write_scope": [ "internal/gateway/", "internal/session/", @@ -1534,9 +1530,9 @@ }, { "name": "Lineage-aware source-filtered search hits", - "status": "planned", + "status": "complete", "contract": "Session and message search can surface parent/child lineage context for matched sessions without widening the same-chat default recall fence", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "memory", "trust_class": [ @@ -1551,9 +1547,6 @@ "internal/memory/session_catalog.go", "docs/content/building-gormes/architecture_plan/phase-3-memory.md" ], - "blocked_by": [ - "parent_session_id lineage for compression splits" - ], "ready_when": [ "Session lineage metadata is persisted and can be queried from the session read model." ], @@ -1568,7 +1561,7 @@ "Same-chat default recall remains unchanged unless scope=user or source filters explicitly widen it.", "Orphaned or incomplete chains produce explicit evidence fields instead of dropped results." ], - "note": "TDD: once parent_session_id exists, make SearchMessages/SearchSessions expose parent/child lineage context for matched sessions without changing default same-chat recall behavior.", + "note": "Complete: TDD landed internal/memory/session_lineage_search_test.go. SearchMessages and SearchSessions now attach SearchLineage evidence with parent_session_id, lineage_kind, child_session_ids, and explicit ok/orphan/unavailable status without changing the same-chat default recall fence.", "write_scope": [ "internal/memory/", "internal/session/", @@ -1595,9 +1588,9 @@ { "name": "Goncho context representation options", "priority": "P3", - "status": "planned", + "status": "complete", "contract": "honcho_context exposes the Honcho v3 session.context representation controls while preserving current same-chat defaults", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "memory", "trust_class": [ @@ -1612,10 +1605,7 @@ "docs/content/building-gormes/goncho_honcho_memory/03-honcho-docs-study.md", "docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md", "internal/goncho/types.go", - "internal/tools/honcho_tools.go" - ], - "blocked_by": [ - "Honcho-compatible scope/source tool schema" + "internal/gonchotools/honcho_tools.go" ], "ready_when": [ "honcho_context already accepts peer, query, max_tokens, session_key, scope, and sources through the Goncho service." @@ -1633,14 +1623,14 @@ "limit_to_session=true cannot widen recall through scope=user.", "Fields that need the future observation table report explicit unsupported evidence." ], - "note": "Docs study landed from Honcho v3 get-context and representation-scopes docs. This is the smallest public-edge slice before full SDK context parity: add typed option fields and schema visibility, but do not claim that summary, observation, or dialectic-backed representation retrieval is complete.", + "note": "TDD landed: ContextParams and the honcho_context schema expose optional peer_target, peer_perspective, limit_to_session, search_top_k, search_max_distance, include_most_frequent, and max_conclusions. Omitted fields preserve same-chat context defaults; limit_to_session=true fails closed without session_key and cannot widen through scope=user; unsupported directional and semantic representation options return structured unavailable evidence. Summaries, observations, and dialectic-backed retrieval remain separate slices.", "write_scope": [ "internal/goncho/", - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/tools -count=1" + "go test ./internal/goncho ./internal/gonchotools -count=1" ], "done_signal": [ "honcho_context schema and service fixtures prove Honcho v3 context options are discoverable, optional, and visibly degraded when not implemented." @@ -1736,11 +1726,11 @@ "write_scope": [ "internal/goncho/", "internal/memory/", - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/memory ./internal/tools -count=1" + "go test ./internal/goncho ./internal/memory ./internal/gonchotools -count=1" ], "done_signal": [ "Directional peer-card fixtures prove observer/observed isolation, max-card cap, and replacement semantics." @@ -1836,11 +1826,11 @@ "write_scope": [ "internal/goncho/", "internal/memory/", - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/memory ./internal/tools -count=1" + "go test ./internal/goncho ./internal/memory ./internal/gonchotools -count=1" ], "done_signal": [ "Summary context fixtures prove short/long cadence, token-budget allocation, summary=false behavior, and visible degradation when no summary can fit." @@ -1872,7 +1862,7 @@ "docs/content/building-gormes/goncho_honcho_memory/02-tool-schemas.md", "docs/content/building-gormes/goncho_honcho_memory/03-honcho-docs-study.md", "docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md", - "internal/tools/honcho_tools.go", + "internal/gonchotools/honcho_tools.go", "internal/goncho/service.go" ], "blocked_by": [ @@ -1894,11 +1884,11 @@ "note": "Honcho docs expose peer.chat as the slow query-specific reasoning path; newer host integrations call this honcho_chat or chat. Gormes should add the alias and contract before porting the full dialectic tool loop.", "write_scope": [ "internal/goncho/", - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/tools -count=1" + "go test ./internal/goncho ./internal/gonchotools -count=1" ], "done_signal": [ "Chat contract tests prove request validation, default reasoning level, response shape, host-compatible tool alias, and explicit streaming degradation." @@ -1964,9 +1954,9 @@ { "name": "Goncho topology design fixtures", "priority": "P3", - "status": "planned", + "status": "complete", "contract": "Goncho workspace, peer, session, and observation defaults are fixture-locked before more memory behavior is added", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "memory", "trust_class": [ @@ -1987,7 +1977,7 @@ "internal/session/directory.go", "internal/goncho/types.go", "internal/goncho/service.go", - "internal/tools/honcho_tools.go" + "internal/gonchotools/honcho_tools.go" ], "blocked_by": [], "ready_when": [ @@ -2009,15 +1999,15 @@ "Conversation sessions follow real context boundaries such as thread, channel, repo, import batch, or delegated child run.", "Cross-peer observation is opt-in and cannot become a default side effect." ], - "note": "Honcho design-pattern docs show that most memory mistakes start with topology mistakes. This row makes the topology contract executable before adding more context, card, summary, or import behavior.", + "note": "TDD landed: internal/goncho/topology_design_test.go locks the default gormes workspace, rejects workspace-per-user, proves canonical session user IDs before source-prefixed external fallback IDs with evidence, keeps deterministic assistants/transport bots/import helpers unobserved by default, names real session boundaries, and keeps cross-peer observation opt-in.", "write_scope": [ "internal/goncho/", "internal/session/", - "internal/tools/", + "internal/gonchotools/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/session ./internal/tools -count=1" + "go test ./internal/goncho ./internal/session ./internal/gonchotools -count=1" ], "done_signal": [ "Topology fixtures prove the default workspace, peer ID derivation, observation defaults, and session boundary choices expected by the operator playbook." @@ -2107,7 +2097,7 @@ "docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md", "internal/goncho/types.go", "internal/goncho/service.go", - "internal/tools/honcho_tools.go", + "internal/gonchotools/honcho_tools.go", "internal/memory/schema.go" ], "blocked_by": [ @@ -2133,12 +2123,12 @@ "note": "Honcho docs make streaming a chat-response transport detail. Goncho should preserve memory quality by treating only completed assistant messages as durable facts.", "write_scope": [ "internal/goncho/", - "internal/tools/", + "internal/gonchotools/", "internal/memory/", "docs/content/building-gormes/architecture_plan/progress.json" ], "test_commands": [ - "go test ./internal/goncho ./internal/tools ./internal/memory -count=1" + "go test ./internal/goncho ./internal/gonchotools ./internal/memory -count=1" ], "done_signal": [ "Streaming fixtures prove completed responses are persisted once and interrupted or partial chunks cannot pollute memory." From 5203e482a2457ddcd82607a9845ecf6f8d94458c Mon Sep 17 00:00:00 2001 From: xel Date: Fri, 24 Apr 2026 21:18:49 -0600 Subject: [PATCH 3/6] landing: tighten serious hierarchy --- ...-04-24-gormes-landing-serious-hierarchy.md | 89 +++ ...gormes-landing-serious-hierarchy-design.md | 48 ++ www.gormes.ai/internal/site/content.go | 114 +-- www.gormes.ai/internal/site/render_test.go | 96 +-- www.gormes.ai/internal/site/static/site.css | 741 +++++++++--------- .../internal/site/static_export_test.go | 72 +- .../internal/site/templates/index.tmpl | 51 +- .../templates/partials/audience_card.tmpl | 6 - www.gormes.ai/tests/home.spec.mjs | 51 +- 9 files changed, 652 insertions(+), 616 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-24-gormes-landing-serious-hierarchy.md create mode 100644 docs/superpowers/specs/2026-04-24-gormes-landing-serious-hierarchy-design.md delete mode 100644 www.gormes.ai/internal/site/templates/partials/audience_card.tmpl diff --git a/docs/superpowers/plans/2026-04-24-gormes-landing-serious-hierarchy.md b/docs/superpowers/plans/2026-04-24-gormes-landing-serious-hierarchy.md new file mode 100644 index 000000000..c8b654051 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-gormes-landing-serious-hierarchy.md @@ -0,0 +1,89 @@ +# Gormes Landing Serious Hierarchy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Apply the approved serious-infra landing reset to `www.gormes.ai`. + +**Architecture:** The landing remains Go-rendered with embedded templates and static CSS. Content changes live in `internal/site/content.go`; structure changes live in `templates/index.tmpl` and partials; visual hierarchy lives in `static/site.css`; render/export/Playwright tests protect the behavior. + +**Tech Stack:** Go `html/template`, embedded assets, vanilla CSS, Playwright. + +--- + +### Task 1: Lock Expected Landing Behavior + +**Files:** +- Modify: `www.gormes.ai/internal/site/render_test.go` +- Modify: `www.gormes.ai/internal/site/static_export_test.go` +- Modify: `www.gormes.ai/tests/home.spec.mjs` + +- [ ] **Step 1: Update render/export tests first** + +Assert the trimmed nav, serious hero text, no hero image, install command set, feature pain block, roadmap focus block, and stale-copy rejects. + +- [ ] **Step 2: Run tests and verify red** + +Run: + +```bash +cd www.gormes.ai && go test ./internal/site -run 'TestRenderIndex_RendersRedesignedLanding|TestExportDir_WritesStaticSite' -count=1 +``` + +Expected: FAIL because the current page still has the gopher hero, old typography assumptions, and no pain/roadmap summary blocks. + +### Task 2: Update Content And Templates + +**Files:** +- Modify: `www.gormes.ai/internal/site/content.go` +- Modify: `www.gormes.ai/internal/site/templates/layout.tmpl` +- Modify: `www.gormes.ai/internal/site/templates/index.tmpl` +- Modify: `www.gormes.ai/internal/site/templates/partials/roadmap_phase.tmpl` + +- [ ] **Step 1: Remove hero image data and trim nav** + +Drop `HeroImage`, set nav to `Install`, `Roadmap`, `GitHub`, add hero note, feature pain bullets, install source note, docs footer link, and roadmap summary fields. + +- [ ] **Step 2: Rebuild section structure** + +Render hero as a single-column editorial block, add the hero note, install header/source callout, pain block before cards, and roadmap summary before generated phase groups. + +- [ ] **Step 3: Collapse roadmap phase details on mobile** + +Use native `
` for phase groups so small screens can scan status/title without absorbing every item. + +### Task 3: Rewrite CSS Hierarchy + +**Files:** +- Modify: `www.gormes.ai/internal/site/static/site.css` + +- [ ] **Step 1: Apply the typography system** + +Limit display serif to `.hero-title`; use DM Sans for section/card/roadmap text; keep JetBrains Mono for command/code/copy affordances. + +- [ ] **Step 2: Tighten hierarchy and mobile layout** + +Remove hero image layout, shrink paragraph line length, make `Install` dominant, make feature cards sharper, space install steps consistently, and collapse roadmap details under mobile widths. + +### Task 4: Verify + +**Files:** +- Test only + +- [ ] **Step 1: Run Go tests** + +```bash +cd www.gormes.ai && go test ./... +``` + +- [ ] **Step 2: Run Playwright tests** + +```bash +cd www.gormes.ai && npm run test:e2e +``` + +- [ ] **Step 3: Check git diff** + +```bash +git diff --stat +git status --short +``` diff --git a/docs/superpowers/specs/2026-04-24-gormes-landing-serious-hierarchy-design.md b/docs/superpowers/specs/2026-04-24-gormes-landing-serious-hierarchy-design.md new file mode 100644 index 000000000..7cbee8dd7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-gormes-landing-serious-hierarchy-design.md @@ -0,0 +1,48 @@ +# Gormes Landing Serious Hierarchy Design + +**Status:** Approved inline 2026-04-24 +**Owner:** xel + +## Goal + +Tighten `gormes.ai` from a good-but-generic landing page into a serious infrastructure-runtime page with decisive hierarchy on mobile and desktop. + +## Locked Direction + +- Do the visual reset in one cohesive pass. +- Trim primary nav to `Install`, `Roadmap`, and `GitHub`; move `Docs` and `Company` to secondary/footer locations. +- Use Fraunces for the hero headline only. +- Use DM Sans for body, nav, cards, roadmap, and footer. +- Use JetBrains Mono only for code blocks and command copy controls. +- Remove the gopher/bear illustration from the hero. +- Rebalance the hero as a single-column editorial block with tighter line length. +- Make `Install` the dominant CTA and `View Source` a smaller outline secondary action. +- Add expectation-setting copy below the hero: `Early-stage. Built for developers who care about reliability over polish.` +- Make feature cards more technical: tighter padding, stronger title contrast, thinner borders, sharper corners, no soft marketing glow. +- Add an explicit pain block before feature cards: `Hermes breaks in production because:` followed by short operational failure bullets. +- Rework install spacing into clear numbered steps with aligned labels, code blocks, and copy buttons. +- Promote the source-backed installer note so it reads as product truth, not a footnote. +- Add a roadmap summary before the full phase list: + - `Current focus: Gateway stability; Memory system` + - `Next milestone: Full Go-native runtime, no Hermes` +- Keep the full generated roadmap available, but collapse phase details on mobile to reduce overload. + +## Out of Scope + +- No new frontend framework or client-side navigation system. +- No hamburger menu. +- No changes to installer scripts. +- No change to roadmap data generation. +- No generated imagery or social-card redesign. + +## Acceptance Criteria + +- Rendered page uses the serious hero copy and no hero image. +- Primary nav contains only `Install`, `Roadmap`, and `GitHub`. +- CSS encodes the typography rule: Fraunces is only used by `.hero-title`; JetBrains Mono is limited to code/copy command surfaces. +- Feature section includes the operational pain block before cards. +- Install section contains three clear steps and source-backed copy. +- Roadmap includes the current-focus and next-milestone summary before phase groups. +- Mobile Playwright checks show no horizontal overflow and collapsed roadmap details below tablet width. +- `go test ./...` passes in `www.gormes.ai`. +- `npm run test:e2e` passes in `www.gormes.ai`. diff --git a/www.gormes.ai/internal/site/content.go b/www.gormes.ai/internal/site/content.go index 0dc0ade06..5ed1ff946 100644 --- a/www.gormes.ai/internal/site/content.go +++ b/www.gormes.ai/internal/site/content.go @@ -3,7 +3,6 @@ package site import ( "encoding/json" "html/template" - "strconv" ) func binarySizeMB() string { @@ -21,22 +20,6 @@ func binarySizeMB() string { return data.Binary.SizeMB } -func binarySizeMBFloat() float64 { - if len(benchmarksJSON) == 0 { - return 17.0 - } - var data struct { - Binary struct { - SizeMB string `json:"size_mb"` - } `json:"binary"` - } - if err := json.Unmarshal(benchmarksJSON, &data); err != nil { - return 17.0 - } - size, _ := strconv.ParseFloat(data.Binary.SizeMB, 64) - return size -} - type NavLink struct { Label string Href string @@ -57,14 +40,6 @@ type FeatureCard struct { Body string } -// AudienceCard is one "Who Gormes is for" persona row in the audience -// section. Title is a short noun-phrase; Body is one sentence of -// concrete framing so a visitor can self-identify quickly. -type AudienceCard struct { - Title string - Body string -} - // RoadmapItem is one sub-phase or work item inside a RoadmapPhase. // Icon is the glyph shown at the start of the row — "✓" (shipped), // "⏳" (pending), or "◌" (ongoing polish). @@ -96,17 +71,12 @@ type LandingPage struct { Title string Description string Nav []NavLink - FooterNav []NavLink HeroKicker string HeroHeadline string - // HeroSubheadLines is rendered as a stack of short paragraphs — - // three tight lines instead of one dense block, so the operations - // pitch reads as a punch on mobile rather than a wall of prose. - HeroSubheadLines []string - HeroFilterLine string - HeroStatusLine string - PrimaryCTA Link - SecondaryCTA Link + HeroLines []string + HeroFilterLine string + PrimaryCTA Link + SecondaryCTA Link InstallSteps []InstallStep InstallFootnote string InstallFootnoteLink string @@ -115,22 +85,11 @@ type LandingPage struct { DocsLinkLabel string DocsLinkHref string - // "Why Gormes" section: manifesto + pain frame + fix cards. - // All three sub-blocks render under a single #why section so the - // reader gets identity → problem → solution in one visual unit. - WhyLabel string - WhyManifestoLine string - WhyManifestoBullets []string - WhyPainHeadline string - WhyPainBullets []string - WhyFixSubhead string - FeatureCards []FeatureCard - - // "Who Gormes is for" — audience filter section. Three personas - // aimed at production-agent operators, not AI tinkerers. - AudienceLabel string - AudienceHeadline string - AudienceCards []AudienceCard + // "Why Gormes" section: pain frame + technical fix cards. + WhyLabel string + WhyPainHeadline string + WhyPainBullets []string + FeatureCards []FeatureCard // Roadmap section: summary block (current focus + next milestone) // up top, then the full phase-by-phase checklist behind a
@@ -149,7 +108,7 @@ type LandingPage struct { // tag linking to the TrebuchetDynamics company site. Must not // carry user input; DefaultPage is the only writer. FooterLeft template.HTML - FooterRight string + FooterRight template.HTML } func DefaultPage() LandingPage { @@ -161,21 +120,14 @@ func DefaultPage() LandingPage { {Label: "Roadmap", Href: "#roadmap"}, {Label: "GitHub", Href: "https://github.com/TrebuchetDynamics/gormes-agent"}, }, - FooterNav: []NavLink{ - {Label: "Why Gormes", Href: "#why"}, - {Label: "Who it's for", Href: "#audience"}, - {Label: "Docs", Href: "https://docs.gormes.ai/"}, - {Label: "Company", Href: "https://trebuchetdynamics.com/"}, - }, - HeroKicker: "§ 01 · OPEN SOURCE · MIT · UNDER CONSTRUCTION", + HeroKicker: "§ 01 · OPEN SOURCE · MIT LICENSE · UNDER CONSTRUCTION", HeroHeadline: "One Go Binary. No Python. No Drift.", - HeroSubheadLines: []string{ + HeroLines: []string{ "Gormes is a Go-native runtime for AI agents.", "Built to solve the operations problem — not the AI problem.", "One static binary. No virtualenvs. No dependency hell.", }, - HeroFilterLine: "Early-stage. Built for developers who care about reliability over polish.", - HeroStatusLine: "Hermes is no longer required. The full Go runtime is still under active construction.", + HeroFilterLine: "Early-stage, reliability-first runtime. Built for developers who care about reliability over polish.", PrimaryCTA: Link{Label: "Install", Href: "#install"}, SecondaryCTA: Link{Label: "View Source", Href: "https://github.com/TrebuchetDynamics/gormes-agent"}, InstallSteps: []InstallStep{ @@ -183,55 +135,37 @@ func DefaultPage() LandingPage { {Label: "2. WINDOWS POWERSHELL", Command: "irm https://gormes.ai/install.ps1 | iex"}, {Label: "3. RUN", Command: "gormes"}, }, - InstallFootnote: "Installs a prebuilt static binary. Rerun the installer to update.", - InstallFootnoteLink: "Source-backed installer is temporary during early development →", + InstallFootnote: "Source-backed for now. Installers manage a checkout while binary releases settle.", + InstallFootnoteLink: "Read the installer source →", InstallFootnoteHref: "https://github.com/TrebuchetDynamics/gormes-agent/tree/main/scripts", - DocsNote: "Deeper reference material lives at", DocsLinkLabel: "docs.gormes.ai →", DocsLinkHref: "https://docs.gormes.ai/", WhyLabel: "§ 02 · WHY GORMES", - WhyManifestoLine: "Gormes is not about smarter agents.", - WhyManifestoBullets: []string{ - "It's about agents that don't fail to install.", - "It's about agents that don't drift between environments.", - "It's about agents that don't crash after six hours.", - "It's about agents that don't lose work on dropped connections.", - }, - WhyPainHeadline: "Why Hermes-stack agents break in production.", + WhyPainHeadline: "Why Hermes breaks in production — and how Gormes fixes it.", WhyPainBullets: []string{ - "Python environments drift between dev, staging, and prod.", - "npm and Nix builds break silently on host package skew.", - "Multi-process Python orchestration crashes or hangs under load.", - "SSE streams drop on flaky networks and kill long-running agents.", - "Debugging a single failure spans Python, Node, and OS runtimes.", + "environments drift", + "installs fail", + "agents crash mid-run", + "streams drop and lose work", }, - WhyFixSubhead: "How Gormes fixes it.", FeatureCards: []FeatureCard{ {Title: "Single Static Binary", Body: "Zero CGO. ~" + binarySizeMB() + " MB. scp it to Termux, Alpine, a fresh VPS — it runs. No Python, no virtualenv, no Nix."}, {Title: "No Runtime Drift", Body: "Pure Go. No pip, no npm, no env activation. The binary you tested is the binary that deploys."}, {Title: "Streams That Don't Drop", Body: "Route-B reconnect treats SSE drops as recoverable, not fatal. Your agent doesn't lose work to a flaky network."}, {Title: "Local Validation", Body: "gormes doctor --offline checks tool schemas before you burn tokens. Catch bad wiring before a model round-trip."}, }, - AudienceLabel: "§ 03 · WHO GORMES IS FOR", - AudienceHeadline: "Production-runtime concerns, not AI demos.", - AudienceCards: []AudienceCard{ - {Title: "Operators of long-running agents", Body: "You need agents that survive restarts, network blips, and host upgrades — not just impressive demos."}, - {Title: "Developers tired of Python/Nix/npm breakage", Body: "You're tired of an agent that worked yesterday breaking today because a transitive dep ticked over."}, - {Title: "Builders who want one binary that just runs", Body: "You'd rather scp one file to a Termux session or Alpine VPS than reproduce a virtualenv."}, - }, RoadmapLabel: "§ 04 · BUILD STATE", RoadmapHeadline: "What works today, and what's still being wired up.", RoadmapCurrentFocus: []string{ - "Gateway stability — Slack shared runtime, WhatsApp, WeChat adapters.", - "Memory system — SQLite + FTS5 lattice, ontological graph, neural recall.", - "Brain transplant — replacing the Hermes runtime with a Go-native agent loop.", + "Gateway stability", + "Memory system", }, - RoadmapNextMilestone: "Fully independent Go-native brain — agent orchestration with no Hermes backend.", + RoadmapNextMilestone: "Full Go-native runtime, no Hermes", RoadmapDetailsSummary: "View full phase-by-phase checklist", ProgressTracker: progressTrackerLabel(), ProgressTrackerURL: "https://docs.gormes.ai/building-gormes/architecture_plan/", RoadmapPhases: buildRoadmapPhases(loadEmbeddedProgress()), FooterLeft: `Gormes v0.1.0 · TrebuchetDynamics`, - FooterRight: "MIT License · 2026", + FooterRight: `docs.gormes.ai → · MIT License · 2026`, } } diff --git a/www.gormes.ai/internal/site/render_test.go b/www.gormes.ai/internal/site/render_test.go index ae3a1a362..9e63ef7e1 100644 --- a/www.gormes.ai/internal/site/render_test.go +++ b/www.gormes.ai/internal/site/render_test.go @@ -15,67 +15,55 @@ func TestRenderIndex_RendersRedesignedLanding(t *testing.T) { text := string(body) wants := []string{ - // Hero — multi-line subhead (3 punchy lines), filter line for - // audience self-selection, status line tucked below CTAs as - // tertiary, no hero illustration. - "OPEN SOURCE · MIT", + // Hero — serious infrastructure framing with short mobile lines. + "OPEN SOURCE · MIT LICENSE", "UNDER CONSTRUCTION", "One Go Binary. No Python. No Drift.", - `class="hero-subhead-line"`, "Gormes is a Go-native runtime for AI agents.", - "Built to solve the operations problem — not the AI problem.", + "Built to solve the operations problem", "One static binary. No virtualenvs. No dependency hell.", - `class="hero-filter"`, - "Early-stage. Built for developers who care about reliability over polish.", - `class="hero-status"`, - "Hermes is no longer required. The full Go runtime is still under active construction.", - // CTA hierarchy — primary dominant, ghost secondary. - `class="btn btn-primary"`, - `class="btn btn-ghost"`, - // Install — footnote rewritten for clarity (prebuilt binary, - // source-backed installer is temporary). + "Early-stage, reliability-first runtime.", + "Built for developers who care about reliability over polish.", + `class="hero-note"`, + // Trimmed primary nav. + `Install`, + `Roadmap`, + `GitHub`, + // Install — clearer structure and promoted source-backed note. + "INSTALL", + "Source-backed installers. One managed checkout.", "1. UNIX / MACOS / TERMUX", "curl -fsSL https://gormes.ai/install.sh | sh", "2. WINDOWS POWERSHELL", "irm https://gormes.ai/install.ps1 | iex", "3. RUN", - "Installs a prebuilt static binary", - "Source-backed installer is temporary during early development →", + "Source-backed for now", + "Read the installer source →", // Copy button (clipboard JS is allowed for this widget only) `class="copy-btn"`, "navigator.clipboard.writeText", - // Why Gormes — manifesto + pain frame + fix cards under one section. - `id="why"`, + // Features — pain frame before technical cards. "WHY GORMES", - "Gormes is not about smarter agents.", - "It's about agents that don't fail to install.", - "It's about agents that don't crash after six hours.", - "Why Hermes-stack agents break in production.", - "Python environments drift between dev, staging, and prod.", - "SSE streams drop on flaky networks and kill long-running agents.", - "How Gormes fixes it.", + "Why Hermes breaks in production — and how Gormes fixes it.", + "Hermes breaks in production because:", + "environments drift", + "installs fail", + "agents crash mid-run", + "streams drop and lose work", "Single Static Binary", "No Runtime Drift", "Streams That Don't Drop", "Local Validation", "Route-B reconnect treats SSE drops", "gormes doctor --offline", - // Audience filter — "Who Gormes is for" personas. - `id="audience"`, - "WHO GORMES IS FOR", - "Operators of long-running agents", - "Developers tired of Python/Nix/npm breakage", - "Builders who want one binary that just runs", - // Roadmap section — summary block (current focus + next milestone) - // up top, full phase checklist behind a
disclosure. + // Roadmap section — summary block first, full generated checklist collapsed. "BUILD STATE", "What works today, and what's still being wired up.", "Current focus", - "Next milestone", "Gateway stability", "Memory system", - "Brain transplant", - "Fully independent Go-native brain", + "Next milestone", + "Full Go-native runtime, no Hermes", `
`, "View full phase-by-phase checklist", // Fuzzy phase-title presence (each phase renders) @@ -92,17 +80,10 @@ func TestRenderIndex_RendersRedesignedLanding(t *testing.T) { "roadmap-item-shipped", // Structural class anchors "roadmap-phase", - // Footer — brand text + license. Footer-nav now carries the - // secondary links (Why Gormes, Who it's for, Docs, Company) so - // the topnav can stay minimal (Install / Roadmap / GitHub). + // Footer — brand text + company anchor + license `Gormes v0.1.0 · TrebuchetDynamics`, - "MIT License · 2026", - `class="footer-nav"`, - `Company`, - `Docs`, - // In-page note pointing at the Hugo docs site - "Deeper reference material lives at", `docs.gormes.ai →`, + "MIT License · 2026", // CSS link `href="/static/site.css"`, // Favicons — full set wired into . @@ -122,6 +103,10 @@ func TestRenderIndex_RendersRedesignedLanding(t *testing.T) { `name="twitter:image" content="https://gormes.ai/static/social-card.png"`, } rejects := []string{ + `
`, + `go-gopher-bear-lowpoly.png`, + `Docs`, + `Company`, "Run Hermes Through a Go Operator Console.", "Hermes, In a Single Static Binary.", "Requires Hermes backend at localhost:8642.", @@ -141,23 +126,13 @@ func TestRenderIndex_RendersRedesignedLanding(t *testing.T) { "Boots Like a Tool", "In-Process Tool Loop", "Survives Dropped Streams", - // Operations-first rewrite that buried the "what is Gormes for" - // answer behind lineage detail. Replaced with "Go-native runtime - // for AI agents" framing. + // Older revisions that buried the first-screen hierarchy. "Gormes is a Go-native rewrite of Hermes Agent — built to solve the operations problem, not the AI problem.", - "Why Hermes breaks in production — and how Gormes fixes it.", + "Gormes is a Go-native runtime for AI agents — built to fix", + "Why Hermes-stack agents break in production.", "Rerun the installer to update the managed Gormes checkout.", "Source-backed for now →", "not production-ready yet", - // v2 single-paragraph subhead replaced by 3-line stack. - "Gormes is a Go-native runtime for AI agents — built to fix the reliability and deployment problems", - // Hero illustration removed in v3 — assert the gopher PNG and - // the .hero-image / .hero-content flex wrappers no longer ship. - `alt="Gormes Gopher"`, - "go-gopher-bear-lowpoly.png", - `class="hero-image"`, - `class="hero-content"`, - `class="btn-secondary"`, // Obsolete single-row ledger copy replaced by grouped roadmap "Phase 3 — SQLite + FTS5 transcript memory.", "Phase 3.A–C — SQLite + FTS5 lattice, ontological graph, neural recall.", @@ -198,7 +173,6 @@ func TestEmbeddedTemplates_ArePresentAndParse(t *testing.T) { "templates/index.tmpl", "templates/partials/install_step.tmpl", "templates/partials/feature_card.tmpl", - "templates/partials/audience_card.tmpl", "templates/partials/roadmap_phase.tmpl", } @@ -217,7 +191,7 @@ func TestEmbeddedTemplates_ArePresentAndParse(t *testing.T) { t.Fatalf("parseTemplates: %v", err) } - for _, want := range []string{"layout", "index", "install_step", "feature_card", "audience_card", "roadmap_phase"} { + for _, want := range []string{"layout", "index", "install_step", "feature_card", "roadmap_phase"} { if templates.Lookup(want) == nil { t.Fatalf("parsed templates missing %q", want) } diff --git a/www.gormes.ai/internal/site/static/site.css b/www.gormes.ai/internal/site/static/site.css index b60262ca8..c33d95ee7 100644 --- a/www.gormes.ai/internal/site/static/site.css +++ b/www.gormes.ai/internal/site/static/site.css @@ -1,34 +1,29 @@ -/* gormes.ai — operator's manual meets editorial quarterly. - Fraunces (variable serif) for display, JetBrains Mono for technical - surfaces, DM Sans for body. Amber accent, dark bed, paper grain. */ +/* gormes.ai landing - serious runtime hierarchy. */ :root { --font-display: 'Fraunces', 'Iowan Old Style', Georgia, serif; - --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --font-body: 'DM Sans', -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif; - --bg-0: #0a0d11; - --bg-1: #121720; - --bg-2: #1a1f29; - --border: #1e232e; - --border-strong: #2a3140; + --bg-0: #090b0f; + --bg-1: #10141b; + --bg-2: #161b24; + --border: #232935; + --border-strong: #343d4d; - --text: #ebe9e2; - --muted: rgba(235, 233, 226, 0.62); - --muted-strong: rgba(235, 233, 226, 0.80); - --label: rgba(235, 233, 226, 0.48); + --text: #eeece5; + --muted: rgba(238, 236, 229, 0.62); + --muted-strong: rgba(238, 236, 229, 0.80); + --label: rgba(238, 236, 229, 0.48); - --accent: #e8c547; - --accent-hover: #f0d66c; - --accent-ink: #1a1300; + --accent: #f0c84b; + --accent-hover: #ffd968; + --accent-ink: #171000; + --danger: #df6f67; - --status-shipped-bg: #0e3b21; --status-shipped-fg: #5be79a; - --status-progress-bg: #0e2b3b; --status-progress-fg: #5bc7e7; - --status-next-bg: #3b2c0e; --status-next-fg: #e7c25b; - --status-later-bg: #1f2434; --status-later-fg: #8a99c7; --max-width: 880px; @@ -37,7 +32,8 @@ * { box-sizing: border-box; } -html, body { +html, +body { margin: 0; padding: 0; background: var(--bg-0); @@ -50,25 +46,31 @@ html, body { font-feature-settings: 'kern', 'liga', 'calt'; } -/* Paper grain. Inline SVG noise, barely-visible overlay, pointer-events - pass-through. Uses soft-light blend so it warms the surface without - touching saturation. */ .grain { position: fixed; inset: 0; pointer-events: none; z-index: 100; - opacity: 0.05; + opacity: 0.035; mix-blend-mode: overlay; background-image: url("data:image/svg+xml;utf8,"); } -a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.15s ease, color 0.15s ease; } -a:hover { color: var(--accent-hover); border-bottom-color: currentColor; } +a { + color: var(--accent); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.15s ease, color 0.15s ease; +} +a:hover { + color: var(--accent-hover); + border-bottom-color: currentColor; +} a:focus-visible, .btn:focus-visible, -.copy-btn:focus-visible { +.copy-btn:focus-visible, +.roadmap-details-summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 2px; @@ -81,51 +83,53 @@ a:focus-visible, padding-right: env(safe-area-inset-right); } -/* ── Topbar ────────────────────────────────────────────────────────── */ -.topbar { border-bottom: 1px solid var(--border); } +.topbar { + border-bottom: 1px solid var(--border); +} .topbar-inner { padding: 18px var(--pad); display: flex; justify-content: space-between; align-items: center; - gap: 16px; + gap: 18px; } .brand { - font-family: var(--font-display); - font-weight: 900; - font-size: 22px; - font-variation-settings: "opsz" 144, "SOFT" 0; - letter-spacing: -0.01em; + font-family: var(--font-body); + font-weight: 700; + font-size: 20px; color: var(--text); border: none; } -.brand:hover { color: var(--accent); border: none; } +.brand:hover { + color: var(--accent); + border: none; +} .topnav { display: flex; - gap: 0; + flex-wrap: wrap; + justify-content: flex-end; + gap: 18px; align-items: center; } .topnav a { - font-family: var(--font-mono); - font-size: 11px; - font-weight: 500; - letter-spacing: 0.14em; + font-size: 12px; + font-weight: 700; text-transform: uppercase; color: var(--muted); - margin-left: 24px; border: none; padding: 4px 0; } -.topnav a:hover { color: var(--text); border: none; } +.topnav a:hover { + color: var(--text); + border: none; +} -/* ── Kickers & section titles ──────────────────────────────────────── */ .kicker { - font-family: var(--font-mono); - font-size: 10px; - font-weight: 500; - letter-spacing: 0.22em; + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; color: var(--label); - margin: 0 0 20px; + margin: 0 0 18px; text-transform: uppercase; } @@ -133,133 +137,134 @@ a:focus-visible, font-family: var(--font-body); font-weight: 700; font-size: 26px; - line-height: 1.15; - letter-spacing: -0.02em; - margin: 0 0 20px; + line-height: 1.12; + margin: 0 0 24px; color: var(--text); + overflow-wrap: break-word; } -/* ── Hero ──────────────────────────────────────────────────────────── */ -/* Editorial typography lane: Fraunces *only* on the hero title and the - brand wordmark. Everything else (section titles, card titles, body) - uses DM Sans. Mono is reserved for kickers, code, and CTAs. */ +.hero { + border-bottom: 1px solid var(--border); +} .hero-inner { - padding: 96px var(--pad) 72px; - display: block; - max-width: 720px; + padding: 76px var(--pad) 58px; +} +.hero-content { + max-width: 680px; + min-width: 0; } .hero-title { font-family: var(--font-display); font-weight: 900; - font-size: clamp(40px, 8vw, 64px); - line-height: 0.96; - letter-spacing: -0.028em; - margin: 0 0 28px; + font-size: clamp(38px, 8vw, 66px); + line-height: 0.98; + margin: 0 0 24px; overflow-wrap: break-word; - font-variation-settings: "opsz" 144, "SOFT" 30; + font-variation-settings: "opsz" 144, "SOFT" 18; color: var(--text); } .hero-subhead { - margin: 0 0 22px; - max-width: 620px; + margin: 0 0 16px; + max-width: 560px; } -.hero-subhead-line { +.hero-subhead p { font-size: 17px; - color: var(--text); - line-height: 1.5; + color: var(--muted-strong); margin: 0 0 8px; + line-height: 1.48; overflow-wrap: break-word; } -.hero-subhead-line:last-child { margin-bottom: 0; } -.hero-subhead-line:nth-child(2) { color: var(--muted-strong); } -.hero-subhead-line:nth-child(3) { color: var(--muted-strong); font-size: 15.5px; } - -.hero-filter { - font-family: var(--font-mono); - font-size: 12px; - letter-spacing: 0.02em; - color: var(--muted); - margin: 0 0 32px; - max-width: 620px; +.hero-note { + font-size: 13px; + color: var(--muted-strong); + margin: 22px 0 30px; + max-width: 520px; line-height: 1.5; border-left: 2px solid var(--accent); - padding: 4px 0 4px 12px; + background: var(--bg-1); + padding: 12px 14px; } -.hero-ctas { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; margin: 0 0 28px; } -.hero-status { - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.04em; - color: var(--label); - margin: 0; - max-width: 620px; - line-height: 1.5; +.hero-ctas { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; } -/* Buttons. Primary is the dominant CTA — filled accent, larger - padding, stronger weight. Ghost (formerly secondary) is a subtle - outline-less link with muted color so it sits below the primary in - the visual hierarchy. */ .btn { display: inline-flex; align-items: center; - gap: 8px; - font-family: var(--font-mono); + justify-content: center; + border-radius: 2px; + border: 1px solid transparent; + font-family: var(--font-body); font-weight: 700; - letter-spacing: 0.14em; text-transform: uppercase; - border-radius: 3px; - border: 1px solid transparent; - transition: background 0.15s, border-color 0.15s, color 0.15s; + transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s; +} +.btn:hover { + transform: translateY(-1px); + border-bottom: 1px solid transparent; } .btn-primary { background: var(--accent); color: var(--accent-ink); font-size: 13px; - padding: 14px 26px; + padding: 14px 24px; } -.btn-primary:hover { background: var(--accent-hover); color: var(--accent-ink); } -.btn-ghost { - background: transparent; - color: var(--muted); - font-size: 11px; - padding: 10px 4px; - letter-spacing: 0.16em; - border-bottom: 1px solid transparent; +.btn-primary:hover { + background: var(--accent-hover); + color: var(--accent-ink); } -.btn-ghost:hover { color: var(--accent); border-bottom-color: var(--accent); } -/* Legacy secondary class — still rendered by the .btn-secondary - selector elsewhere (e.g. cached pages) until the deploy lands. */ .btn-secondary { background: transparent; - color: var(--muted); - font-size: 11px; - padding: 10px 4px; + color: var(--muted-strong); + border-color: var(--border-strong); + font-size: 12px; + padding: 10px 16px; +} +.btn-secondary:hover { + border-color: var(--accent); + color: var(--accent); } -.btn-secondary:hover { color: var(--accent); } -/* ── Install ───────────────────────────────────────────────────────── */ -.install { border-top: 2px solid var(--border-strong); } -.install-inner { padding: 44px var(--pad); } -.install-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.install { + border-bottom: 1px solid var(--border); +} +.install-inner { + padding: 50px var(--pad); +} +.install-header { + max-width: 560px; + margin-bottom: 24px; +} +.install-grid { + display: grid; + gap: 16px; +} .install-step { - display: flex; - flex-direction: column; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 2px; + padding: 16px; + display: grid; gap: 10px; min-width: 0; } .install-step .kicker { margin: 0; color: var(--accent); - font-weight: 700; } -.cmd-wrap { position: relative; min-width: 0; } +.cmd-wrap { + position: relative; + min-width: 0; +} .cmd { background: var(--bg-1); - border: 1px solid var(--border); - padding: 16px 92px 16px 16px; - border-radius: 3px; + border: 1px solid var(--border-strong); + padding: 15px 92px 15px 15px; + border-radius: 2px; font-family: var(--font-mono); font-size: 12.5px; margin: 0; @@ -268,7 +273,10 @@ a:focus-visible, overflow-x: auto; color: var(--text); } -.cmd code { color: var(--text); } +.cmd code { + font-family: var(--font-mono); + color: var(--text); +} .copy-btn { position: absolute; @@ -277,13 +285,12 @@ a:focus-visible, display: inline-flex; align-items: center; gap: 6px; - background: transparent; + background: var(--bg-2); border: 1px solid var(--border-strong); - color: var(--muted); + color: var(--muted-strong); font-family: var(--font-mono); font-size: 10px; font-weight: 700; - letter-spacing: 0.12em; text-transform: uppercase; padding: 6px 10px; border-radius: 2px; @@ -291,325 +298,276 @@ a:focus-visible, min-height: 32px; transition: background 0.15s, border-color 0.15s, color 0.15s; } -.copy-btn:hover { color: var(--accent); border-color: var(--accent); } +.copy-btn:hover { + color: var(--accent); + border-color: var(--accent); +} .copy-btn.copied { - background: var(--status-shipped-bg); + background: rgba(91, 231, 154, 0.12); color: var(--status-shipped-fg); - border-color: var(--status-shipped-bg); + border-color: rgba(91, 231, 154, 0.4); +} +.copy-btn svg { + display: block; +} +.copy-label { + line-height: 1; } -.copy-btn svg { display: block; } -.copy-label { line-height: 1; } .install-footnote { font-size: 13px; color: var(--muted-strong); - margin: 24px 0 0; - font-family: var(--font-mono); - letter-spacing: 0.01em; - overflow-wrap: break-word; - padding: 12px 14px; + margin: 20px 0 0; + line-height: 1.5; + padding: 14px 16px; + border: 1px solid var(--border); background: var(--bg-1); - border-left: 2px solid var(--accent); -} -.install-footnote a { color: var(--accent); font-weight: 700; } -.docs-note { - font-size: 12px; - color: var(--label); - margin: 14px 0 0; - font-family: var(--font-mono); - letter-spacing: 0.01em; + border-radius: 2px; overflow-wrap: break-word; } -.docs-note a { color: var(--accent); } - -/* ── Why Gormes ────────────────────────────────────────────────────── */ -.why { border-top: 2px solid var(--border-strong); } -.why-inner { padding: 64px var(--pad); } -.why-manifesto { +.why { + border-bottom: 1px solid var(--border); +} +.why-inner { + padding: 58px var(--pad); +} +.pain-block { border: 1px solid var(--border); - border-left: 3px solid var(--accent); - padding: 22px 24px 24px; - border-radius: 0; + border-left: 3px solid var(--danger); background: var(--bg-1); - margin: 18px 0 44px; + border-radius: 2px; + padding: 18px 20px; + margin: 0 0 20px; } -.why-manifesto-line { - font-family: var(--font-body); - font-size: 19px; +.pain-block p { font-weight: 700; - letter-spacing: -0.015em; - line-height: 1.25; - margin: 0 0 14px; color: var(--text); -} -.why-manifesto-list { - list-style: none; - padding: 0; - margin: 0; - display: grid; - gap: 6px; -} -.why-manifesto-list li { - font-size: 14px; - color: var(--muted-strong); - line-height: 1.6; - padding-left: 18px; - position: relative; -} -.why-manifesto-list li::before { - content: '✓'; - color: var(--accent); - position: absolute; - left: 0; - font-weight: 700; -} - -/* Pain block — distinct visual treatment from the fix cards. Red-accent - left rule + slightly darker bg so it reads as the emotional spike - before the solution, not "more cards". */ -.why-pain { - border: 1px solid var(--border); - border-left: 3px solid #d77; - background: var(--bg-2); - padding: 22px 24px 26px; - border-radius: 0; - margin: 0 0 44px; -} -.why-pain-headline { - font-size: 22px; - margin: 0 0 14px; + margin: 0 0 10px; } .why-pain-list { list-style: none; padding: 0; margin: 0; display: grid; - gap: 8px; + gap: 6px; } .why-pain-list li { font-size: 14px; color: var(--muted-strong); - line-height: 1.55; - padding-left: 22px; + line-height: 1.5; + padding-left: 18px; position: relative; } .why-pain-list li::before { - content: '✗'; - color: #d77; + content: '-'; + color: var(--danger); position: absolute; left: 0; font-weight: 700; } -.why-fix-subhead { - font-family: var(--font-body); - font-size: 16px; - font-weight: 700; - letter-spacing: -0.005em; - text-transform: uppercase; - letter-spacing: 0.06em; - margin: 0 0 18px; - color: var(--accent); +.features-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; } - -.features-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } -/* Cards: brutal/technical, not soft-marketing. No glow, no hover lift, - sharp corners, tight padding, strong title:body weight contrast. */ .feature-card { background: var(--bg-1); border: 1px solid var(--border); - border-top: 2px solid var(--accent); - padding: 20px 22px; - border-radius: 0; + padding: 18px 18px 20px; + border-radius: 2px; min-width: 0; position: relative; + transition: border-color 0.15s ease, transform 0.15s ease; +} +.feature-card::before { + content: ''; + position: absolute; + top: -1px; + left: 16px; + width: 36px; + height: 1px; + background: var(--accent); +} +.feature-card:hover { + border-color: var(--border-strong); + transform: translateY(-1px); } .feature-card h3 { font-family: var(--font-body); - margin: 0 0 8px; - font-size: 15px; + margin: 0 0 10px; + font-size: 16px; font-weight: 700; - letter-spacing: -0.005em; overflow-wrap: break-word; color: var(--text); - text-transform: none; +} +.feature-card h3::after { + content: ''; + display: block; + width: 100%; + height: 1px; + margin-top: 10px; + background: var(--border); } .feature-card p { margin: 0; - font-size: 13px; + font-size: 13.5px; color: var(--muted-strong); line-height: 1.55; overflow-wrap: break-word; } -/* ── Audience ──────────────────────────────────────────────────────── */ -.audience { border-top: 2px solid var(--border-strong); } -.audience-inner { padding: 64px var(--pad); } -.audience-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 18px; - margin-top: 18px; -} -.audience-card { - background: var(--bg-1); - border: 1px solid var(--border); - border-top: 2px solid var(--border-strong); - padding: 20px 22px; - border-radius: 0; -} -.audience-card-title { - font-family: var(--font-body); - font-weight: 700; - font-size: 14.5px; - margin: 0 0 8px; - color: var(--text); - letter-spacing: -0.005em; +.roadmap { + border-bottom: 1px solid var(--border); } -.audience-card-body { - font-size: 13px; - color: var(--muted-strong); - line-height: 1.55; - margin: 0; +.roadmap-inner { + padding: 58px var(--pad); } - -/* ── Roadmap ───────────────────────────────────────────────────────── */ -.roadmap { border-top: 2px solid var(--border-strong); } -.roadmap-inner { padding: 64px var(--pad); } - .roadmap-summary { display: grid; - grid-template-columns: 1.4fr 1fr; - gap: 20px; - margin: 18px 0 28px; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin: 0 0 22px; } .roadmap-summary-block { background: var(--bg-1); border: 1px solid var(--border); - border-radius: 4px; - padding: 22px 24px; + border-radius: 2px; + padding: 18px 20px; } .roadmap-summary-heading { - font-family: var(--font-mono); - font-size: 11.5px; - letter-spacing: 0.08em; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; text-transform: uppercase; - color: var(--muted); - margin: 0 0 12px; + color: var(--label); + margin: 0 0 10px; } .roadmap-summary-list { list-style: none; padding: 0; margin: 0; display: grid; - gap: 8px; + gap: 7px; } -.roadmap-summary-list li { +.roadmap-summary-list li, +.roadmap-summary-milestone { font-size: 14px; color: var(--muted-strong); - line-height: 1.55; + line-height: 1.5; + margin: 0; +} +.roadmap-summary-list li { padding-left: 16px; position: relative; } .roadmap-summary-list li::before { - content: '→'; + content: '-'; color: var(--accent); position: absolute; left: 0; -} -.roadmap-summary-milestone { - font-size: 14.5px; - color: var(--text); - line-height: 1.55; - margin: 0; + font-weight: 700; } .roadmap-details { margin: 0 0 18px; } .roadmap-details-summary { - font-family: var(--font-mono); - font-size: 13px; - letter-spacing: 0.04em; + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; color: var(--accent); cursor: pointer; - padding: 14px 18px; + padding: 14px 16px; background: var(--bg-1); - border: 1px solid var(--border); - border-radius: 4px; + border: 1px solid rgba(240, 200, 75, 0.45); + border-radius: 2px; list-style: none; user-select: none; - transition: border-color 0.2s ease, color 0.2s ease; + transition: border-color 0.15s ease, color 0.15s ease; +} +.roadmap-details-summary::-webkit-details-marker { + display: none; } -.roadmap-details-summary::-webkit-details-marker { display: none; } .roadmap-details-summary::before { - content: '▸ '; + content: '+'; display: inline-block; - margin-right: 6px; - transition: transform 0.2s ease; + width: 16px; + color: var(--muted); } .roadmap-details[open] .roadmap-details-summary::before { - content: '▾ '; + content: '-'; } .roadmap-details-summary:hover { border-color: var(--border-strong); color: var(--accent-hover); } .roadmap-details[open] .roadmap-details-summary { - margin-bottom: 16px; + margin-bottom: 14px; border-color: var(--border-strong); } -.roadmap-phases { display: grid; gap: 16px; } - +.roadmap-phases { + display: grid; + gap: 12px; +} .roadmap-phase { background: var(--bg-1); border: 1px solid var(--border); border-left: 3px solid var(--border-strong); - border-radius: 4px; - padding: 22px 24px 24px; + border-radius: 2px; + padding: 18px 20px 20px; min-width: 0; - transition: border-left-color 0.2s ease; } -.roadmap-phase:has(.roadmap-status-shipped) { border-left-color: var(--status-shipped-fg); } -.roadmap-phase:has(.roadmap-status-progress) { border-left-color: var(--status-progress-fg); } -.roadmap-phase:has(.roadmap-status-planned) { border-left-color: var(--status-next-fg); } -.roadmap-phase:has(.roadmap-status-later) { border-left-color: var(--status-later-fg); } - +.roadmap-phase:has(.roadmap-status-shipped) { + border-left-color: var(--status-shipped-fg); +} +.roadmap-phase:has(.roadmap-status-progress) { + border-left-color: var(--status-progress-fg); +} +.roadmap-phase:has(.roadmap-status-planned) { + border-left-color: var(--status-next-fg); +} +.roadmap-phase:has(.roadmap-status-later) { + border-left-color: var(--status-later-fg); +} .roadmap-phase-header { display: flex; flex-wrap: wrap; align-items: baseline; - gap: 10px 14px; - margin-bottom: 18px; - padding-bottom: 14px; - border-bottom: 1px dashed var(--border); + gap: 10px 12px; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); } - -/* Stamp-style status pill — outline, monospace, letter-spaced. */ .roadmap-status { - font-family: var(--font-mono); + font-family: var(--font-body); font-size: 10px; font-weight: 700; - padding: 4px 10px; + padding: 4px 9px; border-radius: 2px; - letter-spacing: 0.14em; flex-shrink: 0; white-space: nowrap; border: 1px solid currentColor; background: transparent; } -.roadmap-status-shipped { color: var(--status-shipped-fg); } -.roadmap-status-progress { color: var(--status-progress-fg); } -.roadmap-status-planned { color: var(--status-next-fg); } -.roadmap-status-later { color: var(--status-later-fg); } - +.roadmap-status-shipped { + color: var(--status-shipped-fg); +} +.roadmap-status-progress { + color: var(--status-progress-fg); +} +.roadmap-status-planned { + color: var(--status-next-fg); +} +.roadmap-status-later { + color: var(--status-later-fg); +} .roadmap-title { font-family: var(--font-body); font-size: 17px; font-weight: 700; - letter-spacing: -0.01em; margin: 0; color: var(--text); overflow-wrap: break-word; @@ -619,113 +577,122 @@ a:focus-visible, font-size: 13px; color: var(--muted-strong); margin: 0; - line-height: 1.55; - font-style: italic; + line-height: 1.5; overflow-wrap: break-word; } - .roadmap-items { list-style: none; margin: 0; padding: 0; display: grid; - gap: 8px; + gap: 7px; } .roadmap-item { display: flex; align-items: flex-start; - gap: 12px; - font-family: var(--font-mono); - font-size: 12px; + gap: 10px; + font-size: 12.5px; color: var(--muted-strong); - line-height: 1.6; + line-height: 1.5; min-width: 0; } .roadmap-icon { flex-shrink: 0; width: 16px; text-align: center; - line-height: 1.6; + line-height: 1.5; +} +.roadmap-item-shipped .roadmap-icon { + color: var(--status-shipped-fg); +} +.roadmap-item-pending .roadmap-icon { + color: var(--muted); +} +.roadmap-item-ongoing .roadmap-icon { + color: var(--status-next-fg); } -.roadmap-item-shipped .roadmap-icon { color: var(--status-shipped-fg); } -.roadmap-item-pending .roadmap-icon { color: var(--muted); } -.roadmap-item-ongoing .roadmap-icon { color: var(--status-next-fg); } .roadmap-label { min-width: 0; flex: 1; overflow-wrap: anywhere; } - -/* ── Footer ────────────────────────────────────────────────────────── */ -.footer { border-top: 2px solid var(--border-strong); } -.footer-inner { - padding: 36px var(--pad) 28px; - display: flex; - flex-direction: column; - gap: 18px; -} -.footer-nav { - display: flex; - flex-wrap: wrap; - gap: 0 24px; - row-gap: 8px; -} -.footer-nav a { - font-family: var(--font-mono); - font-size: 11px; - font-weight: 500; - letter-spacing: 0.14em; - text-transform: uppercase; +.roadmap-footer { + margin: 0; + font-size: 13px; color: var(--muted); - border: none; - padding: 4px 0; } -.footer-nav a:hover { color: var(--text); border: none; } -.footer-meta { + +.footer { + border-bottom: 1px solid transparent; +} +.footer-inner { + padding: 26px var(--pad); display: flex; justify-content: space-between; + align-items: center; gap: 14px; - flex-wrap: wrap; - border-top: 1px solid var(--border); - padding-top: 14px; } .footer p { margin: 0; - font-size: 11px; + font-size: 12px; color: var(--label); - font-family: var(--font-mono); - letter-spacing: 0.04em; } -/* ── Responsive ────────────────────────────────────────────────────── */ @media (max-width: 640px) { - .install-grid { grid-template-columns: 1fr; } - .features-grid { grid-template-columns: 1fr; } - .audience-grid { grid-template-columns: 1fr; } - .roadmap-summary { grid-template-columns: 1fr; } - .topnav a { margin-left: 14px; } - .footer-inner { flex-direction: column; align-items: flex-start; gap: 8px; } + .topbar-inner { + flex-wrap: wrap; + align-items: flex-start; + padding: 14px var(--pad); + } + .topnav { + width: 100%; + justify-content: flex-start; + gap: 14px; + } + .hero-inner { + padding: 54px var(--pad) 42px; + } + .hero-title { + font-size: clamp(34px, 13vw, 46px); + } + .hero-subhead p { + font-size: 16px; + } + .hero-ctas .btn-primary { + flex: 1 1 100%; + } + .install-inner, + .why-inner, + .roadmap-inner { + padding: 42px var(--pad); + } + .features-grid, + .roadmap-summary { + grid-template-columns: 1fr; + } + .footer-inner { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } } -@media (max-width: 480px) { - .topbar-inner { flex-wrap: wrap; gap: 10px 0; padding: 14px var(--pad); } - .topnav { flex-wrap: wrap; gap: 4px 14px; } - .topnav a { margin-left: 0; } - .cmd { padding-right: 80px; } - .hero-inner { padding: 64px var(--pad) 48px; } - .hero-title { font-size: clamp(34px, 9vw, 44px); } - .hero-subhead-line { font-size: 16px; } - .why-inner { padding: 52px var(--pad); } - .audience-inner { padding: 52px var(--pad); } - .roadmap-inner { padding: 52px var(--pad); } - .hero-ctas { gap: 10px; } - .btn-primary { flex: 1 1 auto; justify-content: center; text-align: center; } - .roadmap-phase { padding: 18px 18px 20px; } - .footer-meta { flex-direction: column; gap: 6px; } +@media (max-width: 420px) { + :root { + --pad: 22px; + } + .cmd { + padding-right: 82px; + } + .copy-btn { + padding: 6px 8px; + } } @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { + *, + *::before, + *::after { transition: none !important; animation-duration: 0.001ms !important; } diff --git a/www.gormes.ai/internal/site/static_export_test.go b/www.gormes.ai/internal/site/static_export_test.go index 0a9049fbd..f39545397 100644 --- a/www.gormes.ai/internal/site/static_export_test.go +++ b/www.gormes.ai/internal/site/static_export_test.go @@ -23,28 +23,31 @@ func TestExportDir_WritesStaticSite(t *testing.T) { wants := []string{ "One Go Binary. No Python. No Drift.", "Gormes is a Go-native runtime for AI agents.", - "Built to solve the operations problem — not the AI problem.", + "Built to solve the operations problem", "One static binary. No virtualenvs. No dependency hell.", - "Early-stage. Built for developers who care about reliability over polish.", - "Hermes is no longer required. The full Go runtime is still under active construction.", - `class="btn btn-primary"`, - `class="btn btn-ghost"`, - `class="footer-nav"`, + "Early-stage, reliability-first runtime.", + "Built for developers who care about reliability over polish.", + `Install`, + `Roadmap`, + `GitHub`, "curl -fsSL https://gormes.ai/install.sh | sh", "irm https://gormes.ai/install.ps1 | iex", - "Installs a prebuilt static binary", - // Why-Gormes section: manifesto + pain frame + fix cards. - "Gormes is not about smarter agents.", - "Why Hermes-stack agents break in production.", - "Python environments drift between dev, staging, and prod.", - "How Gormes fixes it.", - // Audience filter - "WHO GORMES IS FOR", - "Operators of long-running agents", + "Source-backed for now", + "Read the installer source →", + // Features: pain frame before technical fix cards. + "Why Hermes breaks in production — and how Gormes fixes it.", + "Hermes breaks in production because:", + "environments drift", + "installs fail", + "agents crash mid-run", + "streams drop and lose work", // Roadmap summary + collapse "What works today, and what's still being wired up.", "Current focus", + "Gateway stability", + "Memory system", "Next milestone", + "Full Go-native runtime, no Hermes", "View full phase-by-phase checklist", `
`, // Favicons + social-card meta tags rendered in . @@ -67,6 +70,10 @@ func TestExportDir_WritesStaticSite(t *testing.T) { "Phase 6", } rejects := []string{ + `
`, + `go-gopher-bear-lowpoly.png`, + `Docs`, + `Company`, "Run Hermes Through a Go Operator Console.", "Hermes, In a Single Static Binary.", "Requires Hermes backend at localhost:8642.", @@ -87,18 +94,12 @@ func TestExportDir_WritesStaticSite(t *testing.T) { "A static Go binary that talks to your Hermes backend over HTTP.", "Why a Go layer matters.", "Boots Like a Tool", - // Operations-first v1 copy that buried "what is Gormes for" behind lineage + // Older revisions that buried the first-screen hierarchy. "Gormes is a Go-native rewrite of Hermes Agent — built to solve the operations problem, not the AI problem.", - "Why Hermes breaks in production — and how Gormes fixes it.", + "Gormes is a Go-native runtime for AI agents — built to fix", + "Why Hermes-stack agents break in production.", "Rerun the installer to update the managed Gormes checkout.", "Source-backed for now →", - // v2 single-paragraph subhead replaced by 3-line stack. - "Gormes is a Go-native runtime for AI agents — built to fix the reliability and deployment problems", - // Hero illustration removed in v3. - `alt="Gormes Gopher"`, - `class="hero-image"`, - `class="hero-content"`, - `class="btn-secondary"`, } for _, want := range wants { if !strings.Contains(text, want) { @@ -130,6 +131,29 @@ func TestExportDir_WritesStaticSite(t *testing.T) { if !strings.Contains(string(css), "--bg-0") { t.Fatalf("site.css missing --bg-0 design token") } + cssText := string(css) + for _, want := range []string{ + ".hero-title {\n font-family: var(--font-display);", + ".section-title {\n font-family: var(--font-body);", + ".feature-card h3 {\n font-family: var(--font-body);", + ".feature-card h3::after {", + ".install-step {\n background: var(--bg-1);", + ".roadmap-title {\n font-family: var(--font-body);", + ".cmd {\n background: var(--bg-1);", + } { + if !strings.Contains(cssText, want) { + t.Fatalf("site.css missing typography/layout contract %q", want) + } + } + if strings.Contains(cssText, ".section-title {\n font-family: var(--font-display);") { + t.Fatalf("section titles should not use display serif") + } + if strings.Contains(cssText, ".feature-card h3 {\n font-family: var(--font-display);") { + t.Fatalf("feature titles should not use display serif") + } + if strings.Contains(cssText, ".roadmap-title {\n font-family: var(--font-display);") { + t.Fatalf("roadmap titles should not use display serif") + } // Favicon set + OG social card must land in dist/static/. Guarding // against regressions in the embed list — if a new icon is added it diff --git a/www.gormes.ai/internal/site/templates/index.tmpl b/www.gormes.ai/internal/site/templates/index.tmpl index df4580657..ae062bffe 100644 --- a/www.gormes.ai/internal/site/templates/index.tmpl +++ b/www.gormes.ai/internal/site/templates/index.tmpl @@ -1,65 +1,50 @@ {{define "index"}}
-

{{.HeroKicker}}

-

{{.HeroHeadline}}

-
- {{range .HeroSubheadLines}}

{{.}}

{{end}} -
-

{{.HeroFilterLine}}

-
- {{.PrimaryCTA.Label}} - {{.SecondaryCTA.Label}} +
+

{{.HeroKicker}}

+

{{.HeroHeadline}}

+
+ {{range .HeroLines}}

{{.}}

{{end}} +
+

{{.HeroFilterLine}}

+
-

{{.HeroStatusLine}}

+
+

INSTALL

+

Source-backed installers. One managed checkout.

+
{{range .InstallSteps}}{{template "install_step" .}}{{end}}

{{.InstallFootnote}} {{.InstallFootnoteLink}}

-

{{.DocsNote}} {{.DocsLinkLabel}}

{{.WhyLabel}}

- -
-

{{.WhyManifestoLine}}

-
    - {{range .WhyManifestoBullets}}
  • {{.}}
  • {{end}} -
-
- -
-

{{.WhyPainHeadline}}

+

{{.WhyPainHeadline}}

+
+

Hermes breaks in production because:

    {{range .WhyPainBullets}}
  • {{.}}
  • {{end}}
- -

{{.WhyFixSubhead}}

{{range .FeatureCards}}{{template "feature_card" .}}{{end}}
-
-
-

{{.AudienceLabel}}

-

{{.AudienceHeadline}}

-
- {{range .AudienceCards}}{{template "audience_card" .}}{{end}} -
-
-
-

{{.RoadmapLabel}}

diff --git a/www.gormes.ai/internal/site/templates/partials/audience_card.tmpl b/www.gormes.ai/internal/site/templates/partials/audience_card.tmpl deleted file mode 100644 index 021871d1f..000000000 --- a/www.gormes.ai/internal/site/templates/partials/audience_card.tmpl +++ /dev/null @@ -1,6 +0,0 @@ -{{define "audience_card"}} -
-

{{.Title}}

-

{{.Body}}

-
-{{end}} diff --git a/www.gormes.ai/tests/home.spec.mjs b/www.gormes.ai/tests/home.spec.mjs index e1e98114c..dffe0c1ca 100644 --- a/www.gormes.ai/tests/home.spec.mjs +++ b/www.gormes.ai/tests/home.spec.mjs @@ -3,15 +3,37 @@ import { test, expect } from '@playwright/test'; test('homepage renders the redesigned landing', async ({ page }) => { await page.goto('/'); - await expect(page).toHaveTitle('Gormes — One Go Binary. Same Hermes Brain.'); - await expect(page.getByRole('heading', { name: 'One Go Binary. Same Hermes Brain.' })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Why a Go layer matters.' })).toBeVisible(); - await expect(page.getByRole('heading', { name: "What ships now, what doesn't." })).toBeVisible(); + await expect(page).toHaveTitle('Gormes — One Go Binary. No Python. No Drift.'); + await expect(page.getByRole('heading', { name: 'One Go Binary. No Python. No Drift.' })).toBeVisible(); + await expect(page.getByText('Gormes is a Go-native runtime for AI agents.')).toBeVisible(); + await expect(page.getByText('Built to solve the operations problem')).toBeVisible(); + await expect(page.getByText('One static binary. No virtualenvs. No dependency hell.')).toBeVisible(); + await expect(page.getByText('Early-stage, reliability-first runtime.')).toBeVisible(); + await expect(page.getByText('Built for developers who care about reliability over polish.')).toBeVisible(); + await expect(page.locator('.topnav a')).toHaveText(['Install', 'Roadmap', 'GitHub']); + await expect(page.locator('.hero-image')).toHaveCount(0); + await expect(page.locator('img[src="/static/go-gopher-bear-lowpoly.png"]')).toHaveCount(0); + await expect(page.locator('.hero-ctas .btn-primary')).toHaveText('Install'); + await expect(page.locator('.hero-ctas .btn-secondary')).toHaveText('View Source'); + await expect(page.getByRole('heading', { name: 'Why Hermes breaks in production — and how Gormes fixes it.' })).toBeVisible(); + await expect(page.getByText('Hermes breaks in production because:')).toBeVisible(); + await expect(page.getByText('environments drift')).toBeVisible(); + await expect(page.getByText('installs fail')).toBeVisible(); + await expect(page.getByText('agents crash mid-run')).toBeVisible(); + await expect(page.getByText('streams drop and lose work')).toBeVisible(); + await expect(page.getByRole('heading', { name: "What works today, and what's still being wired up." })).toBeVisible(); + await expect(page.getByText('Current focus')).toBeVisible(); + await expect(page.getByText('Gateway stability')).toBeVisible(); + await expect(page.getByText('Memory system')).toBeVisible(); + await expect(page.getByText('Next milestone')).toBeVisible(); + await expect(page.getByText('Full Go-native runtime, no Hermes')).toBeVisible(); await expect(page.getByText('curl -fsSL https://gormes.ai/install.sh | sh')).toBeVisible(); await expect(page.getByText('irm https://gormes.ai/install.ps1 | iex')).toBeVisible(); - await expect(page.getByText('Rerun the installer to update the managed Gormes checkout.')).toBeVisible(); + await expect(page.getByText('Source-backed for now')).toBeVisible(); + await expect(page.getByText('Read the installer source →')).toBeVisible(); await expect(page.getByText('Requires Hermes backend at localhost:8642.')).toHaveCount(0); await expect(page.getByText('Run Hermes Through a Go Operator Console.')).toHaveCount(0); + await expect(page.getByText('Deeper reference material lives at')).toHaveCount(0); await expect(page.locator('link[href="/static/site.css"]')).toHaveCount(1); // Copy buttons require a tiny inline clipboard script — bounded to install steps. // Three steps now: Unix install, Windows install, run. @@ -34,22 +56,19 @@ for (const vp of MOBILE_VIEWPORTS) { await page.setViewportSize({ width: vp.width, height: vp.height }); await page.goto('/'); - await expect(page.getByRole('heading', { name: 'One Go Binary. Same Hermes Brain.' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'One Go Binary. No Python. No Drift.' })).toBeVisible(); await expect(page.getByText('curl -fsSL https://gormes.ai/install.sh | sh')).toBeVisible(); const heroLayout = await page.evaluate(() => { const content = document.querySelector('.hero-content')?.getBoundingClientRect(); - const image = document.querySelector('.hero-image')?.getBoundingClientRect(); + const title = document.querySelector('.hero-title')?.getBoundingClientRect(); return { contentWidth: content?.width ?? 0, - contentBottom: content?.bottom ?? 0, - imageTop: image?.top ?? 0, + titleWidth: title?.width ?? 0, }; }); expect(heroLayout.contentWidth, `hero content collapsed at ${vp.width}px`).toBeGreaterThan(vp.width * 0.6); - expect(heroLayout.imageTop, `hero image should stack below copy at ${vp.width}px`).toBeGreaterThanOrEqual( - heroLayout.contentBottom - 1, - ); + expect(heroLayout.titleWidth, `hero title too wide at ${vp.width}px`).toBeLessThanOrEqual(vp.width); // The page itself must never generate a horizontal scrollbar. Long code // blocks get their own scroll inside .cmd via overflow-x: auto. @@ -74,7 +93,7 @@ for (const vp of MOBILE_VIEWPORTS) { expect(box.width, `copy button ${i} too narrow at ${vp.width}px`).toBeGreaterThanOrEqual(28); } - // The roadmap has 6 phase groups with expanded sub-items. No phase + // The roadmap has 7 phase groups under a single disclosure. No phase // card or roadmap item should overflow its container on any mobile // viewport — long sub-item labels (4.A Provider adapters has ~100 // chars, Phase 5 collapsed row has ~200 chars) must wrap cleanly. @@ -88,7 +107,9 @@ for (const vp of MOBILE_VIEWPORTS) { }); expect(overflowingNodes, 'roadmap nodes overflow their container').toHaveLength(0); - // All six phase groups must be visible. - await expect(page.locator('.roadmap-phase')).toHaveCount(6); + // All seven phase groups are present in the generated roadmap, but the + // full checklist starts collapsed so mobile users get a clear entry point. + await expect(page.locator('.roadmap-phase')).toHaveCount(7); + await expect(page.locator('.roadmap-details')).not.toHaveAttribute('open', ''); }); } From 7fa1116ae88411892dc8fcced2caa3961e407f16 Mon Sep 17 00:00:00 2001 From: xel Date: Fri, 24 Apr 2026 21:20:04 -0600 Subject: [PATCH 4/6] feat(autoloop): per-run health accumulator --- internal/autoloop/health_writer.go | 182 ++++++++++++++++++++ internal/autoloop/health_writer_test.go | 214 ++++++++++++++++++++++++ 2 files changed, 396 insertions(+) create mode 100644 internal/autoloop/health_writer.go create mode 100644 internal/autoloop/health_writer_test.go diff --git a/internal/autoloop/health_writer.go b/internal/autoloop/health_writer.go new file mode 100644 index 000000000..85bf47fc9 --- /dev/null +++ b/internal/autoloop/health_writer.go @@ -0,0 +1,182 @@ +package autoloop + +import ( + "strconv" + "time" + + "github.com/TrebuchetDynamics/gormes-agent/internal/progress" +) + +// SpecHashProvider returns the current spec hash for a row, used when a +// Flush triggers a new quarantine (the SpecHash is stamped at quarantine +// creation so future selection passes can detect spec drift). Pass nil +// when the caller has no live progress doc to hash from. +type SpecHashProvider func(phaseID, subphaseID, itemName string) string + +type rowKey struct { + phaseID string + subphaseID string + itemName string +} + +type pendingHealth struct { + successes int + failures int + lastCategory progress.FailureCategory + lastBackend string + lastStderr string + staleClear bool + contract string // captured for diagnostics +} + +type healthAccumulator struct { + runID string + now func() time.Time + threshold int + rows map[rowKey]*pendingHealth +} + +func newHealthAccumulator(runID string, now func() time.Time, threshold int) *healthAccumulator { + if threshold <= 0 { + threshold = 3 + } + return &healthAccumulator{ + runID: runID, + now: now, + threshold: threshold, + rows: map[rowKey]*pendingHealth{}, + } +} + +func (a *healthAccumulator) get(c Candidate) *pendingHealth { + key := rowKey{c.PhaseID, c.SubphaseID, c.ItemName} + p, ok := a.rows[key] + if !ok { + p = &pendingHealth{contract: c.Contract} + a.rows[key] = p + } + return p +} + +// RecordSuccess marks one successful worker outcome for the candidate. +func (a *healthAccumulator) RecordSuccess(c Candidate) { + a.get(c).successes++ +} + +// RecordFailure marks one failed worker outcome for the candidate. +func (a *healthAccumulator) RecordFailure(c Candidate, cat progress.FailureCategory, backend, stderrTail string) { + p := a.get(c) + p.failures++ + p.lastCategory = cat + p.lastBackend = backend + p.lastStderr = capStderrTail(stderrTail, 2048) +} + +// MarkStaleQuarantine records that L3 selection treated this candidate as +// stale-quarantined (spec hash mismatch). Used at flush to clear the block +// and reset ConsecutiveFailures so the planner-repaired row gets fresh runway. +func (a *healthAccumulator) MarkStaleQuarantine(c Candidate) { + a.get(c).staleClear = true +} + +// Flush applies all accumulated mutations to progress.json in one batched +// write. The mutate closures own all the quarantine math; this is the single +// place where ConsecutiveFailures is incremented or reset. Pass hashOf=nil +// when no live progress doc is available (Quarantine.SpecHash will be empty +// in that case; selection's stale-clear logic will then mark such quarantines +// stale on the next pass since SpecHash="" rarely matches a real ItemSpecHash). +func (a *healthAccumulator) Flush(progressPath string, hashOf SpecHashProvider) error { + if len(a.rows) == 0 { + return nil + } + + now := a.now().UTC().Format(time.RFC3339) + updates := make([]progress.HealthUpdate, 0, len(a.rows)) + for key, pending := range a.rows { + p := pending + k := key + updates = append(updates, progress.HealthUpdate{ + PhaseID: k.phaseID, + SubphaseID: k.subphaseID, + ItemName: k.itemName, + Mutate: func(h *progress.RowHealth) { + a.applyMutation(h, p, k, now, hashOf) + }, + }) + } + + return progress.ApplyHealthUpdates(progressPath, updates) +} + +func (a *healthAccumulator) applyMutation(h *progress.RowHealth, p *pendingHealth, k rowKey, now string, hashOf SpecHashProvider) { + // Stale-quarantine clear: reset both block and counter, do NOT touch LastSuccess. + if p.staleClear && h.Quarantine != nil { + h.Quarantine = nil + h.ConsecutiveFailures = 0 + } + + if p.failures > 0 { + h.AttemptCount += p.failures + h.LastAttempt = now + h.LastFailure = &progress.FailureSummary{ + RunID: a.runID, + Category: p.lastCategory, + Backend: p.lastBackend, + StderrTail: p.lastStderr, + } + if p.lastBackend != "" && !containsBackend(h.BackendsTried, p.lastBackend) { + h.BackendsTried = append(h.BackendsTried, p.lastBackend) + } + } + + if p.successes > 0 { + h.AttemptCount += p.successes + h.LastAttempt = now + h.LastSuccess = now + h.ConsecutiveFailures = 0 + h.Quarantine = nil + return + } + + if p.failures > 0 { + h.ConsecutiveFailures += p.failures + } + + if h.Quarantine == nil && h.ConsecutiveFailures >= a.threshold && p.failures > 0 { + specHash := "" + if hashOf != nil { + specHash = hashOf(k.phaseID, k.subphaseID, k.itemName) + } + h.Quarantine = &progress.Quarantine{ + Reason: quarantineReason(h.ConsecutiveFailures, p.lastCategory), + Since: now, + AfterRunID: a.runID, + Threshold: a.threshold, + SpecHash: specHash, + LastCategory: p.lastCategory, + } + } +} + +func containsBackend(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +} + +func capStderrTail(s string, max int) string { + if len(s) <= max { + return s + } + return s[len(s)-max:] +} + +func quarantineReason(consecutive int, cat progress.FailureCategory) string { + if cat == "" { + return "auto: " + strconv.Itoa(consecutive) + " consecutive failures" + } + return "auto: " + strconv.Itoa(consecutive) + " consecutive failures, last category " + string(cat) +} diff --git a/internal/autoloop/health_writer_test.go b/internal/autoloop/health_writer_test.go new file mode 100644 index 000000000..41c1390b2 --- /dev/null +++ b/internal/autoloop/health_writer_test.go @@ -0,0 +1,214 @@ +package autoloop + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/TrebuchetDynamics/gormes-agent/internal/progress" +) + +func writeBaseProgress(t *testing.T, path string) { + t.Helper() + body := `{ + "version": "1", + "phases": { + "2": { + "name": "P", + "subphases": { + "2.B": { + "name": "S", + "items": [ + {"name": "row-1", "status": "planned", "contract": "do x"}, + {"name": "row-2", "status": "planned", "contract": "do y"} + ] + } + } + } + } +} +` + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write: %v", err) + } +} + +func fixedNow() func() time.Time { + t := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC) + return func() time.Time { return t } +} + +func candidateOf(phase, sub, item, contract string) Candidate { + return Candidate{ + PhaseID: phase, + SubphaseID: sub, + ItemName: item, + Contract: contract, + } +} + +func TestHealthAccumulator_RecordSuccessSetsLastSuccessAndResetsCounter(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "progress.json") + writeBaseProgress(t, path) + + acc := newHealthAccumulator("run-A", fixedNow(), 3) + acc.RecordSuccess(candidateOf("2", "2.B", "row-1", "do x")) + if err := acc.Flush(path, nil); err != nil { + t.Fatalf("Flush: %v", err) + } + + prog, err := progress.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + row := prog.Phases["2"].Subphases["2.B"].Items[0] + if row.Health == nil { + t.Fatal("row.Health should be set") + } + if row.Health.LastSuccess != "2026-04-24T12:00:00Z" { + t.Fatalf("LastSuccess = %q", row.Health.LastSuccess) + } + if row.Health.ConsecutiveFailures != 0 { + t.Fatalf("ConsecutiveFailures should be 0, got %d", row.Health.ConsecutiveFailures) + } + if row.Health.Quarantine != nil { + t.Fatalf("Quarantine should be nil after success, got %+v", row.Health.Quarantine) + } +} + +func TestHealthAccumulator_RecordFailureIncrementsConsecutive(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "progress.json") + writeBaseProgress(t, path) + + acc := newHealthAccumulator("run-A", fixedNow(), 3) + acc.RecordFailure(candidateOf("2", "2.B", "row-1", "do x"), progress.FailureWorkerError, "codexu", "boom") + if err := acc.Flush(path, nil); err != nil { + t.Fatalf("Flush: %v", err) + } + + prog, _ := progress.Load(path) + row := prog.Phases["2"].Subphases["2.B"].Items[0] + if row.Health.ConsecutiveFailures != 1 { + t.Fatalf("ConsecutiveFailures = %d, want 1", row.Health.ConsecutiveFailures) + } + if row.Health.AttemptCount != 1 { + t.Fatalf("AttemptCount = %d, want 1", row.Health.AttemptCount) + } + if row.Health.Quarantine != nil { + t.Fatalf("should not quarantine on first failure, got %+v", row.Health.Quarantine) + } +} + +func TestHealthAccumulator_QuarantinesAfterThresholdConsecutiveFailures(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "progress.json") + writeBaseProgress(t, path) + + // Pre-load existing health: 2 consecutive failures already. + if err := progress.ApplyHealthUpdates(path, []progress.HealthUpdate{{ + PhaseID: "2", + SubphaseID: "2.B", + ItemName: "row-1", + Mutate: func(h *progress.RowHealth) { + h.AttemptCount = 2 + h.ConsecutiveFailures = 2 + }, + }}); err != nil { + t.Fatalf("seed: %v", err) + } + + acc := newHealthAccumulator("run-A", fixedNow(), 3) + acc.RecordFailure(candidateOf("2", "2.B", "row-1", "do x"), progress.FailureReportValidation, "codexu", "report parse failed") + if err := acc.Flush(path, nil); err != nil { + t.Fatalf("Flush: %v", err) + } + + prog, _ := progress.Load(path) + row := prog.Phases["2"].Subphases["2.B"].Items[0] + if row.Health.ConsecutiveFailures != 3 { + t.Fatalf("ConsecutiveFailures = %d, want 3", row.Health.ConsecutiveFailures) + } + if row.Health.Quarantine == nil { + t.Fatal("expected Quarantine to be set after threshold") + } + if row.Health.Quarantine.LastCategory != progress.FailureReportValidation { + t.Fatalf("Quarantine.LastCategory = %q", row.Health.Quarantine.LastCategory) + } + if row.Health.Quarantine.SpecHash != "" { + t.Fatalf("Quarantine.SpecHash should be empty when hashOf=nil, got %q", row.Health.Quarantine.SpecHash) + } +} + +func TestHealthAccumulator_SuccessAfterFailuresClearsQuarantine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "progress.json") + writeBaseProgress(t, path) + + // Pre-quarantine the row. + if err := progress.ApplyHealthUpdates(path, []progress.HealthUpdate{{ + PhaseID: "2", + SubphaseID: "2.B", + ItemName: "row-1", + Mutate: func(h *progress.RowHealth) { + h.ConsecutiveFailures = 3 + h.Quarantine = &progress.Quarantine{Reason: "auto", Threshold: 3, SpecHash: "abc"} + }, + }}); err != nil { + t.Fatalf("seed: %v", err) + } + + acc := newHealthAccumulator("run-A", fixedNow(), 3) + acc.RecordSuccess(candidateOf("2", "2.B", "row-1", "do x")) + if err := acc.Flush(path, nil); err != nil { + t.Fatalf("Flush: %v", err) + } + + prog, _ := progress.Load(path) + row := prog.Phases["2"].Subphases["2.B"].Items[0] + if row.Health.Quarantine != nil { + t.Fatalf("Quarantine should be cleared after success, got %+v", row.Health.Quarantine) + } + if row.Health.ConsecutiveFailures != 0 { + t.Fatalf("ConsecutiveFailures should reset on success, got %d", row.Health.ConsecutiveFailures) + } +} + +func TestHealthAccumulator_StaleQuarantineClearsAndResetsCounter(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "progress.json") + writeBaseProgress(t, path) + + // Quarantine row-1 with a SpecHash that does NOT match its current spec. + if err := progress.ApplyHealthUpdates(path, []progress.HealthUpdate{{ + PhaseID: "2", + SubphaseID: "2.B", + ItemName: "row-1", + Mutate: func(h *progress.RowHealth) { + h.ConsecutiveFailures = 5 + h.Quarantine = &progress.Quarantine{Reason: "auto", Threshold: 3, SpecHash: "stale-hash"} + }, + }}); err != nil { + t.Fatalf("seed: %v", err) + } + + acc := newHealthAccumulator("run-A", fixedNow(), 3) + acc.MarkStaleQuarantine(candidateOf("2", "2.B", "row-1", "do x")) + if err := acc.Flush(path, nil); err != nil { + t.Fatalf("Flush: %v", err) + } + + prog, _ := progress.Load(path) + row := prog.Phases["2"].Subphases["2.B"].Items[0] + if row.Health.Quarantine != nil { + t.Fatalf("Quarantine should be cleared after stale-quarantine signal, got %+v", row.Health.Quarantine) + } + if row.Health.ConsecutiveFailures != 0 { + t.Fatalf("ConsecutiveFailures should reset on stale-clear, got %d", row.Health.ConsecutiveFailures) + } +} From 2e33214c7e3c0ab9a79fe1adba669549a006c00e Mon Sep 17 00:00:00 2001 From: xel Date: Fri, 24 Apr 2026 21:21:43 -0600 Subject: [PATCH 5/6] fix(site): align footer template with landing content --- www.gormes.ai/internal/site/templates/layout.tmpl | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/www.gormes.ai/internal/site/templates/layout.tmpl b/www.gormes.ai/internal/site/templates/layout.tmpl index 3bbd5da52..d69d6d848 100644 --- a/www.gormes.ai/internal/site/templates/layout.tmpl +++ b/www.gormes.ai/internal/site/templates/layout.tmpl @@ -45,13 +45,8 @@
From 00f69dd106b9469f884657715285f9d392aab34b Mon Sep 17 00:00:00 2001 From: xel Date: Fri, 24 Apr 2026 21:30:55 -0600 Subject: [PATCH 6/6] fix(config): add goncho namespace --- cmd/gormes/doctor.go | 43 +++ cmd/gormes/goncho_doctor_test.go | 58 ++++ cmd/gormes/telegram.go | 8 +- .../architecture_plan/progress.json | 6 +- internal/config/config.go | 202 +++++++++++- internal/config/goncho_config_test.go | 289 ++++++++++++++++++ internal/goncho/config_test.go | 58 ++++ internal/goncho/service.go | 3 +- internal/goncho/types.go | 86 +++++- 9 files changed, 738 insertions(+), 15 deletions(-) create mode 100644 cmd/gormes/goncho_doctor_test.go create mode 100644 internal/config/goncho_config_test.go create mode 100644 internal/goncho/config_test.go diff --git a/cmd/gormes/doctor.go b/cmd/gormes/doctor.go index 6c3df9f2f..28fcca295 100644 --- a/cmd/gormes/doctor.go +++ b/cmd/gormes/doctor.go @@ -49,6 +49,7 @@ var doctorCmd = &cobra.Command{ reg := buildDefaultRegistry(context.Background(), cfg.Delegation, cfg.SkillsRoot(), nil, cfg.Hermes.Model) result := doctor.CheckTools(reg) fmt.Print(result.Format()) + fmt.Print(doctorGonchoConfig(cfg).Format()) if cfg.Telegram.BotToken == "" && !cfg.Discord.Enabled() { fmt.Println("[WARN] gateway: no channels configured ([telegram] or [discord])") @@ -80,3 +81,45 @@ var doctorCmd = &cobra.Command{ return nil }, } + +func doctorGonchoConfig(cfg config.Config) doctor.CheckResult { + g := cfg.Goncho + items := []doctor.ItemInfo{ + { + Name: "runtime", + Status: doctor.StatusPass, + Note: fmt.Sprintf("recent_messages=%d max_message_size=%d max_file_size=%d get_context_max_tokens=%d", + g.RecentMessages, g.MaxMessageSize, g.MaxFileSize, g.GetContextMaxTokens), + }, + { + Name: "features", + Status: doctor.StatusPass, + Note: fmt.Sprintf("reasoning_enabled=%t peer_card_enabled=%t summary_enabled=%t dream_enabled=%t", + g.ReasoningEnabled, g.PeerCardEnabled, g.SummaryEnabled, g.DreamEnabled), + }, + { + Name: "deriver", + Status: doctor.StatusPass, + Note: fmt.Sprintf("deriver_workers=%d representation_batch_max_tokens=%d", + g.DeriverWorkers, g.RepresentationBatchMaxTokens), + }, + { + Name: "dialectic", + Status: doctor.StatusPass, + Note: fmt.Sprintf("dialectic_default_level=%s", g.DialecticDefaultLevel), + }, + } + if !g.DreamEnabled { + items = append(items, doctor.ItemInfo{ + Name: "dream", + Status: doctor.StatusWarn, + Note: "feature_disabled:dream dream_enabled=false reason=dream fixtures are not available yet", + }) + } + return doctor.CheckResult{ + Name: "Goncho config", + Status: doctor.StatusPass, + Summary: fmt.Sprintf("enabled=%t workspace=%s observer_peer=%s", g.Enabled, g.Workspace, g.ObserverPeer), + Items: items, + } +} diff --git a/cmd/gormes/goncho_doctor_test.go b/cmd/gormes/goncho_doctor_test.go new file mode 100644 index 000000000..e0ea76710 --- /dev/null +++ b/cmd/gormes/goncho_doctor_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "strings" + "testing" + + "github.com/TrebuchetDynamics/gormes-agent/internal/config" +) + +func TestDoctorGonchoConfigOutputIncludesEffectiveSettingsAndRedactsSecrets(t *testing.T) { + cfg := config.Config{ + Hermes: config.HermesCfg{APIKey: "sk-do-not-print"}, + Goncho: config.GonchoCfg{ + Enabled: true, + Workspace: "ops-workspace", + ObserverPeer: "ops-observer", + RecentMessages: 5, + MaxMessageSize: 25_000, + MaxFileSize: 5_242_880, + GetContextMaxTokens: 100_000, + ReasoningEnabled: true, + PeerCardEnabled: true, + SummaryEnabled: true, + DreamEnabled: false, + DeriverWorkers: 1, + RepresentationBatchMaxTokens: 1024, + DialecticDefaultLevel: "low", + }, + } + + out := doctorGonchoConfig(cfg).Format() + + for _, want := range []string{ + "Goncho config", + "workspace=ops-workspace", + "observer_peer=ops-observer", + "enabled=true", + "recent_messages=5", + "max_message_size=25000", + "max_file_size=5242880", + "get_context_max_tokens=100000", + "reasoning_enabled=true", + "peer_card_enabled=true", + "summary_enabled=true", + "dream_enabled=false", + "feature_disabled:dream", + "deriver_workers=1", + "representation_batch_max_tokens=1024", + "dialectic_default_level=low", + } { + if !strings.Contains(out, want) { + t.Fatalf("doctor Goncho output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "sk-do-not-print") { + t.Fatalf("doctor Goncho output leaked secret:\n%s", out) + } +} diff --git a/cmd/gormes/telegram.go b/cmd/gormes/telegram.go index 46792c47e..831bc6708 100644 --- a/cmd/gormes/telegram.go +++ b/cmd/gormes/telegram.go @@ -115,11 +115,9 @@ func runTelegram(cmd *cobra.Command, _ []string) error { defer cancel() reg := buildDefaultRegistry(rootCtx, cfg.Delegation, cfg.SkillsRoot(), hc, cfg.Hermes.Model) - gonchotools.RegisterHonchoTools(reg, goncho.NewService(mstore.DB(), goncho.Config{ - WorkspaceID: "default", - ObserverPeerID: "gormes", - RecentMessages: 4, - }, slog.Default())) + gonchoCfg := cfg.Goncho.RuntimeConfig() + gonchoCfg.SessionDirectory = smap + gonchotools.RegisterHonchoTools(reg, goncho.NewService(mstore.DB(), gonchoCfg, slog.Default())) tm := telemetry.New() toolAudit := audit.NewJSONLWriter(config.ToolAuditLogPath()) diff --git a/docs/content/building-gormes/architecture_plan/progress.json b/docs/content/building-gormes/architecture_plan/progress.json index 5e404b95c..f7c61ba8f 100644 --- a/docs/content/building-gormes/architecture_plan/progress.json +++ b/docs/content/building-gormes/architecture_plan/progress.json @@ -2137,9 +2137,9 @@ { "name": "Goncho configuration namespace", "priority": "P3", - "status": "planned", + "status": "complete", "contract": "Gormes owns a Go-native [goncho] configuration namespace that maps Honcho runtime limits and feature gates into existing config loading", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "memory", "trust_class": [ @@ -2179,7 +2179,7 @@ "Invalid reasoning levels and negative limits fail config validation.", "Doctor output includes the effective Goncho config with secrets redacted." ], - "note": "Honcho configuration docs are Python-service oriented. Gormes should map only the durable runtime semantics into its existing Go config path so operators do not run a shadow Honcho service to use Goncho.", + "note": "TDD landed: internal/config/goncho_config_test.go proves [goncho] defaults, GORMES_GONCHO_* overrides, invalid dialectic levels, negative limit validation, and mapping into goncho.Config; internal/goncho/config_test.go locks the Go-native runtime defaults and dialectic levels; cmd/gormes/goncho_doctor_test.go proves doctor visibility with secrets absent and dream feature-disabled evidence.", "write_scope": [ "internal/config/", "internal/goncho/", diff --git a/internal/config/config.go b/internal/config/config.go index 4458e9360..f1d5d1885 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/TrebuchetDynamics/gormes-agent/internal/goncho" "github.com/pelletier/go-toml/v2" "github.com/spf13/pflag" ) @@ -33,6 +34,7 @@ type Config struct { Cron CronCfg `toml:"cron"` Skills SkillsCfg `toml:"skills"` Delegation DelegationCfg `toml:"delegation"` + Goncho GonchoCfg `toml:"goncho"` // Resume is set only via the --resume CLI flag; intentionally not // a TOML field. Empty means "use whatever internal/session had // persisted for this binary's default key." @@ -118,6 +120,43 @@ type DelegationCfg struct { RunLogPath string `toml:"run_log_path"` } +// GonchoCfg configures the in-process Honcho-compatible memory facade. +type GonchoCfg struct { + Enabled bool `toml:"enabled"` + Workspace string `toml:"workspace"` + ObserverPeer string `toml:"observer_peer"` + RecentMessages int `toml:"recent_messages"` + MaxMessageSize int `toml:"max_message_size"` + MaxFileSize int `toml:"max_file_size"` + GetContextMaxTokens int `toml:"get_context_max_tokens"` + ReasoningEnabled bool `toml:"reasoning_enabled"` + PeerCardEnabled bool `toml:"peer_card_enabled"` + SummaryEnabled bool `toml:"summary_enabled"` + DreamEnabled bool `toml:"dream_enabled"` + DeriverWorkers int `toml:"deriver_workers"` + RepresentationBatchMaxTokens int `toml:"representation_batch_max_tokens"` + DialecticDefaultLevel string `toml:"dialectic_default_level"` +} + +func (g GonchoCfg) RuntimeConfig() goncho.Config { + return goncho.Config{ + Enabled: g.Enabled, + WorkspaceID: g.Workspace, + ObserverPeerID: g.ObserverPeer, + RecentMessages: g.RecentMessages, + MaxMessageSize: g.MaxMessageSize, + MaxFileSize: g.MaxFileSize, + GetContextMaxTokens: g.GetContextMaxTokens, + ReasoningEnabled: g.ReasoningEnabled, + PeerCardEnabled: g.PeerCardEnabled, + SummaryEnabled: g.SummaryEnabled, + DreamEnabled: g.DreamEnabled, + DeriverWorkers: g.DeriverWorkers, + RepresentationBatchMaxTokens: g.RepresentationBatchMaxTokens, + DialecticDefaultLevel: goncho.DialecticLevel(g.DialecticDefaultLevel), + } +} + func (d *DelegationCfg) UnmarshalTOML(data []byte) error { type rawDelegationCfg struct { Enabled bool `toml:"enabled"` @@ -182,10 +221,15 @@ func Load(args []string) (Config, error) { if err := loadFile(&cfg); err != nil { return cfg, err } - loadEnv(&cfg) + if err := loadEnv(&cfg); err != nil { + return cfg, err + } if err := loadFlags(&cfg, args); err != nil { return cfg, err } + if err := validateConfig(&cfg); err != nil { + return cfg, err + } return cfg, nil } @@ -245,6 +289,22 @@ func defaults() Config { DefaultTimeout: 45 * time.Second, RunLogPath: "", }, + Goncho: GonchoCfg{ + Enabled: true, + Workspace: goncho.DefaultWorkspaceID, + ObserverPeer: goncho.DefaultObserverPeerID, + RecentMessages: goncho.DefaultRecentMessages, + MaxMessageSize: goncho.DefaultMaxMessageSize, + MaxFileSize: goncho.DefaultMaxFileSize, + GetContextMaxTokens: goncho.DefaultGetContextMaxTokens, + ReasoningEnabled: true, + PeerCardEnabled: true, + SummaryEnabled: true, + DreamEnabled: false, + DeriverWorkers: goncho.DefaultDeriverWorkers, + RepresentationBatchMaxTokens: goncho.DefaultRepresentationBatchMaxTokens, + DialecticDefaultLevel: string(goncho.DialecticLevelLow), + }, } } @@ -289,7 +349,7 @@ func migrateConfig(cfg *Config) error { return nil } -func loadEnv(cfg *Config) { +func loadEnv(cfg *Config) error { if v := os.Getenv("GORMES_ENDPOINT"); v != "" { cfg.Hermes.Endpoint = v } @@ -316,6 +376,109 @@ func loadEnv(cfg *Config) { if v := os.Getenv("GORMES_SKILLS_ROOT"); v != "" { cfg.Skills.Root = v } + if v := os.Getenv("GORMES_GONCHO_ENABLED"); v != "" { + parsed, err := parseEnvBool("GORMES_GONCHO_ENABLED", v) + if err != nil { + return err + } + cfg.Goncho.Enabled = parsed + } + if v := os.Getenv("GORMES_GONCHO_WORKSPACE"); v != "" { + cfg.Goncho.Workspace = v + } + if v := os.Getenv("GORMES_GONCHO_OBSERVER_PEER"); v != "" { + cfg.Goncho.ObserverPeer = v + } + if v := os.Getenv("GORMES_GONCHO_RECENT_MESSAGES"); v != "" { + parsed, err := parseEnvInt("GORMES_GONCHO_RECENT_MESSAGES", v) + if err != nil { + return err + } + cfg.Goncho.RecentMessages = parsed + } + if v := os.Getenv("GORMES_GONCHO_MAX_MESSAGE_SIZE"); v != "" { + parsed, err := parseEnvInt("GORMES_GONCHO_MAX_MESSAGE_SIZE", v) + if err != nil { + return err + } + cfg.Goncho.MaxMessageSize = parsed + } + if v := os.Getenv("GORMES_GONCHO_MAX_FILE_SIZE"); v != "" { + parsed, err := parseEnvInt("GORMES_GONCHO_MAX_FILE_SIZE", v) + if err != nil { + return err + } + cfg.Goncho.MaxFileSize = parsed + } + if v := os.Getenv("GORMES_GONCHO_GET_CONTEXT_MAX_TOKENS"); v != "" { + parsed, err := parseEnvInt("GORMES_GONCHO_GET_CONTEXT_MAX_TOKENS", v) + if err != nil { + return err + } + cfg.Goncho.GetContextMaxTokens = parsed + } + if v := os.Getenv("GORMES_GONCHO_REASONING_ENABLED"); v != "" { + parsed, err := parseEnvBool("GORMES_GONCHO_REASONING_ENABLED", v) + if err != nil { + return err + } + cfg.Goncho.ReasoningEnabled = parsed + } + if v := os.Getenv("GORMES_GONCHO_PEER_CARD_ENABLED"); v != "" { + parsed, err := parseEnvBool("GORMES_GONCHO_PEER_CARD_ENABLED", v) + if err != nil { + return err + } + cfg.Goncho.PeerCardEnabled = parsed + } + if v := os.Getenv("GORMES_GONCHO_SUMMARY_ENABLED"); v != "" { + parsed, err := parseEnvBool("GORMES_GONCHO_SUMMARY_ENABLED", v) + if err != nil { + return err + } + cfg.Goncho.SummaryEnabled = parsed + } + if v := os.Getenv("GORMES_GONCHO_DREAM_ENABLED"); v != "" { + parsed, err := parseEnvBool("GORMES_GONCHO_DREAM_ENABLED", v) + if err != nil { + return err + } + cfg.Goncho.DreamEnabled = parsed + } + if v := os.Getenv("GORMES_GONCHO_DERIVER_WORKERS"); v != "" { + parsed, err := parseEnvInt("GORMES_GONCHO_DERIVER_WORKERS", v) + if err != nil { + return err + } + cfg.Goncho.DeriverWorkers = parsed + } + if v := os.Getenv("GORMES_GONCHO_REPRESENTATION_BATCH_MAX_TOKENS"); v != "" { + parsed, err := parseEnvInt("GORMES_GONCHO_REPRESENTATION_BATCH_MAX_TOKENS", v) + if err != nil { + return err + } + cfg.Goncho.RepresentationBatchMaxTokens = parsed + } + if v := os.Getenv("GORMES_GONCHO_DIALECTIC_DEFAULT_LEVEL"); v != "" { + cfg.Goncho.DialecticDefaultLevel = v + } + return nil +} + +func parseEnvBool(name, value string) (bool, error) { + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + if err != nil { + return false, fmt.Errorf("config env %s: %w", name, err) + } + return parsed, nil +} + +func parseEnvInt(name, value string) (int, error) { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return 0, fmt.Errorf("config env %s: %w", name, err) + } + return parsed, nil } func loadFlags(cfg *Config, args []string) error { @@ -342,6 +505,41 @@ func loadFlags(cfg *Config, args []string) error { return nil } +func validateConfig(cfg *Config) error { + cfg.Goncho.Workspace = strings.TrimSpace(cfg.Goncho.Workspace) + cfg.Goncho.ObserverPeer = strings.TrimSpace(cfg.Goncho.ObserverPeer) + cfg.Goncho.DialecticDefaultLevel = strings.ToLower(strings.TrimSpace(cfg.Goncho.DialecticDefaultLevel)) + + if cfg.Goncho.Workspace == "" { + return fmt.Errorf("config: goncho.workspace is required") + } + if cfg.Goncho.ObserverPeer == "" { + return fmt.Errorf("config: goncho.observer_peer is required") + } + if !goncho.ValidDialecticLevel(cfg.Goncho.DialecticDefaultLevel) { + return fmt.Errorf("config: goncho.dialectic_default_level %q is invalid; want one of minimal, low, medium, high, max", cfg.Goncho.DialecticDefaultLevel) + } + for _, limit := range []struct { + name string + value int + }{ + {name: "recent_messages", value: cfg.Goncho.RecentMessages}, + {name: "max_message_size", value: cfg.Goncho.MaxMessageSize}, + {name: "max_file_size", value: cfg.Goncho.MaxFileSize}, + {name: "get_context_max_tokens", value: cfg.Goncho.GetContextMaxTokens}, + {name: "deriver_workers", value: cfg.Goncho.DeriverWorkers}, + {name: "representation_batch_max_tokens", value: cfg.Goncho.RepresentationBatchMaxTokens}, + } { + if limit.value < 0 { + return fmt.Errorf("config: goncho.%s must be non-negative, got %d", limit.name, limit.value) + } + } + if cfg.Goncho.DeriverWorkers == 0 { + return fmt.Errorf("config: goncho.deriver_workers must be at least 1") + } + return nil +} + func xdgConfigHome() string { if v := os.Getenv("XDG_CONFIG_HOME"); v != "" { return v diff --git a/internal/config/goncho_config_test.go b/internal/config/goncho_config_test.go new file mode 100644 index 000000000..dd3641500 --- /dev/null +++ b/internal/config/goncho_config_test.go @@ -0,0 +1,289 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoad_GonchoDefaults(t *testing.T) { + isolateGonchoConfig(t) + + cfg, err := Load(nil) + if err != nil { + t.Fatal(err) + } + + if !cfg.Goncho.Enabled { + t.Error("Goncho.Enabled default = false, want true") + } + if cfg.Goncho.Workspace != "gormes" { + t.Errorf("Goncho.Workspace default = %q, want gormes", cfg.Goncho.Workspace) + } + if cfg.Goncho.ObserverPeer != "gormes" { + t.Errorf("Goncho.ObserverPeer default = %q, want gormes", cfg.Goncho.ObserverPeer) + } + if cfg.Goncho.RecentMessages != 4 { + t.Errorf("Goncho.RecentMessages default = %d, want 4", cfg.Goncho.RecentMessages) + } + if cfg.Goncho.MaxMessageSize != 25_000 { + t.Errorf("Goncho.MaxMessageSize default = %d, want 25000", cfg.Goncho.MaxMessageSize) + } + if cfg.Goncho.MaxFileSize != 5_242_880 { + t.Errorf("Goncho.MaxFileSize default = %d, want 5242880", cfg.Goncho.MaxFileSize) + } + if cfg.Goncho.GetContextMaxTokens != 100_000 { + t.Errorf("Goncho.GetContextMaxTokens default = %d, want 100000", cfg.Goncho.GetContextMaxTokens) + } + if !cfg.Goncho.ReasoningEnabled { + t.Error("Goncho.ReasoningEnabled default = false, want true") + } + if !cfg.Goncho.PeerCardEnabled { + t.Error("Goncho.PeerCardEnabled default = false, want true") + } + if !cfg.Goncho.SummaryEnabled { + t.Error("Goncho.SummaryEnabled default = false, want true") + } + if cfg.Goncho.DreamEnabled { + t.Error("Goncho.DreamEnabled default = true, want false until fixtures exist") + } + if cfg.Goncho.DeriverWorkers != 1 { + t.Errorf("Goncho.DeriverWorkers default = %d, want 1", cfg.Goncho.DeriverWorkers) + } + if cfg.Goncho.RepresentationBatchMaxTokens != 1024 { + t.Errorf("Goncho.RepresentationBatchMaxTokens default = %d, want 1024", cfg.Goncho.RepresentationBatchMaxTokens) + } + if cfg.Goncho.DialecticDefaultLevel != "low" { + t.Errorf("Goncho.DialecticDefaultLevel default = %q, want low", cfg.Goncho.DialecticDefaultLevel) + } +} + +func TestLoad_GonchoEnvOverridesFile(t *testing.T) { + cfgHome := isolateGonchoConfig(t) + writeGonchoConfigFile(t, cfgHome, ` +[goncho] +enabled = true +workspace = "file-workspace" +observer_peer = "file-observer" +recent_messages = 6 +max_message_size = 111 +max_file_size = 222 +get_context_max_tokens = 333 +reasoning_enabled = true +peer_card_enabled = true +summary_enabled = true +dream_enabled = false +deriver_workers = 2 +representation_batch_max_tokens = 444 +dialectic_default_level = "minimal" +`) + + t.Setenv("GORMES_GONCHO_ENABLED", "false") + t.Setenv("GORMES_GONCHO_WORKSPACE", "env-workspace") + t.Setenv("GORMES_GONCHO_OBSERVER_PEER", "env-observer") + t.Setenv("GORMES_GONCHO_RECENT_MESSAGES", "7") + t.Setenv("GORMES_GONCHO_MAX_MESSAGE_SIZE", "25001") + t.Setenv("GORMES_GONCHO_MAX_FILE_SIZE", "5242881") + t.Setenv("GORMES_GONCHO_GET_CONTEXT_MAX_TOKENS", "99999") + t.Setenv("GORMES_GONCHO_REASONING_ENABLED", "false") + t.Setenv("GORMES_GONCHO_PEER_CARD_ENABLED", "false") + t.Setenv("GORMES_GONCHO_SUMMARY_ENABLED", "false") + t.Setenv("GORMES_GONCHO_DREAM_ENABLED", "true") + t.Setenv("GORMES_GONCHO_DERIVER_WORKERS", "3") + t.Setenv("GORMES_GONCHO_REPRESENTATION_BATCH_MAX_TOKENS", "2048") + t.Setenv("GORMES_GONCHO_DIALECTIC_DEFAULT_LEVEL", "high") + + cfg, err := Load(nil) + if err != nil { + t.Fatal(err) + } + + if cfg.Goncho.Enabled { + t.Error("Goncho.Enabled = true, want env false") + } + if cfg.Goncho.Workspace != "env-workspace" { + t.Errorf("Goncho.Workspace = %q, want env-workspace", cfg.Goncho.Workspace) + } + if cfg.Goncho.ObserverPeer != "env-observer" { + t.Errorf("Goncho.ObserverPeer = %q, want env-observer", cfg.Goncho.ObserverPeer) + } + if cfg.Goncho.RecentMessages != 7 { + t.Errorf("Goncho.RecentMessages = %d, want 7", cfg.Goncho.RecentMessages) + } + if cfg.Goncho.MaxMessageSize != 25_001 { + t.Errorf("Goncho.MaxMessageSize = %d, want 25001", cfg.Goncho.MaxMessageSize) + } + if cfg.Goncho.MaxFileSize != 5_242_881 { + t.Errorf("Goncho.MaxFileSize = %d, want 5242881", cfg.Goncho.MaxFileSize) + } + if cfg.Goncho.GetContextMaxTokens != 99_999 { + t.Errorf("Goncho.GetContextMaxTokens = %d, want 99999", cfg.Goncho.GetContextMaxTokens) + } + if cfg.Goncho.ReasoningEnabled { + t.Error("Goncho.ReasoningEnabled = true, want env false") + } + if cfg.Goncho.PeerCardEnabled { + t.Error("Goncho.PeerCardEnabled = true, want env false") + } + if cfg.Goncho.SummaryEnabled { + t.Error("Goncho.SummaryEnabled = true, want env false") + } + if !cfg.Goncho.DreamEnabled { + t.Error("Goncho.DreamEnabled = false, want env true") + } + if cfg.Goncho.DeriverWorkers != 3 { + t.Errorf("Goncho.DeriverWorkers = %d, want 3", cfg.Goncho.DeriverWorkers) + } + if cfg.Goncho.RepresentationBatchMaxTokens != 2048 { + t.Errorf("Goncho.RepresentationBatchMaxTokens = %d, want 2048", cfg.Goncho.RepresentationBatchMaxTokens) + } + if cfg.Goncho.DialecticDefaultLevel != "high" { + t.Errorf("Goncho.DialecticDefaultLevel = %q, want high", cfg.Goncho.DialecticDefaultLevel) + } +} + +func TestLoad_GonchoRejectsInvalidDialecticDefaultLevel(t *testing.T) { + cfgHome := isolateGonchoConfig(t) + writeGonchoConfigFile(t, cfgHome, ` +[goncho] +dialectic_default_level = "extreme" +`) + + _, err := Load(nil) + if err == nil { + t.Fatal("Load() error = nil, want invalid dialectic_default_level error") + } + if !strings.Contains(err.Error(), "goncho.dialectic_default_level") { + t.Fatalf("Load() error = %v, want goncho.dialectic_default_level", err) + } +} + +func TestLoad_GonchoRejectsNegativeLimits(t *testing.T) { + for _, tc := range []struct { + name string + field string + }{ + {name: "recent messages", field: "recent_messages"}, + {name: "max message size", field: "max_message_size"}, + {name: "max file size", field: "max_file_size"}, + {name: "context max tokens", field: "get_context_max_tokens"}, + {name: "deriver workers", field: "deriver_workers"}, + {name: "representation batch max tokens", field: "representation_batch_max_tokens"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfgHome := isolateGonchoConfig(t) + writeGonchoConfigFile(t, cfgHome, "\n[goncho]\n"+tc.field+" = -1\n") + + _, err := Load(nil) + if err == nil { + t.Fatal("Load() error = nil, want negative limit error") + } + if !strings.Contains(err.Error(), "goncho."+tc.field) { + t.Fatalf("Load() error = %v, want goncho.%s", err, tc.field) + } + }) + } +} + +func TestLoad_GonchoToRuntimeConfig(t *testing.T) { + isolateGonchoConfig(t) + t.Setenv("GORMES_GONCHO_WORKSPACE", "runtime-workspace") + t.Setenv("GORMES_GONCHO_OBSERVER_PEER", "runtime-observer") + t.Setenv("GORMES_GONCHO_RECENT_MESSAGES", "8") + t.Setenv("GORMES_GONCHO_MAX_MESSAGE_SIZE", "12345") + t.Setenv("GORMES_GONCHO_MAX_FILE_SIZE", "67890") + t.Setenv("GORMES_GONCHO_GET_CONTEXT_MAX_TOKENS", "555") + t.Setenv("GORMES_GONCHO_REASONING_ENABLED", "false") + t.Setenv("GORMES_GONCHO_PEER_CARD_ENABLED", "false") + t.Setenv("GORMES_GONCHO_SUMMARY_ENABLED", "false") + t.Setenv("GORMES_GONCHO_DREAM_ENABLED", "true") + t.Setenv("GORMES_GONCHO_DERIVER_WORKERS", "4") + t.Setenv("GORMES_GONCHO_REPRESENTATION_BATCH_MAX_TOKENS", "777") + t.Setenv("GORMES_GONCHO_DIALECTIC_DEFAULT_LEVEL", "medium") + + cfg, err := Load(nil) + if err != nil { + t.Fatal(err) + } + rt := cfg.Goncho.RuntimeConfig() + + if rt.WorkspaceID != "runtime-workspace" { + t.Errorf("WorkspaceID = %q, want runtime-workspace", rt.WorkspaceID) + } + if rt.ObserverPeerID != "runtime-observer" { + t.Errorf("ObserverPeerID = %q, want runtime-observer", rt.ObserverPeerID) + } + if rt.RecentMessages != 8 { + t.Errorf("RecentMessages = %d, want 8", rt.RecentMessages) + } + if rt.MaxMessageSize != 12_345 { + t.Errorf("MaxMessageSize = %d, want 12345", rt.MaxMessageSize) + } + if rt.MaxFileSize != 67_890 { + t.Errorf("MaxFileSize = %d, want 67890", rt.MaxFileSize) + } + if rt.GetContextMaxTokens != 555 { + t.Errorf("GetContextMaxTokens = %d, want 555", rt.GetContextMaxTokens) + } + if rt.ReasoningEnabled { + t.Error("ReasoningEnabled = true, want false") + } + if rt.PeerCardEnabled { + t.Error("PeerCardEnabled = true, want false") + } + if rt.SummaryEnabled { + t.Error("SummaryEnabled = true, want false") + } + if !rt.DreamEnabled { + t.Error("DreamEnabled = false, want true") + } + if rt.DeriverWorkers != 4 { + t.Errorf("DeriverWorkers = %d, want 4", rt.DeriverWorkers) + } + if rt.RepresentationBatchMaxTokens != 777 { + t.Errorf("RepresentationBatchMaxTokens = %d, want 777", rt.RepresentationBatchMaxTokens) + } + if rt.DialecticDefaultLevel != "medium" { + t.Errorf("DialecticDefaultLevel = %q, want medium", rt.DialecticDefaultLevel) + } +} + +func isolateGonchoConfig(t *testing.T) string { + t.Helper() + cfgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfgHome) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + t.Setenv("HERMES_HOME", "") + for _, key := range []string{ + "GORMES_GONCHO_ENABLED", + "GORMES_GONCHO_WORKSPACE", + "GORMES_GONCHO_OBSERVER_PEER", + "GORMES_GONCHO_RECENT_MESSAGES", + "GORMES_GONCHO_MAX_MESSAGE_SIZE", + "GORMES_GONCHO_MAX_FILE_SIZE", + "GORMES_GONCHO_GET_CONTEXT_MAX_TOKENS", + "GORMES_GONCHO_REASONING_ENABLED", + "GORMES_GONCHO_PEER_CARD_ENABLED", + "GORMES_GONCHO_SUMMARY_ENABLED", + "GORMES_GONCHO_DREAM_ENABLED", + "GORMES_GONCHO_DERIVER_WORKERS", + "GORMES_GONCHO_REPRESENTATION_BATCH_MAX_TOKENS", + "GORMES_GONCHO_DIALECTIC_DEFAULT_LEVEL", + } { + t.Setenv(key, "") + } + return cfgHome +} + +func writeGonchoConfigFile(t *testing.T, cfgHome, body string) { + t.Helper() + dir := filepath.Join(cfgHome, "gormes") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/goncho/config_test.go b/internal/goncho/config_test.go new file mode 100644 index 000000000..4b1f5236a --- /dev/null +++ b/internal/goncho/config_test.go @@ -0,0 +1,58 @@ +package goncho + +import "testing" + +func TestConfigEffectiveDefaultsMatchGonchoNamespace(t *testing.T) { + got := Config{}.Effective() + + if got.WorkspaceID != DefaultWorkspaceID { + t.Errorf("WorkspaceID = %q, want %q", got.WorkspaceID, DefaultWorkspaceID) + } + if got.ObserverPeerID != DefaultObserverPeerID { + t.Errorf("ObserverPeerID = %q, want %q", got.ObserverPeerID, DefaultObserverPeerID) + } + if got.RecentMessages != 4 { + t.Errorf("RecentMessages = %d, want 4", got.RecentMessages) + } + if got.MaxMessageSize != 25_000 { + t.Errorf("MaxMessageSize = %d, want 25000", got.MaxMessageSize) + } + if got.MaxFileSize != 5_242_880 { + t.Errorf("MaxFileSize = %d, want 5242880", got.MaxFileSize) + } + if got.GetContextMaxTokens != 100_000 { + t.Errorf("GetContextMaxTokens = %d, want 100000", got.GetContextMaxTokens) + } + if !got.ReasoningEnabled { + t.Error("ReasoningEnabled = false, want true") + } + if !got.PeerCardEnabled { + t.Error("PeerCardEnabled = false, want true") + } + if !got.SummaryEnabled { + t.Error("SummaryEnabled = false, want true") + } + if got.DreamEnabled { + t.Error("DreamEnabled = true, want false until fixtures exist") + } + if got.DeriverWorkers != 1 { + t.Errorf("DeriverWorkers = %d, want 1", got.DeriverWorkers) + } + if got.RepresentationBatchMaxTokens != 1024 { + t.Errorf("RepresentationBatchMaxTokens = %d, want 1024", got.RepresentationBatchMaxTokens) + } + if got.DialecticDefaultLevel != DialecticLevelLow { + t.Errorf("DialecticDefaultLevel = %q, want %q", got.DialecticDefaultLevel, DialecticLevelLow) + } +} + +func TestValidDialecticLevel(t *testing.T) { + for _, level := range []string{"minimal", "low", "medium", "high", "max"} { + if !ValidDialecticLevel(level) { + t.Errorf("ValidDialecticLevel(%q) = false, want true", level) + } + } + if ValidDialecticLevel("extreme") { + t.Error("ValidDialecticLevel(extreme) = true, want false") + } +} diff --git a/internal/goncho/service.go b/internal/goncho/service.go index afc18de1f..1f899f7dd 100644 --- a/internal/goncho/service.go +++ b/internal/goncho/service.go @@ -29,6 +29,7 @@ func NewService(db *sql.DB, cfg Config, log *slog.Logger) *Service { if log == nil { log = slog.Default() } + cfg = cfg.Effective() workspaceID := strings.TrimSpace(cfg.WorkspaceID) if workspaceID == "" { workspaceID = DefaultWorkspaceID @@ -39,7 +40,7 @@ func NewService(db *sql.DB, cfg Config, log *slog.Logger) *Service { } recentLimit := cfg.RecentMessages if recentLimit <= 0 { - recentLimit = 4 + recentLimit = DefaultRecentMessages } return &Service{ db: db, diff --git a/internal/goncho/types.go b/internal/goncho/types.go index 396885a3a..187581f4a 100644 --- a/internal/goncho/types.go +++ b/internal/goncho/types.go @@ -2,16 +2,94 @@ package goncho import ( "context" + "strings" "github.com/TrebuchetDynamics/gormes-agent/internal/session" ) // Config controls the minimal Goncho service defaults for a runtime. type Config struct { - WorkspaceID string - ObserverPeerID string - RecentMessages int - SessionDirectory SessionDirectory + Enabled bool + WorkspaceID string + ObserverPeerID string + RecentMessages int + MaxMessageSize int + MaxFileSize int + GetContextMaxTokens int + ReasoningEnabled bool + PeerCardEnabled bool + SummaryEnabled bool + DreamEnabled bool + DeriverWorkers int + RepresentationBatchMaxTokens int + DialecticDefaultLevel DialecticLevel + SessionDirectory SessionDirectory +} + +type DialecticLevel string + +const ( + DialecticLevelMinimal DialecticLevel = "minimal" + DialecticLevelLow DialecticLevel = "low" + DialecticLevelMedium DialecticLevel = "medium" + DialecticLevelHigh DialecticLevel = "high" + DialecticLevelMax DialecticLevel = "max" +) + +const ( + DefaultRecentMessages = 4 + DefaultMaxMessageSize = 25_000 + DefaultMaxFileSize = 5_242_880 + DefaultGetContextMaxTokens = 100_000 + DefaultDeriverWorkers = 1 + DefaultRepresentationBatchMaxTokens = 1024 +) + +// Effective fills the Go-native Goncho defaults used when older callers still +// construct Config directly instead of going through internal/config. +func (c Config) Effective() Config { + out := c + out.Enabled = true + if strings.TrimSpace(out.WorkspaceID) == "" { + out.WorkspaceID = DefaultWorkspaceID + } + if strings.TrimSpace(out.ObserverPeerID) == "" { + out.ObserverPeerID = DefaultObserverPeerID + } + if out.RecentMessages <= 0 { + out.RecentMessages = DefaultRecentMessages + } + if out.MaxMessageSize <= 0 { + out.MaxMessageSize = DefaultMaxMessageSize + } + if out.MaxFileSize <= 0 { + out.MaxFileSize = DefaultMaxFileSize + } + if out.GetContextMaxTokens <= 0 { + out.GetContextMaxTokens = DefaultGetContextMaxTokens + } + out.ReasoningEnabled = true + out.PeerCardEnabled = true + out.SummaryEnabled = true + if out.DeriverWorkers <= 0 { + out.DeriverWorkers = DefaultDeriverWorkers + } + if out.RepresentationBatchMaxTokens <= 0 { + out.RepresentationBatchMaxTokens = DefaultRepresentationBatchMaxTokens + } + if !ValidDialecticLevel(string(out.DialecticDefaultLevel)) { + out.DialecticDefaultLevel = DialecticLevelLow + } + return out +} + +func ValidDialecticLevel(level string) bool { + switch DialecticLevel(strings.ToLower(strings.TrimSpace(level))) { + case DialecticLevelMinimal, DialecticLevelLow, DialecticLevelMedium, DialecticLevelHigh, DialecticLevelMax: + return true + default: + return false + } } // SessionDirectory exposes the canonical user->session metadata seam needed