@chrono-meta/fh-gate 1.4.95 → 1.4.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/AGENTS.md +18 -0
  3. package/CHEATSHEET.md +1 -1
  4. package/knowledge/shared/harness-core/fh_detail_protocols.md +12 -0
  5. package/knowledge/shared/harness-core/ship_readiness_gate.md +7 -4
  6. package/knowledge/shared/learnings/subagent_invocations_log.yaml +43 -1
  7. package/package.json +6 -1
  8. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  9. package/plugins/fh-commons/agents/quench-challenger.md +49 -23
  10. package/plugins/fh-commons/skills/convergence-loop/SKILL.md +14 -0
  11. package/plugins/fh-commons/skills/deliberation/SKILL.md +14 -0
  12. package/plugins/fh-commons/skills/mcp-circuit-breaker/SKILL.md +10 -1
  13. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  14. package/plugins/fh-meta/CHANGELOG.md +36 -0
  15. package/plugins/fh-meta/agents/beginner.md +4 -1
  16. package/plugins/fh-meta/agents/challenger.md +7 -1
  17. package/plugins/fh-meta/agents/expert.md +1 -1
  18. package/plugins/fh-meta/agents/fact-checker.md +7 -1
  19. package/plugins/fh-meta/agents/hub-persona-auditor.md +2 -1
  20. package/plugins/fh-meta/agents/main-player.md +4 -1
  21. package/plugins/fh-meta/agents/persona-innovator.md +10 -2
  22. package/plugins/fh-meta/skills/agent-composer/SKILL.md +2 -2
  23. package/plugins/fh-meta/skills/apex-review/SKILL.md +5 -0
  24. package/plugins/fh-meta/skills/asset-placement-gate/SKILL.md +38 -8
  25. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +16 -2
  26. package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +45 -10
  27. package/plugins/fh-meta/skills/corpus-grounding-expander/SKILL.md +14 -5
  28. package/plugins/fh-meta/skills/cross-ecosystem-synergy-detection/SKILL.md +93 -30
  29. package/plugins/fh-meta/skills/deep-clarify/SKILL.md +28 -9
  30. package/plugins/fh-meta/skills/fh/SKILL.md +4 -0
  31. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +64 -8
  32. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +20 -7
  33. package/plugins/fh-meta/skills/goal-quench/SKILL.md +48 -15
  34. package/plugins/fh-meta/skills/goal-quench/SKILL_detail.md +58 -11
  35. package/plugins/fh-meta/skills/harness-doctor/SKILL_detail.md +109 -33
  36. package/plugins/fh-meta/skills/harvest-loop/SKILL.md +6 -1
  37. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +126 -17
  38. package/plugins/fh-meta/skills/install-doctor/SKILL.md +50 -14
  39. package/plugins/fh-meta/skills/install-wizard/SKILL.md +26 -7
  40. package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +68 -21
  41. package/plugins/fh-meta/skills/memory-hygiene/SKILL.md +64 -17
  42. package/plugins/fh-meta/skills/meta-prompt-builder/SKILL.md +38 -4
  43. package/plugins/fh-meta/skills/persona-roster-expander/SKILL.md +15 -7
  44. package/plugins/fh-meta/skills/plugin-recommender/SKILL.md +39 -11
  45. package/plugins/fh-meta/skills/plugin-recommender/SKILL_detail.md +24 -7
  46. package/plugins/fh-meta/skills/prompt-regression/SKILL.md +54 -11
  47. package/plugins/fh-meta/skills/salience-splitter/SKILL.md +120 -7
  48. package/plugins/fh-meta/skills/salience-splitter/SKILL_detail.md +46 -13
  49. package/plugins/fh-meta/skills/sim-conductor/SKILL_detail.md +28 -3
  50. package/plugins/fh-meta/skills/steel-quench/SKILL.md +3 -1
  51. package/plugins/fh-meta/skills/verify-bidirectional/SKILL.md +72 -14
  52. package/scripts/count_check.sh +47 -1
  53. package/scripts/degrade_direction_scan.sh +276 -6
  54. package/scripts/degrade_probe_capability.sh +105 -0
  55. package/scripts/package_coverage_check.sh +8 -0
  56. package/scripts/psa_probe_capability.sh +78 -0
  57. package/scripts/public_surface_scan_files.sh +8 -0
  58. package/scripts/selfcheck.sh +15 -0
  59. package/scripts/test_capability_entrypoint_shipping.sh +132 -0
  60. package/scripts/test_count_check_readme_format_lanes.sh +75 -0
  61. package/scripts/test_degrade_scan_shell_probes.sh +415 -0
  62. package/scripts/validate_yaml.sh +146 -0
  63. package/templates/degrade_direction_scan.sh +276 -6
@@ -43,7 +43,7 @@ A designer's anxiety is most dangerous when vague. steel-quench breaks that anxi
43
43
 
44
44
  | Wave | Role | Termination |
45
45
  |---|---|---|
46
- | **Phase 0** (optional) | Counterexample calibration — extract patterns from external bad cases, merge into Wave 1 | No external case → skip |
46
+ | **Phase 0** (optional) | Counterexample calibration — extract patterns from external bad cases, merge into Wave 1 (→ `SKILL_detail.md §Phase0`) | No external case → skip |
47
47
  | **Wave 1** | Challenger attack (quench-challenger) — surface critical flaws, no defense | — |
48
48
  | **Wave 2** | Defense — defend or state as residual risk | — |
49
49
  | **Wave 3+** | Convergence — repeat until a round is clean AND triggers no repair | Zero new S/A **and no repairs made in response, B included** (§Convergence Criteria) |
@@ -52,6 +52,8 @@ A designer's anxiety is most dangerous when vague. steel-quench breaks that anxi
52
52
  | **Wave 5** (optional) | Multi-Team Adversarial Panel — external CLIs or cross-session Claude | Wave 3+ criterion, cross-team |
53
53
  | **Wave-T** (after convergence) | Temper — measure complexity the quench *added*; flag over-hardening | τ-PASS or named τ-FAIL |
54
54
 
55
+ > **Detail**: See `SKILL_detail.md §Phase0` — counterexample calibration full spec (pattern extraction from an external bad case, merge rules into Wave 1) — read when an external bad case is supplied.
56
+
55
57
  ---
56
58
 
57
59
  ## Step 0.3 — Artifact Vulnerability Profile
@@ -107,13 +107,22 @@ First identify the list of affected assets. Actual file modification is performe
107
107
 
108
108
  | Affected Asset | Update Location | Notes |
109
109
  |---|---|---|
110
- | memory `feedback_bidirectional_self_validation.md` | Add cumulative count + new round table | Self-perpetuation of this rule |
110
+ | This SKILL.md §Validation Ledger | Add cumulative count + new round entry | Self-perpetuation of this rule. (Was `memory feedback_bidirectional_self_validation.md` — **absent, verified 2026-08-12**; the rule body is this file.) |
111
111
  | memory `project_*.md` (if affected) | Add relevant section | When naming, identity, or roadmap changes |
112
112
  | `tracks/_audit/*.md` (if affected) | Add pre-design section | When this validation affects persistent assets |
113
113
  | `CATALOG.md` | Add this session entry | For major decisions |
114
114
  | `reference_next_session_starter.md` §1 | Merge this conclusion | Material for next session entry |
115
115
 
116
- **Markdown editing discipline** (`feedback_markdown_edit_discipline`): Use Edit for existing `.md`. Write prohibited. If unavoidable, verify immediately with `git diff`.
116
+ **Markdown editing discipline** this skill's own rule, stated here rather than delegated:
117
+
118
+ > Use `Edit` for any `.md` that already exists. `Write` on an existing file replaces it whole and
119
+ > silently discards content this session never read — that is the failure mode the rule exists for,
120
+ > not a style preference. If a full rewrite is genuinely unavoidable, `Read` the file first, then
121
+ > verify immediately with `git diff` and report what changed.
122
+
123
+ (Previously cited as `feedback_markdown_edit_discipline` — that memory key is **absent, verified
124
+ 2026-08-12**. The rule is live; only the pointer was dead, so it is written out here. A prohibition
125
+ that rests on a file nobody can open is not enforceable.)
117
126
 
118
127
  ### Step 4.5. Change `diff` Review (User Gate Required)
119
128
 
@@ -144,7 +153,9 @@ Skip this step if no compatibility enhancement found (no token-filler).
144
153
 
145
154
  ### Step 6. Update Trigger Count + Skill Update Review
146
155
 
147
- Update trigger count in `memory feedback_bidirectional_self_validation.md`:
156
+ Update the trigger count in the **§Validation Ledger** at the bottom of this file. (It formerly
157
+ pointed at `memory feedback_bidirectional_self_validation.md`, **absent, verified 2026-08-12** —
158
+ so the count had nowhere to land and this step could not actually be performed.)
148
159
 
149
160
  - 5+ accumulated = Skill promotion review (already fulfilled by creating this skill ✅)
150
161
  - 8+ accumulated = skill update review (rule refinement + round table compression + update this skill)
@@ -209,8 +220,8 @@ Speak up **before** entering implementation if any of these apply:
209
220
 
210
221
  - **This skill = validation and recording automation. Core decisions belong to the user** — this harness AI has no independent decision authority
211
222
  - **This harness AI self-catch cannot be applied alone** — follow `fact-checker` rule (narrow 1 / broad N+1)
212
- - **Simplification guard compliance** (`feedback_simplification_evidence`) — when creating/modifying this skill, only update SKILL.md. Do not create additional auxiliary files
213
- - **Markdown editing discipline obligation** (`feedback_markdown_edit_discipline`)prefer Edit. Write prohibited
223
+ - **Simplification guard compliance** — when creating or modifying this skill, update SKILL.md only. Do not spawn auxiliary files; a change that needs a new file needs a reason stated in the same edit. (Formerly cited `feedback_simplification_evidence` — **absent, verified 2026-08-12**)
224
+ - **Markdown editing discipline obligation** — `Edit` on existing `.md`; `Write` only after reading the file, and verified with `git diff` (full statement in Step 4 above). (Formerly cited `feedback_markdown_edit_discipline` — **absent, verified 2026-08-12**)
214
225
 
215
226
  ## External User Environment Adaptation
216
227
 
@@ -220,7 +231,7 @@ This skill's core essence = "channel for updating baseline when user refinement
220
231
 
221
232
  | Origin Environment Dependency | External User Environment Fallback |
222
233
  |---|---|
223
- | `memory feedback_bidirectional_self_validation.md` (rule body) | User environment's own `memory/` or `notes/` bidirectional validation rule / if absent, follow this skill's own rule baseline |
234
+ | Rule body — **this SKILL.md itself** (the origin environment's `memory feedback_bidirectional_self_validation.md` is **absent, verified 2026-08-12**; there is no origin-side file to fall back from) | User environment's own `memory/` or `notes/` bidirectional validation rule / if absent, this skill's own rule baseline — which is now the only baseline in either environment |
224
235
  | `memory feedback_*.md` grep scope (Step 2 priority 1) | User environment's own `learnings/` · `docs/` · `CLAUDE.md` grep (user's own baseline) |
225
236
  | `tracks/*/learnings/feedback_*.md` (Step 2 priority 2) | User environment's own learnings area (auto-detect naming variations) |
226
237
  | `knowledge/shared/harness-core/*.md` (Step 2 priority 4) | User environment's own `docs/` or `knowledge/` grep |
@@ -245,17 +256,64 @@ This skill's core essence = "channel for updating baseline when user refinement
245
256
  ## Done When
246
257
 
247
258
  ```
248
- Steps 1~6 all executed
249
- + fact-checker self-catch mark output
250
- + Step 4.5 diff gate user confirmation complete (y/N response received)
251
- + Update trigger count updated
252
- + External validation path: harvest-loop's Critic isolation pass (SAGE automated critique) can independently judge based on above criteria (skill_quality_rubric.md verifiable criteria)
259
+ Steps 1~6 all executed — a step that produced no artifact is
260
+ not executed (measured: 6 steps, 6 artifacts)
261
+ Step 1 baseline update applied BEFORE Step 2 grep runs — the
262
+ order is the point of the skill, not a formality (mandatory-pass)
263
+ Step 2 consistency grep ran across all 4 priority scopes, and
264
+ each scope reports hits or an explicit "0 hits, scope exists"
265
+ — a scope that does not exist reports MISSING, never 0 (measured: 4 scopes reported)
266
+ ☐ fact-checker self-catch mark output (mandatory-pass)
267
+ ☐ Step 4.5 diff gate: user confirmation received (y/N) before
268
+ any file was modified; no answer == N (mandatory-pass, HITL)
269
+ ☐ §Validation Ledger has one new row and the Count line matches
270
+ the row total (measured: rows == Count)
271
+ ☐ Every memory/asset key cited in the round was confirmed to
272
+ RESOLVE before being used as grounds (mandatory-pass — an absent key
273
+ is cited as absent, never as
274
+ support)
275
+ ☐ The counter-argument was actually re-examined rather than
276
+ conceded to (judged — adversarial pairing:
277
+ `fact-checker` (narrow 1 /
278
+ broad N+1) re-runs the grep
279
+ independently; agreement with
280
+ the user is not evidence the
281
+ user was right)
282
+ ☐ External validation path available: harvest-loop's Critic
283
+ isolation pass can independently judge on the above criteria (judged — adversarial pairing:
284
+ Critic runs isolated, not in
285
+ this session's context)
253
286
  ```
254
287
 
288
+ > **Why the last two are judged, and paired.** This skill fires when the user pushes back, which is
289
+ > precisely the moment agreement is cheapest. A Done When that only counts steps is satisfied by a
290
+ > run that folded immediately. The pairing is the whole check: an isolated re-grep, and an isolated
291
+ > critic — neither of which has seen this session's reasoning.
292
+
255
293
  Verdict: PASS (Step 4.5 diff gate confirmed, baseline updated) | CONDITIONAL_PASS (update applied, external validation still pending) | FAIL (counter-argument confirmed — AI recommendation was wrong, baseline requires redesign) | ESCALATE (counter-argument ambiguous, human judgment required)
256
294
 
257
295
  ## References
258
296
 
259
- - Rule body: `memory feedback_bidirectional_self_validation.md`
260
- - Operating model text: `memory feedback_hub_cc_operating_model.md §2.5·§2.6` — Insight 5 meta dimension + refinement challenge 4 patterns
261
- - Consistency rules: `feedback_external_ai_github_recommendation_verification` · `feedback_reference_own_hub_assets_first` · `feedback_simplification_evidence` · `feedback_markdown_edit_discipline` · `feedback_impact_first_then_tune`
297
+ > **All memory keys below were checked on 2026-08-12 and every one is absent** (same-run
298
+ > known-positive control: `feedback_verify_before_downgrade.md` and `memory_intent_recall.md`
299
+ > both resolve, so the check is not misreporting). A struck entry here is not a fix if the same
300
+ > name is still load-bearing above — the rules those keys carried have been written into the body
301
+ > of this skill (Step 4 markdown discipline, §Constraints, §Validation Ledger), which is why the
302
+ > strikethroughs below are safe to leave as history.
303
+
304
+ - ~~Rule body: `memory feedback_bidirectional_self_validation.md`~~ — **absent (verified 2026-08-12)**. The rule body is **this file**; the cumulative count lives in §Validation Ledger.
305
+ - ~~Operating model text: `memory feedback_hub_cc_operating_model.md §2.5·§2.6`~~ — **absent (verified 2026-08-12)**. The live equivalent is `CLAUDE.md §Identity — 3-Layer Mission + Core Axis`; cite that, not this filename.
306
+ - ~~Consistency rules: `feedback_external_ai_github_recommendation_verification` · `feedback_reference_own_hub_assets_first` · `feedback_simplification_evidence` · `feedback_markdown_edit_discipline` · `feedback_impact_first_then_tune`~~ — **absent (verified 2026-08-12)**. The two that this skill actually depends on are restated in §Constraints above.
307
+ - Live, verified references (these resolve): `tracks/_meta/reference_next_session_starter.md` (session card, Step 4) · `CATALOG.md` · the `fact-checker` agent (Step 3).
308
+
309
+ ## Validation Ledger
310
+
311
+ Cumulative trigger count for Step 6. Append one row per completed round; the count is the number of
312
+ rows. This section is the landing site that the absent memory key used to be.
313
+
314
+ | # | Date | Trigger (user's counter-argument, one line) | Verdict | Baseline change |
315
+ |---|---|---|---|---|
316
+ | — | — | (no rounds recorded in-file yet — prior rounds lived in the now-absent memory key and are not recoverable) | — | — |
317
+
318
+ **Count**: 0 recorded rounds. Do not restate a remembered historical total here; an unrecoverable
319
+ count is `unknown`, not a number carried over from a file nobody can open.
@@ -91,7 +91,53 @@ count_check() { # count_check <label> <file> <expected-string>
91
91
  count_check "fh-meta plugin.json" plugins/fh-meta/.claude-plugin/plugin.json "${meta_sk} skills + ${meta_ag} agents"
92
92
  count_check "fh-commons plugin.json" plugins/fh-commons/.claude-plugin/plugin.json "${com_sk} skills"
93
93
  count_check "marketplace.json fh-meta" .claude-plugin/marketplace.json "${meta_sk} skills + ${meta_ag} agents"
94
- count_check "README header" README.md "${total_sk} skills · ${total_ag} agents"
94
+ # ── README 렌더링만 per-repo override 를 받는다 ──────────────────────────────────────────────
95
+ # 왜 이 한 줄만 다른가: plugin.json·marketplace.json 의 문자열은 **기계 결합**(영문 고정,
96
+ # 소비처가 파싱한다)이지만 README 헤더는 **사람이 읽는 산문**이라 하류 하네스가 자기 언어로 쓴다.
97
+ # 이 스크립트는 공유층이라 여기에 렌더링을 하드코딩하면 다음 sync 가 하류의 지역화를 덮고,
98
+ # 그 레인은 **이식 이후 한 번도 통과 못 하는 검사**가 된다 — 통과 불가능한 게이트는 게이트가
99
+ # 아니라 `--no-verify` 훈련기다(실측: pmh-dev #55 가 그 상태를 손으로 되돌려야 했고, 다음
100
+ # sync 에 다시 깨질 예정이었다. 이슈 chronoloy-dev/pmh-dev#57).
101
+ #
102
+ # 규약: 소비 레포는 `.claude/rules/count_readme_format` 에 printf 템플릿 한 줄을 둔다
103
+ # (`%s` 두 개 = skills 수, agents 수). 그 파일을 **자기 sync 제외목록에 등재**해야 한다 —
104
+ # 등재 안 하면 이 override 자체가 덮이므로, 그 등재가 이 규약의 전제다.
105
+ # 부재 → 영문 기본값. 즉 FH 자신과 지역화 안 한 설치는 종전과 동일하다(fail-closed 아님:
106
+ # README 렌더링은 가역 표면이고, 없는 파일을 요구하면 새 설치가 전부 막힌다).
107
+ README_FMT_FILE="${COUNT_README_FORMAT_FILE:-.claude/rules/count_readme_format}"
108
+ if [ -r "$README_FMT_FILE" ]; then
109
+ README_FMT=$(head -1 "$README_FMT_FILE")
110
+ case "$README_FMT" in
111
+ *%s*%s*) : ;; # %s 두 개 필수 — 하나뿐이면 두 번째 수치가 검사에서 빠진다(무음 축소)
112
+ *) echo "FAIL count: README format override 가 %s 를 두 개 갖지 않는다: '$README_FMT' ($README_FMT_FILE)"; fail=1; README_FMT="" ;;
113
+ esac
114
+ else
115
+ README_FMT='%s skills · %s agents'
116
+ fi
117
+ # 렌더 결과를 **검사한 뒤에** 패턴으로 쓴다. 렌더된 문자열이 그대로 `grep -qE` 패턴이 되므로:
118
+ # · 개행이 들어가면 grep -E 는 그것을 패턴 구분자로 읽어 **OR 검색**이 된다 — 실측: 템플릿
119
+ # `%s\n%s` 는 stale README(99/99)를 PASS 시켰다(뒷조각 `8` 이 "Node 18" 에 걸림).
120
+ # · printf 가 실패해도(잘못된 지시자) rc 를 안 봐서 **부분 렌더**가 패턴이 됐다.
121
+ # 이건 mandatory-pass 게이트이고 selfcheck→prepublishOnly · pre-commit · CI 세 경계에 물려 있다.
122
+ # 대상 독자가 하류 하네스 저자(한국어 템플릿 끝에 개행을 붙이는 건 자연스러운 실수)라 더 그렇다.
123
+ # 기존 가드 `*%s*%s*` 는 "%s 가 둘인가" 만 보지 **렌더 결과**를 안 본다. (배포 직전 보안 패스 A-2)
124
+ if [ -n "$README_FMT" ]; then
125
+ # shellcheck disable=SC2059 # 템플릿은 선언 파일에서 온다 — 그게 이 기능의 요점
126
+ README_STR=$(printf "$README_FMT" "$total_sk" "$total_ag") || {
127
+ echo "FAIL count: README format override 를 렌더할 수 없다: '$README_FMT' ($README_FMT_FILE)"; fail=1; README_STR=""; }
128
+ case "$README_STR" in
129
+ *"
130
+ "*) echo "FAIL count: README format override 가 개행을 렌더한다 — grep -E 패턴이 쪼개져 OR 검색이 된다: '$README_FMT'"; fail=1; README_STR="" ;;
131
+ esac
132
+ # 두 수치가 실제로 렌더 결과에 들어갔는지(`%.0s` 류가 값을 삼키는 경우 차단)
133
+ if [ -n "$README_STR" ]; then
134
+ case "$README_STR" in *"$total_sk"*) : ;; *) echo "FAIL count: 렌더 결과에 skills 수($total_sk)가 없다: '$README_STR'"; fail=1; README_STR="" ;; esac
135
+ fi
136
+ if [ -n "$README_STR" ]; then
137
+ case "$README_STR" in *"$total_ag"*) : ;; *) echo "FAIL count: 렌더 결과에 agents 수($total_ag)가 없다: '$README_STR'"; fail=1; README_STR="" ;; esac
138
+ fi
139
+ [ -n "$README_STR" ] && count_check "README header" README.md "$README_STR"
140
+ fi
95
141
  count_check "local_fh_context fh-meta" templates/local_fh_context.md "(fh-meta, ${meta_sk})"
96
142
 
97
143
  if [ "$fail" -ne 0 ]; then
@@ -40,10 +40,159 @@ PASS='(True|"PASS"|'"'"'PASS'"'"'|"ALLOW"|'"'"'ALLOW'"'"'|"OK"|'"'"'OK'"'"'|"VAL
40
40
  # incl. this gate's own pre-push/pre-commit-hook trigger category — must not be invisibly dropped).
41
41
  # Anything else is tracked as UNSCANNABLE so a load-bearing surface in another language is reported
42
42
  # as "not covered", never silently folded into an "advisory clean" (M#2, steel-quench 2026-07-03).
43
- FILES=(); UNSCANNABLE=()
43
+ FILES=(); UNSCANNABLE=(); MD_ORIGIN=(); UNMEASURED=(); DISCOVERY_ERR=()
44
+ # UNSCANNABLE vs UNMEASURED — 한 통에 넣으면 S 가 살아난다(재검 2026-08-12 지목).
45
+ # UNSCANNABLE = 측정 대상이 아니다(펜스 없는 산문 md, py/sh 아닌 파일). 정상 상태.
46
+ # UNMEASURED = 재려 했는데 **못 쟀다**(python3/cksum 부재로 추출 실패). 이건 절대 CLEAN 이 아니다.
47
+ # 첫 수리는 둘을 UNSCANNABLE 하나에 담았고, 그 배열은 `FILES` 가 **완전히 빌 때만** exit 2 로
48
+ # 승격된다 — 스캔 가능한 .sh 가 하나라도 섞이면 추출 실패가 note 한 줄로 강등되고 rc=0 이 나갔다.
49
+ # 즉 「측정 못 함을 CLEAN 으로 렌더」가 지배 경로(디렉터리 스캔 → typed capability)에 그대로 남아
50
+ # 있었다 — 이 릴리스가 고치겠다고 선언한 바로 그것이다.
51
+
52
+ # ── Markdown fence extraction (2026-08-11) ────────────────────────────────────────────────────
53
+ # The bash this harness actually EXECUTES largely does not live in .sh files — it lives in ```bash
54
+ # fences inside SKILL.md / SKILL_detail.md, and the scanner could not see a single line of it.
55
+ # Measured that day: one sweep batch extracted 61 fenced blocks across 12 skills and found the
56
+ # S5 pipefail-fallback class alive in four of them (harness-doctor E3/E7/Step-11, install-doctor),
57
+ # each rendering a dead check as a PASS. The scanner was not wrong before — it reported those files
58
+ # as UNSCANNABLE (exit 2, "NOT scanned, NOT clean"), which is the honest degrade. But "honestly
59
+ # unmeasured" on the surface where the code actually lives is a coverage hole, not a resolution.
60
+ #
61
+ # Method: for a markdown file, write a shadow .sh whose fenced-block lines sit at their ORIGINAL
62
+ # line numbers and whose every other line is blank. Line numbers then map 1:1, so an emitted
63
+ # finding points at the real file:line with no offset arithmetic to get wrong. A markdown file
64
+ # with NO bash fence stays UNSCANNABLE — nothing was extracted, so nothing was measured.
65
+ # python3 는 이 레포의 선언된 런타임이 아니다(`package.json engines` = node 만). 부재하면 추출이
66
+ # 불가능하고, 그때 md 를 FILES 에도 UNSCANNABLE 에도 넣지 않으면 **요약에서 통째로 사라져**
67
+ # "no smells in N scanned files / exit 0" 이 된다 — 이 릴리스가 고치는 바로 그 클래스를 수리가
68
+ # 재생산한 것이다(배포 직전 보안 패스가 적발, 2026-08-12). 자매 스크립트들은 이미 옳게 한다:
69
+ # validate_yaml → UNCALIBRATED + exit 2, package_coverage → exit 1. 여기도 같은 방향으로 맞춘다.
70
+ _MD_OK=1
71
+ # `cut` joined this list 2026-08-12 (cross-family, gpt-5.5): the uniqueness token is produced by
72
+ # `cksum | cut`, and gating only `cksum` left the pipeline half-guarded — with `cut` missing the
73
+ # token is EMPTY, which restores the A-1 shadow-name collision the cksum guard exists to prevent.
74
+ # Guarding one stage of a pipeline is not guarding the pipeline.
75
+ for _dep in python3 cksum cut; do command -v "$_dep" >/dev/null 2>&1 || _MD_OK=0; done
76
+ # `find` is not optional and its absence is not a small result: EVERY target file — .sh, .py and
77
+ # .md alike — is discovered through it, and the discovery calls redirect stderr. Without this gate
78
+ # a missing/broken `find` yields an empty file list and the run prints "no scannable target files"
79
+ # and exits 0 = clean, on a corpus it never looked at. Same class the fence work exists to close,
80
+ # one layer further up (cross-family, gpt-5.5, 2026-08-12).
81
+ if ! command -v find >/dev/null 2>&1; then
82
+ echo "degrade-scan: INSTRUMENT ERROR — \`find\` is unavailable, so NO file was discovered."
83
+ echo "This is not 'clean' and not 'no targets'; nothing was measured. Fix PATH and re-run."
84
+ exit 2
85
+ fi
86
+ # cksum 도 미선언 의존성이다 — 첫 수리는 python3 만 가드하고 cksum 은 rc 미검사로 도입했다.
87
+ # 부재 시 유일성 토큰이 **빈 문자열**이 돼 shadow 이름 충돌(A-1)이 그대로 부활한다(실증).
88
+ # mktemp 실패 시 예측 가능한 경로(/tmp/degradescan.$$)로 폴백하던 것을 제거했다: 그 경로를 mkdir
89
+ # 하지 않아 정상 폴백은 100% 실패하고, 반대로 공격자가 그 이름에 심링크를 심어두면 **성공해서
90
+ # 레포 밖에 쓴다**(실증됨). 즉 "공격받을 때만 동작하는 코드" 였다.
91
+ _MD_TMP="$(mktemp -d 2>/dev/null)" || { _MD_TMP=""; _MD_OK=0; }
92
+ trap '[ -n "$_MD_TMP" ] && rm -rf "$_MD_TMP"' EXIT
93
+ # LANGUAGE-SPLIT shadows (2026-08-12). The first version extracted only ```bash-family fences, and
94
+ # extending it to ```python by widening the same regex would have written python source into a `.sh`
95
+ # shadow — where `is_sh` is decided by the extension, so the SHELL probes would run on python and the
96
+ # python probes' own file-shape assumptions would be met by accident. That is precisely the
97
+ # "collected but probed with the wrong family" false-clean this scanner already names (n+10 below).
98
+ # So the extraction is per-language and each language gets its own shadow extension; one markdown
99
+ # file can now yield up to two shadows, and `is_sh` keeps deciding correctly by extension.
100
+ # WHY python at all: `except: pass` / `.get(k, True)` — the shapes probes A–E exist for — live in
101
+ # ```python fences, not in .py files, in this corpus. install-doctor's S came from exactly there.
102
+ _md_shadow() { # $1 = markdown path; $2 = fence language class (sh|py)
103
+ # echoes shadow path, or nothing when that language has no fence in the file
104
+ # 반환 규약(2026-08-12, cross-family 지적으로 분리): 0 = 추출됨 · **1 = 그 언어 펜스가 없다(정상)**
105
+ # · **2 = 추출을 시도했으나 실패했다(못 쟀다)**. 첫 판은 둘을 똑같이 1 로 접었고, 그래서 읽을 수
106
+ # 없는 SKILL.md 한 개가 「펜스 없음」= UNSCANNABLE 로 렌더돼 같은 디렉토리에 깨끗한 .sh 가 하나라도
107
+ # 있으면 전체가 exit 0 = clean 으로 나갔다. 이 파일이 존재하는 이유인 바로 그 클래스다.
108
+ local src="$1" lang="$2" out ext pat tok rc
109
+ [ "$_MD_OK" = 1 ] || return 2 # 추출 불가 → 호출부가 UNMEASURED 로 보낸다(드롭 금지)
110
+ case "$lang" in
111
+ sh) ext=sh; pat='bash|sh|shell|zsh' ;;
112
+ py) ext=py; pat='python|python3|py' ;;
113
+ *) return 2 ;; # 알 수 없는 언어 클래스 = 추출 실패, 조용한 성공 금지
114
+ esac
115
+ # 경로 해시를 붙인다: `tr '/' '_'` 만 쓰면 a/b.md 와 a_b.md 가 **같은 shadow** 로 매핑돼
116
+ # 뒤엣것이 앞엣것을 덮고, 결함 파일이 통째로 사라진 채 카운트는 2로 찍힌다(실증됨).
117
+ # 확장자가 언어를 가르므로 같은 md 의 sh/py shadow 는 서로 안 덮는다.
118
+ tok=$(printf '%s' "$src" | cksum | cut -d' ' -f1)
119
+ # 토큰이 비면 유일성이 사라져 a/b.md 와 a_b.md 가 같은 shadow 로 접힌다. 빈 값을 그냥 쓰지 말고
120
+ # 「못 쟀다」로 나가라 — 조용히 충돌한 쪽이 항상 더 나쁘다.
121
+ case "$tok" in ''|*[!0-9]*) return 2 ;; esac
122
+ out="$_MD_TMP/$(echo "$src" | tr '/' '_')-$tok.$ext"
123
+ python3 - "$src" "$out" "$pat" <<'PYEOF'
124
+ import sys, re
125
+ src, out, pat = sys.argv[1], sys.argv[2], sys.argv[3]
126
+ lines = open(src, encoding='utf-8', errors='replace').read().split('\n')
127
+ shadow = [''] * len(lines)
128
+ inside = False
129
+ found = False
130
+ for i, l in enumerate(lines):
131
+ s = l.strip()
132
+ if not inside and re.match(r'^```(' + pat + r')\s*$', s):
133
+ inside = True; continue
134
+ if inside and s == '```':
135
+ inside = False; continue
136
+ if inside:
137
+ shadow[i] = l; found = True
138
+ open(out, 'w', encoding='utf-8').write('\n'.join(shadow))
139
+ sys.exit(0 if found else 3)
140
+ PYEOF
141
+ rc=$?
142
+ case $rc in
143
+ 0) ;; # 추출 성공
144
+ 3) return 1 ;; # 그 언어의 펜스가 이 파일에 없다 — 정상 상태
145
+ *) return 2 ;; # 읽기 실패·인코딩·인터프리터 오류 — 못 쟀다. 「펜스 없음」과 같은 값 금지
146
+ esac
147
+ printf '%s' "$out"
148
+ }
149
+ # 한 md 에서 sh·py 두 shadow 를 모두 수집한다. 어느 쪽도 안 나오면 그때만 UNSCANNABLE/UNMEASURED.
150
+ # 반환: 0 = 하나 이상 수집됨, 1 = 그 파일에 스캔 대상 펜스 없음, 2 = 추출 자체가 불가(못 쟀다)
151
+ _md_collect() {
152
+ local src="$1" lang shp got=0 err=0 rc
153
+ for lang in sh py; do
154
+ shp=$(_md_shadow "$src" "$lang"); rc=$?
155
+ case $rc in
156
+ 0) [ -n "$shp" ] && { FILES+=("$shp"); MD_ORIGIN+=("$shp=$src"); got=1; } ;;
157
+ 1) ;; # 이 언어 펜스 없음 — 정상
158
+ *) err=1 ;; # 추출 실패 — 이 파일은 못 쟀다
159
+ esac
160
+ done
161
+ # 실패가 하나라도 있으면 «못 쟀다»가 이긴다. 다른 언어에서 뭔가 건졌다는 사실이
162
+ # 못 잰 언어를 덮으면 부분 커버리지가 완전 커버리지로 렌더된다.
163
+ [ "$err" = 1 ] && return 2
164
+ [ "$got" = 1 ] && return 0
165
+ return 1
166
+ }
44
167
  for t in "${TARGETS[@]}"; do
168
+ # HYPHEN-LEADING TARGETS are normalized ONCE, here, for every branch (2026-08-12, round 6).
169
+ # The first fix did this inside the directory branch only, so a direct FILE target named
170
+ # `-bad.sh` was collected verbatim and every later `grep "$f"` parsed it as an option cluster —
171
+ # producing "no smells in 1 scanned file, exit 0" for a file carrying a known-positive. Fixing
172
+ # one branch of a two-branch dispatch is the half-fix shape this repo already has a scan for.
173
+ case "$t" in
174
+ -*) t="./$t" ;;
175
+ esac
45
176
  if [ -d "$t" ]; then
46
- while IFS= read -r f; do FILES+=("$f"); done < <(find "$t" -type f \( -name '*.py' -o -name '*.sh' \) 2>/dev/null)
177
+ # SYMLINKED TARGET (2026-08-12, cross-family gpt-5.5): `[ -d ]` follows a symlink, `find` does
178
+ # not — `find /path/to/symlink -type f` enumerates NOTHING and the run printed "no scannable
179
+ # (py/sh) target files" and exited 0 for a directory full of known-positives. `-H` makes find
180
+ # follow the command-line argument only (not links found during the walk, which is what would
181
+ # invite cycles). Measured: symlink target → 0 files without it, 1 with it.
182
+ # (`$t` is already `./`-normalized above, so no per-branch hyphen handling is needed here.)
183
+ _T=(-H "$t")
184
+ # DISCOVERY ERRORS (2026-08-12, cross-family gpt-5.5): the `find` preflight above proves the
185
+ # BINARY exists; it says nothing about whether this particular traversal worked. Every discovery
186
+ # call below redirects stderr and reads its output through a process substitution, which throws
187
+ # the exit status away — so an unreadable target directory produced an empty file list and the
188
+ # run printed "no scannable (py/sh) target files" and exited 0 = CLEAN, having looked at nothing.
189
+ # Reproduced with a mode-000 directory containing a known-positive `bad.sh`.
190
+ # One extra traversal per target is the price; a silent zero is not an acceptable alternative.
191
+ _derr=$(find "${_T[@]}" -type f 2>&1 >/dev/null); _drc=$?
192
+ if [ "$_drc" -ne 0 ] || [ -n "$_derr" ]; then
193
+ DISCOVERY_ERR+=("$t — ${_derr:-find exited $_drc}")
194
+ fi
195
+ while IFS= read -r f; do FILES+=("$f"); done < <(find "${_T[@]}" -type f \( -name '*.py' -o -name '*.sh' \) 2>/dev/null)
47
196
  # Shebang pass — this is what makes git hooks visible at all. Measured 2026-07-28:
48
197
  # `templates/.git-hooks` (files named `pre-push`, no extension, under a dotted directory) —
49
198
  # FH's own mechanical floor — reported "no scannable (py/sh) target files", exit 0.
@@ -60,16 +209,48 @@ for t in "${TARGETS[@]}"; do
60
209
  *.md|*.json|*.yaml|*.yml|*.txt|*.lock|*.png|*.jpg|*.svg|*.pdf|*.zip) continue ;;
61
210
  esac
62
211
  head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(ba|z|k)?sh\b' && FILES+=("$f")
63
- done < <(find "$t" -type f 2>/dev/null)
212
+ done < <(find "${_T[@]}" -type f 2>/dev/null)
213
+ while IFS= read -r f; do
214
+ _md_collect "$f"
215
+ case $? in
216
+ 0) ;; # sh/py 펜스에서 하나 이상 수집됨
217
+ 1) UNSCANNABLE+=("$f") ;; # 펜스가 없다 — 측정 대상이 아님(정상)
218
+ *) UNMEASURED+=("$f") ;; # 추출 자체가 불가 — 못 쟀다(CLEAN 으로 접히면 안 된다)
219
+ esac
220
+ done < <(find "${_T[@]}" -type f -name '*.md' 2>/dev/null)
64
221
  elif [ -f "$t" ]; then
65
222
  tb="${t##*/}"
66
223
  case "$tb" in
67
224
  *.py|*.sh) FILES+=("$t") ;;
225
+ *.md)
226
+ _md_collect "$t"
227
+ case $? in
228
+ 0) ;; # at least one sh/py fence extracted
229
+ 1) UNSCANNABLE+=("$t") ;; # no scannable fence — not a measurement target
230
+ *) UNMEASURED+=("$t") ;; # extraction impossible — could not measure
231
+ esac ;;
68
232
  *.*) UNSCANNABLE+=("$t") ;;
69
233
  *) if head -n1 "$t" 2>/dev/null | grep -qE '^#!.*\b(ba|z|k)?sh\b'; then FILES+=("$t"); else UNSCANNABLE+=("$t"); fi ;;
70
234
  esac
71
235
  fi
72
236
  done
237
+ # 탐색 자체가 실패했으면 그 타깃은 **못 쟀다**. 파일 목록이 비어 나오는 것과 구분이 안 되므로
238
+ # 여기서 명시적으로 비-clean 을 건다 — 이 블록이 없으면 「읽을 수 없는 디렉토리」가
239
+ # 「스캔 대상 없음 · exit 0」으로 렌더된다(같은 파일이 고치는 그 클래스, 한 층 위).
240
+ if [ ${#DISCOVERY_ERR[@]} -gt 0 ]; then
241
+ echo "degrade-scan: ${#DISCOVERY_ERR[@]} target(s) COULD NOT BE MEASURED (directory traversal failed):"
242
+ printf ' (discovery-error) %s\n' "${DISCOVERY_ERR[@]}"
243
+ echo "This is NOT 'clean' — part of the requested surface was never enumerated."
244
+ _FORCE_NONCLEAN=2
245
+ fi
246
+ # 못 잰 파일이 있으면 hits 와 무관하게 비-clean 이다. 이 블록이 FILES 비었을 때만 도는 구조가
247
+ # S 의 정체였다 — 스캔 가능한 파일이 하나라도 있으면 「못 쟀다」가 note 로 강등되고 rc=0 이 나갔다.
248
+ if [ ${#UNMEASURED[@]} -gt 0 ]; then
249
+ echo "degrade-scan: ${#UNMEASURED[@]} file(s) COULD NOT BE MEASURED (markdown fence extraction unavailable — python3/cksum missing):"
250
+ printf ' (unmeasured) %s\n' "${UNMEASURED[@]}"
251
+ echo "This is NOT 'clean' — the fence surface was not scanned at all. Install python3+cksum or scan those files elsewhere."
252
+ _FORCE_NONCLEAN=2
253
+ fi
73
254
  if [ ${#FILES[@]} -eq 0 ]; then
74
255
  if [ ${#UNSCANNABLE[@]} -gt 0 ]; then
75
256
  echo "degrade-scan: ${#UNSCANNABLE[@]} changed file(s) are OUTSIDE the scannable set (py/sh) — NOT scanned, NOT 'clean':"
@@ -77,11 +258,28 @@ if [ ${#FILES[@]} -eq 0 ]; then
77
258
  echo "A load-bearing surface in another language must go straight to cross-family review."
78
259
  exit 2 # advisory non-clean — an orchestrator keying on exit code must not read this as clean
79
260
  fi
80
- echo "degrade-scan: no scannable (py/sh) target files"; exit 0
261
+ echo "degrade-scan: no scannable (py/sh) target files"; exit "${_FORCE_NONCLEAN:-0}"
81
262
  fi
82
263
 
83
264
  hits=0
84
- emit() { printf ' %s:%s\n [%s] %s\n' "$1" "$2" "$3" "$4"; hits=$((hits+1)); }
265
+ # A markdown fence is scanned through a shadow .sh (see §Markdown fence extraction). The finding
266
+ # must point at the file a human can open, so emit translates the shadow path back to its origin —
267
+ # line numbers need no adjustment because the shadow preserves them by construction. A finding that
268
+ # names a path under /tmp is unactionable, which would make the new coverage decorative.
269
+ _origin() {
270
+ local sh="$1" pair lbl
271
+ # 라벨은 shadow 확장자에서 나온다 — 하드코딩하면 python 펜스 결함이 「bash fence」로 보고돼
272
+ # 사람이 그 줄을 열었을 때 안 맞는 코드를 보게 된다(리포트가 거짓말을 하는 형태).
273
+ case "$sh" in
274
+ *.py) lbl='```python fence' ;;
275
+ *) lbl='```bash fence' ;;
276
+ esac
277
+ for pair in "${MD_ORIGIN[@]:-}"; do
278
+ [ "${pair%%=*}" = "$sh" ] && { printf '%s (%s)' "${pair#*=}" "$lbl"; return; }
279
+ done
280
+ printf '%s' "$sh"
281
+ }
282
+ emit() { printf ' %s:%s\n [%s] %s\n' "$(_origin "$1")" "$2" "$3" "$4"; hits=$((hits+1)); }
85
283
 
86
284
  for f in "${FILES[@]}"; do
87
285
  # ---- Shell-shaped probes (S*) -------------------------------------------------------------
@@ -201,6 +399,78 @@ for f in "${FILES[@]}"; do
201
399
  done < <(grep -nE '(([^|]\|[[:space:]]*([a-z]+[[:space:]]+)*wc[^|]*)|(grep([[:space:]]+-[A-Za-z]*c[A-Za-z]*|[[:space:]]+--count)[^|]*))([^|]*\|[^|]*)*\|\|[[:space:]]*echo[[:space:]]+[\"'"'"']?0' "$f" 2>/dev/null \
202
400
  | grep -vE '^[0-9]+:[[:space:]]*#' \
203
401
  | grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade')
402
+
403
+ # S6 — SHELL-DEPENDENT word split: `for x in $VAR` / `cmd -- $LIST` on an unquoted PARAMETER
404
+ # expansion. bash splits it on IFS; **zsh does not** (SH_WORD_SPLIT is off by default), so the
405
+ # loop body runs ONCE with the entire newline-joined blob as a single word. Every per-item test
406
+ # inside then fails against a filename that does not exist — and the loop's counters stay 0,
407
+ # which the surrounding code reports as "nothing found". Same family as the rest of this file:
408
+ # an unmeasured surface rendered as a zero. The default macOS login shell is zsh, and this
409
+ # corpus's bash lives in SKILL.md fences that a human may paste into that shell.
410
+ #
411
+ # SCOPE, and the discriminator this probe would be wrong without: `for f in $(cmd)` is NOT
412
+ # flagged. Command substitution DOES word-split in zsh — SH_WORD_SPLIT governs *parameter*
413
+ # expansion only. Flagging `$(...)` would have made this probe fire on the idiomatic form that
414
+ # behaves identically in both shells, i.e. pure noise. Verified in both shells before shipping.
415
+ # `"$@"` / `${arr[@]}` are excluded for the same reason: those are array expansions, which
416
+ # split by element in zsh too.
417
+ #
418
+ # Measured N=3 (2026-08-11..12): a scan whose `grep` returned rc=2 read as "clean" until it was
419
+ # re-measured; a repair session hit the same shape; and `harness-doctor` L5-A ships it live —
420
+ # `for f in $recent_sessions` (SKILL_detail.md:201/207), where under zsh every skill in the
421
+ # harness reports INACTIVE_90D. Hand-found, not scanner-found: that is what this probe closes.
422
+ # APPLICABILITY — S6 only fires where the execution shell is NOT pinned to a splitting shell.
423
+ # Measured 2026-08-12 on 28 hits, and the split is clean at 100%: all 7 hits inside markdown
424
+ # fences were REAL, and all 21 hits in `.sh` files carrying a `#!…bash` shebang were FALSE —
425
+ # bash runs those files, so the splitting is the intended semantics there, not a defect. A
426
+ # probe that is 75% noise trains authors to dismiss it, which is how the one hit that matters
427
+ # gets waved through (this file already paid that price once at 9/9 FP on S5).
428
+ # So: markdown-fence shadows (a human may paste them into zsh — the macOS login shell) and
429
+ # files whose shebang IS zsh. Nothing else.
430
+ # The test is INVERTED (2026-08-12, round-4 cross-family): the first draft asked "is this a
431
+ # markdown shadow or a zsh script?" and therefore excluded a `.sh` file with NO shebang —
432
+ # which is not pinned to anything either, so the defect is live there too. Ask the question
433
+ # that actually decides it: **does a shebang pin a splitting shell?** Unknown shell → the
434
+ # probe applies, which is the fail-closed direction for a detector.
435
+ _s6_applies=1
436
+ # `ksh[0-9]*` — `#!/usr/bin/env ksh93` was classified as unpinned because `\bksh\b` cannot match
437
+ # inside `ksh93` (a digit is a word character). ksh93 splits `$VAR` like bash, so it IS pinned.
438
+ head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(bash|ksh[0-9]*|dash|ash)\b|^#!.*[/ ]sh([[:space:]]|$)' && _s6_applies=""
439
+ if [ -n "$_s6_applies" ]; then
440
+ # PATTERN (widened 2026-08-12, round 5). The first form only matched a bare `$VAR` / `${VAR}`,
441
+ # so `for f in ${FILES:-}` — same defect, one operator away — was a FALSE NEGATIVE.
442
+ # The braced alternative now admits the expansion operators (`:-` `:=` `:?` `:+` `#` `%` `^` `,`)
443
+ # but **excludes `[` by construction** (`[^}[]*`). That exclusion is doing real work: widening
444
+ # the pattern is exactly what would make `${arr[@]}` match, and an array expansion splits by
445
+ # element in zsh too, so it is not this defect. Encoding it in the pattern rather than as a
446
+ # downstream `grep -v` is deliberate — the line-level filter version of this rule was deleted
447
+ # earlier today precisely because it killed whole lines, including real hits.
448
+ # `/` and `@` joined the operator class 2026-08-12 (round 6): `${FILES//old/new}` is a
449
+ # substitution whose RESULT still splits in bash and not in zsh — the same defect — and it was
450
+ # invisible because `/` was missing from the set. Measured on that construct: bash 2 words,
451
+ # zsh 1. `[` stays excluded so array expansions remain unmatched.
452
+ S6_PAT='(for[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]+in|--)[[:space:]]+(\$[A-Za-z_][A-Za-z0-9_]*|\$\{[A-Za-z_][A-Za-z0-9_]*([:#%^,!/@][^}[]*)?\})([[:space:];]|$)'
453
+ while IFS= read -r m; do
454
+ emit "$f" "${m%%:*}" "S6:wordsplit-shell-dep(sh)" "unquoted parameter expansion in a word-split position (\`for x in \$VAR\` / \`-- \$LIST\`) — bash splits on IFS but zsh does NOT, so under zsh the loop runs once over the whole blob and per-item results silently come back empty; iterate explicitly instead: \`printf '%s\\n' \"\$VAR\" | while IFS= read -r x; do …\` (or use an array). Command substitution \`\$(cmd)\` splits in both shells and is NOT this defect"
455
+ # NO COMMENT STRIPPING, and no array-form exclusion — both were removed 2026-08-12 after two
456
+ # cross-family rounds, and the reasoning is worth keeping because the removal LOOKS like a
457
+ # weakening and is not:
458
+ # · the `grep -vE '\[[@*]\]'` array exclusion was DEAD CODE. Measured against all six
459
+ # spellings: `$S6_PAT` requires `$name` followed by space/;/EOL, so `${arr[@]}` and
460
+ # `"${arr[@]}"` never match it in the first place. The filter excluded nothing real — but
461
+ # it matched the whole LINE, so a genuine defect whose trailing comment mentioned the safe
462
+ # form (`git add -- $FILES # use "${arr[@]}" later`) was silently dropped. A dead filter
463
+ # that only ever fires on false grounds is worse than no filter.
464
+ # · the `sed 's/#.*$//'` that was added to work around it then introduced its own false
465
+ # negative: it cannot tell a comment from a `#` inside a quoted string, so
466
+ # `printf "#"; git add -- $FILES` lost its real hit.
467
+ # Accepted trade, stated rather than hidden: a match living ONLY in a trailing comment is now
468
+ # reported. Full-line comments are still excluded above. Recall over precision is the right
469
+ # direction for an advisory detector — a missed defect is silent, a noisy line is not.
470
+ done < <(grep -nE "$S6_PAT" "$f" 2>/dev/null \
471
+ | grep -vE '^[0-9]+:[[:space:]]*#' \
472
+ | grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade')
473
+ fi
204
474
  fi
205
475
 
206
476
  # Probe A — except/else/finally block returning a permissive value within 2 lines.
@@ -266,4 +536,4 @@ fi
266
536
  # it does NOT assert the changed load-bearing surface is safe (other languages, non-code surfaces,
267
537
  # and the lint's own recall gaps are out of scope). The load-bearing check is the cross-family review.
268
538
  echo "degrade-scan: no default-toward-PASS smells in ${#FILES[@]} scanned py/sh file(s) — does NOT cover other languages / non-code surfaces / the cross-family check (advisory)."
269
- exit 0
539
+ exit "${_FORCE_NONCLEAN:-0}" # 못 잰 파일이 있으면 0 이 아니다 — "안 쟀다"는 "깨끗하다"가 아니다