@chrono-meta/fh-gate 1.4.95 → 1.4.97

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 (76) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/AGENTS.md +18 -0
  3. package/CHEATSHEET.md +1 -1
  4. package/CLAUDE.md +11 -4
  5. package/README.md +19 -0
  6. package/knowledge/shared/harness-core/fh_detail_protocols.md +12 -0
  7. package/knowledge/shared/harness-core/ship_readiness_gate.md +7 -4
  8. package/knowledge/shared/learnings/subagent_invocations_log.yaml +113 -1
  9. package/package.json +10 -2
  10. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  11. package/plugins/fh-commons/agents/quench-challenger.md +49 -23
  12. package/plugins/fh-commons/skills/convergence-loop/SKILL.md +14 -0
  13. package/plugins/fh-commons/skills/deliberation/SKILL.md +14 -0
  14. package/plugins/fh-commons/skills/mcp-circuit-breaker/SKILL.md +10 -1
  15. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  16. package/plugins/fh-meta/CHANGELOG.md +36 -0
  17. package/plugins/fh-meta/agents/beginner.md +4 -1
  18. package/plugins/fh-meta/agents/challenger.md +7 -1
  19. package/plugins/fh-meta/agents/expert.md +1 -1
  20. package/plugins/fh-meta/agents/fact-checker.md +7 -1
  21. package/plugins/fh-meta/agents/hub-persona-auditor.md +2 -1
  22. package/plugins/fh-meta/agents/main-player.md +4 -1
  23. package/plugins/fh-meta/agents/persona-innovator.md +10 -2
  24. package/plugins/fh-meta/skills/agent-composer/SKILL.md +2 -2
  25. package/plugins/fh-meta/skills/apex-review/SKILL.md +5 -0
  26. package/plugins/fh-meta/skills/asset-placement-gate/SKILL.md +38 -8
  27. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +16 -2
  28. package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +45 -10
  29. package/plugins/fh-meta/skills/corpus-grounding-expander/SKILL.md +14 -5
  30. package/plugins/fh-meta/skills/cross-ecosystem-synergy-detection/SKILL.md +93 -30
  31. package/plugins/fh-meta/skills/deep-clarify/SKILL.md +28 -9
  32. package/plugins/fh-meta/skills/fh/SKILL.md +4 -0
  33. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +64 -8
  34. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +20 -7
  35. package/plugins/fh-meta/skills/goal-quench/SKILL.md +48 -15
  36. package/plugins/fh-meta/skills/goal-quench/SKILL_detail.md +58 -11
  37. package/plugins/fh-meta/skills/harness-doctor/SKILL_detail.md +109 -33
  38. package/plugins/fh-meta/skills/harvest-loop/SKILL.md +6 -1
  39. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +126 -17
  40. package/plugins/fh-meta/skills/install-doctor/SKILL.md +50 -14
  41. package/plugins/fh-meta/skills/install-wizard/SKILL.md +26 -7
  42. package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +68 -21
  43. package/plugins/fh-meta/skills/memory-hygiene/SKILL.md +64 -17
  44. package/plugins/fh-meta/skills/meta-prompt-builder/SKILL.md +38 -4
  45. package/plugins/fh-meta/skills/persona-roster-expander/SKILL.md +15 -7
  46. package/plugins/fh-meta/skills/plugin-recommender/SKILL.md +39 -11
  47. package/plugins/fh-meta/skills/plugin-recommender/SKILL_detail.md +24 -7
  48. package/plugins/fh-meta/skills/prompt-regression/SKILL.md +54 -11
  49. package/plugins/fh-meta/skills/salience-splitter/SKILL.md +120 -7
  50. package/plugins/fh-meta/skills/salience-splitter/SKILL_detail.md +46 -13
  51. package/plugins/fh-meta/skills/sim-conductor/SKILL_detail.md +28 -3
  52. package/plugins/fh-meta/skills/steel-quench/SKILL.md +3 -1
  53. package/plugins/fh-meta/skills/verify-bidirectional/SKILL.md +72 -14
  54. package/scripts/compaction_probe.sh +153 -10
  55. package/scripts/consent_registry_check.sh +63 -0
  56. package/scripts/count_check.sh +47 -1
  57. package/scripts/degrade_direction_scan.sh +276 -6
  58. package/scripts/degrade_probe_capability.sh +105 -0
  59. package/scripts/lane_runner_check.sh +425 -0
  60. package/scripts/package_coverage_check.sh +190 -35
  61. package/scripts/psa_probe_capability.sh +78 -0
  62. package/scripts/psa_scan_lib.sh +164 -0
  63. package/scripts/public_surface_scan_files.sh +38 -0
  64. package/scripts/selfcheck.sh +346 -28
  65. package/scripts/test_capability_entrypoint_shipping.sh +132 -0
  66. package/scripts/test_consent_registry.sh +54 -0
  67. package/scripts/test_count_check_readme_format_lanes.sh +75 -0
  68. package/scripts/test_degrade_scan_shell_probes.sh +415 -0
  69. package/scripts/test_marker_crossfamily_lanes.sh +10 -1
  70. package/scripts/test_marker_floor_lanes.sh +25 -1
  71. package/scripts/test_psa_singlefile_lanes.sh +288 -0
  72. package/scripts/test_reviewer_capability_conformance.sh +10 -1
  73. package/scripts/test_selfcheck_state_lanes.sh +173 -0
  74. package/scripts/validate_yaml.sh +146 -0
  75. package/templates/degrade_direction_scan.sh +276 -6
  76. package/templates/goal-quench-settings-merged.json +30 -0
@@ -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 이 아니다 — "안 쟀다"는 "깨끗하다"가 아니다
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env bash
2
+ # degrade_probe_capability.sh — degrade_direction_scan 의 **capability 진입점**.
3
+ #
4
+ # node A(psa) 와 다른 계보의 다른 질문이다: 내용이 아니라 «판정이 어느 방향으로
5
+ # 무너지나»(fail-open 여부)를 본다. 스캔 로직은 재구현하지 않고 기존 스캐너를 부른다.
6
+ #
7
+ # verdict enum (declared): 0=CLEAN 2=FINDINGS 3=NO_TARGET 10=HARNESS_ERROR
8
+ # 3 은 원 스캐너가 exit 0 으로 뭉개던 «스캔할 py/sh 대상이 없다» 를 분리한 값이다 —
9
+ # 그 0 은 «깨끗» 이 아니라 «시작조차 안 함» 이고, 정확히 §ⓑ.4 B1 이 막는 형태다.
10
+ #
11
+ # 🟥 이 파일의 초판이 이 레포의 `scripts/` 를 통째로 지웠다 (2026-08-11, 실발생).
12
+ # 기전: `TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT` 로 정리 트랩을 건 뒤,
13
+ # 무인자 분기에서 **같은 변수에 실제 레포 경로를 재대입**했다 → 트랩이 종료 시
14
+ # `rm -rf $REPO_ROOT/scripts` 를 실행. 트랩은 «내가 만든 것» 이 아니라
15
+ # «그 변수가 지금 가리키는 것» 을 지운다.
16
+ # **처방(구조적)**: 정리 대상(OWNED_TMP)과 스캔 대상(TARGET)을 **다른 변수로 분리**하고,
17
+ # 트랩은 OWNED_TMP 만 본다. OWNED_TMP 는 최초 대입 후 절대 재대입하지 않는다.
18
+ # 그리고 트랩 안에서 경로가 mktemp 산출물인지 한 번 더 검문한다(두 번째 방벽).
19
+ set -o pipefail
20
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
21
+ SCAN="$REPO_ROOT/scripts/degrade_direction_scan.sh"
22
+ [ -r "$SCAN" ] || { echo "degrade-probe: 스캐너 부재 — 계기가 없다"; exit 10; }
23
+
24
+ OWNED_TMP="$(mktemp -d)" # ← 내가 만든 것. 재대입 금지.
25
+ _cleanup() {
26
+ # 두 번째 방벽: 트랩이 지우는 경로가 정말 내가 만든 임시 디렉토리인지 검문한다.
27
+ case "$OWNED_TMP" in
28
+ /tmp/*|/var/folders/*) [ -d "$OWNED_TMP" ] && rm -rf "$OWNED_TMP" ;;
29
+ *) echo "degrade-probe: cleanup 거부 — OWNED_TMP 가 임시 경로가 아니다: $OWNED_TMP" >&2 ;;
30
+ esac
31
+ }
32
+ trap _cleanup EXIT
33
+
34
+ MODE="${1:-}"
35
+ TARGET="" # ← 스캔 대상. 실제 레포 경로를 가리킬 수 있다. 절대 안 지운다.
36
+
37
+ case "$MODE" in
38
+ --known-positive) # 답을 아는 양성: 교과서적 fail-open (검사 실패인데 성공 반환)
39
+ TARGET="$OWNED_TMP"
40
+ cat > "$TARGET/probe_target.sh" <<'FIX'
41
+ #!/bin/sh
42
+ verify_integrity() {
43
+ scan=$(run_scan) || return 0 # 검사가 에러났는데 성공으로 반환 = fail-open
44
+ echo "$scan"
45
+ }
46
+ FIX
47
+ ;;
48
+ --known-negative) # 답을 아는 음성: 같은 모양인데 방향이 옳은 대조군
49
+ TARGET="$OWNED_TMP"
50
+ cat > "$TARGET/probe_target.sh" <<'FIX'
51
+ #!/bin/sh
52
+ verify_integrity() {
53
+ scan=$(run_scan) || return 1 # 검사 실패 → 실패 반환 = fail-closed
54
+ echo "$scan"
55
+ }
56
+ FIX
57
+ ;;
58
+ --target) TARGET="${2:?--target 은 경로가 필요하다}" ;;
59
+ '') TARGET="$REPO_ROOT/scripts" ;;
60
+ *) echo "degrade-probe: 알 수 없는 모드 '$MODE'"; exit 10 ;;
61
+ esac
62
+
63
+ # 대상 존재 여부를 **먼저** 판정한다 — 원 스캐너의 exit 0 은 「대상 없음」과
64
+ # 「깨끗」을 같은 값으로 렌더한다. 그 둘을 여기서 가른다.
65
+ # `.md` joined the候補 set 2026-08-12. Until then this counted only `.sh|.py`, so a pure-markdown
66
+ # target exited 3 = NO_TARGET **without ever invoking the scanner** — which means the release's
67
+ # headline feature (scanning ```bash/```python fences inside SKILL.md) was unreachable through the
68
+ # typed capability path, the one a cockpit caller actually uses. A capability that cannot reach its
69
+ # own advertised surface is not a narrow bug; it is the feature not shipping on that path.
70
+ # 이 pre-count 자체가 `find` 에 의존한다 — 부재하면 0 이 나오고 그건 NO_TARGET 으로 렌더된다.
71
+ # 스캐너 본체는 같은 상황에서 INSTRUMENT ERROR(exit 2)를 내는데 wrapper 가 그걸 «대상 없음»으로
72
+ # 덮어써서 HARNESS_ERROR 판정이 도달 불가였다(cross-family, A급). 계기 부재는 대상 부재가 아니다.
73
+ command -v find >/dev/null 2>&1 || {
74
+ echo "degrade-probe: HARNESS_ERROR — \`find\` 부재로 대상 열거 자체가 불가(대상 없음이 아니다)"; exit 10; }
75
+ # 계기가 **있어도** 이 타깃을 못 훑을 수 있다(권한 없는 디렉토리). 첫 수리는 `command -v` 만 봐서
76
+ # 그 경우가 여전히 NO_TARGET 으로 렌더됐다 — 절반만 닫은 수리다(cross-family 재지적, A급).
77
+ # 「대상이 없다」와 「대상을 못 셌다」는 다른 말이고, 후자는 HARNESS_ERROR 다.
78
+ _perr=$(find -H "$TARGET" -type f 2>&1 >/dev/null); _prc=$?
79
+ if [ "$_prc" -ne 0 ] || [ -n "$_perr" ]; then
80
+ echo "degrade-probe: HARNESS_ERROR — 대상 열거 실패(대상 없음이 아니다): ${_perr:-find exited $_prc}"; exit 10
81
+ fi
82
+ n=$(find -H "$TARGET" -type f \( -name '*.sh' -o -name '*.py' -o -name '*.md' \) 2>/dev/null | wc -l | tr -d ' ')
83
+ [ "${n:-0}" -eq 0 ] && { echo "degrade-probe: NO_TARGET — 스캔 대상 파일 0개(깨끗이 아니다)"; exit 3; }
84
+
85
+ out="$(bash "$SCAN" "$TARGET" 2>&1)"; rc=$? # verdict 는 직접 취한다(PIPE-VERDICT)
86
+ case "$rc" in
87
+ 0) echo "degrade-probe: CLEAN (대상 $n)"; exit 0 ;;
88
+ 2)
89
+ # rc=2 는 스캐너에서 **세 가지**를 뜻한다: 실제 findings · 못 잰 파일(UNMEASURED) ·
90
+ # 스캔 대상 아님(UNSCANNABLE). 셋을 전부 FINDINGS 로 접으면 「못 쟀다」가 「결함을 찾았다」로
91
+ # 렌더돼, 계기 고장이 대상의 결함으로 오귀속된다(반대 방향이지만 같은 계열의 오분류).
92
+ # 순서가 판정이다 — «못 쟀다» 를 **먼저** 본다. findings 검사를 앞에 두면 「일부는 쟀고 일부는
93
+ # 못 쟀다」가 그냥 FINDINGS 로 나가고, 미측정 표면이 판정에서 사라진다(cross-family 지적, A급).
94
+ # `_md_collect` 의 「err 가 got 을 이긴다」와 같은 규율을 이 층에도 건다.
95
+ if printf '%s' "$out" | grep -qi 'COULD NOT BE MEASURED'; then
96
+ echo "degrade-probe: HARNESS_ERROR — 스캐너가 대상을 측정하지 못했다(깨끗이 아니다)"
97
+ printf '%s\n' "$out" | head -4; exit 10
98
+ elif printf '%s' "$out" | grep -qE '\[[A-Z][0-9]?:'; then
99
+ echo "degrade-probe: FINDINGS (대상 $n)"; printf '%s\n' "$out" | head -6; exit 2
100
+ else
101
+ echo "degrade-probe: NO_TARGET — 대상 $n 개가 전부 스캔 대상 밖이다(깨끗이 아니다)"
102
+ printf '%s\n' "$out" | head -4; exit 3
103
+ fi ;;
104
+ *) echo "degrade-probe: HARNESS_ERROR — 스캐너가 enum 밖 $rc 반환"; exit 10 ;;
105
+ esac