You are a senior code reviewer. Review the following code changes.

## Specification

No specification provided. Focus on correctness, tests, and integration.



## Issue Description

Review the code changes against this issue. Determine whether the changes address the problem or feature request described.

```markdown
## Add sprint-plan command for AI-generated sprint planning

## Summary

Add a `project-expert sprint-plan` command that generates a prioritized sprint backlog with assignment recommendations, grounded in the belief network and issue tracker state.

This is the next evolution of project-expert: from PM-style information gathering and analysis into EM-style actionable output. The belief network already performs the reasoning -- it knows who has capacity, what's blocked, what's trivial but idle, and which interventions cascade into the most unblocked recovery paths. The missing piece is an output format designed for action rather than analysis.

## Context

The agents-python belief network (10,800+ nodes) has converged on a structural diagnosis: the primary bottleneck is not technical capability but absent engineering management -- triage, assignment, and sprint enforcement. These are mostly information-processing tasks that the belief network already performs analytically:

- `list-gated` identifies 108 blockers gating 156 recovery-path beliefs
- `explore` tracks team capacity (MR velocity, assignment counts, idle periods)
- `derive` traces transitive unblocking chains (e.g. a trivial config task gating an entire compliance evidence chain)

The traditional PM/EM split is: PM gathers information and reports, EM reasons about it and decides what to do. Project-expert already collapses both roles -- scan/explore/propose/derive does gathering + reasoning in one loop. `sprint-plan` would add the final EM function: converting reasoning into weekly directives.

## Proposed Design

### Inputs

- GitLab/GitHub issue and MR state (from existing scan infrastructure)
- Belief network: blockers, gated recovery paths, team capacity signals
- Previous sprint outcomes (if available): what was planned vs completed
- Configuration: sprint length, team roster, capacity overrides

### Outputs

A structured sprint plan containing:

1. **Prioritized backlog** (5-8 items) -- issues ranked by number of downstream beliefs unblocked (from `list-gated`), technical difficulty estimate, current idle time, and whether it's on a critical path (compliance, safety, etc.)

2. **Assignment recommendations** -- each item matched to a team member with justification based on capacity signal (current MR load), skill match (past MR authorship), and bus-factor consideration (avoid deepening existing concentrations)

3. **Escalation flags** -- items requiring human authority decisions: staffing gaps, stalled conversations, cross-team dependencies

4. **Risk summary** -- what degrades if the sprint plan is ignored: approaching deadlines, compounding blockers

### Usage

```bash
project-expert sprint-plan
project-expert sprint-plan --sprint-length 2w --team-size 6 --format markdown
project-expert sprint-plan --retrospective last-sprint-outcomes.json
```

### Example Output

```
## Sprint Plan: 2026-07-01 to 2026-07-14

### Priority Items

1. GL-71 (UI config entry point) -> assign: caevans
   Trivial task, idle 3+ months, unblocks GL-72/73/74/75 + compliance evidence chain

2. GL-194 (Pathfinder triple-load fix) -> assign: rawhad
   Biggest performance bottleneck, 2 prior MRs failed, needs fresh approach

3. GL-220 (NeMo thread safety) -> assign: rgeada
   Open MR-353, needs review and merge

### Escalation Required

- GL-117/134/137: AIA compliance items stalled 90+ days, need ServiceNow expertise
- kaixu triage authority: recommended 6+ times, 80+ days overdue

### Risk If Ignored

- Credential expiry approaching with no rotation tracking
- 74% of issues remain unassigned, backlog grows monotonically
```

## Implementation Notes

- Should reuse existing `_load_network()` and issue cache infrastructure
- The LLM prompt should include the `list-gated` output and team capacity data
- Consider a `--dry-run` that shows the data that would be sent to the model
- Sprint outcomes should feed back into the belief network (e.g. "GL-71 completed" retracts the blocker and cascades)

## Relationship to Hybrid EM Model

This command is Phase 1 of the AI hybrid EM proposal. The workflow is:

1. AI generates sprint plan (`project-expert sprint-plan`)
2. Human with authority reviews and approves (~15 min/week)
3. Approved assignments are enforced through normal team processes
4. Sprint outcomes feed back into the belief network for the next cycle

See also: issue #7 (derive JSON parse error) and #8 (summary fails on large networks) -- the sprint-plan command will need to handle large networks gracefully.

```


## Code Changes

```diff
diff --git a/ftl_project_expert/cli.py b/ftl_project_expert/cli.py
index 2d2243a..e4dd6c9 100644
--- a/ftl_project_expert/cli.py
+++ b/ftl_project_expert/cli.py
@@ -18,6 +18,7 @@
     RESEARCH_PROMPT,
     build_explore_prompt,
     build_scan_prompt,
+    build_sprint_plan_prompt,
     build_summary_prompt,
 )
 from .sources import GitHubSource, GitLabSource, Issue, JiraSource
@@ -458,6 +459,7 @@ def _cache_issues(issues: list[Issue], project_dir: str) -> None:
             "author": issue.author,
             "created": issue.created,
             "updated": issue.updated,
+            "closed": issue.closed,
             "comment_count": issue.comment_count,
         }
     os.makedirs(project_dir, exist_ok=True)
@@ -2103,6 +2105,371 @@ def summary(ctx):
     _emit(ctx, result)
 
 
+# --- sprint-plan ---
+
+
+def _compute_gating_analysis(network: dict) -> list[dict]:
+    """Compute which nodes gate the most downstream work."""
+    from collections import defaultdict, deque
+
+    nodes = network.get("nodes", {})
+    if not nodes:
+        return []
+
+    dependents_map = defaultdict(set)
+    outlist_gates = defaultdict(set)
+    for k, v in nodes.items():
+        for j in v.get("justifications", []):
+            for a in j.get("antecedents", []):
+                dependents_map[a].add(k)
+            for o in j.get("outlist", []):
+                dependents_map[o].add(k)
+                outlist_gates[o].add(k)
+
+    def _transitive_count(node_id: str) -> int:
+        visited = set()
+        queue = deque([node_id])
+        while queue:
+            current = queue.popleft()
+            if current in visited:
+                continue
+            visited.add(current)
+            for dep in dependents_map.get(current, []):
+                if dep not in visited:
+                    queue.append(dep)
+        return len(visited) - 1
+
+    results = []
+    for node_id in dependents_map:
+        if node_id not in nodes:
+            continue
+        node = nodes[node_id]
+        downstream = _transitive_count(node_id)
+        if downstream == 0:
+            continue
+        results.append({
+            "id": node_id,
+            "text": node.get("text", ""),
+            "truth_value": node.get("truth_value", "?"),
+            "downstream_count": downstream,
+            "gated_conclusions": list(outlist_gates.get(node_id, [])),
+        })
+
+    results.sort(key=lambda x: x["downstream_count"], reverse=True)
+    return results
+
+
+def _compute_team_signals(cached_issues: dict) -> dict:
+    """Infer team composition and capacity from cached issues."""
+    from collections import defaultdict
+
+    team = defaultdict(lambda: {
+        "open": 0, "closed_recent": 0, "total": 0,
+        "priorities": defaultdict(int),
+    })
+    total_open = 0
+    unassigned_open = 0
+    now = datetime.now()
+
+    issues = cached_issues.get("issues", cached_issues)
+    if isinstance(issues, dict):
+        issue_list = list(issues.values()) if issues else []
+    elif isinstance(issues, list):
+        issue_list = issues
+    else:
+        issue_list = []
+
+    for issue in issue_list:
+        state = (issue.get("state") or "").lower()
+        assignees = issue.get("assignees") or []
+        priority = issue.get("priority") or "none"
+        is_open = state in ("open", "opened", "to do", "in progress", "new")
+
+        if is_open:
+            total_open += 1
+
+        if not assignees and is_open:
+            unassigned_open += 1
+
+        is_recent_close = False
+        if not is_open:
+            closed_str = issue.get("closed") or issue.get("updated") or ""
+            if closed_str:
+                try:
+                    closed_dt = datetime.fromisoformat(closed_str.replace("Z", "+00:00"))
+                    is_recent_close = (now - closed_dt.replace(tzinfo=None)).days <= 30
+                except (ValueError, TypeError):
+                    pass
+
+        for assignee in assignees:
+            name = assignee if isinstance(assignee, str) else assignee.get("login", assignee.get("name", "unknown"))
+            team[name]["total"] += 1
+            if is_open:
+                team[name]["open"] += 1
+            if is_recent_close:
+                team[name]["closed_recent"] += 1
+            team[name]["priorities"][priority] += 1
+
+    members = []
+    for name, data in sorted(team.items(), key=lambda x: x[1]["open"], reverse=True):
+        members.append({
+            "name": name,
+            "open_issues": data["open"],
+            "closed_recent": data["closed_recent"],
+            "total": data["total"],
+            "priorities": dict(data["priorities"]),
+        })
+
+    return {
+        "team_members": members,
+        "inferred_team_size": len(members),
+        "total_open": total_open,
+        "unassigned_open": unassigned_open,
+    }
+
+
+def _format_gating_section(gating_analysis: list[dict], max_items: int = 30) -> str:
+    if not gating_analysis:
+        return "No gating analysis available."
+    lines = []
+    for item in gating_analysis[:max_items]:
+        gated = ", ".join(item["gated_conclusions"][:5]) if item["gated_conclusions"] else "indirect"
+        lines.append(
+            f"- `{item['id']}` [{item['truth_value']}] "
+            f"(downstream: {item['downstream_count']}) — {item['text'][:120]}"
+        )
+        if item["gated_conclusions"]:
+            lines.append(f"  Gates: {gated}")
+    total = len(gating_analysis)
+    if total > max_items:
+        lines.append(f"\n({total - max_items} more gating nodes omitted)")
+    return "\n".join(lines)
+
+
+def _format_team_section(team_signals: dict) -> str:
+    if not team_signals["team_members"]:
+        return "No team data available (no assignees found in issues)."
+    lines = [
+        f"Total open issues: {team_signals['total_open']} "
+        f"({team_signals['unassigned_open']} unassigned)",
+        "",
+    ]
+    for m in team_signals["team_members"]:
+        prio_str = ", ".join(f"{k}: {v}" for k, v in m["priorities"].items() if k != "none")
+        lines.append(
+            f"- **{m['name']}**: {m['open_issues']} open, "
+            f"{m['closed_recent']} closed (last 30d)"
+            + (f" | priorities: {prio_str}" if prio_str else "")
+        )
+    return "\n".join(lines)
+
+
+def _format_backlog_section(
+    cached_issues: dict,
+    gating_analysis: list[dict],
+    max_items: int = 40,
+) -> str:
+    issues = cached_issues.get("issues", cached_issues)
+    if isinstance(issues, dict):
+        issue_list = list(issues.values()) if issues else []
+    elif isinstance(issues, list):
+        issue_list = issues
+    else:
+        issue_list = []
+
+    # Build word-boundary patterns for cached issue IDs
+    cached_ids = {str(issue.get("id", "")) for issue in issue_list}
+    id_patterns = {}
+    for cid in cached_ids:
+        if cid:
+            id_patterns[cid] = re.compile(r"\b" + re.escape(cid) + r"\b")
+
+    # Build a map from issue tracker IDs to max belief downstream count
+    issue_belief_impact = {}
+    for g in gating_analysis:
+        text = g["text"]
+        for cached_id, pattern in id_patterns.items():
+            if pattern.search(text):
+                existing = issue_belief_impact.get(cached_id, 0)
+                issue_belief_impact[cached_id] = max(existing, g["downstream_count"])
+
+    priority_order = {"critical": 0, "highest": 1, "high": 2, "medium": 3, "low": 4}
+
+    open_issues = []
+    for issue in issue_list:
+        state = (issue.get("state") or "").lower()
+        if state not in ("open", "opened", "to do", "in progress", "new"):
+            continue
+
+        issue_id = str(issue.get("id", ""))
+        belief_impact = issue_belief_impact.get(issue_id, 0)
+
+        prio = (issue.get("priority") or "medium").lower()
+        prio_rank = priority_order.get(prio, 3)
+
+        open_issues.append({
+            "issue": issue,
+            "priority_rank": prio_rank,
+            "belief_impact": belief_impact,
+        })
+
+    open_issues.sort(key=lambda x: (-x["belief_impact"], x["priority_rank"]))
+
+    if not open_issues:
+        return "No open issues found in cache."
+
+    lines = []
+    for item in open_issues[:max_items]:
+        issue = item["issue"]
+        assignees = issue.get("assignees") or []
+        assignee_str = ", ".join(assignees) if assignees else "unassigned"
+        prio = issue.get("priority") or "—"
+        title = (issue.get("title") or "")[:100]
+        issue_id = issue.get("id", "?")
+        milestone = issue.get("milestone") or ""
+        updated = (issue.get("updated") or "")[:10]
+        impact = item["belief_impact"]
+
+        line = f"- **{issue_id}**: {title}"
+        line += f"\n  Priority: {prio} | Assignee: {assignee_str} | Updated: {updated}"
+        if impact > 0:
+            line += f" | Belief impact: {impact} downstream"
+        if milestone:
+            line += f" | Milestone: {milestone}"
+        lines.append(line)
+
+    total = len(open_issues)
+    if total > max_items:
+        lines.append(f"\n({total - max_items} more open issues omitted)")
+    return "\n".join(lines)
+
+
+@cli.command("sprint-plan")
+@click.option("--sprint-length", default="2w",
+              help="Sprint length (e.g. 1w, 2w, 3w)")
+@click.option("--team-size", type=int, default=None,
+              help="Override team size (default: inferred from issue data)")
+@click.option("--dry-run", is_flag=True, default=False,
+              help="Show data that would be sent to the LLM without invoking it")
+@click.option("--output", "-o", default=None,
+              help="Write output to file instead of stdout")
+@click.pass_context
+def sprint_plan(ctx, sprint_length, team_size, dry_run, output):
+    """Generate a prioritized sprint plan from beliefs and issues."""
+    config = _load_config()
+    if not config:
+        click.echo("Not initialized. Run: project-expert init <platform> <target>")
+        sys.exit(1)
+
+    model = ctx.obj["model"]
+    timeout = ctx.obj["timeout"]
+
+    if not dry_run and not check_model_available(model):
+        click.echo(f"Error: Model '{model}' CLI not available", err=True)
+        sys.exit(1)
+
+    # Load network and compute gating analysis
+    network = _load_network()
+    nodes = network.get("nodes", {})
+    gating_analysis = _compute_gating_analysis(network) if nodes else []
+
+    if gating_analysis:
+        click.echo(
+            f"Gating analysis: {len(gating_analysis)} nodes with downstream impact",
+            err=True,
+        )
+    else:
+        click.echo("WARN: No belief network available for gating analysis", err=True)
+
+    # Load cached issues and compute team signals
+    project_dir = _get_project_dir()
+    cached_issues = _load_cached_issues(project_dir)
+    team_signals = _compute_team_signals(cached_issues) if cached_issues else {
+        "team_members": [], "inferred_team_size": 0,
+        "total_open": 0, "unassigned_open": 0,
+    }
+
+    if cached_issues:
+        click.echo(
+            f"Issues: {team_signals['total_open']} open "
+            f"({team_signals['unassigned_open']} unassigned), "
+            f"{team_signals['inferred_team_size']} team members detected",
+            err=True,
+        )
+    else:
+        click.echo("WARN: No cached issues. Run: project-expert scan", err=True)
+
+    effective_team_size = team_size or team_signals["inferred_team_size"] or 3
+
+    # Load top beliefs by impact
+    beliefs_section = ""
+    max_beliefs = 100
+    if _has_reasons() and Path("reasons.db").exists():
+        result = subprocess.run(
+            ["reasons", "list", "--status", "IN", "--by-impact"],
+            capture_output=True, text=True,
+        )
+        if result.returncode == 0 and result.stdout.strip():
+            lines = [l for l in result.stdout.splitlines() if l.strip()]
+            beliefs_section = "\n".join(lines[:max_beliefs])
+            click.echo(f"Beliefs: using top {min(len(lines), max_beliefs)} of {len(lines)} IN beliefs", err=True)
+    if not beliefs_section and Path("beliefs.md").exists():
+        full_text = Path("beliefs.md").read_text()
+        sections = re.split(r"(?=^### \S+)", full_text, flags=re.MULTILINE)
+        sections = [s for s in sections if s.strip().startswith("###")]
+        beliefs_section = "\n".join(sections[:max_beliefs])
+
+    if not beliefs_section:
+        beliefs_section = "No beliefs available."
+
+    # Build prompt sections
+    gating_section = _format_gating_section(gating_analysis)
+    team_section = _format_team_section(team_signals)
+    backlog_section = _format_backlog_section(cached_issues, gating_analysis)
+
+    project_name = config.get("repo", config.get("project", "unknown"))
+
+    prompt = build_sprint_plan_prompt(
+        project_name=project_name,
+        sprint_length=sprint_length,
+        team_size=effective_team_size,
+        gating_section=gating_section,
+        team_section=team_section,
+        backlog_section=backlog_section,
+        beliefs_section=beliefs_section,
+    )
+
+    if dry_run:
+        prompt_size_kb = len(prompt.encode()) / 1024
+        click.echo(f"\n=== Sprint Plan Prompt ({prompt_size_kb:.0f} KB) ===\n")
+        click.echo(prompt[:5000])
+        if len(prompt) > 5000:
+            click.echo(f"\n... ({len(prompt) - 5000} more chars)")
+        return
+
+    click.echo(f"Generating sprint plan with {model}...", err=True)
+
+    prompt_size_kb = len(prompt.encode()) / 1024
+    try:
+        result = asyncio.run(invoke(prompt, model, timeout=timeout))
+    except Exception as e:
+        click.echo(
+            f"Error: Model {model} failed (prompt size: {prompt_size_kb:.0f} KB): {e}",
+            err=True,
+        )
+        sys.exit(1)
+
+    short_name = project_name.split("//")[-1] if "//" in project_name else project_name
+    safe_name = short_name.replace("/", "-")
+    _create_entry(f"sprint-plan-{safe_name}", f"Sprint Plan: {project_name} ({sprint_length})", result)
+
+    if output:
+        Path(output).write_text(result)
+        click.echo(f"Wrote sprint plan to {output}", err=True)
+
+    _emit(ctx, result)
+
+
 # --- status ---
 
 
diff --git a/ftl_project_expert/prompts/__init__.py b/ftl_project_expert/prompts/__init__.py
index d0a9d17..686c648 100644
--- a/ftl_project_expert/prompts/__init__.py
+++ b/ftl_project_expert/prompts/__init__.py
@@ -4,6 +4,7 @@
 from .explore import build_explore_prompt
 from .propose import PROPOSE_BELIEFS_PROJECT
 from .research import RESEARCH_PROMPT
+from .sprint_plan import build_sprint_plan_prompt
 from .summary import build_summary_prompt
 
 __all__ = [
@@ -11,5 +12,6 @@
     "build_explore_prompt",
     "PROPOSE_BELIEFS_PROJECT",
     "RESEARCH_PROMPT",
+    "build_sprint_plan_prompt",
     "build_summary_prompt",
 ]
diff --git a/ftl_project_expert/prompts/sprint_plan.py b/ftl_project_expert/prompts/sprint_plan.py
new file mode 100644
index 0000000..e799a51
--- /dev/null
+++ b/ftl_project_expert/prompts/sprint_plan.py
@@ -0,0 +1,65 @@
+"""Sprint plan prompt — generate prioritized sprint backlog from beliefs and issues."""
+
+
+def build_sprint_plan_prompt(
+    project_name: str,
+    sprint_length: str,
+    team_size: int,
+    gating_section: str,
+    team_section: str,
+    backlog_section: str,
+    beliefs_section: str,
+) -> str:
+    return f"""You are a senior engineering manager creating a sprint plan grounded in project data and a belief network.
+
+## Project: {project_name}
+## Sprint length: {sprint_length}
+## Team size: {team_size}
+
+## Gated Items (blocking the most downstream work)
+
+{gating_section}
+
+## Team Capacity Signals
+
+{team_section}
+
+## Open Issues (ranked by impact)
+
+{backlog_section}
+
+## Belief Network Context (top beliefs by impact)
+
+{beliefs_section}
+
+## Instructions
+
+Generate a sprint plan with these sections:
+
+1. **Sprint Goal** — A one-sentence sprint goal that captures the highest-impact theme.
+
+2. **Prioritized Backlog** (5-8 items) — Ranked by downstream beliefs unblocked and practical impact.
+   For each item:
+   - Issue ID and title
+   - Why it is prioritized (which beliefs or conclusions does resolving it unblock)
+   - Estimated effort (S/M/L based on issue complexity signals)
+   - Suggested assignee with justification (capacity, expertise, bus-factor)
+
+3. **Assignment Recommendations** — Who should work on what and why.
+   Reference specific capacity signals (open issue count, recent velocity, expertise areas).
+   Flag bus-factor risks where one person owns too much.
+
+4. **Escalation Flags** — Items needing human authority decisions:
+   - Unassigned critical/high-priority items
+   - Stale blockers (no activity in 30+ days)
+   - Cross-team dependencies
+   - Staffing or re-prioritization decisions
+   Be specific about what decision is needed and who should make it.
+
+5. **Risk Summary** — Top 3-5 risks for this sprint:
+   - What could go wrong
+   - Likelihood signal from the data
+   - Mitigation suggestion
+
+Be concrete — reference issue IDs, belief IDs, team member names, and specific data points from the sections above. Do not invent information not present in the data.
+"""

```

## Observation Results

You previously requested observations. Here are the results:

```json
{
  "issue_class_body": {
    "error": "File not found: ftl_project_expert/sources.py",
    "file": "ftl_project_expert/sources.py"
  },
  "load_network_body": {
    "function": "_load_network",
    "file": "ftl_project_expert/cli.py",
    "start_line": 1653,
    "end_line": 1680,
    "source": "def _load_network() -> dict:\n    \"\"\"Load network.json (exported from reasons).\"\"\"\n    network_path = Path(\"network.json\")\n    if not network_path.exists():\n        if _has_reasons():\n            result = subprocess.run(\n                [\"reasons\", \"export\", \"-o\", str(network_path)],\n                capture_output=True, text=True,\n            )\n            if result.returncode != 0:\n                return {\"nodes\": {}}\n        else:\n            return {\"nodes\": {}}\n    try:\n        return json.loads(network_path.read_text())\n    except (json.JSONDecodeError, ValueError, FileNotFoundError):\n        click.echo(f\"WARN: {network_path} is corrupt, empty, or missing, re-exporting\", err=True)\n        if _has_reasons():\n            result = subprocess.run(\n                [\"reasons\", \"export\", \"-o\", str(network_path)],\n                capture_output=True, text=True,\n            )\n            if result.returncode == 0:\n                try:\n                    return json.loads(network_path.read_text())\n                except (json.JSONDecodeError, ValueError, FileNotFoundError):\n                    pass\n        return {\"nodes\": {}}"
  },
  "load_cached_issues_body": {
    "function": "_load_cached_issues",
    "file": "ftl_project_expert/cli.py",
    "start_line": 470,
    "end_line": 476,
    "source": "def _load_cached_issues(project_dir: str) -> dict:\n    \"\"\"Load cached issues.\"\"\"\n    cache_path = os.path.join(project_dir, \"issues-cache.json\")\n    if not os.path.isfile(cache_path):\n        return {}\n    with open(cache_path) as f:\n        return json.load(f)"
  },
  "create_entry_body": {
    "function": "_create_entry",
    "file": "ftl_project_expert/cli.py",
    "start_line": 87,
    "end_line": 109,
    "source": "def _create_entry(topic: str, title: str, content: str) -> None:\n    # Add HHMM timestamp so multiple scans on the same day don't collide\n    timestamp = datetime.now().strftime(\"%H%M\")\n    entry_name = f\"{topic}-{timestamp}\"\n    try:\n        result = subprocess.run(\n            [\"entry\", \"create\", entry_name, title, \"--content\", content],\n            capture_output=True, text=True,\n        )\n        if result.returncode == 0:\n            click.echo(f\"Entry: {result.stdout.strip()}\", err=True)\n        else:\n            result = subprocess.run(\n                [\"entry\", \"create\", entry_name, title],\n                input=content,\n                capture_output=True, text=True,\n            )\n            if result.returncode == 0:\n                click.echo(f\"Entry: {result.stdout.strip()}\", err=True)\n            else:\n                click.echo(f\"WARN: entry create failed: {result.stderr.strip()}\", err=True)\n    except FileNotFoundError:\n        click.echo(\"WARN: entry CLI not found. Install with: uv tool install ftl-entry\", err=True)"
  },
  "emit_body": {
    "function": "_emit",
    "file": "ftl_project_expert/cli.py",
    "start_line": 82,
    "end_line": 84,
    "source": "def _emit(ctx, text: str) -> None:\n    if not ctx.obj.get(\"quiet\"):\n        click.echo(text)"
  },
  "has_reasons_body": {
    "function": "_has_reasons",
    "file": "ftl_project_expert/cli.py",
    "start_line": 121,
    "end_line": 122,
    "source": "def _has_reasons() -> bool:\n    return shutil.which(\"reasons\") is not None"
  },
  "invoke_raises": {
    "function": "invoke",
    "file": "ftl_project_expert/cli.py",
    "explicit_raises": [],
    "calls": [],
    "error": "Function 'invoke' not found"
  },
  "cli_imports": {
    "file": "ftl_project_expert/cli.py",
    "imports": [
      "asyncio",
      "json",
      "os",
      "re",
      "shutil",
      "subprocess",
      "sys",
      "click"
    ],
    "from_imports": [
      {
        "module": "datetime",
        "names": [
          "date",
          "datetime"
        ]
      },
      {
        "module": "pathlib",
        "names": [
          "Path"
        ]
      },
      {
        "module": "llm",
        "names": [
          "check_model_available",
          "invoke",
          "invoke_concurrent_sync",
          "invoke_sync"
        ]
      },
      {
        "module": "prompts",
        "names": [
          "PROPOSE_BELIEFS_PROJECT",
          "RESEARCH_PROMPT",
          "build_explore_prompt",
          "build_scan_prompt",
          "build_sprint_plan_prompt",
          "build_summary_prompt"
        ]
      },
      {
        "module": "sources",
        "names": [
          "GitHubSource",
          "GitLabSource",
          "Issue",
          "JiraSource"
        ]
      },
      {
        "module": "topics",
        "names": [
          "Topic",
          "add_topics",
          "load_queue",
          "parse_topics_from_response",
          "pending_count",
          "pop_at",
          "pop_multiple",
          "pop_next",
          "skip_topic"
        ]
      }
    ],
    "import_section": "\"\"\"Command-line interface for project expert.\"\"\"\n\nimport asyncio\nimport json\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nfrom datetime import date, datetime\nfrom pathlib import Path\n\nimport click\n\nfrom .llm import check_model_available, invoke, invoke_concurrent_sync, invoke_sync\nfrom .prompts import (\n    PROPOSE_BELIEFS_PROJECT,\n    RESEARCH_PROMPT,\n    build_explore_prompt,\n    build_scan_prompt,\n    build_sprint_plan_prompt,\n    build_summary_prompt,\n)\nfrom .sources import GitHubSource, GitLabSource, Issue, JiraSource\nfrom .topics import (\n    Topic,\n    add_topics,\n    load_queue,\n    parse_topics_from_response,\n    pending_count,\n    pop_at,\n    pop_multiple,\n    pop_next,\n    skip_topic,\n)\n\nPROJECT_DIR = \".project-expert\"\n\n\n# --- Config helpers ---\n\n\ndef _load_config() -> dict | None:\n    config_path = Path.cwd() / PROJECT_DIR / \"config.json\"\n    if config_path.is_file():\n        return json.loads(config_path.read_text())\n    return None\n\n\ndef _save_config(config: dict) -> None:\n    config_dir = Path.cwd() / PROJECT_DIR\n    config_dir.mkdir(parents=True, exist_ok=True)\n    (config_dir / \"config.json\").write_text(json.dumps(config, indent=2))\n\n\ndef _get_project_dir() -> str:\n    return str(Path.cwd() / PROJECT_DIR)\n\n\n# --- Source helpers ---\n\n\ndef _get_source(config: dict) -> GitHubSource | GitLabSource | JiraSource:\n    \"\"\"Create the appropriate source from config.\"\"\""
  },
  "sprint_plan_tests": {
    "error": "Parameter error: related_test_files() missing 1 required positional argument: 'file_path'",
    "params_received": [
      "source_file"
    ]
  },
  "sprint_plan_prompt_tests": {
    "error": "Parameter error: related_test_files() missing 1 required positional argument: 'file_path'",
    "params_received": [
      "source_file"
    ]
  },
  "cache_issues_body": {
    "function": "_cache_issues",
    "file": "ftl_project_expert/cli.py",
    "start_line": 436,
    "end_line": 467,
    "source": "def _cache_issues(issues: list[Issue], project_dir: str) -> None:\n    \"\"\"Cache fetched issues so explore can reference them without re-fetching.\"\"\"\n    cache_path = os.path.join(project_dir, \"issues-cache.json\")\n    data = {}\n    if os.path.isfile(cache_path):\n        with open(cache_path) as f:\n            data = json.load(f)\n    for issue in issues:\n        data[issue.id] = {\n            \"id\": issue.id,\n            \"title\": issue.title,\n            \"url\": issue.url,\n            \"platform\": issue.platform,\n            \"body\": issue.body,\n            \"state\": issue.state,\n            \"labels\": issue.labels,\n            \"assignees\": issue.assignees,\n            \"milestone\": issue.milestone,\n            \"priority\": issue.priority,\n            \"issue_type\": issue.issue_type,\n            \"parent\": issue.parent,\n            \"children\": issue.children,\n            \"linked\": issue.linked,\n            \"author\": issue.author,\n            \"created\": issue.created,\n            \"updated\": issue.updated,\n            \"closed\": issue.closed,\n            \"comment_count\": issue.comment_count,\n        }\n    os.makedirs(project_dir, exist_ok=True)\n    with open(cache_path, \"w\") as f:\n        json.dump(data, f, indent=2)"
  },
  "check_model_available_body": {
    "error": "No function found at 'check_model_available'",
    "file": "ftl_project_expert/cli.py"
  },
  "summary_command_body": {
    "function": "summary",
    "file": "ftl_project_expert/cli.py",
    "start_line": 2019,
    "end_line": 2105,
    "source": "@cli.command()\n@click.pass_context\ndef summary(ctx):\n    \"\"\"Synthesize a project summary from beliefs.\"\"\"\n    config = _load_config()\n    if not config:\n        click.echo(\"Not initialized. Run: project-expert init <platform> <target>\")\n        sys.exit(1)\n\n    model = ctx.obj[\"model\"]\n    timeout = ctx.obj[\"timeout\"]\n\n    if not check_model_available(model):\n        click.echo(f\"Error: Model '{model}' CLI not available\", err=True)\n        sys.exit(1)\n\n    # Read beliefs from reasons or beliefs.md\n    max_beliefs = 500\n    beliefs_text = \"\"\n    belief_count = 0\n    total_count = 0\n    sorted_by_impact = False\n\n    if _has_reasons() and Path(\"reasons.db\").exists():\n        result = subprocess.run(\n            [\"reasons\", \"list\", \"--status\", \"IN\", \"--by-impact\"],\n            capture_output=True, text=True,\n        )\n        if result.returncode == 0 and result.stdout.strip():\n            lines = [l for l in result.stdout.splitlines() if l.strip()]\n            total_count = len(lines)\n            if total_count > max_beliefs:\n                lines = lines[:max_beliefs]\n            beliefs_text = \"\\n\".join(lines)\n            belief_count = len(lines)\n            sorted_by_impact = True\n    elif Path(\"beliefs.md\").exists():\n        full_text = Path(\"beliefs.md\").read_text()\n        sections = re.split(r\"(?=^### \\S+)\", full_text, flags=re.MULTILINE)\n        sections = [s for s in sections if s.strip().startswith(\"###\")]\n        total_count = len(sections)\n        if total_count > max_beliefs:\n            sections = sections[:max_beliefs]\n        beliefs_text = \"\\n\".join(sections)\n        belief_count = len(sections)\n\n    if not beliefs_text or belief_count == 0:\n        click.echo(\"No beliefs found. Run the pipeline first:\")\n        click.echo(\"  project-expert scan\")\n        click.echo(\"  project-expert propose-beliefs\")\n        click.echo(\"  project-expert accept-beliefs\")\n        sys.exit(1)\n\n    if total_count > max_beliefs:\n        order = \"by impact\" if sorted_by_impact else \"by file order\"\n        click.echo(\n            f\"Summarizing top {belief_count} of {total_count} beliefs ({order}) with {model}...\",\n            err=True,\n        )\n    else:\n        click.echo(f\"Summarizing {belief_count} beliefs with {model}...\", err=True)\n\n    project_name = config.get(\"repo\", config.get(\"project\", \"unknown\"))\n\n    prompt = build_summary_prompt(\n        beliefs_text=beliefs_text,\n        project_name=project_name,\n        belief_count=belief_count,\n        total_count=total_count,\n        sorted_by_impact=sorted_by_impact,\n    )\n\n    prompt_size_kb = len(prompt.encode()) / 1024\n    try:\n        result = asyncio.run(invoke(prompt, model, timeout=timeout))\n    except Exception as e:\n        click.echo(\n            f\"Error: Model {model} failed (prompt size: {prompt_size_kb:.0f} KB): {e}\",\n            err=True,\n        )\n        sys.exit(1)\n\n    short_name = project_name.split(\"//\")[-1] if \"//\" in project_name else project_name\n    safe_name = short_name.replace(\"/\", \"-\")\n    _create_entry(f\"summary-{safe_name}\", f\"Summary: {project_name}\", result)\n\n    _emit(ctx, result)"
  },
  "build_sprint_plan_prompt_usages": {
    "symbol": "build_sprint_plan_prompt",
    "usages": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 21,
        "text": "build_sprint_plan_prompt,"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2432,
        "text": "prompt = build_sprint_plan_prompt("
      },
      {
        "file": "ftl_project_expert/prompts/sprint_plan.py",
        "line": 4,
        "text": "def build_sprint_plan_prompt("
      },
      {
        "file": "ftl_project_expert/prompts/__init__.py",
        "line": 7,
        "text": "from .sprint_plan import build_sprint_plan_prompt"
      },
      {
        "file": "ftl_project_expert/prompts/__init__.py",
        "line": 15,
        "text": "\"build_sprint_plan_prompt\","
      }
    ],
    "production_usages": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 21,
        "text": "build_sprint_plan_prompt,"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2432,
        "text": "prompt = build_sprint_plan_prompt("
      },
      {
        "file": "ftl_project_expert/prompts/sprint_plan.py",
        "line": 4,
        "text": "def build_sprint_plan_prompt("
      },
      {
        "file": "ftl_project_expert/prompts/__init__.py",
        "line": 7,
        "text": "from .sprint_plan import build_sprint_plan_prompt"
      },
      {
        "file": "ftl_project_expert/prompts/__init__.py",
        "line": 15,
        "text": "\"build_sprint_plan_prompt\","
      }
    ],
    "test_usages": [],
    "production_count": 5,
    "test_count": 0,
    "total_count": 5
  },
  "issue_closed_usages": {
    "symbol": "closed",
    "usages": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 462,
        "text": "\"closed\": issue.closed,"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1062,
        "text": "they are actually closed, done, or merged."
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2167,
        "text": "\"open\": 0, \"closed_recent\": 0, \"total\": 0,"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2196,
        "text": "closed_str = issue.get(\"closed\") or issue.get(\"updated\") or \"\""
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2197,
        "text": "if closed_str:"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2199,
        "text": "closed_dt = datetime.fromisoformat(closed_str.replace(\"Z\", \"+00:00\"))"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2200,
        "text": "is_recent_close = (now - closed_dt.replace(tzinfo=None)).days <= 30"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2210,
        "text": "team[name][\"closed_recent\"] += 1"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2218,
        "text": "\"closed_recent\": data[\"closed_recent\"],"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2261,
        "text": "f\"{m['closed_recent']} closed (last 30d)\""
      },
      {
        "file": "ftl_project_expert/prompts/research.py",
        "line": 36,
        "text": "that is now closed, merged, or resolved?"
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 17,
        "text": "if state_label in (\"closed\", \"all\"):"
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 18,
        "text": "state_instructions = _CLOSED_INSTRUCTIONS if state_label == \"closed\" else _ALL_INSTRUCTIONS"
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 71,
        "text": "Analyze these **closed/resolved** issues and provide:"
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 77,
        "text": "5. **Resolution Quality** \u2014 Are issues being closed with MRs? Are they well-tested? Any re-opened is"
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 86,
        "text": "10. **Resolution Verification** \u2014 For closed issues, does the linked PR's diff actually address the "
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 89,
        "text": "Analyze these issues (both open and closed) and provide:"
      },
      {
        "file": "ftl_project_expert/sources/models.py",
        "line": 29,
        "text": "state: str = \"\"      # \"open\", \"merged\", \"closed\""
      },
      {
        "file": "ftl_project_expert/sources/models.py",
        "line": 108,
        "text": "state: str = \"\"      # \"open\", \"closed\", \"in_progress\", etc."
      },
      {
        "file": "ftl_project_expert/sources/models.py",
        "line": 124,
        "text": "closed: str = \"\""
      },
      {
        "file": "ftl_project_expert/sources/gitlab.py",
        "line": 59,
        "text": "if state == \"closed\":"
      },
      {
        "file": "ftl_project_expert/sources/gitlab.py",
        "line": 60,
        "text": "cmd.append(\"--closed\")"
      },
      {
        "file": "ftl_project_expert/sources/gitlab.py",
        "line": 129,
        "text": "elif state == \"closed\":"
      },
      {
        "file": "ftl_project_expert/sources/gitlab.py",
        "line": 130,
        "text": "cmd.append(\"--closed\")"
      },
      {
        "file": "ftl_project_expert/sources/gitlab.py",
        "line": 247,
        "text": "closed=raw.get(\"closed_at\", \"\") or \"\","
      },
      {
        "file": "ftl_project_expert/sources/jira.py",
        "line": 91,
        "text": "elif state.lower() in (\"closed\", \"done\"):"
      },
      {
        "file": "ftl_project_expert/sources/jira.py",
        "line": 241,
        "text": "closed=fields.get(\"resolutiondate\", \"\") or \"\","
      },
      {
        "file": "ftl_project_expert/sources/github.py",
        "line": 34,
        "text": "\"milestone,author,createdAt,updatedAt,closedAt,comments\","
      },
      {
        "file": "ftl_project_expert/sources/github.py",
        "line": 56,
        "text": "\"milestone,author,createdAt,updatedAt,closedAt,comments\","
      },
      {
        "file": "ftl_project_expert/sources/github.py",
        "line": 91,
        "text": "if state == \"closed\":"
      }
    ],
    "production_usages": [
      {
        "file": "ftl_project_expert/cli.py",
        "line": 462,
        "text": "\"closed\": issue.closed,"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 1062,
        "text": "they are actually closed, done, or merged."
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2167,
        "text": "\"open\": 0, \"closed_recent\": 0, \"total\": 0,"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2196,
        "text": "closed_str = issue.get(\"closed\") or issue.get(\"updated\") or \"\""
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2197,
        "text": "if closed_str:"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2199,
        "text": "closed_dt = datetime.fromisoformat(closed_str.replace(\"Z\", \"+00:00\"))"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2200,
        "text": "is_recent_close = (now - closed_dt.replace(tzinfo=None)).days <= 30"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2210,
        "text": "team[name][\"closed_recent\"] += 1"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2218,
        "text": "\"closed_recent\": data[\"closed_recent\"],"
      },
      {
        "file": "ftl_project_expert/cli.py",
        "line": 2261,
        "text": "f\"{m['closed_recent']} closed (last 30d)\""
      },
      {
        "file": "ftl_project_expert/prompts/research.py",
        "line": 36,
        "text": "that is now closed, merged, or resolved?"
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 17,
        "text": "if state_label in (\"closed\", \"all\"):"
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 18,
        "text": "state_instructions = _CLOSED_INSTRUCTIONS if state_label == \"closed\" else _ALL_INSTRUCTIONS"
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 71,
        "text": "Analyze these **closed/resolved** issues and provide:"
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 77,
        "text": "5. **Resolution Quality** \u2014 Are issues being closed with MRs? Are they well-tested? Any re-opened is"
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 86,
        "text": "10. **Resolution Verification** \u2014 For closed issues, does the linked PR's diff actually address the "
      },
      {
        "file": "ftl_project_expert/prompts/scan.py",
        "line": 89,
        "text": "Analyze these issues (both open and closed) and provide:"
      },
      {
        "file": "ftl_project_expert/sources/models.py",
        "line": 29,
        "text": "state: str = \"\"      # \"open\", \"merged\", \"closed\""
      },
      {
        "file": "ftl_project_expert/sources/models.py",
        "line": 108,
        "text": "state: str = \"\"      # \"open\", \"closed\", \"in_progress\", etc."
      },
      {
        "file": "ftl_project_expert/sources/models.py",
        "line": 124,
        "text": "closed: str = \"\""
      },
      {
        "file": "ftl_project_expert/sources/gitlab.py",
        "line": 59,
        "text": "if state == \"closed\":"
      },
      {
        "file": "ftl_project_expert/sources/gitlab.py",
        "line": 60,
        "text": "cmd.append(\"--closed\")"
      },
      {
        "file": "ftl_project_expert/sources/gitlab.py",
        "line": 129,
        "text": "elif state == \"closed\":"
      },
      {
        "file": "ftl_project_expert/sources/gitlab.py",
        "line": 130,
        "text": "cmd.append(\"--closed\")"
      },
      {
        "file": "ftl_project_expert/sources/gitlab.py",
        "line": 247,
        "text": "closed=raw.get(\"closed_at\", \"\") or \"\","
      },
      {
        "file": "ftl_project_expert/sources/jira.py",
        "line": 91,
        "text": "elif state.lower() in (\"closed\", \"done\"):"
      },
      {
        "file": "ftl_project_expert/sources/jira.py",
        "line": 241,
        "text": "closed=fields.get(\"resolutiondate\", \"\") or \"\","
      },
      {
        "file": "ftl_project_expert/sources/github.py",
        "line": 34,
        "text": "\"milestone,author,createdAt,updatedAt,closedAt,comments\","
      },
      {
        "file": "ftl_project_expert/sources/github.py",
        "line": 56,
        "text": "\"milestone,author,createdAt,updatedAt,closedAt,comments\","
      },
      {
        "file": "ftl_project_expert/sources/github.py",
        "line": 91,
        "text": "if state == \"closed\":"
      }
    ],
    "test_usages": [],
    "production_count": 125,
    "test_count": 0,
    "total_count": 125
  }
}
```

Use these results to inform your review. Do not request the same observations again.


## Instructions

For each significant change (new file, modified function, etc.), provide a structured verdict.

Use this exact format for each change:

### <file_path or file_path:function_name>
VERDICT: PASS | CONCERN | BLOCK
CORRECTNESS: VALID | QUESTIONABLE | BROKEN
SPEC_COMPLIANCE: MEETS | PARTIAL | VIOLATES | N/A
ISSUE_COMPLIANCE: ADDRESSES | PARTIAL | UNRELATED | N/A
BELIEF_COMPLIANCE: CONSISTENT | VIOLATES | N/A
TEST_COVERAGE: COVERED | PARTIAL | UNTESTED
INTEGRATION: WIRED | PARTIAL | MISSING
REASONING: <brief explanation of your assessment>
---

## Review Criteria

1. **CORRECTNESS**: Does the code do what it claims? Is the logic sound?
   - VALID: Logic is correct, no bugs apparent
   - QUESTIONABLE: Logic may have edge cases or unclear behavior
   - BROKEN: Clear bugs or incorrect behavior

2. **SPEC_COMPLIANCE**: Does it meet MUST requirements from the spec?
   - MEETS: All relevant spec requirements satisfied
   - PARTIAL: Some requirements met, others missing or incomplete
   - VIOLATES: Contradicts spec requirements
   - N/A: No spec provided or not applicable

3. **ISSUE_COMPLIANCE** (only when an issue is provided): Do the changes address the problem or feature described in the issue?
   - ADDRESSES: Changes directly solve the issue's stated problem or implement the requested feature
   - PARTIAL: Changes partially address the issue but leave some aspects unresolved
   - UNRELATED: Changes do not appear related to the issue
   - N/A: No issue provided

4. **TEST_COVERAGE**: Are there tests for the new/changed code?
   - COVERED: Tests exist and cover the changes
   - PARTIAL: Some tests exist but coverage is incomplete
   - UNTESTED: No tests for the changes

5. **INTEGRATION**: Are callers updated? Is the feature usable end-to-end?
   - WIRED: Feature is fully integrated and usable
   - PARTIAL: Interface exists but callers not updated, or integration incomplete
   - MISSING: No integration with existing code

6. **BELIEF_COMPLIANCE** (only when beliefs are provided): Do the changes respect known architectural invariants, contracts, and rules?
   - CONSISTENT: Changes align with or reinforce known beliefs
   - VIOLATES: Changes contradict a specific belief — cite the belief ID
   - N/A: No beliefs provided or no relevant beliefs apply

## Verdict Guidelines

- **BLOCK**: Security issues, broken functionality, spec violations, or missing critical integration
- **CONCERN**: Missing tests, partial integration, questionable patterns, or unclear logic
- **PASS**: Correct, tested, well-integrated code

## Important

- Full function bodies for modified functions may be available in the observations section — use them to verify the complete logic, not just the diff hunks
- Related test files (prefixed with ``related_test:``) may be included in observations — check whether existing test assertions still match modified return types, signatures, or behavior. Flag any test that would break due to the changes
- If duplicate test coverage is detected (multiple test files covering the same source), note it in your review
- Focus on actual issues, not style preferences
- If a method signature is added but callers aren't updated, that's PARTIAL integration
- Be specific in reasoning - reference line numbers or function names
- When in doubt, use CONCERN rather than PASS

## Self-Review

After completing your review, add a brief self-assessment:

### SELF_REVIEW
LIMITATIONS: <what context were you missing that affected review quality?>
---

Examples of limitations:
- "Could not see full class to verify no other methods access the modified field"
- "Test file not included in diff - cannot verify coverage claims"
- "Spec file referenced but not provided"


## Feature Requests

If this review tool could be improved to help you do a better job, suggest features:

### FEATURE_REQUESTS
- <suggestion 1>
- <suggestion 2>
---

Examples:
- "Include full file context for modified functions, not just diff hunks"
- "Show callers of modified methods to verify integration"
- "Include test file alongside implementation changes"

Only include this section if you have specific suggestions. Skip if none.
