diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index 3fe00dd3..8ce91283 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -4037,7 +4037,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 ` @@ -4061,9 +4060,10 @@ type ProjectMomentumRow struct { // the single source of truth for "what happened, by whom". // ============================================================ // 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 diff --git a/internal/mcp/integration_test.go b/internal/mcp/integration_test.go index 4790b1bc..7e4b45da 100644 --- a/internal/mcp/integration_test.go +++ b/internal/mcp/integration_test.go @@ -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() @@ -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) @@ -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") } } diff --git a/internal/mcp/ops/catalog.go b/internal/mcp/ops/catalog.go index cc95a474..c142be8d 100644 --- a/internal/mcp/ops/catalog.go +++ b/internal/mcp/ops/catalog.go @@ -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.", } } diff --git a/internal/project/progress.go b/internal/project/progress.go index 39d18498..a908daba 100644 --- a/internal/project/progress.go +++ b/internal/project/progress.go @@ -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"` @@ -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 { @@ -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 "" diff --git a/internal/project/query.sql b/internal/project/query.sql index 6f7a49c2..4631483d 100644 --- a/internal/project/query.sql +++ b/internal/project/query.sql @@ -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 @@ -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