feat(placement): a record you only cite carries its status (#4154)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Failing after 1m4s
CI & Build / Build & push image (push) Skipped

Step 1 made placement cheap for a task whose status CHANGES: create_task
and update_task return where it sits, and the report is written from
that. It did nothing for a task a reply merely cites.

This milestone's own step-6 review reported "#4014 is the open step of
milestone 409". #4014 had been done for four days; the open step was
#4015. The id did not come from a read — it came from a retrieval hint,
which carries an id, a kind and a title and says nothing about status,
while list_milestones said "8 of 9" and would not say which one. The
gap was there to be filled and the nearest-looking id filled it.

Two surfaces, one principle: the status arrives with the id.

1. get_project_milestone_summaries gains next_step — the earliest open
   step, {id, title, status} or None — carried through _BRIEF_FIELDS to
   enter_project, get_project and list_milestones. One extra flat query
   for the whole batch, so #2384's fan-out does not come back.

   OPEN_STEP_STATUSES moves to services/milestones.py and placement.py
   imports it; both surfaces now answer "what is next" and must not
   drift on what counts as open. Both step queries take the same
   readable_notes_clause (rule 78), so a row cannot name a step its own
   progress numbers exclude.

2. _record_kind renders a task's status: [task (done)], [issue (todo)].
   A finished step and an open one read identically before, which is
   exactly the line the misreport was taken from. Only tasks — is_task
   IS status-is-not-None on the model, so there is no fallback branch.

reporting-back gains the practice, owned and registered in the guidance
ownership table: a record you only mention is a record to read.

The guards are structural and each fails on the regression it names:
the query count is asserted rather than the payload shape, and the two
surfaces' agreement is pinned on the rendered ORDER BY, since a mocked
session hands back whatever order the test chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-18 12:08:00 -04:00
co-authored by Claude Opus 5
parent b5df9d6dca
commit 5c6175ad97
13 changed files with 440 additions and 29 deletions
+8 -3
View File
@@ -24,9 +24,14 @@ async def list_milestones(project_id: int) -> dict:
"""List milestones for a Scribe project, ordered by order_index.
Returns every milestone, done ones included: id, title, description,
status (active/done), order_index and progress (total, completed, pct,
status_counts). The plan itself is not listed: get_milestone(id) returns a
milestone's body and its steps.
status (active/done), order_index, progress (total, completed, pct,
status_counts) and `next_step`. The plan itself is not listed:
get_milestone(id) returns a milestone's body and its steps.
`next_step` is the earliest step still open — {id, title, status} — or
null when the plan has none left. Use it as it came back. "7 of 9 done"
invites naming the open one from memory, and a remembered id reads exactly
like a read one while being a step that closed days ago.
"""
uid = current_user_id()
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
+12 -5
View File
@@ -77,9 +77,15 @@ async def enter_project(project_id: int) -> dict:
`milestone_summary` is the 5 most recently touched milestones, any status,
most recent first. Touched counts a step changing, not only the milestone
itself. Each carries its description and progress but NOT its plan:
get_milestone(id) reads a plan and its steps. `milestone_summary_omitted`
says how many others exist; list_milestones lists them all.
itself. Each carries its description, its progress and its `next_step` —
the earliest step still open, {id, title, status}, or null when none is —
but NOT its plan: get_milestone(id) reads a plan and its steps.
`milestone_summary_omitted` says how many others exist; list_milestones
lists them all.
Name the next step from `next_step`, not from recall: progress alone says
a plan has an open step and not which, and a step named from memory reads
exactly like one that was read.
`unplanned_milestones` is the active milestones that have NO steps yet,
in roadmap order (up to 10; `unplanned_milestones_omitted` counts the
@@ -290,8 +296,9 @@ async def get_project(project_id: int) -> dict:
"""Fetch a Scribe project by ID.
Returns full project fields, a milestone_summary list (every milestone,
with description and progress but no plan body; get_milestone reads a
plan), the project's own rules (project_rules), and applicable_rules: the
with description, progress and `next_step` — the earliest still-open step,
or null — but no plan body; get_milestone reads a plan), the project's own
rules (project_rules), and applicable_rules: the
global rules tagged to an area this project works in. Every other global
rule applies too and arrives by retrieval when the work matches it.
"""
+59 -8
View File
@@ -7,9 +7,16 @@ from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.services import access as access_svc
logger = logging.getLogger(__name__)
# The statuses that make a step OPEN work, defined here because this is the
# lower layer: services/placement.py imports it rather than restating it. Two
# surfaces that both answer "what is next" and disagree about what counts as
# next is worse than one of them staying silent.
OPEN_STEP_STATUSES = ("todo", "in_progress")
def embed_milestone(milestone: Milestone) -> None:
"""Refresh a milestone's vectors, fire-and-forget (milestone 415).
@@ -239,13 +246,21 @@ def _progress_from_counts(status_counts: dict[str, int]) -> dict:
async def get_project_milestone_summaries(
user_id: int, project_ids: list[int]
) -> dict[int, list[dict]]:
"""Milestone summaries for MANY projects in two queries total.
"""Milestone summaries for MANY projects in three queries total.
The per-project version below is a nested fan-out: one query to list a
project's milestones, then one more per milestone for its progress. Called
for 25 projects concurrently it asked for ~250 pooled connections against a
pool of 15, and every one of them waited out the 30-second checkout timeout
(#2384). This does the same work in two queries and one session.
(#2384). This does the same work in a fixed number of queries and one
session: the milestones, their step counts, and their open steps.
Each row carries `next_step` — the earliest open step, or None (#4154).
That is NOT the same question `services/placement.py` answers: placement
knows which step you are on and names the next one AFTER it, while a
listing has no current step, so the earliest open one is the whole answer.
The two share `OPEN_STEP_STATUSES` and the creation ordering so they can
never disagree about which steps are candidates.
"""
if not project_ids:
return {}
@@ -261,16 +276,24 @@ async def get_project_milestone_summaries(
counts: dict[int, dict[str, int]] = {}
step_touched: dict[int, datetime] = {}
next_step: dict[int, dict] = {}
if milestones:
milestone_ids = [m.id for m in milestones]
# Both step queries below take the SAME visibility clause (rule 78).
# They have to: `next_step` names a step and the counts beside it
# say how many there are, so a row that could name a step its own
# progress excludes would be reporting two different milestones.
readable = access_svc.readable_notes_clause(user_id)
rows = await session.execute(
select(
Note.milestone_id, Note.status, func.count(Note.id),
func.max(Note.updated_at),
)
.where(
Note.milestone_id.in_([m.id for m in milestones]),
Note.milestone_id.in_(milestone_ids),
Note.status.isnot(None),
Note.deleted_at.is_(None),
readable,
)
.group_by(Note.milestone_id, Note.status)
)
@@ -280,6 +303,28 @@ async def get_project_milestone_summaries(
or latest > step_touched[milestone_id]):
step_touched[milestone_id] = latest
# ONE query for every milestone in the batch, not one per row.
# #2384 was exactly this listing fanned out per milestone, and it
# drained the connection pool; a third flat query keeps the cost
# constant in the number of plans. Ordered the way
# services/placement.py orders steps — creation order, the order a
# plan is written and a batch create inserts — so the first row per
# milestone IS its next open step.
open_rows = await session.execute(
select(Note.milestone_id, Note.id, Note.title, Note.status)
.where(
Note.milestone_id.in_(milestone_ids),
Note.status.in_(OPEN_STEP_STATUSES),
Note.deleted_at.is_(None),
readable,
)
.order_by(Note.created_at.asc(), Note.id.asc())
)
for milestone_id, note_id, title, status in open_rows.fetchall():
next_step.setdefault(
milestone_id, {"id": note_id, "title": title, "status": status},
)
out: dict[int, list[dict]] = {pid: [] for pid in project_ids}
for m in milestones:
entry = m.to_dict()
@@ -289,6 +334,12 @@ async def get_project_milestone_summaries(
# written (#4045). Touched is the later of the two.
touched = [t for t in (m.updated_at, step_touched.get(m.id)) if t]
entry["last_touched_at"] = max(touched).isoformat() if touched else None
# Always present, None included. "8 of 9 done" tells a reader there is
# an open step and not WHICH, and a gap that shape gets filled from
# whatever id is nearest to hand — a retrieval hint carries an id and a
# title and no status, and that is how a done step was reported as the
# open one (#4154). A listing that names it leaves nothing to guess.
entry["next_step"] = next_step.get(m.id)
out.setdefault(m.project_id, []).append(entry)
return out
@@ -299,16 +350,16 @@ async def get_project_milestone_summary(user_id: int, project_id: int) -> list[d
return (await get_project_milestone_summaries(user_id, [project_id])).get(project_id, [])
# What a milestone LISTING needs: enough to say what each plan is and how far
# along it is. The plan itself (`body`) is get_milestone's job. Summaries once
# carried it, and on a project with 39 milestones enter_project came to ~222k
# characters, 168k of them plan bodies. That is past what an MCP client will
# What a milestone LISTING needs: enough to say what each plan is, how far
# along it is, and which step is next. The plan itself (`body`) is
# get_milestone's job. Summaries once carried it, and on a project with 39
# milestones enter_project came to ~222k characters, 168k of them plan bodies. That is past what an MCP client will
# accept as a tool result, so the session handshake arrived as a file to page
# through (#4045). user_id / project_id / timestamps repeat what the caller
# already knows.
_BRIEF_FIELDS = (
"id", "title", "description", "status", "order_index",
"total", "completed", "pct", "status_counts",
"total", "completed", "pct", "status_counts", "next_step",
)
+4 -2
View File
@@ -54,9 +54,11 @@ from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.services import access as access_svc
from scribe.services.milestones import _progress_from_counts
from scribe.services.milestones import OPEN_STEP_STATUSES, _progress_from_counts
_OPEN = ("todo", "in_progress")
# Imported, not restated. The milestone summary names a plan's next open step
# too (#4154), and the two answers must agree about what "open" means.
_OPEN = OPEN_STEP_STATUSES
logger = logging.getLogger(__name__)
+17 -2
View File
@@ -640,7 +640,7 @@ async def get_autoinject_config(user_id: int) -> dict:
def _record_kind(note) -> str:
"""The one-word kind marker for an injected menu line.
"""The kind marker for an injected menu line — and, for a task, its status.
The menu is drawn from every record that carries an embedding, so a snippet,
a stored process, an issue and a stray dev-log all arrive looking identical.
@@ -649,9 +649,24 @@ def _record_kind(note) -> str:
Task-ness wins over `note_type` because it's the more useful distinction at a
glance: "there's an open issue about this" beats "there's a note about this".
A TASK ALSO CARRIES ITS STATUS, because for that kind alone the line is
read as a claim about live work. A finished step and an open one rendered
identically is not a cosmetic gap: a done step was cited as a milestone's
open one on the strength of a line exactly like this, which carries an id,
a kind and a title and said nothing about where the work stood (#4154).
Only for tasks — a note or a snippet has no status to be wrong about.
"""
if note.is_task:
return "issue" if note.task_kind == "issue" else "task"
kind = "issue" if note.task_kind == "issue" else "task"
# No fallback for a missing status: `is_task` IS `status is not None`
# (models/note.py), so a branch for a task without one could never be
# taken, and a dead branch is a claim about the data that isn't true.
#
# Parenthesised rather than dot-joined: the write-path prior-art line
# joins its own fields with " · ", so a dotted status would read as
# another flag beside `seen` instead of as part of the kind.
return f"{kind} ({note.status})"
return note.note_type or "note"