> ## Documentation Index
> Fetch the complete documentation index at: https://docs.obversa.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Backlog grooming, then a person ranks

> Raw tickets in, stories with acceptance checks and the questions to settle first out, each read by a second model; then the product owner ranks.

Hand the week's raw tickets to a team and get back stories a developer
could pick up: each with its acceptance checks and the ticket it came
from, each read back against the raw tickets by a model from another
family, and beside them the questions that must be settled before anyone
writes code. Then the person who owns the product ranks. Nothing here
writes code: the work is deciding what is worth writing, and the run stops
at the person.

```bash theme={null}
npm install @obversa/runtime @obversa/engine-claude-cli @obversa/engine-codex-cli
```

In that directory, create `package.json` with `"type": "module"`, put the
brief and the sample inputs from `examples/use-cases/engineering/`
beside it, then run the copied file.

## The file

```ts theme={null}
import { claude } from '@obversa/engine-claude-cli';
import { codex } from '@obversa/engine-codex-cli';
import {
  briefFromFile,
  formatEvent,
  person,
  run,
  stage,
  workflow,
  type TeamSeat,
} from '@obversa/runtime';

interface BacklogGroomEngines {
  readonly claude: (model: string) => TeamSeat;
  readonly codex: (model: string) => TeamSeat;
}

const realEngines: BacklogGroomEngines = { claude, codex };

/**
 * Backlog grooming, then a person ranks. The raw tickets are whatever the
 * week left behind: support threads, a sales ask, a one-line wish. One
 * model turns each into stories with acceptance checks and a model from
 * another family reads them back against the raw tickets; the same pair
 * writes down the questions that must be settled before anyone codes.
 * Then the product owner ranks. Nothing here writes code: the work is
 * deciding what is worth writing.
 */
function createBacklogGroom(engines: BacklogGroomEngines = realEngines) {
  return workflow('backlog-groom-then-rank', {
    brief: briefFromFile('briefs/backlog.md'),
    options: { timeout: '10m' },

    roles: {
      groom: engines.claude('claude-sonnet-4-5'),
      'story-review': [engines.codex('gpt-5.6-luna')],
      owner: person('Which of these go into the next cycle, and in what order?'),
    },

    stages: [
      stage('split', {
        agent: 'groom',
        writes: 'backlog/stories.md',
        desc: 'Turn every raw ticket in backlog/raw.md into one or more stories, each with its acceptance checks and the ticket it came from.',
        gate: 'Every raw ticket is covered by at least one story and a reviewer from another family has accepted the set.',
        reviewedBy: 'story-review',
        // Three attempts, not two: the allowance matches how open-ended the
        // work is. Grooming a backlog has many defensible answers, so a strict
        // reviewer and a writer need room to meet. Work with one right answer
        // needs less.
        retry: 3,
      }),

      stage('clarify', {
        agent: 'groom',
        writes: 'backlog/questions.md',
        desc: 'For each story, list the questions that must be answered before anyone writes code, with a proposed answer for each.',
        gate: 'Every story has its questions, or the line "no open questions", and a reviewer has accepted them.',
        reviewedBy: 'story-review',
        // Three attempts, not two: the allowance matches how open-ended the
        // work is. Grooming a backlog has many defensible answers, so a strict
        // reviewer and a writer need room to meet. Work with one right answer
        // needs less.
        retry: 3,
      }),

      stage('rank', {
        input: 'owner',
        desc: 'Put the stories and the open questions in front of the product owner.',
        gate: 'The owner has ranked the cycle.',
        sendsBackTo: 'split',
      }),
    ],
  });
}

const result = await run(createBacklogGroom(), {
  onEvent: (event) => console.log(formatEvent(event)),
});
console.log(JSON.stringify(result.outcome, null, 2));
```

Every event of the run prints as one line as it happens, the usage lines
included, and the outcome prints last as JSON.

The run stops at `rank` with the question on the record. The stories
and the questions are in `backlog/` for the owner to read; an answer
through the run's callbacks client carries on from there, and a no goes
back to `split` with the owner's note as the finding.

## What a run printed

The output below is from one real run of this file, with the two command line
tools signed in on the machine. This run did not finish, and it is here
because of that rather than in spite of it.

The reviewer is a model from another family. Three times it read the stories
and sent them back with a real problem: a dark-mode story that said every
screen renders "correctly" without saying which screens or what proves it, and
an export story that had quietly become two, finishing a yearly export and
running one every month. Each round the writer fixed what it was told and the
reviewer found something else true. On the third the loop reached its
allowance and stopped.

Nothing was published. A grooming loop that stops with the work unfinished is
doing its job; one that runs out of attempts and passes the stories on anyway
would not be. The allowance is the thing to match to the work: three rounds
suit a job with a few defensible answers, and splitting a backlog of vague
tickets is not that job.

```text theme={null}
backlog-groom-then-rank ▸ dag (3 nodes)
backlog-groom-then-rank · node split: start
backlog-groom-then-rank › split › split-review ▸ loop (max 4)
backlog-groom-then-rank › split › split-review · iteration 1
backlog-groom-then-rank › split › split-review • split
backlog-groom-then-rank › split › split-review engine:thinking
backlog-groom-then-rank › split › split-review engine:text
backlog-groom-then-rank › split › split-review   tool Read use
backlog-groom-then-rank › split › split-review   tool tool result
backlog-groom-then-rank › split › split-review engine:thinking
backlog-groom-then-rank › split › split-review engine:text
backlog-groom-then-rank › split › split-review   tool Write use
backlog-groom-then-rank › split › split-review   tool tool result
backlog-groom-then-rank › split › split-review engine:thinking
backlog-groom-then-rank › split › split-review   tool Read use
backlog-groom-then-rank › split › split-review   tool tool result
backlog-groom-then-rank › split › split-review engine:thinking
backlog-groom-then-rank › split › split-review   tool Bash use
backlog-groom-then-rank › split › split-review   tool tool result
backlog-groom-then-rank › split › split-review engine:thinking
… 60 more event lines …
backlog-groom-then-rank › split › split-review   tool Edit use
backlog-groom-then-rank › split › split-review   tool tool result
backlog-groom-then-rank › split › split-review engine:thinking
backlog-groom-then-rank › split › split-review engine:text
backlog-groom-then-rank › split › split-review   claude-sonnet-4-5-20250929: 334000/2636 tok
backlog-groom-then-rank › split › split-review • split: fail
backlog-groom-then-rank › split › split-review · until met: split writes: true
backlog-groom-then-rank › split › split-review › review-panel • split
backlog-groom-then-rank › split › split-review › review-panel • split-1
backlog-groom-then-rank › split › split-review › review-panel engine:text
backlog-groom-then-rank › split › split-review › review-panel   gpt-5.6-luna: 192268/2663 tok
backlog-groom-then-rank › split › split-review › review-panel • split-1: fail
backlog-groom-then-rank › split › split-review › review-panel • split: fail
backlog-groom-then-rank › split › split-review · review: fail
review did not pass (Review panel: 0/1 reviewer(s) cleared. - split-1 [block]: backlog/stories.md:40-45 combines completing within two minutes with avoiding timeout errors; these are two distinct user outcomes and should be separate story sections, both sourced from “Export takes forever”.); re-entering split-review
backlog-groom-then-rank › split › split-review ◂ exhausted (3 iter)
backlog-groom-then-rank · node split: done (exhausted)
backlog-groom-then-rank · node clarify: done (aborted)
backlog-groom-then-rank · node rank: done (aborted)
backlog-groom-then-rank ◂ dag fail
```

```json theme={null}
{
  "status": "fail",
  "summary": "dag \"backlog-groom-then-rank\": 2 required node(s) did not complete",
  "data": {
    "split": {
      "status": "exhausted",
      "summary": "review rejected 3× (maxReviewRestarts)",
      "data": {
        "response": "Fixed. Removed the scheduling language from \"Fast export for large invoice volumes\" and replaced it with a concrete performance check. The acceptance checks now verify export reliability and speed for the stated use case, without implying automated scheduling that wasn't requested."
      }
    },
    "clarify": {
      "status": "aborted",
      "summary": "blocked by a failed dependency"
    },
    "rank": {
      "status": "aborted",
      "summary": "blocked by a failed dependency"
    }
  }
}
```

## The team's shape

| step    | does                                                                                                                            | done when                                                                                                  |
| ------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| split   | Turn every raw ticket in backlog/raw\.md into one or more stories, each with its acceptance checks and the ticket it came from. | Every raw ticket is covered by at least one story and a reviewer from another family has accepted the set. |
| clarify | For each story, list the questions that must be answered before anyone writes code, with a proposed answer for each.            | Every story has its questions, or the line "no open questions", and a reviewer has accepted them.          |
| rank    | Put the stories and the open questions in front of the product owner.                                                           | The owner has ranked the cycle.                                                                            |

Where a stage's gate names a file, the package checks the file after the
job. Where it names an exit code, the command decides. Where it names a
reviewer, the reviewer's decision decides. Where it names a person, the
run pauses until the person answers. A model's own report of its work
never passes a stage.

## Gotchas

* **The reviewer reads the raw tickets, not just the stories.** The review's gate is coverage: a raw ticket with no story sends the split back, which is what happens in the proof.
* **Priority is not the model's to decide.** The brief says so and the workflow ends at a person; a story order that appeared in `stories.md` would be a model's guess dressed as a decision.
* **A note that comes back unchanged fails.** A split sent back with findings must change; the same bytes again end the stage rather than run a second identical review.

## Source

The file is `examples/use-cases/engineering/backlog-groom-then-rank.ts`; its proof, brief and
sample inputs sit beside it. The [runtime package page](/packages/runtime)
lists every input.
