Skill: newageinvestments25-byte/nai-weekly-retro (version 1.0.0)

--- name: weekly-retro description: "Weekly retrospective that analyzes memory logs to identify accomplishments, recurring patterns, friction points, and forward-looking recommendations. More strategic than a daily recap — answers 'what should change next week?' Use when: weekly retro, weekly review, what should change, how was this week, retrospective, week in review." --- # Weekly Retro Generate a strategic weekly retrospective from memory log files. ## Quick Start Run the full pipeline: ``` python3 scripts/gather_week.py --memory-dir PATH | \ python3 scripts/analyze.py | \ python3 scripts/retrospective.py --output vault/weekly-retro/YYYY-MM-DD.md ``` ## Pipeline | Step | Script | Input | Output | |------|--------|-------|--------| | 1. Gather | `gather_week.py` | memory/*.md files | Structured JSON (per-day + aggregated) | | 2. Analyze | `analyze.py` | Gathered JSON | Pattern analysis JSON | | 3. Report | `retrospective.py` | Analysis JSON | Markdown retrospective | ## Scripts ### gather_week.py Read memory files for the past N days and extract structured data. ``` python3 scripts/gather_week.py --memory-dir ~/.openclaw/workspace/memory --days 7 ``` Options: - `--memory-dir PATH` — Path to memory directory (default: ~/.openclaw/workspace/memory) - `--days N` — Number of days to look back (default: 7) - `--end-date YYYY-MM-DD` — End date (default: today) - `--config PATH` — Config JSON file ### analyze.py Detect patterns from gathered data. Reads JSON from stdin. Identifies: - Accomplishments (shipped, published, fixed, built) - Recurring themes (topics appearing 3+ days) - Repeated failures and friction points - Time sinks (disproportionate attention) - Unfinished threads (started but not completed) - Work schedule patterns (time-of-day distribution) ``` python3 scripts/gather_week.py ... | python3 scripts/analyze.py ``` Options: - `--history-file PATH` — Path to retro history for longitudinal comparison ### retrospective.py Generate the markdown retrospective. Reads analysis JSON from stdin. Sections: - Week at a Glance (3-sentence summary) - Wins (with evidence) - Patterns (recurring topics, toolchain, work schedule, attention distribution) - Friction Points (with recurrence flags) - Unfinished Business (carry-forward items) - Recommendations (2-3 actionable changes for next week) - Week Score (1-10 with justification) ``` python3 scripts/analyze.py ... | python3 scripts/retrospective.py --output PATH ``` Options: - `--output PATH` — Write to file instead of stdout - `--no-frontmatter` — Skip YAML frontmatter ### history.py Track retrospective history for longitudinal patterns. ``` python3 scripts/history.py --record --analysis analysis.json python3 scripts/history.py --show python3 scripts/history.py --trends ``` Options: - `--record` — Record this week's analysis - `--analysis PATH` — Analysis JSON to record - `--show` — Show past retro summaries - `--trends` — Show longitudinal trends - `--data-dir PATH` — Storage directory ## Cron Integration Schedule a Sunday evening retrospective: ```json { "name": "Weekly Retrospective", "schedule": {"kind": "cron", "expr": "0 20 * * 0", "tz": "America/New_York"}, "payload": { "kind": "agentTurn", "message": "Run the weekly-retro skill. Gather the past 7 days of memory logs, analyze patterns, generate the retrospective, and save to vault/weekly-retro/." } } ``` ## Output The retrospective is Obsidian-compatible markdown with YAML frontmatter including date range, week score, and auto-detected tags. Designed for vault storage and long-term pattern review. ## Dependencies Python standard library only. No external packages required.

path: assets/config.example.json
content: memory_dir: /Users/openclaw/.openclaw/workspace/memory
vault_output_dir: /Users/openclaw/.openclaw/workspace/vault/weekly-retro
history_file: /Users/openclaw/.openclaw/workspace/skills/weekly-retro/history.json
soul_path: /Users/openclaw/.openclaw/workspace/SOUL.md
agents_path: /Users/openclaw/.openclaw/workspace/AGENTS.md
lookback_days: 7
week_score_bias: 0

path: references/retro-format.md
content: # Retrospective Format Reference

The weekly retrospective is a forward-looking analysis document. It is NOT a daily
recap rehash. Every section must answer "so what?" — raw facts without implications
belong in daily logs, not here.

## YAML Frontmatter

```yaml
---
date_start: YYYY-MM-DD
date_end: YYYY-MM-DD
type: weekly-retro
week_score: N
tags: [weekly-retro, <auto-detected>]
---
```

## Sections

### Week at a Glance
Three sentences maximum. Captures the narrative arc of the week — what was the
dominant thrust, what shifted, what's the state of things heading into next week.
Not a list of things that happened. A story.

### 🏆 Wins
Things that actually shipped, resolved, or moved forward meaningfully. Each win
must cite specific evidence (commit hashes, file paths, dates, metrics).
Not "made progress on X" — that's filler. "Shipped X, deployed to Y, verified Z."

### 🔄 Patterns
Recurring themes across 3+ days. Each pattern includes:
- What keeps showing up
- How many days / how frequently
- Why it matters (positive or negative)
- Suggested response (formalize it, automate it, or stop doing it)

### 🧱 Friction Points
What slowed things down. Be specific: broken tools, blocked dependencies, context
switches, late-night debugging, repeated manual steps. Each friction point should
suggest a mitigation.

### 📋 Unfinished Business
Things started but not completed. Carry-forward items for next week. Include why
they stalled (blocked? deprioritized? forgotten?) and whether they still matter.

### 💡 Recommendations
2-3 specific, actionable changes for next week. Not vague ("be more organized").
Concrete ("Set up launchd services for llama.cpp, Open WebUI, and Lilith to
eliminate manual restart burden after reboots").

Each recommendation should trace back to evidence from the analysis.

### 📊 Week Score
A 1-10 rating with 2-3 sentence justification. Criteria:
- What got accomplished vs. what was planned
- How much was proactive vs. reactive
- Whether the week moved the needle on strategic goals
- How much friction was encountered

### 📈 Longitudinal (when history exists)
Trends across multiple weeks:
- Score trajectory
- Recurring friction that hasn't been addressed
- Recommendations that were/weren't acted on
- Emerging long-term patterns

## Anti-Patterns (What a Bad Retro Looks Like)

- "You did a lot this week. Great job!" → No evidence, no analysis.
- Listing every single thing that happened → That's a daily recap, not a retro.
- Vague recommendations → "Try to be more focused" is useless.
- Missing the forest for the trees → Individual events without synthesis.
- No forward-looking content → If it doesn't help next week, it failed.


path: scripts/analyze.py
content: #!/usr/bin/env python3
"""
analyze.py — Pattern detection and analysis for weekly retrospective.

Takes gathered data (from gather_week.py), identifies accomplishments,
recurring themes, failures, time sinks, pattern shifts, unfinished threads,
and formalization candidates. Outputs structured JSON with confidence scores.

Usage:
    python3 gather_week.py [...] | python3 analyze.py [--history PATH] [--soul PATH] [--agents PATH]
    python3 analyze.py --input gathered.json [--history PATH]
"""

import argparse
import json
import os
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path


def _clean_evidence(text):
    """Clean markdown artifacts from evidence text."""
    t = text.strip()
    # Remove leading markdown formatting: ##, numbering, checkboxes
    t = re.sub(r'^#+\s*', '', t)
    t = re.sub(r'^\d+\.\s*', '', t)
    t = re.sub(r'^[⬜✅]\s*', '', t)
    # Remove bold markers around labels like "**Bug 1:** — text"
    # First handle "**Label:** — rest" or "**Label** — rest"
    t = re.sub(r'^\*\*([^*]+?)(?::?\s*)\*\*\s*[-—]\s*', r'\1: ', t)
    # Then remove remaining bold markers (keep the text inside)
    t = re.sub(r'\*\*([^*]*)\*\*', r'\1', t)
    # Remove any remaining stray double asterisks
    t = t.replace('**', '')
    # Remove backtick code markers for cleaner evidence
    t = t.replace('`', '')
    # Strip leading/trailing whitespace and dashes
    t = t.strip().strip('-—').strip()
    # Skip very short or meaningless results
    if len(t) < 15:
        return ''
    # Skip lines that are just section headers
    if t.endswith(':') and len(t) < 60:
        return ''
    return t


def load_json(path):
    """Load a JSON file, return empty dict on failure."""
    try:
        with open(path, 'r') as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def load_stdin_or_file(input_path):
    """Read gathered data from stdin or file."""
    if input_path:
        with open(input_path, 'r') as f:
            return json.load(f)
    else:
        return json.load(sys.stdin)


def scan_for_formalized(soul_path, agents_path):
    """Read SOUL.md and AGENTS.md to find already-formalized patterns."""
    formalized = set()
    for path in [soul_path, agents_path]:
        if path and os.path.exists(path):
            text = Path(path).read_text(encoding='utf-8').lower()
            # Extract key concepts mentioned in operational docs
            # Look for specific workflows, cron jobs, rules that are codified
            if 'cron' in text:
                formalized.add('cron-jobs')
            if 'daily recap' in text or 'daily-recap' in text:
                formalized.add('daily-recap')
            if 'media vault' in text or 'youtube' in text:
                formalized.add('media-vault')
            if 'trending' in text and 'scanner' in text:
                formalized.add('trending-scanner')
            if 'obsidian' in text and 'vault' in text:
                formalized.add('obsidian-vault')
            if 'smart routing' in text or 'model routing' in text:
                formalized.add('model-routing')
            if 'memory' in text and ('daily' in text or 'log' in text):
                formalized.add('memory-logging')
    return formalized


# ---------------------------------------------------------------------------
# Analysis functions
# ---------------------------------------------------------------------------

def analyze_accomplishments(gathered):
    """Extract and deduplicate accomplishments with evidence."""
    agg = gathered['aggregation']
    raw_accomplishments = agg.get('accomplishments', [])

    # Group by similarity (keyword clustering with fallback)
    clusters = defaultdict(list)
    for item in raw_accomplishments:
        text = item['text'].lower()
        # Find the primary subject — order matters (most specific first)
        if 'mission control' in text:
            clusters['Mission Control dashboard'].append(item)
        elif 'tokenpulse' in text or 'token pulse' in text or ('proxy' in text and ('port' in text or 'live' in text)) or ('phase' in text and 'complete' in text):
            clusters['TokenPulse development'].append(item)
        elif 'clawhub' in text or ('skill' in text and ('publish' in text or 'built' in text or 'batch' in text or 'live on' in text)):
            clusters['ClawHub skills'].append(item)
        elif 'lilith' in text:
            clusters['Lilith bot'].append(item)
        elif 'soul' in text or 'identity' in text or 'agents.md' in text or 'workspace file' in text:
            clusters['Identity/workspace rewrite'].append(item)
        elif 'dashboard' in text and ('web' in text or 'react' in text or 'production' in text):
            clusters['TokenPulse development'].append(item)
        elif ('cron' in text and 'job' in text) or 'scanner' in text:
            clusters['Automation setup'].append(item)
        elif 'routing' in text and 'model' in text:
            clusters['Model routing'].append(item)
        elif 'open webui' in text:
            clusters['Open WebUI'].append(item)
        elif 'youtube' in text or 'media vault' in text:
            clusters['Media vault'].append(item)
        elif 'claude' in text and ('max' in text or 'swap' in text):
            clusters['Claude auth'].append(item)
        elif 'workflow' in text and 'crystallizer' in text:
            clusters['Workflow Crystallizer'].append(item)
        elif 'readme' in text or 'changelog' in text or 'getting_started' in text or 'documentation' in text:
            clusters['Documentation'].append(item)
        elif 'launchd' in text or 'service' in text and ('deploy' in text or 'running' in text):
            clusters['Infrastructure'].append(item)
        elif 'build' in text or 'axum' in text or 'sqlite' in text or 'react' in text or 'cargo' in text:
            clusters['TokenPulse development'].append(item)
        elif 'llama.cpp' in text or 'ollama' in text or 'qwen' in text:
            clusters['Local model infrastructure'].append(item)
        elif 'git' in text or 'commit' in text or 'github' in text or 'pushed' in text:
            clusters['Git/shipping'].append(item)
        elif 'fix' in text or 'bug' in text or 'verified' in text or 'tested' in text:
            clusters['Bug fixes & verification'].append(item)
        elif 'research' in text or 'market' in text or 'analysis' in text:
            clusters['Research'].append(item)
        elif 'domain' in text or 'name' in text and 'finalized' in text:
            clusters['Product decisions'].append(item)
        else:
            # Try to assign based on context from date — same-day clustering
            assigned = False
            date = item.get('date', '')
            # Check what other items on this date are categorized as
            for label, items_in_cluster in clusters.items():
                if label == 'Uncategorized':
                    continue
                for existing in items_in_cluster:
                    if existing.get('date') == date:
                        clusters[label].append(item)
                        assigned = True
                        break
                if assigned:
                    break
            if not assigned:
                clusters['Uncategorized'].append(item)

    # Build accomplishment entries with evidence
    results = []
    for label, items in clusters.items():
        if not items:
            continue
        if label == 'Uncategorized' and len(items) <= 2:
            continue  # Skip tiny uncategorized clusters
        dates = sorted(set(i['date'] for i in items))
        # Pick the best evidence lines (most descriptive)
        evidence = []
        seen = set()
        # Score items: prefer positive shipping language over bug/fix language
        def _evidence_score(text):
            t = text.lower()
            score = len(text)  # Longer = more descriptive
            # Boost shipping language
            if any(w in t for w in ['deployed', 'live', 'shipped', 'published', 'built', 'complete']):
                score += 200
            if '✅' in t:
                score += 100
            # Penalize bug/error language in evidence for accomplishment sections
            if any(w in t for w in ['bug', 'error', 'broke', 'missing', 'not working']):
                score -= 150
            return score

        for i in sorted(items, key=lambda x: _evidence_score(x['text']), reverse=True):
            # Deduplicate near-identical evidence
            key = i['text'][:60].lower()
            if key not in seen:
                # Clean up markdown artifacts
                clean_text = _clean_evidence(i['text'])
                if clean_text:
                    evidence.append(clean_text)
                    seen.add(key)
            if len(evidence) >= 5:
                break

        results.append({
            'label': label,
            'count': len(items),
            'dates': dates,
            'evidence': evidence,
            'confidence': min(0.95, 0.5 + len(items) * 0.05 + len(dates) * 0.1),
        })

    # Sort by count descending
    results.sort(key=lambda x: x['count'], reverse=True)
    return results


def analyze_recurring_themes(gathered):
    """Find topics that appeared on 3+ days or consumed heavy attention."""
    agg = gathered['aggregation']
    recurring = agg.get('recurring_topics', {})
    day_weights = agg.get('day_weights', {})

    results = []
    for topic, dates in recurring.items():
        day_count = len(dates)
        if day_count < 2:
            continue

        # Calculate topic weight as share of total content
        # We estimate this from the normalized topic → how many sections in each day
        topic_sections = 0
        topic_weight = 0
        for day in gathered['days']:
            if day.get('missing'):
                continue
            for s in day.get('sections', []):
                from scripts.gather_week import normalize_topic  # noqa
                pass  # We'll use inline normalization instead

        results.append({
            'topic': topic,
            'day_count': day_count,
            'dates': sorted(dates),
            'is_positive': topic not in ('failures', 'bugs', 'friction'),
            'confidence': min(0.95, 0.3 + day_count * 0.15),
        })

    results.sort(key=lambda x: x['day_count'], reverse=True)
    return results


def analyze_recurring_themes_v2(gathered):
    """Find topics that appeared on 2+ days — uses pre-aggregated data."""
    agg = gathered['aggregation']
    recurring = agg.get('recurring_topics', {})

    # Also check tool frequency for recurring tool themes
    tool_freq = agg.get('tool_frequency', {})

    results = []
    for topic, dates in recurring.items():
        day_count = len(dates)
        if day_count < 2:
            continue
        results.append({
            'topic': topic,
            'day_count': day_count,
            'dates': sorted(dates),
            'type': 'topic',
            'confidence': min(0.95, 0.3 + day_count * 0.15),
        })

    # Add tool-based themes (tools used on 3+ days)
    for tool, count in tool_freq.items():
        if count >= 3:
            results.append({
                'topic': f'{tool} (tool usage)',
                'day_count': count,
                'dates': [],  # tool frequency doesn't track per-day
                'type': 'tool',
                'confidence': min(0.9, 0.4 + count * 0.1),
            })

    results.sort(key=lambda x: x['day_count'], reverse=True)
    return results


def analyze_failures(gathered):
    """Find repeated failures and breakage patterns."""
    agg = gathered['aggregation']
    raw_failures = agg.get('failures', [])

    # Group failures by theme
    clusters = defaultdict(list)
    for item in raw_failures:
        text = item['text'].lower()
        if 'tokenpulse' in text or 'proxy' in text or 'header' in text or 'https' in text or 'reqwest' in text:
            clusters['TokenPulse proxy bugs'].append(item)
        elif 'token' in text and ('0' in text or 'unknown' in text or 'missing' in text):
            clusters['Token tracking failures'].append(item)
        elif 'lilith' in text or ('ollama' in text and 'error' in text):
            clusters['Lilith/Ollama errors'].append(item)
        elif 'rate' in text and 'limit' in text:
            clusters['Rate limiting'].append(item)
        elif 'restart' in text or 'reboot' in text or ('manual' in text and 'restart' in text):
            clusters['Manual restart burden'].append(item)
        elif ('log' in text and ('empty' in text or 'missing' in text)) or 'no logging' in text:
            clusters['Logging gaps'].append(item)
        elif 'test' in text and ('zero' in text or 'no ' in text or 'never' in text):
            clusters['Missing tests'].append(item)
        elif 'vision' in text and ('didn' in text or 'not' in text or 'support' in text):
            clusters['Vision model compatibility'].append(item)
        elif 'oauth' in text or 'scope' in text or 'unauthorized' in text or '401' in text:
            clusters['Authentication issues'].append(item)
        elif 'dashboard' in text and ('flat' in text or 'boring' in text or 'not' in text):
            clusters['UX/design feedback'].append(item)
        elif 'streaming' in text and ('0' in text or 'not' in text or 'missing' in text):
            clusters['Streaming extraction gaps'].append(item)
        elif 'junk' in text or 'cleanup' in text or 'corrupt' in text:
            clusters['Data quality'].append(item)
        else:
            # Try to infer from context
            if 'bug' in text or 'fix' in text or 'broke' in text:
                clusters['General bugs'].append(item)
            else:
                clusters['Miscellaneous friction'].append(item)

    results = []
    for label, items in clusters.items():
        dates = sorted(set(i['date'] for i in items))
        raw_evidence = list(dict.fromkeys(i['text'] for i in items))[:5]
        evidence = [_clean_evidence(e) for e in raw_evidence]
        evidence = [e for e in evidence if e]  # Filter empties
        repeated = len(dates) > 1

        results.append({
            'label': label,
            'count': len(items),
            'dates': dates,
            'repeated': repeated,
            'evidence': evidence,
            'confidence': min(0.95, 0.4 + len(items) * 0.08 + (0.2 if repeated else 0)),
        })

    results.sort(key=lambda x: (x['repeated'], x['count']), reverse=True)
    return results


def analyze_time_sinks(gathered):
    """Identify topics that consumed disproportionate attention."""
    days = gathered['days']
    total_content = gathered['aggregation'].get('total_content_bytes', 1)

    # Calculate per-topic content volume using section weight (line count)
    topic_volume = defaultdict(lambda: {'weight': 0, 'sections': 0, 'dates': set()})
    total_weight = 0

    for day in days:
        if day.get('missing'):
            continue
        for section in day.get('sections', []):
            header = section['header']
            topic_key = _normalize_for_sink(header)
            weight = section.get('weight', 0)
            topic_volume[topic_key]['weight'] += weight
            topic_volume[topic_key]['sections'] += 1
            topic_volume[topic_key]['dates'].add(day['date'])
            total_weight += weight

    results = []
    for topic, data in topic_volume.items():
        share = data['weight'] / max(total_weight, 1)
        # A notable topic is >3% of total weight OR >3 sections across days
        if share > 0.03 or data['sections'] >= 3:
            results.append({
                'topic': topic,
                'content_share': round(share, 3),
                'section_count': data['sections'],
                'dates': sorted(data['dates']),
                'is_sink': share > 0.25,
                'confidence': min(0.95, 0.3 + share * 2 + data['sections'] * 0.05),
            })

    # Filter out meta-topics that aren't real "work"
    skip = {'Daily Recap', 'daily-recap'}
    results = [r for r in results if r['topic'] not in skip]

    results.sort(key=lambda x: x['content_share'], reverse=True)
    return results


def _normalize_for_sink(header):
    """Normalize header text for time-sink grouping."""
    h = header.lower()
    if 'tokenpulse' in h:
        return 'TokenPulse'
    if 'lilith' in h:
        return 'Lilith'
    if 'clawhub' in h or 'skill' in h and 'batch' in h:
        return 'ClawHub Skills'
    if 'mission control' in h:
        return 'Mission Control'
    if 'model' in h or 'routing' in h:
        return 'Model Routing'
    if 'soul' in h or 'identity' in h or 'workspace file' in h:
        return 'Identity Rewrite'
    if 'youtube' in h or 'media' in h:
        return 'Media Vault'
    if 'workflow' in h or 'crystallizer' in h:
        return 'Workflow Crystallizer'
    if 'open webui' in h:
        return 'Open WebUI'
    if 'audit' in h:
        return 'Audit'
    if 'daily recap' in h or 'end of day' in h:
        return 'Daily Recap'
    if 'api' in h and ('tokenizer' in h or 'brainstorm' in h):
        return 'TokenPulse'
    if 'research' in h or 'market' in h:
        return 'Research'
    if 'trending' in h or 'scanner' in h:
        return 'Automation'
    if 'strategy' in h or 'quality' in h or 'first.?principle' in h:
        return 'Strategy'
    if 'fiverr' in h or 'freelance' in h:
        return 'Freelance'
    if 'account' in h or 'publishing' in h:
        return 'Publishing'
    if 'documentation' in h:
        return 'Documentation'
    if 'setup' in h or 'config' in h:
        return 'Setup & Config'
    if 'hardware' in h or 'mac mini' in h:
        return 'Infrastructure'
    if 'beta' in h or 'checklist' in h:
        return 'TokenPulse'
    if 'feedback' in h:
        return 'User Feedback'
    if 'discussion' in h:
        return 'Discussions'
    if 'git' in h and 'status' in h:
        return 'TokenPulse'
    if 'domain' in h:
        return 'TokenPulse'
    return header.title()


def analyze_unfinished(gathered):
    """Find action items and TODOs that weren't resolved later in the week."""
    agg = gathered['aggregation']
    action_items = agg.get('action_items', [])
    accomplishments = agg.get('accomplishments', [])

    # Flatten accomplishment text for checking if items were resolved
    accomplished_text = ' '.join(a['text'].lower() for a in accomplishments)

    results = []
    for item in action_items:
        text = item['text']
        text_lower = text.lower()

        # Check if this was likely resolved
        resolved = False
        # Simple heuristic: check if key words from the action item appear in accomplishments
        keywords = set(re.findall(r'\b[a-z]{4,}\b', text_lower))
        keywords -= {'need', 'needs', 'should', 'have', 'been', 'this', 'that', 'with', 'from', 'will'}
        if keywords:
            match_count = sum(1 for kw in keywords if kw in accomplished_text)
            if match_count / len(keywords) > 0.5:
                resolved = True

        if not resolved:
            # Filter out noise: job IDs, pure references, very short items
            if re.match(r'^[a-f0-9-]{20,}$', text.strip()):
                continue  # Skip bare UUIDs
            if 'job id' in text_lower:
                continue  # Skip job ID references
            results.append({
                'text': text,
                'date': item['date'],
                'confidence': 0.6,  # Action items are inherently uncertain
            })

    return results


def analyze_formalization_candidates(gathered, formalized_set):
    """Find things that keep being re-discussed or re-decided that should be codified."""
    agg = gathered['aggregation']
    recurring = agg.get('recurring_topics', {})
    decisions = agg.get('decisions', [])

    candidates = []

    # Topics discussed many days that aren't formalized
    for topic, dates in recurring.items():
        if len(dates) >= 3 and topic not in formalized_set:
            candidates.append({
                'topic': topic,
                'reason': f'Discussed on {len(dates)} days this week — consider formalizing in AGENTS.md or as a skill',
                'dates': sorted(dates),
                'confidence': min(0.9, 0.4 + len(dates) * 0.1),
            })

    # Repeated decisions about the same thing (sign of unclear policy)
    decision_topics = defaultdict(list)
    for d in decisions:
        text_lower = d['text'].lower()
        if 'tokenpulse' in text_lower:
            decision_topics['tokenpulse'].append(d)
        elif 'model' in text_lower or 'routing' in text_lower:
            decision_topics['model-routing'].append(d)
        elif 'lilith' in text_lower:
            decision_topics['lilith'].append(d)

    for topic, items in decision_topics.items():
        dates = set(i['date'] for i in items)
        if len(dates) >= 2 and topic not in formalized_set:
            candidates.append({
                'topic': f'{topic} decisions',
                'reason': f'Decisions about {topic} made on {len(dates)} different days — policy should be documented',
                'dates': sorted(dates),
                'confidence': min(0.85, 0.5 + len(dates) * 0.1),
            })

    return candidates


def analyze_pattern_shifts(gathered, history):
    """Detect what's new this week vs. historical patterns."""
    if not history or not history.get('retros'):
        return {
            'has_history': False,
            'shifts': [],
            'note': 'No prior retros — cannot detect pattern shifts yet.',
        }

    retros = history['retros']
    prior_topics = set()
    prior_friction = set()

    for retro in retros:
        prior_topics.update(retro.get('key_topics', []))
        prior_friction.update(f.get('label', '') for f in retro.get('friction_points', []))

    # Current week topics
    current_topics = set(gathered['aggregation'].get('recurring_topics', {}).keys())

    new_topics = current_topics - prior_topics
    dropped_topics = prior_topics - current_topics

    shifts = []
    for topic in new_topics:
        shifts.append({
            'type': 'new',
            'topic': topic,
            'description': f'"{topic}" is new this week — not seen in prior retros',
        })
    for topic in dropped_topics:
        shifts.append({
            'type': 'dropped',
            'topic': topic,
            'description': f'"{topic}" was active in prior weeks but absent this week',
        })

    # Score trends
    prior_scores = [r.get('week_score', 0) for r in retros if r.get('week_score')]
    score_trend = None
    if len(prior_scores) >= 2:
        avg_recent = sum(prior_scores[-3:]) / len(prior_scores[-3:])
        avg_older = sum(prior_scores[:-3]) / max(len(prior_scores[:-3]), 1)
        if avg_recent > avg_older + 0.5:
            score_trend = 'improving'
        elif avg_recent < avg_older - 0.5:
            score_trend = 'declining'
        else:
            score_trend = 'stable'

    # Check for persistent friction
    current_friction_labels = set()
    # We'll get these from the caller — for now, flag from history
    recurring_friction = []
    friction_counts = Counter()
    for retro in retros:
        for fp in retro.get('friction_points', []):
            friction_counts[fp.get('label', '')] += 1
    for label, count in friction_counts.items():
        if count >= 2:
            recurring_friction.append({
                'label': label,
                'weeks': count,
                'warning': f'This has been a friction point for {count} consecutive weeks',
            })

    # Check for unaddressed recommendations
    unaddressed = []
    for retro in retros[-3:]:  # Last 3 retros
        for rec in retro.get('recommendations', []):
            if not rec.get('addressed', False):
                unaddressed.append({
                    'recommendation': rec.get('text', ''),
                    'from_date': retro.get('date_end', ''),
                    'weeks_ago': len(retros) - retros.index(retro),
                })

    return {
        'has_history': True,
        'prior_retro_count': len(retros),
        'shifts': shifts,
        'score_trend': score_trend,
        'prior_scores': prior_scores,
        'recurring_friction': recurring_friction,
        'unaddressed_recommendations': unaddressed,
    }


def compute_week_score(accomplishments, failures, time_sinks, unfinished, active_days):
    """Compute a suggested week score (1-10) based on analysis."""
    score = 5.0  # Baseline

    # Accomplishments boost
    total_accomplishment_items = sum(a['count'] for a in accomplishments)
    if total_accomplishment_items > 20:
        score += 2.0
    elif total_accomplishment_items > 10:
        score += 1.5
    elif total_accomplishment_items > 5:
        score += 1.0

    # Variety of accomplishment areas
    if len(accomplishments) >= 4:
        score += 0.5

    # Failures drag
    repeated_failures = sum(1 for f in failures if f.get('repeated'))
    if repeated_failures > 2:
        score -= 1.5
    elif repeated_failures > 0:
        score -= 0.5

    # Unfinished items drag slightly
    if len(unfinished) > 5:
        score -= 0.5

    # Active days bonus
    if active_days >= 6:
        score += 0.5
    elif active_days <= 3:
        score -= 0.5

    # Time sink penalty (if one topic > 40% of content)
    for sink in time_sinks:
        if sink['content_share'] > 0.4:
            score -= 0.5
            break

    return max(1, min(10, round(score)))


def main():
    parser = argparse.ArgumentParser(description='Analyze gathered weekly data')
    parser.add_argument('--input', type=str, help='Path to gathered JSON (default: stdin)')
    parser.add_argument('--history', type=str, help='Path to retro history JSON')
    parser.add_argument('--soul', type=str, help='Path to SOUL.md')
    parser.add_argument('--agents', type=str, help='Path to AGENTS.md')
    parser.add_argument('--config', type=str, help='Path to config JSON')
    args = parser.parse_args()

    # Load config
    config = {}
    config_path = args.config or os.environ.get('WEEKLY_RETRO_CONFIG', '')
    if config_path and os.path.exists(config_path):
        config = load_json(config_path)

    # Load gathered data
    gathered = load_stdin_or_file(args.input)

    # Load history
    history_path = args.history or config.get('history_file', '')
    history = load_json(history_path) if history_path else {}

    # Load SOUL.md / AGENTS.md for formalization detection
    soul_path = args.soul or config.get('soul_path', os.path.expanduser('~/.openclaw/workspace/SOUL.md'))
    agents_path = args.agents or config.get('agents_path', os.path.expanduser('~/.openclaw/workspace/AGENTS.md'))
    formalized = scan_for_formalized(soul_path, agents_path)

    # Run analyses
    accomplishments = analyze_accomplishments(gathered)
    recurring_themes = analyze_recurring_themes_v2(gathered)
    failures = analyze_failures(gathered)
    time_sinks = analyze_time_sinks(gathered)
    unfinished = analyze_unfinished(gathered)
    formalization = analyze_formalization_candidates(gathered, formalized)
    pattern_shifts = analyze_pattern_shifts(gathered, history)

    active_days = gathered['aggregation'].get('active_days', 0)
    suggested_score = compute_week_score(
        accomplishments, failures, time_sinks, unfinished, active_days
    )

    output = {
        'meta': gathered['meta'],
        'summary_stats': {
            'active_days': active_days,
            'total_days': gathered['aggregation'].get('total_days', 0),
            'total_content_bytes': gathered['aggregation'].get('total_content_bytes', 0),
            'accomplishment_clusters': len(accomplishments),
            'failure_clusters': len(failures),
            'unfinished_count': len(unfinished),
            'tool_frequency': gathered['aggregation'].get('tool_frequency', {}),
            'time_patterns': gathered['aggregation'].get('time_patterns', {}),
        },
        'accomplishments': accomplishments,
        'recurring_themes': recurring_themes,
        'failures': failures,
        'time_sinks': time_sinks,
        'unfinished': unfinished,
        'formalization_candidates': formalization,
        'pattern_shifts': pattern_shifts,
        'suggested_week_score': suggested_score,
    }

    json.dump(output, sys.stdout, indent=2, default=str)
    print()


if __name__ == '__main__':
    main()


path: scripts/gather_week.py
content: #!/usr/bin/env python3
"""
gather_week.py — Read memory files for the past N days, extract structured data.

Outputs JSON with per-day breakdown and cross-day aggregation.
Handles missing days gracefully (weekends, gaps).

Usage:
    python3 gather_week.py [--memory-dir PATH] [--days N] [--config PATH] [--end-date YYYY-MM-DD]
"""

import argparse
import json
import os
import re
import sys
from datetime import datetime, timedelta
from pathlib import Path
from collections import Counter

# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------

# Patterns that signal accomplishments (shipped, built, fixed, deployed, etc.)
ACCOMPLISHMENT_VERBS = re.compile(
    r'\b(shipped|deployed|published|built|created|fixed|resolved|completed|'
    r'committed|merged|launched|released|verified|tested|confirmed working|'
    r'live on|running on|installed|configured|set up|wired|connected|rewritten|'
    r'rewrote|pushed to|all \d+ skills)\b',
    re.IGNORECASE,
)

# Patterns that signal failures or breakage
FAILURE_PATTERNS = re.compile(
    r'\b(bug|broke|broken|error|failed|failure|crash|issue|hang|timeout|'
    r'401|403|404|500|missing|wrong|not working|doesn\'t work|didn\'t work|'
    r'junk|corrupt|empty|stuck|blocked|rate.?limit|unauthorized)\b',
    re.IGNORECASE,
)

# Patterns for action items / TODOs
ACTION_PATTERNS = re.compile(
    r'(?:^|\n)\s*[-*]\s*(?:TODO|FIXME|NEXT|⬜|Action|Remaining|Need to|'
    r'needs? (?:to be|manual|restart|setup)|should (?:be|have))',
    re.IGNORECASE,
)

# Patterns for decisions made
DECISION_PATTERNS = re.compile(
    r'\b(decided|decision|chose|chosen|confirmed|approved|pivot|finalized|'
    r'approach:|strategy:|architecture|went with|correct approach|'
    r'name (?:confirmed|finalized)|domain)\b',
    re.IGNORECASE,
)

# Known tools and services to track
TOOL_KEYWORDS = [
    'tokenpulse', 'openclaw', 'obsidian', 'discord', 'claude', 'opus',
    'sonnet', 'haiku', 'ollama', 'llama.cpp', 'lilith', 'docker',
    'github', 'clawhub', 'tauri', 'react', 'python', 'rust', 'cargo',
    'sqlite', 'launchd', 'open webui', 'fiverr', 'qwen', 'cliproxy',
    'mission control', 'next.js', 'tailwind', 'claude code', 'codex',
    'lm studio', 'huggingface',
]


def load_config(config_path):
    """Load config from JSON file."""
    with open(config_path, 'r') as f:
        return json.load(f)


def parse_memory_file(filepath):
    """Parse a single memory/YYYY-MM-DD.md file into structured sections."""
    text = Path(filepath).read_text(encoding='utf-8')
    lines = text.split('\n')

    sections = []
    current_section = None
    current_lines = []

    for line in lines:
        # Detect headers (## or ###)
        header_match = re.match(r'^(#{1,4})\s+(.+)', line)
        if header_match:
            if current_section:
                sections.append({
                    'header': current_section,
                    'content': '\n'.join(current_lines).strip(),
                })
            current_section = header_match.group(2).strip()
            current_lines = []
        else:
            current_lines.append(line)

    # Flush last section
    if current_section:
        sections.append({
            'header': current_section,
            'content': '\n'.join(current_lines).strip(),
        })

    return text, sections


def extract_bullets(text):
    """Extract bullet points from markdown text."""
    bullets = []
    for line in text.split('\n'):
        stripped = line.strip()
        if stripped.startswith(('- ', '* ', '• ')):
            bullets.append(stripped[2:].strip())
        elif re.match(r'^\d+\.\s', stripped):
            bullets.append(re.sub(r'^\d+\.\s*', '', stripped).strip())
    return bullets


def extract_accomplishments(text):
    """Find lines that describe something completed/shipped."""
    results = []
    for line in text.split('\n'):
        if ACCOMPLISHMENT_VERBS.search(line) and line.strip():
            clean = line.strip().lstrip('-*• ').strip()
            if len(clean) > 10:  # Filter noise
                results.append(clean)
    return results


def extract_failures(text):
    """Find lines describing failures, bugs, or breakage."""
    # Lines that are clearly categorization, not failures
    false_positive_patterns = re.compile(
        r'^\s*[-*]\s*(?:Personal Productivity|Developer|Hardware|Network|'
        r'Key insight|Key finding|Best business|Revenue target|'
        r'Build approach|Data export)',
        re.IGNORECASE,
    )
    results = []
    for line in text.split('\n'):
        if FAILURE_PATTERNS.search(line) and line.strip():
            if false_positive_patterns.search(line):
                continue
            clean = line.strip().lstrip('-*• ').strip()
            if len(clean) > 10:
                results.append(clean)
    return results


def extract_action_items(text):
    """Find TODOs, remaining work, and action items."""
    results = []
    for line in text.split('\n'):
        if ACTION_PATTERNS.search(line):
            clean = line.strip().lstrip('-*• ').strip()
            if len(clean) > 5:
                results.append(clean)
    return results


def extract_decisions(text):
    """Find decisions and strategic choices."""
    results = []
    for line in text.split('\n'):
        if DECISION_PATTERNS.search(line) and line.strip():
            clean = line.strip().lstrip('-*• ').strip()
            if len(clean) > 10:
                results.append(clean)
    return results


def extract_tools_mentioned(text):
    """Find which tools/services were mentioned."""
    text_lower = text.lower()
    found = []
    for tool in TOOL_KEYWORDS:
        if tool in text_lower:
            found.append(tool)
    return found


def extract_topics(sections):
    """Extract main topics from section headers."""
    topics = []
    for s in sections:
        header = s['header']
        # Skip generic headers
        if header.lower() in ('end of day', 'daily recap', 'discussions', 'notable'):
            continue
        topics.append(header)
    return topics


def estimate_section_weight(section):
    """Estimate how much attention a topic got based on content volume."""
    content = section['content']
    # Count substantive lines (not blank, not just bullets markers)
    lines = [l for l in content.split('\n') if l.strip() and len(l.strip()) > 5]
    return len(lines)


def detect_time_of_day(text):
    """Detect timestamps or time-of-day references in the text."""
    patterns = {
        'late_night': re.compile(r'\b(?:2[3-9]|[012][0-3]):\d{2}\b|late night|2:\d{2}\s*AM|3:\d{2}\s*AM|midnight', re.IGNORECASE),
        'morning': re.compile(r'\b(?:0[6-9]|1[01]):\d{2}\b|morning|AM ET', re.IGNORECASE),
        'afternoon': re.compile(r'\b(?:1[2-7]):\d{2}\b|afternoon|PM ET', re.IGNORECASE),
        'evening': re.compile(r'\b(?:1[89]|2[0-2]):\d{2}\b|evening|tonight', re.IGNORECASE),
    }
    detected = []
    for period, pat in patterns.items():
        if pat.search(text):
            detected.append(period)
    return detected


def process_day(date_str, filepath):
    """Process a single day's memory file into structured data."""
    text, sections = parse_memory_file(filepath)

    # Per-section analysis
    section_data = []
    for s in sections:
        full_text = s['header'] + '\n' + s['content']
        section_data.append({
            'header': s['header'],
            'weight': estimate_section_weight(s),
            'accomplishments': extract_accomplishments(full_text),
            'failures': extract_failures(full_text),
            'action_items': extract_action_items(s['content']),
            'decisions': extract_decisions(full_text),
            'tools': extract_tools_mentioned(full_text),
        })

    return {
        'date': date_str,
        'file_size': len(text),
        'section_count': len(sections),
        'topics': extract_topics(sections),
        'time_of_day': detect_time_of_day(text),
        'accomplishments': extract_accomplishments(text),
        'failures': extract_failures(text),
        'action_items': extract_action_items(text),
        'decisions': extract_decisions(text),
        'tools_mentioned': extract_tools_mentioned(text),
        'sections': section_data,
    }


def aggregate_week(days_data):
    """Cross-day aggregation: find themes, patterns, totals."""
    all_topics = []
    all_tools = Counter()
    all_accomplishments = []
    all_failures = []
    all_action_items = []
    all_decisions = []
    time_patterns = Counter()
    total_content = 0
    active_days = 0

    topic_day_map = {}  # topic -> list of dates

    for day in days_data:
        if day.get('missing'):
            continue
        active_days += 1
        total_content += day['file_size']

        for topic in day['topics']:
            all_topics.append(topic)
            # Normalize topic for grouping
            topic_key = normalize_topic(topic)
            if topic_key not in topic_day_map:
                topic_day_map[topic_key] = set()
            topic_day_map[topic_key].add(day['date'])

        for tool in day['tools_mentioned']:
            all_tools[tool] += 1

        for t in day.get('time_of_day', []):
            time_patterns[t] += 1

        all_accomplishments.extend(
            [{'text': a, 'date': day['date']} for a in day['accomplishments']]
        )
        all_failures.extend(
            [{'text': f, 'date': day['date']} for f in day['failures']]
        )
        all_action_items.extend(
            [{'text': a, 'date': day['date']} for a in day['action_items']]
        )
        all_decisions.extend(
            [{'text': d, 'date': day['date']} for d in day['decisions']]
        )

    # Find recurring topics (appeared on 2+ days) — convert sets to sorted lists
    recurring_topics = {
        topic: sorted(dates) for topic, dates in topic_day_map.items()
        if len(dates) >= 2
    }

    # Content distribution across days
    day_weights = {}
    for day in days_data:
        if not day.get('missing'):
            day_weights[day['date']] = day['file_size']

    return {
        'active_days': active_days,
        'total_days': len(days_data),
        'total_content_bytes': total_content,
        'day_weights': day_weights,
        'all_topics': all_topics,
        'recurring_topics': recurring_topics,
        'tool_frequency': dict(all_tools.most_common()),
        'time_patterns': dict(time_patterns),
        'accomplishment_count': len(all_accomplishments),
        'failure_count': len(all_failures),
        'action_item_count': len(all_action_items),
        'decision_count': len(all_decisions),
        'accomplishments': all_accomplishments,
        'failures': all_failures,
        'action_items': all_action_items,
        'decisions': all_decisions,
    }


def normalize_topic(topic):
    """Normalize a section header for grouping related topics."""
    t = topic.lower().strip()
    # Remove date stamps like "(2026-03-23)" or "(2026-03-23 continued)"
    t = re.sub(r'\(?\d{4}-\d{2}-\d{2}[^)]*\)?', '', t).strip()
    # Remove leading date like "2026-03-22"
    t = re.sub(r'^\d{4}-\d{2}-\d{2}\s*', '', t).strip()

    # Collapse TokenPulse variants
    if 'tokenpulse' in t or 'token pulse' in t or 'token flow' in t:
        return 'tokenpulse'
    if 'lilith' in t:
        return 'lilith'
    # Check for skills/clawhub (careful with word boundaries)
    if 'clawhub' in t or ('skill' in t and ('batch' in t or 'built' in t or 'publish' in t or 'niche' in t)):
        return 'clawhub-skills'
    if 'mission control' in t:
        return 'mission-control'
    if 'claude' in t and ('max' in t or 'swap' in t):
        return 'claude-auth'
    if 'smart routing' in t or ('model' in t and ('routing' in t or 'tier' in t or 'inventory' in t)):
        return 'model-routing'
    if 'youtube' in t or 'media vault' in t:
        return 'media-vault'
    if 'soul' in t or 'identity' in t or 'workspace file' in t:
        return 'identity-rewrite'
    if 'workflow' in t or 'crystallizer' in t:
        return 'workflow-crystallizer'
    if 'open webui' in t:
        return 'open-webui'
    if 'fiverr' in t or 'freelance' in t:
        return 'freelance'
    if 'domain' in t:
        return 'domain'
    if 'setup' in t or 'config' in t:
        return 'setup-config'
    if 'audit' in t:
        return 'audit'
    if 'daily recap' in t or 'end of day' in t:
        return 'daily-recap'
    if 'trending' in t or 'scanner' in t:
        return 'trending-scanner'
    if 'api' in t and ('tokenizer' in t or 'dashboard' in t or 'brainstorm' in t):
        return 'tokenpulse'
    if 'build progress' in t:
        return 'tokenpulse'
    if 'beta' in t:
        return 'tokenpulse'
    if 'mac mini' in t or 'hardware' in t:
        return 'infrastructure'
    if 'git' in t and 'status' in t:
        return 'tokenpulse'
    if 'market' in t or 'research' in t:
        return 'research'
    if 'strategy' in t or 'first.?principle' in t or 'quality' in t:
        return 'strategy'
    if 'ryan' in t and ('feedback' in t or 'task' in t):
        return 'user-feedback'
    if 'account' in t or 'token' in t and 'auth' in t:
        return 'accounts'
    if 'documentation' in t or 'doc' in t:
        return 'documentation'
    if 'bug' in t or 'fix' in t:
        return 'tokenpulse'
    # Keep original if no match (cleaned up)
    return t if t else topic.lower().strip()


def main():
    parser = argparse.ArgumentParser(description='Gather memory files for weekly retrospective')
    parser.add_argument('--memory-dir', type=str, help='Path to memory directory')
    parser.add_argument('--days', type=int, help='Number of days to look back (default: 7)')
    parser.add_argument('--end-date', type=str, help='End date YYYY-MM-DD (default: today)')
    parser.add_argument('--config', type=str, help='Path to config JSON file')
    args = parser.parse_args()

    # Load config
    config = {}
    if args.config and os.path.exists(args.config):
        config = load_config(args.config)
    elif os.environ.get('WEEKLY_RETRO_CONFIG') and os.path.exists(os.environ['WEEKLY_RETRO_CONFIG']):
        config = load_config(os.environ['WEEKLY_RETRO_CONFIG'])

    # Resolve parameters
    memory_dir = args.memory_dir or config.get('memory_dir', os.path.expanduser(
        '~/.openclaw/workspace/memory'
    ))
    lookback = args.days or config.get('lookback_days', 7)

    if args.end_date:
        end_date = datetime.strptime(args.end_date, '%Y-%m-%d')
    else:
        end_date = datetime.now()

    start_date = end_date - timedelta(days=lookback - 1)

    # Gather days
    days_data = []
    for i in range(lookback):
        day = start_date + timedelta(days=i)
        date_str = day.strftime('%Y-%m-%d')
        filepath = os.path.join(memory_dir, f'{date_str}.md')

        if os.path.exists(filepath):
            # Check if file has real content (not just a blank/stub)
            content = Path(filepath).read_text(encoding='utf-8').strip()
            if len(content) < 50:
                days_data.append({
                    'date': date_str,
                    'missing': True,
                    'reason': 'stub_or_empty',
                })
            else:
                try:
                    day_data = process_day(date_str, filepath)
                    days_data.append(day_data)
                except Exception as e:
                    days_data.append({
                        'date': date_str,
                        'missing': True,
                        'reason': f'parse_error: {str(e)}',
                    })
        else:
            days_data.append({
                'date': date_str,
                'missing': True,
                'reason': 'file_not_found',
            })

    # Aggregate
    aggregation = aggregate_week(days_data)

    output = {
        'meta': {
            'start_date': start_date.strftime('%Y-%m-%d'),
            'end_date': end_date.strftime('%Y-%m-%d'),
            'memory_dir': memory_dir,
            'lookback_days': lookback,
            'generated_at': datetime.now().isoformat(),
        },
        'days': days_data,
        'aggregation': aggregation,
    }

    json.dump(output, sys.stdout, indent=2, default=str)
    print()  # trailing newline


if __name__ == '__main__':
    main()


path: scripts/history.py
content: #!/usr/bin/env python3
"""
history.py — Track retrospective history for longitudinal analysis.

Maintains a JSON log of past retros: date ranges, scores, key findings,
recommendations, and whether recommendations were acted on.

Usage:
    python3 history.py record --analysis analysis.json [--history PATH]
    python3 history.py show [--history PATH]
    python3 history.py mark-addressed --recommendation "text" [--history PATH]
    python3 history.py trends [--history PATH]
"""

import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path


DEFAULT_HISTORY_PATH = os.path.expanduser(
    '~/.openclaw/workspace/skills/weekly-retro/history.json'
)


def load_history(path):
    """Load or initialize history file."""
    if os.path.exists(path):
        try:
            with open(path, 'r') as f:
                data = json.load(f)
            # Ensure expected structure
            if 'retros' not in data:
                data['retros'] = []
            if 'meta' not in data:
                data['meta'] = {'created': datetime.now().isoformat()}
            return data
        except (json.JSONDecodeError, KeyError):
            return _new_history()
    return _new_history()


def _new_history():
    return {
        'meta': {
            'created': datetime.now().isoformat(),
            'schema_version': 1,
        },
        'retros': [],
    }


def save_history(data, path):
    """Write history to disk."""
    os.makedirs(os.path.dirname(path), exist_ok=True)
    data['meta']['last_updated'] = datetime.now().isoformat()
    with open(path, 'w') as f:
        json.dump(data, f, indent=2, default=str)
    print(f"History saved to: {path}", file=sys.stderr)


def record_retro(analysis_path, history_path):
    """Record a new retrospective entry from analysis JSON."""
    with open(analysis_path, 'r') as f:
        analysis = json.load(f)

    history = load_history(history_path)

    meta = analysis.get('meta', {})
    start_date = meta.get('start_date', '')
    end_date = meta.get('end_date', '')

    # Check for duplicate (same date range)
    for existing in history['retros']:
        if existing.get('date_start') == start_date and existing.get('date_end') == end_date:
            print(f"Retro for {start_date} to {end_date} already recorded. Updating.", file=sys.stderr)
            history['retros'].remove(existing)
            break

    # Extract key data for the history entry
    accomplishments = analysis.get('accomplishments', [])
    failures = analysis.get('failures', [])
    unfinished = analysis.get('unfinished', [])
    formalization = analysis.get('formalization_candidates', [])
    time_sinks = analysis.get('time_sinks', [])
    recurring_themes = analysis.get('recurring_themes', [])

    # Build compact history entry
    entry = {
        'date_start': start_date,
        'date_end': end_date,
        'recorded_at': datetime.now().isoformat(),
        'week_score': analysis.get('suggested_week_score', None),
        'active_days': analysis.get('summary_stats', {}).get('active_days', 0),
        'key_topics': [t['topic'] for t in recurring_themes[:10]],
        'accomplishment_summary': [
            {'label': a['label'], 'count': a['count']}
            for a in accomplishments[:8]
        ],
        'friction_points': [
            {'label': f['label'], 'count': f['count'], 'repeated': f.get('repeated', False)}
            for f in failures if f['count'] >= 2 or f.get('repeated')
        ],
        'unfinished_count': len(unfinished),
        'unfinished_items': [u['text'][:100] for u in unfinished[:5]],
        'top_time_sink': time_sinks[0]['topic'] if time_sinks else None,
        'top_time_sink_share': time_sinks[0]['content_share'] if time_sinks else None,
        'formalization_candidates': [
            {'topic': fc['topic'], 'reason': fc['reason'][:100]}
            for fc in formalization[:3]
        ],
        'recommendations': [],  # Filled below
    }

    # Extract recommendations — we need to regenerate them from the analysis
    # Since retrospective.py generates them, we'll extract the key info
    recs = _extract_recommendations(analysis)
    entry['recommendations'] = recs

    history['retros'].append(entry)

    # Sort by date
    history['retros'].sort(key=lambda x: x.get('date_end', ''))

    save_history(history, history_path)
    print(f"Recorded retro for {start_date} to {end_date} (score: {entry['week_score']})", file=sys.stderr)

    return entry


def _extract_recommendations(analysis):
    """Extract recommendation entries for history tracking."""
    # Re-derive recommendations using same logic as retrospective.py
    recs = []
    failures = analysis.get('failures', [])
    unfinished = analysis.get('unfinished', [])
    formalization = analysis.get('formalization_candidates', [])
    time_patterns = analysis.get('summary_stats', {}).get('time_patterns', {})
    time_sinks = analysis.get('time_sinks', [])

    repeated = [f for f in failures if f.get('repeated')]
    if repeated:
        recs.append({
            'text': f"Address recurring {repeated[0]['label'].lower()}",
            'source': 'repeated_failure',
            'addressed': False,
        })

    if len(unfinished) > 3:
        recs.append({
            'text': f"Clear the backlog ({len(unfinished)} open threads)",
            'source': 'unfinished_threads',
            'addressed': False,
        })

    if formalization:
        recs.append({
            'text': f"Formalize the {formalization[0]['topic']} workflow",
            'source': 'formalization',
            'addressed': False,
        })

    if time_patterns.get('late_night', 0) >= 2:
        recs.append({
            'text': 'Watch the late-night pattern — batch complex work for mornings',
            'source': 'time_pattern',
            'addressed': False,
        })

    if time_sinks and time_sinks[0]['content_share'] > 0.35:
        recs.append({
            'text': f"Diversify focus — {time_sinks[0]['topic']} consumed {time_sinks[0]['content_share']*100:.0f}% of attention",
            'source': 'time_sink',
            'addressed': False,
        })

    return recs[:3]


def mark_addressed(recommendation_text, history_path):
    """Mark a recommendation as addressed."""
    history = load_history(history_path)

    found = False
    for retro in reversed(history['retros']):
        for rec in retro.get('recommendations', []):
            if recommendation_text.lower() in rec.get('text', '').lower():
                rec['addressed'] = True
                rec['addressed_at'] = datetime.now().isoformat()
                found = True
                print(f"Marked as addressed: \"{rec['text']}\" (from {retro['date_start']} to {retro['date_end']})", file=sys.stderr)
                break
        if found:
            break

    if not found:
        print(f"No matching recommendation found for: \"{recommendation_text}\"", file=sys.stderr)
        sys.exit(1)

    save_history(history, history_path)


def show_history(history_path):
    """Display history summary."""
    history = load_history(history_path)
    retros = history.get('retros', [])

    if not retros:
        print("No retrospective history yet.")
        return

    print(f"=== Retrospective History ({len(retros)} entries) ===\n")

    for retro in retros:
        score = retro.get('week_score', '?')
        start = retro.get('date_start', '?')
        end = retro.get('date_end', '?')
        active = retro.get('active_days', '?')
        sink = retro.get('top_time_sink', 'N/A')

        print(f"📅 {start} to {end}  |  Score: {score}/10  |  Active days: {active}  |  Focus: {sink}")

        # Accomplishments
        accs = retro.get('accomplishment_summary', [])
        if accs:
            acc_str = ', '.join(f"{a['label']} ({a['count']})" for a in accs[:4])
            print(f"   ✅ {acc_str}")

        # Friction
        friction = retro.get('friction_points', [])
        if friction:
            fric_str = ', '.join(f['label'] for f in friction[:3])
            print(f"   ⚠️  Friction: {fric_str}")

        # Recommendations
        recs = retro.get('recommendations', [])
        for rec in recs:
            status = '✅' if rec.get('addressed') else '⬜'
            print(f"   {status} {rec['text']}")

        print()


def show_trends(history_path):
    """Display longitudinal trends."""
    history = load_history(history_path)
    retros = history.get('retros', [])

    if len(retros) < 2:
        print("Need at least 2 retros for trend analysis.")
        return

    print("=== Longitudinal Trends ===\n")

    # Score trend
    scores = [(r['date_end'], r.get('week_score', 0)) for r in retros if r.get('week_score')]
    if scores:
        print("📊 Score History:")
        for date, score in scores:
            bar = '█' * score + '░' * (10 - score)
            print(f"   {date}: [{bar}] {score}/10")
        avg = sum(s for _, s in scores) / len(scores)
        print(f"   Average: {avg:.1f}/10")
        if len(scores) >= 3:
            recent_avg = sum(s for _, s in scores[-3:]) / 3
            older_avg = sum(s for _, s in scores[:-3]) / max(len(scores) - 3, 1) if len(scores) > 3 else scores[0][1]
            if recent_avg > older_avg + 0.5:
                print("   📈 Trend: Improving")
            elif recent_avg < older_avg - 0.5:
                print("   📉 Trend: Declining")
            else:
                print("   ➡️  Trend: Stable")
        print()

    # Persistent friction
    from collections import Counter
    friction_counter = Counter()
    for retro in retros:
        for fp in retro.get('friction_points', []):
            friction_counter[fp['label']] += 1

    persistent = [(label, count) for label, count in friction_counter.items() if count >= 2]
    if persistent:
        print("🔁 Persistent Friction (2+ weeks):")
        for label, count in sorted(persistent, key=lambda x: x[1], reverse=True):
            print(f"   ⚠️  {label} — {count} weeks")
        print()

    # Recommendation follow-through
    total_recs = 0
    addressed_recs = 0
    for retro in retros:
        for rec in retro.get('recommendations', []):
            total_recs += 1
            if rec.get('addressed'):
                addressed_recs += 1

    if total_recs > 0:
        rate = addressed_recs / total_recs * 100
        print(f"📋 Recommendation Follow-Through: {addressed_recs}/{total_recs} ({rate:.0f}%)")
        if rate < 30:
            print("   ⚠️  Low follow-through — recommendations may need to be more actionable")
        elif rate > 70:
            print("   ✅ Strong follow-through")
        print()

    # Topic evolution
    all_topics = {}
    for i, retro in enumerate(retros):
        for topic in retro.get('key_topics', []):
            if topic not in all_topics:
                all_topics[topic] = []
            all_topics[topic].append(i)

    evolving = [(t, weeks) for t, weeks in all_topics.items() if len(weeks) >= 2]
    if evolving:
        print("🔄 Recurring Topics Across Weeks:")
        for topic, weeks in sorted(evolving, key=lambda x: len(x[1]), reverse=True)[:8]:
            print(f"   {topic} — appeared in {len(weeks)} retros")
        print()


def main():
    parser = argparse.ArgumentParser(description='Manage retrospective history')
    subparsers = parser.add_subparsers(dest='command', help='Command')

    # record
    record_parser = subparsers.add_parser('record', help='Record a new retro from analysis JSON')
    record_parser.add_argument('--analysis', required=True, help='Path to analysis JSON')
    record_parser.add_argument('--history', default=None, help='Path to history file')

    # show
    show_parser = subparsers.add_parser('show', help='Display history summary')
    show_parser.add_argument('--history', default=None, help='Path to history file')

    # mark-addressed
    mark_parser = subparsers.add_parser('mark-addressed', help='Mark a recommendation as addressed')
    mark_parser.add_argument('--recommendation', required=True, help='Text of the recommendation')
    mark_parser.add_argument('--history', default=None, help='Path to history file')

    # trends
    trends_parser = subparsers.add_parser('trends', help='Display longitudinal trends')
    trends_parser.add_argument('--history', default=None, help='Path to history file')

    args = parser.parse_args()

    # Resolve history path
    config_path = os.environ.get('WEEKLY_RETRO_CONFIG', '')
    config = {}
    if config_path and os.path.exists(config_path):
        with open(config_path, 'r') as f:
            config = json.load(f)

    history_path = getattr(args, 'history', None) or config.get('history_file', DEFAULT_HISTORY_PATH)

    if args.command == 'record':
        record_retro(args.analysis, history_path)
    elif args.command == 'show':
        show_history(history_path)
    elif args.command == 'mark-addressed':
        mark_addressed(args.recommendation, history_path)
    elif args.command == 'trends':
        show_trends(history_path)
    else:
        parser.print_help()


if __name__ == '__main__':
    main()


path: scripts/retrospective.py
content: #!/usr/bin/env python3
"""
retrospective.py — Generate a markdown retrospective from analysis JSON.

Takes analysis output (from analyze.py), produces a beautiful markdown document
suitable for Obsidian with YAML frontmatter, forward-looking recommendations,
and longitudinal context when history exists.

Usage:
    python3 analyze.py [...] | python3 retrospective.py [--output PATH]
    python3 retrospective.py --input analysis.json [--output PATH]
"""

import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path


def load_stdin_or_file(input_path):
    """Read analysis data from stdin or file."""
    if input_path:
        with open(input_path, 'r') as f:
            return json.load(f)
    else:
        return json.load(sys.stdin)


def auto_tags(analysis):
    """Generate tags from analysis content."""
    tags = ['weekly-retro']

    # Add tags from top accomplishment clusters
    for acc in analysis.get('accomplishments', [])[:5]:
        label = acc['label'].lower().replace(' ', '-')
        if label != 'other':
            tags.append(label)

    # Add tags from recurring themes
    for theme in analysis.get('recurring_themes', [])[:3]:
        topic = theme['topic'].lower().replace(' ', '-').replace('(', '').replace(')', '')
        if topic not in tags:
            tags.append(topic)

    # Add time pattern tags
    time_patterns = analysis.get('summary_stats', {}).get('time_patterns', {})
    if time_patterns.get('late_night', 0) >= 2:
        tags.append('late-night-sessions')

    return tags


def format_frontmatter(analysis, tags):
    """Generate YAML frontmatter."""
    meta = analysis.get('meta', {})
    start = meta.get('start_date', 'unknown')
    end = meta.get('end_date', 'unknown')
    score = analysis.get('suggested_week_score', '?')

    lines = [
        '---',
        f'date_start: {start}',
        f'date_end: {end}',
        'type: weekly-retro',
        f'week_score: {score}',
        f'tags: [{", ".join(tags)}]',
        '---',
    ]
    return '\n'.join(lines)


def format_week_at_a_glance(analysis):
    """Generate the 3-sentence week summary."""
    stats = analysis.get('summary_stats', {})
    accomplishments = analysis.get('accomplishments', [])
    failures = analysis.get('failures', [])
    time_sinks = analysis.get('time_sinks', [])
    active_days = stats.get('active_days', 0)
    total_days = stats.get('total_days', 7)

    # Determine the dominant thrust — skip meta-topics like "Daily Recap"
    skip_topics = {'Daily Recap', 'daily-recap', 'End of Day'}
    top_topics = [s for s in sorted(time_sinks, key=lambda x: x['content_share'], reverse=True)
                  if s['topic'] not in skip_topics]
    dominant = top_topics[0]['topic'] if top_topics else 'various projects'

    # Count accomplishment items
    total_wins = sum(a['count'] for a in accomplishments)
    win_areas = len(accomplishments)

    # Sentence 1: What dominated
    s1 = f"This was a {active_days}-day week dominated by {dominant}"
    if len(top_topics) > 1:
        s1 += f" alongside {top_topics[1]['topic']}"
    s1 += '.'

    # Sentence 2: What was accomplished
    if total_wins > 15:
        s2 = f"Massive output — {total_wins} accomplishments across {win_areas} areas, with significant shipping and deployment."
    elif total_wins > 8:
        s2 = f"Strong output with {total_wins} accomplishments across {win_areas} areas."
    elif total_wins > 3:
        s2 = f"Moderate output — {total_wins} accomplishments, mostly concentrated in a few areas."
    else:
        s2 = f"Light output with {total_wins} notable accomplishments."

    # Sentence 3: Forward-looking
    unfinished = analysis.get('unfinished', [])
    if len(unfinished) > 5:
        s3 = f"Heading into next week with {len(unfinished)} open threads — prioritization will be key."
    elif failures and any(f.get('repeated') for f in failures):
        s3 = "Some recurring issues need addressing before they become structural."
    else:
        s3 = "Good momentum to carry into next week."

    return f"{s1} {s2} {s3}"


def format_wins(accomplishments):
    """Format the Wins section."""
    if not accomplishments:
        return "*No major wins identified this week.*"

    lines = []
    # Show substantial clusters (3+ items) with full evidence
    major = [a for a in accomplishments if a['count'] >= 3]
    minor = [a for a in accomplishments if a['count'] < 3 and a['label'] not in ('Uncategorized',)]

    for acc in major:
        dates_str = ', '.join(acc['dates'])
        lines.append(f"**{acc['label']}** ({acc['count']} items, {dates_str})")
        for ev in acc['evidence'][:3]:
            lines.append(f"  - {ev}")
        lines.append('')

    # Consolidate small clusters into a compact "Also shipped" section
    if minor:
        lines.append("**Also shipped:**")
        for acc in minor:
            # Single best evidence line per minor cluster
            ev = acc['evidence'][0] if acc['evidence'] else acc['label']
            lines.append(f"  - **{acc['label']}**: {ev}")
        lines.append('')

    return '\n'.join(lines).strip() if lines else "*No major wins identified.*"


def format_patterns(recurring_themes, time_sinks, time_patterns):
    """Format the Patterns section."""
    lines = []

    # Recurring topic themes (not tool mentions — those are noise)
    topic_themes = [t for t in recurring_themes if t.get('type') != 'tool']
    if topic_themes:
        lines.append("**Recurring topics:**")
        for theme in topic_themes[:6]:
            topic = theme['topic']
            days = theme['day_count']
            if theme.get('dates'):
                date_str = ', '.join(theme['dates'][:5])
                lines.append(f"  - **{topic}** — {days} days ({date_str})")
            else:
                lines.append(f"  - **{topic}** — {days} days")
        lines.append('')

    # Top tools (condensed — one line, not per-tool)
    tool_themes = [t for t in recurring_themes if t.get('type') == 'tool' and t['day_count'] >= 4]
    if tool_themes:
        tool_str = ', '.join(f"{t['topic'].replace(' (tool usage)', '')} ({t['day_count']}d)" for t in tool_themes[:6])
        lines.append(f"**Core toolchain this week:** {tool_str}")
        lines.append('')

    # Time-of-day patterns
    if time_patterns:
        active_times = sorted(time_patterns.items(), key=lambda x: x[1], reverse=True)
        time_desc = ', '.join(f"{t.replace('_', ' ')} ({c}d)" for t, c in active_times)
        lines.append(f"**Work schedule:** {time_desc}")
        if time_patterns.get('late_night', 0) >= 2:
            lines.append("  - ⚠️ Late-night sessions on {0} days — sustainability concern".format(
                time_patterns['late_night']
            ))
        lines.append('')

    # Attention distribution — the real insight
    major_sinks = [s for s in time_sinks if s['content_share'] > 0.03]
    if major_sinks:
        lines.append("**Where attention went:**")
        for sink in major_sinks[:6]:
            pct = sink['content_share'] * 100
            bar_len = int(pct / 5)  # Scale to ~20 chars max
            bar = '█' * bar_len + '░' * (20 - bar_len)
            lines.append(f"  - {sink['topic']}: `{bar}` {pct:.0f}%")
        lines.append('')

    return '\n'.join(lines).strip() if lines else "*No strong patterns detected.*"


def format_friction(failures):
    """Format the Friction Points section."""
    if not failures:
        return "*No significant friction points this week.*"

    lines = []
    shown = 0
    for f in failures:
        if shown >= 5:
            break
        if f['count'] < 2 and not f.get('repeated'):
            continue
        repeat_marker = ' 🔁' if f.get('repeated') else ''
        dates_str = ', '.join(f['dates'])
        lines.append(f"**{f['label']}**{repeat_marker}")
        if f.get('repeated'):
            lines.append(f"  - Appeared on {dates_str} — recurring pattern")
        for ev in f['evidence'][:2]:
            lines.append(f"  - {ev}")
        lines.append('')
        shown += 1

    # Include single-occurrence but notable failures if nothing shown yet
    if not lines:
        for f in failures[:3]:
            lines.append(f"**{f['label']}**")
            for ev in f['evidence'][:2]:
                lines.append(f"  - {ev}")
            lines.append('')

    return '\n'.join(lines).strip() if lines else "*No significant friction points.*"


def format_unfinished(unfinished):
    """Format the Unfinished Business section."""
    if not unfinished:
        return "*All threads appear resolved. Clean slate heading into next week.*"

    lines = []
    # Group by date
    by_date = {}
    for item in unfinished:
        d = item.get('date', 'unknown')
        if d not in by_date:
            by_date[d] = []
        by_date[d].append(item['text'])

    for date in sorted(by_date.keys()):
        lines.append(f"**From {date}:**")
        for text in by_date[date][:5]:
            lines.append(f"  - {text}")
        lines.append('')

    return '\n'.join(lines).strip()


def generate_recommendations(analysis):
    """Generate 2-3 specific, actionable recommendations."""
    recs = []
    failures = analysis.get('failures', [])
    unfinished = analysis.get('unfinished', [])
    time_sinks = analysis.get('time_sinks', [])
    formalization = analysis.get('formalization_candidates', [])
    time_patterns = analysis.get('summary_stats', {}).get('time_patterns', {})
    pattern_shifts = analysis.get('pattern_shifts', {})

    # Recommendation from repeated failures (skip vague labels)
    repeated = [f for f in failures if f.get('repeated') and f['label'].lower() not in ('miscellaneous friction', 'general bugs')]
    if repeated:
        top_failure = repeated[0]
        top_evidence = top_failure['evidence'][0] if top_failure['evidence'] else 'multiple issues'
        label = top_failure['label']
        dates = ', '.join(top_failure['dates'])

        # Customize advice based on failure type
        if 'rate limit' in label.lower():
            advice = (f"**Mitigate {label.lower()}** — Hit on {dates}. "
                      f"Consider batching heavy coding sessions, scheduling Claude Code work "
                      f"for off-peak hours, or splitting tasks across sessions to avoid hitting limits mid-flow.")
        else:
            advice = (f"**Fix {label}** — Appeared on {dates}. "
                      f"Root cause: {top_evidence[:120]}. "
                      f"Invest time to fix the underlying issue rather than patching repeatedly.")

        recs.append({
            'text': advice,
            'source': 'repeated_failure',
            'evidence': top_failure['evidence'][:2],
        })

    # Recommendation from unfinished threads
    if len(unfinished) > 3:
        recs.append({
            'text': f"**Clear the backlog** — {len(unfinished)} open threads carried over. "
                    f"Before starting new work next week, triage these: keep what's still relevant, drop what's stale, schedule what matters.",
            'source': 'unfinished_threads',
            'evidence': [u['text'] for u in unfinished[:3]],
        })

    # Recommendation from formalization candidates
    if formalization:
        top_candidate = formalization[0]
        recs.append({
            'text': f"**Formalize the {top_candidate['topic']} workflow** — {top_candidate['reason']}. "
                    f"Writing it down once prevents re-deciding it every session.",
            'source': 'formalization',
            'evidence': [],
        })

    # Recommendation from time patterns
    if time_patterns.get('late_night', 0) >= 2:
        recs.append({
            'text': "**Watch the late-night pattern** — Multiple sessions ran past midnight this week. "
                    "High-quality decisions and code reviews are harder when fatigued. "
                    "Consider batching complex work for morning sessions.",
            'source': 'time_pattern',
            'evidence': [],
        })

    # Recommendation from unaddressed prior recommendations
    unaddressed = pattern_shifts.get('unaddressed_recommendations', [])
    if unaddressed:
        rec = unaddressed[0]
        recs.append({
            'text': f"**Revisit prior recommendation** — \"{rec['recommendation']}\" was suggested "
                    f"{rec.get('weeks_ago', '?')} weeks ago but hasn't been addressed. "
                    f"Either do it, delegate it, or explicitly drop it.",
            'source': 'unaddressed_prior',
            'evidence': [],
        })

    # Recommendation from dominant time sink
    if time_sinks and time_sinks[0]['content_share'] > 0.35:
        sink = time_sinks[0]
        recs.append({
            'text': f"**Diversify focus** — {sink['topic']} consumed {sink['content_share']*100:.0f}% of this week's attention. "
                    f"Consider whether that concentration was intentional or a sign of scope creep.",
            'source': 'time_sink',
            'evidence': [],
        })

    # Take best 3
    return recs[:3]


def format_recommendations(recs):
    """Format recommendations as numbered list."""
    if not recs:
        return "*No specific recommendations — solid week across the board.*"

    lines = []
    for i, rec in enumerate(recs, 1):
        lines.append(f"{i}. {rec['text']}")
        if rec.get('evidence'):
            for ev in rec['evidence'][:2]:
                lines.append(f"   - _{ev}_")
        lines.append('')

    return '\n'.join(lines).strip()


def format_week_score(score, analysis):
    """Format the Week Score section with justification."""
    stats = analysis.get('summary_stats', {})
    accomplishments = analysis.get('accomplishments', [])
    failures = analysis.get('failures', [])
    active_days = stats.get('active_days', 0)

    total_wins = sum(a['count'] for a in accomplishments)
    repeated_failures = sum(1 for f in failures if f.get('repeated'))

    # Build justification
    parts = []
    if total_wins > 15:
        parts.append(f"exceptional output ({total_wins} accomplishments)")
    elif total_wins > 8:
        parts.append(f"strong output ({total_wins} accomplishments)")
    else:
        parts.append(f"moderate output ({total_wins} accomplishments)")

    if repeated_failures > 0:
        parts.append(f"dragged by {repeated_failures} recurring issue(s)")

    if active_days >= 6:
        parts.append(f"consistent {active_days}-day engagement")

    # Capitalize first letter of each sentence
    justification = '. '.join(p[0].upper() + p[1:] if p else p for p in parts) + '.'

    # Score emoji
    if score >= 8:
        emoji = '🟢'
    elif score >= 6:
        emoji = '🟡'
    elif score >= 4:
        emoji = '🟠'
    else:
        emoji = '🔴'

    return f"**{emoji} {score}/10**\n\n{justification}"


def format_longitudinal(pattern_shifts):
    """Format the Longitudinal section (only when history exists)."""
    if not pattern_shifts.get('has_history'):
        return None  # Don't include section at all

    lines = []
    prior_count = pattern_shifts.get('prior_retro_count', 0)
    lines.append(f"*Based on {prior_count} prior retrospective(s).*")
    lines.append('')

    # Score trend
    trend = pattern_shifts.get('score_trend')
    prior_scores = pattern_shifts.get('prior_scores', [])
    if trend and prior_scores:
        scores_str = ' → '.join(str(s) for s in prior_scores[-5:])
        trend_emoji = {'improving': '📈', 'declining': '📉', 'stable': '➡️'}.get(trend, '')
        lines.append(f"**Score trend:** {trend_emoji} {trend.title()} ({scores_str})")
        lines.append('')

    # Pattern shifts
    shifts = pattern_shifts.get('shifts', [])
    new_topics = [s for s in shifts if s['type'] == 'new']
    dropped = [s for s in shifts if s['type'] == 'dropped']

    if new_topics:
        lines.append("**New this week:**")
        for s in new_topics[:5]:
            lines.append(f"  - {s['description']}")
        lines.append('')

    if dropped:
        lines.append("**Dropped from prior weeks:**")
        for s in dropped[:5]:
            lines.append(f"  - {s['description']}")
        lines.append('')

    # Recurring friction
    recurring_friction = pattern_shifts.get('recurring_friction', [])
    if recurring_friction:
        lines.append("**Persistent friction:**")
        for rf in recurring_friction:
            lines.append(f"  - ⚠️ **{rf['label']}** — {rf['warning']}")
        lines.append('')

    # Unaddressed recommendations
    unaddressed = pattern_shifts.get('unaddressed_recommendations', [])
    if unaddressed:
        lines.append("**Unaddressed recommendations:**")
        for ua in unaddressed[:3]:
            lines.append(f"  - \"{ua['recommendation']}\" (from {ua.get('from_date', '?')})")
        lines.append('')

    return '\n'.join(lines).strip() if lines else None


def build_retrospective(analysis):
    """Build the full markdown retrospective."""
    tags = auto_tags(analysis)
    meta = analysis.get('meta', {})
    start = meta.get('start_date', 'unknown')
    end = meta.get('end_date', 'unknown')

    sections = []

    # Frontmatter
    sections.append(format_frontmatter(analysis, tags))
    sections.append('')

    # Title
    sections.append(f"# Weekly Retrospective — {start} to {end}")
    sections.append('')

    # Week at a Glance
    sections.append("## Week at a Glance")
    sections.append('')
    sections.append(format_week_at_a_glance(analysis))
    sections.append('')

    # Wins
    sections.append("## 🏆 Wins")
    sections.append('')
    sections.append(format_wins(analysis.get('accomplishments', [])))
    sections.append('')

    # Patterns
    sections.append("## 🔄 Patterns")
    sections.append('')
    time_patterns = analysis.get('summary_stats', {}).get('time_patterns', {})
    sections.append(format_patterns(
        analysis.get('recurring_themes', []),
        analysis.get('time_sinks', []),
        time_patterns,
    ))
    sections.append('')

    # Friction Points
    sections.append("## 🧱 Friction Points")
    sections.append('')
    sections.append(format_friction(analysis.get('failures', [])))
    sections.append('')

    # Unfinished Business
    sections.append("## 📋 Unfinished Business")
    sections.append('')
    sections.append(format_unfinished(analysis.get('unfinished', [])))
    sections.append('')

    # Recommendations
    sections.append("## 💡 Recommendations")
    sections.append('')
    recs = generate_recommendations(analysis)
    sections.append(format_recommendations(recs))
    sections.append('')

    # Week Score
    sections.append("## 📊 Week Score")
    sections.append('')
    score = analysis.get('suggested_week_score', 5)
    sections.append(format_week_score(score, analysis))
    sections.append('')

    # Longitudinal (only if history exists)
    longitudinal = format_longitudinal(analysis.get('pattern_shifts', {}))
    if longitudinal:
        sections.append("## 📈 Longitudinal")
        sections.append('')
        sections.append(longitudinal)
        sections.append('')

    # Footer
    sections.append("---")
    sections.append(f"*Generated by weekly-retro skill on {datetime.now().strftime('%Y-%m-%d %H:%M ET')}*")

    return '\n'.join(sections)


def main():
    parser = argparse.ArgumentParser(description='Generate markdown retrospective')
    parser.add_argument('--input', type=str, help='Path to analysis JSON (default: stdin)')
    parser.add_argument('--output', type=str, help='Output file path (default: stdout)')
    parser.add_argument('--config', type=str, help='Path to config JSON')
    args = parser.parse_args()

    # Load config
    config = {}
    config_path = args.config or os.environ.get('WEEKLY_RETRO_CONFIG', '')
    if config_path and os.path.exists(config_path):
        with open(config_path, 'r') as f:
            config = json.load(f)

    # Load analysis data
    analysis = load_stdin_or_file(args.input)

    # Build retrospective
    retro_md = build_retrospective(analysis)

    # Output
    if args.output:
        output_path = args.output
    elif config.get('vault_output_dir'):
        start = analysis.get('meta', {}).get('start_date', 'unknown')
        end = analysis.get('meta', {}).get('end_date', 'unknown')
        output_dir = config['vault_output_dir']
        os.makedirs(output_dir, exist_ok=True)
        output_path = os.path.join(output_dir, f'{start}-to-{end}.md')
    else:
        output_path = None

    if output_path:
        os.makedirs(os.path.dirname(output_path), exist_ok=True)
        Path(output_path).write_text(retro_md, encoding='utf-8')
        print(f"Retrospective written to: {output_path}", file=sys.stderr)
    else:
        print(retro_md)


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)