Skip to content

Commit a19ce91

Browse files
taylaandclaude
andcommitted
feat: study backlog phase 1 — topics CRUD, auto-persist, agent surfacing
Query layer over existing parked_topics table (no new table, YAGNI). - Migration v16: add source (parked|struggled|manual) and tech_area columns to parked_topics, update unique index - studyctl backlog list/add/resolve — CLI commands for backlog management - backlog_logic.py — FCIS functional core for formatting, summarising, and planning auto-persist actions (zero I/O, zero mocks in tests) - Auto-persist struggled topics at session end via plan_auto_persist() - Inject backlog summary into agent persona at session start - Architecture doc with PlantUML diagrams (docs/architecture/) - 27 new tests (14 unit, 9 CLI, 4 fixture updates), 765 total passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1652996 commit a19ce91

12 files changed

Lines changed: 1475 additions & 26 deletions

File tree

‎docs/architecture/study-backlog-phase1.md‎

Lines changed: 646 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Brainstorm: Study Backlog Phase 1 — 2026-04-03
2+
3+
## What We're Building
4+
5+
Cross-session study backlog surfaced via `studyctl topics list/add/resolve`. The backlog is a **query layer over existing data** — no new table. Uses `parked_topics` as the single store for all backlog items (parked, struggled, manually added).
6+
7+
### Commands
8+
9+
- `studyctl topics list` — show pending backlog items across all sessions
10+
- `studyctl topics add "topic" --tech "Python" --note "..."` — manually add to backlog
11+
- `studyctl topics resolve <id>` — mark as resolved
12+
- Agent surfaces backlog count at session start
13+
14+
## Why This Approach
15+
16+
- `parked_topics` table (v14) already has status tracking, session refs, timestamps
17+
- Creating a separate `study_backlog` table would duplicate data and add sync complexity
18+
- Query layer is simpler: `SELECT * FROM parked_topics WHERE status='pending'`
19+
- Struggled topics get persisted to the same table at session end — one code path
20+
21+
## Key Decisions
22+
23+
| Decision | Choice | Rationale |
24+
|----------|--------|-----------|
25+
| Data model | Query layer over `parked_topics` | No new table, no data duplication, YAGNI |
26+
| Struggled topics | Persist to `parked_topics` at session end | Scan `session-topics.md` for `status:struggling`, insert with `source='struggled'` |
27+
| Manual add | NULL session refs | Migration v16 relaxes FK, `source='manual'` distinguishes them |
28+
| Agent surfacing | Inject backlog count into agent persona | "You have 3 outstanding Python topics" at session start |
29+
30+
## Migration v16
31+
32+
- Make `study_session_id` and `session_id` NULLable on `parked_topics`
33+
- Add `source` column: `CHECK('parked', 'struggled', 'manual')` DEFAULT 'parked'
34+
- Add `tech_area` column (TEXT, nullable) for technology categorization
35+
36+
## Scope
37+
38+
### In scope (Phase 1)
39+
- CLI commands: `topics list`, `topics add`, `topics resolve`
40+
- Migration v16: nullable FKs + source + tech_area columns
41+
- Auto-persist struggled topics at session end
42+
- Agent persona injection at session start
43+
- Tests: unit for query logic (FCIS), CLI tests
44+
45+
### Out of scope (Phase 2)
46+
- AI-driven categorization
47+
- Priority scoring from history
48+
- `studyctl topics suggest` command

‎packages/agent-session-tools/src/agent_session_tools/migrations.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
logger = logging.getLogger(__name__)
1414

1515
# Current schema version - increment when adding new migrations
16-
CURRENT_VERSION = 15
16+
CURRENT_VERSION = 16
1717

1818
# Migration functions: version -> (description, migration_func)
1919
MIGRATIONS: dict[int, tuple[str, Callable[[sqlite3.Connection], None]]] = {}
@@ -586,6 +586,34 @@ def migrate_v15(conn: sqlite3.Connection) -> None:
586586
""")
587587

588588

589+
@migration(16, "Add source and tech_area columns to parked_topics for study backlog")
590+
def migrate_v16(conn: sqlite3.Connection) -> None:
591+
"""Extend parked_topics for study backlog: distinguish parked/struggled/manual
592+
entries and allow technology area categorization.
593+
594+
- source: where the entry came from (parked, struggled, manual)
595+
- tech_area: technology category (Python, SQL, etc.)
596+
- Updated unique index to allow same question from different sources
597+
- FKs are already nullable (ON DELETE SET NULL) from v14
598+
"""
599+
conn.execute("""
600+
ALTER TABLE parked_topics
601+
ADD COLUMN source TEXT NOT NULL DEFAULT 'parked'
602+
CHECK(source IN ('parked', 'struggled', 'manual'))
603+
""")
604+
conn.execute("""
605+
ALTER TABLE parked_topics
606+
ADD COLUMN tech_area TEXT
607+
""")
608+
# Rebuild unique index to include source — allows same question
609+
# from different sources (e.g. parked during session, then manually added)
610+
conn.execute("DROP INDEX IF EXISTS uix_parked_topics_session_question")
611+
conn.execute("""
612+
CREATE UNIQUE INDEX uix_parked_topics_session_question
613+
ON parked_topics (study_session_id, question, source)
614+
""")
615+
616+
589617
def check_migration_status(db_path: Path) -> dict:
590618
"""Check migration status without modifying database.
591619
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Backlog logic — pure functional core, no I/O.
2+
3+
Decides how to format, summarise, and persist study backlog items.
4+
The imperative shells (_topics.py, _study.py) handle all side effects.
5+
6+
See docs/mentoring/functional-core-imperative-shell.md for the pattern.
7+
See docs/architecture/study-backlog-phase1.md for the full design.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from collections import defaultdict
13+
from dataclasses import dataclass, field
14+
from typing import TYPE_CHECKING
15+
16+
if TYPE_CHECKING:
17+
from studyctl.session_state import TopicEntry
18+
19+
20+
@dataclass
21+
class BacklogItem:
22+
"""A single backlog entry — pre-fetched from DB."""
23+
24+
id: int
25+
question: str
26+
topic_tag: str | None
27+
tech_area: str | None
28+
source: str # parked | struggled | manual
29+
context: str | None
30+
parked_at: str
31+
session_topic: str | None # from study_sessions.topic via join
32+
33+
34+
@dataclass
35+
class FormattedBacklog:
36+
"""Result of format_backlog_list() — ready for display."""
37+
38+
items: list[BacklogItem] = field(default_factory=list)
39+
total: int = 0
40+
by_tech: dict[str, list[BacklogItem]] = field(default_factory=dict)
41+
by_source: dict[str, int] = field(default_factory=dict)
42+
43+
44+
@dataclass
45+
class PersistAction:
46+
"""A struggled topic to persist to parked_topics."""
47+
48+
question: str
49+
topic_tag: str | None
50+
context: str | None
51+
study_session_id: str
52+
source: str = "struggled"
53+
54+
55+
def format_backlog_list(
56+
items: list[BacklogItem],
57+
*,
58+
tech_filter: str | None = None,
59+
source_filter: str | None = None,
60+
) -> FormattedBacklog:
61+
"""Filter and group backlog items for display. Pure logic."""
62+
filtered = items
63+
if tech_filter:
64+
filtered = [i for i in filtered if i.tech_area == tech_filter]
65+
if source_filter:
66+
filtered = [i for i in filtered if i.source == source_filter]
67+
68+
by_tech: dict[str, list[BacklogItem]] = defaultdict(list)
69+
by_source: dict[str, int] = defaultdict(int)
70+
for item in filtered:
71+
tech_key = item.tech_area or "Uncategorized"
72+
by_tech[tech_key].append(item)
73+
by_source[item.source] += 1
74+
75+
return FormattedBacklog(
76+
items=filtered,
77+
total=len(filtered),
78+
by_tech=dict(by_tech),
79+
by_source=dict(by_source),
80+
)
81+
82+
83+
def build_backlog_summary(
84+
pending_items: list[BacklogItem],
85+
current_topic: str,
86+
) -> str | None:
87+
"""Build markdown snippet for agent persona injection.
88+
89+
Returns None if no pending items. Prioritises items matching
90+
the current topic's tech area (simple substring match).
91+
"""
92+
if not pending_items:
93+
return None
94+
95+
# Sort: items matching current topic first, then the rest
96+
topic_lower = current_topic.lower()
97+
98+
def relevance(item: BacklogItem) -> int:
99+
if item.tech_area and item.tech_area.lower() in topic_lower:
100+
return 0
101+
if item.topic_tag and item.topic_tag.lower() in topic_lower:
102+
return 0
103+
return 1
104+
105+
sorted_items = sorted(pending_items, key=relevance)
106+
107+
lines = [f"You have {len(sorted_items)} outstanding study topics:\n"]
108+
for item in sorted_items:
109+
tech = f" [{item.tech_area}]" if item.tech_area else ""
110+
source = f" ({item.source})" if item.source != "parked" else ""
111+
lines.append(f"- {item.question}{tech}{source}")
112+
113+
return "\n".join(lines)
114+
115+
116+
def plan_auto_persist(
117+
topic_entries: list[TopicEntry],
118+
existing_questions: set[str],
119+
study_session_id: str,
120+
) -> list[PersistAction]:
121+
"""Decide which struggled topics to persist to parked_topics.
122+
123+
Filters for status='struggling', deduplicates against
124+
existing_questions (already in parked_topics for this session).
125+
"""
126+
actions: list[PersistAction] = []
127+
for entry in topic_entries:
128+
if entry.status != "struggling":
129+
continue
130+
if entry.topic in existing_questions:
131+
continue
132+
actions.append(
133+
PersistAction(
134+
question=entry.topic,
135+
topic_tag=entry.topic,
136+
context=entry.note or None,
137+
study_session_id=study_session_id,
138+
)
139+
)
140+
return actions

‎packages/studyctl/src/studyctl/cli/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
"sidebar": "studyctl.cli._study:sidebar_cmd",
4646
# _clean.py — cleanup orphaned session artifacts
4747
"clean": "studyctl.cli._clean:clean",
48+
# _topics.py — study backlog management
49+
"backlog": "studyctl.cli._topics:topics_group",
4850
# _doctor.py — diagnostic health checks
4951
"doctor": "studyctl.cli._doctor:doctor",
5052
# _upgrade.py — update check + upgrade apply

‎packages/studyctl/src/studyctl/cli/_study.py‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,80 @@ def _auto_clean_zombies() -> None:
154154
)
155155

156156

157+
def _build_backlog_notes(topic: str) -> str | None:
158+
"""Gather pending backlog items and build a summary for the agent persona.
159+
160+
Returns None if no pending items. Uses FCIS pattern — gather data
161+
from parking.py, delegate formatting to backlog_logic.
162+
"""
163+
import contextlib
164+
165+
with contextlib.suppress(Exception):
166+
from studyctl.backlog_logic import BacklogItem, build_backlog_summary
167+
from studyctl.parking import get_parked_topics
168+
169+
raw = get_parked_topics(status="pending")
170+
if not raw:
171+
return None
172+
items = [
173+
BacklogItem(
174+
id=t["id"],
175+
question=t["question"],
176+
topic_tag=t.get("topic_tag"),
177+
tech_area=t.get("tech_area"),
178+
source=t.get("source", "parked"),
179+
context=t.get("context"),
180+
parked_at=t["parked_at"],
181+
session_topic=None,
182+
)
183+
for t in raw
184+
]
185+
return build_backlog_summary(items, topic)
186+
return None
187+
188+
189+
def _auto_persist_struggled(
190+
study_session_id: str,
191+
topic_entries: list,
192+
) -> None:
193+
"""Persist struggled topics from session-topics.md to the backlog.
194+
195+
Uses FCIS pattern — plan_auto_persist decides what to persist,
196+
then we execute by calling park_topic for each action.
197+
"""
198+
import contextlib
199+
200+
with contextlib.suppress(Exception):
201+
from studyctl.backlog_logic import plan_auto_persist
202+
from studyctl.parking import get_parked_topics, park_topic
203+
204+
# Gather existing questions for this session to deduplicate
205+
existing = get_parked_topics(study_session_id=study_session_id)
206+
existing_questions = {t["question"] for t in existing}
207+
208+
# Decide
209+
actions = plan_auto_persist(topic_entries, existing_questions, study_session_id)
210+
211+
# Execute
212+
persisted = 0
213+
for action in actions:
214+
result = park_topic(
215+
question=action.question,
216+
topic_tag=action.topic_tag,
217+
context=action.context,
218+
study_session_id=action.study_session_id,
219+
source=action.source,
220+
)
221+
if result:
222+
persisted += 1
223+
224+
if persisted:
225+
console.print(
226+
f"[dim]Saved {persisted} struggled "
227+
f"topic{'s' if persisted != 1 else ''} to backlog[/dim]"
228+
)
229+
230+
157231
def _handle_start(
158232
ctx: click.Context,
159233
topic: str,
@@ -323,6 +397,11 @@ def _handle_start(
323397
if session_exists(session_name):
324398
kill_session(session_name)
325399

400+
# Inject backlog context into agent persona
401+
backlog_notes = _build_backlog_notes(topic)
402+
if backlog_notes:
403+
previous_notes = f"{previous_notes}\n\n{backlog_notes}" if previous_notes else backlog_notes
404+
326405
# Build commands before creating panes
327406
persona_file = build_persona_file(mode, topic, energy, previous_notes=previous_notes)
328407

@@ -549,7 +628,11 @@ def _handle_end(_ctx: click.Context) -> None:
549628
# Capture session context before ending
550629
from studyctl.session_state import parse_parking_file, parse_topics_file
551630

552-
notes = _build_session_notes(parse_topics_file(), parse_parking_file())
631+
topic_entries = parse_topics_file()
632+
notes = _build_session_notes(topic_entries, parse_parking_file())
633+
634+
# Auto-persist struggled topics to backlog
635+
_auto_persist_struggled(study_id, topic_entries)
553636

554637
# End the DB session with captured notes
555638
end_study_session(study_id, notes=notes)

0 commit comments

Comments
 (0)