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
20 changes: 20 additions & 0 deletions src/portfolio_truth_reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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()
Comment on lines +813 to +814

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve manual notes that start with the purpose

Because this normalization now runs for every legacy_registry_path, not just registries known to be generated by portfolio_truth_render, a hand-maintained legacy row whose Notes text legitimately begins with the repo/group purpose gets rewritten in the canonical snapshot. For example, with purpose API migration and notes API migration blocked on auth, the loop removes the leading phrase and stores only blocked on auth in declared.notes; scope the stripping to generated registry input or another unambiguous generated prefix so legacy source text is not silently lost.

Useful? React with 👍 / 👎.

return value


def _select_tool_provenance(
repo_entry: dict[str, Any],
group_entry: dict[str, Any],
Expand Down
47 changes: 47 additions & 0 deletions tests/test_portfolio_truth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down