GitHub issue conventions for this repository: imperative verb-first titles (or symptom-first for bugs) with aggressive inline-code markup and no trailing period; a 1–3-sentence lede that often opens with a provenance line (`Follow-up to #N.`, `Spun off from #N.`, `Part of #N.`); ordered section inventory (`## Why` / `## Background`, `## Current state` / `## Current behavior` / `## The bug` / `## Root cause` / `## Reproduction`, `## What we're building` / `## Scope` / `## Approach` / `## Proposed fix`, mandatory `## Acceptance criteria` GitHub task-list of observable outcomes ending in a test item, `## Out of scope`, `## References`, `## Notes`); archetypes for bug / feature / research-spike / follow-up-cleanup issues; the duplicate check (including pull requests, and reading the match total before the rows); labels; the mutually-exclusive `afk`/`hitl` execution-mode convention flipped as forks are decided, with `epic` containers exempt; epics and sub-issues; dependencies expressed ONLY as native GitHub dependency edges and never restated in the body; cross-repo `owner/repo#N` refs; linked absolute-branch file paths; and the standing rule to NEVER add a "generated by AI" disclaimer. Activate whenever drafting, filing, rewriting, or critiquing the title or body of a GitHub issue for this repository.
php artisan boost:add-skill robot-council/core
Save this content to: .claude/skills/writing-issues/SKILL.md
# Writing Issues
This skill captures the house style for GitHub issue titles and bodies. **It is one shared
document, carried identically by `robot-council/core` and `robot-council/cli`, so it names no
repository of its own** -- the recipes below take `{owner}` and `{repo}` from the checkout, and the
duplicate check reads the repository once into `$here`. Where a repository is named in this file it
is because a measurement was taken there, which is a fact rather than an example.
An issue is a brief for whoever picks it up — often an autonomous
agent. Write it so the work is unambiguous before anyone touches code: state the *why*, pin
down the *current* behavior, describe the *desired* outcome, and bound the scope explicitly.
The defining feature of a good issue here is a crisp, testable **Acceptance criteria**
checklist.
This is the sibling of [`writing-pull-requests`](../writing-pull-requests/SKILL.md) and
[`writing-commits`](../writing-commits/SKILL.md); the inline-code, file-link, bullet, and
em-dash conventions are shared — when in doubt, match those skills.
## Start from the archetype template
[`.github/ISSUE_TEMPLATE/`](../../../.github/ISSUE_TEMPLATE) holds a Markdown skeleton per
archetype — `1-bug.md`, `2-feature.md`, `3-research-spike.md`, `4-follow-up-cleanup.md`. A human
filing through the web UI gets one pre-filled; **you should start from the matching file too**,
so both paths produce the same shape. Copy it, fill it in, strip the front matter and the
`<!-- … -->` guidance, and pass the result as the body file, then apply the labels its front
matter names (a body file does not carry them). Where an issue doesn't fit an archetype, build
the body from the section inventory below.
**This skill is the source of truth; the templates carry only the skeleton and a pointer back
here.** When the house style changes, change it here first and reconcile the templates — never
the reverse, or the two silently diverge.
## Output format
When proposing an issue, give the **title** on its own line and present the **body as a single
fenced Markdown code block** so it can be pasted. If the body contains fenced blocks, open the
outer fence with more backticks than any inner one. The body is GitHub-Flavored Markdown — never
include an `H1` (`#`) heading; GitHub renders the title separately. Top-level sections are `##`,
sub-sections `###`.
When you create the issue yourself, **write the body to a file and pass it as a file from the
start** — never inline the Markdown as an argument. Issue bodies are dense with backticks, `$`,
`!`, backslashes, and fenced code blocks, every one of which the shell mangles or errors on as an
inline argument (the failure is intermittent, so an inline body looks fine until one finally trips
it). The REST form keeps working when the GraphQL quota behind `gh issue` is spent (see
[`github-api-budget`](../../rules/github-api-budget.md)):
**`{owner}` and `{repo}` are literal.** `gh` fills them from the checkout, so these act on the
repository you are in rather than one named here -- which matters because most of them are
**writes**. Keep the quotes: PowerShell parses an unquoted `{…}` as a script block and splits the
argument. The mechanism and its bounds are in
[`writing-pull-requests`](../writing-pull-requests/SKILL.md).
```bash
gh api -X POST 'repos/{owner}/{repo}/issues' -f title='…' -F [email protected] -f 'labels[]=<existing-label>'
gh api -X PATCH 'repos/{owner}/{repo}/issues/<n>' -F [email protected]
```
`gh issue create --body-file body.md` and `gh issue edit <n> --body-file body.md` are the
equivalent wrapper forms.
## The disclaimer rule (non-negotiable)
**Never** add a `> *This was generated by AI during triage.*` blockquote (or any equivalent
"written/generated by AI" line) to an issue body, and strip one when rewriting an issue.
## Titles
The title is an imperative, verb-first phrase naming the deliverable or the outcome — what the
issue *accomplishes*, not a topic label. No trailing period. Keep it specific and scannable.
The examples below are illustrative.
- **Default form — imperative outcome:** `Publish the council migration under a timestamped filename`,
`Validate `robot-council.members` entries on package boot`, `Add a `--dry-run` flag to the `robot-council` command`.
- **Bugs may lead with the symptom** instead of an imperative, naming the faulty component and
the user-visible consequence: `RobotCouncil::members() returns null (not []) when no members are configured`.
- **Disambiguating parenthetical** — append the mechanism in parens when it sharpens the title:
`(not [])`, `(string "1" vs boolean)`.
- **Inline code in titles** — backtick anything a developer would type or grep: commands
(`robot-council`), config keys (`robot-council.members`), classes and methods.
- **No Conventional-Commit prefixes.** Never prefix an issue title with `feat:` / `fix:` /
`chore:` etc. — those belong on commits, not issues. Strip the prefix when rewriting and fold
the verb into the imperative phrase (`fix: members null` → `Fix RobotCouncil::members() returning null`).
- **Research spike:** prefix with `Research:` — `Research: decide how council members are persisted`.
## Body: the standard skeleton
Most issues follow this spine. Drop the sections that don't apply; never drop **Acceptance
criteria**.
1. **Lede** — 1–3 sentences. Frame the problem and the desired outcome. Lead with the
user-visible effect or root cause, not the implementation. The lede frequently opens with a
**provenance line**: `Follow-up to #N.`, `Spun off from #N.`, `Part of #N.`,
`Discovered while … for #N.` A provenance line says where the issue *came from*; it never
states a dependency — see *Dependencies belong in the edges*.
2. **Context** — `## Why` (motivation, often the strongest section on a feature) and/or the
current-state sections below.
3. **Desired outcome / plan** — `## What we're building`, `## Scope`, `## Approach`,
`## Proposed fix`.
4. **`## Acceptance criteria`** — mandatory. A GitHub task-list of observable outcomes.
5. **`## Out of scope`** — explicit non-goals, each pointing at its tracking issue.
6. **`## References`** — links, file paths, sibling tickets.
7. **No relationship line.** Dependencies live in GitHub's native dependency edges
(`blocked_by` / `blocking`) and are **never** restated in the body.
## Section inventory
Use the section that fits; these are the established names (don't invent synonyms casually):
- **`## Why`** — why this matters now. On features this carries the weight: explain the forces
that make the work necessary.
- **`## Background`** — domain context the reader needs before the rest makes sense.
- **`## Current state`** / **`## Current behavior`** — what exists today, with file, class, and
config-key references. The factual baseline the work changes.
- **`## The bug`** / **`## Root cause`** / **`## Reproduction`** — bug issues only. `## The
bug` / `## Root cause` walk the mechanism (quote the offending code in a fenced block);
`## Reproduction` gives the concrete trigger and the observed error.
- **`## Desired behavior`** — the target contract, stated as behavior not implementation.
- **`## What we're building`** / **`## Target behavior`** — the deliverable for features (tables
are welcome for concrete mappings).
- **`## Scope`** — a bounded list of the changes the issue covers (common on follow-up
cleanups).
- **`## Approach`** / **`## Proposed approach`** / **`## Chosen approach`** / **`## Proposed
fix`** / **`## Fix`** — the recommended path. Name the design decision and why; include
before/after code where it removes ambiguity. For a fix already made, `## Fix` describes what
was done.
- **`## Questions to resolve`** — research spikes: the numbered open questions the spike must
answer.
- **`## Acceptance criteria`** — see below. Always present.
- **`## Out of scope`** — first-class. Each deferred item links the issue that owns it (or says
"file a follow-up").
- **`## References`** — bullet list: source files (linked), related tickets, external docs/URLs.
- **`## Notes`** — caveats, edge cases, "worth a follow-up" asides.
- **`## Test plan`** / **`## Files`** — optional, used when the issue doubles as the working
record of an in-flight change (mirrors the PR conventions; `[x]` for already-done).
## Acceptance criteria — the core of every issue
This is the section that makes an issue actionable. Conventions:
- A GitHub **task list** (`- [ ]`), one box per independently verifiable outcome. Use `- [x]`
only when the issue records work already completed.
- Each item is an **observable outcome**, not an implementation step. "Running `robot-council`
with no members configured exits `0` and prints the empty-council message" — not "add a
default to the config lookup".
- Be concrete: name the command, the config key, the class, the output, the exception that must
no longer throw.
- **Bound the behavior on both sides** — assert the new behavior *and* assert the unaffected
cases still hold ("an absent key yields `[]`; a configured list is returned unchanged").
- **End with a test/coverage item.** Nearly every issue closes its AC list with a regression or
feature-test requirement ("a Pest test covers the absent-key and configured-list cases").
- For research spikes, the AC are **decisions documented**, not code ("a persistence approach is
chosen and recorded with its trade-offs", "a clear go / no-go recommendation is recorded").
## Relationships and cross-references
### Dependencies belong in the edges, never in the body
**Never write `Blocked by #N.` or `Blocks #N` into an issue body.** Express a dependency
*only* as a native GitHub dependency edge (`dependencies/blocked_by`, `dependencies/blocking`);
the REST calls are in [`github-api-budget`](../../rules/github-api-budget.md).
**Why.** Prose and edges drift apart, and the prose is the half that fails silently — the edge
is machine-maintained, the sentence is not. The recurring shapes: a body naming a **closed**
ticket as its gate while the real edge points elsewhere; a list of blockers half of which have
shipped; a claim that a blocker in another repository "has no edge to hang it on", which is not
true, because edges work across repositories. In each, the edge was right and the sentence was
wrong.
There is a second, sharper failure the prose form invites: **a bare `#N` resolves against the
current repository**, so a reference copied from another repository's tracker silently points at
an unrelated issue here. An edge stores a repository-qualified object id and cannot make that
mistake.
**The carve-out — context cross-references stay.** What is banned is the *dependency claim*,
not the reference. These remain welcome, because the graph does not track them and they cannot
fall out of sync with it:
- **Provenance** — `Follow-up to #N.`, `Spun off from #N.`, `Part of #N.`
- **Inline cross-refs** — `tracked in #N`, `tracked separately in #N`, `see #N / #M`.
- **Naming what something is** — "the contract this codes against is `#N`".
Where a body needs to explain *why* something waits, describe the **condition** — what has to be
true, what artifact must exist — rather than naming a ticket as the gate.
**If the prose is right and the graph is not, add the missing edge.** Do not keep the sentence
as a substitute for the edge it is describing.
### Reference forms
- **Cross-repo refs** — `owner/repo#N`, either bare or as a backticked link where it reads better
(`` [`owner/repo#N`](https://github.com/owner/repo/issues/N) ``). Qualify **every** cross-repo
reference: a bare `#N` silently resolves against the repository the body lives in.
- **In `## Out of scope` / `## References` bullets** — end the bullet with `— tracked in #N.`
so the residual work visibly has a home.
## Inline code, links, bullets
Shared with `writing-pull-requests` — the short version:
- **Backtick aggressively**: file paths, class/method names, config keys
(`robot-council.members`), commands (`vendor/bin/pest`), env vars, version arrows
(`` `13.31.0` → `13.32.0` ``).
- **File paths are linked.** The target is the **absolute branch URL**
(`` [`<path>`](https://github.com/<owner>/<repo>/blob/<branch>/<path>) ``,
`/tree/` for directories), exactly as the PR skill specifies; vendor refs may include line
anchors (`…/PackageServiceProvider.php#L20-L35`).
- **Fenced code blocks** (`php`, `yaml`, `bash`) quote the offending or proposed code in bug
issues, and config excerpts elsewhere. Show before/after as two labelled blocks or
inline-commented code, not a `diff` block.
- **Bullets are `-` only**, nested with a 2-space indent. **Bold-tag lead-ins**
(`- **Per-key default** — …`) categorize list items. **Em-dash `—`** separates a tag from its
prose. **Don't force-wrap** prose.
- **Tables** for naturally tabular data — config-key inventories, version matrices.
## Labels
Apply the labels that match the issue's nature; multiple are normal. **The label set is curated
by hand, so re-list it before labeling rather than trusting the summary below:**
```bash
gh api 'repos/{owner}/{repo}/labels?per_page=100' --jq '.[] | "\(.name)\t\(.description)"'
```
Apply only labels that exist, choosing by each label's description. If an issue needs a label
that does not exist, name it in your proposal and ask before creating it; never create labels as
a side effect of filing. There is **no `bug` label**: a bug is `development` with a symptom-first
title and, where available, the `Bug` issue type.
- **`development`** — code work in the package; the default, carried by most issues.
- **`build`** — Composer, PHPStan, Pint, Pest configuration, GitHub Actions.
- **`dependencies`** — applied by Dependabot to its update pull requests; not for hand-filed issues.
- **`documentation`** — README and other prose.
- **`security`** — security-sensitive work. An exploitable vulnerability does not go in a public
issue at all: report it privately per [`security-audit`](../security-audit/SKILL.md).
- **`ui`** — the package's published views or other user-visible output.
- **`research`** — a spike whose deliverable is a documented decision, not code.
- **`QA`** — verify expected behavior, usually paired with the build ticket it validates.
- **`upstream`** — a report or request to file against a dependency, per
[`filing-defects-across-repos`](../../rules/filing-defects-across-repos.md).
- **`test-flake`** — an intermittent failure that passes in isolation; not a real defect.
- **`browser-tests`** — browser-driven test work (the Pest browser plugin is not installed yet).
- **`quiet-machine`** — timing or measurement work that needs no concurrent runs, per
[`measurement-parity`](../../rules/measurement-parity.md).
- **`macos-environment`** · **`windows-environment`** — must be completed on that platform.
- **`handed-back`** — pull requests only: returned to the author by
[`pre-merge-check`](../../rules/pre-merge-check.md) with a finding outstanding.
- **`afk`** · **`hitl`** · **`epic`** — execution mode, below. A `hitl` issue also carries its
flavor: **`decision-fork`** (a design decision) or **`action-flavor`** (a real-world action).
### Priority — `fleet-facing` / `housekeeping` (exactly one)
Every open issue also carries **exactly one** of these, and it decides the order work is taken in: **`fleet-facing` comes first, `housekeeping` last.**
- **`fleet-facing`** — changes what an agent or operator on the fleet experiences: the service's API and behavior, the bridge and its follower, enrollment and credential storage, the tool list a harness sees, the messages a session reads, and the README steps a fleet user follows. A measurement that decides whether one of those works on a platform counts too.
- **`housekeeping`** — repository upkeep with no direct effect on the fleet: CI wiring, the release-notes generator, skills and rules, a test flake, or a measurement of code that has already been replaced.
**Classify by who notices if it never ships.** If only people working in the repository would, it is `housekeeping`, even when it changes shipped code or carries `security`. A cleanup of the service's API is `fleet-facing`, because the deployed bridge talks to it. An `epic` carries the label its slices mostly share. Still file `housekeeping` residue per [`closing-a-ticket`](../../rules/closing-a-ticket.md); the label lowers its priority, not its right to a ticket.
### Issue types
The `robot-council` organization enables the `Task`, `Bug`, and `Feature` issue types. Set exactly
one on every issue: `Bug` for a bug, `Feature` for a feature, and `Task` for a research spike, a
decision fork, a follow-up cleanup, or an epic. The templates declare the matching `type:` for
issues filed through the web UI. When filing through REST, pass the type's name:
```bash
gh api -X POST 'repos/{owner}/{repo}/issues' -f title='…' -F [email protected] -f type=Feature
```
**Read the type back after setting it.** GitHub's REST description says a type set without push
access is silently dropped, so the call succeeds either way:
`gh api 'repos/{owner}/{repo}/issues/<n>' --jq '.type.name'`.
### Execution mode — `afk` / `hitl` (exactly one — except on an `epic`)
Every open issue carries **exactly one** of `afk` / `hitl`. It tells whoever triages the tracker what *kind* of attention the issue needs to be *completed*: an autonomous coding agent, or a human.
**The exception is `epic`.** An issue labeled `epic` is exempt: it is a container whose slices each carry their own execution mode, not a unit of work anyone picks up and builds. An epic with neither label is **correctly labeled** — don't "fix" it. Apply `hitl` to an epic only where the container *itself* waits on a human.
- **`afk`** (away-from-keyboard) — buildable autonomously: the design is **pinned or already decided**, or the fix is **fully specified**, so there is **no genuine design fork to resolve in the issue's own scope**. Most build slices, dependency bumps, refactors, and already-specified bug fixes are `afk`. The test: *could a competent agent finish this alone, without a human deciding anything or doing anything outside the repo?* If yes, `afk`.
- **`hitl`** (human-in-the-loop) — completing it **requires a human**, for either reason: a human **decision** — an unresolved design fork (the issue carries a `## Questions to resolve` / `## Decisions to resolve` section), a policy / cost / go-no-go call, or a settled decision being re-opened; or a real-world **action** — a step outside the repo a coding agent can't perform (provisioning or credentialing, creating an external account, installing an app, publishing somewhere that needs a human's login). If a human must **decide or act** before the issue can close, `hitl`. There are only these two modes; fold real-world actions into `hitl`.
For the **decision** flavor, surface the fork the right way — lead with a compare/contrast packet, *then* the interactive prompt — following the [`design-decision-forks`](../../rules/design-decision-forks.md) rule (don't restate it here).
**A decision-fork ticket delivers two things and nothing else: the decision, recorded, and the follow-up tickets it produces.** Build work the decision unblocks goes into those follow-ups, blocked by the fork through native dependency edges. Its acceptance criteria describe *a decision being recorded and its follow-ups existing*, never a build being done — a criterion naming a file to change belongs on a follow-up.
**How to classify** — a **leaf** issue takes the mode of what *its own* completion needs; don't inherit a blocker's mode (blockers carry their own labels). An **`epic`** is exempt — classify its slices, not the container. When genuinely on the fence, prefer `hitl`: it's safer to route an ambiguous issue to a human than to let an agent build past an unmade decision.
**Keep the label current — flip it when the classification changes.** Flip `hitl` → `afk` once the fork is **decided and recorded** on the issue — relabel (dropping `decision-fork` too) and reconcile the body in the *same change* (retire the `## Questions to resolve` / `## Decisions to resolve` section, tick the decision-recorded acceptance criterion). What remains on a fork ticket after the flip is filing its follow-ups, not building them. Flip `afk` → `hitl` the moment work **surfaces a genuine design fork** an agent shouldn't decide alone — relabel (adding `decision-fork`), add a `## Decisions to resolve` section, and settle it with the user (per [`design-decision-forks`](../../rules/design-decision-forks.md)) before building.
## Epics and sub-issues
An **`epic`** is a container. It holds the shared context a body of work rests on and **carries no deliverables of its own** — no acceptance criteria, no "decisions taken here", nothing anyone could pick up and build. Every deliverable is a sub-issue. If a reader cannot tell what to *do* from a slice alone, the slice is under-written, not the epic.
That constraint is what makes an epic useful. A container with its own checklist competes with its slices, and the work ends up recorded in two places that drift.
**When a ticket becomes an epic.** Convert one when it has accumulated several independently shippable deliverables — usually visible as an acceptance-criteria list whose items would each make a reasonable ticket. Two shapes recur:
- A **decision plus its build.** Spin the decision into its own gating slice rather than leaving it at the head of the container, so the fork is something a human can be handed and the epic is not itself `hitl`.
- **Measurement, decision, then build.** File the measuring slices even when they are already done — see below.
**File retroactive sub-issues for work already finished.** When converting a ticket that has already produced results, give each completed piece its own sub-issue, record what it found in the body, and close it as `completed`. It costs a minute and it gives the finding a home: an epic body is a poor place for a measurement, because the next edit overwrites it.
**Labels on a container.** An `epic` takes the topic labels that describe its subject and **not** `afk` / `hitl`, nor labels that describe how its slices will be worked — those belong on the slices that earn them.
**Wiring, and it is not the same thing as a dependency.** A sub-issue says *this is part of that*; a `blocked_by` edge says *this cannot start until that finishes*. Most epics need both, and they are set independently:
```bash
id=$(gh api 'repos/{owner}/{repo}/issues/<child>' --jq '.id') # numeric .id, NOT the issue number
gh api -X POST 'repos/{owner}/{repo}/issues/<parent>/sub_issues' -F sub_issue_id=$id
```
**Sub-issues work across repositories.** The endpoint keys on the global database id, so a slice living in another repository attaches to an epic here exactly like a local one; listing it only in prose loses it. Read the set back with `GET …/sub_issues`, and see [`github-api-budget`](../../rules/github-api-budget.md) for why these are REST calls.
## Check for an existing issue first
Before creating any issue, search the tracker for one that already covers the same **concept** —
match on the idea, not just the title, since an existing issue may be worded completely differently.
Run a few searches with varied terms and **include closed issues** (a fixed or won't-fix issue still
means *don't* refile):
```bash
# One block, because the control and the questions have to be the same instrument. The repository
# is resolved from the checkout once; the terms go in `-f` FIELDS rather than into the URL, so `gh`
# encodes them. `-X GET` because `gh api` sends POST as soon as a field is present, and `timeout`
# because the failure mode here is a hang rather than an error -- both explained below.
here=$(gh api 'repos/{owner}/{repo}' --jq .full_name)
for q in "robot-council" "<symptom or feature>" "<affected file / class / config key>" "<class of problem or label>"; do
printf '%s -> ' "$q"
timeout 25 gh api -X GET search/issues -f q="repo:$here $q" -f per_page=100 \
--jq '"matches: \(.total_count) (showing \(.items|length)) from: \(.items[0].repository_url // "-" | sub(".*/repos/"; ""))",
(.items[] | " \(if .pull_request then "PR " else "iss" end) #\(.number) \(.title)")' \
|| echo "NO ANSWER (rc=$?) -- not a clean check"
done
echo "checkout: $here <- every from: above must equal this"
```
**A search term written into the URL with a space in it HANGS**, which is why the terms are passed
as fields. Measured on 2026-09-24 with `gh` 2.92.0: `gh api "search/issues?q=repo:…+two words"`
never returns. `--verbose` shows why -- it sends `GET /search/issues?q=…+two words&per_page=1
HTTP/1.1`, a request line with a raw space in it, and then waits for a reply that never comes. One
such call ran **22 minutes** before it was killed by hand. It is not the search endpoint's fault
and not `q`'s: the same raw space anywhere in a query string does it, measured with
`repos/{owner}/{repo}/issues?labels=a b`. A space in the **path** is harmless by contrast --
`repos/{owner}/{repo}/iss ues` answers `404` promptly. The hazard is that the placeholders above
are phrases, so following the recipe literally is what triggers it.
**`-f` costs the placeholders, and that is the reason `$here` exists.** `gh` substitutes `{owner}`
and `{repo}` in a **path**, and in a field whose value is *exactly* the placeholder -- which is what
`writing-pull-requests`'s GraphQL recipe relies on with `-F owner='{owner}'`. It does **not**
substitute them inside a longer field value: measured, `-f q='repo:{owner}/{repo} term'` sends the
braces literally and GitHub answers `Validation Failed`. So the repository is read once through the
path form and interpolated as text.
**Read the `matches:` line before the rows.** `search/issues` returns one page, so a query broader than the page size is answered with a silent prefix, and the truncated output is identical in shape to a query that genuinely found everything. In `UAMS-Web/uams-statamic` (measured 2026-09-06) a broad query reported hundreds of matches while a narrow one reported single digits, and nothing but the total distinguished the truncated page from the complete one.
**A total greater than the rows shown means the check has not answered the question.** Either paginate (add `-f page=2`, until a short page) or narrow the query until the total fits — "nothing matches" is not a conclusion you can draw from a truncated page. **`incomplete_results` is not that signal**: it reports a server-side timeout, not pagination, and it is `false` on truncated responses.
**`gh issue list` and `gh search issues` exclude pull requests; the REST search endpoint above does not.** That matters because a fix often ships as a pull request with no issue behind it, so the wrapper hides exactly the in-flight work the check exists to find — in `UAMS-Web/uams-statamic` (2026-09-05) a wrapper search returned the issue and never the pull request implementing it. The `.pull_request` key is what distinguishes the two kinds, hence the `PR`/`iss` prefix. The endpoint cannot phrase-match and it indexes comments; see [`github-api-budget`](../../rules/github-api-budget.md).
**Everything above guards against a query that is too NARROW. None of it guards against a query that did not RUN.** Those produce the same output, and the second one is the easier mistake: a malformed `--jq` expression prints its parse error to stderr, `gh` exits non-zero, and a pipeline ending in `|| echo "(none)"` prints exactly what a genuinely clean check prints. Observed while filing against `robot-council/cli` on 2026-09-21, where the check read:
```
=== duplicate check ===
(none)
```
Two issues were filed on the strength of that line. Re-run afterwards with a working parser the check was genuinely clean — so no duplicate was filed, but that was luck rather than method, and the same output would have appeared had there been ten.
**So show the query finding something, in the same invocation, before reading it finding nothing.** A term certain to appear in the repository is enough, and it costs one call.
**A count is not enough on its own, and the control has to name the repository that answered.** A term common enough to be a reliable control is common enough to match in *either* repository, so a healthy total confirms the instrument while saying nothing about which tracker was asked — and a duplicate check aimed at the wrong one reads exactly like a clean one. Measured from a `robot-council/core` checkout on 2026-09-24: the term `robot-council` returned **197** matches aimed at this repository and **126** aimed at the other. Both look fine. `repository_url` off the first item is what tells them apart.
That is why `robot-council` is the **first** term in the block above rather than a separate
invocation: the control and the questions then share one endpoint, one `--jq`, one quoting and one
bound, so nothing can differ between them except the term.
Read the control's total and its `from:` first. If the total is `0`, if the line is missing entirely, or if `from:` is not this checkout, stop — nothing below it is evidence. This is [`an-empty-result-is-not-evidence`](../../rules/an-empty-result-is-not-evidence.md) applied to the duplicate check; that rule owns the general form and the reasoning, and is not restated here.
**`from:` reads `-` when a query matches nothing**, which is the honest answer and not a failure: there is no item to take a repository from. That is why the total is read first — the two lines answer different questions.
Vary the terms across the symptom, the affected file/class/config key, and the class of problem —
one query rarely surfaces a differently-worded duplicate. **The `repo:` qualifier above is `$here`,
read from the checkout**, so these search the repository you are in; for work that spans
repositories, substitute the other one for `$here` in a further query and expect its `from:` to name
that repository rather than this one. If anything plausibly covers the concept:
**link it and skip**, or add a comment / sharpen the existing issue — don't open a duplicate. Only
file once you've confirmed nothing matches.
## Archetypes at a glance
Pick the skeleton that matches the work:
- **Bug fix** → lede (with discovery provenance) · `## The bug` or `## Root cause` (offending
code) · `## Reproduction` · `## Proposed fix` (fix code) · `## Acceptance criteria` (ends in a
regression test) · `## References`.
- **Feature** → lede · `## Why` · `## What we're building` (+ behavior detail / tables) ·
`## Acceptance criteria` · `## Out of scope` · `## References`.
- **Research spike** → `Research:`-prefixed title · lede ("**Research spike.** … deliverable is
a documented decision, not code") · `## Why this is a spike` · `## Questions to resolve` ·
`## Acceptance criteria` (decisions + go/no-go) · `## References`.
- **Follow-up cleanup** → `Follow-up to #N.` lede · `## Scope` · short `## Acceptance criteria`.
`Follow-up to #N.` is provenance and stays; any dependency on `#N` is an edge.
## Tone and voice
- Technical, prose-driven, specific. Lead with *why*, then *what*. Concrete file paths, config
keys, commands, URLs, error strings over generalities.
- Bound scope deliberately — `## Out of scope` is part of writing a good issue, not an
afterthought.
- No exclamation marks. Use `*emphasis*` sparingly, only on the word that carries the sentence.
- **Impersonal voice throughout** — every artifact posts under one account, so no `I`/`my`/`you`/`your`, and no vouching stance. See [`impersonal-voice-in-github-artifacts`](../../rules/impersonal-voice-in-github-artifacts.md).
- **No emoji** — not in the title, the body, or any comment. Use words: a bolded clause says what a
glyph was standing in for, and a glyph breaks downstream where nothing can report it. See
[`no-emoji-in-durable-records`](../../rules/no-emoji-in-durable-records.md).
## Example shape
A compact bug issue demonstrating the core conventions. Illustrative only: `members()` and the
config key are invented to show the shape.
**Title:** `RobotCouncil::members() returns null (not []) when no members are configured`
`````markdown
````
Discovered while writing feature tests for the `robot-council` command. `RobotCouncil\RobotCouncil::members()` declares an `array` return type, but when `robot-council.members` is absent from config it returns `null`, so PHP throws a `TypeError` and the command fails before printing anything.
## Root cause
[`src/RobotCouncil.php`](https://github.com/robot-council/core/blob/main/src/RobotCouncil.php) -- **the package's file, named because the example is its**; the application has no `src/`:
```php
public function members(): array
{
return config('robot-council.members'); // null when the key is absent
}
```
## Proposed fix
Default the lookup to an empty array so the signature holds:
```php
return config('robot-council.members', []);
```
## Acceptance criteria
- [ ] `RobotCouncil::members()` returns `[]` when `robot-council.members` is absent.
- [ ] `RobotCouncil::members()` still returns the configured list unchanged when the key is present.
- [ ] A Pest test covers the absent-key and configured-list cases.
````
`````
(Lede opens with discovery provenance and leads with the user-visible failure. `## Root cause`
quotes the offending code; `## Proposed fix` shows the one-line change. Acceptance criteria are
observable outcomes that bound both the fixed and the unaffected behavior and close with a
regression test. No AI disclaimer. File path links to the absolute branch URL.)
This skill captures the house style for GitHub issue titles and bodies. It is one shared
document, carried identically by robot-council/core and robot-council/cli, so it names no
repository of its own -- the recipes below take {owner} and {repo} from the checkout, and the
duplicate check reads the repository once into $here. Where a repository is named in this file it
is because a measurement was taken there, which is a fact rather than an example.
An issue is a brief for whoever picks it up — often an autonomous agent. Write it so the work is unambiguous before anyone touches code: state the why, pin down the current behavior, describe the desired outcome, and bound the scope explicitly. The defining feature of a good issue here is a crisp, testable Acceptance criteria checklist.
This is the sibling of writing-pull-requests and
writing-commits; the inline-code, file-link, bullet, and
em-dash conventions are shared — when in doubt, match those skills.
.github/ISSUE_TEMPLATE/ holds a Markdown skeleton per
archetype — 1-bug.md, 2-feature.md, 3-research-spike.md, 4-follow-up-cleanup.md. A human
filing through the web UI gets one pre-filled; you should start from the matching file too,
so both paths produce the same shape. Copy it, fill it in, strip the front matter and the
<!-- … --> guidance, and pass the result as the body file, then apply the labels its front
matter names (a body file does not carry them). Where an issue doesn't fit an archetype, build
the body from the section inventory below.
This skill is the source of truth; the templates carry only the skeleton and a pointer back here. When the house style changes, change it here first and reconcile the templates — never the reverse, or the two silently diverge.
When proposing an issue, give the title on its own line and present the body as a single
fenced Markdown code block so it can be pasted. If the body contains fenced blocks, open the
outer fence with more backticks than any inner one. The body is GitHub-Flavored Markdown — never
include an H1 (#) heading; GitHub renders the title separately. Top-level sections are ##,
sub-sections ###.
When you create the issue yourself, write the body to a file and pass it as a file from the
start — never inline the Markdown as an argument. Issue bodies are dense with backticks, $,
!, backslashes, and fenced code blocks, every one of which the shell mangles or errors on as an
inline argument (the failure is intermittent, so an inline body looks fine until one finally trips
it). The REST form keeps working when the GraphQL quota behind gh issue is spent (see
github-api-budget):
{owner} and {repo} are literal. gh fills them from the checkout, so these act on the
repository you are in rather than one named here -- which matters because most of them are
writes. Keep the quotes: PowerShell parses an unquoted {…} as a script block and splits the
argument. The mechanism and its bounds are in
writing-pull-requests.
gh api -X POST 'repos/{owner}/{repo}/issues' -f title='…' -F [email protected] -f 'labels[]=<existing-label>'
gh api -X PATCH 'repos/{owner}/{repo}/issues/<n>' -F [email protected]
gh issue create --body-file body.md and gh issue edit <n> --body-file body.md are the
equivalent wrapper forms.
Never add a > *This was generated by AI during triage.* blockquote (or any equivalent
"written/generated by AI" line) to an issue body, and strip one when rewriting an issue.
The title is an imperative, verb-first phrase naming the deliverable or the outcome — what the issue accomplishes, not a topic label. No trailing period. Keep it specific and scannable. The examples below are illustrative.
Publish the council migration under a timestamped filename,
Validate robot-council.members entries on package boot, Add a --dry-runflag to therobot-council command.RobotCouncil::members() returns null (not []) when no members are configured.(not []), (string "1" vs boolean).robot-council), config keys (robot-council.members), classes and methods.feat: / fix: /
chore: etc. — those belong on commits, not issues. Strip the prefix when rewriting and fold
the verb into the imperative phrase (fix: members null → Fix RobotCouncil::members() returning null).Research: — Research: decide how council members are persisted.Most issues follow this spine. Drop the sections that don't apply; never drop Acceptance criteria.
Follow-up to #N., Spun off from #N., Part of #N.,
Discovered while … for #N. A provenance line says where the issue came from; it never
states a dependency — see Dependencies belong in the edges.## Why (motivation, often the strongest section on a feature) and/or the
current-state sections below.## What we're building, ## Scope, ## Approach,
## Proposed fix.## Acceptance criteria — mandatory. A GitHub task-list of observable outcomes.## Out of scope — explicit non-goals, each pointing at its tracking issue.## References — links, file paths, sibling tickets.blocked_by / blocking) and are never restated in the body.Use the section that fits; these are the established names (don't invent synonyms casually):
## Why — why this matters now. On features this carries the weight: explain the forces
that make the work necessary.## Background — domain context the reader needs before the rest makes sense.## Current state / ## Current behavior — what exists today, with file, class, and
config-key references. The factual baseline the work changes.## The bug / ## Root cause / ## Reproduction — bug issues only. ## The bug / ## Root cause walk the mechanism (quote the offending code in a fenced block);
## Reproduction gives the concrete trigger and the observed error.## Desired behavior — the target contract, stated as behavior not implementation.## What we're building / ## Target behavior — the deliverable for features (tables
are welcome for concrete mappings).## Scope — a bounded list of the changes the issue covers (common on follow-up
cleanups).## Approach / ## Proposed approach / ## Chosen approach / ## Proposed fix / ## Fix — the recommended path. Name the design decision and why; include
before/after code where it removes ambiguity. For a fix already made, ## Fix describes what
was done.## Questions to resolve — research spikes: the numbered open questions the spike must
answer.## Acceptance criteria — see below. Always present.## Out of scope — first-class. Each deferred item links the issue that owns it (or says
"file a follow-up").## References — bullet list: source files (linked), related tickets, external docs/URLs.## Notes — caveats, edge cases, "worth a follow-up" asides.## Test plan / ## Files — optional, used when the issue doubles as the working
record of an in-flight change (mirrors the PR conventions; [x] for already-done).This is the section that makes an issue actionable. Conventions:
- [ ]), one box per independently verifiable outcome. Use - [x]
only when the issue records work already completed.robot-council
with no members configured exits 0 and prints the empty-council message" — not "add a
default to the config lookup".[]; a configured list is returned unchanged").Never write Blocked by #N. or Blocks #N into an issue body. Express a dependency
only as a native GitHub dependency edge (dependencies/blocked_by, dependencies/blocking);
the REST calls are in github-api-budget.
Why. Prose and edges drift apart, and the prose is the half that fails silently — the edge is machine-maintained, the sentence is not. The recurring shapes: a body naming a closed ticket as its gate while the real edge points elsewhere; a list of blockers half of which have shipped; a claim that a blocker in another repository "has no edge to hang it on", which is not true, because edges work across repositories. In each, the edge was right and the sentence was wrong.
There is a second, sharper failure the prose form invites: a bare #N resolves against the
current repository, so a reference copied from another repository's tracker silently points at
an unrelated issue here. An edge stores a repository-qualified object id and cannot make that
mistake.
The carve-out — context cross-references stay. What is banned is the dependency claim, not the reference. These remain welcome, because the graph does not track them and they cannot fall out of sync with it:
Follow-up to #N., Spun off from #N., Part of #N.tracked in #N, tracked separately in #N, see #N / #M.#N".Where a body needs to explain why something waits, describe the condition — what has to be true, what artifact must exist — rather than naming a ticket as the gate.
If the prose is right and the graph is not, add the missing edge. Do not keep the sentence as a substitute for the edge it is describing.
owner/repo#N, either bare or as a backticked link where it reads better
([`owner/repo#N`](https://github.com/owner/repo/issues/N)). Qualify every cross-repo
reference: a bare #N silently resolves against the repository the body lives in.## Out of scope / ## References bullets — end the bullet with — tracked in #N.
so the residual work visibly has a home.Shared with writing-pull-requests — the short version:
robot-council.members), commands (vendor/bin/pest), env vars, version arrows
(`13.31.0` → `13.32.0`).[`<path>`](https://github.com/<owner>/<repo>/blob/<branch>/<path>),
/tree/ for directories), exactly as the PR skill specifies; vendor refs may include line
anchors (…/PackageServiceProvider.php#L20-L35).php, yaml, bash) quote the offending or proposed code in bug
issues, and config excerpts elsewhere. Show before/after as two labelled blocks or
inline-commented code, not a diff block.- only, nested with a 2-space indent. Bold-tag lead-ins
(- **Per-key default** — …) categorize list items. Em-dash — separates a tag from its
prose. Don't force-wrap prose.Apply the labels that match the issue's nature; multiple are normal. The label set is curated by hand, so re-list it before labeling rather than trusting the summary below:
gh api 'repos/{owner}/{repo}/labels?per_page=100' --jq '.[] | "\(.name)\t\(.description)"'
Apply only labels that exist, choosing by each label's description. If an issue needs a label
that does not exist, name it in your proposal and ask before creating it; never create labels as
a side effect of filing. There is no bug label: a bug is development with a symptom-first
title and, where available, the Bug issue type.
development — code work in the package; the default, carried by most issues.build — Composer, PHPStan, Pint, Pest configuration, GitHub Actions.dependencies — applied by Dependabot to its update pull requests; not for hand-filed issues.documentation — README and other prose.security — security-sensitive work. An exploitable vulnerability does not go in a public
issue at all: report it privately per security-audit.ui — the package's published views or other user-visible output.research — a spike whose deliverable is a documented decision, not code.QA — verify expected behavior, usually paired with the build ticket it validates.upstream — a report or request to file against a dependency, per
filing-defects-across-repos.test-flake — an intermittent failure that passes in isolation; not a real defect.browser-tests — browser-driven test work (the Pest browser plugin is not installed yet).quiet-machine — timing or measurement work that needs no concurrent runs, per
measurement-parity.macos-environment · windows-environment — must be completed on that platform.handed-back — pull requests only: returned to the author by
pre-merge-check with a finding outstanding.afk · hitl · epic — execution mode, below. A hitl issue also carries its
flavor: decision-fork (a design decision) or action-flavor (a real-world action).fleet-facing / housekeeping (exactly one)Every open issue also carries exactly one of these, and it decides the order work is taken in: fleet-facing comes first, housekeeping last.
fleet-facing — changes what an agent or operator on the fleet experiences: the service's API and behavior, the bridge and its follower, enrollment and credential storage, the tool list a harness sees, the messages a session reads, and the README steps a fleet user follows. A measurement that decides whether one of those works on a platform counts too.housekeeping — repository upkeep with no direct effect on the fleet: CI wiring, the release-notes generator, skills and rules, a test flake, or a measurement of code that has already been replaced.Classify by who notices if it never ships. If only people working in the repository would, it is housekeeping, even when it changes shipped code or carries security. A cleanup of the service's API is fleet-facing, because the deployed bridge talks to it. An epic carries the label its slices mostly share. Still file housekeeping residue per closing-a-ticket; the label lowers its priority, not its right to a ticket.
The robot-council organization enables the Task, Bug, and Feature issue types. Set exactly
one on every issue: Bug for a bug, Feature for a feature, and Task for a research spike, a
decision fork, a follow-up cleanup, or an epic. The templates declare the matching type: for
issues filed through the web UI. When filing through REST, pass the type's name:
gh api -X POST 'repos/{owner}/{repo}/issues' -f title='…' -F [email protected] -f type=Feature
Read the type back after setting it. GitHub's REST description says a type set without push
access is silently dropped, so the call succeeds either way:
gh api 'repos/{owner}/{repo}/issues/<n>' --jq '.type.name'.
afk / hitl (exactly one — except on an epic)Every open issue carries exactly one of afk / hitl. It tells whoever triages the tracker what kind of attention the issue needs to be completed: an autonomous coding agent, or a human.
The exception is epic. An issue labeled epic is exempt: it is a container whose slices each carry their own execution mode, not a unit of work anyone picks up and builds. An epic with neither label is correctly labeled — don't "fix" it. Apply hitl to an epic only where the container itself waits on a human.
afk (away-from-keyboard) — buildable autonomously: the design is pinned or already decided, or the fix is fully specified, so there is no genuine design fork to resolve in the issue's own scope. Most build slices, dependency bumps, refactors, and already-specified bug fixes are afk. The test: could a competent agent finish this alone, without a human deciding anything or doing anything outside the repo? If yes, afk.hitl (human-in-the-loop) — completing it requires a human, for either reason: a human decision — an unresolved design fork (the issue carries a ## Questions to resolve / ## Decisions to resolve section), a policy / cost / go-no-go call, or a settled decision being re-opened; or a real-world action — a step outside the repo a coding agent can't perform (provisioning or credentialing, creating an external account, installing an app, publishing somewhere that needs a human's login). If a human must decide or act before the issue can close, hitl. There are only these two modes; fold real-world actions into hitl.For the decision flavor, surface the fork the right way — lead with a compare/contrast packet, then the interactive prompt — following the design-decision-forks rule (don't restate it here).
A decision-fork ticket delivers two things and nothing else: the decision, recorded, and the follow-up tickets it produces. Build work the decision unblocks goes into those follow-ups, blocked by the fork through native dependency edges. Its acceptance criteria describe a decision being recorded and its follow-ups existing, never a build being done — a criterion naming a file to change belongs on a follow-up.
How to classify — a leaf issue takes the mode of what its own completion needs; don't inherit a blocker's mode (blockers carry their own labels). An epic is exempt — classify its slices, not the container. When genuinely on the fence, prefer hitl: it's safer to route an ambiguous issue to a human than to let an agent build past an unmade decision.
Keep the label current — flip it when the classification changes. Flip hitl → afk once the fork is decided and recorded on the issue — relabel (dropping decision-fork too) and reconcile the body in the same change (retire the ## Questions to resolve / ## Decisions to resolve section, tick the decision-recorded acceptance criterion). What remains on a fork ticket after the flip is filing its follow-ups, not building them. Flip afk → hitl the moment work surfaces a genuine design fork an agent shouldn't decide alone — relabel (adding decision-fork), add a ## Decisions to resolve section, and settle it with the user (per design-decision-forks) before building.
An epic is a container. It holds the shared context a body of work rests on and carries no deliverables of its own — no acceptance criteria, no "decisions taken here", nothing anyone could pick up and build. Every deliverable is a sub-issue. If a reader cannot tell what to do from a slice alone, the slice is under-written, not the epic.
That constraint is what makes an epic useful. A container with its own checklist competes with its slices, and the work ends up recorded in two places that drift.
When a ticket becomes an epic. Convert one when it has accumulated several independently shippable deliverables — usually visible as an acceptance-criteria list whose items would each make a reasonable ticket. Two shapes recur:
hitl.File retroactive sub-issues for work already finished. When converting a ticket that has already produced results, give each completed piece its own sub-issue, record what it found in the body, and close it as completed. It costs a minute and it gives the finding a home: an epic body is a poor place for a measurement, because the next edit overwrites it.
Labels on a container. An epic takes the topic labels that describe its subject and not afk / hitl, nor labels that describe how its slices will be worked — those belong on the slices that earn them.
Wiring, and it is not the same thing as a dependency. A sub-issue says this is part of that; a blocked_by edge says this cannot start until that finishes. Most epics need both, and they are set independently:
id=$(gh api 'repos/{owner}/{repo}/issues/<child>' --jq '.id') # numeric .id, NOT the issue number
gh api -X POST 'repos/{owner}/{repo}/issues/<parent>/sub_issues' -F sub_issue_id=$id
Sub-issues work across repositories. The endpoint keys on the global database id, so a slice living in another repository attaches to an epic here exactly like a local one; listing it only in prose loses it. Read the set back with GET …/sub_issues, and see github-api-budget for why these are REST calls.
Before creating any issue, search the tracker for one that already covers the same concept — match on the idea, not just the title, since an existing issue may be worded completely differently. Run a few searches with varied terms and include closed issues (a fixed or won't-fix issue still means don't refile):
# One block, because the control and the questions have to be the same instrument. The repository
# is resolved from the checkout once; the terms go in `-f` FIELDS rather than into the URL, so `gh`
# encodes them. `-X GET` because `gh api` sends POST as soon as a field is present, and `timeout`
# because the failure mode here is a hang rather than an error -- both explained below.
here=$(gh api 'repos/{owner}/{repo}' --jq .full_name)
for q in "robot-council" "<symptom or feature>" "<affected file / class / config key>" "<class of problem or label>"; do
printf '%s -> ' "$q"
timeout 25 gh api -X GET search/issues -f q="repo:$here $q" -f per_page=100 \
--jq '"matches: \(.total_count) (showing \(.items|length)) from: \(.items[0].repository_url // "-" | sub(".*/repos/"; ""))",
(.items[] | " \(if .pull_request then "PR " else "iss" end) #\(.number) \(.title)")' \
|| echo "NO ANSWER (rc=$?) -- not a clean check"
done
echo "checkout: $here <- every from: above must equal this"
A search term written into the URL with a space in it HANGS, which is why the terms are passed
as fields. Measured on 2026-09-24 with gh 2.92.0: gh api "search/issues?q=repo:…+two words"
never returns. --verbose shows why -- it sends GET /search/issues?q=…+two words&per_page=1 HTTP/1.1, a request line with a raw space in it, and then waits for a reply that never comes. One
such call ran 22 minutes before it was killed by hand. It is not the search endpoint's fault
and not q's: the same raw space anywhere in a query string does it, measured with
repos/{owner}/{repo}/issues?labels=a b. A space in the path is harmless by contrast --
repos/{owner}/{repo}/iss ues answers 404 promptly. The hazard is that the placeholders above
are phrases, so following the recipe literally is what triggers it.
-f costs the placeholders, and that is the reason $here exists. gh substitutes {owner}
and {repo} in a path, and in a field whose value is exactly the placeholder -- which is what
writing-pull-requests's GraphQL recipe relies on with -F owner='{owner}'. It does not
substitute them inside a longer field value: measured, -f q='repo:{owner}/{repo} term' sends the
braces literally and GitHub answers Validation Failed. So the repository is read once through the
path form and interpolated as text.
Read the matches: line before the rows. search/issues returns one page, so a query broader than the page size is answered with a silent prefix, and the truncated output is identical in shape to a query that genuinely found everything. In UAMS-Web/uams-statamic (measured 2026-09-06) a broad query reported hundreds of matches while a narrow one reported single digits, and nothing but the total distinguished the truncated page from the complete one.
A total greater than the rows shown means the check has not answered the question. Either paginate (add -f page=2, until a short page) or narrow the query until the total fits — "nothing matches" is not a conclusion you can draw from a truncated page. incomplete_results is not that signal: it reports a server-side timeout, not pagination, and it is false on truncated responses.
gh issue list and gh search issues exclude pull requests; the REST search endpoint above does not. That matters because a fix often ships as a pull request with no issue behind it, so the wrapper hides exactly the in-flight work the check exists to find — in UAMS-Web/uams-statamic (2026-09-05) a wrapper search returned the issue and never the pull request implementing it. The .pull_request key is what distinguishes the two kinds, hence the PR/iss prefix. The endpoint cannot phrase-match and it indexes comments; see github-api-budget.
Everything above guards against a query that is too NARROW. None of it guards against a query that did not RUN. Those produce the same output, and the second one is the easier mistake: a malformed --jq expression prints its parse error to stderr, gh exits non-zero, and a pipeline ending in || echo "(none)" prints exactly what a genuinely clean check prints. Observed while filing against robot-council/cli on 2026-09-21, where the check read:
=== duplicate check ===
(none)
Two issues were filed on the strength of that line. Re-run afterwards with a working parser the check was genuinely clean — so no duplicate was filed, but that was luck rather than method, and the same output would have appeared had there been ten.
So show the query finding something, in the same invocation, before reading it finding nothing. A term certain to appear in the repository is enough, and it costs one call.
A count is not enough on its own, and the control has to name the repository that answered. A term common enough to be a reliable control is common enough to match in either repository, so a healthy total confirms the instrument while saying nothing about which tracker was asked — and a duplicate check aimed at the wrong one reads exactly like a clean one. Measured from a robot-council/core checkout on 2026-09-24: the term robot-council returned 197 matches aimed at this repository and 126 aimed at the other. Both look fine. repository_url off the first item is what tells them apart.
That is why robot-council is the first term in the block above rather than a separate
invocation: the control and the questions then share one endpoint, one --jq, one quoting and one
bound, so nothing can differ between them except the term.
Read the control's total and its from: first. If the total is 0, if the line is missing entirely, or if from: is not this checkout, stop — nothing below it is evidence. This is an-empty-result-is-not-evidence applied to the duplicate check; that rule owns the general form and the reasoning, and is not restated here.
from: reads - when a query matches nothing, which is the honest answer and not a failure: there is no item to take a repository from. That is why the total is read first — the two lines answer different questions.
Vary the terms across the symptom, the affected file/class/config key, and the class of problem —
one query rarely surfaces a differently-worded duplicate. The repo: qualifier above is $here,
read from the checkout, so these search the repository you are in; for work that spans
repositories, substitute the other one for $here in a further query and expect its from: to name
that repository rather than this one. If anything plausibly covers the concept:
link it and skip, or add a comment / sharpen the existing issue — don't open a duplicate. Only
file once you've confirmed nothing matches.
Pick the skeleton that matches the work:
## The bug or ## Root cause (offending
code) · ## Reproduction · ## Proposed fix (fix code) · ## Acceptance criteria (ends in a
regression test) · ## References.## Why · ## What we're building (+ behavior detail / tables) ·
## Acceptance criteria · ## Out of scope · ## References.Research:-prefixed title · lede ("Research spike. … deliverable is
a documented decision, not code") · ## Why this is a spike · ## Questions to resolve ·
## Acceptance criteria (decisions + go/no-go) · ## References.Follow-up to #N. lede · ## Scope · short ## Acceptance criteria.
Follow-up to #N. is provenance and stays; any dependency on #N is an edge.## Out of scope is part of writing a good issue, not an
afterthought.*emphasis* sparingly, only on the word that carries the sentence.I/my/you/your, and no vouching stance. See impersonal-voice-in-github-artifacts.no-emoji-in-durable-records.A compact bug issue demonstrating the core conventions. Illustrative only: members() and the
config key are invented to show the shape.
Title: RobotCouncil::members() returns null (not []) when no members are configured
````
Discovered while writing feature tests for the `robot-council` command. `RobotCouncil\RobotCouncil::members()` declares an `array` return type, but when `robot-council.members` is absent from config it returns `null`, so PHP throws a `TypeError` and the command fails before printing anything.
## Root cause
[`src/RobotCouncil.php`](https://github.com/robot-council/core/blob/main/src/RobotCouncil.php) -- **the package's file, named because the example is its**; the application has no `src/`:
```php
public function members(): array
{
return config('robot-council.members'); // null when the key is absent
}
```
## Proposed fix
Default the lookup to an empty array so the signature holds:
```php
return config('robot-council.members', []);
```
## Acceptance criteria
- [ ] `RobotCouncil::members()` returns `[]` when `robot-council.members` is absent.
- [ ] `RobotCouncil::members()` still returns the configured list unchanged when the key is present.
- [ ] A Pest test covers the absent-key and configured-list cases.
````
(Lede opens with discovery provenance and leads with the user-visible failure. ## Root cause
quotes the offending code; ## Proposed fix shows the one-line change. Acceptance criteria are
observable outcomes that bound both the fixed and the unaffected behavior and close with a
regression test. No AI disclaimer. File path links to the absolute branch URL.)
How can I help you explore Laravel packages today?