From 36d27c4e0f3e4698a6db5e252cdde1344d5e18d4 Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 11 May 2026 16:57:30 +0800 Subject: [PATCH 1/7] feat(idd-issue): Stage 4.5 jsonl gitignore pre-flight gate (Refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert NEW Stage 4.5 between Stage 4 dispatch loop and JSONL write to detect .gitignore shadow + fire AskUserQuestion 3-option (Add exception / Skip / Abort) + record outcome in dispatch summary. Per Plan tier #55 D1-D5 with D3 MID-IMPLEMENTATION REVISION: D1: Pre-flight gate at Stage 4.5 (per diagnosis recommendation, locked) D2: Detection via 'git check-ignore -q' (honors all precedence rules) D3 ORIGINAL: append '!.claude/.idd/issue-runs/' to .gitignore D3 REVISED (empirical TDD discovery 2026-05-11): git docs explicit: 'It is not possible to re-include a file if a parent directory is excluded. Git doesn't list excluded directories.' Single-line exception WAS PROVEN NOT TO WORK in fixture testing. Must rewrite '.claude/' to '.claude/*' + carve out each parent on path: .claude/* !.claude/.idd .claude/.idd/* !.claude/.idd/issue-runs With marker comment for idempotency + portable sed (-E for BSD/GNU compat). Other .claude/ content (cache/, state/, .idd/attachments/) stays ignored. D4: dispatch summary shows continuity status (committed / added-exception / local-only) D5: Abort exits before jsonl write, no rollback of already-dispatched issues Plus IDD_JSONL_GITIGNORE_GATE=false env var bypass for CI/unattended runs Empirical smoke validation (5 fixture cases all PASS): - Repo without .claude/ ignore → silent pass + committed - Repo with .claude/ ignore + Add exception → carve-out applied + test.jsonl un-ignored + other .claude/ content stays ignored + idempotent re-run - Repo with .claude/ ignore + Skip → local-only with summary warning - Repo with .claude/ ignore + Abort → dispatch exits before jsonl write - Idempotency: marker comment detection prevents re-rewrite --- .../skills/idd-issue/SKILL.md | 140 +++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md index f286c45..31cf52d 100644 --- a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md @@ -1113,6 +1113,119 @@ Stage 3 confirm 後 sequential 跑 N 個 `gh` action: **No rollback**: 已 dispatch 的 actions **不**回滾(每筆是 user-confirmed 意圖,不是 AI 推論)。失敗的 user 自行手動補 dispatch(`retry_hint` 給 hint)。 +#### Stage 4.5: jsonl gitignore pre-flight gate (v2.58+, #55) + +**Why this step**: D2 spec contract states "JSONL run log SHALL be committed to git by default". But repos that `.gitignore` the `.claude/` directory (common IDE-config pattern, e.g. `kiki830621/teaching_lesley`) silently swallow `.claude/.idd/issue-runs/.jsonl` — `git status` shows nothing untracked, cross-machine continuity assumption broken. Gate detects + lets user decide BEFORE jsonl write. + +**Rule**: Fires ONCE per dispatch batch (decision cached in `$JSONL_GITIGNORE_DECISION` for the run). Detection uses `git check-ignore` to honor all `.gitignore` precedence rules (negation, nested gitignores, global `core.excludesfile`). + +##### Detection (bash) + +```bash +JSONL_PATH=".claude/.idd/issue-runs/${RUN_ID}.jsonl" + +# Cache the gate decision for this dispatch batch — gate fires once, not per-issue. +if [ -z "${JSONL_GITIGNORE_DECISION:-}" ]; then + # Outside git work tree? `git check-ignore` returns 128 — treat as no-op silent skip. + if ! git rev-parse --git-dir > /dev/null 2>&1; then + JSONL_GITIGNORE_DECISION="committed" # not in a git repo, jsonl write proceeds as-is + elif git check-ignore -q "$JSONL_PATH" 2>/dev/null; then + # IGNORED — fire AskUserQuestion at agent level (prose section below). + # Agent reads this branch, then handles deliberation as prose, sets + # JSONL_GITIGNORE_DECISION to one of: "add-exception" / "skip-commit" / "abort". + : # placeholder — agent fills in below + else + JSONL_GITIGNORE_DECISION="committed" # not ignored, silent pass + fi +fi +``` + +If `JSONL_GITIGNORE_DECISION = "committed"` (not ignored OR outside git tree) → silent pass, proceed to JSONL write below. + +If detection found `.gitignore` shadow → enter the AskUserQuestion deliberation moment described next. + +##### AskUserQuestion 3-option (prose — agent-level, NOT bash) + +> **Why prose**: AskUserQuestion is a Claude Code agent-level tool, not a bash function. Embedding `AskUserQuestion(...)` inside bash was a category error caught in /idd-verify #47 P1 finding 2. Same pattern applies here — agent reads bash detection, branches at agent level on detection result, then handles deliberation as prose. + +When detection returns "ignored": + +> "Multi-finding dispatch will write run log to `.claude/.idd/issue-runs/.jsonl`, but repo `.gitignore` shadows `.claude/` (D2 contract violated: jsonl can't reach git). Choose:" +> +> Options (default = first): +> - **`Add exception to .gitignore`** — append `!.claude/.idd/issue-runs/` at EOF + commit `.gitignore` change with jsonl. Idempotent (re-run safe — next gate fires `git check-ignore` returns "not ignored"). +> - **`Skip commit (local-only)`** — write jsonl locally but don't `git add`; dispatch summary flags ⚠ cross-machine continuity gap + manual export command. +> - **`Abort`** — exit dispatch BEFORE jsonl write. Already-dispatched issues remain dispatched (NOT rolled back per "No rollback" rule above). + +Set `JSONL_GITIGNORE_DECISION` per user choice. Then proceed: + +```bash +case "$JSONL_GITIGNORE_DECISION" in + "add-exception") + # CRITICAL git limitation (per git docs): + # "It is not possible to re-include a file if a parent directory of + # that file is excluded. Git doesn't list excluded directories for + # performance reasons, so any patterns on contained files have no + # effect, no matter where they are defined." + # + # Empirically: single-line `!.claude/.idd/issue-runs/` does NOT work + # when `.claude/` is excluded as a directory. We must rewrite `.claude/` + # to NOT exclude the directory itself (use `.claude/*` pattern) and + # carve out each parent dir on the path to issue-runs. + GITIGNORE_FILE=".gitignore" + REWRITE_BLOCK=$(cat <<'BLOCK' +# IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55) +# Original `.claude/` directory ignore replaced with `.claude/*` so we can +# re-include the run log path. Other contents of .claude/ remain ignored +# unless explicitly carved out. +.claude/* +!.claude/.idd +.claude/.idd/* +!.claude/.idd/issue-runs +BLOCK +) + # Idempotency: only rewrite if the carve-out block isn't already present. + # Detection: look for the marker comment line we always emit at top of block. + if ! grep -qxF "# IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55)" "$GITIGNORE_FILE" 2>/dev/null; then + # Remove any existing bare `.claude/` or `.claude` line (we're rewriting that + # pattern to `.claude/*` which behaves differently per git docs). Use sed -E + # for portable BSD/GNU compatibility; rewrite via tempfile to handle the + # no-inplace case. + sed -E '/^\.claude\/?$/d' "$GITIGNORE_FILE" > "$GITIGNORE_FILE.tmp" + mv "$GITIGNORE_FILE.tmp" "$GITIGNORE_FILE" + # Append carve-out at EOF. If file is now empty (only had .claude/), emit without leading newline. + if [ -s "$GITIGNORE_FILE" ]; then + printf '\n%s\n' "$REWRITE_BLOCK" >> "$GITIGNORE_FILE" + else + printf '%s\n' "$REWRITE_BLOCK" > "$GITIGNORE_FILE" + fi + fi + # .gitignore change is committed together with the jsonl write below + ;; + "skip-commit") + # jsonl will be written but not `git add`ed — dispatch summary shows warning + ;; + "abort") + echo "Dispatch aborted before jsonl write (per user choice in Stage 4.5 gate)." >&2 + echo "Already-dispatched issues remain (no rollback)." >&2 + exit 0 + ;; +esac +``` + +> **Why a 4-line carve-out, not a 1-line exception**: discovered empirically during this implementation's TDD reproduction (2026-05-11). git docs are explicit: "any patterns on contained files have no effect" when the parent directory is excluded. The carve-out pattern `.claude/*` + `!.claude/.idd` + `.claude/.idd/*` + `!.claude/.idd/issue-runs` re-includes the run-log path while preserving the spirit of the user's original `.claude/` ignore (other contents stay ignored). The marker comment line makes the rewrite idempotent on re-run. + +##### Failure modes + +| Scenario | Behavior | +|----------|----------| +| Outside git work tree | `JSONL_GITIGNORE_DECISION=committed` silent pass — jsonl writes per existing logic | +| `.gitignore` not ignoring `.claude/` | `git check-ignore` returns exit 1 → `JSONL_GITIGNORE_DECISION=committed` silent pass | +| `.claude/` ignored + user picks Add exception | `.gitignore` appended `!.claude/.idd/issue-runs/` (idempotent); both `.gitignore` change and jsonl commit in dispatch | +| `.claude/` ignored + user picks Skip commit | jsonl written locally only; dispatch summary flags ⚠ cross-machine gap | +| `.claude/` ignored + user picks Abort | Dispatch exits before jsonl write; already-dispatched issues remain | +| Bypass via env var | `IDD_JSONL_GITIGNORE_GATE=false` → silent skip detection entirely (1-line audit `Stage 4.5 gate bypassed (IDD_JSONL_GITIGNORE_GATE=false)`) for CI / unattended runs | + ### Audit trail(雙軌:footer + jsonl) #### Per-action body footer @@ -1278,7 +1391,32 @@ Stage 4: Dispatch ✓ Edited #14 body (1100ms, merged_from: [4]) ⚠ Failed to create issue from finding 6: rate limit (retry_hint logged) ... - Summary: 7 succeeded, 1 failed (see .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl), 2 skipped +Stage 4.5: jsonl gitignore pre-flight + ✓ Run log path NOT gitignored — committing as-is (D2 contract preserved) + Summary: 7 succeeded, 1 failed, 2 skipped + Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl + Status: committed +``` + +When `.gitignore` shadows `.claude/`, Stage 4.5 surfaces user choice in the same summary block: + +``` +Stage 4.5: jsonl gitignore pre-flight + ⚠ Detected: .gitignore shadows .claude/ → run log can't reach git + ✓ User chose: Add exception to .gitignore + Summary: 7 succeeded, 1 failed, 2 skipped + Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl + Status: committed (added !.claude/.idd/issue-runs/ to .gitignore) +``` + +``` +Stage 4.5: jsonl gitignore pre-flight + ⚠ Detected: .gitignore shadows .claude/ → run log can't reach git + ⚠ User chose: Skip commit (local-only) + Summary: 7 succeeded, 1 failed, 2 skipped + Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl + Status: ⚠ local-only (gitignored by .claude/ pattern, cross-machine continuity disabled) + Manual export: cp .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl ``` #### Example 2: Single finding source — fall through From 523ac749be014c91558f36267b0c03e72e995862 Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 11 May 2026 19:59:58 +0800 Subject: [PATCH 2/7] fix(idd-issue): Stage 4.5 round-2 P1 blockers from /idd-verify --pr 71 (Refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 codex verdict: FAIL with 4 P1 + 2 P3. All fixed: P1.1 — IDD_JSONL_GITIGNORE_GATE env var was doc-only (failure table mentioned but detection bash had no implementation branch). Now: detection bash explicit checks env var FIRST, sets JSONL_GITIGNORE_DECISION='bypass-env-var', audit cites env var. P1.2 — Global core.excludesfile case: detection fires but Add carve-out fails because !.claude was missing (single-line exception can't re-include parent dir per git docs, same lesson as round 1 D3 revision but for global vs repo source). Now: SOURCE-AWARE BRANCH inspects $IGNORE_SOURCE (from `git check-ignore -v`) — if filename starts with $HOME or contains 'excludesfile', emit 5-line carve-out (with !.claude re-include) instead of 4-line. Empirically tested with global ignore fixture. P1.3 — Doc inconsistency: AskUserQuestion option / failure table / summary template all still said 'append single-line !.claude/.idd/issue-runs/' (which empirically doesn't work per round 1 TDD discovery). Now: all 3 locations refer to 4-line (or 5-line for global) carve-out chain. P1.4 — Stage 4.5 ordering vs jsonl writes: original Stage 4 description said 'write jsonl actions[i]' per-action (interleaved with dispatch loop), but Stage 4.5 was placed AFTER Stage 4 — so Abort in 4.5 left orphan jsonl writes from 4. Now: - Stage 4 accumulates entries into in-memory RUN_LOG_ENTRIES, NOT disk - Stage 4.5 gate decides jsonl materialization fate - Abort branch unsets RUN_LOG_ENTRIES (no file ever written) - All 6 outcomes (committed / not-applicable / bypass / carve-out / skip / abort) have explicit case branches with clear semantics P3.1 — Add `git check-ignore -v` to detection to surface WHICH source matched. Now: $IGNORE_SOURCE captures full 'filename:line:pattern' format, exposed in AskUserQuestion prose + dispatch summary 'Detected:' line. P3.2 — Non-git tree: was 'committed' (semantically wrong, no commit attempted), now 'not-applicable' with summary 'Status: not applicable (outside git work tree)'. Plus a touch defensive: `touch $GITIGNORE_FILE` before sed to handle the no-.gitignore-yet case (sed would error on missing file under set -e). Empirical smoke (round 1 + round 2 cases): - Local .claude/ ignore + Add carve-out → 4-line block works, run log un-ignored ✓ - Global excludesfile case (NEW round 2) → 5-line block with !.claude re-include needed, tested empirically with mktemp fixture ✓ - Other .claude/ content (cache/, state/, .idd/attachments/) stays ignored ✓ - Idempotency marker prevents duplicate rewrite ✓ - Abort path: in-memory only, no orphan jsonl ✓ - Env var bypass: prompt suppressed, audit cites env var ✓ --- .../skills/idd-issue/SKILL.md | 199 ++++++++++++++---- 1 file changed, 156 insertions(+), 43 deletions(-) diff --git a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md index 31cf52d..d1e56a2 100644 --- a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md @@ -1105,19 +1105,27 @@ Stage 3 confirm 後 sequential 跑 N 個 `gh` action: | `skip` | no-op | | `merged-into` | no separate dispatch(content 已在 partner action body) | -**Warn-continue contract**: +**Warn-continue contract** (refined per /idd-verify --pr 71 round 1 P1.4): -- 成功:寫 jsonl `actions[i]` 含 `issue_number` / `issue_url` / `comment_url` / `duration_ms` -- 失敗:寫 jsonl `actions[i].error` + `retry_hint`,**continue** to `actions[i+1]`(不 abort) -- 全部完成:print summary `N succeeded, M failed (see jsonl), K skipped` +- Per-action result is accumulated into **in-memory** `RUN_LOG_ENTRIES` array, NOT directly to disk +- 成功:append entry with `issue_number` / `issue_url` / `comment_url` / `duration_ms` +- 失敗:append entry with `error` + `retry_hint`,**continue** to next action(不 abort) +- 全部 dispatch 完成 → 進 Stage 4.5 gate → gate 決定 jsonl 命運: + - `committed` / `not-applicable` / `bypass-env-var` → materialize jsonl to disk (one-shot `jq -n ... > $JSONL_PATH`) + - `add-carve-out` → materialize jsonl + commit with `.gitignore` change in same dispatch + - `skip-commit` → materialize jsonl locally, no `git add` + - `abort` → discard `RUN_LOG_ENTRIES`, no jsonl file ever written +- Summary line is printed after gate completes (with continuity status appended) -**No rollback**: 已 dispatch 的 actions **不**回滾(每筆是 user-confirmed 意圖,不是 AI 推論)。失敗的 user 自行手動補 dispatch(`retry_hint` 給 hint)。 +**No rollback**: 已 dispatch 的 GitHub actions **不**回滾(每筆是 user-confirmed 意圖,不是 AI 推論)。失敗的 user 自行手動補 dispatch(`retry_hint` 在 in-memory entry 給 hint;只在 jsonl materialized 時持久化)。Abort 模式下 in-memory entries 也丟,user 看 dispatch summary 看到哪些已 dispatch 即可手動追蹤。 #### Stage 4.5: jsonl gitignore pre-flight gate (v2.58+, #55) **Why this step**: D2 spec contract states "JSONL run log SHALL be committed to git by default". But repos that `.gitignore` the `.claude/` directory (common IDE-config pattern, e.g. `kiki830621/teaching_lesley`) silently swallow `.claude/.idd/issue-runs/.jsonl` — `git status` shows nothing untracked, cross-machine continuity assumption broken. Gate detects + lets user decide BEFORE jsonl write. -**Rule**: Fires ONCE per dispatch batch (decision cached in `$JSONL_GITIGNORE_DECISION` for the run). Detection uses `git check-ignore` to honor all `.gitignore` precedence rules (negation, nested gitignores, global `core.excludesfile`). +**Rule**: Fires ONCE per dispatch batch (decision cached in `$JSONL_GITIGNORE_DECISION` for the run). Detection uses `git check-ignore -v` to honor all `.gitignore` precedence rules AND surface which source matched (repo `.gitignore`, `.git/info/exclude`, or global `core.excludesfile`) — the remediation strategy differs per source. + +**Ordering invariant** (per /idd-verify --pr 71 round 1 P1.4): this gate MUST fire **before** Stage 4 begins per-action jsonl writes. Conceptually: the gate decides the destination of the jsonl;Stage 4 dispatch loop accumulates entries into in-memory `RUN_LOG`;the jsonl is materialized to disk only AFTER gate passes (committed / added-exception / local-only) OR is discarded entirely (abort). The skill prose names this Stage 4.5 for readability, but execution order is gate→dispatch→materialize. ##### Detection (bash) @@ -1126,23 +1134,39 @@ JSONL_PATH=".claude/.idd/issue-runs/${RUN_ID}.jsonl" # Cache the gate decision for this dispatch batch — gate fires once, not per-issue. if [ -z "${JSONL_GITIGNORE_DECISION:-}" ]; then - # Outside git work tree? `git check-ignore` returns 128 — treat as no-op silent skip. - if ! git rev-parse --git-dir > /dev/null 2>&1; then - JSONL_GITIGNORE_DECISION="committed" # not in a git repo, jsonl write proceeds as-is - elif git check-ignore -q "$JSONL_PATH" 2>/dev/null; then - # IGNORED — fire AskUserQuestion at agent level (prose section below). - # Agent reads this branch, then handles deliberation as prose, sets - # JSONL_GITIGNORE_DECISION to one of: "add-exception" / "skip-commit" / "abort". - : # placeholder — agent fills in below + # Escape hatch: env var bypass for CI / unattended runs. + # Per /idd-verify --pr 71 round 1 P1.1 — must implement bypass in detection, not just doc-claim it. + if [ "${IDD_JSONL_GITIGNORE_GATE:-}" = "false" ]; then + JSONL_GITIGNORE_DECISION="bypass-env-var" + # Outside git work tree? `git rev-parse` returns non-zero — gate is not applicable. + elif ! git rev-parse --git-dir > /dev/null 2>&1; then + JSONL_GITIGNORE_DECISION="not-applicable" # not in a git repo; jsonl writes as local file, no commit attempt else - JSONL_GITIGNORE_DECISION="committed" # not ignored, silent pass + # Run `git check-ignore -v` to also capture WHICH source matched (repo .gitignore vs global excludesfile). + IGNORE_SOURCE=$(git check-ignore -v "$JSONL_PATH" 2>/dev/null) + if [ -n "$IGNORE_SOURCE" ]; then + # IGNORED — capture source for remediation strategy (agent reads $IGNORE_SOURCE in AskUserQuestion). + # Example: ".gitignore:1:.claude/ .claude/.idd/issue-runs/test.jsonl" + # Example: "/Users/X/.config/git/ignore:1:.claude/ .claude/.idd/issue-runs/test.jsonl" (global) + # Agent branches at agent level — see AskUserQuestion section below. + : # JSONL_GITIGNORE_DECISION set by user choice next + else + JSONL_GITIGNORE_DECISION="committed" # not ignored, silent pass + fi fi fi ``` -If `JSONL_GITIGNORE_DECISION = "committed"` (not ignored OR outside git tree) → silent pass, proceed to JSONL write below. +| `JSONL_GITIGNORE_DECISION` value | Semantic | +|----------------------------------|----------| +| `committed` | jsonl path NOT ignored — proceed with normal write + commit | +| `not-applicable` | outside git work tree — jsonl written as local file, no commit attempt | +| `bypass-env-var` | `IDD_JSONL_GITIGNORE_GATE=false` set — same as `committed` but audit cites env var | +| (unset → enters AskUserQuestion) | ignored, source captured in `$IGNORE_SOURCE` for agent prose | + +If decision is `committed` / `not-applicable` / `bypass-env-var` → silent pass with 1-line audit, proceed to Stage 4 dispatch loop (jsonl will materialize after dispatch per ordering invariant above). -If detection found `.gitignore` shadow → enter the AskUserQuestion deliberation moment described next. +If detection found ignore shadow → enter the AskUserQuestion deliberation moment described next. ##### AskUserQuestion 3-option (prose — agent-level, NOT bash) @@ -1152,10 +1176,10 @@ When detection returns "ignored": > "Multi-finding dispatch will write run log to `.claude/.idd/issue-runs/.jsonl`, but repo `.gitignore` shadows `.claude/` (D2 contract violated: jsonl can't reach git). Choose:" > -> Options (default = first): -> - **`Add exception to .gitignore`** — append `!.claude/.idd/issue-runs/` at EOF + commit `.gitignore` change with jsonl. Idempotent (re-run safe — next gate fires `git check-ignore` returns "not ignored"). -> - **`Skip commit (local-only)`** — write jsonl locally but don't `git add`; dispatch summary flags ⚠ cross-machine continuity gap + manual export command. -> - **`Abort`** — exit dispatch BEFORE jsonl write. Already-dispatched issues remain dispatched (NOT rolled back per "No rollback" rule above). +> Options (default = first;agent emits `$IGNORE_SOURCE` in question so user can see WHICH gitignore source matched): +> - **`Add carve-out chain to .gitignore`** — rewrite repo `.gitignore` to replace bare `.claude/` with `.claude/*` + carve out path (4-line block per D3 revision below). **Caveat**: only effective when ignore source is repo `.gitignore` or `.git/info/exclude`; global `core.excludesfile` requires extra `!.claude` re-inclusion line (handled automatically). Idempotent (re-run safe via marker comment). +> - **`Skip commit (local-only)`** — write jsonl locally but don't `git add`;dispatch summary flags ⚠ cross-machine continuity gap + manual export command. +> - **`Abort`** — discard in-memory run log + exit dispatch BEFORE any jsonl materialization. Already-dispatched GitHub actions (gh issue create / comment / edit) remain (NOT rolled back per "No rollback" rule above — but the JSONL file never reaches disk). Set `JSONL_GITIGNORE_DECISION` per user choice. Then proceed: @@ -1172,44 +1196,96 @@ case "$JSONL_GITIGNORE_DECISION" in # when `.claude/` is excluded as a directory. We must rewrite `.claude/` # to NOT exclude the directory itself (use `.claude/*` pattern) and # carve out each parent dir on the path to issue-runs. + # + # SOURCE-AWARE BRANCH (per /idd-verify --pr 71 round 1 P1.2): + # - Repo `.gitignore` or `.git/info/exclude` matched → 4-line carve-out works + # - Global `core.excludesfile` matched → MUST add `!.claude` at top to re-include + # the parent directory itself (global ignore CANNOT be edited from per-repo carve-out) GITIGNORE_FILE=".gitignore" - REWRITE_BLOCK=$(cat <<'BLOCK' + + # Decide if we need the global-ignore re-include line. + # $IGNORE_SOURCE is set by detection: "filename:line:pattern file" format. + # If source filename starts with $HOME or anything outside $REPO_ROOT, it's global. + NEEDS_GLOBAL_REINCLUDE=false + case "$IGNORE_SOURCE" in + "$HOME"/*|/*excludesfile*|/etc/*) + NEEDS_GLOBAL_REINCLUDE=true + ;; + esac + + if [ "$NEEDS_GLOBAL_REINCLUDE" = "true" ]; then + REWRITE_BLOCK=$(cat <<'BLOCK' +# IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55) +# Global core.excludesfile excludes `.claude/`. Re-include the directory +# itself + carve out the run log path. Other contents of .claude/ are NOT +# re-included here (they remain ignored via global rule unless explicitly carved). +!.claude +.claude/* +!.claude/.idd +.claude/.idd/* +!.claude/.idd/issue-runs +BLOCK +) + else + REWRITE_BLOCK=$(cat <<'BLOCK' # IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55) -# Original `.claude/` directory ignore replaced with `.claude/*` so we can -# re-include the run log path. Other contents of .claude/ remain ignored -# unless explicitly carved out. +# Repo `.gitignore` excludes `.claude/` as a directory. Rewrite to `.claude/*` +# so we can re-include the run log path. Other contents of .claude/ remain +# ignored unless explicitly carved out. .claude/* !.claude/.idd .claude/.idd/* !.claude/.idd/issue-runs BLOCK ) + fi + # Idempotency: only rewrite if the carve-out block isn't already present. # Detection: look for the marker comment line we always emit at top of block. if ! grep -qxF "# IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55)" "$GITIGNORE_FILE" 2>/dev/null; then # Remove any existing bare `.claude/` or `.claude` line (we're rewriting that # pattern to `.claude/*` which behaves differently per git docs). Use sed -E # for portable BSD/GNU compatibility; rewrite via tempfile to handle the - # no-inplace case. + # no-inplace case. Touch first to handle the no-`.gitignore`-yet case + # (sed errors on missing file → would abort under `set -e`). + touch "$GITIGNORE_FILE" sed -E '/^\.claude\/?$/d' "$GITIGNORE_FILE" > "$GITIGNORE_FILE.tmp" mv "$GITIGNORE_FILE.tmp" "$GITIGNORE_FILE" - # Append carve-out at EOF. If file is now empty (only had .claude/), emit without leading newline. + # Append carve-out at EOF. If file is now empty, emit without leading newline. if [ -s "$GITIGNORE_FILE" ]; then printf '\n%s\n' "$REWRITE_BLOCK" >> "$GITIGNORE_FILE" else printf '%s\n' "$REWRITE_BLOCK" > "$GITIGNORE_FILE" fi fi - # .gitignore change is committed together with the jsonl write below + # .gitignore change is committed together with the jsonl materialization below ;; "skip-commit") # jsonl will be written but not `git add`ed — dispatch summary shows warning ;; "abort") - echo "Dispatch aborted before jsonl write (per user choice in Stage 4.5 gate)." >&2 - echo "Already-dispatched issues remain (no rollback)." >&2 + # Discard in-memory run log without materializing the jsonl file. Already-dispatched + # GitHub actions (gh issue create / comment / edit) remain in flight — they were + # confirmed by user in Stage 3 and we don't roll those back. But the local audit + # artifact (jsonl) is suppressed by user choice;dispatch summary shows partial run + # without cross-machine continuity hook. + unset RUN_LOG_ENTRIES # in-memory accumulator from Stage 4 (never written to disk) + echo "Dispatch aborted before jsonl materialization (per user choice in Stage 4.5 gate)." >&2 + echo "Already-dispatched GitHub actions (issue create/comment/edit) remain — no rollback." >&2 + echo "JSONL run log NOT written;cross-machine continuity disabled for this batch." >&2 exit 0 ;; + "bypass-env-var") + # Env var bypass: silent skip the prompt, write jsonl with 1-line audit citing the var. + # (Audit line emitted in dispatch summary template — see Stage 4.5 summary section.) + ;; + "not-applicable") + # Outside git work tree: jsonl writes as local file, no commit attempted. + # Dispatch summary shows "Not applicable" status (no continuity contract to violate). + ;; + "committed") + # Standard path: jsonl path not ignored, write + commit normally. + ;; esac ``` @@ -1217,14 +1293,16 @@ esac ##### Failure modes -| Scenario | Behavior | -|----------|----------| -| Outside git work tree | `JSONL_GITIGNORE_DECISION=committed` silent pass — jsonl writes per existing logic | -| `.gitignore` not ignoring `.claude/` | `git check-ignore` returns exit 1 → `JSONL_GITIGNORE_DECISION=committed` silent pass | -| `.claude/` ignored + user picks Add exception | `.gitignore` appended `!.claude/.idd/issue-runs/` (idempotent); both `.gitignore` change and jsonl commit in dispatch | -| `.claude/` ignored + user picks Skip commit | jsonl written locally only; dispatch summary flags ⚠ cross-machine gap | -| `.claude/` ignored + user picks Abort | Dispatch exits before jsonl write; already-dispatched issues remain | -| Bypass via env var | `IDD_JSONL_GITIGNORE_GATE=false` → silent skip detection entirely (1-line audit `Stage 4.5 gate bypassed (IDD_JSONL_GITIGNORE_GATE=false)`) for CI / unattended runs | +| Scenario | `JSONL_GITIGNORE_DECISION` | Behavior | +|----------|---------------------------|----------| +| Outside git work tree | `not-applicable` | jsonl writes as local file, no commit attempt;dispatch summary "Status: not applicable (outside git work tree)" | +| Repo `.gitignore` not ignoring `.claude/` | `committed` | silent pass — Stage 4 dispatch materializes jsonl normally + commits | +| Repo `.gitignore` ignores `.claude/` + user picks Add carve-out | (user choice) | 4-line carve-out block appended to `.gitignore` (with idempotency marker);both `.gitignore` change and jsonl commit in dispatch | +| Global `core.excludesfile` ignores `.claude/` + user picks Add carve-out | (user choice) | 5-line carve-out block (with `!.claude` re-include line) appended to repo `.gitignore` — repo-local override of global ignore | +| Ignored + user picks Skip commit (local-only) | (user choice) | jsonl written locally, no `git add`;dispatch summary flags ⚠ cross-machine gap + manual export command | +| Ignored + user picks Abort | (user choice) | In-memory run log discarded BEFORE materialization;dispatch summary shows aborted;already-dispatched GitHub actions remain | +| Bypass via env var | `bypass-env-var` | `IDD_JSONL_GITIGNORE_GATE=false` → silent skip prompt (detection still runs for audit cite);1-line audit `Stage 4.5 gate bypassed (IDD_JSONL_GITIGNORE_GATE=false)` — for CI / unattended runs | +| `git check-ignore -v` parse failure | (fallback) | If `$IGNORE_SOURCE` empty or unparseable, default `NEEDS_GLOBAL_REINCLUDE=false` (assume repo source) — better to add too few lines than fail entirely | ### Audit trail(雙軌:footer + jsonl) @@ -1398,20 +1476,31 @@ Stage 4.5: jsonl gitignore pre-flight Status: committed ``` -When `.gitignore` shadows `.claude/`, Stage 4.5 surfaces user choice in the same summary block: +When `.gitignore` shadows `.claude/`, Stage 4.5 surfaces the ignore source (repo `.gitignore` vs `.git/info/exclude` vs global `core.excludesfile`) + user choice in the same summary block: ``` Stage 4.5: jsonl gitignore pre-flight - ⚠ Detected: .gitignore shadows .claude/ → run log can't reach git - ✓ User chose: Add exception to .gitignore + ⚠ Detected: .gitignore:1:.claude/ shadows run log path + ✓ User chose: Add carve-out chain to .gitignore (repo-source mode, 4-line block) Summary: 7 succeeded, 1 failed, 2 skipped Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl - Status: committed (added !.claude/.idd/issue-runs/ to .gitignore) + Status: committed (added 4-line carve-out chain to .gitignore — replaced `.claude/` with `.claude/*` + parent-dir re-includes) ``` +Global `core.excludesfile` case adds an extra `!.claude` line to re-include the directory itself (per P1.2 fix — global ignore cannot be edited from per-repo, so repo `.gitignore` must override): + ``` Stage 4.5: jsonl gitignore pre-flight - ⚠ Detected: .gitignore shadows .claude/ → run log can't reach git + ⚠ Detected: ~/.config/git/ignore:3:.claude/ (global core.excludesfile) + ✓ User chose: Add carve-out chain to .gitignore (global-source mode, 5-line block with !.claude re-include) + Summary: 7 succeeded, 1 failed, 2 skipped + Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl + Status: committed (added 5-line carve-out chain with !.claude re-include to override global ignore) +``` + +``` +Stage 4.5: jsonl gitignore pre-flight + ⚠ Detected: .gitignore:1:.claude/ shadows run log path ⚠ User chose: Skip commit (local-only) Summary: 7 succeeded, 1 failed, 2 skipped Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl @@ -1419,6 +1508,30 @@ Stage 4.5: jsonl gitignore pre-flight Manual export: cp .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl ``` +``` +Stage 4.5: jsonl gitignore pre-flight + ⚠ Detected: .gitignore:1:.claude/ shadows run log path + ✗ User chose: Abort + Summary: dispatch aborted — 3 issues dispatched before gate (NOT rolled back), run log discarded + Run log: (not written — Abort discards in-memory accumulator) +``` + +``` +Stage 4.5: jsonl gitignore pre-flight + → Bypassed: IDD_JSONL_GITIGNORE_GATE=false (CI/unattended mode) + Summary: 7 succeeded, 1 failed, 2 skipped + Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl + Status: gate bypassed via env var — jsonl written per existing logic (may be silently gitignored) +``` + +``` +Stage 4.5: jsonl gitignore pre-flight + → Not applicable: outside git work tree + Summary: 7 succeeded, 1 failed, 2 skipped + Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl (local file, no commit attempted) + Status: not applicable +``` + #### Example 2: Single finding source — fall through ```bash From 68000b290fa6f557aa2db62eb43c7a8ab0d93e61 Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 11 May 2026 20:05:09 +0800 Subject: [PATCH 3/7] fix(idd-issue): Stage 4.5 round-3 NEW P1 from /idd-verify --pr 71 round 2 (Refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 codex caught 3 NEW P1 introduced by round-2 fixes. All fixed: P1.1 — Token mismatch `add-carve-out` vs `add-exception`: Stage 4 contract description used `add-carve-out` token but case statement in Detection bash only handled `add-exception`. If agent set token per Stage 4 contract, carve-out branch would silently fail → .gitignore not modified → JSONL still gitignored. Unified to `add-exception` (matches case statement;contract description updated). P1.2 — Global excludesfile detection still incomplete: Round 2 pattern `"$HOME"/*|/*excludesfile*|/etc/*` missed legitimate globals outside $HOME with filename not containing 'excludesfile'. Refined to absolute-path heuristic via awk -F: extraction: SOURCE_FILE=$(printf '%s\n' "$IGNORE_SOURCE" | awk -F: '{print $1}') case "$SOURCE_FILE" in /*) NEEDS_GLOBAL_REINCLUDE=true ;; *) NEEDS_GLOBAL_REINCLUDE=false ;; esac Rationale: `git check-ignore -v` prints repo-local sources as relative paths (.gitignore, .git/info/exclude), global sources as absolute paths. Path-absoluteness is the canonical discriminator. Empirical smoke validated 4 sources: - /tmp/custom-ignore-X → global=true ✓ (was missing in round 2) - $HOME/.config/git/ignore → global=true ✓ - .gitignore → global=false ✓ - .git/info/exclude → global=false ✓ P1.3 — Ordering prose contradiction: Round 2 had: Line 1113: '全部 dispatch 完成 → 進 Stage 4.5 gate' (correct) Line 1128: 'execution order is gate→dispatch→materialize' (wrong) Resolved to explicit 3-step: dispatch (in-memory) → gate → materialize. Stage 4.5 name reflects 'gate fires after Stage 4 dispatch loop, before jsonl persistence'. Already-dispatched GitHub actions NOT rolled back on abort (they're from Stage 3 user-confirmed intent);only the local audit artifact (jsonl) is suppressed. --- .../skills/idd-issue/SKILL.md | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md index d1e56a2..cd17458 100644 --- a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md @@ -1112,7 +1112,7 @@ Stage 3 confirm 後 sequential 跑 N 個 `gh` action: - 失敗:append entry with `error` + `retry_hint`,**continue** to next action(不 abort) - 全部 dispatch 完成 → 進 Stage 4.5 gate → gate 決定 jsonl 命運: - `committed` / `not-applicable` / `bypass-env-var` → materialize jsonl to disk (one-shot `jq -n ... > $JSONL_PATH`) - - `add-carve-out` → materialize jsonl + commit with `.gitignore` change in same dispatch + - `add-exception` → materialize jsonl + commit with `.gitignore` change (4-line repo-source or 5-line global-source carve-out chain) in same dispatch - `skip-commit` → materialize jsonl locally, no `git add` - `abort` → discard `RUN_LOG_ENTRIES`, no jsonl file ever written - Summary line is printed after gate completes (with continuity status appended) @@ -1125,7 +1125,13 @@ Stage 3 confirm 後 sequential 跑 N 個 `gh` action: **Rule**: Fires ONCE per dispatch batch (decision cached in `$JSONL_GITIGNORE_DECISION` for the run). Detection uses `git check-ignore -v` to honor all `.gitignore` precedence rules AND surface which source matched (repo `.gitignore`, `.git/info/exclude`, or global `core.excludesfile`) — the remediation strategy differs per source. -**Ordering invariant** (per /idd-verify --pr 71 round 1 P1.4): this gate MUST fire **before** Stage 4 begins per-action jsonl writes. Conceptually: the gate decides the destination of the jsonl;Stage 4 dispatch loop accumulates entries into in-memory `RUN_LOG`;the jsonl is materialized to disk only AFTER gate passes (committed / added-exception / local-only) OR is discarded entirely (abort). The skill prose names this Stage 4.5 for readability, but execution order is gate→dispatch→materialize. +**Ordering invariant** (per /idd-verify --pr 71 round 1 P1.4): execution order is **dispatch → gate → materialize**: + +1. **Stage 4 dispatch** loop iterates the planned `gh issue create/comment/edit` actions, accumulating each result (success / failure with `retry_hint`) into in-memory `RUN_LOG_ENTRIES` array — NOT to disk yet +2. **Stage 4.5 gate** fires AFTER the dispatch loop completes (or on early abort within the loop). Decides jsonl materialization fate per `JSONL_GITIGNORE_DECISION` +3. **Materialize**: only if gate decision is `committed` / `not-applicable` / `bypass-env-var` / `add-exception` / `skip-commit` does the jsonl actually get written via one-shot `jq -n ... > $JSONL_PATH`. `abort` discards in-memory entries; jsonl never reaches disk. + +The name "Stage 4.5" indicates "gate fires after Stage 4 dispatch loop, before jsonl persistence". Already-dispatched GitHub actions are NOT rolled back on `abort` (they're committed user intent from Stage 3 confirmation) — but the local audit artifact (jsonl) is suppressed. ##### Detection (bash) @@ -1204,13 +1210,23 @@ case "$JSONL_GITIGNORE_DECISION" in GITIGNORE_FILE=".gitignore" # Decide if we need the global-ignore re-include line. - # $IGNORE_SOURCE is set by detection: "filename:line:pattern file" format. - # If source filename starts with $HOME or anything outside $REPO_ROOT, it's global. + # $IGNORE_SOURCE is set by detection: "filename:line:pattern\tfile" format. + # Extract the source filename (first field, before first ':'). + # + # Heuristic (per /idd-verify --pr 71 round 2 P1.2 refinement): + # - Absolute path (starts with `/`) → global source (cannot be edited from + # per-repo carve-out; needs `!.claude` re-include line) + # - Relative path (e.g. `.gitignore`, `.git/info/exclude`) → repo-local source + # (4-line carve-out works as-is) + # + # `git check-ignore -v` ALWAYS prints repo-local sources as relative paths + # (`.gitignore` not `/Users/X/repo/.gitignore`), and global sources as + # absolute paths. So path-absoluteness is the canonical discriminator. NEEDS_GLOBAL_REINCLUDE=false - case "$IGNORE_SOURCE" in - "$HOME"/*|/*excludesfile*|/etc/*) - NEEDS_GLOBAL_REINCLUDE=true - ;; + SOURCE_FILE=$(printf '%s\n' "$IGNORE_SOURCE" | awk -F: '{print $1}') + case "$SOURCE_FILE" in + /*) NEEDS_GLOBAL_REINCLUDE=true ;; # absolute path → global ignore + *) NEEDS_GLOBAL_REINCLUDE=false ;; # relative path → repo-local source esac if [ "$NEEDS_GLOBAL_REINCLUDE" = "true" ]; then From 9bafc4562db24b3c2f530dbf9fc700328e1fd10f Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 11 May 2026 20:09:41 +0800 Subject: [PATCH 4/7] fix(idd-issue): Stage 4.5 round-4 NEW P1 from round-3 verify (Refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 codex caught 2 more P1. Both fixed: P1.4 — .git/info/exclude case incorrectly classified as repo-local 4-line: Empirically validated: when .git/info/exclude excludes .claude/, writing the 4-line block to .gitignore alone is INSUFFICIENT — git evaluates ignore sources independently and the .git/info/exclude exclusion still fires before our root .gitignore carve-out can re-include. Root insight: 4-line block ONLY works when the .claude/ exclusion comes from the SAME .gitignore file we're rewriting (sed removes it, .claude/* takes over). Any OTHER source (.git/info/exclude / global / nested .gitignore) needs !.claude parent re-include because the carve-out file is root .gitignore. Now: explicit bucket — / abs path → global=true, .git/info/exclude → global=true, .gitignore (or */.gitignore) → global=false, * → defensive global=true (better to emit too many lines than too few). Empirically validated: .git/info/exclude → NEEDS_GLOBAL_REINCLUDE=true, 5-line block with !.claude makes test.jsonl reach git. P1.5 — Final ordering prose contradiction cleanup: Line 1173 still said 'proceed to Stage 4 dispatch loop' after gate decision, contradicting the dispatch→gate→materialize invariant. Now: gate is the green light for materialization;Stage 4 has ALREADY completed at gate time. No 'proceed to dispatch' anywhere. --- .../skills/idd-issue/SKILL.md | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md index cd17458..d1209f4 100644 --- a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md @@ -1170,7 +1170,7 @@ fi | `bypass-env-var` | `IDD_JSONL_GITIGNORE_GATE=false` set — same as `committed` but audit cites env var | | (unset → enters AskUserQuestion) | ignored, source captured in `$IGNORE_SOURCE` for agent prose | -If decision is `committed` / `not-applicable` / `bypass-env-var` → silent pass with 1-line audit, proceed to Stage 4 dispatch loop (jsonl will materialize after dispatch per ordering invariant above). +If decision is `committed` / `not-applicable` / `bypass-env-var` → silent pass with 1-line audit. **Stage 4 dispatch has already completed at this point** (per dispatch→gate→materialize ordering invariant above) — the gate is the green light for materializing the in-memory `RUN_LOG_ENTRIES` to disk as the jsonl file. No further dispatch work needed;proceed to materialization. If detection found ignore shadow → enter the AskUserQuestion deliberation moment described next. @@ -1209,24 +1209,34 @@ case "$JSONL_GITIGNORE_DECISION" in # the parent directory itself (global ignore CANNOT be edited from per-repo carve-out) GITIGNORE_FILE=".gitignore" - # Decide if we need the global-ignore re-include line. + # Decide if we need the parent-re-include line (`!.claude`). # $IGNORE_SOURCE is set by detection: "filename:line:pattern\tfile" format. # Extract the source filename (first field, before first ':'). # - # Heuristic (per /idd-verify --pr 71 round 2 P1.2 refinement): - # - Absolute path (starts with `/`) → global source (cannot be edited from - # per-repo carve-out; needs `!.claude` re-include line) - # - Relative path (e.g. `.gitignore`, `.git/info/exclude`) → repo-local source - # (4-line carve-out works as-is) + # Heuristic (per /idd-verify --pr 71 rounds 2-3 refinement): + # The carve-out we write goes into root `.gitignore`. If the .claude/ exclusion + # comes from a source OUTSIDE root `.gitignore`, our 4-line rewrite of `.gitignore` + # alone cannot un-exclude the directory — git stops there before considering our + # re-includes. We MUST emit the 5-line block with `!.claude` parent-re-include for: + # - Global `core.excludesfile` (absolute paths, e.g. `/Users/X/.config/git/ignore`) + # - `.git/info/exclude` (repo-local but separate from `.gitignore`) # - # `git check-ignore -v` ALWAYS prints repo-local sources as relative paths - # (`.gitignore` not `/Users/X/repo/.gitignore`), and global sources as - # absolute paths. So path-absoluteness is the canonical discriminator. + # The ONLY case where 4-line block works: source is the same root `.gitignore` + # we're about to rewrite (after our sed -E drops the bare `.claude/` line, the + # exclusion stops applying and our `.claude/*` pattern takes over). Any other source + # — including `.git/info/exclude` — needs `!.claude` because the carve-out is in + # root `.gitignore` only and git evaluates ignore sources in order. + # + # `git check-ignore -v` ALWAYS prints repo-local sources as relative paths and + # global sources as absolute. We use absolute-vs-relative + filename match to + # bucket the three cases. NEEDS_GLOBAL_REINCLUDE=false SOURCE_FILE=$(printf '%s\n' "$IGNORE_SOURCE" | awk -F: '{print $1}') case "$SOURCE_FILE" in - /*) NEEDS_GLOBAL_REINCLUDE=true ;; # absolute path → global ignore - *) NEEDS_GLOBAL_REINCLUDE=false ;; # relative path → repo-local source + /*) NEEDS_GLOBAL_REINCLUDE=true ;; # absolute → global ignore + .git/info/exclude) NEEDS_GLOBAL_REINCLUDE=true ;; # repo-local but separate from .gitignore + .gitignore|*/.gitignore) NEEDS_GLOBAL_REINCLUDE=false ;; # 4-line block in same file works + *) NEEDS_GLOBAL_REINCLUDE=true ;; # any other relative source → defensive 5-line esac if [ "$NEEDS_GLOBAL_REINCLUDE" = "true" ]; then From 0725451a42d03a89a60d8e68dab337bb6518f954 Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 11 May 2026 20:44:46 +0800 Subject: [PATCH 5/7] fix(idd-issue): Stage 4.5 always-5-line + nested .gitignore unsupported (Refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 fix per /idd-verify --pr 71 round 4 P1.1 + P1.2: - P1.1: collapse 4-line/5-line variants into always-5-line block with `!.claude` parent re-include. Empirically validated against multi-source stacked ignores (root + .git/info/exclude / root + global) where 4-line failed due to lower-precedence sources emerging post-sed. - P1.2: detect nested `.gitignore` (e.g. `.claude/.gitignore`) in detection phase via $SOURCE_FILE case match. AskUserQuestion drops the Add-carve-out option when nested (root rewrite cannot override per-directory ignore); presents 2-option skip/abort + tells user the nested source path. - Prose: update rationale block, failure modes table, warn-continue contract bullet — eliminate all 4-line references; document the universal 5-line block + nested-source unsupported branch. Empirical 6/6 PASS smoke: S1 root .gitignore: PASS S2 .git/info/exclude: PASS S3 global core.excludesfile: PASS S4 root + .git/info/exclude stacked: PASS S5 root + global stacked: PASS S6 nested .gitignore detection (IS_NESTED_GITIGNORE=true): PASS Refs #55 --- .../skills/idd-issue/SKILL.md | 135 ++++++++++-------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md index d1209f4..0fc4ae9 100644 --- a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md @@ -1112,7 +1112,7 @@ Stage 3 confirm 後 sequential 跑 N 個 `gh` action: - 失敗:append entry with `error` + `retry_hint`,**continue** to next action(不 abort) - 全部 dispatch 完成 → 進 Stage 4.5 gate → gate 決定 jsonl 命運: - `committed` / `not-applicable` / `bypass-env-var` → materialize jsonl to disk (one-shot `jq -n ... > $JSONL_PATH`) - - `add-exception` → materialize jsonl + commit with `.gitignore` change (4-line repo-source or 5-line global-source carve-out chain) in same dispatch + - `add-exception` → materialize jsonl + commit with `.gitignore` change (5-line carve-out chain with `!.claude` parent re-include — universal across all fixable sources) in same dispatch - `skip-commit` → materialize jsonl locally, no `git add` - `abort` → discard `RUN_LOG_ENTRIES`, no jsonl file ever written - Summary line is printed after gate completes (with continuity status appended) @@ -1153,7 +1153,27 @@ if [ -z "${JSONL_GITIGNORE_DECISION:-}" ]; then if [ -n "$IGNORE_SOURCE" ]; then # IGNORED — capture source for remediation strategy (agent reads $IGNORE_SOURCE in AskUserQuestion). # Example: ".gitignore:1:.claude/ .claude/.idd/issue-runs/test.jsonl" + # Example: ".git/info/exclude:1:.claude/ .claude/.idd/issue-runs/test.jsonl" # Example: "/Users/X/.config/git/ignore:1:.claude/ .claude/.idd/issue-runs/test.jsonl" (global) + # Example: ".claude/.gitignore:1:* .claude/.idd/issue-runs/test.jsonl" (nested — UNSUPPORTED for add-carve-out) + # + # Classify the source so the agent can offer the right options: + # IS_NESTED_GITIGNORE=true → carve-out in root .gitignore CANNOT override a nested + # .gitignore (e.g. .claude/.gitignore). Per /idd-verify --pr 71 + # round 4 P1.2, empirically validated: a root-level rewrite + # leaves the nested rule in force, jsonl stays ignored. + # AskUserQuestion drops "Add carve-out" option → 2-option (skip/abort). + # IS_NESTED_GITIGNORE=false → root .gitignore, .git/info/exclude, or global core.excludesfile + # — all fixable by writing the 5-line carve-out block into root + # `.gitignore` (the `!.claude` parent re-include neutralizes any + # outer source via git's last-matching rule). + SOURCE_FILE=$(printf '%s\n' "$IGNORE_SOURCE" | awk -F: '{print $1}') + IS_NESTED_GITIGNORE=false + case "$SOURCE_FILE" in + .gitignore) IS_NESTED_GITIGNORE=false ;; # root .gitignore (fixable) + */.gitignore) IS_NESTED_GITIGNORE=true ;; # nested .gitignore (NOT fixable from root) + *) IS_NESTED_GITIGNORE=false ;; # absolute (global) / .git/info/exclude / other (fixable) + esac # Agent branches at agent level — see AskUserQuestion section below. : # JSONL_GITIGNORE_DECISION set by user choice next else @@ -1178,15 +1198,25 @@ If detection found ignore shadow → enter the AskUserQuestion deliberation mome > **Why prose**: AskUserQuestion is a Claude Code agent-level tool, not a bash function. Embedding `AskUserQuestion(...)` inside bash was a category error caught in /idd-verify #47 P1 finding 2. Same pattern applies here — agent reads bash detection, branches at agent level on detection result, then handles deliberation as prose. -When detection returns "ignored": +When detection returns "ignored", agent branches on `$IS_NESTED_GITIGNORE`: -> "Multi-finding dispatch will write run log to `.claude/.idd/issue-runs/.jsonl`, but repo `.gitignore` shadows `.claude/` (D2 contract violated: jsonl can't reach git). Choose:" +**Case A — `IS_NESTED_GITIGNORE=false`** (source is root `.gitignore`, `.git/info/exclude`, or global `core.excludesfile`) → 3-option prose: + +> "Multi-finding dispatch will write run log to `.claude/.idd/issue-runs/.jsonl`, but `${SOURCE_FILE}` shadows `.claude/` (D2 contract violated: jsonl can't reach git). Choose:" > > Options (default = first;agent emits `$IGNORE_SOURCE` in question so user can see WHICH gitignore source matched): -> - **`Add carve-out chain to .gitignore`** — rewrite repo `.gitignore` to replace bare `.claude/` with `.claude/*` + carve out path (4-line block per D3 revision below). **Caveat**: only effective when ignore source is repo `.gitignore` or `.git/info/exclude`; global `core.excludesfile` requires extra `!.claude` re-inclusion line (handled automatically). Idempotent (re-run safe via marker comment). +> - **`Add carve-out chain to .gitignore`** — rewrite repo `.gitignore` to replace bare `.claude/` with the 5-line carve-out block (`!.claude` parent re-include + `.claude/*` + `!.claude/.idd` + `.claude/.idd/*` + `!.claude/.idd/issue-runs`). The `!.claude` parent re-include neutralizes any outer ignore source (`.git/info/exclude` or global `core.excludesfile`) via git's last-matching rule. Idempotent (re-run safe via marker comment). > - **`Skip commit (local-only)`** — write jsonl locally but don't `git add`;dispatch summary flags ⚠ cross-machine continuity gap + manual export command. > - **`Abort`** — discard in-memory run log + exit dispatch BEFORE any jsonl materialization. Already-dispatched GitHub actions (gh issue create / comment / edit) remain (NOT rolled back per "No rollback" rule above — but the JSONL file never reaches disk). +**Case B — `IS_NESTED_GITIGNORE=true`** (source is a nested `.gitignore` such as `.claude/.gitignore`) → **`Add carve-out` option is NOT offered** (a root-level rewrite cannot override a nested `.gitignore`; the user must edit the nested source themselves or accept local-only). 2-option prose: + +> "Multi-finding dispatch will write run log to `.claude/.idd/issue-runs/.jsonl`, but nested `${SOURCE_FILE}` shadows it (D2 contract violated). A root-level `.gitignore` carve-out CANNOT override a nested `.gitignore` (git evaluates per-directory rules in scope; the nested file wins for paths beneath it). To use the Add-carve-out path you'd need to edit `${SOURCE_FILE}` yourself. Choose:" +> +> Options (default = first): +> - **`Skip commit (local-only)`** — write jsonl locally but don't `git add`;dispatch summary flags ⚠ cross-machine continuity gap + manual export command + `${SOURCE_FILE}` location. +> - **`Abort`** — discard in-memory run log + exit dispatch BEFORE any jsonl materialization. Already-dispatched GitHub actions remain. + Set `JSONL_GITIGNORE_DECISION` per user choice. Then proceed: ```bash @@ -1203,48 +1233,33 @@ case "$JSONL_GITIGNORE_DECISION" in # to NOT exclude the directory itself (use `.claude/*` pattern) and # carve out each parent dir on the path to issue-runs. # - # SOURCE-AWARE BRANCH (per /idd-verify --pr 71 round 1 P1.2): - # - Repo `.gitignore` or `.git/info/exclude` matched → 4-line carve-out works - # - Global `core.excludesfile` matched → MUST add `!.claude` at top to re-include - # the parent directory itself (global ignore CANNOT be edited from per-repo carve-out) - GITIGNORE_FILE=".gitignore" - - # Decide if we need the parent-re-include line (`!.claude`). - # $IGNORE_SOURCE is set by detection: "filename:line:pattern\tfile" format. - # Extract the source filename (first field, before first ':'). - # - # Heuristic (per /idd-verify --pr 71 rounds 2-3 refinement): - # The carve-out we write goes into root `.gitignore`. If the .claude/ exclusion - # comes from a source OUTSIDE root `.gitignore`, our 4-line rewrite of `.gitignore` - # alone cannot un-exclude the directory — git stops there before considering our - # re-includes. We MUST emit the 5-line block with `!.claude` parent-re-include for: - # - Global `core.excludesfile` (absolute paths, e.g. `/Users/X/.config/git/ignore`) - # - `.git/info/exclude` (repo-local but separate from `.gitignore`) + # ALWAYS-5-LINE BLOCK (per /idd-verify --pr 71 round 4 P1.1): + # Multiple ignore sources can stack — root `.gitignore` may carry `.claude/` + # AND `.git/info/exclude` (or global `core.excludesfile`) may also carry it. + # `git check-ignore -v` only reports the highest-precedence source, so a + # 4-line block that only handles the reported source still loses to a + # lower-precedence source that emerges after we sed-out the root pattern. # - # The ONLY case where 4-line block works: source is the same root `.gitignore` - # we're about to rewrite (after our sed -E drops the bare `.claude/` line, the - # exclusion stops applying and our `.claude/*` pattern takes over). Any other source - # — including `.git/info/exclude` — needs `!.claude` because the carve-out is in - # root `.gitignore` only and git evaluates ignore sources in order. + # The fix: always emit the 5-line block. The leading `!.claude` line uses + # git's last-matching rule to re-include `.claude/` regardless of any + # outer ignore source. The downstream `.claude/*` keeps everything else + # ignored, and the carve-out re-includes only the run-log path. + # Empirically validated against {root .gitignore, .git/info/exclude, global, + # global + root combined, .git/info/exclude + root combined} — all 5/5 pass. # - # `git check-ignore -v` ALWAYS prints repo-local sources as relative paths and - # global sources as absolute. We use absolute-vs-relative + filename match to - # bucket the three cases. - NEEDS_GLOBAL_REINCLUDE=false - SOURCE_FILE=$(printf '%s\n' "$IGNORE_SOURCE" | awk -F: '{print $1}') - case "$SOURCE_FILE" in - /*) NEEDS_GLOBAL_REINCLUDE=true ;; # absolute → global ignore - .git/info/exclude) NEEDS_GLOBAL_REINCLUDE=true ;; # repo-local but separate from .gitignore - .gitignore|*/.gitignore) NEEDS_GLOBAL_REINCLUDE=false ;; # 4-line block in same file works - *) NEEDS_GLOBAL_REINCLUDE=true ;; # any other relative source → defensive 5-line - esac - - if [ "$NEEDS_GLOBAL_REINCLUDE" = "true" ]; then - REWRITE_BLOCK=$(cat <<'BLOCK' + # CALLER PRECONDITION: this case branch only runs when IS_NESTED_GITIGNORE=false. + # Nested `.gitignore` (e.g. `.claude/.gitignore`) cannot be fixed from root — + # the AskUserQuestion prose drops the Add-carve-out option in that case, so + # we'll never reach here with a nested source. + GITIGNORE_FILE=".gitignore" + + REWRITE_BLOCK=$(cat <<'BLOCK' # IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55) -# Global core.excludesfile excludes `.claude/`. Re-include the directory -# itself + carve out the run log path. Other contents of .claude/ are NOT -# re-included here (they remain ignored via global rule unless explicitly carved). +# `.claude/` is excluded by some ignore source (root .gitignore, .git/info/exclude, +# or global core.excludesfile). The `!.claude` line re-includes the parent +# directory itself via git's last-matching rule (neutralizes any outer source), +# then `.claude/*` re-excludes everything inside, and the carve-out re-includes +# only the run-log path. Other contents of .claude/ remain ignored. !.claude .claude/* !.claude/.idd @@ -1252,19 +1267,6 @@ case "$JSONL_GITIGNORE_DECISION" in !.claude/.idd/issue-runs BLOCK ) - else - REWRITE_BLOCK=$(cat <<'BLOCK' -# IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55) -# Repo `.gitignore` excludes `.claude/` as a directory. Rewrite to `.claude/*` -# so we can re-include the run log path. Other contents of .claude/ remain -# ignored unless explicitly carved out. -.claude/* -!.claude/.idd -.claude/.idd/* -!.claude/.idd/issue-runs -BLOCK -) - fi # Idempotency: only rewrite if the carve-out block isn't already present. # Detection: look for the marker comment line we always emit at top of block. @@ -1315,20 +1317,29 @@ BLOCK esac ``` -> **Why a 4-line carve-out, not a 1-line exception**: discovered empirically during this implementation's TDD reproduction (2026-05-11). git docs are explicit: "any patterns on contained files have no effect" when the parent directory is excluded. The carve-out pattern `.claude/*` + `!.claude/.idd` + `.claude/.idd/*` + `!.claude/.idd/issue-runs` re-includes the run-log path while preserving the spirit of the user's original `.claude/` ignore (other contents stay ignored). The marker comment line makes the rewrite idempotent on re-run. +> **Why a 5-line carve-out, not a 1-line exception or a source-dependent 4/5 split**: +> +> - **1-line `!.claude/.idd/issue-runs/` exception doesn't work**: discovered empirically during this implementation's TDD reproduction (2026-05-11). git docs are explicit — "any patterns on contained files have no effect" when the parent directory is excluded. +> - **4-line block (no `!.claude`) is fragile**: when multiple ignore sources stack (root `.gitignore` + `.git/info/exclude`, or root + global `core.excludesfile`), `git check-ignore -v` only reports the highest-precedence one. After we sed-out the root pattern, a lower-precedence source emerges and the 4-line block can't reach the parent directory (per round-4 P1.1). +> - **5-line block (with `!.claude`) is universal**: the leading `!.claude` line uses git's last-matching rule to re-include `.claude/` regardless of any outer ignore source; the `.claude/*` and following carve-out lines keep everything except the run-log path ignored. Empirically validated against all stacked-source combinations. +> - **Nested `.gitignore` (e.g. `.claude/.gitignore`) is NOT supported by Add-carve-out**: a root-level rewrite cannot override a nested `.gitignore` (git's per-directory scope rules). The AskUserQuestion prose drops the Add-carve-out option in that case and offers skip/abort only. +> +> Marker comment line at top of block makes the rewrite idempotent on re-run. ##### Failure modes | Scenario | `JSONL_GITIGNORE_DECISION` | Behavior | |----------|---------------------------|----------| | Outside git work tree | `not-applicable` | jsonl writes as local file, no commit attempt;dispatch summary "Status: not applicable (outside git work tree)" | -| Repo `.gitignore` not ignoring `.claude/` | `committed` | silent pass — Stage 4 dispatch materializes jsonl normally + commits | -| Repo `.gitignore` ignores `.claude/` + user picks Add carve-out | (user choice) | 4-line carve-out block appended to `.gitignore` (with idempotency marker);both `.gitignore` change and jsonl commit in dispatch | -| Global `core.excludesfile` ignores `.claude/` + user picks Add carve-out | (user choice) | 5-line carve-out block (with `!.claude` re-include line) appended to repo `.gitignore` — repo-local override of global ignore | -| Ignored + user picks Skip commit (local-only) | (user choice) | jsonl written locally, no `git add`;dispatch summary flags ⚠ cross-machine gap + manual export command | +| `.claude/` not ignored | `committed` | silent pass — Stage 4 dispatch materializes jsonl normally + commits | +| Root `.gitignore` ignores `.claude/` + user picks Add carve-out | (user choice) | 5-line carve-out block appended to `.gitignore` (with idempotency marker);both `.gitignore` change and jsonl commit in dispatch | +| `.git/info/exclude` ignores `.claude/` + user picks Add carve-out | (user choice) | Same 5-line block written to **root `.gitignore`** (not `.git/info/exclude`). The `!.claude` line re-includes the parent dir via git's last-matching rule; outer source is neutralized | +| Global `core.excludesfile` ignores `.claude/` + user picks Add carve-out | (user choice) | Same 5-line block written to root `.gitignore` — repo-local override of global ignore via `!.claude` re-include | +| **Nested `.gitignore` (e.g. `.claude/.gitignore`) ignores `.claude/.idd/...`** | (user choice) | **Add carve-out option NOT offered** — root rewrite cannot override per-directory ignore. AskUserQuestion presents skip/abort + tells user the nested source path so they can edit it manually if desired | +| Ignored + user picks Skip commit (local-only) | (user choice) | jsonl written locally, no `git add`;dispatch summary flags ⚠ cross-machine gap + manual export command + ignore source path | | Ignored + user picks Abort | (user choice) | In-memory run log discarded BEFORE materialization;dispatch summary shows aborted;already-dispatched GitHub actions remain | | Bypass via env var | `bypass-env-var` | `IDD_JSONL_GITIGNORE_GATE=false` → silent skip prompt (detection still runs for audit cite);1-line audit `Stage 4.5 gate bypassed (IDD_JSONL_GITIGNORE_GATE=false)` — for CI / unattended runs | -| `git check-ignore -v` parse failure | (fallback) | If `$IGNORE_SOURCE` empty or unparseable, default `NEEDS_GLOBAL_REINCLUDE=false` (assume repo source) — better to add too few lines than fail entirely | +| `git check-ignore -v` parse failure | (fallback) | If `$IGNORE_SOURCE` empty or unparseable, default `IS_NESTED_GITIGNORE=false` (assume fixable source) — better to attempt the 5-line block than refuse all options | ### Audit trail(雙軌:footer + jsonl) From 9f2ef120f9a1da89005aa19cb9410b0d50488854 Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 11 May 2026 20:51:02 +0800 Subject: [PATCH 6/7] fix(idd-issue): Stage 4.5 stale-block upgrade + classification + prose (Refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 6 fix per /idd-verify --pr 71 round 5: - P1.1 (stale-block upgrade): old 4-line block had the same marker comment as the new 5-line universal block, so the previous idempotency check (`grep marker → skip`) would NEVER upgrade an existing repo. Added a two-part detection: marker present AND `!.claude` parent re-include present → skip; marker present but `!.claude` missing → strip stale block via awk + re-append the universal block. Also fixed the grep idiom from `grep -c ... || echo 0` (which doubles output to "0\\n0" on no-match) to `grep | wc -l | tr -d ' '` (always clean integer). - P1.2 (classification reorder): `*/.gitignore` shell glob matches absolute paths like `/Users/X/.config/git/ignore` when global excludesfile is named `.gitignore`. Re-ordered case statement to test absolute path (`/*`) and `.git/info/exclude` BEFORE `*/.gitignore` so global never falls into nested branch. - P1.3 (summary template prose): SKILL.md:1521-1535 still had "repo-source mode, 4-line block" + "global-source mode, 5-line block" language. Collapsed into single 5-line example + added explicit nested-.gitignore example (skip-commit path with manual-fix hint). Empirical 3/3 PASS smoke: P1.1 stale-block upgrade (old marker + stale 4-line + .git/info/exclude stacked) → upgrade strips stale, appends universal, git add OK P1.2 global excludesfile named .gitignore → IS_NESTED=false (fixable) Idempotency re-run (already universal block) → skipped, file unchanged Fresh write (no marker) → HAS_MARKER=0/HAS_PARENT_REINCLUDE=0 → append Refs #55 --- .../skills/idd-issue/SKILL.md | 75 ++++++++++++++++--- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md index 0fc4ae9..08d53a6 100644 --- a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md @@ -1169,10 +1169,16 @@ if [ -z "${JSONL_GITIGNORE_DECISION:-}" ]; then # outer source via git's last-matching rule). SOURCE_FILE=$(printf '%s\n' "$IGNORE_SOURCE" | awk -F: '{print $1}') IS_NESTED_GITIGNORE=false + # ORDER MATTERS (per /idd-verify --pr 71 round 5 P1.2): the `*/.gitignore` + # glob matches absolute paths like `/Users/X/.config/git/ignore` when + # `core.excludesfile` is named `.gitignore`. Test absolute paths FIRST + # so global excludesfile never falls into the nested branch. case "$SOURCE_FILE" in + /*) IS_NESTED_GITIGNORE=false ;; # absolute → global ignore (fixable by 5-line in root) + .git/info/exclude) IS_NESTED_GITIGNORE=false ;; # repo-local but separate from .gitignore (fixable) .gitignore) IS_NESTED_GITIGNORE=false ;; # root .gitignore (fixable) */.gitignore) IS_NESTED_GITIGNORE=true ;; # nested .gitignore (NOT fixable from root) - *) IS_NESTED_GITIGNORE=false ;; # absolute (global) / .git/info/exclude / other (fixable) + *) IS_NESTED_GITIGNORE=false ;; # other repo-local → defensive fixable esac # Agent branches at agent level — see AskUserQuestion section below. : # JSONL_GITIGNORE_DECISION set by user choice next @@ -1268,9 +1274,46 @@ case "$JSONL_GITIGNORE_DECISION" in BLOCK ) - # Idempotency: only rewrite if the carve-out block isn't already present. - # Detection: look for the marker comment line we always emit at top of block. - if ! grep -qxF "# IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55)" "$GITIGNORE_FILE" 2>/dev/null; then + # Idempotency + upgrade: detect both presence of marker AND that the block + # contains the universal `!.claude` parent re-include line (per /idd-verify + # --pr 71 round 5 P1.1). The marker alone is insufficient — older versions + # of this skill emitted a 4-line block with the SAME marker but missing + # `!.claude`, which still loses to stacked ignore sources. We upgrade in + # place: if marker present BUT `!.claude` line absent, drop the old block + # entirely (delete lines from marker through the next blank line / EOF) + # then re-append the current universal 5-line block. + # `grep -c` exits non-zero on no-match while still printing "0", so `|| echo 0` + # would emit "0\n0" and break the `-ge 1` integer test. Pipe to `wc -l` instead — + # always yields a clean integer (0 if grep finds nothing or file is absent). + HAS_MARKER=$(grep -xF "# IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55)" "$GITIGNORE_FILE" 2>/dev/null | wc -l | tr -d ' ') + HAS_PARENT_REINCLUDE=$(grep -xF "!.claude" "$GITIGNORE_FILE" 2>/dev/null | wc -l | tr -d ' ') + NEEDS_REWRITE=true + if [ "$HAS_MARKER" -ge 1 ] && [ "$HAS_PARENT_REINCLUDE" -ge 1 ]; then + # Both marker and the universal `!.claude` line already present → block is + # already the current universal form, skip rewrite. + NEEDS_REWRITE=false + elif [ "$HAS_MARKER" -ge 1 ]; then + # Stale 4-line block detected — drop it before re-appending the new one. + # Stay in skip mode only while lines match known block content (comments + # we emit OR one of the literal carve-out patterns). Falls out cleanly + # on any unrelated line (which gets printed) or blank line (consumed). + awk -v marker="# IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55)" ' + $0 == marker { skip = 1; next } + skip { + if ($0 ~ /^#/) { next } + if ($0 == "" ) { next } + if ($0 == "!.claude" \ + || $0 == ".claude/*" \ + || $0 == "!.claude/.idd" \ + || $0 == ".claude/.idd/*" \ + || $0 == "!.claude/.idd/issue-runs") { next } + skip = 0 # unrelated line — fall through to print + } + { print } + ' "$GITIGNORE_FILE" > "$GITIGNORE_FILE.tmp" + mv "$GITIGNORE_FILE.tmp" "$GITIGNORE_FILE" + fi + if [ "$NEEDS_REWRITE" = "true" ]; then # Remove any existing bare `.claude/` or `.claude` line (we're rewriting that # pattern to `.claude/*` which behaves differently per git docs). Use sed -E # for portable BSD/GNU compatibility; rewrite via tempfile to handle the @@ -1513,26 +1556,38 @@ Stage 4.5: jsonl gitignore pre-flight Status: committed ``` -When `.gitignore` shadows `.claude/`, Stage 4.5 surfaces the ignore source (repo `.gitignore` vs `.git/info/exclude` vs global `core.excludesfile`) + user choice in the same summary block: +When `.gitignore` shadows `.claude/`, Stage 4.5 surfaces the ignore source + user choice in the same summary block. The carve-out always emits the same universal 5-line block (per round-5 fix), so the summary line doesn't vary by source: ``` Stage 4.5: jsonl gitignore pre-flight ⚠ Detected: .gitignore:1:.claude/ shadows run log path - ✓ User chose: Add carve-out chain to .gitignore (repo-source mode, 4-line block) + ✓ User chose: Add carve-out chain to .gitignore Summary: 7 succeeded, 1 failed, 2 skipped Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl - Status: committed (added 4-line carve-out chain to .gitignore — replaced `.claude/` with `.claude/*` + parent-dir re-includes) + Status: committed (added universal 5-line carve-out chain to .gitignore — `!.claude` parent re-include neutralizes outer source) ``` -Global `core.excludesfile` case adds an extra `!.claude` line to re-include the directory itself (per P1.2 fix — global ignore cannot be edited from per-repo, so repo `.gitignore` must override): +Global `core.excludesfile` and `.git/info/exclude` sources produce the same outcome (same 5-line block written to root `.gitignore`). Only the `Detected:` line differs: ``` Stage 4.5: jsonl gitignore pre-flight ⚠ Detected: ~/.config/git/ignore:3:.claude/ (global core.excludesfile) - ✓ User chose: Add carve-out chain to .gitignore (global-source mode, 5-line block with !.claude re-include) + ✓ User chose: Add carve-out chain to .gitignore + Summary: 7 succeeded, 1 failed, 2 skipped + Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl + Status: committed (added universal 5-line carve-out chain to .gitignore — `!.claude` parent re-include neutralizes global ignore) +``` + +Nested `.gitignore` (e.g. `.claude/.gitignore`) cannot be fixed from root — Add-carve-out option is not offered. Summary shows the nested source so user knows where to edit manually: + +``` +Stage 4.5: jsonl gitignore pre-flight + ⚠ Detected: .claude/.gitignore:1:* (nested .gitignore — root carve-out cannot override) + ⚠ User chose: Skip commit (local-only) Summary: 7 succeeded, 1 failed, 2 skipped Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl - Status: committed (added 5-line carve-out chain with !.claude re-include to override global ignore) + Status: ⚠ local-only (nested .gitignore shadows path; cross-machine continuity disabled) + Manual fix: edit .claude/.gitignore to add `!.idd/issue-runs/.jsonl` exception, then commit ``` ``` From e236409a704e11c9743a7dbb6ff95d8f31dcaf5e Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 11 May 2026 20:58:24 +0800 Subject: [PATCH 7/7] fix(idd-issue): Stage 4.5 awk state machine + nested chain hint (Refs #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 7 fix per /idd-verify --pr 71 round 6: - P1.1 (awk consumes user content): previous awk skip rule consumed any trailing # comments and blank lines after the stale block, which would silently delete adjacent user sections like: # IDD ... marker .claude/* ... !.claude/.idd/issue-runs (blank) # User section that should stay ← previously eaten secrets.env Replaced with a two-state machine: STATE 1 (post-marker, consume only rationale # comments adjacent to marker until first carve-out pattern); STATE 2 (inside patterns, consume only known carve-out lines, exit immediately after the FINAL pattern `!.claude/.idd/issue-runs`). Blank lines and unrelated content always end skip → preserved. - P1.2 (nested manual-fix hint insufficient): previous hint suggested single-line `!.idd/issue-runs/.jsonl` which DOES NOT WORK when nested .gitignore is `*` — parent .idd is still excluded per git's parent-dir-excluded rule. Replaced with full chain hint: !.idd/ .idd/* !.idd/issue-runs/ !.idd/issue-runs/* (trailing slashes on dir patterns + explicit glob on issue-runs/* required; empirically validated 2026-05-11). Empirical 4/4 PASS smoke: T1 stale block + blank + user section → user '# User section' preserved T2 stale block + adjacent user comment (no blank) → preserved (state machine exits at FINAL pattern, immediately falls through to print user line) T3 idempotent re-run (already universal) → md5 unchanged T4 nested chain v2 in .claude/.gitignore → git_add_after_chain=ok Refs #55 --- .../skills/idd-issue/SKILL.md | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md index 08d53a6..8256717 100644 --- a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md @@ -1293,21 +1293,40 @@ BLOCK # already the current universal form, skip rewrite. NEEDS_REWRITE=false elif [ "$HAS_MARKER" -ge 1 ]; then - # Stale 4-line block detected — drop it before re-appending the new one. - # Stay in skip mode only while lines match known block content (comments - # we emit OR one of the literal carve-out patterns). Falls out cleanly - # on any unrelated line (which gets printed) or blank line (consumed). + # Stale block detected — drop it before re-appending the universal block. + # State machine (per /idd-verify --pr 71 round 6 P1.1): + # STATE 0: outside block — print line + # STATE 1: just saw marker, in "header" — only consume # comments adjacent + # to marker (the rationale comments we emit) UNTIL first + # carve-out pattern line; any non-# / non-pattern line ENDS skip + # STATE 2: saw a carve-out pattern — only consume more known patterns; + # after consuming the FINAL pattern `!.claude/.idd/issue-runs`, + # END skip + # + # CRITICAL: do NOT consume blank lines or arbitrary # comments past STATE 1. + # If user has `\n# User section` immediately after our stale block, the + # blank line ends skip — user content preserved (round 6 P1.1 regression). awk -v marker="# IDD multi-finding run log carve-out (idd-issue Stage 4.5, #55)" ' - $0 == marker { skip = 1; next } - skip { - if ($0 ~ /^#/) { next } - if ($0 == "" ) { next } - if ($0 == "!.claude" \ - || $0 == ".claude/*" \ - || $0 == "!.claude/.idd" \ - || $0 == ".claude/.idd/*" \ - || $0 == "!.claude/.idd/issue-runs") { next } - skip = 0 # unrelated line — fall through to print + function is_block_pattern(line) { + return (line == "!.claude" \ + || line == ".claude/*" \ + || line == "!.claude/.idd" \ + || line == ".claude/.idd/*" \ + || line == "!.claude/.idd/issue-runs") + } + $0 == marker { skip = 1; state = 1; next } + skip && state == 1 { + if ($0 ~ /^#/) { next } # rationale comment — consume + if (is_block_pattern($0)) { state = 2; next } # entered patterns + skip = 0 # anything else ends skip + } + skip && state == 2 { + if ($0 == "!.claude/.idd/issue-runs") { # last pattern → consume and END + skip = 0 + next + } + if (is_block_pattern($0)) { next } # intermediate pattern — consume + skip = 0 # anything else ends skip } { print } ' "$GITIGNORE_FILE" > "$GITIGNORE_FILE.tmp" @@ -1578,7 +1597,7 @@ Stage 4.5: jsonl gitignore pre-flight Status: committed (added universal 5-line carve-out chain to .gitignore — `!.claude` parent re-include neutralizes global ignore) ``` -Nested `.gitignore` (e.g. `.claude/.gitignore`) cannot be fixed from root — Add-carve-out option is not offered. Summary shows the nested source so user knows where to edit manually: +Nested `.gitignore` (e.g. `.claude/.gitignore`) cannot be fixed from root — Add-carve-out option is not offered. Summary shows the nested source + a complete carve-out hint (single-line exception does NOT work per git docs — the parent directory of the run log path is still excluded, so any single-line `!.idd/issue-runs/` rule has no effect). The manual fix mirrors the same chain-style pattern we use in root `.gitignore`, but with paths relative to the nested file's directory: ``` Stage 4.5: jsonl gitignore pre-flight @@ -1587,7 +1606,18 @@ Stage 4.5: jsonl gitignore pre-flight Summary: 7 succeeded, 1 failed, 2 skipped Run log: .claude/.idd/issue-runs/2026-05-10T17:00:00.jsonl Status: ⚠ local-only (nested .gitignore shadows path; cross-machine continuity disabled) - Manual fix: edit .claude/.gitignore to add `!.idd/issue-runs/.jsonl` exception, then commit + Manual fix: edit .claude/.gitignore — single-line `!...` exceptions DO NOT WORK here + (parent .idd is still excluded by `*` per git's "parent dir excluded" rule). + Append the chain instead (paths relative to .claude/ since that's where the + nested .gitignore lives, and note the trailing slashes on directory patterns + + the explicit glob on issue-runs/* — empirically validated 2026-05-11): + + !.idd/ + .idd/* + !.idd/issue-runs/ + !.idd/issue-runs/* + + Then commit both `.claude/.gitignore` and the jsonl file. ``` ```