diff --git a/plugins/issue-driven-dev/references/config-protocol.md b/plugins/issue-driven-dev/references/config-protocol.md index 1366acc..6717b96 100644 --- a/plugins/issue-driven-dev/references/config-protocol.md +++ b/plugins/issue-driven-dev/references/config-protocol.md @@ -433,14 +433,24 @@ If a group's `when` matches AND a candidate's `when` matches, **groups take prec "ask_each_time": false, // OPTIONAL. If true and candidates/groups exist, always prompt. - "pr_policy": "ask" // OPTIONAL. "always" | "never" | "ask" (default). + "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` work exactly as before — they're plain single-target configs. All new fields are additive. +**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 @@ -456,6 +466,20 @@ Controls whether `idd-implement` opens a PR or commits directly. `idd-all` always enforces `--pr` regardless of `pr_policy`. Full path contract: [pr-flow.md](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](../rules/tagging-collaborators.md). It is a *resolution accelerator*, **not** an authority: a table hit is still existence-verified via `gh api users/` 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. + ## Resolution algorithm (canonical) ``` diff --git a/plugins/issue-driven-dev/rules/tagging-collaborators.md b/plugins/issue-driven-dev/rules/tagging-collaborators.md index f68e34b..bdf788f 100644 --- a/plugins/issue-driven-dev/rules/tagging-collaborators.md +++ b/plugins/issue-driven-dev/rules/tagging-collaborators.md @@ -55,6 +55,19 @@ git log --pretty=format:'%an <%ae>' | sort -u > /tmp/idd-commit-authors.txt - Handles inferred from git config / email domains - Handles from `~/.gitconfig` / `~/.ssh/config` / `gh auth status` +### Step 2.5: Consult the config registry first (acceleration, not authority) + +If the walked-up IDD config has a `collaborators[]` array (schema in [references/config-protocol.md](../references/config-protocol.md)), use it as the **first** resolution attempt — it carries the human's own curated alias/name → `@login` mapping, so it resolves `Hardy` / `楊浩弘` / a student ID that the raw API list can't. Match the input, in priority order: + +1. `github_login` exact (case-insensitive) +2. `aliases[]` exact (case-insensitive) +3. `email` exact — **only if** the input literally is an email +4. `display_name` substring + +On a **hit**, resolve to that entry's `github_login` — **but the table is an accelerator, never an authority.** It can go stale (a collaborator removed, a login renamed after the config was written), so a hit MUST still be existence-verified via `gh api users/` before it counts as resolved and before `--mention-attested` is passed. A hit that fails existence-verification falls through to Step 3 (treat as no config match). + +On a **miss** (no `collaborators[]`, or no entry matches), fall through to Step 3 and fuzzy-match against the API-fetched list from Step 2 as before. The registry never replaces Step 2's fetch — it only front-runs the resolution when it can. + ### Step 3: Resolve user input → @login Apply fuzzy matching against the real list: diff --git a/plugins/issue-driven-dev/scripts/tests/collaborators-schema/test.sh b/plugins/issue-driven-dev/scripts/tests/collaborators-schema/test.sh new file mode 100644 index 0000000..2336dad --- /dev/null +++ b/plugins/issue-driven-dev/scripts/tests/collaborators-schema/test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# test.sh — schema-consistency drift guard for the collaborators[] identity +# registry (PsychQuant/issue-driven-development#86). +# +# #86 adds an OPTIONAL `collaborators[]` config array so IDD can resolve a +# person's alias / email / display-name → their GitHub @login WITHOUT guessing +# (feeds rules/tagging-collaborators.md Step 2-3 as a resolution accelerator). +# The schema is documented across THREE files that MUST agree: +# - references/config-protocol.md (schema source of truth + PII boundary) +# - rules/tagging-collaborators.md (consumer: table-lookup then verify) +# - skills/idd-config/SKILL.md (validate: schema checks) +# +# This is a C_shared_module_coord change, so the real failure mode is DRIFT: +# rename a field in one file, forget the other two, and the tagging protocol +# resolves against a stale schema. There is deliberately no behavioral test — +# the resolution itself is LLM-executed prose (consistent with the whole rules/ +# corpus), so there is no resolver binary to unit-test. What IS mechanically +# checkable, and what breaks in practice, is that all three files still describe +# the same distinctive field set + the PII boundary. The needles below are +# 0-occurrence in all three files BEFORE #86 (verified), so each is a genuine +# drift signal, not a token that happens to pre-exist for another reason +# (`role`, e.g., pre-exists via groups[].repos[].role — deliberately not tested). +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$HERE/../../lib/assert-helpers.sh" +ROOT="$(cd "$HERE/../../.." && pwd)" # → plugins/issue-driven-dev + +PROTOCOL="$ROOT/references/config-protocol.md" +TAGGING="$ROOT/rules/tagging-collaborators.md" +IDDCONFIG="$ROOT/skills/idd-config/SKILL.md" + +echo "collaborators-schema (3-file drift guard, #86)" + +# ── config-protocol.md — the schema source of truth ── +assert_output_grep "protocol declares the collaborators[] field" "collaborators[" "$PROTOCOL" +assert_output_grep "protocol: github_login (required @-handle)" "github_login" "$PROTOCOL" +assert_output_grep "protocol: display_name (required real name)" "display_name" "$PROTOCOL" +assert_output_grep "protocol: aliases (optional fuzzy-match keys)" "aliases" "$PROTOCOL" +assert_output_grep "protocol: PII boundary documented" "PII" "$PROTOCOL" + +# ── tagging-collaborators.md — consumes the registry as a lookup accelerator ── +assert_output_grep "tagging references the collaborators[] registry" "collaborators[" "$TAGGING" +assert_output_grep "tagging resolves to github_login" "github_login" "$TAGGING" +# regression lock: a table HIT must STILL existence-verify (table can be stale). +assert_output_grep "tagging keeps the gh api users/ existence-verify" "users/" "$TAGGING" + +# ── skills/idd-config/SKILL.md — validate schema-checks the registry ── +assert_output_grep "idd-config validate covers collaborators[]" "collaborators[" "$IDDCONFIG" +assert_output_grep "idd-config validate checks github_login format" "github_login" "$IDDCONFIG" +assert_output_grep "idd-config validate checks aliases uniqueness" "aliases" "$IDDCONFIG" +# the PII-reminder contract lives in idd-config too — keep it anchored so it +# can't be dropped from validate while config-protocol.md still advertises it. +assert_output_grep "idd-config validate carries the PII reminder" "PII" "$IDDCONFIG" + +print_summary diff --git a/plugins/issue-driven-dev/skills/idd-config/SKILL.md b/plugins/issue-driven-dev/skills/idd-config/SKILL.md index 312a388..d8a2d3c 100644 --- a/plugins/issue-driven-dev/skills/idd-config/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-config/SKILL.md @@ -261,6 +261,7 @@ Local-only(`TARGET=""`):不寫 `github_repo`,提示 GitHub-backed idd-* ``` TaskCreate(name="validate_load", description="讀 IDD config(.claude/.idd/local.json 優先,legacy 次之,#195),JSON parse") TaskCreate(name="validate_schema", description="檢查 required fields + 各 candidates/groups 結構") +TaskCreate(name="validate_collaborators", description="若有 collaborators[]:github_login 必填+格式、display_name 必填、role enum、aliases 全域唯一;email 出現→PII warning(#86)") TaskCreate(name="validate_repo_exists", description="對 github_repo / candidates[].github_repo / groups[].repos[].github_repo 跑 gh repo view 驗證實際存在") TaskCreate(name="validate_predicate_form", description="when 區塊 path_contains / title_matches 等 key 是 known set") TaskCreate(name="validate_report", description="輸出 PASS / list of issues") @@ -272,6 +273,8 @@ TaskCreate(name="validate_report", description="輸出 PASS / list of issues") - `github_repo` 形式:`owner/repo`(regex `^[\w\-\.]+/[\w\-\.]+$`) - `candidates[].github_repo` 同上 - `groups[]` 必須有**剛好一個** `role: "primary"` +- `collaborators[]`(若存在,schema 見 [config-protocol.md](../../references/config-protocol.md)「`collaborators[]` field」):每個 entry `github_login` **必填**且符合 GitHub login charset `^[A-Za-z0-9-]+$`(明顯錯字如含空白/`@`/中文 → error);`display_name` **必填**非空;`role`(若有)∈ `{maintainer, collaborator, advisor, external}`(其餘 → warning);**`aliases` 全域唯一** —— 跨所有 entry 的 `aliases` + `github_login` 攤平後不得重複(否則 fuzzy match 會 ambiguous,這是 error) +- `collaborators[].email` 若出現 → **PII 提醒**(warning,非 error):email 是可識別個資,不該進 committed/public config,只放 private/gitignored config layer(見 config-protocol.md「PII boundary」) - `when.path_contains` / `path_matches` / `title_matches` / `label_in` / `git_remote_matches` / `git_branch_matches` / `all` / `any` / `not` — 不認識的 key → warning(不 fail) - `gh repo view` 對每個 repo 跑(warning 而非 error,因為 private repo 無權限會失敗但 config 本身可能合法)