Fix goals audit to preserve prose and support partial goals

GoalsSaver changes:
- Now preserves prose content (Vision, Principles, etc.) before goal sections
- Only rewrites ## Active, ## Future, ## Non-Goals sections
- Extracts and reinserts prose content between frontmatter and goals

Audit handler changes:
- Supports new status field: "met", "partial", "not_met"
- Maintains backward compatibility with old "checked" boolean format
- Sets Goal.partial=True for partially achieved goals
- Report shows partial count in summary

This fixes a bug where the audit button would delete all prose content
from goals.md files that had philosophy/context sections.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-09 01:55:35 -04:00
parent 69ec7df308
commit b084e9e30a
2 changed files with 119 additions and 11 deletions

View File

@ -428,7 +428,12 @@ class MilestonesParser(BaseParser):
class GoalsSaver:
"""Save goals back to goals.md file."""
"""Save goals back to goals.md file.
This saver preserves prose content (Vision, Principles, etc.) that appears
before the goal sections. Only the ## Active, ## Future, and ## Non-Goals
sections are rewritten with updated checkbox states.
"""
def __init__(self, path: Path, frontmatter: dict):
"""Initialize saver.
@ -441,11 +446,19 @@ class GoalsSaver:
self.frontmatter = frontmatter
def save(self, goal_list: GoalList):
"""Save goals back to file.
"""Save goals back to file, preserving prose content.
Args:
goal_list: GoalList to save
"""
# Read existing file to preserve prose content
existing_content = ""
if self.path.exists():
existing_content = self.path.read_text()
# Extract prose content (everything before first goal section)
prose_content = self._extract_prose_content(existing_content)
lines = []
# Write frontmatter
@ -454,7 +467,12 @@ class GoalsSaver:
lines.append(f"{key}: {value}")
lines.append("---")
lines.append("")
lines.append("# Goals")
# Add preserved prose content (without frontmatter, it's already added)
if prose_content:
lines.append(prose_content)
# Ensure there's a blank line before goal sections
if not prose_content.endswith("\n\n"):
lines.append("")
# Active goals
@ -488,6 +506,53 @@ class GoalsSaver:
atomic_write(self.path, "\n".join(lines))
def _extract_prose_content(self, content: str) -> str:
"""Extract prose content before goal sections.
Preserves everything between the frontmatter and the first goal section
(## Active, ## Future, or ## Non-Goals).
Args:
content: Full file content
Returns:
Prose content without frontmatter, or empty string
"""
if not content:
return ""
# Remove frontmatter
lines = content.split("\n")
start_idx = 0
# Skip frontmatter (between --- markers)
if lines and lines[0].strip() == "---":
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
start_idx = i + 1
break
# Find where goal sections start
goal_section_headers = ["## active", "## future", "## non-goal", "## non goal"]
end_idx = len(lines)
for i, line in enumerate(lines[start_idx:], start_idx):
line_lower = line.strip().lower()
if any(line_lower.startswith(header) for header in goal_section_headers):
end_idx = i
break
# Extract prose content
prose_lines = lines[start_idx:end_idx]
# Strip leading/trailing empty lines but preserve internal structure
while prose_lines and not prose_lines[0].strip():
prose_lines.pop(0)
while prose_lines and not prose_lines[-1].strip():
prose_lines.pop()
return "\n".join(prose_lines)
@staticmethod
def _get_checkbox(goal: Goal) -> str:
"""Get the checkbox marker for a goal's state.

View File

@ -2512,29 +2512,53 @@ generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
updated=frontmatter.get("updated"),
)
def parse_status(item: dict) -> tuple[bool, bool]:
"""Parse status field to (completed, partial) tuple.
Supports both old format (checked: bool) and new format (status: str).
"""
# New format: status field
status = item.get("status", "").lower()
if status == "met":
return True, False
elif status == "partial":
return False, True
elif status == "not_met":
return False, False
# Old format fallback: checked field
checked = item.get("checked", False)
return checked, False
# Process active goals
for item in audit_data.get("active", []):
completed, partial = parse_status(item)
goal = Goal(
text=item.get("text", ""),
completed=item.get("checked", False),
completed=completed,
partial=partial,
priority=item.get("priority", "medium"),
)
goal_list.active.append(goal)
# Process future goals
for item in audit_data.get("future", []):
completed, partial = parse_status(item)
goal = Goal(
text=item.get("text", ""),
completed=item.get("checked", False),
completed=completed,
partial=partial,
priority=item.get("priority", "medium"),
)
goal_list.future.append(goal)
# Process non-goals
for item in audit_data.get("non_goals", []):
completed, partial = parse_status(item)
goal = Goal(
text=item.get("text", ""),
completed=item.get("checked", False),
completed=completed,
partial=partial,
priority=item.get("priority", "medium"),
)
goal_list.non_goals.append(goal)
@ -2545,6 +2569,19 @@ generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
def _build_audit_report(self, audit_data: dict) -> str:
"""Build a human-readable report from audit data."""
def get_status_marker(item: dict) -> str:
"""Get checkbox marker from status or checked field."""
status = item.get("status", "").lower()
if status == "met":
return "[x]"
elif status == "partial":
return "[~]"
elif status == "not_met":
return "[ ]"
# Fallback to old format
return "[x]" if item.get("checked") else "[ ]"
lines = []
lines.append("=" * 60)
lines.append("GOALS AUDIT REPORT")
@ -2555,7 +2592,7 @@ generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
lines.append("ACTIVE GOALS")
lines.append("-" * 40)
for item in audit_data.get("active", []):
status = "[x]" if item.get("checked") else "[ ]"
status = get_status_marker(item)
lines.append(f"{status} {item.get('text', '')}")
lines.append(f" -> {item.get('reason', 'No reason provided')}")
lines.append("")
@ -2564,7 +2601,7 @@ generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
lines.append("FUTURE GOALS")
lines.append("-" * 40)
for item in audit_data.get("future", []):
status = "[x]" if item.get("checked") else "[ ]"
status = get_status_marker(item)
lines.append(f"{status} {item.get('text', '')}")
lines.append(f" -> {item.get('reason', 'No reason provided')}")
lines.append("")
@ -2573,7 +2610,7 @@ generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
lines.append("NON-GOALS")
lines.append("-" * 40)
for item in audit_data.get("non_goals", []):
status = "[x]" if item.get("checked") else "[ ]"
status = get_status_marker(item)
lines.append(f"{status} {item.get('text', '')}")
lines.append(f" -> {item.get('reason', 'No reason provided')}")
lines.append("")
@ -2583,7 +2620,13 @@ generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
lines.append("=" * 60)
lines.append("SUMMARY")
lines.append("=" * 60)
lines.append(f"Active Goals: {summary.get('active_met', 0)}/{summary.get('active_total', 0)} met")
active_met = summary.get('active_met', 0)
active_partial = summary.get('active_partial', 0)
active_total = summary.get('active_total', 0)
if active_partial > 0:
lines.append(f"Active Goals: {active_met} met, {active_partial} partial / {active_total} total")
else:
lines.append(f"Active Goals: {active_met}/{active_total} met")
lines.append(f"Future Goals: {summary.get('future_started', 0)}/{summary.get('future_total', 0)} started")
lines.append(f"Non-Goals: {summary.get('non_goals_confirmed', 0)}/{summary.get('non_goals_total', 0)} confirmed")
lines.append("")