diff --git a/src/aipass/flow/apps/handlers/dashboard/push_central.py b/src/aipass/flow/apps/handlers/dashboard/push_central.py index ca740dca..4849cd3b 100644 --- a/src/aipass/flow/apps/handlers/dashboard/push_central.py +++ b/src/aipass/flow/apps/handlers/dashboard/push_central.py @@ -9,14 +9,13 @@ """ Push to Plans Central Handler -Pushes Flow's plan data to the central PLANS.central.json file at .ai_central. +Pushes plan data for ALL branches to the central PLANS.central.json file at .ai_central. This handler follows the 3-tier logging standard (no Prax imports, no logging). Features: -- Reads fplan_registry.json to get Flow's plans -- Extracts only plans where location='flow' (Flow's own plans) -- Updates branches.flow section in PLANS.central.json -- Preserves all other branch sections +- Reads all per-type plan registries +- Groups plans by branch (derived from location path) +- Updates per-branch sections in PLANS.central.json - Calls aggregate_central_impl to rebuild top-level active_plans - Calculates global statistics across all branches - Pure handler - returns boolean for success/failure @@ -124,26 +123,29 @@ def _load_registry() -> Dict[str, Any]: return merged -def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict], List[Dict]]: - """Extract Flow's own plans from registry +def _extract_plans_by_branch(registry: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """Extract plans from registry grouped by branch. Args: - registry: The fplan_registry.json data + registry: Merged registry data (all plan types) Returns: - Tuple of (active_plans, recently_closed_plans) + Dict mapping branch_name -> branch section with active_plans, + recently_closed, and statistics. """ plans = registry.get("plans", {}) - active = [] - closed = [] + branch_buckets: Dict[str, Dict[str, List]] = {} for plan_key, plan_data in plans.items(): - # Only include plans where location is 'flow' (Flow's own plans) location = plan_data.get("location", "") - if location != str(FLOW_ROOT): + if not location: continue - # plan_key is composite PREFIX-NNNN from merged registry + branch_name = Path(location).name + + if branch_name not in branch_buckets: + branch_buckets[branch_name] = {"active": [], "closed": [], "location": location} + plan_entry = { "plan_id": plan_key, "subject": plan_data.get("subject", ""), @@ -151,24 +153,35 @@ def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict], List[Dict "created": plan_data.get("created", ""), "file_path": plan_data.get("file_path", ""), "relative_path": plan_data.get("relative_path", ""), + "branch": branch_name, } if plan_data.get("status") == "open": - active.append(plan_entry) + branch_buckets[branch_name]["active"].append(plan_entry) else: - # Add closed metadata plan_entry["closed"] = plan_data.get("closed", "") plan_entry["closed_reason"] = plan_data.get("closed_reason", "") - closed.append(plan_entry) + branch_buckets[branch_name]["closed"].append(plan_entry) - # Sort active by created date (newest first) - active.sort(key=lambda x: x.get("created", ""), reverse=True) + result: Dict[str, Dict[str, Any]] = {} + for branch_name, bucket in branch_buckets.items(): + active = sorted(bucket["active"], key=lambda x: x.get("created", ""), reverse=True) + closed = sorted(bucket["closed"], key=lambda x: x.get("closed", ""), reverse=True) + recently_closed = closed[:5] - # Sort closed by closed date (newest first) and limit to last 5 - closed.sort(key=lambda x: x.get("closed", ""), reverse=True) - recently_closed = closed[:5] + result[branch_name] = { + "branch_name": branch_name.upper(), + "branch_path": bucket["location"], + "active_plans": active, + "recently_closed": recently_closed, + "statistics": { + "active_count": len(active), + "total_closed": len(closed), + "recently_closed_included": len(recently_closed), + }, + } - return active, recently_closed + return result def _load_central() -> Dict[str, Any]: @@ -223,80 +236,45 @@ def _calculate_global_statistics(central_data: Dict[str, Any]) -> Dict[str, int] def push_to_plans_central() -> bool: - """Push Flow's plan data to .ai_central/PLANS.central.json + """Push plan data for ALL branches to .ai_central/PLANS.central.json Algorithm: - 1. Read fplan_registry.json - 2. Extract only plans where location='flow' (Flow's own plans) - 3. Format for central structure with branch metadata - 4. Read existing PLANS.central.json if exists - 5. Update ONLY branches.flow section - 6. Update global_statistics (total counts across all branches) - 7. Preserve ALL other branch sections - 8. Write back to PLANS.central.json - 9. Call aggregate_central_impl to rebuild top-level active_plans with validation + 1. Read all per-type plan registries + 2. Group plans by branch (derived from location path) + 3. Build per-branch sections with active/closed/stats + 4. Write all branch sections to PLANS.central.json + 5. Update global_statistics (total counts across all branches) + 6. Call aggregate_central_impl to rebuild top-level active_plans with validation Returns: True on success, False on failure """ try: - # Ensure .ai_central directory exists AI_CENTRAL_DIR.mkdir(parents=True, exist_ok=True) - # Load registry registry = _load_registry() + branch_sections = _extract_plans_by_branch(registry) - # Extract Flow's plans - active_plans, recently_closed = _extract_flow_plans(registry) - - # Build Flow's branch section now = datetime.now(timezone.utc).isoformat() - flow_section = { - "branch_name": "FLOW", - "branch_path": str(FLOW_ROOT), - "last_updated": now, - "active_plans": active_plans, - "recently_closed": recently_closed, - "statistics": { - "active_count": len(active_plans), - "total_closed": len( - [ - p - for p in registry.get("plans", {}).values() - if p.get("location") == str(FLOW_ROOT) and p.get("status") == "closed" - ] - ), - }, - } + for section in branch_sections.values(): + section["last_updated"] = now - # Load existing central file central_data = _load_central() - - # Update Flow's section - if "branches" not in central_data: - central_data["branches"] = {} - central_data["branches"]["flow"] = flow_section - - # Update global statistics + central_data["branches"] = branch_sections central_data["global_statistics"] = _calculate_global_statistics(central_data) - - # Update generated_at timestamp central_data["generated_at"] = now - # Write back to central file with open(CENTRAL_FILE, "w", encoding="utf-8") as f: json.dump(central_data, f, indent=2, ensure_ascii=False) - # Call aggregate_central_impl to rebuild top-level arrays with validation - # This ensures active_plans is built from all branches and validates files exist aggregate_central_impl(heal=True, central_file=CENTRAL_FILE, central_dir=AI_CENTRAL_DIR) + total_active = sum(len(s.get("active_plans", [])) for s in branch_sections.values()) json_handler.log_operation( "plans_central_pushed", { - "active_plans": len(active_plans), - "recently_closed": len(recently_closed), - "branches_reporting": central_data["global_statistics"].get("branches_reporting", 0), + "active_plans": total_active, + "branches_reporting": len(branch_sections), "success": True, }, ) diff --git a/src/aipass/flow/tests/test_push_central.py b/src/aipass/flow/tests/test_push_central.py index 28eccdf1..e4c9d743 100644 --- a/src/aipass/flow/tests/test_push_central.py +++ b/src/aipass/flow/tests/test_push_central.py @@ -312,81 +312,74 @@ class TestLoadRegistry: # ============================================= -# 4. _extract_flow_plans +# 4. _extract_plans_by_branch # ============================================= -class TestExtractFlowPlans: - """Tests for _extract_flow_plans.""" +class TestExtractPlansByBranch: + """Tests for _extract_plans_by_branch.""" - def test_extracts_active_plans_matching_flow_root(self): - """Returns active plans where location matches FLOW_ROOT.""" + def test_groups_plans_by_branch(self): + """Groups plans into per-branch sections by location path name.""" mod = _import_mod() - flow_root = str(mod.FLOW_ROOT) registry = { "plans": { "FPLAN-0001": { - "subject": "Plan A", + "subject": "Flow plan", "status": "open", "created": "2026-04-20", "file_path": "/p/FPLAN-0001.md", - "location": flow_root, - "relative_path": "FPLAN-0001.md", + "location": "/repo/src/aipass/flow", + }, + "DPLAN-0001": { + "subject": "Devpulse plan", + "status": "open", + "created": "2026-04-21", + "file_path": "/p/DPLAN-0001.md", + "location": "/repo/src/aipass/devpulse", }, } } - active, _ = mod._extract_flow_plans(registry) - assert len(active) == 1 - assert active[0]["plan_id"] == "FPLAN-0001" - assert active[0]["subject"] == "Plan A" - assert active[0]["status"] == "open" + result = mod._extract_plans_by_branch(registry) + assert "flow" in result + assert "devpulse" in result + assert result["flow"]["statistics"]["active_count"] == 1 + assert result["devpulse"]["statistics"]["active_count"] == 1 - def test_extracts_closed_plans(self): - """Returns closed plans with closed metadata.""" + def test_extracts_active_and_closed(self): + """Separates active and closed plans within a branch.""" mod = _import_mod() - flow_root = str(mod.FLOW_ROOT) registry = { "plans": { "FPLAN-0001": { - "subject": "Done plan", + "subject": "Open", + "status": "open", + "created": "2026-04-20", + "file_path": "/p/FPLAN-0001.md", + "location": "/repo/src/aipass/flow", + }, + "FPLAN-0002": { + "subject": "Closed", "status": "closed", "created": "2026-04-15", - "closed": "2026-04-20", - "closed_reason": "completed", - "file_path": "/p/FPLAN-0001.md", - "location": flow_root, - "relative_path": "FPLAN-0001.md", + "closed": "2026-04-18", + "closed_reason": "done", + "file_path": "/p/FPLAN-0002.md", + "location": "/repo/src/aipass/flow", }, } } - active, closed = mod._extract_flow_plans(registry) - assert len(active) == 0 - assert len(closed) == 1 - assert closed[0]["closed"] == "2026-04-20" - assert closed[0]["closed_reason"] == "completed" - - def test_excludes_plans_from_other_locations(self): - """Excludes plans where location does not match FLOW_ROOT.""" - mod = _import_mod() - registry = { - "plans": { - "FPLAN-0001": { - "subject": "Other branch plan", - "status": "open", - "created": "2026-04-20", - "file_path": "/other/FPLAN-0001.md", - "location": "/some/other/path", - }, - } - } - active, closed = mod._extract_flow_plans(registry) - assert len(active) == 0 - assert len(closed) == 0 + result = mod._extract_plans_by_branch(registry) + flow = result["flow"] + assert flow["statistics"]["active_count"] == 1 + assert flow["statistics"]["total_closed"] == 1 + assert len(flow["active_plans"]) == 1 + assert len(flow["recently_closed"]) == 1 + assert flow["recently_closed"][0]["closed"] == "2026-04-18" def test_sorts_active_newest_first(self): - """Active plans are sorted by created date, newest first.""" + """Active plans sorted by created date, newest first.""" mod = _import_mod() - flow_root = str(mod.FLOW_ROOT) registry = { "plans": { "FPLAN-0001": { @@ -394,114 +387,110 @@ class TestExtractFlowPlans: "status": "open", "created": "2026-04-01", "file_path": "/p/FPLAN-0001.md", - "location": flow_root, + "location": "/repo/src/aipass/flow", }, "FPLAN-0002": { "subject": "Newest", "status": "open", "created": "2026-04-25", "file_path": "/p/FPLAN-0002.md", - "location": flow_root, - }, - "FPLAN-0003": { - "subject": "Middle", - "status": "open", - "created": "2026-04-15", - "file_path": "/p/FPLAN-0003.md", - "location": flow_root, + "location": "/repo/src/aipass/flow", }, } } - active, _ = mod._extract_flow_plans(registry) + result = mod._extract_plans_by_branch(registry) + active = result["flow"]["active_plans"] assert active[0]["subject"] == "Newest" - assert active[1]["subject"] == "Middle" - assert active[2]["subject"] == "Oldest" + assert active[1]["subject"] == "Oldest" - def test_closed_plans_limited_to_5(self): - """Recently closed plans are limited to 5.""" + def test_closed_limited_to_5(self): + """Recently closed plans limited to 5 per branch.""" mod = _import_mod() - flow_root = str(mod.FLOW_ROOT) plans = {} for i in range(1, 9): plans[f"FPLAN-{str(i).zfill(4)}"] = { - "subject": f"Closed plan {i}", + "subject": f"Closed {i}", "status": "closed", "created": "2026-04-01", "closed": f"2026-04-{str(i + 10).zfill(2)}", - "closed_reason": "done", "file_path": f"/p/FPLAN-{str(i).zfill(4)}.md", - "location": flow_root, + "location": "/repo/src/aipass/flow", } - registry = {"plans": plans} - _, closed = mod._extract_flow_plans(registry) - assert len(closed) == 5 + result = mod._extract_plans_by_branch({"plans": plans}) + assert len(result["flow"]["recently_closed"]) == 5 - def test_closed_sorted_newest_first(self): - """Closed plans are sorted by closed date, newest first.""" + def test_empty_registry(self): + """Returns empty dict for empty registry.""" + mod = _import_mod() + result = mod._extract_plans_by_branch({"plans": {}}) + assert result == {} + + def test_missing_plans_key(self): + """Returns empty dict when registry has no plans key.""" + mod = _import_mod() + result = mod._extract_plans_by_branch({}) + assert result == {} + + def test_skips_plans_without_location(self): + """Plans with empty location are skipped.""" mod = _import_mod() - flow_root = str(mod.FLOW_ROOT) registry = { "plans": { "FPLAN-0001": { - "subject": "Old close", - "status": "closed", - "created": "2026-03-01", - "closed": "2026-03-10", + "subject": "No location", + "status": "open", + "created": "2026-04-20", "file_path": "/p/FPLAN-0001.md", - "location": flow_root, - }, - "FPLAN-0002": { - "subject": "New close", - "status": "closed", - "created": "2026-04-01", - "closed": "2026-04-20", - "file_path": "/p/FPLAN-0002.md", - "location": flow_root, + "location": "", }, } } - _, closed = mod._extract_flow_plans(registry) - assert closed[0]["subject"] == "New close" - assert closed[1]["subject"] == "Old close" + result = mod._extract_plans_by_branch(registry) + assert result == {} - def test_empty_registry(self): - """Returns empty lists for empty registry.""" + def test_branch_section_structure(self): + """Each branch section has required keys.""" mod = _import_mod() - active, closed = mod._extract_flow_plans({"plans": {}}) - assert active == [] - assert closed == [] - - def test_missing_plans_key(self): - """Returns empty lists when registry has no plans key.""" - mod = _import_mod() - active, closed = mod._extract_flow_plans({}) - assert active == [] - assert closed == [] - - def test_plan_entry_structure(self): - """Plan entries have all required keys.""" - mod = _import_mod() - flow_root = str(mod.FLOW_ROOT) registry = { "plans": { "FPLAN-0001": { - "subject": "Structured plan", + "subject": "Structured", "status": "open", "created": "2026-04-20", "file_path": "/p/FPLAN-0001.md", "relative_path": "FPLAN-0001.md", - "location": flow_root, + "location": "/repo/src/aipass/flow", }, } } - active, _ = mod._extract_flow_plans(registry) - entry = active[0] + result = mod._extract_plans_by_branch(registry) + section = result["flow"] + assert section["branch_name"] == "FLOW" + assert section["branch_path"] == "/repo/src/aipass/flow" + assert "active_plans" in section + assert "recently_closed" in section + assert "statistics" in section + entry = section["active_plans"][0] assert "plan_id" in entry assert "subject" in entry - assert "status" in entry - assert "created" in entry - assert "file_path" in entry - assert "relative_path" in entry + assert "branch" in entry + + def test_plan_entries_have_branch_field(self): + """Each plan entry includes the branch field.""" + mod = _import_mod() + registry = { + "plans": { + "DPLAN-0001": { + "subject": "Test", + "status": "open", + "created": "2026-04-20", + "file_path": "/p/DPLAN-0001.md", + "location": "/repo/src/aipass/devpulse", + }, + } + } + result = mod._extract_plans_by_branch(registry) + assert result["devpulse"]["active_plans"][0]["branch"] == "devpulse" # ============================================= @@ -651,8 +640,15 @@ class TestPushToPlansCentral: ai_central = tmp_path / ".ai_central" mock_registry = {"plans": {}, "next_number": 1} - mock_active = [{"plan_id": "FPLAN-0001", "subject": "Test", "status": "open"}] - mock_closed: list = [] + mock_branches = { + "flow": { + "branch_name": "FLOW", + "branch_path": str(mod.FLOW_ROOT), + "active_plans": [{"plan_id": "FPLAN-0001", "subject": "Test", "status": "open"}], + "recently_closed": [], + "statistics": {"active_count": 1, "total_closed": 0, "recently_closed_included": 0}, + } + } mock_central = { "generated_at": "", "branches": {}, @@ -663,7 +659,7 @@ class TestPushToPlansCentral: patch.object(mod, "AI_CENTRAL_DIR", ai_central), patch.object(mod, "CENTRAL_FILE", central_file), patch.object(mod, "_load_registry", return_value=mock_registry), - patch.object(mod, "_extract_flow_plans", return_value=(mock_active, mock_closed)), + patch.object(mod, "_extract_plans_by_branch", return_value=mock_branches), patch.object(mod, "_load_central", return_value=mock_central), patch.object(mod, "aggregate_central_impl") as mock_agg, ): @@ -703,30 +699,44 @@ class TestPushToPlansCentral: assert central_file.exists() written = json.loads(central_file.read_text(encoding="utf-8")) assert "branches" in written - assert "flow" in written["branches"] assert "global_statistics" in written assert "generated_at" in written assert written["generated_at"] != "" - def test_preserves_other_branches(self, tmp_path): - """Preserves other branch sections when updating flow.""" + def test_writes_multiple_branches(self, tmp_path): + """Writes per-branch sections from registry data.""" mod = _import_mod() central_file = tmp_path / "PLANS.central.json" ai_central = tmp_path / ".ai_central" mock_registry = {"plans": {}, "next_number": 1} - mock_central = { - "generated_at": "2026-04-01T00:00:00Z", - "branches": { - "drone": {"branch_name": "DRONE", "statistics": {"active_count": 3, "total_closed": 1}}, + mock_branches = { + "flow": { + "branch_name": "FLOW", + "branch_path": "/repo/src/aipass/flow", + "active_plans": [], + "recently_closed": [], + "statistics": {"active_count": 0, "total_closed": 0, "recently_closed_included": 0}, }, - "global_statistics": {"total_active": 3, "total_closed": 1, "branches_reporting": 1}, + "devpulse": { + "branch_name": "DEVPULSE", + "branch_path": "/repo/src/aipass/devpulse", + "active_plans": [{"plan_id": "DPLAN-0001"}], + "recently_closed": [], + "statistics": {"active_count": 1, "total_closed": 0, "recently_closed_included": 0}, + }, + } + mock_central = { + "generated_at": "", + "branches": {}, + "global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0}, } with ( patch.object(mod, "AI_CENTRAL_DIR", ai_central), patch.object(mod, "CENTRAL_FILE", central_file), patch.object(mod, "_load_registry", return_value=mock_registry), + patch.object(mod, "_extract_plans_by_branch", return_value=mock_branches), patch.object(mod, "_load_central", return_value=mock_central), patch.object(mod, "aggregate_central_impl"), ): @@ -734,8 +744,8 @@ class TestPushToPlansCentral: assert result is True written = json.loads(central_file.read_text(encoding="utf-8")) - assert "drone" in written["branches"] assert "flow" in written["branches"] + assert "devpulse" in written["branches"] def test_creates_ai_central_dir(self, tmp_path): """Creates .ai_central directory if it does not exist.""" @@ -773,26 +783,25 @@ class TestPushToPlansCentral: assert result is False - def test_flow_section_has_correct_statistics(self, tmp_path): - """Flow section statistics reflect actual plan counts.""" + def test_branch_section_has_correct_statistics(self, tmp_path): + """Branch section statistics reflect actual plan counts.""" mod = _import_mod() central_file = tmp_path / "PLANS.central.json" ai_central = tmp_path / ".ai_central" - flow_root_str = str(mod.FLOW_ROOT) - mock_registry = { - "plans": { - "FPLAN-0001": {"status": "open", "location": flow_root_str}, - "FPLAN-0002": {"status": "open", "location": flow_root_str}, - "FPLAN-0003": {"status": "closed", "location": flow_root_str}, - }, - "next_number": 4, + mock_registry = {"plans": {}, "next_number": 4} + mock_branches = { + "flow": { + "branch_name": "FLOW", + "branch_path": str(mod.FLOW_ROOT), + "active_plans": [ + {"plan_id": "FPLAN-0001", "status": "open"}, + {"plan_id": "FPLAN-0002", "status": "open"}, + ], + "recently_closed": [{"plan_id": "FPLAN-0003", "status": "closed"}], + "statistics": {"active_count": 2, "total_closed": 1, "recently_closed_included": 1}, + } } - mock_active = [ - {"plan_id": "FPLAN-0001", "status": "open"}, - {"plan_id": "FPLAN-0002", "status": "open"}, - ] - mock_closed = [{"plan_id": "FPLAN-0003", "status": "closed"}] mock_central = { "generated_at": "", "branches": {}, @@ -803,7 +812,7 @@ class TestPushToPlansCentral: patch.object(mod, "AI_CENTRAL_DIR", ai_central), patch.object(mod, "CENTRAL_FILE", central_file), patch.object(mod, "_load_registry", return_value=mock_registry), - patch.object(mod, "_extract_flow_plans", return_value=(mock_active, mock_closed)), + patch.object(mod, "_extract_plans_by_branch", return_value=mock_branches), patch.object(mod, "_load_central", return_value=mock_central), patch.object(mod, "aggregate_central_impl"), ): @@ -817,18 +826,117 @@ class TestPushToPlansCentral: assert flow["branch_name"] == "FLOW" def test_global_statistics_updated(self, tmp_path): - """Global statistics are recalculated after flow section update.""" + """Global statistics are recalculated from all branch sections.""" mod = _import_mod() central_file = tmp_path / "PLANS.central.json" ai_central = tmp_path / ".ai_central" mock_registry = {"plans": {}, "next_number": 1} + mock_branches = { + "flow": { + "branch_name": "FLOW", + "branch_path": str(mod.FLOW_ROOT), + "active_plans": [], + "recently_closed": [], + "statistics": {"active_count": 0, "total_closed": 0, "recently_closed_included": 0}, + }, + "devpulse": { + "branch_name": "DEVPULSE", + "branch_path": "/repo/src/aipass/devpulse", + "active_plans": [{"plan_id": "DPLAN-0001"}], + "recently_closed": [], + "statistics": {"active_count": 5, "total_closed": 3, "recently_closed_included": 0}, + }, + } mock_central = { "generated_at": "", - "branches": { - "drone": {"statistics": {"active_count": 5, "total_closed": 3}}, + "branches": {}, + "global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0}, + } + + with ( + patch.object(mod, "AI_CENTRAL_DIR", ai_central), + patch.object(mod, "CENTRAL_FILE", central_file), + patch.object(mod, "_load_registry", return_value=mock_registry), + patch.object(mod, "_extract_plans_by_branch", return_value=mock_branches), + patch.object(mod, "_load_central", return_value=mock_central), + patch.object(mod, "aggregate_central_impl"), + ): + result = mod.push_to_plans_central() + + assert result is True + written = json.loads(central_file.read_text(encoding="utf-8")) + stats = written["global_statistics"] + assert stats["total_active"] == 5 + assert stats["total_closed"] == 3 + assert stats["branches_reporting"] == 2 + + def test_log_operation_contains_expected_fields(self, tmp_path, mock_json_handler): + """Log operation includes active_plans and branches_reporting.""" + mod = _import_mod() + central_file = tmp_path / "PLANS.central.json" + ai_central = tmp_path / ".ai_central" + + mock_registry = {"plans": {}, "next_number": 1} + mock_branches = { + "flow": { + "branch_name": "FLOW", + "branch_path": str(mod.FLOW_ROOT), + "active_plans": [{"plan_id": "FPLAN-0001"}], + "recently_closed": [], + "statistics": {"active_count": 1, "total_closed": 0, "recently_closed_included": 0}, }, - "global_statistics": {"total_active": 5, "total_closed": 3, "branches_reporting": 1}, + } + mock_central = { + "generated_at": "", + "branches": {}, + "global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0}, + } + + with ( + patch.object(mod, "AI_CENTRAL_DIR", ai_central), + patch.object(mod, "CENTRAL_FILE", central_file), + patch.object(mod, "_load_registry", return_value=mock_registry), + patch.object(mod, "_extract_plans_by_branch", return_value=mock_branches), + patch.object(mod, "_load_central", return_value=mock_central), + patch.object(mod, "aggregate_central_impl"), + ): + mod.push_to_plans_central() + + call_args = mock_json_handler.call_args + log_data = call_args[0][1] + assert log_data["active_plans"] == 1 + assert "branches_reporting" in log_data + + def test_non_flow_branch_plans_in_central(self, tmp_path): + """Regression: non-flow branch plans must appear in PLANS.central.json.""" + mod = _import_mod() + central_file = tmp_path / "PLANS.central.json" + ai_central = tmp_path / ".ai_central" + + mock_registry = { + "plans": { + "DPLAN-0181": { + "subject": "Devpulse plan", + "status": "open", + "created": "2026-05-01", + "file_path": "/repo/src/aipass/devpulse/DPLAN-0181.md", + "location": "/repo/src/aipass/devpulse", + }, + "FPLAN-0001": { + "subject": "Flow plan", + "status": "open", + "created": "2026-05-02", + "file_path": "/repo/src/aipass/flow/FPLAN-0001.md", + "location": str(mod.FLOW_ROOT), + }, + }, + "next_number": 1, + } + mock_central = { + "generated_at": "", + "branches": {}, + "global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0}, } with ( @@ -842,39 +950,9 @@ class TestPushToPlansCentral: assert result is True written = json.loads(central_file.read_text(encoding="utf-8")) - stats = written["global_statistics"] - # drone(5 active, 3 closed) + flow(0 active, 0 closed) - assert stats["total_active"] == 5 - assert stats["total_closed"] == 3 - assert stats["branches_reporting"] == 2 - - def test_log_operation_contains_expected_fields(self, tmp_path, mock_json_handler): - """Log operation includes active_plans, recently_closed, branches_reporting.""" - mod = _import_mod() - central_file = tmp_path / "PLANS.central.json" - ai_central = tmp_path / ".ai_central" - - mock_registry = {"plans": {}, "next_number": 1} - mock_active = [{"plan_id": "FPLAN-0001"}] - mock_closed = [{"plan_id": "FPLAN-0002"}, {"plan_id": "FPLAN-0003"}] - mock_central = { - "generated_at": "", - "branches": {}, - "global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0}, - } - - with ( - patch.object(mod, "AI_CENTRAL_DIR", ai_central), - patch.object(mod, "CENTRAL_FILE", central_file), - patch.object(mod, "_load_registry", return_value=mock_registry), - patch.object(mod, "_extract_flow_plans", return_value=(mock_active, mock_closed)), - patch.object(mod, "_load_central", return_value=mock_central), - patch.object(mod, "aggregate_central_impl"), - ): - mod.push_to_plans_central() - - call_args = mock_json_handler.call_args - log_data = call_args[0][1] - assert log_data["active_plans"] == 1 - assert log_data["recently_closed"] == 2 - assert "branches_reporting" in log_data + assert "devpulse" in written["branches"] + devpulse = written["branches"]["devpulse"] + assert devpulse["statistics"]["active_count"] == 1 + assert len(devpulse["active_plans"]) == 1 + assert devpulse["active_plans"][0]["plan_id"] == "DPLAN-0181" + assert devpulse["active_plans"][0]["branch"] == "devpulse"