From b5df9d6dcaeb4b32a7afc291034847642104f0bf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 18 Sep 2026 11:52:14 -0400 Subject: [PATCH] feat(plugin): a reply's sections are chosen, not filled (#4153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 409 step 6 measured the scaffold on live sessions and found its two halves disagreeing: adherence passed and the read test failed. Completion replies carried every section the table asks for and were still hard to read. The cause was in the skill, not in compliance with it. It said to pick a kind of reply "then fill its sections... keep them even when one is short", which is an instruction to complete a form, and nothing anywhere set a ceiling. A faithful reply and an unreadable one were the same reply. Four changes to the discipline around the scaffold. The categories and their sections are untouched. - Sections are what to consider including, not a form to complete. A section answering a standing question ("does anything need me?") is always answered, even with "nothing"; a section that explains earns its place only when it changes what the operator does. Otherwise it belongs in the record's log, where it is available and not in the way. - Write the shortest reply that carries the answer, with named exceptions so this cannot be read as "always be terse". - "Needs you" takes BOTH tests: theirs to decide, AND work is waiting on it. A question answerable by reading something or taking an available measurement is work not yet done, not a request — settle it, say which way you went, and leave them free to overrule. - A decision already made gets acted on. Re-arguing a settled question reads as contradicting yourself rather than as being careful, and costs the operator the decision twice. "Before sending" gains a second pass for what can go, since the existing check asks what is MISSING, which a bloated reply passes. Guards in tests/test_reply_discipline.py, three topics registered for ownership. Every guard was falsified against the pre-change text before committing (rule 167): all five fail on it and pass on the fix, and the sixth deliberately passes both since it guards the scaffold against collateral damage. No absence checks — the skill legitimately discusses filling in order to warn against it, so asserting "fill" is absent would false-alarm on the corrected text (snippet #3352). Instance-agnostic per rule 115: the added text carries no record ids, no software-specific terms and no verbatim quotes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/reporting-back/SKILL.md | 45 ++++++++- tests/test_guidance_ownership.py | 12 +++ tests/test_reply_discipline.py | 137 ++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 tests/test_reply_discipline.py diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 35f110d..ff674d1 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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.0436", + "version": "2026.09.18.1551", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/skills/reporting-back/SKILL.md b/plugin/skills/reporting-back/SKILL.md index a2f1288..6fd6aea 100644 --- a/plugin/skills/reporting-back/SKILL.md +++ b/plugin/skills/reporting-back/SKILL.md @@ -10,9 +10,25 @@ while you worked: they don't hold the files you read, the names you used or the order you did things in. A reply that follows *your* path is accurate and still unreadable to them. Shape it around **where the work stands**. -Pick the kind of reply first (the tables below), then fill its sections. The -sections are what lets the operator find things at a glance, so keep them even -when one is short — "**Needs you:** nothing" is an answer they were looking for. +Pick the kind of reply first (the tables below). Its sections are **what to +consider including, not a form to complete.** + +Two kinds of section, and they behave differently: + +- **A section that answers a standing question** — *does anything need me? what + happens next?* — is always answered, even when the answer is nothing. + "**Needs you:** nothing" is what they were looking for. +- **A section that explains** — how it was done, why that way, what else you + noticed — earns its place only when it changes what the operator does or + decides. When it would not, leave it out: that detail belongs in the record's + log, where it is available and not in the way. + +**Write the shortest reply that carries the answer.** To someone reading +quickly, length is not thoroughness — it is work handed back to them. A reply +that fills every heading faithfully and runs a full screen is worse than four +lines naming the two things that changed their position. Extra length has to be +earned: a comparison they asked for, options that need laying side by side, a +measurement whose numbers are the point. ## Every reply @@ -33,6 +49,13 @@ when one is short — "**Needs you:** nothing" is an answer they were looking fo working. If a term has to appear, explain it once. - **Place the work in Scribe.** Name the task, issue or milestone it belongs to, by id *and* title (using-scribe: "Name the record, never just its number"). +- **A decision already made gets acted on, and the reply says what you did with + it.** Once the operator has chosen, that is the input to the work, not a topic + to revisit. If something you have since learned genuinely overturns the + choice, say so once and plainly — name the new evidence and what it changes — + and otherwise let the decision stand. Laying out the trade-offs of a settled + question again reads as contradicting yourself rather than as being careful, + and it costs the operator the decision twice. ## Take the placement from the record @@ -137,6 +160,15 @@ Notes on each section: - **Needs you** — an action, an approval, a decision, or "nothing". If it's an action, give the reason with it. An approval you are holding for also gets its own **Approval requested** section, and this line points at it. + + **Two tests, and it takes both: is this theirs to decide, and is work waiting + on it?** A choice that is genuinely theirs — a priority, a trade-off only they + can price, something they have to live with afterwards — belongs here. A + question you could settle by reading something, by taking a measurement you + already have access to, or by choosing the obvious default does not: that is + work not yet done, and sending it moves your uncertainty onto them. Settle it, + say which way you went and why, and leave them free to overrule you. This + section is for what blocks them, not for what you are unsure about. - **Next** — from `placement.next`, or say the milestone is finished. If you found something you didn't fix, the offer to fix it goes here. @@ -146,3 +178,10 @@ Read the reply as the operator will: someone who wasn't there, reading quickly. Can they tell **what was done, whether anything needs them, and what happens next** without asking a follow-up? If not, the sections are what's missing — not more detail. + +Then read it once more for **what can go**. A section filled because it was in +the table, reasoning supporting a conclusion nobody is going to dispute, a +finding already written to the record — none of it changes what the operator +does, so none of it belongs in the reply. Cutting is not hiding: the log holds +it, and the reply stays readable. A reply that has been cut twice is the one +they can act on. diff --git a/tests/test_guidance_ownership.py b/tests/test_guidance_ownership.py index 656a6c3..2df092b 100644 --- a/tests/test_guidance_ownership.py +++ b/tests/test_guidance_ownership.py @@ -170,6 +170,18 @@ TOPICS: tuple[Topic, ...] = ( Topic("the operator's own reply shapes come first", "skill:reporting-back", ("reply_preferences", 'content_type="rule"'), "the operator's own shapes come first"), + # Milestone 409 step 7: the sections were being FILLED rather than chosen, + # so a reply could satisfy every heading and still be unreadable. These + # three are the discipline around the scaffold, not the scaffold itself. + Topic("sections are chosen, not filled, and the reply is cut twice", + "skill:reporting-back", ("not a form to complete", "what can go"), + "write the shortest reply that carries the answer"), + Topic("a needs-you item is theirs to decide and blocks work", + "skill:reporting-back", ("is this theirs to decide",), + "this section is for what blocks them, not for what you are unsure about"), + Topic("a settled decision is acted on, not re-opened", + "skill:reporting-back", ("already made",), + "reads as contradicting yourself rather than as being careful"), # ── 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"), diff --git a/tests/test_reply_discipline.py b/tests/test_reply_discipline.py new file mode 100644 index 0000000..72df2d7 --- /dev/null +++ b/tests/test_reply_discipline.py @@ -0,0 +1,137 @@ +"""A reply's sections are chosen, not filled (#4153, milestone 409 step 7). + +WHY THIS EXISTS + +Step 6 measured the scaffold on live sessions and found the two halves +disagreeing: **adherence passed and the read test failed.** Completion replies +carried every section the table asks for — placement, what changed, what needs +the operator, what comes next — and the operator still could not read them. + +The cause was in the skill rather than in compliance with it. It said to pick a +kind of reply "then fill its sections… keep them even when one is short", which +is an instruction to complete a form. Nothing anywhere set a ceiling, and a +section with a heading and nothing to say gets filled rather than dropped. So a +faithful reply and an unreadable one were the same reply. + +WHAT IS PINNED + +The discipline, not the scaffold. The fifteen categories and their sections are +unchanged and are not this file's subject: + + 1. Sections are chosen — a standing question is always answered, an + explanation earns its place. + 2. The reply is as short as the answer allows, and gets a second pass for + what can go. + 3. "Needs you" takes BOTH tests: theirs to decide, and blocking. + 4. A decision already made is acted on rather than re-argued. + +RULE 167, AND WHY THERE IS NO ABSENCE CHECK HERE + +The obvious guard — assert the skill no longer tells anyone to "fill" a +section — is the trap snippet #3352 names. This skill legitimately discusses +filling in order to warn against it ("not a form to complete", "a section +filled because it was in the table"), so an absence check would fail on the +corrected text: a false alarm about the very thing it protects. + +So every assertion here is a PRESENCE check, and one is POSITIONAL — +`test_the_needs_you_test_sits_with_the_needs_you_section` pins where the test +lives, not merely that the words occur somewhere in the file. All four were +falsified against the pre-#4153 text before being committed. +""" +from __future__ import annotations + +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SKILL = ROOT / "plugin/skills/reporting-back/SKILL.md" + + +def _flat() -> str: + """Whitespace-flattened: the file is hard-wrapped, so phrases straddle lines.""" + return " ".join(SKILL.read_text().split()).lower() + + +def test_sections_are_chosen_rather_than_completed(): + """The framing the operator reacted to was "fill its sections".""" + text = _flat() + assert "not a form to complete" in text + # Both halves of the distinction, or "choose" collapses back into "fill": + # a standing question is answered even when the answer is nothing, an + # explanation is dropped when it changes nothing. + assert "always answered, even when the answer is nothing" in text + assert "changes what the operator does or decides" in text + + +def test_the_reply_carries_a_length_discipline(): + """Without a ceiling, every section is an invitation to keep writing.""" + text = _flat() + assert "write the shortest reply that carries the answer" in text + # And the ceiling has named exceptions, so this cannot be read as + # "always be terse" — a measurement the operator asked for still earns room. + assert "extra length has to be earned" in text + + +def test_the_reply_gets_a_second_pass_for_what_can_go(): + """Cutting is a separate act from writing, and needs saying separately. + + The pre-existing check asked whether anything was MISSING, which a bloated + reply passes. + """ + text = _flat() + assert "what can go" in text + # Cutting must not read as withholding, or it will not be done. + assert "cutting is not hiding" in text + + +def test_the_needs_you_test_sits_with_the_needs_you_section(): + """POSITIONAL. The test has to be where the section is defined. + + A reader reaches this while writing that section; stated anywhere else it + is a paragraph nobody is reading at the moment it applies. Asserted by + offset rather than by presence, so moving it away fails here. + """ + raw = " ".join(SKILL.read_text().split()) + lowered = raw.lower() + + needs_you = lowered.index("- **needs you** — an action") + the_test = lowered.index("is this theirs to decide") + next_bullet = lowered.index("- **next** —", needs_you) + + assert needs_you < the_test < next_bullet, ( + "the needs-you test has moved out of the Needs you bullet; a reader " + "writing that section will not meet it" + ) + # Both halves are load-bearing: "theirs" alone still admits a question the + # session could have answered, "blocking" alone admits one that is not + # theirs to make. + assert "work waiting on it" in lowered + assert "not for what you are unsure about" in lowered + + +def test_a_settled_decision_is_acted_on_rather_than_re_argued(): + """Re-opening a decision reads as self-contradiction, not as diligence. + + Phrased as a practice rather than a prohibition (rule 165): the instruction + is what to DO with a decision, with the failure named after it. + """ + text = _flat() + assert "a decision already made gets acted on" in text + assert "reads as contradicting yourself rather than as being careful" in text + # The escape hatch stays open, or this becomes a rule against ever + # correcting anything — which is the opposite of what is wanted. + assert "genuinely overturns the choice" in text + + +def test_the_scaffold_itself_is_untouched(): + """This step changed the discipline AROUND the sections, not the sections. + + If a future edit deletes a category while tightening the prose, that is a + change to #4009's subject and should not ride along silently here. + """ + text = _flat() + for section in ("where this sits", "what now works", "how / why", + "needs you", "next"): + assert section in text, f"completion-report section {section!r} is gone" + for kind in ("completion", "finding", "blocked / failed", "progress", + "decision", "clarification", "handoff", "approval", "conflict"): + assert kind in text, f"reply kind {kind!r} is gone"