@chrono-meta/fh-gate 1.4.72 → 1.4.73

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 (31) hide show
  1. package/.claude/rules/.public-surface-patterns.defaults +44 -0
  2. package/.claude/rules/fh_4axis_gate.md +207 -0
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/AGENTS.md +26 -2
  5. package/CATALOG.md +31 -0
  6. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
  7. package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
  8. package/package.json +21 -1
  9. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  10. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  11. package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
  12. package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
  13. package/scripts/chamber_candidate_collect.sh +223 -0
  14. package/scripts/degrade_direction_scan.sh +222 -0
  15. package/scripts/fh_session_load.sh +202 -0
  16. package/scripts/gate_pathspec_check.sh +166 -0
  17. package/scripts/prepush_guard_check.sh +374 -0
  18. package/scripts/psa_scan_lib.sh +153 -0
  19. package/scripts/public_surface_scan_files.sh +157 -0
  20. package/scripts/selfcheck.sh +16 -0
  21. package/scripts/session_close_check.sh +171 -0
  22. package/scripts/test_degrade_scan_shell_probes.sh +185 -0
  23. package/scripts/test_prepush_stdin_integrity.sh +119 -0
  24. package/scripts/universal_guard_check.sh +280 -0
  25. package/templates/.claude/rules/mcp_tool_gating.md +157 -0
  26. package/templates/.git-hooks/pre-commit +848 -0
  27. package/templates/.git-hooks/pre-push +585 -0
  28. package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
  29. package/templates/degrade_direction_scan.sh +222 -0
  30. package/templates/predelete_check.sh +72 -0
  31. package/templates/regression_guard.sh +563 -0
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bash
2
+ # public_surface_scan_files.sh — Pre-Publish confidentiality floor for `npm publish`.
3
+ #
4
+ # Mechanizes the npm-publish half of the Pre-Publish Surface Gate (CLAUDE.md). The pre-commit
5
+ # confidentiality scan sees only the ADDED LINES of this repo's commits; a token committed before
6
+ # that scan existed, or carried in a files[] entry, would otherwise reach the registry unscanned.
7
+ # This scans the FULL CONTENT of the exact npm-published file set (npm pack --dry-run) against the
8
+ # same operator-private patterns, and blocks `npm publish` on a HIGH/MED hit.
9
+ #
10
+ # Honest scope: covers `npm publish` only (wired via package.json prepublishOnly). The separate-repo
11
+ # go-public surface (gh repo create --public / visibility flip / first push to a new public remote) is
12
+ # not an npm or git op against this repo, so no hook here sees it — it stays prose + PRE-PUBLISH-CHECKLIST.md
13
+ # (genuinely un-hookable). Same shape as the Destructive-Op story: git/npm surface mechanized, the
14
+ # separate-repo go-public surface stays prose.
15
+ #
16
+ # Degrade direction: irreversible surface (publish) → fail-CLOSED. Patterns absent, or the published
17
+ # file set unresolved → BLOCK (proceed only on an explicit, logged PUBLIC_SURFACE_OK=1). Never silent-allow.
18
+ #
19
+ # Override (explicit, logged — mirrors the pre-commit PUBLIC_SURFACE_OK channel):
20
+ # PUBLIC_SURFACE_OK=1 npm publish … ← after conscious review of the flagged hit(s).
21
+ #
22
+ # Patterns are single-source (shared with the pre-commit scan): .claude/rules/.public-surface-patterns.defaults
23
+ # (committed, universal) + .claude/rules/.public-surface-patterns (gitignored, operator literals).
24
+
25
+ set -uo pipefail
26
+
27
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
28
+ PSA_DEFAULTS="$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults"
29
+ PSA_OVERRIDE="${PSA_PATTERNS:-$REPO_ROOT/.claude/rules/.public-surface-patterns}"
30
+ # PSA_PLACEHOLDER and the default psa_low_allowlisted now come from scripts/psa_scan_lib.sh.
31
+ # A second copy here is exactly the divergence this refactor removed; do not reintroduce one.
32
+
33
+ # LOW-severity allowlist by file (HIGH/MED still block). For the PUBLISH scan this is nearly vacuous:
34
+ # a published file should carry NO operator-private token at all. Deliberately names no operator-private
35
+ # file literal here (the pre-commit copy of this list does, but it lives in a git-hooks-allowlisted path;
36
+ # THIS script is public-tracked, so hardcoding e.g. a companion-script name would itself be a LOW leak —
37
+ # caught by the pre-commit confidentiality scan, 2026-06-27). Generic template paths only.
38
+
39
+ echo "[Pre-Publish] public-surface scan (npm-published file content)..."
40
+
41
+ # ── Load patterns via the shared library (scripts/psa_scan_lib.sh) ──
42
+ # One implementation of loading + row validation + exemption decisions, shared with pre-commit and
43
+ # pre-push. What this file KEEPS for itself, deliberately:
44
+ # • the file-level `grep -a` scan. It forces every published file to be read as TEXT; the library's
45
+ # line-stream interface would drop back to text-only handling and re-open the hole a challenger
46
+ # found earlier — a token inside an SVG / null-byte file (docs/pillars.svg ships) scanning clean.
47
+ # • a STRICTER LOW allowlist (below). A published artifact should carry no operator-private token
48
+ # at all, so the commit-time list of files that legitimately name wiring tokens does not apply.
49
+ # Sourcing the library and then redefining the function is how that difference stays visible
50
+ # instead of being buried as a divergence.
51
+ PSA_LIB="$REPO_ROOT/scripts/psa_scan_lib.sh"
52
+ if [ ! -r "$PSA_LIB" ]; then
53
+ echo " ❌ scripts/psa_scan_lib.sh missing — the confidentiality scanner cannot run."
54
+ [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] || { echo " Fail-closed on the publish boundary."; exit 1; }
55
+ else
56
+ . "$PSA_LIB"
57
+ fi
58
+
59
+ # Stricter than the library default — see the note above. Named here so the difference is legible.
60
+ psa_low_allowlisted() {
61
+ case "$1" in
62
+ templates/*) return 0 ;;
63
+ *) return 1 ;;
64
+ esac
65
+ }
66
+
67
+ psa_load "$PSA_DEFAULTS" "$PSA_OVERRIDE"
68
+
69
+ # Publish is an irreversible surface: EVERY incomplete-instrument state blocks, including the merely
70
+ # absent operator override (which only warns at commit time — see the library header for why the two
71
+ # surfaces degrade differently).
72
+ _psa_why=""
73
+ [ "$PSA_DEFAULTS_OK" -eq 0 ] && _psa_why="committed pattern defaults missing/unreadable/empty"
74
+ [ "$PSA_BAD_ROWS" -gt 0 ] && _psa_why="${_psa_why:+$_psa_why; }$PSA_BAD_ROWS unusable pattern row(s)"
75
+ [ "$PSA_OVERRIDE_PRESENT" -eq 0 ] && _psa_why="${_psa_why:+$_psa_why; }operator-literal override absent/empty (HIGH company/companion literals NOT scanned)"
76
+ if [ -n "$_psa_why" ]; then
77
+ echo " ❌ incomplete confidentiality instrument — $_psa_why"
78
+ if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
79
+ echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1 (conscious — the scan is incomplete)"
80
+ echo "$(date +%Y-%m-%dT%H:%M:%S) PUBLIC_SURFACE_OK override (npm publish, incomplete instrument)" \
81
+ >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
82
+ else
83
+ echo " Fail-closed on an irreversible surface: an incomplete instrument cannot certify clean."
84
+ exit 1
85
+ fi
86
+ fi
87
+
88
+
89
+ # Named residual (cross-family audit 2026-06-27, deferred): this scans the WORKING-TREE content of the
90
+ # packed file list, not the final tarball bytes. A content-generating publish lifecycle (prepack/prepare
91
+ # that writes files AFTER this prepublishOnly scan) could ship bytes this never saw, and a path containing
92
+ # a newline would mis-split the list. The robust fix is to scan the actual `npm pack` tarball; deferred
93
+ # because THIS package's lifecycle is content-neutral (prepare = chmod only, no prepack). Re-open if a
94
+ # content-generating lifecycle is ever added.
95
+ # ── Resolve the exact npm-published file set (fail-closed if unresolved OR partial) ──
96
+ FILES=$(npm pack --dry-run --json 2>/dev/null \
97
+ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{JSON.parse(s)[0].files.forEach(f=>console.log(f.path))}catch(e){process.exit(3)}})' 2>/dev/null || true)
98
+ if [ -z "$FILES" ]; then
99
+ echo " ❌ could not resolve the npm-published file set (npm pack --dry-run failed)."
100
+ [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] && { echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1"; exit 0; }
101
+ echo " Fail-closed on an irreversible surface. Fix npm pack or override with PUBLIC_SURFACE_OK=1."
102
+ exit 1
103
+ fi
104
+
105
+ # Wrong-set guard (challenger M6): a future npm --json shape change could yield a NON-empty but PARTIAL
106
+ # file list (forEach iterates a renamed/nested structure without throwing) → files silently unscanned.
107
+ # npm always ships package.json in the tarball; its absence means the parse got a wrong set → fail-closed.
108
+ if ! printf '%s\n' "$FILES" | grep -qx "package.json"; then
109
+ echo " ❌ published file set looks wrong — 'package.json' (always shipped) is absent from the parse."
110
+ [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] && { echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1"; exit 0; }
111
+ echo " Fail-closed (possible npm --json shape change). Verify npm pack output or PUBLIC_SURFACE_OK=1."
112
+ exit 1
113
+ fi
114
+
115
+ # ── Scan full content of each published file ──
116
+ LEAK=0
117
+ while IFS= read -r f; do
118
+ [ -z "$f" ] && continue
119
+ path="$REPO_ROOT/$f"
120
+ [ -f "$path" ] || continue
121
+ while IFS=$'\t' read -r sev regex; do
122
+ [ -z "$regex" ] && continue
123
+ case "$sev" in \#*) continue;; esac
124
+ while IFS= read -r tok; do
125
+ [ -z "$tok" ] && continue
126
+ printf '%s' "$tok" | grep -qiE "$PSA_PLACEHOLDER" && continue
127
+ if [ "$sev" = "LOW" ] && psa_low_allowlisted "$f"; then continue; fi
128
+ echo " ❌ $sev leak — $f: '$tok' would ship to the registry"
129
+ LEAK=1
130
+ # -a forces every published file to scan as TEXT (challenger S1): -I skipped binary-classified
131
+ # files, so a token in an SVG / null-byte file (docs/pillars.svg ships) would slip unscanned.
132
+ done <<< "$(grep -aoiE "$regex" "$path" 2>/dev/null | sort -u || true)"
133
+ done <<< "$PSA_STREAM"
134
+ done <<< "$FILES"
135
+
136
+ if [ "$LEAK" -eq 1 ]; then
137
+ if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
138
+ echo " ⚠️ public-surface hit(s) allowed by PUBLIC_SURFACE_OK=1 (conscious, reviewed intent)"
139
+ echo "$(date +%Y-%m-%dT%H:%M:%S) PUBLIC_SURFACE_OK override (npm publish) — suppressed hit(s) above" \
140
+ >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
141
+ exit 0
142
+ fi
143
+ echo " An operator-private token would ship in the npm package. Generalize it (real home path → '~'"
144
+ echo " or '{project}'; companion/corp name → a generic phrase), or PUBLIC_SURFACE_OK=1 npm publish …"
145
+ exit 1
146
+ fi
147
+
148
+ # Renamed with the refactor: the flag is PSA_OVERRIDE_PRESENT, set by psa_load. The bare name was a
149
+ # leftover that referenced nothing under `set -u` and aborted the scan at its final line.
150
+ if [ "$PSA_OVERRIDE_PRESENT" -eq 0 ]; then
151
+ echo " ⚠️ PASS (DEFAULTS-ONLY) — no home-path leak, but HIGH operator literals were NOT scanned (override absent)."
152
+ else
153
+ echo " ✅ PASS — no operator-private token in the published file set (full pattern set)."
154
+ fi
155
+ # Residual (named, not closed): the scan is a DENYLIST against loaded patterns — an un-patterned secret
156
+ # shape (an API key the patterns don't describe) still ships. This gate is not a general secret-detector.
157
+ exit 0
@@ -83,6 +83,22 @@ else
83
83
  fail=1
84
84
  fi
85
85
 
86
+ # degrade-scan shell probes — the anchor was written 2026-07-28 and shipped with ZERO callers,
87
+ # reproducing [[feedback_built_but_not_wired]] in the same session that cited it. The subject
88
+ # (scripts/degrade_direction_scan.sh) and the anchor both ship, so this runs in package mode too;
89
+ # only a missing SUBJECT is a legitimate skip.
90
+ if [ ! -f scripts/degrade_direction_scan.sh ]; then
91
+ echo "SKIP degrade-scan shell probes (subject scripts/degrade_direction_scan.sh absent)"
92
+ elif [ -f scripts/test_degrade_scan_shell_probes.sh ]; then
93
+ if ! bash scripts/test_degrade_scan_shell_probes.sh; then
94
+ fail=1
95
+ fi
96
+ else
97
+ # subject present, anchor gone => the calibration was deleted. Real failure, not a skip.
98
+ echo "FAIL degrade-scan shell probes: scan present but scripts/test_degrade_scan_shell_probes.sh missing"
99
+ fail=1
100
+ fi
101
+
86
102
  # Referenced-path existence is a source-tree check. The npm package intentionally
87
103
  # ships a narrower runtime surface, so package-mode selfcheck skips this section.
88
104
  if [ -d ".claude/rules" ]; then
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bash
2
+ # session_close_check.sh — mechanical checklist for the session close chain (CLAUDE.md §Session Wrap-up ①–⑥).
3
+ #
4
+ # WHY (loop_engineering.md census, 2026-07-10): the close chain's complete/persist legs were PROSE —
5
+ # card-last ordering and step coverage lived on salience alone, and the measured misses (card
6
+ # staleness class) all landed on exactly these legs. This script is the MECH floor: it VERIFIES
7
+ # state, it does not perform the steps (the session still runs them; the script catches skips).
8
+ # Built on operator instruction 2026-07-10 (strengthen-the-weak pass; evidence-threshold override
9
+ # recorded — the miss class was already measured, only the build trigger was overridden).
10
+ #
11
+ # READ-ONLY. Exit 0 = close state consistent · exit 1 = a close invariant is violated (card-last
12
+ # broken, or a required artifact missing). Advisory lines are prefixed ⚠️ , violations ❌ .
13
+ #
14
+ # Usage: bash scripts/session_close_check.sh [repo_root] (run at close time, before ⑥ push)
15
+
16
+ set -uo pipefail
17
+
18
+ FH="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
19
+ TODAY=$(date +%Y-%m-%d)
20
+ CARD="$FH/tracks/_meta/reference_next_session_starter.md"
21
+ FAIL=0
22
+
23
+ _mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
24
+
25
+ echo "── session close check: $FH ($TODAY) ──"
26
+
27
+ # ① status snapshot — uncommitted / unpushed work must be known, not forgotten
28
+ DIRTY=$(git -C "$FH" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
29
+ UNPUSHED=$(git -C "$FH" log --oneline @{u}.. 2>/dev/null | wc -l | tr -d ' ')
30
+ [ "$DIRTY" -gt 0 ] && echo "⚠️ ① $DIRTY uncommitted path(s) — decide: commit or leave deliberately"
31
+ [ "$UNPUSHED" -gt 0 ] && echo "⚠️ ① $UNPUSHED unpushed commit(s) — push before close or record why"
32
+ [ "$DIRTY" -eq 0 ] && [ "$UNPUSHED" -eq 0 ] && echo "✅ ① working tree clean, nothing unpushed"
33
+
34
+ # ①-b open-PR sweep (surface-not-auto — requires gh; skip silently offline)
35
+ if command -v gh >/dev/null 2>&1; then
36
+ PRS=$(gh pr list --author "@me" --state open --json number 2>/dev/null | grep -c '"number"' || true)
37
+ [ "${PRS:-0}" -gt 0 ] && echo "⚠️ ①-b $PRS open PR(s) by you — classify: self-mergeable vs awaiting-external"
38
+ fi
39
+
40
+ # ② FH assets changed today → harvest-loop owed
41
+ # NOTE: no `grep -q` here — under `set -o pipefail`, -q's early exit SIGPIPEs git log (141),
42
+ # masking a real match as pipeline failure so the warning NEVER fired on true positives
43
+ # (caught by a Sonnet blind probe 2026-07-10, 5/5 deterministic repro). `grep -c` reads the
44
+ # whole stream; `|| true` guards its exit-1-on-zero.
45
+ FH_CHANGED=$(git -C "$FH" log --since="today 00:00" --name-only --pretty=format: 2>/dev/null \
46
+ | grep -cE '^(plugins/.*SKILL\.md|\.claude/rules/|templates/|CLAUDE\.md|knowledge/)' || true)
47
+ if [ "${FH_CHANGED:-0}" -gt 0 ]; then
48
+ echo "⚠️ ② FH assets changed today ($FH_CHANGED path-touch(es)) — harvest-loop (or an explicit skip note) is owed"
49
+ fi
50
+
51
+ # ④ real-time completion log — required whenever any commit landed today
52
+ COMMITS_TODAY=$(git -C "$FH" log --since="today 00:00" --oneline 2>/dev/null | wc -l | tr -d ' ')
53
+ FC="$FH/tracks/_meta/fh_completed_${TODAY}.md"
54
+ if [ "$COMMITS_TODAY" -gt 0 ] && [ ! -f "$FC" ]; then
55
+ echo "❌ ④ commits landed today but tracks/_meta/fh_completed_${TODAY}.md is missing"
56
+ FAIL=1
57
+ fi
58
+
59
+ # ④-b npm freshness — files[] assets changed since last version tag → republish owed.
60
+ # Patterns are NARROWED to the actually-shipped subpaths (package.json files[]): knowledge/ ships only
61
+ # shared/{harness-core,dialogue,rules}; docs/ ships only {codex-compat,CONTRIBUTING,pillars}. A broad
62
+ # ^knowledge/ / ^docs/ over-matched git-tracked-but-UNshipped files (e.g. knowledge/shared/learnings/
63
+ # subagent_invocations_log.yaml, which changes almost every self-dev session) → guaranteed per-session
64
+ # false positive that trains the runner to ignore the line (Axis-2 challenger catch 2026-07-13).
65
+ SHIP_RE='^(plugins/|knowledge/shared/(harness-core|dialogue|rules)/|docs/(codex-compat|CONTRIBUTING|pillars)|README|AGENTS\.md|CLAUDE\.md|CHEATSHEET|CATALOG\.md)'
66
+ LAST_TAG=$(git -C "$FH" describe --tags --abbrev=0 2>/dev/null || true)
67
+ if [ -n "$LAST_TAG" ] && [ -f "$FH/package.json" ]; then
68
+ CHANGED_SINCE_TAG=$(git -C "$FH" diff --name-only "$LAST_TAG"..HEAD 2>/dev/null)
69
+ if printf '%s\n' "$CHANGED_SINCE_TAG" | grep -qE "$SHIP_RE"; then
70
+ echo "⚠️ ④-b npm-shipped assets changed since $LAST_TAG — propose lockstep republish (never auto)"
71
+ fi
72
+ # ④-b-drift: auto-FIRE a drift-CANDIDATE reminder (not a parity verdict) — **in BOTH directions**.
73
+ # The two entry points are read by DIFFERENT runtimes (CLAUDE.md → Claude Code · AGENTS.md/codex-compat
74
+ # → Codex/OpenCode and other non-CC runtimes). A rule that lands in only one is INVISIBLE to the other,
75
+ # so either file changing alone is a candidate — not just the CC→Codex direction.
76
+ # HONEST SCOPE: this tests file co-occurrence, not topical parity — false-positive (a genuinely
77
+ # runtime-specific change needs no mirror) and false-negative (file touched for an unrelated reason)
78
+ # are both possible. The reminder is mechanized; the drift DETERMINATION stays judged (sync, or record
79
+ # drift:none).
80
+ # Origin (2026-07-19): the REVERSE direction was unwired and a real miss slipped through — a field
81
+ # harness's boundary-crossing behavior rules landed in AGENTS.md only, leaving Claude Code sessions
82
+ # unaware of a rule whose violation destroys a downstream harness's identity. Half a check caught none
83
+ # of it, because the miss happened to travel the unwired way.
84
+ _ENTRY_CC='^(CLAUDE\.md|knowledge/shared/(harness-core|dialogue|rules)/)'
85
+ _ENTRY_CX='^(AGENTS\.md|docs/codex-compat)'
86
+ if printf '%s\n' "$CHANGED_SINCE_TAG" | grep -qE "$_ENTRY_CC" \
87
+ && ! printf '%s\n' "$CHANGED_SINCE_TAG" | grep -qE "$_ENTRY_CX"; then
88
+ echo "⚠️ ④-b drift candidate (CC→Codex): shipped CLAUDE.md/knowledge changed but AGENTS.md/docs/codex-compat did not — JUDGE entry-point parity (sync, or record drift:none if genuinely unaffected)"
89
+ fi
90
+ if printf '%s\n' "$CHANGED_SINCE_TAG" | grep -qE "$_ENTRY_CX" \
91
+ && ! printf '%s\n' "$CHANGED_SINCE_TAG" | grep -qE "$_ENTRY_CC"; then
92
+ echo "⚠️ ④-b drift candidate (Codex→CC): AGENTS.md/docs/codex-compat changed but CLAUDE.md/knowledge did not — JUDGE entry-point parity (a rule living only in AGENTS.md is invisible to Claude Code sessions)"
93
+ fi
94
+ fi
95
+
96
+ # ⑤ CARD-LAST invariant — the card must be the NEWEST close artifact. A card older than
97
+ # fh_completed / signal files written this session = ⑤ ran before ①–④ finished (the bug class).
98
+ if [ -f "$CARD" ]; then
99
+ CARD_E=$(_mtime "$CARD")
100
+ NEWER=$(find "$FH/tracks/_meta" -maxdepth 1 -type f \( -name "fh_completed_*.md" -o -name "fh_signal_*.md" \) -newer "$CARD" 2>/dev/null | wc -l | tr -d ' ')
101
+ if [ "$NEWER" -gt 0 ]; then
102
+ echo "❌ ⑤ card-last violated — $NEWER close artifact(s) newer than the session card; re-run ⑤ (delta update)"
103
+ FAIL=1
104
+ else
105
+ echo "✅ ⑤ card is the newest close artifact (card-last holds)"
106
+ fi
107
+ else
108
+ echo "❌ ⑤ session card missing: $CARD"
109
+ FAIL=1
110
+ fi
111
+
112
+ # ⑤-b card-drift probe — 카드의 "부재 주장"을 실물과 대조 (advisory, never FAIL).
113
+ # WHY (N=3, 2026-07-22 주간감사 🟥1): 카드 🔴 "frontier-digest 미가동 — 로그도 산출물도 0"이
114
+ # 오판정이었다(실측 launchd 14/14 발화·산출 12/14). 07-20 S-4 재발에 이어 3회째 →
115
+ # operations.md §Recurrence escalation: 습관 규칙이 아니라 기계 프로브.
116
+ # HONEST SCOPE: 카드 🔴/🟡 줄에서 부재-주장 키워드를 잡고, 그 줄의 이름/경로 토큰으로
117
+ # 실물을 글롭 검색한다. 어휘가 안 겹치면 못 잡는다(무음 FN) — 앵커지 floor 가 아니다.
118
+ # 방향은 advisory: 가역 표면에서 하드 블록은 --no-verify 를 학습시킨다(#165 HIGH-1 동일 원리).
119
+ _ABSENCE_RE='미가동|산출물[^가-힣]*0|로그[^가-힣]*0|0건|부재|안 돌|미생성|not running|no output|zero output'
120
+ # 부정/정정 문맥 가드 — 부재-주장을 **인용하며** 정정하는 줄만 건너뛴다. 판별자는 debunk
121
+ # 어휘 단독이 아니라 **부재-키워드가 인용부호 안에 있는가** — challenger A-1 실측: 살아있는
122
+ # 주장 + 무관한 '정정 필요' 가 같은 줄이면 debunk-단독 가드가 진짜 경고를 무음 삼켰다(FN).
123
+ _DEBUNK_RE='오판정|정정|거짓|아니었|반증'
124
+ _QUOTED_ABSENCE_RE='["“”'"'"'『][^"“”'"'"'』]*(미가동|부재|미생성)'
125
+ if [ -f "$CARD" ]; then
126
+ DRIFT_HITS=0
127
+ while IFS= read -r line; do
128
+ # 부재-주장 줄에서 검증 가능한 토큰 2계층 추출:
129
+ # (a) 명시 경로/글롭 (슬래시나 * 포함) — 그대로 글롭 확장
130
+ # (b) 이름 토큰 (하이픈/언더스코어 포함 ≥6자, e.g. frontier-digest) — -/_ 정규화 후
131
+ # tracks/_meta{,/logs} 파일명 부분일치 검색
132
+ # `*` 는 추출 클래스에서 제외 — 마크다운 강조(**tok**)가 토큰에 붙어 글롭 분기로
133
+ # 오폭한다(known-pair P 픽스처가 잡은 계기 불량, 2026-07-23). 경로 판정은 슬래시로만.
134
+ tokens=$(printf '%s\n' "$line" | grep -oE '[A-Za-z0-9_./-]+' \
135
+ | sed 's/^[.-]*//; s/[.-]*$//' \
136
+ | grep -E '(/|[A-Za-z0-9]+[-_][A-Za-z0-9]+)' | grep -E '.{6,}' \
137
+ | grep -vE '^[0-9._-]+$' | sort -u) # 날짜/숫자 토큰 제외 (실카드 FP)
138
+ [ -z "$tokens" ] && continue
139
+ while IFS= read -r tok; do
140
+ found=0
141
+ case "$tok" in
142
+ */*) # (a) 경로 — **파일만** 인정(-f). 디렉토리를 세면 카드의 위치-언급
143
+ # (tracks/_meta/ 등)이 "실물"로 잡힌다(challenger A-2 FP). 인프라 루트 제외.
144
+ case "$tok" in tracks/_meta|tracks/_meta/|tracks/_meta/logs|tracks/_meta/logs/) continue ;; esac
145
+ # shellcheck disable=SC2086
146
+ for f in $FH/$tok $FH/${tok}*; do [ -f "$f" ] && found=$((found+1)); done ;;
147
+ *) # (b) 이름 토큰 — 양쪽 구분자 변형으로 검색
148
+ norm_u=$(printf '%s' "$tok" | tr '-' '_'); norm_h=$(printf '%s' "$tok" | tr '_' '-')
149
+ found=$(find "$FH/tracks/_meta" -maxdepth 2 -type f ! -name "$(basename "$CARD")" \( -name "*${norm_u}*" -o -name "*${norm_h}*" \) 2>/dev/null | wc -l | tr -d ' ') ;;
150
+ esac
151
+ if [ "${found:-0}" -gt 0 ]; then
152
+ echo "⚠️ ⑤-b card-drift: 카드가 부재를 주장하는데 실물이 있다 — 토큰 '$tok' 매치 ${found}건. 줄: $(printf '%s' "$line" | cut -c1-80)…"
153
+ echo " → 주장을 손검증하라 (미가동≠산출누락 — 07-22 오판정 클래스). advisory, 차단 아님."
154
+ DRIFT_HITS=$((DRIFT_HITS+1)); break
155
+ fi
156
+ done <<CARD_TOK_EOF
157
+ $tokens
158
+ CARD_TOK_EOF
159
+ done <<CARD_LINE_EOF
160
+ $(grep -E '(🔴|🟡)' "$CARD" 2>/dev/null | grep -E "$_ABSENCE_RE" | grep -v '^description:' \
161
+ | while IFS= read -r _l; do
162
+ if printf '%s\n' "$_l" | grep -qE "$_DEBUNK_RE" \
163
+ && printf '%s\n' "$_l" | grep -qE "$_QUOTED_ABSENCE_RE"; then continue; fi
164
+ printf '%s\n' "$_l"
165
+ done || true)
166
+ CARD_LINE_EOF
167
+ [ "$DRIFT_HITS" -eq 0 ] && echo "✅ ⑤-b no card absence-claim contradicted by on-disk artifacts"
168
+ fi
169
+
170
+ echo "── close check: $([ "$FAIL" -eq 0 ] && echo CONSISTENT || echo VIOLATIONS) ──"
171
+ exit "$FAIL"
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env bash
2
+ # test_degrade_scan_shell_probes.sh — regression anchor for the shell (S*) probes of
3
+ # scripts/degrade_direction_scan.sh.
4
+ #
5
+ # WHY THIS EXISTS (measured 2026-07-28, known-pair calibration):
6
+ # The scan COLLECTED `.sh` files but every probe was Python-shaped (`except:` / `.get(k, True)` /
7
+ # `if not x:` / `.split()`). A known-positive bash file carrying four distinct default-toward-PASS
8
+ # shapes scored 0/4 and the scan printed "no default-toward-PASS smells in 1 scanned py/sh file".
9
+ # That is a FALSE CLEAN — strictly worse than honest non-coverage, because a caller keying on the
10
+ # message or exit code reads it as verified.
11
+ # A second, larger hole surfaced in the same run: git hooks are named `pre-push` / `pre-commit`
12
+ # (no extension) and live under a DOTTED directory, so `templates/.git-hooks` — FH's own mechanical
13
+ # floor — reported "no scannable (py/sh) target files", exit 0.
14
+ #
15
+ # The assertions below pin: (1) the S-probes separate a known pair, (2) extensionless shell files
16
+ # under a dotted directory are collected, (3) the Python probes did not regress, and (4) two
17
+ # deliberate NON-detections stay non-detections — flagging them would push an author to delete a
18
+ # remedy or to silence a legitimate precondition guard.
19
+ #
20
+ # Usage: bash scripts/test_degrade_scan_shell_probes.sh
21
+ # Exit: 0 = all assertions pass; 1 = a regression.
22
+ set -uo pipefail
23
+
24
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
25
+ SCAN="$REPO_ROOT/scripts/degrade_direction_scan.sh"
26
+ [ -f "$SCAN" ] || { echo "FAIL: $SCAN not found"; exit 1; }
27
+
28
+ TMP="$(mktemp -d)"
29
+ trap 'rm -rf "$TMP"' EXIT
30
+
31
+ pass=0; fail=0
32
+ ok() { printf ' ✅ %s\n' "$1"; pass=$((pass+1)); }
33
+ bad() { printf ' ❌ %s\n' "$1"; fail=$((fail+1)); }
34
+
35
+ # Count S-probe hit lines. Deliberately counts the probe TAG, not the summary line: a summary can say
36
+ # "clean" for reasons unrelated to detection, and this anchor must not be satisfiable by prose.
37
+ s_hits() { bash "$SCAN" "$@" 2>&1 | grep -cE '\[S[0-9]:'; }
38
+ p_hits() { bash "$SCAN" "$@" 2>&1 | grep -cE '\[[A-F][0-9]?:'; }
39
+ rc_of() { bash "$SCAN" "$@" >/dev/null 2>&1; echo $?; }
40
+
41
+ # ── Fixtures ────────────────────────────────────────────────────────────────────────
42
+ # Known POSITIVE: four distinct shell-shaped default-toward-PASS constructs, one per probe.
43
+ cat > "$TMP/known_positive.sh" <<'EOF'
44
+ #!/usr/bin/env bash
45
+ check_secret() {
46
+ scan_output=$(run_scanner "$1") || return 0 # S1: the check errored -> report success
47
+ if [ -z "$scan_output" ]; then
48
+ return 0 # S4: empty == errored == "clean"
49
+ fi
50
+ return 1
51
+ }
52
+ verdict=$(get_verdict) || verdict="PASS" # S3: unresolved -> permissive verdict
53
+ if [ "$verdict" = "BLOCK" ]; then
54
+ exit 1
55
+ else
56
+ exit 0 # S2: unenumerated case -> allow
57
+ fi
58
+ EOF
59
+
60
+ # Known NEGATIVE: the same logic written fail-closed. Must stay silent, or the probes are noise.
61
+ cat > "$TMP/known_negative.sh" <<'EOF'
62
+ #!/usr/bin/env bash
63
+ set -euo pipefail
64
+ check_secret() {
65
+ if ! scan_output=$(run_scanner "$1"); then
66
+ echo "scanner failed - fail closed" >&2; return 1
67
+ fi
68
+ [ -n "$scan_output" ] && return 1
69
+ return 0
70
+ }
71
+ EOF
72
+
73
+ # Extensionless hook under a DOTTED directory — the collection bug's exact shape.
74
+ mkdir -p "$TMP/.git-hooks"
75
+ cat > "$TMP/.git-hooks/pre-push" <<'EOF'
76
+ #!/usr/bin/env bash
77
+ verdict=$(classify_refs) || verdict="ALLOW"
78
+ EOF
79
+
80
+ # Deliberate NON-detections.
81
+ cat > "$TMP/non_detections.sh" <<'EOF'
82
+ #!/usr/bin/env bash
83
+ # (a) integer sanitization — the PRESCRIBED remedy for the pipefail-fallback class, not the defect.
84
+ count=$(grep -c pattern file)
85
+ if [ "${count:-0}" -gt 0 ]; then echo "found"; fi
86
+ # (b) SCOPE guards — "this run does not apply here" is not a claim that a check passed.
87
+ [ -d "$HOME/projects" ] || exit 0
88
+ [[ "$1" =~ ^[0-9]{4}$ ]] || return 0
89
+ EOF
90
+
91
+ # DEPENDENCY guards must NOT be swept up by the scope-guard exclusion above. `[ -f lib ] || exit 0`
92
+ # says "my guard library is missing, therefore allow" — the fail-open shape measured on qasp
93
+ # 2026-07-28. An earlier draft of the scoping hid it; this fixture pins the distinction.
94
+ cat > "$TMP/dependency_guards.sh" <<'EOF'
95
+ #!/usr/bin/env bash
96
+ [ -f "$GUARD_LIB" ] || exit 0
97
+ [ -x "$SCANNER" ] || exit 0
98
+ EOF
99
+
100
+ # Python known-pair — the pre-existing probes must not have regressed.
101
+ printf 'def f(x):\n try:\n return g(x)\n except Exception:\n return True\n' > "$TMP/kp.py"
102
+ printf 'def f(x):\n try:\n return g(x)\n except Exception:\n raise\n' > "$TMP/kn.py"
103
+
104
+ # ── Assertions ──────────────────────────────────────────────────────────────────────
105
+ echo "degrade-scan shell-probe regression anchor"
106
+
107
+ n=$(s_hits "$TMP/known_positive.sh")
108
+ [ "$n" -eq 4 ] && ok "known-positive .sh: 4/4 shell smells detected" \
109
+ || bad "known-positive .sh: expected 4 S-hits, got $n (probes blind to bash again)"
110
+
111
+ rc=$(rc_of "$TMP/known_positive.sh")
112
+ [ "$rc" -eq 2 ] && ok "known-positive .sh: advisory exit 2" \
113
+ || bad "known-positive .sh: expected exit 2, got $rc"
114
+
115
+ n=$(s_hits "$TMP/known_negative.sh")
116
+ [ "$n" -eq 0 ] && ok "known-negative .sh: silent (probes discriminate, not just fire)" \
117
+ || bad "known-negative .sh: expected 0 S-hits, got $n"
118
+
119
+ rc=$(rc_of "$TMP/known_negative.sh")
120
+ [ "$rc" -eq 0 ] && ok "known-negative .sh: exit 0" \
121
+ || bad "known-negative .sh: expected exit 0, got $rc"
122
+
123
+ # Directory walk must reach an extensionless shell file inside a dotted directory.
124
+ n=$(s_hits "$TMP/.git-hooks")
125
+ [ "$n" -ge 1 ] && ok "extensionless hook under a dotted dir: collected and scanned" \
126
+ || bad "extensionless hook under a dotted dir: not scanned ($n hits) — the git-hook floor is invisible again"
127
+
128
+ # ...and so must an explicit file argument naming it.
129
+ n=$(s_hits "$TMP/.git-hooks/pre-push")
130
+ [ "$n" -ge 1 ] && ok "extensionless hook as a direct file argument: scanned" \
131
+ || bad "extensionless hook as a direct file argument: not scanned ($n hits)"
132
+
133
+ n=$(s_hits "$TMP/non_detections.sh")
134
+ [ "$n" -eq 0 ] && ok "non-detections stay silent (\${v:-0} sanitization + precondition guards)" \
135
+ || bad "non-detections fired $n time(s) — flagging the remedy trains authors to delete it"
136
+
137
+ # A DOTTED shell filename (`helper.bash`) must not be silently dropped from a directory walk.
138
+ # Cross-family finding (gpt-5.5, 2026-07-28), reproduced before acceptance: the directory path
139
+ # dropped it in silence while the explicit-file path reported the same file as UNSCANNABLE.
140
+ # Silent on one path, honest on the other, is the fail-open half.
141
+ mkdir -p "$TMP/dotted"
142
+ cat > "$TMP/dotted/helper.bash" <<'EOF'
143
+ #!/usr/bin/env bash
144
+ scan=$(run_scanner "$1") || return 0
145
+ EOF
146
+ cat > "$TMP/dotted/gate.sh" <<'EOF'
147
+ #!/usr/bin/env bash
148
+ scan=$(run_scanner "$1") || return 0
149
+ EOF
150
+ n=$(s_hits "$TMP/dotted")
151
+ [ "$n" -eq 2 ] && ok "dotted shell filename (helper.bash) scanned alongside gate.sh in a directory walk" \
152
+ || bad "dotted shell filename: expected 2 S-hits, got $n — a .bash/.zsh gate is silently dropped again"
153
+
154
+ n=$(s_hits "$TMP/dependency_guards.sh")
155
+ [ "$n" -eq 2 ] && ok "dependency guards (\`[ -f lib ] || exit 0\`) still detected — scope exclusion did not swallow them" \
156
+ || bad "dependency guards: expected 2 S-hits, got $n — 'guard library missing → allow' is hidden again"
157
+
158
+ n=$(p_hits "$TMP/kp.py")
159
+ [ "$n" -ge 1 ] && ok "python known-positive: pre-existing probes still fire" \
160
+ || bad "python known-positive: no hits — the Python probes regressed"
161
+
162
+ n=$(p_hits "$TMP/kn.py")
163
+ [ "$n" -eq 0 ] && ok "python known-negative: still silent" \
164
+ || bad "python known-negative: $n hit(s) — Python probes became noisy"
165
+
166
+ # The field-propagated copy must not drift from the canonical one. Two copies of the same
167
+ # normalizer diverge, and the lenient half silently drops what the strict half catches — measured
168
+ # 2026-07-28: `templates/` was 2 lines behind BEFORE this session's fix and then a full 8 KB behind
169
+ # after it, so field harnesses (the ones the cross-family gate doc actually points at) were running
170
+ # the version that scored 0/4 on the known-positive while `scripts/` scored 4/4.
171
+ TPL="$REPO_ROOT/templates/degrade_direction_scan.sh"
172
+ if [ ! -d "$REPO_ROOT/templates" ]; then
173
+ # Package mode: the npm tarball may ship a narrower surface. Absent templates/ is not drift.
174
+ printf ' \u2013 field-copy drift check SKIPPED (no templates/ — package mode)\n'
175
+ elif [ ! -f "$TPL" ]; then
176
+ bad "templates/ exists but degrade_direction_scan.sh is MISSING there — field harnesses get no scan"
177
+ elif cmp -s "$TPL" "$SCAN"; then
178
+ ok "field-propagated copy is byte-identical to scripts/ (no divergent-normalizer drift)"
179
+ else
180
+ bad "templates/degrade_direction_scan.sh has DRIFTED from scripts/ — the field copy is what qasp/pmh run; sync it (cp scripts/degrade_direction_scan.sh templates/)"
181
+ fi
182
+
183
+ echo "----"
184
+ echo "degrade-scan shell probes: $pass passed, $fail failed"
185
+ [ "$fail" -eq 0 ] || exit 1