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
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:
+11
-2
@@ -147,11 +147,20 @@ def _now():
|
||||
def fake_note(**attrs) -> MagicMock:
|
||||
"""A stand-in Note: own (user_id=7, the caller `_bind_user` binds), live,
|
||||
not a task, no structured data. The injected menu reads is_task /
|
||||
task_kind / note_type for its kind marker, user_id for the "shared by …"
|
||||
attribution, data for a snippet's language, deleted_at for trash."""
|
||||
task_kind / note_type / status for its kind marker, user_id for the
|
||||
"shared by …" attribution, data for a snippet's language, deleted_at for
|
||||
trash.
|
||||
|
||||
`status` follows `is_task`, because on the real model it DEFINES it —
|
||||
`Note.is_task` is `status is not None`. A stand-in task with no status is
|
||||
a row the database cannot hold, and code that reads both would be tested
|
||||
against a shape it will never meet.
|
||||
"""
|
||||
is_task = attrs.get("is_task", False)
|
||||
return _with_defaults({
|
||||
"id": 1, "title": "t", "body": "", "tags": [], "user_id": 7,
|
||||
"note_type": "note", "is_task": False, "task_kind": "work",
|
||||
"status": "todo" if is_task else None,
|
||||
"data": None, "deleted_at": None,
|
||||
# Milestone 317: a truthy mock here reads as "this note carries a
|
||||
# check", which trips the guard on records that may not have one.
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""A record you merely CITE carries its status (#4154, milestone 409 step 8).
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
Step 1 (#4010) made placement cheap for a task whose status CHANGES: the write
|
||||
returns where it sits, and the report is written from that. It does nothing for
|
||||
a task a reply only mentions. In this milestone's own step-6 review the session
|
||||
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.
|
||||
|
||||
Two surfaces, one principle: the status arrives with the id.
|
||||
|
||||
1. A milestone summary row names its next open step, so the listing that
|
||||
prompts the question also answers it.
|
||||
2. An injected menu line renders a task's status, so a finished step cannot
|
||||
read as live work.
|
||||
|
||||
THE ONE THAT MATTERS MOST
|
||||
|
||||
`test_the_listing_and_placement_agree_on_what_open_means` — two surfaces now
|
||||
answer "what is next" and they must not drift. It is written as a behavioural
|
||||
cross-check rather than `assert milestones.OPEN_STEP_STATUSES is placement._OPEN`,
|
||||
which shares one object today and would therefore pass no matter what either
|
||||
side did with it (rule 167: a guard has to be able to fail). This one fails if
|
||||
either side changes its ordering or its notion of "open" alone.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import true
|
||||
|
||||
from scribe.services import milestones as ms
|
||||
from scribe.services import placement as pl
|
||||
from tests.helpers import fake_note, make_mock_session
|
||||
|
||||
|
||||
def _milestone_row(mid: int, project_id: int = 5):
|
||||
"""A Milestone as the first query returns it — .to_dict() feeds the entry."""
|
||||
row = MagicMock()
|
||||
row.id, row.project_id, row.user_id = mid, project_id, 7
|
||||
row.updated_at = None
|
||||
row.to_dict.return_value = {"id": mid, "title": f"M{mid}", "project_id": project_id}
|
||||
return row
|
||||
|
||||
|
||||
def _sessions(results: list, counter: list[int], seen_sql: list | None = None):
|
||||
"""A patched `async_session` handing out queued results and counting queries.
|
||||
|
||||
Every query in `get_project_milestone_summaries` pulls the next entry, so
|
||||
the ORDER of `results` pins the order of the queries — which is what makes
|
||||
the query-count assertion meaningful rather than incidental.
|
||||
|
||||
`seen_sql` collects the rendered statements. The ordering these surfaces
|
||||
have to agree on lives in an ORDER BY, which no amount of feeding rows to a
|
||||
mock can exercise — a stand-in hands back whatever order the test chose.
|
||||
"""
|
||||
session = make_mock_session()
|
||||
|
||||
async def _execute(stmt=None, *_a, **_kw):
|
||||
counter[0] += 1
|
||||
if seen_sql is not None:
|
||||
seen_sql.append(str(stmt))
|
||||
rows = results.pop(0) if results else []
|
||||
r = MagicMock()
|
||||
r.fetchall = MagicMock(return_value=rows)
|
||||
r.scalars = MagicMock(return_value=MagicMock(all=lambda: rows))
|
||||
return r
|
||||
|
||||
session.execute = _execute
|
||||
return MagicMock(return_value=session)
|
||||
|
||||
|
||||
def _order_by(sql: str) -> str:
|
||||
"""The ORDER BY tail of a rendered statement, normalised."""
|
||||
_, _, tail = sql.upper().partition("ORDER BY")
|
||||
return " ".join(tail.split())
|
||||
|
||||
|
||||
async def _summaries(milestones, counts, open_steps, counter=None, seen_sql=None):
|
||||
counter = counter if counter is not None else [0]
|
||||
results = [milestones, counts, open_steps]
|
||||
with patch.object(ms, "async_session", _sessions(results, counter, seen_sql)), \
|
||||
patch.object(ms.access_svc, "readable_notes_clause",
|
||||
MagicMock(return_value=true())):
|
||||
return await ms.get_project_milestone_summaries(7, [5])
|
||||
|
||||
|
||||
async def _placement_sql(seen_sql: list):
|
||||
"""Run `task_placement` far enough to render its sibling-steps query."""
|
||||
session = make_mock_session()
|
||||
milestone = MagicMock(id=409, project_id=5, user_id=7, title="M", status="active")
|
||||
|
||||
async def _execute(stmt=None, *_a, **_kw):
|
||||
seen_sql.append(str(stmt))
|
||||
r = MagicMock()
|
||||
r.scalars = MagicMock(return_value=MagicMock(
|
||||
first=lambda: milestone, all=lambda: [],
|
||||
))
|
||||
return r
|
||||
|
||||
session.execute = _execute
|
||||
with patch.object(pl, "async_session", MagicMock(return_value=session)), \
|
||||
patch.object(pl.access_svc, "can_read_project", AsyncMock(return_value=False)), \
|
||||
patch.object(pl.access_svc, "readable_notes_clause",
|
||||
MagicMock(return_value=true())):
|
||||
await pl.task_placement(7, SimpleNamespace(
|
||||
id=1, project_id=5, milestone_id=409, status="todo",
|
||||
))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_milestone_row_names_its_next_open_step():
|
||||
"""The listing that says "8 of 9" now says WHICH one, in the same read."""
|
||||
rows = await _summaries(
|
||||
[_milestone_row(409)],
|
||||
[(409, "done", 8), (409, "todo", 1)],
|
||||
[(409, 4154, "Step 8 — a cited record carries its status", "todo")],
|
||||
)
|
||||
assert rows[5][0]["next_step"] == {
|
||||
"id": 4154,
|
||||
"title": "Step 8 — a cited record carries its status",
|
||||
"status": "todo",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_finished_plan_names_no_next_step_rather_than_omitting_the_key():
|
||||
"""Always present, None included.
|
||||
|
||||
A key that disappears when the answer is "nothing left" makes a reader
|
||||
test for its absence to learn the answer, and a reader who forgets is back
|
||||
to guessing — which is the failure this step exists for.
|
||||
"""
|
||||
rows = await _summaries([_milestone_row(416)], [(416, "done", 9)], [])
|
||||
assert rows[5][0]["next_step"] is None
|
||||
assert "next_step" in rows[5][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_earliest_open_step_wins_not_the_earliest_step():
|
||||
"""The done-first case, which is the shape every part-finished plan has.
|
||||
|
||||
Steps arrive in creation order, so a plan whose first two are closed must
|
||||
name the third. Naming the first would reproduce the exact misreport: a
|
||||
step that IS in the milestone, that IS plausible, and that is finished.
|
||||
"""
|
||||
rows = await _summaries(
|
||||
[_milestone_row(409)],
|
||||
[(409, "done", 2), (409, "todo", 2)],
|
||||
# The query filters to open steps, so the closed ones never appear —
|
||||
# this asserts the ORDER of what does: earliest open, not last written.
|
||||
[(409, 4015, "Step 6", "in_progress"), (409, 4154, "Step 8", "todo")],
|
||||
)
|
||||
assert rows[5][0]["next_step"]["id"] == 4015
|
||||
assert rows[5][0]["next_step"]["status"] == "in_progress"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_batch_does_not_fan_out_per_milestone():
|
||||
"""#2384's shape must not come back through the new query.
|
||||
|
||||
Three queries for one milestone and three for forty — the cost is in the
|
||||
number of QUERIES, not the number of plans. A per-milestone "what's next"
|
||||
lookup would produce identical output and reproduce the pool exhaustion.
|
||||
"""
|
||||
counter = [0]
|
||||
await _summaries(
|
||||
[_milestone_row(i) for i in range(40)],
|
||||
[(i, "todo", 1) for i in range(40)],
|
||||
[(i, 1000 + i, f"S{i}", "todo") for i in range(40)],
|
||||
counter=counter,
|
||||
)
|
||||
assert counter[0] == 3, f"{counter[0]} queries for 40 milestones"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_step_queries_take_the_same_visibility_clause():
|
||||
"""Rule 78, and a correctness point on top of it.
|
||||
|
||||
`next_step` names a step; the counts beside it say how many there are. Read
|
||||
through different clauses, a row could name a step its own progress numbers
|
||||
exclude — one row describing two different milestones.
|
||||
"""
|
||||
seen = []
|
||||
|
||||
def _clause(uid):
|
||||
seen.append(uid)
|
||||
return true()
|
||||
|
||||
results = [[_milestone_row(409)], [(409, "todo", 1)], [(409, 1, "S", "todo")]]
|
||||
with patch.object(ms, "async_session", _sessions(results, [0])), \
|
||||
patch.object(ms.access_svc, "readable_notes_clause", _clause):
|
||||
await ms.get_project_milestone_summaries(7, [5])
|
||||
|
||||
# Built ONCE and reused, so the two queries cannot be given different ones.
|
||||
assert seen == [7]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_listing_and_placement_agree_on_what_open_means():
|
||||
"""THE GUARD. Two surfaces answer "what is next"; they must not drift.
|
||||
|
||||
Behavioural on purpose — see the module docstring. `placement` answers for
|
||||
a session that knows which step it is on, the listing for one that does
|
||||
not, so the two are only equal when the current step is the first. That is
|
||||
the case checked here, and it fails if either side's ordering or its open
|
||||
set moves without the other.
|
||||
"""
|
||||
steps = [
|
||||
SimpleNamespace(id=1, title="Step 1", status="done"),
|
||||
SimpleNamespace(id=2, title="Step 2", status="done"),
|
||||
SimpleNamespace(id=3, title="Step 3", status="todo"),
|
||||
SimpleNamespace(id=4, title="Step 4", status="todo"),
|
||||
]
|
||||
# From the first step, placement's "the next open one after this" and the
|
||||
# listing's "the earliest open one" are the same question.
|
||||
from_placement = pl._next_open(steps, current_id=1)
|
||||
|
||||
listing_sql: list = []
|
||||
rows = await _summaries(
|
||||
[_milestone_row(409)],
|
||||
[(409, "done", 2), (409, "todo", 2)],
|
||||
[(409, s.id, s.title, s.status) for s in steps if s.status in ms.OPEN_STEP_STATUSES],
|
||||
seen_sql=listing_sql,
|
||||
)
|
||||
assert rows[5][0]["next_step"] == from_placement
|
||||
|
||||
# And they agree on ORDERING, which the rows above cannot show: a stand-in
|
||||
# session hands back whatever order this test chose, so "the first row
|
||||
# wins" would pass against any ORDER BY at all. The database does the
|
||||
# sorting in production, so the assertion belongs on the statement.
|
||||
placement_sql: list = []
|
||||
await _placement_sql(placement_sql)
|
||||
steps_query = next(q for q in placement_sql if "ORDER BY" in q.upper())
|
||||
assert _order_by(listing_sql[-1]) == _order_by(steps_query)
|
||||
assert _order_by(listing_sql[-1]), "the open-steps query has no ORDER BY at all"
|
||||
|
||||
|
||||
def test_a_task_line_in_the_menu_says_where_the_work_stands():
|
||||
"""The other half: an id and a title with no status is what got cited."""
|
||||
from scribe.services.plugin_context import _record_kind
|
||||
|
||||
assert _record_kind(fake_note(is_task=True, status="done")) == "task (done)"
|
||||
assert _record_kind(fake_note(is_task=True, status="todo")) == "task (todo)"
|
||||
assert _record_kind(
|
||||
fake_note(is_task=True, task_kind="issue", status="in_progress")
|
||||
) == "issue (in_progress)"
|
||||
|
||||
|
||||
def test_a_record_with_no_status_gains_no_parenthesis():
|
||||
"""Only tasks. A note or a snippet has no status to be wrong about, and a
|
||||
marker that appeared on every line would stop being read."""
|
||||
from scribe.services.plugin_context import _record_kind
|
||||
|
||||
assert _record_kind(fake_note(note_type="snippet")) == "snippet"
|
||||
assert _record_kind(fake_note(note_type="process")) == "process"
|
||||
assert _record_kind(fake_note()) == "note"
|
||||
|
||||
|
||||
def test_the_listing_tools_say_what_next_step_is_and_how_to_use_it():
|
||||
"""The contract every MCP client reads (rule 119, decision #4027).
|
||||
|
||||
The field is only worth adding if a caller knows it is there. Without this
|
||||
the docstring can be tidied to a parameter list and the one surface that
|
||||
reaches a non-Claude-Code client goes quiet about it.
|
||||
"""
|
||||
from tests.helpers import tool_doc
|
||||
|
||||
for module, name in (
|
||||
("scribe.mcp.tools.milestones", "list_milestones"),
|
||||
("scribe.mcp.tools.projects", "enter_project"),
|
||||
("scribe.mcp.tools.projects", "get_project"),
|
||||
):
|
||||
doc = tool_doc(module, name).lower()
|
||||
assert "next_step" in doc, f"{name} does not mention next_step"
|
||||
# Naming the field is not enough — a reader has to be told that a null
|
||||
# is an answer, or an absent next step reads as data not yet loaded.
|
||||
assert "null" in doc or "none" in doc, f"{name} does not say when it is empty"
|
||||
|
||||
|
||||
def test_the_reply_skill_says_a_cited_record_still_gets_read():
|
||||
"""The other half of the fix is a practice, and it has one home.
|
||||
|
||||
Presence, not absence — the skill legitimately discusses recall in order to
|
||||
warn against it, so an absence check here would be satisfied by the warning
|
||||
itself (snippet #3352).
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
skill = pathlib.Path(__file__).resolve().parents[1] / "plugin/skills/reporting-back/SKILL.md"
|
||||
text = " ".join(skill.read_text().split()).lower()
|
||||
assert "a record you only mention is a record to read" in text
|
||||
# And it points at the field rather than restating how to compute it.
|
||||
assert "next_step" in text
|
||||
@@ -182,6 +182,12 @@ TOPICS: tuple[Topic, ...] = (
|
||||
Topic("a settled decision is acted on, not re-opened",
|
||||
"skill:reporting-back", ("already made",),
|
||||
"reads as contradicting yourself rather than as being careful"),
|
||||
# Milestone 409 step 8: `placement` rides a WRITE, so a task the reply only
|
||||
# cites arrived with nothing vouching for it — which is how a step finished
|
||||
# four days earlier was reported as the open one (#4154).
|
||||
Topic("a record you only cite still gets read",
|
||||
"skill:reporting-back", ("next_step", "only mention"),
|
||||
"a record you only mention is a record to read"),
|
||||
# ── per-tool contracts and in-band behaviour — owned by the server ──
|
||||
Topic("closing a task cues the report", "docstrings", ("report_back",), "reporting this to the operator?"),
|
||||
Topic("a note that asserts a fact carries its check", "docstrings", ("verify_with", "expires_when"),
|
||||
|
||||
@@ -34,6 +34,7 @@ def _milestone(mid: int, status: str, touched_day: int) -> dict:
|
||||
"updated_at": "2026-01-01T00:00:00+00:00", "last_touched_at": touched,
|
||||
"total": 4, "completed": 2, "pct": 50.0,
|
||||
"status_counts": {"todo": 2, "in_progress": 0, "done": 2, "cancelled": 0},
|
||||
"next_step": {"id": 900 + mid, "title": f"M{mid} step 3", "status": "todo"},
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +53,10 @@ def test_brief_rows_leave_out_the_plan_and_what_the_caller_already_knows():
|
||||
"id": 1, "title": "M1", "description": "what M1 is for", "status": "active",
|
||||
"order_index": 1, "total": 4, "completed": 2, "pct": 50.0,
|
||||
"status_counts": {"todo": 2, "in_progress": 0, "done": 2, "cancelled": 0},
|
||||
# Trimmed of the plan body, but NOT of which step is next (#4154): the
|
||||
# listing is where "2 of 4 done" gets read, and that number is exactly
|
||||
# what invites a reader to name the open step from memory.
|
||||
"next_step": {"id": 901, "title": "M1 step 3", "status": "todo"},
|
||||
}]
|
||||
|
||||
|
||||
|
||||
@@ -148,8 +148,10 @@ async def test_injected_menu_labels_the_record_kind():
|
||||
hits = [
|
||||
(0.92, fake_note(id=1, title="debounce — rate-limit a callback", note_type="snippet")),
|
||||
(0.91, fake_note(id=2, title="Release checklist", note_type="process")),
|
||||
(0.90, fake_note(id=3, title="Auth token expiry", is_task=True, task_kind="issue")),
|
||||
(0.89, fake_note(id=4, title="Ship the drafter", is_task=True)),
|
||||
(0.90, fake_note(id=3, title="Auth token expiry", is_task=True,
|
||||
task_kind="issue", status="todo")),
|
||||
(0.89, fake_note(id=4, title="Ship the drafter", is_task=True,
|
||||
status="done")),
|
||||
(0.88, fake_note(id=5, title="Why we dropped CalDAV")),
|
||||
]
|
||||
with patch.object(plugin_context, "semantic_search_notes",
|
||||
@@ -165,8 +167,10 @@ async def test_injected_menu_labels_the_record_kind():
|
||||
assert "[snippet]" in by_id[1]
|
||||
assert "[process]" in by_id[2]
|
||||
# Task-ness wins over note_type, and an issue says so rather than "task".
|
||||
assert "[issue]" in by_id[3]
|
||||
assert "[task]" in by_id[4]
|
||||
# A task also carries its status (#4154): #4 is DONE, and a line that said
|
||||
# only "[task]" is what let a finished step be cited as an open one.
|
||||
assert "[issue (todo)]" in by_id[3]
|
||||
assert "[task (done)]" in by_id[4]
|
||||
assert "[note]" in by_id[5]
|
||||
# Still title-first: the marker is metadata, not an excuse to carry bodies.
|
||||
assert "body" not in out["context"]
|
||||
|
||||
@@ -487,7 +487,9 @@ async def test_write_path_labels_a_non_snippet_hit_with_its_kind():
|
||||
)
|
||||
|
||||
ctx = out["context"]
|
||||
assert "· issue]" in ctx # the issue says what it is
|
||||
# The issue says what it is — and, since #4154, where it stands: a piece of
|
||||
# prior art offered as "already tried" reads differently when it is still open.
|
||||
assert "· issue (todo)]" in ctx
|
||||
assert '[similar 0.72] "debounce helper"' in ctx # the snippet does not
|
||||
# The header now names the right opener for each kind.
|
||||
assert "get_task(id)" in ctx and "get_snippet(id)" in ctx
|
||||
|
||||
Reference in New Issue
Block a user