Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/issue-driven-dev/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "issue-driven-dev",
"description": "v2.102.2: Deep Research light integration (#277, ruling b). idd-diagnose gains a non-binding pointer (the #111 superpowers hand-off shape: pure suggestion, no presence check, no dependency) fired when the diagnosis's quality depends on facts OUTSIDE the repo — with trigger examples AND counter-examples (the overly-broad-signal risk). Output flows back via '/idd-comment --type note' as summary + link, never full text (#116) — that is what keeps external research inside the audit trail. Both real-user misconceptions get canonical answers where they lived: research attaches AT diagnose (not after plan), and research vs implement are different phases' work, not substitutes. usecase-routing scenario 32 + a three-row internal-corpus vs external-world boundary table (idd-find / idd-ask / Deep Research). Deep integration stays a recorded residue until a plugin-dependable primitive exists.",
"version": "2.107.0",
"version": "2.108.0",
"author": {
"name": "Che Cheng"
},
Expand Down
24 changes: 24 additions & 0 deletions plugins/issue-driven-dev/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.108.0] - 2026-08-14

### Added

- **Global config layer + a derived repo map (#302)** — every resolution mechanism was single-point: it resolved one
repo and could see nothing about the relationships between them. So when a rule says "this belongs to the project
layer", nothing could answer the prior question — **does that layer exist here?** 「地方政府在不在」. If it does not,
resolution falls through to whatever `cwd` happens to be. `~/.claude/.idd/global.json` is the global layer (that path
is already on the walk-up route, so it costs no new scanning logic, and the filename must be `global.json` — calling
it `local.json` would make `$HOME` read as "a repo" and poison the boundary check). `scripts/idd-repo-map.sh`
derives the map from the filesystem on every run and is **never stored as a source of truth**: a hand-maintained
registry goes stale, and staleness is the disease — #301's recorded case was caused by a project being extracted into
its own repo with nothing anywhere updated. It recognises **both** config formats, because an unseen config does not
mean "one fewer row"; it means "this layer does not exist" and triggers the wrong upward resolution.

- **Residual clause for target resolution (#301)** — resolution had only two routes, mechanical `cwd` walk-up or a
hand-written predicate, and neither answers "which repo does this belong to **by its nature**". The clause takes the
shape of ROC Constitution Art. 111 (事務有全國一致之性質者屬於中央,有因地制宜之性質者屬於地方): repo-specific → that
repo, toolchain-wide → the tool's repo, machine-wide → global. Order matters and cannot be swapped — **check the map
first**: a verdict of "project layer" is useless if that project has no tracker, which is exactly why this was blocked
on #302. When the layer is absent, resolution moves up but **stops at the current git repo boundary** (user: 「上收其實
通常會到我現在正在開起的專案的git,要跨過的話通常會需要特別指定」); crossing it requires an explicit `--repo`. Still
undecidable → `AskUserQuestion`, never a guess: an issue filed into the wrong repo is not something anyone notices.

## [2.107.0] - 2026-08-14

### Added
Expand Down
43 changes: 43 additions & 0 deletions plugins/issue-driven-dev/references/config-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -744,3 +744,46 @@ User runs `/idd-issue`, attaches label `cross-package`. Re-resolve picks the gro
**支援承諾(明文,因為缺這句話本身就是問題)**:legacy 路徑的**讀取**支援不設移除日期,但**不保證新工具會實作它**。任何新的 config 掃描器只需要認 current 路徑;若它同時認 legacy,那是選配。

**為什麼這句話必須寫下來**:#195 只遷了寫入端,於是「既沒有遷移、也沒有承諾永久支援」—— 三種可能狀態裡最差的一種。下游無法規劃:每個新的掃描器都得自己決定要不要寫兩份解析,而漏認一種格式的語意(在 #302 的 repo 地圖裡)是「該層不存在」,會觸發**錯誤的上收**。實測一台使用中的機器有 **32 個 repo** 仍在 legacy 路徑上。

## Global 層與 residual clause(#302 / #301)

### Global config:`~/.claude/.idd/global.json`

**位置的四個理由**(考慮過 `~/.idd/`,不採用):

1. walk-up **已經**會經過 `$HOME/.claude/.idd/` —— 終止條件的檢查在 break 之前,所以這條路徑本來就在讀取路徑上,只需要多認一個檔名,零新增掃描邏輯。
2. 與 project 層的 `.claude/.idd/local.json` 完全對稱,只差 `local` / `global`。
3. IDD 是 Claude Code plugin,`~/.claude/` 是它的生態家(`settings.json`、`rules/`、`plugins/` 都在此);另開 `~/.idd/` 等於在 home 再放一個 dotdir。
4. 檔名**必須**是 `global.json` 而非 `local.json` —— 後者會讓 `$HOME` 被誤讀成「一個 repo」,汙染既有的 repo-boundary 判定。

生態先例:同一套工具鏈的 `codex-pro` 已採 `~/.codex-pro/profile.yaml`(global)+ `<project>/.codex-pro/profile.yaml`(project)雙層 resolve。本案同模式,只是寄生在 `~/.claude/` 之下。

### 地圖:自動推導,永不手寫

`scripts/idd-repo-map.sh`(`--json` 可供程式消費)每次從檔案系統重算,**不存成 source of truth**。

**為什麼不能手寫**:手寫 registry 會 stale,而 stale 正是這個問題的病因 —— #301 記錄的那條分散案例,成因就是某 project 中途被 extract 成獨立 repo 而**沒有任何地方被更新**。手寫地圖只是把同一種病從 walk-up 搬到 registry。

**掃描器必須認兩種 config 格式**(#303)。漏認一種**不是「少一筆」** —— 沒被看到的 config 語意上等於「該層不存在」,會觸發**錯誤的上收**。

### Residual clause:未列舉事項依「事務性質」歸屬(#301)

現行 target resolution 只有兩條路:機械地依 `cwd` walk-up 找最近的 config,或依賴有人**事先寫好** predicate。兩者都答不出「這件事**在性質上**該歸哪個 repo」。

**判準(憲法第 111 條均權制度的形式)**:

> 事務有全國一致之性質者屬於中央,有因地制宜之性質者屬於地方。

對應到這裡:

| 事務性質 | 歸屬 |
|---|---|
| 只對**這個 repo** 有意義(它的 bug、它的功能、它的文件)| 該 repo |
| 對**這套工具鏈**都有意義(跨 repo 的慣例、plugin 本身的行為)| 該工具的 repo |
| 對**這台機器上的所有工作**都有意義(環境、憑證、個人慣例)| global 層 |

**執行順序(不可調換)**:

1. 先用 `idd-repo-map.sh` 確認**該層是否存在**。判準說「屬 project 層」但那個 project 沒有自己的 tracker 時,往下歸會落空 —— 這正是 #301 被 #302 擋住的原因:判準決定「該歸哪一層」,地圖決定「那一層是否存在」。
2. 該層不存在 → **上收到最近的存在層**,但**停在當前 git repo 邊界**。使用者原話:「上收其實通常會到我現在正在開起的專案的git,要跨過的話通常會需要特別指定」。要跨過邊界必須顯式 `--repo`。
3. 仍然無法判定 → `AskUserQuestion`,**不要猜**。歸錯 repo 的 issue 沒有人會發現它放錯了。
90 changes: 90 additions & 0 deletions plugins/issue-driven-dev/scripts/idd-repo-map.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# idd-repo-map.sh — derive the repo map that target resolution needs (#302).
#
# WHY A MAP AT ALL
#
# Every existing resolution mechanism is single-point: it resolves ONE repo and
# can see nothing about the relationships between repos. So when a rule says
# "this belongs to the project layer", nothing can answer the prior question —
# **does that layer actually exist here?** If it does not, resolution falls
# through to whatever `cwd` happens to be, which is how work lands in the wrong
# tracker. (「地方政府在不在」— the layer can be absent.)
#
# WHY DERIVED, NEVER HAND-WRITTEN
#
# A hand-maintained registry goes stale, and staleness is the disease, not the
# cure: #301's recorded case was caused by a project being extracted into its
# own repo with **nothing anywhere updated**. A hand-written map would just move
# that same failure from walk-up into the registry. So the map is recomputed
# from the filesystem every time, and is never stored as a source of truth.
#
# BOTH CONFIG FORMATS ARE RECOGNISED (#303)
#
# Missing one format is not "one fewer row" — a repo whose config was not seen
# reads as "this layer does not exist", which triggers the wrong upward
# resolution. Measured on one machine: 20+ current-path, 17 legacy-path.
#
# Usage:
# idd-repo-map.sh [--json] [root ...] # default root: ~/Developer
#
# Always exits 0. Prints a table, or JSON with --json.

set -u

JSON=0
ROOTS=()
while [ $# -gt 0 ]; do
case "$1" in
--json) JSON=1; shift ;;
-h|--help) sed -n '2,/^$/p' "$0" | sed 's/^#\{1,\} \{0,1\}//'; exit 0 ;;
*) ROOTS+=("$1"); shift ;;
esac
done
[ ${#ROOTS[@]} -eq 0 ] && ROOTS=("$HOME/Developer")

GLOBAL="$HOME/.claude/.idd/global.json"
rows=""

emit() { # $1=repo_dir $2=config_path $3=format
local dir="$1" cfg="$2" fmt="$3" slug=""
slug=$(jq -r '.github_repo // empty' "$cfg" 2>/dev/null)
# A config that exists but names no repo is NOT a resolved layer — say so
# rather than letting an empty string read as a match.
[ -z "$slug" ] && slug="(no github_repo)"
rows="${rows}${dir}\t${slug}\t${fmt}\n"
}

for root in "${ROOTS[@]}"; do
[ -d "$root" ] || continue
while IFS= read -r cfg; do
case "$cfg" in
*/.claude/.idd/local.json) emit "$(dirname "$(dirname "$(dirname "$cfg")")")" "$cfg" current ;;
*/.claude/issue-driven-dev.local.json) emit "$(dirname "$(dirname "$cfg")")" "$cfg" legacy ;;
esac
done < <(find "$root" \
\( -name node_modules -o -name .git -o -name .build -o -name .venv \) -prune -o \
\( -path '*/.claude/.idd/local.json' -o -path '*/.claude/issue-driven-dev.local.json' \) \
-print 2>/dev/null)
done

if [ "$JSON" = "1" ]; then
printf '%b' "$rows" | jq -R -s --arg g "$GLOBAL" '
{global: ($g | if (. | test("^/")) then . else null end),
global_present: false,
repos: (split("\n") | map(select(length > 0) | split("\t")
| {dir: .[0], github_repo: .[1], config_format: .[2]}))}' 2>/dev/null \
| jq --argjson present "$([ -f "$GLOBAL" ] && echo true || echo false)" '.global_present = $present'
exit 0
fi

echo "global config: $GLOBAL $([ -f "$GLOBAL" ] && echo "(present)" || echo "(absent — no global layer)")"
echo ""
if [ -z "$rows" ]; then
echo "no IDD-configured repo found under: ${ROOTS[*]}"
exit 0
fi
printf '%b' "$rows" | sort | awk -F'\t' '{printf " %-58s %-38s %s\n", $1, $2, $3}'
echo ""
printf '%b' "$rows" | awk -F'\t' '{c[$3]++} END {printf "total: %d (current: %d, legacy: %d)\n", NR, c["current"], c["legacy"]}'
echo "(legacy rows are migratable — see scripts/migrate-idd-config.sh, #303)"
exit 0
43 changes: 43 additions & 0 deletions plugins/issue-driven-dev/scripts/tests/idd-repo-map/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Test: idd-repo-map.sh derives the layer map both formats and all (#302).
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$(cd "$HERE/../.." && pwd)/idd-repo-map.sh"
. "$(cd "$HERE/../../lib" && pwd)/assert-helpers.sh"

S=$(mktemp -d); trap 'rm -rf "$S"' EXIT
mkdir -p "$S/p1/.claude/.idd" "$S/p2/.claude" "$S/p3/.claude/.idd" "$S/n/node_modules/x/.claude/.idd"
echo '{"github_repo":"o/p1"}' > "$S/p1/.claude/.idd/local.json"
echo '{"github_repo":"o/p2"}' > "$S/p2/.claude/issue-driven-dev.local.json"
echo '{}' > "$S/p3/.claude/.idd/local.json"
echo '{"github_repo":"o/noise"}' > "$S/n/node_modules/x/.claude/.idd/local.json"

OUT=$(bash "$SCRIPT" "$S" 2>&1); RC=$?
assert_exit "always exits 0" 0 "$RC"

# The whole point of the map: BOTH formats must be seen. Missing one does not
# mean "one fewer row" — an unseen config reads as "this layer does not exist"
# and triggers the wrong upward resolution (#303 measured 17 legacy repos).
assert_grep "current-format repo is mapped" "o/p1" "$OUT"
assert_grep "legacy-format repo is mapped" "o/p2" "$OUT"
assert_grep "format is reported per row" "legacy" "$OUT"
refute_grep "node_modules is pruned" "noise" "$OUT"

# A config that exists but names no repo is NOT a resolved layer. Letting the
# empty string through would make it look like a match.
assert_grep "config without github_repo is called out, not silently empty" \
"(no github_repo)" "$OUT"

JSON=$(bash "$SCRIPT" --json "$S" 2>/dev/null)
require "--json emits parseable JSON" bash -c 'printf "%s" "$0" | jq -e . >/dev/null' "$JSON"
require "--json reports three repos" \
bash -c '[ "$(printf "%s" "$0" | jq ".repos | length")" = "3" ]' "$JSON"
require "--json states whether a global layer exists" \
bash -c 'printf "%s" "$0" | jq -e "has(\"global_present\")" >/dev/null' "$JSON"

OUT2=$(bash "$SCRIPT" "$S/does-not-exist" 2>&1)
assert_grep "a missing root yields an explicit no-repo line, not silence" \
"no IDD-configured repo found" "$OUT2"

print_summary "idd-repo-map"
exit $?
Loading