From 28574a85b166efdcef22c64115dcc407fc3275d9 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Sat, 25 Jul 2026 00:53:49 -0400 Subject: [PATCH 1/2] feat(handoff): accept synonym section headings in lint Co-authored-by: Cursor --- src/brigade/handoff_cmd/issue_ops.py | 53 ++++++++++++++++++++ src/brigade/handoff_cmd/linting.py | 7 ++- tests/test_handoff_cmd.py | 73 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/brigade/handoff_cmd/issue_ops.py b/src/brigade/handoff_cmd/issue_ops.py index 2b69e606..89469185 100644 --- a/src/brigade/handoff_cmd/issue_ops.py +++ b/src/brigade/handoff_cmd/issue_ops.py @@ -331,6 +331,59 @@ def _parse_markdown_sections(text: str) -> dict[str, str]: return {name: "\n".join(lines).strip() for name, lines in sections.items()} +def _normalize_section_heading(name: str) -> str: + return re.sub(r"\s+", " ", name.strip().casefold()).rstrip(":").strip() + + +_SECTION_SYNONYMS: dict[str, str] = { + "type": "Type", + "handoff type": "Type", + "kind": "Type", + "title": "Title", + "name": "Title", + "summary": "Summary", + "tldr": "Summary", + "tl;dr": "Summary", + "overview": "Summary", + "durable facts": "Durable facts", + "facts": "Durable facts", + "evidence": "Evidence", + "recommended memory action": "Recommended memory action", + "memory action": "Recommended memory action", + "recommended action": "Recommended memory action", + "target card": "Target card", + "card": "Target card", + "card target": "Target card", + "suggested card content": "Suggested card content", + "card content": "Suggested card content", + "target document": "Target document", + "document": "Target document", + "document target": "Target document", + "suggested document content": "Suggested document content", + "document content": "Suggested document content", +} + + +def _canonicalize_sections(sections: dict[str, str]) -> tuple[dict[str, str], list[str]]: + resolved: dict[str, str] = {} + owners: dict[str, str] = {} + errors: list[str] = [] + for raw_name, body in sections.items(): + key = _normalize_section_heading(raw_name) + canonical = _SECTION_SYNONYMS.get(key) + if canonical is None: + resolved[raw_name] = body + continue + prior = owners.get(canonical) + if prior is not None and prior != raw_name: + errors.append(f"ambiguous section headings for {canonical}: {prior!r}, {raw_name!r}") + continue + owners[canonical] = raw_name + if canonical not in resolved or (body and not resolved[canonical]): + resolved[canonical] = body + return resolved, errors + + def _section_value(sections: dict[str, str], name: str) -> str: raw = sections.get(name, "") lines: list[str] = [] diff --git a/src/brigade/handoff_cmd/linting.py b/src/brigade/handoff_cmd/linting.py index eaa77322..6c1c2d01 100644 --- a/src/brigade/handoff_cmd/linting.py +++ b/src/brigade/handoff_cmd/linting.py @@ -191,7 +191,9 @@ def lint_file(path: Path) -> HandoffLintResult: warnings=(), ) - sections = _parse_markdown_sections(text) + raw_sections = _parse_markdown_sections(text) + sections, collision_errors = _canonicalize_sections(raw_sections) + errors.extend(collision_errors) for required in ("Type", "Title", "Summary", "Recommended memory action"): if required not in sections or not _section_value(sections, required): errors.append(f"missing required section: {required}") @@ -233,7 +235,8 @@ def _loose_field(text: str, name: str) -> str | None: def _migrate_extract(text: str) -> tuple[dict[str, str], list[str]]: """Merge proper `## Section` values with loose bullet metadata; report gaps.""" - sections = _parse_markdown_sections(text) + raw_sections = _parse_markdown_sections(text) + sections, _collision_errors = _canonicalize_sections(raw_sections) def field(section_name: str) -> str: return _section_value(sections, section_name) or _loose_field(text, section_name) or "" diff --git a/tests/test_handoff_cmd.py b/tests/test_handoff_cmd.py index 6a676ee4..6c80045f 100644 --- a/tests/test_handoff_cmd.py +++ b/tests/test_handoff_cmd.py @@ -63,6 +63,30 @@ """ +SYNONYM_NO_CARD_HANDOFF = """# Memory Handoff + +## Kind +learning + +## Name +Handoff lint synonyms + +## TL;DR +Near-miss headings should lint clean. + +## Memory action +no-card + +## Document target +.learnings/LEARNINGS.md + +## Document content +### Handoff lint synonyms + +Synonym headings resolve to canonical sections. +""" + + PROMOTED_IMPORT_HANDOFF = """# Memory Handoff ## Type @@ -2064,3 +2088,52 @@ def test_handoff_lint_surfaces_injection_signals(tmp_path, capsys): assert "line " in out assert "classic-injection" in out or "ignore-instructions" in out assert "handoff migrate" not in out + + +def test_handoff_lint_accepts_synonym_section_headings(tmp_path): + note = tmp_path / "synonym.md" + note.write_text(SYNONYM_NO_CARD_HANDOFF) + result = handoff_cmd.lint_file(note) + assert result.valid, result.errors + + +def test_handoff_lint_rejects_ambiguous_synonym_collision(tmp_path): + note = tmp_path / "collision.md" + note.write_text( + """# Memory Handoff + +## Summary +First summary + +## TL;DR +Second summary + +## Type +learning + +## Title +Collision + +## Recommended memory action +no-card + +## Target document +.learnings/LEARNINGS.md + +## Suggested document content +### Collision + +Body +""" + ) + result = handoff_cmd.lint_file(note) + assert not result.valid + assert any("ambiguous section headings" in err for err in result.errors) + assert any("Summary" in err for err in result.errors) + + +def test_handoff_lint_still_accepts_canonical_headings(tmp_path): + note = tmp_path / "canonical.md" + note.write_text(NO_CARD_HANDOFF) + result = handoff_cmd.lint_file(note) + assert result.valid, result.errors From 153b19c030f4248743289d2b9224667821e5c538 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Sat, 25 Jul 2026 01:17:29 -0400 Subject: [PATCH 2/2] fix(handoff): resolve synonym headings in draft summaries --- src/brigade/handoff_cmd/drafts.py | 3 ++- tests/test_handoff_cmd.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/brigade/handoff_cmd/drafts.py b/src/brigade/handoff_cmd/drafts.py index 1fb12c63..748ca2b9 100644 --- a/src/brigade/handoff_cmd/drafts.py +++ b/src/brigade/handoff_cmd/drafts.py @@ -378,7 +378,8 @@ def _draft_summary( text = path.read_text(errors="replace") except OSError: text = "" - sections = _parse_markdown_sections(text) + raw_sections = _parse_markdown_sections(text) + sections, _collision_errors = _canonicalize_sections(raw_sections) lint_result = lint_file(path) action = lint_result.action target_card = ( diff --git a/tests/test_handoff_cmd.py b/tests/test_handoff_cmd.py index 6c80045f..4c9eb9cd 100644 --- a/tests/test_handoff_cmd.py +++ b/tests/test_handoff_cmd.py @@ -2137,3 +2137,45 @@ def test_handoff_lint_still_accepts_canonical_headings(tmp_path): note.write_text(NO_CARD_HANDOFF) result = handoff_cmd.lint_file(note) assert result.valid, result.errors + + +def test_handoff_draft_summary_resolves_synonym_target_card(tmp_path): + note = tmp_path / "synonym-card.md" + note.write_text( + """# Memory Handoff + +## Kind +learning + +## Name +Synonym draft target + +## TL;DR +Drafts should resolve synonym card targets. + +## Memory action +create-card + +## Card +synonym-draft.md + +## Card content +--- +topic: synonym-draft +category: foundation +tags: [memory] +--- + +# Synonym draft + +Body +""" + ) + draft = handoff_cmd._draft_summary( + note, + target=tmp_path, + inbox=".claude/memory-handoffs", + watched=True, + ) + assert draft.action == "create-card" + assert draft.target_card == "synonym-draft.md"