{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/TitusKirch/skills/main/tituskirch-skills.schema.json", "title": "TitusKirch skills config", "description": "Optional, committed config shared by the TitusKirch skills (atomic-commit, pull-request, issue, release, merge-deps, prune-branches, work-issue, work-queue, write-docs; update-deps and handoff read shared keys only). Place at the repo root as .tituskirch-skills.json. Absent → skills behave on detection/native defaults only. Resolution per setting: config → native → built-in default.", "type": "object", "additionalProperties": false, "properties": { "$schema": { "type": "string", "description": "Optional pointer to this schema for editor validation." }, "forge": { "type": "string", "enum": ["github", "gitlab"], "default": "github", "description": "Code forge for the forge-bound skills — where pull requests (GitLab: merge requests) and releases live. github is driven by the gh CLI, gitlab by glab. This is the single home for the forge axis; the issue tracker is a separate axis (issue.tracker / work.tracker). Not every forge-bound skill implements every forge: pull-request, prune-branches and merge-deps speak both — merge-deps needs mergeDeps.gitlab.bot on GitLab, since its queue's author is not a constant there — while release is GitHub-only and says so when it meets another forge rather than degrading silently." }, "forgeHost": { "type": ["string", "null"], "minLength": 1, "pattern": "^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]+)?$", "description": "The host this repo's forge lives on — a bare hostname with an optional port (gitlab.example.com, github.example.com:8443), never a URL and never a path. Self-hosted GitLab is the normal GitLab deployment and GitHub Enterprise has the same shape, so which host a repo talks to is a per-repo fact and must never be resolved once and reused across repos. null or absent means derive it — the origin remote's host first (a repo-level fact), then whatever the forge CLI is already authenticated against (glab's GITLAB_HOST or configured hostname, gh's GH_HOST or hosts.yml). Authentication is never duplicated here: this key names the host, the CLI holds the credentials." }, "language": { "$ref": "#/$defs/language", "description": "Shared default message/title/body language for all skills. Overridable per skill section." }, "verify": { "type": ["string", "null"], "minLength": 1, "description": "The command this repo runs to check itself (tests / lint / build) — a repo-level fact, shared by every skill that needs to know the repo still passes. work-implement runs it before pushing, work-review against the pushed head it is reviewing (establishing green rather than inheriting the implementer's), update-deps after updating, merge-deps against a dependency PR's own head (overridable via mergeDeps.verify). Write the check command only: a skill checking a head that is not the working tree installs that head's lockfile itself. null or absent → each skill detects it from the repo, and a command that cannot be detected is reported as unrun, never treated as green. It lives at the root, not inside a skill section, so disabling a skill never withdraws it." }, "trustedBots": { "$ref": "#/$defs/trustedBots", "description": "Allowlist of automated authors (apps and bots) the third-party-reading skills may treat as authoritative — a repo-level trust fact, empty by default. Lives at the root, not in a skill section, because several skills share it and it must not be withdrawn by disabling one of them." }, "grillWith": { "$ref": "#/$defs/grillWith", "description": "The interview engine the drafting skills drive before a draft hardens — issue (a thin request) and refine-issue (an issue's open decisions). It lives at the root, not in a skill section, because both of them drive it and an interview style is a property of the repo: a per-skill key would duplicate the same value and let the two drift apart." }, "commit": { "description": "atomic-commit settings, or false to disable the atomic-commit skill for the repo.", "oneOf": [ { "$ref": "#/$defs/commitShape" }, { "const": false } ] }, "pr": { "description": "pull-request settings, or false to disable the pull-request skill for the repo.", "oneOf": [ { "$ref": "#/$defs/prShape" }, { "const": false } ] }, "release": { "description": "release skill settings, or false to disable the release skill for the repo. Drives release-please; the release tool itself is detected from the repo, never configured here.", "oneOf": [ { "$ref": "#/$defs/releaseShape" }, { "const": false } ] }, "mergeDeps": { "description": "merge-deps skill settings, or false to disable the merge-deps skill for the repo. Absent is not disabled — it means report-only, since mergeDeps.merge defaults to false. Named mergeDeps (not deps) to disambiguate against the separate update-deps skill: this section is only ever about merging the dependency-bot request queue. Scope is fixed to a single author: on GitHub the constant app/dependabot, deliberately not configurable; on GitLab the one identity mergeDeps.gitlab.bot names, since no constant exists there. Either way it is one author and never a selector — the key can only narrow.", "oneOf": [ { "$ref": "#/$defs/mergeDepsShape" }, { "const": false } ] }, "pruneBranches": { "description": "prune-branches skill settings, or false to disable the prune-branches skill for the repo. Absent is not disabled — it means the built-in defaults, since the skill deletes nothing without an explicit per-run confirmation. Scope is fixed to the integration branch's own remote, and the built-in protected set is fixed too; pruneBranches.protect only ever adds to it.", "oneOf": [ { "$ref": "#/$defs/pruneBranchesShape" }, { "const": false } ] }, "issue": { "description": "issue skill settings, or false to disable the issue skill for the repo.", "oneOf": [ { "allOf": [ { "$ref": "#/$defs/issueShape" }, { "if": { "properties": { "tracker": { "const": "linear" } }, "required": ["tracker"] }, "then": { "required": ["linear"], "properties": { "linear": { "required": ["team"] } } } } ] }, { "const": false } ] }, "docs": { "description": "write-docs settings, or false to opt the repo out of docs entirely.", "oneOf": [ { "$ref": "#/$defs/docsShape" }, { "const": false } ] }, "work": { "description": "work-issue / work-queue settings, or false to disable both skills for the repo.", "oneOf": [ { "allOf": [ { "$ref": "#/$defs/workShape" }, { "if": { "properties": { "tracker": { "const": "linear" } }, "required": ["tracker"] }, "then": { "required": ["tracker", "linear", "labels"], "properties": { "linear": { "required": ["team", "statuses"] }, "labels": { "required": ["repo"] } } } } ] }, { "const": false } ] }, "profiles": { "type": "object", "minProperties": 1, "propertyNames": { "minLength": 1 }, "additionalProperties": { "$ref": "#/$defs/profile" }, "description": "Named execution-context overlays. Selected by the TITUSKIRCH_SKILLS_PROFILE environment variable, falling back to \"ci\" when CI is set to a truthy value, and to no profile otherwise. The selected profile is deep-merged onto the base config and the profiles key is dropped from the result: objects merge recursively, arrays and scalars are replaced rather than concatenated, and an explicit null sets null rather than deleting a key. An unknown or unset profile name resolves to the base config unchanged. Profiles do not nest." } }, "$defs": { "instructions": { "type": "string", "minLength": 1, "description": "Free-text, repo-wide guidance appended to a skill's prompt (committed, so it applies for every agent). Additive preference only: it steers wording and emphasis but never overrides hard constraints — commitlint rules, schema values, forge/tracker rules, templates, or the skill's built-in guardrails." }, "grillWith": { "description": "The name of a model-invocable interview skill, or null/false to never grill. It names a SKILL, not an interview mode: a round-based engine docks by having its name typed here, with no schema change and no release. Absent → today's behaviour, drive grilling when it is installed and skip the pass when it is not. A named skill that is not installed → skip the pass, draft as today, and say so. A named skill that declares disable-model-invocation (grill-me and batch-grill-me both do, so neither is a valid value) → report it as a config error and skip the pass; never silently fall back to another engine, which would run a different interview than the one configured and report success.", "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "null" }, { "const": false } ] }, "labelOrOff": { "description": "A label name, or false to switch the mechanic off.", "oneOf": [ { "type": "string", "minLength": 1 }, { "const": false } ] }, "trustedBots": { "type": "array", "default": [], "uniqueItems": true, "description": "Allowlist of automated authors (apps and bots) whose third-party text the skills that read it may treat as authoritative — empty by default, so no bot is trusted until a repo names it, the way merge-deps names app/dependabot. A human author is never listed here (repo write/maintain/admin permission is checked live instead); this list exists because an app's write access is not readable with a normal token. On Linear an OAuth app is the equivalent case and belongs here too, recognised by its @oauthapp.linear.app email. Read by work-implement, work-review, handoff, merge-deps and issue; the queue, update-deps and release skills read it in their reduced form.", "items": { "type": "object", "additionalProperties": false, "required": ["id", "login"], "properties": { "id": { "type": ["integer", "string"], "description": "The author's immutable account id — what the match is actually made on. On GitHub this is user.id (an integer, present for humans and bots alike) or the app id from performed_via_github_app. Immutable across renames, unlike the login, which is why it is the identifier that matters." }, "login": { "type": "string", "minLength": 1, "description": "The author's login/handle (e.g. \"dependabot[bot]\") — present so the list is human-readable, not used for matching. A login is reusable once an account is renamed or deleted, so a login that disagrees with its id is the rename signal: report it, never silently trust it." } } } }, "language": { "description": "A language code or name (e.g. \"en\", \"de\", \"fr\", \"pt-BR\"), or \"match\" to mirror the repo/source language. A scalar applies to both title and body; the object form sets them separately.", "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "object", "additionalProperties": false, "minProperties": 1, "properties": { "title": { "type": "string", "minLength": 1 }, "body": { "type": "string", "minLength": 1 } } } ] }, "languageScalar": { "type": "string", "minLength": 1, "description": "A language code or name (e.g. \"en\", \"de\", \"pt-BR\"), or \"match\" to mirror the repo/source language. Scalar only — no title/body split (used where the artifact has a single body, e.g. commit messages)." }, "titleConvention": { "type": "object", "additionalProperties": false, "description": "Title style. The referencing section sets the default (conventional for pr, plain for issue).", "properties": { "convention": { "type": "string", "enum": ["conventional", "plain"], "description": "conventional (type: subject) or plain." } } }, "commitShape": { "description": "Shape of the commit section: properties and types only, with no required keys and no cross-key constraints. Referenced twice — by the root section, which composes it with its constraints, and by a profile, where a fragment must stay valid on its own. Never referenced directly by a config.", "type": "object", "additionalProperties": false, "properties": { "language": { "$ref": "#/$defs/languageScalar", "description": "Overrides the root language for commit messages (a language code/name, or \"match\"). Commits carry no title/body split, so this is a scalar only." }, "scopes": { "enum": [true, false, "auto"], "default": "auto", "description": "Force Conventional-Commit scope usage: true = always, false = never, \"auto\" = detect from history/commitlint. Default: \"auto\". A commitlint scope-enum / scope-empty rule still wins (hard constraint)." }, "scopeVocab": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true, "description": "Preferred scope vocabulary, unioned with scopes detected from the git log; filtered against a commitlint scope-enum when one exists. Use when the intended scopes (e.g. monorepo packages or not-yet-committed areas) aren't all in the history yet." }, "instructions": { "$ref": "#/$defs/instructions", "description": "Free-text guidance for commit messages. Additive preference only — never overrides commitlint, detection, or the skill's guardrails." } } }, "prShape": { "description": "Shape of the pr section: properties and types only, with no required keys and no cross-key constraints. Referenced twice — by the root section, which composes it with its constraints, and by a profile, where a fragment must stay valid on its own. Never referenced directly by a config.", "type": "object", "additionalProperties": false, "properties": { "base": { "type": "string", "minLength": 1, "description": "PR base branch. Overrides the GitHub default branch (e.g. a feature → dev flow)." }, "title": { "$ref": "#/$defs/titleConvention", "default": { "convention": "conventional" }, "description": "PR title style. Default: conventional." }, "language": { "$ref": "#/$defs/language", "description": "Overrides the root language for PR title/body." }, "instructions": { "$ref": "#/$defs/instructions", "description": "Free-text guidance for the PR title/body. Additive preference only — never overrides the template, detection, or the skill's guardrails." } } }, "releaseShape": { "description": "Shape of the release section: properties and types only, with no required keys and no cross-key constraints. Referenced twice — by the root section, which composes it with its constraints, and by a profile, where a fragment must stay valid on its own. Never referenced directly by a config.", "type": "object", "additionalProperties": false, "properties": { "promote": { "enum": [false, "auto", "create"], "default": false, "description": "Who opens a promotion PR (for each head → base edge of the chain). false = release-only, never promote; promotion is opt-in and this is the default. \"auto\" = automation already opens it, so the skill only finds, undrafts and merges it and never creates one. \"create\" = no such automation, the skill may open it (the only PR it ever opens, in any mode). Default: false." }, "stages": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 }, "description": "Ordered promotion chain: lowest integration stage first, release branch last (where release-please runs). Each consecutive pair is a promotion edge — [\"dev\",\"staging\",\"main\"] is two edges (dev → staging, staging → main). The canonical N-stage form of head/base: when present it wins over both; when absent the chain is [head, base]. A single element [\"main\"] means the release is cut directly on base with no promotion. Branches must be distinct (enforced). Default: absent (falls back to head/base)." }, "base": { "type": "string", "minLength": 1, "description": "Release branch, where releases are cut — the last element of stages when that is set. Default: the repo's default branch." }, "head": { "type": "string", "minLength": 1, "description": "Integration branch promoted onto base — the first element of stages when that is set, and ignored when it is. Default: pr.base, else the repo's default branch." }, "timeout": { "type": "integer", "minimum": 1, "default": 600, "description": "Seconds bounding each wait (the release PR appearing, checks finishing). Default: 600." } } }, "mergeDepsShape": { "description": "Shape of the mergeDeps section: properties and types only, with no required keys and no cross-key constraints. Referenced twice — by the root section, which composes it with its constraints, and by a profile, where a fragment must stay valid on its own. Never referenced directly by a config.", "type": "object", "additionalProperties": false, "properties": { "merge": { "enum": [false, "patch", "grouped", "all"], "default": false, "description": "Ceiling on what may be merged; a permission ladder, narrowest first. false = triage and report only, never comment or merge; merging is opt-in and this is the default. \"patch\" = patch-level updates only. \"grouped\" = Dependabot's grouped minor+patch PRs, plus everything \"patch\" allows. \"all\" = nothing excluded by mode, majors included. A mode is a ceiling, never a trigger: every PR must still clear assessment, and mergeDeps.confirm decides which then wait for a human. Default: false." }, "confirm": { "enum": ["major", "always"], "default": "major", "description": "Once merging is opted in via merge, which merges still wait for an explicit per-PR confirmation. \"major\" (default) = only major / semver-breaking bumps; the low-risk tier the mode allows (patch, minor, grouped minor+patch) merges on the standing opt-in once it clears assessment, so an opted-in repo is not asked twice for routine updates. \"always\" = every merge waits, the strict prior behaviour. Independent of merge: merge is the ceiling on what may merge at all, confirm is which of those still need a human yes. The plan is always shown first regardless, a major never auto-merges, and confirm can neither raise the ceiling nor lower a gate — an unknown check list, a failed verify or an undeterminable update type still holds the PR. Default: \"major\"." }, "verify": { "type": "string", "minLength": 1, "description": "Command run against the PR's own head before merging, in a throwaway worktree whose dependencies the skill installs from that head's own lockfile first. The primary safety gate, not a fallback: a Dependabot PR into an integration branch often triggers no relevant CI at all, and an empty check list is never read as green. Set this only for a genuinely different command (an audit, a narrower suite) — never to prepend an install, which is the skill's job and silently reports green when forgotten. Default: the root verify key, else detected." }, "cap": { "type": "integer", "minimum": 1, "default": 5, "description": "Max PRs merged per run. Default: 5." }, "gitlab": { "type": "object", "additionalProperties": false, "description": "GitLab-only settings, read when forge is gitlab and inert otherwise.", "properties": { "bot": { "type": "object", "additionalProperties": false, "required": ["id", "login"], "description": "The single account whose merge requests are this repo's dependency queue on GitLab. Required there, with no default: GitHub's app/dependabot is a constant the forge itself runs, while Renovate on GitLab is always self-run — its author is either a dedicated user holding a personal access token or the internal bot user GitLab mints for a project or group access token, a per-repo, per-instance fact. No identity configured means the skill stops; it never infers one from a branch prefix, a label or a title. It names exactly one identity and never a selector type, which is the one shape that could widen the guarantee the skill rests on. The schema does not demand it alongside forge:gitlab, because a profile fragment must stay valid on its own and a gitlab repo need not use this skill at all — the stop is a runtime rule.", "properties": { "id": { "type": ["integer", "string"], "minimum": 1, "minLength": 1, "description": "The account's immutable GitLab user id — what the match is actually made on, re-asserted per merge request before the skill touches it. GitLab's merge-request API filters on author_id (numeric, mutually exclusive with author_username), and a bot account backed by an access token is an ordinary user there, so nothing but the id distinguishes it. Constrained to an id an account could hold — GitLab mints user ids from 1 — because this half is the one that matches: an empty or zero id fails closed, matching nothing, which reads on the run as a healthy empty queue rather than as a broken config." }, "login": { "type": "string", "minLength": 1, "description": "The account's username (e.g. \"renovate-bot\", \"project_4_bot_a1b2c3\") — present so the entry is readable and so the queue can be narrowed with glab mr list --author, never used for matching. A username is reusable once an account is renamed or deleted, so a username that disagrees with its id is the rename signal: report it, never silently trust it. Same id/login split as trustedBots, deliberately one object rather than that key's array: this names the queue, not an allowlist." } } } } } } }, "pruneBranchesShape": { "description": "Shape of the pruneBranches section: properties and types only, with no required keys and no cross-key constraints. Referenced twice — by the root section, which composes it with its constraints, and by a profile, where a fragment must stay valid on its own. Never referenced directly by a config.", "type": "object", "additionalProperties": false, "properties": { "age": { "type": "integer", "minimum": 1, "default": 90, "description": "Days without a commit after which a branch counts as stale by age — the skill's fourth category, measured from the tip commit's committer date. That category is always listed and never preselected, so this threshold widens or narrows what is reported, never what is deleted unasked. Default: 90." }, "protect": { "type": "array", "default": [], "uniqueItems": true, "items": { "type": "string", "minLength": 1 }, "description": "Extra protected-branch glob patterns (e.g. \"release/*\", \"legacy-*\"), matched against a branch's short name on both the local and the remote side. Additive only, the way issue.labels.exclude adds to a label catalog: it is appended to the built-in protected set — the forge's default branch, the integration branch (pr.base), any branch checked out in a worktree, any branch the forge itself marks protected, the head of an open pull request, and the name fallback main/master/dev/develop/stage/staging/prod/production/next — and can never shrink it, so no config value can unprotect the default branch. release/* is deliberately absent from the built-in set (it is the shape of release-please's own throwaway branches), which is what this key is for in a repo that genuinely ships from it. Default: []." } } }, "issueShape": { "description": "Shape of the issue section: properties and types only, with no required keys and no cross-key constraints. Referenced twice — by the root section, which composes it with its constraints, and by a profile, where a fragment must stay valid on its own. Never referenced directly by a config.", "type": "object", "additionalProperties": false, "properties": { "tracker": { "type": "string", "enum": ["github", "gitlab", "linear", "local"], "description": "Active issue tracker (github = GitHub Issues, gitlab = GitLab Issues, linear = Linear, local = committed issue files under issue.local.dir). Set by the guided setup; never guessed silently. Distinct from the forge axis (root forge): a repo can file its issues in Linear or in-tree and still open its pull requests on GitHub, and the two axes are set independently. Where a forge-native tracker is chosen it talks to the same host the forge axis resolves (root forgeHost) — GitHub Issues and GitLab Issues live on the instance that hosts the code." }, "language": { "$ref": "#/$defs/language", "description": "Overrides the root language for issue title/body." }, "title": { "$ref": "#/$defs/titleConvention", "default": { "convention": "plain" }, "description": "Issue title style. Default: plain (most trackers)." }, "linear": { "type": "object", "additionalProperties": false, "description": "Linear defaults (used when tracker = linear).", "properties": { "team": { "type": "string", "minLength": 1, "description": "Required to create on Linear. Human name/key (e.g. \"ENG\"); resolved to the team id via the catalog cache." }, "project": { "type": ["string", "null"], "description": "Default project." }, "priority": { "enum": ["none", "low", "medium", "high", "urgent", null], "description": "Default priority, seeding Linear's native priority field on the create call. Named here, numeric there: the call takes 0 none, 1 urgent, 2 high, 3 medium, 4 low, so the word is mapped across and the scale runs opposite to it. Absent or none sends nothing (Linear stores 0 either way). Priority is a field on Linear, never a label: the priority: label group is a GitHub convention (see work.priorityLabels) and is not reproduced on Linear, so its absence from a Linear label catalog is not a finding." }, "defaultState": { "type": ["string", "null"], "description": "Default workflow state on create." } } }, "local": { "type": "object", "additionalProperties": false, "description": "Local file-tracker options (used when tracker = local). Needs nothing set: unlike linear, whose team cannot be derived, every key here has a default, so tracker = local is complete on its own and the schema requires no companion.", "properties": { "dir": { "type": "string", "minLength": 1, "default": ".agents/issues", "description": "Repo-relative directory holding the issue files, one NNNN-slug.md per issue. Committed on purpose — being reviewable and shareable is the reason for choosing files over a service — and a sibling of .agents/handoffs/ for the same reason. Default: \".agents/issues\"." } } }, "labels": { "type": "object", "additionalProperties": false, "description": "Label handling.", "properties": { "exclude": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true, "description": "Glob patterns (e.g. \"stack:*\", \"autorelease:*\") for catalog labels the agent must never auto-apply." } } }, "template": { "type": ["string", "null"], "description": "Forces one issue template, as a repo-relative path to the file under .github/ISSUE_TEMPLATE/ (a .md template or a .yml form). Unset is the normal case and does not mean \"no template\": the skill then chooses per issue by reading the templates' own description (.yml) / about (.md), so this key exists to override that choice, not to supply a default for it. An explicit null means the same thing — no forced template — but it is terminal: it does not fall back to the deprecated issue.github.template, so a profile can clear a forced template without resurrecting the old key. Only an absent issue.template reaches that fallback, so presence must be tested before the value is read. Applies on both trackers: a committed template states how the repo writes issues, which the tracker receiving them does not change, so the same files are read on Linear as on GitHub. The skill reads the file and composes the body itself, so this is a path and not a template name: `gh issue create --template` takes the name: from a template's frontmatter, is rejected alongside --body/--body-file, and cannot see .yml forms at all." }, "github": { "type": "object", "additionalProperties": false, "description": "GitHub issue defaults (used when tracker = github).", "properties": { "template": { "type": ["string", "null"], "deprecated": true, "description": "Deprecated — use issue.template, which applies on both trackers. Still read as a fallback when issue.template is absent, so existing configs keep working; setup writes the new key. An explicit null on issue.template means \"no forced template\" and ends the lookup — it does not reach this key." } } }, "instructions": { "$ref": "#/$defs/instructions", "description": "Free-text guidance for the issue title/body. Additive preference only — never overrides the tracker rules, template, or the skill's guardrails." } } }, "docsShape": { "description": "Shape of the docs section: properties and types only, with no required keys and no cross-key constraints. Referenced twice — by the root section, which composes it with its constraints, and by a profile, where a fragment must stay valid on its own. Never referenced directly by a config.", "type": "object", "additionalProperties": false, "properties": { "preset": { "type": "string", "enum": ["package", "cli", "app", "service", "infra"], "description": "Project type — selects the whole set of sections to scaffold; none is implicit. Falls back to repo detection, then asks." }, "language": { "$ref": "#/$defs/language", "description": "Overrides the root language for generated docs." }, "instructions": { "$ref": "#/$defs/instructions", "description": "Free-text guidance for generated docs (tone, house conventions). Additive preference only — never overrides the docs format or the skill's guardrails." } } }, "workShape": { "description": "Shape of the work section: properties and types only, with no required keys and no cross-key constraints. Referenced twice — by the root section, which composes it with its constraints, and by a profile, where a fragment must stay valid on its own. Never referenced directly by a config.", "type": "object", "additionalProperties": false, "properties": { "tracker": { "type": "string", "enum": ["github", "gitlab", "linear", "local"], "description": "Active tracker for working issues (github = GitHub Issues, gitlab = GitLab Issues, linear = Linear, local = committed issue files under work.local.dir). Falls back to issue.tracker." }, "cap": { "type": "integer", "minimum": 1, "default": 10, "description": "Max issues a single drain works across the whole run (mandatory bound) — throughput, not simultaneity. How many of them run at once is work.concurrency. Default: 10." }, "concurrency": { "type": "integer", "minimum": 1, "description": "Max workers a drain runs at the same time under parallel: true — the ceiling the machine can stand (each worker costs a worktree, a full dependency install, CPU and tracker rate limit), which is a different question from how many issues the run may work in total. Never raises cap: the effective concurrency is the lower of the two. Inert when parallel is false, where one worker at a time is the construction. No static default — it defaults to work.cap, so a config that does not set it behaves exactly as before this key existed." }, "branch": { "oneOf": [ { "const": "worktree" }, { "type": "string", "pattern": "^branch:.+" } ], "default": "worktree", "description": "Where work lands: \"worktree\" (own branch + PR per issue) or \"branch:\" (all issues on one shared branch, e.g. \"branch:dev\"). Default: worktree." }, "parallel": { "type": "boolean", "default": false, "description": "Run workers concurrently in isolated git worktrees instead of sequentially. Independent of branch. Default: false." }, "queueBranch": { "type": "boolean", "default": false, "description": "Group a worktree drain's pull requests behind one queue branch: each issue branch is cut as ai/queue/- instead of ai/-, and each issue PR targets an open ai/queue- instead of pr.base. The ai/queue/ prefix is the signal — it is what asks the target repo's workflow to group that PR, so this key, not the repo, decides whether a given run is grouped; a repo carrying the workflow states only that it can group. Opt-in, and deliberately off by default, because the mode only completes where the target repo owns a workflow that cuts the queue branch and fast-forwards its integration branch onto that PR's head once it is green — without one, the prefix matches nothing and every issue PR falls back to pr.base. The drain never cuts, merges or fast-forwards the queue branch itself and needs no bypass-capable credential. Inert under branch:, which opens no per-issue PR at all. Default: false." }, "feedback": { "type": "string", "enum": ["pr", "issue"], "description": "Where the two loops write their round-by-round output — review verdicts and their rationale, the implementer's blocked reason, a surfaced label/body conflict. \"pr\" puts it on the pull request, leaving the issue its requirement, its lifecycle label and a link to the thread; \"issue\" puts it in the issue's comments, for a loop that opens no pull request. No fixed default: it follows work.branch, so worktree defaults to \"pr\" and branch: to \"issue\". In \"pr\" mode with no pull request to post to, the feedback falls back to the issue rather than being dropped, and a formal review the forge refuses on a self-authored pull request falls back to a plain pull-request comment." }, "labels": { "type": "object", "additionalProperties": false, "description": "Label names the work skills reason about. Each is a label string, or false to disable that mechanic. Most are lifecycle states the loops write; repo and needsTriage are markers the loops only read.", "properties": { "ready": { "$ref": "#/$defs/labelOrOff" }, "working": { "$ref": "#/$defs/labelOrOff" }, "reviewRequested": { "$ref": "#/$defs/labelOrOff", "description": "The \"pushed, awaiting AI review\" hand-off label — the review queue's input, written by the implement loop and consumed by the review loop. Default: \"ai: review requested\", which reads symmetrically with changesRequested. A repo whose tracker labels this state differently pins its own string here; the label must already exist on the tracker before a skill copy adopts it, because a label query answers a missing label with an empty result rather than an error." }, "reviewing": { "$ref": "#/$defs/labelOrOff", "description": "The review loop's lease label — the tracker-global claim ai: working is for the implement loop, giving cross-clone mutual exclusion a checkout-local lock cannot. Opt-in: unlike the other labels it defaults to false (off), so a repo that never sets it keeps today's lock-only behaviour, and only a repo that sets it to a string (e.g. \"ai: reviewing\") gains the lease. A reviewing orphan always reclaims to reviewRequested (a review pushes no artifact, so there is no crash-before/after-push split), gated by the reconcile's assignee/age guard." }, "changesRequested": { "$ref": "#/$defs/labelOrOff" }, "needsHuman": { "$ref": "#/$defs/labelOrOff" }, "done": { "$ref": "#/$defs/labelOrOff" }, "blocked": { "$ref": "#/$defs/labelOrOff" }, "needsTriage": { "$ref": "#/$defs/labelOrOff", "description": "The repo's \"nobody has assessed this yet\" label (e.g. \"needs triage\"), which the work loops never write and only read. Naming it turns on the contradiction check: an issue carrying it alongside a lifecycle label (ready / changesRequested) claims both \"unassessed\" and \"approved for an agent\", so the implement queue withholds that issue and names it in the run's report rather than obeying the more permissive label — it is not worked, and it is not marked blocked, because nothing is wrong with the work. Opt-in: unlike the lifecycle labels it defaults to false (off), because it is a repo's own triage convention rather than a state the loop owns, and guessing a string would fire the check on a label the repo never meant." }, "repo": { "$ref": "#/$defs/labelOrOff" } } }, "review": { "type": "object", "additionalProperties": false, "description": "AI-review-gate options (work-review / work-review-queue). Absent → defaults.", "properties": { "maxRounds": { "type": "integer", "minimum": 1, "default": 3, "description": "Max AI-review rounds before the reviewer escalates to needsHuman instead of changesRequested. The round count is derived from the issue's `reviewRequested` label-transition events, not a stored counter. Default: 3." }, "timeout": { "type": "integer", "minimum": 1, "default": 600, "description": "Seconds bounding the review unit's wait for CI on a pull request it has marked ready for review (the draft gate). Measured from the later of the un-draft and the head's own commit, so on a round answering a CI failure — where the PR is already ready and nothing un-drafts — the budget starts at the push and is partly spent before the first poll, by the review's own worktree, install and verify. Size it against that wall-clock span, not against the pipeline's duration alone. Still running when it elapses → needsHuman, never an accepted verdict and never back into draft. Sized per repo because CI duration is: a repo whose pipeline outlasts this default escalates every issue without it. Deliberately not work.loop.*, which paces a drain between runs and which the units never read. Default: 600.", "$comment": "Mirrors release.timeout — seconds, bounding one wait rather than the run." } } }, "loop": { "type": "object", "additionalProperties": false, "description": "How a repeating driver (/loop, cron, a human) paces re-runs of a queue drain that ended in backpressure — nothing eligible right now, but the counterpart loop can still produce this loop's input. Read by work-implement-queue and work-review-queue only; the units never wait. A drain that ended on the cap is work remaining, not backpressure, and re-runs immediately without consulting these. `mode` says how a wait is paced, `wait` and `maxWait` how long it may last; none of them decides WHETHER to wait, which is the queue-state cascade's job. Deliberately not the single-flight lock's stale window, which is crash detection measured in hours: two jobs, two numbers.", "properties": { "mode": { "type": "string", "enum": ["fixed", "adaptive", "auto"], "default": "auto", "description": "How the backpressure wait is paced. \"fixed\" waits work.loop.wait every round — no lock read, no state, the portable floor. \"adaptive\" starts at work.loop.wait and multiplies it by 1.5 after each empty check, capped at work.loop.maxWait, resetting to the floor the moment anything is eligible: deliberately asymmetric, because work arrives in clusters. \"auto\" (the default) blocks on the counterpart's single-flight lock instead of estimating — its `refreshed` heartbeat moves once per counterpart iteration, so one Bash call polls that local file and returns exactly when there is something to query for, falling back to \"adaptive\" wherever that lock or its stamp is unreadable (another host, a drain between cap-ended runs). Only \"auto\" reads the counterpart's lock; none of the three changes what the drain does with the work it finds.", "$comment": "The default is the one that adapts, so a repo tuning nothing gets a wait sized to its own counterpart rather than to the repo this number was first measured on." }, "wait": { "type": "integer", "minimum": 1, "default": 120, "description": "Seconds to wait before re-checking the queue when a drain ends in backpressure. Its role follows work.loop.mode: the whole wait under \"fixed\", the floor and starting value under \"adaptive\", and the fallback wait under \"auto\" wherever the counterpart's lock or stamp cannot be read. Calibrated to one counterpart iteration, which runs in minutes (a fresh worktree installs its dependencies before the gate, and the model reads the issue and the diff before either), so the usual 60 s driver floor would poll 3-5x per counterpart issue and re-read the skill each time — a calibration measured on one repo, which is why the default mode adapts rather than trusting this number. Default: 120." }, "maxWait": { "type": "integer", "minimum": 1, "default": 600, "description": "Ceiling on a SINGLE backpressure wait, not a budget for the run's total waiting — a drain that is right to keep waiting keeps waiting, and what ends a wait that should end is the cascade (a frozen heartbeat, a frozen updatedAt, terminal states only), never this number. Unused under mode \"fixed\", where the wait never grows; the cap on the backoff under \"adaptive\"; the cap on one blocking wait under \"auto\", where it doubles as the minimum wake rate, since a human labelling an issue ai: ready produces no heartbeat event. Default: 600, because Claude Code truncates a Bash call there — lower it to make \"auto\" behave more like \"fixed\"." } } }, "priorityLabels": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true, "description": "GitHub priority labels, highest first (e.g. [\"urgent\", \"high\", \"medium\", \"low\"]). Linear uses its native priority field and ignores these. The local tracker reads this same ladder, matching an issue file's `priority` field against it, so the ordering is configured once for both label-shaped trackers." }, "local": { "type": "object", "additionalProperties": false, "description": "Local file-tracker options (used when tracker = local). Every key has a default, so unlike linear this section may be omitted entirely and the root's if/then requires nothing alongside tracker = local.", "properties": { "dir": { "type": "string", "minLength": 1, "description": "Repo-relative directory holding the issue files. Falls back to issue.local.dir, then to \".agents/issues\" — the same two-step fallback linear.team takes to issue.linear.team, so the directory is stated once for both skills." } } }, "linear": { "type": "object", "additionalProperties": false, "description": "Linear options (used when tracker = linear).", "properties": { "team": { "type": "string", "minLength": 1, "description": "Team name/key/id, resolved via the catalog cache. Falls back to issue.linear.team." }, "statuses": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true, "description": "Workflow states that count as startable (e.g. [\"Todo\", \"In Progress\"]). ANDed with the label filter, so every state an eligible issue can legitimately sit in has to appear here — the ones states.ready and states.changesRequested name, plus states.accepted, which is where a human hands an already-accepted issue back from. A state the mapping can produce but this list omits takes the issue out of the queue for good, silently. That much is required, and stays required however the board is written — a human moving a card writes this same field, with no automation to switch off. Covering what Linear's GitHub integration writes on pull-request events is a SEPARATE, optional widening, and worth it only where labels.ready is a real label: the label filter is what absorbs a foreign state landing INSIDE this list. Under labels.ready: false this list is the sole positive gate, so widening hands the queue to whatever the integration writes; there, silence that integration's pull-request state automation or restore a positive label gate instead. Omitting linear.states is not an answer to that: this key is separate, required, and read regardless." }, "states": { "type": "object", "additionalProperties": false, "description": "Linear workflow state names, written alongside the lifecycle label. State names are per-team and cannot be derived, so there is no default: an unmapped step leaves the workflow state untouched, and omitting the key drives the lifecycle by label alone. Never guess a state name. Every key but `done` is a step the work loop writes; `done` is the terminal shipped state, which the work loop deliberately never writes. Each state is a BEST-EFFORT write, not a state these skills maintain: Linear's own GitHub integration writes the same field on any event on the linked pull request, label-blind, and no reconcile sweep inspects a workflow state, let alone puts a drifted one back — so turn that integration's pull-request state automation off, keep statuses wide enough to cover what the integration writes (only where labels.ready is a real label), or leave this key unmapped. Turning that automation off removes the automated writer, not every one: a human moving a card writes this same field. Leaving this key unmapped settles what the BOARD claims and nothing about the queue — statuses is a separate, required key the selection query reads either way — so under labels.ready: false it has to be paired with silencing that automation.", "properties": { "ready": { "type": "string", "minLength": 1, "description": "State a human parks a startable issue in; the anchor for statuses. Read as documentation on the normal path — the one exception is the implement reconcile, which writes it when it flips a crashed pre-push lease back to ready." }, "working": { "type": "string", "minLength": 1, "description": "Set on the lease (ready to working), e.g. \"In Progress\"." }, "reviewRequested": { "type": "string", "minLength": 1, "description": "Set when the work is pushed and awaits AI review, e.g. \"In Review\"." }, "changesRequested": { "type": "string", "minLength": 1, "description": "Set when AI review requests changes and hands the issue back to the implement loop, e.g. \"Changes Requested\"." }, "needsHuman": { "type": "string", "minLength": 1, "description": "Set when the reviewer escalates to a human, e.g. \"Needs Human\"." }, "accepted": { "type": "string", "minLength": 1, "description": "Set when AI review accepts the work — the `done` lifecycle step, whose label (work.labels.done, \"ai: done\") means AI-accepted, not shipped. Deliberately NOT a terminal board column: the work sits on pr.base, unmerged and unreleased, so name the column that says so (\"Accepted\", \"Ready for release\"). Belongs in statuses too, because a human handing an accepted issue back relabels it without moving the state. Unmapped leaves the state untouched, which is the safe outcome — the wrong write is a Done the work has not earned." }, "done": { "type": "string", "minLength": 1, "description": "Terminal shipped state, e.g. \"Done\" — the change is on the repo's default branch. Never written by the work loop, which by its own contract stops at pr.base: written by Linear's GitHub integration where pr.base IS the default branch, and otherwise by the release skill when it merges the promotion edge whose base is the default branch. A repo that promotes by hand, with no release tool for that skill to drive, has no writer for this state and should leave it unmapped rather than let the board claim a ship that nothing observed." } } } } } } }, "profile": { "type": "object", "additionalProperties": false, "minProperties": 1, "description": "A partial config, deep-merged onto the base when its profile is selected. Sections are referenced by shape, not by their constrained form: a fragment such as {\"tracker\":\"linear\"} must be valid on its own, while the constraints (a linear tracker requiring team and labels) are satisfied by the merged result. Setting a section to false disables that skill for the profile.", "properties": { "forge": { "type": "string", "enum": ["github", "gitlab"], "default": "github", "description": "Code forge for the forge-bound skills, overlaid for this execution context. Same axis and same values as the root key." }, "forgeHost": { "type": ["string", "null"], "minLength": 1, "pattern": "^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]+)?$", "description": "The host this repo's forge lives on, overlaid for this execution context — a runner reaching a self-hosted instance by a different name than a local session does. Same shape as the root key." }, "language": { "$ref": "#/$defs/language", "description": "Shared default message/title/body language for all skills. Overridable per skill section." }, "verify": { "type": ["string", "null"], "minLength": 1, "description": "The command this repo runs to check itself (tests / lint / build) — a repo-level fact, shared by every skill that needs to know the repo still passes. work-implement runs it before pushing, work-review against the pushed head it is reviewing (establishing green rather than inheriting the implementer's), update-deps after updating, merge-deps against a dependency PR's own head (overridable via mergeDeps.verify). Write the check command only: a skill checking a head that is not the working tree installs that head's lockfile itself. null or absent → each skill detects it from the repo, and a command that cannot be detected is reported as unrun, never treated as green. It lives at the root, not inside a skill section, so disabling a skill never withdraws it." }, "trustedBots": { "$ref": "#/$defs/trustedBots", "description": "Allowlist of automated authors the third-party-reading skills may treat as authoritative, overlaid for this execution context. Replaces the base array wholesale (arrays are replaced, not concatenated, on profile merge)." }, "grillWith": { "$ref": "#/$defs/grillWith", "description": "The interview engine for this execution context — null or false where nobody is at the terminal to answer, so an unattended profile drafts directly instead of offering a pass that cannot be taken." }, "commit": { "description": "atomic-commit settings, or false to disable the atomic-commit skill for the repo.", "oneOf": [ { "$ref": "#/$defs/commitShape" }, { "const": false } ] }, "pr": { "description": "pull-request settings, or false to disable the pull-request skill for the repo.", "oneOf": [ { "$ref": "#/$defs/prShape" }, { "const": false } ] }, "release": { "description": "release skill settings, or false to disable the release skill for the repo. Drives release-please; the release tool itself is detected from the repo, never configured here.", "oneOf": [ { "$ref": "#/$defs/releaseShape" }, { "const": false } ] }, "mergeDeps": { "description": "merge-deps skill settings, or false to disable the merge-deps skill for the repo. Absent is not disabled — it means report-only, since mergeDeps.merge defaults to false. Named mergeDeps (not deps) to disambiguate against the separate update-deps skill: this section is only ever about merging the dependency-bot request queue. Scope is fixed to a single author: on GitHub the constant app/dependabot, deliberately not configurable; on GitLab the one identity mergeDeps.gitlab.bot names, since no constant exists there. Either way it is one author and never a selector — the key can only narrow.", "oneOf": [ { "$ref": "#/$defs/mergeDepsShape" }, { "const": false } ] }, "pruneBranches": { "description": "prune-branches skill settings, or false to disable the prune-branches skill for the repo. Absent is not disabled — it means the built-in defaults, since the skill deletes nothing without an explicit per-run confirmation. Scope is fixed to the integration branch's own remote, and the built-in protected set is fixed too; pruneBranches.protect only ever adds to it.", "oneOf": [ { "$ref": "#/$defs/pruneBranchesShape" }, { "const": false } ] }, "issue": { "description": "issue skill settings, or false to disable the issue skill for the repo.", "oneOf": [ { "$ref": "#/$defs/issueShape" }, { "const": false } ] }, "docs": { "description": "write-docs settings, or false to opt the repo out of docs entirely.", "oneOf": [ { "$ref": "#/$defs/docsShape" }, { "const": false } ] }, "work": { "description": "work-issue / work-queue settings, or false to disable both skills for the repo.", "oneOf": [ { "$ref": "#/$defs/workShape" }, { "const": false } ] } } } } }