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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 72 additions & 23 deletions .claude/skills/neutral-reconcile/SKILL.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions llp/0007-config-and-onboarding.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ file → defaults. Tracked (not gitignored). Fields:
> field (default `false`) — opt-in per repo to let the terminal reconcilePR rung
> squash-merge a finished PR instead of holding it for a human.

> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):**
> adds `maxActiveWork` (default `4`) for bounded intake and `mergeQueue` (default
> `false`) for queue-owned freshness and landing.

## Baseline

`.neutral/baseline.json` (tracked) lists request LLPs that already existed / were
Expand Down
8 changes: 8 additions & 0 deletions llp/0009-maintenance-reconcilers.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ per-tick fan-out of LLP 0010.)
*precaution* only — CI (the green rung) is the authoritative gate after the
push (LLP 0002: the resolving agent does not grade its own merge).
- `UNKNOWN` → wait (GitHub still computing).

> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):**
> in `mergeQueue` mode an own PR's `BEHIND` state is satisfied by the queue's
> merge-group validation, so Neutral does not merge the target into every branch.
2. **Green** — `gh pr view --json statusCheckRollup`, read against the current head
SHA:
- `FAILURE`/`ERROR` → an agent fixes from the failing logs
Expand Down Expand Up @@ -140,6 +144,10 @@ what, via the change-set DAG, unblocks dependents (LLP 0003).
> instead of `ready-hold`/`held` — flip ready if draft, then squash-merge. The
> three rungs and the `neutral:stuck` override are unchanged.

> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):**
> with both `automerge` and `mergeQueue` enabled, the terminal emits `enqueue` and
> then `wait`s while GitHub reports a `mergeQueueEntry`.

**Reuse.** `reconcilePR` is shared with the pipeline family — the Reviewer
reconciler (LLP 0000) becomes rung 3 plus the new mergeable/green rungs — so
neutral's own change-set PRs gain the CI and mergeability self-healing the pipeline
Expand Down
4 changes: 4 additions & 0 deletions llp/0017-triage-at-review-cap.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ This decision inserts a **triage** step at the cap. Before parking, neutral re-j
they ride the **issue-fix** reconciler, so a deferred finding reaches neutral state only
when its `neutral:fix` issue has a fix attempt (the two invariants compose — LLP 0008).

> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):**
> the follow-up issue is still recorded immediately, but its fix attempt waits for
> `maxActiveWork` admission. Review feedback cannot bypass the global work cap.

## The triage judgment lives in the worker, not the classifier

"Could this cause a production defect?" is a judgment, so it stays **out** of the pure rung
Expand Down
4 changes: 4 additions & 0 deletions llp/0019-automerge.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ flips a draft ready (`gh pr ready`) and **squash-merges** it (`gh pr merge
--squash`), the same squash-only-at-the-final-PR rule as the human merge
(LLP 0003).

> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):**
> `mergeQueue: true` changes this landing strategy from direct `merge` to `enqueue`;
> `automerge` remains the explicit authority to take either terminal action.

Everything upstream of the terminal rung is unchanged — automerge **relaxes the
hold, never the gates**:

Expand Down
5 changes: 5 additions & 0 deletions llp/0030-own-pr-approved-label.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ label tracks the current reviewed-clean head and **cannot go stale**: when maste
an approved PR goes `BEHIND`, its next-tick action is `merge-base` (no `approved`) and the sync
strips the label until the rebased head is re-reviewed clean and returns to `ready-hold`/`held`.

> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):**
> queue mode adds `enqueue` and queue-owned `wait` to the reviewed-clean terminal.
> `BEHIND` alone no longer strips approval because GitHub validates freshness in the
> merge group; a changed head still invalidates the head-keyed review as before.

<a id="no-body-marker"></a>**No body verdict marker for own PRs.** A foreign PR persists its
verdict in a head-keyed `<!-- neutral-verdict: <sha> -->` body marker for idempotency, because
neutral cannot push to the fork and must not re-label a settled head. An own PR needs no such
Expand Down
4 changes: 4 additions & 0 deletions llp/0052-observe-command.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ had **no command at all**. `neutral backlog`, `neutral implementable`,
`neutral prs` and `neutral issues` each report their own gap family, but two
pipeline gaps were left to skill prose:

> **Extended-by [LLP 0060](0060-admission-control-and-merge-queue.decision.md):**
> the same observe surface now also emits the deterministic active-work admission
> state consumed by the reconciler skill.

- *a change set with a plan but unmerged tasks* — findable only by enumerating
`origin/integration/*` by hand and running `neutral ready <slug>` per branch,
from a worktree of that branch (the working-tree read fails on the main
Expand Down
89 changes: 89 additions & 0 deletions llp/0060-admission-control-and-merge-queue.decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# LLP 0060: Bound active work and delegate freshness to the merge queue

**Type:** Decision
**Status:** Accepted
**Systems:** Engine, Engineer, Reviewer
**Author:** Phil / Codex
**Date:** 2026-08-19
**Related:** 0002, 0007, 0008, 0009, 0017, 0019, 0030, 0052

## Context

Neutral previously treated every branch-disjoint gap as permission to start more
work. Review could therefore create a positive-feedback loop: several PRs generated
several reviews, non-blocking findings generated `neutral:fix` issues, and the next
tick generated still more PRs. At the same time, every advance of the target branch
made the remaining own PRs `BEHIND`; the mergeable rung merged the target into all of
them, invalidating head-keyed reviews and checks and creating another round of work.

GitHub's merge queue can validate a PR against the latest target in a synthesized
merge group. It removes the need to keep every candidate branch freshly merged, but
it does not limit how much work Neutral starts. The two controls are complementary:
bound admission before work fans out, then let the queue serialize landing.

## Decision

<a id="admission-control"></a>
### Admission control

Add `maxActiveWork` to `.neutral/config.json`, default **4**. A non-negative integer
is valid; `0` is an explicit intake stop. One slot is consumed by each distinct,
non-frozen work surface Neutral already owns:

- an in-scope open PR, including a reviewed-clean PR waiting to land;
- an unshipped `integration/*` change set without its own open PR;
- a `fix/issue-*` attempt branch without its own open PR.

The observations are deduplicated, so an integration branch and its PR are one
surface, not two. A `neutral:stuck` PR that owes only its report or awaits a human,
or a `neutral:stuck` issue, is **frozen**: it remains visible but consumes no slot.
A human reply makes a stuck PR active again at its `unstick` action.

`neutral observe` emits the complete `admission` decision (`limit`, `used`,
`available`, `open`, active surfaces and frozen surfaces). The skill acts on it; it
does not recount work. At capacity:

- every existing change set and PR continues through its owed action;
- triage may record deferred findings as a `neutral:fix` issue, preserving the
finding, but the issue-fix worker does not start a branch until a slot opens;
- no new design/change-set branch, design-first integration branch, issue-fix
branch, or repo-hygiene PR is started.

When slots are available, a tick may admit at most `available` new surfaces. A
Designer may therefore mint at most that many change sets, not the whole backlog.
Once a branch is minted it appears in the next observation and consumes its slot.
The cap throttles intake only; it is never a reason to stop healing admitted work.

<a id="merge-queue"></a>
### Merge queue

Add `mergeQueue` to `.neutral/config.json`, default **false**. It is an opt-in
landing strategy for a repository whose target branch is configured to require a
GitHub merge queue. `automerge` remains the authority boundary:

- `mergeQueue: true`, `automerge: false` keeps the human-held terminal, while the
queue still owns base freshness;
- both true change the own reviewed-clean terminal from `merge` to `enqueue`.

For own PRs in queue mode, `BEHIND` is not a heal action. Real conflicts (`DIRTY`),
current-head checks, current-head review and `neutral:stuck` retain their existing
precedence. At the reviewed-clean terminal, `enqueue` flips a draft ready and runs
`gh pr merge <N> --squash --match-head-commit <headSha>`. On a queue-required target,
GitHub adds the exact head to the queue and validates the synthesized merge group.

Queue membership is read from the Pull Request GraphQL `mergeQueueEntry` field.
While an entry exists, the classifier returns `wait` with `approved: true`; it does
not re-enqueue, merge the target into the branch, or repeat review. If GitHub drops
the entry, the next observation re-derives the ordinary rungs and may enqueue again.
Foreign review-only PRs retain their existing contributor-facing freshness rules.

## Consequences

- Review follow-ups become backlog while capacity is full instead of recursively
becoming immediate PRs.
- A long-lived reviewed PR still applies useful backpressure until it lands; a truly
stuck one does not freeze the whole system.
- Queue-enabled repositories stop rewriting every own PR when the target advances.
Merge-group CI, not branch churn, proves integration freshness.
- Repositories without queue configuration preserve the existing `merge-base` and
direct `merge` behavior. `automerge` and `mergeQueue` remain explicit opt-ins.
63 changes: 63 additions & 0 deletions src/admission.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// @ts-check
// Pure admission control over the tick's existing observations. It limits new work
// without interrupting maintenance of work already in flight.
// @ref LLP 0060#admission-control [implements]

/** @import { AdmissionState, AdmissionSurface, ChangeSetState, IssueFixState } from './types.d.ts' */

/**
* Count the work surfaces Neutral already owns. An open PR replaces its underlying
* integration/fix branch as the one visible surface, so the sets are deduplicated.
* A genuinely held `neutral:stuck` artifact is frozen: it remains visible but does
* not consume capacity. A replied-to stuck PR (`unstick`) is active again.
*
* @param {{ changesets?: ChangeSetState[], prs?: Array<{number: number, head: string, action: string, stuck?: boolean}>, issues?: IssueFixState[] }} obs
* @param {number} limit
* @returns {AdmissionState}
*/
export function admissionState({ changesets = [], prs = [], issues = [] } = {}, limit = 4) {
/** @type {AdmissionSurface[]} */
const active = []
/** @type {AdmissionSurface[]} */
const frozen = []
const openHeads = new Set()

for (const p of prs) {
if (p.action === 'mark-adopted') continue
openHeads.add(p.head)
const surface = { kind: /** @type {'pr'} */ ('pr'), target: `pr#${p.number}`, reason: p.head }
if (p.stuck && (p.action === 'held' || p.action === 'stuck-report')) frozen.push(surface)
else active.push(surface)
}

for (const c of changesets) {
if (c.shipped || openHeads.has(c.integration)) continue
active.push({ kind: 'changeset', target: `changeset/${c.slug}`, reason: c.integration })
}

for (const i of issues) {
if (i.state === 'stuck') {
frozen.push({ kind: 'issue', target: `issue#${i.number}`, reason: i.via || 'stuck' })
continue
}
if (i.state !== 'attempt-exists' || !i.via) continue
if (i.via.startsWith('pr:#')) continue
const branch = i.via.startsWith('branch:') ? i.via.slice('branch:'.length) : ''
if (branch && openHeads.has(branch)) continue
active.push({ kind: 'issue', target: `issue#${i.number}`, reason: i.via })
}

const safeLimit = Number.isInteger(limit) && limit >= 0 ? limit : 4
const available = Math.max(0, safeLimit - active.length)
return {
limit: safeLimit,
used: active.length,
available,
open: available > 0,
active,
frozen,
reason: available > 0
? `${available} new work slot(s) available`
: `at capacity (${active.length}/${safeLimit}) — reconcile existing work; admit no new branch or PR`
}
}
15 changes: 10 additions & 5 deletions src/commands/idle.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { collectChangeSets } from '../changesets.js'
import { collectPRs } from './prs.js'
import { collectIssues } from './issues.js'
import { idleState } from '../idle.js'
import { admissionState } from '../admission.js'
import { selectInitiative } from '../autophagy.js'
import { listDisposedAutophagyPRs } from '../github.js'
import { readContextSize } from '../context.js'
Expand All @@ -35,11 +36,11 @@ import { readContextSize } from '../context.js'
* @param {typeof run} [exec]
* @param {() => (number|null)} [readCtx]
* @param {{ now?: number, damped?: string[] }} [opts]
* @returns {Promise<{ idle: boolean, recycle: boolean, initiative: string|null, contextSize: number|null, threshold: number, blockers: import('../types.d.ts').IdleBlocker[], members: import('../types.d.ts').MemberState[] }>}
* @returns {Promise<{ idle: boolean, recycle: boolean, initiative: string|null, contextSize: number|null, threshold: number, admission: import('../types.d.ts').AdmissionState, blockers: import('../types.d.ts').IdleBlocker[], members: import('../types.d.ts').MemberState[] }>}
*/
export async function collectIdle(repo, exec = run, readCtx = readContextSize, { now = Date.now(), damped = [] } = {}) {
const config = loadConfig(repo)
const { contextRecycleThreshold: threshold } = config
const { contextRecycleThreshold: threshold, maxActiveWork } = config
const [{ backlog }, implementable, prs, issues, disposed] = await Promise.all([
collectBacklog(repo),
collectImplementable(repo, exec),
Expand All @@ -51,13 +52,17 @@ export async function collectIdle(repo, exec = run, readCtx = readContextSize, {
// @ref LLP 0052#idle-extension [implements] — the idle predicate sees change-set gaps
const changesets = await collectChangeSets(repo, exec, { openHeads: new Set(prs.map(p => p.head)) })
const { idle, blockers } = idleState({ backlog, implementable, changesets, prs, issues })
const admission = admissionState({ changesets, prs, issues }, maxActiveWork)
const contextSize = readCtx()
const recycle = idle && contextSize !== null && contextSize > threshold
const { initiative: member, members } = selectInitiative({ openPRs: prs, disposed, config, now, damped })
// @ref LLP 0035#priority [implements] — recycle preempts repo hygiene
// @ref LLP 0047#selection [implements] — else the LRR member, else null
const initiative = recycle ? 'recycle' : (idle ? member : null)
return { idle, recycle, initiative, contextSize, threshold, blockers, members }
// Repo-hygiene creates a new work surface, so the same admission gate applies.
// Context recycle creates no repo work and may still run while intake is paused.
// @ref LLP 0060#admission-control [implements]
const initiative = recycle ? 'recycle' : (idle && admission.open ? member : null)
return { idle, recycle, initiative, contextSize, threshold, admission, blockers, members }
}

/**
Expand All @@ -77,7 +82,7 @@ export async function idleCommand(repo, args, exec = run, readCtx = readContextS
process.stdout.write(JSON.stringify(s, null, 2) + '\n')
} else {
const ctx = s.contextSize === null ? 'unmeasured' : `${s.contextSize} tok`
process.stdout.write(`idle=${s.idle} initiative=${s.initiative ?? 'none'} context=${ctx} (T=${s.threshold})\n`)
process.stdout.write(`idle=${s.idle} initiative=${s.initiative ?? 'none'} context=${ctx} (T=${s.threshold}) admission=${s.admission.used}/${s.admission.limit}\n`)
if (!s.idle) {
for (const b of s.blockers) process.stdout.write(` blocker: ${b.family} ${b.target} — ${b.reason}\n`)
} else if (s.initiative !== 'recycle') {
Expand Down
9 changes: 7 additions & 2 deletions src/commands/observe.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { collectChangeSets } from '../changesets.js'
import { collectPRs } from './prs.js'
import { collectIssues } from './issues.js'
import { idleState } from '../idle.js'
import { admissionState } from '../admission.js'
import { loadConfig } from '../config.js'
import { padStart } from '../format.js'

/** @import { ChangeSetState, IdleBlocker, IssueFixState, Llp } from '../types.d.ts' */
Expand All @@ -21,9 +23,10 @@ import { padStart } from '../format.js'
* report is offline-safe like its parts.
* @param {string} repo
* @param {typeof run} [exec]
* @returns {Promise<{ backlog: Llp[], implementable: Array<{number: number, slug: string, title: string}>, changesets: ChangeSetState[], prs: Awaited<ReturnType<typeof collectPRs>>, issues: IssueFixState[], neutral: boolean, gaps: IdleBlocker[] }>}
* @returns {Promise<{ backlog: Llp[], implementable: Array<{number: number, slug: string, title: string}>, changesets: ChangeSetState[], prs: Awaited<ReturnType<typeof collectPRs>>, issues: IssueFixState[], admission: import('../types.d.ts').AdmissionState, neutral: boolean, gaps: IdleBlocker[] }>}
*/
export async function collectObserve(repo, exec = run) {
const { maxActiveWork } = loadConfig(repo)
const [{ backlog }, implementable, prs, issues] = await Promise.all([
collectBacklog(repo),
collectImplementable(repo, exec),
Expand All @@ -32,7 +35,8 @@ export async function collectObserve(repo, exec = run) {
])
const changesets = await collectChangeSets(repo, exec, { openHeads: new Set(prs.map(p => p.head)) })
const { idle, blockers } = idleState({ backlog, implementable, changesets, prs, issues })
return { backlog, implementable, changesets, prs, issues, neutral: idle, gaps: blockers }
const admission = admissionState({ changesets, prs, issues }, maxActiveWork)
return { backlog, implementable, changesets, prs, issues, admission, neutral: idle, gaps: blockers }
}

/**
Expand All @@ -59,6 +63,7 @@ export async function observeCommand(repo, args, exec = run) {
const none = ' (none)'
process.stdout.write(
(o.neutral ? 'observe: neutral state — no gaps\n' : `observe: ${o.gaps.length} gap(s)\n`) +
`admission: ${o.admission.used}/${o.admission.limit} active, ${o.admission.available} available — ${o.admission.open ? 'OPEN' : 'PAUSED'}\n` +
'pipeline\n' +
' backlog — requests needing a design:\n' +
(o.backlog.length ? o.backlog.map(l => ` ${padStart(String(l.number), 4, '0')} ${l.title} [${l.type}]`).join('\n') : none) + '\n' +
Expand Down
Loading
Loading