Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 59 additions & 26 deletions internal/mcp/brief.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."`

@augmentcode augmentcode Bot Jul 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

internal/mcp/brief.go:84 documents sections as a strict filter, but proposals_pending is always emitted as a scalar (so callers filtering out proposals will still see proposals_pending: 0). Consider clarifying this in the tool/schema description so consumers don’t misread 0 as an explicitly computed value.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Date *string `json:"date,omitempty" jsonschema_description:"Target date YYYY-MM-DD (default: today)"`
}

Expand All @@ -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:"-"`
Expand All @@ -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
Expand All @@ -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{
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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 ->
Expand Down
60 changes: 60 additions & 0 deletions internal/mcp/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions internal/mcp/morning_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ func TestBriefOutput_MorningWireShape(t *testing.T) {
"active_goals",
"rss_highlights",
"content_pipeline",
"proposals_pending",
}

type fieldExpectation struct {
Expand Down Expand Up @@ -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)")
}
}
2 changes: 1 addition & 1 deletion internal/mcp/ops/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
Expand Down
5 changes: 3 additions & 2 deletions skills/koopa-system/references/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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.

Expand Down
Loading