Publish training skills collection

This commit is contained in:
2026-08-12 16:59:21 -04:00
commit bbfeac41de
24 changed files with 2041 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.system/
__pycache__/
*.py[cod]
+23
View File
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2026 Kyle Merritt
Copyright (c) 2026 Matt Pocock
Copyright (c) 2026 Jo Van Eyck
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+78
View File
@@ -0,0 +1,78 @@
# Codex Skills Collection
This repository is a curated collection of Codex skills referenced in the
AI tools for developers training talks. It keeps the skills in one ordinary git
repository so they can be reviewed, installed, edited, and used as examples of
how skills package reusable agent workflows.
## Installation
Run the setup script to symlink each skill directory into your Codex skills
folder:
```bash
./setup-skills.sh
```
By default, skills are linked into `$HOME/.codex/skills`. To install somewhere
else:
```bash
CODEX_SKILLS_DIR=/path/to/skills ./setup-skills.sh
```
To preview what would be linked:
```bash
./setup-skills.sh --dry-run
```
The installer discovers every top-level directory that contains a `SKILL.md`
file.
## Included Skills
### Local / Custom Skills
- `gitea-issues` - work with self-hosted Gitea repositories, issues, pull
requests, and issue dependency workflows.
- `grill-me` - interview a user about a plan or design until the decision tree
is resolved.
- `shared-abstraction-refactor` - find duplicated local patterns and promote
obvious shared abstractions.
### Copied Or Adapted From Other Repositories
- `c4-diff` - copied from Jo Van Eyck's skills repository:
https://github.com/jovaneyck/skills
- `grill-with-docs` - copied from Matt Pocock's skills repository:
https://github.com/mattpocock/skills
- `to-spec` - copied from Matt Pocock's skills repository:
https://github.com/mattpocock/skills
- `to-tickets` - copied from Matt Pocock's skills repository:
https://github.com/mattpocock/skills
- `wayfinder` - copied from Matt Pocock's skills repository:
https://github.com/mattpocock/skills
## Attribution
Thanks to Matt Pocock for the engineering planning skills in
https://github.com/mattpocock/skills, including `wayfinder`, `grill-with-docs`,
`to-spec`, and `to-tickets`.
Thanks to Jo Van Eyck for the `c4-diff` skill in
https://github.com/jovaneyck/skills.
The copied upstream skills remain credited to their original authors. This repo
packages them together with local skills so they can be installed and used as a
single training-oriented Codex skills collection.
## Scope
This repo intentionally includes only the skills referenced in the training
talks. Other personal skills may exist elsewhere, but they are left out here so
the examples match the material from the presentations.
## License
This repository is licensed under the MIT License. See [LICENSE](./LICENSE).
+216
View File
@@ -0,0 +1,216 @@
---
name: c4-diff
description: Generate before/after/diff C4 component diagrams (Mermaid) between two git commits so reviewers can see architectural change at a glance. Use whenever someone wants to visualize how a change, PR, branch, or range of commits altered the structure of the code — added/removed/changed components and relationships. Triggers include "c4 diff", "diff diagram", "architecture diff", "what changed architecturally", "diagram this PR", "component diff between commits", "show structural changes between <sha> and <sha>". Prefer this skill over hand-drawing diagrams whenever two commit-ish references are involved.
---
# C4 Diff Diagrams
Turn a git change (two commit-ish references) into three C4 **component** diagrams — `before`, `after`, and a combined `diff` with a red/green/amber overlay — so a reviewer can validate the *system-level* impact of a change without reading every line.
This is review-grade documentation, not a modeling platform. Favor a small, honest, traceable diagram over an exhaustive one.
## When this applies
Use this whenever the task involves comparing the structure of the code at two points in history: a PR, a branch vs its base, a single commit, or an arbitrary `<base>..<head>` range. If only one reference is given, diff it against its parent (`<sha>~1`) or the merge-base with the default branch — state which you chose.
## Inputs
- `base` — the "before" commit-ish (SHA, tag, branch). Required.
- `head` — the "after" commit-ish. Defaults to `HEAD`.
- `out` — output directory. Defaults to `./artifacts`.
Confirm these before doing work if any are ambiguous. Resolve each reference to its commit metadata so the reader gets the same context GitHub shows — short SHA, subject line, author, and date — not just an opaque hash:
```bash
git show -s --format="%h %s (%an, %ad)" --date=short <base>
git show -s --format="%h %s (%an, %ad)" --date=short <head>
```
Use this metadata in every diagram title and artifact heading.
### Diffing a PR
A PR is just a `base`/`head` pair, but pick the right `base`: use the **merge-base** where the branch forked, not the base branch's current tip. Otherwise unrelated commits that landed on the base branch after the fork leak into the diff and misattribute changes to the PR.
```bash
# PR of `feature` into `main`
base=$(git merge-base main feature) # where the branch diverged
head=feature # PR tip
git diff --name-status "$base" "$head"
```
This mirrors exactly what GitHub's "Files changed" tab shows. If the branches aren't fetched locally, fetch them first (`git fetch origin main feature`).
## Core principles
The value of these diagrams collapses if a reviewer can't trust them, so hold to three things:
1. **Traceable.** Every node and edge must correspond to real code you read — a class/module and an actual import, call, or reference. Note the evidence (file path + symbol). If you can't point to the code, don't draw it.
2. **Deterministic intent.** Derive structure from the code, not from what you assume the author *meant*. Don't invent relationships to make the picture tidy.
3. **Focused.** Diagram the **impacted subgraph**. Components touched by the changed files, plus their immediate neighbors (one hop) for context. Everything else is noise that hides the signal.
## Workflow
### 1. List what changed
```bash
git diff --name-status <base> <head>
```
`A`/`M`/`D`/`R` tell you added / modified / deleted / renamed files. Renames matter: a renamed or moved file is usually the *same* component, not a remove+add, so treat `R` (and content-identical delete+add pairs) as a moved/renamed node, which is a **change**, not an add and a remove.
Filter out noise that isn't architecture: tests, generated code, lockfiles, build output, docs. Keep the set of source files that define or wire up components.
### 2. Map files to components
At component level, treat each **class / primary exported module** as a component. Read the changed files (and their close neighbors) to identify:
- The components each file defines.
- The relationships out of those components: imports/`require`, constructor injection, direct calls, instantiation. Each relationship needs concrete evidence.
Read file content **at each commit** without disturbing the working tree:
```bash
git show <base>:path/to/file.ts # before
git show <head>:path/to/file.ts # after
```
A file missing at one side (command errors) means the component was added or removed there — useful signal.
### 3. Build the two graphs
Construct a `before` graph (at `base`) and an `after` graph (at `head`), each limited to the impacted subgraph + one hop of neighbors:
- **Nodes**: `{ id, name, path }` — one per component.
- **Edges**: `{ from, to, kind, evidence }``kind` is the relationship (e.g. `imports`, `calls`, `injects`).
Compute the one-hop neighborhood over the **union** of the before and after graphs, not each side in isolation. That way a *removed* node still pulls in the neighbors it used to touch, those neighbors are exactly what make the removal legible to a reviewer. Don't expand to neighbors-of-neighbors; one hop is the budget.
Keep node `id`s stable across before/after (based on the component's identity, not its file path) so the diff can match them even across renames/moves.
### 4. Diff the graphs
Compare `before` and `after`:
| Element | Added | Removed | Changed |
|---------|-------|---------|---------|
| **Node** | in `after` only | in `before` only | in both, but renamed/moved, or its set of in/out edges changed |
| **Edge** | in `after` only | in `before` only | same endpoints, but direction flipped, target changed, or kind changed |
When in doubt between "changed" and "add+remove", prefer **changed** if the component keeps its identity (same responsibility, renamed/moved). This keeps the reviewer oriented instead of making context disappear.
### 5. Render three diagrams
All three are `C4Component` Mermaid diagrams over the same impacted subgraph.
- **`before`** — the graph at `base`, no overlay.
- **`after`** — the graph at `head`, no overlay.
- **`diff`** — the *union* of both graphs, color-overlaid so added/removed/changed elements stand out and unchanged elements provide context.
#### Diff color overlay
Apply these with `UpdateElementStyle` (nodes) and `UpdateRelStyle` (relationships). Removed elements stay in the diagram (so context isn't lost) — the color, not absence, communicates removal.
| State | Meaning | Node style | Edge style |
|-------|---------|-----------|-----------|
| Added | green | `$bgColor="#e6ffed", $borderColor="#22863a", $fontColor="#22863a"` | `$lineColor="#22863a", $textColor="#22863a"` |
| Removed | red | `$bgColor="#ffeef0", $borderColor="#cb2431", $fontColor="#cb2431"` | `$lineColor="#cb2431", $textColor="#cb2431"` |
| Changed | amber | `$bgColor="#fff5b1", $borderColor="#b08800", $fontColor="#735c0f"` | `$lineColor="#b08800", $textColor="#735c0f"` |
| Unchanged | default | (no style) | (no style) |
Prefix labels so the diagram survives being viewed without color (accessibility, plain-text diffs): `"[+] NewComponent"`, `"[-] OldComponent"`, `"[~] ChangedComponent"`.
The six hex codes are easy to transcribe wrong by hand. Copy the style lines you need verbatim from this block rather than retyping them. Swap in your node/edge ids and delete the states you don't use:
```
%% added (green)
UpdateElementStyle(ID, $bgColor="#e6ffed", $borderColor="#22863a", $fontColor="#22863a")
UpdateRelStyle(FROM, TO, $lineColor="#22863a", $textColor="#22863a")
%% removed (red)
UpdateElementStyle(ID, $bgColor="#ffeef0", $borderColor="#cb2431", $fontColor="#cb2431")
UpdateRelStyle(FROM, TO, $lineColor="#cb2431", $textColor="#cb2431")
%% changed (amber)
UpdateElementStyle(ID, $bgColor="#fff5b1", $borderColor="#b08800", $fontColor="#735c0f")
UpdateRelStyle(FROM, TO, $lineColor="#b08800", $textColor="#735c0f")
```
When a relationship maps to one obvious call, you may put that symbol in the relationship's technology slot: `Rel(orderService, inventoryService, "reserves via", "reserve(order)")`, so the evidence travels *with* the diagram instead of living only in prose. Keep it to the single call that best represents the edge; skip it when the edge is a bundle of interactions.
#### Diff diagram example
Put the human-readable commit context in the title so the diagram is self-describing when pasted into a PR:
```mermaid
C4Component
title Component Diff — feat: add inventory reservation (03363a5) vs feat: add example codebase (08da9df)
Container_Boundary(app, "Order Processing") {
Component(orderController, "OrderController", "TS", "HTTP entry point")
Component(orderService, "[~] OrderService", "TS", "Orchestrates orders")
Component(orderRepository, "OrderRepository", "TS", "Persistence")
Component(paymentGateway, "PaymentGateway", "TS", "Charges cards")
Component(inventoryService, "[+] InventoryService", "TS", "Reserves stock")
Component(notificationService, "[-] NotificationService", "TS", "Sends emails")
}
Rel(orderController, orderService, "calls")
Rel(orderService, orderRepository, "persists via")
Rel(orderService, paymentGateway, "charges via")
Rel(orderService, inventoryService, "reserves via")
Rel(orderService, notificationService, "notifies via")
UpdateElementStyle(inventoryService, $bgColor="#e6ffed", $borderColor="#22863a", $fontColor="#22863a")
UpdateElementStyle(notificationService, $bgColor="#ffeef0", $borderColor="#cb2431", $fontColor="#cb2431")
UpdateElementStyle(orderService, $bgColor="#fff5b1", $borderColor="#b08800", $fontColor="#735c0f")
UpdateRelStyle(orderService, inventoryService, $lineColor="#22863a", $textColor="#22863a")
UpdateRelStyle(orderService, notificationService, $lineColor="#cb2431", $textColor="#cb2431")
```
### 6. Write artifacts
Write three files to `<out>/` (default `./artifacts/`):
- `before.component.md`
- `after.component.md`
- `diff.component.md`
The color legend and overlay belong **only** in `diff.component.md`. `before` and `after` are plain single-state snapshots — no legend, no `[+]/[-]/[~]` prefixes, no styling, because there's nothing to compare against and a legend implies color that isn't there.
Each file contains the Mermaid diagram plus a short **Evidence** section: a bullet per node/edge that changed, citing the file path and symbol that justifies it. This is what makes the diagram reviewable rather than decorative.
End with a one-paragraph summary answering the reviewer's three questions: *What new parts exist? What was removed? What relationships changed?*
## Artifact template
Use this structure for each artifact file. Lead with the commit context so a reader knows exactly what two points in history are being compared:
```markdown
# Component Diagram (diff)
**Base:** `08da9df` — feat: add example order-processing codebase (Jo Van Eyck, 2026-07-14)
**Head:** `03363a5` — feat: add inventory reservation, drop order notifications (Jo Van Eyck, 2026-07-14)
```mermaid
C4Component
...
```
## Legend
🟢 added 🔴 removed 🟠 changed ⚪ unchanged (context)
## Evidence
- 🟢 `InventoryService` — new component in `src/inventoryService.ts`; wired in `src/orderService.ts` (`this.inventory.reserve(...)`).
- 🔴 `NotificationService` — deleted `src/notificationService.ts`; removed field/usage in `src/orderService.ts`.
- 🟠 `OrderService` — dependency set changed (gained `InventoryService`, dropped `NotificationService`) in `src/orderService.ts`.
## Summary
One paragraph: what's new, what's gone, what relationships changed.
```
## Keep it honest
- If the change touches nothing architectural (formatting, comments, config), say so plainly and produce a diff diagram that states "no structural change" rather than manufacturing one.
- Component diagrams show *structure*, not *sequence*. If only the call order changed (e.g. a step moved earlier) but the dependency set didn't, that is **not** a structural change — don't render it as one. Note it in a single line of the summary if it matters, but never invent nodes or edges to represent ordering; the diagram can't express it and a reviewer will be misled. The impacted component can be colored "changed" to indicate that behaviour changed.
- Don't infer relationship *types* you can't see in code (e.g. "async message" vs "sync call") unless there's an explicit construct proving it.
- Stay under ~20 elements per diagram; if the impacted subgraph is larger, focus on the components that actually changed and their direct neighbors.
+315
View File
@@ -0,0 +1,315 @@
---
name: gitea-issues
description: Read, create, draft, claim, implement from, label, comment on, and manage Gitea repositories, issues, pull requests, and epic-style dependency links using the Gitea API. Use when the user asks to create a Gitea repository, publish a local repo to Gitea, inspect an issue, summarize an issue, create an issue, publish issues to Gitea, draft Gitea work items, create epic cards, attach child issues, take the next ready issue from an epic, create a branch or PR for an issue, or update ready/in-progress/review/done issue status labels.
metadata:
short-description: Work from Gitea issues
---
# Gitea Issues
Use this skill to create repositories and to read, draft, create, claim, implement from, and update issues and pull requests in the user's Gitea repository.
## Safety
Never ask the user to paste tokens in chat. Authentication must come from environment variables, the current git remote, or a local env file.
The helpers resolve configuration in this order:
1. Explicit environment variables.
2. The current repository's `origin` remote for `GITEA_BASE_URL`, `GITEA_OWNER`, and `GITEA_REPO`.
3. `~/.config/gitea/gitea.env`.
4. `~/.config/secrets/gitea.env`.
5. The legacy fallback `~/.config/gitea/data-analysis-agent.env`.
A generic env file can look like:
```text
export GITEA_BASE_URL="https://tea.example.com"
export GITEA_TOKEN="..."
```
Required values:
- `GITEA_BASE_URL`
- `GITEA_OWNER` or a Gitea `origin` remote
- `GITEA_REPO` or a Gitea `origin` remote
- `GITEA_TOKEN`
Creating pull requests requires a token with repository read/write scope. Issue-only tokens can read and label issues but cannot call the Gitea pulls API.
Creating repositories requires a token with repository creation permission.
Before creating an issue, draft the title, body, and labels and ask for confirmation unless the user explicitly says to create it without confirmation.
## Create Repository
Use the bundled helper instead of hand-writing repository API calls:
```bash
python ~/.codex/skills/gitea-issues/scripts/create_repo.py \
--owner anodyine \
--name project-name \
--description "Short repository description"
```
The helper reads environment variables first, then infers the owner from the
current git remote or local env files, then falls back to the authenticated
Gitea user. It returns the existing repository URL if the repo already exists.
When publishing a local directory, initialize Git locally, commit the current
files, add the SSH remote returned by the user's Gitea host convention, and push
the default branch after the repository exists.
## Issue Format
Use this structure by default:
```markdown
## Problem
## Scope
## Acceptance Criteria
```
Prefer this label vocabulary:
- `status/needs-triage`
- `status/needs-info`
- `status/ready-for-agent`
- `status/ready-for-human`
- `status/in-progress`
- `status/review`
- `status/done`
- `status/wontfix`
- `type/epic`
- `type/feature`
- `type/bug`
- `type/refactor`
- `type/docs`
- `type/test`
- `type/architecture`
- `area/frontend`
- `area/backend`
- `area/docs`
- `area/tests`
- `area/agent-workflow`
- `area/workflow`
## Create Issue
Use the bundled helper instead of hand-writing API calls:
```bash
python ~/.codex/skills/gitea-issues/scripts/create_issue.py \
--title "Issue title" \
--body-file /path/to/body.md \
--label status/ready-for-agent \
--label type/docs
```
The helper reads environment variables first, then infers repo identity from the current git remote, then falls back to the local env files. It fetches repository labels, maps label names to numeric IDs, creates the issue, and prints the issue URL.
If requested labels do not exist in Gitea, stop and tell the user which labels are missing. Do not create the issue with a silently incomplete label set.
Default to grouping implementation issues under an epic. Before creating new
ready-for-agent work, either attach it to an existing focused epic or create a
small epic for the related batch. Only create a standalone issue when the user
explicitly asks for a one-off or no reasonable epic exists.
## Read Issue
Use the bundled read helper instead of hand-parsing API JSON:
```bash
python ~/.codex/skills/gitea-issues/scripts/read_issue.py --issue 5
```
Include comments when needed:
```bash
python ~/.codex/skills/gitea-issues/scripts/read_issue.py --issue 5 --comments
```
The helper prints a compact Markdown summary with issue metadata, labels, body, dependencies/blockers, and optional comments. Prefer this when using an issue as task input for coding work.
## Work the Next Ready Epic Issue
When asked to take the highest-priority ready task from an epic, treat labels as the agent-writeable source of truth. Gitea project columns are not available through this project's API. Use the epic body's `## Child Issues In Priority Order` list for priority, and select the first open child with `status/ready-for-agent`.
Use the workflow helper to find and claim the issue:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py next-ready \
--epic 5 \
--claim
```
This prints the selected issue and moves it from `status/ready-for-agent` to `status/in-progress`. Then:
1. Read the selected issue with comments.
2. Create a branch named exactly `ISSUE-X`, where `X` is the issue number.
3. Attach that branch to the issue ref using `set-ref`.
4. Implement the requested change, including relevant tests and modular docs.
5. Run the issue-relevant automated tests.
6. Commit with a concise, logical message.
7. Push the branch.
8. Create a Gitea pull request.
9. Comment on the issue with the PR link.
10. Move the issue to `status/review`, not `status/done`.
11. Switch the local checkout back to `main` so the next agent starts new work from `main`.
The human reviewer marks the issue `status/done` after review and merge.
## YOLO Mode
Use YOLO mode only when the user explicitly asks for yolo/fully automated
issue work or when a DAG/workflow parameter clearly enables it. The normal
workflow above remains the default.
YOLO mode is for fast exploration where the agent should build, test, merge,
delete the branch, and mark the issue done so the user can review the merged
result later. It is appropriate for work the user has not yet deeply reviewed,
prototypes, small follow-ups, and low-risk changes where post-merge review is
acceptable.
In YOLO mode:
1. Claim the issue and move it to `status/in-progress`.
2. Create and attach the `ISSUE-X` branch as usual.
3. Implement the change, including relevant tests and docs.
4. Run the issue-relevant automated tests.
5. Commit and push the branch.
6. Create the pull request.
7. Merge the pull request immediately if it is mergeable.
8. Delete the merged branch unless the user asks to keep it.
9. Comment on the issue with the merged PR URL and verification summary.
10. Move the issue to `status/done`.
Never use YOLO mode for protected, ambiguous, security-sensitive, destructive,
or high-risk work unless the user explicitly accepts that risk. If tests fail,
the PR is not mergeable, or the implementation required major unplanned
decisions, stop at the normal review state instead of merging.
After creating the PR, use the helper to merge and close the loop:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py yolo-merge \
--issue 6 \
--pull 12
```
By default this uses a merge commit and asks Gitea to delete the PR branch after
merge. To keep the branch:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py yolo-merge \
--issue 6 \
--pull 12 \
--no-delete-branch
```
## Attach Branch or Tag Ref
After creating the issue branch, attach it to the issue so the Gitea sidebar does not show `No Branch/Tag Specified`:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py set-ref \
--issue 6 \
--ref ISSUE-6
```
Use the exact branch name created for the issue. For the standard issue workflow, that branch is `ISSUE-X`, where `X` is the issue number.
## Update Issue Status
Use this helper instead of hand-writing label API calls:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py set-status \
--issue 6 \
--status status/review
```
The helper adds the requested status label and removes other `status/*` labels from the issue.
## Create Pull Request and Link Issue
Write the PR body to a temporary Markdown file so shell quoting does not damage the requested format, then create the PR:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py create-pr \
--head ISSUE-6 \
--base main \
--title "Add reusable desktop UI test harness" \
--body-file /tmp/pr-body.md
```
Use this PR body format when the user does not provide a different one:
````markdown
What did you change?
Why did you change it this way rather than using a different strategy or pattern?
Pre-Test Setup:
```bash
git fetch origin
git switch ISSUE-6
git pull --ff-only origin ISSUE-6
cd path/to/relevant/app-or-package
```
Manual Test Plan:
- [ ] Run the relevant test command.
```bash
command-to-run
```
- [ ] Open the changed UI or workflow.
- [ ] Interact with the changed behavior step by step, such as navigating to links, clicking controls, choosing files, typing in fields, selecting options, and checking visible feedback.
- [ ] Confirm the expected result and note any visual or interaction issues.
Post-Merge Cleanup:
```bash
git switch main
git pull origin main
git branch -d ISSUE-6
git push origin --delete ISSUE-6
```
````
The `Pre-Test Setup` block should use the actual PR branch name and relevant package path so the reviewer can paste it into a terminal before running tests. The manual test plan should contain only the verification steps and expected checks required to test the change. Include automated test commands when they are relevant, but also include human UI or workflow steps when the reviewer needs to inspect behavior directly: opening links, clicking controls, choosing files, typing in fields, selecting options, and checking visible feedback or layout. Commands intended to be copied and run should be in fenced `bash` blocks, including commands in the manual test plan. The `Post-Merge Cleanup` block should use the actual PR branch name so the reviewer can paste it into a terminal after merging. After creating the PR, comment on the issue and move it to review:
```bash
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py comment \
--issue 6 \
--body "Implemented in PR: <pull-request-url>"
python ~/.codex/skills/gitea-issues/scripts/issue_workflow.py set-status \
--issue 6 \
--status status/review
```
## Epic Issues
Gitea does not provide true nested epics in this project. Represent an epic as a normal issue plus dependency links:
- Create an epic issue with `type/epic` when available, or `type/architecture` if the label does not exist yet.
- List child issues in the epic body under `## Child Issues In Priority Order` so agents can use `next-ready --epic`.
- Attach each child issue as a dependency of the epic. Semantically, the epic is blocked until its child issues are complete.
- Prefer one focused epic per delivery goal, such as "Frontend testing foundation".
- When creating multiple follow-up issues, create the epic in the same turn and link the children before handing work to another agent.
Use the bundled dependency helper:
```bash
python ~/.codex/skills/gitea-issues/scripts/add_dependency.py \
--parent 5 \
--child 4
```
The parent is the blocked issue. The child is the issue it depends on.
Project boards are currently a manual UI step for this Gitea instance because its REST API does not expose project endpoints. Use labels and dependency links as the agent-writeable source of truth.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "Gitea Issues"
short_description: "Work Gitea issues through review or yolo merge"
default_prompt: "Use $gitea-issues to read, draft, create, claim, label, comment on, and work ready-for-agent Gitea issues through branch and pull request workflows, including the default human-review path or explicit yolo merge mode."
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Make one Gitea issue depend on another issue."""
from __future__ import annotations
import argparse
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from gitea_common import DEFAULT_ENV_FILE, config
def api_url(base_url: str, owner: str, repo: str, suffix: str) -> str:
encoded_owner = urllib.parse.quote(owner, safe="")
encoded_repo = urllib.parse.quote(repo, safe="")
return f"{base_url.rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
def request_json(method: str, url: str, token: str, payload: dict[str, Any]) -> Any:
request = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), method=method)
request.add_header("Authorization", f"token {token}")
request.add_header("Accept", "application/json")
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"Gitea API request failed: HTTP {exc.code}\n{error_body}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
return json.loads(body) if body else None
def main() -> int:
parser = argparse.ArgumentParser(description="Make a Gitea issue depend on another issue.")
parser.add_argument("--parent", type=int, required=True, help="Issue number that is blocked.")
parser.add_argument("--child", type=int, required=True, help="Issue number the parent depends on.")
parser.add_argument("--child-owner", help="Owner of the child issue repo. Defaults to GITEA_OWNER.")
parser.add_argument("--child-repo", help="Repo of the child issue. Defaults to GITEA_REPO.")
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
args = parser.parse_args()
cfg = config(args.env_file.expanduser())
child_owner = args.child_owner or cfg["GITEA_OWNER"]
child_repo = args.child_repo or cfg["GITEA_REPO"]
issue = request_json(
"POST",
api_url(
cfg["GITEA_BASE_URL"],
cfg["GITEA_OWNER"],
cfg["GITEA_REPO"],
f"issues/{args.parent}/dependencies",
),
cfg["GITEA_TOKEN"],
{"owner": child_owner, "repo": child_repo, "index": args.child},
)
print(issue.get("html_url") or issue.get("url") or json.dumps(issue, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Create a Gitea issue using env vars or a local env file."""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from gitea_common import DEFAULT_ENV_FILE, config
def request_json(method: str, url: str, token: str, payload: dict[str, Any] | None = None) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method)
request.add_header("Authorization", f"token {token}")
request.add_header("Accept", "application/json")
if payload is not None:
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"Gitea API request failed: HTTP {exc.code}\n{error_body}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
return json.loads(body) if body else None
def api_url(base_url: str, owner: str, repo: str, suffix: str) -> str:
encoded_owner = urllib.parse.quote(owner, safe="")
encoded_repo = urllib.parse.quote(repo, safe="")
return f"{base_url.rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
def get_label_ids(base_url: str, owner: str, repo: str, token: str, names: list[str]) -> list[int]:
if not names:
return []
labels = request_json("GET", api_url(base_url, owner, repo, "labels?limit=1000"), token)
by_name = {label["name"]: label["id"] for label in labels}
missing = [name for name in names if name not in by_name]
if missing:
raise SystemExit(
"Missing Gitea labels: "
+ ", ".join(missing)
+ "\nCreate those labels first, then rerun the issue creation."
)
return [by_name[name] for name in names]
def main() -> int:
parser = argparse.ArgumentParser(description="Create an issue in the configured Gitea repo.")
parser.add_argument("--title", required=True)
parser.add_argument("--body", default="")
parser.add_argument("--body-file", type=Path)
parser.add_argument("--label", action="append", default=[])
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
args = parser.parse_args()
cfg = config(args.env_file.expanduser())
body = args.body
if args.body_file:
body = args.body_file.read_text(encoding="utf-8")
label_ids = get_label_ids(
cfg["GITEA_BASE_URL"],
cfg["GITEA_OWNER"],
cfg["GITEA_REPO"],
cfg["GITEA_TOKEN"],
args.label,
)
issue = request_json(
"POST",
api_url(cfg["GITEA_BASE_URL"], cfg["GITEA_OWNER"], cfg["GITEA_REPO"], "issues"),
cfg["GITEA_TOKEN"],
{"title": args.title, "body": body, "labels": label_ids},
)
print(issue.get("html_url") or issue.get("url") or json.dumps(issue, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Create or return a Gitea repository using env vars or a local env file."""
from __future__ import annotations
import argparse
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from gitea_common import DEFAULT_ENV_FILE, auth_config
def request_json(method: str, url: str, token: str, payload: dict[str, Any] | None = None) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method)
request.add_header("Authorization", f"token {token}")
request.add_header("Accept", "application/json")
if payload is not None:
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise GiteaHTTPError(exc.code, error_body) from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
return json.loads(body) if body else None
class GiteaHTTPError(Exception):
def __init__(self, status: int, body: str) -> None:
super().__init__(f"Gitea API request failed: HTTP {status}\n{body}")
self.status = status
self.body = body
def api_root(base_url: str) -> str:
return f"{base_url.rstrip('/')}/api/v1"
def repo_url(base_url: str, owner: str, repo: str) -> str:
encoded_owner = urllib.parse.quote(owner, safe="")
encoded_repo = urllib.parse.quote(repo, safe="")
return f"{api_root(base_url)}/repos/{encoded_owner}/{encoded_repo}"
def get_authenticated_user(base_url: str, token: str) -> dict[str, Any]:
return request_json("GET", f"{api_root(base_url)}/user", token)
def get_repo(base_url: str, token: str, owner: str, repo: str) -> dict[str, Any] | None:
try:
return request_json("GET", repo_url(base_url, owner, repo), token)
except GiteaHTTPError as exc:
if exc.status == 404:
return None
raise SystemExit(str(exc)) from exc
def create_repo(
base_url: str,
token: str,
owner: str,
repo: str,
description: str,
private: bool,
default_branch: str,
) -> dict[str, Any]:
user = get_authenticated_user(base_url, token)
login = user.get("login") or user.get("username") or ""
payload = {
"name": repo,
"description": description,
"private": private,
"auto_init": False,
"default_branch": default_branch,
}
if owner == login:
url = f"{api_root(base_url)}/user/repos"
else:
encoded_owner = urllib.parse.quote(owner, safe="")
url = f"{api_root(base_url)}/orgs/{encoded_owner}/repos"
try:
return request_json("POST", url, token, payload)
except GiteaHTTPError as exc:
if exc.status == 409:
existing = get_repo(base_url, token, owner, repo)
if existing:
return existing
raise SystemExit(str(exc)) from exc
def main() -> int:
parser = argparse.ArgumentParser(description="Create or return a Gitea repository.")
parser.add_argument("--name", required=True, help="Repository name to create.")
parser.add_argument("--owner", help="User or org owner. Defaults to GITEA_OWNER or authenticated user.")
parser.add_argument("--description", default="")
parser.add_argument("--private", action=argparse.BooleanOptionalAction, default=False)
parser.add_argument("--default-branch", default="main")
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
args = parser.parse_args()
cfg = auth_config(args.env_file.expanduser())
base_url = cfg["GITEA_BASE_URL"]
token = cfg["GITEA_TOKEN"]
owner = args.owner or cfg.get("GITEA_OWNER", "")
if not owner:
user = get_authenticated_user(base_url, token)
owner = user.get("login") or user.get("username") or ""
if not owner:
raise SystemExit("Could not determine Gitea owner. Pass --owner.")
repo = get_repo(base_url, token, owner, args.name)
if repo is None:
repo = create_repo(
base_url=base_url,
token=token,
owner=owner,
repo=args.name,
description=args.description,
private=args.private,
default_branch=args.default_branch,
)
print(repo.get("html_url") or repo.get("clone_url") or json.dumps(repo, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Shared Gitea configuration helpers for issue scripts."""
from __future__ import annotations
import os
import re
import shlex
import subprocess
from pathlib import Path
from urllib.parse import urlparse
DEFAULT_ENV_FILE = Path("~/.config/gitea/gitea.env").expanduser()
SECRETS_ENV_FILE = Path("~/.config/secrets/gitea.env").expanduser()
LEGACY_ENV_FILE = Path("~/.config/gitea/data-analysis-agent.env").expanduser()
REQUIRED_ENV = ("GITEA_BASE_URL", "GITEA_OWNER", "GITEA_REPO", "GITEA_TOKEN")
def load_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export ") :]
if "=" not in line:
continue
key, raw_value = line.split("=", 1)
key = key.strip()
try:
parsed = shlex.split(raw_value.strip(), comments=False, posix=True)
except ValueError as exc:
raise SystemExit(f"Could not parse {path}: {exc}") from exc
values[key] = parsed[0] if parsed else ""
return values
def discover_origin_config() -> dict[str, str]:
try:
remote = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return {}
if not remote:
return {}
parsed = urlparse(remote)
host = ""
path = ""
if parsed.scheme in {"http", "https", "ssh"} and parsed.netloc:
host = parsed.hostname or parsed.netloc.split("@")[-1].split(":")[0]
path = parsed.path.lstrip("/")
else:
match = re.match(r"(?:(?P<user>[^@]+)@)?(?P<host>[^:]+):(?P<path>.+)$", remote)
if match:
host = match.group("host")
path = match.group("path")
if not host or not path:
return {}
if path.endswith(".git"):
path = path[:-4]
parts = [part for part in path.split("/") if part]
if len(parts) < 2:
return {}
return {
"GITEA_BASE_URL": f"https://{host}",
"GITEA_OWNER": parts[-2],
"GITEA_REPO": parts[-1],
}
def config(env_file: Path | None = None) -> dict[str, str]:
env_path = (env_file or DEFAULT_ENV_FILE).expanduser()
file_values = load_env_file(env_path)
if env_path == DEFAULT_ENV_FILE:
secrets_values = load_env_file(SECRETS_ENV_FILE)
legacy_values = load_env_file(LEGACY_ENV_FILE)
file_values = {**legacy_values, **secrets_values, **file_values}
origin_values = discover_origin_config()
base_url = (
os.environ.get("GITEA_BASE_URL")
or os.environ.get("GITEA_URL")
or origin_values.get("GITEA_BASE_URL")
or file_values.get("GITEA_BASE_URL")
or file_values.get("GITEA_URL", "")
)
values = {
"GITEA_BASE_URL": base_url,
"GITEA_OWNER": os.environ.get("GITEA_OWNER")
or origin_values.get("GITEA_OWNER")
or file_values.get("GITEA_OWNER", ""),
"GITEA_REPO": os.environ.get("GITEA_REPO")
or origin_values.get("GITEA_REPO")
or file_values.get("GITEA_REPO", ""),
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN") or file_values.get("GITEA_TOKEN", ""),
}
missing = [key for key, value in values.items() if not value]
if missing:
raise SystemExit(
"Missing required Gitea configuration: "
+ ", ".join(missing)
+ f"\nSet them in the shell, {env_path}, {SECRETS_ENV_FILE}, "
+ "or run from a git repo with a Gitea origin."
)
return values
def auth_config(env_file: Path | None = None) -> dict[str, str]:
"""Return Gitea API auth config without requiring a repository context."""
env_path = (env_file or DEFAULT_ENV_FILE).expanduser()
file_values = load_env_file(env_path)
if env_path == DEFAULT_ENV_FILE:
secrets_values = load_env_file(SECRETS_ENV_FILE)
legacy_values = load_env_file(LEGACY_ENV_FILE)
file_values = {**legacy_values, **secrets_values, **file_values}
origin_values = discover_origin_config()
base_url = (
os.environ.get("GITEA_BASE_URL")
or os.environ.get("GITEA_URL")
or origin_values.get("GITEA_BASE_URL")
or file_values.get("GITEA_BASE_URL")
or file_values.get("GITEA_URL", "")
)
values = {
"GITEA_BASE_URL": base_url,
"GITEA_OWNER": os.environ.get("GITEA_OWNER")
or origin_values.get("GITEA_OWNER")
or file_values.get("GITEA_OWNER", ""),
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN") or file_values.get("GITEA_TOKEN", ""),
}
missing = [key for key in ("GITEA_BASE_URL", "GITEA_TOKEN") if not values[key]]
if missing:
raise SystemExit(
"Missing required Gitea configuration: "
+ ", ".join(missing)
+ f"\nSet them in the shell, {env_path}, {SECRETS_ENV_FILE}, "
+ "or run from a git repo with a Gitea origin."
)
return values
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Gitea issue workflow helpers for ready-agent issue work."""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from gitea_common import DEFAULT_ENV_FILE, config
STATUS_LABELS = {
"status/needs-triage",
"status/needs-info",
"status/ready-for-agent",
"status/ready-for-human",
"status/in-progress",
"status/review",
"status/done",
"status/wontfix",
}
def api_url(cfg: dict[str, str], suffix: str) -> str:
encoded_owner = urllib.parse.quote(cfg["GITEA_OWNER"], safe="")
encoded_repo = urllib.parse.quote(cfg["GITEA_REPO"], safe="")
return f"{cfg['GITEA_BASE_URL'].rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
def request_json(method: str, cfg: dict[str, str], suffix: str, payload: dict[str, Any] | None = None) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(api_url(cfg, suffix), data=data, method=method)
request.add_header("Authorization", f"token {cfg['GITEA_TOKEN']}")
request.add_header("Accept", "application/json")
if payload is not None:
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise GiteaError(exc.code, error_body) from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
return json.loads(body) if body else None
class GiteaError(Exception):
def __init__(self, status: int, body: str) -> None:
super().__init__(f"Gitea API request failed: HTTP {status}\n{body}")
self.status = status
self.body = body
def labels_by_name(cfg: dict[str, str]) -> dict[str, dict[str, Any]]:
labels = request_json("GET", cfg, "labels?limit=1000")
return {label["name"]: label for label in labels}
def issue_labels(issue: dict[str, Any]) -> set[str]:
return {label["name"] for label in issue.get("labels", [])}
def read_issue(cfg: dict[str, str], issue_number: int) -> dict[str, Any]:
return request_json("GET", cfg, f"issues/{issue_number}")
def child_issue_numbers(epic_body: str) -> list[int]:
section_match = re.search(
r"^## Child Issues.*?(?=^## |\Z)",
epic_body,
flags=re.MULTILINE | re.DOTALL,
)
source = section_match.group(0) if section_match else epic_body
seen: set[int] = set()
numbers: list[int] = []
for match in re.finditer(r"#(\d+)", source):
number = int(match.group(1))
if number not in seen:
numbers.append(number)
seen.add(number)
return numbers
def set_status(cfg: dict[str, str], issue_number: int, status: str) -> dict[str, Any]:
if status not in STATUS_LABELS:
raise SystemExit(f"Unsupported status label: {status}")
labels = labels_by_name(cfg)
missing = [label for label in [status, *STATUS_LABELS] if label not in labels]
if missing:
raise SystemExit("Missing Gitea labels: " + ", ".join(sorted(missing)))
issue = read_issue(cfg, issue_number)
current = issue_labels(issue)
if status not in current:
request_json("POST", cfg, f"issues/{issue_number}/labels", {"labels": [labels[status]["id"]]})
for label in sorted(current & (STATUS_LABELS - {status})):
request_json("DELETE", cfg, f"issues/{issue_number}/labels/{labels[label]['id']}")
return read_issue(cfg, issue_number)
def set_ref(cfg: dict[str, str], issue_number: int, ref: str) -> dict[str, Any]:
return request_json("PATCH", cfg, f"issues/{issue_number}", {"ref": ref})
def print_issue_summary(issue: dict[str, Any]) -> None:
labels = ", ".join(sorted(issue_labels(issue))) or "(none)"
print(f"#{issue['number']} {issue['title']}")
print(f"URL: {issue['html_url']}")
print(f"Labels: {labels}")
print(f"Ref: {issue.get('ref') or '(none)'}")
def cmd_next_ready(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
epic = read_issue(cfg, args.epic)
child_numbers = child_issue_numbers(epic.get("body") or "")
for number in child_numbers:
issue = read_issue(cfg, number)
labels = issue_labels(issue)
if issue.get("state") == "open" and "status/ready-for-agent" in labels:
if args.claim:
issue = set_status(cfg, number, "status/in-progress")
print_issue_summary(issue)
return 0
print(f"No open child issue with status/ready-for-agent found under epic #{args.epic}.", file=sys.stderr)
return 2
def cmd_set_status(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
issue = set_status(cfg, args.issue, args.status)
print_issue_summary(issue)
return 0
def cmd_set_ref(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
issue = set_ref(cfg, args.issue, args.ref)
print_issue_summary(issue)
return 0
def cmd_comment(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
body = args.body_file.read_text(encoding="utf-8") if args.body_file else args.body
comment = request_json("POST", cfg, f"issues/{args.issue}/comments", {"body": body})
print(comment.get("html_url") or comment.get("url") or json.dumps(comment, indent=2))
return 0
def cmd_create_pr(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
body = args.body_file.read_text(encoding="utf-8") if args.body_file else args.body
payload = {"base": args.base, "head": args.head, "title": args.title, "body": body}
try:
pr = request_json("POST", cfg, "pulls", payload)
except GiteaError as exc:
if exc.status != 409:
raise SystemExit(str(exc)) from exc
owner = cfg["GITEA_OWNER"]
pulls = request_json("GET", cfg, f"pulls?state=open&head={owner}:{args.head}&base={args.base}") or []
if not pulls:
raise SystemExit(str(exc)) from exc
pr = pulls[0]
print(pr.get("html_url") or pr.get("url") or json.dumps(pr, indent=2))
return 0
def cmd_yolo_merge(args: argparse.Namespace) -> int:
cfg = config(args.env_file.expanduser())
pr = request_json("GET", cfg, f"pulls/{args.pull}")
if pr.get("state") != "open":
raise SystemExit(f"Pull request #{args.pull} is not open.")
if pr.get("merged"):
raise SystemExit(f"Pull request #{args.pull} is already merged.")
if pr.get("mergeable") is False:
raise SystemExit(f"Pull request #{args.pull} is not mergeable.")
payload = {
"Do": args.method,
"delete_branch_after_merge": args.delete_branch,
}
try:
request_json("POST", cfg, f"pulls/{args.pull}/merge", payload)
except GiteaError as exc:
raise SystemExit(str(exc)) from exc
pr_url = pr.get("html_url") or pr.get("url") or f"PR #{args.pull}"
comment_body = args.comment or f"YOLO mode merged {pr_url} after automated implementation and verification."
request_json("POST", cfg, f"issues/{args.issue}/comments", {"body": comment_body})
issue = set_status(cfg, args.issue, "status/done")
print_issue_summary(issue)
print(f"Merged PR: {pr_url}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Work with the configured Gitea issue workflow.")
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
subparsers = parser.add_subparsers(dest="command", required=True)
next_ready = subparsers.add_parser("next-ready", help="Find the highest-priority ready child issue in an epic.")
next_ready.add_argument("--epic", type=int, required=True)
next_ready.add_argument("--claim", action="store_true", help="Move the selected issue to status/in-progress.")
next_ready.set_defaults(func=cmd_next_ready)
set_status_parser = subparsers.add_parser("set-status", help="Replace an issue status label.")
set_status_parser.add_argument("--issue", type=int, required=True)
set_status_parser.add_argument("--status", required=True)
set_status_parser.set_defaults(func=cmd_set_status)
set_ref_parser = subparsers.add_parser("set-ref", help="Attach a branch or tag ref to an issue.")
set_ref_parser.add_argument("--issue", type=int, required=True)
set_ref_parser.add_argument("--ref", required=True)
set_ref_parser.set_defaults(func=cmd_set_ref)
comment = subparsers.add_parser("comment", help="Add an issue comment.")
comment.add_argument("--issue", type=int, required=True)
comment.add_argument("--body", default="")
comment.add_argument("--body-file", type=Path)
comment.set_defaults(func=cmd_comment)
create_pr = subparsers.add_parser("create-pr", help="Create or return an existing open pull request.")
create_pr.add_argument("--head", required=True)
create_pr.add_argument("--base", default="main")
create_pr.add_argument("--title", required=True)
create_pr.add_argument("--body", default="")
create_pr.add_argument("--body-file", type=Path)
create_pr.set_defaults(func=cmd_create_pr)
yolo_merge = subparsers.add_parser(
"yolo-merge",
help="Merge a PR immediately, optionally delete its branch, comment, and mark the issue done.",
)
yolo_merge.add_argument("--issue", type=int, required=True)
yolo_merge.add_argument("--pull", type=int, required=True)
yolo_merge.add_argument(
"--method",
default="merge",
choices=("merge", "rebase", "rebase-merge", "squash"),
help="Gitea merge method.",
)
yolo_merge.add_argument(
"--delete-branch",
action=argparse.BooleanOptionalAction,
default=True,
help="Delete the PR branch after merge.",
)
yolo_merge.add_argument("--comment", default="")
yolo_merge.set_defaults(func=cmd_yolo_merge)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Read a Gitea issue and print a compact Markdown summary."""
from __future__ import annotations
import argparse
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
from gitea_common import DEFAULT_ENV_FILE, config
def api_url(base_url: str, owner: str, repo: str, suffix: str) -> str:
encoded_owner = urllib.parse.quote(owner, safe="")
encoded_repo = urllib.parse.quote(repo, safe="")
return f"{base_url.rstrip('/')}/api/v1/repos/{encoded_owner}/{encoded_repo}/{suffix.lstrip('/')}"
def request_json(method: str, url: str, token: str) -> Any:
request = urllib.request.Request(url, method=method)
request.add_header("Authorization", f"token {token}")
request.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"Gitea API request failed: HTTP {exc.code}\n{error_body}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea API request failed: {exc.reason}") from exc
return json.loads(body) if body else None
def issue_line(issue: dict[str, Any]) -> str:
return f"#{issue['number']} {issue['title']} ({issue['state']}) - {issue['html_url']}"
def print_issue(issue: dict[str, Any], dependencies: list[dict[str, Any]], comments: list[dict[str, Any]] | None) -> None:
labels = [label["name"] for label in issue.get("labels", [])]
print(f"# Issue #{issue['number']}: {issue['title']}")
print()
print(f"- URL: {issue['html_url']}")
print(f"- State: {issue['state']}")
print(f"- Labels: {', '.join(labels) if labels else '(none)'}")
print(f"- Created: {issue.get('created_at')}")
print(f"- Updated: {issue.get('updated_at')}")
print()
print("## Body")
print()
print(issue.get("body") or "(empty)")
print()
print("## Dependencies")
print()
if dependencies:
for dependency in sorted(dependencies, key=lambda item: item["number"]):
print(f"- {issue_line(dependency)}")
else:
print("(none)")
if comments is not None:
print()
print("## Comments")
print()
if comments:
for comment in comments:
user = comment.get("user", {}).get("login", "unknown")
print(f"### Comment by {user} at {comment.get('created_at')}")
print()
print(comment.get("body") or "(empty)")
print()
else:
print("(none)")
def main() -> int:
parser = argparse.ArgumentParser(description="Read an issue in the configured Gitea repo.")
parser.add_argument("--issue", type=int, required=True)
parser.add_argument("--comments", action="store_true", help="Include issue comments.")
parser.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
args = parser.parse_args()
cfg = config(args.env_file.expanduser())
issue = request_json(
"GET",
api_url(cfg["GITEA_BASE_URL"], cfg["GITEA_OWNER"], cfg["GITEA_REPO"], f"issues/{args.issue}"),
cfg["GITEA_TOKEN"],
)
dependencies = request_json(
"GET",
api_url(
cfg["GITEA_BASE_URL"],
cfg["GITEA_OWNER"],
cfg["GITEA_REPO"],
f"issues/{args.issue}/dependencies?limit=1000",
),
cfg["GITEA_TOKEN"],
)
comments = None
if args.comments:
comments = request_json(
"GET",
api_url(
cfg["GITEA_BASE_URL"],
cfg["GITEA_OWNER"],
cfg["GITEA_REPO"],
f"issues/{args.issue}/comments?limit=1000",
),
cfg["GITEA_TOKEN"],
)
print_issue(issue, dependencies, comments)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+10
View File
@@ -0,0 +1,10 @@
---
name: grill-me
description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me".
---
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
Ask the questions one at a time.
If a question can be answered by exploring the codebase, explore the codebase instead.
+7
View File
@@ -0,0 +1,7 @@
---
name: grill-with-docs
description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go.
disable-model-invocation: true
---
Run a `/grilling` session, using the `/domain-modeling` skill.
+5
View File
@@ -0,0 +1,5 @@
interface:
display_name: "Grill with Docs"
short_description: "Grill a design and write its docs"
policy:
allow_implicit_invocation: false
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
skills_dir="${CODEX_SKILLS_DIR:-$HOME/.codex/skills}"
dry_run=0
usage() {
printf 'Usage: %s [--dry-run]\n' "$(basename "$0")"
printf 'Create symlinks for this repo'\''s skill directories in %s.\n' "$skills_dir"
}
case "${1:-}" in
"")
;;
--dry-run|-n)
dry_run=1
;;
--help|-h)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
run() {
if (( dry_run )); then
printf 'DRY RUN:'
printf ' %q' "$@"
printf '\n'
else
"$@"
fi
}
if (( dry_run )); then
printf 'DRY RUN: mkdir -p %q\n' "$skills_dir"
else
mkdir -p "$skills_dir"
fi
found=0
while IFS= read -r -d '' skill_file; do
found=1
skill_path="$(dirname "$skill_file")"
skill_name="$(basename "$skill_path")"
link_path="$skills_dir/$skill_name"
if [[ -L "$link_path" ]]; then
current_target="$(readlink "$link_path")"
if [[ "$current_target" == "$skill_path" ]]; then
printf 'Already linked: %s -> %s\n' "$link_path" "$skill_path"
else
printf 'Skipping existing symlink: %s -> %s\n' "$link_path" "$current_target"
fi
continue
fi
if [[ -e "$link_path" ]]; then
printf 'Skipping existing path: %s\n' "$link_path"
continue
fi
run ln -s "$skill_path" "$link_path"
if (( dry_run )); then
printf 'Would link: %s -> %s\n' "$link_path" "$skill_path"
else
printf 'Linked: %s -> %s\n' "$link_path" "$skill_path"
fi
done < <(find "$repo_root" -mindepth 2 -maxdepth 2 -name SKILL.md -print0 | sort -z)
if (( ! found )); then
printf 'No skill directories found under %s\n' "$repo_root" >&2
exit 1
fi
+131
View File
@@ -0,0 +1,131 @@
---
name: shared-abstraction-refactor
description: Use when asked to find duplicated local patterns across a bounded code area, scan/report shared-abstraction opportunities, or promote obvious shared abstractions. Scope first, avoid whole-repo scanning by default, and distinguish scan mode from low-risk implementation mode for styles, helpers, types, schemas, tests, fixtures, docs, or other reusable project structures.
metadata:
short-description: Find and promote shared abstractions
---
# Shared Abstraction Refactor
## Overview
Use this skill for focused pattern-promotion refactors: finding repeated local code, styles, types, fixtures, or docs in a bounded area and deciding whether to promote them into an existing or new shared layer.
The goal is reuse without context bloat. Do not turn every task into a whole-repo archaeology dig.
## Modes
Use **scan mode** when the user asks to scan, find opportunities, identify candidates, report duplication, or otherwise asks what could be shared. In scan mode, do not edit files. Return candidates with confidence, recommended action, and deferrals. Validation is optional unless a command helps confirm findings.
Use **implement mode** when the user asks to create abstractions, promote shared code, refactor, or implement obvious opportunities. In implement mode, edit only low-risk abstractions whose meaning is stable across use sites, then update call sites, tests, and docs.
If the request is ambiguous and the mode cannot be safely inferred, ask one concise question before editing.
## Scope First
Before reading broadly, identify the refactor scope. If the user did not provide enough information and it cannot be safely inferred, ask at most 2-3 concise questions.
Required inputs:
- target path or subsystem,
- artifact type to inspect,
- mode: scan/report only or implement obvious low-risk promotions.
Useful optional inputs:
- tech stack,
- known shared surfaces,
- validation command,
- search budget: narrow, medium, or broad,
- promotion threshold, such as "appears in 2+ places with the same meaning."
Good scoping questions:
- "Which area should I inspect: frontend components, backend schemas, tests, fixtures, or docs?"
- "Should I only report shared-abstraction candidates, or also implement obvious low-risk promotions?"
- "What duplication type should I prioritize: styles, helpers, types, fixtures, or docs?"
If the user already provides enough scope, proceed without asking.
## Workflow
1. Load only directly relevant local guidance, such as `AGENTS.md` and a subsystem architecture doc.
2. Inventory known shared surfaces in the target domain before inspecting many implementation files.
3. Search with tools first, preferably `rg` and file listings, to identify candidate repetition without loading every file.
4. Sample representative files from each candidate cluster instead of reading the whole subsystem.
5. Classify candidates as one of:
- reuse existing shared primitive,
- promote to shared primitive,
- leave local because meaning differs,
- defer for a broader refactor.
6. For each candidate, state why it is worth doing now or why it should wait.
7. If implementing, promote only obvious, low-risk abstractions whose meaning is stable across use sites.
8. Update affected call sites and tests.
9. Run the relevant validation command when code changes are made.
10. Update docs when the shared structure or durable convention changes.
## Search Strategy
Prefer targeted searches over broad reading.
Examples:
```bash
rg -n "#[0-9a-fA-F]{3,8}|border-radius|font-weight|padding" apps/desktop/src/components
rg -n "interface .*Payload|type .*Status|Record<string" apps/desktop/src
rg -n "def .*|class .*|TypedDict|BaseModel" apps/backend/src
rg -n "expected|fixture|golden|must_not" .
```
Use the searches as routing signals. Open only the files needed to confirm whether repetition has the same meaning.
## Promotion Rules
Promote when:
- the same idea appears in multiple places,
- the shared meaning is clear,
- the shared name can be specific and honest,
- the change reduces future drift,
- validation is available or the change is documentation-only.
Do not promote when:
- code only looks similar but means different things,
- a shared abstraction would need vague names such as `common`, `utils`, or `misc`,
- the pattern is still changing rapidly,
- the promotion requires reading unrelated subsystems,
- the risk is high relative to the requested task.
When unsure, produce a short candidate report instead of implementing.
## Mode Outputs
For scan mode, return:
- searched scope,
- shared surfaces checked,
- candidate abstractions,
- recommended action for each candidate,
- a brief "why now" or "why not yet" judgment for each candidate,
- files to inspect next,
- risks or reasons not to promote.
For implement mode, return:
- what was promoted,
- which shared layer now owns it,
- important call-site changes,
- validation run and result,
- any deferred candidates.
## Context Discipline
Use narrow scope first:
- local file or component,
- domain shared primitives,
- one clearly similar neighboring implementation,
- broader scans only when the user explicitly asks for a refactor sweep.
If you discover likely duplication outside the requested scope, mention it as a follow-up rather than expanding the task silently.
@@ -0,0 +1,4 @@
interface:
display_name: "Shared Abstraction Refactor"
short_description: "Find and promote reusable patterns"
default_prompt: "Use $shared-abstraction-refactor to scan a bounded area for duplicated patterns and recommend or implement low-risk shared abstractions."
+75
View File
@@ -0,0 +1,75 @@
---
name: to-spec
description: Turn the current conversation into a spec and publish it to the project issue tracker — no interview, just synthesis of what you've already discussed.
disable-model-invocation: true
---
This skill takes the current conversation context and codebase understanding and produces a spec. Do NOT interview the user — just synthesize what you already know.
The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not.
## Process
1. Explore the repo to understand the current state of the codebase, if you haven't already. Use the project's domain glossary vocabulary throughout the spec, and respect any ADRs in the area you're touching.
2. Sketch out the seams at which you're going to test the feature. Existing seams should be preferred to new ones. Use the highest seam possible. If new seams are needed, propose them at the highest point you can. The fewer seams across the codebase, the better - the ideal number is one.
Check with the user that these seams match their expectations.
3. Write the spec using the template below, then publish it to the project issue tracker. Apply the `ready-for-agent` triage label - no need for additional triage.
<spec-template>
## Problem Statement
The problem that the user is facing, from the user's perspective.
## Solution
The solution to the problem, from the user's perspective.
## User Stories
A LONG, numbered list of user stories. Each user story should be in the format of:
1. As an <actor>, I want a <feature>, so that <benefit>
<user-story-example>
1. As a mobile bank customer, I want to see balance on my accounts, so that I can make better informed decisions about my spending
</user-story-example>
This list of user stories should be extremely extensive and cover all aspects of the feature.
## Implementation Decisions
A list of implementation decisions that were made. This can include:
- The modules that will be built/modified
- The interfaces of those modules that will be modified
- Technical clarifications from the developer
- Architectural decisions
- Schema changes
- API contracts
- Specific interactions
Do NOT include specific file paths or code snippets. They may end up being outdated very quickly.
Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it within the relevant decision and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
## Testing Decisions
A list of testing decisions that were made. Include:
- A description of what makes a good test (only test external behavior, not implementation details)
- Which modules will be tested
- Prior art for the tests (i.e. similar types of tests in the codebase)
## Out of Scope
A description of the things that are out of scope for this spec.
## Further Notes
Any further notes about the feature.
</spec-template>
+5
View File
@@ -0,0 +1,5 @@
interface:
display_name: "To Spec"
short_description: "Turn a conversation into a spec"
policy:
allow_implicit_invocation: false
+105
View File
@@ -0,0 +1,105 @@
---
name: to-tickets
description: Break a plan, spec, or the current conversation into a set of tracer-bullet tickets, each declaring its blocking edges, published to the configured tracker — edges as text in one file per ticket locally, or native blocking links on a real tracker.
disable-model-invocation: true
---
# To Tickets
Break a plan, spec, or conversation into a set of **tickets** — tracer-bullet vertical slices, each declaring the tickets that **block** it.
The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not.
## Process
### 1. Gather context
Work from whatever is already in the conversation context. If the user passes a reference (a spec path, an issue number or URL) as an argument, fetch it and read its full body and comments.
### 2. Explore the codebase (optional)
If you have not already explored the codebase, do so to understand the current state of the code. Ticket titles and descriptions should use the project's domain glossary vocabulary, and respect ADRs in the area you're touching.
Look for opportunities to prefactor the code to make the implementation easier. "Make the change easy, then make the easy change."
### 3. Draft vertical slices
Break the work into **tracer bullet** tickets.
<vertical-slice-rules>
- Each slice cuts a narrow but COMPLETE path through every layer (schema, API, UI, tests) — vertical, NOT a horizontal slice of one layer
- A completed slice is demoable or verifiable on its own
- Each slice is sized to fit in a single fresh context window
- Any prefactoring should be done first
</vertical-slice-rules>
Give each ticket its **blocking edges** — the other tickets that must complete before it can start. A ticket with no blockers can start immediately.
**Wide refactors are the exception to vertical slicing.** A **wide refactor** is one mechanical change — rename a column, retype a shared symbol — whose **blast radius** fans across the whole codebase, so a single edit breaks thousands of call sites at once and no vertical slice can land green. Don't force it into a tracer bullet; sequence it as **expandcontract**. First expand: add the new form beside the old so nothing breaks. Then migrate the call sites over in batches sized by blast radius (per package, per directory), each batch its own ticket blocked by the expand, keeping CI green batch to batch because the old form still exists. Finally contract: delete the old form once no caller remains, in a ticket blocked by every migrate batch. When even the batches can't stay green alone, keep the sequence but let them share an integration branch that all block a final integrate-and-verify ticket — green is promised only there.
### 4. Quiz the user
Present the proposed breakdown as a numbered list. For each ticket, show:
- **Title**: short descriptive name
- **Blocked by**: which other tickets (if any) must complete first
- **What it delivers**: the end-to-end behaviour this ticket makes work
Ask the user:
- Does the granularity feel right? (too coarse / too fine)
- Are the blocking edges correct — does each ticket only depend on tickets that genuinely gate it?
- Should any tickets be merged or split further?
Iterate until the user approves the breakdown.
### 5. Publish the tickets to the configured tracker
Publish the approved tickets. **How** depends on the tracker `/setup-matt-pocock-skills` configured — the tickets are the same either way, only the shape of the blocking edges changes:
- **Local files** → write one file per ticket under `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01` in dependency order (blockers first). Each file's "Blocked by" lists the numbers/titles it depends on. Use the per-ticket file template below — one ticket per file, never a single combined file.
- **A real issue tracker (GitHub, Linear, …)** → publish one issue per ticket in dependency order (blockers first) so each ticket's blocking edges can reference real identifiers. Use the platform's native blocking / sub-issue relationship where it has one; otherwise set each ticket's "Blocked by" to the blocking issues. Apply the `ready-for-agent` triage label unless instructed otherwise — the tickets are agent-grabbable by construction.
Work the **frontier**: any ticket whose blockers are all done. For a purely linear chain that means top to bottom.
Do NOT close or modify any parent issue.
<local-ticket-template>
# <NN> — <Ticket title>
**What to build:** the end-to-end behaviour this ticket makes work, from the user's perspective — not a layer-by-layer implementation list.
**Blocked by:** the numbers/titles of the tickets that gate this one, or "None — can start immediately".
**Status:** ready-for-agent
- [ ] Acceptance criterion 1
- [ ] Acceptance criterion 2
</local-ticket-template>
<issue-template>
## Parent
A reference to the parent issue on the tracker (if the source was an existing issue, otherwise omit this section).
## What to build
The end-to-end behaviour this ticket makes work, from the user's perspective — not layer-by-layer implementation.
## Acceptance criteria
- [ ] Criterion 1
- [ ] Criterion 2
## Blocked by
- A reference to each blocking ticket, or "None — can start immediately".
</issue-template>
In either form, avoid specific file paths or code snippets — they go stale fast. Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
+5
View File
@@ -0,0 +1,5 @@
interface:
display_name: "To Tickets"
short_description: "Split a plan into tracer-bullet tickets"
policy:
allow_implicit_invocation: false
+128
View File
@@ -0,0 +1,128 @@
---
name: wayfinder
description: Plan a huge chunk of work — more than one agent session can hold — as a shared map of decision tickets on your issue tracker, and resolve them one at a time until the way to the destination is clear.
disable-model-invocation: true
---
A loose idea has arrived — too big for one agent session, and wrapped in fog: the way from here to the **destination** isn't visible yet. Wayfinding is about finding that way, not charging at the destination. This skill charts the way as a **shared map** on the repo's issue tracker, then works its **decision tickets** — questions whose resolution is a decision, not slices of a build to execute — one at a time until the route is clear.
The destination varies per effort, and naming it is the first act of charting — it shapes every ticket. It might be a spec to hand off and iterate on, a decision to lock before planning starts, or a change made in place like a data-structure migration. The map is domain-agnostic — engineering work, course content, whatever fits the shape.
## Plan, don't do
Wayfinder is **planning** by default: each ticket resolves a decision, and the map is done when the way is clear — nothing left to decide before someone goes and does the thing. The pull to just do the work is usually the signal you've reached the edge of the map and it's time to hand off. An effort can override this in its **Notes** — carrying execution into the map itself — but absent that, produce decisions, not deliverables.
## Refer by name
Every map and ticket is an issue, so it has a **name** — its title. In everything the human reads — narration, the map's Decisions-so-far — refer to it by that name, never by a bare id, number, or slug. A wall of `#42, #43, #44` is illegible; names read at a glance. The id and URL don't vanish — a name wraps its link — but they ride _inside_ the name, never stand in for it.
## The Map
The map is a single issue on this repo's issue tracker, labelled `wayfinder:map` — the canonical artifact. Its tickets are child issues of the map.
The map is an **index**, not a store. It lists the decisions made and points at the tickets that hold their detail; a decision lives in exactly one place — its ticket — so the map never restates it, only gists it and links.
**Where the map, its child tickets, blocking, and frontier queries physically live is tracker-specific.** The issue tracker should have been provided to you — run `/setup-matt-pocock-skills` if not. Consult the tracker doc's "Wayfinding operations" section for how _this_ repo expresses them. If no tracker has been provided, default to the local-markdown tracker.
### The map body
The whole map at low resolution, loaded once per session. Open tickets are **not** listed — they are open child issues, found by query.
```markdown
## Destination
<what reaching the end of this map looks like — the spec, decision, or change this effort is finding its way to. One or two lines; every session orients to it before choosing a ticket.>
## Notes
<domain; skills every session should consult; standing preferences for this effort>
## Decisions so far
<!-- the index — one line per closed ticket: enough to judge relevance, then zoom the link for the detail the ticket holds -->
- [<closed ticket title>](link) — <one-line gist of the answer>
## Not yet specified
<!-- see "Fog of war": in-scope fog you can't ticket yet; graduates as the frontier advances -->
## Out of scope
<!-- see "Out of scope": work ruled beyond the destination; closed, never graduates -->
```
### Tickets
Each ticket is a **child issue** of the map; the tracker's issue id is its identity. Its body is the question, sized to one 100K token agent session:
```markdown
## Question
<the decision or investigation this ticket resolves>
```
Each ticket carries a `wayfinder:<type>` label — one of `research`, `prototype`, `grilling`, `task` (see [Ticket Types](#ticket-types)).
A session **claims** a ticket by assigning it to the dev driving the map, **first**, before any work, so concurrent sessions skip it. That assignee _is_ the claim: an open, unassigned ticket is unclaimed.
Blocking uses the tracker's **native** dependency relationship — essential because it renders the frontier _visually_ in the tracker's own UI, so the human sees what's takeable without opening the map. Only a tracker that lacks native blocking falls back to a body convention. A ticket is **unblocked** when every ticket blocking it is closed; the **frontier** is the open, unblocked, unclaimed children — the edge of the known.
The answer isn't part of the body — it's recorded on resolution (see [Work through the map](#work-through-the-map)). Assets created while resolving a ticket are linked from the issue, not pasted in.
## Ticket Types
Every ticket is either **HITL** — human in the loop, worked _with_ a human who speaks for themselves — or **AFK**, driven by the agent alone. A HITL ticket only resolves through that live exchange; the agent never stands in for the human's side of it (a grilling agent that answers its own questions has broken this).
- **Research** (AFK): Reading documentation, third-party APIs, or local resources like knowledge bases to surface a fact a decision waits on. Resolved by a `/research` **subagent**. Use when knowledge outside the current working directory is required.
- **Prototype** (HITL): Raise the fidelity of the discussion by making a cheap, rough, concrete artifact to react to — an outline, a rough take, a stub, or UI/logic code via the /prototype skill. Links the prototype as an asset. Use when "how should it look" or "how should it behave" is the key question.
- **Grilling** (HITL): Conversation. The default case. Always invoke the /grilling and /domain-modeling skills.
- **Task** (HITL or AFK): Manual work that must happen before a _decision_ can be made — nothing to decide, prototype, or research, but the discussion is blocked until it's done. Signing up for a service so its API can be judged, provisioning access, moving data so its shape can be seen. This is the one type that _does_ rather than decides — and it earns its place by unblocking a decision, not by delivering the destination. The agent drives it alone where it can (AFK); otherwise it hands the human a precise checklist (HITL). Resolved when the work is done; the answer records what was done and any resulting facts (credentials location, new URLs, row counts) later tickets depend on.
## Fog of war
The map is _deliberately_ incomplete: don't chart what you can't yet see. Beyond the live tickets lies the **fog of war** — the dim view of decisions and investigations you can tell are coming but can't yet pin down, because they hang on questions still open. Resolving a ticket clears the fog ahead of it, graduating whatever's now specifiable into fresh tickets — one at a time, until the way to the destination is clear and no tickets remain.
The map's **Not yet specified** section is where that dim view is written down: the suspected question, the area to revisit later. It's the undiscovered frontier _toward_ the destination — everything here is in scope, just not sharp enough to ticket. Write as loosely or as fully as the view allows; it doubles as a signpost for collaborators reading where the effort is headed.
**Fog or ticket?** The test is whether you can state the question precisely now — _not_ whether you can answer it now.
- **Ticket when** the question is already sharp — even if it's blocked and you can't act on it yet.
- **Not yet specified when** you can't yet phrase it that sharply. Don't pre-slice the fog into ticket-sized pieces: it's coarser than a ticket, and one patch may graduate into several tickets, or none, once the frontier reaches it.
**Not yet specified** excludes what's already decided (Decisions so far), what's already a live ticket, and what's out of scope (the next section).
## Out of scope
Fog only ever gathers _toward_ the destination. The destination fixes the scope, so work beyond it is **out of scope** — it isn't fog, and it doesn't belong in **Not yet specified**. It gets its own **Out of scope** section on the map: work you've consciously ruled out of _this_ effort. Scope, not sharpness, lands it here.
Out-of-scope work never graduates — the frontier stops at the destination — so it returns only if the destination is redrawn, and then as a fresh effort, not a resumption.
Ruling something out of scope is a scoping act, not a step on the route. When a ticket that already exists turns out to sit past the destination — mis-scoped in while charting, or exposed by a resolution — **close it** (a closed ticket is unambiguously off the frontier) and leave one line in the **Out of scope** section: the gist plus why it's out of scope, linking the closed ticket. It stays out of **Decisions so far**, which records the route actually walked — a scope boundary isn't a step on it.
## Invocation
Two modes. Either way, **never resolve more than one ticket per session** — with the exception of research tickets.
### Chart the map
User invokes with a loose idea.
1. **Name the destination.** Run a `/grilling` and `/domain-modeling` session to pin down what this map is finding its way to — the spec, decision, or change. The destination fixes the scope, so it's settled first.
2. **Map the frontier.** Grill again, **breadth-first** this time: fan out across the whole space rather than deep on any one thread, surfacing the open decisions and the first steps takeable now. **If this surfaces no fog** — the way to the destination is already clear, the whole journey small enough for one session — you don't need a map. Stop and ask the user how they'd like to proceed.
3. **Create the map** (label `wayfinder:map`): Destination and Notes filled in, Decisions-so-far empty, the fog sketched into **Not yet specified**.
4. **Create the tickets you can specify now** as child issues of the map — then wire blocking edges in a **second pass** (issues need ids before they can reference each other). Wiring sorts them into the frontier and the blocked; everything you can't yet specify stays in the fog — the **Not yet specified** section.
5. **Fire the research subagents.** For each `research` ticket you just created, spin up a `/research` subagent to resolve it in parallel, capturing its findings on a throwaway `research/<name>` branch with a context pointer from the ticket.
6. Stop — charting is one session's work; it hand-resolves nothing.
### Work through the map
User invokes with a map (URL or number). A ticket is **optional** — without one, you pick the next decision, not the user.
1. Load the **map** — the low-res view, not every ticket body.
2. Choose the ticket. If the user named one, use it. Otherwise take the first frontier ticket in order. **Claim it**: assign it to yourself before any work.
3. Resolve it — **zoom as needed**: fetch the full body of any related or closed ticket on demand; invoke the skills the `## Notes` block names. If in doubt, use `/grilling` and `/domain-modeling`.
4. Record the resolution: post the answer as a **resolution comment**, **close** the issue, and **append a context pointer** to the map's Decisions-so-far.
5. Add newly-surfaced tickets (create-then-wire); graduate any fog the answer has made specifiable, clearing each graduated patch from **Not yet specified** so it lives only as its new ticket. If the answer reveals a ticket — this one or another — sits beyond the destination, **rule it out of scope** rather than resolving it on the route. If the decision invalidates other parts of the map, update or delete those tickets.
The user may run unblocked tickets in parallel, so expect other sessions to be editing the tracker concurrently.
+5
View File
@@ -0,0 +1,5 @@
interface:
display_name: "Wayfinder"
short_description: "Map a large effort as decision tickets"
policy:
allow_implicit_invocation: false