From b084e9e30a960f7fbc7c4c049379fe013f0ed09c Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 9 Jan 2026 01:55:35 -0400 Subject: [PATCH] Fix goals audit to preserve prose and support partial goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/development_hub/parsers/goals_parser.py | 73 +++++++++++++++++++-- src/development_hub/views/dashboard.py | 57 ++++++++++++++-- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/src/development_hub/parsers/goals_parser.py b/src/development_hub/parsers/goals_parser.py index ce3e00d..a6e72b7 100644 --- a/src/development_hub/parsers/goals_parser.py +++ b/src/development_hub/parsers/goals_parser.py @@ -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,8 +467,13 @@ class GoalsSaver: lines.append(f"{key}: {value}") lines.append("---") lines.append("") - lines.append("# Goals") - lines.append("") + + # 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 lines.append("## Active") @@ -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. diff --git a/src/development_hub/views/dashboard.py b/src/development_hub/views/dashboard.py index 44cd832..1234eb9 100644 --- a/src/development_hub/views/dashboard.py +++ b/src/development_hub/views/dashboard.py @@ -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("")