Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/brigade/handoff_cmd/drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
53 changes: 53 additions & 0 deletions src/brigade/handoff_cmd/issue_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
7 changes: 5 additions & 2 deletions src/brigade/handoff_cmd/linting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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 ""
Expand Down
115 changes: 115 additions & 0 deletions tests/test_handoff_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2064,3 +2088,94 @@ 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


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"
Loading