diff --git a/docs/superpowers/specs/2026-09-02-project-cadence-design.md b/docs/superpowers/specs/2026-09-02-project-cadence-design.md index d946e67..0aa3929 100644 --- a/docs/superpowers/specs/2026-09-02-project-cadence-design.md +++ b/docs/superpowers/specs/2026-09-02-project-cadence-design.md @@ -1,9 +1,9 @@ -# Project cadence — Lyra as BIT's observer and picker +# Project cadence — BIT observes, Lyra picks - **Date:** 2026-09-02 - **Status:** Spec — approved to build - **Branch:** `feat/project-cadence` -- **Second repo:** `~/break-it-down` (BIT) — needs its own branch work, see Phase 0 +- **Two repos, both worked on:** `~/break-it-down` (BIT) — Part A; `project-lyra` — Part B - **Related:** `lyra/thoughts.py` (surfacing machinery), `docs/ROADMAP.md` --- @@ -56,10 +56,21 @@ Mirrors the frame already committed to for the pokerlog: > **BIT is the system of record. Lyra is a client of it, not its container.** +**The test that decides where any piece of work goes:** + +> **Does it produce or derive facts? → BIT. Does it require knowing Brian? → Lyra.** + | | Owns | Rationale | |---|---|---| -| **BIT** | Facts: projects, tasks, blockers, estimates, **time logs** | Standalone value. Human-editable UI. Works with Lyra dead. | -| **Lyra** | The relationship: cadence config, nudge state, dormancy, **the pick** | None of this is a fact about the garage. It's her state *about* the garage. | +| **BIT** | Facts and everything derived from them: projects, tasks, blockers, estimates, **time logs**, **the git observer**, **cadence health** | Standalone value. Human-editable UI. Works with Lyra dead — and the observer is what keeps that true. | +| **Lyra** | The relationship: **the pick**, nudge state, backoff, conversational logging, decomposition offers | Every one of these needs to know it's 9pm, that he has 20 minutes, that he was warm on seismo-relay yesterday. | + +An earlier draft of this spec put the git observer and cadence math in Lyra. That +was wrong and violated the frame above: **the observer produces facts and knows +nothing about Brian.** Worse, it would have made BIT's data true only while Lyra +was running — reintroducing exactly the coupling the separable frame exists to +prevent. Auto-logging and cadence health also make BIT meaningfully better as a +standalone product, which is the justification for keeping it separable at all. Same distinction as poker's *ledger* (facts) vs *relationship* (her memory of the sessions). @@ -161,45 +172,48 @@ branch has both halves. `main` (Feb 17) has neither. ``` ┌────────────────────────┐ - │ git repos on disk │ + │ git repos on host │ │ (7 of 8 BIT projects) │ └───────────┬────────────┘ - │ walk history, group by local day - ▼ - ┌────────────────────────┐ ┌──────────────────────────────┐ - │ lyra/gitobserver.py │ │ chat: "spent an hour in the │ - │ deterministic, no LLM │ │ garage" → log_work tool │ - └───────────┬────────────┘ └──────────────┬───────────────┘ - │ │ - └──────────────┬─────────────────────┘ - ▼ POST /api/tasks/{id}/time-logs - ┌───────────────────────────┐ - │ BIT (facts) │ - │ projects · tasks · blockers│ - │ time_logs · goals │ - └───────────┬───────────────┘ - │ GET /actionable, /time-summary, /tasks - ▼ - ┌───────────────────────────┐ - │ lyra/projects.py │ - │ BIT client + cadence math │ - │ (pure, no LLM) │ - └───────────┬───────────────┘ - │ - ┌──────────────┼──────────────────┐ - ▼ ▼ ▼ - lyra/tools.py cadence state thoughts.py surfacing - (she can talk (Lyra's DB: (salience → surface → - about it) goals, nudge ping → response, - state, dormancy) budget + backoff) + │ git_observer.py — BIT's code, runs on the HOST + │ walk history, group by local day, no LLM + ▼ POST /api/tasks/{id}/time-logs + ┌──────────────────────────────────────────────────┐ + │ BIT — facts, and everything derived from them │ + │ projects · tasks · blockers · estimates │ + │ time_logs · goals · git_sessions │ + │ GET /api/actionable │ + │ GET /api/projects/health (cadence, new) │ + │ cadence health rendered on the project cards │ + └────────────────────┬─────────────────────────────┘ + │ HTTP + ▼ + ┌──────────────────────────────────────────────────┐ + │ lyra/bit.py — thin client. No cadence math here. │ + └────────────────────┬─────────────────────────────┘ + ┌───────────┴───────────┐ + ▼ ▼ + lyra/tools.py lyra/projects.py + log_work, pick_next, nudge state, ignore counts, + break_down, status backoff, quiet hours + ▲ │ + │ ▼ + chat: "spent an hour thoughts.py surfacing + in the garage" (salience → surface → + ping → response) ``` -**Data flow, one line:** git and chat write facts into BIT; Lyra reads BIT, computes -cadence, and decides whether and how to open her mouth. +**Data flow, one line:** git and chat write facts into BIT; BIT derives cadence; +Lyra reads BIT and decides whether, when, and how to open her mouth. --- -## Phase 0 — BIT consolidation +# Part A — work in `~/break-it-down` (BIT) + +Everything here is deterministic. **No LLM appears anywhere in Part A.** All of it +makes BIT better as a standalone product, independently of Lyra. + +## A0 — consolidation and deploy There is no production data. The 8 projects / 77 tasks on `10.0.0.40` were test builds; a snapshot lives at `~/bit-seed-snapshot/` if any of it is worth reseeding @@ -217,26 +231,28 @@ through the existing JSON import. 5. Create the real projects. `Clean up march 26` becomes actual physical projects (garage, music backup, spare closet). -**Exit criteria:** one BIT instance on this box serving blockers *and* time logs, -reachable from Lyra. +**Exit criteria:** one BIT instance on this box serving blockers *and* time logs. ---- +## A1 — the git observer -## Phase 1 — the git observer +Ships **with BIT**, in BIT's repo. Deterministic Python, no model, no knowledge of +Brian — it produces facts, which is why it lives here. -`lyra/gitobserver.py`. **Deterministic. No LLM anywhere in this module** (principle 4), -which keeps the backfill reproducible and unit-testable. +**Deployment note:** BIT runs in Docker; the git repos live on the host. So the +observer runs **on the host** (cron or systemd timer) and POSTs to BIT's API like +any other client. That avoids volume-mounting every repo into the container while +keeping the code in BIT's repo where it belongs. ### Config -A repo↔project mapping in Lyra's config: local repo path → BIT project id. -Explicit, not auto-discovered — auto-discovery would guess wrong and pollute the -ledger. +A repo↔project mapping: local repo path → BIT project id. Stored in BIT (a small +`observed_repos` table with a settings UI later, or config file for v1). Explicit, +never auto-discovered — auto-discovery would guess wrong and pollute the ledger. ### Session derivation -For each repo, group commits **by local calendar day** (Brian's configured -timezone via `clock.py`, not UTC — a 11pm commit belongs to that evening). +For each repo, group commits **by local calendar day** in Brian's timezone, not +UTC — an 11pm commit belongs to that evening. For each day with commits: @@ -245,11 +261,12 @@ For each day with commits: - `MIN_MINUTES = 15` — a single-commit day would otherwise be 0 - `MAX_MINUTES = 240` — a 9am and an 11pm commit is not a 14-hour session -These are heuristics and must be **config, not constants**, and documented as -estimates in the spec and in the code. +These are heuristics. They must be **config, not constants**, and documented as +estimates in both the spec and the code. -- `session_type = "git"` — never `'manual'` or `'pomodoro'`. Estimated time must - never masquerade as measured time. +- `session_type = "git"` — never `manual` or `pomodoro`. Estimated time must never + masquerade as measured time. The field is a free-text `String(50)`, so this is + **not a schema change**. - `note` = commit count and short SHA range, for traceability. ### Attribution @@ -258,23 +275,19 @@ estimates in the spec and in the code. task. Two deterministic tiers: 1. **Conventional-commit scope match.** `feat(persona): …` → match `persona` - against task titles and tags in that project, case-insensitive substring. Plain - string matching, no model. + against task titles and tags in that project, case-insensitive substring. + Plain string matching, no model. 2. **Fallback:** a per-project `Unfiled work` task, created on demand. -Attribution is a nice-to-have; **the totals are the point**. Attribution must never -block or fail a backfill. LLM-assisted attribution is explicitly deferred (see -Non-goals). +Attribution is a nice-to-have; **the totals are the point**. It must never block or +fail a backfill. LLM-assisted attribution is explicitly deferred (see Non-goals). ### Idempotency — the critical correctness property The observer runs repeatedly and must never double-log. -Lyra owns a `git_sessions` table: `(repo_path, local_date)` unique → `bit_time_log_id`, -`last_sha`. A day is logged only if that key is absent. This is idempotent by -construction and survives BIT being reset — on a fresh BIT, clear the table and -re-backfill cleanly. - +A `git_sessions` table in BIT: `(repo_path, local_date)` unique → `time_log_id`, +`last_sha`. A day is logged only if that key is absent. Idempotent by construction. A re-run over a day that gained new commits **updates** the existing time log rather than adding a second one. @@ -282,29 +295,18 @@ rather than adding a second one. One-shot run over history since 2026-03-21 (or repo start). `project-lyra` alone should produce ~30 sessions. **This is the emotional payload of the entire -project** — the first time BIT is opened after this, it says "30 days worked since +project** — the first time BIT is opened afterwards it says "30 days worked since March" instead of showing a tombstone. -### Non-code projects in v1 - -The git observer does nothing for the garage or the music collection. Those are -covered in v1 by **conversational logging**: "I spent an hour in the garage" → -`log_work` tool → a real `time_logs` row. Zero new infrastructure. Automated -observation of physical work is deferred past v1 (see Non-goals). - **Exit criteria:** BIT shows true history for every configured repo; re-running the observer changes nothing. ---- +## A2 — cadence health -## Phase 2 — cadence +Derived from `time_logs` + `Project.weekly_hours_goal`, both of which are BIT's. +No knowledge of Brian required, so it belongs here rather than in Lyra. -`lyra/projects.py` — BIT HTTP client plus **pure cadence functions** (easy to test, -no I/O). - -### Health states - -Computed from `time_logs` + `Project.weekly_hours_goal`: +### States - `on_cadence` — trailing-7-day minutes ≥ `weekly_hours_goal` - `drifting` — some activity in the window, below goal @@ -314,41 +316,56 @@ Computed from `time_logs` + `Project.weekly_hours_goal`: `weekly_hours_goal` is nullable, so `untracked` is the common initial state and must render sensibly. -### Lyra-side state +**Trap:** `weekly_hours_goal` and `total_hours_goal` are named *hours* but the +source comment says they store **minutes**. Do not multiply by 60. -A small table keyed by BIT project id: cadence target, last surfaced, ignore -count, dormancy, salience. **Not in BIT** — it's her state about the project, not -a fact about it. +### Surface it + +- `GET /api/projects/health` — cadence state and accumulated time per project +- **Render it on the project cards.** They currently show `Created 3/20/2026` and + nothing about activity, which is precisely how the graveyard effect happens. + Last-touched plus cadence state plus total logged time turns that wall of dead + cards into a readable status board — and this is the payoff of A1 becoming + visible to a human without Lyra involved at all. + +**Exit criteria:** opening BIT shows, at a glance, which projects are alive. --- -## Phase 3 — Lyra tools +# Part B — work in `project-lyra` -Following the `lyra/tools.py` spec + dispatch pattern: +Everything here needs to know something about Brian. This is where the LLM lives, +and it is allowed to be flaky, because a missed nudge costs nothing. -- `projects_overview()` — every project with cadence health and accumulated time -- `project_status(name)` — detail, recent sessions, what's actionable -- `log_work(project, task, minutes, note)` — conversational session logging -- `pick_next(minutes_available)` — see Phase 4 -- `break_down(task_id)` — generate a subtask tree, push it through BIT's existing - JSON import -- `set_goal(project, weekly_hours)` -- `set_dormant(project)` / `wake(project)` +## B1 — client and tools ---- +- `lyra/bit.py` — thin HTTP client over BIT's API. **No cadence math**; BIT already + computed it. Degrades to a clear "BIT is down" message, never a stack trace into + her context and never a fabricated answer about project state. +- `lyra/tools.py` additions, following the existing spec + dispatch pattern: + - `projects_overview()` — every project with cadence health and logged time + - `project_status(name)` — detail, recent sessions, what's actionable + - `log_work(project, task, minutes, note)` — **conversational session logging** + - `pick_next(minutes_available)` — see B2 + - `break_down(task_id)` — generate a subtask tree, push it through BIT's + existing JSON import + - `set_goal(project, weekly_hours)`, `set_dormant(project)`, `wake(project)` -## Phase 4 — surfacing and the pick +**Conversational logging is how non-code projects get tracked in v1.** The git +observer does nothing for the garage or the music collection; "I spent an hour in +the garage" → `log_work` → a real `time_logs` row. Zero new infrastructure. +Automated observation of physical work is deferred past v1 (see Non-goals). -### The pick +## B2 — the pick The one place an LLM is genuinely required. Input: `/api/actionable`, minutes available, recent sessions, cadence health, time of day. Output: **one task and one -sentence of why.** Not a list. A list is what BIT already does badly. +sentence of why.** Not a list — a list is what BIT already does badly, at 68 items. ### The decomposition trigger -Live data shows tasks like "Multi-user authentication — est 480", "Historical data -tracking — 360". **You cannot start an eight-hour task**, and these sit in +Live data shows tasks like "Multi-user authentication — est 480" and "Historical +data tracking — 360". **You cannot start an eight-hour task**, and these sit in `/actionable` forever radiating "why bother." Rule: `estimated_minutes > DECOMPOSE_THRESHOLD` (default 60) means decomposition @@ -356,7 +373,7 @@ hasn't happened yet. When the best candidates are all oversized, Lyra offers to break one down instead of proposing it — closing the anti-perfectionism loop through `break_down`, using the JSON import that already exists for exactly this. -### Surfacing +## B3 — surfacing Reuse `thoughts.py` machinery; **do not put projects in the `threads` table.** A thought is her interiority; a project is a fact about Brian's life. Conflating them @@ -364,6 +381,10 @@ pollutes both — her dream cycle would generate the garage as a thought, and th garage would appear in her self-narrative. Separate storage, shared salience → surface → ping → response loop. +Lyra owns a small table keyed by BIT project id holding **nudge state only**: last +surfaced, ignore count, snooze, salience. Cadence itself is read from BIT, never +recomputed here. + **Channel priority:** 1. **Conversation (default).** Next time Brian's talking to her anyway, she raises @@ -433,28 +454,35 @@ every day life" is the stated goal and feature creep is the named enemy. ## Testing -Follows existing conventions (`tests/`, pytest, replay evals for LLM paths). +Split by repo, matching each one's existing conventions. + +### In BIT (`pytest`, backend) - **Git observer:** fixture repo built in a tmpdir with controlled commit timestamps. Assert day grouping across timezone boundaries, MIN/MAX/padding - clamps, scope-match attribution, `Unfiled work` fallback, and — most - importantly — **that a second run produces zero new time logs**. + clamps, conventional-commit scope attribution, `Unfiled work` fallback, and — + most importantly — **that a second run produces zero new time logs**. - **Cadence functions:** pure, table-driven. Boundary cases: no goal set, goal met - exactly, dormancy threshold ±1 day, zero sessions ever. -- **BIT client:** against a stub server. Include the `/tree` 404 case so a README - regression can't silently break it. + exactly, dormancy threshold ±1 day, zero sessions ever, and the + hours-named-but-minutes-valued goal fields. +- **`/api/projects/health`:** shape and states against a seeded DB. + +### In `project-lyra` (`pytest`, existing `tests/`) + +- **`lyra/bit.py`:** against a stub server. Include a 404 case so a BIT API change + can't silently break it, and assert graceful degradation when BIT is down. +- **Nudge state:** ignore counts, backoff to dormant, quiet hours, push budget. + Pure and deterministic — no model. - **The pick and the surfacing copy:** replay eval, parameterized by `EVAL_BACKEND`/`EVAL_MODEL`, matching the persona eval pattern. Assert it returns *one* task, and that it offers decomposition when candidates are oversized. ---- - ## Open questions 1. **Ports for BIT on this box.** `:3002/:8002` are free here; confirm at deploy. 2. **Which of the 77 snapshotted tasks are worth reseeding**, versus starting the - real projects clean. Brian's call during Phase 0. + real projects clean. Brian's call during A0. 3. **Whether `weekly_hours_goal` gets set per project at all in v1**, or whether `untracked` + days-since-last-session is enough to start. Leaning: ship without goals, add them once real session data shows what a realistic cadence is.