Issue Management
Manage issues and epics in issues/ using the issuectl CLI as the primary
interface. The user's message determines the action:
- Create: user describes a problem, task, or feature →
issuectl create "<title>" ...(the slug is derived from the title by default; see "Identifiers" for slug policy) - Search/list: user asks to find, list, or check issues →
issuectl ls,issuectl show,issuectl search - Close: user says an issue is done/resolved →
issuectl close <slug> - Update: user wants to change status, assignee, or other details →
issuectl update <slug> ...
Determine the action from the user's message and arguments. If unclear, ask.
Always pass --json to every issuectl command. The output is
structured and reliable to parse; the human-readable mode is for
terminal users only. All examples below already include --json.
For an unfamiliar command, discover its exact flags and arguments without
scraping text help: run issuectl <command> --help --json. Read its data
object, which has subcommands, flags, args, examples, accepted
possible_values, and any env mapping. For example, use issuectl create --help --json before filing
an issue.
issuectl validates inputs strictly (rejects unknown values for --type,
--priority, --status, etc.) and exits non-zero on errors. Read stderr
when a command fails — the error message names the offending value and
the valid alternatives.
--json output contract
Every command follows one contract so you can consume any of them the same way:
- Success (including partial success) → one object on stdout:
{"schema_version":1,"data":…, "warnings":[]}. Read the command result only from.data: it is an issue object, an array, or an action result. Non-fatal warnings are exclusively in the top-level.warningsarray. A partialimportstill uses this success envelope despite exit 2. - Error (exit ≠ 0, no work landed) → one object on stderr:
{"schema_version":1,"error":{"code":"<stable-kebab-code>","message":"…"}}, with optional context insideerror; stdout is empty. This covers validation errors, not-found, conflicts, and bad flags (usage-error). - Schema rule:
schema_versionis the CLI JSON API version, independent of issue-file schemas. It changes only for breaking JSON changes; additive fields do not bump it. Runissuectl version --jsonand inspect.dataforsupported_schemasplus every bundled skill's version pin. - Exit codes:
0success ·2refused-but-actionable (duplicate precheck strong match → error envelope on stderr; partial import where some records landed → result object on stdout) ·1everything else (validation error, not-found, bad flag, conflict). Branch on the exit code first, then decide whether to read stdout or stderr. - Shared field vocabulary (same key, same meaning everywhere):
slug,title,version(optimistic-concurrency token — pass back as--expected-version),dir(the issue's directory),path(a single file),dry_run(bool),diff(unified-diff string),warnings(string array).openusesis_dir(bool: was--dirrequested) so it never collides with thedirdirectory field.
Configuration inspection
Before a write from an unfamiliar directory, inspect the repo schema location and
its effective policy. config show reports every value with source: "file"
when it was declared in issues/.schema.yaml, or source: "default" when the
built-in schema supplies it. config path --json returns its result at
.data.path; config show --json returns its result under .data, including
{ "path": "...", "exists": true|false, "values": { "schema.fields.priority": { "value": {…}, "source": "file|default" } } }.
issuectl --json config path
issuectl --json config show
Companion skill catalog
issuectl --json skill list enumerates the bundled /issue, /issue-new, and
/issue-intake workflows plus the complete Claude, pi, and Codex installation
contract. Read supported_agents, install, and skills before automating an
install. The installer defaults to every skill and all three runtimes; use
--agent claude|pi|codex|all, --target <dir>, --dry-run, and explicit
--force as needed. skill pi-status only reports legacy global pi copies.
issuectl --json skill list
Install or upgrade issuectl
This skill was installed for issuectl 0.18.3. On the
first invocation in a session, run issuectl --version and compare:
- Missing: install one of:
- Homebrew (macOS/Linux):
brew install jarimustonen/issuectl/issuectl - Cargo (any platform with a Rust toolchain):
cargo install issuectl - Shell installer (no toolchain):
curl -LsSf https://github.com/jarimustonen/issuectl/releases/latest/download/issuectl-installer.sh | sh
- Homebrew (macOS/Linux):
- Older than
0.18.3: tell the user the skill expects0.18.3and suggest upgrading via the same channel they originally used (brew upgrade jarimustonen/issuectl/issuectl,cargo install issuectl --force, or re-run the shell installer). Stop and wait — schema/CLI surface may have changed. - Newer than
0.18.3: the installed binary is ahead of what this skill was written for. Tell the user to refresh the skill so the instructions match the CLI surface they actually have:issuectl skill install --force(all bundled skills for Claude, pi, and Codex by default; select one runtime with--agent). Then runissuectl doctor(orissuectl doctor --fix) — a newer binary often ships schema rules or migrations the repo hasn't picked up yet. Continue with the task once both are done. - Equal: proceed normally.
Identifiers
Issues are identified by short kebab-case slugs (the primary key in
every command that takes an issue argument). By default issuectl create
derives a descriptive 2-3 word slug from the title (e.g.
login-redirect-loops) — the slug shows up in directory names, branch
names, and every agent command, so a recognizable one pays off. Pass
--slug to override the derived default with an explicit slug. The CLI
falls back to a random intensifier-adjective-noun slug (e.g.
extremely-quiet-otter) only when the title yields no sensible slug, or
when you force it with --slug-random (for a title that would leak
sensitive data) — see "Action: Create → step 2" for the operational
details. When this derived identifier differs from straightforward title
slugification, creation warns that it retains 2–3 significant words and
drops stop-words; read that advisory from top-level warnings under
--json. Body
cross-references use @<slug> form. The epic: and related:
frontmatter fields store bare slugs / @<slug> strings (no leading
#NN).
Arguments
Argument: $ARGUMENTS
Actions
Action: Search / List
Use the CLI rather than greppa hakemistoa. The CLI knows the frontmatter schema.
- List open issues:
issuectl --json ls - Filter via flags:
issuectl --json ls -t bug -p high -a alice-t/--type: bug, task, feature, improvement, chore, epic-p/--priority: low, normal, high-s/--status: untriaged, open, in-progress, testing, needs-info, deferred, done, fixed, wontfix, duplicate, cannot-reproduce, obsolete (theuntriaged/needs-info/deferredactive states come from the standard intake flow — see "Action: Intake")-a/--assignee USERNAME(matchesassigneefor issues,ownerfor epics)-l/--label LABEL-e/--epic <slug>(children of an epic)- Bare
lsis open-only, but — absent an explicit--all/--closed— pinning-s/--status(or astatus:/folder:query term) lifts that open-only default, sols -s done/ls -s fixedlist closed and archived issues too.--all/--closedstill win:ls --closed -s donestays scoped to the closed folder.
- Filter via query string (same syntax as
searchand web?q=):issuectl --json ls "status:in-progress assignee:alice"issuectl --json ls "-label:wontfix updated:<-14d"(negation, relative date)issuectl --json ls "assignee:none"(any/nonefor present/absent)issuectl --json ls 'text:"phrase to match"'(quote multi-word text)- Supported fields:
status,type,priority,assignee,owner,epic,label,slug,folder,updated,created,closed,text. Bareword (nofield:prefix) is treated astext:. - Date filters use relative offsets:
<-14d(strict),<=-14d(inclusive), and the same for>/>=. Anchor is today (local timezone — same as howcreated/updatedare written). Use<=0dfor "today or earlier" (don't write+0din URLs;+URL-decodes to space). - Multiple terms AND together; no OR / parens in v1.
- Escape inside an unquoted value:
\:literal colon,\\literal backslash,\literal space,\"literal quote,\-at token start to escape negation. Or quote the whole value:text:"foo:bar". Inside"..."only\\and\"are escapes — every other backslash is preserved literally, so paths and regex fragments survive (text:"C:\temp"matchesC:\temp). - When a positional query is given to
ls, the implicit "open only" default is dropped — combine with--all/--closedorfolder:/status:to scope. Plain--status fixed(no positional query) still implies open-only, matching the old behavior.
- Include closed:
--all(both) or--closed(only closed) - Show details for one:
issuectl --json show <slug> - Epic hierarchy as a tree:
issuectl --json epic tree <slug>renders the epic plus its child issues (issues whoseepic:points at it; a child epic is expanded in turn). Read-only.--jsonemits it structurally — a node object{slug,title,status,priority,type,children:[…]}for one epic, or an array of such nodes for every top-level epic when the slug is omitted (issuectl --json epic tree). A missing slug exits1with thenot-founderror envelope. - Search (same query syntax; bareword shorthand):
issuectl --json search KEYWORD [--all]- Also:
issuectl --json search "deadlock text:flock"
- Also:
- Stats:
issuectl --json stats - Find likely duplicates (local heuristics — title/label/body-token overlap, no remote AI):
- All open pairs:
issuectl --json duplicates(aliasdups) - Against one issue:
issuectl --json duplicates <slug> --threshold 0.0..1.0tunes sensitivity (default0.30);--allincludes closed.- JSON (all-pairs):
[{a_slug,a_title,b_slug,b_title,score,title_overlap,body_overlap,label_overlap}], highest score first. - JSON (single
<slug>):[{slug,title,score,title_overlap,body_overlap,label_overlap}].
- All open pairs:
Default scope: ls (without a positional query) and search cover open
issues only. Add --all when the user asks for "all issues", "closed
issues", or "history of @".
Process the JSON with jq to extract what the user asked for. Format the
result as a compact list when displaying back to the user (e.g.
@<slug> — Title (type, status, assignee)), not the raw JSON.
Action: Close
Closing means setting a closing status and moving the issue to closed/.
The CLI does both atomically — never git mv by hand.
issuectl --json close <slug>— defaults tofixedfor bugs,doneotherwiseissuectl --json close <slug> --status wontfix— explicit closing statusissuectl --json close <slug> --as <user>— record the closer as theclosed_by:frontmatter field (optional; same author grammar asnote --as)issuectl --json close <slug> --comment "resolution"— append the resolution rationale;--noteand--messageare aliasesissuectl --json close <slug> --commit HASH:summary— also record a commit (repeatable)issuectl --json close <slug> --stamp— after closing, rewrite the current HEAD commit's message to append aFixes-Issue: @<slug>trailer, so the trailer-drivenissuectl changelogpicks up the landing commit with zero manual trailer discipline. Run it after committing the fix (it stamps whatever HEAD is) and before pushing/merging (rewriting changes HEAD's sha). Message-only — tree, author, and dates are preserved and the index is untouched. Fail-safe: it never blocks the close — thestampobject in the JSON reports{"status":"stamped","sha":...,"previous_sha":...},{"status":"already_present","sha":...}, or{"status":"skipped","reason":...}(HEAD detached / a merge commit / signed / mid rebase-cherry-pick-merge-revert / no commit to stamp). Cannot be combined with a--committhat resolves to HEAD (the rewrite would orphan that recorded sha).
Output shape (closed_by present only when --as is passed; stamp present only when --stamp is passed):
{ "slug": "extremely-quiet-otter",
"dir": "/abs/path/issues/closed/extremely-quiet-otter",
"moved_to_closed": true, "version": "sha256:...", "closed_by": "alice" }
Reopening (update --status <active>) clears closed_by alongside closed:.
Closing statuses (any of these triggers move to closed/):
done— work completed successfully (tasks, features, chores, epics)fixed— bug fix committed and verifiedwontfix— decided not to fix (by design, out of scope, etc.)duplicate— duplicate of another issue (also--add-related "@<slug>"via update first)cannot-reproduce— bug could not be reproducedobsolete— no longer relevant
The Definition-of-Done gate applies only to delivery closes (done and fixed
by default). Non-delivery dispositions stay ungated even with dod.strict: true.
A project can declare custom delivery closes in issues/.schema.yaml by
classifying the status as closing under status_classes and including it in
dod.delivery_statuses. That list replaces the defaults, so restate done and
fixed to retain them; an explicit empty list disables the transition-time gate.
Steps:
- Determine the appropriate closing status from the user's message
- Run
issuectl --json close <slug> [--status X] [--as <user>] [--commit HASH:summary] - If closing an epic: update the
## Issueslist in the epic's item.md with final statuses of all child issues (the CLI does not edit body markdown) - If the issue belongs to an epic (has
epic:in frontmatter): update the parent epic's## Issueslist to reflect the closed status - Confirm to user with the slug, title, closing status, and new location
Batch close: if the user provides multiple slugs, run issuectl --json close for each. Confirm each one.
Action: Update
Use issuectl --json update <slug> with one or more flags. The CLI updates
frontmatter and bumps updated: automatically. If the new status is a
closing status, the issue is also moved to closed/ (same as close).
Common flags:
--title "New title"rewrites the body-backed# <title>H1. The JSON response echoes the persistedtitlewhen this flag is requested.--status STATUS(active or closing)-t/--type TYPE(bug, task, feature, improvement, chore, epic, or any value the repo's.schema.yamladds tofields.type.enum; rejected withSchemaViolationif the new type's required body sections are missing, with a list of## <Section>headings to add first; rejected if combined with a close→open reopen on the same call. Changing toepicautomatically migrates a lonereporter:toowner:and reports a warning; an assignee or a conflicting owner remains an actionable error.)--assignee USER/--no-assignee,--owner USER/--no-owner(epics), and--no-reporter--priority low|normal|high--epic <slug>/--no-epic--add-label LABEL/--remove-label LABEL(repeatable)--add-related "@<slug>"/--remove-related "@<slug>"(repeatable; bare slug also accepted)--add-blocked-by "@<slug>"/--remove-blocked-by "@<slug>"(repeatable; bare slug also accepted) — set/clear DAG dependency edges (this issue is blocked by<slug>). Same shape as--add-related; equivalent toissuectl depend add/remove.--add-commit HASH:summary(repeatable)--lane NAME/--no-lane,--lane-seq <int>/--no-lane-seq, and--add-collision TOKEN/--remove-collision TOKEN— optional scheduling fields that driveissuectl dag. A lane is a serial queue, so only its head can spawn: choose lanes as independently mergeable conflict boundaries, not themes. The number of ordinary lanes is the parallelism budget; use sharedcollision:tokens for cross-lane hot files rather than merging whole lanes.lane_seqis a coarse intra-lane precedence key consulted afterblocked_byand priority but before the slug tie-break, so you can pin "do this member before that one" without a fake dependency. The reserved lane value--lane unlanedmarks an issue confirmed parallel-safe:dagtreats every member as independently headed and spawnable, never serializing siblings. It differs from an absent lane, which means "unclassified".issuectl dag [--json]renders lanes, their serialdepth, head-of-line, and totalspawnable_headsso callers can see current parallelism. Anin-progresshead remains spawnable: it is resumable work, and callers prevent duplicate runs with reservations. Seedocs/design/lane-design.mdfor the full guidance. Passdag --reservations <file|-|json>to have spawnability account for lane/collision tokens in-flight runs hold.
Example flows:
issuectl --json update extremely-quiet-otter --title "Clarify retry behavior"issuectl --json update extremely-quiet-otter --status in-progressissuectl --json update extremely-quiet-otter --assignee alice --status testingissuectl --json update extremely-quiet-otter --add-commit "abc123:fix login state"issuectl --json update extremely-quiet-otter --add-label backend --add-label apiissuectl --json update extremely-quiet-otter --add-blocked-by "@other-slug"(gate this issue behind@other-slug)issuectl --json update extremely-quiet-otter --no-assignee --type epic(clear an assignee before converting to an epic)issuectl --json update extremely-quiet-otter --no-owner --type task(clear an epic owner before converting to a non-epic)issuectl --json update --query "status:open label:stale" --priority high --add-label triagedapplies the same update flags to every query match under one repo lock. Add--dry-runfor bulk-compatible per-issue diffs without writes.issuectl --json update --patch-file patch.yamlapplies the same one-transaction YAML/JSON format asapply, includingexpected_version:compare-and-swap and orderedbody_ops:. Add--dry-runto preview it.generate-patch | issuectl --json update --patch-file -reads that patch from stdin without a temporary file. Use./-to read a file literally named-.
Supply exactly one target: a positional <slug>, --query <q>, or
--patch-file <path|->. Patch inputs cannot be combined with field flags. The
accepted forms are a patch file path or - for stdin; inline JSON argv is not
accepted because stdin provides composition without adding a quoting-sensitive
second input grammar. JSON content is accepted through either supported form.
Under --json, a patch must contain a non-empty expected_version: just as it
does with apply. --dry-run is available for query and patch-file targets,
not the positional-slug form. Query results use bulk's {dry_run, count, results[]} data shape; patch-file results use apply's single-mutation shape.
For whole-document replacement, issuectl body set <slug> --from-file <path> and issuectl update <slug> --body-file <path> preserve the existing title H1 when the incoming body has no H1 and report that preservation in top-level warnings[]. An incoming different H1 is accepted but also warns; use update --title when the title change is intentional.
Among scheduling and dependency fields, update conditionally echoes only
lane, lane_seq, and collision: when the invocation requests one of those
fields, .data carries its persisted post-update value even when the operation
was a no-op. Set values are returned directly, cleared lane/lane_seq are
null, and collision is the resulting list or null when empty. Presence
matters: a missing key means this invocation did not request that field, so its
stored value is unknown from this response; a present null means the field is
now unset or empty. Use has("lane") (and the corresponding key name) before
reading it.
blocked_by is not an update echo. After update --add-blocked-by /
--remove-blocked-by, read the canonical @-prefixed references from
issuectl show <slug> --json at .data.blocked_by; use issuectl dag --json
when you need the scheduling view, where each issue row has a canonical bare-slug
blocked_by list.
For example, the scheduling-field excerpt of .data for a call that requests
all three conditionally echoed fields is:
{ "lane": "cli-fixes", "lane_seq": 40,
"collision": ["crates/issuectl/src/main.rs"] }
Prefer commit trailers over manual --add-commit. Add
Refs-Issue: @<slug> (or Fixes-Issue: @<slug> to also signal
"close when verified") as the last paragraph of the commit
message, then run issuectl sync-commits to walk
<merge-base..HEAD> and append matching commits to each issue's
commits[]. Idempotent — safe to re-run. --dry-run previews
the plan; --no-branch-fallback disables the implicit
"branch named after a slug" attribution.
Recording the last commit on main: the default range is
<merge-base(HEAD, main/master)>..HEAD, which on main collapses to
an empty HEAD..HEAD (merge-base == HEAD) and scans nothing — so a
bare issuectl sync-commits right after committing/merging on main
records nothing. When the default range is empty, sync-commits emits a
warning (in text and as a warnings[] entry in --json) rather than
looking silently successful. To record the just-landed commit, pass an
explicit range: issuectl sync-commits --range HEAD~1..HEAD (or
--range origin/main..HEAD before pushing).
To seed the changelog trailer without any manual discipline, close
the issue with issuectl close <slug> --stamp right after committing
the fix — it stamps the Fixes-Issue: @<slug> trailer onto HEAD for
you (see the Close action above).
Output shape:
{ "slug": "extremely-quiet-otter", "dir": "/abs/path/...",
"version": "sha256:...", "moved_to_closed": false, "moved_to_open": false }
Adding the issue to an epic: also update the parent epic's ## Issues list
in its item.md (CLI handles frontmatter only, not body sections).
Action: Note
Append a timestamped block to an issue's ## Comments section
(creating it if missing). Same flock + optimistic-version contract
as update; body-only mutation.
issuectl --json note <slug> --as <user> "<message>"commentis a visible alias fornote—issuectl --json comment <slug> --as <user> "<message>"is identical.- The message text comes from exactly one source: the positional
argument,
--message/--body/--comment "<text>"(mirrorsclose --comment/--messageandcreate --body),--from-file PATH/--body-file PATH(aliases;-reads stdin, likecreate --body-file), or--stdin. Passing two at once is a usage error ({"error":{"code":"usage-error",…}}, non-zero exit); passing none is an error too. --decisionappends to## Decisionsinstead.--agent-runappends to## Agent Runsinstead.--dry-runprints a unified diff and exits 0 without writing.--expected-version <token>is optional on--json(opt-in compare-and-swap): when passed, the write fails on a version mismatch; it is enforced whenever passed. Pass it when your flow is read-then-write and another writer could interleave (multiple agents, human + agent) — fetch the token from.datainshow --jsonand pass it back unchanged. Omit it only when you are the sole writer; omitting it is an unguarded write (a concurrent update can be lost —flockstill prevents a corrupt/torn file, but it does not detect a stale read).- Transition-rule mismatches detected by
noteandcheckare emitted as warnings (stderr;warningsarray in--json) — the write goes through. The unifiedapplypath keeps rule violations as hard errors so they can be fixed in the same transaction.
Block shape (auto-generated):
### 2026-05-07T12:00:00Z · @alice
<message>
Reopen flow: update --status <active> on a closed issue
auto-appends a ## Reopen Notes — <today> section in the same
write — no extra CLI step is needed.
Action: Set / Check / Label / Apply (focused mutation verbs)
These wrap update for the common single-field and body-toggle
cases agents reach for. They share update's flock + optimistic
concurrency contract — --expected-version is optional on
--json (opt-in compare-and-swap, enforced when passed), and every
verb supports --dry-run (prints a unified diff, no write). The one
exception is apply: its patch file must still declare a non-empty
expected_version: when invoked with --json (the transactional
multi-field patch keeps its own concurrency contract).
issuectl set <slug> <field> <value>— set a single frontmatter field. Supported scalar built-ins (status,priority,assignee,owner,epic) take the typed path; schema-declared custom keys that are not reserved go through the validatedcustom_fieldsslot. Other built-in or reserved keys error with a hint to their dedicated mutation surface. Use--clearto remove a supported non-status field. For the scalar scheduling fields, useupdate --lane NAME/--no-laneandupdate --lane-seq <int>/--no-lane-seq.collisionis list-valued; use repeatableupdate --add-collision TOKEN/--remove-collision TOKEN. Reserved keys such aslabels,related,type, andtitlelikewise require their dedicated flags or commands.issuectl assign <slug> <user>— convenience wrapper forset <slug> assignee <user>; routes through the identical typed path, so validation, idempotency, and the--json/--expected-versioncontract are the same. Useissuectl assign <slug> --clearto unassign (mirrorsset --clear).issuectl check <slug> "<task substring>"— toggle a unique- [ ]/- [x]line in the issue body. Errors when zero or multiple checkbox lines match the substring.issuectl label <slug> add|remove <label>— idempotent label add / remove. The operation also accepts the flag formissuectl label <slug> --add|--remove <label>; pass exactly one form. A malformed invocation under--jsonemits the standard error envelope (never a silent no-op).issuectl apply <patch.yaml|->— compatibility spelling for a multi-field transactional patch; prefer canonicalupdate --patch-file. Pass-for stdin or./-for a literal file named-. The YAML/JSON patch declaresslug:plus any combination of built-in fields,custom_fields:, label / related list ops, commits, and abody_ops:list of body mutations applied in order under the same flock. Each body op is one of:body_ops: - set_checkbox: match: "tests passing" checked: true # idempotent: safe to retry - append_note: author: ci-bot message: "all checks green" section: agent_runs # or comments (default) / decisionsset_checkboxis idempotent — replaying the same op against an already-target body is a no-op (the box doesn't flip back). Rolls back cleanly on schema violation or any failed body op; the legacy → flat directory migration and default.schema.yamlbootstrap also defer until after validation passes, so a failing patch leaves no repo side effects.
JSON output shape (same envelope as update):
{ "slug": "...", "version": "sha256:...",
"moved_to_closed": false, "moved_to_open": false }
With --dry-run, the JSON envelope adds "dry_run": true and
"diff": "<unified diff>" and the on-disk file is untouched.
Output shape:
{ "slug": "extremely-quiet-otter", "version": "sha256:...",
"dir": "/abs/path/issues/extremely-quiet-otter" }
Action: Create
1. Gather Information
If $ARGUMENTS already provides enough context, use it. Otherwise ask the user
interactively for missing details. Tailor questions to the issue type.
Possible questions:
- What type? — bug, task, feature, improvement, chore, or epic (infer from context: X is broken = bug, we need to build Y = feature/task, set up Z = chore)
- What is the problem/goal? — clear description
- Where does it happen? — service / page / feature →
--source - How to reproduce? — bugs only; goes into the body
## Reproductionsection - Reporter —
whoamior ask - Assignee — ask if not known
- Priority — low, normal, or high (default normal)
- Epic — does this belong to an existing epic? Check with
issuectl --json ls -t epic
Epic suggestion: if the user describes a multi-week, 3+ task initiative, suggest creating an epic instead.
2. Create with the CLI
The slug is derived from the title by default. issuectl create "Login redirect loops on safari" auto-derives login-redirect-loops — lowercased,
stop-words stripped, trimmed to 2-3 words — so you normally don't pass
--slug at all. If the derived slug collides with an existing issue, the
CLI disambiguates with a numeric suffix (-2, -3, …) and reports that
predictability-affecting result as a warning. Pass an explicit --slug <kebab> only to override the derived default with a
different descriptive slug; an explicit --slug that collides errors
(retry with a different slug). When the title would leak sensitive data
(customer names, emails, secrets) into the directory name and git history,
pass --slug-random to force a random intensifier-adjective-noun
slug instead. The CLI also falls back to a random slug automatically when
the title yields no sensible slug (empty, all stop-words, or non-ASCII).
issuectl --json create \
--type bug \
--title "Login redirect loops on safari" \
--reporter alice \
--assignee bob \
--priority normal \
--source "frontend/login" \
--description "Users get stuck in a 302 loop after SSO redirect."
(The slug is derived from the title — login-redirect-loops here — so no
--slug is needed. Add --slug <kebab> to override, or --slug-random
to force a random slug.)
new is accepted as an alias for create, and --body as an alias
for --description, so issuectl --json create --type task --title X --body "…" works identically — canonical forms stay create /
--description. To set the initial body from a file instead of inline
text, use --body-file <path> (mutually exclusive with
--description/--body — combining them is a usage error); pass -
to read the body from stdin (use ./- for a file literally named -).
The file's structured Markdown content is written directly below the generated
# <title> heading (and optional --source line) without an added
## Description wrapper. A repository schema may still append stubs for any
required H2 sections the content omits:
issuectl --json create --type feature --title "Bulk export" --body-file notes.md
printf '## Context\n\nPiped body.\n' | issuectl --json create --type task --title X --body-file -
The title may also be passed positionally
(issuectl create "Login redirect loops" --type bug) instead of via
--title; pass exactly one of the two (both/neither is an error).
For epics, use --owner instead of --reporter/--assignee:
issuectl --json create --type epic --title "API v2 migration" --owner cara --priority high
To schedule the issue into the DAG at creation, create mirrors update's
scheduling flags — --lane NAME, --lane-seq <int>, and repeatable
--add-collision TOKEN — so an issue that should start laned is born that
way in one call instead of a follow-up update --lane (same setters, same
validation; see the --lane/--lane-seq/--add-collision semantics under
update):
issuectl --json create --type feature --title "Bulk export" \
--lane cli-fixes --lane-seq 40 \
--add-collision crates/issuectl/src/main.rs
(An issue created without any of these hashes identically to the pre-field shape — the lane fields are projected only when set.)
Relevant .data shape for a plain create:
{ "slug": "login-redirect-loops",
"title": "Login redirect loops on safari",
"path": "/abs/path/issues/login-redirect-loops/item.md",
"dir": "/abs/path/issues/login-redirect-loops" }
The CLI:
- Uses
--slug <kebab>when given (validated: ≥2 lowercase ASCII kebab segments) - Otherwise derives a 2-3 significant-word kebab slug from the title, dropping stop-words (numeric suffix on collision); if the result differs from straightforward title slugification, top-level
warningsexplains why.--slug-random, or an unsluggable title, yields a randomintensifier-adjective-nounslug instead - Writes the issue item and returns its canonical location in
.data.path; do not reconstruct the path from the slug - Returns the slug and item file path in
--json(parse.data.slugand.data.path)
Other useful flags: --epic <slug>, --label X (repeatable), --related "@<slug>" (repeatable), --field key=value (repeatable; for custom frontmatter fields declared in issues/.schema.yaml, e.g. --field team=payments), --check-duplicates (refuse to create and exit 2 — printing the shared error envelope {"error":{"code":"duplicate-precheck","message":...,"matches":[...]}} on stderr — when a strong duplicate already exists; re-run without the flag to create anyway).
3. Flesh out the body
Without --body-file, issuectl create writes a minimal body (# Title,
optional _Source: ..._, ## Description). With --body-file, it places the
supplied structured Markdown after the title/source preamble without that
wrapper. For bugs, append ## Reproduction and ## Quick Test sections by
editing the item.md directly (use .data.path for the file or
.data.dir for its directory). For epics, ensure ## Goal, ## Issues,
## Phases, and ## Comments sections exist. The default renderer does not add
them, although repository schema requirements may append missing stubs.
4. Copy Screenshots
If the user provides image file paths, convert them to AVIF and copy them into the issue directory. Reference them in item.md with relative paths.
5. Confirm
Show the created issue/epic path and a brief summary.
Action: Intake (standard intake flow)
issuectl intake is the first-class flow for filing and triaging incoming
bug reports and feature requests, replacing the old ad-hoc label scheme
(via:<channel> + needs-triage). Intake state lives in status, not in
labels. See docs/design/intake-flow.md. Two audiences share one namespace: a
reporting agent files; a developer / product-manager dispositions.
The flow adds three active statuses and a set of intake fields:
- Statuses:
untriaged(filed, awaiting a triage decision — the reception state),needs-info(filed but un-actionable pending reporter input),deferred(worthwhile but intentionally not scheduled now). All three are active (not closing); they show up inls/queries like any active issue. - Fields:
provenance(where it came from — chat/email/github/…; a first-class field distinct from the body--sourceline, open-valued unless a repo declares anenum:),provenance_detail(free text for theothercase),source_ref(external message id, the idempotency key),disposition_reason(enumby-design | out-of-scope | wontfix | withdrawn | superseded— the structured why for a closing disposition),disposition_note(free-text specifics),duplicate_of(directed slug link for aduplicate),deferred_until(wake-up date for a parked item).
Filing (reporting agent)
issuectl intake file --type bug --title "Login loops on Safari" \
--body-file report.md --reporter alice \
--provenance chat --source-ref "chat:123/message:456" \
[--provenance-detail "…"] [--priority high] [--slug login-loops] [--label …] --json
- Lands the item directly in
untriaged; the filer never names the entry state. - Takes any non-
epictype.--body-file -reads stdin;--body "<text>"for a one-liner. Protected keys (status,type,reporter,provenance, …) are rejected via--field— use the dedicated flags. - Idempotent on
(provenance, source-ref): a retry returns the existing item with"deduplicated": true(exit 0) instead of creating a second issue. - Output:
{ "slug", "status": "untriaged", "dir", "version", "deduplicated" }. issuectl intake withdraw <slug> --reason "…"retracts an untriaged report (→ wontfixwithdisposition_reason: withdrawn) — by convention the reporter uses it; the CLI does not enforce reporter identity.
The /issue-new skill wraps this filing path faithfully (verbatim capture +
issuectl attach for screenshots).
Inspecting the queue (developer / PM)
issuectl intake queue --json # default: untriaged, oldest first
issuectl intake queue --json --needs-analysis # only items lacking a ## Triage analysis section
issuectl intake queue --json --state deferred # a non-default view (deferred|needs-info)
issuectl intake queue --json --type bug --provenance chat
issuectl intake show <slug> --json # item + attachments + analysis section
queue is a stable projection of the actionable untriaged set (both bugs and
feature requests, every provenance). deferred/needs-info are excluded from
the default view. Each row carries needs_analysis (derived from the presence of
a ## Triage analysis body section — there is no stored analysis state). show
adds attachments (names) and analysis (the section text, or null). For a
legacy row with no first-class provenance, one exact via:<channel> label is
projected as provenance; malformed or multiple distinct channels remain null
and intake migrate reports them for manual review.
Dispositions (developer / PM — each a first-class transition)
issuectl intake accept <slug> [--assignee <who>] [--priority low|normal|high] --json # → open
issuectl intake defer <slug> --reason "…" [--until <date>] --json # → deferred
issuectl intake need-info <slug> --reason "…" --json # → needs-info
issuectl intake reject <slug> --reason "…" [--kind by-design|wontfix|out-of-scope] --json # → wontfix + disposition_reason
issuectl intake cannot-reproduce <slug> --reason "…" --json # → cannot-reproduce (bug-only)
issuectl intake duplicate <slug> --of <canonical-slug> --json # → duplicate + duplicate_of
issuectl intake obsolete <slug> --reason "…" [--superseded-by <slug>] --json # → obsolete
issuectl intake retype <slug> --to <type> --json # reclassify the type hint
issuectl intake reopen <slug> [--to untriaged|open] --reason "…" --json # closing → active
--reasonis required ondefer,need-info,reject,cannot-reproduce,obsolete,reopen,withdraw— the why is captured structurally, not left in prose.reject --kinddefaults towontfixwhen omitted; passby-design/out-of-scopeexplicitly for those.reopen --todefaults tountriagedwhen omitted.cannot-reproduceis bug-only.- Each transition
…(truncated)