feat(gateway): decomposition coverage gate + AC visibility (guardrails spec 2)

The decomposition floor that pairs with the roll-up gate (spec 4): a PM
cannot finish decomposing a parent while one of its acceptance criteria has
no subtask responsible for it — the "two leaves, half the ACs silently
dropped" pattern. Three parts:

- Gate: i_am_idle is rejected for a cell_pm/main_pm whose owned parent still
  has criteria in unclaimed_parent_acceptance_criteria (claimed = referenced
  by any live, non-cancelled child). Distinct from the roll-up gate, which
  fires at submit_up/complete and demands a *completed* child; this fires
  earlier and asks only that every criterion be *claimed*. Safe-by-
  construction: inert until a PM declares coverage, so legacy / not-yet-
  adopted decompositions are never blocked.

- Visibility: PM-facing briefings (give_me_work, i_will_plan, submit_up) and
  every delegate response now carry parent_ac_coverage ({id,text,claimed,
  verified} per criterion) + unclaimed_parent_acs, so a PM can map subtasks
  to criterion ids via covers_parent_criteria and see what is still
  uncovered after each delegate. Off for leaf roles, so a developer's own
  criteria never surface as bogus "unclaimed" noise.

- Prompts: cell_pm / main_pm role prompts document covers_parent_criteria and
  the new idle enforcement in the existing Coverage discipline.

TaskService.{parent_ac_coverage,unclaimed_parent_acceptance_criteria} added
beside uncovered_parent_acceptance_criteria; all three refactored onto a
shared _parent_ac_ref_sets helper (keeps each under the xenon B ceiling,
preserves the committed roll-up behavior). Verb tables regenerated for the
new delegate param — the regen also syncs pre-existing table drift that was
never regenerated after earlier merges (read_messages, pass_review
ac_verdicts, board pitch). Two brand-new generated tables (prompter,
secretary) are left untracked pending a separate decision.
This commit is contained in:
Renn F
2026-06-16 03:49:00 +02:00
parent 5ce4570c85
commit 1fb723174a
14 changed files with 380 additions and 29 deletions
+2 -1
View File
@@ -8,7 +8,7 @@
| Verb | Body schema | | Verb | Body schema |
|------|-------------| |------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` | | `complete` | `complete(task_id: UUID, notes: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None)` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` | | `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` | | `give_me_work` | `give_me_work()` |
| `i_am_idle` | `i_am_idle()` | | `i_am_idle` | `i_am_idle()` |
@@ -35,4 +35,5 @@
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
+1
View File
@@ -30,4 +30,5 @@
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
+1
View File
@@ -29,4 +29,5 @@
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
@@ -16,6 +16,7 @@
| Tool | Body schema | | Tool | Body schema |
|------|-------------| |------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None)` | | `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None)` |
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` | | `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` | | `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` | | `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
@@ -24,4 +25,5 @@
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
+2 -1
View File
@@ -8,7 +8,7 @@
| Verb | Body schema | | Verb | Body schema |
|------|-------------| |------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` | | `complete` | `complete(task_id: UUID, notes: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None)` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None)` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` | | `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` | | `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` | | `give_me_work` | `give_me_work()` |
@@ -35,4 +35,5 @@
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
@@ -16,6 +16,7 @@
| Tool | Body schema | | Tool | Body schema |
|------|-------------| |------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None)` | | `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None)` |
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` | | `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` | | `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` | | `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
@@ -24,4 +25,5 @@
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
+2 -1
View File
@@ -12,7 +12,7 @@
| `give_me_work` | `give_me_work()` | | `give_me_work` | `give_me_work()` |
| `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str, blocker_type: str | None = None, what_needed: str | None = None)` | | `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str, blocker_type: str | None = None, what_needed: str | None = None)` |
| `i_am_idle` | `i_am_idle()` | | `i_am_idle` | `i_am_idle()` |
| `pass_review` | `pass_review(task_id: UUID, notes: str)` | | `pass_review` | `pass_review(task_id: UUID, notes: str, ac_verdicts: list[str] | None = None)` |
| `resume` | `resume(task_id: UUID)` | | `resume` | `resume(task_id: UUID)` |
| `unclaim` | `unclaim(task_id: UUID)` | | `unclaim` | `unclaim(task_id: UUID)` |
@@ -27,4 +27,5 @@
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
+42 -3
View File
@@ -36,6 +36,7 @@ as the source of truth for verb signatures.
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
## qa ## qa
@@ -49,7 +50,7 @@ as the source of truth for verb signatures.
| `give_me_work` | `give_me_work()` | | `give_me_work` | `give_me_work()` |
| `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str, blocker_type: str | None = None, what_needed: str | None = None)` | | `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str, blocker_type: str | None = None, what_needed: str | None = None)` |
| `i_am_idle` | `i_am_idle()` | | `i_am_idle` | `i_am_idle()` |
| `pass_review` | `pass_review(task_id: UUID, notes: str)` | | `pass_review` | `pass_review(task_id: UUID, notes: str, ac_verdicts: list[str] | None = None)` |
| `resume` | `resume(task_id: UUID)` | | `resume` | `resume(task_id: UUID)` |
| `unclaim` | `unclaim(task_id: UUID)` | | `unclaim` | `unclaim(task_id: UUID)` |
@@ -64,6 +65,7 @@ as the source of truth for verb signatures.
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
## documenter ## documenter
@@ -94,6 +96,7 @@ as the source of truth for verb signatures.
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
## cell_pm ## cell_pm
@@ -103,7 +106,7 @@ as the source of truth for verb signatures.
| Verb | Body schema | | Verb | Body schema |
|------|-------------| |------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` | | `complete` | `complete(task_id: UUID, notes: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None)` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` | | `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` | | `give_me_work` | `give_me_work()` |
| `i_am_idle` | `i_am_idle()` | | `i_am_idle` | `i_am_idle()` |
@@ -130,6 +133,7 @@ as the source of truth for verb signatures.
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
## main_pm ## main_pm
@@ -139,7 +143,7 @@ as the source of truth for verb signatures.
| Verb | Body schema | | Verb | Body schema |
|------|-------------| |------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` | | `complete` | `complete(task_id: UUID, notes: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None)` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None)` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` | | `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` | | `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` | | `give_me_work` | `give_me_work()` |
@@ -166,6 +170,7 @@ as the source of truth for verb signatures.
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
## product_owner ## product_owner
@@ -183,6 +188,7 @@ as the source of truth for verb signatures.
| Tool | Body schema | | Tool | Body schema |
|------|-------------| |------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None)` | | `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None)` |
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` | | `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` | | `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` | | `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
@@ -191,6 +197,7 @@ as the source of truth for verb signatures.
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
## head_marketing ## head_marketing
@@ -208,6 +215,7 @@ as the source of truth for verb signatures.
| Tool | Body schema | | Tool | Body schema |
|------|-------------| |------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None)` | | `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None)` |
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` | | `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` | | `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` | | `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
@@ -216,6 +224,7 @@ as the source of truth for verb signatures.
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `notify_ack` | `notify_ack(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `channels` | `channels()` | | `channels` | `channels()` |
## auditor ## auditor
@@ -237,3 +246,33 @@ as the source of truth for verb signatures.
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
| `channels` | `channels()` | | `channels` | `channels()` |
## prompter
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
## secretary
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
+4 -2
View File
@@ -23,7 +23,7 @@ When the briefing carries `company_goals`, let the charter guide how you scope a
|---|---|---| |---|---|---|
| `give_me_work()` | Returns your highest-priority task (your own pending PM task, or a subtask in `awaiting_pm_review` for you to merge). | None. | | `give_me_work()` | Returns your highest-priority task (your own pending PM task, or a subtask in `awaiting_pm_review` for you to merge). | None. |
| `i_will_plan(task_id, plan, approach, sub_tasks, technical_considerations?, risks?, open_questions?)` | Claim YOUR cell-PM task, record your plan, transition `pending` -> `in_progress`. Always call this before `delegate`. **The gate REJECTS thin plans:** `approach` must be **≥150 chars** explaining HOW you decompose + route + sequence (not a one-liner); `sub_tasks` is a non-empty list of `{title, description}` where **every `description` is ≥60 chars saying what that step actually does** — each sub_task is both a `delegate` target AND a progress-checklist item, so it must be a real step. Also fill `technical_considerations`, `risks` (`{risk, mitigation}`), `open_questions` (`{question, answered}`). Example sub_task: `{"title": "Add timestamp comment to README", "description": "be-dev-1 edits README.md, prepends an HTML comment <!-- smoke-test: <date> --> above the H1, leaving the rest of the file untouched"}`. Empty/thin values are rejected, not just an empty Plan tab. | Task assigned to you; task in `pending`/`needs_revision`. | | `i_will_plan(task_id, plan, approach, sub_tasks, technical_considerations?, risks?, open_questions?)` | Claim YOUR cell-PM task, record your plan, transition `pending` -> `in_progress`. Always call this before `delegate`. **The gate REJECTS thin plans:** `approach` must be **≥150 chars** explaining HOW you decompose + route + sequence (not a one-liner); `sub_tasks` is a non-empty list of `{title, description}` where **every `description` is ≥60 chars saying what that step actually does** — each sub_task is both a `delegate` target AND a progress-checklist item, so it must be a real step. Also fill `technical_considerations`, `risks` (`{risk, mitigation}`), `open_questions` (`{question, answered}`). Example sub_task: `{"title": "Add timestamp comment to README", "description": "be-dev-1 edits README.md, prepends an HTML comment <!-- smoke-test: <date> --> above the H1, leaving the rest of the file untouched"}`. Empty/thin values are rejected, not just an empty Plan tab. | Task assigned to you; task in `pending`/`needs_revision`. |
| `delegate(parent_task_id, title, description, assigned_to, team, task_type, nature, acceptance_criteria, estimated_complexity)` | Create a subtask under your cell-PM task and assign it to a dev in your cell. `nature``technical`/`non_technical`. `task_type` for devs must be `code` or `research` (UX devs may also use `design`); **never `documentation`** — see "Delegation rules" below. Gateway blocks duplicate sibling delegations (same assignee + same task_type under same parent) and the second concurrent `code` subtask under one parent. | Parent claimed by you and `in_progress`; assignee is a dev slug in your cell. | | `delegate(parent_task_id, title, description, assigned_to, team, task_type, nature, acceptance_criteria, estimated_complexity, covers_parent_criteria?)` | Create a subtask under your cell-PM task and assign it to a dev in your cell. `nature``technical`/`non_technical`. `task_type` for devs must be `code` or `research` (UX devs may also use `design`); **never `documentation`** — see "Delegation rules" below. `covers_parent_criteria` is the list of YOUR criterion ids (from the briefing's `parent_ac_coverage`) this subtask satisfies — see "Coverage" below. Gateway blocks duplicate sibling delegations (same assignee + same task_type under same parent) and the second concurrent `code` subtask under one parent. | Parent claimed by you and `in_progress`; assignee is a dev slug in your cell. |
| `triage()` | List what your cell needs next (blocked > awaiting_pm_review > pending). | None. | | `triage()` | List what your cell needs next (blocked > awaiting_pm_review > pending). | None. |
| `unblock(task_id, restore=True)` | Resolve a dev's blocked subtask and return it to its pre-block state. | Subtask is in your cell. | | `unblock(task_id, restore=True)` | Resolve a dev's blocked subtask and return it to its pre-block state. | Subtask is in your cell. |
| `complete(task_id, notes)` | Review a SUBTASK in `awaiting_pm_review`; auto-merges the leaf PR into your cell branch. | All descendants of the subtask terminal; PR open and mergeable. | | `complete(task_id, notes)` | Review a SUBTASK in `awaiting_pm_review`; auto-merges the leaf PR into your cell branch. | All descendants of the subtask terminal; PR open and mergeable. |
@@ -133,13 +133,15 @@ Decomposition is where scope silently disappears. The failure mode: your cell-PM
**The rule: before you `i_am_idle()` after delegating, account for EVERY acceptance criterion on your cell-PM task.** Walk the list. For each criterion, name the subtask whose `acceptance_criteria` cover it. Three legal outcomes per criterion — and only three: **The rule: before you `i_am_idle()` after delegating, account for EVERY acceptance criterion on your cell-PM task.** Walk the list. For each criterion, name the subtask whose `acceptance_criteria` cover it. Three legal outcomes per criterion — and only three:
1. **Covered now** — a subtask you just delegated has an `acceptance_criteria` entry that satisfies it. (Make the mapping explicit: when you write a subtask's criteria, phrase them so a reader can trace each one back to the cell criterion it serves.) 1. **Covered now** — a subtask you just delegated has an `acceptance_criteria` entry that satisfies it. Make the mapping **machine-explicit**: pass `covers_parent_criteria=[<criterion ids>]` on that `delegate` so the gateway records which of YOUR criteria the child owns. The criterion ids are in your briefing under `parent_ac_coverage` (each `{id, text, claimed, verified}`); the ones still without a home are listed in `unclaimed_parent_acs`. Phrase the child's criteria so a reader can also trace each back by eye.
2. **Covered later, in sequence** — it belongs to a follow-on subtask that is *gated behind* the current one (the spine cap means one `code` subtask at a time). Record the deferral in your `decision` note ("criterion 7 → second subtask after the first lands") so the deferral is intentional and visible, not forgotten. 2. **Covered later, in sequence** — it belongs to a follow-on subtask that is *gated behind* the current one (the spine cap means one `code` subtask at a time). Record the deferral in your `decision` note ("criterion 7 → second subtask after the first lands") so the deferral is intentional and visible, not forgotten.
3. **Out of scope for your cell** — it genuinely belongs to another cell or the Main PM aggregate. Say so in the `decision` note. Do not silently drop it. 3. **Out of scope for your cell** — it genuinely belongs to another cell or the Main PM aggregate. Say so in the `decision` note. Do not silently drop it.
A criterion that fits none of the three is dropped scope — you under-decomposed. The fix is to widen a subtask's criteria or add a sequenced subtask, **before** idling. Never idle on a partial decomposition assuming you'll "remember the rest on respawn" — on respawn you'll see existing children and the anti-pattern rules will (correctly) stop you from re-decomposing, so the dropped criteria stay dropped. Map coverage now, while you still can. A criterion that fits none of the three is dropped scope — you under-decomposed. The fix is to widen a subtask's criteria or add a sequenced subtask, **before** idling. Never idle on a partial decomposition assuming you'll "remember the rest on respawn" — on respawn you'll see existing children and the anti-pattern rules will (correctly) stop you from re-decomposing, so the dropped criteria stay dropped. Map coverage now, while you still can.
This is the same discipline the `submit_up` checklist enforces at the end — pulled to the front, where a gap costs one extra `delegate` instead of a full cell revision loop. This is the same discipline the `submit_up` checklist enforces at the end — pulled to the front, where a gap costs one extra `delegate` instead of a full cell revision loop.
**The gateway now backs this up.** Once you start declaring `covers_parent_criteria`, `i_am_idle()` is **rejected** while any of your criteria remain in `unclaimed_parent_acs` — the reject names the uncovered criteria, and the fix is one more `delegate` (covering them) or, for a sequenced follow-on, leave the upstream child live and idle once every criterion is claimed by *some* live subtask. Check `parent_ac_coverage` in the response after each `delegate`: when `unclaimed_parent_acs` is empty, your decomposition covers the task and you may idle. (Mapping coverage is opt-in by design — if you never pass `covers_parent_criteria`, the gate stays silent — but declaring it is the expected practice and the only way the cell self-checks for dropped scope.)
7. `i_am_idle()` -> wait. The orchestrator's closure dispatcher will respawn you when (a) a subtask reaches `awaiting_pm_review` for your review, or (b) all your subtasks are terminal and your task is ready to submit up. 7. `i_am_idle()` -> wait. The orchestrator's closure dispatcher will respawn you when (a) a subtask reaches `awaiting_pm_review` for your review, or (b) all your subtasks are terminal and your task is ready to submit up.
8. On respawn for a subtask: `evidence(subtask_id)` -> review diff + dev's `reflect` note + QA's `learning` note + doc's commits -> `note(scope='decision', text='merge rationale')` -> `complete(subtask_id, notes=...)`. The leaf PR auto-merges into your cell branch. 8. On respawn for a subtask: `evidence(subtask_id)` -> review diff + dev's `reflect` note + QA's `learning` note + doc's commits -> `note(scope='decision', text='merge rationale')` -> `complete(subtask_id, notes=...)`. The leaf PR auto-merges into your cell branch.
9. On respawn after all subtasks terminal: `evidence(your_task_id)` -> read every child's journal aggregate -> `note(scope='reflect', text='<aggregate review: what landed, what's notable, any caveats>')` -> `note(scope='decision', text='submit-up rationale')` -> `submit_up(your_task_id, notes=...)`. Main PM takes over. 9. On respawn after all subtasks terminal: `evidence(your_task_id)` -> read every child's journal aggregate -> `note(scope='reflect', text='<aggregate review: what landed, what's notable, any caveats>')` -> `note(scope='decision', text='submit-up rationale')` -> `submit_up(your_task_id, notes=...)`. Main PM takes over.
+3 -1
View File
@@ -49,7 +49,7 @@ This is the single most common mental-model mistake at your seat. Get it right:
|---|---|---| |---|---|---|
| `give_me_work()` | Returns your highest-priority task (your root in `pending`, or a cell-PM task in `awaiting_pm_review` for you to merge). | None. | | `give_me_work()` | Returns your highest-priority task (your root in `pending`, or a cell-PM task in `awaiting_pm_review` for you to merge). | None. |
| `i_will_plan(task_id, plan, approach, sub_tasks, technical_considerations?, risks?, open_questions?)` | Claim YOUR root task, record your cell-distribution plan, transition `pending` -> `in_progress`. Always call this before `delegate`. **The gate REJECTS thin plans:** `approach` must be **≥150 chars** describing HOW you split work across cells + sequencing + dependencies (not a one-liner); `sub_tasks` is a non-empty list of `{title, description}` where **every `description` is ≥60 chars** stating what that cell slice delivers — each sub_task is both a `delegate` target AND a progress-checklist item. Also fill `technical_considerations`, `risks` (`{risk, mitigation}`), `open_questions` (`{question, answered}`). Empty/thin values are rejected, not just an empty Plan tab. | Task assigned to you; task in `pending`/`needs_revision`. | | `i_will_plan(task_id, plan, approach, sub_tasks, technical_considerations?, risks?, open_questions?)` | Claim YOUR root task, record your cell-distribution plan, transition `pending` -> `in_progress`. Always call this before `delegate`. **The gate REJECTS thin plans:** `approach` must be **≥150 chars** describing HOW you split work across cells + sequencing + dependencies (not a one-liner); `sub_tasks` is a non-empty list of `{title, description}` where **every `description` is ≥60 chars** stating what that cell slice delivers — each sub_task is both a `delegate` target AND a progress-checklist item. Also fill `technical_considerations`, `risks` (`{risk, mitigation}`), `open_questions` (`{question, answered}`). Empty/thin values are rejected, not just an empty Plan tab. | Task assigned to you; task in `pending`/`needs_revision`. |
| `delegate(parent_task_id, title, description, assigned_to, team, task_type, nature, acceptance_criteria, estimated_complexity)` | Create a subtask under your root and assign it to a Cell PM (`be-pm`, `fe-pm`, `ux-pm`). One subtask per cell that needs work. **`task_type` must be `planning`** (Cell PMs decompose; they don't execute). `nature``technical`/`non_technical`. Gateway blocks duplicate sibling delegations (same Cell PM + same task_type under same parent). | Parent claimed by you and `in_progress`; assignee is a Cell PM slug. | | `delegate(parent_task_id, title, description, assigned_to, team, task_type, nature, acceptance_criteria, estimated_complexity, covers_parent_criteria?)` | Create a subtask under your root and assign it to a Cell PM (`be-pm`, `fe-pm`, `ux-pm`). One subtask per cell that needs work. **`task_type` must be `planning`** (Cell PMs decompose; they don't execute). `nature``technical`/`non_technical`. `covers_parent_criteria` is the list of YOUR root criterion ids (from the briefing's `parent_ac_coverage`) this cell now owns — map every root criterion to a cell before you idle. Gateway blocks duplicate sibling delegations (same Cell PM + same task_type under same parent). | Parent claimed by you and `in_progress`; assignee is a Cell PM slug. |
| `triage_all()` | List blockers and reviews across all cells. | None. | | `triage_all()` | List blockers and reviews across all cells. | None. |
| `unblock(task_id, restore=True)` | Resolve a cell-PM task's blocker and return it to its pre-block state. | None. | | `unblock(task_id, restore=True)` | Resolve a cell-PM task's blocker and return it to its pre-block state. | None. |
| `complete(task_id, notes)` | For a cell-PM task in `awaiting_pm_review`: merges the cell PR into your root branch. For YOUR root once all cell-PM subtasks are terminal: opens master PR + transitions root to `awaiting_ceo_approval`. | All descendants terminal; journal `decision` recorded. | | `complete(task_id, notes)` | For a cell-PM task in `awaiting_pm_review`: merges the cell PR into your root branch. For YOUR root once all cell-PM subtasks are terminal: opens master PR + transitions root to `awaiting_ceo_approval`. | All descendants terminal; journal `decision` recorded. |
@@ -133,6 +133,8 @@ The description is a **brief**, not a spec. The Cell PM and its dev design and b
**Forward the work-unit breakdown — don't flatten it.** The upstream draft's "The Work" already enumerates this cell's work as independently-shippable units in dependency order. Carry that breakdown into the brief: list the units, note which are independent of each other, and tell the Cell PM to refine each unit into its own developer leaf so both cell developers can build at the same time (aim for at least two parallel units where the work genuinely splits). Do NOT compress the units into one "build all of it" slice — that recreates the oversized-task problem one level down and is how acceptance criteria get dropped. If the upstream draft did not break the work down, do that breakdown yourself before you delegate. **Forward the work-unit breakdown — don't flatten it.** The upstream draft's "The Work" already enumerates this cell's work as independently-shippable units in dependency order. Carry that breakdown into the brief: list the units, note which are independent of each other, and tell the Cell PM to refine each unit into its own developer leaf so both cell developers can build at the same time (aim for at least two parallel units where the work genuinely splits). Do NOT compress the units into one "build all of it" slice — that recreates the oversized-task problem one level down and is how acceptance criteria get dropped. If the upstream draft did not break the work down, do that breakdown yourself before you delegate.
Keep it to goal + constraints + the unit breakdown; the `acceptance_criteria` above define "done", and the Cell PM owns the HOW. Keep it to goal + constraints + the unit breakdown; the `acceptance_criteria` above define "done", and the Cell PM owns the HOW.
**Map your root's criteria to the cell subtask that owns them.** Your briefing carries `parent_ac_coverage` (each root criterion as `{id, text, claimed, verified}`) and `unclaimed_parent_acs` (the ids with no cell subtask yet). When you `delegate` a slice to a cell, pass `covers_parent_criteria=[<root criterion ids>]` naming which root criteria that cell now owns — every root criterion must be claimed by some cell before you idle. Once you start declaring coverage, the gateway **rejects `i_am_idle()`** while `unclaimed_parent_acs` is non-empty, naming the gap; the fix is one more `delegate` to the cell that should own it. (Opt-in: if you never pass `covers_parent_criteria` the gate stays silent, but declaring it is how a dropped cross-cell criterion gets caught here instead of at the CEO.)
7. `i_am_idle()` -> wait. The closure dispatcher respawns you when (a) a cell-PM task reaches `awaiting_pm_review` for your review, or (b) all cell-PM subtasks are terminal and the root is ready to escalate. 7. `i_am_idle()` -> wait. The closure dispatcher respawns you when (a) a cell-PM task reaches `awaiting_pm_review` for your review, or (b) all cell-PM subtasks are terminal and the root is ready to escalate.
8. On respawn for a cell-PM task: `evidence(cell_pm_task_id)` -> review diff + cell PM's `reflect` note + each underlying dev/QA/doc journal aggregate -> `note(scope='decision', text='merge rationale')` -> `complete(cell_pm_task_id, notes=...)`. The cell PR auto-merges into your root branch. 8. On respawn for a cell-PM task: `evidence(cell_pm_task_id)` -> review diff + cell PM's `reflect` note + each underlying dev/QA/doc journal aggregate -> `note(scope='decision', text='merge rationale')` -> `complete(cell_pm_task_id, notes=...)`. The cell PR auto-merges into your root branch.
9. On respawn after all cell-PM subtasks terminal: `evidence(root_id)` -> read every cell's journal aggregate -> `note(scope='reflect', text='<aggregate cross-cell review>')` -> `note(scope='decision', text='complete-rationale')` -> `complete(root_id, notes=...)`. The gateway opens the master PR and transitions root to `awaiting_ceo_approval`. CEO takes it from there. 9. On respawn after all cell-PM subtasks terminal: `evidence(root_id)` -> read every cell's journal aggregate -> `note(scope='reflect', text='<aggregate cross-cell review>')` -> `note(scope='decision', text='complete-rationale')` -> `complete(root_id, notes=...)`. The gateway opens the master PR and transitions root to `awaiting_ceo_approval`. CEO takes it from there.
+91 -4
View File
@@ -705,7 +705,12 @@ class Choreographer:
) )
async def _briefing_for( async def _briefing_for(
self, agent_id: UUID, task_id: UUID | None, *, task: Any | None = None self,
agent_id: UUID,
task_id: UUID | None,
*,
task: Any | None = None,
include_ac_coverage: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Assemble context_briefing for agent_id, optionally scoped to task_id. """Assemble context_briefing for agent_id, optionally scoped to task_id.
@@ -713,6 +718,12 @@ class Choreographer:
path holds it). The prior-work handoff is built only when it is passed path holds it). The prior-work handoff is built only when it is passed
no extra fetch so task-scoped error paths that carry only an id simply no extra fetch so task-scoped error paths that carry only an id simply
omit the digest rather than pay a redundant read for it. omit the digest rather than pay a redundant read for it.
``include_ac_coverage`` is set only on PM decomposition touchpoints
(give_me_work / i_will_plan / delegate / submit_up) so the PM sees, per
parent criterion, what is still unclaimed and can pass
``covers_parent_criteria`` on delegate. Off everywhere else so a leaf's
own criteria never surface as bogus "unclaimed" noise to a developer.
""" """
repo = self._deps.evidence_repo repo = self._deps.evidence_repo
task_handoff: dict[str, Any] | None = None task_handoff: dict[str, Any] | None = None
@@ -734,7 +745,22 @@ class Choreographer:
task_handoff=task_handoff, task_handoff=task_handoff,
company_goals=await repo.company_goals(), company_goals=await repo.company_goals(),
) )
return build_context_briefing(inputs) briefing = build_context_briefing(inputs)
if include_ac_coverage and task_id is not None:
coverage = await self.task.parent_ac_coverage(task_id)
if coverage:
# Compact decomposition view: surface each parent criterion's id
# so the PM can map subtasks to it via covers_parent_criteria,
# and call out the ones still unclaimed. Mirrors how sizing_hint
# is merged ad-hoc — context_briefing is free-form dict[str, Any].
briefing = {
**briefing,
"parent_ac_coverage": coverage,
"unclaimed_parent_acs": [
c["id"] for c in coverage if not c["claimed"]
],
}
return briefing
async def _run_claim_guards( async def _run_claim_guards(
self, self,
@@ -2863,6 +2889,10 @@ class Choreographer:
return await self._emit_rejection( return await self._emit_rejection(
guard, agent_id=agent_id, task_id=None, verb="i_am_idle" guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
) )
if guard := await self._pm_uncovered_decomposition_guard(agent_id, briefing):
return await self._emit_rejection(
guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
)
paused_ids = await self._auto_pause_in_progress_tasks(agent_id) paused_ids = await self._auto_pause_in_progress_tasks(agent_id)
await self.task.mark_agent_idle(agent_id) await self.task.mark_agent_idle(agent_id)
if paused_ids: if paused_ids:
@@ -2956,6 +2986,49 @@ class Choreographer:
context_briefing=briefing, context_briefing=briefing,
) )
async def _pm_uncovered_decomposition_guard(
self, agent_id: UUID, briefing: dict[str, Any]
) -> Envelope | None:
"""Refuse i_am_idle when a PM left a parent criterion unclaimed (Spec 2).
The decomposition floor: a PM that has mapped *some* children to parent
acceptance criteria (``covers_parent_criteria``) cannot idle while
another parent criterion still has no subtask responsible for it the
"two leaves, half the ACs silently dropped" pattern from PR #175.
Distinct from the roll-up gate (Spec 4), which fires at submit_up /
complete / escalate and demands a *completed* child; this fires earlier,
at PM exit, and asks only that every criterion be *claimed*.
Safe-by-construction: ``unclaimed_parent_acceptance_criteria`` returns
``[]`` until the PM declares coverage on at least one child, so this is
inert for legacy / not-yet-adopted decompositions and never blocks a PM
who never touched coverage at all.
"""
agent = await self.task.agent_for(agent_id)
if not agent or agent.role not in ("cell_pm", "main_pm"):
return None
assigned = await self.task.list_assigned_for_agent(agent_id)
for parent in assigned:
if str(parent.status) in self._TERMINAL_STATUSES:
continue
unclaimed = await self.task.unclaimed_parent_acceptance_criteria(parent.id)
# isinstance keeps the gate inert under partial test mocks (an
# AsyncMock TaskService returns a truthy stub, not a concrete list).
if not isinstance(unclaimed, list) or not unclaimed:
continue
listing = "; ".join(unclaimed)
return Envelope.invalid_state(
message=(
f"task {parent.id} has {len(unclaimed)} acceptance criteria with"
" no subtask responsible for them; cannot idle mid-decomposition."
),
remediate=(
"delegate (or reassign) subtasks whose covers_parent_criteria"
f" include these criteria, then retry i_am_idle: {listing}"
),
context_briefing=briefing,
)
return None
async def _auto_pause_in_progress_tasks(self, agent_id: UUID) -> list[str]: async def _auto_pause_in_progress_tasks(self, agent_id: UUID) -> list[str]:
"""Pause every in_progress task assigned to this agent. """Pause every in_progress task assigned to this agent.
@@ -3086,7 +3159,9 @@ class Choreographer:
) )
agent = await self.task.agent_for(pm_agent_id) agent = await self.task.agent_for(pm_agent_id)
role_str = str(agent.role) if agent is not None else "cell_pm" role_str = str(agent.role) if agent is not None else "cell_pm"
briefing = await self._briefing_for(pm_agent_id, task_id, task=t) briefing = await self._briefing_for(
pm_agent_id, task_id, task=t, include_ac_coverage=True
)
try: try:
role = spec_module.Role(role_str) role = spec_module.Role(role_str)
except ValueError: except ValueError:
@@ -3906,6 +3981,16 @@ class Choreographer:
hint = self._sizing_hint(inputs) hint = self._sizing_hint(inputs)
if hint is not None: if hint is not None:
briefing = {**briefing, "sizing_hint": hint} briefing = {**briefing, "sizing_hint": hint}
# Recompute coverage AFTER creation so the PM sees this child counted and
# exactly which parent criteria are still unclaimed before the next
# delegate / idle. The pre-create briefing built in delegate() is stale.
coverage = await self.task.parent_ac_coverage(parent_task_id)
if coverage:
briefing = {
**briefing,
"parent_ac_coverage": coverage,
"unclaimed_parent_acs": [c["id"] for c in coverage if not c["claimed"]],
}
return Envelope.ok( return Envelope.ok(
status="created", status="created",
task_id=str(new_task.id), task_id=str(new_task.id),
@@ -4211,7 +4296,9 @@ class Choreographer:
returns, the task is handed off to the Main PM (reassign + a2a). returns, the task is handed off to the Main PM (reassign + a2a).
""" """
t = await self.task.get(task_id) t = await self.task.get(task_id)
briefing = await self._briefing_for(pm_agent_id, task_id, task=t) briefing = await self._briefing_for(
pm_agent_id, task_id, task=t, include_ac_coverage=True
)
if t is None: if t is None:
return await self._emit_rejection( return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"), Envelope.not_found(message=f"task {task_id} not found"),
+94 -16
View File
@@ -5671,6 +5671,51 @@ class TaskService(BaseService):
statuses = result.scalars().all() statuses = result.scalars().all()
return all(s in terminal for s in statuses) return all(s in terminal for s in statuses)
async def _parent_ac_ref_sets(
self, task_id: UUID
) -> tuple[TaskTable, set[str], set[str], bool] | None:
"""Load a parent and its children's parent-AC-ref coverage sets.
Shared core of the three AC-coverage primitives. Returns ``None`` when
the parent is missing or has no stable criterion ids (nothing to cover).
Otherwise ``(parent, claimed, verified, any_declared)`` where
``claimed`` is the union of parent_ac_refs over all non-cancelled
children, ``verified`` the union over COMPLETED children only, and
``any_declared`` whether *any* child declared a ref at all (the
safe-by-construction inertness signal a cancelled-only declaration
still counts as "coverage tracking is active here").
"""
parent = await self.get(task_id)
if not parent or not parent.acceptance_criteria_ids:
return None
result = await self.session.execute(
select(TaskTable.status, TaskTable.parent_ac_refs).where(
TaskTable.parent_task_id == task_id
)
)
claimed: set[str] = set()
verified: set[str] = set()
any_declared = False
for status, refs in result.all():
refset = set(refs or [])
any_declared = any_declared or bool(refset)
if status != TaskStatus.CANCELLED:
claimed |= refset
if status == TaskStatus.COMPLETED:
verified |= refset
return parent, claimed, verified, any_declared
@staticmethod
def _criteria_texts_not_in(parent: TaskTable, covered: set[str]) -> list[str]:
"""Texts of the parent criteria whose id is not in ``covered``."""
ids = parent.acceptance_criteria_ids or []
texts = parent.acceptance_criteria or []
return [
texts[idx] if idx < len(texts) else ac_id
for idx, ac_id in enumerate(ids)
if ac_id not in covered
]
async def uncovered_parent_acceptance_criteria(self, task_id: UUID) -> list[str]: async def uncovered_parent_acceptance_criteria(self, task_id: UUID) -> list[str]:
"""Parent ACs not yet satisfied by a COMPLETED child — for the roll-up gate. """Parent ACs not yet satisfied by a COMPLETED child — for the roll-up gate.
@@ -5682,28 +5727,61 @@ class TaskService(BaseService):
COMPLETED (cancelled children do not count their work did not pass QA). COMPLETED (cancelled children do not count their work did not pass QA).
Returns the uncovered criterion *texts* for a human-readable rejection. Returns the uncovered criterion *texts* for a human-readable rejection.
""" """
parent = await self.get(task_id) loaded = await self._parent_ac_ref_sets(task_id)
if not parent or not parent.acceptance_criteria: if loaded is None:
return [] return []
result = await self.session.execute( parent, _claimed, verified, any_declared = loaded
select(TaskTable.status, TaskTable.parent_ac_refs).where( if not any_declared:
TaskTable.parent_task_id == task_id
)
)
rows = list(result.all())
if not any((refs or []) for _status, refs in rows):
# Decomposition predates coverage tracking — do not enforce.
return [] return []
covered: set[str] = set() return self._criteria_texts_not_in(parent, verified)
for status, refs in rows:
if status == TaskStatus.COMPLETED: async def unclaimed_parent_acceptance_criteria(self, task_id: UUID) -> list[str]:
covered.update(refs or []) """Parent ACs not claimed by any live child — the decomposition floor.
Spec-2 counterpart of ``uncovered_parent_acceptance_criteria`` (the
roll-up gate): where that one asks whether every criterion is satisfied
by a COMPLETED child, this asks the earlier, weaker question has every
criterion been *claimed* by some still-live subtask? Used at PM exit
(``i_am_idle``) so a PM cannot finish decomposing while a parent
criterion has no subtask responsible for it (the "two leaves, half the
ACs dropped" pattern). Safe-by-construction: inert (``[]``) until a PM
declares coverage on at least one child, mirroring the roll-up gate.
Returns the uncovered criterion texts for a human-readable rejection.
"""
loaded = await self._parent_ac_ref_sets(task_id)
if loaded is None:
return []
parent, claimed, _verified, any_declared = loaded
if not any_declared:
return []
return self._criteria_texts_not_in(parent, claimed)
async def parent_ac_coverage(self, task_id: UUID) -> list[dict[str, Any]]:
"""Per-criterion decomposition coverage for a parent task.
For each parent acceptance criterion returns its stable id, text, and
whether some live (non-cancelled) child claims it via ``parent_ac_refs``
(``claimed``) and whether a COMPLETED child does (``verified``). Returns
``[]`` when the task has no ``acceptance_criteria_ids``. Unlike the gate
primitives this is deliberately NOT inert when coverage is undeclared
it always reports the parent's criteria so a decomposing PM can see, per
delegate, which criteria still lack a subtask. Visibility source for the
decomposition briefing; the gates read the inert variants instead.
"""
loaded = await self._parent_ac_ref_sets(task_id)
if loaded is None:
return []
parent, claimed, verified, _any = loaded
ids = parent.acceptance_criteria_ids or [] ids = parent.acceptance_criteria_ids or []
texts = parent.acceptance_criteria or [] texts = parent.acceptance_criteria or []
return [ return [
texts[idx] if idx < len(texts) else ac_id {
"id": ac_id,
"text": texts[idx] if idx < len(texts) else ac_id,
"claimed": ac_id in claimed,
"verified": ac_id in verified,
}
for idx, ac_id in enumerate(ids) for idx, ac_id in enumerate(ids)
if ac_id not in covered
] ]
async def set_plan( async def set_plan(
@@ -203,3 +203,63 @@ async def test_i_am_idle_allows_dev_owning_awaiting_pm_review() -> None:
env = await c.i_am_idle(agent_id) env = await c.i_am_idle(agent_id)
assert env.status == "idle" assert env.status == "idle"
task_svc.mark_agent_idle.assert_awaited_once() task_svc.mark_agent_idle.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_idle_refuses_pm_with_uncovered_decomposition() -> None:
"""A PM that declared coverage but left a parent criterion unclaimed cannot
idle the decomposition floor (Spec 2)."""
agent_id = uuid4()
parent_id = uuid4()
parent = MagicMock(id=parent_id, status="in_progress")
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = [parent]
task_svc.list_in_progress_for_agent.return_value = [parent]
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
task_svc.unclaimed_parent_acceptance_criteria.return_value = ["crit b", "crit c"]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert str(parent_id) in body["message"]
assert "covers_parent_criteria" in body["remediate"]
assert "crit b" in body["remediate"] and "crit c" in body["remediate"]
task_svc.mark_agent_idle.assert_not_awaited()
@pytest.mark.asyncio
async def test_i_am_idle_allows_pm_with_full_coverage() -> None:
"""Coverage primitive returns [] (covered or undeclared) -> PM idles through."""
agent_id = uuid4()
parent = MagicMock(id=uuid4(), status="in_progress")
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = [parent]
task_svc.list_in_progress_for_agent.return_value = []
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
task_svc.unclaimed_parent_acceptance_criteria.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
assert env.status == "idle"
task_svc.mark_agent_idle.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_idle_decomposition_guard_is_pm_only() -> None:
"""The decomposition floor is PM-only: a developer never hits it even with a
(hypothetical) unclaimed list, and the coverage primitive is not consulted."""
agent_id = uuid4()
parent = MagicMock(id=uuid4(), status="in_progress")
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = [parent]
task_svc.list_in_progress_for_agent.return_value = []
task_svc.agent_for.return_value = MagicMock(role="developer")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
assert env.status == "idle"
task_svc.unclaimed_parent_acceptance_criteria.assert_not_awaited()
+74
View File
@@ -672,6 +672,80 @@ async def test_uncovered_parent_acs_empty_when_all_covered() -> None:
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == [] assert await svc.uncovered_parent_acceptance_criteria(parent.id) == []
@pytest.mark.asyncio
async def test_parent_ac_coverage_maps_claimed_and_verified() -> None:
# Per-criterion visibility: a COMPLETED child both claims and verifies its
# criterion; an in-flight child only claims; an untouched criterion is
# neither. This is the digest a decomposing PM reads from the briefing.
parent = _build_task(
acceptance_criteria=["crit a", "crit b", "crit c"],
acceptance_criteria_ids=["id-a", "id-b", "id-c"],
)
svc = _svc_with_children(
parent,
[
(TaskStatus.COMPLETED, ["id-a"]),
(TaskStatus.IN_PROGRESS, ["id-b"]),
],
)
assert await svc.parent_ac_coverage(parent.id) == [
{"id": "id-a", "text": "crit a", "claimed": True, "verified": True},
{"id": "id-b", "text": "crit b", "claimed": True, "verified": False},
{"id": "id-c", "text": "crit c", "claimed": False, "verified": False},
]
@pytest.mark.asyncio
async def test_parent_ac_coverage_empty_without_ac_ids() -> None:
# No stable ids on the parent (e.g. created before the linkage) -> nothing to
# report; the digest stays absent rather than emitting bogus rows.
parent = _build_task(acceptance_criteria=["a"], acceptance_criteria_ids=[])
svc = _svc_with_children(parent, [(TaskStatus.IN_PROGRESS, ["id-a"])])
assert await svc.parent_ac_coverage(parent.id) == []
@pytest.mark.asyncio
async def test_unclaimed_parent_acs_inert_without_declared_coverage() -> None:
# The decomposition floor is opt-in: with no child declaring parent_ac_refs
# it returns [] so a PM who never adopts coverage is never blocked at idle.
parent = _build_task(
acceptance_criteria=["a", "b"], acceptance_criteria_ids=["id-a", "id-b"]
)
svc = _svc_with_children(
parent, [(TaskStatus.IN_PROGRESS, []), (TaskStatus.IN_PROGRESS, [])]
)
assert await svc.unclaimed_parent_acceptance_criteria(parent.id) == []
@pytest.mark.asyncio
async def test_unclaimed_parent_acs_counts_live_children_not_just_completed() -> None:
# The distinction from the roll-up gate: an in-flight child *claims* its
# criterion (so the decomposition floor is satisfied) even though it has not
# yet *verified* it (so the roll-up gate still flags it). A cancelled child's
# claim does not count -- its work died with it.
parent = _build_task(
acceptance_criteria=["crit a", "crit b", "crit c"],
acceptance_criteria_ids=["id-a", "id-b", "id-c"],
)
rows = [
(TaskStatus.IN_PROGRESS, ["id-a"]), # live -> claims crit a
(TaskStatus.CANCELLED, ["id-b"]), # cancelled -> claim void
]
# unclaimed: crit a is claimed by the live child; crit b (only the cancelled
# child) and crit c (nobody) remain.
assert await _svc_with_children(parent, rows).unclaimed_parent_acceptance_criteria(
parent.id
) == ["crit b", "crit c"]
# roll-up still flags crit a too: the live child has not COMPLETED it.
assert await _svc_with_children(parent, rows).uncovered_parent_acceptance_criteria(
parent.id
) == [
"crit a",
"crit b",
"crit c",
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unblock_with_branch_resumes_in_progress() -> None: async def test_unblock_with_branch_resumes_in_progress() -> None:
# A task claimed (has a branch) before it blocked resumes in_progress. # A task claimed (has a branch) before it blocked resumes in_progress.