Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [v1.2.6] — 2026-03-25

### Added

- **PR-Aware Planning (`src/day_planner.py`, `src/memory.py`)**: Introduced a explicit `ROUTING RULE` in the department planner. The `in_review_section` now dynamically fetches PR IDs and reviewer lists from memory, allowing the LLM to accurately assign `pr_review` tasks to the correct engineers.
- **Comprehensive Integration Suite (`tests/test_integration.py`, `tests/test_flow.py`)**: Added extensive integration tests covering the full daily cycle, including ticket status transitions, PR force-merging logic, and non-engineering lifecycle artifacts.

### Changed

- **Causal Chain Reliability (`src/normal_day.py`)**: Refined how causal chains are initialized. The system now prioritizes existing chains from active incidents over ticket-level history, ensuring continuity during high-pressure events.
- **Codebase Hardening (`Across all files`)**: Executed a systemic removal of legacy ASCII section dividers and redundant comments to improve maintainability and reduce context window bloat.

### Fixed

- **PR Generation Logic (`src/normal_day.py`)**: Fixed a bug where PRs were only spawning on "forced" completions; they are now correctly triggered whenever an engineering ticket is marked as complete.
- **Validation False Positives (`src/day_planner.py`)**: Updated `_parse_plan` to stop logging warnings for "hallucinated names" when the model correctly identifies valid external collaborators or cross-department leads.

---

## [v1.2.5] — 2026-03-25

### Added
Expand Down
51 changes: 23 additions & 28 deletions src/day_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ class DepartmentPlanner:
{{
"activity_type": "exactly one of: ticket_progress | pr_review | 1on1 | async_question | design_discussion | mentoring | deep_work",
"description": "string — max 6 words",
"related_id": "string — MUST be from owned tickets or null",
"related_id": "string — for ticket_progress: MUST be from owned tickets or null. For pr_review: use the PR ID (e.g. PR-107) from the IN REVIEW TICKETS section above.",
"collaborator": ["string"],
"estimated_hrs": float
}}
Expand Down Expand Up @@ -192,6 +192,11 @@ class DepartmentPlanner:
### IN REVIEW TICKETS - owned tickets that are pending review
{in_review_section}

ROUTING RULE: For each ticket listed above, look up its linked PR's `reviewers` list.
Assign a `pr_review` agenda item to those reviewers — NOT to the ticket author.
The author should NOT receive any ticket_progress items for tickets that are already In Review.
For pr_review items, `related_id` should be the PR ID (e.g. PR-107), not the ticket ID.

### ENGINEER CAPACITY TODAY (hours available after stress/on-call)
{capacity_section}

Expand Down Expand Up @@ -281,7 +286,17 @@ def plan(
]
capacity_section = "\n".join(cap_lines)

in_review_lines = [f" - [{tid}]" for tid in sprint_context.in_review]
in_review_lines = []
for tid in sprint_context.in_review:
pr = mem.get_pr_by_ticket_id(tid)
if pr:
reviewers = ", ".join(pr.get("reviewers", []))
pr_id = pr.get("pr_id", "?")
in_review_lines.append(
f" - [{tid}] → {pr_id} | awaiting review from: {reviewers}"
)
else:
in_review_lines.append(f" - [{tid}] → no PR found")
in_review_section = (
"\n".join(in_review_lines) if in_review_lines else " (none)"
)
Expand Down Expand Up @@ -374,8 +389,6 @@ def plan(

return result

# ─── Parsing ─────────────────────────────────────────────────────────────

def _parse_plan(
self,
raw: str,
Expand All @@ -394,7 +407,7 @@ def _parse_plan(
integrity check still catches any stray violations, but does not need
to be the primary guardrail.
"""
# Strip any accidental markdown fences

clean = raw.replace("```json", "").replace("```", "").strip()
data = json_repair.loads(clean)

Expand All @@ -405,10 +418,6 @@ def _parse_plan(
)
return self._fallback_plan(org_theme, day, date, cross_signals), {}

# ── Build a fast-lookup set of valid (engineer → ticket) pairs ────────
# If SprintContext is present we can enforce ownership at parse time
# as a belt-and-suspenders check. This should rarely fire now that
# the LLM receives the locked menu.
owned_by: Dict[str, str] = (
sprint_context.owned_tickets if sprint_context else {}
)
Expand All @@ -418,15 +427,13 @@ def _parse_plan(
for ep in data.get("engineer_plans", []):
name = ep.get("name", "")
if name not in self.members:
continue # LLM invented a name — skip silently
continue

agenda = []
for a in ep.get("agenda", []):
activity_type = a.get("activity_type", "ticket_progress")
related_id = a.get("related_id")

# Belt-and-suspenders ownership check — catches the rare LLM
# slip-through. Logs a warning instead of silently stripping.
if activity_type == "ticket_progress" and related_id and sprint_context:
actual_owner = owner_of.get(related_id)
if actual_owner and actual_owner != name:
Expand All @@ -437,12 +444,10 @@ def _parse_plan(
)
continue

# Get the flat list of all valid employee names
all_valid_names = {
n for members in LIVE_ORG_CHART.values() for n in members
}

# Coerce the raw LLM output, but strictly filter out hallucinated names
raw_collabs = _coerce_collaborators(a.get("collaborator"))
valid_collabs = [c for c in raw_collabs if c in all_valid_names]

Expand All @@ -456,7 +461,6 @@ def _parse_plan(
)
)

# Fallback agenda if LLM returned nothing useful
if not agenda:
agenda = [
AgendaItem(
Expand All @@ -471,12 +475,11 @@ def _parse_plan(
name=name,
dept=self.dept,
agenda=agenda,
stress_level=0, # will be patched by orchestrator after parse
stress_level=0,
focus_note=ep.get("focus_note", ""),
)
)

# Ensure every member has a plan (even if LLM missed them)
planned_names = {p.name for p in eng_plans}
for name in self.members:
if name not in planned_names:
Expand All @@ -492,18 +495,16 @@ def _parse_plan(
"design_discussion",
"async_question",
):
# Create a normalized key of the participants
participants = frozenset([ep.name] + a.collaborator)
collab_key = (a.activity_type, participants)

if collab_key in seen_collaborations:
continue # We already kept the initiator's version of this meeting
continue
seen_collaborations.add(collab_key)

unique_agenda.append(a)
ep.agenda = unique_agenda

# ── Proposed events ───────────────────────────────────────────────────
proposed: List[ProposedEvent] = []
for pe in data.get("proposed_events", []):
actors = [a for a in pe.get("actors", []) if a]
Expand Down Expand Up @@ -890,11 +891,6 @@ def _format_other_plans(
return "\n".join(lines) if lines else " (no other departments)"


# ─────────────────────────────────────────────────────────────────────────────
# ORCHESTRATOR — top-level entry point for flow.py
# ─────────────────────────────────────────────────────────────────────────────


class DayPlannerOrchestrator:
"""
Called once per day from flow.py's daily_cycle(), replacing _generate_theme().
Expand All @@ -919,7 +915,6 @@ def __init__(self, config: dict, worker_llm, planner_llm, clock):
self._planner_llm = planner_llm
self._clock = clock

# Build one DepartmentPlanner per department
self._dept_planners: Dict[str, DepartmentPlanner] = {}
for dept, members in LIVE_ORG_CHART.items():
is_primary = "eng" in dept.lower()
Expand All @@ -941,8 +936,8 @@ def __init__(self, config: dict, worker_llm, planner_llm, clock):
external_contact_names=external_names,
config=config,
)
# TicketAssigner is stateless per-call — one instance for the whole sim
self._ticket_assigner: Optional[TicketAssigner] = None # set on first plan()

self._ticket_assigner: Optional[TicketAssigner] = None

def plan(
self,
Expand Down
3 changes: 3 additions & 0 deletions src/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1735,6 +1735,9 @@ def upsert_pr(self, pr: Dict):
def get_reviewable_prs_for(self, name: str) -> List[Dict]:
return list(self._prs.find({"reviewers": name, "status": "open"}, {"_id": 0}))

def get_pr_by_ticket_id(self, ticket_id: str) -> Optional[Dict]:
return self._prs.find_one({"ticket_id": ticket_id}, {"_id": 0})

def log_slack_messages(
self, channel: str, messages: List[Dict], export_dir: Path
) -> Tuple[str, str]:
Expand Down
31 changes: 15 additions & 16 deletions src/normal_day.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ def _handle_ticket_progress(

backstory = get_voice_card(assignee, "async", self._gd, self._mem)

# Role framing differs by dept type
if is_non_eng:
persona = self._config.get("personas", {}).get(assignee, {})
agent_role = persona.get("role", dept_of_name(assignee, self._org_chart))
Expand Down Expand Up @@ -368,17 +367,26 @@ def _handle_ticket_progress(
if "in_progress_since" not in ticket:
ticket["in_progress_since"] = self._state.day

# ── Initialise or extend causal chain on the ticket itself ────────────
# For non-eng tickets there is no ActiveIncident, so we track the chain
# directly on the ticket document. For eng tickets we also do this so
# evals can traverse the chain without needing an open incident.
from causal_chain_handler import CausalChainHandler

active_inc = next(
(i for i in self._state.active_incidents if i.ticket_id == ticket_id),
None,
)

existing_chain = ticket.get("causal_chain", [ticket_id])
chain = CausalChainHandler(existing_chain[0])
for artifact in existing_chain[1:]:
chain.append(artifact)

if active_inc and getattr(active_inc, "causal_chain", None):
chain = active_inc.causal_chain
else:
existing_chain = ticket.get("causal_chain", [ticket_id])
chain = CausalChainHandler(existing_chain[0])
for artifact in existing_chain[1:]:
chain.append(artifact)

comment_id = f"{ticket_id}_comment_{len(ticket['comments'])}"
chain.append(comment_id)

Expand Down Expand Up @@ -414,15 +422,6 @@ def _handle_ticket_progress(
is_complete,
)

active_inc = next(
(i for i in self._state.active_incidents if i.ticket_id == ticket_id),
None,
)
if active_inc and getattr(active_inc, "causal_chain", None):
active_inc.causal_chain.append(comment_id)
if spawned_pr_id:
active_inc.causal_chain.append(spawned_pr_id)

ticket["causal_chain"] = chain.snapshot()
ticket["updated_at"] = current_actor_time_iso

Expand Down Expand Up @@ -728,7 +727,6 @@ def _complete_eng_ticket(
chain: CausalChainHandler,
is_complete: bool,
) -> Optional[str]:
"""Spawns a PR for a completed engineering ticket. Returns pr_id."""
ticket_id = ticket.get("id")
linked_prs = ticket.get("linked_prs", [])
ticket_age = self._state.day - ticket.get("in_progress_since", self._state.day)
Expand All @@ -750,7 +748,8 @@ def _complete_eng_ticket(
and not open_pr_with_changes
and actor_clock_ok
)
if force_spawn and not linked_prs:

if (force_spawn or is_complete) and not linked_prs:
pr = self._git.create_pr(
author=assignee,
ticket_id=ticket_id,
Expand Down
Loading
Loading