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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.09.18.1551",
"version": "2026.09.18.1606",
"author": {
"name": "Bryan Van Deusen"
},
+9 -1
View File
@@ -66,7 +66,15 @@ it is wrong. So take placement from Scribe:
the milestone, `position` (step N of M), `progress`, and `next` (the next open
step). Use those values as they came back.
- For a wider view, `get_milestone` (a plan and its steps) or `enter_project`
(the whole project).
(the whole project). Every milestone they list carries **`next_step`** — the
earliest step still open, or null when none is — so a reply that says what
comes next takes it from the listing it already read. Progress alone says a
plan has an open step and not which one, and that is the gap recall fills.
- **A record you only mention is a record to read.** `placement` rides the
write that changed a task, so a task you cite without touching arrives with
nothing vouching for it. A retrieval hint carries an id, a kind and a title;
where a task stands is in the line's kind marker — `[task (done)]` — and a
line you are working from memory has no marker at all.
- Work with no task behind it: say so plainly — "this wasn't tracked as a
task" — and offer to record it. An honest "untracked" is a placement too.
+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"
+11 -2
View File
@@ -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.
+297
View File
@@ -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
+6
View File
@@ -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"),
+5
View File
@@ -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"},
}]
+8 -4
View File
@@ -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"]
+3 -1
View File
@@ -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