Skip to content

Latest commit

 

History

History
731 lines (573 loc) · 35.8 KB

File metadata and controls

731 lines (573 loc) · 35.8 KB

Config Resolution Protocol

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.

Why this exists

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.

Five mechanisms (priority order)

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.

Mechanism 1: per-invocation override flag

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/bar

Behavior:

  • 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."

Mechanism 2: Candidates list (ask_each_time)

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 candidates exists AND ask_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.

Mechanism 3.5: Submodule boundary detection (#162)

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-tree non-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 origin remote (or a non-owner/repo-shaped URL) falls back to the parent config with a warning.
  • Explicit mechanisms still win: --target (1) and matching candidates predicates (2/3) take precedence — declare a path_contains candidate 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.

Mechanism 4: Cascading config (walk-up)

The skill walks up the directory tree from cwd looking for the config file. First found wins.

Path precedence(v2.35.0+,namespace 重組):

  1. New(preferred): .claude/.idd/local.json
  2. 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):

  • $HOME directory (do not look outside the user's home)
  • The filesystem root /
  • A directory containing .git/ AND either .claude/.idd/local.json or .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.json

idd-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.).

Mechanism 3: Predicate-based auto-selection (when clauses)

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" }
    }
  ]
}

Supported predicates

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

Two-stage resolution (idd-issue)

idd-issue evaluates predicates in two passes because some need the gathered issue info:

  1. Step 0.5 pass — only path / git / branch predicates are evaluable. Match? → tentative default.
  2. 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).

First-match wins

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.

Composability

Predicates compose with the other mechanisms:

  • --target flag still overrides everything (mechanism 1)
  • ask_each_time: true still 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

Mechanism 5: git remote fallback

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.

Third-party clone detection (#192)

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.

Repo-visibility → privacy-scrubbing gate strictness (#202)

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.

Config-placement × ignore-mechanism matrix

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.

Mechanism 6 (orthogonal): Groups — multi-repo cross-linked issue creation

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"}
      ]
    }
  ]
}

Repo roles

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).

Per-group fields (optional)

{
  "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 summary
  • tracking_body_mode: "full" — tracking issues get the full body too (use when each repo needs to track full context independently)

Creation flow (idd-issue with group selected)

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 --target for the missing ones

Triggering a group

Groups are picked just like candidates:

  • Predicate match: groups[].when matches 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.

Schema (full, extended)

{
  "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.

pr_policy field

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.

collaborators[] field

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.

auto_tag field

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.

sdd_bias field

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。

verify_profiles field

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 比失敗更糟)

Resolution algorithm (canonical)

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.

When skills should write back to config

Only idd-issue writes back to config, and only in these specific cases:

  1. 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.json MUST NOT be written for new installs)
  2. 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.

Implementing in a skill

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.

Edge cases

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.

Migration from v2.22.x

No migration needed. Existing single-target configs continue to work. Users can opt into:

  • candidates + ask_each_time for multi-target prompting (Phase 1, v2.23.0+)
  • Multiple per-directory .local.json files for cascading (Phase 1, v2.23.0+)
  • --target flag for one-off overrides (Phase 1, v2.23.0+)
  • candidates[].when predicates for auto-routing (Phase 2A, v2.24.0+)
  • groups for multi-repo coordinated issues (Phase 2B, v2.25.0+)

These are all additive.

Worked examples

Example 1: Monorepo with sub-package routing by path

// ~/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.

Example 2: Same path, route by issue topic

{
  "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.

Example 3: Cross-package bug reported in 3 repos

{
  "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:

  1. Creates primary issue in PsychQuant/foo#42
  2. Creates tracking issue in bar#15, body starts Tracking primary: PsychQuant/foo#42
  3. Creates tracking issue in glue#8, body starts Tracking primary: PsychQuant/foo#42
  4. Comments on foo#42: Tracked in: PsychQuant/bar#15, PsychQuant/glue#8