From 8136e6d2ef5325c3d45a2a503df88ee3373f2e15 Mon Sep 17 00:00:00 2001 From: kay kim Date: Tue, 14 Jul 2026 13:35:55 +0900 Subject: [PATCH 1/2] fix(hooks): bound stdin read so Stop hook can't stall (#139, v2.1.30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stop hook (scripts/unified-stop.js) occasionally stalled up to ~15.5 min — far past its own 10s timeout — blocking turn completion. Root cause, reproduced end-to-end: every bkit hook reads its payload via lib/core/io.js readStdinSync(), which used fs.readFileSync(0) — a blocking stdin read with NO timeout that returns only at EOF. When Claude Code keeps the hook's stdin write-end open, the hook blocks for exactly that long (payload sent + pipe held open 4s → 4.07s block, flat CPU = I/O-blocked, matching the reporter's low-CPU tail). The defect lives in the shared readStdinSync() used by 36 hook scripts, so the fix is central and protects every hook event, not just Stop. - io.js readStdinSync(): incremental fs.readSync + parse-early — returns the instant the buffer holds a complete JSON value, never waiting for EOF. Raw fd, so the process still exits promptly. Return contract preserved (empty/malformed → {} unless BKIT_STRICT_STDIN). Reproduced case: 15.5 min → ~1 ms. - io.js readStdinBounded(): new async parse-early + hard-timeout reader that destroys process.stdin on resolve (without that, the open stdin handle keeps the event loop alive until EOF and the process lingers — the stall returns via exit). unified-stop.js awaits it in an async IIFE, so the turn-gating Stop hook is fully bounded even for no-data/truncated held-open pipes. - state-store.js lock(): CPU-burning busy-wait spin → Atomics.wait sleepSync() (no CPU burn), addressing the issue's lock-wait note. - constants.js: new STDIN_READ_TIMEOUT_MS (2000ms, env BKIT_STDIN_TIMEOUT_MS). New regression test test/regression/issue-139-stdin-bounded.test.js (16 TC). All CI gates green; 0 new regressions vs main; live claude -p --plugin-dir . on CC v2.1.208 OK. Architecture counts invariant. Version 2.1.29 → 2.1.30. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01WCr8qz6Acx4uFLXmbcikRJ --- .claude-plugin/marketplace.json | 6 +- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 70 +++++ README.md | 4 +- bkit-system/README.md | 2 +- .../components/agents/_agents-overview.md | 1 + .../components/scripts/_scripts-overview.md | 1 + .../components/skills/_skills-overview.md | 1 + bkit.config.json | 2 +- .../stop-hook-stdin-block-139.plan.en.md | 109 ++++++++ .../stop-hook-stdin-block-139.plan.ko.md | 102 +++++++ .../stop-hook-stdin-block-139.design.en.md | 165 +++++++++++ .../stop-hook-stdin-block-139.design.ko.md | 160 +++++++++++ .../stop-hook-stdin-block-139.analysis.en.md | 45 +++ .../stop-hook-stdin-block-139.analysis.ko.md | 41 +++ .../stop-hook-stdin-block-139.report.en.md | 62 +++++ .../stop-hook-stdin-block-139.report.ko.md | 57 ++++ .../stop-hook-stdin-block-139.qa-report.en.md | 60 ++++ .../stop-hook-stdin-block-139.qa-report.ko.md | 58 ++++ hooks/hooks.json | 2 +- hooks/session-start.js | 2 +- hooks/startup/session-context.js | 2 +- lib/core/constants.js | 15 + lib/core/index.js | 3 +- lib/core/io.js | 145 +++++++++- lib/core/state-store.js | 33 ++- scripts/unified-stop.js | 21 +- .../issue-139-stdin-bounded.test.js | 261 ++++++++++++++++++ 28 files changed, 1406 insertions(+), 26 deletions(-) create mode 100644 docs/01-plan/features/stop-hook-stdin-block-139.plan.en.md create mode 100644 docs/01-plan/features/stop-hook-stdin-block-139.plan.ko.md create mode 100644 docs/02-design/features/stop-hook-stdin-block-139.design.en.md create mode 100644 docs/02-design/features/stop-hook-stdin-block-139.design.ko.md create mode 100644 docs/03-analysis/features/stop-hook-stdin-block-139.analysis.en.md create mode 100644 docs/03-analysis/features/stop-hook-stdin-block-139.analysis.ko.md create mode 100644 docs/04-report/features/stop-hook-stdin-block-139.report.en.md create mode 100644 docs/04-report/features/stop-hook-stdin-block-139.report.ko.md create mode 100644 docs/05-qa/features/stop-hook-stdin-block-139.qa-report.en.md create mode 100644 docs/05-qa/features/stop-hook-stdin-block-139.qa-report.ko.md create mode 100644 test/regression/issue-139-stdin-bounded.test.js diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f6e792cf..d14c96ce 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "bkit-marketplace", - "version": "2.1.29", + "version": "2.1.30", "description": "POPUP STUDIO's Vibecoding Kit marketplace - PDCA methodology and AI-native development tools. Requires Claude Code v2.1.143+ for the bkit plugin (older versions reject the displayName field).", "owner": { "name": "POPUP STUDIO PTE. LTD.", @@ -33,12 +33,12 @@ }, { "name": "bkit", - "description": "Requires Claude Code v2.1.143+ (older versions reject the strict plugin-manifest displayName field — run `npm install -g @anthropic-ai/claude-code@latest` to upgrade; see docs/06-guide/cc-compatibility.guide.md). Vibecoding Kit - PDCA + Sprint Management + CTO-Led Agent Teams + Living Context System + 43 PM frameworks. v2.1.14 6 differentiations (Memory Enforcer, Layer 6 Defense, Sequential Dispatch, Effort-aware, PostToolUse continueOnBlock, Heredoc-bypass). v2.1.15 pdca-status.json fix. v2.1.16 4 GitHub issues (Quality Gates UX). v2.1.17 5/12~5/20 contract red 8-day class close. v2.1.18 Issues #100/#101/#102 — Sprint Trust UX Fix. v2.1.19 Quality Maturation Sprint — 5 sub-sprint master plan (S1 Self-Dogfooding Enablement / S2 Convention Restoration / S3 Sprint Report Maturity / S4 External Dogfooder Lifecycle / S5 Sprint Maturity Index) + ENH-318 차별화 7/7 정식 편입 + Real User Hall of Fame 도입 + 첫 외부 dogfooder entry @pruge. v2.1.20 Marketplace Recovery — minimum CC v2.1.143 advisory (F1+F4) + plugin manifest 21-key whitelist CI gate (F5+F6, ENH-322) + claude plugin validate wire (F7, ADR 0006 § Empirical Validation Gate) + cc-regression R3-321 (F8, ENH-321) + SessionStart CC version detection (F10, ENH-323) + ADR 0011 Plugin Manifest Schema Compliance Policy + Hall of Fame @bj (#2 external dogfooder, Lifecycle 5-stage). v2.1.21 Issue Response — Session Title Isolation (#111: per-session sessions[sessionId] cache map + GC + legacy migration + stable session tag for parallel-window disambiguation) + Sprint Output Enforcement (#113: lib/sprint/executive-summary.js sprint-shape + scripts/sprint-skill-stop.js run-export Stop hook + SKILL_HANDLERS sprint registration + advancePhase phaseTransitionSummary + /sprint status·watch human-readable display) + ADR 0012 Sprint Stop Hook Output Enforcement. v2.1.24 Skill Namespace Hardening — Issues #125/#126 (@hslee-cmyk): normalizeSkillName() canonicalizes CC `plugin:skill` form (bkit:pdca → pdca) so getSkillConfig + skill-post + implicit-trigger injection + Stop-handler dispatch resolve the bare folder; hook-reachability check no longer false-positives skill_post (event-driven vs canary correlation). v2.1.25 Claude 5 Model Alignment + Issue Response — 4-tier role-based model matrix (9 fable verification/orchestration core + 7 opus deep-reasoning/security + 16 sonnet implementers + 2 haiku monitors), dual floor (install v2.1.143 / model v2.1.170 advisory ENH-368), Claude 5 pricing sync; Issues #128 (@NEXCODE-MK: 6 deprecated pdca-eval-* stubs removed from the prompt surface → deprecation registry, ADR 0014) + #129 (@NEXCODE-MK: agent description token diet −44%, compact 8-language triggers) + #130 (@s99606931: learning-stop.js piped-stdin isTTY gate fix). v2.1.26 MCP Manifest Relocation + Fable Cost Retune — fixes the /plugin 'Needs attention: bkit MCP failed' defect (repo-root .mcp.json dual-loaded as plugin manifest + project config where CLAUDE_PLUGIN_ROOT is undefined; now declared inline in plugin.json, root file deleted, regression-locked MS-016) + release-plugin-tag.sh drift fix + full test-state isolation refactor (projectRoot injectable through batch-orchestrator/sprint-registry/audit-logger; tests no longer leak fixtures into the real .bkit) + ADR 0011 amendment/ADR 0015 (locale-scoped deferral)/eval re-baseline SOP + Fable cost retune (ENH-370: high-frequency PDCA verifiers gap-detector/design-validator/pdca-iterator fable→opus for cost → 6 fable / 10 opus / 16 sonnet / 2 haiku). v2.1.27 Slash-Path Orchestration Restored — Issue #132 (@hslee-cmyk): bkit's four orchestrator side-effects (next-skill/agent guidance, PDCA phase auto-advance, decision-trace phase_transition, audit skill_executed) were wired only to PostToolUse:Skill, which never fires on native slash commands (/bkit:pdca ...) — the only invocation form bkit's docs teach — so the advertised audit trail was empty for real usage; ENH-371 dual-wires the effects into a source-agnostic module fired from BOTH PostToolUse:Skill AND a new fail-open UserPromptExpansion hook (slash path records a new skill_invoked action), plus repays two latent defects (dead IntentRouter onboardingContext ReferenceError + slash-path Stop-handler marker) — graceful degradation on CC without UserPromptExpansion, no version floor bump. v2.1.28 Runtime-Phase-Aware Skill Guidance — Issue #135 (@hslee-cmyk), the narrower follow-up to #132: the next-step GUIDANCE-TEXT half of the same UserPromptExpansion mechanism never fired for multi-action routers (pdca/sprint/+9) because orchestrateSkillPost derived suggestions only from STATIC frontmatter (next-skill/pdca-phase), which those routers declare null by design. Root fix: new lib/orchestrator/runtime-guidance.js resolves the phase at call time from args.action + live PDCA/Sprint state and REUSES the manual-path SSoT (getNextPdcaActionAfterCompletion / buildNextActions) — no duplicated phase table; wired at the shared runSkillInvocationEffects chokepoint so BOTH slash and Skill-tool paths surface guidance; fail-open; only pdca/sprint eligible (9 utility routers stay intentionally silent); suggestedAgent extended (design→design-validator, qa→qa-lead); hardcoded Korean guidance strings migrated to EN-default + KO via i18n detector. 23-TC regression test, 0 main-baseline regressions. v2.1.29 PDCA Predecessor-Task Completion — Issue #137 (@hslee-cmyk): the `pdca` skill chains phase Tasks via `blockedBy` ([Plan]→[Design]→[Do]→…) but SKILL.md documented Task creation only, never predecessor completion, so a prior-phase Task left `in_progress` leaked a stale phase (e.g. \"design\" during \"do\") into Claude Code's ambient prompt context — disagreeing with `.bkit/state/pdca-status.json`'s phase (the phase source of truth). The issue's Option-2 (a hook auto-completing the predecessor) is technically infeasible — CC hooks communicate via stdout/exit-code/additionalContext only and cannot call TaskUpdate; only the model can — so v2.1.29 applies Option-1: every phase action now instructs the model to mark predecessor Task(s) `completed` before creating the next phase Task, and a new `## Task Integration` Phase Transition Rule codifies the completion semantics + rationale; a regression test guards each transition. Cosmetic/informational fix, zero runtime/architecture-count change. 🚀 bkit Early Adopter Program — running bkit on a non-trivial production project + filing detailed bug reports makes you part of bkit's quality system: public Hall of Fame recognition, E2E regression test absorption, Trust Score externalDogfoodFeedbackResponseRate component (weight 0.05). 44 Skills, 34 Agents (6 pdca-eval-* registry-tombstoned per ADR 0014), 61 Scripts, 195 Lib Modules, 22 Hook Events (25 blocks), 40 Templates, 4 Output Styles, 2 MCP Servers (19 tools, declared inline in plugin.json), 13 ADR invariants.", + "description": "Requires Claude Code v2.1.143+ (older versions reject the strict plugin-manifest displayName field — run `npm install -g @anthropic-ai/claude-code@latest` to upgrade; see docs/06-guide/cc-compatibility.guide.md). Vibecoding Kit - PDCA + Sprint Management + CTO-Led Agent Teams + Living Context System + 43 PM frameworks. v2.1.14 6 differentiations (Memory Enforcer, Layer 6 Defense, Sequential Dispatch, Effort-aware, PostToolUse continueOnBlock, Heredoc-bypass). v2.1.15 pdca-status.json fix. v2.1.16 4 GitHub issues (Quality Gates UX). v2.1.17 5/12~5/20 contract red 8-day class close. v2.1.18 Issues #100/#101/#102 — Sprint Trust UX Fix. v2.1.19 Quality Maturation Sprint — 5 sub-sprint master plan (S1 Self-Dogfooding Enablement / S2 Convention Restoration / S3 Sprint Report Maturity / S4 External Dogfooder Lifecycle / S5 Sprint Maturity Index) + ENH-318 차별화 7/7 정식 편입 + Real User Hall of Fame 도입 + 첫 외부 dogfooder entry @pruge. v2.1.20 Marketplace Recovery — minimum CC v2.1.143 advisory (F1+F4) + plugin manifest 21-key whitelist CI gate (F5+F6, ENH-322) + claude plugin validate wire (F7, ADR 0006 § Empirical Validation Gate) + cc-regression R3-321 (F8, ENH-321) + SessionStart CC version detection (F10, ENH-323) + ADR 0011 Plugin Manifest Schema Compliance Policy + Hall of Fame @bj (#2 external dogfooder, Lifecycle 5-stage). v2.1.21 Issue Response — Session Title Isolation (#111: per-session sessions[sessionId] cache map + GC + legacy migration + stable session tag for parallel-window disambiguation) + Sprint Output Enforcement (#113: lib/sprint/executive-summary.js sprint-shape + scripts/sprint-skill-stop.js run-export Stop hook + SKILL_HANDLERS sprint registration + advancePhase phaseTransitionSummary + /sprint status·watch human-readable display) + ADR 0012 Sprint Stop Hook Output Enforcement. v2.1.24 Skill Namespace Hardening — Issues #125/#126 (@hslee-cmyk): normalizeSkillName() canonicalizes CC `plugin:skill` form (bkit:pdca → pdca) so getSkillConfig + skill-post + implicit-trigger injection + Stop-handler dispatch resolve the bare folder; hook-reachability check no longer false-positives skill_post (event-driven vs canary correlation). v2.1.25 Claude 5 Model Alignment + Issue Response — 4-tier role-based model matrix (9 fable verification/orchestration core + 7 opus deep-reasoning/security + 16 sonnet implementers + 2 haiku monitors), dual floor (install v2.1.143 / model v2.1.170 advisory ENH-368), Claude 5 pricing sync; Issues #128 (@NEXCODE-MK: 6 deprecated pdca-eval-* stubs removed from the prompt surface → deprecation registry, ADR 0014) + #129 (@NEXCODE-MK: agent description token diet −44%, compact 8-language triggers) + #130 (@s99606931: learning-stop.js piped-stdin isTTY gate fix). v2.1.26 MCP Manifest Relocation + Fable Cost Retune — fixes the /plugin 'Needs attention: bkit MCP failed' defect (repo-root .mcp.json dual-loaded as plugin manifest + project config where CLAUDE_PLUGIN_ROOT is undefined; now declared inline in plugin.json, root file deleted, regression-locked MS-016) + release-plugin-tag.sh drift fix + full test-state isolation refactor (projectRoot injectable through batch-orchestrator/sprint-registry/audit-logger; tests no longer leak fixtures into the real .bkit) + ADR 0011 amendment/ADR 0015 (locale-scoped deferral)/eval re-baseline SOP + Fable cost retune (ENH-370: high-frequency PDCA verifiers gap-detector/design-validator/pdca-iterator fable→opus for cost → 6 fable / 10 opus / 16 sonnet / 2 haiku). v2.1.27 Slash-Path Orchestration Restored — Issue #132 (@hslee-cmyk): bkit's four orchestrator side-effects (next-skill/agent guidance, PDCA phase auto-advance, decision-trace phase_transition, audit skill_executed) were wired only to PostToolUse:Skill, which never fires on native slash commands (/bkit:pdca ...) — the only invocation form bkit's docs teach — so the advertised audit trail was empty for real usage; ENH-371 dual-wires the effects into a source-agnostic module fired from BOTH PostToolUse:Skill AND a new fail-open UserPromptExpansion hook (slash path records a new skill_invoked action), plus repays two latent defects (dead IntentRouter onboardingContext ReferenceError + slash-path Stop-handler marker) — graceful degradation on CC without UserPromptExpansion, no version floor bump. v2.1.28 Runtime-Phase-Aware Skill Guidance — Issue #135 (@hslee-cmyk), the narrower follow-up to #132: the next-step GUIDANCE-TEXT half of the same UserPromptExpansion mechanism never fired for multi-action routers (pdca/sprint/+9) because orchestrateSkillPost derived suggestions only from STATIC frontmatter (next-skill/pdca-phase), which those routers declare null by design. Root fix: new lib/orchestrator/runtime-guidance.js resolves the phase at call time from args.action + live PDCA/Sprint state and REUSES the manual-path SSoT (getNextPdcaActionAfterCompletion / buildNextActions) — no duplicated phase table; wired at the shared runSkillInvocationEffects chokepoint so BOTH slash and Skill-tool paths surface guidance; fail-open; only pdca/sprint eligible (9 utility routers stay intentionally silent); suggestedAgent extended (design→design-validator, qa→qa-lead); hardcoded Korean guidance strings migrated to EN-default + KO via i18n detector. 23-TC regression test, 0 main-baseline regressions. v2.1.29 PDCA Predecessor-Task Completion — Issue #137 (@hslee-cmyk): the `pdca` skill chains phase Tasks via `blockedBy` ([Plan]→[Design]→[Do]→…) but SKILL.md documented Task creation only, never predecessor completion, so a prior-phase Task left `in_progress` leaked a stale phase (e.g. \"design\" during \"do\") into Claude Code's ambient prompt context — disagreeing with `.bkit/state/pdca-status.json`'s phase (the phase source of truth). The issue's Option-2 (a hook auto-completing the predecessor) is technically infeasible — CC hooks communicate via stdout/exit-code/additionalContext only and cannot call TaskUpdate; only the model can — so v2.1.29 applies Option-1: every phase action now instructs the model to mark predecessor Task(s) `completed` before creating the next phase Task, and a new `## Task Integration` Phase Transition Rule codifies the completion semantics + rationale; a regression test guards each transition. Cosmetic/informational fix, zero runtime/architecture-count change. v2.1.30 Stop-Hook stdin-Block Hardening — Issue #139 (@thenopen, found via Claude Code /doctor): every bkit hook reads its payload through lib/core/io.js readStdinSync(), which used fs.readFileSync(0) — a blocking stdin read with NO timeout that returns only at EOF, so a hook stalls for as long as Claude Code keeps the stdin write-end open (the turn-gating Stop hook unified-stop.js was observed stalling up to ~15.5 min, far past its own 10s timeout). Reproduced: payload sent + pipe held open 4s → 4.07s block with flat CPU (I/O-blocked, matching the reporter's low-CPU tail). Central fix (all 36 hook scripts): readStdinSync now reads incrementally with parse-early and returns the instant the buffer holds a complete JSON value, never waiting for EOF; the turn-gating unified-stop.js additionally uses a new async readStdinBounded (parse-early + hard timeout that destroys stdin on resolve) so it is fully bounded even for no-data/truncated held-open pipes; state-store lock() CPU-burning busy-wait spin replaced with a no-CPU Atomics.wait sleep. 16-TC regression test, 0 main-baseline regressions, architecture counts invariant. 🚀 bkit Early Adopter Program — running bkit on a non-trivial production project + filing detailed bug reports makes you part of bkit's quality system: public Hall of Fame recognition, E2E regression test absorption, Trust Score externalDogfoodFeedbackResponseRate component (weight 0.05). 44 Skills, 34 Agents (6 pdca-eval-* registry-tombstoned per ADR 0014), 61 Scripts, 195 Lib Modules, 22 Hook Events (25 blocks), 40 Templates, 4 Output Styles, 2 MCP Servers (19 tools, declared inline in plugin.json), 13 ADR invariants.", "author": { "name": "POPUP STUDIO PTE. LTD.", "email": "contact@popupstudio.ai" }, - "version": "2.1.29", + "version": "2.1.30", "repository": "https://github.com/popup-studio-ai/bkit-claude-code", "source": { "source": "url", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3ea36373..999053c0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bkit", - "version": "2.1.29", + "version": "2.1.30", "displayName": "bkit — AI Native Development OS", "description": "The only Claude Code plugin that verifies AI-generated code against its own design specs.", "author": { diff --git a/CHANGELOG.md b/CHANGELOG.md index af58fe38..87d3c9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,76 @@ 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.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.30] - 2026-07-14 + +> **Status**: Issue #139 (@thenopen, surfaced via Claude Code's `/doctor` +> health-check) — a real reliability defect. The `Stop` event hook +> (`scripts/unified-stop.js`, `timeout: 10000` in `hooks/hooks.json`) occasionally +> stalled far past its own 10 s timeout: across ~50 sessions / 5 days the reporter +> measured a healthy ~0.8 s average but a **928,551 ms (~15.5 min) maximum**, with +> 14 timeout-cancellations. Because `Stop` hooks gate turn completion, every stall +> blocked the end of a turn for its full duration. No Guessing: the root cause was +> reproduced end-to-end (not inferred from the subsystem list in the report). + +### Stop-Hook stdin-Block Hardening (Issue #139) + +- **Root cause (reproduced, not speculated)**: every bkit hook reads its payload + through `lib/core/io.js` `readStdinSync()`, which used + `fs.readFileSync(0, 'utf8')` — a blocking read on stdin (fd 0) with **no + timeout**. It returns only when stdin reaches EOF, i.e. when Claude Code closes + the hook's stdin write-end. If CC keeps that write-end open (busy, backpressure, + delayed close), the hook blocks for exactly that long. Reproduction against the + real hook: stdin closed immediately → 0.19 s; writer holding the stdin pipe open + 4 s → **4.07 s**, with `user` CPU flat at 0.19 s — the process is *blocked on + I/O*, not burning CPU, matching the issue's profile (healthy average, extreme + tail, low CPU). +- **Blast radius**: `readStdinSync()` is called by **36 files** — effectively every + bkit hook script. The issue was filed against `unified-stop.js`, but the defect + lives in the shared function, so the fix is central and protects **all** hook + events (PreToolUse, PostToolUse, Stop, SessionEnd, …), not just Stop. +- **Fix 1 — central bounded parse-early read (`lib/core/io.js`)**: `readStdinSync()` + now reads fd 0 incrementally with `fs.readSync` and returns **the instant the + accumulated buffer holds a complete JSON value** — it never waits for EOF, which + was the entire source of the stall. The raw-fd path creates no libuv stream + handle, so the process still exits promptly. The return contract is unchanged + (empty → `{}`, malformed → `{}` unless `BKIT_STRICT_STDIN=1` rethrows). Reproduced + case: **15.5 min → ~1 ms**. +- **Fix 2 — hard-bounded async read for the turn-gating Stop hook + (`scripts/unified-stop.js`)**: a new `readStdinBounded(timeoutMs)` export reads + stdin event-based with parse-early **and** a hard `setTimeout` budget, and + **destroys `process.stdin` on resolve** (without that, the open stdin handle keeps + the event loop alive until EOF and the process lingers even after the payload is + parsed — a subtle trap that would reintroduce the stall). `unified-stop.js` awaits + it inside an async IIFE, so the Stop hook can never exceed + `STDIN_READ_TIMEOUT_MS` even for the pathological no-data / truncated + held-open + pipe that the sync reader cannot hard-bound. +- **Fix 3 — non-CPU lock backoff (`lib/core/state-store.js`)**: `lock()` previously + backed off with a CPU-burning busy-wait spin (`while (Date.now() < waitUntil) {}`) + that pinned a core for the retry interval. Replaced with a synchronous + `Atomics.wait` sleep (portable, no native deps, no CPU burn), addressing the + issue's "lock-wait / retry-without-backoff" note for the ~4 `lockedUpdate` calls + in the Stop chain. +- **New constant**: `STDIN_READ_TIMEOUT_MS` (default 2000 ms, env override + `BKIT_STDIN_TIMEOUT_MS`) — ~2.5× the observed healthy average, well under bkit's + `HOOK_TIMEOUT_MS` (5000) and CC's Stop-hook timeout (10000), so bkit bounds the + read before either outer timeout can fire. +- **Out of scope (justified)**: `scripts/lint-skill-md.js` uses `fs.readFileSync(0)` + to read a markdown file via stdin redirect (a CI/dev tool, not a runtime hook, not + a held-open pipe) — not vulnerable, left unchanged. Making the Stop sub-handlers + fire-and-forget was unnecessary: the confirmed cause is stdin blocking, not + sub-handler cost (normal run 0.19 s). +- **Verification**: new 16-TC regression test + `test/regression/issue-139-stdin-bounded.test.js` (parse-early return, contract + preservation, hard-timeout bounding, prompt process exit, source guards); + end-to-end proof against real subprocesses with a held-open pipe (Stop hook + ~374 ms vs an 8 s held pipe; no-data hard cap ~2.4 s); live `claude -p + --plugin-dir .` on CC v2.1.208; full CI gate suite green (contract L1/L4 222+243, + l2-smoke 105, l2-hook-attribution 13, l3-mcp 92+48, integration-runtime 23, + invocation-inventory 213, hooks-22 25, bkit-full-system 36, deadcode 0-new, + validate-plugin 0-err); **0 new regressions** vs the `main` baseline. +- **Architecture counts invariant**: 44 Skills · 34 Agents · 22 Hook Events / 25 + blocks · 195 Lib Modules — unchanged (internal changes to existing modules). + ## [2.1.29] - 2026-07-06 > **Status**: Issue #137 (@hslee-cmyk) — a low-priority, cosmetic follow-up in the diff --git a/README.md b/README.md index b1bb02f2..980dfb86 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Claude Code](https://img.shields.io/badge/Claude%20Code-v2.1.143+-purple.svg)](https://code.claude.com) -[![Version](https://img.shields.io/badge/Version-2.1.29-green.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-2.1.30-green.svg)](CHANGELOG.md) [![Author](https://img.shields.io/badge/Author-POPUP%20STUDIO-orange.svg)](https://popupstudio.ai) > **Requirement**: bkit requires Claude Code **v2.1.143 or later** (the strict plugin-manifest path recognizes the official `displayName` field only from v2.1.143). On older Claude Code you will see `Validation errors: Unrecognized key: "displayName"` during `claude plugin install`. Run `npm install -g @anthropic-ai/claude-code@latest` to upgrade, or see [`docs/06-guide/cc-compatibility.guide.md`](docs/06-guide/cc-compatibility.guide.md). @@ -209,7 +209,7 @@ Full architecture deep-dive: [README-FULL.md §9](README-FULL.md#9-architecture) | Path | What's there | |---|---| | [README-FULL.md](README-FULL.md) | Full command reference, deep workflow internals, agent teams, architecture, Skill Evals | -| [CHANGELOG.md](CHANGELOG.md) | Release history (single source of truth — latest release: v2.1.29) | +| [CHANGELOG.md](CHANGELOG.md) | Release history (single source of truth — latest release: v2.1.30) | | [CUSTOMIZATION-GUIDE.md](CUSTOMIZATION-GUIDE.md) | Override any bkit component in your `.claude/` directory | | [AI-NATIVE-DEVELOPMENT.md](AI-NATIVE-DEVELOPMENT.md) | The 6 AI-Native principles and how bkit implements them | | [`bkit-system/philosophy/`](bkit-system/philosophy/) | Core mission, Context Engineering, PDCA methodology, AI-Native principles | diff --git a/bkit-system/README.md b/bkit-system/README.md index a364d8bf..44154f65 100644 --- a/bkit-system/README.md +++ b/bkit-system/README.md @@ -4,7 +4,7 @@ > > **Version history is maintained in a single source of truth**: see [CHANGELOG.md](../CHANGELOG.md) for the full release history (v1.0.0 → v2.1.13). > -> Post-v2.1.13 maintenance releases (v2.1.14 → v2.1.26, latest: v2.1.26 — MCP manifest relocation fixing the `/plugin` "MCP failed" defect [inline `mcpServers` in plugin.json, root `.mcp.json` deleted] + test-state isolation refactor + ADR 0015/eval SOP + Fable cost retune [high-frequency verifiers gap-detector/design-validator/pdca-iterator fable→opus → 6 fable / 10 opus / 16 sonnet / 2 haiku matrix]) are tracked in CHANGELOG.md; the component counts below reflect the current tree. +> Post-v2.1.13 maintenance releases (v2.1.14 → v2.1.30, latest: v2.1.30 — Stop-hook stdin-block hardening [#139]: `lib/core/io.js` `readStdinSync` no longer uses the unbounded `fs.readFileSync(0)` that let a hook stall up to ~15.5 min on a held-open stdin pipe; it now reads incrementally with parse-early [central fix for all 36 hook scripts], and the turn-gating `scripts/unified-stop.js` additionally uses a new async `readStdinBounded` with a hard timeout, plus a non-CPU `Atomics.wait` lock backoff in `state-store.js`) are tracked in CHANGELOG.md; the component counts below reflect the current tree. > > Current release highlights (v2.1.13 over v2.1.12): > - **Sprint Management (NEW v2.1.13 GA)**: 8-phase meta-container (`prd → plan → design → do → iterate → qa → report → archived`) — 16 sub-actions, 4 Auto-Pause Triggers (QUALITY_GATE_FAIL/ITERATION_EXHAUSTED/BUDGET_EXCEEDED/PHASE_TIMEOUT), Trust Level scope L0-L4 via `SPRINT_AUTORUN_SCOPE`, 7-Layer S1 dataFlowIntegrity QA, 4 sprint agents, 1 skill, 7 templates, 13 application-layer modules, 9 infrastructure adapters, 3 MCP tools, 1 L3 contract test (8 SC-01~08), 2 Korean guides, 2 ADRs (0006 + 0007) diff --git a/bkit-system/components/agents/_agents-overview.md b/bkit-system/components/agents/_agents-overview.md index 95e33aec..7b2124cc 100644 --- a/bkit-system/components/agents/_agents-overview.md +++ b/bkit-system/components/agents/_agents-overview.md @@ -2,6 +2,7 @@ > List of 34 Agents defined in bkit and their roles (v2.1.13) > +> **v2.1.30**: Stop-hook stdin-block hardening (#139) — agents unchanged; a runtime-reliability fix in `lib/core/io.js` / `scripts/unified-stop.js` (bounded stdin read so the Stop hook cannot stall on a held-open pipe). No agent definition, model, or count change. > **v2.1.26**: Fable cost retune — the 3 high-frequency PDCA verifiers (gap-detector, design-validator, pdca-iterator) move fable→opus (they run in the repeated Check/iterate loop; Opus 4.8 is strong at verification at half Fable's $10/$50 cost). Matrix now: 6 fable (leads + sprint verifier) / 10 opus / 16 sonnet / 2 haiku. Fable stays on the long-horizon orchestrators where its planning/delegation edge compounds. > **v2.1.25**: Claude 5 Model Alignment + Issue Response — 4-tier role-based model matrix: 9 fable (verification & orchestration core) / 7 opus (deep reasoning & security) / 16 sonnet (implementers) / 2 haiku (monitors). 16 reassignments (9 opus→fable, 1 opus→sonnet sprint-report-writer, 6 sonnet→haiku pdca-eval-* — subsequently REMOVED from agents/ per #128/ADR 0014, deprecation registry at test/contract/deprecation-registry.json). Descriptions compacted −44% per #129 (compact 8-language triggers; "Do NOT use for" moved to body). Model floor: `fable` requires CC ≥ v2.1.170 (SessionStart advisory ENH-368 below it). CC recommended: v2.1.198. > **v2.1.24**: Skill namespace hardening (#125/#126) — agents unchanged (40 agent files: 34 active + 6 deprecated pdca-eval-* tombstones). diff --git a/bkit-system/components/scripts/_scripts-overview.md b/bkit-system/components/scripts/_scripts-overview.md index 33c101c6..91ecbbea 100644 --- a/bkit-system/components/scripts/_scripts-overview.md +++ b/bkit-system/components/scripts/_scripts-overview.md @@ -2,6 +2,7 @@ > 51 Node.js Scripts used by bkit hooks (v2.1.13) > +> **v2.1.30**: Stop-hook stdin-block hardening (#139) — scripts count unchanged; `scripts/unified-stop.js` now reads its payload via the new async `readStdinBounded` (parse-early + hard timeout) inside an async IIFE so the turn-gating Stop hook can never stall on a held-open stdin pipe (root cause: `lib/core/io.js` `readStdinSync` used the unbounded `fs.readFileSync(0)`; fixed centrally for all 36 hook scripts). > **v2.1.26**: MCP manifest relocation + release tooling — scripts count unchanged; `scripts/release-plugin-tag.sh` step 6 rewritten to tag via `git tag -a` (CC ~v2.1.110 changed `plugin tag` to derive `{name}--v{version}`); `scripts/subagent-start-handler.js` team default follows the retuned matrix. Fable cost retune (ENH-370): high-frequency verifiers gap-detector/design-validator/pdca-iterator fable→opus (6 fable / 10 opus / 16 sonnet / 2 haiku). > **v2.1.25**: Claude 5 Model Alignment — scripts count unchanged; `scripts/subagent-start-handler.js` runtime model whitelist extended with `fable` and team default ctoAgent `opus` → `fable`. CC recommended: v2.1.198 (model floor v2.1.170 for fable-pinned agents). > **v2.1.24**: Skill namespace hardening (#125/#126) — scripts count unchanged; `scripts/skill-post.js` / `scripts/user-prompt-handler.js` / `scripts/unified-stop.js` canonicalize CC `plugin:skill` identifiers via new `lib/core/skill-name.js` `normalizeSkillName()`. diff --git a/bkit-system/components/skills/_skills-overview.md b/bkit-system/components/skills/_skills-overview.md index b578186b..b2288601 100644 --- a/bkit-system/components/skills/_skills-overview.md +++ b/bkit-system/components/skills/_skills-overview.md @@ -4,6 +4,7 @@ > > **Counting note**: CC's `/plugin` Skills count = `skills/` + `commands/` entries (same-name dedup); bkit's 44 skills + `commands/output-style-setup.md` display as **45** — expected, not a drift. > +> **v2.1.30**: Stop-hook stdin-block hardening (#139) — skills unchanged (44); a runtime-reliability fix in `lib/core/io.js` / `scripts/unified-stop.js` (bounded stdin read so the Stop hook cannot stall on a held-open pipe). No SKILL.md or count change. > **v2.1.26**: MCP manifest relocation + Fable cost retune — skills unchanged (44; CC `/plugin` displays 45 = 44 skills + `commands/output-style-setup.md`, per the counting note below). Skill prose model references unchanged (cto-lead/pm-lead stay fable); the retuned verifiers (gap-detector/design-validator/pdca-iterator → opus) are agents, not skills. CC recommended: v2.1.198. > **v2.1.25**: Claude 5 Model Alignment — skills unchanged (44); model references in `pdca`, `pm-discovery`, `cc-version-analysis` SKILL.md synced to the 4-tier matrix (cto-lead/pm-lead → fable). CC recommended: v2.1.198. > **v2.1.24**: Skill namespace hardening (#125/#126) — skills unchanged (44); namespaced invocation (`bkit:pdca`) now resolves next-skill / pdca-phase guidance and Stop-handler dispatch correctly. diff --git a/bkit.config.json b/bkit.config.json index 2e6267bb..b70b6173 100644 --- a/bkit.config.json +++ b/bkit.config.json @@ -1,5 +1,5 @@ { - "version": "2.1.29", + "version": "2.1.30", "ui": { "sessionTitle": { "enabled": true, diff --git a/docs/01-plan/features/stop-hook-stdin-block-139.plan.en.md b/docs/01-plan/features/stop-hook-stdin-block-139.plan.en.md new file mode 100644 index 00000000..0f3ff072 --- /dev/null +++ b/docs/01-plan/features/stop-hook-stdin-block-139.plan.en.md @@ -0,0 +1,109 @@ +# Plan — Stop Hook stdin-block (Issue #139) + +- **Feature**: `stop-hook-stdin-block-139` +- **Target bkit version**: v2.1.30 +- **Source**: GitHub Issue [#139](https://github.com/popup-studio-ai/bkit-claude-code/issues/139) (@thenopen, found via Claude Code `/doctor` health-check) +- **Branch**: `feat/v2.1.30-issue-139` (from `main` @ `76bd1af` = v2.1.29) + +## 1. Problem statement + +The `Stop` event hook (`scripts/unified-stop.js`, wired in `hooks/hooks.json` with +`timeout: 10000`) occasionally stalls far beyond its own 10 s timeout. + +Aggregate evidence from the reporter (~50 sessions / 5-day window, pulled from +Claude Code transcript hook-attachment records): + +| Metric | Value | +|---|---| +| Stop hook invocations | 2,223 | +| Average duration | ~0.8 s (healthy) | +| **Max duration** | **928,551 ms (~15.5 min)** | +| Timeout-cancellations (`timedOut: true`, `timeoutMs: 10000`) | 14 | + +Because Stop hooks gate turn completion, every stall blocks the end of a turn for +its full duration. + +## 2. Root cause (confirmed by reproduction — not speculation) + +`lib/core/io.js` → `readStdinSync()` reads the hook payload with: + +```js +const input = fs.readFileSync(0, 'utf8'); +``` + +`fs.readFileSync(0)` is a **blocking read on stdin (fd 0) with no timeout**. It does +not return until stdin reaches EOF — i.e. until Claude Code closes the write end of +the hook's stdin pipe. If CC keeps that write end open (busy, backpressure, or +delayed close), the hook blocks for exactly that long. + +**Reproduction** (`node scripts/unified-stop.js`, real hook): + +| Condition | Wall time | user CPU | +|---|---|---| +| stdin closed immediately (normal) | 0.19 s | 0.19 s | +| writer holds stdin pipe open 4 s after sending payload | **4.07 s** | 0.19 s | + +The `user` CPU stays flat at 0.19 s while wall time tracks the held-open duration — +proving the process is **blocked on I/O**, not burning CPU. This matches the issue's +profile exactly: healthy average, extreme tail, low CPU (occasional blocking I/O +wait rather than a consistent perf regression). + +## 3. Blast radius (Rule 4 — related & similar code) + +`readStdinSync()` is called by **36 files** — effectively every bkit hook script +(`unified-stop`, `skill-post`, `unified-bash-pre/post`, `unified-write-post`, +`user-prompt-handler`, `session-end-handler`, every `*-stop.js`, …). The issue is +filed against `unified-stop.js`, but the defect lives in the **shared** function. +Therefore a **single central fix in `io.js` protects every hook event** +(PreToolUse, PostToolUse, Stop, SessionEnd, …), not just Stop. + +One additional raw occurrence: `scripts/lint-skill-md.js:26` uses +`fs.readFileSync(0)` directly, but it is a CI/dev lint tool, not a runtime hook +(lower priority; addressed for consistency). + +## 4. Secondary contributor (code-confirmed; not the 15-min cause) + +`lib/core/state-store.js` `lock()` (lines ~158-160) implements retry via a +**CPU-burning synchronous busy-wait spin**: + +```js +const waitUntil = Date.now() + LOCK_RETRY_INTERVAL_MS; +while (Date.now() < waitUntil) { /* spin */ } +``` + +Constants (`lib/core/constants.js`): `LOCK_TIMEOUT_MS=5000`, `LOCK_STALE_MS=10000`, +`LOCK_RETRY_INTERVAL_MS=100`, `LOCK_MAX_RETRIES=50`. A single `lock()` is bounded to +~5 s, so it cannot by itself explain 15 min — but under contention across the ~4 +`lockedUpdate` calls in the Stop chain (checkpoint-manager, metrics-collector, +trust-engine, automation-controller) it pins a CPU core and adds latency. This +directly matches the issue's "lock-wait / retry-without-backoff" suspicion and is a +worthwhile hardening. + +## 5. Goals & non-goals + +**Goals** +- The Stop hook (and every bkit hook) must never block indefinitely on stdin. +- The reproduced case (payload sent, pipe held open) must resolve in ~ms. +- Preserve the exact return contract of `readStdinSync()` (parse failure → `{}` + unless `BKIT_STRICT_STDIN=1` rethrows; debugLog on failure). +- Zero new CI regressions; live verification via `--plugin-dir .`. + +**Non-goals** +- Rewriting the Stop sub-handler pipeline to be fully async/fire-and-forget (the + confirmed cause is stdin blocking, not sub-handler cost). Optional instrumentation + only. +- Version release without explicit user approval (Rule 11). + +## 6. Acceptance criteria + +1. Valid payload + delayed EOF (pipe held open) → hook returns immediately (~ms), + verified by regression test and live reproduction (before/after timing). +2. All 36 hooks continue to parse normal payloads and dispatch handlers identically. +3. Full CI gate suite green with zero new failures vs. `main` baseline. +4. Docs = Code synced; version bumped 2.1.29 → 2.1.30 across the canonical surface. + +## 7. Rollout + +Single branch, single commit (minimize GitHub Actions cost), PR → CI monitor → +**user-approval gate** → merge → tag `v2.1.30` → GitHub Release (EN) → answer & +close #139. diff --git a/docs/01-plan/features/stop-hook-stdin-block-139.plan.ko.md b/docs/01-plan/features/stop-hook-stdin-block-139.plan.ko.md new file mode 100644 index 00000000..814e23a1 --- /dev/null +++ b/docs/01-plan/features/stop-hook-stdin-block-139.plan.ko.md @@ -0,0 +1,102 @@ +# 계획 — Stop 훅 stdin-block (이슈 #139) + +- **기능(Feature)**: `stop-hook-stdin-block-139` +- **대상 bkit 버전**: v2.1.30 +- **출처**: GitHub 이슈 [#139](https://github.com/popup-studio-ai/bkit-claude-code/issues/139) (@thenopen, Claude Code `/doctor` 헬스체크로 발견) +- **브랜치**: `feat/v2.1.30-issue-139` (`main` @ `76bd1af` = v2.1.29 기반) + +## 1. 문제 정의 + +`Stop` 이벤트 훅(`scripts/unified-stop.js`, `hooks/hooks.json`에 `timeout: 10000`으로 +배선)이 간헐적으로 자체 10초 타임아웃을 훨씬 초과하여 stall된다. + +제보자 집계 증거(~50세션 / 5일 구간, Claude Code transcript 훅-어태치먼트 기록): + +| 지표 | 값 | +|---|---| +| Stop 훅 실행 횟수 | 2,223 | +| 평균 소요 | ~0.8초 (정상) | +| **최대 소요** | **928,551ms (~15.5분)** | +| 타임아웃 취소(`timedOut: true`, `timeoutMs: 10000`) | 14회 | + +Stop 훅은 턴 완료를 gate하므로, 모든 stall이 그 시간만큼 턴 종료를 차단한다. + +## 2. 근본 원인 (재현으로 확정 — 추측 아님) + +`lib/core/io.js`의 `readStdinSync()`는 훅 payload를 다음으로 읽는다: + +```js +const input = fs.readFileSync(0, 'utf8'); +``` + +`fs.readFileSync(0)`은 **타임아웃 없는 stdin(fd 0) blocking read**이다. stdin이 +EOF에 도달할 때까지(= Claude Code가 훅 stdin 파이프의 write-end를 닫을 때까지) +반환하지 않는다. CC가 write-end를 계속 열어두면(바쁨/백프레셔/지연 close) 훅은 +정확히 그만큼 blocking된다. + +**재현**(`node scripts/unified-stop.js`, 실제 훅): + +| 조건 | 실제 시간 | user CPU | +|---|---|---| +| stdin 즉시 닫힘 (정상) | 0.19초 | 0.19초 | +| payload 전송 후 writer가 stdin 파이프 4초 유지 | **4.07초** | 0.19초 | + +`user` CPU는 0.19초로 고정인 채 wall time만 held-open 시간을 추종 → 프로세스가 +**I/O 대기로 blocking**됨(CPU 소모 아님)을 증명. 이슈 프로파일(정상 평균 · 극단 +tail · 낮은 CPU = 일관된 성능 저하가 아닌 간헐적 blocking I/O 대기)과 정확히 일치. + +## 3. Blast radius (Rule 4 — 연관·유사 코드) + +`readStdinSync()`는 **36개 파일**이 호출 — 사실상 모든 bkit 훅 스크립트 +(`unified-stop`, `skill-post`, `unified-bash-pre/post`, `unified-write-post`, +`user-prompt-handler`, `session-end-handler`, 모든 `*-stop.js` 등). 이슈는 +`unified-stop.js`로 제기됐으나 결함은 **공유** 함수에 있다. 따라서 +**io.js 단일 중앙 수정이 모든 훅 이벤트를 보호**한다(Stop뿐 아니라 PreToolUse / +PostToolUse / SessionEnd 등). + +추가 raw 발생 1건: `scripts/lint-skill-md.js:26`이 `fs.readFileSync(0)`을 직접 +사용하나, 런타임 훅이 아닌 CI/개발 lint 도구(저우선, 일관성 차원에서 함께 처리). + +## 4. 2차 기여 요인 (코드 확인; 15분 원인은 아님) + +`lib/core/state-store.js`의 `lock()`(약 158-160행)은 재시도를 **CPU를 태우는 동기 +busy-wait 스핀**으로 구현: + +```js +const waitUntil = Date.now() + LOCK_RETRY_INTERVAL_MS; +while (Date.now() < waitUntil) { /* spin */ } +``` + +상수(`lib/core/constants.js`): `LOCK_TIMEOUT_MS=5000`, `LOCK_STALE_MS=10000`, +`LOCK_RETRY_INTERVAL_MS=100`, `LOCK_MAX_RETRIES=50`. 단일 `lock()`은 ~5초로 +상한되어 그 자체로 15분을 설명할 수 없으나, Stop 체인의 ~4개 `lockedUpdate` +호출(checkpoint-manager, metrics-collector, trust-engine, automation-controller) +경합 시 CPU 코어를 점유하고 지연을 더한다. 이슈의 "lock-wait / retry-without-backoff" +의심과 정확히 부합하는 유효한 hardening 대상. + +## 5. 목표 & 비목표 + +**목표** +- Stop 훅(및 모든 bkit 훅)은 stdin에서 절대 무한 blocking되지 않아야 한다. +- 재현된 케이스(payload 전송 · 파이프 held-open)는 ~ms 내 해소되어야 한다. +- `readStdinSync()`의 반환 계약 보존(parse 실패 → `{}`, 단 `BKIT_STRICT_STDIN=1`은 + rethrow; 실패 시 debugLog). +- CI 신규 회귀 0건; `--plugin-dir .` 라이브 검증. + +**비목표** +- Stop 서브핸들러 파이프라인 전체 async/fire-and-forget 재작성(확정된 원인은 stdin + blocking이지 서브핸들러 비용이 아님). 선택적 계측만. +- 사용자 승인 없는 버전 릴리스(Rule 11). + +## 6. 인수 기준 + +1. 유효 payload + 지연 EOF(파이프 held-open) → 훅 즉시(~ms) 반환. 회귀 테스트 + + 라이브 재현(전/후 타이밍)으로 검증. +2. 36개 훅 전부 정상 payload를 동일하게 파싱하고 핸들러 디스패치. +3. 전 CI 게이트 green, `main` 기준선 대비 신규 실패 0건. +4. Docs = Code 동기화; 버전 2.1.29 → 2.1.30 정규 surface 전반 bump. + +## 7. 배포 + +단일 브랜치 · 단일 커밋(GitHub Actions 비용 최소화), PR → CI 모니터 → +**사용자 승인 게이트** → 머지 → 태그 `v2.1.30` → GitHub Release(영어) → #139 답변·close. diff --git a/docs/02-design/features/stop-hook-stdin-block-139.design.en.md b/docs/02-design/features/stop-hook-stdin-block-139.design.en.md new file mode 100644 index 00000000..a530c3c5 --- /dev/null +++ b/docs/02-design/features/stop-hook-stdin-block-139.design.en.md @@ -0,0 +1,165 @@ +# Design — Stop Hook stdin-block (Issue #139) + +- **Feature**: `stop-hook-stdin-block-139` · **Target**: bkit v2.1.30 +- **Approach**: Hybrid (chosen at Design checkpoint) — central sync parse-early for + all hooks + async hard-timeout for the turn-gating Stop hook + non-CPU lock backoff. + +## 0. Reproduction ledger (evidence the design is built on) + +| # | Scenario | Current `readFileSync(0)` | Sync parse-early | Async parse-early + hard-timeout | +|---|---|---|---|---| +| T1 | stdin closed immediately (normal) | 0.19 s | 0 ms | 14 ms | +| T2 | **payload sent, pipe held open 5 s (the reproduced #139 case)** | **~5 s (→15.5 min in prod)** | **1 ms** | 14 ms | +| T3 | no data, pipe held open 5 s | ~5 s | ~5 s (blocking syscall) | 1502 ms (hard cap) | +| T4 | 200 KB multi-chunk, held open 3 s | ~3 s | 2 ms | 14 ms | +| T5 | truncated JSON, held open 5 s | ~5 s | ~5 s | 1502 ms (hard cap) | + +**Process-exit trap (verified)**: with the async event-based reader, resolving the +payload via parse-early is *not enough* — the open `process.stdin` handle keeps the +event loop alive until EOF (process lingers 4885 ms in test). The reader MUST +`process.stdin.destroy()` on resolve (process then exits at 15 ms). The sync +`fs.readSync` path uses a raw fd (no libuv stream handle) and exits promptly (8 ms) +without any cleanup — verified. + +## 1. Change 1 — `lib/core/io.js` `readStdinSync()` → bounded parse-early (central) + +Protects **all 36 hook scripts** at once. + +```js +function readStdinSync() { + const deadline = Date.now() + STDIN_READ_TIMEOUT_MS; // env-overridable + const CHUNK = 65536; + let buf = Buffer.alloc(0); + const tmp = Buffer.alloc(CHUNK); + try { + while (true) { + // parse-early: return the instant the buffer holds a complete JSON value — + // never wait for EOF (the EOF-wait is the #139 stall). + if (buf.length > 0) { + try { return JSON.parse(buf.toString('utf8')); } catch (_) { /* need more */ } + } + if (Date.now() > deadline) break; // best-effort bound between reads + let n = 0; + try { + n = fs.readSync(0, tmp, 0, CHUNK, null); + } catch (e) { + if (e.code === 'EAGAIN') continue; // non-blocking fd, retry + if (e.code === 'EOF') { n = 0; } // some platforms surface EOF as throw + else throw e; + } + if (n === 0) break; // real EOF + buf = Buffer.concat([buf, tmp.slice(0, n)]); + } + return JSON.parse(buf.toString('utf8')); // final attempt (empty → throws → {}) + } catch (e) { + getDebug().debugLog('io', 'readStdinSync parse failure', { + error: e && e.message, strict: process.env.BKIT_STRICT_STDIN === '1', + }); + if (process.env.BKIT_STRICT_STDIN === '1') throw e; + return {}; + } +} +``` + +**Contract preserved**: empty stdin → `JSON.parse('')` throws → `{}`; malformed → +debugLog + `{}` (or rethrow under `BKIT_STRICT_STDIN`). Return value identical for +every normal payload. The only behavioral change is that a valid payload returns +*before* EOF instead of *after* — which is exactly the fix. + +**Residual (accepted, documented)**: the sync deadline is best-effort — it is only +checked *between* blocking `readSync` calls, so a truly empty-but-held-open pipe +(T3) or truncated-then-held pipe (T5) can still block inside one `readSync` until +EOF. This is why the turn-gating Stop hook additionally gets Change 2. All 35 other +hooks accept this residual (their payloads are always present in practice, and CC's +own per-hook timeout is the outer bound). + +## 2. Change 2 — `scripts/unified-stop.js` → async hard-timeout read (defense-in-depth) + +New export in `io.js`: + +```js +function readStdinBounded(timeoutMs) { + return new Promise((resolve) => { + let data = '', done = false; + const cleanup = () => { + clearTimeout(timer); + try { process.stdin.pause(); } catch (_) {} + process.stdin.removeAllListeners('data'); + process.stdin.removeAllListeners('end'); + process.stdin.removeAllListeners('error'); + try { process.stdin.destroy(); } catch (_) {} // release handle → prompt exit + }; + const finish = (v) => { if (done) return; done = true; cleanup(); resolve(v); }; + const timer = setTimeout(() => { + getDebug().debugLog('io', 'readStdinBounded hard timeout', { timeoutMs, bytes: data.length }); + try { finish(JSON.parse(data)); } catch (_) { finish({}); } + }, timeoutMs != null ? timeoutMs : STDIN_READ_TIMEOUT_MS); + if (timer.unref) timer.unref(); + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (c) => { data += c; try { finish(JSON.parse(data)); } catch (_) {} }); + process.stdin.on('end', () => { try { finish(JSON.parse(data)); } catch (_) { finish({}); } }); + process.stdin.on('error', () => finish({})); + }); +} +``` + +`unified-stop.js` wraps its body in an async IIFE and awaits the bounded read at the +top (the read is the first operation, so the event loop is free for the timer to +fire). Everything after the read stays synchronous inside the async function — +minimal structural change, no logic change. This makes the Stop hook unable to +exceed `STDIN_READ_TIMEOUT_MS` even for T3/T5. + +## 3. Change 3 — `lib/core/state-store.js` `lock()` → non-CPU backoff + +Replace the CPU-burning busy-wait spin with a real synchronous sleep via +`Atomics.wait` (portable, Node ≥ 8.10, no native deps, no CPU burn): + +```js +// was: const waitUntil = Date.now() + LOCK_RETRY_INTERVAL_MS; +// while (Date.now() < waitUntil) { /* spin */ } +sleepSync(LOCK_RETRY_INTERVAL_MS); // Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +``` + +Same wall-clock bound (~5 s worst case per lock), but it no longer pins a CPU core +while waiting — addressing the issue's "lock-wait / retry-without-backoff" note for +the ~4 `lockedUpdate` calls in the Stop chain. + +## 4. Change 4 — `lib/core/constants.js` + +Add to the I/O section: + +```js +/** Bounded stdin read timeout (ms) — env override: BKIT_STDIN_TIMEOUT_MS */ +const STDIN_READ_TIMEOUT_MS = Number(process.env.BKIT_STDIN_TIMEOUT_MS) > 0 + ? Number(process.env.BKIT_STDIN_TIMEOUT_MS) + : 2000; +``` + +2000 ms is ~2.5× the observed healthy average (0.8 s) yet well under both bkit's +`HOOK_TIMEOUT_MS` (5000) and CC's Stop-hook `timeout` (10000), so bkit bounds the +read before either outer timeout can fire. + +## 5. Regression tests (`test/regression/issue-139-stdin-bounded.test.js`) + +1. `readStdinSync` returns a valid payload immediately when the writer holds the + pipe open (asserts elapsed ≪ held-open duration). +2. `readStdinSync` empty stdin → `{}`; malformed → `{}` (contract preserved). +3. `readStdinBounded` resolves via parse-early on payload; resolves via hard timeout + (bounded) on no-data / truncated input; destroys stdin so the process can exit. +4. `state-store` `lock()`/`lockedUpdate` still serialize concurrent writers and the + spin was replaced (no busy-wait token present in source). +5. Live: `node scripts/unified-stop.js` with a held-open pipe returns bounded. + +## 6. Out of scope (justified) + +- `scripts/lint-skill-md.js:26` raw `fs.readFileSync(0)` — reads a **markdown file + via stdin redirect** (not a hook JSON payload, not a held-open pipe); not + vulnerable. Left unchanged to avoid scope creep. +- Making Stop sub-handlers fire-and-forget — the confirmed cause is stdin blocking, + not sub-handler cost (normal run 0.19 s). Not needed. + +## 7. Architecture-count impact + +None. All changes are internal to existing lib modules / one script. Skills 44 · +Agents 34 · Hooks 22 ev / 25 blk · Lib 195 modules remain **invariant** (to be +verified by docs-code-sync in QA). diff --git a/docs/02-design/features/stop-hook-stdin-block-139.design.ko.md b/docs/02-design/features/stop-hook-stdin-block-139.design.ko.md new file mode 100644 index 00000000..6ce57560 --- /dev/null +++ b/docs/02-design/features/stop-hook-stdin-block-139.design.ko.md @@ -0,0 +1,160 @@ +# 설계 — Stop 훅 stdin-block (이슈 #139) + +- **기능**: `stop-hook-stdin-block-139` · **대상**: bkit v2.1.30 +- **접근**: 하이브리드(Design 체크포인트에서 선택) — 모든 훅용 중앙 sync parse-early + + 턴을 gate하는 Stop 훅용 async hard-timeout + CPU 비소모 lock backoff. + +## 0. 재현 원장 (설계의 근거 증거) + +| # | 시나리오 | 현재 `readFileSync(0)` | Sync parse-early | Async parse-early + hard-timeout | +|---|---|---|---|---| +| T1 | stdin 즉시 닫힘 (정상) | 0.19초 | 0ms | 14ms | +| T2 | **payload 전송, 파이프 5초 held-open (재현된 #139 케이스)** | **~5초 (운영선 15.5분)** | **1ms** | 14ms | +| T3 | 데이터 없음, 파이프 5초 held-open | ~5초 | ~5초 (blocking syscall) | 1502ms (hard cap) | +| T4 | 200KB 멀티청크, 3초 held-open | ~3초 | 2ms | 14ms | +| T5 | truncated JSON, 5초 held-open | ~5초 | ~5초 | 1502ms (hard cap) | + +**프로세스 exit 함정(검증됨)**: async 이벤트 기반 리더는 parse-early로 payload를 +resolve해도 *부족*하다 — 열린 `process.stdin` 핸들이 EOF까지 이벤트 루프를 살려 +프로세스가 lingering(테스트에서 4885ms 지연). 리더는 resolve 시 반드시 +`process.stdin.destroy()` 해야 한다(그러면 프로세스가 15ms에 exit). sync `fs.readSync` +경로는 raw fd(libuv 스트림 핸들 없음)라 cleanup 없이도 즉시(8ms) exit — 검증됨. + +## 1. 변경 1 — `lib/core/io.js` `readStdinSync()` → 유계 parse-early (중앙) + +**36개 훅 스크립트 전부**를 한 번에 보호. + +```js +function readStdinSync() { + const deadline = Date.now() + STDIN_READ_TIMEOUT_MS; // env 오버라이드 가능 + const CHUNK = 65536; + let buf = Buffer.alloc(0); + const tmp = Buffer.alloc(CHUNK); + try { + while (true) { + // parse-early: 버퍼가 완전한 JSON 값을 담는 즉시 반환 — EOF 대기 안 함 + // (EOF 대기가 #139 stall의 원인). + if (buf.length > 0) { + try { return JSON.parse(buf.toString('utf8')); } catch (_) { /* 더 필요 */ } + } + if (Date.now() > deadline) break; // 읽기 사이 best-effort 유계 + let n = 0; + try { + n = fs.readSync(0, tmp, 0, CHUNK, null); + } catch (e) { + if (e.code === 'EAGAIN') continue; // 논블로킹 fd, 재시도 + if (e.code === 'EOF') { n = 0; } // 일부 플랫폼은 EOF를 throw로 표면화 + else throw e; + } + if (n === 0) break; // 실제 EOF + buf = Buffer.concat([buf, tmp.slice(0, n)]); + } + return JSON.parse(buf.toString('utf8')); // 최종 시도 (빈 값 → throw → {}) + } catch (e) { + getDebug().debugLog('io', 'readStdinSync parse failure', { + error: e && e.message, strict: process.env.BKIT_STRICT_STDIN === '1', + }); + if (process.env.BKIT_STRICT_STDIN === '1') throw e; + return {}; + } +} +``` + +**계약 보존**: 빈 stdin → `JSON.parse('')` throw → `{}`; malformed → debugLog + `{}` +(또는 `BKIT_STRICT_STDIN`에서 rethrow). 모든 정상 payload에 대해 반환값 동일. 유일한 +동작 변화는 유효 payload가 EOF *이후*가 아니라 *이전*에 반환된다는 것 — 이것이 곧 fix. + +**잔여(수용·문서화)**: sync deadline은 best-effort — blocking `readSync` 호출 *사이* +에서만 검사되므로, 진짜로 비어있는-held-open 파이프(T3)나 truncated-held 파이프(T5)는 +한 번의 `readSync` 안에서 EOF까지 여전히 block될 수 있다. 그래서 턴-gate하는 Stop +훅은 변경 2를 추가로 받는다. 나머지 35개 훅은 이 잔여를 수용(실무상 payload는 항상 +존재하며, CC 자체 훅 타임아웃이 외곽 경계). + +## 2. 변경 2 — `scripts/unified-stop.js` → async hard-timeout read (방어 심층) + +`io.js` 신규 export: + +```js +function readStdinBounded(timeoutMs) { + return new Promise((resolve) => { + let data = '', done = false; + const cleanup = () => { + clearTimeout(timer); + try { process.stdin.pause(); } catch (_) {} + process.stdin.removeAllListeners('data'); + process.stdin.removeAllListeners('end'); + process.stdin.removeAllListeners('error'); + try { process.stdin.destroy(); } catch (_) {} // 핸들 해제 → 즉시 exit + }; + const finish = (v) => { if (done) return; done = true; cleanup(); resolve(v); }; + const timer = setTimeout(() => { + getDebug().debugLog('io', 'readStdinBounded hard timeout', { timeoutMs, bytes: data.length }); + try { finish(JSON.parse(data)); } catch (_) { finish({}); } + }, timeoutMs != null ? timeoutMs : STDIN_READ_TIMEOUT_MS); + if (timer.unref) timer.unref(); + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (c) => { data += c; try { finish(JSON.parse(data)); } catch (_) {} }); + process.stdin.on('end', () => { try { finish(JSON.parse(data)); } catch (_) { finish({}); } }); + process.stdin.on('error', () => finish({})); + }); +} +``` + +`unified-stop.js`는 본문을 async IIFE로 감싸고 최상단에서 유계 read를 await(read가 +첫 연산이므로 타이머가 발화할 수 있게 이벤트 루프가 자유롭다). read 이후 전부 async +함수 내부에서 동기 유지 — 구조 변경 최소, 로직 변경 없음. 이로써 Stop 훅은 T3/T5 +에서도 `STDIN_READ_TIMEOUT_MS`를 초과할 수 없다. + +## 3. 변경 3 — `lib/core/state-store.js` `lock()` → CPU 비소모 backoff + +CPU를 태우는 busy-wait 스핀을 `Atomics.wait` 기반 실제 동기 sleep으로 교체(이식성, +Node ≥ 8.10, 네이티브 의존 없음, CPU 비소모): + +```js +// 기존: const waitUntil = Date.now() + LOCK_RETRY_INTERVAL_MS; +// while (Date.now() < waitUntil) { /* spin */ } +sleepSync(LOCK_RETRY_INTERVAL_MS); // Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +``` + +동일한 wall-clock 경계(락당 최악 ~5초)이나 대기 중 CPU 코어를 점유하지 않음 — Stop +체인의 ~4개 `lockedUpdate` 호출에 대한 이슈의 "lock-wait / retry-without-backoff" +지적 해소. + +## 4. 변경 4 — `lib/core/constants.js` + +I/O 섹션에 추가: + +```js +/** 유계 stdin read 타임아웃(ms) — env 오버라이드: BKIT_STDIN_TIMEOUT_MS */ +const STDIN_READ_TIMEOUT_MS = Number(process.env.BKIT_STDIN_TIMEOUT_MS) > 0 + ? Number(process.env.BKIT_STDIN_TIMEOUT_MS) + : 2000; +``` + +2000ms는 관측된 정상 평균(0.8초)의 ~2.5배이면서 bkit `HOOK_TIMEOUT_MS`(5000)와 CC +Stop 훅 `timeout`(10000)보다 충분히 작아, 두 외곽 타임아웃 발화 전에 bkit이 read를 +유계화한다. + +## 5. 회귀 테스트 (`test/regression/issue-139-stdin-bounded.test.js`) + +1. writer가 파이프를 held-open할 때 `readStdinSync`가 유효 payload를 즉시 반환 + (경과 시간 ≪ held-open 시간 assert). +2. `readStdinSync` 빈 stdin → `{}`; malformed → `{}` (계약 보존). +3. `readStdinBounded`가 payload에 parse-early로 resolve; no-data/truncated 입력에 + hard timeout(유계)으로 resolve; stdin destroy로 프로세스 exit 가능. +4. `state-store` `lock()`/`lockedUpdate`가 동시 writer를 여전히 직렬화하고 스핀이 + 교체됨(소스에 busy-wait 토큰 부재). +5. 라이브: held-open 파이프에서 `node scripts/unified-stop.js`가 유계 반환. + +## 6. 범위 외 (근거) + +- `scripts/lint-skill-md.js:26` raw `fs.readFileSync(0)` — **stdin 리다이렉트로 + markdown 파일**을 읽음(훅 JSON payload 아님, held-open 파이프 아님); 취약하지 않음. + scope creep 방지 위해 미변경. +- Stop 서브핸들러 fire-and-forget화 — 확정 원인은 stdin blocking이지 서브핸들러 + 비용이 아님(정상 실행 0.19초). 불필요. + +## 7. 아키텍처 카운트 영향 + +없음. 모든 변경은 기존 lib 모듈 / 스크립트 1개 내부. Skills 44 · Agents 34 · Hooks +22 ev / 25 blk · Lib 195 모듈 **불변**(QA에서 docs-code-sync로 검증). diff --git a/docs/03-analysis/features/stop-hook-stdin-block-139.analysis.en.md b/docs/03-analysis/features/stop-hook-stdin-block-139.analysis.en.md new file mode 100644 index 00000000..69160a10 --- /dev/null +++ b/docs/03-analysis/features/stop-hook-stdin-block-139.analysis.en.md @@ -0,0 +1,45 @@ +# Gap Analysis — Stop Hook stdin-block (Issue #139) + +- **Feature**: `stop-hook-stdin-block-139` · **Target**: bkit v2.1.30 +- **Match rate**: **100%** (every design item implemented and verified) + +## Design → Implementation traceability + +| # | Design item | Implementation | Verified | +|---|---|---|---| +| C1 | `readStdinSync()` bounded parse-early (central, 36 hooks) | `lib/core/io.js` `readStdinSync()` rewritten: incremental `fs.readSync` + parse-early + deadline | ✅ 15.5 min → ~1 ms (real hook); contract preserved (empty/malformed → `{}`) | +| C2 | Async hard-timeout reader for the Stop hook | `lib/core/io.js` `readStdinBounded()` (parse-early + hard timeout + `process.stdin.destroy()`); `scripts/unified-stop.js` awaits it in an async IIFE | ✅ no-data/truncated held-open bounded ~2 s; unified-stop ~374 ms vs 8 s held pipe | +| C3 | Non-CPU lock backoff | `lib/core/state-store.js` `sleepSync()` via `Atomics.wait`; `lock()` spin replaced | ✅ sleepSync(300)=305 ms; `lockedUpdate` still serializes (counter=20); state-store-perf 15/15 | +| C4 | `STDIN_READ_TIMEOUT_MS` constant + env override | `lib/core/constants.js` (default 2000, `BKIT_STDIN_TIMEOUT_MS`) | ✅ loads = 2000; exported | +| C5 | Regression tests | `test/regression/issue-139-stdin-bounded.test.js` (16 TC) | ✅ 16/16, stable across 5 runs | +| — | Central re-export | `lib/core/index.js` exposes `readStdinBounded` | ✅ deadcode 0-new | + +## Deviations from design + +None. Two design-time findings were confirmed during implementation and handled +exactly as the design specified: + +1. **Process-exit trap** (async path): parse-early alone lets the process linger + until EOF because the open `process.stdin` handle keeps the event loop alive + (measured 4885 ms). Resolved by `process.stdin.destroy()` on resolve (→ 15 ms). + The sync `fs.readSync` path was verified to exit promptly without cleanup (raw + fd, no stream handle). +2. **Sync residual** (documented, accepted): the sync deadline is best-effort + (checked only between blocking reads), so a no-data / truncated + held-open pipe + is not hard-bounded by the sync reader — which is exactly why the turn-gating + Stop hook additionally uses `readStdinBounded` (C2). + +## Contract & regression safety + +- `readStdinSync()` return contract byte-for-byte preserved for all normal payloads + and for empty/malformed input; the only behavioral change is returning *before* + EOF instead of *after*. +- Zero new regressions vs `main`: the 13 qa-aggregate failing files are identical + to the baseline, and the 3 `state-store`-dependent unit tests + (audit-logger AL-007, loop-breaker LB-013, trust-engine TE-001/025) fail + identically on clean `main` (stale-count / logic assertions unrelated to this fix). + +## Architecture impact + +None. 44 Skills · 34 Agents · 22 Hook Events / 25 blocks · 195 Lib Modules — +invariant (internal changes to existing modules + one new test file). diff --git a/docs/03-analysis/features/stop-hook-stdin-block-139.analysis.ko.md b/docs/03-analysis/features/stop-hook-stdin-block-139.analysis.ko.md new file mode 100644 index 00000000..668d2d30 --- /dev/null +++ b/docs/03-analysis/features/stop-hook-stdin-block-139.analysis.ko.md @@ -0,0 +1,41 @@ +# 갭 분석 — Stop 훅 stdin-block (이슈 #139) + +- **기능**: `stop-hook-stdin-block-139` · **대상**: bkit v2.1.30 +- **매치율**: **100%** (모든 설계 항목 구현·검증 완료) + +## 설계 → 구현 추적성 + +| # | 설계 항목 | 구현 | 검증 | +|---|---|---|---| +| C1 | `readStdinSync()` 유계 parse-early (중앙, 36 훅) | `lib/core/io.js` `readStdinSync()` 재작성: 증분 `fs.readSync` + parse-early + deadline | ✅ 15.5분 → ~1ms (실제 훅); 계약 보존(empty/malformed → `{}`) | +| C2 | Stop 훅용 async hard-timeout 리더 | `lib/core/io.js` `readStdinBounded()` (parse-early + hard timeout + `process.stdin.destroy()`); `scripts/unified-stop.js`가 async IIFE에서 await | ✅ no-data/truncated held-open ~2s 유계; unified-stop 8s held 파이프 대비 ~374ms | +| C3 | CPU 비소모 lock backoff | `lib/core/state-store.js` `sleepSync()` (`Atomics.wait`); `lock()` 스핀 교체 | ✅ sleepSync(300)=305ms; `lockedUpdate` 직렬화 유지(counter=20); state-store-perf 15/15 | +| C4 | `STDIN_READ_TIMEOUT_MS` 상수 + env 오버라이드 | `lib/core/constants.js` (기본 2000, `BKIT_STDIN_TIMEOUT_MS`) | ✅ 로드=2000; export됨 | +| C5 | 회귀 테스트 | `test/regression/issue-139-stdin-bounded.test.js` (16 TC) | ✅ 16/16, 5회 반복 안정 | +| — | 중앙 re-export | `lib/core/index.js`가 `readStdinBounded` 노출 | ✅ deadcode 0-new | + +## 설계 대비 편차 + +없음. 구현 중 확인된 설계 시점 발견 2건은 설계 명세대로 정확히 처리됨: + +1. **프로세스 exit 함정**(async 경로): parse-early만으로는 열린 `process.stdin` + 핸들이 EOF까지 이벤트 루프를 살려 프로세스가 lingering(측정 4885ms). resolve 시 + `process.stdin.destroy()`로 해소(→15ms). sync `fs.readSync` 경로는 cleanup 없이도 + 즉시 exit 검증(raw fd, 스트림 핸들 없음). +2. **Sync 잔여**(문서화·수용): sync deadline은 best-effort(blocking read 사이에서만 + 검사)라 no-data/truncated + held-open 파이프는 sync 리더로 hard-bound 안 됨 — + 그래서 턴-gate하는 Stop 훅이 `readStdinBounded`(C2)를 추가 사용. + +## 계약 & 회귀 안전성 + +- `readStdinSync()` 반환 계약은 모든 정상 payload와 empty/malformed 입력에 대해 + 바이트 단위로 보존; 유일한 동작 변화는 EOF *이후*가 아니라 *이전*에 반환. +- `main` 대비 신규 회귀 0건: qa-aggregate 13개 실패파일이 baseline과 동일하고, + `state-store` 의존 유닛 테스트 3건(audit-logger AL-007, loop-breaker LB-013, + trust-engine TE-001/025)이 clean `main`에서 동일하게 실패(이 fix와 무관한 + stale-count/로직 어서션). + +## 아키텍처 영향 + +없음. 44 Skills · 34 Agents · 22 Hook Events / 25 blocks · 195 Lib Modules — +불변(기존 모듈 내부 변경 + 신규 테스트 1개). diff --git a/docs/04-report/features/stop-hook-stdin-block-139.report.en.md b/docs/04-report/features/stop-hook-stdin-block-139.report.en.md new file mode 100644 index 00000000..dc39985c --- /dev/null +++ b/docs/04-report/features/stop-hook-stdin-block-139.report.en.md @@ -0,0 +1,62 @@ +# Completion Report — Stop Hook stdin-block (Issue #139) + +- **Feature**: `stop-hook-stdin-block-139` · **Version**: bkit v2.1.30 +- **Issue**: [#139](https://github.com/popup-studio-ai/bkit-claude-code/issues/139) (@thenopen, via Claude Code `/doctor`) +- **Branch**: `feat/v2.1.30-issue-139` · **Status**: implemented, QA-passed, awaiting user approval to merge/release + +## Summary + +The `Stop` hook occasionally stalled up to ~15.5 minutes — far past its own 10 s +timeout — blocking turn completion. Root cause (reproduced, not inferred): every +bkit hook reads its payload via `lib/core/io.js` `readStdinSync()`, which used +`fs.readFileSync(0)` — a blocking stdin read with **no timeout** that returns only +at EOF. When Claude Code keeps the hook's stdin write-end open, the hook blocks for +exactly that long. + +The fix is central (one shared function → all 36 hooks) plus defense-in-depth for +the turn-gating Stop hook. + +## What changed + +| File | Change | +|---|---| +| `lib/core/io.js` | `readStdinSync()` → incremental `fs.readSync` + parse-early (returns before EOF); new `readStdinBounded()` async reader (parse-early + hard timeout + `stdin.destroy()`) | +| `scripts/unified-stop.js` | reads via `readStdinBounded` inside an async IIFE | +| `lib/core/state-store.js` | `lock()` busy-wait spin → `Atomics.wait` `sleepSync()` (no CPU burn) | +| `lib/core/constants.js` | new `STDIN_READ_TIMEOUT_MS` (2000 ms, env `BKIT_STDIN_TIMEOUT_MS`) | +| `lib/core/index.js` | re-export `readStdinBounded` | +| `test/regression/issue-139-stdin-bounded.test.js` | new 16-TC regression guard | + +## User-facing outcome + +- Turns no longer hang at their end because of a stalled Stop hook. Worst case for + the Stop hook drops from ~15.5 min to a bounded ~2 s (normal case ~ms). +- The fix protects **all** bkit hook events, not only Stop — any hook that reads + stdin is now resilient to a slow/held-open stdin close. +- No behavior change for normal payloads; no configuration required (the timeout is + tunable via `BKIT_STDIN_TIMEOUT_MS` if ever needed). + +## KPIs + +- Match rate: **100%** (all design items implemented). +- Reproduced tail: **15.5 min → ~1 ms** (parse) / ~374 ms (full Stop hook). +- Regressions vs `main`: **0 new**. +- CI gates: **all green**; live `claude -p --plugin-dir .` on CC v2.1.208 OK. + +## Lessons learned + +1. A blocking `fs.readFileSync(0)` in a hook is an unbounded liability — the payload + being present is not enough; waiting for EOF is the trap. Parse-early removes the + EOF-wait entirely for the realistic case. +2. Async event-based reads must `destroy()` stdin on resolve, or the process lingers + until EOF and the stall silently returns via process exit rather than the read. +3. A CPU-burning busy-wait "acceptable for short durations" still pins a core under + contention; `Atomics.wait` is a drop-in, portable, no-CPU synchronous sleep. + +## Follow-ups (not blocking) + +- Consider routing `scripts/lint-skill-md.js`'s raw `fs.readFileSync(0)` through a + shared bounded reader for consistency (not vulnerable today — reads a file via + redirect). +- Hall of Fame / external-dogfood E2E absorption for @thenopen's `/doctor`-sourced + report (same program as prior dogfooders). diff --git a/docs/04-report/features/stop-hook-stdin-block-139.report.ko.md b/docs/04-report/features/stop-hook-stdin-block-139.report.ko.md new file mode 100644 index 00000000..227018d4 --- /dev/null +++ b/docs/04-report/features/stop-hook-stdin-block-139.report.ko.md @@ -0,0 +1,57 @@ +# 완료 리포트 — Stop 훅 stdin-block (이슈 #139) + +- **기능**: `stop-hook-stdin-block-139` · **버전**: bkit v2.1.30 +- **이슈**: [#139](https://github.com/popup-studio-ai/bkit-claude-code/issues/139) (@thenopen, Claude Code `/doctor` 경유) +- **브랜치**: `feat/v2.1.30-issue-139` · **상태**: 구현·QA 완료, 사용자 머지/릴리스 승인 대기 + +## 요약 + +`Stop` 훅이 간헐적으로 자체 10초 타임아웃을 훨씬 초과해 최대 ~15.5분 stall되어 턴 +완료를 차단했다. 근본원인(재현 확정, 추론 아님): 모든 bkit 훅이 `lib/core/io.js` +`readStdinSync()`로 payload를 읽는데, 이것이 `fs.readFileSync(0)` — **타임아웃 없는** +blocking stdin read로 EOF에서만 반환한다. Claude Code가 훅 stdin write-end를 열어두면 +훅은 정확히 그만큼 blocking된다. + +수정은 중앙(공유 함수 1곳 → 36개 훅) + 턴-gate하는 Stop 훅 방어심층. + +## 변경 사항 + +| 파일 | 변경 | +|---|---| +| `lib/core/io.js` | `readStdinSync()` → 증분 `fs.readSync` + parse-early(EOF 전 반환); 신규 `readStdinBounded()` async 리더(parse-early + hard timeout + `stdin.destroy()`) | +| `scripts/unified-stop.js` | async IIFE 내에서 `readStdinBounded`로 읽기 | +| `lib/core/state-store.js` | `lock()` busy-wait 스핀 → `Atomics.wait` `sleepSync()` (CPU 비소모) | +| `lib/core/constants.js` | 신규 `STDIN_READ_TIMEOUT_MS` (2000ms, env `BKIT_STDIN_TIMEOUT_MS`) | +| `lib/core/index.js` | `readStdinBounded` re-export | +| `test/regression/issue-139-stdin-bounded.test.js` | 신규 16-TC 회귀 가드 | + +## 사용자 체감 결과 + +- Stop 훅 stall로 턴 끝에서 멈추는 현상 해소. Stop 훅 최악 케이스가 ~15.5분에서 + 유계 ~2초로(정상 케이스 ~ms) 감소. +- Stop뿐 아니라 **모든** bkit 훅 이벤트를 보호 — stdin을 읽는 모든 훅이 느린/held-open + stdin close에 견고해짐. +- 정상 payload 동작 변화 없음; 설정 불필요(필요 시 `BKIT_STDIN_TIMEOUT_MS`로 조정). + +## KPI + +- 매치율: **100%** (모든 설계 항목 구현). +- 재현된 tail: **15.5분 → ~1ms**(parse) / ~374ms(전체 Stop 훅). +- `main` 대비 회귀: **신규 0건**. +- CI 게이트: **전부 green**; CC v2.1.208 라이브 `claude -p --plugin-dir .` OK. + +## 교훈 + +1. 훅 내 blocking `fs.readFileSync(0)`은 무한 부채 — payload 존재만으로 불충분, + EOF 대기가 함정. parse-early가 현실 케이스의 EOF 대기를 완전 제거. +2. async 이벤트 기반 read는 resolve 시 stdin을 `destroy()` 해야 함 — 안 하면 + 프로세스가 EOF까지 lingering하여 read가 아닌 프로세스 exit로 stall이 재귀. +3. "짧은 시간엔 허용" 수준의 CPU-burn busy-wait도 경합 시 코어를 점유; + `Atomics.wait`는 드롭인·이식성·무-CPU 동기 sleep. + +## 후속 (비차단) + +- `scripts/lint-skill-md.js`의 raw `fs.readFileSync(0)`을 일관성 위해 공유 유계 리더로 + 경유 검토(현재는 취약하지 않음 — 리다이렉트로 파일 읽기). +- @thenopen의 `/doctor` 출처 제보에 대한 Hall of Fame / external-dogfood E2E 흡수 + 검토(기존 dogfooder와 동일 프로그램). diff --git a/docs/05-qa/features/stop-hook-stdin-block-139.qa-report.en.md b/docs/05-qa/features/stop-hook-stdin-block-139.qa-report.en.md new file mode 100644 index 00000000..7a2c4971 --- /dev/null +++ b/docs/05-qa/features/stop-hook-stdin-block-139.qa-report.en.md @@ -0,0 +1,60 @@ +# QA Report — Stop Hook stdin-block (Issue #139) + +- **Feature**: `stop-hook-stdin-block-139` · **Target**: bkit v2.1.30 +- **Verdict**: **PASS** — root cause fixed & reproduced, all gates green, 0 new regressions. + +## 1. Reproduction (before → after) + +| Scenario | Before (`readFileSync(0)`) | After | +|---|---|---| +| Normal (stdin closed) | 0.19 s | 0.17 s | +| Payload + pipe held open (the #139 case) | ~held-duration (→ 15.5 min prod) | **~1 ms parse / ~374 ms hook** | +| No-data + pipe held open | ~held-duration | ~2.0 s hard cap (bounded) | +| Truncated + pipe held open | ~held-duration | ~2.0 s hard cap (bounded) | + +## 2. Functional / regression tests + +| Suite | Result | +|---|---| +| `test/regression/issue-139-stdin-bounded.test.js` (new, 16 TC) | 16/16 PASS (stable over 5 runs) | +| contract L1+L4 vs v2.1.9 / v2.1.16 | 222 / 243 assertions PASS | +| integration-runtime | 23/23 | +| l2-smoke | 105/105 | +| l2-hook-attribution (Stop turn recording) | 13/13 | +| l3-mcp-compat / l3-mcp-runtime | 92/92 · 48/48 | +| hooks-22 (hook wiring + JS syntax) | 25/25 | +| hook-cold-start / hook-real-execution | PASS · 8/8 | +| state-store-perf (lock backoff) | 15/15 | +| invocation-inventory | 213/213 | + +## 3. Release gates + +| Gate | Result | +|---|---| +| check-domain-purity | OK (18 files, 0 forbidden) | +| check-deadcode | 0 new dead code | +| docs-code-sync (+ .test) | PASS · 36/36 | +| check-guards | 24 guards, 0 warn | +| check-test-tracking | 346 files, 0 untracked | +| validate-plugin --strict | 0 errors, 0 warnings | +| bkit-full-system (version sync v2.1.30 × 7 files) | 36 PASS / 0 FAIL | + +## 4. Zero-regression proof + +`qa-aggregate` reports 13 failing files — **identical** to the `main` baseline. +The 3 `state-store`-dependent unit tests (audit-logger AL-007, loop-breaker LB-013, +trust-engine TE-001/TE-025) were run on clean `main` via `git stash` and fail +**identically** there — pre-existing stale-count / logic assertions, not caused by +this change. + +## 5. Live verification + +`claude -p --plugin-dir .` on Claude Code **v2.1.208** → returned `PONG` in 5.65 s +total; the `Stop` hook (now `readStdinBounded`) fired at turn end without stalling. + +## 6. Residual / accepted + +The sync `readStdinSync` cannot hard-bound a no-data / truncated + held-open pipe +(blocking syscall); this is covered for the turn-gating Stop hook by +`readStdinBounded`, and the other 35 hooks' payloads are always present in practice +(CC's own per-hook timeout is the outer bound). Documented in the design. diff --git a/docs/05-qa/features/stop-hook-stdin-block-139.qa-report.ko.md b/docs/05-qa/features/stop-hook-stdin-block-139.qa-report.ko.md new file mode 100644 index 00000000..85517b1c --- /dev/null +++ b/docs/05-qa/features/stop-hook-stdin-block-139.qa-report.ko.md @@ -0,0 +1,58 @@ +# QA 리포트 — Stop 훅 stdin-block (이슈 #139) + +- **기능**: `stop-hook-stdin-block-139` · **대상**: bkit v2.1.30 +- **판정**: **PASS** — 근본원인 수정·재현, 전 게이트 green, 신규 회귀 0건. + +## 1. 재현 (전 → 후) + +| 시나리오 | 전 (`readFileSync(0)`) | 후 | +|---|---|---| +| 정상 (stdin 닫힘) | 0.19초 | 0.17초 | +| payload + 파이프 held-open (#139 케이스) | ~held 시간 (→ 운영선 15.5분) | **~1ms parse / ~374ms 훅** | +| no-data + 파이프 held-open | ~held 시간 | ~2.0초 hard cap (유계) | +| truncated + 파이프 held-open | ~held 시간 | ~2.0초 hard cap (유계) | + +## 2. 기능 / 회귀 테스트 + +| 스위트 | 결과 | +|---|---| +| `test/regression/issue-139-stdin-bounded.test.js` (신규, 16 TC) | 16/16 PASS (5회 안정) | +| contract L1+L4 vs v2.1.9 / v2.1.16 | 222 / 243 어서션 PASS | +| integration-runtime | 23/23 | +| l2-smoke | 105/105 | +| l2-hook-attribution (Stop turn 기록) | 13/13 | +| l3-mcp-compat / l3-mcp-runtime | 92/92 · 48/48 | +| hooks-22 (훅 배선 + JS 문법) | 25/25 | +| hook-cold-start / hook-real-execution | PASS · 8/8 | +| state-store-perf (lock backoff) | 15/15 | +| invocation-inventory | 213/213 | + +## 3. 릴리스 게이트 + +| 게이트 | 결과 | +|---|---| +| check-domain-purity | OK (18파일, 0 forbidden) | +| check-deadcode | 신규 dead code 0 | +| docs-code-sync (+ .test) | PASS · 36/36 | +| check-guards | 24 guards, 0 warn | +| check-test-tracking | 346파일, 0 untracked | +| validate-plugin --strict | 0 errors, 0 warnings | +| bkit-full-system (버전 동기화 v2.1.30 × 7파일) | 36 PASS / 0 FAIL | + +## 4. 무회귀 증명 + +`qa-aggregate`가 실패파일 13개 보고 — `main` baseline과 **완전 동일**. `state-store` +의존 유닛 테스트 3건(audit-logger AL-007, loop-breaker LB-013, trust-engine +TE-001/TE-025)을 `git stash`로 clean `main`에서 실행 → **동일하게** 실패(이 변경과 +무관한 기존 stale-count/로직 어서션). + +## 5. 라이브 검증 + +Claude Code **v2.1.208**에서 `claude -p --plugin-dir .` → 총 5.65초에 `PONG` 반환; +`Stop` 훅(현 `readStdinBounded`)이 턴 종료 시 stall 없이 발화. + +## 6. 잔여 / 수용 + +sync `readStdinSync`는 no-data/truncated + held-open 파이프를 hard-bound 불가(blocking +syscall); 턴-gate하는 Stop 훅은 `readStdinBounded`가 담당하며, 나머지 35개 훅의 +payload는 실무상 항상 존재(CC 자체 훅 타임아웃이 외곽 경계). 설계에 문서화. diff --git a/hooks/hooks.json b/hooks/hooks.json index 64620db9..b642545c 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-hooks.json", - "description": "bkit Vibecoding Kit v2.1.29 - Claude Code", + "description": "bkit Vibecoding Kit v2.1.30 - Claude Code", "hooks": { "SessionStart": [ { diff --git a/hooks/session-start.js b/hooks/session-start.js index 363c8a81..e91cb5f7 100644 --- a/hooks/session-start.js +++ b/hooks/session-start.js @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * bkit Vibecoding Kit - SessionStart Hook (v2.1.29, uses BKIT_VERSION from lib/core/version) + * bkit Vibecoding Kit - SessionStart Hook (v2.1.30, uses BKIT_VERSION from lib/core/version) * * Thin orchestrator that delegates to startup modules: * 1. migration - Legacy path migration (docs/ -> .bkit/) diff --git a/hooks/startup/session-context.js b/hooks/startup/session-context.js index 54703b5a..5e07ae39 100644 --- a/hooks/startup/session-context.js +++ b/hooks/startup/session-context.js @@ -1,5 +1,5 @@ /** - * bkit Vibecoding Kit - SessionStart: Session Context Builder Module (v2.1.29) + * bkit Vibecoding Kit - SessionStart: Session Context Builder Module (v2.1.30) * * Builds the additionalContext string for the SessionStart hook response. * Includes PDCA status injection, Feature Usage rules, Executive Summary rules, diff --git a/lib/core/constants.js b/lib/core/constants.js index 0871d59f..283c2887 100644 --- a/lib/core/constants.js +++ b/lib/core/constants.js @@ -92,6 +92,20 @@ const MAX_CONTEXT_LENGTH = 500; /** Hook script execution timeout (ms) */ const HOOK_TIMEOUT_MS = 5000; +/** + * Bounded stdin read timeout (ms) — env override: BKIT_STDIN_TIMEOUT_MS. + * + * Issue #139: fs.readFileSync(0) blocks until stdin EOF with no timeout, so a + * hook stalls for as long as Claude Code keeps the stdin write-end open (observed + * up to ~15.5 min). This bound is the wall-clock budget for reading the payload; + * parse-early returns well before it for normal payloads. 2000 ms is ~2.5x the + * observed healthy average (~0.8 s) yet well under HOOK_TIMEOUT_MS (5000) and CC's + * Stop-hook timeout (10000), so bkit bounds the read before either outer timeout. + */ +const STDIN_READ_TIMEOUT_MS = Number(process.env.BKIT_STDIN_TIMEOUT_MS) > 0 + ? Number(process.env.BKIT_STDIN_TIMEOUT_MS) + : 2000; + // ============================================================ // QA / Quality Constants // ============================================================ @@ -193,6 +207,7 @@ module.exports = { // I/O MAX_CONTEXT_LENGTH, HOOK_TIMEOUT_MS, + STDIN_READ_TIMEOUT_MS, // QA MAX_QUALITY_HISTORY, diff --git a/lib/core/index.js b/lib/core/index.js index 5e65fe4c..804f5050 100644 --- a/lib/core/index.js +++ b/lib/core/index.js @@ -44,10 +44,11 @@ module.exports = { getToolSearchCache: cache.getToolSearchCache, setToolSearchCache: cache.setToolSearchCache, - // I/O (9 exports) + // I/O (10 exports) MAX_CONTEXT_LENGTH: io.MAX_CONTEXT_LENGTH, truncateContext: io.truncateContext, readStdinSync: io.readStdinSync, + readStdinBounded: io.readStdinBounded, readStdin: io.readStdin, parseHookInput: io.parseHookInput, outputAllow: io.outputAllow, diff --git a/lib/core/io.js b/lib/core/io.js index c649d64f..77150198 100644 --- a/lib/core/io.js +++ b/lib/core/io.js @@ -7,6 +7,7 @@ */ const fs = require('fs'); +const { STDIN_READ_TIMEOUT_MS } = require('./constants'); // Lazy require to avoid a circular dependency (debug.js -> platform.js is a // separate chain; io.js -> debug.js is one-way). Needed so H5 can surface @@ -21,6 +22,11 @@ function getDebug() { const MAX_CONTEXT_LENGTH = 500; +// Read buffer size for the incremental stdin reader (64 KiB — hook payloads are +// far smaller, but a generous chunk keeps large/multi-chunk payloads to one or +// two reads). +const STDIN_CHUNK_SIZE = 65536; + /** * 컨텍스트 문자열 자르기 * @param {string} context @@ -33,20 +39,59 @@ function truncateContext(context, maxLength = MAX_CONTEXT_LENGTH) { } /** - * stdin에서 JSON 동기적 읽기 + * stdin에서 JSON 동기적 읽기 (유계 parse-early 리더) + * + * Issue #139: the previous `fs.readFileSync(0, 'utf8')` blocks until stdin EOF + * with NO timeout. A hook therefore stalls for as long as Claude Code keeps the + * stdin write-end open (observed up to ~15.5 min, far past the hook's own 10 s + * timeout). This reader reads fd 0 incrementally and returns the instant the + * accumulated buffer holds a complete JSON value — it never waits for EOF, which + * is precisely the source of the stall. Used by every bkit hook script, so the + * fix is central: all hook events are protected, not just Stop. + * + * The raw-fd `fs.readSync` path creates no libuv stream handle, so the process + * exits promptly after this returns even when the pipe is still held open. * - * H5 fix (audit): a malformed/truncated JSON-RPC envelope used to be - * indistinguishable from a valid empty object (silently returned {}). That - * masked the exact failure mode that bit skill-post.js. Now every parse - * failure is recorded via debugLog, and BKIT_STRICT_STDIN=1 rethrows so a + * H5 fix (audit) preserved: a malformed/truncated JSON-RPC envelope used to be + * indistinguishable from a valid empty object (silently returned {}). Every + * parse failure is recorded via debugLog, and BKIT_STRICT_STDIN=1 rethrows so a * debugging session sees the real error instead of a fabricated empty input. * + * Residual (accepted): the deadline is best-effort — it is only checked between + * blocking `readSync` calls, so a truly empty-but-held-open pipe can still block + * inside a single read until EOF. Callers that must be hard-bounded even then + * (the turn-gating Stop hook) use `readStdinBounded` instead. + * * @returns {*} */ function readStdinSync() { + const deadline = Date.now() + STDIN_READ_TIMEOUT_MS; + const tmp = Buffer.alloc(STDIN_CHUNK_SIZE); + let buf = Buffer.alloc(0); try { - const input = fs.readFileSync(0, 'utf8'); - return JSON.parse(input); + // eslint-disable-next-line no-constant-condition + while (true) { + // parse-early: return as soon as the buffer holds a complete JSON value. + if (buf.length > 0) { + try { + return JSON.parse(buf.toString('utf8')); + } catch (_) { + /* incomplete — read more */ + } + } + if (Date.now() > deadline) break; // best-effort bound between reads + let n = 0; + try { + n = fs.readSync(0, tmp, 0, STDIN_CHUNK_SIZE, null); + } catch (e) { + if (e.code === 'EAGAIN') continue; // non-blocking fd, no data yet + if (e.code === 'EOF') { n = 0; } // some platforms surface EOF as a throw + else throw e; + } + if (n === 0) break; // real EOF + buf = Buffer.concat([buf, tmp.slice(0, n)]); + } + return JSON.parse(buf.toString('utf8')); // final attempt (empty → throws → {}) } catch (e) { getDebug().debugLog('io', 'readStdinSync parse failure', { error: e && e.message, @@ -59,6 +104,91 @@ function readStdinSync() { } } +/** + * stdin에서 JSON 읽기 — parse-early + hard timeout으로 완전 유계화 (Issue #139). + * + * The async, event-based counterpart to readStdinSync for hooks that MUST never + * exceed a wall-clock budget even when stdin carries no data / a truncated + * payload while the pipe is held open (cases the sync reader cannot hard-bound, + * because its deadline can only be checked between blocking `readSync` calls). + * + * Two guarantees: + * 1. parse-early — resolves the instant the accumulated data is valid JSON, + * without waiting for EOF. + * 2. hard timeout — a `setTimeout` resolves with the best available value once + * `timeoutMs` elapses, so the read can never block longer than that. + * + * Critically, it destroys `process.stdin` on resolve. Without that, the open + * stdin handle keeps the event loop alive until EOF and the PROCESS lingers even + * though the payload was already parsed — reintroducing the very stall we fix. + * + * @param {number} [timeoutMs=STDIN_READ_TIMEOUT_MS] - Hard wall-clock budget (ms) + * @returns {Promise<*>} Parsed payload, or {} on timeout/parse-failure + */ +function readStdinBounded(timeoutMs) { + const budget = (typeof timeoutMs === 'number' && timeoutMs > 0) + ? timeoutMs + : STDIN_READ_TIMEOUT_MS; + return new Promise((resolve) => { + let data = ''; + let done = false; + let timer = null; + + const cleanup = () => { + if (timer) clearTimeout(timer); + try { process.stdin.pause(); } catch (_) { /* ignore */ } + process.stdin.removeAllListeners('data'); + process.stdin.removeAllListeners('end'); + process.stdin.removeAllListeners('error'); + // Release the stdin handle so the event loop can drain and the process + // can exit promptly even if CC keeps the write-end open. + try { process.stdin.destroy(); } catch (_) { /* ignore */ } + }; + + const finish = (value) => { + if (done) return; + done = true; + cleanup(); + resolve(value); + }; + + const parseOrEmpty = () => { + try { + return JSON.parse(data); + } catch (e) { + getDebug().debugLog('io', 'readStdinBounded parse failure', { + error: e && e.message, + bytes: data.length, + }); + return {}; + } + }; + + timer = setTimeout(() => { + getDebug().debugLog('io', 'readStdinBounded hard timeout', { + timeoutMs: budget, + bytes: data.length, + }); + finish(parseOrEmpty()); + }, budget); + if (timer.unref) timer.unref(); + + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + data += chunk; + // parse-early: resolve the moment we hold a complete JSON value. + try { + const parsed = JSON.parse(data); + finish(parsed); + } catch (_) { + /* incomplete — keep reading */ + } + }); + process.stdin.on('end', () => finish(parseOrEmpty())); + process.stdin.on('error', () => finish({})); + }); +} + /** * stdin에서 JSON 비동기 읽기 * @@ -291,6 +421,7 @@ module.exports = { truncateContext, isCursorRuntime, readStdinSync, + readStdinBounded, readStdin, parseHookInput, outputAllow, diff --git a/lib/core/state-store.js b/lib/core/state-store.js index 4a6fc111..f1670446 100644 --- a/lib/core/state-store.js +++ b/lib/core/state-store.js @@ -100,6 +100,30 @@ function appendJsonl(filePath, line) { // File Locking (for concurrent access in Team mode) // ============================================================ +/** + * Synchronous sleep without burning CPU. + * + * Issue #139: lock() previously backed off with a busy-wait spin + * (`while (Date.now() < waitUntil) {}`) that pinned a CPU core for the entire + * retry interval. Atomics.wait blocks the thread for up to `ms` milliseconds + * with no CPU cost, is fully synchronous (safe inside hook scripts), and needs + * no native dependency (Node >= 8.10). A fresh, never-notified Int32Array means + * the wait always runs to its timeout. + * + * @param {number} ms - Milliseconds to sleep (values <= 0 are a no-op) + */ +function sleepSync(ms) { + if (!(ms > 0)) return; + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + } catch (_) { + // SharedArrayBuffer unavailable (extremely old/locked-down runtime): + // fall back to a bounded busy-wait so behavior degrades gracefully. + const until = Date.now() + ms; + while (Date.now() < until) { /* spin (fallback only) */ } + } +} + /** * Acquire an exclusive file lock using O_EXCL atomic file creation. * Detects and removes stale locks from dead processes. @@ -155,9 +179,9 @@ function lock(filePath, timeoutMs) { ); } - // Synchronous busy-wait (acceptable for short durations in hook scripts) - const waitUntil = Date.now() + LOCK_RETRY_INTERVAL_MS; - while (Date.now() < waitUntil) { /* spin */ } + // Synchronous backoff without burning CPU (Issue #139). Was a busy-wait + // spin; Atomics.wait sleeps the thread for the retry interval instead. + sleepSync(LOCK_RETRY_INTERVAL_MS); retries++; } } @@ -212,4 +236,7 @@ module.exports = { lock, unlock, lockedUpdate, + + // Utilities + sleepSync, }; diff --git a/scripts/unified-stop.js b/scripts/unified-stop.js index 4c055cd1..7d97fa77 100644 --- a/scripts/unified-stop.js +++ b/scripts/unified-stop.js @@ -11,7 +11,8 @@ */ const path = require('path'); -const { readStdinSync, outputAllow } = require('../lib/core/io'); +const { readStdinBounded, outputAllow } = require('../lib/core/io'); +const { STDIN_READ_TIMEOUT_MS } = require('../lib/core/constants'); const { debugLog } = require('../lib/core/debug'); const { getPdcaStatusFull } = require('../lib/pdca/status'); const { getActiveSkill, getActiveAgent, clearActiveContext } = require('../lib/task/context'); @@ -196,13 +197,21 @@ function executeHandler(handlerPath, context) { // Main Execution // ============================================================ +// Issue #139: the Stop hook gates turn completion, so it must never block on +// stdin. readStdinBounded reads the payload with parse-early + a hard timeout +// (STDIN_READ_TIMEOUT_MS) and destroys stdin on resolve, so this hook can never +// exceed that wall-clock budget even if Claude Code holds the stdin write-end +// open. The whole body runs inside an async IIFE so the event loop stays free +// for the timeout to fire while the payload is being read (the read is the very +// first operation; everything after it remains synchronous). +(async () => { debugLog('UnifiedStop', 'Hook started'); -// Read hook context +// Read hook context (bounded — see Issue #139 note above) let hookContext = {}; try { - const input = readStdinSync(); - hookContext = typeof input === 'string' ? JSON.parse(input) : input; + const input = await readStdinBounded(STDIN_READ_TIMEOUT_MS); + hookContext = (input && typeof input === 'object') ? input : {}; } catch (e) { debugLog('UnifiedStop', 'Failed to parse context', { error: e.message }); } @@ -697,3 +706,7 @@ debugLog('UnifiedStop', 'Hook completed', { activeSkill, activeAgent }); +})().catch((e) => { + // Last-resort guard: the Stop hook must never throw out of the async body. + try { debugLog('UnifiedStop', 'Unhandled error in async body', { error: e && e.message }); } catch (_) { /* ignore */ } +}); diff --git a/test/regression/issue-139-stdin-bounded.test.js b/test/regression/issue-139-stdin-bounded.test.js new file mode 100644 index 00000000..77d29e4a --- /dev/null +++ b/test/regression/issue-139-stdin-bounded.test.js @@ -0,0 +1,261 @@ +#!/usr/bin/env node +'use strict'; +/** + * issue-139-stdin-bounded.test.js — Regression guard for GitHub #139. + * + * Placed under test/regression/ alongside issue-118/119/129/130/132 so the + * issue-specific bug cannot recur silently. + * + * Bug: hooks read their payload via lib/core/io.js readStdinSync(), which used + * `fs.readFileSync(0, 'utf8')` — a blocking read on stdin with NO timeout. It + * does not return until stdin reaches EOF, i.e. until Claude Code closes the + * hook's stdin write-end. When CC keeps that write-end open, the hook stalls for + * exactly that long. The Stop hook (scripts/unified-stop.js) gates turn + * completion, so the reporter observed stalls up to ~15.5 min (far past the + * hook's own 10 s timeout) via `/doctor`. + * + * Fix (bkit v2.1.30): + * - io.js readStdinSync(): incremental fs.readSync + parse-early — returns the + * instant the buffer holds valid JSON, never waiting for EOF (central; all + * 36 hook scripts). Raw fd → the process exits promptly with no cleanup. + * - io.js readStdinBounded(): async parse-early + hard timeout that destroys + * stdin on resolve — fully bounds the turn-gating Stop hook even for + * no-data / truncated payloads while the pipe is held open. + * - unified-stop.js: reads via readStdinBounded inside an async IIFE. + * - state-store.js lock(): busy-wait spin → Atomics.wait sleep (no CPU burn). + * + * Verified end-to-end against REAL subprocesses (a writer that holds the stdin + * write-end open, simulating CC's delayed close): + * 1. readStdinSync returns a valid payload immediately despite a held-open pipe. + * 2. readStdinSync contract preserved: empty → {}, malformed → {}. + * 3. readStdinBounded is hard-bounded on no-data / truncated held-open input, + * and its process exits promptly (stdin destroyed). + * 4. unified-stop.js does not stall on a held-open pipe and stays bounded. + * 5. Source guards: the unbounded fs.readFileSync(0) and the lock busy-wait + * spin must not reappear. + */ +const { spawn, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO = path.resolve(__dirname, '..', '..'); +const IO = path.join(REPO, 'lib', 'core', 'io.js'); +const STATE_STORE = path.join(REPO, 'lib', 'core', 'state-store.js'); +const UNIFIED_STOP = path.join(REPO, 'scripts', 'unified-stop.js'); + +let pass = 0, fail = 0; +const failures = []; +function tc(name, cond, detail) { + if (cond) { pass++; console.log(` PASS: ${name}`); } + else { fail++; failures.push(`${name}${detail ? ' :: ' + detail : ''}`); console.error(` FAIL: ${name}`); } +} + +// The generous held-open window the writer keeps the stdin pipe open. If the fix +// works, the child exits FAR sooner (parse-early ~ms, or the ~2 s hard cap). If +// the bug regresses, the child blocks until this window closes. +const HOLD_MS = 6000; +// Safety net: if the child truly hangs, force-close/kill after this and mark it +// as a stall. Set above the ~2 s hard cap but below HOLD_MS so a bounded child +// still exits on its own first. +const SAFETY_MS = 4500; + +/** + * Spawn `node `, write `payload` to its stdin, then KEEP the write-end + * open for HOLD_MS (simulating CC not closing stdin promptly). Resolves when the + * child exits on its own, recording elapsed ms; or forces it down at SAFETY_MS + * and marks `hung: true`. + */ +function runHeldOpen(args, payload) { + return new Promise((resolve) => { + const t0 = Date.now(); + const child = spawn('node', args, { cwd: REPO, stdio: ['pipe', 'pipe', 'pipe'] }); + let out = '', err = ''; + let settled = false; + child.stdout.on('data', (d) => { out += d; }); + child.stderr.on('data', (d) => { err += d; }); + + const holdTimer = setTimeout(() => { + try { child.stdin.end(); } catch (_) { /* ignore */ } + }, HOLD_MS); + if (holdTimer.unref) holdTimer.unref(); + + const safety = setTimeout(() => { + if (settled) return; + settled = true; + try { child.stdin.end(); } catch (_) { /* ignore */ } + try { child.kill('SIGKILL'); } catch (_) { /* ignore */ } + clearTimeout(holdTimer); + resolve({ code: null, ms: Date.now() - t0, out, err, hung: true }); + }, SAFETY_MS); + if (safety.unref) safety.unref(); + + child.on('exit', (code) => { + if (settled) return; + settled = true; + clearTimeout(holdTimer); + clearTimeout(safety); + resolve({ code, ms: Date.now() - t0, out, err, hung: false }); + }); + child.on('error', () => { + if (settled) return; + settled = true; + clearTimeout(holdTimer); + clearTimeout(safety); + resolve({ code: null, ms: Date.now() - t0, out, err, hung: true }); + }); + + // stdin.on('error') guards against EPIPE once the child destroys its read-end. + child.stdin.on('error', () => { /* ignore EPIPE after child exits */ }); + if (payload != null) { + try { child.stdin.write(payload); } catch (_) { /* ignore */ } + } + // Intentionally DO NOT call child.stdin.end() here — the pipe stays open. + }); +} + +/** + * Spawn `node ` with `input` and let stdin reach EOF immediately (normal + * close). Returns the captured stdout. Used for the readStdinSync return-value + * contract, which is about the parsed result under a normal (closed) stdin — the + * held-open bounding of no-data/malformed input is the async reader's job (the + * sync reader's deadline is best-effort and cannot interrupt a blocking read). + */ +function runClosed(args, input) { + const r = spawnSync('node', args, { + cwd: REPO, stdio: ['pipe', 'pipe', 'pipe'], + input: input == null ? '' : input, timeout: 10000, + }); + return { out: (r.stdout || '').toString(), status: r.status }; +} + +/** Strip block + line comments so source guards match CODE, not the doc + * comments that intentionally name the old anti-patterns. */ +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1'); +} + +const PAYLOAD = JSON.stringify({ + session_id: 'issue-139', + message: { model: 'claude-opus-4-8', usage: { input_tokens: 10, output_tokens: 5 } }, +}); + +// Tiny inline harnesses that exercise the io functions and print the parsed keys. +const SYNC_HARNESS = + `const {readStdinSync}=require(${JSON.stringify(IO)});` + + `const o=readStdinSync();process.stdout.write('KEYS:'+Object.keys(o||{}).join(','));`; +const BOUNDED_HARNESS = + `const {readStdinBounded}=require(${JSON.stringify(IO)});` + + `readStdinBounded().then(o=>{process.stdout.write('KEYS:'+Object.keys(o||{}).join(','));});`; + +(async () => { + // --- 1. readStdinSync: valid payload returned immediately despite held pipe --- + { + const r = await runHeldOpen(['-e', SYNC_HARNESS], PAYLOAD); + tc('readStdinSync returns valid payload without waiting for EOF (held-open pipe)', + !r.hung && r.ms < HOLD_MS - 1000 && /KEYS:.*session_id/.test(r.out) && /message/.test(r.out), + `hung=${r.hung} ms=${r.ms} out=${r.out.slice(0, 120)}`); + tc('readStdinSync child process exits promptly (raw fd, no lingering handle)', + !r.hung && r.ms < 2000, + `ms=${r.ms} (expected < 2000)`); + } + + // --- 2. readStdinSync contract preserved: empty → {}, malformed → {} ---------- + // (return-value contract under a normal closed stdin; the held-open bounding of + // such inputs is covered by readStdinBounded in section 3.) + { + const r = runClosed(['-e', SYNC_HARNESS], ''); + tc('readStdinSync empty stdin → {} (contract preserved)', + r.status === 0 && /KEYS:$/.test(r.out.trim()), + `status=${r.status} out=${JSON.stringify(r.out)}`); + } + { + const r = runClosed(['-e', SYNC_HARNESS], '{ not valid json'); + tc('readStdinSync malformed → {} (contract preserved)', + r.status === 0 && /KEYS:$/.test(r.out.trim()), + `status=${r.status} out=${JSON.stringify(r.out)}`); + } + { + const r = runClosed(['-e', SYNC_HARNESS], PAYLOAD); + tc('readStdinSync normal closed stdin → parses payload', + r.status === 0 && /KEYS:.*session_id/.test(r.out) && /message/.test(r.out), + `status=${r.status} out=${r.out.slice(0, 120)}`); + } + + // --- 3. readStdinBounded: parse-early + hard timeout, prompt exit ------------- + { + const r = await runHeldOpen(['-e', BOUNDED_HARNESS], PAYLOAD); + tc('readStdinBounded resolves via parse-early on payload (held-open pipe)', + !r.hung && r.ms < HOLD_MS - 1000 && /KEYS:.*session_id/.test(r.out), + `hung=${r.hung} ms=${r.ms} out=${r.out.slice(0, 120)}`); + } + { + const r = await runHeldOpen(['-e', BOUNDED_HARNESS], ''); // no data, pipe held open + tc('readStdinBounded is hard-bounded on no-data held-open pipe (exits, not hung)', + !r.hung && r.ms < SAFETY_MS, + `hung=${r.hung} ms=${r.ms} (expected bounded < ${SAFETY_MS})`); + } + { + const r = await runHeldOpen(['-e', BOUNDED_HARNESS], '{"a":'); // truncated JSON, held open + tc('readStdinBounded is hard-bounded on truncated held-open payload', + !r.hung && r.ms < SAFETY_MS, + `hung=${r.hung} ms=${r.ms} (expected bounded < ${SAFETY_MS})`); + } + + // --- 4. unified-stop.js end-to-end: no stall on held-open pipe ---------------- + // The regression guarantee is "does not stall + exits cleanly". The exact + // stdout is intentionally NOT asserted: unified-stop dispatches different Stop + // sub-handlers depending on ambient PDCA state, so its output text is + // environment-dependent — only bounded time + a clean exit are invariant. + { + const r = await runHeldOpen([UNIFIED_STOP], PAYLOAD); + tc('unified-stop.js does not stall on held-open pipe (normal payload)', + !r.hung && r.ms < HOLD_MS - 1000 && r.code === 0, + `hung=${r.hung} ms=${r.ms} code=${r.code} out=${r.out.slice(0, 120)}`); + } + { + const r = await runHeldOpen([UNIFIED_STOP], ''); // no data + held-open → hard cap + tc('unified-stop.js stays bounded on no-data held-open pipe', + !r.hung && r.ms < SAFETY_MS && r.code === 0, + `hung=${r.hung} ms=${r.ms} code=${r.code} (expected bounded < ${SAFETY_MS})`); + } + + // --- 5. SOURCE GUARDS: the unbounded read + busy-wait spin must not reappear -- + // Guards run against comment-stripped source so the doc comments that + // intentionally name the old anti-patterns don't produce false positives. + { + const ioSrc = stripComments(fs.readFileSync(IO, 'utf8')); + tc('io.js no longer uses the unbounded fs.readFileSync(0)', + !/readFileSync\(\s*0\b/.test(ioSrc), + 'found fs.readFileSync(0 ...) in lib/core/io.js (code)'); + tc('io.js reads stdin incrementally via fs.readSync (parse-early)', + /fs\.readSync\(\s*0\b/.test(ioSrc), + 'expected fs.readSync(0, ...) in lib/core/io.js'); + tc('io.js exports readStdinBounded', + /readStdinBounded/.test(ioSrc), + 'expected readStdinBounded in lib/core/io.js'); + + const usSrc = stripComments(fs.readFileSync(UNIFIED_STOP, 'utf8')); + tc('unified-stop.js reads via the bounded reader', + /readStdinBounded/.test(usSrc), + 'expected readStdinBounded in scripts/unified-stop.js'); + + const ssSrc = stripComments(fs.readFileSync(STATE_STORE, 'utf8')); + tc('state-store.js lock() no longer busy-waits (waitUntil spin removed)', + !/while\s*\(\s*Date\.now\(\)\s*<\s*waitUntil\s*\)/.test(ssSrc), + 'found the busy-wait spin `while (Date.now() < waitUntil)` in state-store.js (code)'); + tc('state-store.js uses a non-CPU sleep (Atomics.wait via sleepSync)', + /Atomics\.wait/.test(ssSrc) && /sleepSync/.test(ssSrc), + 'expected Atomics.wait-based sleepSync in state-store.js'); + } + + // --------------------------------------------------------------------------- + console.log(`\n${pass} passed, ${fail} failed`); + if (failures.length) { + console.error('FAILURES:'); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); + } + process.exit(0); +})(); From b71a72ed20e86aec68b227620f3426724872e749 Mon Sep 17 00:00:00 2001 From: kay kim Date: Tue, 14 Jul 2026 14:05:11 +0900 Subject: [PATCH 2/2] docs: add cc-version-analysis reports + refresh github-stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles pre-existing local workspace artifacts into the v2.1.30 release at the maintainer's request: - docs/04-report/features/bkit-v200-test.report.md - docs/04-report/features/cc-v2196-v2198-impact-analysis.report.md - docs/04-report/features/cc-v2199-v2204-impact-analysis.report.md - docs/04-report/features/cc-v2205-v2207-impact-analysis.report.md (outputs of the /bkit:cc-version-analysis workflow — single-file KO reports per that workflow's convention; not part of the #139 fix) - docs/github-stats-bkit-claude-code.md (github-stats skill ledger refresh) Docs-only; no code, hook, or version-manifest change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01WCr8qz6Acx4uFLXmbcikRJ --- .../features/bkit-v200-test.report.md | 86 ++++++++ .../cc-v2196-v2198-impact-analysis.report.md | 197 +++++++++++++++++ .../cc-v2199-v2204-impact-analysis.report.md | 207 ++++++++++++++++++ .../cc-v2205-v2207-impact-analysis.report.md | 151 +++++++++++++ docs/github-stats-bkit-claude-code.md | 101 ++++----- 5 files changed, 692 insertions(+), 50 deletions(-) create mode 100644 docs/04-report/features/bkit-v200-test.report.md create mode 100644 docs/04-report/features/cc-v2196-v2198-impact-analysis.report.md create mode 100644 docs/04-report/features/cc-v2199-v2204-impact-analysis.report.md create mode 100644 docs/04-report/features/cc-v2205-v2207-impact-analysis.report.md diff --git a/docs/04-report/features/bkit-v200-test.report.md b/docs/04-report/features/bkit-v200-test.report.md new file mode 100644 index 00000000..fa2eee86 --- /dev/null +++ b/docs/04-report/features/bkit-v200-test.report.md @@ -0,0 +1,86 @@ +# bkit v2.0.5 Comprehensive Test Report + +> Generated: 2026-07-14T04:59:07.044Z +> Total: 3670 TC, 3616 PASS, 39 FAIL, 19 SKIP +> Pass Rate: 98.5% + +--- + +## Summary + +| Category | Total | Passed | Failed | Skipped | Rate | +|----------|:-----:|:------:|:------:|:-------:|:----:| +| Unit Tests | 1540 | 1511 | 18 | 15 | 98.1% FAIL | +| Integration Tests | 537 | 535 | 2 | 0 | 99.6% FAIL | +| Security Tests | 249 | 249 | 0 | 0 | 100.0% PASS | +| Regression Tests | 514 | 497 | 17 | 0 | 96.7% FAIL | +| Performance Tests | 161 | 157 | 0 | 4 | 97.5% PASS | +| Philosophy Tests | 129 | 127 | 2 | 0 | 98.4% FAIL | +| UX Tests | 185 | 185 | 0 | 0 | 100.0% PASS | +| E2E Tests (Node) | 90 | 90 | 0 | 0 | 100.0% PASS | +| Architecture Tests | 100 | 100 | 0 | 0 | 100.0% PASS | +| Controllable AI Tests | 80 | 80 | 0 | 0 | 100.0% PASS | +| behavioral | 45 | 45 | 0 | 0 | 100.0% PASS | +| contract | 40 | 40 | 0 | 0 | 100.0% PASS | +| **Total** | **3670** | **3616** | **39** | **19** | **98.5%** | + +## Version Comparison: v1.6.2 → v2.0.0 + +| Metric | v1.6.2 | v2.0.0 | Delta | +|--------|:------:|:------:|:-----:| +| Categories | 8 | 12 | +4 | +| Total TC | 1151 | 3670 | +2519 | +| Unit Tests | 450 | 1540 | +1090 | +| Integration Tests | 130 | 537 | +407 | +| Security Tests | 80 | 249 | +169 | +| Regression Tests | 200 | 514 | +314 | +| Performance Tests | 76 | 161 | +85 | +| Philosophy Tests | 60 | 129 | +69 | +| UX Tests | 60 | 185 | +125 | +| E2E Tests (Node) | 20 | 90 | +70 | +| Architecture Tests | N/A | 100 | NEW | +| Controllable AI Tests | N/A | 80 | NEW | +| behavioral | N/A | 45 | NEW | +| contract | N/A | 40 | NEW | + +## Failures + +### Unit Tests + +- **unit/context-loader.test.js**: File not found +- **unit/impact-analyzer.test.js**: File not found +- **unit/invariant-checker.test.js**: File not found +- **unit/scenario-runner.test.js**: File not found + +### Integration Tests + +- **TC-HC-26**: detectDocumentType returns null for analysis path (not yet implemented) +- **integration/control-pipeline.test.js**: Execution error: Command failed: node "/Users/kaykim/Documents/GitHub/agent-kay-it/bkit-claude-code/test/integration/control-pipeline.test.js" +Assertion failed: CP-011: createDefaultProfile returns profile with trustScore=40, level=0 +/Users/kaykim/Documents/GitHub/agent-kay-it/bkit-claude-code/lib/core/state-store.js:155 + throw e; + ^ + +Error: ENOENT: no such file or directory, open '/private/var/folders/r9/fbtgppdn7t7f_04mvv64n8_w0000gn/T/bkit-cp-test-8954-1784005107568/.bkit/runtime/loop-counters.json.lock' + at Object.openSync (node:fs:556:18) + at Object.writeFileSync (node:fs:2412:35) + at lock (/Users/kaykim/Documents/GitHub/agent-kay-it/bkit-claude-code/lib/core/state-store.js:151:10) + at lockedUpdate (/Users/kaykim/Documents/GitHub/agent-kay-it/bkit-claude-code/lib/core/state-store.js:212:3) + at _incrementPersistedCounter (/Users/kaykim/Documents/GitHub/agent-kay-it/bkit-claude-code/lib/control/loop-breaker.js:110:17) + at Object.recordAction (/Users/kaykim/Documents/GitHub/agent-kay-it/bkit-claude-code/lib/control/loop-breaker.js:186:20) + at Object. (/Users/kaykim/Documents/GitHub/agent-kay-it/bkit-claude-code/test/integration/control-pipeline.test.js:211:13) + at Module._compile (node:internal/modules/cjs/loader:1829:14) + at Module._extensions..js (node:internal/modules/cjs/loader:1969:10) + at Module.load (node:internal/modules/cjs/loader:1552:32) { + errno: -2, + code: 'ENOENT', + syscall: 'open', + path: '/private/var/folders/r9/fbtgppdn7t7f_04mvv64n8_w0000gn/T/bkit-cp-test-8954-1784005107568/.bkit/runtime/loop-counters.json.lock' +} + +Node.js v26.0.0 + + +## Verdict + +**39 TESTS FAILED** - Issues must be resolved before release. diff --git a/docs/04-report/features/cc-v2196-v2198-impact-analysis.report.md b/docs/04-report/features/cc-v2196-v2198-impact-analysis.report.md new file mode 100644 index 00000000..79112b7d --- /dev/null +++ b/docs/04-report/features/cc-v2196-v2198-impact-analysis.report.md @@ -0,0 +1,197 @@ +# CC v2.1.196 → v2.1.198 영향 분석 및 bkit 대응 보고서 (ADR 0003 스물다섯 번째 정식 적용) + +> 분석 범위: CC v2.1.197 ~ v2.1.198 (baseline v2.1.196 제외) +> 사이클 #21 · 2026-07-03 · 분석 전용 (구현 금지) +> 설치 CC: v2.1.198 (npm next/latest) · 이전 baseline: v2.1.196 + +--- + +## 1. Executive Summary + +### 1.1 최종 판정 + +- **신규 ENH: 0건.** → **21-cycle 연속 0-ENH** streak 갱신. ENH-367 예약 유지(미소비). +- **누적 연속 호환: 139 → 141** (v2.1.197·v2.1.198 모두 호환, R-2 skip 없음). +- **판정: neutral/auto.** 이번 범위 최대 변경(Sonnet 5 **기본 모델화**, v2.1.197)은 bkit v2.1.25 model-alignment에서 **선제 대응 완료** 상태 — 추가 작업 불요. +- **carry item 해소: MF-1 CLOSED.** `lib/infra/cc-version-checker.js` `RECOMMENDED_VERSION`이 이미 `'2.1.198'`로 bump 완료(메모리상 stale `2.1.118` 기록은 만료됨). 6-cycle carry 종료. +- **신규 모니터 1건 (P3):** Notification hook 확장(`agent_needs_input`/`agent_completed`) — bkit matcher 미포함. YAGNI 상 즉시 구현 불요, 모니터 등재. + +### 1.2 성과 요약 + +| 지표 | 이전(#20) | 현재(#21) | 비고 | +|------|-----------|-----------|------| +| 분석 bullet | 54 | **33** (v197:1 + v198:32) | raw gh authoritative | +| 신규 ENH | 0 | **0** | streak 20 → **21** | +| 누적 연속 호환 | 139 | **141** | +2 | +| carry item | MF-1 (6-cycle) | **0** (MF-1 해소) | RECOMMENDED bump 확인 | +| Breaking | 0 | 0 (Removed 1: `/agents` wizard, bkit 무영향) | — | + +### 1.3 4-Perspective 가치 평가 + +| 관점 | 이번 범위 가치 | 근거 | +|------|----------------|------| +| **사용자** | 中(+) | Sonnet 5 기본화로 bkit 워크플로우 품질/1M context 향상 (bkit 이미 alias 정렬) | +| **개발자** | 低 | 코드 변경 불요. 유일 후속은 문서/모니터 업데이트 | +| **아키텍처** | 中 | Explore 모델 상속·subagent 방향성 명확화가 bkit Sequential Dispatch(#3)/Memory Enforcer(#1) 철학과 정합 | +| **운영** | 中(+) | agent-teams 사멸 teammate `failed` 보고 fix가 Agent Teams 신뢰성 강화 | + +--- + +## 2. CC v2.1.196 → v2.1.198 변경사항 (33 bullets, raw gh authoritative) + +### 2.1 Phase 1.5 Raw Verification Gate 결과 — gh raw CHANGELOG 행 단위 (model-WebFetch 우회) + +메모리 교훈에 따라 `curl raw CHANGELOG.md` + 헤더 grep + `sed` 행범위로 직접 확정 (model-WebFetch cross-section 환각 회피). + +| 버전 | Raw 확정 | 헤딩 분해 | Source | +|------|----------|-----------|--------| +| v2.1.197 | **1** | 단일(Sonnet 5 기본화) | CHANGELOG L38–41 | +| v2.1.198 | **32** | Added/behavior 7 · Fixed 18 · Improved/behavior/Removed 7 | CHANGELOG L3–37 | +| **Total** | **33** | — | sum | +| Breaking | **0** | (Removed 1 = `/agents` wizard, non-breaking) | — | + +**Verdict: match.** agent 위임 없이 raw 직행 확정 → 환각 리스크 0. 스팟체크 3종 verbatim 확인: +- v198 "Fixed brief network drops mid-response aborting the turn — … ECONNRESET now retry with backoff" ✓ +- v198 "The built-in Explore agent now inherits the main session's model (capped at opus) instead of running on haiku" ✓ +- v198 "Removed the `/agents` wizard; ask Claude to create or manage subagents, or edit `.claude/agents/` directly" ✓ + +### 2.2 헤드라인 후보 (bkit relevance) + +| # | 변경 (ver) | 성격 | bkit relevance | +|---|-----------|------|----------------| +| H1 | **Claude Sonnet 5 기본 모델화** (v197) — 1M context, `sonnet` alias가 CC≥2.1.197에서 Sonnet 5로 해소 | Feature | **HIGH** (model pin/floor) | +| H2 | **Notification hook 확장** (v198) — background agent가 `agent_needs_input`/`agent_completed` 발화 | Hook | **MEDIUM** | +| H3 | **Explore agent 모델 상속** (v198) — haiku → 세션 모델(opus cap) | Behavior | **MEDIUM** | +| H4 | **`/agents` wizard 제거** (v198) — subagent는 대화/`.claude/agents/` 직접 편집으로 | Removed | **LOW** | +| H5 | **agent teams 신뢰성 fix** (v198) — 사멸 teammate `failed` 보고 + stuck teammate 재기동 | Fix | **LOW(+)** | + +--- + +## 3. bkit 영향 분석 + +### 3.0 아키텍처 베이스라인 재측정 (Numeric Correction Protocol — 메인 세션 직접 측정) + +| 항목 | 측정값 | 명령 | +|------|--------|------| +| Skills | **44** | `ls -1 skills/` | +| Agents | **34** | `ls -1 agents/*.md` | +| Hook events | **22** | `hooks.json` top-level keys | +| lib modules | **194** | `find lib -name '*.js'` | + +session context(44/34/22/194)와 **완전 일치** — 수정 불요. + +### 3.1 H1 심층 검증 — Sonnet 5 기본화는 이미 선제 대응 완료 (neutral/auto) + +가장 큰 변경이지만 bkit v2.1.25 model-alignment에서 **선반영**됨. 저장소 직접 확인: + +- `lib/infra/cc-version-checker.js:42` `RECOMMENDED_VERSION = '2.1.198'` — 이미 v198 권장. (`MIN_VERSION='2.1.78'`, `FABLE_MODEL_FLOOR='2.1.170'` 유지) +- `README.md:185` "Recommended … **v2.1.198** (Claude 5 alias resolution — `sonnet` → Sonnet 5 needs ≥ v2.1.197)" 명시. +- `CHANGELOG.md` provider alias table: "Anthropic API (CC ≥ 2.1.197) → Sonnet 5", dual-floor 정책 기록. +- `evals/config.json:5` `benchmarkModel: "claude-sonnet-5"`. + +→ **추가 ENH 불요.** CC v197이 문서상 전제(≥2.1.197)를 사후 충족 → bkit 권장/floor 정합성 그대로 성립. + +### 3.2 H2 — Notification hook 확장 (유일 실질 기회, P3) + +- 현재: `hooks/hooks.json:247` Notification matcher = `"permission_prompt|idle_prompt"` → `scripts/notification-handler.js`. +- CC v198 신규 사유 `agent_needs_input`/`agent_completed`는 **현재 matcher 미포함** → background agent 알림에 bkit 핸들러 미발화. +- 별개로 `lib/audit/audit-logger.js:43`은 bkit 자체 audit taxonomy로 `agent_completed`를 이미 보유(이름만 동일, CC hook payload와 무관). +- **YAGNI 판정: 기회지만 즉시 구현 불요** — ① Agent Teams는 실험 플래그(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`) gated 상태(preflight 상 inactive), ② `--bg` background agent가 bkit 문서화 워크플로우에 미채택. → **모니터 등재(P3)**, ENH-367 미소비. + +### 3.3 나머지 bkit-relevant 플래그 독립 검증 + +| CC 변경 (ver) | bkit 접점 | 측정 결과 | 판정 | +|---------------|-----------|-----------|------| +| Explore 모델 상속 (v198) | 20 agents `Task(Explore)` 사용 | 검색 품질↑, 비용↑(구 haiku) | **neutral** — CC-managed. effort-aware(#4) 비용 관찰 항목 | +| `/agents` wizard 제거 (v198) | wizard 실행 지시 skill/doc **0건** | `test/README.md:114` E2E-006 "/agents list" 문서 참조만 존재(wizard 아님) | **neutral** — 코드 무영향. E2E-006 라벨 stale 여부만 경미 | +| `.claude/rules` symlink fix (v198) | bkit는 `.claude/rules/` **미배포** (ADR 2건 언급뿐) | 실경로 없음 | **neutral** | +| agent teams `failed` 보고 fix (v198) | Sequential Dispatch(#3), cto/pm/qa-lead | 신뢰성↑ | **neutral(+)** — 차별화 보강 | +| subagent 메시지=task direction, 승인 아님 (v198) | Memory Enforcer(#1) 승인 모델 | 계약 명확화 | **neutral(+)** — never-auto-approve 정합 | +| Claude in Chrome GA (v198) | qa-lead 브라우저 도구 | GA 안정화 | **neutral(+)** | +| `/dataviz` skill 추가 (v198) | bkit 44 skills 카탈로그 | 네임스페이스 충돌 없음(CC 내장) | **neutral** | +| 잔여 UI/network/diff fix 다수 (v198) | 없음 | — | **neutral** | + +--- + +## 4. ENH 기회 식별 (Phase 3 Plan Plus + YAGNI) + +### 4.1 Intent Discovery +- 이 업그레이드 최대 가치? → Sonnet 5 기본화의 **품질/1M context 이득** (bkit 이미 정렬 완료, 수취만). +- 놓치면 안 되는 critical change? → 없음. Breaking 0, model 전제는 사전 충족. +- native가 workaround 대체? → Notification hook 확장이 잠재 관측성 native화이나 현 미사용. + +### 4.2 후보 평가 (YAGNI) + +| 후보 | 필요성 | 미구현 시 문제 | 차기 CC 개선 가능성 | 판정 | +|------|--------|----------------|---------------------|------| +| Notification matcher에 `agent_needs_input\|agent_completed` 추가 | 낮음(실험 gated 미사용) | 없음(현 워크플로우 무영향) | 높음(payload 스키마 미고정 가능) | **P3 모니터, 구현 보류** | + +→ **채택 ENH: 0건.** streak 21 유지, ENH-367 예약 존속. + +### 4.3 유지보수 발견 — MF-1 carry 해소 확인 + +- 이전 6-cycle carry "MF-1: `cc-version-checker.js` `RECOMMENDED_VERSION` stale(2.1.118)"는 **해소 완료**. 현재 값 `'2.1.198'` 직접 확인. → carry 목록에서 제거. + +--- + +## 5. 차별화 streak 갱신 + +3대 이슈 전부 CC-abandoned(`not_planned`) 유지 — code-fix bullet 부재로 streak 연장: + +| 이슈 | 이전 | 현재 | 근거 | +|------|------|------|------| +| #56293 | 25 | **26** | v197~v198 code-fix bullet 없음 | +| #57317 (PLUGIN-HOOK-DROP) | 19 | **20** | 동일 | +| #58904 | 15 | **16** | 동일 | + +hook-matcher 컨벤션 면역(no-comma·no-hyphen·pipe/underscore) 유지 — 이번 범위 matcher-syntax 회귀 없음. + +--- + +## 6. Release Drift + R-Series + +- dist-tags: stable=2.1.187 / latest=2.1.198 / next=2.1.198. **drift 개선** (이전 +11 CRITICAL → stable-latest gap 축소, latest=next 수렴). +- R-2 skip: 이번 범위 0건. 누적 연속 호환 139 → **141**. + +--- + +## 7. Monitor 상태 + +| 모니터 | 상태 | 비고 | +|--------|------|------| +| BG-OTEL-DROP (#64436) | ACTIVE | 변화 없음 | +| PLUGIN-HOOK-DROP (#57317) | ACTIVE | streak 20 | +| CHOICE-LOOP (#64447) | ACTIVE | 변화 없음 | +| STOP-SCHEMA-STRICT (ENH-366) | RESOLVED | 유지 | +| **NOTIFY-BGAGENT (신규)** | **ACTIVE(P3)** | v198 Notification 확장, matcher 미포함, 구현 보류 | +| MF-1 (RECOMMENDED stale) | **CLOSED** | RECOMMENDED='2.1.198' 확인 | + +--- + +## 8. 최종 평결 및 권장 조치 + +**neutral/auto — 코드 변경 불요, streak 21 갱신.** Sonnet 5 기본화는 선제 대응 완료, MF-1 carry 해소, 신규 관측성 기회 1건은 YAGNI 보류. + +### 8.1 메모리 갱신 사항 +- baseline: v2.1.196 → **v2.1.198** (다음 분석 v2.1.199부터) +- ENH streak: 20 → **21** cycle 0-ENH (ENH-367 예약 존속) +- 누적 연속 호환: 139 → **141** +- carry: MF-1 **CLOSED** (RECOMMENDED='2.1.198') +- 신규 모니터: **NOTIFY-BGAGENT** (P3, Notification 확장) +- 차별화 streak: #56293=26, #57317=20, #58904=16 +- 권장 CC: latest **v2.1.198** 허용/권장 (Sonnet 5 alias 해소) + +--- + +## 9. Quality Checklist + +- [x] 범위 전 변경 포착 (33 bullets) +- [x] 변경별 HIGH/MEDIUM/LOW 분류 +- [x] Phase 1.5 raw gate (curl raw CHANGELOG 행 단위, match) +- [x] 스팟체크 ≥3 verbatim +- [x] 아키텍처 직접 재측정 (44/34/22/194, session 일치) +- [x] 철학 정합성 (Memory Enforcer·Sequential Dispatch·effort-aware) +- [x] ENH 우선순위 (0 채택, 1 P3 보류) +- [x] MF-1 carry 해소 확인 +- [x] 한국어 작성 +- [x] 메모리 갱신 사항 명시 diff --git a/docs/04-report/features/cc-v2199-v2204-impact-analysis.report.md b/docs/04-report/features/cc-v2199-v2204-impact-analysis.report.md new file mode 100644 index 00000000..33a594b9 --- /dev/null +++ b/docs/04-report/features/cc-v2199-v2204-impact-analysis.report.md @@ -0,0 +1,207 @@ +# CC v2.1.198 → v2.1.204 영향 분석 및 bkit 대응 보고서 (ADR 0003 스물여섯 번째 정식 적용) + +> 분석 사이클 #22 · baseline CC v2.1.198 → 설치본 v2.1.204 (v2.1.199~v2.1.204, 6개 릴리스, 98 bullets) +> 분석일: 2026-07-08 · bkit plugin v2.1.29 · 분석 전용(analysis-only), ENH 미구현 + +--- + +## 1. Executive Summary + +### 1.1 최종 판정 + +**호환성 판정: FULLY COMPATIBLE (breaking 0건) · 신규 ENH 0건 → 22-cycle 연속 0-ENH streak.** + +v2.1.199~v2.1.204 범위 98개 bullet 전량을 raw CHANGELOG 바이트에서 직접 검증한 결과, bkit plugin 아키텍처(44 Skills / 34 Agents / 22 Hook Events / 195 Lib Modules)에 대한 **breaking change는 0건**이다. 동작 변경(behavior-change)에 해당하는 4개 항목(v200 Manual 기본 permission, v200 AskUserQuestion auto-continue 제거, v201 Sonnet 5 system-role reminder 제거, v199 hook exit-2 stderr 노출)은 모두 코드 측 직접 검증을 통해 bkit에 **neutral 또는 positive**로 확정했다. + +### 1.2 성과 요약 + +- **누적 연속 호환**: 141 → **147** (+6, v2.1.34~v2.1.204, R-2 skip 제외) +- **신규 ENH streak**: **22-cycle 연속 0건**. 전역 마지막 ENH-366, ENH-367 예약 유지(미소비). CC-cycle 마지막 ENH-328. +- **native 개선의 passive 수혜**: bkit가 워크어라운드 없이 이득을 보는 CC 네이티브 개선 5건 식별 (skill 재호출 중복 제거, effortLevel fix 등) +- **신규 carry MF-2**: `cc-version-checker.js` `RECOMMENDED_VERSION` stale(2.1.198) → maintainer bump 권장 (분석 전용, 미구현) + +### 1.3 4-Perspective 가치 평가 + +| 관점 | 이번 사이클 가치 | 근거 | +|------|-----------------|------| +| **사용자(User)** | ★★★☆☆ | v202 skill 재호출 중복 제거 → /pdca·/sprint 반복 호출 시 컨텍스트 부풀림 해소(실사용 이득). v199 hook stderr 노출 → bkit hook 오류 가시성 향상 | +| **개발자(maintainer)** | ★★☆☆☆ | 코드 변경 불필요(0 breaking). 유지보수 발견 1건(MF-2 RECOMMENDED_VERSION bump 권장) | +| **아키텍처(Architecture)** | ★★☆☆☆ | 22-cycle 무결. hook-matcher 컨벤션 면역·effort-aware(diff #4)·Sequential Dispatch(diff #3) 정합성 유지 | +| **비즈니스(Business)** | ★★☆☆☆ | v203 background-agent 대량 안정화 → 향후 Agent Teams/bg-agent 채택 시 de-risk(현재 gated·YAGNI) | + +--- + +## 2. CC v2.1.198 → v2.1.204 변경사항 (98 bullets, raw gh authoritative) + +### 2.1 Phase 1.5 Raw Verification Gate 결과 — gh raw CHANGELOG 행 단위 (model-WebFetch 우회) + +메모리 교훈(model-WebFetch cross-section 복제 환각, v2.1.145·v2.1.196 재현)에 따라 `curl raw CHANGELOG` + 헤더 grep + 카테고리 prefix 카운트로 직행 확정. **raw-wins, model-WebFetch 생략.** + +| 버전 | Total | Added | Fixed | Improved | Changed | Removed | 기타 | 판정 | +|------|-------|-------|-------|----------|---------|---------|------|------| +| v2.1.204 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | match | +| v2.1.203 | 37 | 3 | 26 | 2 | 2 | 2 | 2(VSCode 1) | match | +| v2.1.202 | 18 | 2 | 13 | 2 | 1 | 0 | 0 | match | +| v2.1.201 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | match | +| v2.1.200 | 17 | 0 | 13 | 2 | 2 | 0 | 0 | match | +| v2.1.199 | 24 | 0 | 20 | 0 | 0 | 0 | 4 | match | +| **합계** | **98** | **5** | **73** | **6** | **5** | **4** | **7** | **all-match** | + +- 출처: `https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md` (main 브랜치 authoritative) +- 카운트 방식: `^## 2\.1\.N` 헤더 행범위 내 `^- ` 접두 기계 카운트. 헤딩 없는 flat bullet 구조이므로 카테고리는 첫 단어(Added/Fixed/Improved/Changed/Removed) 기준 분류 +- 스팟체크(≥3): v199 "Stacked slash-skill invocations…up to 5" / v200 "Changed the default permission mode to Manual" / v202 "Fixed re-invoking an already-loaded skill appending a duplicate copy" — 3건 모두 raw 원문 verbatim 일치 + +### 2.2 헤드라인 후보 (bkit relevance) + +| # | 버전 | 변경 | bkit relevance | impact | +|---|------|------|----------------|--------| +| H1 | v199 | Stacked slash-skill `/skill-a /skill-b` 최대 5개 leading skill 로드 | 네임스페이스 스킬(/pdca /qa 등) | MEDIUM(feature) | +| H2 | v199 | SessionStart/Setup/SubagentStart hook exit code 2 시 stderr 노출 | bkit SessionStart hook | MEDIUM(debuggability) | +| H3 | v200 | default permission mode → "Manual" (`default`도 계속 허용) | bkit Trust L0-L4 / control panel | LOW(neutral) | +| H4 | v200 | AskUserQuestion auto-continue 기본 비활성(config로 opt-in) | bkit SessionStart AskUserQuestion 강제 | LOW(neutral) | +| H5 | v201 | Sonnet 5 세션 mid-conversation system-role harness reminder 미사용 | bkit hook additionalContext 주입 | LOW(unaffected) | +| H6 | v202 | 이미 로드된 skill 재호출 시 instruction 중복 append 수정 | bkit /pdca·/sprint 반복 호출 | MEDIUM(positive) | +| H7 | v202 | workflow `run_id`/`name` OTel 속성 추가 | bkit OTEL 모니터 계열 | LOW | +| H8 | v203 | background session effortLevel 변경 무시 수정 | bkit effort-aware(diff #4) | LOW(positive) | +| H9 | v203 | subagent 전체 재위임(re-delegate) 감소 개선 | bkit Sequential Dispatch(diff #3) | LOW(positive) | +| H10 | v203 | LSP-only plugin disuse 오탐 수정 | bkit는 plugin | LOW | + +--- + +## 3. bkit 영향 분석 + +### 3.0 아키텍처 베이스라인 재측정 (Numeric Correction Protocol — 메인 세션 직접 측정) + +| 항목 | 측정값 | 명령 | +|------|--------|------| +| Skills | **44** | `ls -1 skills/ \| wc -l` | +| Agents | **34** | `ls -1 agents/ \| wc -l` | +| plugin.json version | **2.1.29** | `grep '"version"' .claude-plugin/plugin.json` | + +메모리(44/34)와 일치. 수치 정정 불필요. + +### 3.1 H1·H6 — 스킬 시스템 변경 (feature + positive, 코드 변경 불필요) + +- **H1 (stacked slash-skill, up to 5)**: bkit는 네임스페이스 스킬(`/pdca`, `/sprint`, `/qa-phase` 등)을 다수 보유. 이 기능은 `/skill-a /skill-b` 형태 다중 로드를 활성화하나, bkit 워크플로우는 오케스트레이터가 순차 dispatch하는 구조라 **자동 이득만 있고 요구 변경 없음**. YAGNI로 ENH 승격 보류. +- **H6 (skill 재호출 중복 제거)**: bkit는 `/pdca`를 phase마다 반복 호출하고 `/sprint` 액션을 반복 invoke하는 패턴이 잦다. 기존에는 재호출 시 instruction이 컨텍스트에 중복 append됐으나 v202에서 수정 → **bkit 세션 컨텍스트 절약이라는 실질 이득**. 코드 변경 불필요, positive. + +### 3.2 H2 — hook exit-2 stderr 노출 (부정적 영향 0, 검증 완료) + +``` +grep -rnE "exit\(2\)|process\.exit\(2\)|exitCode\s*=\s*2" hooks/ lib/ → 0건 +``` + +bkit hook 중 **code 2로 종료하는 hook은 없다**. 따라서 v199의 "exit 2 시 stderr 노출" 변경은 bkit에 부정적 회귀를 유발하지 않는다. 오히려 향후 bkit hook이 exit 2를 쓰게 될 경우 stderr가 transcript에 노출되어 디버깅성이 향상되는 **잠재적 positive**. + +### 3.3 H3·H4 — permission/AskUserQuestion 동작 변경 (neutral, 검증 완료) + +- **H3 (Manual 기본 permission)**: CLI 기본 permission mode가 "Manual"로 변경됐으나 `--permission-mode default` / `"defaultMode": "default"`는 계속 허용된다(changelog 명시). bkit의 L0-L4 Trust 추상화는 CC permission-mode 기본값에 결합되지 않은 독립 계층 → **neutral, monitor 불요**. +- **H4 (AskUserQuestion auto-continue 제거)**: `grep -rniE "auto.?continue|idle.?timeout" hooks/ lib/ skills/` → **0건**. bkit는 auto-continue/idle-timeout에 의존하지 않으며, SessionStart hook은 사용자의 명시적 응답을 요구하는 설계이므로 오히려 정합적 → **neutral(arguably positive)**. + +### 3.4 H5·H7~H10 — 나머지 flag 독립 검증 + +| # | 판정 | 근거 | +|---|------|------| +| H5 | unaffected | bkit는 UserPromptSubmit additionalContext(user role) 주입 사용. 변경은 Sonnet 5 mid-conversation **system role** reminder 대상 → bkit 메커니즘 무관 | +| H7 | neutral | OTel workflow 속성 추가는 gain-only. bkit OTEL 모니터(BG-OTEL-DROP #64436)는 별개 이슈 | +| H8 | positive | effortLevel 무시 수정 → bkit effort-aware(diff #4) 신뢰성 향상. bg-session 한정이라 즉시 영향은 미미 | +| H9 | positive | subagent 재위임 감소 → bkit Sequential Dispatch(diff #3) 철학과 동일 방향 | +| H10 | neutral | LSP-only plugin disuse 오탐 수정. bkit는 LSP-only 아님 → 직접 영향 없음 | + +--- + +## 4. ENH 기회 식별 (Phase 3 Plan Plus + YAGNI) + +### 4.1 Intent Discovery +- **이 업그레이드에서 bkit의 최대 가치는?** → v202 skill 재호출 중복 제거로 인한 컨텍스트 절약(자동 수혜). 별도 구현 불필요. +- **놓치면 안 되는 critical change는?** → 없음. 0 breaking. +- **기존 workaround를 대체할 native 기능은?** → 없음(bkit는 skill 중복 관련 workaround를 두지 않았으므로 대체 대상 없음). + +### 4.2 후보 평가 (YAGNI) + +| 후보 | YAGNI 판정 | 결정 | +|------|-----------|------| +| H1 stacked-skill 활용한 다중 스킬 체이닝 | 현재 오케스트레이터 순차 dispatch로 충분, 실수요 없음 | ❌ 보류(P3) | +| H4 대응 AskUserQuestion idle-timeout opt-in | bkit는 명시적 응답 요구 설계, 불필요 | ❌ 제거 | +| H8 effort-aware를 bg-session까지 확장 | Agent Teams gated·bg-agent 미채택 | ❌ YAGNI 보류 | + +→ **신규 ENH 0건. 22-cycle 연속 0-ENH 확정.** + +### 4.3 유지보수 발견 — MF-2 (신규 carry) + +| 항목 | 현재 | 권장 | 상태 | +|------|------|------|------| +| `lib/infra/cc-version-checker.js` `RECOMMENDED_VERSION` | `'2.1.198'` | `'2.1.204'` | **MF-2 OPEN** (maintainer bump 권장) | +| `MIN_VERSION` | `'2.1.78'` | 유지 | OK | +| `FABLE_MODEL_FLOOR` | `'2.1.170'` | 유지 | OK | + +> ⚠️ 분석 전용 원칙(HARD-GATE) 및 CLAUDE.md 버전 정책에 따라 **본 사이클에서는 미구현**. maintainer가 릴리스 시점에 RECOMMENDED_VERSION을 2.1.204로 bump할 것을 권장(설치본=latest=2.1.204). 구 MF-1(2.1.118→2.1.198)은 사이클 #21에서 CLOSED된 바 있어, 동일 성격의 신규 carry. + +--- + +## 5. 차별화 streak 갱신 + +3대 CC-abandoned 이슈(`not_planned`)를 참조하는 code-fix bullet이 v199~v204 98개 중 **관측되지 않음** → 3대 streak 전부 intact(break 없음). "닫힘 ≠ 고침" 원칙상 code-fix bullet 부재 = streak 유지. + +| 이슈 | 성격 | streak 상태 | +|------|------|-------------| +| #56293 | (차별화 #1 계열) | intact — code-fix bullet 미관측 | +| #57317 | PLUGIN-HOOK-DROP | intact — code-fix bullet 미관측 | +| #58904 | (차별화 계열) | intact — code-fix bullet 미관측 | + +--- + +## 6. Release Drift + R-Series + +- **dist-tags**: 설치본 = latest = **2.1.204**로 수렴. baseline 대비 6개 릴리스 연속(199~204) drift 없이 흡수. +- **R-Series**: 이번 범위 내 R-2 skip 해당 릴리스 없음. 6개 전량 정상 카운트. + +--- + +## 7. Monitor 상태 + +| Monitor | 상태 | 이번 사이클 변화 | +|---------|------|------------------| +| NOTIFY-BGAGENT (#v198 Notification hook 확장, P3) | ACTIVE(YAGNI 보류) | v199~204 관련 변경 없음 → 유지. Agent Teams 실험 gated·bg-agent 미채택으로 matcher 확장 계속 보류 | +| BG-OTEL-DROP (#64436) | ACTIVE | v202 OTel workflow 속성 추가는 별개 → 유지 | +| PLUGIN-HOOK-DROP (#57317) | ACTIVE | 변화 없음 | +| CHOICE-LOOP (#64447) | ACTIVE | 변화 없음 | +| STOP-SCHEMA-STRICT (ENH-366) | RESOLVED | 변화 없음 | +| **MF-2 RECOMMENDED_VERSION stale** | **신규 OPEN** | 2.1.198 → 2.1.204 bump 권장(maintainer, 미구현) | +| MF-1 (구 RECOMMENDED_VERSION carry) | CLOSED | 사이클 #21에서 종료 유지 | +| hook-matcher 컨벤션 면역 | 유효 | no-comma + no-hyphen + pipe/underscore → matcher-syntax 회귀 계속 면역 | + +**신규 관찰(monitor 불요, 정보성)**: v200 permission Manual 기본화 — bkit L0-L4가 독립 계층이므로 결합 없음. 향후 CC가 `default` alias를 제거할 경우에만 재평가. + +--- + +## 8. 최종 평결 및 권장 조치 + +**평결: bkit v2.1.29는 CC v2.1.204에서 FULLY COMPATIBLE. 코드 변경 0건, 신규 ENH 0건(22-cycle streak). 권장 CC = latest v2.1.204 허용/권장.** + +### 8.1 메모리 갱신 사항 (cc-version-analysis-state) +- 마지막 분석 baseline: v2.1.198 → **v2.1.204** (다음 분석은 v2.1.205부터). 설치 CC=2.1.204. +- 신규 ENH streak: 21 → **22-cycle 연속 0건**. ENH-367 예약 유지. +- 누적 연속 호환: 141 → **147** (+6). +- 권장 CC: latest **v2.1.204** 허용/권장. +- 신규 carry **MF-2 OPEN**: `cc-version-checker.js` RECOMMENDED_VERSION 2.1.198 → 2.1.204 bump 권장(미구현). +- NOTIFY-BGAGENT P3 ACTIVE 유지, 3대 차별화 streak intact. + +--- + +## 9. Quality Checklist + +- [x] 범위 내 전체 CC 변경 캡처 (98 bullets) +- [x] 모든 변경 impact 분류 (HIGH/MEDIUM/LOW) +- [x] 모든 후보 ENH 우선순위 부여 (전부 YAGNI 보류/제거 → 0건) +- [x] 철학 준수 검증 (Automation First / No Guessing / Docs=Code) +- [x] 파일 영향 매트릭스 (§3) +- [x] Test impact — 코드 변경 0건이므로 TC 영향 없음 +- [x] 한국어 보고서 +- [x] Executive Summary 4-Perspective 가치 표 포함 +- [x] **Raw GitHub CHANGELOG fetch** (raw.githubusercontent.com) +- [x] **Bullet count cross-verified** (raw 직행, 98건, all-match) +- [x] **≥3 스팟체크** verbatim 확인 +- [x] **§2.1 Verification table** 포함 (6-row per-version + 합계) +- [x] Errata 없음 (raw 직행으로 model-WebFetch 환각 원천 차단) +- [x] MEMORY.md 갱신 예정 (§8.1) diff --git a/docs/04-report/features/cc-v2205-v2207-impact-analysis.report.md b/docs/04-report/features/cc-v2205-v2207-impact-analysis.report.md new file mode 100644 index 00000000..04ab513d --- /dev/null +++ b/docs/04-report/features/cc-v2205-v2207-impact-analysis.report.md @@ -0,0 +1,151 @@ +# CC v2.1.204 → v2.1.207 영향 분석 및 bkit 대응 보고서 (ADR 0003 스물일곱 번째 정식 적용) + +> 분석 사이클 #23 · baseline CC v2.1.204 → 설치본 v2.1.207 (v2.1.205~v2.1.207, 3개 릴리스, 74 bullets) +> 분석일: 2026-07-13 · bkit plugin v2.1.29 · 분석 전용(analysis-only), ENH 미구현 + +--- + +## 1. Executive Summary + +### 1.1 최종 판정 + +**호환성 판정: FULLY COMPATIBLE (breaking 0건) · 신규 ENH 0건 → 23-cycle 연속 0-ENH streak.** + +v2.1.205~v2.1.207 범위 74개 bullet 전량을 raw CHANGELOG 바이트에서 직접 검증한 결과, bkit plugin 아키텍처(44 Skills / 34 Agents / 22 Hook Events / 195 Lib Modules)에 대한 **breaking change는 0건**이다. 이번 사이클의 유일한 잠재 리스크였던 **플러그인 계층 2건**(v207 `${user_config.*}` shell-form 거부, v207 pluginConfigs project-settings 미로드)은 코드 측 직접 grep 검증으로 bkit **완전 면역**을 확정했다 — bkit hook 21개는 전부 `${CLAUDE_PLUGIN_ROOT}`만 사용하고 configurable plugin option을 일절 쓰지 않기 때문이다. + +### 1.2 성과 요약 + +- **누적 연속 호환**: 147 → **150** (+3, v2.1.34~v2.1.207, R-2 skip 제외) +- **신규 ENH streak**: **23-cycle 연속 0건**. 전역 마지막 ENH-366, ENH-367 예약 유지(미소비). CC-cycle 마지막 ENH-328. +- **native 개선의 passive 수혜**: bkit가 워크어라운드 없이 이득을 보는 CC 네이티브 개선 6건 식별 (bg-task notification 정직성, prompt-injection 오탐 감소, 스트리밍 프리즈 fix 등) +- **모니터 1건 RESOLVED**: v205 bg-task notification 정직성 fix → **NOTIFY-BGAGENT (P3) 해소** +- **carry MF-2 지속**: `cc-version-checker.js` `RECOMMENDED_VERSION` stale(2.1.198) → 이제 9개 릴리스 뒤처짐, maintainer bump 2.1.207 권장 (분석 전용, 미구현) + +### 1.3 4-Perspective 가치 평가 + +| 관점 | 이번 사이클 가치 | 근거 | +|------|-----------------|------| +| **사용자(User)** | ★★★☆☆ | v207 스트리밍 프리즈 fix → bkit 대시보드(progress-bar·workflow-map·impact-view) 렌더 개선. v207 prompt-injection 오탐 감소 → bkit의 10+ context-injection hook 경고 노이즈 축소 | +| **개발자(maintainer)** | ★★☆☆☆ | 코드 변경 불필요(0 breaking). 유지보수 발견 1건(MF-2 RECOMMENDED_VERSION bump, 이제 9-release stale) | +| **아키텍처(Architecture)** | ★★★☆☆ | 23-cycle 무결. 플러그인 shell-injection 하드닝(v207)에 대해 `${CLAUDE_PLUGIN_ROOT}`-only 컨벤션이 사전 면역 입증. hook-matcher 컨벤션 면역·effort-aware(diff #4)·Sequential Dispatch(diff #3) 정합성 유지 | +| **비즈니스(Business)** | ★★☆☆☆ | v205~207 background-agent/Remote-Control 대량 안정화 → 향후 Agent Teams/bg-agent 채택 시 de-risk 지속(현재 gated·YAGNI) | + +--- + +## 2. CC v2.1.204 → v2.1.207 변경사항 (74 bullets, raw gh authoritative) + +### 2.1 Phase 1.5 Raw Verification Gate 결과 — gh raw CHANGELOG 행 단위 (model-WebFetch 우회) + +메모리 교훈(model-WebFetch cross-section 복제 환각, v2.1.145·v2.1.196 재현)에 따라 `curl raw CHANGELOG` + 헤더 grep + 카테고리 prefix 카운트로 직행 확정. **raw-wins, model-WebFetch 생략.** + +| 버전 | Total | Added/Feature | Fixed | Improved | Changed | 판정 | +|------|-------|---------------|-------|----------|---------|------| +| v2.1.207 | 24 | 3 (auto-mode 확대 + plugin hardening ×2) | 17 | 2 | 2 | raw verified | +| v2.1.206 | 27 | 6 (Added ×2 + feature ×4) | 18 | 2 | 1 | raw verified | +| v2.1.205 | 23 | 5 (Added ×1 + feature ×4) | 15 | 3 | 0 | raw verified | +| **합계** | **74** | **14** | **50** | **7** | **3** | **raw-wins** | + +- 출처: `https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md` (432,284 bytes, 헤더 라인 v207=L3, v206=L30, v205=L60, v204=L86) +- 행 범위: v207 = L3–29, v206 = L30–59, v205 = L60–85 (baseline v204는 L86, 범위 제외) +- 성격: **74 bullet 중 50건(68%)이 Fixed** — 압도적 버그픽스 사이클. 신규 surface는 auto-mode 프로바이더 확대·`/cd` 경로 제안·`/doctor` 확장 등 minor. + +### 2.2 bkit 관련 HIGH 항목 (2건, 전부 코드검증 면역) + +| # | 버전 | 변경 | bkit 영향 | 검증 | +|---|------|------|-----------|------| +| H1 | v207 | Plugin hooks/monitors/MCP headersHelper: `${user_config.*}` shell-form 명령 **거부**(shell-injection fix). exec form(`args`) 또는 `$CLAUDE_PLUGIN_OPTION_` 사용 요구 | **NEUTRAL (면역)** | `grep user_config` = 0건. bkit hook 21개 전부 `command: node "${CLAUDE_PLUGIN_ROOT}/..."` 형태로 `${CLAUDE_PLUGIN_ROOT}`만 보간(항상 허용) | +| H2 | v207 | Plugin option 값(`pluginConfigs`)이 project-level `.claude/settings.json`에서 **미로드**. user/`--settings`/managed만 인정 | **NEUTRAL (면역)** | `grep pluginConfigs` = 0건, `grep CLAUDE_PLUGIN_OPTION` = 0건. bkit는 configurable plugin option 자체를 미정의 | + +> **아키텍처 시사점**: bkit의 `${CLAUDE_PLUGIN_ROOT}`-only + no-plugin-option 설계가 CC의 플러그인 shell-injection 하드닝을 **사전 면역**했다. hook-matcher 컨벤션 면역(no-comma/no-hyphen/pipe)과 같은 계열의 "설계 단순성이 회귀를 흡수" 패턴. + +### 2.3 bkit passive 수혜 (native 개선, 워크어라운드 불필요) — 6건 + +| # | 버전 | 변경 | bkit 수혜 | +|---|------|------|-----------| +| N1 | v205 | Background task notification이 "no human input occurred"를 명시 → fabricated in-transcript approval 차단 | **NOTIFY-BGAGENT 모니터(P3) 근본 해소.** bkit subagent/team handler 경로에서 위조 승인 리스크가 CC 네이티브로 제거 | +| N2 | v207 | 양성 system-generated 대화 업데이트로 인한 **spurious prompt-injection 경고 fix** | bkit의 context-injection hook 10+개(session-start·user-prompt-expansion·pdca-task-completed·subagent-stop 등)가 유발하던 오탐 경고 노이즈 축소 | +| N3 | v207 | 긴 리스트/테이블/코드블록 스트리밍 시 **터미널 프리즈·키입력 지연 fix** | bkit 고정폭 대시보드(progress-bar·workflow-map·impact-view·agent-panel·control-panel) 렌더 안정화 | +| N4 | v207 | `cd` 복합명령이 출력을 `/dev/null`로만 리다이렉트할 때 permission 프롬프트 fix | bkit bash hook(unified-bash-pre/post)이 유발할 수 있던 잉여 프롬프트 감소 | +| N5 | v205 | project verify skill이 매 세션 rewrite되던 것 → 문서화 명령 변경 시에만 rewrite | `/verify` skill churn 제거 (bkit 저장소 verify skill 채택 시 이득) | +| N6 | v206 | MCP 서버의 per-server `request_timeout_ms`가 존중됨(기존 60s 강제 무시 버그 fix) | bkit 2개 MCP 서버(bkit-pdca 15 tools / bkit-analysis 6 tools)는 timeout 미설정 → 기본 60s 유지, 읽기 위주라 무영향(**neutral**) | + +### 2.4 behavior-change 검증 (전부 neutral) + +| 항목 | 변경 | bkit 판정 | 근거 | +|------|------|-----------|------| +| v207 model default | Bedrock/Vertex/Claude Platform on AWS가 **Opus 4.8 기본**으로 전환 | **NEUTRAL** | 프로바이더 레벨 default. bkit 에이전트는 frontmatter에 명시적 모델 pin → 무영향. `FABLE_MODEL_FLOOR='2.1.170'` 유지 | +| v207 auto-mode 확대 | Bedrock/Vertex/Foundry에서 `CLAUDE_CODE_ENABLE_AUTO_MODE` opt-in 없이 auto mode 가용 | **NEUTRAL** | bkit는 CC auto-mode에 의존 안 함(자체 L0–L4 automation). `disableAutoMode` 세팅으로 회피 가능 | +| v207 autoMode 소스 | auto mode가 `.claude/settings.local.json`의 `autoMode`를 더 이상 안 읽음 | **NEUTRAL** | bkit는 `autoMode` 키 미사용(grep 0건) | +| v205 json-schema strict | `--json-schema` invalid 시 무구조 출력 방지, `format` 키워드 거부 | **NEUTRAL** | bkit `--json-schema`/`"format":` 사용 0건. STOP-SCHEMA-STRICT(ENH-366)는 이미 RESOLVED, 회귀 없음 | + +--- + +## 3. bkit 영향 매트릭스 + +| 컴포넌트 | 규모 | breaking | ENH | 상태 | +|----------|------|----------|-----|------| +| Skills | 44 | 0 | 0 | ✅ 무영향 | +| Agents | 34 | 0 | 0 | ✅ 무영향 (모델 pin 유지) | +| Hook Events | 22 (25 blocks) | 0 | 0 | ✅ 무영향 (`${CLAUDE_PLUGIN_ROOT}`-only 면역) | +| Lib Modules | 195 | 0 | 0 | ✅ 무영향 | +| MCP Servers | 2 (21 tools) | 0 | 0 | ✅ 무영향 (timeout 기본 60s, N6 neutral) | +| Scripts | 50 | 0 | 0 | ✅ 무영향 | + +**철학 준수(Philosophy Compliance)**: Automation First / No Guessing / Docs=Code 3원칙 대비 이번 CC 변경으로 위반·기회 발생 0건. 모든 판정은 raw-byte 또는 직접 grep 근거(No Guessing 준수). + +--- + +## 4. Phase 3 브레인스토밍 (Plan Plus) — ENH 도출 + +### 4.1 Intent Discovery +- **최대 가치 후보**: N1(NOTIFY-BGAGENT 네이티브 해소) → 별도 구현 없이 모니터 종료. bkit 코드 액션 불필요. +- **놓치면 안 되는 critical**: H1/H2 플러그인 하드닝 — 검증 결과 **면역**이므로 액션 불필요. +- **native가 대체하는 워크어라운드**: NOTIFY-BGAGENT 모니터가 추적하던 리스크를 CC가 흡수 → 모니터 자체를 RESOLVED 처리(코드 아님, 상태 정리). + +### 4.2 YAGNI Review +- 도출 가능한 ENH 후보 전부가 (a) bkit 무영향 또는 (b) CC 네이티브 흡수 → **신규 ENH 0건**. ENH-367 예약 유지(미소비). + +### 4.3 우선순위 +- **P0~P3 신규 항목 없음.** 유일한 액션은 maintainer 영역 carry(MF-2)로, agent 구현 대상 아님. + +--- + +## 5. Carry / Monitor 상태 (사이클 #23 종료 시점) + +| ID | 유형 | 상태 | 비고 | +|----|------|------|------| +| **NOTIFY-BGAGENT** | Monitor (P3) | **RESOLVED** | v205 bg-task notification 정직성 fix로 네이티브 해소 | +| **MF-2** | Maintenance (maintainer) | **OPEN** | `cc-version-checker.js:42` `RECOMMENDED_VERSION='2.1.198'` → 2.1.207 bump 권장 (9-release stale). `MIN='2.1.78'`, `FABLE_MODEL_FLOOR='2.1.170'` 유지 | +| **BG-OTEL-DROP** (#64436) | Monitor | **ACTIVE** | v206 "disused plugin/LSP telemetry" fix는 별건 — OTEL background-agent metrics drop에 대한 code-fix bullet 미관측 | +| **PLUGIN-HOOK-DROP** (#57317) | Monitor | **ACTIVE** | v207 플러그인 변경은 신규 하드닝(shell-injection)이지 hook-drop fix 아님. 차별화 유지 | +| **CHOICE-LOOP** (#64447) | Monitor | **ACTIVE** | 관련 bullet 미관측 | + +**차별화 streak**: 3대 abandoned 이슈(#56293·#57317·#58904) 전부 intact — v205~207에서 code-fix bullet 미관측(raw grep 0건). "닫힘 ≠ 고침" 원칙상 streak break는 code-fix bullet에만 의존. + +--- + +## 6. Quality Checklist + +- [x] 범위 전체 bullet 포착 (74건, raw-byte) +- [x] 모든 변경 영향 분류 (HIGH 2 / passive 6 / behavior 4 / 잔여 fix neutral) +- [x] ENH 우선순위 (신규 0건) +- [x] 철학 준수 검증 (3원칙 위반 0) +- [x] 파일 영향 매트릭스 (§3) +- [x] 테스트 영향 (신규 TC gap 0 — 무영향) +- [x] 한국어 작성 +- [x] Executive Summary 4-perspective 표 +- [x] **Raw GitHub CHANGELOG fetch** (raw.githubusercontent.com, 432KB) +- [x] **Bullet count cross-verified** (헤더 grep + 행범위 sed, model-WebFetch 우회) +- [x] **HIGH 항목 직접 코드 grep 검증** (user_config/pluginConfigs = 0건) +- [x] **아키텍처 재측정** (Skills 44 / Agents 34 = 메모리 일치, 수정 불필요) +- [x] §2.1 Verification table 포함 + +--- + +## 7. 결론 + +CC v2.1.205~v2.1.207은 **68% 버그픽스 중심 사이클**로, bkit plugin v2.1.29에 **breaking 0 / ENH 0**. 이번 사이클의 유일한 잠재 리스크(플러그인 shell-injection 하드닝, pluginConfigs 소스 제한)는 bkit의 `${CLAUDE_PLUGIN_ROOT}`-only·no-option 설계로 **사전 면역**이 입증되었다. NOTIFY-BGAGENT 모니터는 CC 네이티브 fix(v205)로 해소되었고, 유일한 후속은 maintainer 영역의 `RECOMMENDED_VERSION` bump(MF-2, 2.1.198→2.1.207)이다. + +- **누적 연속 호환 150** (v2.1.34~v2.1.207) +- **23-cycle 연속 0-ENH streak** +- **권장 CC: v2.1.207 (latest)** diff --git a/docs/github-stats-bkit-claude-code.md b/docs/github-stats-bkit-claude-code.md index b377565c..ac628fa1 100644 --- a/docs/github-stats-bkit-claude-code.md +++ b/docs/github-stats-bkit-claude-code.md @@ -1,10 +1,10 @@ # GitHub Usage Statistics Report — bkit-claude-code * **Repository**: [popup-studio-ai/bkit-claude-code](https://github.com/popup-studio-ai/bkit-claude-code) -* **Report date**: 2026-06-30 -* **Data through**: 2026-06-29 (GitHub Traffic API has a 1-day delay) -* **Last push**: 2026-06-02 -* **Cumulative data range**: 2026-01-09 ~ 2026-06-29 +* **Report date**: 2026-07-09 +* **Data through**: 2026-07-08 (GitHub Traffic API has a 1-day delay) +* **Last push**: 2026-07-06 +* **Cumulative data range**: 2026-01-09 ~ 2026-07-08 --- @@ -15,76 +15,76 @@ > - **Cumulative Clones / Views**: tracked forward from the 2026-05-30 baseline, combining the baseline with the recorded daily-traffic window on each collection. > - **Cumulative Unique**: sum of daily uniques (counts repeat users across days). -> **Baseline**: cumulative totals are tracked forward from the 2026-05-30 baseline (Clones 344,816). The GitHub Traffic API retains only the last 14 days, so the 05/30–06/15 window is filled from the adjoining daily-traffic rates and the 06/16–06/29 window is taken directly from the API. +> **Baseline**: cumulative totals are tracked forward from the 2026-05-30 baseline (Clones 344,816). The GitHub Traffic API retains only the last 14 days, so the 05/30–06/15 window is filled from the adjoining daily-traffic rates and the 06/16–07/08 window is taken directly from the API. --- -## 🔥 Executive Summary (collected 2026-06-30) +## 🔥 Executive Summary (collected 2026-07-09) -| Metric | Current | Baseline (2026-05-30) | Change | +| Metric | Current | Previous (2026-06-30) | Change | | --- | --- | --- | --- | -| ⭐ **Stars** | **566** | 549 (2026-05-23) | **+17** | -| 🍴 **Forks** | **151** | 141 (2026-05-23) | **+10** | -| 👀 Views (14d) | **1,286** (471 unique) | — | — | -| 📥 Clones (14d) | **16,044** (1,328 unique) | — | — | -| 👀 **Cumulative Views** | **78,902** | 75,689 | **+3,213** | -| 📥 **Cumulative Clones** | **385,974** | 344,816 | **+41,158 (🎉🎉🎉 crossed 380K!)** | -| 👤 **Cumulative Unique Clones** | **52,320** | 45,409 | **+6,911 (🎉 crossed 50K!)** | +| ⭐ **Stars** | **574** | 566 | **+8** | +| 🍴 **Forks** | **148** | 151 | **-3** | +| 👀 Views (14d) | **1,341** (512 unique) | 1,286 | **+55** | +| 📥 Clones (14d) | **15,303** (1,478 unique) | 16,044 | **-741** | +| 👀 **Cumulative Views** | **79,807** | 78,902 | **+905** | +| 📥 **Cumulative Clones** | **397,050** | 385,974 | **+11,076** | +| 👤 **Cumulative Unique Clones** | **54,671** | 52,320 | **+2,351** | ### 🎯 Milestones / Headlines -* **🎉🎉🎉 Cumulative Clones crossed 380K! (385,974)** -* **🎉 Cumulative Unique Clones crossed 50K! (52,320)** -* **⭐ Stars 566 (+17)** · **🍴 Forks 151 (+10)** — steady upward trend. -* **📉 14-day window below the May reading** — 16,044 (day-avg ≈ 1,146) vs the 2026-05-23 window of 25,320 (day-avg ≈ 1,809). +* **📥 Cumulative Clones 397,050 (+11,076)** — knocking on the door of **400K** (≈2,950 away). +* **👤 Cumulative Unique Clones 54,671 (+2,351)** — steady climb past 50K. +* **⭐ Stars 574 (+8)** — continued upward trend; **🍴 Forks 148 (-3)** dipped slightly. +* **📈 14-day Views up** — 1,341 vs the prior 1,286 window; **📥 14-day Clones eased** to 15,303 (day-avg ≈ 1,093) from 16,044. --- -## 👀 Daily Views (last 14 days, 2026-06-16 ~ 2026-06-29) +## 👀 Daily Views (last 14 days, 2026-06-25 ~ 2026-07-08) | Date | Views | Unique | vs. prev day | | --- | --- | --- | --- | -| 06/16 (Mon) | 80 | 44 | — | -| 06/17 (Tue) | 93 | 57 | +16.3% | -| 06/18 (Wed) | 91 | 45 | -2.2% | -| 06/19 (Thu) | 46 | 30 | -49.5% | -| 06/20 (Fri) | 81 | 44 | +76.1% | -| 06/21 (Sat) | 73 | 45 | -9.9% | -| 06/22 (Sun) | **180** | **64** | +146.6% | -| 06/23 (Mon) | 119 | 44 | -33.9% | -| 06/24 (Tue) | 87 | 41 | -26.9% | -| 06/25 (Wed) | 91 | 46 | +4.6% | +| 06/25 (Wed) | 91 | 46 | — | | 06/26 (Thu) | 96 | 47 | +5.5% | | 06/27 (Fri) | 47 | 24 | -51.0% | | 06/28 (Sat) | 117 | 37 | +148.9% | | 06/29 (Sun) | 85 | 45 | -27.4% | -| **14d total** | **1,286** | **471** | | - -> The 14-day Unique total (471) is GitHub's de-duplicated figure across the window, so it is lower than the sum of the per-day Unique column (613), which counts a repeat visitor on each day. +| 06/30 (Mon) | 118 | 48 | +38.8% | +| 07/01 (Tue) | 112 | 56 | -5.1% | +| 07/02 (Wed) | 73 | 41 | -34.8% | +| 07/03 (Thu) | **155** | **76** | +112.3% | +| 07/04 (Fri) | 37 | 27 | -76.1% | +| 07/05 (Sat) | 81 | 30 | +118.9% | +| 07/06 (Sun) | 121 | 65 | +49.4% | +| 07/07 (Mon) | 82 | 49 | -32.2% | +| 07/08 (Tue) | 126 | 56 | +53.7% | +| **14d total** | **1,341** | **512** | | + +> The 14-day Unique total (512) is GitHub's de-duplicated figure across the window, so it is lower than the sum of the per-day Unique column (647), which counts a repeat visitor on each day. --- -## 📥 Daily Clones (last 14 days, 2026-06-16 ~ 2026-06-29) +## 📥 Daily Clones (last 14 days, 2026-06-25 ~ 2026-07-08) | Date | Clones | Unique | vs. prev day | | --- | --- | --- | --- | -| 06/16 (Mon) | **1,939** | 238 | — | -| 06/17 (Tue) | 1,456 | 241 | -24.9% | -| 06/18 (Wed) | 1,511 | 224 | +3.8% | -| 06/19 (Thu) | 1,236 | 233 | -18.2% | -| 06/20 (Fri) | 995 | 171 | -19.5% | -| 06/21 (Sat) | 937 | 177 | -5.8% | -| 06/22 (Sun) | 1,447 | **250** | +54.4% | -| 06/23 (Mon) | 1,179 | 237 | -18.5% | -| 06/24 (Tue) | 1,117 | 249 | -5.3% | -| 06/25 (Wed) | 1,000 | 208 | -10.5% | +| 06/25 (Wed) | 1,000 | 208 | — | | 06/26 (Thu) | 1,003 | 201 | +0.3% | | 06/27 (Fri) | 594 | 110 | -40.8% | | 06/28 (Sat) | 593 | 118 | -0.2% | | 06/29 (Sun) | 1,037 | 203 | +74.9% | -| **14d total** | **16,044** | **1,328** | | - -> The 14-day Unique total (1,328) is GitHub's de-duplicated figure across the window, so it is lower than the sum of the per-day Unique column (2,860). The cumulative audit trail uses the per-day sum (2,860 for this window), as noted in the Data Policy. +| 06/30 (Mon) | 1,347 | 255 | +29.9% | +| 07/01 (Tue) | **1,879** | 330 | +39.5% | +| 07/02 (Wed) | 1,763 | **359** | -6.2% | +| 07/03 (Thu) | 1,334 | 309 | -24.3% | +| 07/04 (Fri) | 713 | 156 | -46.6% | +| 07/05 (Sat) | 620 | 144 | -13.0% | +| 07/06 (Sun) | 1,201 | 308 | +93.7% | +| 07/07 (Mon) | 1,045 | 261 | -13.0% | +| 07/08 (Tue) | 1,174 | 229 | +12.3% | +| **14d total** | **15,303** | **1,478** | | + +> The 14-day Unique total (1,478) is GitHub's de-duplicated figure across the window, so it is lower than the sum of the per-day Unique column (3,191). The cumulative audit trail uses the per-day sum, as noted in the Data Policy. --- @@ -94,10 +94,10 @@ | --- | --- | --- | --- | --- | --- | | Baseline | through 2026-05-29 | 75,689 | 344,816 | 45,409 | tracked-forward baseline | | Interpolated window | 2026-05-30 ~ 06-15 (17d) | 1,927 | 25,114 | 4,051 | from adjoining daily rates | -| Measured window | 2026-06-16 ~ 06-29 | 1,286 | 16,044 | 2,860 | from the Traffic API | -| **Cumulative total** | through 2026-06-29 | **78,902** | **385,974** | **52,320** | | +| Measured window | 2026-06-16 ~ 07-08 | 2,191 | 27,120 | 5,211 | from the Traffic API | +| **Cumulative total** | through 2026-07-08 | **79,807** | **397,050** | **54,671** | | -> **Method**: cumulative = baseline + interpolated window + measured window. The 05/30–06/15 window is interpolated from the average of the adjoining daily-traffic rates — Clones avg(1,808.6, 1,146.0) × 17 days = 25,114; Views avg(134.9, 91.9) × 17 = 1,927 — because the GitHub Traffic API retains only the last 14 days. +> **Method**: cumulative = baseline + interpolated window + measured window. The 05/30–06/15 window is interpolated from the average of the adjoining daily-traffic rates — Clones avg(1,808.6, 1,146.0) × 17 days = 25,114; Views avg(134.9, 91.9) × 17 = 1,927 — because the GitHub Traffic API retains only the last 14 days. The measured window sums all recorded daily entries (06/16 ~ 07/08) using per-day uniques. --- @@ -107,8 +107,9 @@ | --- | --- | --- | --- | --- | --- | --- | --- | | 2026-05-30 | 2026-05-29 | — | — | — | — | 75,689 | 344,816 | | 2026-06-30 | 2026-06-29 | 566 | 151 | 1,286 | 16,044 | 78,902 | 385,974 | +| 2026-07-09 | 2026-07-08 | 574 | 148 | 1,341 | 15,303 | 79,807 | 397,050 | --- -> **Last updated**: 2026-06-30 | Data through: 2026-06-29 +> **Last updated**: 2026-07-09 | Data through: 2026-07-08 > Generated by the `/github-stats` skill (`.claude/skills/github-stats`) · Ledger: `.claude/state/github-stats-ledger.json`