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
3.5 Submodule boundary (#162) ← cwd inside a submodule → its own origin
overrides the parent config's github_repo
(unless "submodules": "off"); never silent
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.
When the walk-up (mechanism 4) resolves a config that lives in a parent
repo while cwd is inside a git submodule, the parent's github_repo
would silently target the wrong repo (the pain point that motivated #162).
Resolution therefore consults scripts/lib/resolve-submodule-route.sh before
falling back to the config's bare github_repo:
git rev-parse --show-superproject-working-treenon-empty → inside a submodule- config key
"submodules"(optional):"auto"(default) routes to the submodule's own origin;"off"keeps the parent config's routing - Never silent: whichever way it routes, a surface line is printed when a
submodule boundary is crossed. A submodule without an
originremote (or a non-owner/repo-shaped URL) falls back to the parent config with a warning. - Explicit mechanisms still win:
--target(1) and matchingcandidatespredicates (2/3) take precedence — declare apath_containscandidate in the parent config to pin a submodule to any repo regardless of this mechanism.
Backward-compat note: repos that relied on "submodule cwd routes to the
parent repo" must set "submodules": "off" (one line). The default flips to
the submodule because the silent-wrong-repo direction is the bug class.
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 AND isPrivate both come from the SAME
# `gh repo view "$ORIGIN" --json isFork,parent,viewerPermission,isPrivate`
# call (no extra round-trip; isPrivate folded in for the privacy-scrubbing gate, #202).
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.
The same gh repo view classification also drives the privacy-scrubbing gate
(openspec change add-privacy-scrubbing-gate, rules/privacy-scrubbing.md),
which every IDD skill runs before gh issue create/comment/edit egress. The
isPrivate field is added to the existing --json list (isFork,parent,viewerPermission,isPrivate)
— zero extra round-trip, same fold-in technique as the #192 permission probe:
| Visibility | Predicate | Gate level |
|---|---|---|
| third-party | viewerPermission ∉ {WRITE, MAINTAIN, ADMIN} (reuses the detection above, incl. fail-safe) |
ENFORCE — block-with-diff + require confirm |
| own-public | you have write access AND isPrivate=false |
WARN — flag, default proceed |
| private | isPrivate=true |
LIGHT — no block on ordinary identifiers |
Adding isPrivate resolves the push-permission proxy residue flagged at
add-third-party-clone-setup/design.md:145: push-permission alone conflated
own-public with own-private, so the gate could not differentiate their strictness.
With isPrivate folded in, own-public → WARN and private → LIGHT split cleanly.
(The semantic residue — "is this name/content private?" — is separate and stays
LLM-judgment; see the change's Open Question Q2.)
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.
"collaborators": [ // OPTIONAL. Identity registry — resolve a person's
{ // alias / email / display-name → GitHub @login WITHOUT guessing.
"github_login": "hardy1yang", // REQUIRED. Canonical handle — the only string GitHub notifies.
"display_name": "Hau-Hung Yang", // REQUIRED. Real name (may be 中文), echoed back on resolve.
"role": "collaborator", // OPTIONAL. maintainer | collaborator | advisor | external.
"aliases": ["hardy", "楊浩弘", "s1093301"],// OPTIONAL. Nicknames / student IDs / romanizations for fuzzy match.
"email": "hardy@example.edu" // OPTIONAL, PII — private/gitignored config ONLY, never committed/public.
}
]
}Backward compatibility: configs without candidates / groups / ask_each_time / pr_policy / collaborators 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.
An OPTIONAL identity registry so IDD can resolve a person's alias / email / display-name → their GitHub @login without guessing — the hard rule set by tagging-collaborators.md. It is a resolution accelerator, not an authority: a table hit is still existence-verified via gh api users/<login> before any mention is posted, because the table can go stale.
| Field | Req? | Meaning |
|---|---|---|
github_login |
✅ | Canonical GitHub handle — the only string GitHub actually notifies. Charset A-Za-z0-9-. |
display_name |
✅ | Real name (may be 中文), echoed back to the user on resolve as a sanity check. |
role |
— | maintainer | collaborator | advisor | external. Informational only. |
aliases |
— | Nicknames / student IDs / romanizations for fuzzy input matching. Unique across the whole registry. |
email |
— | PII. See boundary below. |
PII boundary (important). email is personally-identifiable and MUST NOT live in a committed / public config. Keep the non-PII fields (github_login / display_name / role / aliases) in the normal walked-up config; put email only in a private / gitignored config layer. idd-config validate emits a PII reminder whenever it sees an email in a registry entry, so a leak into a tracked config is surfaced early. This mirrors the git-privacy boundary: a person's raw email is third-party PII, not your own derivative content.
An OPTIONAL object that turns on selective git-tag automation at two high-value IDD milestones (#85): idd-issue tags the rollback baseline, idd-verify tags the review-ready snapshot. Deliberately selective — only these two points get a tag, so a long-running repo doesn't accumulate hundreds of stale lifecycle tags.
{
"auto_tag": {
"enabled": true,
"baseline_format": "idd-{N}-baseline",
"verified_format": "idd-{N}-verified",
"push_remote": "origin"
}
}| Field | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch. Default-ON: with no config, idd-issue / idd-verify create + push these tags automatically. Set enabled: false to opt out completely. |
baseline_format |
idd-{N}-baseline |
Tag placed by idd-issue on the local default-branch HEAD at issue-open time (the rollback anchor). {N} = issue number. |
verified_format |
idd-{N}-verified |
Tag placed by idd-verify on the Aggregate-PASS snapshot (PR head in PR mode, current HEAD in local mode). |
push_remote |
origin |
Remote the tags are pushed to. |
Side-effect note (important). Tag creation includes a git push to push_remote — a repo-wide side effect visible to everyone with the remote. Because the default is ON, a fresh IDD user gets idd-{N}-baseline / idd-{N}-verified tags on their remote without asking; enabled: false is the one-line opt-out. Tagging is idempotent (a tag that already exists is skipped, so re-runs never re-tag) and graceful-skip (a push failure on a fork / permission-denied / rejected remote warns and continues — it never aborts the idd-issue / idd-verify workflow). Intermediate lifecycle steps (diagnose / plan / implement) are deliberately not tagged; phase-level tagging stays manual.
An OPTIONAL string that shifts where a complexity hard-gate hit escalates to (#252; gate semantics in rules/sdd-integration.md). Default routing sends a hard-gate hit to Plan; a repo that wants spec records for every gate-worthy change opts in with:
{
"sdd_bias": "high"
}| Value | Hard-gate hit escalates to |
|---|---|
absent / "default" |
Plan — shipped behavior, byte-identical to pre-#252 |
"high" |
Spectra — verdict line reads Spectra via hard-gate (sdd_bias) |
| anything else | treated as default silently (absent-safe: same walk-up first-match read as every other field; no abort, no warning noise) |
Scope guarantee. The field acts only at the hard-gate exit (idd-diagnose Step 3.5): it never widens Layer 2's Spectra qualification, never touches Layer P signals or the Simple default, and has zero effect when the gate does not fire.
Trade-off note(流程重量). sdd_bias: high 買到的是「每個 gate-worthy 改動都有 spec 紀錄」,付出的是流程重量 — 每次硬閘命中都走 proposal / design / spec / tasks 全套 artifacts,而不是單一 plan-mode approval。預設不翻轉:「over-trigger 成本高於 under-plan 殘餘風險」是 #129 的既有使用者裁決,本欄位是 opt-in 的團隊姿態(spec-first posture),不是新 baseline。
An OPTIONAL object registering repo-local custom verification profiles for idd-verify --profile (#258; profile semantics + built-in profiles in verify-profiles.md):
{
"verify_profiles": {
"clinical": {
"lenses": [
{"key": "phi-leak", "focus": "protected health information leaving the trust boundary ..."},
{"key": "fact-vs-chart", "focus": "claims unsupported by the chart / source records ..."}
],
"da_focus": "adversarially refute the other reviewers' passes ...",
"input": "file"
}
}
}| Rule | Behavior |
|---|---|
名稱與內建(code / prose / academic)碰撞 |
內建勝 + warning(內建是行為契約,特別是 code 的 backward-compat 保證,config 不得靜默改寫) |
schema 不合(lenses 空 / 缺 da_focus / input ∉ {file,git}) |
該 entry 忽略 + warning(absent-safe,不 abort) |
--profile <name> 內建與 config 都查無 |
abort + 可用 profile 清單(fail-loud — 顯式 per-invocation 意圖,typo 靜默跑錯 profile 比失敗更糟) |
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