From e39c010810d32ecf6f6eafbd38fcb7dc16992ca6 Mon Sep 17 00:00:00 2001 From: che cheng Date: Fri, 14 Aug 2026 05:51:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20global=20config=20=E5=B1=A4=20+=20?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E6=8E=A8=E5=B0=8E=E7=9A=84=20repo=20?= =?UTF-8?q?=E5=9C=B0=E5=9C=96=E3=80=81target=20resolution=20=E7=9A=84=20re?= =?UTF-8?q?sidual=20clause=20(#302,=20#301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #302 既有的六個機制全是單點解析:解出一個 repo,看不到 repo 之間的關係。所以規則說 「這件事屬 project 層」時,沒有東西能回答前一個問題 —— 那一層存不存在(「地方政府 在不在」)。不存在時就會落到 cwd 碰巧是什麼。 - ~/.claude/.idd/global.json 作為 global 層(該路徑本來就在 walk-up 路線上;檔名 必須是 global.json,叫 local.json 會讓 /Users/che 被讀成「一個 repo」) - scripts/idd-repo-map.sh 每次從檔案系統重算,永不存成 source of truth —— 手寫 registry 會 stale,而 stale 正是病因本身(#301 的案例就是 project 被 extract 成獨立 repo 而沒有任何地方被更新) - 掃描器認兩種 config 格式:漏認一種不是「少一筆」,而是「該層不存在」→ 錯誤上收 #301 residual clause 取憲法第 111 條均權制度的形式:只對這個 repo 有意義的歸該 repo、 對整套工具鏈有意義的歸該工具、對整台機器有意義的歸 global。執行順序不可調換 —— 先查地圖確認該層存在(判準決定該歸哪一層,地圖決定那一層在不在,這正是本張被 #302 擋住的原因)。該層不存在則上收,但停在當前 git repo 邊界;跨越要顯式 --repo。 仍無法判定則 AskUserQuestion,不猜 —— 歸錯 repo 的 issue 沒有人會發現。 46/46 suites 綠(新增 idd-repo-map 10 條)。 --- .../.claude-plugin/plugin.json | 2 +- plugins/issue-driven-dev/CHANGELOG.md | 24 +++++ .../references/config-protocol.md | 43 +++++++++ .../issue-driven-dev/scripts/idd-repo-map.sh | 90 +++++++++++++++++++ .../scripts/tests/idd-repo-map/test.sh | 43 +++++++++ 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100755 plugins/issue-driven-dev/scripts/idd-repo-map.sh create mode 100755 plugins/issue-driven-dev/scripts/tests/idd-repo-map/test.sh diff --git a/plugins/issue-driven-dev/.claude-plugin/plugin.json b/plugins/issue-driven-dev/.claude-plugin/plugin.json index 57b62e0..1781298 100644 --- a/plugins/issue-driven-dev/.claude-plugin/plugin.json +++ b/plugins/issue-driven-dev/.claude-plugin/plugin.json @@ -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" }, diff --git a/plugins/issue-driven-dev/CHANGELOG.md b/plugins/issue-driven-dev/CHANGELOG.md index c33e076..c0194bd 100644 --- a/plugins/issue-driven-dev/CHANGELOG.md +++ b/plugins/issue-driven-dev/CHANGELOG.md @@ -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 diff --git a/plugins/issue-driven-dev/references/config-protocol.md b/plugins/issue-driven-dev/references/config-protocol.md index 32638e1..a07e21f 100644 --- a/plugins/issue-driven-dev/references/config-protocol.md +++ b/plugins/issue-driven-dev/references/config-protocol.md @@ -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)+ `/.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 沒有人會發現它放錯了。 diff --git a/plugins/issue-driven-dev/scripts/idd-repo-map.sh b/plugins/issue-driven-dev/scripts/idd-repo-map.sh new file mode 100755 index 0000000..ef3837d --- /dev/null +++ b/plugins/issue-driven-dev/scripts/idd-repo-map.sh @@ -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 diff --git a/plugins/issue-driven-dev/scripts/tests/idd-repo-map/test.sh b/plugins/issue-driven-dev/scripts/tests/idd-repo-map/test.sh new file mode 100755 index 0000000..cd8431d --- /dev/null +++ b/plugins/issue-driven-dev/scripts/tests/idd-repo-map/test.sh @@ -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 $?