From 7239e3c479e86876e36829ebf409b56a5e5ab7d6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 14 Sep 2026 10:11:58 -0400 Subject: [PATCH] feat(409): the reporting reflex reaches every surface a session reads (#4012) Step 3 of milestone 409 "Response shapes". Step 2's reporting-back skill only helps if it fires, and only exists in the Claude Code plugin. - scribe_static_context.md and using-scribe (new reflex 11) say: report back in a shape the operator can read, placed from the `placement` block rather than memory, and point at the reporting-back skill. using-scribe also lists it among the sibling process-skills. - update_task returns a one-line `report_back` cue when a task is closed (done or cancelled). A tool response is the only surface every MCP client sees, at the moment the report is about to be written. - _INSTRUCTIONS takes no line: there is no budget without trading out a session-start reflex. The decision is recorded in server.py's comment block so it is not re-litigated blind. - Guards: test_instruction_surfaces_agree pins the reflex and the placement pointer on both plugin surfaces; a tool test pins the cue on closing statuses and its absence on every other update. Plugin version minted. Co-Authored-By: Claude Opus 5 (1M context) --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_static_context.md | 6 ++++ plugin/skills/using-scribe/SKILL.md | 13 +++++++-- src/scribe/mcp/server.py | 7 +++++ src/scribe/mcp/tools/tasks.py | 19 ++++++++++++- tests/test_instruction_surfaces_agree.py | 36 ++++++++++++++++++++++++ tests/test_mcp_tool_report_back_cue.py | 33 ++++++++++++++++++++++ 7 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 tests/test_mcp_tool_report_back_cue.py diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index c7740fc..6aeb9d5 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your active-project context, process-skills (writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "2026.09.14.1406", + "version": "2026.09.14.1411", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index 7c98fd0..c2cb568 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -55,6 +55,12 @@ for the operator's work, and as your own working memory across sessions. moment it's complete. When you **fix** something — even in passing — record it as its own issue (`create_task(kind="issue")`), not as a work-log line on an unrelated open task. +- **Report back in a shape the operator can read** — they were not there while + you worked, so organise each reply around where the work stands rather than + the order you did things in: which task or milestone it belongs to, what now + works, what needs them, what comes next. Take the placement from the + `placement` block task writes return, not from memory. The `reporting-back` + skill holds the shape for each kind of reply. - **Tag to Systems as you write** — `enter_project` lists the project's Systems (its named subsystems/areas). When you create or meaningfully update a record, ask which areas it is about and pass `system_ids`; if an area has diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index 14523e5..124134f 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -220,6 +220,15 @@ Two constraints on *how* that's achieved: either: `verify_snippet` compares the recorded location and code against the repo, which is richer and already wired to drift detection. +11. **Report back in a shape the operator can read.** They were not there while + you worked, so organise the reply around **where the work stands**, not the + order you did things in: which task or milestone it belongs to, what now + works, what needs them, and what comes next. Take the placement from the + `placement` block that `create_task` / `update_task` return — the milestone, + step N of M, the next open step — rather than from memory. The + `reporting-back` skill holds the shape for each kind of reply: completions, + findings, decisions, handoffs, "where are we". + ## Stay inside the active project's scope Once a project is in scope — you called `enter_project`, or the working repo is @@ -300,6 +309,6 @@ nothing will tell you it drifted. ## Other Scribe process-skills -This plugin also ships focused process-skills — writing-plans, systematic -debugging, verification, and brainstorming. Reach for the matching one when its +This plugin also ships focused process-skills — writing-plans, reporting-back, +systematic debugging, verification, and brainstorming. Reach for the matching one when its situation arises, the same way you reach for this skill. diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 9992884..0c16c6f 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -34,6 +34,13 @@ from quart import Quart # competing for the last ~68 characters, so an addition here is a trade, never # an append. # +# Milestone 409 step 3 (reporting back to the operator in a readable shape) +# took NO line here, deliberately: there is no room without trading out a +# session-start reflex, and the moment it applies is when a task closes. So it +# rides in-band instead — update_task returns `placement` and a one-line +# `report_back` cue on done/cancelled, which every MCP client sees — with the +# full shapes in the reporting-back skill and the static context. +# # Milestone 317 (a note's own verify_with / expires_when, and the sweep over # them) was DECLINED a line, deliberately, by the operator — not overlooked. # The reasoning, so it is not re-litigated blind: this is a map, and its own diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index fb267c9..6e93a9a 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -271,6 +271,9 @@ async def update_task( work sits and what comes next — read them from here rather than reconstructing them, because a remembered milestone title or "next step" reads exactly like a real one when it is wrong. + + Closing a task (done or cancelled) also returns `report_back`: a one-line + reminder of what the reply to the operator should cover. """ uid = current_user_id() fields: dict = {} @@ -308,7 +311,10 @@ async def update_task( await systems_tools.attach_systems( uid, getattr(note, "user_id", uid) or uid, data, task_id, note.project_id ) - return await placement_svc.attach_placement(uid, data, note) + await placement_svc.attach_placement(uid, data, note) + if status in _CLOSING_STATUSES: + data["report_back"] = REPORT_BACK_CUE + return data async def add_task_log(task_id: int, content: str) -> dict: @@ -344,6 +350,17 @@ async def add_task_log(task_id: int, content: str) -> dict: return data +# The in-band half of milestone 409 step 3. The reporting-back skill and the +# static context carry the full shapes, but both live only in the Claude Code +# plugin; a tool response reaches every MCP client, at the moment a piece of +# work closes, which is exactly when the report is about to be written. One +# line on purpose: a template here would be read as the reply itself. +_CLOSING_STATUSES = ("done", "cancelled") +REPORT_BACK_CUE = ( + "Reporting this to the operator? Say where it sits (from `placement`), " + "what now works, what needs them, and what comes next." +) + _ITEM_KEYS = {"title", "body", "type", "status", "priority", "kind", "tags", "system_ids"} diff --git a/tests/test_instruction_surfaces_agree.py b/tests/test_instruction_surfaces_agree.py index 84400dc..ce02051 100644 --- a/tests/test_instruction_surfaces_agree.py +++ b/tests/test_instruction_surfaces_agree.py @@ -322,3 +322,39 @@ def test_a_surface_claiming_rules_bind_also_names_what_does_not(): f"'how the operator likes this done' as something it may not proceed " f"past. Name the other kind, however briefly." ) + + +# ── Reporting back (milestone 409 step 3) ────────────────────────────── +# +# The reporting-back skill carries the shapes, but a skill only helps if it +# fires. The reflex that sends a session to it lives on the two plugin +# surfaces a session always reads; the in-band cue on update_task is the half +# that reaches clients with no plugin at all. _INSTRUCTIONS took no line, on +# purpose — server.py's comment block records why. + +REPORT_REFLEX = "report back in a shape the operator can read" +REPORT_SURFACES = ( + ROOT / "plugin" / "hooks" / "scribe_static_context.md", + ROOT / "plugin" / "skills" / "using-scribe" / "SKILL.md", +) + + +def test_the_reporting_reflex_reaches_every_plugin_surface(): + missing = [] + for path in REPORT_SURFACES: + text = " ".join(path.read_text().split()).lower() + if REPORT_REFLEX not in text or "reporting-back" not in text: + missing.append(str(path.relative_to(ROOT))) + assert not missing, ( + f"these surfaces no longer send a session to the reporting-back skill " + f"({REPORT_REFLEX!r} plus the skill's name): {missing}. Without the " + f"reflex the skill loads only when its description happens to match." + ) + + +def test_the_reporting_reflex_takes_placement_from_the_record(): + """The failure milestone 409 began from: a placement reconstructed from + memory reads exactly like a real one when it is wrong.""" + for path in REPORT_SURFACES: + text = " ".join(path.read_text().split()) + assert "`placement`" in text, f"{path.relative_to(ROOT)} no longer points at the placement block" diff --git a/tests/test_mcp_tool_report_back_cue.py b/tests/test_mcp_tool_report_back_cue.py new file mode 100644 index 0000000..67094a1 --- /dev/null +++ b/tests/test_mcp_tool_report_back_cue.py @@ -0,0 +1,33 @@ +"""update_task closes a task with a one-line reminder of what the report needs. + +The in-band half of milestone 409 step 3: the skill and static context only +exist in the Claude Code plugin, and a tool response reaches every MCP client +at the moment a piece of work closes. Pinned on the response, not the wording. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +pytestmark = pytest.mark.usefixtures("_bind_user") + + +async def _update(**kwargs): + from scribe.mcp.tools.tasks import update_task + + note = MagicMock(id=5, user_id=7, project_id=None) + note.to_dict.return_value = {"id": 5} + with patch("scribe.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=note)), \ + patch("scribe.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()), \ + patch("scribe.mcp.tools.tasks.placement_svc.attach_placement", AsyncMock()): + return await update_task(task_id=5, **kwargs) + + +@pytest.mark.parametrize("status", ["done", "cancelled"]) +async def test_closing_a_task_carries_the_cue(status): + out = await _update(status=status) + assert "placement" in out["report_back"] and "needs them" in out["report_back"] + + +@pytest.mark.parametrize("kwargs", [{"status": "in_progress"}, {"status": "todo"}, {"body": "more notes"}]) +async def test_other_updates_do_not(kwargs): + assert "report_back" not in await _update(**kwargs)