GitHub Build Intake: $ARGUMENTS
$ARGUMENTS is one of:
- A GitHub
org/repotoken (e.g.,acme/frontend-v2). - A full GitHub repo URL (e.g.,
https://github.com/acme/frontend-v2). - The literal token
github(or an omitted repo) — resolves merged configgithub.queueRepo, falling back to the identitygithub.org/github.repo.
An explicit org/repo token or GitHub URL always wins. github.queueRepo may be canonical
owner/repo or a short repo name normalized to github.org. It changes only the scanned queue;
the Phase 3a.0 repo:<current> gate still resolves the code repository from repo /
github.repo / the git remote.
Run one build-intake cycle. The first eligible issue in the configured ready build label is claimed, built via the github-agent workflow run in-session (Phase 3c, culminating in lisa-implement), relabeled to the configured done label (env-aware — see Workflow resolution), then the cycle exits. Remaining ready issues stay queued for later scheduler invocations.
This skill also accepts an optional assignee=<github-login> queue filter. Resolve it in this
order:
$ARGUMENTSassignee=<login>.lisa.config.local.jsonintake.assignee- empty default
When the resolved assignee is empty, scan the shared ready queue exactly as before. When it is non-empty, filter the ready-item query to issues already assigned to that login. This filter is selection-only: never assign or reassign issues as part of build intake.
Workflow resolution
Build-queue label names are read from .lisa.config.json github.labels.build.*, falling back to defaults documented in the config-resolution rule. Bash pattern:
ROLE_RESOLVER="${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-plugins/lisa}}/scripts/resolve-lifecycle-role.mjs"
READY=$(node "$ROLE_RESOLVER" --role ready --vendor github --intent read) || exit $?
CLAIMED=$(node "$ROLE_RESOLVER" --role claimed --vendor github --intent write) || exit $?
read_intake_assignee() {
local cli_value local_v
cli_value=$(printf '%s\n' "$ARGUMENTS" | sed -n 's/.*assignee=\([^[:space:]]*\).*/\1/p' | head -1)
local_v=$(jq -r '.intake.assignee // empty' .lisa.config.local.json 2>/dev/null)
echo "${cli_value:-${local_v:-}}"
}
ASSIGNEE=$(read_intake_assignee)
For env-keyed done, resolve the env first, then look up done[<env>]:
- Explicit caller arg (
target_env=staging) wins. - Otherwise, infer the env from the PR's base branch via
deploy.branches(reverse lookup). - If
doneis a string in config, use it directly regardless of env. - If
doneis a map and env cannot be resolved, fail loudly — do not pick arbitrarily. - Promotion completeness caps the result. The base branch names the environment the change
entered, never the environments it has reached. Walk
deploy.orderfrom its lowest rung and write the highest contiguously reached rung at or below the resolved env: a rung is reached only when the merge commit is an ancestor of itsdeploy.branchesbranch (git merge-base --is-ancestor <merge-sha> origin/<branch>, asserted for every env branch at or below the resolved one — not only the PR's base) and that branch's most recent deploy concludedsuccess(read theconclusion, never thestatus; onlysuccesspromotes — a null conclusion and every other conclusion,failure/cancelled/timed_out/neutral/skipped/stale/action_required, leave the rung unreached, and an in-flight deploy is unknown, not green). Wheredeploy.orderis absent the ladder is the single resolved env; where a branch exposes no deploy surface at all, ancestry alone decides that rung. A hotfix merged straight to the production branch that skippedstagingtherefore writes the rung below the gap and stays open. The recorded reason carries all three fields —<first unreached env> (<its branch>) — <condition>, the condition beingmissing ancestry,deploy unknown: <run URL, or "no concluded run">, ordeploy concluded <conclusion>: <run URL>; a failing run named without its environment and branch is an incomplete reason. An open back-fill PR against a skipped environment branch is outstanding delivery, not branch hygiene. Seeconfig-resolution→ "Promotion completeness".
TARGET_ENV="${target_env:-}"
if [ -z "$TARGET_ENV" ] && [ -n "$PR_BASE_BRANCH" ]; then
TARGET_ENV=$(jq -r --arg b "$PR_BASE_BRANCH" \
'.deploy.branches // {} | to_entries[] | select(.value == $b) | .key' \
.lisa.config.json 2>/dev/null | head -1)
fi
DONE_TYPE=$(jq -r '.github.labels.build.done | type' .lisa.config.json 2>/dev/null)
if [ "$DONE_TYPE" = "string" ]; then
DONE=$(jq -r '.github.labels.build.done' .lisa.config.json)
DONE_LABELS_JSON=$(jq -c '[.github.labels.build.done]' .lisa.config.json)
elif [ "$DONE_TYPE" = "object" ]; then
[ -z "$TARGET_ENV" ] && { echo "ERROR: github.labels.build.done is env-keyed but env not resolvable"; exit 1; }
DONE=$(jq -r --arg e "$TARGET_ENV" '.github.labels.build.done[$e] // empty' .lisa.config.json)
[ -z "$DONE" ] && { echo "ERROR: github.labels.build.done has no entry for env '$TARGET_ENV'"; exit 1; }
DONE_LABELS_JSON=$(jq -c '[.github.labels.build.done[]]' .lisa.config.json)
else
case "$TARGET_ENV" in
dev) DONE="status:on-dev" ;;
staging) DONE="status:on-stg" ;;
production) DONE="status:done" ;;
*) echo "ERROR: cannot resolve done label without env"; exit 1 ;;
esac
DONE_LABELS_JSON=$(jq -cn --arg d "$DONE" '[$d]')
fi
In prose below, the role names refer to the resolved labels: e.g. "the ready label" means whatever github.labels.build.ready resolves to (default: status:ready).
Confirmation policy
Do NOT ask the caller whether to proceed. Once invoked with a repo, run the cycle to completion — claim and dispatch the first eligible issue through the in-session lifecycle (Phase 3c), relabel a successful build to $DONE, write the summary, and exit. The caller (a human or a cron) has already authorized the run by invoking the skill; re-prompting defeats the purpose of a background queue.
Specifically forbidden:
- Previewing projected scope (issue count, projected PR count, build duration) and asking whether to continue.
- Offering A/B/C-style choices like "proceed / skip a few / dry-run only".
- Pausing because the queue is large, issues look complex, or issues are likely to be
Blockedby the pre-flight gate. Pre-flightBlockedis a valid terminal state of the per-issue lifecycle, not a failure mode. - Pausing because the build flow looks expensive.
The only legitimate reasons to stop early:
- Missing repo or required configuration. Surface the missing value and exit.
- Label namespace not adopted (no issue carries any of
$READY/$CLAIMED/$DONE). Surface a label-convention error and exit (this is setup, not a normal idle cycle — see "Adoption" at the bottom). - Empty pre-work set. Exit cleanly on the denominator-stated summary from
summarizeDryLane— which names every lane swept, its count, and the open total. A bare "nothing to do" is not an acceptable exit: it is indistinguishable from a wrong denominator (#2657).
Lifecycle assumed
The GitHub Issues build lifecycle uses labels (we deliberately do NOT key off open/closed alone — closed issues aren't always the right post-build state):
ready → claimed → done(env-keyed)
(human) (us claim) (us done; PR ready)
(Defaults: status:ready / status:in-progress / status:on-dev/status:on-stg/status:done.)
This skill ONLY transitions:
$READY→$CLAIMED(claim)$CLAIMED→$DONE(build complete, PR ready)
A "transition" means: remove the old role label and add the new one, in two gh issue edit calls (--remove-label + --add-label) or one combined call. The skill MUST verify exactly one build-lifecycle label (from the resolved $READY/$CLAIMED/$DONE set) is present after the update — having two simultaneously breaks idempotency.
Pre-flight check: at the start of each cycle, confirm at least one of the resolved role labels ($READY, $CLAIMED, or any $DONE value) exists on the repo via gh label list --repo <org>/<repo> --json name. If none exist, the convention has not been adopted — surface the label-convention error and exit.
Phases
Phase 1 — Resolve the repo
- Parse
$ARGUMENTS:org/repotoken → use as-is.- GitHub URL → extract
organdrepo. - Literal
githubor omitted repo → resolve local then globalgithub.queueRepo, falling back togithub.org/github.repo; normalize a shortqueueRepotogithub.org; error if the resulting identity/queue cannot be resolved. - Never replace current-repo identity with the queue repo. An umbrella queue is only a scan target.
- Confirm
gh auth statussucceeds. - Confirm the repo is reachable:
gh repo view <org>/<repo> --json name --jq '.name'.
Phase 2 — Find ready issues
if [ -n "$ASSIGNEE" ]; then
gh issue list --repo <org>/<repo> --label "$READY" --assignee "$ASSIGNEE" --state open \
--json number,title,labels,assignees,milestone,createdAt --limit 100
else
gh issue list --repo <org>/<repo> --label "$READY" --state open \
--json number,title,labels,assignees,milestone,createdAt --limit 100
fi
If empty, run a secondary check to distinguish a genuinely empty queue from an unconfigured repo:
gh label list --repo <org>/<repo> --json name \
| jq -r --arg r "$READY" --arg c "$CLAIMED" --argjson d "$DONE_LABELS_JSON" \
'[.[] | .name | select(. == $r or . == $c or (. as $n | $d | index($n)))] | length'
If none of the configured role labels exist on the repo → label convention not adopted, surface a setup error and exit.
2a. Sweep every pre-work lane, and state the denominator
GitHub Issues has no state-type field, so labels are the only lane available here — a constraint of
GitHub's data model, not a preference (see "Why labels" above). That makes the omission risk
higher, not lower: there is no type to fall back on, so the pre-work set must be derived from
configured roles, never from a hardcoded roster of label names. The pre-work lanes are:
- the configured
$READYlabel — the human-flipped lane, worked first; - the configured
$BLOCKEDlabel (github.labels.build.blocked) — items that were never started, each carrying a written blocker that nothing re-read until Phase 2.5; - open issues carrying no build role label — which this scanner must see anyway in order to determine and stamp their repo.
Count each lane and the repo's total open count (gh issue list --state open --limit 1000 --json number | jq length), then build the denominator with the shared helper — GitHub callers pass the
lane type explicitly, since there is none to read:
buildIntakeDenominator({ lanes: [{name: "$READY", type: "unstarted", count: <n>},
{name: "$BLOCKED", type: "unstarted", count: <n>},
{name: "(no role label)", type: "backlog", count: <n>},
{name: "$CLAIMED", type: "started", count: <n>}, …],
totalOpen: <open issue count> })
summarizeDryLane(denominator, { queue: "<org>/<repo>" })
Every candidate outside $READY must clear Phase 2.5 before it is treated as a candidate at all. If
nothing survives, exit on the denominator-stated summary from summarizeDryLane — never a bare
"nothing to do". The run recorder rejects a dry build-intake run that does not name what it swept
(see automation-runbook-contract).
2a.1 Re-probe the blockers instead of inheriting them
A blocker is a claim with a timestamp, not a fact — it goes stale the moment its condition comes
true, and nothing re-read one before this phase. For each $BLOCKED candidate:
- Human gate first, and it is absolute. An issue carrying the configured human-needed label
(
github.labels.build.human_needed, defaulthuman-needed) or a[lisa-human-gate]marker in its body is never auto-selected, whatever any probe says. - Extract the stated discharge condition, then probe it. Machine-testable conditions — a version on trunk, a published package, a CI run history, an advisory's patched status — rot fastest and are cheapest to check. A human decision is not machine-testable; leave it.
- Classify with
classifyPreWorkCandidate(...)fromscripts/intake-blocker-reprobe.mjs. A discharge with no recorded evidence is not a discharge, and neither is a candidate nothing probed this cycle — the helper refuses both. - Record the result on the issue either way via
formatReprobeNote(...)as a comment, so the next cycle reads the answer rather than re-deriving it. Keep it idempotent. - On
selectable: true, relabel$BLOCKED → $READYwith the discharging evidence in the same comment, and treat it as an ordinary candidate. On anything else, leave it where it is.
2b. Lifecycle-label trust resolution (bot-authored labels are not signals)
A status:* label is only a lifecycle signal if a trustworthy actor applied it. Third-party GitHub Apps write labels through the same API humans do, and at least one — coderabbitai[bot] — stamps status:* on issues seconds after filing. Measured on CodySwannGT/lisa#2460–#2540: seven of eight bot-applied lifecycle labels landed 26–118s after creation. Those labels are guesses wearing the costume of the intake contract, and they break the queue in both directions:
- a bot-applied
$CLAIMEDmakes an unworked issue look like somebody's active work, so no cycle picks it up and no human re-examines it (#2470 sat unworked for a day this way); - a bot-applied
$READYputs an issue into the build queue that no human ever flipped ready (#2538), inverting the gate the ready role exists to be.
Before treating any candidate's lifecycle labels as meaningful, resolve which ones are trustworthy:
TRUST_DIR=$(mktemp -d)
trap 'rm -rf "$TRUST_DIR"' EXIT
gh api "repos/<org>/<repo>/issues/<n>" > "$TRUST_DIR/issue.json"
# --paginate emits ONE ARRAY PER PAGE. Reading `$t[0]` would pass only page one,
# silently dropping later label events — the newest of which is exactly the
# application this classifier keys on. --slurp + `add` flattens all pages.
gh api "repos/<org>/<repo>/issues/<n>/timeline?per_page=100" --paginate --slurp \
> "$TRUST_DIR/pages.json"
jq 'add // []' "$TRUST_DIR/pages.json" > "$TRUST_DIR/timeline.json"
# Resolve config the SAME way `read_role` does: the local file overrides the
# global one key by key. Reading only `.lisa.config.json` would resolve a stale
# terminal `done` label for any project that overrides it locally, and
# `lisa-repair-intake` WRITES on the drift direction that mistake produces.
# Both files are optional — a missing one must not abort the cycle.
jq -s '(.[0] // {}) * (.[1] // {})' \
<(cat .lisa.config.json 2>/dev/null || echo '{}') \
<(cat .lisa.config.local.json 2>/dev/null || echo '{}') \
> "$TRUST_DIR/config.json"
jq -n --slurpfile i "$TRUST_DIR/issue.json" \
--slurpfile t "$TRUST_DIR/timeline.json" \
--slurpfile c "$TRUST_DIR/config.json" \
'{issue: $i[0], timeline: $t[0], config: $c[0]}' \
> "$TRUST_DIR/input.json"
node "${CLAUDE_PLUGIN_ROOT}/scripts/lifecycle-label-trust.mjs" < "$TRUST_DIR/input.json"
The temp directory is per invocation. Fixed /tmp/lisa-*.json paths let two concurrent intake cycles interleave one issue's metadata with another's timeline, which yields a confident verdict about an item that was never examined.
The classifier returns trusted, untrusted, unknownProvenance, and a per-label evaluated list carrying the actor and the latency behind each decision. Use trusted wherever this skill would otherwise read the raw label set, and specifically:
- a candidate whose
$READYis untrusted is not claimable — skip it and leave it for a human to flip genuinely ready; report it in the summary rather than dispatching it; - a candidate whose
$CLAIMEDis untrusted is not claimed — if its$READYis trusted it stays a normal candidate, exactly as if the bot label were absent.
2c. A trusted claim is a skip reason
The scan filters on --label "$READY" alone, so an issue carrying both $READY and $CLAIMED comes back as a candidate. The rules above rescue the case where a bot applied the claim; they say nothing about the case where a human did — which is the strongest claim signal there is, and the one that was being ignored. An issue a person marked in-progress is somebody's active work, and dispatching a second agent onto it is how two branches end up fixing the same thing.
The classifier answers this directly, so the skill does not have to reason about it:
{ "claimable": false,
"reason": "already carries a trusted \"status:in-progress\", so somebody is working it; intake must not dispatch a second agent onto the same issue" }
A candidate with claimable: false is skipped and reported with its reason. It is not an error and not a stall — it is the queue working. Never strip the claim to make it claimable: that is the label-flap the section above refuses, aimed at a human this time.
claimable is computed from the trusted set, not the raw labels, which is what keeps it from undoing 2b — a bot-applied claim leaves the issue claimable exactly as if it were absent.
Never unlabel to correct this. The bot re-applies its label on each subsequent review event, so reverting produces a label-flap loop that is worse than the defect. The guard is that intake stops believing the label; it writes nothing. Distrust is idempotent and cannot race.
A label whose provenance cannot be established (applied at creation, which GitHub records no labeled event for) is trusted but listed in unknownProvenance — failing closed there would ignore the human status:ready that opens the queue. Report that list; do not silently drop it.
Lifecycle membership is decided by the status: prefix, never by a pinned member list. The live family has drifted 7 → 6 members, and a literal set breaks silently: an unrecognised member simply fails to match, so the guard would report clean on exactly the case it stopped covering.
Phase 3 — Process the first eligible ready issue
3.0 Human-hold gate (absolute, and it runs before every other gate)
A person parks an item by putting [lisa-human-gate] in its description. That marker used to be
read only for candidates outside $READY (Phase 2's blocker re-probe), so an item already in
the ready lane was claimed with the hold never consulted — one was dispatched and fully implemented
before a human vetoed the merge. The check was correct; it was unreachable from the path that
matters.
Run this first, ahead of the repo-scope gate (3a.0) and the leaf-only gate (3a), for every ready
candidate. Ordering is load-bearing for the same reason it is inside classifyPreWorkCandidate: no
other gate's verdict — however conclusive — may promote an item a person parked.
Classify with
classifyReadyCandidate(...)fromscripts/intake-blocker-reprobe.mjs. It shares the gate test with the pre-work classifier deliberately — two copies of a substring test drift, and a drifted gate fails silently, by quietly ceasing to match. Do not re-implement the test here, and do not key it onreason=: markers in the wild carry noreason=key at all and sit anywhere in the body, so a structured parse would miss them while appearing to work on every item that happens to have one. Pass the item'scommentsalongside its labels and body. A hold is ended by a release recorded in a comment, so a reader handed no comments cannot see the discharge — it goes on holding an item whose question was answered weeks ago, which is the defect this gate carried from the day it was written (CodySwannGT/lisa#3852). Omitting them fails closed, and that is exactly why it is easy to miss: nothing breaks, the item simply never comes back.On
claimable: falsewith reasonhuman-gate, do not claim and do not dispatch.Reconcile the lane; do not merely skip. Skipping alone leaves the item in
$READY, re-judged and re-rejected every cycle forever and seen by nothing —lisa-repair-intakesweeps items that are not in the ready role and excludes gated ones outright, so a ready-and-gated item falls outside its filter twice over. CallplanHumanGateReconciliation({ labels, body, humanNeededLabel, readyLabel, alreadyNotified })and apply exactly the actions it returns: remove$READY, add the configured human-needed marker, and postformatHumanGateNote()once. The planner is idempotent by state, so an item already out of the lane and already marked yields no second mutation and no second comment. This is the same repair the leaf-only gate already performs for a ready item that must not be dispatched.On
claimable: truefor an item that still carries a hold, RELEASE it — do not just proceed. The hold left durable state behind: the item is out of the queue and flagged as needing a person, and answering the question does not undo either. CallplanHumanGateRelease({ labels, body, comments, humanNeededLabel, readyLabel, lifecycleLabels, alreadyNotified })and apply exactly the actions it returns: remove the configured human-needed marker, add the configured ready role back, and postformatHumanGateReleaseNote()once. It is the exact inverse of step 3's planner and it refuses in both directions — an item still held plans nothing, and an item never held plans nothing, so it can only ever un-do a hold and can never promote something on its own. It is idempotent by state, so a second cycle over a released item yields no second mutation and no second comment.Never edit the description to clear a hold. The only body write available is a whole-body replacement, so deleting one line means rewriting the whole record and hoping nothing was dropped — the reason holds accumulated instead of being lifted. The hold note stays in the description as history; the release is a comment beside it.
Name it in the cycle summary via
summarizeHumanGateHolds([...]), so the record distinguishes "nothing was eligible" from "something eligible was held for a person". A lane mutation nobody can see afterwards is the same class of problem this gate exists to fix. Report alongside it what the precision rule SKIPPED, viasummarizeHumanGateMentions(n)— the marker occurrences that were mentions rather than declarations (CodySwannGT/lisa#3815). A rule that quietly declines to honour half the occurrences it sees reads exactly like a rule that saw none, so the count is printed even when it is zero. Report what was RELEASED beside it viasummarizeHumanGateReleases([...]), printed even when it is zero: a release path that has stopped working and a cycle with nothing to release read identically otherwise, which is how a missing inverse stays missing.Continue to the next candidate. A held item does not end the cycle.
3a.0 Repo-scope gate (claim only current-repo issues)
GitHub Issues live in one repo by definition, so the scanned repo's issues are usually inherently current-repo. But a planning/umbrella repo's issues can target sibling repos, so this skill still claims only issues for the repo it is running in. Run this gate before the leaf-only gate (3a) and the claim (3b), per the repo-scope-split rule's "Claim-time repo scoping" section (cite it by slug; do not restate its decision table).
- Resolve the current repo per
config-resolution"Repo scoping" (.repo→.github.repo→git remote get-url originbasename). If unresolvable, stop and report. - Cheap path first. Prefer candidates already carrying the
repo:<current>label. Keep the Phase 2 scan broad so unlabeled issues are still seen, determined, and stamped. - Per candidate, apply the repo-scope decision (
repo-scope-split):- Carries
repo:<other>→ skip (leave itreadyfor that repo's own intake); next candidate. - Unlabeled → determine the target repo(s) from the issue + code surfaces, then stamp
repo:<name>viagh issue edit <n> --add-label "repo:<name>"(create the label lazily) so later cycles filter cheaply; re-apply with the now-known repo. (An issue whose work is entirely in the scanned repo is simply labeledrepo:<current>.) - Container visibility is allowed. A multi-repo Epic / Story / Spike may legitimately carry multiple
repo:<name>labels for operator visibility. Do not split or claim it here; leave the repo markers intact and fall through to the leaf-only gate, which repairs the stale build-ready label instead of dispatching the container. - Multi-repo leaf → split, never claim. Run the
repo-scope-splitwork-time procedure into single-repo siblings, each created build-ready (build_ready: true) and stamped with its ownrepo:<name>; the current repo's sibling becomes a normal candidate. - Single-repo leaf for the current repo → fall through to 3a (leaf-only gate) and 3b (claim).
- Carries
- Continue until a claimable current-repo leaf is found (claim it; one per cycle) or the candidate set is exhausted — exit cleanly on the denominator-stated summary, naming the current repo alongside the swept lanes.
3a. Leaf-only claim gate (repair containers)
Build intake dispatches only independently implementable leaf work units to the build agent. This enforces the claim-time arm of the vendor-neutral leaf-only-lifecycle rule: a parent/container that still carries a stale build-ready role (e.g. status:ready applied before this rule existed, or hand-applied to an Epic/Story) is never dispatched — intake moves it out of the pickup queue by replacing $READY with $CLAIMED, then posts a clear lifecycle-repair message. It is the claim-time complement to the write-time labeling in lisa-github-write-issue and the validate-time S15 gate in lisa-github-validate-issue; all three cite the same rule so the classification never drifts. Never silently implement a container.
Run this gate before the leaf claim relabel, starting with the oldest/highest-priority ready candidate. Do NOT comment "Claimed" or dispatch the lifecycle for an issue that fails the gate. A container repair still changes labels: remove $READY, add $CLAIMED, explain that parent/container $CLAIMED means rollup/build-lane progress through child/leaf work rather than direct implementation, record it, and end the cycle.
Resolve container vs. leaf — structural first, then nominal. Per leaf-only-lifecycle the classification is structural: an issue is a container if it has open child work, whatever its declared type; otherwise the type label decides. Resolve child work using the same hierarchy lisa-github-read-issue uses — native sub-issues first, then body parentage (task-list checkboxes referencing other issues, Parent: #<n> references). Dependency links such as Blocked by: are not parentage; they are handled by the active dependency hold gate below.
# Native sub-issues via GraphQL (same query lisa-github-read-issue uses).
SUBS=$(gh api graphql -f query='
query($org:String!,$repo:String!,$number:Int!){
repository(owner:$org,name:$repo){
issue(number:$number){
subIssues(first: 100) {
nodes { number state }
}
}
}
}' -F org=<org> -F repo=<repo> -F number=<number> 2>/dev/null)
# Count children still OPEN — a parent whose children are all closed is no longer
# holding open work and rolls up via lisa-github-read-issue's rollup, not here.
OPEN_CHILDREN=$(echo "$SUBS" | jq -r '[.data.repository.issue.subIssues.nodes[]? | select(.state == "OPEN")] | length' 2>/dev/null)
OPEN_CHILDREN=${OPEN_CHILDREN:-0}
If the GraphQL subIssues field is unavailable (older GHES), fall back to parsing the body for child references exactly as lisa-github-read-issue does, and treat the issue as a container if any referenced child issue is open. Note "GraphQL sub-issues unavailable" so the operator knows parentage was text-derived.
Classify and act (first match wins). type: is read from the issue's labels (type:Epic, type:Story, type:Spike, type:Bug, type:Task, type:Sub-task, type:Improvement):
| Condition | Class | Action |
|---|---|---|
OPEN_CHILDREN > 0 (open child work, any type) |
Container | Move to $CLAIMED as lifecycle repair — do NOT dispatch |
no open children AND type = Epic |
Childless Epic (pure rollup container) | Move to $CLAIMED as lifecycle repair — do NOT dispatch |
no open children AND type ≠ Epic (Bug, Task, Sub-task, Improvement, Story, Spike, or no type: label) |
Leaf work unit | Proceed to 3b claim |
The childless-parent exception promotes every childless type except Epic to a dispatchable leaf: a childless Story is a directly shippable increment and a childless Spike is the investigation unit, so neither is stranded. Only a childless Epic is held back — an Epic is a pure rollup container by design, and a childless one is an incomplete decomposition or a mis-applied role, moved out of the ready pickup queue for repair/rollup and never dispatched.
Lifecycle repair (default action for a flagged container). Move the issue out of the pickup queue by removing $READY and adding $CLAIMED, post a single lifecycle-repair comment, and record the issue under "Repaired (container)" in the summary. Do NOT dispatch the lifecycle. Keep the comment idempotent — skip posting if an identical [claude-build-intake] lifecycle-repair comment already exists on the issue, so a re-entrant cycle doesn't spam it.
gh issue edit <number> --repo <org>/<repo> --remove-label "$READY" --add-label "$CLAIMED"
gh issue comment <number> --repo <org>/<repo> --body "[claude-build-intake] Lifecycle repair: this issue carried the build-ready role ($READY) but is a parent/container with open child work (or a childless Epic). I moved it to $CLAIMED without invoking the build agent. For parent/container issues, $CLAIMED means rollup/build-lane progress through child/leaf work; direct implementation must happen on leaf issues. Build-ready is leaf-only per leaf-only-lifecycle — move $READY onto its leaf children, or decompose/reclassify a childless Epic."
This gate never blocks a legitimate flat Task/Bug: those have no open children and a leaf type:, so they fall straight through to the claim in 3b.
Active dependency hold gate. After the leaf-only gate passes, but still before the claim relabel, parse explicit blocker relationships from the issue body and durable Lisa relationship sections. Support these forms at minimum:
Blocked by: #123Blocked by: #123, #456Blocked by: owner/repo#123Blocked by: https://github.com/owner/repo/issues/123
Resolve local #123 references against the candidate issue's repo. Resolve qualified refs and GitHub issue URLs against their named repo. For each blocker, read the blocker issue's status labels with gh issue view <number> --repo <owner>/<repo> --json labels,state.
Default cleared blocker labels for GitHub build intake are:
status:code-reviewstatus:on-devstatus:on-stgstatus:done
A blocker is active if it is open and has no cleared status label. Treat status:ready, status:in-progress, missing status labels, and inaccessible blockers as active. Closed blockers are cleared. If any blocker is active, skip the candidate without changing lifecycle labels, without posting "Claimed", and without dispatching the lifecycle. Record it under "Skipped (active blockers)" in the summary and include the active blocker refs. Keep any dependency-hold comment idempotent with a [claude-build-intake] prefix.
3b. Claim
Rejection detection runs first — before the relabel below. Per the vendor-neutral rejection-detection rule (cite the slug; do not restate its classification table), classify this item at the top of 3b, BEFORE the $READY → $CLAIMED relabel — after the relabel the current-lane signal is gone. Read the item's Label-Event History from lisa-github-read-issue (chronological LabeledEvent / UnlabeledEvent on the configured $READY label) and classify it rejection-reclaim | forward-only | never-left-ready | unknown. Lane names come from .lisa.config.json (github.labels.build.*), never hardcoded. A failing/absent history yields unknown and the claim proceeds — detection never blocks the build. Items carrying a learning marker ([lisa-learning-drop] / [lisa-learning-pr] / [lisa-learning-upstream-handoff]) or the learning:needs-triage label are never rejection triggers (no learning-about-learning). Carry the classification into the relabel and lifecycle below.
On rejection-reclaim, reflect before re-implementing (per rejection-detection): read the rejection evidence through the access layer — the issue comments posted after the backward transition (the QA rejection comment) and the review threads on the rejected PR via lisa-github-read-issue — assemble ONE candidate learning (rule, why, provenance linking the rejection comment + rejected PR, evidence links, scope hint, triggering issue, fingerprint sll4-sha1(rule\ntriggering_issue)[:12]), and route it to the lisa-persist-learning skill. If that skill is absent, record the candidate as a comment carrying a visible prose line plus the marker (a bare marker renders as an empty bubble) — Recorded a candidate learning from this rejection (queued for the judgment gate): <one-line candidate rule>. then <!-- [lisa-rejection-candidate] key=<issue>-<transition-ts> --> — and proceed. Dedupe on <issue>-<backward-transition-timestamp> — a second re-claim produces no duplicate. Unreadable/absent evidence → no candidate, still implement.
Claim-time archaeology runs second — after rejection detection, still before the relabel below. Classify this item per the vendor-neutral claim-archaeology rule, with the rejection classification above as its input. All shared semantics — ancestry signals, classification, learning-loop exclusion, cost budget, candidate derivation, marker dedupe, and the never-block degrade — live in that one slug; change them there, never here. GitHub wiring only: the typed relations and closingIssuesReferences are already in the read bundle; text-similarity searches use gh search issues over recently-closed issues; the fallback candidate comment is posted with gh issue comment.
The two claim-time-guards run third and fourth — still before the relabel below. Both semantics live in that one vendor-neutral slug; do not restate them here. GitHub wiring only:
two-failed-attemptsvalve. Count[lisa-build-attempt]markers on the issue from the read bundle's comments (match on the marker, never the title), applying both filters fromclaim-time-guards: count a marker only when it carriesmeasures=work(a marker with nomeasures=counts aswork), and only when itscreatedAtis after the issue most recently gained the ready label. The ready-lane entry comes from the read bundle'sLabeledEventstream, which is already fetched — no extra call. If that history isunknown, count everymeasures=workmarker regardless of age and say so in the comment. With two or more surviving markers, do not claim: relabel to the configured blocked role (gh issue edit <number> --repo <org>/<repo> --remove-label "$READY" --add-label "$BLOCKED", resolved fromgithub.labels.build.blockedperconfig-resolution), post the operator-readable comment naming both attempts, and stop the cycle at Phase 3e. Every non-success terminal outcome recorded in 3c/3d also appends a fresh<!-- [lisa-build-attempt] n=<N> outcome=<outcome> measures=<work|machine> -->marker so the next cycle can count it —measures=machinewhen the run was terminated by a signal or its outcome wasrecovery-required,measures=workwhen the build ran and did not satisfy the issue.already-implementedcheck. Probe for this issue's own key —git log --all --grep "<org>/<repo>#<number>"andgh pr list --repo <org>/<repo> --state all --search "<org>/<repo>#<number>" --json number,state,mergedAt,url. On a hit, claim as normal but route 3c to verify-and-close instead oflisa-implement: verify what shipped against the issue's acceptance criteria, post evidence vialisa-github-evidencenaming the shipping PR/commit, then run the ordinary 3d transition and 3d.1 rollup. A partial hit implements only the remaining gap. An unreadable history degrades to "no hit" and the ordinary path proceeds — the guard never blocks the claim. This is notDUPLICATE_ALREADY_FIXED(a different canonical issue) and notclaim-archaeology(a different ancestor issue); it is this issue's own work already having shipped without a transition.
gh issue edit <number> --repo <org>/<repo> --remove-label "$READY" --add-label "$CLAIMED"
# Assign to the authenticated user ONLY when the issue is currently unassigned (attributable claim;
# do not pile a second assignee onto an issue that already has an owner):
gh issue view <number> --repo <org>/<repo> --json assignees -q '.assignees | length' # → if 0:
gh issue edit <number> --repo <org>/<repo> --add-assignee "@me"
gh issue comment <number> --repo <org>/<repo> --body "[claude-build-intake] Claimed by Claude. Starting build."
This is the idempotency lock — a re-entrant cycle's --label $READY filter will not see this issue again.
If the relabel fails (permission, race), log under "Errors" in the cycle summary and skip this issue. Do not invoke the build flow on an issue you didn't successfully claim.
3c. Run the per-issue lifecycle in-session (never as a subagent)
After the claim succeeds, run the per-issue lifecycle defined by the github-agent workflow in the current session — never by spawning github-agent (or any named worker) via the Agent tool. The lifecycle culminates in a team-first flow (lisa-implement), and that flow can only create its agent team from the lead session: a spawned teammate cannot add named teammates (Claude teams are flat), so dispatching the build into a subagent strands lisa-implement without its team and collapses the build into a single inline worker. Concretely:
- Run the gates in-session via their skills, exactly as
github-agent.mddefines them and with all of its gating behaviors intact:lisa-github-read-issue— the full issue graph (mandatory; never ad-hocghreads)lisa-github-verify— pre-flight quality gate, including the draft-then-block procedure on FAILlisa-ticket-triage— analytical triage gate (aBLOCKEDverdict stops the cycle with findings posted)- Intent determination from the
type:label
- Dispatch the flow in-session: when the gates pass, invoke the lifecycle skill via the Skill tool —
lisa-implement <org>/<repo>#<number>for Build / Fix / Improve / Investigate-Only (orlisa-planfor an Epic) — passing the full context bundle from the read step. When 3b classified this itemrejection-reclaim, the context bundle passed tolisa-implementMUST include the rejection evidence summary (what was rejected, the defect the QA comment named, the approach named as wrong) — reuse the evidence already read in 3b, do not fetch it twice — so the plan can address it perrejection-detection; absence of evidence never blocks.lisa-implement's own or
…(truncated)