fix(rules): planning reads list rules by id and title instead of restating them (#4081)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 53s
CI & Build / TypeScript typecheck (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m4s
CI & Build / Build & push image (push) Skipped

start_planning on project 2 replied with 92,645 characters, 65k of them
applicable_rules. Milestone 414 made a project's listing every global rule
tagged to an area it works in (before, the rules of subscribed rulebooks, and
project 2 subscribed to none), and the non-brief rules_payload sent each as a
full rule_brief. get_milestone, get_project and get_task carried the same.

Every rules_payload form now lists: id, title, the topic a global rule sits in,
and `via` for a co_surfaces partner. get_rule reads one in full, and retrieval
delivers them in full when work matches — the reasoning #4045 applied to the
handshake. A test pins 81 full-length rules under 6k characters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 13:26:48 -04:00
co-authored by Claude Opus 5
parent 0bf7406f42
commit ac01eee040
2 changed files with 49 additions and 14 deletions
+24 -14
View File
@@ -1112,36 +1112,46 @@ def rules_payload(
(`plugin_context`) — computes a marker and shows nobody anything, and (`plugin_context`) — computes a marker and shows nobody anything, and
counting it would put rules in the denominator that no agent ever saw. counting it would put rules in the denominator that no agent ever saw.
EVERY FORM LISTS, NONE RESTATES. Rules reach a session in full by
retrieval, so these payloads say which constraints exist — id and title,
the topic a global rule sits in, `via` for a co_surfaces partner — and
get_rule reads one. Planning reads carried the full rule_brief until a
project's listing grew to every global rule tagged to its areas
(milestone 414) and start_planning replied with 92k characters (#4081),
the shape #4045 had just removed from the handshake.
`brief` is the session handshake's form (#4045): the project's own rules `brief` is the session handshake's form (#4045): the project's own rules
as id and title, nothing else. Rules reach a session in full by only. Only what is shown is recorded as surfaced.
retrieval, so the handshake lists which of the project's constraints exist
rather than restating them; get_rule reads one. Only what is shown is
recorded as surfaced.
""" """
project_rules = [_rule_line(r) for r in applicable.get("project_rules", [])]
if brief: if brief:
project_rules = [
{"id": r["id"], "title": r["title"]}
for r in applicable.get("project_rules", [])
]
record_rule_surfaced( record_rule_surfaced(
user_id=user_id, rule_ids=[r["id"] for r in project_rules], source=source, user_id=user_id, rule_ids=[r["id"] for r in project_rules], source=source,
) )
return {"project_rules": project_rules} return {"project_rules": project_rules}
rules = [_rule_line(r) for r in applicable.get("rules", [])]
record_rule_surfaced( record_rule_surfaced(
user_id=user_id, user_id=user_id,
rule_ids=( rule_ids=[r["id"] for r in rules] + [r["id"] for r in project_rules],
[r["id"] for r in applicable.get("rules", [])]
+ [r["id"] for r in applicable.get("project_rules", [])]
),
source=source, source=source,
) )
return { return {
"applicable_rules": applicable["rules"], "applicable_rules": rules,
"applicable_rules_truncated": applicable["truncated"], "applicable_rules_truncated": applicable["truncated"],
"project_rules": applicable.get("project_rules", []), "project_rules": project_rules,
} }
def _rule_line(brief: dict) -> dict:
"""One rule as a listing names it: enough to recognise it and fetch it.
Keys a row does not carry are left out rather than sent empty (#2483)."""
line = {"id": brief["id"], "title": brief["title"]}
for key in ("topic_title", "via"):
if brief.get(key):
line[key] = brief[key]
return line
# ── The staleness marker (milestone 323 step 5) ──────────────────────── # ── The staleness marker (milestone 323 step 5) ────────────────────────
# #
# WHAT THIS CAN AND CANNOT SEE. An etag catches a rule that MOVED after a # WHAT THIS CAN AND CANNOT SEE. An etag catches a rule that MOVED after a
+25
View File
@@ -193,3 +193,28 @@ async def test_list_milestones_lists_every_milestone_without_plans():
out = await list_milestones(project_id=5) out = await list_milestones(project_id=5)
assert len(out["milestones"]) == 30 assert len(out["milestones"]) == 30
assert all("body" not in m for m in out["milestones"]) assert all("body" not in m for m in out["milestones"])
def test_a_planning_read_lists_rules_without_restating_them():
"""#4081: a project's listing is every global rule tagged to its areas, so
the full rule_brief of each put start_planning at 92k characters. Planning
reads name the rules; get_rule reads one."""
from scribe.services.rulebooks import rules_payload
applicable = {
"rules": [{"id": i, "title": f"r{i}", "statement": PLAN, "when_to_apply": PLAN,
"topic_title": "git", "relations": [{"note": PLAN}]} for i in range(50)]
+ [{"id": 900, "title": "partner", "statement": PLAN, "via": "co_surfaces"}],
"project_rules": [{"id": 100 + i, "title": f"pr{i}", "statement": PLAN}
for i in range(30)],
"truncated": True,
}
with patch("scribe.services.rulebooks.record_rule_surfaced") as surfaced:
out = rules_payload(applicable, user_id=7, source="start_planning")
assert out["applicable_rules"][0] == {"id": 0, "title": "r0", "topic_title": "git"}
assert out["applicable_rules"][-1] == {"id": 900, "title": "partner", "via": "co_surfaces"}
assert out["project_rules"][0] == {"id": 100, "title": "pr0"}
assert out["applicable_rules_truncated"] is True
assert len(surfaced.call_args.kwargs["rule_ids"]) == 81
assert len(json.dumps(out)) < 6_000, len(json.dumps(out))