This file is the single source of truth for how every idd-* skill resolves the target GitHub repo and related settings.
All idd-* skills MUST follow this protocol. Where individual skill SKILL.md files refer to "reading .claude/issue-driven-dev.local.json", they implicitly mean the algorithm here.
Issue-driven development frequently runs in monorepos or nested project structures where:
- Different sub-packages have different upstream GitHub repos
- The same directory might want to send issues to multiple repos depending on the issue topic
- Sometimes the user wants a one-off override without changing config
A single cwd-only config check is insufficient. This protocol defines three composable mechanisms.
When an idd-* skill needs to determine the target repo, it walks this priority list and uses the first match:
1. --target <owner/repo> flag ← runtime override (per invocation)
2. ask_each_time + candidates ← runtime menu (from config)
3. Predicate match (when clauses) ← auto-pick candidate or group by context
4. Cascading config (walk up) ← static routing by directory
5. git remote fallback ← last resort, with prompt
In addition, groups (multi-repo cross-linked issue creation) are an orthogonal feature that may be triggered at mechanism 2 or 3, regardless of which routing layer applied.
Skills accept a runtime flag to override target resolution for one invocation:
| Skill | Flag | Accepted values |
|---|---|---|
idd-issue |
--target |
owner/repo OR group:<label> (groups only meaningful for idd-issue) |
idd-list, idd-diagnose, idd-update, idd-verify, idd-close, idd-implement, idd-edit, idd-report |
--repo |
owner/repo only |
idd-comment |
--repo |
owner/repo only — --target already means "link target issue" |
/idd-issue --target PsychQuant/foo
/idd-issue --target group:cross-package-bug
/idd-list --repo owner/monorepo
/idd-diagnose #42 --repo PsychQuant/barBehavior:
- Use the value directly for this invocation
- Do NOT write back to config
- Do NOT trigger the AskUserQuestion menu
- This is a one-off — the next invocation goes back to normal resolution
Why two flag names: only idd-issue resolves to a group (vs. a single repo). Sibling skills always operate on one repo, so --repo is more natural and avoids collision with idd-comment's pre-existing --target (link target). The semantic intent is identical — both are "use this repo, ignore config for this invocation."
A single config can list multiple candidate repos. When ask_each_time: true, the skill always prompts.
{
"github_repo": "owner/default-repo",
"github_owner": "owner",
"attachments_release": "attachments",
"candidates": [
{"label": "Outer monorepo", "github_repo": "owner/monorepo", "github_owner": "owner", "attachments_release": "attachments"},
{"label": "Sub: vibe-mixing", "github_repo": "PsychQuant/vibe-mixing", "github_owner": "PsychQuant", "attachments_release": "attachments"},
{"label": "Sub: che-word-mcp", "github_repo": "PsychQuant/che-word-mcp", "github_owner": "PsychQuant", "attachments_release": "attachments"}
],
"ask_each_time": true
}Behavior:
- If
candidatesexists ANDask_each_time: true, use AskUserQuestion to let user pick - Each candidate inherits unset fields (
github_owner,attachments_release) from the top-level config if not provided - The chosen candidate's fields are used for that invocation only — config is NOT modified
If ask_each_time is false or missing, the top-level github_repo wins by default. Candidates are ignored unless the user supplies --target matching a candidate label or repo.
The skill walks up the directory tree from cwd looking for the config file. First found wins.
Path precedence(v2.35.0+,namespace 重組):
- New(preferred):
.claude/.idd/local.json - Legacy(backward compat):
.claude/issue-driven-dev.local.json
Walk-up checks both at every level — new path takes precedence at the same level. If only legacy is found, skill continues normally but MUST print a one-line migration hint:
ℹ Found legacy config at <path>. Consider running:
mkdir -p <dir>/.claude/.idd && mv <path> <dir>/.claude/.idd/local.json
(legacy path will keep working, but new format is .claude/.idd/local.{md,json})
Note: this happens before mechanisms 2 and 3 in execution order — we need to find the config before we can read candidates / evaluate predicates. The numbering reflects logical priority, not execution order.
Stop boundaries (whichever comes first):
$HOMEdirectory (do not look outside the user's home)- The filesystem root
/ - A directory containing
.git/AND either.claude/.idd/local.jsonor.claude/issue-driven-dev.local.json(treat as repo boundary)
Walk-up algorithm (bash):
find_idd_config() {
local dir="$PWD"
while [ "$dir" != "/" ] && [ "$dir" != "$HOME/.." ]; do
# New path wins at the same directory level
if [ -f "$dir/.claude/.idd/local.json" ]; then
echo "$dir/.claude/.idd/local.json"
return 0
fi
if [ -f "$dir/.claude/issue-driven-dev.local.json" ]; then
echo "$dir/.claude/issue-driven-dev.local.json"
# Skill should print migration hint when this branch fires
return 0
fi
[ "$dir" = "$HOME" ] && break # don't go above $HOME
dir=$(dirname "$dir")
done
return 1
}Migration command(使用者一次搬完):
cd <repo-root>
mkdir -p .claude/.idd
[ -f .claude/issue-driven-dev.local.json ] && \
mv .claude/issue-driven-dev.local.json .claude/.idd/local.json
[ -f .claude/issue-driven-dev.local.md ] && \
mv .claude/issue-driven-dev.local.md .claude/.idd/local.md
[ -f .claude/state/idd-bridge.json ] && \
mkdir -p .claude/.idd/state && \
mv .claude/state/idd-bridge.json .claude/.idd/state/bridge.jsonidd-config skill 在 v2.35.0+ 會偵測 legacy 路徑並 offer auto-migrate。
Example:
~/Developer/big-monorepo/.claude/issue-driven-dev.local.json → owner/monorepo
~/Developer/big-monorepo/packages/foo/.claude/issue-driven-dev.local.json → PsychQuant/foo
# Running from various cwds:
~/Developer/big-monorepo/ → finds ./.claude/... → owner/monorepo
~/Developer/big-monorepo/packages/foo/ → finds ./.claude/... → PsychQuant/foo
~/Developer/big-monorepo/packages/foo/src/lib/ → walks up to packages/foo → PsychQuant/foo
~/Developer/big-monorepo/docs/ → walks up to monorepo → owner/monorepo
This is the standard monorepo tooling pattern (eslintrc, tsconfig, prettier, etc.).
Each candidate (and each group, see below) MAY have a when clause. When present, the skill evaluates predicates against the current invocation context and picks the first matching candidate as the default for this run.
{
"github_repo": "owner/default",
"candidates": [
{
"label": "Music workspace",
"github_repo": "kiki/music-notes",
"when": { "path_contains": "creative/music" }
},
{
"label": "Vibe mixing",
"github_repo": "PsychQuant/vibe-mixing",
"when": { "path_contains": "vibe-mixing" }
},
{
"label": "Plugin marketplace (auto by title)",
"github_repo": "PsychQuant/psychquant-claude-plugins",
"when": { "title_matches": "(?i)\\b(plugin|mcp|skill)\\b" }
}
]
}| Predicate | Type | Evaluable when | Notes |
|---|---|---|---|
path_contains |
string | Step 0.5 | Substring match on absolute cwd path |
path_matches |
glob | Step 0.5 | Glob (* ** ?) match on absolute cwd |
git_remote_matches |
regex | Step 0.5 | Regex match on git remote get-url origin |
git_branch_matches |
regex | Step 0.5 | Regex match on current branch name |
label_in |
array of strings | After Step 2 (idd-issue) / immediately (idd-edit etc.) | Any chosen label matches |
type_in |
array of strings | After Step 2 (idd-issue) | Issue type matches |
title_matches |
regex | After Step 2 (idd-issue) | Regex on issue title |
body_matches |
regex | After Step 2 (idd-issue) | Regex on issue body |
priority_in |
array of strings | After Step 2 (idd-issue) | P0/P1/P2/P3 |
all |
array of predicates | mixed | All sub-predicates must match (logical AND) |
any |
array of predicates | mixed | At least one sub-predicate must match (logical OR) |
not |
single predicate | mixed | Negate |
idd-issue evaluates predicates in two passes because some need the gathered issue info:
- Step 0.5 pass — only path / git / branch predicates are evaluable. Match? → tentative default.
- After Step 2 pass — title / type / label / body / priority predicates become evaluable. If a candidate with content predicates now matches AND the tentative default has lower-priority match, prompt user to confirm switching.
For other skills (idd-list, idd-comment, etc.), only Step 0.5 predicates apply (they don't gather issue content the same way).
Candidates are evaluated in order. First one whose when evaluates true is the tentative default. If none matches, fall through to top-level github_repo.
To express "default fallback" as a candidate (so it appears in the menu), include one with no when clause as the last entry — it always matches.
Predicates compose with the other mechanisms:
--targetflag still overrides everything (mechanism 1)ask_each_time: truestill prompts, but preselects the predicate-matched candidate- Walk-up still applies first to find the config file; predicates evaluate against the contents of whichever config was found
If no config is found anywhere on the path:
ORIGIN=$(git remote get-url origin 2>/dev/null | sed -E 's#(\.git)?$##; s#.*[:/]([^/]+/[^/]+)$#\1#')Then run the fork-aware + third-party detection (only idd-issue does this; other skills just use origin or prompt). Resolution order (first match wins): E2 fork → E-TP third-party → E1 own.
A third-party clone is a clone of a repo you neither own nor can push to (reference / tutorial / study material — e.g. git clone of someone else's public repo). It is IS_FORK=false yet not yours, so it would otherwise fall into E1 and silently route issues to the original author's public tracker + write IDD config into their working tree.
Detection is hybrid — owner-mismatch as a cheap pre-filter, repo permission as the decisive signal, with the permission folded into the same gh repo view call (no extra round-trip):
ORIGIN_OWNER="${ORIGIN%%/*}"
SELF_LOGIN=$(gh api user --jq .login 2>/dev/null)
# viewerPermission came from `gh repo view "$ORIGIN" --json isFork,parent,viewerPermission`
IS_THIRD_PARTY=false
if [ -n "$SELF_LOGIN" ] && [ "$ORIGIN_OWNER" != "$SELF_LOGIN" ]; then
case "$VIEWER_PERMISSION" in
WRITE|MAINTAIN|ADMIN) IS_THIRD_PARTY=false ;; # org / collaborator you can push to
*) IS_THIRD_PARTY=true ;; # READ/TRIAGE/NONE/probe-fail → fail-safe
esac
fi- fork precedence: E2 (fork) is evaluated before third-party. A fork is also owner-mismatch + often no-push, but carries its own contributor/customization/divergent semantics — judging it first prevents a double-prompt.
- fail-safe: if the permission probe is unavailable (auth scope / rate limit / API error) after owner-mismatch matched, default to third-party (prompt) rather than silently using origin.
When third-party is detected, idd-issue Step 0.5.E presents a 3-option routing (Upstream w/ public-visibility warning / your own tracking repo via --target, never auto-created / local-only) and applies the placement defaults below.
Where IDD config lives and how it is ignored depends on the situation:
| Situation | config location | ignore mechanism |
|---|---|---|
| Your own repo | .claude/.idd/local.json, committed or globally ignored |
per team convention |
| third-party clone | .claude/.idd/local.json (local) |
.git/info/exclude ignores .claude/.idd/ — per-clone, never committed/pushed, never touches the upstream's tracked .gitignore |
| monorepo / nested | walk-up config placed above the git repo (non-git parent) | same |
For third-party clones the config also defaults to "pr_policy": "never" (no push permission → local direct-commit; see idd-all Phase 0.5). The ignore rule is written via the shared scripts/git-ignore-block.sh primitive (direction=exclude). Editing the upstream's tracked .gitignore is forbidden — it would stack a commit on their history (= pollution); .git/info/exclude lives inside .git/ and never leaves the clone.
A group is a single logical issue spread across multiple repos. The user picks the group; idd-issue creates one primary issue and one tracking issue in each other repo, with bidirectional cross-references.
{
"candidates": [...],
"groups": [
{
"label": "Cross-package bug",
"repos": [
{"github_repo": "PsychQuant/foo", "role": "primary"},
{"github_repo": "PsychQuant/bar", "role": "tracking"},
{"github_repo": "PsychQuant/glue", "role": "tracking"}
],
"when": { "label_in": ["cross-package"] }
},
{
"label": "MCP + plugin marketplace",
"repos": [
{"github_repo": "PsychQuant/che-word-mcp", "role": "primary"},
{"github_repo": "PsychQuant/psychquant-claude-plugins", "role": "tracking"}
]
}
]
}| Role | Behavior |
|---|---|
primary |
The "real" issue. Created first. Contains the full body. The other tracking issues link back here. Exactly one repo per group must be primary. |
tracking |
Lightweight tracking issue. Body starts with Tracking primary: owner/repo#N and a one-line summary (or full description copy — configurable). |
{
"label": "...",
"repos": [...],
"when": { ... },
"tracking_body_mode": "minimal" // or "full" — default "minimal"
}tracking_body_mode: "minimal"— tracking issues just say "Tracking primary: X#N" + one-line summarytracking_body_mode: "full"— tracking issues get the full body too (use when each repo needs to track full context independently)
1. Create primary issue in primary.github_repo → get #N
2. For each tracking repo:
- Create issue with body starting:
> Tracking primary: {primary.github_repo}#{N}
> {summary}
3. Add comment to primary issue listing all tracking issues:
"Tracked in:
- {tracking-repo-1}#{Na}
- {tracking-repo-2}#{Nb}"
4. Report all issue URLs to user
If any creation fails partway:
- Don't roll back already-created issues (manual cleanup is more transparent)
- Report which succeeded and which failed
- User can retry with
--targetfor the missing ones
Groups are picked just like candidates:
- Predicate match:
groups[].whenmatches the context → preselect that group - Menu:
ask_each_time: true→ groups appear alongside candidates in the picker --target group:<label>→ directly trigger a named group from CLI
If a group's when matches AND a candidate's when matches, groups take precedence (because they express a stronger intent — multi-repo coordination). Both can show in the menu when ask_each_time: true.
{
"github_repo": "owner/repo", // REQUIRED. Default target (fallback when no predicate matches).
"github_owner": "owner", // OPTIONAL. Derived from github_repo if missing.
"attachments_release": "attachments", // OPTIONAL. Default "attachments".
"tracking_upstream": "upstream/repo", // OPTIONAL. Set when fork-aware detection chose Both mode.
"candidates": [ // OPTIONAL. Multi-target list.
{
"label": "Human-readable name",
"github_repo": "owner/repo",
"github_owner": "owner", // Optional, derives from github_repo
"attachments_release": "attachments", // Optional, defaults to top-level
"when": { // OPTIONAL (Phase 2A). Predicates for auto-selection.
"path_contains": "creative/music"
// or "path_matches", "git_remote_matches", "title_matches",
// "label_in", "type_in", "all", "any", "not", etc.
}
}
],
"groups": [ // OPTIONAL (Phase 2B). Multi-repo coordinated issues.
{
"label": "Cross-package bug",
"repos": [
{"github_repo": "PsychQuant/foo", "role": "primary"},
{"github_repo": "PsychQuant/bar", "role": "tracking"}
],
"when": { "label_in": ["cross-package"] }, // OPTIONAL
"tracking_body_mode": "minimal" // OPTIONAL. "minimal" or "full". Default "minimal".
}
],
"ask_each_time": false, // OPTIONAL. If true and candidates/groups exist, always prompt.
"pr_policy": "ask" // OPTIONAL. "always" | "never" | "ask" (default).
// Controls idd-implement PR vs direct-commit path.
// Fork detection always overrides to "always".
// See references/pr-flow.md for full algorithm.
}Backward compatibility: configs without candidates / groups / ask_each_time / pr_policy work exactly as before — they're plain single-target configs. All new fields are additive.
Controls whether idd-implement opens a PR or commits directly.
| Value | Behavior |
|---|---|
always |
Feature branch + push + gh pr create. Same as passing --pr every time. |
never |
Stay on current branch, no push, no PR. Same as --no-pr. Suits solo repos. |
ask (default) |
Prompt via AskUserQuestion on first invocation; cache within conversation. |
Override priority (highest first): --pr / --no-pr flag > fork detection > pr_policy config > ask default.
idd-all always enforces --pr regardless of pr_policy. Full path contract: pr-flow.md.
function resolve_target(invocation_args, context):
# 1. CLI flag wins
if invocation_args has --target T:
if T starts with "group:":
return Group(by_label=T[6:], from=walked_up_config)
return Single(T) # do not touch config
# 2. Walk up to find closest config
config = find_idd_config()
if config is null:
return git_remote_or_prompt()
# 3. Predicate match (Phase 2A) — try groups first, then candidates
matched_group = first(g in config.groups where evaluate(g.when, context))
matched_cand = first(c in config.candidates where evaluate(c.when, context))
tentative_default = matched_group or matched_cand or config.github_repo
# 4. Ask if explicit
if config.ask_each_time:
chosen = AskUserQuestion(
options = config.candidates ++ config.groups,
preselect = tentative_default
)
return chosen # may be Single or Group
# 5. Otherwise use the tentative default
if tentative_default is a Group:
return Group(tentative_default.repos)
return Single(tentative_default.github_repo)
# Two-stage for idd-issue: re-evaluate after issue info gathered
function reresolve_after_step2(initial, step2_context):
if initial was forced (--target) or chosen by ask_each_time:
return initial # respect explicit user choice
# Re-evaluate predicates with title/type/label/body now known
new_match = first(c in config.candidates where evaluate(c.when, full_context))
if new_match exists AND new_match != initial:
if AskUserQuestion("Switch to {new_match}?", default=yes):
return new_match
return initial
When Single(T) is returned: ordinary issue creation in repo T.
When Group(repos) is returned: see Mechanism 6 — primary + tracking issues with cross-linking.
Only idd-issue writes back to config, and only in these specific cases:
- First-run fork detection chose a target and there was no prior config — write the chosen target to
$PWD/.claude/.idd/local.json(v2.35.0+; legacy.claude/issue-driven-dev.local.jsonMUST NOT be written for new installs) - First-run non-fork auto-resolved origin — write to
$PWD/.claude/.idd/local.json
When writing config for the first time, also create $PWD/.claude/.idd/local.md with a brief frontmatter describing the resolution(matches the legacy .md companion file convention).
In all other cases (--target override, candidates pick, walk-up to existing config), the config is read-only.
A skill SKILL.md should reference this protocol rather than re-explaining the algorithm:
## Configuration
Reads target repo per [config-protocol.md](../../references/config-protocol.md).
Supports `--target owner/repo` flag for one-off override.If the skill needs to know specific resolution behavior beyond reading github_repo, document that diff explicitly.
| Case | Behavior |
|---|---|
Multiple .claude/issue-driven-dev.local.json on path |
Closest to cwd wins (walk-up stops at first) |
cwd is outside $HOME (e.g. /tmp) |
Walk up stops at /; if no config found, falls to mechanism 5 |
candidates exists but ask_each_time: false AND no when matches |
Top-level github_repo is used; candidates available only via --target <label-or-repo> |
--target matches a candidate's label |
Use that candidate's fields |
--target is owner/repo not in candidates |
Use it directly with default attachments_release |
--target group:<label> |
Look up named group in walked-up config; if not found, error |
Config exists but github_repo empty |
Treat as "config missing", fall through to mechanism 5 |
User on a fork with tracking_upstream set |
Continue routing per the field (idd-issue handles cross-linking; other skills use github_repo only) |
Multiple candidates' when clauses match |
First (in array order) wins; document this in user-facing error if ambiguity is suspected |
A group's when matches AND a candidate's when matches |
Group wins (stronger intent — multi-repo coordination) |
Group has zero primary or multiple primary |
Validation error; refuse to create. Log clear message. |
| Group's primary creation succeeds but tracking fails | Do NOT roll back. Report which succeeded; user retries failed ones with --target |
| Predicate references content (title/label) at Step 0.5 | Skip — only re-evaluable at Step 2 stage. Falls through to default for Step 0.5 pass. |
No migration needed. Existing single-target configs continue to work. Users can opt into:
candidates+ask_each_timefor multi-target prompting (Phase 1, v2.23.0+)- Multiple per-directory
.local.jsonfiles for cascading (Phase 1, v2.23.0+) --targetflag for one-off overrides (Phase 1, v2.23.0+)candidates[].whenpredicates for auto-routing (Phase 2A, v2.24.0+)groupsfor multi-repo coordinated issues (Phase 2B, v2.25.0+)
These are all additive.
// ~/Developer/big-monorepo/.claude/issue-driven-dev.local.json
{
"github_repo": "owner/big-monorepo",
"candidates": [
{
"label": "Music sub-package",
"github_repo": "owner/music",
"when": { "path_contains": "/packages/music" }
},
{
"label": "API sub-package",
"github_repo": "owner/api",
"when": { "path_contains": "/packages/api" }
}
]
}cd ~/Developer/big-monorepo/packages/music/src && /idd-issue → auto-routes to owner/music.
cd ~/Developer/big-monorepo/docs && /idd-issue → no match, falls to top-level owner/big-monorepo.
{
"github_repo": "owner/main",
"candidates": [
{
"label": "Plugin marketplace",
"github_repo": "PsychQuant/psychquant-claude-plugins",
"when": { "title_matches": "(?i)\\b(plugin|skill|hook)\\b" }
},
{
"label": "MCP server",
"github_repo": "PsychQuant/che-word-mcp",
"when": { "all": [
{ "label_in": ["mcp"] },
{ "title_matches": "(?i)word|docx" }
]}
}
]
}Predicates evaluated in two passes — Step 0.5 sees nothing matches (no path predicates), so tentative default = owner/main. After Step 2 gathers title/labels, re-evaluation kicks in. If user typed a title with "plugin", confirm switching to psychquant-claude-plugins.
{
"github_repo": "PsychQuant/foo",
"groups": [
{
"label": "Cross-stack: foo+bar+glue",
"repos": [
{"github_repo": "PsychQuant/foo", "role": "primary"},
{"github_repo": "PsychQuant/bar", "role": "tracking"},
{"github_repo": "PsychQuant/glue", "role": "tracking"}
],
"when": { "label_in": ["cross-package"] }
}
],
"ask_each_time": false
}User runs /idd-issue, attaches label cross-package. Re-resolve picks the group:
- Creates primary issue in
PsychQuant/foo→#42 - Creates tracking issue in
bar→#15, body startsTracking primary: PsychQuant/foo#42 - Creates tracking issue in
glue→#8, body startsTracking primary: PsychQuant/foo#42 - Comments on
foo#42:Tracked in: PsychQuant/bar#15, PsychQuant/glue#8