CLAUDE.md@plugins/gh-tools · git:20261004.d2a660b · 2026-10-04 · sha256 a6a302a266b0467d
CLAUDE.md@plugins/gh-tools git:20261004.d2a660bA
Immutable. This exact content never changes and is served at /api/v1/blob/a6a302a266b0467d.
# gh-tools Plugin
> GitHub workflow automation: GFM link validation, WebFetch enforcement, issue title optimization.
**Hub**: [Root CLAUDE.md](../../CLAUDE.md) | **Sibling**: [itp-hooks CLAUDE.md](../itp-hooks/CLAUDE.md)
## Hooks
### PreToolUse Hooks
| Hook | Matcher | Purpose |
| ------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `webfetch-github-guard.sh` | WebFetch | Soft-blocks WebFetch for github.com URLs |
| `gh-repo-identity-guard.mjs` | Bash | Blocks gh writes when user lacks push access |
| `pretooluse-path-owner-guard.mjs` | Bash | Blocks repo creation and pushes to an owner the path registry does not map (escape: `ALLOW_OWNER_MISMATCH=1`) |
| `gh-issue-no-anchors.mjs` | Bash | Blocks in-page anchor links in issue and PR bodies, which GitHub does not resolve |
| `pretooluse-stale-checkout-claim-guard.mjs` | Bash | Denies `gh issue\|pr create/edit/comment` when this checkout last fetched over 2 hours ago, because published claims about a repository are only as good as the tree you can see (escape: `STALE-CHECKOUT-OK`) |
### PostToolUse Hooks
| Hook | Matcher | Purpose |
| ----------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gh-issue-title-reminder.mjs` | Bash | Reminds to optimize issue title after comment |
| `posttooluse-manual-pat-page-nudge.sh` | Bash | Nudges toward the `gh-fine-grained-pat` skill when a command opens GitHub's token settings page by hand (escape: `MANUAL-PAT-PAGE-OK`) |
| `posttooluse-pr-linkage-and-label-reminder.mjs` | Bash | After `gh pr create/edit`, reports which issues GitHub will close on merge (its OWN parse, not a body regex) and whether the PR has any labels. Non-blocking; always exits 0. |
#### Why the linkage reminder asks GitHub instead of reading the body
`Fixes #123` is normally **intentional**, so a guard that fires on the keyword is noise if it warns and wrong if it blocks. The failure worth catching is the opposite one: a closing link nobody meant to create. On example-org/research-repo#787 the body contained the ordinary sentence "rejected two of the three fixes #788 first proposed", GitHub parsed the last two words as the keyword, and the PR silently promised to close an unrelated P2 issue. Nothing in the diff, title or review showed it — `closingIssuesReferences` was the only place it was visible, and the same pattern recurred twice more while the first instance was being explained. Reporting GitHub's own parse after the fact has no false-positive class at all: it states what will happen and lets the author decide whether that was the intent.
#### Why the stale-checkout guard measures fetch age, not commits behind
"How many commits behind is HEAD" cannot work in a hook: the answer is computed against `@{u}`, a local ref only as fresh as the last fetch, so a week-stale checkout reports zero and is maximally wrong. Getting a true count means hitting the network on every publishing command. The mtime of `.git/FETCH_HEAD` needs no network and answers the question that actually matters — how old is my view of this repository — and unlike the commit count it cannot be silently wrong in the reassuring direction. A never-fetched clone is treated as maximally stale, not as fine.
### UserPromptSubmit Hooks
| Hook | Purpose |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userpromptsubmit-fine-grained-pat-reminder.sh` | Detects GitHub-token-creation intent and reminds to use the `gh-fine-grained-pat` skill (declarative, scoped, no broad classic tokens). Escape: `FGPAT-REMINDER-OK` |
## Fine-grained PAT creation (use the skill, not the UI)
GitHub has **no API** to create fine-grained PATs. When you need a token (release,
CI, read-only audit, account-scoped, …), use the **`gh-fine-grained-pat` skill**
([skills/gh-fine-grained-pat](./skills/gh-fine-grained-pat/SKILL.md)) — it
browser-automates the web UI from a declarative JSON spec and stores the value
straight into the SCS vault. The two hooks above surface this proactively
(prompt intent) and reactively (manual settings-page open). Prefer it over
hand-clicking or a broad classic `ghp_*` token.
## GitHub Issue Title Optimization (2026-02-05)
The `gh-issue-title-reminder.mjs` hook enforces the **Title Evolution** pattern:
### Principle
GitHub allows **256 characters** for issue titles. Maximize this limit to create informative, searchable titles that reflect the full context of the issue.
### When It Triggers
| Command Pattern | Triggers? |
| -------------------------------- | ---------------------- |
| `gh issue comment <n>` | Yes |
| `gh api .../issues/<n>/comments` | Yes |
| `gh issue create` | Yes (generic reminder) |
### Ownership Check
The hook only shows reminders for issues you own. Account resolution (ADR 2026-06-21):
1. **Primary**: the repo's `origin` host-alias `git@github.com-<account>:…` names the account.
2. **Fallback**: `curl /user` with the token from `~/.claude/tools/bin/gh-token-for-repo`.
> Legacy `GH_ACCOUNT` env and `~/.claude/.secrets/gh-token-*` filename detection are **retired** (mise is retired and no longer injects env; `.secrets` is deleted). The hooks derive the account from the origin host-alias only.
### Threshold
Reminder appears if title < 200 characters (room for improvement).
### Example Reminder
```
[gh-tools] Issue Title Optimization Reminder
Issue: #21 in terrylica/cc-skills
Current title (77/256 chars):
"Findings: Parameter-Free Feature Selection"
Consider updating the title to:
- Reflect new findings from this comment
- Maximize the 256-character limit
- Capture the full journey/context of the issue
Commands:
gh issue view 21 --json title --jq '.title | length'
gh issue edit 21 --title "..."
```
## WebFetch Enforcement
The `webfetch-github-guard.sh` hook soft-blocks WebFetch for github.com URLs:
- Suggests `gh issue view`, `gh pr view` alternatives
- Shows specific gh commands for the URL pattern
- User can override if needed
- Where itp-hooks is also installed, its WebFetch fallback guard denies every WebFetch first, so this soft-block only decides on installs without itp-hooks
**ADR**: [/docs/adr/2026-01-03-gh-tools-webfetch-enforcement.md](/docs/adr/2026-01-03-gh-tools-webfetch-enforcement.md)
## Repo Identity Guard
The `gh-repo-identity-guard.mjs` hook blocks gh CLI write operations when the authenticated user lacks push access to the target repository.
### Why it exists
An ambient `GH_TOKEN` belonging to the wrong account makes `gh` writes land under that account with no error to notice (incident 2026-02-09: a per-directory env config failed to parse and the global token won). The guard catches the mismatch before the write, not after.
### Guard Behavior
1. **Detect write commands**: `gh issue/pr/label create|comment|edit|close|delete`, `gh api -X POST/PUT/PATCH/DELETE`
2. **Extract target repo**: from `--repo`/`-R` flag, `gh api repos/owner/repo/...` path, or git remote
3. **Resolve authenticated user**: origin host-alias account (fast-path allow when it owns the target) → cache → `curl /user` API
4. **Check permission**: owner match → allow; push permission → allow; otherwise → **DENY**
### Process Safety
- **No `gh` CLI calls** — uses `curl` with `Authorization: token` header (prevents credential helper recursion / process storms)
- **Fail-open** — if API fails or token missing, allows through (gh CLI will fail itself)
- **Cache-backed** — `/tmp/.gh-identity-cache-{uid}.json` with 5-min TTL prevents repeated API calls
### Deny Message
```
[gh-identity-guard] BLOCKED: Wrong GitHub account for owner/repo
Authenticated as: username (via source)
Target repository: owner/repo
Push permission: DENIED
Fix (ADR 2026-06-21 host-alias model):
1. Check the origin alias names the right account:
git remote get-url origin # expect git@github.com-<account>:owner/repo
2. If the alias is wrong, repoint it:
git remote set-url origin git@github.com-<owner>:owner/repo.git
3. Export the matching token for this repo, then retry:
export GH_TOKEN=$(~/.claude/tools/bin/gh-token-for-repo)
```
## Discovery Provenance (Mandatory for Issue Creation)
When filing GitHub issues from a Claude Code session, **always** append a Discovery Provenance section. This links the issue back to the exact conversation and terminal recording where it was discovered.
### Format (2 rows — no redundancy)
```markdown
### Discovery Provenance
| Reference | Value |
| -------------- | ------------- |
| Session JSONL | `<full path>` |
| Asciinema Cast | `<full path>` |
```
- **Session JSONL** embeds the Claude Code session UUID in the filename — no separate "Session ID" row needed
- **Asciinema Cast** embeds the iTerm2 session UUID, termid, profile, and timestamp — no separate "iTerm2 Session" row needed
- **Date** is redundant — GitHub issues have creation timestamps
### Deterministic Lookups
**Session JSONL** — from `.session-chain-cache.json`:
```bash
ENCODED="-$(pwd | sed 's|^/||' | tr '/' '-')"
SESSION_ID=$(jq -r '.currentSessionId' ~/.claude/projects/$ENCODED/.session-chain-cache.json)
echo "$HOME/.claude/projects/$ENCODED/$SESSION_ID.jsonl"
```
**Asciinema Cast** — from `$ITERM_SESSION_ID`:
```bash
TERMID="${ITERM_SESSION_ID%:*}"
UUID="${ITERM_SESSION_ID#*:}"
ls -t ~/asciinemalogs/*."$TERMID"."$UUID".*.cast 2>/dev/null | head -1
```
### Why These Two Paths Are Sufficient
| Path | Embedded Information |
| -------------- | ----------------------------------------------------------------------------------------- |
| Session JSONL | Claude Code session UUID, project directory, full conversation transcript |
| Asciinema Cast | iTerm2 session UUID, terminal pane ID (`w0t5p2`), profile name, recording start time, PID |
No individual IDs need to be listed separately — the full paths are strictly more informative.
## Skills
- [fork-intelligence](./skills/fork-intelligence/SKILL.md)
- [issue-create](./skills/issue-create/SKILL.md)
- [issues-workflow](./skills/issues-workflow/SKILL.md)
- [pr-gfm-validator](./skills/pr-gfm-validator/SKILL.md)
- [research-archival](./skills/research-archival/SKILL.md)
- [gh-fine-grained-pat](./skills/gh-fine-grained-pat/SKILL.md) — browser-automate fine-grained PAT creation from a declarative JSON spec (GitHub exposes no API for this); has its own skill-level [CLAUDE.md](./skills/gh-fine-grained-pat/CLAUDE.md) (selector map + its hard-won gotchas)
## GitHub Operations Policy
**Use gh CLI** for all GitHub operations (see WebFetch Enforcement above).
**Install gh via Homebrew ONLY**: `brew install gh` (a version-manager-shimmed `gh` caused iTerm2 tab spawning).
**GitHub Actions**: local-first — see the user hub's Local-First CI/CD rule.
## GitHub Issues as Insight Repository
**CRITICAL**: NEVER post to upstream third-party repositories. Which owner you _may_ write to is **not** a fixed account — it is decided per filesystem path by this plugin's own `hooks/pretooluse-path-owner-guard.mjs` (registered on `PreToolUse`/`Bash` in [hooks.json](./hooks/hooks.json)), which resolves the expected owner via `resolveExpectedOwner()` in [`hooks/lib/path-owner-registry.mjs`](./hooks/lib/path-owner-registry.mjs) against the machine-readable SSoT `~/.claude/path-owner-registry.toml` (longest matching `path_prefix` wins, plus per-prefix `allow_orgs`). **That registry is the SSoT — read it, do not restate it here.** Escape hatch: `ALLOW_OWNER_MISMATCH=1`.
**Philosophy**: Treat GitHub Issues as human-readable insight repository for research and findings.
**Body Limit**: Issue bodies and comments each support **65,536 characters**. Always aim to maximize a single post — pack comprehensive analysis, multi-perspective reasoning, historical context, and all evidence into one body rather than fragmenting across multiple issues or comments. Target ~60,000 chars to leave headroom. Only split if you genuinely exceed the limit. See [issue-create SKILL.md](./skills/issue-create/SKILL.md) for the pre-post size check pattern.
### When to Post Issue Comments
| Action | Safe to Post? | Example |
| ------------------------ | --------------------------------------------- | ----------------------------------------------------------- |
| Repo under a mapped path | Yes — as that path's registered owner | `~/eon`→`terrylica`, `~/gh/<org>`→`<org>` |
| Your fork | Always | `terrylica/claude-code` (fork of `anthropics/claude-code`) |
| Upstream third-party | NEVER (read-only) | `anthropics/claude-code`, `moonrepo/proto` |
| Collaborative team repo | If the prefix's `allow_orgs` lists that owner | `~/eon/collab` external-collaborator owners |
### Best Practices
**Post validated findings to relevant GitHub Issues**:
- Comment on open OR closed issues with new insights discovered during research
- Use Issue comments to track evolving understanding (more visible than git commits)
- Reference Issue numbers in commit messages for traceability (e.g., `fix: address issue #123`)
- Create new Issues when discovering genuinely new research directions
**Upstream repositories (read-only)**:
- Search and read existing Issues for insights
- Reference upstream Issue numbers in your own repo's Issues (e.g., "Related to anthropics/claude-code#22055")
- DO NOT comment, create, or modify Issues in upstream repos
**Example workflow**:
1. Discover insight while investigating Claude Code permissions behavior
2. Search `anthropics/claude-code` for related Issues (read-only)
3. Create Issue in `terrylica/cc-skills` documenting findings
4. Reference upstream Issue: "Investigation of anthropics/claude-code#22055 revealed..."
5. Post follow-up findings as comments in YOUR repo's Issue
**Which owner applies here**: resolve it from the path, don't assume — `~/.claude/path-owner-registry.toml` is the SSoT, and `gh repo list <owner>` verifies once you know the owner.
## Multi-Account Authentication (host-alias model, ADR 2026-06-21)
This plugin respects the **host-alias single-source-of-truth**: a repo's `origin`
remote (`git@github.com-<account>:owner/repo.git`) names its account and drives the
SSH key, commit identity (`includeIf hasconfig:remote.*.url`), and gh account. The
neutral `gh` wrapper in `~/.zshrc` derives the account from that alias, sets
`GH_CONFIG_DIR=~/.config/gh-<account>`, and strips any ambient `GH_TOKEN`.
**Tokens are never injected by an env manager and never ambient.** When a token is needed
(e.g. release scripts), resolve it fresh:
```bash
GH_PAT="$(~/.claude/tools/bin/gh-token-for-repo)" # account = origin alias → that gh profile
```
**ADR**: `~/.claude/docs/adr/2025-12-17-github-multi-account-authentication.md` (§2026-06-21)
## Environment Variables
`gh` itself needs no env vars here — it resolves the account from the repo's remote
alias via the wrapper. Do **not** set `GH_TOKEN`/`GH_ACCOUNT` in any env config or the shell;
a stale ambient `GH_TOKEN` outranks the isolated profile and 401s after a rotation.
For an explicit token in a script, use `gh-token-for-repo` (above).
| `GH_ORGS` | No | Comma-separated list of orgs the current account may write to (identity guard allowlist) |
## Process Safety
The hooks avoid credential helper recursion by:
1. Using `GH_TOKEN` environment variable (not credential helpers)
2. Using `gh issue view` (read-only operations) or `curl` with `Authorization: token` header
3. Including 10-second timeout on all hook operations
## References
- [README.md](./README.md) - Full plugin documentation
- [hooks.json](./hooks/hooks.json) - Hook configuration
- [Issue Create Skill](./skills/issue-create/SKILL.md)
- [Issues Workflow Skill](./skills/issues-workflow/SKILL.md)
- [Research Archival Skill](./skills/research-archival/SKILL.md)