Implement this with your agent

Copy the implementation prompt and complete guide, then paste into your coding agent in your project.

Read the prompt
Implement persisted complexity routing for your issue or review workflow using this guide as reference. Read repository instructions and existing state, dispatch and review code first. Adapt the smallest change, preserve stored state and supported runtimes, run the verification commands, and report observed output, adaptations, unrun checks and limits. Do not commit, push or deploy.
Copy the complete text manually

Select all the text below and copy it into your agent.

Spend review effort where the change can hurt

Issue Flow · No. 153

Shipped

Issue Flow v0.11.0 adds persisted complexity profiles: plain wording changes take a fast documentation route, documentation with shipped-contract impact takes a standard route, and code or operational work takes the deep route. The profile controls review rounds and time budget, and it survives resume. The broader pattern is risk-based orchestration: classify the work from frozen input, spend more attention where the failure cost is higher, and make the stop condition part of the state machine. NIST’s AI Risk Management Framework makes a similar case for setting risk-management activity according to risk tolerance, with clear human roles. Its core guidance is a useful reference when deciding which routes can be autonomous and which must stop for a person.

Freeze the input before choosing a route

Do not classify an issue from a live conversation that can change while agents are working. Freeze the title and body first, then derive a profile from that snapshot. A small classifier can start with explicit signals:

export function classifyIssue(issue) {
  const text = `${issue.title ?? ''}\n${issue.body ?? ''}`;
  const docs = /\b(doc|docs|documentation|readme|copy|wording|typo|guide|changelog)\b/i.test(text);
  const shippedContract = /\b(test|tests|template|generated|workflow|manifest|plugin\.json|package\.json|api|auth|security|migration|acceptance criteria|all \d+)/i.test(text);

  if (docs && !shippedContract) {
    return { kind: 'fast-docs', reviewRounds: 1, budgetSeconds: 900, reason: 'documentation-only wording change' };
  }
  if (docs) {
    return { kind: 'standard', reviewRounds: 2, budgetSeconds: 1800, reason: 'documentation with shipped-contract impact' };
  }
  return { kind: 'deep', reviewRounds: 4, budgetSeconds: 1800, reason: 'code or operational change' };
}

The exact vocabulary belongs to your repository. The important property is that the result is a named profile, not a loose collection of decisions made later. Persist it with the run timestamp and copy the review cap into every lane created by a split:

export function createRun({ repo, issue, policy, now = () => new Date().toISOString() }) {
  const complexity = classifyIssue(issue);
  return {
    schema: 1,
    repo,
    issue,
    policy,
    complexity,
    createdAt: now(),
    lanes: [{
      slug: 'root',
      title: issue.title,
      base: policy.base,
      review: { rounds: [], maxRounds: complexity.reviewRounds },
    }],
  };
}

On resume, load the stored profile instead of reclassifying the current issue text. Otherwise a user editing the issue halfway through could quietly turn a one-round run into a four-round run, or the reverse.

Route the review fleet by semantic load

Round count is only one lever. The number of finders and verifiers should follow the amount of production meaning in the diff, not generated fixtures or indexes. A simple plan can make that decision explicit:

export function fleetPlan(lines, round, { ofFix = false, risk = false } = {}) {
  if (lines < 60 && !risk) return { finders: 1, maxVerifiers: 2 };

  const floor = risk ? 2 : (ofFix ? 1 : 2);
  const finders = ofFix
    ? Math.min(3, Math.max(floor, Math.ceil(lines / 300)))
    : Math.min(5, Math.max(floor, Math.ceil(lines / 150)));

  return { finders, maxVerifiers: ofFix ? 4 : 8 };
}

Before counting, calculate semantic changed lines and exclude generated evaluation fixtures and indexes that would make the same small production change look larger. Keep the whole diff in every relevant brief, though. The optimization is fewer duplicate readers, not less context.

For a first round, use the pull request’s full semantic diff. For later rounds, size the fleet from the fix patch. A fix review asks whether the open finding was addressed, so paying for a full first-round fleet again is usually the wrong unit of work. If the issue is marked high risk, retain a floor of two finders even when the line count is small.

Make autonomous stops typed and observable

The loop needs a stopping function that returns a human-readable action instead of silently falling through to “keep going”:

export function decideBudget(run, now = new Date()) {
  const elapsed = (now.getTime() - Date.parse(run.createdAt)) / 1000;
  if (run.complexity?.budgetSeconds && elapsed > run.complexity.budgetSeconds) {
    return {
      kind: 'stop',
      reason: 'exhausted',
      detail: `${run.complexity.kind} budget expired`,
      action: 'finish the current artifact or restart with an explicit override',
    };
  }
  return null;
}

Check the budget before selecting the next stage, and stop only if the run is not already done. A timed-out run should retain its current artifact, profile, and evidence paths so a person can inspect or explicitly restart it. Do not turn expiry into approval.

Review caps need the same shape. If a lane has reached its profile’s maximum rounds and still has a major finding open, hand the decision back. The user can rule that a finding is fixed or withdrawn, or direct one more round with a reason. That is materially different from an autonomous loop approving over an unresolved major.

The same principle applies to GitHub itself. GitHub reviews can request changes or approve, and protected branches can require approvals before merge. GitHub’s review documentation describes those as merge controls, while protected-branch documentation explains stale approvals and the requirement to reapprove new pushes. Your local state machine should preserve that distinction rather than treating a green local verdict as permission to merge.

Use it, then verify it

Create a small table-driven test set for the classifier and persistence boundary:

import test from 'node:test';
import assert from 'node:assert/strict';
function classifyIssue(issue) {
  const text = `${issue.title ?? ''}\n${issue.body ?? ''}`;
  const docs = /\b(doc|docs|documentation|readme|copy|wording|typo|guide|changelog)\b/i.test(text);
  const shippedContract = /\b(test|tests|template|generated|workflow|manifest|plugin\.json|package\.json|api|auth|security|migration|acceptance criteria|all \d+)/i.test(text);
  if (docs && !shippedContract) return { kind: 'fast-docs', reviewRounds: 1, budgetSeconds: 900, reason: 'documentation-only wording change' };
  if (docs) return { kind: 'standard', reviewRounds: 2, budgetSeconds: 1800, reason: 'documentation with shipped-contract impact' };
  return { kind: 'deep', reviewRounds: 4, budgetSeconds: 1800, reason: 'code or operational change' };
}

function createRun({ repo, issue, policy, now = () => new Date().toISOString() }) {
  const complexity = classifyIssue(issue);
  return { schema: 1, repo, issue, policy, complexity, createdAt: now(), lanes: [{ slug: 'root', title: issue.title, base: policy.base, review: { rounds: [], maxRounds: complexity.reviewRounds } }] };
}

test('wording-only issue uses the fast route', () => {
  const profile = classifyIssue({ title: 'docs: fix a typo', body: 'Correct spelling.' });
  assert.deepEqual(profile, {
    kind: 'fast-docs', reviewRounds: 1, budgetSeconds: 900,
    reason: 'documentation-only wording change',
  });
});

test('the route is persisted', () => {
  const run = createRun({
    repo: { owner: 'example', name: 'app' },
    issue: { title: 'docs: fix a typo', body: 'Correct spelling.' },
    policy: { base: 'main' },
    now: () => '2026-01-01T00:00:00.000Z',
  });
  assert.equal(run.createdAt, '2026-01-01T00:00:00.000Z');
  assert.equal(run.complexity.kind, 'fast-docs');
  assert.equal(run.lanes[0].review.maxRounds, 1);
});

Run the test with node --test complexity.test.mjs. You should see two passing tests. Add boundary cases for a documentation issue that mentions a manifest, a split lane inheriting the profile, an expired budget when the run is already done, and a review cap with an open major. The expected result is not merely a number of agents; it is a durable stop that names what a human must decide.

Gotchas

  • Trap: reclassifying on resume. Symptom: the same run changes review depth because the issue body changed. Escape: persist the profile and createdAt when the run is created, then load them unchanged.
  • Trap: counting generated files as semantic work. Symptom: a tiny production change gets the maximum reviewer fleet. Escape: exclude known generated paths from the sizing metric while keeping them visible in the review context.
  • Trap: making “time budget” a suggestion. Symptom: next continues dispatching after the promised window. Escape: check expiry before state selection and return a typed exhausted stop with the current artifact.
  • Trap: letting a cap mean approval. Symptom: a pull request becomes ready with an unresolved major because the loop ran out of rounds. Escape: surface the open finding and require a typed human ruling or explicit override.
  • Trap: retrying a remote review action without idempotency. Symptom: duplicate comments or review threads appear after a network timeout. Escape: use stable finding IDs and make posting/resolution operations reconcile against recorded state. AWS’s retry guidance recommends idempotent operations for this reason. AWS retry guidance also warns that retrying non-transient failures can increase load, so fail fast when the cause is known.

Sources

Changelog

  • feat(issueflow): prevent red-team review loops (#287) (b70becf)
  • fix(issueflow): keep entrypoint within context budget (be9798f)
  • feat(issueflow): route review effort by issue complexity (#284) (db23313)