@chrono-meta/fh-gate 1.4.78 → 1.4.80

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.
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ # halffix_propagation_scan.sh — pre-commit advisory: this fix may have landed in only one copy.
3
+ #
4
+ # THE DEFECT — "반쪽-수리" (half-fix)
5
+ # A defect class lives in N sibling copies. The fix lands in ONE and nothing says so. Measured 3x
6
+ # in this project, and the shape is worse than the count: every one of the three occurred INSIDE
7
+ # an edit that was itself repairing an earlier half-fix. scripts/psa_scan_lib.sh's header records
8
+ # five such divergences found in a single 2026-07-26 audit — every confidentiality defect that
9
+ # audit found was a divergence between duplicated copies, not a flaw in the idea.
10
+ #
11
+ # WHAT IT DOES
12
+ # Takes the distinctive symbols and path literals touched by the staged diff, re-greps the tree,
13
+ # and NAMES the tracked files that carry the same token but are not staged. That is the whole
14
+ # contribution: the author already knows what they fixed; what they lose is the sibling.
15
+ #
16
+ # MARK, DO NOT BLOCK — this is mandated, not preferred. The spec for this debt is explicit:
17
+ # "표시(차단 아님 — 정당한 복제도 있다)". Legitimate duplication exists (templates/ ships a
18
+ # field-propagated copy of scripts/ ON PURPOSE, and selfcheck asserts they stay byte-identical).
19
+ # A detector that blocks on correct duplication is a detector that gets disabled.
20
+ #
21
+ # THE DISCRIMINATOR — if every copy is staged, the fix propagated and this stays SILENT.
22
+ # Without that, the scan fires loudest exactly when the author did the right thing. Two prior
23
+ # claims on this same mistake in this repo: S5 (9/9 false positives, narrowed 2026-07-28) and
24
+ # S6 (0 true positives on the planned surface, retargeted 2026-07-31). Lane N3 pins it.
25
+ #
26
+ # Usage: bash scripts/halffix_propagation_scan.sh # reads the staged diff of $PWD
27
+ # Opt out: put `noqa: half-fix` on any added line in the commit.
28
+
29
+ set -u
30
+ cd "$(git rev-parse --show-toplevel 2>/dev/null || echo .)" || exit 0
31
+ git rev-parse --git-dir >/dev/null 2>&1 || exit 0
32
+
33
+ # Deletions are excluded (ACM): a removed file's symbols surviving elsewhere is not a half-fix,
34
+ # it is the normal state of a deletion, and flagging it would be pure noise.
35
+ STAGED=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)
36
+ [ -n "$STAGED" ] || exit 0
37
+
38
+ DIFF=$(git diff --cached -U0 --diff-filter=ACM 2>/dev/null)
39
+ printf '%s' "$DIFF" | grep -qE '^\+.*noqa:?[[:space:]]*half-fix' && exit 0
40
+
41
+ # Anchor tokens, from CHANGED lines only (added and removed — a removed spelling is exactly what a
42
+ # sibling may still carry). Two shapes:
43
+ # · identifiers >= 10 chars — long enough that a collision is meaningful. Shell/py keywords and
44
+ # the everyday vocabulary (`then`, `echo`, `return`, `local`) are all shorter, so the length
45
+ # floor does the keyword filtering without a denylist to maintain. (Lane N6.)
46
+ # · path literals `a/b` — the spec names filenames as anchors alongside symbols. (Lane P7.)
47
+ # The ENCLOSING function counts as a changed symbol even when the edited line itself carries no
48
+ # distinctive token — and a half-fix is a function-level thing, so this is the common case, not an
49
+ # edge one. git already hands it over in the hunk header (`@@ -2 +2 @@ psa_low_allowlisted() {`),
50
+ # so the context comes for free rather than from a hand-rolled scope parser.
51
+ # Found by lane P1 failing: the fix edited only a `case` line, whose longest token was 9 chars, and
52
+ # the scan went silent on a textbook two-copy divergence.
53
+ CHANGED=$( { printf '%s\n' "$DIFF" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)'
54
+ printf '%s\n' "$DIFF" | sed -n 's/^@@ .* @@ //p'
55
+ } )
56
+ TOKENS=$( { printf '%s\n' "$CHANGED" | grep -oE '[A-Za-z_][A-Za-z0-9_-]{9,}'
57
+ printf '%s\n' "$CHANGED" | grep -oE '[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+'
58
+ } | sort -u )
59
+ # NO early exit here. R1 (tokens) and R2 (whole-file copies) are INDEPENDENT rules, and an empty
60
+ # token set is the normal state for a short edit — `a() { :; }` → `a() { echo fixed; }` carries no
61
+ # 10-char anchor at all. An early `exit 0` on empty tokens silently disabled R2 for exactly the
62
+ # edits R2 exists to catch. (Caught by lane R2p, 2026-07-31, after the same shape had already
63
+ # passed 10/10 in the other lanes — a rule can be correct and unreachable.)
64
+
65
+ # Cap, and SAY SO when it bites. A silent truncation reads as "checked everything" when it did not.
66
+ MAX_TOKENS="${HALFFIX_MAX_TOKENS:-60}"
67
+ TOTAL=$(printf '%s\n' "$TOKENS" | grep -c .)
68
+ if [ "$TOTAL" -gt "$MAX_TOKENS" ]; then
69
+ echo " ℹ️ half-fix scan: $TOTAL anchor tokens in this diff, examining the first $MAX_TOKENS (raise with HALFFIX_MAX_TOKENS)." >&2
70
+ TOKENS=$(printf '%s\n' "$TOKENS" | head -n "$MAX_TOKENS")
71
+ fi
72
+
73
+ # A token in many files is framework vocabulary, not a duplicated fix site. (Lane N5.)
74
+ MAX_FILES="${HALFFIX_MAX_FILES:-8}"
75
+ staged_has() { printf '%s\n' "$STAGED" | grep -qxF "$1"; }
76
+
77
+ hits=""
78
+ while IFS= read -r tok; do
79
+ [ -n "$tok" ] || continue
80
+ files=$(git grep -l --fixed-strings -e "$tok" -- . 2>/dev/null)
81
+ [ -n "$files" ] || continue
82
+ n=$(printf '%s\n' "$files" | grep -c .)
83
+ [ "$n" -le "$MAX_FILES" ] || continue
84
+ others=""
85
+ while IFS= read -r f; do
86
+ [ -n "$f" ] || continue
87
+ staged_has "$f" || others="${others}${others:+, }$f"
88
+ done <<< "$files"
89
+ [ -n "$others" ] || continue # every copy staged → propagated → silent (lane N3)
90
+ hits="${hits} ⚠️ HALF-FIX \`$tok\` also lives in: $others
91
+ "
92
+ done <<< "$TOKENS"
93
+
94
+ # ── R2 — whole-file copy divergence. ─────────────────────────────────────────────────────────
95
+ # The token rule alone missed this repo's real duplicate pair: a script's NAME lives in 11–18 files
96
+ # here (docs, CATALOG, the manifest, selfcheck refs), so the ubiquity filter suppressed it, while an
97
+ # internal symbol like psa_low_allowlisted lives in 2. Measured 2026-07-31 — lanes 10/10 green, live
98
+ # probe silent. The lanes were necessary and not sufficient.
99
+ #
100
+ # Exact, not heuristic: the sibling was BYTE-IDENTICAL at HEAD and only one side is staged, so it is
101
+ # a divergence by construction and needs no threshold. Same-basename files that were never copies
102
+ # (CLAUDE.md vs templates/CLAUDE.md, the 40 SKILL.md files) stay silent — a basename rule would have
103
+ # flooded on exactly those.
104
+ while IFS= read -r sf; do
105
+ [ -n "$sf" ] || continue
106
+ base=$(basename "$sf")
107
+ head_blob=$(git rev-parse "HEAD:$sf" 2>/dev/null) || continue
108
+ while IFS= read -r cand; do
109
+ [ -n "$cand" ] && [ "$cand" != "$sf" ] || continue
110
+ staged_has "$cand" && continue
111
+ cand_blob=$(git rev-parse "HEAD:$cand" 2>/dev/null) || continue
112
+ [ "$cand_blob" = "$head_blob" ] || continue # were they the SAME file before this edit?
113
+ hits="${hits} ⚠️ HALF-FIX \`$sf\` was byte-identical to \`$cand\` at HEAD — only one side is staged
114
+ "
115
+ done <<< "$(git ls-files -- "*/$base" "$base" 2>/dev/null)"
116
+ done <<< "$STAGED"
117
+
118
+ [ -n "$hits" ] || exit 0
119
+
120
+ {
121
+ echo "⚠️ HALF-FIX PROPAGATION — symbols you changed also exist in files you did NOT stage."
122
+ echo " Not a verdict: templates/ ships deliberate copies of scripts/. Judge each, then proceed."
123
+ printf '%s' "$hits"
124
+ echo " Silence this commit with a \`noqa: half-fix\` comment on any added line."
125
+ } >&2
126
+ exit 0
@@ -21,10 +21,27 @@ set -uo pipefail
21
21
  REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
22
22
  cd "$REPO_ROOT" || exit 1
23
23
 
24
- if [ ! -d .git ] || [ ! -f package.json ]; then
24
+ # Source-checkout test uses `-e`, not `-d`. In a git WORKTREE `.git` is a FILE (a gitdir pointer),
25
+ # so the old `-d` test read every worktree as "installed package" and skipped the check entirely —
26
+ # silently, with exit 0. Measured 2026-07-31: a worktree created specifically to approximate CI
27
+ # reported PASS while this check had not run at all, i.e. the instrument used to justify wiring CI
28
+ # was itself fail-open on the surface it was standing in. `-e` covers both the ordinary checkout
29
+ # (dir) and the worktree (file); genuine package mode has no `.git` of either kind, so it still
30
+ # skips. Anchored by scripts/test_package_coverage_lanes.sh.
31
+ if [ ! -e .git ]; then
25
32
  echo "SKIP package-coverage (not a source checkout)"
26
33
  exit 0
27
34
  fi
35
+ # PREDICATE SPLIT (cross-family review, 2026-07-31). These were one condition, and folding them
36
+ # together meant `.git` present + manifest missing returned SKIP + exit 0 — "we are in a checkout
37
+ # and cannot read what ships" reported as "nothing to check here". Absence of the input is not
38
+ # absence of the defect; `not found != 0` (CLAUDE.md §Instrument-Calibration). Only the no-.git
39
+ # case is a legitimate skip (an installed package, where the un-shipped files are correctly gone).
40
+ if [ ! -f package.json ]; then
41
+ echo "FAIL package-coverage: source checkout with no package.json — the shipped file list is"
42
+ echo " unreadable, so coverage is UNMEASURED, not clean"
43
+ exit 1
44
+ fi
28
45
 
29
46
  # ── Accepted-absent, with the reason each one is NOT a defect ────────────────────────────────
30
47
  # Adding a line here is a decision, not a silencer: each entry states why shipping it would be
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bash
2
+ # pipe_verdict_guard.sh — PreToolUse(Bash) advisory: a verdict read from the wrong end of a pipe.
3
+ #
4
+ # THE DEFECT
5
+ # `cmd | tail -5; echo "exit=$?"` reports TAIL's status, not cmd's. A gate that FAILED reads as
6
+ # exit 0. The degrade direction is toward PASS, which is the direction that never announces
7
+ # itself. Measured 6× in this project between 2026-07-29 and 2026-07-31.
8
+ #
9
+ # WHY A HOOK AND NOT A FILE LINTER (measured 2026-07-31, and it reversed the plan on record)
10
+ # The session card prescribed "add an S6 class to scripts/degrade_direction_scan.sh". Every
11
+ # `pipe + $?` occurrence in this repo's shell scripts was then hand-verified: 7 hits, 7 correct
12
+ # — in all 7 the final stage WAS the command under test. True positives in shipped files: 0.
13
+ # All 6 recurrences lived in interactively-composed commands, which a repo scanner never reads.
14
+ # S6 would have shipped a probe with no true positives, the exact failure mode S5 records in
15
+ # its own comment ("100% FP trains dismissal of the one hit that will matter"). So the guard
16
+ # moved to the surface where the defect actually occurs: the Bash tool call itself.
17
+ #
18
+ # TWO RULES, DELIBERATELY UNEQUAL IN CONFIDENCE
19
+ # R1 — deterministic, zero-FP. `${PIPESTATUS[…]}` is bash-only. This project's Bash tool runs
20
+ # zsh, where that expands to the EMPTY STRING (zsh spells it `$pipestatus[1]`, 1-indexed).
21
+ # A verdict read from it is not wrong, it is ABSENT. No judgment involved.
22
+ # R2 — heuristic, narrowed to DISPLAY FILTERS as the final stage (tail/head/cat/less/more).
23
+ # A final `grep -q`, a script, or a subshell is usually the thing whose status is wanted —
24
+ # those are the 7 correct shapes above and are not flagged. Narrowing costs recall; the
25
+ # alternative is a probe nobody reads.
26
+ #
27
+ # DEGRADE DIRECTION: advisory. This guard WARNS and exits 0 — it never blocks a Bash call, because
28
+ # a mis-read verdict is re-runnable and a false block on a developer's shell trains --no-verify
29
+ # reflexes on hooks that DO guard irreversible surfaces. Set FH_PIPE_VERDICT_BLOCK=1 to escalate.
30
+ # If the command cannot be extracted, it stays silent: an unparsed input is not a finding.
31
+ #
32
+ # Usage:
33
+ # hook: PreToolUse matcher "Bash" → bash scripts/pipe_verdict_guard.sh
34
+ # test: printf '%s' "<command>" | bash scripts/pipe_verdict_guard.sh --stdin-raw
35
+ # Opt out on a single command with a trailing `# noqa: pipe-verdict`.
36
+
37
+ set -u
38
+
39
+ CMD=""
40
+ if [ "${1:-}" = "--stdin-raw" ]; then
41
+ CMD=$(cat)
42
+ else
43
+ RAW=$(cat)
44
+ # PreToolUse payload. Absent/!Bash/unparseable → stay silent (see degrade direction above).
45
+ CMD=$(printf '%s' "$RAW" | python3 -c '
46
+ import json,sys
47
+ try: d = json.load(sys.stdin)
48
+ except Exception: sys.exit(0)
49
+ if d.get("tool_name") != "Bash": sys.exit(0)
50
+ sys.stdout.write(d.get("tool_input", {}).get("command", "") or "")
51
+ ' 2>/dev/null) || CMD=""
52
+ fi
53
+ [ -n "$CMD" ] || exit 0
54
+
55
+ # Explicit opt-outs, checked before any rule.
56
+ printf '%s' "$CMD" | grep -qE '#[[:space:]]*noqa:?[[:space:]]*pipe-verdict' && exit 0
57
+
58
+ hits=""
59
+ add() { hits="${hits} ⚠️ PIPE-VERDICT $1
60
+ $2
61
+ "; }
62
+
63
+ # Flatten to ONE line before any matching. grep is line-oriented, so `.*` never spans a newline and
64
+ # every multi-line command missed — which is the worse half, because the invocations that actually
65
+ # recur here are multi-line. A newline is a statement separator, so `; ` is the faithful substitute.
66
+ # (Found by the Axis-2 adversarial pass on this guard, 2026-07-31; lanes A* pin it.)
67
+ FLAT=$(printf '%s' "$CMD" | tr '\n' ';' | sed 's/;/; /g')
68
+
69
+ # ── R1 — PIPESTATUS under zsh: the value is empty, so the verdict is absent. ──────────────────
70
+ # Brace-optional: zsh accepts `$PIPESTATUS[0]` as well, and the brace-anchored form missed it (lane B*).
71
+ if printf '%s' "$FLAT" | grep -qE '\$\{?PIPESTATUS[[{]' ; then
72
+ add "R1 \${PIPESTATUS[…]} is empty in zsh" \
73
+ "This shell is zsh; the bash array does not exist here, so the verdict expands to \"\". Use zsh's \`\$pipestatus[1]\` (1-indexed), or drop the pipe and read \$? directly."
74
+ fi
75
+
76
+ # ── R2 — display filter as the final stage, then a read of $?. ────────────────────────────────
77
+ # `||` is neutralized first: `a || b || echo 0` contains no pipeline, and reading it as one is
78
+ # how the sibling S5 probe produced 9 false positives before it was narrowed (2026-07-28).
79
+ # `set -o pipefail` in the same command makes `$?` after a pipeline correct — not a finding.
80
+ NORM=$(printf '%s' "$FLAT" | sed 's/||/__OR__/g')
81
+ if ! printf '%s' "$NORM" | grep -qE 'set -o pipefail|set -[a-zA-Z]*o[a-zA-Z]* pipefail'; then
82
+ if printf '%s' "$NORM" \
83
+ | grep -qE '\|[[:space:]]*(tail|head|cat|less|more)([[:space:]][^|;&]*)?[[:space:]]*[;&].*\$\?'; then
84
+ add "R2 \$? after a display filter" \
85
+ "\$? holds the filter's status (tail/head/cat almost always succeed), not the command's — a FAILED check reads as 0. Capture first: \`out=\$(cmd 2>&1); rc=\$?\` then print \"\$out\" | tail."
86
+ fi
87
+ fi
88
+
89
+ [ -n "$hits" ] || exit 0
90
+
91
+ printf '%s' "$hits" >&2
92
+ if [ "${FH_PIPE_VERDICT_BLOCK:-0}" = "1" ]; then exit 2; fi
93
+ exit 0
@@ -2,7 +2,13 @@
2
2
  # selfcheck.sh — mandatory-pass (deterministic) checks on FH's own executable surface.
3
3
  # Class: mandatory-pass (harness_6axis_framework.md §Axis 5 check classes) — blocks on fail.
4
4
  # Scope: executables shipped via npm files[] + the bash infra driving the FH gate chain.
5
- # Syntax-only (node --check / bash -n): zero side effects, no network, runs anywhere.
5
+ # NOT syntax-only any more, and this line used to say it was. Syntax checks (node --check / bash -n)
6
+ # are only the first section; behavioural lane suites follow and they DO have side effects and
7
+ # environment needs: temp dirs, a loopback HTTP stub on 127.0.0.1:18011, git, `timeout`, and — via
8
+ # the session-close lanes — an optional `gh` call that reaches GitHub when the binary is present.
9
+ # Corrected 2026-07-31 (cross-family review): the stale "zero side effects, no network" claim
10
+ # survived the additions that falsified it, which is how a reader ends up trusting the wrong
11
+ # invariant. No remote network is REQUIRED; some is possible.
6
12
  # Wiring: `npm test` for any session; `prepublishOnly` so a publish cannot ship a
7
13
  # syntactically broken executable.
8
14
  set -u
@@ -105,10 +111,25 @@ fi
105
111
  # absent from the tarball — including templates/predelete_check.sh, which CLAUDE.md instructs you
106
112
  # to run before a destructive op. Wired here in the same commit that created it, because the two
107
113
  # previous anchors this session shipped with zero callers.
108
- if [ -f scripts/package_coverage_check.sh ]; then
114
+ # Anchored 2026-07-31. Until then this was the ONE subject in this file exempt from the
115
+ # "subject present but anchor missing => FAIL" rule the eight blocks below enforce — and the
116
+ # exemption cost something real: its source-checkout predicate tested `-d .git`, so inside a git
117
+ # WORKTREE (where .git is a FILE) it printed SKIP and returned 0 without scanning. A worktree is
118
+ # how a fresh CI checkout gets approximated, so the check was absent from exactly the tree used
119
+ # to reason about CI. scripts/test_package_coverage_lanes.sh pins the predicate across all four
120
+ # tree shapes plus a known pair.
121
+ if [ ! -f scripts/package_coverage_check.sh ]; then
122
+ echo "SKIP test_package_coverage_lanes.sh (subject scripts/package_coverage_check.sh absent)"
123
+ elif [ -f scripts/test_package_coverage_lanes.sh ]; then
124
+ if ! bash scripts/test_package_coverage_lanes.sh; then
125
+ fail=1
126
+ fi
109
127
  if ! bash scripts/package_coverage_check.sh; then
110
128
  fail=1
111
129
  fi
130
+ else
131
+ echo "FAIL test_package_coverage_lanes.sh: package_coverage_check.sh present but its anchor is missing"
132
+ fail=1
112
133
  fi
113
134
 
114
135
  # memory-link-check — the memory store is a GRAPH (memory_intent_recall.md: nodes=files,
@@ -132,6 +153,21 @@ fi
132
153
  # prevent. test_card_drift_probe.sh had shipped with ZERO callers since it was written; wiring it
133
154
  # here closes that, and the anchors are added to files[] in the same change so package mode runs
134
155
  # them too rather than reporting a deleted anchor.
156
+ # consent-class registry floor. Its subject decides whether standing consent may skip an approval
157
+ # prompt, so an uncalibrated instrument there hands out autonomy the operator never granted. The
158
+ # anchor was written into tests/ with ZERO callers first — the same defect this file already
159
+ # records twice above; wiring it here is the fix, not a note about the fix.
160
+ if [ ! -f scripts/consent_registry_check.sh ]; then
161
+ echo "SKIP test_consent_registry.sh (subject scripts/consent_registry_check.sh absent)"
162
+ elif [ -f scripts/test_consent_registry.sh ]; then
163
+ if ! bash scripts/test_consent_registry.sh; then
164
+ fail=1
165
+ fi
166
+ else
167
+ echo "FAIL test_consent_registry.sh: consent_registry_check.sh present but its anchor is missing"
168
+ fail=1
169
+ fi
170
+
135
171
  # sidecar_wait stdin plumbing. Its subject is dispatched by auto-decorrelation / steel-quench /
136
172
  # sim-conductor / AGENTS.md as the REQUIRED wait form, so a regression there silently empties every
137
173
  # cross-family verification. The anchor's first version shipped in tests/ with ZERO callers — the
@@ -177,6 +213,42 @@ else
177
213
  fail=1
178
214
  fi
179
215
 
216
+ # Two guards that read the AUTHOR's own actions rather than the repo's files. Both were added
217
+ # 2026-07-31; the pipe-verdict lane shipped in PR #209 WITHOUT this wiring, which is itself the
218
+ # half-fix class the second guard exists to catch — found by running that guard on this repo.
219
+ if [ ! -f scripts/sidecar_calibrate.sh ]; then
220
+ echo "SKIP test_ollama_panel_lanes.sh (subject scripts/sidecar_calibrate.sh absent)"
221
+ elif [ -f scripts/test_ollama_panel_lanes.sh ]; then
222
+ if ! bash scripts/test_ollama_panel_lanes.sh; then
223
+ fail=1
224
+ fi
225
+ else
226
+ echo "FAIL test_ollama_panel_lanes.sh: sidecar_calibrate.sh present but its ollama-leg anchor is missing"
227
+ fail=1
228
+ fi
229
+
230
+ if [ ! -f scripts/pipe_verdict_guard.sh ]; then
231
+ echo "SKIP test_pipe_verdict_guard_lanes.sh (subject scripts/pipe_verdict_guard.sh absent)"
232
+ elif [ -f scripts/test_pipe_verdict_guard_lanes.sh ]; then
233
+ if ! bash scripts/test_pipe_verdict_guard_lanes.sh; then
234
+ fail=1
235
+ fi
236
+ else
237
+ echo "FAIL test_pipe_verdict_guard_lanes.sh: pipe_verdict_guard.sh present but its anchor is missing"
238
+ fail=1
239
+ fi
240
+
241
+ if [ ! -f scripts/halffix_propagation_scan.sh ]; then
242
+ echo "SKIP test_halffix_lanes.sh (subject scripts/halffix_propagation_scan.sh absent)"
243
+ elif [ -f scripts/test_halffix_lanes.sh ]; then
244
+ if ! bash scripts/test_halffix_lanes.sh; then
245
+ fail=1
246
+ fi
247
+ else
248
+ echo "FAIL test_halffix_lanes.sh: halffix_propagation_scan.sh present but its anchor is missing"
249
+ fail=1
250
+ fi
251
+
180
252
  for _anchor in scripts/test_session_close_lanes.sh scripts/test_card_drift_probe.sh; do
181
253
  if [ ! -f scripts/session_close_check.sh ]; then
182
254
  echo "SKIP ${_anchor##*/} (subject scripts/session_close_check.sh absent)"
@@ -203,10 +275,31 @@ if [ -d ".claude/rules" ]; then
203
275
  # fail=0 → SELFCHECK: PASS. The check would have silently ceased to exist while still
204
276
  # reporting a pass — the same shape count_check.sh:71 already guards against with its
205
277
  # impossible-zero rule. fh-meta always has refs; zero means the instrument broke.
206
- _refs=$(grep -hoE '\`[^\` ]+\`' CLAUDE.md .claude/rules/*.md 2>/dev/null \
207
- | sed 's/\`//g' \
208
- | grep -E '^(knowledge|templates|scripts|docs|plugins|\.claude)/[^*{}<>$]+\.(md|sh|ya?ml|jsonc|json)$' \
209
- | sort -u)
278
+ # EXTRACTION MOVED OFF grep (2026-07-31, measured on the first real CI run). The pipeline used to
279
+ # be `grep -hoE` + sed + `grep -E` over CLAUDE.md, which is Korean-heavy. On macOS (BSD grep, UTF-8
280
+ # locale) it returned ~50 refs; on the ubuntu runner (GNU grep, LANG unset => C locale) it returned
281
+ # ZERO, and the impossible-zero guard below is the only reason that surfaced as a failure instead
282
+ # of "no refs, all clean". Same root cause as the card-drift probe failing its positive lanes in
283
+ # the same run: multibyte text through locale-dependent grep.
284
+ # python3 reads the files as UTF-8 explicitly, so this extractor no longer has a locale at all.
285
+ # It is already a hard dependency of selfcheck (validate_plugins/marketplace, memory_link_check),
286
+ # so this adds nothing to the requirement set. The regex is the same one, transcribed.
287
+ _refs=$(python3 - <<'REFPY' 2>/dev/null
288
+ import re, glob
289
+ pat = re.compile(r'^(knowledge|templates|scripts|docs|plugins|\.claude)/[^*{}<>$]+\.(md|sh|ya?ml|jsonc|json)$')
290
+ seen = set()
291
+ for f in ['CLAUDE.md'] + sorted(glob.glob('.claude/rules/*.md')):
292
+ try:
293
+ text = open(f, encoding='utf-8', errors='replace').read()
294
+ except OSError:
295
+ continue
296
+ for tok in re.findall(r'`([^` ]+)`', text):
297
+ if pat.match(tok):
298
+ seen.add(tok)
299
+ for p in sorted(seen):
300
+ print(p)
301
+ REFPY
302
+ )
210
303
  if [ -z "$_refs" ]; then
211
304
  echo "FAIL ref-path: extractor produced 0 refs — the scan broke, it did not pass"
212
305
  fail=1
@@ -146,7 +146,21 @@ fi
146
146
  # HONEST SCOPE: 카드 🔴/🟡 줄에서 부재-주장 키워드를 잡고, 그 줄의 이름/경로 토큰으로
147
147
  # 실물을 글롭 검색한다. 어휘가 안 겹치면 못 잡는다(무음 FN) — 앵커지 floor 가 아니다.
148
148
  # 방향은 advisory: 가역 표면에서 하드 블록은 --no-verify 를 학습시킨다(#165 HIGH-1 동일 원리).
149
- _ABSENCE_RE='미가동|산출물[^가-힣]*0|로그[^가-힣]*0|0건|부재|안 돌|미생성|not running|no output|zero output'
149
+ # COLLATION-FREE BY CONSTRUCTION (2026-07-31, diagnosed ON the runner after two local hypotheses
150
+ # were refuted). This regex used to contain the Hangul RANGE `[^가-힣]`. A multibyte range inside a
151
+ # bracket expression is collation-dependent, and GNU grep in the C locale — the GitHub runner's
152
+ # default — rejects it outright: `grep: Invalid collation character`.
153
+ # The failure mode is what makes it serious: grep writes that to stderr, exits 2, and emits NOTHING.
154
+ # Downstream this is indistinguishable from exit 1 "no match", so the pipeline produced zero lines
155
+ # and the probe concluded "no absence claims in the card" — CLEAN — on every input, positive and
156
+ # negative alike. Its 7 lanes had passed 3/3 on macOS for weeks because BSD grep accepts the range.
157
+ # Same shape as the pyyaml CI trigger caught earlier the same day: grep's ERROR status folded into
158
+ # its NO-MATCH branch. `not found != 0`, and neither is `could not look`.
159
+ # The replacement uses an ASCII-only class, which has no collation to be invalid. SEMANTIC SHIFT,
160
+ # stated rather than hidden: the original meant "산출물 then non-Hangul then 0" (keep the claim
161
+ # inside one clause); this means "산출물 then no DIGIT within 20 chars then 0". Slightly wider, and
162
+ # the lanes below are what pin that it did not become too wide.
163
+ _ABSENCE_RE='미가동|산출물[^0-9]{0,20}0|로그[^0-9]{0,20}0|0건|부재|안 돌|미생성|not running|no output|zero output'
150
164
  # 부정/정정 문맥 가드 — 부재-주장을 **인용하며** 정정하는 줄만 건너뛴다. 판별자는 debunk
151
165
  # 어휘 단독이 아니라 **부재-키워드가 인용부호 안에 있는가** — challenger A-1 실측: 살아있는
152
166
  # 주장 + 무관한 '정정 필요' 가 같은 줄이면 debunk-단독 가드가 진짜 경고를 무음 삼켰다(FN).
@@ -181,6 +181,91 @@ probe_runtime() {
181
181
  probe_runtime codex "gpt-5.6-sol"
182
182
  probe_runtime agy "Gemini 3.1 Pro (High)"
183
183
 
184
+ # ── Local OpenAI-compatible panel (Ollama) — a DIFFERENT anchor, on purpose. ──────────────────
185
+ # One host serves several model FAMILIES (measured 2026-07-31: an OpenAI-lineage open-weight, a
186
+ # Qwen, a Gemma, a Mistral on one endpoint), so the panel's unit here is the model, not the binary.
187
+ # Everything is local, so nothing leaves the machine — this is the only panel member a residency-
188
+ # constrained session may use on company-adjacent work.
189
+ #
190
+ # WHY THE IDENTITY PROBE IS REPLACED, NOT REUSED — the self-report anchor above is INVALID for this
191
+ # class. Measured: `gpt-oss:20b` asked which model it is answered "The underlying model is GPT-4
192
+ # (likely)". Open-weight models do not reliably know their own name, so a self-report anchor would
193
+ # mark every one of them UNTRUSTED-PIN and drop a genuinely exact pin from the panel. Applying an
194
+ # instrument that cannot separate a known-positive from a known-negative on this target is the
195
+ # calibration failure this whole file exists to prevent — so the anchor moves to the SERVER'S
196
+ # response envelope, which reports the model actually loaded. That is a server-side fact rather
197
+ # than a model's claim, i.e. strictly stronger than what codex/agy expose through their CLIs.
198
+ #
199
+ # HOST IS NEVER HARDCODED. A LAN/Tailscale address is an internal hostname, and this file is
200
+ # public-tracked; §Company residency keeps those out of committed content. Default is loopback;
201
+ # point FH_OLLAMA_HOST at a remote node from a local, gitignored place.
202
+ OLLAMA_HOST_URL="${FH_OLLAMA_HOST:-http://127.0.0.1:11434}"
203
+ # Measured floor, not a guess: at num_predict=64 a reasoning model spent the ENTIRE budget in its
204
+ # `thinking` field (780 chars) and returned an EMPTY response — which reads identically to "cannot
205
+ # carry a verdict". At 512 the same model answered `PASS`. Budget starvation and incapacity are
206
+ # different findings and must not be reported as one.
207
+ OLLAMA_NUM_PREDICT="${FH_OLLAMA_NUM_PREDICT:-512}"
208
+
209
+ probe_ollama() {
210
+ command -v curl >/dev/null 2>&1 || { echo "ollama ABSENT — curl missing (absence measured)"; return 0; }
211
+ curl -sf --max-time 10 "$OLLAMA_HOST_URL/api/version" >/dev/null 2>&1 || {
212
+ printf 'ollama ABSENT — no server at the configured host (absence measured, not assumed)\n'
213
+ printf ' set FH_OLLAMA_HOST to probe a remote node; default is loopback\n'
214
+ return 0
215
+ }
216
+
217
+ local models="${FH_OLLAMA_MODELS:-}"
218
+ if [ -z "$models" ]; then
219
+ models=$(curl -sf --max-time 15 "$OLLAMA_HOST_URL/api/tags" 2>/dev/null \
220
+ | python3 -c 'import json,sys
221
+ try: d=json.load(sys.stdin)
222
+ except Exception: raise SystemExit
223
+ print(",".join(m["name"] for m in d.get("models",[])[:6]))' 2>/dev/null)
224
+ fi
225
+ [ -n "$models" ] || { echo "ollama REACHABLE but no models listed"; return 0; }
226
+
227
+ # Control runs ONCE per host, not per model: it is a property of the server, and repeating it per
228
+ # model would just multiply the cost of a fact that cannot differ.
229
+ local ctl ctl_state
230
+ ctl=$(curl -s --max-time 20 "$OLLAMA_HOST_URL/api/generate" \
231
+ -d '{"model":"fh-calib-nonexistent:99b","prompt":"hi","stream":false}' 2>&1)
232
+ if printf '%s' "$ctl" | grep -qiE '"error"|not found'; then ctl_state="rejects-bogus"; else ctl_state="accepts-bogus"; fi
233
+
234
+ local IFS=,
235
+ for m in $models; do
236
+ [ -n "$m" ] || continue
237
+ local body out env_model resp compact pin v
238
+ body=$(python3 -c 'import json,sys; print(json.dumps({"model":sys.argv[1],"prompt":sys.argv[2],"stream":False,"options":{"num_predict":int(sys.argv[3]),"temperature":0}}))' \
239
+ "$m" "$VERDICT_PROMPT" "$OLLAMA_NUM_PREDICT")
240
+ out=$(curl -sf --max-time 240 "$OLLAMA_HOST_URL/api/generate" -d "$body" 2>/dev/null)
241
+ if [ -z "$out" ]; then
242
+ printf 'ollama %-22s UNREACHABLE-THIS-RUN (measured, not inferred)\n' "$m"; continue
243
+ fi
244
+ env_model=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("model",""))' 2>/dev/null)
245
+ resp=$(printf '%s' "$out" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("response") or "").strip())' 2>/dev/null)
246
+ compact=$(printf '%s' "$resp" | tr -s '[:space:]' ' ' | sed 's/^ *//; s/ *$//')
247
+ if [ "$env_model" = "$m" ]; then pin="PIN-OK(envelope)"; else pin="UNTRUSTED-PIN"; fi
248
+ if [ "${#compact}" -le 12 ] && printf '%s' "$compact" | grep -qiE '^(pass|fail)[.!]?$'; then v="VERDICT-OK"; else v="VERDICT-UNPARSEABLE"; fi
249
+ printf 'ollama %-22s REACHABLE · %s · control: %s · %s\n' "$m" "$pin" "$ctl_state" "$v"
250
+ [ -z "$QUIET" ] && printf ' answered: %s\n' "$(printf '%s' "$compact" | cut -c1-60)"
251
+ if [ "$v" = "VERDICT-UNPARSEABLE" ] && [ -z "$compact" ]; then
252
+ printf ' ⚠️ EMPTY answer. Before recording this as "cannot carry a verdict", re-run with a\n'
253
+ printf ' larger FH_OLLAMA_NUM_PREDICT — a reasoning model can spend the whole budget\n'
254
+ printf ' thinking and return nothing, which looks identical from out here.\n'
255
+ fi
256
+ [ "$pin" = "PIN-OK(envelope)" ] && [ "$v" = "VERDICT-OK" ] && PANEL="${PANEL:+$PANEL, }ollama:$m"
257
+ done
258
+ }
259
+ # `--stub-model` means the caller is the hermetic CLI lane harness, which drives stub BINARIES and
260
+ # asserts on an empty panel. This leg talks to a SERVER, so on a developer machine with a local
261
+ # Ollama running it would populate the panel and break those lanes — which is exactly what happened
262
+ # when this was first wired (test_sidecar_calibrate_lanes lane6, "empty panel not stated", caught by
263
+ # the existing suite rather than by review). Skipping under --stub-model keeps each harness hermetic
264
+ # in its own way; this leg's own lanes drive a stub SERVER via FH_OLLAMA_HOST instead.
265
+ if [ -z "$STUB_MODEL" ] && { [ -z "$ONLY" ] || [ "$ONLY" = "ollama" ]; }; then
266
+ probe_ollama
267
+ fi
268
+
184
269
  if [ -n "$PANEL" ]; then
185
270
  echo "PANEL: $PANEL — usable different-family auditor(s), pin verified this run"
186
271
  else
@@ -29,6 +29,25 @@ run_fixture() { # $1=name $2=card-content $3=make-artifact(0/1) $4=expect-wa
29
29
  local out; out=$(bash "$CHECK" "$T" 2>/dev/null)
30
30
  local warned=0
31
31
  printf '%s\n' "$out" | grep -q "⑤-b card-drift" && warned=1
32
+ # RUN EVERY FIXTURE TWICE — ambient locale AND LC_ALL=C. Added 2026-07-31 after the probe passed
33
+ # 3/3 here for weeks while returning warn=0 for EVERY lane on the ubuntu runner. Cause: the
34
+ # absence regex held a Hangul RANGE `[^가-힣]`, which GNU grep in the C locale rejects with
35
+ # "Invalid collation character" — it exits 2 and prints nothing, which downstream is
36
+ # indistinguishable from "no match", so the probe reported the card clean on every input.
37
+ # BSD grep accepts the range, so no amount of running this suite on macOS could ever have caught
38
+ # it. MEASURED LIMIT, stated because the first version of this comment overclaimed: LC_ALL=C on
39
+ # macOS is NOT a stand-in for GNU grep — reverting the Hangul range with this dual-run in place
40
+ # still passed here. So this leg detects the class only where GNU grep runs, i.e. in CI. It is
41
+ # kept because a locale-divergent verdict is a defect wherever it is observed, and it costs one
42
+ # extra invocation; the platform-independent detector is the source lint below.
43
+ local out_c; out_c=$(LC_ALL=C LANG=C bash "$CHECK" "$T" 2>/dev/null)
44
+ local warned_c=0
45
+ printf '%s\n' "$out_c" | grep -q "⑤-b card-drift" && warned_c=1
46
+ if [ "$warned" != "$warned_c" ]; then
47
+ echo "❌ $name — LOCALE-DIVERGENT: ambient warn=$warned but LC_ALL=C warn=$warned_c"
48
+ echo " 텍스트 계기가 로케일에 따라 다른 판정을 낸다 — 어느 쪽이 맞든 캘리브레이션 실패다."
49
+ rm -rf "$T"; FAILED=1; return
50
+ fi
32
51
  rm -rf "$T"
33
52
  if [ "$warned" = "$expect" ]; then
34
53
  echo "✅ $name (warn=$warned, expected=$expect)"
@@ -73,5 +92,41 @@ run_fixture "P4 영어 부재주장 (A-4 앵커)" \
73
92
  run_fixture "N7 인용된 부재키워드+정정 → 무경고 (실카드 07-22 클래스)" \
74
93
  "- 🔴 foo-digest 산출 누락. 기존 카드의 \"미가동\" 주장은 오판정이었음" 1 0
75
94
 
95
+
96
+ # ── LOCALE-RANGE LINT (platform-independent, added 2026-07-31) ─────────────────────────
97
+ # The behavioural dual-run above cannot fire on BSD grep, so the class needs a detector that does
98
+ # not depend on which grep is installed. This one reads the SOURCE: a bracket expression containing
99
+ # a non-ASCII RANGE (`[^가-힣]`, `[ㄱ-ㅎ]`, …) is collation-dependent by construction and will be
100
+ # rejected outright by GNU grep in the C locale — "Invalid collation character", exit 2, no output,
101
+ # which downstream reads as "no match" and therefore as clean.
102
+ # Alternation of non-ASCII literals (`(🔴|🟡)`, `미가동|부재`) is NOT flagged: it carries no
103
+ # collation, and the runner proved it works (the emoji line-selection stage passed there while the
104
+ # range stage errored). Flagging it would push an author to mangle working code.
105
+ echo "-- locale-range lint: 비ASCII 문자 범위를 쓰는 정규식 (collation 의존) --"
106
+ LINT_OUT=$(python3 - "$SCRIPT_DIR" <<'LINTPY'
107
+ import re, sys, glob, os
108
+ root = sys.argv[1]
109
+ # a bracket expression containing <non-ascii> - <non-ascii>
110
+ rng = re.compile(r'\[[^]\n]*[^\x00-\x7F]-[^\x00-\x7F][^]\n]*\]')
111
+ hits = []
112
+ for f in sorted(glob.glob(os.path.join(root, '*.sh'))):
113
+ for i, line in enumerate(open(f, encoding='utf-8', errors='replace'), 1):
114
+ if line.lstrip().startswith('#'):
115
+ continue
116
+ m = rng.search(line)
117
+ if m:
118
+ hits.append(f"{os.path.basename(f)}:{i}: {m.group(0)}")
119
+ print('\n'.join(hits))
120
+ LINTPY
121
+ )
122
+ if [ -n "$LINT_OUT" ]; then
123
+ echo "❌ 비ASCII 범위 발견 — GNU grep(C 로케일)에서 exit 2 로 죽고 결과가 '무매치'로 읽힌다:"
124
+ printf '%s\n' "$LINT_OUT" | sed 's/^/ /'
125
+ FAILED=1
126
+ else
127
+ echo "✅ locale-range lint: 비ASCII 문자 범위 없음"
128
+ fi
129
+
130
+
76
131
  echo "── card-drift probe calibration: $([ "$FAILED" -eq 0 ] && echo "PASS (전 픽스처) — 배선 가능" || echo "FAIL — 계기 불량, 배선 금지") ──"
77
132
  exit "$FAILED"