JIRA Build Intake: $ARGUMENTS
All Atlassian operations in this skill go through lisa-atlassian-access. Do not call MCP tools or acli directly.
$ARGUMENTS is one of:
- A JIRA project key (e.g.
SE) — scans that project for tickets in the configuredreadystatus. - A full JQL filter (e.g.
project = SE AND component = "frontend" AND Status = Ready) — used as-is. The skill will not append aStatus = <ready>clause if the JQL already names a status, so callers can intentionally widen. It also auto-scopes the query to the current repo (Phase 1 step 2) unless the JQL already constrains repo (arepo:label orcomponent =term), so a multi-repo project / forwarded assignee filter is not widened to sibling repos by accident.
Run one build-intake cycle. The first eligible ready ticket is claimed, built via the jira-agent workflow run in-session (Phase 3c, culminating in lisa-implement), transitioned to the configured done status (env-aware — see below), then the cycle exits. Remaining ready tickets stay queued for later scheduler invocations.
Workflow resolution
Status names are read from .lisa.config.json jira.workflow.*, falling back to defaults documented in the config-resolution rule. Bash pattern:
read_role() {
# Single resolver — see config-resolution "The single resolver".
local value
value=$(node "${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-plugins/lisa}}/scripts/resolve-lifecycle-role.mjs" \
--role "$1" --vendor jira --intent "${2:-read}") || return $?
[ -n "$value" ] || { echo "Error: required JIRA role '$1' resolved empty." >&2; return 2; }
printf '%s\n' "$value"
}
READY=$(read_role ready read) || exit $?
CLAIMED=$(read_role claimed write) || exit $?
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 base isstaging, env isstaging). - If
donein config is a string (not a map), 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".
# Resolve env, then DONE.
TARGET_ENV="${target_env:-}" # from caller args if supplied
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_RAW=$(jq -r '.jira.workflow.done // empty' .lisa.config.json 2>/dev/null)
DONE_TYPE=$(jq -r '.jira.workflow.done | type' .lisa.config.json 2>/dev/null)
if [ "$DONE_TYPE" = "string" ]; then
DONE="$DONE_RAW"
elif [ "$DONE_TYPE" = "object" ]; then
[ -z "$TARGET_ENV" ] && { echo "ERROR: jira.workflow.done is env-keyed but env not resolvable"; exit 1; }
DONE=$(jq -r --arg e "$TARGET_ENV" '.jira.workflow.done[$e] // empty' .lisa.config.json)
[ -z "$DONE" ] && { echo "ERROR: jira.workflow.done has no entry for env '$TARGET_ENV'"; exit 1; }
else
# Default: env-keyed map matching legacy hardcoded names.
case "$TARGET_ENV" in
dev) DONE="On Dev" ;;
staging) DONE="On Stg" ;;
production) DONE="Done" ;;
*) echo "ERROR: cannot resolve done status without env"; exit 1 ;;
esac
fi
Run one build-intake cycle. The first eligible ticket in $READY is claimed by transitioning to $CLAIMED, built via the in-session lifecycle (Phase 3c), transitioned to $DONE on completion, then the cycle exits.
Confirmation policy
Do NOT ask the caller whether to proceed. Once invoked with a project key or JQL, run the cycle to completion — claim and dispatch the first eligible ticket through the in-session lifecycle (Phase 3c), transition 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 (ticket 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" — the documented behavior IS the default.
- Pausing because the queue is large, tickets look complex, or tickets are likely to be
Blockedby the pre-flight gate. The pre-flightBlockedoutcome is a valid terminal state of the per-ticket lifecycle, not a failure mode — surfacing those tickets to humans is success. - Pausing because the build flow looks expensive. The cost of one cycle is bounded; the cost of stalling a scheduled cron waiting on a human is unbounded.
The only legitimate reasons to stop early:
- Missing project key / JQL or required configuration. Surface the missing value and exit.
- Workflow misconfigured (pre-flight check finds
$CLAIMEDor$DONEnot reachable, or$READYstatus absent). Surface and exit. - 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 JIRA workflow has these statuses (configured per project — see Workflow resolution above for how role names map to actual workflow values):
TODO → ready → claimed → done(env-keyed) → On QA → archive
(PM/ (us claim) (us done; (downstream)
human) PR ready)
This skill ONLY transitions $READY → $CLAIMED on claim, and $CLAIMED → $DONE on completion. It never touches TODO, post-done statuses, or any blocked/closed states.
Pre-flight check: at start of each cycle, attempt the $CLAIMED and $DONE transitions against a sample ready ticket via lisa-atlassian-access operation: transition key: <K> to: "<status>" (in a probe / dry-run sense — or fetch transition metadata if the access skill exposes that). If the transitions are unreachable, stop and report the workflow misconfiguration to the caller — do not invent transitions.
Phases
Phase 1 — Resolve the query
- Parse
$ARGUMENTSinto a base JQL:- Project key:
project = <KEY> AND Status = "$READY". - Full JQL: use as-is. If it does not include a
Statusclause, appendAND Status = "$READY".
- Project key:
- Repo-scope pre-filter (query-time arm of
repo-scope-split). A JIRA project can oversee multiple repos (frontend/backend/infrastructure), so an unscopedproject = <KEY> AND Status = $READY— or anassignee = … AND Status = $READYfilter forwarded bylisa-intake— pulls in every sibling repo's ready tickets and forces the claim-time gate (3a.0) to skip them one-by-one: a full wasted scan when none belong to the current repo. Narrow the candidate set at query time so other-repo tickets never enter it:- Resolve the current repo per
config-resolution"Repo scoping" (.repo→.github.repo→git remote get-url originbasename) — the same resolution 3a.0 uses. - If the base JQL already constrains repo — it contains a
repo:label term or acomponent = "<repo>"term — the caller has already scoped (or intentionally widened) it; leave it untouched. - Else, if the current repo resolved, append (before the
ORDER BY):
This drops tickets explicitly stamped for a sibling repo up front, while still surfacing unlabeled tickets (AND (labels = "repo:<current>" OR labels IS EMPTY)labels IS EMPTY) so the claim-time gate can determine + stamp them — i.e. it pre-applies only the unambiguous "wrong-repo → skip" arm of 3a.0 and never hides work the stamping path must see. The JIRA component alias and any rarer residual cases stay with the authoritative claim-time gate in 3a.0. - If the current repo cannot be resolved, skip this augmentation and fall back to the broad scan (3a.0 still enforces scoping). Do not fail the cycle solely because the pre-filter could not be built.
- Resolve the current repo per
- Append the ordering:
ORDER BY priority DESC, created ASC.
# CURRENT_REPO resolved per config-resolution "Repo scoping" (see 3a.0).
# Append a repo pre-filter only when the JQL does not already constrain repo.
if [ -n "$CURRENT_REPO" ] && ! printf '%s' "$BASE_JQL" | grep -qiE 'repo:|component[[:space:]]*='; then
BASE_JQL="${BASE_JQL} AND (labels = \"repo:${CURRENT_REPO}\" OR labels IS EMPTY)"
fi
JQL="${BASE_JQL} ORDER BY priority DESC, created ASC"
- Confirm the configured Atlassian site by invoking
lisa-atlassian-accessoperation: list-sites(it enforces connection match against.lisa.config.json).
Phase 2 — Sweep every pre-work status by CATEGORY
Sweep by statusCategory, never by a roster of status names. JIRA groups statuses into three
categories — To Do (new), In Progress (indeterminate), Done (done) — and a Blocked
status is not a fourth category: a project that wants one models it under To Do, i.e. work
that was never started. Keying the candidate set on status names therefore omits every pre-work
lane a project invented, and the scanner reports an empty queue over a full one. The same defect on
the Linear side produced 31 consecutive false "dry lane" cycles (#2657).
- Run the ready query first:
lisa-atlassian-accessoperation: search-issues jql: "<JQL>". This is the human-flipped lane and it is worked first. - Then sweep the rest of pre-work with the same base JQL, swapping the status clause for
statusCategory = "To Do", and read the total open count (statusCategory != Done). The open total is what makes an omitted lane arithmetically visible. - Page to exhaustion — a single unpaged
search-issuescall is not a count.
Capture each ticket's: key, summary, issue type, priority, assignee, parent (epic), status (with its category), labels, components.
Build the denominator with the shared helper, which owns the pre-work vocabulary so no two scanners can disagree about what counts:
buildIntakeDenominator({ lanes: [{name: "<status>", type: "<statusCategory>", count: <rows>}, …],
totalOpen: <statusCategory != Done count> })
summarizeDryLane(denominator, { queue: "<project key>" })
The helper accepts JIRA's category vocabulary directly (new / To Do normalize to pre-work).
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).
Phase 2.5 — 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 pre-work candidate outside $READY:
- Human gate first, and it is absolute. A ticket carrying the configured human-needed label
(
jira.labels.human_needed, defaultHuman Needed) or a[lisa-human-gate]marker in its description 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 ticket 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, transition the ticket to$READYwith the discharging evidence in the same comment, and treat it as an ordinary candidate. On anything else, leave it where it is.
Phase 3 — Process the first eligible ready ticket
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 tickets)
A JIRA project can oversee multiple repos (frontend / backend / infrastructure). This skill claims only tickets 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 — do not claim tickets you cannot scope. - Cheap path first. Phase 1's query-time pre-filter has already dropped tickets explicitly stamped for a sibling repo, so the Phase 2 result set is current-repo-labeled + unlabeled tickets. Prefer candidates already carrying
repo:<current>— a JIRA label, or a component equal to the repo name (accepted as an alias); the pre-filter is label-only, so a ticket scoped solely by a sibling-repo component can still appear and is skipped here. The result set still includes unlabeled tickets so they can be determined and stamped; this gate orders/filters what remains. - Per candidate, apply the repo-scope decision (
repo-scope-split):- Carries
repo:<other>(label or component) → skip (leave itreadyfor that repo's own intake); next candidate. - Unlabeled → determine the target repo(s) from the ticket (description, AC, technical approach) confirmed against the code surfaces, then stamp
repo:<name>vialisa-atlassian-accessoperation: write-ticket(add the label / set the component) so later cycles filter cheaply; re-apply with the now-known repo. - Multi-repo leaf → split, never claim. Run the
repo-scope-splitwork-time procedure to break it 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 (skip / safe-block containers)
Build intake claims only independently implementable leaf work units. This enforces the claim-time arm of the vendor-neutral leaf-only-lifecycle rule: a parent/container that still carries a stale build-ready status (e.g. Ready applied before this rule existed, or hand-applied to an Epic/Story) is never claimed — intake skips it or safe-blocks it with a clear lifecycle-repair message. It is the claim-time complement to the write-time labeling in lisa-jira-write-ticket and the validate-time S15 gate in lisa-jira-validate-ticket; all three cite the same rule so the classification never drifts. Never silently implement a container.
Run this gate before the claim transition, starting with the oldest/highest-priority ready candidate. Do NOT transition, comment "Claimed", or dispatch the lifecycle for a ticket that fails the gate.
Resolve container vs. leaf — structural first, then nominal. Per leaf-only-lifecycle the classification is structural: a ticket is a container if it has open child work, whatever its declared type; otherwise the issue type decides. Resolve child work using the same hierarchy lisa-jira-read-ticket uses — JIRA's native Epic → Story → Sub-task parentage (Epic link / parent field for Stories under an Epic, and the subtask relationship for Sub-tasks under a Story/Task). Issue links (blocks / is blocked by) express cross-item dependencies and are not parentage — do not count them as children.
Fetch the ticket's children via lisa-atlassian-access operation: search-issues with a JQL that resolves both subtasks and Epic-linked Stories, then count those still open (not in a resolved/Done status):
# Children of <TICKET>: native subtasks plus, for an Epic, its linked Stories.
# (parent = <TICKET>) covers Sub-tasks and child issues; ("Epic Link" = <TICKET>)
# covers Stories under an Epic on JIRA instances that expose the Epic Link field.
CHILDREN_JQL='(parent = "<TICKET>" OR "Epic Link" = "<TICKET>")'
# Count children whose status is NOT a resolved/terminal one. A parent whose
# children are all Done is no longer holding open work and rolls up via
# leaf-only-lifecycle's rollup, not here.
OPEN_CHILDREN_JQL="${CHILDREN_JQL} AND statusCategory != Done"
Invoke lisa-atlassian-access operation: search-issues jql: "<OPEN_CHILDREN_JQL>" and let OPEN_CHILDREN be the count of returned issues (0 if none). If the JQL cannot resolve the Epic Link field on this instance (older JIRA / team-managed projects expose parentage differently), fall back to the parentage lisa-jira-read-ticket derives and treat the ticket as a container if any derived child is open. Note "Epic Link unavailable — parentage derived" so the operator knows how children were resolved.
Classify and act (first match wins). The issue type comes from the ticket's issuetype field (Epic, Story, Spike, Bug, Task, Sub-task, Improvement):
| Condition | Class | Action |
|---|---|---|
OPEN_CHILDREN > 0 (open child work, any type) |
Container | Skip / safe-block — do NOT claim |
| no open children AND type = Epic | Childless Epic (pure rollup container) | Skip / safe-block — do NOT claim |
| no open children AND type ≠ Epic (Bug, Task, Sub-task, Improvement, Story, Spike, or no recognized type) | Leaf work unit | Proceed to 3b claim |
The childless-parent exception promotes every childless type except Epic to a claimable 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 stays unclaimed — an Epic is a pure rollup container by design, and a childless one is an incomplete decomposition or a mis-applied role, never an implementable unit.
Safe-block (default action for a flagged container). Leave the build-ready status in place (don't silently transition it away — that hides the lifecycle error), post a single lifecycle-repair comment, record the ticket under "Skipped (container)" in the summary, and end the cycle. Do NOT transition to $CLAIMED. Keep the comment idempotent — skip posting if an identical [claude-build-intake] lifecycle-repair comment already exists on the ticket, so a re-entrant cycle doesn't spam it.
Post via lisa-atlassian-access operation: comment key: <TICKET> body: "<message>" with:
[claude-build-intake] Not claimed: this ticket carries the build-ready status ($READY) but is a container with open child work (or a childless Epic), which violates the leaf-only-lifecycle rule. Build-ready is leaf-only per leaf-only-lifecycle — an agent claims and implements leaves, never a container. Repair: move $READY off this parent onto its leaf children (or, for a childless Epic, decompose it into leaf children or reclassify it to a leaf type). A parent's lifecycle state rolls up from its children and is never set to ready directly.
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.
3b. Claim
Rejection detection runs first — before the transition below. Per the vendor-neutral rejection-detection rule (cite the slug; do not restate its classification table), classify this ticket at the top of 3b, BEFORE the $READY → $CLAIMED transition — after the transition the current-status signal is gone. Read the ticket's status changelog via lisa-atlassian-access operation: changelog key: <TICKET> and classify it rejection-reclaim | forward-only | never-left-ready | unknown (a rejection-reclaim is a changelog entry whose to is the configured $READY status following an earlier review/done-ward status). Status names come from .lisa.config.json, never hardcoded. A failing/absent changelog yields unknown and the claim proceeds — detection never blocks the build. Tickets 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 transition and lifecycle below.
On rejection-reclaim, reflect before re-implementing (per rejection-detection): read the rejection evidence through the access layer — the ticket comments posted after the backward transition (the QA rejection comment) via lisa-atlassian-access operation: read-ticket / comment reads and the review threads on the rejected PR — 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 via lisa-atlassian-access operation: comment 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 transition below. Classify this ticket 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. JIRA wiring only: the typed relations are already in the read bundle; text-similarity searches run through lisa-atlassian-access operation: search-issues jql: over recently-closed tickets; the fallback candidate comment is posted via lisa-atlassian-access operation: comment.
The two claim-time-guards run third and fourth — still before the transition below. Both semantics live in that one vendor-neutral slug; do not restate them here. JIRA wiring only:
two-failed-attemptsvalve. Count[lisa-build-attempt]markers on the ticket from the read bundle's comments (match on the marker, never the summary), applying both filters fromclaim-time-guards: count a marker only when it carriesmeasures=work(a marker with nomeasures=counts aswork), and only when its timestamp is after the ticket most recently transitioned into the ready status. Read that transition withlisa-atlassian-access operation: changelog key:<K>—read-ticketusesfields=*all, which does not include the changelog, so this is an explicit extra read. If the changelog cannot be read, count everymeasures=workmarker regardless of age and say so in the comment. With two or more surviving markers, do not claim: transition to the configured blocked status (lisa-atlassian-access operation: transition to: "$BLOCKED", resolved fromjira.workflow.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 ticket.already-implementedcheck. Probe for this ticket's own key —git log --all --grep "<TICKET>"and the merged/open PRs referencing<TICKET>(gh pr list --state all --search "<TICKET>"in the bound repo). On a hit, claim as normal but route 3c to verify-and-close instead oflisa-implement: verify what shipped against the ticket's acceptance criteria, post evidence vialisa-jira-evidencenaming the shipping PR/commit, then run the ordinary 3d transition and 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 ticket) and notclaim-archaeology(a different ancestor ticket); it is this ticket's own work already having shipped without a transition.
Transition the ticket from $READY to $CLAIMED by invoking lisa-atlassian-access operation: transition key: <TICKET> to: "$CLAIMED".
- Assign to the authenticated user when the ticket is unassigned. A claim must be attributable. If the ticket has no assignee, assign it to the authenticated account — prefer acli
--assignee @me(resolves server-side to the authenticated user, which avoids the federated-accountIdmis-assignment), orwrite-ticketwith theaccountIdfrom the/rest/api/3/myselfidentity probe the access skill already documents. Leave an already-assigned ticket's assignee untouched — never reassign work that already has an owner. - Post a
[claude-build-intake]comment vialisa-atlassian-accessoperation: comment key: <TICKET> body: "Claimed by Claude. Starting build." - This is the idempotency lock — a re-entrant cycle's
Status = $READYfilter will not see this ticket again.
If the transition fails (permission, missing transition, race), log under "Errors" in the cycle summary and skip this ticket. Do not invoke the build flow on a ticket you didn't successfully claim.
3c. Run the per-ticket lifecycle in-session (never as a subagent)
After the claim succeeds, run the per-ticket lifecycle defined by the jira-agent workflow in the current session — never by spawning jira-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
jira-agent.mddefines them and with all of its gating behaviors intact:lisa-jira-read-ticket— the full ticket graph (mandatory; never ad-hoc reads)lisa-jira-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 issue type
- Dispatch the flow in-session: when the gates pass, invoke the lifecycle skill via the Skill tool —
lisa-implement <TICKET>for Build / Fix / Improve / Investigate-Only (orlisa-planfor an Epic) — passing the full context bundle from the read step. When 3b classified this ticketrejection-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 orchestration preamble then creates the per-item agent team (input-resolver, Roster Decision, specialist fanout) exactly as a direct invocation would. - Milestone sync and evidence (
lisa-jira-sync,lisa-jira-evidence) happen at the milestones thejira-agentworkflow defines, within the dispatched flow.
If you are somehow running this skill as a spawned teammate inside an existing team (nested misrouting — Intake keeps this chain in the lead session), do NOT run the lifecycle inline and do NOT spawn named peers. Return this payload to the lead so the lead session can run this Phase 3c in-session:
{
"type": "delegation-request",
"phase": "jira-build-intake 3c",
"workItem": "<TICKET>",
"context": {
"claimedStatus": "$CLAIMED",
"doneResolution": "Resolve $DONE from the PR base branch per this skill's Workflow resolution section"
},
"onSuccess": "Confirm the returned PR is merged, then apply Phase 3d and Phase 3d.1",
"onBlockedOrError": "Leave the ticket where the lifecycle left it and record the surfaced outcome"
}
The lifecycle run returns one of the following outcomes; resume this scanner with it:
- Success — the build flow completed and a PR exists; evidence posted. The PR may already be merged or still open (auto-merge enabled, awaiting checks/merge). "Success" means the build work is sound — it does not assert the change reached an environment. The env transition in 3d gates on the PR actually being merged; an open PR does not advance the ticket to a
doneenv status. - Blocked by jira-verify pre-flight gate — the pre-flight gate (jira-agent workflow step 2) transitions the ticket to
Blockedand reassigns to Reporter. This is correct and expected — let it stand. Record the outcome and move on. - Duplicate already fixed —
lisa-ticket-triagereturnedDUPLICATE_ALREADY_FIXEDwith a canonical ticket reference and empirical base-branch evidence. Post the triage finding, ensure the nativeduplicates <canonical>link exists, transition to the terminal$DONEstatus with resolutionDuplicate, and do not open a PR. If the canonical fix is merged but not yet on the production branch, the close comment must say the production error can recur until the canonical ticket promotes and that recurrence is tracked by the canonical ticket; do not reopen this duplicate for that recurrence. - Blocked by ticket-triage ambiguities — triage posts findings and the lifecycle stops. The ticket stays in
$CLAIMED. Surface to human; do not auto-transition. Record under "Errors" with reason"Triage found ambiguities — see comments on <ticket-key>". - Errored — exception, missing config, etc. Leave the ticket in
$CLAIMEDfor human investigation. Record under "Errors" with the exception summary.
3c.1 Close duplicate already fixed
Run this only when the returned triage verdict is exactly DUPLICATE_ALREADY_FIXED.
- Verify the structured result includes a canonical ticket reference, the canonical PR/commit, and empirical evidence that the canonical fix is present on the base branch. If any piece is missing, treat the outcome as Held instead of closing.
- Post or preserve the triage-finding comment that explains why this ticket is a duplicate and names the canonical ticket.
- Ensure the native
duplicates <canonical>link exists throughlisa-atlassian-access. - Resolve the terminal
$DONEvalue exactly as in Phase 3d. For env-keyed workflows, duplicate closeout uses the production/final done status, not an intermediateOn Dev/On Stgwaypoint. - Transition to
$DONEwith JIRA resolutionDuplicate. Ifaclicannot set resolution on transition, use the Atlassian REST transition-with-fields path exposed bylisa-atlassian-access, or a documented follow-up edit that sets the resolution immediately after transition. Do not report success with an empty resolution when the workflow requires one. - Post a close comment naming the canonical ticket, PR/commit, and base-branch evidence.
If the canonical fix is merged but not yet present on the production branch, append the production-promotion caveat to the close comment: the production error can recur until the canonical ticket promotes, and recurrence is tracked by the canonical ticket rather than by reopening this duplicate.
This path is distinct from BLOCKED: ambiguity, open blockers, and duplicate-of-open findings remain held for human action and must not be auto-closed.
3c.2 Confirm applied learnings (last_confirmed bump)
Run this at the end of 3c, after the lifecycle outcome is recorded and before 3d. It keeps the decay pass safe: a genuinely useful learning that keeps applying stays fresh, while dead weight ages out.
- Identify which learnings demonstrably applied. Resolve the learnings surface with
resolveProjectLearningsFileand parse it withparseLearningsFilefrom@codyswann/lisa/learnings(never hardcode the path; a missing file skips this step silently). An entry counts as applied ONLY when its rule was explicitly cited or observably followed in this claim's plan, diff, or review responses — the plan quotes the rule or its id, or the diff does specifically what the rule mandates where the default behavior would have differed. Presence in context is NOT application: entries reach a session only through the contract's bounded projection, so counting
…(truncated)