diff --git a/internal/mcp/brief.go b/internal/mcp/brief.go index 368a0ae6..e7a76bfd 100644 --- a/internal/mcp/brief.go +++ b/internal/mcp/brief.go @@ -65,22 +65,25 @@ func resolveDefaultSections(caller string) []string { // // Mode selects the briefing flavour and is required. Sections is a STRICT // filter that applies only in morning mode: when non-empty, only the listed -// groups are populated and every other morning field stays at its empty-slice -// default. Omit Sections (or pass an empty list) to populate every morning -// group. Sections is ignored in reflection mode. +// groups are populated and every other morning field stays at its zero-value +// default ([] for the list fields, 0 for proposals_pending) — an unrequested +// field's default is not a computed result. Omit Sections (or pass an empty +// list) to populate every morning group. Sections is ignored in reflection +// mode. // // Morning group → response field mapping: // -// "todos" → overdue_todos, today_todos, recurring_todos, committed_todos, upcoming_todos +// "todos" → overdue_todos, today_todos, active_todos, recurring_todos, committed_todos, upcoming_todos // "goals" → active_goals // "rss" → rss_highlights // "content_pipeline" → content_pipeline +// "proposals" → proposals_pending // // Unknown group names are ignored silently (no error, no warning). type BriefInput struct { As string `json:"as,omitempty" jsonschema_description:"Caller agent identity (e.g. koopa0-dev)."` - Mode string `json:"mode" jsonschema_description:"Briefing mode (required): 'morning' = daily-planning pull (todos/goals/rss/content_pipeline); 'reflection' = end-of-day plan-vs-actual retrospective (daily plan items + completion counts). brief is a pure planning-state pull and carries no agent memory."` - Sections FlexStringSlice `json:"sections,omitempty" jsonschema_description:"MORNING-ONLY strict filter on which groups to populate (default: all). Ignored in reflection mode. Omit or pass [] to get the full morning briefing. Group key → response fields: 'tasks' → overdue_todos/today_todos/recurring_todos/committed_todos/upcoming_todos; 'goals' → active_goals; 'rss' → rss_highlights; 'content_pipeline' → content_pipeline. Unknown keys silently ignored."` + Mode string `json:"mode" jsonschema_description:"Briefing mode (required): 'morning' = daily-planning pull (todos/goals/rss/content_pipeline/proposals); 'reflection' = end-of-day plan-vs-actual retrospective (daily plan items + completion counts). brief is a pure planning-state pull and carries no agent memory."` + Sections FlexStringSlice `json:"sections,omitempty" jsonschema_description:"MORNING-ONLY strict filter on which groups to populate (default: all). Ignored in reflection mode. Omit or pass [] to get the full morning briefing. Group key → response fields: 'todos' → overdue_todos/today_todos/active_todos/recurring_todos/committed_todos/upcoming_todos; 'goals' → active_goals; 'rss' → rss_highlights; 'content_pipeline' → content_pipeline; 'proposals' → proposals_pending (count of agent-proposed area/goal/project drafts awaiting owner triage). Unknown keys silently ignored."` Date *string `json:"date,omitempty" jsonschema_description:"Target date YYYY-MM-DD (default: today)"` } @@ -93,15 +96,16 @@ type BriefOutput struct { Date string `json:"date"` // Morning fields. - OverdueTodos []todo.PendingDetail `json:"-"` - TodayTodos []todo.PendingDetail `json:"-"` - ActiveTodos []todo.PendingDetail `json:"-"` - RecurringTodos []todo.Item `json:"-"` - CommittedTodos []daily.Item `json:"-"` - UpcomingTodos []todo.PendingDetail `json:"-"` - ActiveGoals []goal.ActiveGoalSummary `json:"-"` - RSSHighlights []RSSHighlight `json:"-"` - ContentPipeline []ContentSummary `json:"-"` + OverdueTodos []todo.PendingDetail `json:"-"` + TodayTodos []todo.PendingDetail `json:"-"` + ActiveTodos []todo.PendingDetail `json:"-"` + RecurringTodos []todo.Item `json:"-"` + CommittedTodos []daily.Item `json:"-"` + UpcomingTodos []todo.PendingDetail `json:"-"` + ActiveGoals []goal.ActiveGoalSummary `json:"-"` + RSSHighlights []RSSHighlight `json:"-"` + ContentPipeline []ContentSummary `json:"-"` + ProposalsPending int64 `json:"-"` // Reflection fields. PlannedItems []daily.Item `json:"-"` @@ -125,6 +129,12 @@ type briefMorningWire struct { ActiveGoals []goal.ActiveGoalSummary `json:"active_goals"` RSSHighlights []RSSHighlight `json:"rss_highlights"` ContentPipeline []ContentSummary `json:"content_pipeline"` + // ProposalsPending is the summed count of agent-proposed area/goal/project + // drafts awaiting owner triage. Unlike the list fields it is a scalar, so + // it always serialises (0 when nothing is pending) — the push consumer + // gates its nudge on N > 0. int64 matches the count(*) source type, so no + // narrowing conversion is needed. + ProposalsPending int64 `json:"proposals_pending"` } // briefReflectionWire is the wire shape for mode=reflection. Field tags mirror @@ -148,17 +158,18 @@ func (o BriefOutput) MarshalJSON() ([]byte, error) { switch o.Mode { case briefModeMorning: return json.Marshal(briefMorningWire{ - Mode: o.Mode, - Date: o.Date, - OverdueTodos: o.OverdueTodos, - TodayTodos: o.TodayTodos, - ActiveTodos: o.ActiveTodos, - RecurringTodos: o.RecurringTodos, - CommittedTodos: o.CommittedTodos, - UpcomingTodos: o.UpcomingTodos, - ActiveGoals: o.ActiveGoals, - RSSHighlights: o.RSSHighlights, - ContentPipeline: o.ContentPipeline, + Mode: o.Mode, + Date: o.Date, + OverdueTodos: o.OverdueTodos, + TodayTodos: o.TodayTodos, + ActiveTodos: o.ActiveTodos, + RecurringTodos: o.RecurringTodos, + CommittedTodos: o.CommittedTodos, + UpcomingTodos: o.UpcomingTodos, + ActiveGoals: o.ActiveGoals, + RSSHighlights: o.RSSHighlights, + ContentPipeline: o.ContentPipeline, + ProposalsPending: o.ProposalsPending, }) case briefModeReflection: return json.Marshal(briefReflectionWire{ @@ -277,6 +288,7 @@ func (s *Server) fillBriefMorning(ctx context.Context, date time.Time, requested runSection("goals", func(c context.Context) { s.fillGoals(c, out) }) runSection("rss", func(c context.Context) { s.fillRSSHighlights(c, date, out) }) runSection("content_pipeline", func(c context.Context) { s.fillContentPipeline(c, out) }) + runSection("proposals", func(c context.Context) { s.fillProposalsPending(c, out) }) wg.Wait() } @@ -398,6 +410,27 @@ func (s *Server) fillContentPipeline(ctx context.Context, out *BriefOutput) { out.ContentPipeline = toContentSummaries(all) } +// fillProposalsPending sums the agent-proposed area/goal/project drafts still +// in status=proposed — the count Koopa's admin triage badge shows, surfaced +// here so the push consumer (hermes) can decide whether to nudge him back to +// the queue without holding admin credentials. Each count is read +// independently and degrades to its own zero on error, matching the other +// section fillers. +func (s *Server) fillProposalsPending(ctx context.Context, out *BriefOutput) { + var total int64 + if pending, err := s.goals.ProposalsPendingCount(ctx); err == nil { + total += pending.Goals + pending.Areas + } else { + s.logger.Warn("brief: proposed goals+areas count", "error", err) + } + if projects, err := s.projects.ProposedProjectsCount(ctx); err == nil { + total += projects + } else { + s.logger.Warn("brief: proposed projects count", "error", err) + } + out.ProposalsPending = total +} + // fillBriefReflection populates the reflection fields: the day's plan items // plus plan-vs-actual counts. Completion is derived from each planned todo's // CURRENT state (done -> completed, someday -> deferred, anything else -> diff --git a/internal/mcp/integration_test.go b/internal/mcp/integration_test.go index 7e4b45da..c5aa2660 100644 --- a/internal/mcp/integration_test.go +++ b/internal/mcp/integration_test.go @@ -1773,6 +1773,66 @@ func TestIntegration_BriefReflection_CountsFromTodoState(t *testing.T) { } } +// TestIntegration_BriefMorning_ProposalsPending proves the morning brief's +// proposals_pending sums all three proposed-entity kinds (area + goal + +// project) awaiting owner triage — the count hermes reads to decide whether to +// nudge Koopa back to the triage queue. setupServer truncates goals and +// projects (so each starts with zero proposed rows) but preserves the +// migration-002 seed areas, so proposed areas can leak in from other tests; +// clearing them first makes the summed count deterministic. One proposed draft +// of each kind is seeded through the real propose_* write path, so the count +// exercises the actual status='proposed' rows the admin badge reads. +func TestIntegration_BriefMorning_ProposalsPending(t *testing.T) { + s := setupServer(t) + + if _, err := testPool.Exec(t.Context(), `DELETE FROM areas WHERE status = 'proposed'`); err != nil { + t.Fatalf("clearing pre-existing proposed areas: %v", err) + } + + if _, _, err := callHandlerAs(t, "claude", s.proposeArea, ProposeAreaInput{ + Name: "Brief Count Area", + }); err != nil { + t.Fatalf("proposeArea: %v", err) + } + // Area omitted → an unclassified proposed goal; still status='proposed', so + // it counts, without coupling the test to area resolution. + if _, _, err := callHandlerAs(t, "claude", s.proposeGoal, ProposeGoalInput{ + Title: "Brief Count Goal", + }); err != nil { + t.Fatalf("proposeGoal: %v", err) + } + if _, _, err := callHandlerAs(t, "claude", s.proposeProject, ProposeProjectInput{ + Name: "Brief Count Project", + }); err != nil { + t.Fatalf("proposeProject: %v", err) + } + + // Cases pin both the sum (all three proposed kinds) and the section-filter + // wiring: an explicit ["proposals"] must run the filler (a runSection-label + // mismatch would silently return 0), and requesting a different section must + // leave it uncomputed at 0 (the strict filter must exclude it). + tests := []struct { + name string + sections FlexStringSlice + want int64 + }{ + {name: "all sections (default)", sections: nil, want: 3}, + {name: "proposals section explicitly selected", sections: FlexStringSlice{"proposals"}, want: 3}, + {name: "unrelated section excludes proposals", sections: FlexStringSlice{"goals"}, want: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, out, err := callHandler(t, s.brief, BriefInput{Mode: "morning", Sections: tt.sections}) + if err != nil { + t.Fatalf("brief(morning, sections=%v): %v", tt.sections, err) + } + if out.ProposalsPending != tt.want { + t.Errorf("ProposalsPending = %d, want %d", out.ProposalsPending, tt.want) + } + }) + } +} + // --- project_progress (read-only PARA momentum/stalled) --- // // These tests pin the load-bearing semantics: the HUMAN-ONLY activity diff --git a/internal/mcp/morning_test.go b/internal/mcp/morning_test.go index e7a7cccf..ed280e71 100644 --- a/internal/mcp/morning_test.go +++ b/internal/mcp/morning_test.go @@ -152,6 +152,7 @@ func TestBriefOutput_MorningWireShape(t *testing.T) { "active_goals", "rss_highlights", "content_pipeline", + "proposals_pending", } type fieldExpectation struct { @@ -324,3 +325,48 @@ func TestBriefOutput_ReflectionWireShape(t *testing.T) { } } } + +// TestBriefOutput_ProposalsPendingWire pins proposals_pending as a scalar on +// the morning wire: it always serialises (present at 0, never omitted) so the +// push consumer can gate its nudge on N > 0, it carries the value set on the +// output through MarshalJSON's morning branch, and it never leaks into the +// reflection envelope (it is a morning-only field). +func TestBriefOutput_ProposalsPendingWire(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + out BriefOutput + want string // exact JSON number expected for proposals_pending + }{ + { + name: "morning zero — present as 0, not omitted", + out: BriefOutput{Mode: briefModeMorning, Date: "2026-05-27"}, + want: "0", + }, + { + name: "morning positive — carries the summed count through MarshalJSON", + out: BriefOutput{Mode: briefModeMorning, Date: "2026-05-27", ProposalsPending: 7}, + want: "7", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + parsed := marshalToKeyMap(t, tt.out) + raw, ok := parsed["proposals_pending"] + if !ok { + t.Fatalf("morning BriefOutput missing proposals_pending key") + } + if string(raw) != tt.want { + t.Errorf("proposals_pending = %s, want %s", raw, tt.want) + } + }) + } + + // Reflection mode must not emit proposals_pending — it is morning-only. + reflection := BriefOutput{Mode: briefModeReflection, Date: "2026-05-27", ProposalsPending: 5} + if _, ok := marshalToKeyMap(t, reflection)["proposals_pending"]; ok { + t.Error("reflection BriefOutput emitted proposals_pending, want absent (morning-only field)") + } +} diff --git a/internal/mcp/ops/catalog.go b/internal/mcp/ops/catalog.go index c142be8d..0e5f89a4 100644 --- a/internal/mcp/ops/catalog.go +++ b/internal/mcp/ops/catalog.go @@ -32,7 +32,7 @@ func Brief() Meta { Writability: ReadOnly, Stability: StabilityStable, Since: since, - Description: "Read-only planning-state pull. Pick a mode (required): 'morning' = single-call daily-planning briefing (overdue/today/recurring/committed/upcoming todos, active_goals, rss_highlights, content_pipeline); 'reflection' = end-of-day plan-vs-actual retrospective (planned_items + completed/deferred/planned counts + completion_rate). brief is a pure planning-state pull and carries no agent memory. Morning mode is filterable via the sections parameter (ignored in reflection mode) — valid keys (omit or pass [] for all): 'tasks' (overdue/today/recurring/committed/upcoming todos), 'goals' (active_goals), 'rss' (rss_highlights — feeds tagged priority=high, NOT relevance-ranked; use search_knowledge for ranked retrieval), 'content_pipeline' (content_pipeline). Every caller gets all sections by default; pass an explicit sections list to narrow the briefing. Scope is the target date (default today), not since-last-session.", + Description: "Read-only planning-state pull. Pick a mode (required): 'morning' = single-call daily-planning briefing (overdue/today/active/recurring/committed/upcoming todos, active_goals, rss_highlights, content_pipeline, proposals_pending); 'reflection' = end-of-day plan-vs-actual retrospective (planned_items + completed/deferred/planned counts + completion_rate). brief is a pure planning-state pull and carries no agent memory. Morning mode is filterable via the sections parameter (ignored in reflection mode) — valid keys (omit or pass [] for all): 'todos' (overdue/today/active/recurring/committed/upcoming todos), 'goals' (active_goals), 'rss' (rss_highlights — feeds tagged priority=high, NOT relevance-ranked; use search_knowledge for ranked retrieval), 'content_pipeline' (content_pipeline), 'proposals' (proposals_pending — count of agent-proposed area/goal/project drafts awaiting owner triage). Every caller gets all sections by default; pass an explicit sections list to narrow the briefing. Scope is the target date (default today), not since-last-session.", FieldEnums: map[string][]string{ "mode": {"morning", "reflection"}, }, diff --git a/skills/koopa-system/references/tools.md b/skills/koopa-system/references/tools.md index a2b478d6..77ab72e9 100644 --- a/skills/koopa-system/references/tools.md +++ b/skills/koopa-system/references/tools.md @@ -47,7 +47,7 @@ is hand-maintained. | Tool | Key params | Returns | |---|---|---| -| `brief` | `mode` (`morning` / `reflection`), `sections?` (morning only), `date?` | `morning` = single-call daily-planning briefing (overdue/today/active/recurring/committed/upcoming todos, active_goals, rss_highlights, content_pipeline). `reflection` = end-of-day plan-vs-actual retrospective (planned_items + completed/deferred/planned counts + completion_rate). `mode` is required. Read-only; carries no agent memory. Scope is the target date (default today), not since-last-session. | +| `brief` | `mode` (`morning` / `reflection`), `sections?` (morning only), `date?` | `morning` = single-call daily-planning briefing (overdue/today/active/recurring/committed/upcoming todos, active_goals, rss_highlights, content_pipeline, proposals_pending). `reflection` = end-of-day plan-vs-actual retrospective (planned_items + completed/deferred/planned counts + completion_rate). `mode` is required. Read-only; carries no agent memory. Scope is the target date (default today), not since-last-session. | | `search_knowledge` | `query`, `content_type?`, `after?`, `before?`, `limit?` | Hybrid (FTS + pgvector) retrieval over the content corpus. See Search section below. | | `project_progress` | (none) | Live PARA momentum/stalled intelligence for Koopa's projects, goals, and areas, computed at read time. Returns the owner's full PARA (not caller-scoped): `projects[]` with expected_cadence, days_since_human_activity, open_next_action, milestone_done/total, and a `stalled` flag; `goals[]` with milestone progress + stalled-project rollup; `areas[]` with `area_neglected` (no owner activity attributed to the area via any project, goal, or milestone for >14 days). Progress counts owner activity only (`actor='human'`); agent/system actors never count. | @@ -62,10 +62,11 @@ Morning mode is filterable via `sections` (omit or pass `[]` for all). Valid key | Section | Returns | |---|---| -| `tasks` | overdue / today / committed / upcoming todos | +| `todos` | overdue / today / active / recurring / committed / upcoming todos | | `goals` | active_goals | | `rss` | rss_highlights — feeds tagged `priority=high`, NOT relevance-ranked; use `search_knowledge` for ranked retrieval | | `content_pipeline` | content_pipeline | +| `proposals` | proposals_pending — count of agent-proposed area/goal/project drafts awaiting owner triage | Every caller gets all sections by default; pass an explicit `sections` list to narrow the briefing.