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
8 changes: 4 additions & 4 deletions internal/db/query.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 61 additions & 14 deletions internal/mcp/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,34 @@ func seedProgressProject(t *testing.T, slug, title, cadence string, goalID, area
return id
}

// seedNoCadenceProject inserts an in_progress project with expected_cadence
// left NULL (the CHECK constraint rejects an empty string, so
// seedProgressProject's cadence param can't express "unset") under a
// non-human actor, and returns its id.
func seedNoCadenceProject(t *testing.T, slug, title string) uuid.UUID {
t.Helper()
ctx := t.Context()
tx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("seedNoCadenceProject begin: %v", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after commit
if _, err := tx.Exec(ctx, "SELECT set_config('koopa.actor', 'codex', true)"); err != nil {
t.Fatalf("seedNoCadenceProject set actor: %v", err)
}
var id uuid.UUID
if err := tx.QueryRow(ctx,
`INSERT INTO projects (slug, title, status) VALUES ($1, $2, 'in_progress') RETURNING id`,
slug, title,
).Scan(&id); err != nil {
t.Fatalf("seedNoCadenceProject(%q): %v", slug, err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("seedNoCadenceProject commit: %v", err)
}
return id
}

// seedProgressGoal inserts an in_progress goal and returns its id.
func seedProgressGoal(t *testing.T, title string) uuid.UUID {
t.Helper()
Expand Down Expand Up @@ -2022,18 +2050,29 @@ func TestIntegration_ProjectProgress_AreaNeglect(t *testing.T) {
}

// TestIntegration_ProjectProgress_CandidateFilter pins the candidate gate:
// proposed/archived projects and projects WITHOUT an expected_cadence are
// excluded from projects[]. Only in_progress|planned with a cadence appear.
// proposed/archived projects are excluded from projects[]. A cadence-less
// in_progress|planned project IS included (expected_cadence "" and stalled
// always false — there is no threshold to exceed without a cadence), even
// when it has an open next action, so the assertion exercises the
// cadenceDays[""] lookup-miss branch of Stalled rather than the unrelated
// !openNextAction short-circuit.
func TestIntegration_ProjectProgress_CandidateFilter(t *testing.T) {
s := setupServer(t)

seedProgressProject(t, "candidate", "Candidate", "weekly", nil, nil)
// No cadence → excluded.
if _, err := testPool.Exec(t.Context(),
`INSERT INTO projects (slug, title, status) VALUES ('no-cadence', 'No Cadence', 'in_progress')`,
); err != nil {
t.Fatalf("seed no-cadence project: %v", err)
}
// No cadence → still included, just never stalled — even with an open
// next action and no human activity at all, so the assertion actually
// exercises the cadenceDays[""] lookup-miss branch of Stalled (which
// only matters when lastHuman is nil — see Stalled's nil-lastHuman
// short-circuit) rather than passing vacuously via !openNextAction or a
// too-recent lastHuman. expected_cadence is omitted (NULL — the CHECK
// constraint rejects '') under a non-human actor, like
// seedProgressProject, so the creation event itself doesn't count as
// human activity — current_actor() falls back to 'human' when
// koopa.actor is unset, which would otherwise give this project a
// last_human_activity_at of "just now" and mask the bug entirely.
noCadenceID := seedNoCadenceProject(t, "no-cadence", "No Cadence")
seedProgressTodo(t, noCadenceID, "Open next action", "todo")
// proposed → excluded even with a cadence.
if _, err := testPool.Exec(t.Context(),
`INSERT INTO projects (slug, title, status, expected_cadence, created_by)
Expand All @@ -2047,17 +2086,25 @@ func TestIntegration_ProjectProgress_CandidateFilter(t *testing.T) {
t.Fatalf("projectProgress: %v", err)
}

slugs := make(map[string]bool, len(out.Projects))
byProjectSlug := make(map[string]ProgressProject, len(out.Projects))
for _, p := range out.Projects {
slugs[p.Slug] = true
byProjectSlug[p.Slug] = p
}
if !slugs["candidate"] {
if _, ok := byProjectSlug["candidate"]; !ok {
t.Errorf("candidate project missing from projects[]")
}
if slugs["no-cadence"] {
t.Errorf("no-cadence project present in projects[], want excluded (cadence-less)")
noCadence, ok := byProjectSlug["no-cadence"]
if !ok {
t.Errorf("no-cadence project missing from projects[], want included (cadence-less, not excluded)")
} else {
if !noCadence.OpenNextAction {
t.Fatalf("no-cadence project open_next_action = false, want true (test setup didn't link the seeded todo)")
}
if noCadence.Stalled {
t.Errorf("no-cadence project stalled = true, want false (no cadence means no threshold to exceed)")
}
}
if slugs["proposed-proj"] {
if _, ok := byProjectSlug["proposed-proj"]; ok {
t.Errorf("proposed project present in projects[], want excluded")
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/ops/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ func ProjectProgress() Meta {
Writability: ReadOnly,
Stability: StabilityStable,
Since: "1.7.0",
Description: "Read-only PARA momentum/stalled intelligence for Koopa's projects, goals, and areas — computed LIVE at read time, nothing stored. No parameters: it always returns the owner's full PARA, not a caller-scoped view. projects[]: every candidate project (status in_progress|planned with an expected_cadence set) with its expected_cadence, last_human_activity_at, days_since_human_activity, open_next_action (has an open todo OR an incomplete milestone), milestone_done/total, and a stalled flag. stalled = days_since_human_activity > 2× the cadence period (daily=1, weekly=7, biweekly=14, monthly=30 days) AND there is an open next action — a project with no open next action is '待規劃' (to-plan), never stalled. goals[]: each active goal with milestone progress and a rollup of how many of its projects are stalled. areas[]: each active area with area_neglected (no human activity attributed to the area — via any project, goal, or milestone — for >14 days). HUMAN ACTIVITY ONLY: progress counts solely activity_events by the owner (actor='human'); agent/system actors (hermes, codex, …) never count as progress. Read this to ground a conversation in what has gone quiet, never to change anything.",
Description: "Read-only PARA momentum/stalled intelligence for Koopa's projects, goals, and areas — computed LIVE at read time, nothing stored. No parameters: it always returns the owner's full PARA, not a caller-scoped view. projects[]: every candidate project (status in_progress|planned) with its expected_cadence, last_human_activity_at, days_since_human_activity, open_next_action (has an open todo OR an incomplete milestone), milestone_done/total, and a stalled flag. expected_cadence is optional — a project without one still appears, with expected_cadence \"\" and stalled always false (no threshold to exceed). stalled = days_since_human_activity > 2× the cadence period (daily=1, weekly=7, biweekly=14, monthly=30 days) AND there is an open next action — a project with no open next action is '待規劃' (to-plan), never stalled. goals[]: each active goal with milestone progress and a rollup of how many of its projects are stalled. areas[]: each active area with area_neglected (no human activity attributed to the area — via any project, goal, or milestone — for >14 days). HUMAN ACTIVITY ONLY: progress counts solely activity_events by the owner (actor='human'); agent/system actors (hermes, codex, …) never count as progress. Read this to ground a conversation in what has gone quiet, never to change anything.",
}
}

Expand Down
18 changes: 10 additions & 8 deletions internal/project/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ const stalledFactor = 2
// ProjectMomentum is one candidate project's live momentum signal. It is
// populated entirely from a read-time JOIN — no field is persisted.
// LastHumanActivityAt is nil when no human (actor='human') event is scoped
// to the project; the project still appears (it is a candidate by status +
// cadence), it simply has no human activity yet.
// to the project; the project still appears (it is a candidate by status
// alone), it simply has no human activity yet.
type ProjectMomentum struct {
Slug string `json:"slug"`
Title string `json:"title"`
Expand Down Expand Up @@ -87,8 +87,10 @@ type AreaActivity struct {
}

// Momentum returns the live momentum signal for every candidate project —
// status in_progress|planned with an expected_cadence set. Read-only: it
// runs one SELECT and computes nothing it stores.
// status in_progress|planned. expected_cadence is optional: a project
// without one still appears, it simply never reads as stalled (Stalled
// treats an unrecognised/empty cadence as no threshold to exceed). Read-only:
// it runs one SELECT and computes nothing it stores.
func (s *Store) Momentum(ctx context.Context) ([]ProjectMomentum, error) {
rows, err := s.q.ProjectMomentum(ctx)
if err != nil {
Expand Down Expand Up @@ -216,10 +218,10 @@ func asTime(v interface{}) *time.Time {
return &t
}

// deref returns the string a *string points to, or "" when nil. The
// expected_cadence column is nullable in the schema but every project_progress
// candidate row has it set (the query's WHERE excludes NULL), so the empty
// fallback is defensive only.
// deref returns the string a *string points to, or "" when nil. A
// project_progress candidate row's expected_cadence is nullable — "" means the
// owner hasn't set a cadence yet, which Stalled treats as no threshold to
// exceed (never stalled).
func deref(s *string) string {
if s == nil {
return ""
Expand Down
8 changes: 4 additions & 4 deletions internal/project/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,10 @@ ORDER BY title;

-- name: ProjectMomentum :many
-- Per-project momentum row for the project_progress tool. One row per
-- candidate project (status in_progress|planned AND expected_cadence set —
-- proposed/archived/cadence-less projects are excluded because a project
-- without a cadence has no "expected frequency" to be stalled against).
-- candidate project (status in_progress|planned — proposed/archived are
-- excluded). expected_cadence is NOT required: a project without one still
-- appears, it simply never registers as stalled (project.Stalled treats an
-- unrecognised/empty cadence as "no threshold to exceed").
--
-- HUMAN ACTIVITY ONLY: last_human_activity_at is the latest
-- activity_events.occurred_at for an event scoped to this project
Expand Down Expand Up @@ -185,7 +186,6 @@ LEFT JOIN (
GROUP BY ae.project_id
) ha ON ha.project_id = p.id
WHERE p.status IN ('in_progress', 'planned')
AND p.expected_cadence IS NOT NULL
ORDER BY p.title;

-- name: ActiveGoalMilestones :many
Expand Down
Loading