scripts/bash/lifecycle-lint.sh
#!/usr/bin/env bash
# Read-only lifecycle lint (tracker-integration §2 lifecycle state machine).
#
# Every non-interactive issue must carry exactly one lifecycle label, and a
# closed issue must carry `done` or `cancelled`. Each violation gets ONE
# proposed label, which the confirm-gated factory-setup backfill can apply:
# closed -> keep the single terminal label it has; otherwise `done` when
# a merged MR/PR closed it, `cancelled` when none did
# open, none -> `intent` (a missing label reads as intent)
# open, many -> the furthest non-terminal state it carries
# open, only a terminal label -> "-" (a human decides; reopened work)
# `interactive` issues are reported, never counted or proposed.
#
# This script never writes to the tracker.
#
# The names are the canonical lifecycle labels. A repo with an active `labels:`
# mapping in its provider config (--config, default .adlc/issues-provider.yml)
# is refused rather than linted against the wrong names.
#
# Usage:
# lifecycle-lint.sh --provider <gitlab|github> --project <path|owner/repo> [--json <file>] [--config <file>]
# lifecycle-lint.sh --issues <file> [--json <file>] [--config <file>] # offline
# --issues: JSON array of {iid, state: opened|closed, labels: [..], closed_by_merged}
#
# Exit codes: 0 = no violations, 1 = violations found, 2 = usage or tracker error.
set -euo pipefail
PROVIDER="" PROJECT="" ISSUES="" JSON_OUT="" CONFIG=".adlc/issues-provider.yml"
while [ $# -gt 0 ]; do
case "$1" in
--provider) PROVIDER="${2:-}"; shift 2 ;;
--project) PROJECT="${2:-}"; shift 2 ;;
--issues) ISSUES="${2:-}"; shift 2 ;;
--json) JSON_OUT="${2:-}"; shift 2 ;;
--config) CONFIG="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,/^set -euo/p' "$0" | sed '$d'; exit 0 ;;
*) echo "lifecycle-lint: unknown argument: $1" >&2; exit 2 ;;
esac
done
command -v jq >/dev/null || { echo "lifecycle-lint: jq is required" >&2; exit 2; }
if [ -f "$CONFIG" ] && grep -q '^labels:' "$CONFIG"; then
echo "lifecycle-lint: $CONFIG has an active labels: mapping; this lint only knows the canonical names. Lint by hand, or remove the mapping." >&2
exit 2
fi
LIFECYCLE='["intent","spec-gated","executing","parked","validation","done","cancelled"]'
fetch_issues() {
case "$PROVIDER" in
gitlab)
local enc; enc="$(printf '%s' "$PROJECT" | jq -sRr @uri)"
glab api --paginate "projects/$enc/issues?state=all&per_page=100" \
| jq -s 'add // [] | map({iid, state, labels, closed_by_merged: null})'
;;
github)
gh issue list -R "$PROJECT" --state all --limit 5000 --json number,state,labels \
| jq 'map({iid: .number, state: (if .state == "CLOSED" then "closed" else "opened" end),
labels: [.labels[].name], closed_by_merged: null})'
;;
*) echo "lifecycle-lint: --provider must be gitlab or github" >&2; exit 2 ;;
esac
}
closed_by_merged() {
case "$PROVIDER" in
gitlab)
local enc; enc="$(printf '%s' "$PROJECT" | jq -sRr @uri)"
glab api "projects/$enc/issues/$1/closed_by" | jq 'map(select(.state == "merged")) | length > 0'
;;
github)
# A linked PR is not enough: it must be merged.
gh api graphql -f owner="${PROJECT%%/*}" -f name="${PROJECT#*/}" -F number="$1" -f query='
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) { issue(number: $number) {
closedByPullRequestsReferences(first: 100, includeClosedPrs: true) { nodes { merged } } } } }' \
| jq 'if .data.repository.issue == null then error("issue not found")
else .data.repository.issue.closedByPullRequestsReferences.nodes | map(.merged == true) | any end'
;;
esac
}
if [ -n "$ISSUES" ]; then
DATA="$(jq '.' "$ISSUES")" || { echo "lifecycle-lint: unreadable --issues file" >&2; exit 2; }
elif [ -n "$PROVIDER" ] && [ -n "$PROJECT" ]; then
DATA="$(fetch_issues)" || { echo "lifecycle-lint: tracker fetch failed" >&2; exit 2; }
# Merge evidence only where the proposal depends on it: closed issues with
# no terminal label, or with both.
for iid in $(printf '%s' "$DATA" | jq -r --argjson L "$LIFECYCLE" '
.[] | select(.state == "closed")
| ([.labels[] | select(. == "done" or . == "cancelled")] | length) as $t
| select($t != 1) | .iid'); do
merged="$(closed_by_merged "$iid")" || { echo "lifecycle-lint: closed_by lookup failed for #$iid" >&2; exit 2; }
DATA="$(printf '%s' "$DATA" | jq --argjson i "$iid" --argjson m "$merged" \
'map(if .iid == $i then .closed_by_merged = $m else . end)')"
done
else
echo "lifecycle-lint: give --issues <file>, or --provider and --project" >&2; exit 2
fi
PLAN="$(printf '%s' "$DATA" | jq --argjson L "$LIFECYCLE" '
def rank: {"intent":0,"spec-gated":1,"executing":2,"parked":3,"validation":4}[.];
def classify:
[.labels[] | select(. as $l | $L | index($l))] as $lc
| [$lc[] | select(. != "done" and . != "cancelled")] as $nt
| ($lc | length) as $n
| if .state == "closed" then
($n - ($nt | length)) as $t
| if $n == 1 and $t == 1 then null
else
{ lifecycle: $lc,
violation: (if $n == 0 then "closed, no lifecycle label"
elif $t == 0 then "closed, not terminal"
else "closed, extra lifecycle labels" end),
proposed: (if ($lc | index("done")) and ($lc | index("cancelled") | not) then "done"
elif ($lc | index("cancelled")) and ($lc | index("done") | not) then "cancelled"
elif .closed_by_merged == true then "done"
else "cancelled" end) }
end
else
if $n == 1 and ($nt | length) == 1 then null
elif $n == 0 then {lifecycle: [], violation: "open, no lifecycle label", proposed: "intent"}
elif ($nt | length) == 0 then {lifecycle: $lc, violation: "open, terminal label only", proposed: "-"}
else
{ lifecycle: $lc,
violation: (if ($nt | length) < $n then "open, terminal label on an open issue"
else "open, \($n) lifecycle labels" end),
proposed: ($nt | max_by(rank)) }
end
end;
sort_by(.iid)
| map(. as $i | (classify) as $c | select($c != null) | {iid, state, labels} + $c
+ {interactive: ($i.labels | index("interactive") != null)})
| { violations: map(select(.interactive | not) | del(.interactive)),
interactive: map(select(.interactive) | del(.interactive)) }
')"
TOTAL="$(printf '%s' "$DATA" | jq 'length')"
PLAN="$(printf '%s' "$PLAN" | jq --argjson n "$TOTAL" --arg p "$PROJECT" \
'{project: (if $p == "" then null else $p end), issues: $n} + .')"
V="$(printf '%s' "$PLAN" | jq '.violations | length')"
I="$(printf '%s' "$PLAN" | jq '.interactive | length')"
if [ "$V" -gt 0 ]; then
echo "| Issue | State | Lifecycle labels | Violation | Proposed |"
echo "|--|--|--|--|--|"
printf '%s' "$PLAN" | jq -r '.violations[]
| "| #\(.iid) | \(.state) | \(if (.lifecycle | length) == 0 then "none" else (.lifecycle | join(", ")) end) | \(.violation) | \(.proposed) |"'
echo
fi
if [ "$I" -gt 0 ]; then
echo "Interactive issues (reported, not counted):"
printf '%s' "$PLAN" | jq -r '.interactive[] | "- #\(.iid) (\(.state)): \(.violation)"'
echo
fi
echo "lifecycle-lint: $TOTAL issues, $V violations, $I interactive (reported, not counted)"
[ -n "$JSON_OUT" ] && printf '%s\n' "$PLAN" > "$JSON_OUT"
[ "$V" -eq 0 ]
scripts/powershell/lifecycle-lint.ps1
#!/usr/bin/env pwsh
# Read-only lifecycle lint (tracker-integration §2 lifecycle state machine).
# PowerShell twin of scripts/bash/lifecycle-lint.sh: same rules, output and
# exit codes. This script never writes to the tracker.
#
# Usage:
# lifecycle-lint.ps1 -Provider <gitlab|github> -Project <path|owner/repo> [-Json <file>] [-Config <file>]
# lifecycle-lint.ps1 -Issues <file> [-Json <file>] [-Config <file>] # offline
# An active `labels:` mapping in -Config (default .adlc/issues-provider.yml) is refused.
#
# Exit codes: 0 = no violations, 1 = violations found, 2 = usage or tracker error.
[CmdletBinding()]
param(
[string]$Provider = "",
[string]$Project = "",
[string]$Issues = "",
[string]$Json = "",
[string]$Config = ".adlc/issues-provider.yml"
)
$ErrorActionPreference = "Stop"
if ((Test-Path $Config) -and (Select-String -Path $Config -Pattern '^labels:' -Quiet)) {
[Console]::Error.WriteLine("lifecycle-lint: $Config has an active labels: mapping; this lint only knows the canonical names. Lint by hand, or remove the mapping.")
exit 2
}
$Lifecycle = @("intent", "spec-gated", "executing", "parked", "validation", "done", "cancelled")
$Rank = @{ "intent" = 0; "spec-gated" = 1; "executing" = 2; "parked" = 3; "validation" = 4 }
function Get-Enc([string]$p) { [System.Uri]::EscapeDataString($p) }
function Get-Issues {
switch ($Provider) {
"gitlab" {
$enc = Get-Enc $Project
$pages = & glab api --paginate "projects/$enc/issues?state=all&per_page=100"
if ($LASTEXITCODE -ne 0) { throw "tracker fetch failed" }
# --paginate prints one JSON array per page; join them into one array.
$text = ($pages -join "`n") -replace '\]\s*\[', ','
return @($text | ConvertFrom-Json | ForEach-Object {
[pscustomobject]@{ iid = [int]$_.iid; state = $_.state; labels = @($_.labels); closed_by_merged = $null } })
}
"github" {
$raw = & gh issue list -R $Project --state all --limit 5000 --json number,state,labels
if ($LASTEXITCODE -ne 0) { throw "tracker fetch failed" }
return @(($raw -join "`n") | ConvertFrom-Json | ForEach-Object {
[pscustomobject]@{ iid = [int]$_.number; state = $(if ($_.state -eq "CLOSED") { "closed" } else { "opened" });
labels = @($_.labels | ForEach-Object { $_.name }); closed_by_merged = $null } })
}
default { Write-Error "lifecycle-lint: -Provider must be gitlab or github"; exit 2 }
}
}
function Test-ClosedByMerged([int]$iid) {
if ($Provider -eq "gitlab") {
$enc = Get-Enc $Project
$raw = & glab api "projects/$enc/issues/$iid/closed_by"
if ($LASTEXITCODE -ne 0) { throw "closed_by lookup failed for #$iid" }
$mrs = ($raw -join "`n") | ConvertFrom-Json
if ($null -eq $mrs) { $mrs = @() }
return [bool](@($mrs | Where-Object { $_ -and $_.state -eq "merged" }).Count -gt 0)
}
# A linked PR is not enough: it must be merged.
$owner, $name = $Project.Split("/", 2)
$q = 'query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { issue(number: $number) { closedByPullRequestsReferences(first: 100, includeClosedPrs: true) { nodes { merged } } } } }'
$raw = & gh api graphql -f "owner=$owner" -f "name=$name" -F "number=$iid" -f "query=$q"
if ($LASTEXITCODE -ne 0) { throw "closed_by lookup failed for #$iid" }
$v = ($raw -join "`n") | ConvertFrom-Json
if ($null -eq $v -or $null -eq $v.data.repository.issue) { throw "issue #$iid not found" }
return [bool](@($v.data.repository.issue.closedByPullRequestsReferences.nodes | Where-Object { $_ -and $_.merged -eq $true }).Count -gt 0)
}
try {
if ($Issues) {
$data = @((Get-Content -Raw $Issues) | ConvertFrom-Json)
} elseif ($Provider -and $Project) {
$data = Get-Issues
foreach ($i in $data) {
if ($i.state -ne "closed") { continue }
$t = @($i.labels | Where-Object { $_ -eq "done" -or $_ -eq "cancelled" }).Count
if ($t -ne 1) { $i.closed_by_merged = Test-ClosedByMerged $i.iid }
}
} else {
Write-Error "lifecycle-lint: give -Issues <file>, or -Provider and -Project"; exit 2
}
} catch {
[Console]::Error.WriteLine("lifecycle-lint: $($_.Exception.Message)"); exit 2
}
function Get-Classification($i) {
$lc = @($i.labels | Where-Object { $Lifecycle -contains $_ })
$nt = @($lc | Where-Object { $_ -ne "done" -and $_ -ne "cancelled" })
$n = $lc.Count
if ($i.state -eq "closed") {
$t = $n - $nt.Count
if ($n -eq 1 -and $t -eq 1) { return $null }
$violation = if ($n -eq 0) { "closed, no lifecycle label" } elseif ($t -eq 0) { "closed, not terminal" } else { "closed, extra lifecycle labels" }
$hasDone = $lc -contains "done"; $hasCancelled = $lc -contains "cancelled"
$proposed = if ($hasDone -and -not $hasCancelled) { "done" }
elseif ($hasCancelled -and -not $hasDone) { "cancelled" }
elseif ($i.closed_by_merged -eq $true) { "done" } else { "cancelled" }
return @{ lifecycle = $lc; violation = $violation; proposed = $proposed }
}
if ($n -eq 1 -and $nt.Count -eq 1) { return $null }
if ($n -eq 0) { return @{ lifecycle = @(); violation = "open, no lifecycle label"; proposed = "intent" } }
if ($nt.Count -eq 0) { return @{ lifecycle = $lc; violation = "open, terminal label only"; proposed = "-" } }
$violation = if ($nt.Count -lt $n) { "open, terminal label on an open issue" } else { "open, $n lifecycle labels" }
$furthest = $nt | Sort-Object { $Rank[$_] } | Select-Object -Last 1
return @{ lifecycle = $lc; violation = $violation; proposed = $furthest }
}
$violations = @(); $interactive = @()
foreach ($i in ($data | Sort-Object { [int]$_.iid })) {
$c = Get-Classification $i
if ($null -eq $c) { continue }
$row = [ordered]@{ iid = [int]$i.iid; state = $i.state; labels = @($i.labels);
lifecycle = @($c.lifecycle); violation = $c.violation; proposed = $c.proposed }
if ($i.labels -contains "interactive") { $interactive += [pscustomobject]$row } else { $violations += [pscustomobject]$row }
}
if ($violations.Count -gt 0) {
"| Issue | State | Lifecycle labels | Violation | Proposed |"
"|--|--|--|--|--|"
foreach ($v in $violations) {
$lcText = if ($v.lifecycle.Count -eq 0) { "none" } else { $v.lifecycle -join ", " }
"| #$($v.iid) | $($v.state) | $lcText | $($v.violation) | $($v.proposed) |"
}
""
}
if ($interactive.Count -gt 0) {
"Interactive issues (reported, not counted):"
foreach ($v in $interactive) { "- #$($v.iid) ($($v.state)): $($v.violation)" }
""
}
"lifecycle-lint: $($data.Count) issues, $($violations.Count) violations, $($interactive.Count) interactive (reported, not counted)"
if ($Json) {
[ordered]@{ project = $(if ($Project) { $Project } else { $null }); issues = $data.Count; violations = $violations; interactive = $interactive } |
ConvertTo-Json -Depth 6 | Set-Content -Encoding utf8 $Json
}
if ($violations.Count -gt 0) { exit 1 }
exit 0
SKILL.md
---
name: factory-tickets
description: Use when the user asks for their active worklist — open PRs with next actionable move, merged work not yet closed, takable tickets, and blocked work, classified by next move.
---
# factory-tickets
## What this skill does
`factory-tickets` is the read-only personal worklist engine of the software factory. It provides the user with an immediate, live, and actionable overview of their active queue, categorized by "next move" rather than abstract status.
It operates directly against the external issue tracker and PR host (GitHub, GitLab, Linear, Jira) using the tracker-agnostic integration layer (`factory-runbook/references/tracker-integration.md`).
**Requires:** the `factory-runbook` skill (provides the tracker-agnostic integration layer): `adlc-cli skills add tikalk/adlc-team-skills --skill factory-runbook`
---
## When to use
- At the start of a session or when planning your day, to find out exactly what needs your attention next.
- To identify which of your pull requests are ready to merge, blocked, or awaiting your review feedback.
- To discover unassigned, unblocked tickets (`spec-gated`) that are ready for execution.
**When NOT to use**:
- To change ticket status, assignees, or edit bodies (this skill is **strictly read-only**).
- To execute or implement tasks (use `factory-mission` instead).
---
## Operating Process
### 1. Provider & Identity Discovery
1. Discover the active tracker provider and credentials (`factory-runbook/references/tracker-integration.md`).
2. Identify the authenticated user's login. Restrict the search to:
- Pull requests **authored by** the authenticated account.
- Tickets **assigned to** the authenticated account (or another login if explicitly requested).
### 2. Dual-Authority Paged Queries
Never poll individual items. Run two paged searches (via MCP or CLI) to fetch the entire active set in one round-trip per source:
1. **Pull Request Search**: fetch the user's open PRs, including: draft status, review decision, unresolved threads, named check statuses, and mergeability.
2. **Issue Search**: fetch the user's assigned open issues, including: title, description, state, labels, milestones, and linked project metadata.
### 3. Match Evidence (Tracker-Agnostic)
A PR routinely cross-references multiple tickets. Associate a pull request to a ticket **only** when there is explicit evidence:
- A **closing link** established by the tracker itself.
- A **branch name** that contains the ticket number in the repository's convention.
- A **PR title** that explicitly names the ticket number.
If no explicit evidence exists, leave the association cell empty. Never guess based on mere textual mention.
### 4. Classify Next Action (Next-Move Sections)
Assign every retrieved item to exactly one of the following priority sections. **Render what was assigned — never reclassify during formatting**:
1. **Active Pull Requests (My Turn)**: Pull requests where the next action belongs to the user:
- `Merge` — required checks are green, human approval is complete. **Ready-to-merge sorts first (highest priority)**.
- `Address Review Feedback` — changes requested by a reviewer.
- `Fix failing check: <name>` — a required CI check has failed (reported by name, not rolled up).
- `Answer N comment threads` — unresolved reviewer questions.
- `Finish Draft` — PR is still in draft state.
2. **Active Pull Requests (Their Turn)**: Pull requests waiting on external actions:
- `Awaiting Review` — Ready for review, requested reviewer hasn't responded.
- `Awaiting CI` — required checks are still running.
3. **Merged, Ticket Still Open**: The PR that closes this ticket has merged, but the ticket remains open on the board. Move is: verify, then close.
4. **Takable Now (Backlog)**: Open issues with lifecycle `spec-gated`, automation-gating `agent-can-execute`, and no pull request claiming them. (If empty, display an explicit "none").
5. **Waiting on Someone Else**: Open issues with unresolved blocked-by dependencies, naming the blocking ticket.
*Note: Epics and parent issues go in an Appendix below a rule, showing child counts and progress. They are never mixed into the active task list.*
### 5. Lifecycle Lint (read-only)
When the user asks whether the board's lifecycle labels are healthy, run the lint against the whole project. It reads open and closed issues and writes nothing:
- bash: `scripts/bash/lifecycle-lint.sh --provider <gitlab|github> --project <path|owner/repo> [--json <file>]`
- PowerShell: `scripts/powershell/lifecycle-lint.ps1 -Provider <gitlab|github> -Project <path|owner/repo> [-Json <file>]`
It applies the one-label rule of the lifecycle state machine (`tracker-integration.md` §2):
- Every non-interactive issue carries exactly one lifecycle label.
- A closed issue carries `done` or `cancelled`.
Each violation gets one proposed label:
- A closed issue keeps the single terminal label it has. Otherwise it gets `done` if a merged MR/PR closed it, and `cancelled` if none did.
- An open issue with no lifecycle label gets `intent`.
- An open issue with several labels gets the furthest non-terminal state it carries.
- An open issue with only a terminal label gets `-`: a human decides.
`interactive` issues are listed separately and never counted. Show the table and the summary line (`N issues, V violations, I interactive`). The script exits 1 when there are violations. Corrections are never made here: hand the `--json` plan to the confirm-gated backfill in `factory-setup` Mode 3.
---
## Invariants & Safety Constraints
1. **Strictly Read-Only**: This skill physically cannot edit, comment, create, or modify any ticket, label, assignee, or board field. No `gh` or `glab` write commands are present.
2. **No Caching**: Every run performs a live fetch. A cached or stale queue is worse than none.
3. **No Rollup Guessing**: Never report check status from a rolled-up "failing" commit status if a job was merely cancelled or skipped. Read and name the exact failing context.
4. **Unparsed Word Reporting**: If the user's request contains words the query parser doesn't recognize, do not drop them. List them in an "Unparsed Terms" section below the table so the user knows what was ignored.