VCS Operations Reference
VCS Auto-Detection
Detect which VCS platform the current repo uses and select the right CLI:
# Check git remote
REMOTE_URL=$(git remote get-url origin 2>/dev/null)
if echo "$REMOTE_URL" | grep -q "github.com"; then
VCS=github # use `gh`
else
VCS=gitlab # use `glab`
fi
Session Config overrides:
vcs: github|gitlab— force a specific platformgitlab-host: <host>— override auto-detected GitLab host (glab reads host from git remote by default)
How Other Skills Reference This
Directive: Consuming skills MUST NOT duplicate VCS auto-detection logic or CLI command syntax inline. This skill is the single source of truth for all VCS operations.
When a skill needs VCS operations, include this reference block in its instructions:
VCS Reference: Detect the VCS platform per the "VCS Auto-Detection" section of the gitlab-ops skill. Use CLI commands per the "Common CLI Commands" section. For GitLab API operations, see "Canonical Project Identity."
Canonical commands: All glab and gh command syntax — flags, output formats,
pagination options — is defined in the "Common CLI Commands" section below. Consuming
skills must reference that section rather than redefining commands. If a skill needs a
command variant not listed there, add it to this file first, then reference it.
What consuming skills should include:
- The reference block above (copy-paste it verbatim)
- Any skill-specific parameters they pass to commands (e.g., label names, issue templates)
- They should NOT include raw
glab/ghinvocations or detection snippets
Canonical Project Identity
GitLab REST endpoints accept a URL-encoded namespace/project path. Select the GitLab host and project path explicitly; never derive a numeric project ID from glab repo view, search projects?search=, or use :id placeholders. Those forms can resolve through the ambient working directory or a stale search result and target another project after a rename, fork, or scaffold.
Set the identity once per operation sequence and reuse the encoded identifier without encoding it again:
GITLAB_HOST="<selected GitLab hostname>"
GROUP_PATH="<selected group path>"
PROJECT_NAME="<selected project name>"
PROJECT_PATH="$GROUP_PATH/$PROJECT_NAME"
ENCODED_PROJECT_PATH="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$PROJECT_PATH")"
For a link target in another project, use the same path-first shape instead of a numeric ID:
TARGET_PROJECT_PATH="<target namespace>/<target project>"
TARGET_ENCODED_PROJECT_PATH="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$TARGET_PROJECT_PATH")"
Pass --hostname "$GITLAB_HOST" to every glab api call. The endpoint itself then pins the project, including directly after creating a repository when the current directory does not yet identify the new project.
GitHub continues to use an owner/repo slug; gh repo takes it positionally and rejects -R:
gh repo view --json nameWithOwner -q '.nameWithOwner'
Canonical enumeration pattern
To enumerate ALL projects (or issues) in a group, a single page is never the whole result — paginate and guard against silent truncation:
# GitLab — paginate a group's projects, following x-next-page until empty
page=1
while [ -n "$page" ]; do
resp=$(glab api --hostname "$GITLAB_HOST" "groups/<group-id>/projects?simple=true&include_subgroups=true&per_page=100&page=$page" --include)
# parse the response body ($resp) for project ids/paths here, deduping by id.
# Then advance by reading the `x-next-page` response header — an empty value
# means this was the last page, so the loop exits (the guard above is what breaks).
page=$(printf '%s\n' "$resp" | awk -F': *' 'tolower($1)=="x-next-page"{sub(/\r/,"",$2); print $2}')
done
- Follow pagination via the
x-next-pageresponse header — loop until it comes back empty. A single-page read on a known-large group is a signal the loop stopped early, not proof the group is small. - Dedupe by project id — subgroup traversal can surface the same project more than once.
- Silent-zero guard:
membership=truecan return a misleadingly small subset (e.g. a host that only sees a handful of a group's dozens of projects). If the count looks suspiciously low relative to the known group size, retry WITHOUTmembership(rely oninclude_subgroups=truealone) before trusting the result. A zero/one-page result on a known-large group is a probable auth/pagination bug — treat it as a bug signal, never as ground truth that "the group is actually empty."
Label Taxonomy
Taxonomy convention — priority REVERSED to scoped :: (supersedes #727 for this one axis).
priority::<level>is canonical. #727's stated rationale was that "this repo mirrors to GitHub, which has no scoped-label semantics … while a migration would break every existing label reference and issue." Both halves were checked on 2026-07-25 and neither holds:- Issues are not mirrored at all.
aiat-poc-infra/docs/github-mirror-runbook.md:1,5describes a git push-mirror with GitHub as "read-only downstream";docs/gitlab-team-org-2026-06-21.md:45confirms there is no two-way GitLab issue sync. Nothing crosses the boundary that a label rename could break. - GitHub already uses the scoped form.
gh api "repos/AIAT-AIandBusinessgrowth/aiat-barrierefrei-engine/labels"returnspriority::high,priority::low,priority::med,priority::mediumacross 77 open issues, and zeropriority:high. Same pattern onaiat-doc-vlm. GitHub treats::as an ordinary string; it merely does not enforce mutual exclusion. - Volume agrees independently: 416
priority::against 249priority:and 7 bare at the time of the decision. Chasing the minority spelling would mean re-labelling the majority. Producers were migrated FIRST (this change); the label-data migration follows separately, because migrating data before producers means the divergence returns within a day.
- Issues are not mirrored at all.
area:/type:/status:/from:stay SINGLE-COLON — but NOT on #727's rationale, which is disproven above. They stay because nothing measured argues for flipping them, and because each axis is its own migration cost. Flipping them is a separate decision and is explicitly NOT made here. Note thatstatusin particular is the worst-disciplined axis on the instance (354 assignments, only 48 percent scoped, 5 genuine value conflicts), so any future flip there needs a conflict-resolution pass first.- Readers accept both spellings. Every consumer that MATCHES a label compares through
scripts/lib/label-scope.mjsnormalizeLabel(), which collapses::to:— so issues still carryingpriority:highkeep being counted until the data migration lands. Only WRITES are canonical.
Priority Labels
priority::critical— blocking production or userspriority::high— important, schedule this sprintpriority::medium— plan for next sprintpriority::low— backlog, nice-to-have
Status Labels
status:ready— defined, ready to pick upstatus:in-progress— actively being worked onstatus:review— MR/PR created, awaiting reviewstatus:blocked— waiting on external dependency
Area Labels
area:frontend|area:backend|area:databasearea:ai|area:security|area:testingarea:ci|area:infrastructure|area:compliancearea:skills|area:vcs|area:harness
Type Labels
bug|feature|enhancement|refactorchore|documentation|epic|discovery|carryover|broken-windowcarryover— auto-created for 2×SPIRAL or FAILED agent tasks; seescripts/lib/spiral-carryover.mjs.broken-window— knowingly-broken shipment, hard due-date, filed by session-end Phase 2.6 (#730/H5); seescripts/lib/spiral-carryover.mjs(createBrokenWindowIssue).
Provenance Labels
from:<agent>— SHOULD be applied to any issue/MR created by an automated agent (e.g.from:discovery,from:reconcile), so operators can filter agent-authored items from human-authored ones. Single-colon form, per the taxonomy convention above.
Issue Linking (blocks / is_blocked_by)
GitLab's native issue-link types blocks and is_blocked_by (glab api --silent --hostname "$GITLAB_HOST" -X POST "projects/${ENCODED_PROJECT_PATH}/issues/${ISSUE_IID}/links" -f target_project_id="$TARGET_ENCODED_PROJECT_PATH" -f target_issue_iid="$OTHER_ISSUE_IID" -f link_type="$LINK_TYPE") are a Premium/Ultimate license feature. Set LINK_TYPE to blocks or is_blocked_by; the target accepts an encoded project path, so no numeric project ID is needed. On a Free/Core-tier GitLab instance this call returns HTTP 403 — a license-gate signal, not an auth/permission failure. Do not retry with different credentials or escalate as an auth bug.
Fallback (non-Premium instances):
- Use
relates_toinstead —link_type=relates_tois available on every GitLab tier (no ordering semantics, just an unscoped relation). Same API shape, only thelink_typevalue changes:glab api --silent --hostname "$GITLAB_HOST" -X POST \ "projects/${ENCODED_PROJECT_PATH}/issues/${ISSUE_IID}/links" \ -f target_project_id="$TARGET_ENCODED_PROJECT_PATH" \ -f target_issue_iid="$OTHER_ISSUE_IID" \ -f link_type=relates_to - Document the blocking semantics in the issue body — since
relates_tocarries no ordering meaning, add an explicit ordering note to both issues, e.g.⚠ Ordering: erst #<blocker_iid>, dann dieses Issue — blocks-Link nicht verfügbar (non-Premium). - Recognize the 403 as a license signal, not an auth error — before assuming a token/scope problem, try
relates_toon the same project pair: ifrelates_tosucceeds whereblocks/is_blocked_by403s, the license gate — not authentication — is the cause.
GitHub has no native issue-blocking relation at all — the body-ordering-note fallback in step 2 above is the standing convention there too, regardless of license tier (see "GitHub (gh)" below).
Common CLI Commands
Directive — consult this only for a command NOT listed below; every example here already complies. Each repo-scoped glab/gh invocation carries -R <OWNER>/<REPO> (glab also accepts GROUP/SUBGROUP/REPO or a full remote URL — resolveRepoSpec() in scripts/lib/vcs-repo-spec.mjs produces the right spec per platform); without the flag the target is whatever the ambient cwd remote happens to be, which is the wrong project in a sibling worktree, an /autopilot child, or a fork. Exactly four exceptions, each probed against the binaries: glab api/gh api (no --repo exists — pin the host with --hostname from resolveRepoHost() instead), gh repo <*> (rejects -R; takes the repository positionally), a glab repo call that already names the repository positionally, and — conditionally, not subcommand-wide — gh pr checks|view|diff|ready|merge|comment, where -R is legal ONLY alongside the <number>|<url>|<branch> positional: gh pr checks -R <OWNER>/<REPO> <BRANCH> carries the flag, while a positional-less gh pr checks -R <OWNER>/<REPO> --watch exits 1 with argument required when using the --repo flag — so name the PR or drop the flag, and never derive this from --help, which lists -R under INHERITED FLAGS with no such qualifier.
GitLab (glab)
# Issues
glab issue list -R <OWNER>/<REPO> --per-page 50 # All open issues
glab issue list -R <OWNER>/<REPO> --label "status:ready" --per-page 10 # Ready to work on
glab issue list -R <OWNER>/<REPO> --label "priority::high" --per-page 10 # High priority
glab issue list -R <OWNER>/<REPO> --closed --per-page 10 # Recently closed
glab issue view -R <OWNER>/<REPO> <IID> # View issue details
glab issue view -R <OWNER>/<REPO> <IID> --comments # With comments
glab issue create -R <OWNER>/<REPO> --title "title" --label "priority::high,status:ready"
glab issue update -R <OWNER>/<REPO> <IID> --label "status:in-progress" # WARNING: --label REPLACES the full set — see caveat below
glab issue close -R <OWNER>/<REPO> <IID> # then VERIFY: re-read the issue; it must show state=closed
glab issue note -R <OWNER>/<REPO> <IID> -m "Comment text" # Add comment
# MRs
glab mr list -R <OWNER>/<REPO> # Open MRs
glab mr create -R <OWNER>/<REPO> --fill --draft # Create draft MR
glab mr merge -R <OWNER>/<REPO> <MR_IID> # Merge MR
# Pipelines
glab pipeline list -R <OWNER>/<REPO> --per-page 5 # Recent pipelines
glab pipeline status -R <OWNER>/<REPO> <ID> # Pipeline details
# API (no --repo exists here — the encoded endpoint and explicit host identify the target)
glab api --hostname "$GITLAB_HOST" "projects/${ENCODED_PROJECT_PATH}/issues?state=opened&per_page=50"
glab api --hostname "$GITLAB_HOST" "projects/${ENCODED_PROJECT_PATH}/milestones?state=active"
Label update caveat (PUT-replaces, not additive): glab issue update --label (and the underlying GitLab labels API) PUT-REPLACES the entire label set — it does not add to the existing set. To change a single label you must pass the FULL desired label list, or use the dedicated add/remove operations, which are themselves unreliable across glab versions. Preferred safe pattern: use --label (adds) together with --unlabel (removes) on glab issue update when your installed glab version supports both; otherwise read the current labels first, compute the full new set, and PUT once. The same PUT-replace semantics apply to glab mr update --label.
Close verification: after glab issue close <IID>, always verify the close actually landed — re-read the issue (glab issue view <IID>) and confirm state: closed in the output. A stale or wrong project path, or a silent 404, can report local success while closing nothing; use the canonical project identity above for API operations rather than resolving a numeric ID.
Commit-body close-keyword footgun: GitLab (and GitHub) auto-close an issue when a commit pushed to the default branch contains a close keyword — close/closes/closed/fix/fixes/fixed/resolve/resolves/resolved — followed by #N ANYWHERE in the commit body, not just the subject line. This fires even inside a negation ("does NOT close #N") — the platform pattern-matches the keyword + issue reference; it does not parse English negation, so the negation offers no protection. Rule: when a commit body needs to MENTION an issue without closing intent, always use a non-closing reference — refs #N, part of #N, siehe #N — never a close-keyword verb next to the number, negated or not.
-f/--raw-field vs -F/--field on glab api: -f (--raw-field) sends a literal string value with no coercion and no @file expansion. -F (--field) interprets a value starting with @ as a file to read, and coerces bare true/false/null/numeric strings to their typed form. Prefer -f for literal values — it avoids an unintended @-expansion when a value happens to start with @ (e.g. an @mention in a comment body).
GitHub (gh)
# Issues
gh issue list -R <OWNER>/<REPO> --limit 50 # All open issues
gh issue list -R <OWNER>/<REPO> --label "status:ready" --limit 10 # Ready to work on
gh issue list -R <OWNER>/<REPO> --label "priority::high" --limit 10 # High priority
gh issue list -R <OWNER>/<REPO> --state closed --limit 10 # Recently closed
gh issue view -R <OWNER>/<REPO> <NUMBER> # View issue details
gh issue view -R <OWNER>/<REPO> <NUMBER> --comments # With comments
gh issue create -R <OWNER>/<REPO> --title "title" --label "priority::high,status:ready"
gh issue edit -R <OWNER>/<REPO> <NUMBER> --add-label "status:in-progress"
gh issue close -R <OWNER>/<REPO> <NUMBER>
gh issue comment -R <OWNER>/<REPO> <NUMBER> --body "Comment text" # Add comment
# PRs
gh pr list -R <OWNER>/<REPO> --state open # Open PRs
gh pr create -R <OWNER>/<REPO> --fill --draft # Create draft PR
gh pr merge -R <OWNER>/<REPO> <NUMBER> # Merge PR
# Workflows (CI equivalent)
gh run list -R <OWNER>/<REPO> --limit 5 # Recent workflow runs
gh run view -R <OWNER>/<REPO> <RUN_ID> # Run details
# API (no --repo exists here — the endpoint path IS the target; pin the host with --hostname)
gh api "repos/{owner}/{repo}/issues?state=open&per_page=50"
gh api "repos/{owner}/{repo}/milestones?state=open"
Issue Templates
Bug Template
## Description
What happens vs. what should happen.
## Steps to Reproduce
1.
2.
## Root Cause (if known)
## Acceptance Criteria
- [ ]
Feature Template
## Goal
What should be achieved and why.
## Tasks
- [ ]
## Acceptance Criteria
- [ ]
## Session Type
[housekeeping|feature|deep]
Carryover Template (from /close)
## [Carryover] Original Task Description
### What was completed
- [completed items]
### What remains
- [ ] [remaining task 1]
- [ ] [remaining task 2]
### Context for next session
[relevant context, file paths, decisions made]
### Revisit-Trigger
[the concrete condition or event that reopens this — e.g. "when <metric/state> passes <threshold>", "at the next <session type/release>". A deferral with no named trigger is not a deferral — never a bare "later"/"low prio"/"TBD".]
### Open Questions
_(optional — include only when unanswered questions remain in STATE.md `## Open Questions` at close; omit this section entirely otherwise)_
- [ ] [unanswered question 1] (source: W<N>/<agent>, prio: high|medium|low)
- [ ] [unanswered question 2] (source: W<N>/<agent>, prio: high|medium|low)
### Original Issue
Relates to #ORIGINAL_IID
### Revisit-Trigger is mandatory for the /close carryover template above: a carryover deferred without a concrete, checkable reopen condition is a rot risk — "later" reliably means "never". (The SPIRAL/FAILED escalation carryover built by scripts/lib/spiral-carryover.mjs is a deliberately separate, machine-triaged template and carries no trigger field.)
Discovery Finding
## [Discovery] <finding title>
**Probe:** <probe_name>
**Severity:** <priority::critical|high|medium|low>
**Category:** <code|infra|ui|arch|session|audit|vault|feature>
### Finding
<description of the problem>
### Evidence
- **File:** `<file_path>`
- **Line:** <line_number>
- **Code:**
Impact
<why this matters — severity rationale>
Recommended Fix
Acceptance Criteria
- <specific, verifiable condition>
- Quality gates pass after fix
Labels: `type:discovery`, `priority::<level>`, `area:<inferred>`, `status:ready`
## Template-First Enforcement (PSA-005 + #519)
Pattern 3 of the gsd Pattern Adoption (Issue #519) registers a PreToolUse hook
`hooks/pre-bash-templates-first.mjs` that **blocks** `gh|glab pr|mr|issue create|new`
Bash calls when the current session contains no prior `Read` on a matching template file.
**When this matters:** before you or a subagent opens an MR, PR, or issue via CLI, a
matching template must have been read in the current session:
- GitHub: `.github/PULL_REQUEST_TEMPLATE.md` / `.github/ISSUE_TEMPLATE*`
- GitLab: `.gitlab/merge_request_templates/Default.md` / `.gitlab/issue_templates/*`
Accepted template paths are configured in `.orchestrator/policy/templates-policy.json`
(versioned, operator-editable). Default behaviour:
- `enforcement: "block"` — hook exits 2 when no prior template Read is found
- Allow-list of host-specific template globs (GitHub + GitLab by default)
- `bypass_patterns` — list of command substrings that skip the hook (e.g. CI/bot calls)
**Bypass options for the current session** (when the hook blocks unexpectedly):
1. **Read the template first** — re-run the `create` call after a `Read` on the template
path; the hook re-evaluates and sees the Read.
2. **Session acknowledgement** — write `.orchestrator/runtime/templates-acknowledged.json`
containing `{ sessionId, acknowledgedAt }`; the hook allows all subsequent `create`
calls in this session.
**What the hook mechanically enforces** (what this skill previously documented as convention only):
- "Template-first" for every new MR/PR/issue
- Prevents convention drift across repos by turning the documentation requirement into
a hard gate — the same shift from rule to mechanism that PSA-003 made for destructive commands
**If the hook blocks incorrectly**, follow this sequence:
1. Read the template — retry the `create` call.
2. If the hook still blocks, open a bug issue against `hooks/pre-bash-templates-first.mjs`
with reproduce steps (command, session ID, template path that should have matched).
### Issue/MR Creation Checklist (with template-first gate active)
```bash
# 1. Read the relevant template first (satisfies the hook)
# GitLab MR
Read .gitlab/merge_request_templates/Default.md
# GitHub PR
Read .github/PULL_REQUEST_TEMPLATE.md
# 2. Then create — hook now passes
glab mr create -R <OWNER>/<REPO> --title "..." --description "..."
gh pr create -R <OWNER>/<REPO> --title "..." --body "..."
Cross-References
- Hook implementation:
hooks/pre-bash-templates-first.mjs - Read-history helper:
hooks/_lib/transcript-history.mjs(checks session transcript for prior Reads) - Enforcement policy:
.orchestrator/policy/templates-policy.json - Session acknowledgement:
.orchestrator/runtime/templates-acknowledged.json - Sister hook (destructive-command model):
hooks/pre-bash-destructive-guard.mjs - PRD: "gsd Pattern Adoption Quick-Wins" (#519; archived in the private Meta-Vault) § Pattern 3 + § 3 Gherkin Pattern 3