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"