#!/usr/bin/env python3 """ decision_tracker.py — Board Meeting Decision Parser & Reporter Part of the C-Level Advisor / Decision Logger skill. Parses memory/board-meetings/decisions.md and produces actionable reports. Stdlib only. No dependencies. Usage: python decision_tracker.py --summary python decision_tracker.py --overdue python decision_tracker.py --conflicts python decision_tracker.py --owner "CMO" python decision_tracker.py --search "pricing" python decision_tracker.py --due-within 7 python decision_tracker.py --demo # Run with sample data """ import argparse import os import re import sys from datetime import date, datetime, timedelta from pathlib import Path from typing import Optional # ───────────────────────────────────────────── # Data structures # ───────────────────────────────────────────── class ActionItem: def __init__(self, text: str, owner: str, due: Optional[date], review: Optional[date], completed: bool, completed_date: Optional[date], result: str): self.text = text self.owner = owner self.due = due self.review = review self.completed = completed self.completed_date = completed_date self.result = result def is_overdue(self) -> bool: if self.completed: return False if self.due and self.due < date.today(): return True return False def is_due_within(self, days: int) -> bool: if self.completed: return False if self.due: return date.today() <= self.due <= date.today() + timedelta(days=days) return False class Decision: def __init__(self): self.date: Optional[date] = None self.title: str = "" self.decision: str = "" self.owner: str = "" self.deadline: Optional[date] = None self.review: Optional[date] = None self.rationale: str = "" self.user_override: str = "" self.rejected: list[str] = [] self.action_items: list[ActionItem] = [] self.supersedes: str = "" self.superseded_by: str = "" self.raw_transcript: str = "" def is_active(self) -> bool: return not bool(self.superseded_by.strip()) def has_override(self) -> bool: return bool(self.user_override.strip()) # ───────────────────────────────────────────── # Parser # ───────────────────────────────────────────── def parse_date(s: str) -> Optional[date]: """Parse YYYY-MM-DD or return None.""" if not s: return None s = s.strip() for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d.%m.%Y"): try: return datetime.strptime(s, fmt).date() except ValueError: continue return None def parse_action_item(line: str) -> Optional[ActionItem]: """ Parse a line like: - [ ] Action text — Owner: CMO — Due: 2026-03-15 — Review: 2026-03-29 - [x] Action text — Owner: CEO — Completed: 2026-03-10 — Result: Done """ line = line.strip() if not line.startswith("- ["): return None completed = line.startswith("- [x]") or line.startswith("- [X]") text_start = line.find("]") + 1 raw = line[text_start:].strip() # Split on " — " (em dash with spaces) or " - " fallback parts_raw = re.split(r"\s+[—\-]{1,2}\s+", raw) text = parts_raw[0].strip() if parts_raw else raw def extract(label: str, parts: list[str]) -> str: for p in parts: if p.lower().startswith(label.lower() + ":"): return p[len(label) + 1:].strip() return "" owner = extract("Owner", parts_raw[1:]) due_str = extract("Due", parts_raw[1:]) review_str = extract("Review", parts_raw[1:]) completed_str = extract("Completed", parts_raw[1:]) result = extract("Result", parts_raw[1:]) return ActionItem( text=text, owner=owner, due=parse_date(due_str), review=parse_date(review_str), completed=completed, completed_date=parse_date(completed_str), result=result, ) def parse_decisions(content: str) -> list[Decision]: """Parse the full decisions.md content into Decision objects.""" decisions = [] current: Optional[Decision] = None in_rejected = False in_actions = False for line in content.splitlines(): # New decision entry header_match = re.match(r"^## (\d{4}-\d{2}-\d{2}) — (.+)$", line) if header_match: if current: decisions.append(current) current = Decision() current.date = parse_date(header_match.group(1)) current.title = header_match.group(2).strip() in_rejected = False in_actions = False continue if current is None: continue # Field parsing def extract_field(label: str) -> Optional[str]: pattern = rf"^\*\*{re.escape(label)}:\*\*\s*(.*)$" m = re.match(pattern, line) return m.group(1).strip() if m else None val = extract_field("Decision") if val is not None: current.decision = val in_rejected = False in_actions = False continue val = extract_field("Owner") if val is not None: current.owner = val continue val = extract_field("Deadline") if val is not None: current.deadline = parse_date(val) continue val = extract_field("Review") if val is not None: current.review = parse_date(val) continue val = extract_field("Rationale") if val is not None: current.rationale = val continue val = extract_field("User Override") if val is not None: current.user_override = val in_rejected = False in_actions = False continue val = extract_field("Supersedes") if val is not None: current.supersedes = val continue val = extract_field("Superseded by") if val is not None: current.superseded_by = val continue val = extract_field("Raw transcript") if val is not None: current.raw_transcript = val continue # Section headers if re.match(r"^\*\*Rejected:\*\*", line): in_rejected = True in_actions = False continue if re.match(r"^\*\*Action Items:\*\*", line): in_actions = True in_rejected = False continue if line.startswith("**"): in_rejected = False in_actions = False # List items if in_rejected and line.strip().startswith("-"): item = line.strip().lstrip("- ").strip() if item and not item.startswith("