diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b9f03ef..184a6df 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -15,7 +15,7 @@ "plugins": [ { "name": "issue-driven-dev", - "version": "2.103.3", + "version": "2.109.0", "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. v2.102.1: reopen / resume path (#278) — the legal return trip from closed. idd-close gains a 'Reopen / resume path' section (close's dual operation): reopen-vs-new-issue criteria (same Expected -> reopen for trail continuity; morphed need -> new issue Refs old; broken upstream artifact -> #200's re-baseline, out of this path), resume point decided by the closing summary's WHY (premise changed -> re-diagnose; pure deferral -> implement), and the old summary stays untouched (append-only; reopen = note comment + idd-update phase rollback + optional prepend-note). Cross-referenced against auto-close-trap recovery. usecase-routing scenario 31. From a real user exchange; verify on substitute basis (disclosed). v2.102.0: three-front release. Skill-description contract + Path Map (#276, two-phase): idd-plan's frontmatter description — the ONLY surface read at skill-selection time — now names its diagnosis precondition; 5 skills gain the house pattern (drift-guard skill-description-contract, RED 8 first); docs/workflows.md gains a mermaid Path Flowchart mirroring the decision tree with all 36 catalog paths, rendered deterministically to the wiki Path-Map page by scripts/generate-path-map.py (drift-guard path-map-sync: freshness / coverage / discovery). Egress data-safety cluster (#275 + #273): empty-body guard — a provided-but-empty body now refuses (exit 15, band discipline; edit floors at 10 stripped chars because overwrite semantics turn empty dispatch into data loss — live incident 2026-07-22; explicit-intent escape --allow-empty-body); and the comment-PATCH surgery channel enters the nets via the new edit-comment verb (the #226 rollout's tracked-separately whitelist debt retired — it had bypassed EVERY net), with idd-edit's batch loop consuming the refusal band into a second outcome bucket (final exit stays 4). Dogfood: the #163 contract layer caught this release's own SCRUB_LEVEL provenance gap on first sweep. 42 suites, 0 fail. v2.99.1: staleness sweep + guard-net expansion (#267). README carried three stale gpt-5.5 pins and a stale vendored-codex-call claim — all outside the drift-guard scan net; fixed and the net widened: model-generation-sync now refutes pins in README + both catalog docs (31 assertions), and a new docs-catalog-sync suite requires every skills/* directory to appear in the catalog docs (the #122 no-forcing-function root cause is now test-detectable; it caught idd-ask and idd-config on its first RED). docs/workflows.md + skill-dimensions.md backfilled to v2.99 reality (P-find-lookup / P-ask-history / P-report-rollup / P-config-maintain / P-verify-file-profile paths, matrix rows, D12 4th member). 38 suites 0 fail. v2.99.0: /idd-ask — grounded QA over the issue corpus (#72), the surfacing family's 4th member mirroring /spectra-ask. Natural-language question -> decide-to-search gate (greetings/meta skip; bug-shaped questions never trigger diagnose) -> retrieval delegating idd-find's search backend (family rule: never rebuild a read-only query) -> full-text read of top-N hits (default 5, capped 10) -> grounded synthesis: first line blockquotes the question, every claim carries an issue/comment citation, source priority closed-with-PR > open > orphaned comment with conflicts surfaced, ending with Referenced Issues; corpus silence reported honestly, never filled from training memory. Read-only allowed-tools locked. First live run of the #140 fourth-member procedure (Q3 weak-hit judgment recorded in the family canonical). New capability spec idd-ask (+2 requirements); new drift-guard suite; 37 suites 0 fail. v2.98.0: codex channel goes full-dependency (#264, user ruling 'like superpowers'). The vendored bin/codex-call is DELETED — it trailed pai 2.18.0 by four security/correctness fixes (token-exp NSNumber parse, OAuth-file umask 0o077, form-encoding escape, post-flock re-read). Executable now resolves from the parallel-ai-agents plugin cache (MIN_PAI 2.19.0 — the codexModel/codexEffort contract floor, pai issue 22); model/effort/max-time governance resolves from codex-pro's EXTERNAL-CONSUMER CONTRACT (MIN_CODEX_PRO 0.7.0: machine-readable references/defaults.json base + global/project profile.yaml overlay, codex-pro issue 7) and is passed explicitly on all three call paths (canonical Workflow args + manual fan-out + legacy direct). IDD's tree contains ZERO model pins — generation bumps touch codex-pro's defaults.json only. Dependency wiring mirrors the superpowers shape: install-time dependencies entry (codex-pro@codex-pro), allowCrossMarketplaceDependenciesOn, check-plugin-presence pre-flight, fail-fast with a one-step install instruction, no soft fallback. model-generation-sync drift-guard reshaped to the v2 contract (a re-vendored codex-call fails the suite). 36 suites 0 fail. v2.97.0: 9-issue drain via 5 cluster PRs (#259-#263). Composable verification profiles (#258): idd-verify --profile code|prose|academic (+ config-registered custom via verify_profiles) switches the (lens set, DA focus, input source, freshness) four-tuple; new --file/--dir input sources make the git worktree optional; file-mode SHA-256 freshness gate mirrors the #228 diff gate (never silently exempted); code default byte-identical. New /idd-find skill (#139): surfacing-only semantic lookup over the open+closed corpus with GitHub relevance + phase/PR overlay; read-only, filter flags redirect to idd-list, embedding honestly deferred. Dashboard comment contract (#133) + idd-report --rollup (#134): one human-facing narrative snapshot per issue (marker-located, updates bound to phase transitions only, anti-#116) and a pull-only four-group attention view (need-attention / in-progress / stalled>14d / recently-closed). sdd_bias config switch (#252): hard-gate hits escalate to Spectra when high; default routing byte-identical. Layer V unattended deferred-record (#120): registry literal + structured catch-up record aggregated by idd-all Phase 6. Surfacing-primitives family doc, D12 axis (#140). Model-generation sync (#251): codex-call default gpt-5.6-sol is the tree's single generation pin (live-probed); prose generation-neutral; idd-route candidate renamed codex-xhigh. Docs path catalog completed (#122). 5 new drift-guard suites; 36 suites 0 fail. v2.96.0: gh-egress hardening cluster + idd-edit batch semantics. Exit-code band >=10 (#227: 10=privacy/11=mention/12=unscannable/13=attestation/14=usage; wrapper never exits <10 on its own — rc<10 is always gh's, so unattended callers can split gate-refusal from gh-failure on $? alone). Unified python3 content-net scan (#225: kills the jq/no-jq divergence; taxonomy = projects keys + path-shaped values under sensitive key names; fail-closed wide net when python3 absent). Phase 2 rollout (#226: all 6 skills' comment/edit egress now dispatch through gh-egress with attestation — the #117 mention net is mechanically enforced on the comment channel). idd-edit batch x R5 (#158: per-comment refuse + continue, batch outcome report, exit 4 iff any refused). v2.95.0: Discussions intake bridge (#221) — opt-in `idd-list --discussions` (GraphQL surface: Q&A/Ideas + unanswered + deduped vs issue refs; graceful no-op) + `idd-issue --from-discussion` (Provenance seed + draft-and-confirm reply, unattended never posts); cardinal rule: never auto-file. Plus idd-verify diff-freshness gate (#228: FROZEN_SHA vs HEAD before aggregate — refuse stale-snapshot verdicts) and the IDD_CALLER registry (#161: dynamic tree-sweep drift-guard). v2.94.0: selective git auto-tag (#85) — idd-issue tags idd-{N}-baseline at main HEAD (rollback anchor); idd-verify tags idd-{N}-verified on Aggregate PASS (review snapshot). Only these two milestones (no diagnose/plan/implement tags) so the tag namespace stays clean. Config `auto_tag` (default-ON, opt-out via enabled:false); idempotent (existing tag skipped) + graceful-skip on push failure (never aborts the workflow). v2.93.1: collaborator identity registry in idd-config (#86) — optional `collaborators[]` config field mapping a person's alias / email / display-name → GitHub @login WITHOUT guessing (github_login required; email is PII, private/gitignored only). tagging-collaborators.md Step 2.5 consults the registry first as an accelerator (a hit is still existence-verified via `gh api users/`; a miss falls through to the API fuzzy-match); idd-config validate checks login charset + globally-unique aliases + PII reminder. v2.93.0: reshape Plan / pre-implementation tier (Cluster C, #129/#57/#111, via reshape-plan-preimpl-tier Spectra change) — first-class `meeting` issue type (meeting-first routing + Phase A/B/C deliberation + self-contained close gate), complexity hard gate (>=5-file interdependent-concept OR shared-abstraction MUST-trigger Plan, escalate-only), and superpowers pre-implementation hand-off (README stage-mapping table + non-binding brainstorming pointer, no self-built staging skill). v2.92.1: hotfix — parallel-ai-agents install-time dependency pointed at the wrong marketplace (psychquant-claude-plugins), making v2.92.0 fail to load and silently dropping all /idd-* skills; corrected to the parallel-ai-agents marketplace. v2.92.0: /idd-all batch-drain release — 23 issues verified+closed via 16 PRs (#223, #229-#243), the plugin's largest self-dogfood. Added: unattended-contract (state-file signal + TTL, TTY heuristic removed, idd-all/chain dependency early gates #123/#222/#211); gh-egress unconditional @-mention net with --mention-attested escape-or-attest contract (#117) atop 6-item mechanical-net precision hardening (#203); idd-close Step 6.3 doc-sync sweep (#220); test aggregator + GitHub Actions CI, 21 suites (#217); idd-list blocked-state grouping + all-blocked banner (#84); config Mechanism 3.5 submodule routing (#162); check-plugin-presence enabled-state detection exit 3 (#212); monorepo host plugin disambiguation (#68); assert-helpers eval-content ban + safe output-grep pair (#188); diagnosis-detection contract fixtures (#61). Changed: parallel-ai-agents promoted to install-time dependency, vendored ensemble fork DELETED, idd-verify two-tier chain (#219); DA sequenced-spawn eliminates the #119 socket-crash polling window (#130); spectra-archive-post-ic --force-linked-issue vs --linked-issue intent separation (#172); worktree conventions unified on the managed helper (#169); bridge state migrated to .claude/.idd/state/bridge.json (#199); .gitattributes LF policy (#216); merge-completeness fixtures default-branch self-sufficiency (#224). Audits: dependency bindings vs deep-integration rule (#210), rules layering 12/12 (#215). Follow-ups filed: #225-#228.", "author": { "name": "Che Cheng" diff --git a/plugins/issue-driven-dev/.claude-plugin/plugin.json b/plugins/issue-driven-dev/.claude-plugin/plugin.json index 1781298..af911fc 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.108.0", + "version": "2.109.0", "author": { "name": "Che Cheng" }, diff --git a/plugins/issue-driven-dev/CHANGELOG.md b/plugins/issue-driven-dev/CHANGELOG.md index c0194bd..c75879f 100644 --- a/plugins/issue-driven-dev/CHANGELOG.md +++ b/plugins/issue-driven-dev/CHANGELOG.md @@ -5,6 +5,77 @@ 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.109.0] - 2026-08-15 + +### Fixed — post-merge audit of the 2026-08-13/14 session + +The `#295` line reached `main` at round 7 without any independent review, and the twenty-one issues in PRs #306–#314 +had none at all. An ensemble audit of `8d0ec33..737dbe0` returned 4 CRITICAL and 17 HIGH. The pattern it found in the +unreviewed half is worth stating plainly: **documentation was shipped and recorded as implementation.** Three "fixes" +did not execute. Each of them passed the author's tests, because those tests exercised the code that was written and +never the seam where it meets `gh`, the filesystem, or a reader. + +- **`migrate-idd-config.sh` moved files outside the tree it was told to scan, and reported success** — `find -print` + with `while read -r` splits a path containing a NEWLINE into two entries, and the second fragment is a **relative** + path that `dirname`/`mv` then resolve against the caller's cwd. Reproduced: with a victim at + `caller/.claude/.claude/…` and a newline-named repo inside the scan root, `--apply ../scan` relocated the victim — + entirely outside the scan — and printed `✓ migrated: .claude`. Now `-print0`/`read -d ''`, plus a guard that refuses + any path not under the scan root, `archive/` and `.claude/worktrees/` pruning, and a breadcrumb that will not truncate + an existing file. This is the first script here that moves user data; it was moving the wrong files. + +- **The round-7 truncation repair never worked, and never reached the surface users invoke** — `gh api --paginate --jq` + emits **one JSON array per page**, so `--argjson` rejected the concatenation, jq died, and the empty-payload guard + silently disabled the entire audit on any repo containing a >100-comment issue (verified against + `microsoft/vscode#301011`). Folded with `jq -s add`, plus a refusal to swap in a re-fetch that *shrinks* the comment + set and integer validation on `.number` before it reaches an API path. Separately, the repair had only ever been + applied to `scripts/check-closed-without-summary.sh`; **`/idd-list --audit-closes` — the surface that actually prints + the `--retroactive` invitation — still had the truncated fetch**, and now documents the same repair. + +- **`bare_re`'s trailing anchor sent emphasised headings to `missing`** — `**Closing Summary** - fixed the parser` has a + tail, so the phrase-only form rejected it and no hash form matched. `emph_re` covers it; the anchor stays, because it + is what keeps ordinary prose out of the presence test (5 of 9 genuinely-missing issues in a real repo mention the + phrase in prose and must stay flagged). + +- **`#286` was inert** — `gh release upload FILE#TEXT` sets a **display label, not the asset name**; the asset always + takes the on-disk basename. An earlier revision computed `upload_name` and never used it at all, so the documented + naming convention had **never** been applied. Attachments are now staged under the target basename before upload, and + `--clobber` is restored (removing it broke legitimate re-uploads). + +- **`#293`/`#305` was documentation only** — a contract file plus a prose ⚠ near each site, while all seven call sites + still ran `.[0]` on a coarse search. The client-side filter and the `createdAt` ordering check are now in the code at + the gate, the branch resolution and verify's auto-detect, and the reference examples are correct rather than merely + annotated. + +- **`#302`'s global layer had no reader** — the claim that the path was "already on the walk-up route, just recognise + one more filename" described a change that had not been made; the walk-up only ever checked `local.json` and the + legacy name. The reader exists now, as a last resort that announces itself; the remaining consumers are recorded as + residue rather than implied to be done. + +- **`idd-repo-map.sh` reproduced the row-forging channel from scratch** — a `github_repo` containing a newline emitted a + standalone forged row and corrupted the footer counts; ESC reached the terminal raw; tabs shifted columns. The sibling + script spent seven rounds closing exactly this. The shape of that script was copied without its safety; both now + sanitise every field that reaches stdout. + +- **Two prose callouts had been inserted inside fenced bash blocks** (`pr-flow.md`, `idd-close`), breaking the very + commands the contract says get copied. + +- **`marketplace.json` was five releases behind `plugin.json`** (2.103.3 vs 2.108.0) — the version-conflict resolution + had forced the maximum onto only one of the two files. + +### Fixed — tests that could not fail + +- **The prose-drift scan was case-sensitive against a canonically-capitalised marker**, so it could not fire on the + realistic literal — **and its positive control planted the lowercase form**, so the control passed while the check was + blind. A positive control that certifies a capability the check does not have is worse than no control at all. The + scan is case-insensitive and the canary now plants the canonical case. + +- **New suite `acquisition-truncation`** — the truncation repair lives in the live-`gh` branch, which every existing + suite skips because `--json-file` short-circuits it; the audit deleted all nineteen lines with 46/46 still green. The + new suite stubs `gh` on PATH so the real code runs, and acid confirms **5/5** of its mechanisms turn assertions red on + their own. Reaching that took three attempts, each recorded in the file: the first assertion could not tell "recovered" + from "failed safely", the second could not tell either from "the audit aborted before printing anything" (hence a + canary), and the shrink guard needed its own stub. + ## [2.108.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 a07e21f..f9b0210 100644 --- a/plugins/issue-driven-dev/references/config-protocol.md +++ b/plugins/issue-driven-dev/references/config-protocol.md @@ -751,7 +751,7 @@ User runs `/idd-issue`, attaches label `cross-package`. Re-resolve picks the gro **位置的四個理由**(考慮過 `~/.idd/`,不採用): -1. walk-up **已經**會經過 `$HOME/.claude/.idd/` —— 終止條件的檢查在 break 之前,所以這條路徑本來就在讀取路徑上,只需要多認一個檔名,零新增掃描邏輯。 +1. walk-up 的終止條件檢查在 break 之前,所以 `$HOME/.claude/.idd/` 在**目錄層級**上就在讀取路徑上 —— 但**檔名不是自動就認的**。這一點原本寫成「只需要多認一個檔名」,而那個改動當時並沒有做,於是 global 層有了規格卻沒有任何 reader(post-merge audit 2026-08-15 指出)。現已在 `scripts/check-closed-without-summary.sh` 的 walk-up 之後補上讀取(repo-local 皆未命中時才用,並印一行說明來源)。**其餘 consumer 尚未接上 —— 那是 residue,不是已完成的事**。 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 判定。 diff --git a/plugins/issue-driven-dev/references/external-agent-delegation.md b/plugins/issue-driven-dev/references/external-agent-delegation.md index 600e49c..dfc5072 100644 --- a/plugins/issue-driven-dev/references/external-agent-delegation.md +++ b/plugins/issue-driven-dev/references/external-agent-delegation.md @@ -76,7 +76,7 @@ idd-verify #98 --branch # diff against origin/ a. Count commits ref'ing #N since origin/ N>0 → local mode HEAD~N..HEAD N=0 → continue - b. Search open PRs ref'ing #N: gh pr list --search "#N in:body" --state open + b. Search open PRs ref'ing #N: gh pr list --search "#N in:body" --state open --json number,body,createdAt | jq '…精篩…' # references/pr-issue-matching.md > ⚠ **`in:body "#N"` 不是精確比對**(#293 / #305)——它會誤中跨 repo 引用(`codex-pro#7` → `#7`)與無關的 PR。search 只能當粗篩,判定必須照 [`references/pr-issue-matching.md`](pr-issue-matching.md) 在 client 端精篩,並檢查 PR 不早於 issue。 diff --git a/plugins/issue-driven-dev/references/pr-flow.md b/plugins/issue-driven-dev/references/pr-flow.md index 14ce067..40bc96a 100644 --- a/plugins/issue-driven-dev/references/pr-flow.md +++ b/plugins/issue-driven-dev/references/pr-flow.md @@ -222,11 +222,12 @@ Identical to PR path: `: (#NNN)`, no auto-close trailers. OPEN_PRS=$(gh pr list --repo "$GITHUB_REPO" --state open \ --search "in:body \"#${N}\" OR in:body \"Refs #${N}\"" \ -> ⚠ **`in:body "#N"` 不是精確比對**(#293 / #305)——它會誤中跨 repo 引用(`codex-pro#7` → `#7`)與無關的 PR。search 只能當粗篩,判定必須照 [`references/pr-issue-matching.md`](pr-issue-matching.md) 在 client 端精篩,並檢查 PR 不早於 issue。 --json number,url,headRefName) ``` +> ⚠ **`in:body "#N"` 不是精確比對**(#293 / #305)——它會誤中跨 repo 引用(`codex-pro#7` → `#7`)與無關的 PR。search 只能當粗篩,判定必須照 [`references/pr-issue-matching.md`](pr-issue-matching.md) 在 client 端精篩,並檢查 PR 不早於 issue。 + Decision: | Path taken in implement | Open PR found? | `idd-close` behavior | diff --git a/plugins/issue-driven-dev/references/usecase-routing.md b/plugins/issue-driven-dev/references/usecase-routing.md index 024cb18..77ee5e8 100644 --- a/plugins/issue-driven-dev/references/usecase-routing.md +++ b/plugins/issue-driven-dev/references/usecase-routing.md @@ -99,7 +99,8 @@ N>0 → 用 HEAD~N..HEAD(fall through,下一步不跑) 2. N=0 → 查 open PRs ref'ing #98: - gh pr list --search "#98 in:body" --state open + gh pr list --search "#98 in:body" --state open --json number,body,createdAt \ + | jq 'map(select((.body//"") | test("(^|[^A-Za-z0-9_/-])#98([^0-9]|$)")))' # 精篩,見 references/pr-issue-matching.md > ⚠ **`in:body "#N"` 不是精確比對**(#293 / #305)——它會誤中跨 repo 引用(`codex-pro#7` → `#7`)與無關的 PR。search 只能當粗篩,判定必須照 [`references/pr-issue-matching.md`](pr-issue-matching.md) 在 client 端精篩,並檢查 PR 不早於 issue。 diff --git a/plugins/issue-driven-dev/scripts/check-closed-without-summary.sh b/plugins/issue-driven-dev/scripts/check-closed-without-summary.sh index 5d817ed..dae61b9 100755 --- a/plugins/issue-driven-dev/scripts/check-closed-without-summary.sh +++ b/plugins/issue-driven-dev/scripts/check-closed-without-summary.sh @@ -74,6 +74,19 @@ else [ "$dir" = "$HOME" ] && break dir=$(dirname "$dir") done + # The global layer (#302). Nothing read it before: the walk-up only ever + # checked `local.json` and the legacy name, so the CHANGELOG's claim that + # the path was "already on the walk-up route, just recognise one more + # filename" described a change that had not been made. It is made here. + # + # It is a LAST resort and it is not a repo boundary: `$HOME` is not a repo, + # which is exactly why the file is named `global.json` rather than + # `local.json` — the latter would make the walk-up read `$HOME` as one. + if [ -z "$REPO" ] && [ -f "$HOME/.claude/.idd/global.json" ]; then + REPO=$(jq -r '.default_github_repo // .github_repo // empty' \ + "$HOME/.claude/.idd/global.json" 2>/dev/null) + [ -n "$REPO" ] && echo "note: repo resolved from the global layer ($REPO) — no repo-local config found." >&2 + fi fi GH_ARGS=(issue list --state closed --json number,title,state,comments --limit "$LIMIT") [ -n "$REPO" ] && GH_ARGS+=(--repo "$REPO") @@ -117,10 +130,34 @@ else RESOLVED_REPO="$REPO" [ -z "$RESOLVED_REPO" ] && RESOLVED_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) for n in $TRUNCATED; do - FULL=$(gh api "repos/$RESOLVED_REPO/issues/$n/comments" --paginate --jq '[.[] | {body}]' 2>/dev/null) - if [ -n "$FULL" ]; then + # `gh api --paginate --jq` emits ONE JSON ARRAY PER PAGE, concatenated — + # NOT one value. Verified against microsoft/vscode#301011 (155 comments): + # the output fails to parse ("Extra data: line 2 column 1"), so --argjson + # rejected it, jq died, ISSUES_JSON became empty, and the empty-payload + # guard silently disabled the WHOLE audit on any repo containing a long + # issue. `jq -s add` folds the pages into one array. (`--slurp` is not an + # option here: gh refuses it together with --jq.) + # + # `.number` is validated as an integer first: it is fetched data, and it + # is being interpolated into an API path. + case "$n" in + ''|*[!0-9]*) echo "note: skipping non-numeric issue id in re-fetch" >&2; continue ;; + esac + FULL=$(gh api "repos/$RESOLVED_REPO/issues/$n/comments" --paginate \ + --jq '[.[] | {body}]' 2>/dev/null | jq -s 'add // []' 2>/dev/null) + # An empty/!valid result must NOT be treated as success: a partial re-fetch + # that SHRINKS the comment set would route a real summary to `missing`. + if [ -n "$FULL" ] && printf '%s' "$FULL" | jq -e 'type == "array" and length > 0' >/dev/null 2>&1; then + # Refuse a re-fetch that returns FEWER comments than we already had — + # that is a partial page, and swapping it in would delete evidence. ISSUES_JSON=$(printf '%s' "$ISSUES_JSON" \ - | jq --argjson full "$FULL" --argjson n "$n" 'map(if .number == $n then .comments = $full else . end)') + | jq --argjson full "$FULL" --argjson n "$n" ' + map(if (.number | tostring) == ($n | tostring) + then (if ($full | length) >= (.comments | length) + then .comments = $full + else .idd_comments_truncated = true end) + else . end)') || ISSUES_JSON="" + [ -z "$ISSUES_JSON" ] && { echo "note: re-fetch merge failed — audit skipped, no conclusion drawn." >&2; exit 0; } else # Could not complete the fetch. Do NOT let a partial comment set decide # the destructive class: mark it so the classifier can never call it @@ -257,7 +294,14 @@ CLASSIFY=' # `### Problem` does not contain the phrase -- while sending a summary written # at h3 straight to the destructive class. def present_re: "^[ \t>]*[#\\x{FF03}]{1,6}[^\\p{L}\\p{N}]*closing[\\s\\x{00A0}\\x{200B}\\x{3000}]+summary"; + # Two forms. (a) a line that is ESSENTIALLY JUST the phrase — setext titles, + # bare title lines. The trailing anchor is what keeps ordinary prose ("I forgot + # the closing summary, sorry") out of the presence test, which matters: 5 of 9 + # genuinely-missing issues in a real repo mention the phrase in prose and must + # stay flagged. (b) an EMPHASISED heading, which may carry a tail — the `$` + # anchor alone sent `**Closing Summary** - fixed the parser` to `missing`. def bare_re: "^[ \t>]*[^\\p{L}\\p{N}]*closing[\\s\\x{00A0}\\x{200B}\\x{3000}]+summary[^\\p{L}\\p{N}]*$"; + def emph_re: "^[ \t>]*(\\*\\*|__|\\*|_)[^\\p{L}\\p{N}]*closing[\\s\\x{00A0}\\x{200B}\\x{3000}]+summary"; def lead_re: "^ {0,3}#{1,6}[^\\p{L}\\p{N}]*closing[\\s\\x{00A0}\\x{200B}\\x{3000}]+summary"; # Control characters are structural here (record + field delimiters) and can # also repaint a terminal; U+2028/U+2029 and the bidi controls can forge or @@ -293,7 +337,7 @@ CLASSIFY=' # failure this rewrite exists to make unreachable. def has_heading_anywhere: ((. // "") | split("\n")) - | any(test(present_re; "i") or test(bare_re; "i")); + | any(test(present_re; "i") or test(bare_re; "i") or test(emph_re; "i")); # Does anything non-blank follow the lead line? A heading with nothing under it # is not a summary, and letting it read as compliant made such an issue # INVISIBLE -- printed in no section at all, while --retroactive also aborts on diff --git a/plugins/issue-driven-dev/scripts/idd-repo-map.sh b/plugins/issue-driven-dev/scripts/idd-repo-map.sh index ef3837d..f629f53 100755 --- a/plugins/issue-driven-dev/scripts/idd-repo-map.sh +++ b/plugins/issue-driven-dev/scripts/idd-repo-map.sh @@ -45,12 +45,30 @@ done GLOBAL="$HOME/.claude/.idd/global.json" rows="" +# Values read out of a config file are UNTRUSTED: the file lives inside a +# scanned repo, which in any shared checkout or cloned template is written by +# someone else. Without this, a `github_repo` containing a newline emitted a +# standalone forged row that looked exactly like real data (and corrupted the +# footer counts), a tab shifted the columns, and a raw ESC reached the terminal. +# That is the same row-forging class the sibling script spent seven verify +# rounds closing — reproduced from scratch here because the shape of the script +# was copied without its hard-won safety. +sanitize_field() { + LC_ALL=C tr -d '\000-\010\011\013\014\016-\037\177' \ + | python3 -c 'import sys +bad = {0x061C, 0x200B, 0x200E, 0x200F, 0x2028, 0x2029, 0x2060, 0xFEFF} +bad |= set(range(0x202A, 0x202F)) | set(range(0x2066, 0x206A)) | set(range(0xE0000, 0xE0080)) +s = "".join(" " if ord(c) in bad else c for c in sys.stdin.read()) +sys.stdout.write(s.replace("\n", " ")[:160])' +} + 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) + slug=$(jq -r '.github_repo // empty' "$cfg" 2>/dev/null | sanitize_field) # 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)" + [ -z "$(printf '%s' "$slug" | tr -d '[:space:]')" ] && slug="(no github_repo)" + dir=$(printf '%s' "$dir" | sanitize_field) rows="${rows}${dir}\t${slug}\t${fmt}\n" } @@ -62,7 +80,8 @@ for root in "${ROOTS[@]}"; do */.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 \ + \( -name node_modules -o -name .git -o -name .build -o -name .venv \ + -o -name archive -o -name archived -o -path '*/.claude/worktrees' \) -prune -o \ \( -path '*/.claude/.idd/local.json' -o -path '*/.claude/issue-driven-dev.local.json' \) \ -print 2>/dev/null) done diff --git a/plugins/issue-driven-dev/scripts/migrate-idd-config.sh b/plugins/issue-driven-dev/scripts/migrate-idd-config.sh index 48064c6..978123f 100755 --- a/plugins/issue-driven-dev/scripts/migrate-idd-config.sh +++ b/plugins/issue-driven-dev/scripts/migrate-idd-config.sh @@ -47,8 +47,21 @@ failed=0 for root in "${ROOTS[@]}"; do [ -d "$root" ] || { echo "note: not a directory, skipping: $root" >&2; continue; } # -prune the heavy directories rather than filtering after the fact. - while IFS= read -r legacy; do + # -print0 / read -d "" is not stylistic. With -print, a directory name that + # contains a NEWLINE splits one entry into two, and the second fragment is a + # RELATIVE path that `dirname`/`mv` then resolve against the CALLER's cwd. + # Reproduced: with a victim at caller/.claude/.claude/local config and a + # newline-named repo inside the scan root, `--apply ../scan` relocated the + # victim -- a file outside the scanned tree entirely -- and reported + # "✓ migrated: .claude migrated: 1". + while IFS= read -r -d '' legacy; do found=$((found + 1)) + # Belt and braces: even with -print0, never act on a path that is not an + # absolute path inside the root we were told to scan. + case "$legacy" in + "$root"/*) : ;; + *) echo " ✗ refusing a path outside the scan root: $legacy" >&2; failed=$((failed + 1)); continue ;; + esac dir=$(dirname "$legacy") # .../.claude current="$dir/.idd/local.json" repo=$(dirname "$dir") @@ -75,15 +88,22 @@ for root in "${ROOTS[@]}"; do fi # Leave a breadcrumb: a repo whose config silently relocated is confusing to # anyone who bookmarked the old path or greps for it. - printf '%s\n' \ - "This file moved to .claude/.idd/local.json (#303, $(date +%Y-%m-%d))." \ - "The old path is no longer written by any IDD skill." \ - > "$legacy.moved" + # Never truncate an existing file: the breadcrumb is a courtesy, not a + # reason to destroy something a user put there. + if [ -e "$legacy.moved" ]; then + echo " note: $legacy.moved already exists — breadcrumb not written" >&2 + else + printf '%s\n' \ + "This file moved to .claude/.idd/local.json (#303, $(date +%Y-%m-%d))." \ + "The old path is no longer written by any IDD skill." \ + > "$legacy.moved" + fi echo " ✓ migrated: $repo" migrated=$((migrated + 1)) done < <(find "$root" \ - \( -name node_modules -o -name .git -o -name .build -o -name .venv \) -prune -o \ - -type f -name "$LEGACY_NAME" -print 2>/dev/null) + \( -name node_modules -o -name .git -o -name .build -o -name .venv \ + -o -name archive -o -name archived -o -path '*/.claude/worktrees' \) -prune -o \ + -type f -name "$LEGACY_NAME" -print0 2>/dev/null) done echo "" diff --git a/plugins/issue-driven-dev/scripts/tests/acquisition-truncation/test.sh b/plugins/issue-driven-dev/scripts/tests/acquisition-truncation/test.sh new file mode 100755 index 0000000..f72cdd6 --- /dev/null +++ b/plugins/issue-driven-dev/scripts/tests/acquisition-truncation/test.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Test: the >100-comment acquisition repair in check-closed-without-summary.sh. +# +# WHY THIS SUITE EXISTS +# +# `gh issue list --json comments` resolves the nested connection as +# `comments(first: 100)` and returns the OLDEST 100. A closing summary is by +# construction the NEWEST comment, so on any issue past 100 comments it is +# exactly the element dropped — and the classifier then says `missing`, the one +# class that invites the irreversible `--retroactive`. +# +# The repair for that lives in the live-`gh` branch, which every other suite +# skips because `--json-file` short-circuits it. The result: a post-merge audit +# deleted all nineteen lines of the repair and 46/46 suites stayed green. This +# suite closes that hole by stubbing `gh` on PATH, so the real code runs. +# +# Usage: bash test.sh (exit 0 = pass, 1 = fail) + +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HELPER="$(cd "$HERE/../.." && pwd)/check-closed-without-summary.sh" +. "$(cd "$HERE/../../lib" && pwd)/assert-helpers.sh" + +STUB=$(mktemp -d); trap 'rm -rf "$STUB"' EXIT + +# `gh` stub: `issue list` returns one closed issue whose comment array is capped +# at 100 with NO heading (exactly what the real API does); `api .../comments` +# paginates, emitting ONE ARRAY PER PAGE like the real `--paginate --jq`, with +# the closing summary in the final page. +cat > "$STUB/gh" <<'STUBEOF' +#!/usr/bin/env bash +case "$1 $2" in + "issue list") + python3 -c ' +import json +print(json.dumps([{"number":4242,"title":"long issue, summary past the cap", + "state":"CLOSED","comments":[{"body":"c%d"%i} for i in range(100)]}]))' + ;; + "api "*|"repo view") + if [ "$1" = "repo view" ] || [ "$2" = "view" ]; then echo "o/r"; exit 0; fi + # two pages, each its own JSON array — the shape that broke --argjson + python3 -c ' +import json +print(json.dumps([{"body":"c%d"%i} for i in range(100)])) +print(json.dumps([{"body":"## Closing Summary\n\n### Problem\nreal"}]))' + ;; + *) echo "[]" ;; +esac +STUBEOF +chmod +x "$STUB/gh" + +OUT=$(PATH="$STUB:$PATH" bash "$HELPER" --repo o/r 2>&1); RC=$? + +assert_exit "advisory exit 0 even on the live-gh path" 0 "$RC" + +# THE assertion: the summary lives past the cap, so a working repair finds it and +# the issue stays quiet. A broken repair (or none) reports it MISSING and invites +# the duplicate post. +# NOT "absent from MISSING" — that is also satisfied by a repair which merely +# FAILED SAFELY (the issue then lands in PRESENT). The assertion must separate +# "the summary was recovered" from "we gave up without doing damage", so it +# demands the issue be QUIET: recovered, compliant, printed in no section at +# all. The first cut asserted the weaker thing, and reverting the pagination fix +# left it green — a test that could not see the bug it was written for. +refute_grep "an issue whose summary is past the cap is RECOVERED (quiet, not merely non-missing)" \ + "4242" "$OUT" +# CANARY. "4242 is absent" is ALSO satisfied by the audit dying before it prints +# anything — and reverting the pagination fix does exactly that (the merge fails, +# the guard aborts). Without this line the suite stayed green on that mutation: +# the assertion could not tell "recovered" from "never ran". +assert_grep "…and the audit actually completed rather than aborting" \ + "No closed issue is missing" "$OUT" +refute_grep "…with no skip notice" "audit skipped" "$OUT" + +# The fail-safe half: when the re-fetch cannot be completed, the issue must fall +# to PRESENT, never to MISSING. Stub a failing api call. +cat > "$STUB/gh" <<'STUBEOF' +#!/usr/bin/env bash +case "$1 $2" in + "issue list") + python3 -c ' +import json +print(json.dumps([{"number":4243,"title":"refetch fails","state":"CLOSED", + "comments":[{"body":"c%d"%i} for i in range(100)]}]))' + ;; + "repo view") echo "o/r" ;; + *) exit 1 ;; +esac +STUBEOF +chmod +x "$STUB/gh" +OUT2=$(PATH="$STUB:$PATH" bash "$HELPER" --repo o/r 2>&1); RC2=$? +assert_exit "advisory exit 0 when the re-fetch fails" 0 "$RC2" +refute_grep "a failed re-fetch never reaches MISSING" \ + "4243" "$(printf '%s\n' "$OUT2" | sed -n '/^MISSING/,/^$/p')" + +# The shrink guard: a re-fetch that returns FEWER comments than we already had +# is a partial page. Swapping it in would delete evidence and could route a real +# summary to MISSING, so it must be refused and the issue marked instead. +cat > "$STUB/gh" <<'STUBEOF' +#!/usr/bin/env bash +case "$1 $2" in + "issue list") + python3 -c ' +import json +print(json.dumps([{"number":4244,"title":"refetch returns fewer","state":"CLOSED", + "comments":[{"body":"## Closing Summary\n\n### Problem\nreal"}] + [{"body":"c%d"%i} for i in range(99)]}]))' + ;; + "repo view") echo "o/r" ;; + *) python3 -c 'import json; print(json.dumps([{"body":"only one comment came back"}]))' ;; +esac +STUBEOF +chmod +x "$STUB/gh" +OUT3=$(PATH="$STUB:$PATH" bash "$HELPER" --repo o/r 2>&1); RC3=$? +assert_exit "advisory exit 0 when the re-fetch shrinks the set" 0 "$RC3" +refute_grep "a shrinking re-fetch must not delete the evidence we already had" \ + "4244" "$(printf '%s\n' "$OUT3" | sed -n '/^MISSING/,/^$/p')" +assert_grep "…and that run completed too" "closed issue" "$OUT3" + +# `.number` is fetched data and gets interpolated into a `gh api` path, so it is +# validated as an integer first. Without a case that exercises it, the guard was +# just an untested line. +cat > "$STUB/gh" <<'STUBEOF' +#!/usr/bin/env bash +case "$1 $2" in + "issue list") + python3 -c ' +import json +print(json.dumps([{"number":"4245 --hostname evil.example","title":"non-numeric id", + "state":"CLOSED","comments":[{"body":"c%d"%i} for i in range(100)]}]))' + ;; + "repo view") echo "o/r" ;; + *) echo "SHOULD-NOT-BE-CALLED-WITH-A-NON-NUMERIC-ID" ;; +esac +STUBEOF +chmod +x "$STUB/gh" +OUT4=$(PATH="$STUB:$PATH" bash "$HELPER" --repo o/r 2>&1); RC4=$? +assert_exit "advisory exit 0 on a non-numeric issue id" 0 "$RC4" +assert_grep "a non-numeric id is refused before it reaches the api path" \ + "skipping non-numeric issue id" "$OUT4" +refute_grep "…and the api call is never made with it" \ + "SHOULD-NOT-BE-CALLED" "$OUT4" + +print_summary "acquisition-truncation" +exit $? diff --git a/plugins/issue-driven-dev/scripts/tests/check-closed-without-summary/fixtures/mixed.json b/plugins/issue-driven-dev/scripts/tests/check-closed-without-summary/fixtures/mixed.json index f163fd4..aecc48d 100644 --- a/plugins/issue-driven-dev/scripts/tests/check-closed-without-summary/fixtures/mixed.json +++ b/plugins/issue-driven-dev/scripts/tests/check-closed-without-summary/fixtures/mixed.json @@ -779,5 +779,25 @@ "body": "c99 - no heading anywhere" } ] + }, + { + "number": 160, + "title": "REAL summary: bold heading WITH a trailing tail (C4 — the $ anchor sent this to MISSING)", + "state": "CLOSED", + "comments": [ + { + "body": "**Closing Summary** - fixed the parser, suite green\n\n### Problem\nreal content" + } + ] + }, + { + "number": 161, + "title": "REAL summary: italic heading with a tail", + "state": "CLOSED", + "comments": [ + { + "body": "*Closing Summary*: done\n\n### Problem\nreal content" + } + ] } ] diff --git a/plugins/issue-driven-dev/scripts/tests/check-closed-without-summary/test.sh b/plugins/issue-driven-dev/scripts/tests/check-closed-without-summary/test.sh index f0428d8..ab4e044 100644 --- a/plugins/issue-driven-dev/scripts/tests/check-closed-without-summary/test.sh +++ b/plugins/issue-driven-dev/scripts/tests/check-closed-without-summary/test.sh @@ -374,5 +374,18 @@ require "#157 is visible in the advisory bucket" require "#158 (known-truncated comment set) is PRESENT, never MISSING" unverified 158 refute "#158 is NOT in MISSING — an incomplete fetch cannot prove absence" flagged 158 +# ── post-merge audit (2026-08-15): emphasised headings with a tail ── +# `bare_re`'s trailing `$` anchor is what keeps ordinary prose out of the +# presence test, but it also rejected every emphasised heading carrying a tail, +# sending a real summary to MISSING. `emph_re` covers that shape; these two +# fixtures are its regression lock (acid: removing emph_re turns them red). +refute "#160 (bold heading with a tail) is NOT in MISSING" flagged 160 +refute "#161 (italic heading with a tail) is NOT in MISSING" flagged 161 +# The counterpart the loosening must NOT break: prose mentioning the phrase +# still cannot rescue an issue that has no summary. +require "prose mentioning the marker still cannot rescue an issue" \ + bash -c 'printf "%s" "[{\"number\":9001,\"title\":\"p\",\"state\":\"CLOSED\",\"comments\":[{\"body\":\"I forgot the closing summary, sorry\"}]}]" > "$0/p.json"; + bash "$1" --json-file "$0/p.json" | grep -q "9001"' "${TMPDIR:-/tmp}" "$HELPER" + print_summary "check-closed-without-summary" exit $? diff --git a/plugins/issue-driven-dev/scripts/tests/closing-summary-prose-drift/test.sh b/plugins/issue-driven-dev/scripts/tests/closing-summary-prose-drift/test.sh index f2fad8e..8b4c825 100644 --- a/plugins/issue-driven-dev/scripts/tests/closing-summary-prose-drift/test.sh +++ b/plugins/issue-driven-dev/scripts/tests/closing-summary-prose-drift/test.sh @@ -44,7 +44,13 @@ SCRIPT="$PLUGIN/scripts/check-closed-without-summary.sh" # marker words. Matching on the SHAPE rather than on a remembered string is what # makes this check survive rewordings the author did not anticipate. scan_prose_regex() { - grep -rnE --include='*.md' -- '\^[^`]*closing[^`]*summary' "$PLUGIN" 2>/dev/null \ + # -i is not optional: the marker is canonically CAPITALISED (`## Closing + # Summary`), so a case-sensitive scan cannot fire on the realistic literal. + # It shipped case-sensitive, and its positive control planted the LOWERCASE + # form — so the control passed while the check was blind to every regex a + # reader would actually quote. A positive control that certifies a capability + # the check does not have is worse than none. + grep -rniE --include='*.md' -- '\^[^`]*closing[^`]*summary' "$PLUGIN" 2>/dev/null \ | grep -v '/CHANGELOG.md:' } @@ -58,7 +64,10 @@ require "no prose file quotes a regex literal for the closing-summary marker" \ # swallowing --include — all three happened while writing this file) reads as a # clean repo. CANARY="$PLUGIN/.drift-canary.md" -printf 'canary: `^ {0,3}#{1,2} closing summary`\n' > "$CANARY" +# The canary plants the CANONICAL CAPITALISATION — the form the check must be +# able to see. Planting the lowercase form is what let a case-sensitive scan +# pass its own control. +printf 'canary: `^ {0,3}#{1,2} Closing Summary`\n' > "$CANARY" CANARY_SEEN=$(scan_prose_regex | grep -c 'drift-canary' || true) rm -f "$CANARY" require "positive control: the regex scan actually detects a planted literal" \ diff --git a/plugins/issue-driven-dev/skills/idd-close/SKILL.md b/plugins/issue-driven-dev/skills/idd-close/SKILL.md index 98bf25f..4983aed 100644 --- a/plugins/issue-driven-dev/skills/idd-close/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-close/SKILL.md @@ -316,11 +316,16 @@ Exit code: OPEN_PRS=$(gh pr list --repo "$GITHUB_REPO" --state open \ --search "in:body \"#${NUMBER}\"" \ -> ⚠ **`in:body "#N"` 不是精確比對**(#293 / #305)——它會誤中跨 repo 引用(`codex-pro#7` → `#7`)與無關的 PR。search 只能當粗篩,判定必須照 [`references/pr-issue-matching.md`](../../references/pr-issue-matching.md) 在 client 端精篩,並檢查 PR 不早於 issue。 - - --json number,url,headRefName,mergeable) + --json number,url,body,createdAt,headRefName,mergeable \ + | jq --argjson n "$NUMBER" ' + # search is a COARSE FILTER (#293/#305): GitHub tokenizes `#N`, so + # `in:body "#7"` matches a PR whose body only contains `codex-pro#7`, and + # `in:body "#10"` matched a PR containing no `#10` at all. Decide here. + map(select((.body // "") | test("(^|[^A-Za-z0-9_/-])#\($n)([^0-9]|$)")))') ``` +> ⚠ **`in:body "#N"` 不是精確比對**(#293 / #305)——它會誤中跨 repo 引用(`codex-pro#7` → `#7`)與無關的 PR。search 只能當粗篩,判定必須照 [`references/pr-issue-matching.md`](../../references/pr-issue-matching.md) 在 client 端精篩,並檢查 PR 不早於 issue。 + | 結果 | 行為 | |------|------| | 沒有 open PR | ✅ 通過(可能走 direct-commit path,或 PR 已 merged 變 closed state) | @@ -351,7 +356,20 @@ else # on merge (a bare branch NAME does not; #184 DA-1). Fall back to a local # idd/-* branch ref. BRANCH_REF=$(gh pr list --repo "$GITHUB_REPO" --state merged \ - --search "in:body \"#${NUMBER}\"" --json headRefOid -q '.[0].headRefOid' 2>/dev/null) + --search "in:body \"#${NUMBER}\"" --json number,body,createdAt,headRefOid 2>/dev/null \\ + | jq -r --argjson n "$NUMBER" --arg t "$ISSUE_CREATED_AT" ' + # GitHub tokenizes `#N`; the search is a COARSE FILTER only (#293/#305). + # Decide client-side: `#N` must not be preceded by a repo-ref character + # (excludes owner/repo#N) nor followed by a digit (excludes #12 vs #123), + # and a PR created BEFORE the issue cannot be its feature branch. + map(select(((.body // "") | test("(^|[^A-Za-z0-9_/-])#\\($n)([^0-9]|$)")) + and (.createdAt >= $t))) + | if length == 0 then "" + elif length == 1 then .[0].headRefOid + else (.[0].headRefOid) # caller MUST surface that there were several + end') + # ISSUE_CREATED_AT=$(gh issue view "$NUMBER" --repo "$GITHUB_REPO" --json createdAt --jq .createdAt) + # 多筆命中時不得靜默取第一筆 —— 印出「有 N 筆候選」再繼續(契約見 references/pr-issue-matching.md) if [ -z "$BRANCH_REF" ]; then BRANCH_REF=$(git -C "$WORKDIR" branch --list "idd/${NUMBER}-*" --format='%(refname:short)' | head -1) fi diff --git a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md index e6f829b..96bf3db 100644 --- a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md @@ -1033,6 +1033,9 @@ idx=1 # 指向同一份檔案,而且沒有任何錯誤訊息。 for f in "${ATTACHMENT_PATHS[@]}"; do ext="${f##*.}" + # `ext` reaches the asset name too, so it gets the same ASCII fold. + ext=$(printf '%s' "$ext" | LC_ALL=C tr -c 'A-Za-z0-9' '_' | cut -c1-10) + [ -z "$ext" ] && ext="bin" desc=$(make_desc "$f") # 簡短描述 e.g. "snq_timeline" # ASCII-fold:非 [A-Za-z0-9._-] 一律轉底線,再摺疊連續底線 desc=$(printf '%s' "$desc" | LC_ALL=C tr -c 'A-Za-z0-9._-' '_' | sed 's/__*/_/g; s/^_//; s/_$//') @@ -1048,8 +1051,28 @@ for f in "${ATTACHMENT_PATHS[@]}"; do echo "note: asset name collision — uploading as $upload_name instead" >&2 fi - gh release upload "$ATTACHMENTS_RELEASE" "$f#$upload_name" \ - --repo "$GITHUB_REPO" + # `gh release upload FILE#TEXT` sets a DISPLAY LABEL, not the asset name — + # the asset always takes the basename of the file on disk. (Verified: + # `gh release upload --help` — "To define a display label for an asset, append + # text starting with `#` after the file name".) An earlier version of this + # block computed `upload_name` and then never used it at all, so the naming + # convention documented below was NEVER applied; a later one passed it after a + # `#` and was equally inert. + # + # The only way to control the asset name is to upload a file whose BASENAME is + # the name you want. Stage a copy under the target name, upload that, remove it. + staging=$(mktemp -d) + cp "$f" "$staging/$upload_name" + if ! gh release upload "$ATTACHMENTS_RELEASE" "$staging/$upload_name" \ + --repo "$GITHUB_REPO" --clobber; then + # --clobber is correct HERE: the collision check above already refused to + # overwrite a pre-existing foreign asset, so anything left to clobber is our + # own re-upload of the same attachment. Without it a legitimate re-run fails. + echo "✗ upload failed: $upload_name" >&2 + rm -rf "$staging" + exit 1 + fi + rm -rf "$staging" idx=$((idx + 1)) done diff --git a/plugins/issue-driven-dev/skills/idd-list/SKILL.md b/plugins/issue-driven-dev/skills/idd-list/SKILL.md index 5fd7e20..9ef2157 100644 --- a/plugins/issue-driven-dev/skills/idd-list/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-list/SKILL.md @@ -90,9 +90,31 @@ gh issue list \ --json number,title,state,labels,updatedAt,body,comments ``` +> ### ⚠ `comments` 在這裡也被截斷(post-merge audit 2026-08-15) +> +> `--json comments` 會把巢狀 connection 解成 `comments(first: 100)`:**硬上限、不分頁、且回的是最舊的 100 則**(實測 `microsoft/vscode#301011`:155 則只回 100,首則 createdAt 與 REST page 1 相同)。 +> +> 這對 **Step 3 的 phase 推斷**與 **Step 4 的 `--audit-closes`** 都是致命的:closing summary 依定義是**最新**的一則,所以任何超過 100 則 comment 的 closed issue,它正是保證被丟掉的那一則 → `--audit-closes` 判 `missing` → 印出 `remediate: idd-close --retroactive #N` → 在已有 summary 的 issue 上貼重複內容。 +> +> **`#295` 的修法只落在 `scripts/check-closed-without-summary.sh`,沒有落在這裡** —— 而這裡才是使用者實際呼叫、且實際印出那句邀請的地方。修法(與該 script 同款): +> +> ```bash +> # 任何 comments 陣列長度 >= 100 的 issue 都可能被截斷,逐一補抓全量。 +> # 注意 `gh api --paginate --jq` 每頁各吐一個 array,必須 `jq -s add` 收攏。 +> for n in $(printf '%s' "$ISSUES_JSON" | jq -r '.[] | select((.comments|length) >= 100) | .number'); do +> case "$n" in ''|*[!0-9]*) continue ;; esac # .number 會進 API 路徑,先驗型 +> FULL=$(gh api "repos/$GITHUB_REPO/issues/$n/comments" --paginate \ +> --jq '[.[] | {body}]' 2>/dev/null | jq -s 'add // []' 2>/dev/null) +> # 補抓失敗、或補回來的比原本更少(部分頁)→ 標記,**永不判 missing** +> ... +> done +> ``` +> +> **不得只在其中一個 consumer 修**。這個 marker 有多個讀取端,而 `#295` 連七輪的教訓正是「修在被指出的地方、留下同族的鄰居」。 + 按 `updatedAt` desc 排序(最新活動在最上面)。**排序必須在 server 端發生** —— `--limit` 是 server 端套用的,先截再排等於「隨便 N 筆,排好序」,而且截掉的是誰完全看不出來(#299)。 -> **若 `--search` 與 `--label` 併用有衝突**(GitHub 的 search 語法與 `--label` flag 走不同路徑),退而求其次:把 `--limit` 放大到 `3 × $LIMIT` 抓回來、本地排序後再取前 `$LIMIT`,並在 footer 註明「已從 N 筆中取最近 M 筆」。**不可**維持現狀的靜默截斷。 +> **實測 `--search` 與 `--label` 可以併用**(2026-08-15 對本 repo 驗過)。保留退路僅為防禦 GitHub 端行為變動:若哪天真的衝突:把 `--limit` 放大到 `3 × $LIMIT` 抓回來、本地排序後再取前 `$LIMIT`,並在 footer 註明「已從 N 筆中取最近 M 筆」。**不可**維持現狀的靜默截斷。 ### Step 2.5: Fetch Open PRs (v2.51.0+) diff --git a/plugins/issue-driven-dev/skills/idd-verify/SKILL.md b/plugins/issue-driven-dev/skills/idd-verify/SKILL.md index 7d77bba..68a1b96 100644 --- a/plugins/issue-driven-dev/skills/idd-verify/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-verify/SKILL.md @@ -335,7 +335,7 @@ TaskCreate(name="triage_followup_issues", description="Step 5b: 分類 non-block a. N=$(git log --grep "#$NUMBER" origin/$DEFAULT_BRANCH..HEAD --oneline | wc -l) N>0 → 本地 mode HEAD~N..HEAD N=0 → b - b. PRS=$(gh pr list --search "#$NUMBER in:body" --state open --json number,headRefName,author) + b. PRS=$(gh pr list --search "#$NUMBER in:body" --state open --json number,body,createdAt,headRefName,author) > ⚠ **`in:body "#N"` 不是精確比對**(#293 / #305)——它會誤中跨 repo 引用(`codex-pro#7` → `#7`)與無關的 PR。search 只能當粗篩,判定必須照 [`references/pr-issue-matching.md`](../../references/pr-issue-matching.md) 在 client 端精篩,並檢查 PR 不早於 issue。