Skip to content

Commit e136b72

Browse files
committed
feat(portfolio): add decision queue digest
1 parent 41789c5 commit e136b72

4 files changed

Lines changed: 335 additions & 3 deletions

File tree

src/portfolio_decision_queue.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Decision-queue compression for portfolio truth.
2+
3+
This layer is intentionally narrower than default attention. ``active-product``
4+
and ``active-infra`` form the watch set; the decision queue is only for current
5+
truth entries that already carry a concrete decision signal.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from dataclasses import dataclass
11+
from typing import Any
12+
13+
CONTRACT_VERSION = "decision_queue_v1"
14+
MAX_DECISION_QUEUE_ITEMS = 5
15+
16+
NON_DEFAULT_STATES = frozenset(
17+
{"parked", "archived", "experiment", "evidence-history", "manual-only"}
18+
)
19+
20+
21+
def _mapping(value: Any) -> dict[str, Any]:
22+
return value if isinstance(value, dict) else {}
23+
24+
25+
def _text(value: Any) -> str:
26+
return value.strip() if isinstance(value, str) else ""
27+
28+
29+
@dataclass(frozen=True)
30+
class DecisionQueueItem:
31+
project: str
32+
path: str
33+
attention_state: str
34+
decision_type: str
35+
why_now: str
36+
evidence: tuple[str, ...]
37+
source_freshness: str
38+
recommended_action: str
39+
do_not_refresh_docs_unless: str
40+
41+
def to_dict(self) -> dict[str, Any]:
42+
return {
43+
"project": self.project,
44+
"path": self.path,
45+
"attention_state": self.attention_state,
46+
"decision_type": self.decision_type,
47+
"why_now": self.why_now,
48+
"evidence": list(self.evidence),
49+
"source_freshness": self.source_freshness,
50+
"recommended_action": self.recommended_action,
51+
"do_not_refresh_docs_unless": self.do_not_refresh_docs_unless,
52+
}
53+
54+
55+
def _decision_for_project(
56+
project: dict[str, Any], *, generated_at: str
57+
) -> DecisionQueueItem | None:
58+
identity = _mapping(project.get("identity"))
59+
derived = _mapping(project.get("derived"))
60+
risk = _mapping(project.get("risk"))
61+
security = _mapping(project.get("security"))
62+
63+
attention_state = _text(derived.get("attention_state")) or "manual-only"
64+
project_name = _text(identity.get("display_name")) or "Repo"
65+
path = _text(identity.get("path")) or project_name
66+
67+
if attention_state in {"archived", "evidence-history"}:
68+
return None
69+
70+
evidence: list[str] = []
71+
decision_type = ""
72+
why_now = ""
73+
recommended_action = ""
74+
75+
if bool(risk.get("security_risk")):
76+
critical = int(security.get("dependabot_critical") or 0)
77+
high = int(security.get("dependabot_high") or 0)
78+
decision_type = "security follow-up"
79+
why_now = "Current portfolio truth marks this project with security risk."
80+
evidence.append(f"security_risk=true; dependabot critical={critical}, high={high}")
81+
recommended_action = "Decide whether to run the repo's security follow-up lane."
82+
elif attention_state in NON_DEFAULT_STATES:
83+
return None
84+
elif attention_state == "decision-needed":
85+
decision_type = "owner or human decision"
86+
why_now = "Current portfolio truth marks this project as decision-needed."
87+
evidence.append("attention_state=decision-needed")
88+
risk_summary = _text(risk.get("risk_summary"))
89+
if risk_summary:
90+
evidence.append(risk_summary)
91+
recommended_action = "Resolve the explicit portfolio decision before expanding scope."
92+
else:
93+
return None
94+
95+
return DecisionQueueItem(
96+
project=project_name,
97+
path=path,
98+
attention_state=attention_state,
99+
decision_type=decision_type,
100+
why_now=why_now,
101+
evidence=tuple(evidence),
102+
source_freshness=generated_at or "unknown",
103+
recommended_action=recommended_action,
104+
do_not_refresh_docs_unless=(
105+
"Do not refresh context, roadmap, handoff, AGENTS, or docs unless "
106+
"that work directly resolves this decision."
107+
),
108+
)
109+
110+
111+
def build_decision_queue(portfolio_truth: dict[str, Any]) -> list[dict[str, Any]]:
112+
"""Return the small decision queue from current portfolio truth.
113+
114+
This is deliberately stricter than the watch set: active product or active
115+
infrastructure projects are ignored unless current truth also contains a
116+
concrete decision signal.
117+
"""
118+
projects = portfolio_truth.get("projects") or []
119+
generated_at = _text(portfolio_truth.get("generated_at"))
120+
queue: list[DecisionQueueItem] = []
121+
for project in projects:
122+
if not isinstance(project, dict):
123+
continue
124+
item = _decision_for_project(project, generated_at=generated_at)
125+
if item is not None:
126+
queue.append(item)
127+
128+
decision_rank = {"security follow-up": 0, "owner or human decision": 1}
129+
queue.sort(
130+
key=lambda item: (
131+
decision_rank.get(item.decision_type, 9),
132+
item.project.lower(),
133+
)
134+
)
135+
return [item.to_dict() for item in queue[:MAX_DECISION_QUEUE_ITEMS]]
136+
137+
138+
def summarize_decision_queue(items: list[dict[str, Any]]) -> dict[str, Any]:
139+
type_counts: dict[str, int] = {}
140+
for item in items:
141+
decision_type = _text(item.get("decision_type")) or "unknown"
142+
type_counts[decision_type] = type_counts.get(decision_type, 0) + 1
143+
return {
144+
"contract_version": CONTRACT_VERSION,
145+
"decision_queue_count": len(items),
146+
"decision_queue_type_counts": type_counts,
147+
}

src/weekly_command_center.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any
77

88
from src.portfolio_automation import select_automation_candidates
9+
from src.portfolio_decision_queue import build_decision_queue, summarize_decision_queue
910
from src.portfolio_truth_types import truth_latest_path
1011
from src.report_enrichment import build_weekly_review_pack
1112

@@ -95,6 +96,8 @@ def build_weekly_command_center_digest(
9596
)
9697
truth = portfolio_truth or {}
9798
truth_summary = _build_truth_summary(truth)
99+
decision_queue = build_decision_queue(truth)
100+
decision_queue_summary = summarize_decision_queue(decision_queue)
98101
operator_decision = _operator_decision(operator_summary, operator_queue)
99102
operator_why = _safe_text(operator_summary.get("trend_summary")) or _safe_text(
100103
operator_summary.get("why_it_matters")
@@ -133,7 +136,8 @@ def build_weekly_command_center_digest(
133136
or "Decision quality is not available yet.",
134137
"authority_cap": _safe_text(decision_quality.get("authority_cap")) or AUTHORITY_CAP,
135138
},
136-
"portfolio_truth": truth_summary,
139+
"portfolio_truth": {**truth_summary, **decision_queue_summary},
140+
"decision_queue": decision_queue,
137141
"path_attention": _build_path_attention_items(truth),
138142
"automation_candidates": [
139143
candidate.to_dict()
@@ -193,13 +197,30 @@ def render_weekly_command_center_markdown(digest: dict[str, Any]) -> str:
193197
f"- Next Step: {_safe_text(digest.get('next_step'))}",
194198
f"- Decision Quality: `{_safe_text(decision_quality.get('status'))}` — {_safe_text(decision_quality.get('summary'))}",
195199
f"- Operating Paths: {_safe_text(digest.get('operating_paths_summary')) or 'No operating-path summary is recorded yet.'}",
196-
f"- Portfolio Truth: {portfolio_truth.get('project_count', 0)} projects, {portfolio_truth.get('active_project_count', 0)} active registry entries, {portfolio_truth.get('default_attention_count', 0)} default attention, {portfolio_truth.get('decision_needed_count', 0)} decision-needed",
200+
f"- Portfolio Truth: {portfolio_truth.get('project_count', 0)} projects, {portfolio_truth.get('active_project_count', 0)} active registry entries, {portfolio_truth.get('default_attention_count', 0)} default attention, {portfolio_truth.get('decision_queue_count', 0)} decision queue",
197201
f"- Risk Posture: {risk_posture.get('elevated_count', 0)} elevated, {tier_counts.get('moderate', 0)} moderate, {tier_counts.get('baseline', 0)} baseline",
198202
f"- Security Posture: {security_posture.get('scanned_count', 0)} scanned, {security_posture.get('repos_with_open_high_critical', 0)} with open high/critical Dependabot alerts ({security_posture.get('total_open_critical', 0)} critical, {security_posture.get('total_open_high', 0)} high)",
199203
"",
200-
"## Path Attention",
204+
"## Decision Queue",
201205
]
202206

207+
decision_queue = list(digest.get("decision_queue") or [])
208+
if not decision_queue:
209+
lines.append("- No portfolio decisions clear the current evidence bar.")
210+
else:
211+
for item in decision_queue:
212+
lines.append(
213+
f"- **{item['project']}** [{item['decision_type']}]: "
214+
f"{item['why_now']} Next: {item['recommended_action']}"
215+
)
216+
217+
lines.extend(
218+
[
219+
"",
220+
"## Path Attention",
221+
]
222+
)
223+
203224
path_attention = list(digest.get("path_attention") or [])
204225
if not path_attention:
205226
lines.append("- No active path clarifications are currently surfaced.")
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
from src.portfolio_decision_queue import build_decision_queue, summarize_decision_queue
4+
5+
6+
def _project(
7+
name: str,
8+
*,
9+
attention_state: str,
10+
security_risk: bool = False,
11+
dependabot_critical: int = 0,
12+
dependabot_high: int = 0,
13+
) -> dict:
14+
return {
15+
"identity": {"display_name": name, "path": name},
16+
"derived": {
17+
"attention_state": attention_state,
18+
"registry_status": "active",
19+
"activity_status": "active",
20+
},
21+
"risk": {
22+
"risk_tier": "baseline",
23+
"risk_summary": "No elevated risk factors.",
24+
"security_risk": security_risk,
25+
},
26+
"security": {
27+
"dependabot_critical": dependabot_critical,
28+
"dependabot_high": dependabot_high,
29+
},
30+
}
31+
32+
33+
def test_default_attention_without_decision_signal_stays_out_of_queue() -> None:
34+
truth = {
35+
"generated_at": "2026-06-19T04:36:19+00:00",
36+
"projects": [
37+
_project("Product", attention_state="active-product"),
38+
_project("Infra", attention_state="active-infra"),
39+
_project("Manual", attention_state="manual-only"),
40+
_project("Experiment", attention_state="experiment"),
41+
],
42+
}
43+
44+
assert build_decision_queue(truth) == []
45+
assert summarize_decision_queue([]) == {
46+
"contract_version": "decision_queue_v1",
47+
"decision_queue_count": 0,
48+
"decision_queue_type_counts": {},
49+
}
50+
51+
52+
def test_decision_needed_project_enters_queue() -> None:
53+
truth = {
54+
"generated_at": "2026-06-19T04:36:19+00:00",
55+
"projects": [_project("NeedsDecision", attention_state="decision-needed")],
56+
}
57+
58+
[item] = build_decision_queue(truth)
59+
assert item["project"] == "NeedsDecision"
60+
assert item["decision_type"] == "owner or human decision"
61+
assert item["source_freshness"] == "2026-06-19T04:36:19+00:00"
62+
assert "attention_state=decision-needed" in item["evidence"]
63+
64+
65+
def test_security_risk_enters_queue_even_when_manual_only() -> None:
66+
truth = {
67+
"generated_at": "2026-06-19T04:36:19+00:00",
68+
"projects": [
69+
_project(
70+
"ManualSecurity",
71+
attention_state="manual-only",
72+
security_risk=True,
73+
dependabot_critical=1,
74+
)
75+
],
76+
}
77+
78+
[item] = build_decision_queue(truth)
79+
assert item["project"] == "ManualSecurity"
80+
assert item["decision_type"] == "security follow-up"
81+
assert item["evidence"] == ["security_risk=true; dependabot critical=1, high=0"]
82+
83+
84+
def test_archived_security_risk_stays_out_of_queue() -> None:
85+
truth = {
86+
"projects": [
87+
_project(
88+
"ArchivedSecurity",
89+
attention_state="archived",
90+
security_risk=True,
91+
dependabot_critical=1,
92+
)
93+
],
94+
}
95+
96+
assert build_decision_queue(truth) == []

tests/test_weekly_command_center.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,16 @@ def test_build_weekly_command_center_digest_surfaces_truth_and_guardrails() -> N
144144
assert digest["portfolio_truth"]["active_project_count"] == 3
145145
assert digest["portfolio_truth"]["default_attention_count"] == 2
146146
assert digest["portfolio_truth"]["decision_needed_count"] == 2
147+
assert digest["portfolio_truth"]["decision_queue_count"] == 2
148+
assert digest["portfolio_truth"]["decision_queue_type_counts"] == {
149+
"owner or human decision": 2
150+
}
147151
assert digest["portfolio_truth"]["investigate_override_count"] == 2
148152
assert digest["portfolio_truth"]["attention_state_counts"]["manual-only"] == 1
153+
assert [item["project"] for item in digest["decision_queue"]] == [
154+
"GithubRepoAuditor",
155+
"JobCommandCenter",
156+
]
149157
assert digest["path_attention"][0]["repo"] == "JobCommandCenter"
150158
assert digest["path_attention"][0]["headline"] == "Unspecified stable path"
151159
assert all(item["attention_state"] == "decision-needed" for item in digest["path_attention"])
@@ -159,6 +167,8 @@ def test_build_weekly_command_center_digest_surfaces_truth_and_guardrails() -> N
159167
assert digest["portfolio_truth"]["risk_tier_counts"]["deferred"] == 1
160168

161169
rendered_md = render_weekly_command_center_markdown(digest)
170+
assert "## Decision Queue" in rendered_md
171+
assert "owner or human decision" in rendered_md
162172
assert "## Risk Posture" in rendered_md
163173
assert "GithubRepoAuditor" in rendered_md
164174
assert "JobCommandCenter" in rendered_md
@@ -353,6 +363,11 @@ def test_security_posture_surfaces_open_alerts_critical_first() -> None:
353363
top = posture["top_alerts"]
354364
assert [item["repo"] for item in top] == ["CriticalRepo", "HighRepo"]
355365
assert top[0]["dependabot_critical"] == 2
366+
assert [item["project"] for item in digest["decision_queue"]] == [
367+
"CriticalRepo",
368+
"HighRepo",
369+
]
370+
assert digest["portfolio_truth"]["decision_queue_count"] == 2
356371

357372
rendered = render_weekly_command_center_markdown(digest)
358373
assert "## Security Posture" in rendered
@@ -384,3 +399,56 @@ def test_security_posture_reports_not_run_when_no_overlay() -> None:
384399
rendered = render_weekly_command_center_markdown(digest)
385400
assert "## Security Posture" in rendered
386401
assert "Security overlay not run" in rendered
402+
403+
404+
def test_default_attention_watch_set_does_not_create_decision_queue() -> None:
405+
portfolio_truth = {
406+
"projects": [
407+
{
408+
"identity": {"display_name": "ActiveProduct"},
409+
"declared": {"operating_path": "finish"},
410+
"derived": {
411+
"registry_status": "active",
412+
"attention_state": "active-product",
413+
"activity_status": "active",
414+
"path_override": "",
415+
"path_confidence": "high",
416+
"context_quality": "standard",
417+
},
418+
"risk": {
419+
"risk_tier": "baseline",
420+
"risk_factors": [],
421+
"risk_summary": "No elevated risk factors.",
422+
"security_risk": False,
423+
},
424+
},
425+
{
426+
"identity": {"display_name": "ActiveInfra"},
427+
"declared": {"operating_path": "maintain"},
428+
"derived": {
429+
"registry_status": "active",
430+
"attention_state": "active-infra",
431+
"activity_status": "active",
432+
"path_override": "",
433+
"path_confidence": "high",
434+
"context_quality": "standard",
435+
},
436+
"risk": {
437+
"risk_tier": "baseline",
438+
"risk_factors": [],
439+
"risk_summary": "No elevated risk factors.",
440+
"security_risk": False,
441+
},
442+
},
443+
]
444+
}
445+
446+
digest = _digest_for(portfolio_truth)
447+
448+
assert digest["portfolio_truth"]["default_attention_count"] == 2
449+
assert digest["portfolio_truth"]["decision_queue_count"] == 0
450+
assert digest["decision_queue"] == []
451+
452+
rendered = render_weekly_command_center_markdown(digest)
453+
assert "2 default attention, 0 decision queue" in rendered
454+
assert "No portfolio decisions clear the current evidence bar." in rendered

0 commit comments

Comments
 (0)