Ctrl K
ComunidadGuías…

AGENTS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<!--
Robot Dev Team Project
File: AGENTS.md
Description: Automation agent operations guide.
License: MIT
SPDX-License-Identifier: MIT
Copyright (c) 2025 MCKNLY LLC
-->

# Automation Agent Guide

This repository is frequently operated by LLM-based agents. Use the guidelines below to stay aligned with the system design and avoid disruptive changes.

## Mission Summary
- Maintain the FastAPI webhook service that routes GitLab events to local agent CLIs.
- Ensure configuration transparency: rely on bind-mounted host configs instead of duplicating credentials.
- Preserve container reproducibility (Dockerfile, docker-entrypoint, docker-compose) while keeping developer ergonomics strong.
- Support mention-trigger splitting so multi-mention webhooks enqueue one job per mentioned user.

## Environment Expectations
- Python 3.12+ with `uv` available for dependency management.
- Most shipped agent CLIs use native installers, so the base image ships no Node.js / npm (Gemini's harness was replaced by the Antigravity CLI, binary name `agy`). The one exception is the optional Pi harness (`@earendil-works/pi-coding-agent`), whose `scripts/install-pi.sh` bootstraps a pinned, user-local Node 22 at boot -- but only when a `pi-*` route is enabled and credentialed, so the Node-free base image is preserved for everyone who does not opt in.
- GitLab CLI (`glab`) accessible; tokens provided via environment.
- Agents run inside Docker by default, but local execution should always work.
- Windows operators should work inside WSL2 with Docker Desktop integration (see `docs/AGENT_ONBOARDING.md`).

## Setup Commands
```bash
python -m venv .venv
source .venv/bin/activate
pip install uv
uv pip install --editable .[dev]  # includes pytest/httpx when tests are added
```

Start the API with:
```bash
./launch-uvicorn-dev
```
The helper loads `.env` plus compose environment defaults before launching `uvicorn` from `.venv` with auto-reload enabled, so local runs mirror container settings.

Launch the full stack via Docker Compose:
```bash
docker compose up --build
```

## Configuration Notes
- Copy `.env.example` to `.env`; keep secrets out of version control. Reference `docs/ENVIRONMENT.md` for variable descriptions and compose override patterns.
- Keep `config/routes.yaml` rules deterministic and minimal—first match wins for each evaluation (multi-mention events run the resolver separately per mention).
- Prompt templates live in `prompts/`; prefer plain text with `${VARIABLE}` substitutions.
- Project repositories are mounted via `docker-compose.yml` (edit the volume paths to match your local directory structure). Projects should be organized as `<namespace>/<project-name>` to mirror GitLab's path structure.
- Enable `ENABLE_AUTO_CLONE=true` to automatically clone projects on first webhook trigger.
- Startup runs a strict preflight (`app/preflight.py`, invoked by `docker-entrypoint.sh`): the container refuses to boot when an enabled route dispatches an agent with no GitLab token or no git identity, and it installs only the harnesses those routes actually need. Install scripts declare their binary with a `# provides: <binary>` line. Run `python -m app.preflight` to check a config by hand; there is no escape hatch, so a bad config must be fixed.
- Stable releases use checked agreement between `[project].version`, the editable root entry in `uv.lock`, a dated changelog section, and an annotated protected `vX.Y.Z` tag. The first three are in-tree, so `tests/test_release_contract.py` checks them in the `validate` job, whose only rule excludes the scheduled drift-audit pipeline and which therefore runs on merge requests, default-branch pushes, and `v*` tag pushes alike -- the merge request is the early warning, not the only one. Release preparation must run `uv lock` after the version bump, and the dated section must actually carry the notes, because it becomes the published release description. The protected tag pipeline promotes the already-tested full-SHA image digest to SemVer aliases without rebuilding it and stores durable metadata in the Generic Package Registry. An annotated protected `vX.Y.Z-yank` tag authorizes withdrawal and SemVer-compatible alias reconciliation. Previews and `edge` are unsupported. The release jobs run in a slim Python image with **no git**, so the GitLab Release is created and updated through the project Releases API with `CI_JOB_TOKEN` (`apply_release_plan()`), not through a CLI: the project is `CI_PROJECT_ID`, never discovered from the checkout, which is what a `glab release create` without a project selector tried to do -- it shelled out to `git` and failed the job after the aliases and package files were already published. Do not reintroduce a CLI on this path, and do not send `ref` with the release (the tag always exists; omitting `ref` means the API can never create one). The asset links are reconciled against the existing release rather than re-sent wholesale because GitLab rejects duplicate release link names and URLs, and that read is strict on purpose: a masked `404` is confirmed against the releases collection and an unparseable `assets.links` payload raises, since misreading either as "no links" reintroduces the duplicate-link failure after the writes. See `docs/RELEASING.md`.
- Docker Hub promotion runs inside `release_publish` (`docker.io/mcknly/robot-dev-team`). It copies the qualified digest from the private registry with `crane copy` to `X.Y.Z` and the same moving aliases the private registry gets; it never rebuilds, and it uses the same `apply_alias()` as the private side, with `copy_from`. Three invariants. The public `X.Y.Z` is preflighted (`preflight_public_release()`) in the pre-mutation window beside the SBOM and scan gates, because the private `X.Y.Z` is immutable: a public conflict found after the private aliases move leaves a release no retry can finish. A tag already at the digest is never re-pushed, which is what keeps retries independent of Docker Hub's beta immutable-tag rule (`^[0-9]+\.[0-9]+\.[0-9]+$`, set by the owner) and its undocumented same-digest re-push behaviour; that rule must never match a moving alias. And the manifest is written only after every public tag verifies, carrying `public_aliases` and `public_references` (the version tag and digest only, since a permanent receipt cannot vouch for moving tags); `build_receipt()` copies `validated_public_references()`, which is strict and treats an absent field as `[]` so pre-promotion receipts keep their bytes. `release_yank` reconciles Docker Hub after the private registry (`yank_public_aliases()`), under three rules that are not stylistic. A Docker Hub alias falls back only to a release that was itself published there (`public_release_digests()`), never to a private-only one, because pre-promotion images were never held to the public-surface checks. Docker Hub never holds the private withdrawal back: every Docker Hub step, credentials included, runs after the private reconcile. And the yank record is written only once both registries agree, so a failure leaves no record, `github_release_withdraw` does not run, and a retry converges. With no public fallback the token cannot delete, so the job repoints what it can and fails naming the tags for the owner to delete; a retry counts an absent alias as removed. Two more rules came out of review, and both registries share them through `plan_alias_reconciliation()`/`select_fallback()`. **Aliases are found by live lookup of `X.Y`, `X`, `latest`, never from the manifest**: a release inherits aliases after publication, above all through an earlier yank, so a record-driven yank leaves them on the bad digest and still writes its record. And **fallbacks are chosen by digest**: the newest compatible release with a different image, or keep the alias when the newest compatible release is newer and ships the same image. Retagging to an older same-digest release reports a rollback while serving the yanked bytes. The recorded list (`aliases`/`public_aliases`, the latter validated strictly and required to agree with `public_references`) only decides what an absent tag means: recreated if recorded with a fallback, removed if recorded without one, recorded as skipped (never recreated) if it belongs to a newer live release of the same image, ignored if never recorded. The Docker Hub deletion path is reachable only when the private registry has a fallback and Docker Hub does not (in practice the promotion boundary, or a newer publication that stopped mid-copy); every other no-fallback case is the private refusal, and the runbook sends it to fix-forward. The protected `main` pipeline also exposes an optional manual `dockerhub_credential_probe` job that copies its qualified digest to `mcknly/robot-dev-team:ci-credential-probe-<pipeline-id>`, verifies cross-registry digest equality, and records non-secret evidence. The read/write CI PAT deliberately cannot delete; the Docker Hub owner removes the mutable probe tag after recording the evidence. Three things hold it together. The job `needs` `security_scan` (without artifacts), because DAG scheduling would otherwise let it publish a digest the vulnerability gate later rejects. It fails on **any** existing probe tag, a matching one included, because reading a public tag needs no push right and `crane auth login` never contacts the registry, so skipping the copy would "prove" a revoked or read-only token. And it receives the credentials only through the `dockerhub-publication` environment scope: a protected variable reaches every job on a protected ref regardless of `rules:`, including the shared `rdt-validate` jobs, and CE has no protected environments, so `test_only_approved_jobs_receive_scoped_credentials` pins which jobs may declare that environment. See `docs/RELEASING.md` and `docs/CI.md`.
- Public GitHub projection (`scripts/github_publication.py`, `publish` stage; contract in `docs/MIRRORING.md`). `github_release_publish` is **manual** on protected stable tags and is the only writer of public `main`, `rc`, `v*` tags, and GitHub Releases; `github_release_withdraw` follows `release_yank`; `github_publication_audit` runs alone on a schedule, tokenless. Invariants that are not stylistic: the release commit's tree is the tagged tree byte for byte, and only that tree's objects enter the scratch repo, so no canonical commit is ever pushable; `main`, `rc`, and the tag move in **one atomic, unforced push**; every date is the canonical tag's tagger time and the identity is the fixed noreply one, so retries reproduce the same objects and the receipt carries no timestamp; the outbound host gate (server **and** registry host) runs over the tree before any fetch and over every outbound object before any write -- except the yank reason, which is withheld rather than failing, because a blocked withdrawal is worse; the changelog section is also held to the tracker-citation matcher, which fails publication closed, because the tree guard never ran on pre-rule tags -- but withdrawal withholds cited notes from the notice (`WITHHELD_NOTES`) instead of failing, because `v0.3.0` was already projected with them and a blocked withdrawal would leave the citations public *and* the release looking current; the release stays a draft until its assets read back. Neither writing job consumes an artifact -- every input is the protected tag or durable package state -- because a manual job can be played after 30-day artifacts expire. An existing public tag is adopted only if the commit and tag object ids are exactly what the job would build; a published release is only ever checked, never repaired, so a missing asset fails closed. Every release's first parent is anchored to the durable receipts (`release_anchor()`: the previous receipted release, else `LEGACY_PREFIX_COMMIT`), in publication, tag adoption, and the audit's chain walk -- never to whatever public `main` currently is, or a hand-made commit becomes permanent receipted ancestry. Name and notes for publisher, withdrawal, and audit all come from `release_presentation()`, so the audit compares a withdrawal notice whole. A yanked version is published only to finish refs that already landed, and then only as withdrawn; withdrawal fails, not no-ops, on a public tag without a published release. Any PATCH of a published release must pass `make_latest` explicitly: GitHub defaults it to true on update. `GITHUB_MIRROR_TOKEN` reaches only jobs declaring `github-publication`, via askpass with global/system git config disabled -- never a URL. `scripts/__init__.py` exists so `mypy app scripts` sees one module path; run the tool as `python -m scripts.github_publication`.
- The release SBOM is staged by the build side, not produced by the release. `sbom_publish` uploads the exact `sbom.spdx.json` bytes of the smoke-tested image to `robot-dev-team-sbom/sha256-<hex>/`, and `publish_release()` copies them to `robot-dev-team-release/X.Y.Z/` and links them. Do not re-derive the document anywhere on this path: Syft embeds a per-run `documentNamespace` and timestamp, so a regenerated SBOM fails `upload_package_file()`'s content check on a retry. Do not fold the upload back into `scripts/ci-build-image.sh` (Alpine, no Python, and a post-push failure would be unrecoverable behind that script's own immutable-SHA-tag guard). The SBOM lives in its own package so `package_versions()` -- hardcoded to `PACKAGE_NAME`, and walked on every publish and yank -- keeps meaning "the set of releases"; it also checks each record's returned `name`, because GitLab's `package_name` filter is a substring search and a phantom version would reach alias planning with no manifest check. The fetch sits between `crane_digest()` and the first `apply_alias()` so a missing SBOM fails closed with nothing mutated -- tests assert that by failing on any alias write, not by inspecting state afterwards -- and the release link is version-scoped, never digest-scoped, so `plan_release()` stays resolvable before the digest is known. Both ends require the document's SPDX `name` to be the scanned image reference (`generate-sbom.sh --source-name`, i.e. `${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}`): the package path binds nothing on its own, since the manual-recovery upload is unconstrained. `sbom_publish` runs on the protected `rdt` runner despite needing no Docker, because it writes durable supply-chain state and `rdt-validate` also hosts untrusted merge-request containers. The link is gated by `include_sbom` rather than added unconditionally, because `desired_release_links()` is shared with the yank path and GitLab does not check that a link URL resolves -- a release published before this change would otherwise gain a permanent 404 link. The SBOM is an inventory and provenance artifact, **not** a scanner input: the vulnerability gate scans the image digest and never the SBOM, because SPDX cannot carry the Go function symbols Grype's `gosymbols` qualifier needs, so an SPDX input silently falls back to module-granularity Go matching (`docs/DEPENDENCY_MANAGEMENT.md`).
- Releases are gated on a vulnerability scan of the exact digest (`security` stage, checksum-pinned Grype in `TOOL_RELEASES`). The blocking rule is High/Critical **with an available fix**; unfixed and wont-fix are recorded non-blocking (a dated plan tightens it). Four invariants hold this together and none of them are stylistic. `release_publish` never runs the scanner -- it verifies the evidence `security_scan_release` staged under `robot-dev-team-scan/sha256-<hex>/`, because a Grype report embeds a scan timestamp and re-deriving it would fail `upload_package_file()`'s content check on a retry with the aliases already moved; the gate therefore sits in the same pre-mutation window as the SBOM fetch, between `crane_digest()` and the first `apply_alias()`. The **yank** path installs crane only (`--tools crane`) and never reads scan evidence: withdrawal has to work during the outage that would fail a scan, and when the CVE motivating the yank is the finding that fails it. The report is bound to the digest from **inside** the document (`source.target.manifestDigest` plus the scanned `userInput`, which is what rejects a tag-reference scan), because the package path binds nothing on its own -- and the evaluation is bound to the report by `report_sha256` and to its policy by `policy.sha256`, which `release_publish` recomputes from the tag's own `security/vulnerability-exceptions.yaml` bytes. That last check needs no YAML parser, which is what keeps it available on a release path that may not import a third-party module. The hash binds the evaluation to *a* report, so the staged report is also re-parsed and target-checked at consumption time, and every applied exception is re-checked for expiry there -- evidence is digest-keyed and reusable, so enforcing expiry only at production time would let a later release pass on a lapsed acceptance. All staged evidence goes through `validate_scan_evaluation()` so the scan job's retry path gets the same checks as the release. The Grype pin is what makes the threshold reproducible, not just what makes it trustworthy -- 0.106 to 0.116 moved this image's High/Critical count more than any dependency bump, so the evaluator rejects a report from any other version. The pin fixes a resolved-config *shape* as well as a report format: registry credentials bind through `GRYPE_REGISTRY_AUTH_*` and not the `SYFT_*` names Grype's own config help prints, so `validate_grype_registry_auth()` gates every live scan on `grype config --load` resolving that authority with non-empty credentials, and a bump must re-verify that document still renders `registry.auth` as a mapping list. That guard never logs the resolved document and never lets it reach an exception -- the unreadable-YAML branch raises after leaving the handler, because `from None` would leave PyYAML's quoted source in `__context__`. The staged-report recovery path skips the guard and stays registry-free. Exceptions are scoped to an exact package+version+type; unused and expired entries are hard failures. Prefer exact entries over the bounded class form: a class entry is unbounded over ids for that artifact, so it silently absorbs anything newly disclosed against the same build. `python scripts/release_tools.py evaluate --report <path> --digest <sha256>` re-runs the policy offline against a report you already have, which is how an exception-file edit is checked before it merges. See `docs/CI.md` and `docs/RELEASING.md`.
- The image ships the license notices of what is compiled into `glab` and `uv`, because neither upstream package does: `notices/{glab,uv}/` is copied to `/usr/share/doc/{glab,uv}/`, with glab's own `LICENSE` as `copyright`, the FreeType credit in `CREDITS`, and a `notices.json` recording each file's origin and SHA-256. The tree is **generated and committed**, never hand-edited: `python -m scripts.third_party_notices collect --glab-binary ... --uv-binary ...` rebuilds it byte-identically from the binaries' own build data (Go buildinfo, cargo-auditable), checking every download against a pin -- Go modules against glab's `go.sum`, crates against uv's `Cargo.lock`, the rest against `COLLECTION_INPUTS`. A `GLAB_VERSION` or `UV_VERSION` bump must move `COLLECTION_INPUTS` and regenerate `notices/` in the same MR; `tests/test_third_party_notices.py` fails otherwise. A component whose archive omits its license gets a reviewed, hash-pinned `NOTICE_FALLBACKS` entry; never generate text from an SPDX id. The same goes for a component that embeds another work whose license it only links to (d2's `NOTICE.txt` files), which no check detects -- re-read them, and the bundles themselves, when d2 moves: d2's `dagre.js` keeps graphlib's BSD source header, installed through a fallback with `lines`/`embedded_in` so only the header ships and `collect` proves it is what the bundle carries. Notices are collected from subdirectories on purpose, because `-sys` crates bundle C libraries (jemalloc, zstd, AWS-LC) whose notices sit below the crate root. `scripts/ci-build-image.sh` runs `check-image` inside the built image as `appuser` before the push, joining the SBOM to what is installed. `notices/` is excluded from `header_guard` and from EOL conversion because either would alter upstream bytes. See `docs/DEPENDENCY_MANAGEMENT.md`.
- Grok Build (optional, disabled by default) is a cloud harness: mount the host `~/.grok` read-write (it holds the `grok login` OAuth session). Grok does **not** read stdin -- its routes pass the prompt in argv via `-p "${PROMPT}"`, like `agy`. Do not "fix" this to `--prompt-file /dev/stdin`: that works from a shell but fails on every dispatch with ENXIO, because grok re-opens fd 0 by path and the dispatcher's pipe has no writer left. Its routes raise `max_inactivity_seconds` because `plain` output stays silent until the run ends, and pass `--no-auto-update` -- not a version pin (the installer fetches latest each boot) but a guard: grok's self-updater stages into the bind-mounted `~/.grok` and would repoint the symlink the operator's host grok resolves through. See `docs/ADDING_AN_AGENT.md`.
- Harness CLIs are installed at **every container start**, never baked into the image. That is a recorded decision (`docs/SYSTEM_DESIGN.md` section 7) with revisit triggers; changing it is its own issue. Its public consequences are documented, not incidental: a pulled image needs egress on each start, the release SBOM and vulnerability gate cover the built image only, and a derived image with pre-placed harnesses is unsupported. `docs/DEPENDENCY_MANAGEMENT.md` ("Boot-time egress") is the host allowlist consumers build from, and the one place the hosts are listed -- do not copy them into the README. Any installer URL change must update the table in the same MR -- `tests/test_runtime_install_contract.py` fails otherwise -- and every installer `curl` keeps `--connect-timeout`. The Claude and Antigravity rows stay marked untraced for their vendor `install` steps, and the table is written for a *fresh* container, because a restart keeps the previous harnesses. The running-container inventory in `docs/RELEASING.md` uses `docker export`, never `docker commit`: a commit captures the Compose-injected tokens in the image config. The image's `debian-snapshot`/`debian-source` labels are the corresponding-source pointer (`docs/LICENSE_REVIEW.md`) and must stay derived from `DEBIAN_SNAPSHOT`; they record the build's APT sources, not the origin of every inherited package. The image is `linux/amd64` only.
- Codex installs **two** binaries, not one. Since 0.147.0 `codex` routes tool calls through a separate `codex-code-mode-host` binary that ships as its own GitHub release asset -- the `codex-<arch>-unknown-linux-musl.tar.gz` tarball does not contain it -- and it fails **closed** without it: every tool call errors with `failed to spawn code-mode host`, so the dispatch still exits 0 while running no commands and posting no GitLab comment. The router logs `status=ok` and the only trace is the run-log, which makes this the quietest possible failure. `scripts/install-codex.sh` extracts it into its own temp dir, because the codex install's `codex-*-unknown-linux-musl` glob would otherwise match the host binary too. The installer resolves `releases/latest` once, stages both assets from that tag, and replaces the existing pair only when both are ready. Keep that pairing intact: dropping the second binary silently disarms every codex route at the next container boot, and the harness looks healthy from the outside.
- Goose (optional, disabled by default) drives a model served on the host. Its config dir is bind-mounted **read-only** at a staging path; `app/goose_config.py` materializes a container-local copy at boot, redirecting loopback URLs at `host.docker.internal` so a local `base_url` reaches the host instead of resolving to the container. Never mount it straight onto `~/.config/goose` -- the container would then rewrite the operator's host config and break Goose on the host. See `docs/ADDING_AN_AGENT.md`.
- Use `docs/GITLAB_WEBHOOKS.md` when guiding users through webhook setup or troubleshooting delivery issues.

## Testing Guidance
- Add or update pytest suites under `tests/`. Mock `glab` calls and agent subprocesses to keep tests fast and deterministic.
- Run `pytest` before committing when tests exist.
- If introducing new dependencies, update `pyproject.toml` and regenerate lockfiles if applicable (currently using editable installs via `uv`).
- **Virtual environment portability**: The `.venv` may have been created inside a Docker container, causing script shebangs to reference `/work/projects/...` paths that don't exist on the host. If `pytest` fails with "file not found" or similar path errors, recreate the venv:
  ```bash
  rm -rf .venv
  python -m venv .venv
  source .venv/bin/activate
  pip install uv
  uv pip install --editable .[dev]
  ```
  Alternatively, create a temporary venv elsewhere (e.g., `/tmp/test-venv`) to avoid modifying the repository's `.venv`.

## Coding Standards
- Follow the ASCII-only policy unless files already include Unicode.
- Add concise comments only when necessary to orient the reader around non-trivial logic.
- Keep logging structured and avoid leaking secrets (tokens, secrets in payloads).
- **Never write the canonical GitLab hostname into a tracked file** -- not in docs, not in a
  runbook snippet, not in a wrapper's error message, not in a test constant. Refer to the instance
  by role, or use a `<your-gitlab-host>` placeholder inside a shell snippet. The published tree is
  mirrored to GitHub and the wrappers are copied into the public image, so a literal hands a
  grep-driven scraper a resolvable endpoint, a registry, and an API base already assembled.
  `test_no_tracked_file_names_the_canonical_instance_by_hostname` in
  `tests/test_public_surface.py` enforces zero occurrences across every tracked file, and it fails
  rather than skips. See `docs/MIRRORING.md`.
- **Never cite the canonical tracker in a tracked file** -- no issue or merge-request shorthand,
  no note anchor, no pipeline or job number, no package page, and no `issue-<number>` branch name
  that points at a real issue. That covers docs, the changelog, code comments, test docstrings,
  `.gitlab-ci.yml`, exception files, generated JSON, and image labels. On GitHub the shorthand
  links to an unrelated issue or to nothing, and the rest points a reader at a page they cannot
  open. Where the reference stood in for a reason, write the reason; keep public facts such as
  versions, digests, CVE and GHSA ids, and upstream `gitlab-org/cli` records as full
  `https://gitlab.com/...` URLs. Issue and MR descriptions, review comments, and commit messages
  are not mirrored and may cite ids freely (`Closes #N` belongs there). `prompts/` is exempt: those
  are runtime instructions for agents working in an operator's own GitLab projects.
  `test_no_tracked_file_cites_the_private_tracker` in `tests/test_public_surface.py` enforces it;
  a line that only looks like a reference (fixture data) goes on the reviewed, exact-line
  allowlist in `tests/fixtures/private-reference-allowlist.tsv`, never a prose citation.

## Git Practices
- Commit related changes together with descriptive messages.
- Never revert user-authored changes without explicit instruction.
- If the working tree appears to change unexpectedly, stop and seek guidance.

## Helpful Files
- `docs/SYSTEM_DESIGN.md` — canonical reference for architecture and container expectations.
- `docker-entrypoint.sh` — controls runtime installation of agent CLIs via `scripts/install-*.sh`, gated by `app/preflight.py`.
- `app/preflight.py` — startup config validation; emits the set of harness installers the active routes require.
- `app/goose_config.py` — materializes the container's Goose config from the read-only host mount, redirecting loopback URLs at the Docker host gateway. Run by `docker-entrypoint.sh` only when a goose route is enabled and credentialed.
- `app/services/branch_pruning.py` — background pruner for merged remote branches. Authenticates once per pass outside any repository, then pins every git command to that identity with `-c` credential overrides parsed from `glab-usr`'s `CREDENTIAL-CONFIG` line; skips the cycle if the line is absent rather than deleting branches as an unknown agent.
- `gitlab-connect` — unified CLI wrapper that authenticates via `glab-usr` and provides issue/MR create, edit, comment, and view helpers.
- `glab-usr` — retains the low-level authentication switch used by `gitlab-connect`. Uses convention-based env var resolution (`<AGENT>_AGENT_GITLAB_TOKEN`, `<AGENT>_AGENT_GIT_*`) so any agent name works without code changes. Credentials are installed under a **host-scoped** helper key (`credential.<protocol>://<host>.helper`) whose first value is empty — git reads that as a helper-list reset, which is what stops an operator's global credential helper from answering first and attributing the push to the wrong agent. Do not "simplify" this back to a plain `credential.helper`: helper lists accumulate across config scopes, so a local helper does not displace a global one. The global fallback store is per host (`~/.config/glab-cli/git-credentials-<host>`), never git's shared `~/.git-credentials`, which a single-line write would truncate. The scoped reset cuts both ways: a repository-scoped key outranks a global one, so anything that authenticates *outside* a repository and then runs git *inside* one must override the helper with `git -c` (command scope) rather than re-authenticating with the repo as cwd — writing `.git/config` under a running agent reintroduces the misattribution. `glab-usr` emits a `CREDENTIAL-CONFIG` line on stderr carrying the key and quoted helper for exactly that purpose; parse it instead of rebuilding the key, because a mismatched key fails silently. The repository store is resolved with `git rev-parse --git-common-dir`, never `$repo_root/.git`: in a linked worktree or a submodule `.git` is a *file*, so a `-d .git` test drops silently to the global path and writes the helper and identity into `~/.gitconfig` for every checkout under that home. A consequence worth knowing: the common dir is shared by all worktrees of a repository, so one agent per repository is an invariant, worktrees included. See `docs/AGENT_ONBOARDING.md`.
- `docs/ADDING_AN_AGENT.md` — step-by-step guide for onboarding a custom agent CLI (BYOA).
- `docs/DEPENDENCY_MANAGEMENT.md` — dependency pinning policy, update cadence, SBOM workflow, and supported toolchain versions.
- `docs/MIRRORING.md` — canonical-source policy, public GitHub mirror contract, the hostname rule, and what release evidence is and is not published.
- `scripts/third_party_notices.py` — collects (`collect`, network) and verifies (`verify`, offline; `check-image`, inside the built image) the third-party license notices under `notices/`. Standard library only.
- `docs/LICENSE_REVIEW.md` — license classification of the release image's SBOM, produced by `scripts/license_review.py` from `security/license-evidence.json`. `classify` is offline. It exits non-zero while any entry is unresolved or has a license it can't classify ("needs review"), and it refuses an SBOM whose bytes don't match the required `--sbom-sha256` (the registry's recorded hash for the digest-keyed package) or whose SPDX `name` isn't the required `--source-name`. `--digest` alone is only a label; `docs/LICENSE_REVIEW.md` §7 is the runnable procedure, including the `crane digest` check that the name resolves to the digest. Take `--source-name` from the build, never from the SBOM. Every parser and classifier rule fails closed: unplaceable characters are rejected, deps.dev `non-standard` text goes to manual review, and Debian permissive names match exactly or as `name-…`, never as bare prefixes. For the Rust crates in `uv`, the CycloneDX SBOM uv ships inside the image is the primary source; crates.io is the cross-check. Never write the SBOM's root package name into the evidence: it is the canonical registry reference, so `entry_of()` keys it as `oci/image`.
- `docs/CI.md` — GitLab CI stages, rootless image build and smoke-test flow, publication gates, artifacts, and operator prerequisites.
- `docs/RELEASING.md` — protected-tag release contract, SemVer image aliases, durable release metadata, and yank/rollback runbook.
- `docs/ENVIRONMENT.md` — environment variable reference and docker-compose override examples.
- `docs/AGENT_ONBOARDING.md` — Linux and Windows (WSL) onboarding checklists for agents.
- `docs/GITLAB_WEBHOOKS.md` — webhook configuration workflow and troubleshooting tips.
- `docs/GROUP_SETUP.md` — GitLab group setup with automatic webhook provisioning via File Hooks.
- `gitlab/file_hooks/add_webhooks.rb` — File Hook script for automatic webhook creation on new projects.
- `gitlab/docker-compose.gitlab.yml` — example Docker Compose for GitLab CE with File Hook support.
- `gitlab/readme-gitlab.md` — GitLab CE deployment guidance and upgrade path instructions.
- `docs/DASHBOARD_GUIDE.md` — dashboard usage and kill-switch behaviour.
- `docs/ADD_NEW_PROJECT.md` — checklist for adding a new project to an existing deployment.
- `docs/CONTRIBUTING.md` — issue workflow, branch strategy, and MR expectations.
- `docs/CHANGELOG.md` — release history snapshot for the public track.
- `scripts/header_guard.py` — quick check to ensure headers stay compliant with the MIT licensing policy.
- `fs.inotify.*` sysctls — Claude’s dev server keeps a reload watcher running; if you see `Too many open files`, raise `fs.inotify.max_user_watches` / `fs.inotify.max_user_instances` on the host (e.g., `sudo sysctl fs.inotify.max_user_watches=262144 fs.inotify.max_user_instances=512`) before restarting the container.

## Troubleshooting Tips
- Verify webhook authentication by comparing `X-Gitlab-Token` to `GITLAB_WEBHOOK_SECRET`.
- Inspect `run-logs/` for captured prompts and agent outputs when debugging failures (multi-mention events log once per mention trigger using the derived event id).
- Use `docker compose logs app` to review runtime logs in containerized deployments; queued triggers are processed sequentially by the in-memory worker.
- When `glab` commands fail, run `glab auth status` inside the container to confirm tokens.
- If every agent dispatch fails with `glab-usr authentication failed` and the run-log shows `mkdir /home/appuser/.config/glab-cli: permission denied`, a `docker compose exec` ran without `-u appuser` and left root-owned dotfiles in `$HOME`. Recover with `docker compose exec -u root app chown -R appuser:appuser /home/appuser` (or restart the container; the entrypoint chowns on startup). Always pass `-u appuser` when exec-ing into the container to test the wrappers -- see `docs/AGENT_ONBOARDING.md`.

Stay aligned with these conventions to keep the automation pipeline predictable for both humans and agents.