feat(shapes): the practice is written where it is read, and the coverage line measures the slip (milestone 439 step 6)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 47s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / Build & push image (push) Successful in 29s

- reusing-code: "Before the turn ends — say what you built" — the four
  verdicts, the one classify_shapes(repo=…) call, and the component file as a
  shape. Description names the end-of-turn moment.
- shape-accounting: the writer judges; audits are the check that it held.
  The write path SUGGESTS (no more hook instances); component file rows and
  whole-file canon described; scoped covers Svelte too.
- _INSTRUCTIONS reuse line: "before the turn ends, say what you built
  (create_snippet the reusable, classify_shapes the rest)" — 1570/1600.
- Coverage line: "written-shape check (7d): N turns checked, M asked, K left
  unjudged", from the Stop hook's recorded outcomes; silent until the
  question has been put.
- test_guidance_ownership pins the new topic on reusing-code.
- prior-art hook header no longer says it stamps instance rows.

Plugin version minted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 08:46:58 -04:00
co-authored by Claude Opus 5.5
parent 8b1567cf2c
commit 582a5a4f48
10 changed files with 153 additions and 22 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "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).", "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.10.01.1245", "version": "2026.10.01.1246",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+5 -4
View File
@@ -17,8 +17,9 @@
# It is also the shape ledger's write-path feed (#2791): it names the # It is also the shape ledger's write-path feed (#2791): it names the
# definitions being written (`shapes=`), and the server — only when the # definitions being written (`shapes=`), and the server — only when the
# session has PULLED a snippet this code references or resembles — records # session has PULLED a snippet this code references or resembles — records
# them as instance rows, classified_by=hook. Evidence, not judgment; the # it as a PROPOSAL on them ("looks like #N"), never a status (milestone 439).
# context line says what landed so a wrong stamp is corrected in the moment. # The agent's own end-of-turn judgment is the verdict; the same names go to
# the written-shapes ledger the Stop hook asks about.
# #
# NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so # NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so
# the write proceeds untouched and Claude sees the note beside the tool result. # the write proceeds untouched and Claude sees the note beside the tool result.
@@ -103,8 +104,8 @@ fi
# changes the inside of a function rather than its signature — the definition # changes the inside of a function rather than its signature — the definition
# enclosing the edit, found by walking the target file upward from the edited # enclosing the edit, found by walking the target file upward from the edited
# lines. The server decides whether evidence exists (the session pulled a # lines. The server decides whether evidence exists (the session pulled a
# snippet this code references or resembles) and stamps instance rows; with # snippet this code references or resembles) and records it as a proposal;
# no pulled canon in play, nothing is recorded. Titles only still — this sends # with no pulled canon in play, nothing is recorded. Titles only still — this sends
# names, not bodies. # names, not bodies.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# #
+27 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: reusing-code name: reusing-code
description: Use when you're about to build ANY shape — a component, control, route handler, service class, helper, test scaffold — search recorded snippets FIRST and start from the recorded shape instead of re-solving it. And the FIRST time a shape is built, record it as a snippet so every later instance starts from it. Triggers on "write a util/helper", "I need a function that…", "let me add a component/button/field/route", or having just built the first instance of anything. description: Use when you're about to build ANY shape — a component, control, route handler, service class, helper, test scaffold — search recorded snippets FIRST and start from the recorded shape instead of re-solving it. And before the turn that built it ends, say what it is — record the reusable as a snippet, classify the rest — so every later instance starts from it. Triggers on "write a util/helper", "I need a function that…", "let me add a component/button/field/route", having just built the first instance of anything, or the end-of-turn list of shapes to judge.
--- ---
# Reusing code — the pattern library # Reusing code — the pattern library
@@ -85,6 +85,32 @@ through recall/auto-inject; this skill is the active reflex around that.
per reusable thing. If it already exists, `update_snippet` it instead of per reusable thing. If it already exists, `update_snippet` it instead of
recording a second copy (the create gate will flag a near-duplicate anyway). recording a second copy (the create gate will flag a near-duplicate anyway).
## Before the turn ends — say what you built
You are the one who knows what the code you just wrote is. The shape ledger
records it from your answer, not from a guess made later, so give the answer
in the turn that built it — while it is still true. When a turn wrote
definitions nobody has judged, the plugin's Stop hook lists them once, with
whatever evidence the machinery holds ("looks like #N", "#M is canon in this
directory", "new"); other clients reach the same moment through
`list_shapes(project_id, path=…)`. For each one, decide:
- **Something another part of the code should reuse** → record it with
`create_snippet` (the fields above). Its own row becomes the canon.
- **Built from a recorded snippet** → `instance` of it.
- **A deliberate departure from one** → `variant`, with the why.
- **A genuine one-off** → `exempt`, with a reason (`reason_code` such as
`one-off-handler`, `test-helper`, `scoped-css` or `pure-helper` indexes it).
One call records them all: `classify_shapes(project_id, repo="<the repo's
remote>", classifications=[{path, symbol, kind, status, snippet_id?, reason?}])`.
`repo` lets a verdict land on a shape the ledger has not synced yet — the one
you wrote a minute ago. A component file is a shape too (`kind: "file"`, named
by its stem): when a card, chip or dialog is the reusable thing, the file is
what you record. An unsure call is still yours to make — the reason you write
is what the next reader judges it by — and a verdict is permanent, so each
shape is asked about once.
## A shared snippet is a suggestion, not a standard ## A shared snippet is a suggestion, not a standard
Scribe is multi-user, so a search can return snippets other people own. Those Scribe is multi-user, so a search can return snippets other people own. Those
+25 -15
View File
@@ -18,8 +18,8 @@ row carries a status:
- `exempt` — judged genuinely one-off. **Reason required.** A recorded - `exempt` — judged genuinely one-off. **Reason required.** A recorded
judgment, not silence — it stops the next pass re-litigating it. judgment, not silence — it stops the next pass re-litigating it.
- `scoped` — one-off **by construction**, stamped by the coverage sync - `scoped` — one-off **by construction**, stamped by the coverage sync
(a Vue component's scoped `<style>` rules and its `<script setup>` (a Vue or Svelte component's own styles and instance-script functions —
functions — unreachable from any other file). Accounted for without a unreachable from any other file). Accounted for without a
judgment; still proposed against, grouped and flagged; any judgment you judgment; still proposed against, grouped and flagged; any judgment you
make overrides it. Not the todo. make overrides it. Not the todo.
- `unclassified` — nobody has judged it yet. **This is the todo list.** - `unclassified` — nobody has judged it yet. **This is the todo list.**
@@ -39,22 +39,32 @@ row carries a status:
nothing. Rows, never prose — a consumer list in a note or verification nothing. Rows, never prose — a consumer list in a note or verification
detail cannot be sorted, queried, or diffed. detail cannot be sorted, queried, or diffed.
## Rows that arrive on their own ## The writer judges; audits check that it happened
Two feeds keep the ledger current between your batches, so most shapes never The ledger is filled from your answers, given while you still know them. When
need a hand judgment: a turn wrote definitions nobody has judged, the plugin's Stop hook lists them
once — with whatever the machinery holds as evidence — and you classify them
in one `classify_shapes(project_id, repo=…, …)` call (the `reusing-code` skill
says what each verdict means). `repo` lets a verdict land on a shape the sync
has not read yet. So a project worked this way stays accounted as it grows,
and an audit pass is a CHECK that the reflex held — the coverage line names
what slipped — not the way rows get classified.
What still arrives without you:
- **The sync** stamps a snippet's own reference location `canonical` - **The sync** stamps a snippet's own reference location `canonical`
(`classified_by: mechanical`). (`classified_by: mechanical`) — including a component FILE row when the
- **The write path** stamps instances as you work: when you `get_snippet` a snippet's location names the file and no symbol.
canon and then write code that references or resembles it, the - **The write path suggests**: when you `get_snippet` a canon and then write
definitions being written land as `instance` rows (`classified_by: hook`, code that references or resembles it, the shapes being written carry it as
the evidence in `reason`), and the prior-art hint tells you what landed a proposal, and the prior-art hint says "→ looks like #N". It is evidence
("Shape accounting: recorded at … → instance of #N"). Offered-but-unopened for your verdict, never a verdict — so *pull the canon you are
snippets stamp nothing — so *pull the canon you are instantiating*; that instantiating*; that pull is what puts it in front of you at the end of the
pull is what turns your reuse into accounting. A `hook` row is evidence, not turn. Rows the hook stamped `instance` before it stopped doing so are listed
judgment: it never overrides a classification you made, and a by `stamps_to_review` for judgment.
`classify_shapes` call overrides it. - **A component is a row of its own** (`kind: "file"`, named by its stem): a
file that renders and defines nothing named after itself. It is judged like
any other shape.
## The machine proposes, judgment classifies ## The machine proposes, judgment classifies
+2 -1
View File
@@ -56,7 +56,8 @@ reads Agent Skills) and in each tool's description.
matched, not none. Rules bind; preferences guide and you keep them current; matched, not none. Rules bind; preferences guide and you keep them current;
lessons inform. lessons inform.
- Search before acting or building, scoped with the active project_id; start - Search before acting or building, scoped with the active project_id; start
from a recorded snippet, and create_snippet what you build. from a recorded snippet; before the turn ends, say what you built
(create_snippet the reusable, classify_shapes the rest).
- Work is tasks (a fix is kind="issue"): in_progress on start, add_task_log as - Work is tasks (a fix is kind="issue"): in_progress on start, add_task_log as
you go, done on finish; tag system_ids. you go, done on finish; tag system_ids.
- A plan is a milestone: find the existing one - A plan is a milestone: find the existing one
+22
View File
@@ -922,6 +922,16 @@ async def compute_coverage(
except Exception: except Exception:
logger.warning("stamp review read failed", exc_info=True) logger.warning("stamp review read failed", exc_info=True)
review = {} review = {}
# The write-time figure (milestone 439): how often the end-of-turn
# question was put on this project, and how often it was walked past.
# An audit reads the ledger; this reads whether the reflex held.
try:
from scribe.services import shape_check as shape_check_svc
write_time = await shape_check_svc.window_summary(project_id)
except Exception:
logger.warning("write-time figure read failed", exc_info=True)
write_time = None
return { return {
"total": len(rows), "total": len(rows),
"accounted": len(rows) - unclassified, "accounted": len(rows) - unclassified,
@@ -950,6 +960,8 @@ async def compute_coverage(
# holds (#4608). # holds (#4608).
"weak_stamps": review.get("weak_count", 0), "weak_stamps": review.get("weak_count", 0),
"incoherent_canons": review.get("incoherent_count", 0), "incoherent_canons": review.get("incoherent_count", 0),
# {checked, asked, left, days} from the Stop hook's recorded outcomes.
"write_time": write_time,
# Honesty flag, not decoration: every surface that shows the number # Honesty flag, not decoration: every surface that shows the number
# is expected to carry it through. # is expected to carry it through.
"estimate": True, "estimate": True,
@@ -1154,4 +1166,14 @@ def coverage_line(coverage: dict) -> str:
review.append(f"{n_i} incoherent canon{'s' if n_i != 1 else ''}") review.append(f"{n_i} incoherent canon{'s' if n_i != 1 else ''}")
if review: if review:
line += f"; {' · '.join(review)} to judge — stamps_to_review" line += f"; {' · '.join(review)} to judge — stamps_to_review"
# Whether the writers judged what they wrote (milestone 439). Silent until
# the question has been put at least once: a project nobody has written to
# through the plugin has no figure, not a perfect one.
wt = coverage.get("write_time") or {}
if wt.get("checked"):
line += (
f"; written-shape check ({wt.get('days', 7)}d): {wt['checked']} "
f"turn{'s' if wt['checked'] != 1 else ''} checked, {wt.get('asked', 0)} asked, "
f"{wt.get('left', 0)} left unjudged"
)
return line return line
+45
View File
@@ -27,6 +27,9 @@ report_check gives.
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.app_log import AppLog from scribe.models.app_log import AppLog
@@ -145,3 +148,45 @@ def parse_written(raw: str, *, cap: int = 200) -> list[tuple[str, str, str]]:
if len(out) >= cap: if len(out) >= cap:
break break
return out return out
# The window the coverage line reports the write-time figure over. A week is
# long enough to hold a few working sessions on a project and short enough
# that a reflex that started slipping shows up while it is still news.
WINDOW_DAYS = 7
def summarise(outcomes: list[str]) -> dict:
"""{checked, asked, left} from a list of recorded outcomes.
`checked` is turns the question was put to (a first stop that wrote
something): passed + blocked. `asked` is the blocked ones. `left` is
asks the agent walked past — the stop after a block that still had
unjudged shapes. That last number is the slip the milestone exists to
make visible."""
checked = sum(1 for o in outcomes if o in ("passed", "blocked"))
asked = sum(1 for o in outcomes if o == "blocked")
left = sum(1 for o in outcomes if o == "left_after_block")
return {"checked": checked, "asked": asked, "left": left}
async def window_summary(project_id: int, *, days: int = WINDOW_DAYS) -> dict:
"""The write-time figure for one project over the last ``days``."""
since = datetime.now(timezone.utc) - timedelta(days=days)
async with async_session() as session:
rows = (await session.execute(
select(AppLog.details).where(
AppLog.category == "plugin",
AppLog.action == "shape_check",
AppLog.created_at >= since,
)
)).scalars().all()
outcomes: list[str] = []
for raw in rows:
try:
details = json.loads(raw or "{}")
except (TypeError, ValueError):
continue
if details.get("project_id") == project_id:
outcomes.append(details.get("outcome") or "")
return {**summarise(outcomes), "days": days}
+5
View File
@@ -198,6 +198,11 @@ TOPICS: tuple[Topic, ...] = (
Topic("reuse recorded shapes; record at first build", "skill:reusing-code", Topic("reuse recorded shapes; record at first build", "skill:reusing-code",
("create_snippet", "when_to_use", "first build", "second copy"), ("create_snippet", "when_to_use", "first build", "second copy"),
"prior art offered beside a write is not noise", index=("create_snippet",)), "prior art offered beside a write is not noise", index=("create_snippet",)),
# Milestone 439: the writer judges what it wrote, at the end of the turn,
# asked by the plugin's Stop hook — the moment the knowledge exists.
Topic("judge what you wrote before the turn ends", "skill:reusing-code",
("before the turn ends", "classify_shapes", 'kind: "file"'),
"you are the one who knows what the code you just wrote is"),
Topic("report back where the work stands", "skill:reporting-back", ("reporting-back", "placement"), Topic("report back where the work stands", "skill:reporting-back", ("reporting-back", "placement"),
"take the placement from the record"), "take the placement from the record"),
Topic("the operator's own reply shapes come first", "skill:reporting-back", Topic("the operator's own reply shapes come first", "skill:reporting-back",
+13
View File
@@ -907,3 +907,16 @@ def test_a_file_is_a_unit_when_it_renders_and_nothing_inside_carries_its_name(pa
from scribe.services.coverage import class_references, extract_definitions, is_file_unit from scribe.services.coverage import class_references, extract_definitions, is_file_unit
assert is_file_unit(path, text, extract_definitions(text), class_references(path, text)) is unit assert is_file_unit(path, text, extract_definitions(text), class_references(path, text)) is unit
def test_the_line_says_whether_writers_judged_what_they_wrote():
"""Milestone 439: the write-time figure — silent until the question has
been put, then turns checked, asked, and asks walked past."""
from scribe.services.coverage import coverage_line
base = {"accounted": 10, "total": 10, "counts": {"exempt": 10},
"unclassified": 0, "computed_at": "2026-10-01T00:00:00"}
assert "written-shape check" not in coverage_line(base)
assert "written-shape check" not in coverage_line(
{**base, "write_time": {"checked": 0, "asked": 0, "left": 0, "days": 7}})
line = coverage_line({**base, "write_time": {"checked": 12, "asked": 4, "left": 1, "days": 7}})
assert line.endswith("; written-shape check (7d): 12 turns checked, 4 asked, 1 left unjudged")
+8
View File
@@ -79,3 +79,11 @@ async def test_an_unknown_outcome_is_refused_before_anything_is_written():
pytest.raises(ValueError): pytest.raises(ValueError):
await record_shape_check(7, "skipped", written=1, unjudged=1) await record_shape_check(7, "skipped", written=1, unjudged=1)
session.add.assert_not_called() session.add.assert_not_called()
def test_the_window_counts_turns_asks_and_asks_walked_past():
from scribe.services.shape_check import summarise
assert summarise([]) == {"checked": 0, "asked": 0, "left": 0}
got = summarise(["passed", "blocked", "judged_after_block", "blocked", "left_after_block"])
assert got == {"checked": 3, "asked": 2, "left": 1}