diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index e86786a..3745e12 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -786,6 +786,10 @@ def _select_with_legacy( if value: return value legacy_value = str(legacy.get(field, "") or "").strip() + if field == "notes": + legacy_value = _strip_generated_note_purpose_prefix( + legacy_value, repo_entry=repo_entry, group_entry=group_entry + ) if legacy_value: provenance[f"declared.{field}"] = { "source": "legacy_registry", @@ -795,6 +799,22 @@ def _select_with_legacy( return "" +def _strip_generated_note_purpose_prefix( + notes: str, + *, + repo_entry: dict[str, Any], + group_entry: dict[str, Any], +) -> str: + """Keep generated registry markdown idempotent when used as legacy input.""" + value = notes.strip() + purpose = str(repo_entry.get("purpose") or group_entry.get("purpose") or "").strip() + if not purpose: + return value + while value == purpose or value.startswith(f"{purpose} "): + value = value[len(purpose) :].strip() + return value + + def _select_tool_provenance( repo_entry: dict[str, Any], group_entry: dict[str, Any], diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 31a1d7b..88b1043 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -1234,6 +1234,53 @@ def test_publish_is_noop_for_unchanged_compatibility_outputs( assert report_output.stat().st_mtime_ns == report_mtime +def test_generated_registry_notes_do_not_accumulate_purpose_prefix( + portfolio_workspace: Path, + tmp_path: Path, +) -> None: + catalog_path = tmp_path / "portfolio-catalog.yaml" + catalog_path.write_text( + """ +repos: + Alpha: + owner: d + purpose: flagship workbook-first flow + lifecycle_state: active + review_cadence: weekly + intended_disposition: maintain + category: commercial + tool_provenance: codex +""" + ) + legacy_registry_path = tmp_path / "project-registry.md" + legacy_registry_path.write_text( + """ +# Project Registry + +## Standalone Projects (Root Level) + +| Project | Status | Tool | Context Quality | Stack | Context Files | Category | Notes | +|---------|--------|------|-----------------|-------|---------------|----------|-------| +| Alpha | active | codex | full | Next.js | CLAUDE.md | commercial | flagship workbook-first flow flagship workbook-first flow handoff note | +""" + ) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=catalog_path, + legacy_registry_path=legacy_registry_path, + include_notion=False, + ) + + alpha = next( + project + for project in result.snapshot.projects + if project.identity.display_name == "Alpha" + ) + assert alpha.declared.purpose == "flagship workbook-first flow" + assert alpha.declared.notes == "handoff note" + + def test_publish_failure_leaves_live_files_untouched( portfolio_workspace: Path, portfolio_catalog: Path,