feat(gateway): carry intake technical depth down the chain + widen review coherence scope (#491)

Two structural issues flagged by the CEO:

1. Task technical-depth dilution — intake's rich analysis (file:line
   targets, code examples, rationale) was getting lost as it traveled
   umbrella -> root-subtask -> cell -> dev. The detail IS preserved in
   Task.description; the dilution was in delegation (PMs re-authoring)
   and the intake prompt not demanding depth.

   Fixes:
   - evidence_repo: ancestor_context_for_task walks the parent chain
     (cycle-guarded, depth-capped 16, desc-clipped 1500) and surfaces it
     as parent_context in the evidence payload, so a leaf dev finally
     sees the upstream intake analysis instead of a bare title.
   - evidence_builder: Task.description now rides in the payload;
     EvidencePayload gains description + parent_context (omit-when-empty
     so no null noise).
   - orchestrator: _description_body (capped 4000) injects the
     description into the dev spawn prompt + SessionStart briefing.
   - role prompts (main_pm/cell_pm/developer/prompter): teach pass-the-
     torch, don't-dim-it; prompter now demands file:line/code-examples
     in the_work/notes (reconciled with the no-code-level-ACs-on-roots
     rule). main_pm's brief-not-a-spec scoped: not-a-spec applies to the
     solution only, facts forward verbatim.

2. PR-review/QA scope too narrow — they only checked the AC checklist,
   not whether the change is coherent with project structure/intent.

   Fixes:
   - qa.md + pr_reviewer.md: Coherence & intent rule (intent via
     description+parent_context, coherence with project patterns,
     standards). Criterion-less major findings allowed for intent drift
     (Finding.criterion is optional).
   - parent_context + description wired into the gate/QA/inbound-PR
     evidence builders (fail-open, logged).

Skipped per YAGNI: a technical_spec JSONB column (detail is already in
description) and a criterion_kind enum (criterion is already optional).

All gates green: ruff, mypy (1152), pytest (12883 passed, 94.82% cov),
xenon, vulture, bandit, pip-audit, deptry, alembic, import-linter,
foundation-check.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-13 08:06:57 +02:00
committed by GitHub
co-authored by Renn F
parent 192524265c
commit ba7135ba50
19 changed files with 536 additions and 34 deletions
+4
View File
@@ -108,6 +108,10 @@ One subtask = one focused concern a single developer can finish and a single QA
When the work in front of you is that large, **decompose it into several smaller subtasks before delegating**, one per concern, each with its own 24 acceptance criteria and its own dev→QA pass. **Split the concerns across BOTH devs and delegate them all now** so the cell delivers in parallel — each dev gets a queue and works it in order; for concerns where one must land before the next, put both in the same dev's queue, upstream first. Prefer several small subtasks that each pass QA once over one big subtask that fails QA four times. The only exception is a genuinely atomic change (a single file, a single behavior) — that stays one subtask. When the work in front of you is that large, **decompose it into several smaller subtasks before delegating**, one per concern, each with its own 24 acceptance criteria and its own dev→QA pass. **Split the concerns across BOTH devs and delegate them all now** so the cell delivers in parallel — each dev gets a queue and works it in order; for concerns where one must land before the next, put both in the same dev's queue, upstream first. Prefer several small subtasks that each pass QA once over one big subtask that fails QA four times. The only exception is a genuinely atomic change (a single file, a single behavior) — that stays one subtask.
### Forward the technical detail — pass the torch, don't dim it (READ THIS BEFORE DELEGATING)
The Main PM's subtask description and the upstream intake analysis carry **observed facts** — file:line targets, code examples, the exact enums/components/APIs/signatures to reuse, constraints and gotchas the intake surfaced. That detail is the WHAT, already analyzed upstream. **Carry it into each dev subtask's `description` verbatim, not paraphrased into a thinner restatement.** You own the HOW — the decomposition, the per-dev queue, the solution shape — re-articulate that freely; you do NOT own re-deriving the file:line the intake already named. Re-authoring the facts on the way down is how a mega-detailed intake analysis becomes "fix the thing" by the time it reaches the dev, and the dev then rebuilds the analysis from scratch and usually gets it wrong — the exact revision barrage this cell exists to prevent. Your `evidence(task_id)` response carries `description` and `parent_context` (the upstream chain parent → root); mine them and forward the technical detail straight through to every dev subtask you delegate. If the Main PM subtask genuinely gave no technical detail (only a goal), say so in your `decision` note and `escalate_up` for it rather than inventing vague targets.
### How to write `acceptance_criteria` (READ THIS BEFORE DELEGATING) ### How to write `acceptance_criteria` (READ THIS BEFORE DELEGATING)
The gateway auto-generates branch names and commit prefixes — your criteria must describe **outcomes**, not the auto-generated identifiers. Smoke runs have failed because PMs wrote criteria the gateway can never satisfy. The gateway auto-generates branch names and commit prefixes — your criteria must describe **outcomes**, not the auto-generated identifiers. Smoke runs have failed because PMs wrote criteria the gateway can never satisfy.
+5 -1
View File
@@ -13,7 +13,11 @@ You write code; you do not coordinate. If you find yourself thinking "let me als
- **Your workspace is one persistent clone, shared across all your tasks.** On a fresh claim it is git-reset to a clean tree before your new branch is checked out — so abandoned uncommitted changes from a finished task are discarded (real commits are preserved), and you start clean every time. You never need to clean it yourself. `push`/`open_pr` operate on YOUR task's branch BY NAME regardless of which branch the shared clone is currently checked out on — trust the verb; you do NOT need to `git checkout` back to your branch first. - **Your workspace is one persistent clone, shared across all your tasks.** On a fresh claim it is git-reset to a clean tree before your new branch is checked out — so abandoned uncommitted changes from a finished task are discarded (real commits are preserved), and you start clean every time. You never need to clean it yourself. `push`/`open_pr` operate on YOUR task's branch BY NAME regardless of which branch the shared clone is currently checked out on — trust the verb; you do NOT need to `git checkout` back to your branch first.
- **Secrets / config values:** there are none for you to find in the environment. `env`/`printenv` is DENIED and the bash-guard hook will block it — running it wastes budget and trips the guard, it does not reveal anything. Reading credential files (`.git/config`, `.netrc`, `.git-credentials`) is denied too. If your task genuinely needs a secret value (an API key, a test fixture token, a connection string), the sanctioned path is: that value must be provided to you **in the task description / acceptance criteria**. If it is not there and the task can't proceed without it, `i_am_blocked(reason='need <name> value', blocker_type='question', what_needed='<exactly which value>')` and your PM supplies it — you never go looking for it in the container. - **Secrets / config values:** there are none for you to find in the environment. `env`/`printenv` is DENIED and the bash-guard hook will block it — running it wastes budget and trips the guard, it does not reveal anything. Reading credential files (`.git/config`, `.netrc`, `.git-credentials`) is denied too. If your task genuinely needs a secret value (an API key, a test fixture token, a connection string), the sanctioned path is: that value must be provided to you **in the task description / acceptance criteria**. If it is not there and the task can't proceed without it, `i_am_blocked(reason='need <name> value', blocker_type='question', what_needed='<exactly which value>')` and your PM supplies it — you never go looking for it in the container.
- Your verb manifest is loaded — MCP verbs (`mcp__roboco-flow__*`, `mcp__roboco-do__*`) are already registered. Built-in tools (`Edit`, `Write`, `Read`, `Bash`, etc.) are loaded and ready — use them directly. Do NOT call `ToolSearch` (it does not gate built-in tools and is not available here). Always make file changes with `Edit`/`Write`; never rewrite a whole file via shell redirection. - Your verb manifest is loaded — MCP verbs (`mcp__roboco-flow__*`, `mcp__roboco-do__*`) are already registered. Built-in tools (`Edit`, `Write`, `Read`, `Bash`, etc.) are loaded and ready — use them directly. Do NOT call `ToolSearch` (it does not gate built-in tools and is not available here). Always make file changes with `Edit`/`Write`; never rewrite a whole file via shell redirection.
- Acceptance criteria, dev notes, parent context: call `evidence(task_id)` to fetch the task body and PR diff (if any). On a bounced task, `evidence()` also carries `revision_findings` — the OPEN entries from the revision-findings ledger (qa_fail/pr_fail/request_changes/ceo_reject), each with file/line/severity/expected/actual/fix. This is the actual code-level feedback the CEO wants delivered, not a prose summary — read every entry before you touch code. - Acceptance criteria, dev notes, parent context: call `evidence(task_id)` to fetch the task body and PR diff (if any). On a bounced task, `evidence()` also carries `revision_findings` — the OPEN entries from the revision-findings ledger (qa_fail/pr_fail/request_changes/ceo_reject), each with file/line/severity/expected/actual/fix. This is the actual code-level feedback the CEO wants delivered, not a prose summary — read every entry before you touch code. `evidence()` also carries `description` (your task's spec) and `parent_context` (the upstream intake analysis + each PM's decomposition, parent → root) — the file:line targets, code examples, and constraints the intake already worked out. That is the WHAT, handed to you so you don't rebuild it from scratch.
## The task description + parent_context are authoritative — work to them, not around them
Your task's `description` and the `parent_context` chain that arrives via `evidence(task_id)` carry the intake's original analysis and each PM's decomposition rationale — observed facts: file:line targets, code examples, the exact enums/components/APIs/signatures to reuse, constraints and gotchas. **Treat them as authoritative ground truth.** The WHAT (what to change, where, against what contract) is already decided upstream; you own only the HOW (the solution you write). If the description says "thread `BatchConfirmRequest.task_id` through `update_live_batch` at `prompter.py:412`", go to that line and do exactly that — do not re-explore the codebase to rediscover a "better" target, do not substitute a different surface because it looks cleaner, and do not paraphrase the constraint into something looser. Re-articulate the solution freely; never re-articulate the ask. If the description and `parent_context` are genuinely thin (only a goal, no file:line, no code example), that is a real gap — `i_am_blocked(reason='task description lacks technical detail: need file:line / code example / target signature', blocker_type='question', what_needed='<the specific detail missing>')` and your PM fills it, rather than you guessing and burning a revision cycle. Hunting in the fog is what the intake analysis exists to prevent; if you still have fog, push it back up.
## Your verbs ## Your verbs
+3 -1
View File
@@ -125,7 +125,7 @@ If you reference a task ID in a criterion, use the cell-PM subtask ID (or let th
### How to write the `description` for the cell-PM subtask ### How to write the `description` for the cell-PM subtask
The description is a **brief**, not a spec. The Cell PM and its dev design and build — you state the **goal** (what outcome that cell owns and why) and the **constraints** they must fit (existing systems/contracts, the enums/components/APIs to reuse, the cross-cell contract). Then stop. Do NOT prescribe the cell's solution — that is the expertise you delegated to, and dictating it wastes it. The description is a **brief**, not a spec. "Not a spec" scopes only to the *solution*, not to the *facts*. You state the **goal** (what outcome that cell owns and why) and the **constraints** they must fit (existing systems/contracts, the exact enums/components/APIs to reuse, the cross-cell contract), and you forward the intake's observed technical detail **verbatim** (see the rule below). Then stop. Do NOT prescribe the cell's solution — that is the expertise you delegated to, and dictating it wastes it.
- ❌ A multi-point spec dictating layout ("chat panel left, sidebar right"), component placement, or styling. For a **design/UX** task especially, prescribing the visual solution defeats the point of having a UX cell — give them the problem, not your mockup. - ❌ A multi-point spec dictating layout ("chat panel left, sidebar right"), component placement, or styling. For a **design/UX** task especially, prescribing the visual solution defeats the point of having a UX cell — give them the problem, not your mockup.
- ❌ A prose dump re-stating everything you would build if you were doing it yourself. - ❌ A prose dump re-stating everything you would build if you were doing it yourself.
@@ -136,6 +136,8 @@ The description is a **brief**, not a spec. The Cell PM and its dev design and b
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.
**Forward intake's observed facts verbatim; re-articulate only the solution.** This is the most important rule at your seat and the single biggest source of revision churn when you get it wrong. The WHAT — the file:line targets the intake analysis named, the code examples it quoted, the exact enums/components/APIs/signatures to reuse, the constraints and gotchas it surfaced — is the PO/HoM intake's analysis, already done. Carry it into the cell subtask's `description` **word-for-word, not paraphrased into a thinner restatement**. The HOW — the solution shape, the decomposition, the layout — is what you and the Cell PM own; re-articulate that freely. "Do not prescribe the solution" scopes ONLY to the solution; it does **not** license you to flatten the intake's technical detail into a vague goal on the way down. A dev who receives "improve the intake flow" instead of "`PrompterService.confirm_live_batch` at `roboco/services/prompter.py:412` drops the `project_ids` scope on a redraft re-confirm — thread `BatchConfirmRequest.task_id` through `update_live_batch` and re-run `_validate_batch_scope`" has to rebuild the intake's analysis from scratch, usually gets it wrong, and burns a revision cycle you could have prevented by forwarding the line you already had. Mine your `evidence(root_id)` response and the upstream PO/HoM handoff for that detail — at the root, the intake analysis lives in the root's own `description` and the PO/HoM journal handoff (a root has no parent, so its `parent_context` is empty); `parent_context` carries the upstream chain once you've delegated, on the cell-PM subtasks and the dev leaves below them. Pass the detail straight through to every cell subtask. If the intake genuinely gave no technical detail (only a goal), say so in the `decision` note rather than inventing vague targets, and `dm('product-owner', ...)` to get it filled before you delegate.
**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.) **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.)
**Some root criteria are yours alone — never delegate them.** A criterion satisfiable only by your own machinery (e.g. "a PR is opened from `feature/main_pm/...`", "contributor PR #N is closed and linked") cannot be honored by any cell — a cell can't operate in your branch namespace or close a PR it doesn't own. Do NOT push it into a cell's `acceptance_criteria` or `covers_parent_criteria`; declare it root-owned instead: `declare_coverage(task_id=<your own root>, criteria=[<ids>])`. `parent_ac_coverage` then shows `claimed_by: "root"` for it, and it counts as claimed+satisfied for `i_am_idle` and the roll-up gate — no cell involved. **Some root criteria are yours alone — never delegate them.** A criterion satisfiable only by your own machinery (e.g. "a PR is opened from `feature/main_pm/...`", "contributor PR #N is closed and linked") cannot be honored by any cell — a cell can't operate in your branch namespace or close a PR it doesn't own. Do NOT push it into a cell's `acceptance_criteria` or `covers_parent_criteria`; declare it root-owned instead: `declare_coverage(task_id=<your own root>, criteria=[<ids>])`. `parent_ac_coverage` then shows `claimed_by: "root"` for it, and it counts as claimed+satisfied for `i_am_idle` and the roll-up gate — no cell involved.
+8
View File
@@ -62,6 +62,14 @@ You have a second, distinct surface: the **in-path PR-review gate**. After a Cel
**The named-deliverable/silent-drop rule.** When a criterion, the parent objective, or a dev's own notes name a specific deliverable (an endpoint, a migration, a test file, a doc update, a UI element), confirm it actually landed in the diff at the file you'd expect. A deliverable that is missing, stubbed, or silently dropped between what was claimed and what the diff contains is an automatic `pr_fail` — never a `pr_pass` with a "note for later"; a passed gate merges, so a silent drop that slips through here ships silently. **The named-deliverable/silent-drop rule.** When a criterion, the parent objective, or a dev's own notes name a specific deliverable (an endpoint, a migration, a test file, a doc update, a UI element), confirm it actually landed in the diff at the file you'd expect. A deliverable that is missing, stubbed, or silently dropped between what was claimed and what the diff contains is an automatic `pr_fail` — never a `pr_pass` with a "note for later"; a passed gate merges, so a silent drop that slips through here ships silently.
**Coherence & intent — your scope is bigger than the AC checklist (non-negotiable).** Ticking every acceptance criterion is the floor, not the ceiling. A diff can satisfy every criterion and still be wrong for *this* project: it can solve the right problem the wrong way, ignore a convention the codebase already follows, duplicate a helper that exists three files over, or build something the CEO did not actually ask for. Before you `pr_pass`, check the bigger scope:
1. **Intent — is this what the intake/parent objective actually asked for?** Compare the assembled diff to the parent task's objective and the intake's stated intent (the `description` + `parent_context` in your `claim_gate_review` evidence — the file:line targets and code examples the intake worked out), not only to the AC list. A diff that satisfies the ACs but drifts from the intent — solves an adjacent problem, over-builds past the named target, or quietly swaps the surface the intake specified — is a `pr_fail` with a `criterion`-less `major` finding (`expected`: the intake's intent, `actual`: what the diff does instead). "They did what the task says" is not a pass when what the task says was diluted on the way down and the diff followed the dilution.
2. **Coherence — does it fit the project it lands in?** The diff should read like it belongs in this codebase: it reuses the project's existing helpers/types/patterns rather than re-inventing them, follows the project's layering and file style, and doesn't introduce a parallel way of doing something the project already does one way. A change that is technically correct but structurally foreign is a `pr_fail`, not a "ship it, refactor later." The conventions validator catches the mechanical half (placement, modularity, suppressions); your judgment catches the rest — a hand-rolled retry when a project helper exists, a new config loader next to the existing one, a service doing what a route should.
3. **Standards — does it hold the project's bar?** No silent `except: pass` / `# type: ignore` / commented-out code / debug `print`; error handling and naming match the project's posture; tests follow the project's test style. A diff that passes its ACs while lowering the project's hygiene bar is a `pr_fail`.
These are the difference between a gate that catches a wrong-but-AC-compliant change before it merges and one that waves it through to a CEO rejection or a shipped regression. When in doubt, `pr_fail` with a concrete finding and let the owning dev/cell PM respond.
**On a blocked `pr_pass`:** three guards can refuse the transition, each with a reviewer-aware `remediate` pointing at `pr_fail` (never `i_am_blocked` — you have no such verb): **On a blocked `pr_pass`:** three guards can refuse the transition, each with a reviewer-aware `remediate` pointing at `pr_fail` (never `i_am_blocked` — you have no such verb):
- **Toolchain / conventions:** if the toolchain or conventions validator cannot run in your workspace (interpreter mismatch, validator hang), `remediate` points at `pr_fail(findings=[{severity: 'blocker', expected: '...', actual: 'toolchain: ...'}])` so the dev rebuilds the environment. - **Toolchain / conventions:** if the toolchain or conventions validator cannot run in your workspace (interpreter mismatch, validator hang), `remediate` points at `pr_fail(findings=[{severity: 'blocker', expected: '...', actual: 'toolchain: ...'}])` so the dev rebuilds the environment.
- **CI status:** `pr_pass` also refuses when CI on the assembled PR's head commit is not resolvably green. Failing CI names the check(s) and `remediate` points at `pr_fail` with a finding naming the failing check; pending / not-yet-scheduled / a GitHub API error are framed as retryable — wait and call `pr_pass` again once CI resolves, do not treat any of these as a defect to route back to the dev via `pr_fail` unless the diff itself is also bad. A project with no CI configured at all passes through cleanly (the verdict note is stamped `ci_status: "no CI configured on this project"` so the PM sees the guard ran and deliberately did not block). Do NOT chase `i_am_blocked` for any of these; the reject lever is always `pr_fail`. - **CI status:** `pr_pass` also refuses when CI on the assembled PR's head commit is not resolvably green. Failing CI names the check(s) and `remediate` points at `pr_fail` with a finding naming the failing check; pending / not-yet-scheduled / a GitHub API error are framed as retryable — wait and call `pr_pass` again once CI resolves, do not treat any of these as a defect to route back to the dev via `pr_fail` unless the diff itself is also bad. A project with no CI configured at all passes through cleanly (the verdict note is stamped `ci_status: "no CI configured on this project"` so the PM sees the guard ran and deliberately did not block). Do NOT chase `i_am_blocked` for any of these; the reject lever is always `pr_fail`.
+14
View File
@@ -75,6 +75,20 @@ When — and only when — you can write a complete spec:
- Don't call it with a partial or speculative draft just to fill a turn. Prose-only is correct until the spec is real. - Don't call it with a partial or speculative draft just to fill a turn. Prose-only is correct until the spec is real.
- The project's architectural standard (`.roboco/conventions.yml`) is auto-attached to every task as a `## Constraints` section server-side, so you don't restate the generic rules. Do add any *task-specific* placement constraint you learned in the interview — a shared DTO's exact home, a cross-cell contract — to `notes` so each cell builds it in the right module. - The project's architectural standard (`.roboco/conventions.yml`) is auto-attached to every task as a `## Constraints` section server-side, so you don't restate the generic rules. Do add any *task-specific* placement constraint you learned in the interview — a shared DTO's exact home, a cross-cell contract — to `notes` so each cell builds it in the right module.
## Technical depth — capture the analysis IN the draft, not just in the chat
This is the most important rule at your seat and the single biggest source of downstream revision churn when you get it wrong. You read the repo, you find the exact file:line to change, the exact signature to add or reuse, the code shape that already exists — that analysis is the whole point of having you interview instead of the CEO typing a one-liner. **It must live in the draft fields, not only in your prose chat with the CEO.** The chat is lost the moment the CEO confirms the card; only the draft travels down the chain — Main PM → Cell PM → dev. A brilliant analysis you only spoke in chat, but never wrote into `the_work` / `notes` / `what_this_builds`, is diluted to nothing by the time a dev reads the task description, and the dev rebuilds your analysis from scratch (usually wrong). That is the exact barrage of revisions this rule prevents.
So write the technical detail you discovered into the draft:
- **`the_work` items** — each independently-shippable unit should name the **file:line** it touches and the **change** at that location, not just the outcome. ❌ "improve the intake re-confirm flow". ✅ "`PrompterService.confirm_live_batch` at `roboco/services/prompter.py:412` drops the `project_ids` scope on a redraft re-confirm — thread `BatchConfirmRequest.task_id` through `update_live_batch` and re-run `_validate_batch_scope` against the original scope."
- **`notes`** — the exact enums/components/APIs/signatures to reuse, the constraint or gotcha, a short code example when the shape is non-obvious. `notes` is where a dev finds "reuse `render_findings` from `evidence_builder.py`, don't re-roll a renderer" or "the `Finding.criterion` field is optional — a coherence/intent finding is filed without a criterion id".
- **`what_this_builds`** — concrete artifacts, named with the real path/identifier.
"Use the real names you find in the repo" (above) is the floor. The bar is: **a dev reading the composed task description sees the file:line and the code example you found, and goes straight to the point instead of hunting in the fog.** If you couldn't pin a file:line because the surface is genuinely unknown, say so in `notes` ("target file not yet determined — the cell PM locates the handler during decomposition") rather than leaving a vague goal that reads as if you did the analysis.
**This does not override the coordination-level-AC rule for MegaTask roots.** That rule (below) says a root's *acceptance criteria* stay coordination-level — don't put code-level ACs on the root. It does NOT say the root's `the_work` and `notes` should be vague. The `the_work` items and `notes` on a root still name the specific files/signatures each cell will touch, because the Main PM forwards `the_work` to the cell PMs who forward it to the devs — that detail is what survives the chain. Code-level *ACs* belong on the cell/dev subtasks the Main PM delegates to; code-level *detail in the work-unit descriptions* belongs on the root and rides all the way down.
## MegaTasks (several tasks at once) ## MegaTasks (several tasks at once)
When you are scoped to a **MegaTask**, the CEO wants several distinct tasks worked at once across the repos in your workspace — for example a SaaS app, its open-source core engine, and a framework adapter, which don't share a codebase. Interview exactly as usual, but produce **one draft per task** and submit them **together** with `propose_batch` instead of `propose_draft`. When you are scoped to a **MegaTask**, the CEO wants several distinct tasks worked at once across the repos in your workspace — for example a SaaS app, its open-source core engine, and a framework adapter, which don't share a codebase. Interview exactly as usual, but produce **one draft per task** and submit them **together** with `propose_batch` instead of `propose_draft`.
+10 -1
View File
@@ -46,7 +46,6 @@ A pass without evidence is a betrayal of your role: the entire downstream chain
| anything else (`pending`/`in_progress`/`awaiting_documentation`/etc.) | not yours to act on — `i_am_idle()` | | anything else (`pending`/`in_progress`/`awaiting_documentation`/etc.) | not yours to act on — `i_am_idle()` |
## Workflow ## Workflow
1. `give_me_work()` -> task in `awaiting_qa`. 1. `give_me_work()` -> task in `awaiting_qa`.
2. `claim_review(task_id)` -> read the response in full: `pr_url`, `commits`, `files_changed`, `dev_summary`, `acceptance_criteria_status`, **and the dev's journal entries (`decision`, `reflect`, `struggle`, `learning`)**. The journal tells you why; the diff tells you what. 2. `claim_review(task_id)` -> read the response in full: `pr_url`, `commits`, `files_changed`, `dev_summary`, `acceptance_criteria_status`, **and the dev's journal entries (`decision`, `reflect`, `struggle`, `learning`)**. The journal tells you why; the diff tells you what.
3. If you need to re-inspect anything, call `evidence(task_id)`. **Do not** grep the workspace or run `Bash git diff` — the diff is in the response. 3. If you need to re-inspect anything, call `evidence(task_id)`. **Do not** grep the workspace or run `Bash git diff` — the diff is in the response.
@@ -71,6 +70,16 @@ You have five journal scopes. QA's job is fundamentally about evidence — spars
The gateway requires `learning` before `pass`/`fail`. Your `notes` argument carries the public verdict; the journal carries the reasoning — and the panel renders your decision's `options`/`chosen`/`rationale`/`consequences` as named sections so PMs can read them at a glance. **A decision with only `text=…` is a regression — always fill the structured fields.** The gateway requires `learning` before `pass`/`fail`. Your `notes` argument carries the public verdict; the journal carries the reasoning — and the panel renders your decision's `options`/`chosen`/`rationale`/`consequences` as named sections so PMs can read them at a glance. **A decision with only `text=…` is a regression — always fill the structured fields.**
## Coherence & intent — your scope is bigger than the AC checklist
Checking "does the diff satisfy every acceptance criterion" is the floor, not the ceiling. A diff can tick every criterion and still be wrong: it can solve the right problem the wrong way for *this* project, ignore a convention the codebase already follows, duplicate a helper that exists three files over, or build something the CEO did not actually ask for. Your job is the bigger scope — **is the change logical toward the project it modifies, coherent with that project's structure and standards, and actually what was asked — not merely what the task's AC list says.** Three things to check beyond the AC walk:
1. **Intent — is this what the CEO/intake actually asked for?** Read the `description` and `parent_context` in your `claim_review` / `evidence` response: that is the intake's original analysis (the WHAT, with file:line targets and code examples) and each PM's decomposition. Compare the diff to *that*, not only to the ACs. A diff that satisfies the ACs but drifts from the intake's stated intent — solves an adjacent problem, over-builds past the named target, or quietly swaps the surface the intake specified — is a fail with a `criterion`-less finding (`severity: major`, `expected`: the intake's intent, `actual`: what the diff does instead). "They did what the task says" is not a pass if what the task says was diluted on the way down and the diff followed the dilution.
2. **Coherence — does it fit the project it lands in?** The diff should read like it belongs in this codebase: it reuses the project's existing helpers/types/patterns rather than re-inventing them, follows the file's surrounding style and the project's layering (a route delegates to a service, a component stays presentational), and doesn't introduce a parallel way of doing something the project already does one way. A change that is technically correct but structurally foreign — a new config loader when the project already has one, a hand-rolled retry when a project helper exists, a model defined where the project puts services — is a fail, not a "ship it, refactor later." The `convention_findings` in your evidence catch the mechanical half of this (placement, modularity, suppressions); your judgment catches the rest.
3. **Standards — does it hold the project's bar?** Beyond the conventions validator: error handling matches the project's posture, naming follows the project's conventions, no silent `except: pass` / `# type: ignore` / commented-out code / debug `print`, and tests follow the project's test style. A diff that passes its ACs while lowering the project's hygiene bar is a fail.
These are not "nice to have" — they are the difference between a review that catches a wrong-but-AC-compliant change before it merges and one that waves it through to a CEO rejection (or worse, a shipped regression). When in doubt, fail with a concrete finding and let the dev respond; a fail costs one cycle, a wrong pass costs the whole chain.
## Mandatory checklist before `pass` / `fail` ## Mandatory checklist before `pass` / `fail`
1. ✅ You are NOT the original developer (gateway-enforced for `claim_review`; the convention also forbids self-pass even if the gate slips). 1. ✅ You are NOT the original developer (gateway-enforced for `claim_review`; the convention also forbids self-pass even if the gate slips).
+41 -1
View File
@@ -4174,6 +4174,26 @@ class AgentOrchestrator:
self._TOOL_LOAD_CACHE[role] = block self._TOOL_LOAD_CACHE[role] = block
return block return block
@staticmethod
def _description_body(description: str | None, *, cap: int = 4000) -> str:
"""The task description as a bounded body for a prompt/briefing block.
The description is the actual ask it travels with the spawn prompt and
SessionStart briefing so the dev starts with the spec instead of a bare
title. Capped so a giant umbrella description can't swamp the prompt;
the full upstream chain is still available via ``evidence()``.
"""
text = (description or "").strip()
if not text:
return "(none — ask the PM before proceeding)"
if len(text) <= cap:
return text
omitted = len(text) - cap
return (
f"{text[:cap]}\n… [{omitted} chars omitted — evidence() carries"
" the full text]"
)
@staticmethod @staticmethod
def _format_task_briefing_block(task_id: str, task: dict[str, Any]) -> str: def _format_task_briefing_block(task_id: str, task: dict[str, Any]) -> str:
"""Build the ``## Current task`` markdown block from a fetched task.""" """Build the ``## Current task`` markdown block from a fetched task."""
@@ -4187,6 +4207,9 @@ class AgentOrchestrator:
) )
branch = task.get("branch_name") or "(to be created)" branch = task.get("branch_name") or "(to be created)"
project_slug = task.get("project_slug") or "(unset — ask PM)" project_slug = task.get("project_slug") or "(unset — ask PM)"
description_body = AgentOrchestrator._description_body(
task.get("description") or ""
)
return ( return (
"\n## Current task\n" "\n## Current task\n"
f"- **ID:** `{task.get('id', task_id)}`\n" f"- **ID:** `{task.get('id', task_id)}`\n"
@@ -4196,6 +4219,8 @@ class AgentOrchestrator:
f"- **Project slug:** `{project_slug}` " f"- **Project slug:** `{project_slug}` "
"(pass this as `project_slug=` on every git/task tool)\n" "(pass this as `project_slug=` on every git/task tool)\n"
f"- **Branch:** `{branch}`\n" f"- **Branch:** `{branch}`\n"
"\n### Description (the ask — treat as ground truth)\n"
f"{description_body}\n"
"\n### Acceptance criteria\n" "\n### Acceptance criteria\n"
f"{criteria}\n" f"{criteria}\n"
) )
@@ -13931,6 +13956,7 @@ Run the project's quality checks against acceptance criteria:
task_id = task.get("id", "unknown") task_id = task.get("id", "unknown")
title = task.get("title", "Untitled") title = task.get("title", "Untitled")
status = task.get("status", "unknown") status = task.get("status", "unknown")
description = task.get("description") or ""
# Determine workflow state based on task attributes # Determine workflow state based on task attributes
has_plan = bool(task.get("plan")) has_plan = bool(task.get("plan"))
@@ -13942,6 +13968,12 @@ Run the project's quality checks against acceptance criteria:
workflow_state, task_id, open_findings_block workflow_state, task_id, open_findings_block
) )
# The task spec travels with the prompt so the dev starts with the
# actual ask (file:line targets, constraints, the intake's rationale)
# instead of hunting in the fog. evidence() carries the full upstream
# ancestor chain on top; this is the leaf's own brief.
desc_block = f"DESCRIPTION:\n{self._description_body(description)}"
return f"""You have been assigned a development task. return f"""You have been assigned a development task.
TASK ID: {task_id} TASK ID: {task_id}
@@ -13949,9 +13981,17 @@ TITLE: {title}
STATUS: {status} STATUS: {status}
WORKFLOW STATE: {workflow_state} WORKFLOW STATE: {workflow_state}
{desc_block}
Treat the description and any upstream technical detail you receive via
evidence() as authoritative ground truth file:line targets, code examples,
and constraints come from the intake analysis and PM decomposition. Re-articulate
only the HOW (the solution); the WHAT is already decided upstream.
{instructions} {instructions}
Start by calling evidence(task_id="{task_id}") for full details and acceptance criteria. Start by calling evidence(task_id="{task_id}") for full details, acceptance
criteria, and the upstream parent/ancestor context (the original intake analysis).
When out of work: i_am_idle(). When out of work: i_am_idle().
""" """
@@ -1024,11 +1024,16 @@ class Choreographer:
# Push the prior-work digest so a freshly spawned / respawned agent # Push the prior-work digest so a freshly spawned / respawned agent
# resumes from the previous worker's PR + commits + journal rather # resumes from the previous worker's PR + commits + journal rather
# than re-exploring the codebase cold on every lifecycle hand-off. # than re-exploring the codebase cold on every lifecycle hand-off.
handoff_highlights = await repo.journal_highlights_for_task(task_id) handoff_highlights = await repo.journal_highlights_for_task(
task_id, include_ancestors=True
)
open_findings = await findings_lib.open_findings_for_task( open_findings = await findings_lib.open_findings_for_task(
self.task.session, task_id self.task.session, task_id
) )
task_handoff = build_task_handoff(task, handoff_highlights, open_findings) parent_context = await repo.ancestor_context_for_task(task_id)
task_handoff = build_task_handoff(
task, handoff_highlights, open_findings, parent_context
)
return { return {
"recent_team_activity": await repo.recent_team_activity(agent_id), "recent_team_activity": await repo.recent_team_activity(agent_id),
"blockers_in_my_lane": await repo.blockers_in_lane(agent_id), "blockers_in_my_lane": await repo.blockers_in_lane(agent_id),
@@ -1200,7 +1200,18 @@ class PRGateMixin(_Base):
prior_findings = await findings_lib.full_ledger_for_task( prior_findings = await findings_lib.full_ledger_for_task(
self.task.session, t.id self.task.session, t.id
) )
return { # The ask: this assembled task's own description + the upstream
# chain (parent → root) so the gate reviewer checks INTENT against
# the intake's original analysis, not only the AC list. Empty chain
# for a parentless root is fine (omitted downstream when empty).
parent_context: list[dict[str, Any]] = []
try:
parent_context = await self.evidence_repo.ancestor_context_for_task(t.id)
except Exception as exc:
logger.warning(
"gate_review_parent_context_skip", task_id=str(t.id), error=str(exc)
)
evidence: dict[str, Any] = {
"pr_number": t.pr_number, "pr_number": t.pr_number,
"pr_url": t.pr_url, "pr_url": t.pr_url,
"pr_diff": diff, "pr_diff": diff,
@@ -1209,3 +1220,9 @@ class PRGateMixin(_Base):
"revision_findings": render_findings(open_findings), "revision_findings": render_findings(open_findings),
"prior_findings": render_findings(prior_findings), "prior_findings": render_findings(prior_findings),
} }
description = getattr(t, "description", None)
if description:
evidence["description"] = description
if parent_context:
evidence["parent_context"] = parent_context
return evidence
@@ -495,17 +495,36 @@ class PRReviewerMixin(_Base):
) )
async def _build_pr_review_evidence(self, t: Any) -> dict[str, Any]: async def _build_pr_review_evidence(self, t: Any) -> dict[str, Any]:
"""Inline evidence for claim_pr_review: the PR's unified diff (read-only).""" """Inline evidence for claim_pr_review: the PR's unified diff (read-only).
Carries the review task's own ``description`` (the CEO/intake review ask)
and the upstream ``parent_context`` chain so the reviewer judges the
contributor's diff against the stated intent, not in a vacuum. Both are
empty for a bare external-PR task and omitted when empty.
"""
slug = await self._project_slug_for(t) slug = await self._project_slug_for(t)
diff = "" diff = ""
if slug and t.pr_number: if slug and t.pr_number:
diff = await self.git.get_pr_diff(slug, t.pr_number) diff = await self.git.get_pr_diff(slug, t.pr_number)
return { evidence: dict[str, Any] = {
"pr_number": t.pr_number, "pr_number": t.pr_number,
"pr_url": t.pr_url, "pr_url": t.pr_url,
"pr_diff": diff, "pr_diff": diff,
"is_external_pr": True, "is_external_pr": True,
} }
description = getattr(t, "description", None)
if description:
evidence["description"] = description
try:
parent_context = await self.evidence_repo.ancestor_context_for_task(t.id)
except Exception as exc:
logger.warning(
"pr_review_parent_context_skip", task_id=str(t.id), error=str(exc)
)
parent_context = []
if parent_context:
evidence["parent_context"] = parent_context
return evidence
async def _pr_review_tracing_gate( async def _pr_review_tracing_gate(
self, self,
@@ -224,6 +224,11 @@ class QAMixin(_Base):
journal_highlights = await self.evidence_repo.journal_highlights_for_task( journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id task_id
) )
# The ask-chain (parent → root descriptions) so QA judges INTENT
# against the intake's original analysis, not only the leaf's ACs.
# Leaf-only journals stay (include_ancestors defaults False above);
# ancestor *descriptions* are the ask, not work-so-far.
parent_context = await self.evidence_repo.ancestor_context_for_task(task_id)
convention_findings = await self._qa_convention_findings(qa_agent_id, t) convention_findings = await self._qa_convention_findings(qa_agent_id, t)
open_findings = await findings_lib.open_findings_for_task( open_findings = await findings_lib.open_findings_for_task(
self.task.session, t.id self.task.session, t.id
@@ -241,6 +246,7 @@ class QAMixin(_Base):
convention_findings=convention_findings, convention_findings=convention_findings,
revision_findings=open_findings, revision_findings=open_findings,
prior_findings=prior_findings, prior_findings=prior_findings,
parent_context=parent_context,
) )
async def _verify_qa_owner( async def _verify_qa_owner(
+3 -1
View File
@@ -1919,8 +1919,9 @@ class ContentActions:
branch_name=t.branch_name, actor_agent_id=agent_id branch_name=t.branch_name, actor_agent_id=agent_id
) )
journal_highlights = await self.evidence_repo.journal_highlights_for_task( journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id task_id, include_ancestors=True
) )
parent_context = await self.evidence_repo.ancestor_context_for_task(task_id)
open_findings = await findings_lib.open_findings_for_task( open_findings = await findings_lib.open_findings_for_task(
self.task.session, task_id self.task.session, task_id
) )
@@ -1930,6 +1931,7 @@ class ContentActions:
files_changed=files_changed, files_changed=files_changed,
pr_diff_summary=diff, pr_diff_summary=diff,
revision_findings=open_findings, revision_findings=open_findings,
parent_context=parent_context,
) )
return Envelope.ok( return Envelope.ok(
status=str(t.status), status=str(t.status),
+52 -19
View File
@@ -23,6 +23,8 @@ _EVIDENCE_OMIT_WHEN_EMPTY = (
"convention_findings", "convention_findings",
"revision_findings", "revision_findings",
"prior_findings", "prior_findings",
"parent_context",
"description",
) )
@@ -36,6 +38,14 @@ class EvidencePayload:
dev_summary: str | None dev_summary: str | None
journal_highlights: list[dict[str, Any]] journal_highlights: list[dict[str, Any]]
acceptance_criteria_status: list[dict[str, Any]] acceptance_criteria_status: list[dict[str, Any]]
# The task's own description (the spec / brief) — the dev's ``evidence()``
# call finally carries the ask, not only the work-so-far. Omitted when empty.
description: str | None = None
# The upstream ``description`` chain (immediate parent → root) so a
# downstream owner reads the intake's original analysis and each PM's
# decomposition rationale verbatim instead of a re-paraphrased summary.
# Empty for a parentless task.
parent_context: list[dict[str, Any]] = field(default_factory=list)
# Architectural-conventions validator findings on the changed files, so QA # Architectural-conventions validator findings on the changed files, so QA
# can flag a misplaced definition / suppression. Empty when the subsystem # can flag a misplaced definition / suppression. Empty when the subsystem
# is off; a single ``could_not_run`` entry surfaces a fail-loud explicitly. # is off; a single ``could_not_run`` entry surfaces a fail-loud explicitly.
@@ -149,12 +159,15 @@ def build_evidence_for_task(
convention_findings: list[dict[str, Any]] | None = None, convention_findings: list[dict[str, Any]] | None = None,
revision_findings: list[Any] | None = None, revision_findings: list[Any] | None = None,
prior_findings: list[Any] | None = None, prior_findings: list[Any] | None = None,
parent_context: list[dict[str, Any]] | None = None,
) -> EvidencePayload: ) -> EvidencePayload:
"""Compose an EvidencePayload from a Task model + supplemental data. """Compose an EvidencePayload from a Task model + supplemental data.
``revision_findings`` / ``prior_findings`` take raw ledger rows (the ``revision_findings`` / ``prior_findings`` take raw ledger rows (the
caller fetches; this module stays DB-free) and render them via caller fetches; this module stays DB-free) and render them via
``render_findings``. ``render_findings``. ``parent_context`` is the upstream ``description``
chain (parent root) the caller fetches via EvidenceRepo so the dev
reads the intake's original analysis verbatim.
""" """
return EvidencePayload( return EvidencePayload(
pr_number=task.pr_number, pr_number=task.pr_number,
@@ -165,6 +178,8 @@ def build_evidence_for_task(
dev_summary=task.dev_notes, dev_summary=task.dev_notes,
journal_highlights=list(journal_highlights), journal_highlights=list(journal_highlights),
acceptance_criteria_status=list(task.acceptance_criteria_status or []), acceptance_criteria_status=list(task.acceptance_criteria_status or []),
description=_typed(task.description, str, None),
parent_context=list(parent_context or []),
convention_findings=list(convention_findings or []), convention_findings=list(convention_findings or []),
revision_findings=render_findings(revision_findings), revision_findings=render_findings(revision_findings),
prior_findings=render_findings(prior_findings), prior_findings=render_findings(prior_findings),
@@ -191,19 +206,28 @@ def _has_prior_work(
qa_review: dict[str, Any] | None, qa_review: dict[str, Any] | None,
pm_review: dict[str, Any] | None, pm_review: dict[str, Any] | None,
open_findings: list, open_findings: list,
description: str | None = None,
parent_context: list[dict[str, Any]] | None = None,
) -> bool: ) -> bool:
"""True when any resumable prior-work signal is present on the task.""" """True when any resumable prior-work signal — or the task spec itself —
return bool( is present. The spec (``description``) and upstream ``parent_context`` count
commits so a freshly-claimed leaf still gets a handoff carrying the intake's
or acceptance analysis, not ``None``."""
or highlights return any(
or pr_number is not None [
or dev_summary commits,
or completed_deps acceptance,
or pr_review is not None highlights,
or qa_review is not None pr_number is not None,
or pm_review is not None dev_summary,
or open_findings completed_deps,
pr_review is not None,
qa_review is not None,
pm_review is not None,
open_findings,
description,
parent_context,
]
) )
@@ -211,15 +235,18 @@ def build_task_handoff(
task: Any, task: Any,
journal_highlights: list[dict[str, Any]], journal_highlights: list[dict[str, Any]],
open_findings: list[Any] | None = None, open_findings: list[Any] | None = None,
parent_context: list[dict[str, Any]] | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Compose a compact prior-work digest for the briefed task. """Compose a compact prior-work digest for the briefed task.
Returns ``None`` when there is no task or no prior work worth resuming Returns ``None`` only when there is no task AND no spec to carry the
from, so the briefing only carries a handoff when one genuinely exists. task's own ``description`` and the upstream ``parent_context`` chain now
DB-only by design no git diff so it is cheap enough to attach to count as worth carrying, so a freshly-claimed leaf finally receives the
every task-scoped briefing. ``open_findings`` takes raw ledger rows (the intake's analysis at claim time instead of an empty handoff. DB-only by
caller fetches this module stays DB-free); rendered under design no git diff so it is cheap enough to attach to every
``revision_findings`` when non-empty. task-scoped briefing. ``open_findings`` takes raw ledger rows (the caller
fetches this module stays DB-free); rendered under ``revision_findings``
when non-empty.
""" """
if task is None: if task is None:
return None return None
@@ -228,6 +255,8 @@ def build_task_handoff(
highlights = _typed(journal_highlights, list, []) highlights = _typed(journal_highlights, list, [])
pr_number = _typed(task.pr_number, int, None) pr_number = _typed(task.pr_number, int, None)
dev_summary = _typed(task.dev_notes, str, None) dev_summary = _typed(task.dev_notes, str, None)
description = _typed(task.description, str, None)
parent_context = _typed(parent_context, list, [])
# Upstream dependencies that completed and were cleared — present only on a # Upstream dependencies that completed and were cleared — present only on a
# just-unblocked task, so the revived dependent knows what it can build on. # just-unblocked task, so the revived dependent knows what it can build on.
completed_deps = _typed(getattr(task, "completed_dependency_ids", None), list, []) completed_deps = _typed(getattr(task, "completed_dependency_ids", None), list, [])
@@ -253,6 +282,8 @@ def build_task_handoff(
qa_review, qa_review,
pm_review, pm_review,
open_findings, open_findings,
description,
parent_context,
): ):
return None return None
handoff: dict[str, Any] = { handoff: dict[str, Any] = {
@@ -262,6 +293,8 @@ def build_task_handoff(
"commit_count": len(commits), "commit_count": len(commits),
"recent_commits": commits[-BRIEFING_LIST_CAP:], "recent_commits": commits[-BRIEFING_LIST_CAP:],
"dev_summary": dev_summary, "dev_summary": dev_summary,
"description": description,
"parent_context": parent_context[:BRIEFING_LIST_CAP],
"acceptance_criteria_status": acceptance[:BRIEFING_LIST_CAP], "acceptance_criteria_status": acceptance[:BRIEFING_LIST_CAP],
"journal_highlights": highlights[:BRIEFING_LIST_CAP], "journal_highlights": highlights[:BRIEFING_LIST_CAP],
"completed_dependency_ids": [ "completed_dependency_ids": [
+93 -2
View File
@@ -24,6 +24,13 @@ _HANDOFF_CONTENT_CAP = 800
_NORTH_STAR_CAP = 600 _NORTH_STAR_CAP = 600
_BRAND_VOICE_CAP = 600 _BRAND_VOICE_CAP = 600
_A2A_PREVIEW_CAP = 200 _A2A_PREVIEW_CAP = 200
# Per-ancestor description cap in the upstream-context chain — the root's
# intake analysis is the valuable part; a giant umbrella description can't
# flood the evidence payload it rides.
_ANCESTOR_DESC_CAP = 1500
# Backstop on the parent_task_id walk so a malformed/cyclic hierarchy can't
# loop or explode the query. Real depth is ≤4 (umbrella→root→cell→leaf).
_HIERARCHY_DEPTH_CAP = 16
# similar_memory's "kind" label per index type; anything absent (LEARNINGS) # similar_memory's "kind" label per index type; anything absent (LEARNINGS)
# falls back to "learning" via .get() below. # falls back to "learning" via .get() below.
_MEMORY_KIND_BY_INDEX = { _MEMORY_KIND_BY_INDEX = {
@@ -317,7 +324,78 @@ class EvidenceRepo:
for row in result.all() for row in result.all()
] ]
async def journal_highlights_for_task(self, task_id: UUID) -> list[dict[str, Any]]: async def _ancestor_task_ids(self, task_id: UUID) -> list[UUID]:
"""Walk ``parent_task_id`` up to the root, returning ancestor ids
(immediate parent first). Cycle-guarded + depth-capped so a malformed
hierarchy can't loop or explode the query. Stops clean on a missing
row or a root (``parent_task_id`` is NULL).
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable
ancestors: list[UUID] = []
seen: set[str] = {str(task_id)}
current: UUID = task_id
for _ in range(_HIERARCHY_DEPTH_CAP):
parent_id = (
await self._db.execute(
select(TaskTable.parent_task_id).where(TaskTable.id == current)
)
).scalar_one_or_none()
if parent_id is None:
break # missing row or reached a root
key = str(parent_id)
if key in seen:
break # cycle guard
seen.add(key)
ancestors.append(parent_id)
current = parent_id
return ancestors
async def ancestor_context_for_task(self, task_id: UUID) -> list[dict[str, Any]]:
"""The upstream ``description`` chain (immediate parent → root), so a
downstream owner the dev or PM claiming a leaf reads the intake's
original analysis and each PM's decomposition rationale verbatim
instead of a re-paraphrased summary. Each ancestor carries title +
description (capped) + depth (1 = immediate parent). Empty for a
parentless task. Best-effort: a missing ancestor row is skipped.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable
ancestor_ids = await self._ancestor_task_ids(task_id)
if not ancestor_ids:
return []
rows = (
await self._db.execute(
select(
TaskTable.id,
TaskTable.title,
TaskTable.description,
).where(TaskTable.id.in_(ancestor_ids))
)
).all()
by_id = {str(r.id): r for r in rows}
context: list[dict[str, Any]] = []
for depth, aid in enumerate(ancestor_ids, start=1):
row = by_id.get(str(aid))
if row is None:
continue
context.append(
{
"task_id": str(aid),
"depth": depth,
"title": row.title,
"description": _clip(row.description, _ANCESTOR_DESC_CAP),
}
)
return context
async def journal_highlights_for_task(
self, task_id: UUID, *, include_ancestors: bool = False
) -> list[dict[str, Any]]:
"""The task's upstream handoff: every author's decision / reflection / """The task's upstream handoff: every author's decision / reflection /
note journal entry tied to this task, oldest first. note journal entry tied to this task, oldest first.
@@ -328,6 +406,15 @@ class EvidenceRepo:
and struggle entries are personal and excluded. Ownership is enforced by and struggle entries are personal and excluded. Ownership is enforced by
the caller (``evidence`` only serves the task's assignee), so private the caller (``evidence`` only serves the task's assignee), so private
task-scoped entries are surfaced to the owner who needs the full handoff. task-scoped entries are surfaced to the owner who needs the full handoff.
When ``include_ancestors`` is set, the query also pulls every ancestor
task's handoff entries (walking ``parent_task_id`` to the root), so a
dev or PM claiming a leaf reads the PO / HM analysis and each PM's
decomposition rationale instead of re-deriving it. Oldest-first
ordering puts the root's analysis at the top (the "what and why"); the
leaf's own entries follow. Opt-in so QA's leaf-journal view (the dev's
intent) stays undiluted QA gets the parent objective via a dedicated
evidence field instead.
""" """
from sqlalchemy import select from sqlalchemy import select
@@ -339,6 +426,10 @@ class EvidenceRepo:
JournalEntryType.TASK_REFLECTION, JournalEntryType.TASK_REFLECTION,
JournalEntryType.GENERAL, JournalEntryType.GENERAL,
) )
if include_ancestors:
scope_ids: list[UUID] = [task_id, *await self._ancestor_task_ids(task_id)]
else:
scope_ids = [task_id]
query = ( query = (
select( select(
JournalEntryTable.type, JournalEntryTable.type,
@@ -350,7 +441,7 @@ class EvidenceRepo:
) )
.join(JournalTable, JournalEntryTable.journal_id == JournalTable.id) .join(JournalTable, JournalEntryTable.journal_id == JournalTable.id)
.join(AgentTable, JournalTable.agent_id == AgentTable.id) .join(AgentTable, JournalTable.agent_id == AgentTable.id)
.where(JournalEntryTable.task_id == task_id) .where(JournalEntryTable.task_id.in_(scope_ids))
.where(JournalEntryTable.type.in_(handoff_types)) .where(JournalEntryTable.type.in_(handoff_types))
.order_by(JournalEntryTable.timestamp.asc()) .order_by(JournalEntryTable.timestamp.asc())
.limit(50) .limit(50)
+3 -1
View File
@@ -82,7 +82,9 @@ class TestBriefingScope:
) )
briefing = await choreo._briefing_for(uuid4(), task_id, task=task, full=True) briefing = await choreo._briefing_for(uuid4(), task_id, task=task, full=True)
assert briefing["task_handoff"]["pr_number"] == _PR_NUMBER assert briefing["task_handoff"]["pr_number"] == _PR_NUMBER
repo.journal_highlights_for_task.assert_awaited_once_with(task_id) repo.journal_highlights_for_task.assert_awaited_once_with(
task_id, include_ancestors=True
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_slim_with_task_omits_handoff(self) -> None: async def test_slim_with_task_omits_handoff(self) -> None:
@@ -444,3 +444,68 @@ class TestExtractPmReview:
digest = build_task_handoff(t, []) digest = build_task_handoff(t, [])
assert digest is not None assert digest is not None
assert "pm_review" not in digest assert "pm_review" not in digest
class TestDescriptionAndParentContext:
"""The ask (description + upstream parent_context) now rides the evidence
payload and the task handoff so a freshly-claimed leaf receives the intake's
analysis instead of an empty handoff."""
def test_payload_omits_description_when_none(self) -> None:
"""A descriptionless task must not emit ``description: null`` in every
evidence payload omitted like the other noise-when-empty fields."""
t = _task()
ev = build_evidence_for_task(t, journal_highlights=[], files_changed=[])
assert "description" not in ev.as_dict()
def test_payload_carries_description_when_set(self) -> None:
t = _task()
t.description = "edit prompter.py:412 to thread task_id through"
ev = build_evidence_for_task(t, journal_highlights=[], files_changed=[])
assert (
ev.as_dict()["description"]
== "edit prompter.py:412 to thread task_id through"
)
def test_payload_carries_parent_context(self) -> None:
t = _task()
chain = [
{
"task_id": "p",
"depth": 1,
"title": "Root",
"description": "intake analysis",
}
]
ev = build_evidence_for_task(
t, journal_highlights=[], files_changed=[], parent_context=chain
)
assert ev.as_dict()["parent_context"] == chain
def test_payload_omits_parent_context_when_empty(self) -> None:
t = _task()
ev = build_evidence_for_task(t, journal_highlights=[], files_changed=[])
assert "parent_context" not in ev.as_dict()
def test_handoff_carries_description_and_parent_context(self) -> None:
"""A leaf with no commits/PR yet still gets a handoff when the spec
(description) or the upstream chain is present the intake analysis
reaches the dev at claim time."""
t = _task(pr_number=None, pr_url=None, commits=[], dev_notes="")
t.description = "the ask: edit foo.py:10"
chain = [
{"task_id": "p", "depth": 1, "title": "Root", "description": "upstream"}
]
handoff = build_task_handoff(t, [], parent_context=chain)
assert handoff is not None
assert handoff["description"] == "the ask: edit foo.py:10"
assert handoff["parent_context"] == chain
def test_handoff_none_when_no_spec_and_no_prior_work(self) -> None:
"""A bare task with no spec, no commits, no PR, no findings, no upstream
chain still collapses to None no empty handoff payload."""
t = _task(pr_number=None, pr_url=None, commits=[], dev_notes="")
t.commits = [] # _task's `commits or [...]` default would re-seed one
t.acceptance_criteria_status = []
# MagicMock auto-attrs make description a non-str -> _typed -> None.
assert build_task_handoff(t, []) is None
@@ -141,7 +141,9 @@ async def test_evidence_populates_journal_highlights() -> None:
env = await ca.evidence(agent_id=agent_id, task_id=task_id) env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict() body = env.as_dict()
assert body["evidence"]["journal_highlights"] == highlights assert body["evidence"]["journal_highlights"] == highlights
evidence_repo.journal_highlights_for_task.assert_awaited_once_with(task_id) evidence_repo.journal_highlights_for_task.assert_awaited_once_with(
task_id, include_ancestors=True
)
@pytest.mark.asyncio @pytest.mark.asyncio
+146 -1
View File
@@ -13,7 +13,25 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from roboco.services.gateway.evidence_repo import EvidenceRepo from roboco.services.gateway.evidence_repo import (
_ANCESTOR_DESC_CAP,
_HIERARCHY_DEPTH_CAP,
EvidenceRepo,
)
def _scalar_result(value: object) -> MagicMock:
"""A query result whose ``scalar_one_or_none()`` returns ``value``."""
r = MagicMock()
r.scalar_one_or_none.return_value = value
return r
def _rows_result(rows: list[object]) -> MagicMock:
"""A query result whose ``all()`` returns ``rows``."""
r = MagicMock()
r.all.return_value = rows
return r
def _empty_repo() -> EvidenceRepo: def _empty_repo() -> EvidenceRepo:
@@ -233,3 +251,130 @@ async def test_journal_highlights_for_task_maps_rows_with_author() -> None:
"timestamp": ts.isoformat(), "timestamp": ts.isoformat(),
} }
] ]
# ---------------------------------------------------------------------------
# Parent-chain walk + ancestor context (the intake-analysis torch carrier).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ancestor_task_ids_walks_parent_to_root() -> None:
leaf, parent, grand = uuid4(), uuid4(), uuid4()
db = MagicMock()
db.execute = AsyncMock(
side_effect=[
_scalar_result(parent),
_scalar_result(grand),
_scalar_result(None), # grand is a root
]
)
repo = EvidenceRepo(db)
assert await repo._ancestor_task_ids(leaf) == [parent, grand]
@pytest.mark.asyncio
async def test_ancestor_task_ids_cycle_guard_stops() -> None:
leaf, parent = uuid4(), uuid4()
db = MagicMock()
# leaf -> parent -> leaf (cycles back to the start, already in `seen`).
db.execute = AsyncMock(side_effect=[_scalar_result(parent), _scalar_result(leaf)])
repo = EvidenceRepo(db)
assert await repo._ancestor_task_ids(leaf) == [parent]
@pytest.mark.asyncio
async def test_ancestor_task_ids_missing_row_returns_empty() -> None:
db = MagicMock()
db.execute = AsyncMock(side_effect=[_scalar_result(None)])
repo = EvidenceRepo(db)
assert await repo._ancestor_task_ids(uuid4()) == []
@pytest.mark.asyncio
async def test_ancestor_task_ids_depth_capped() -> None:
start = uuid4()
chain = [uuid4() for _ in range(_HIERARCHY_DEPTH_CAP + 5)]
db = MagicMock()
db.execute = AsyncMock(side_effect=[_scalar_result(p) for p in chain])
repo = EvidenceRepo(db)
result = await repo._ancestor_task_ids(start)
assert len(result) == _HIERARCHY_DEPTH_CAP
assert result == chain[:_HIERARCHY_DEPTH_CAP]
@pytest.mark.asyncio
async def test_ancestor_context_for_task_parentless_returns_empty() -> None:
db = MagicMock()
db.execute = AsyncMock(side_effect=[_scalar_result(None)])
repo = EvidenceRepo(db)
assert await repo.ancestor_context_for_task(uuid4()) == []
@pytest.mark.asyncio
async def test_ancestor_context_for_task_maps_chain_with_depth() -> None:
leaf, parent, grand = uuid4(), uuid4(), uuid4()
row_p = SimpleNamespace(id=parent, title="Cell PM slice", description="p-desc")
row_g = SimpleNamespace(id=grand, title="Root", description="g-desc")
db = MagicMock()
db.execute = AsyncMock(
side_effect=[
_scalar_result(parent),
_scalar_result(grand),
_scalar_result(None), # end of _ancestor_task_ids
_rows_result([row_p, row_g]), # the batch fetch
]
)
repo = EvidenceRepo(db)
assert await repo.ancestor_context_for_task(leaf) == [
{
"task_id": str(parent),
"depth": 1,
"title": "Cell PM slice",
"description": "p-desc",
},
{"task_id": str(grand), "depth": 2, "title": "Root", "description": "g-desc"},
]
@pytest.mark.asyncio
async def test_ancestor_context_for_task_skips_missing_ancestor_row() -> None:
leaf, parent, grand = uuid4(), uuid4(), uuid4()
row_p = SimpleNamespace(id=parent, title="Cell PM slice", description="p-desc")
db = MagicMock()
db.execute = AsyncMock(
side_effect=[
_scalar_result(parent),
_scalar_result(grand),
_scalar_result(None),
_rows_result([row_p]), # grand's row is missing from the batch
]
)
repo = EvidenceRepo(db)
assert await repo.ancestor_context_for_task(leaf) == [
{
"task_id": str(parent),
"depth": 1,
"title": "Cell PM slice",
"description": "p-desc",
},
]
@pytest.mark.asyncio
async def test_ancestor_context_for_task_clips_long_description() -> None:
leaf, parent = uuid4(), uuid4()
row_p = SimpleNamespace(
id=parent, title="P", description="x" * (_ANCESTOR_DESC_CAP + 500)
)
db = MagicMock()
db.execute = AsyncMock(
side_effect=[
_scalar_result(parent),
_scalar_result(None),
_rows_result([row_p]),
]
)
repo = EvidenceRepo(db)
ctx = await repo.ancestor_context_for_task(leaf)
assert len(ctx[0]["description"]) == _ANCESTOR_DESC_CAP
@@ -0,0 +1,34 @@
"""``AgentOrchestrator._description_body`` — the bounded description block for
the dev spawn prompt + SessionStart briefing. Pure static helper, no fixtures."""
from __future__ import annotations
from roboco.runtime.orchestrator import AgentOrchestrator
_PLACEHOLDER = "(none — ask the PM before proceeding)"
class TestDescriptionBody:
def test_empty_returns_placeholder(self) -> None:
assert AgentOrchestrator._description_body("") == _PLACEHOLDER
assert AgentOrchestrator._description_body(None) == _PLACEHOLDER
assert AgentOrchestrator._description_body(" \n ") == _PLACEHOLDER
def test_short_description_returned_verbatim(self) -> None:
desc = "edit prompter.py:412 — thread task_id through update_live_batch"
assert AgentOrchestrator._description_body(desc) == desc
def test_long_description_capped_with_omitted_marker(self) -> None:
cap = 4000
desc = "x" * (cap + 1500)
body = AgentOrchestrator._description_body(desc)
assert body.startswith("x" * cap)
assert "chars omitted" in body
assert "evidence() carries" in body
# The kept prefix is exactly the cap; the marker follows.
assert len(body.split("\n", 1)[0]) == cap
def test_custom_cap_respected(self) -> None:
body = AgentOrchestrator._description_body("x" * 50, cap=10)
assert body.startswith("x" * 10)
assert "chars omitted" in body