@chrono-meta/fh-gate 1.4.77 → 1.4.79
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.
- package/.claude/rules/fh_4axis_gate.md +63 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +96 -260
- package/CLAUDE.md +2 -7
- package/docs/codex-compat.md +4 -1
- package/knowledge/shared/harness-core/agents_md_runtime_details.md +233 -0
- package/knowledge/shared/harness-core/loop_engineering.md +1 -1
- package/knowledge/shared/harness-core/multi_model_sidecar_strategy.md +1 -1
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +14 -0
- package/knowledge/shared/rules/operational_adaptation.md +1 -130
- package/package.json +14 -5
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/skills/install-doctor/SKILL.md +88 -0
- package/plugins/fh-meta/skills/install-wizard/SKILL.md +1 -1
- package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +117 -3
- package/scripts/fh_node_check.sh +184 -0
- package/scripts/fh_session_load.sh +101 -31
- package/scripts/halffix_propagation_scan.sh +126 -0
- package/scripts/package_coverage_check.sh +40 -1
- package/scripts/pipe_verdict_guard.sh +93 -0
- package/scripts/selfcheck.sh +114 -21
- package/scripts/session_close_check.sh +15 -1
- package/scripts/sidecar_calibrate.sh +275 -0
- package/scripts/test_card_drift_probe.sh +55 -0
- package/scripts/test_halffix_lanes.sh +170 -0
- package/scripts/test_node_check_lanes.sh +179 -0
- package/scripts/test_ollama_panel_lanes.sh +120 -0
- package/scripts/test_package_coverage_lanes.sh +250 -0
- package/scripts/test_pipe_verdict_guard_lanes.sh +96 -0
- package/scripts/test_sidecar_calibrate_lanes.sh +218 -0
- package/scripts/test_sidecar_wait_stdin.sh +13 -1
- package/templates/.git-hooks/pre-commit +9 -0
- package/templates/settings.PreToolUse.snippet.json +49 -0
- package/templates/settings.SessionStart.snippet.json +54 -0
- package/scripts/consent_registry_check.sh +0 -390
- package/scripts/test_consent_registry.sh +0 -255
- package/templates/consent_classes.yaml.example +0 -75
|
@@ -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"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test_halffix_lanes.sh — known pairs for scripts/halffix_propagation_scan.sh
|
|
3
|
+
#
|
|
4
|
+
# WHY, AND WHY LANES FIRST
|
|
5
|
+
# 2026-07-30 harvest #1: rounds that wrote the lane BEFORE touching the code had zero
|
|
6
|
+
# fix-reverts-fix regressions. Order made the convergence, not the patch.
|
|
7
|
+
#
|
|
8
|
+
# THE DEFECT BEING GUARDED — "반쪽-수리" (half-fix)
|
|
9
|
+
# A defect class lives in N sibling copies. The fix lands in ONE. The siblings keep the bug, and
|
|
10
|
+
# nothing says so. Measured 3x in this project, and the shape is worse than it sounds: every one
|
|
11
|
+
# of the three occurred INSIDE an edit that was itself repairing an earlier half-fix.
|
|
12
|
+
# scripts/psa_scan_lib.sh's header records five such divergences found in a single audit — the
|
|
13
|
+
# override-readable check, the fail-closed-on-no-patterns branch, the SPACE-vs-TAB row, the
|
|
14
|
+
# `head -1` shielding, and the LOW allowlist — each repaired where it was found and nowhere else.
|
|
15
|
+
#
|
|
16
|
+
# THE DISCRIMINATOR THAT MAKES THIS USEFUL INSTEAD OF NOISY (lane N3)
|
|
17
|
+
# If BOTH copies are staged, the fix WAS propagated and the scan must stay silent. A detector that
|
|
18
|
+
# fires on correct propagation is a detector that gets ignored — the S5 lesson (9/9 FP, narrowed
|
|
19
|
+
# 2026-07-28) and the S6 lesson (0 true positives on the planned surface, retargeted 2026-07-31)
|
|
20
|
+
# are the two prior claims on this exact mistake in this repo.
|
|
21
|
+
#
|
|
22
|
+
# ADVISORY BY MANDATE, not by preference: the operator's spec for this debt says "표시(차단 아님 —
|
|
23
|
+
# 정당한 복제도 있다)" — mark, do not block, because legitimate duplication exists.
|
|
24
|
+
|
|
25
|
+
set -u
|
|
26
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
27
|
+
SCAN="$ROOT/scripts/halffix_propagation_scan.sh"
|
|
28
|
+
pass=0; fail=0
|
|
29
|
+
|
|
30
|
+
# mkrepo <dir> — a throwaway git repo; caller populates and stages.
|
|
31
|
+
mkrepo() {
|
|
32
|
+
rm -rf "$1"; mkdir -p "$1"; ( cd "$1" && git init -q . && git config user.email t@e && git config user.name t )
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# expect <label> <FLAG|SILENT> <repo> [needle]
|
|
36
|
+
expect() {
|
|
37
|
+
local label="$1" want="$2" repo="$3" needle="${4:-}" out got
|
|
38
|
+
out=$( cd "$repo" && bash "$SCAN" 2>&1 )
|
|
39
|
+
if printf '%s' "$out" | grep -q 'HALF-FIX'; then got=FLAG; else got=SILENT; fi
|
|
40
|
+
if [ "$got" = "$want" ] && { [ -z "$needle" ] || printf '%s' "$out" | grep -q "$needle"; }; then
|
|
41
|
+
printf ' ✅ %-50s %s\n' "$label" "$got"; pass=$((pass+1))
|
|
42
|
+
else
|
|
43
|
+
printf ' ❌ %-50s %s (expected %s%s)\n' "$label" "$got" "$want" \
|
|
44
|
+
"${needle:+, needle '$needle'}"; fail=$((fail+1))
|
|
45
|
+
printf ' out: %s\n' "$(printf '%s' "$out" | head -6)"
|
|
46
|
+
fi
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
|
50
|
+
echo "[halffix] known pairs"
|
|
51
|
+
|
|
52
|
+
# ── P1: the canonical shape — a distinctive symbol duplicated in a sibling, only one staged ──
|
|
53
|
+
R="$T/p1"; mkrepo "$R"
|
|
54
|
+
printf 'psa_low_allowlisted() {\n case "$1" in templates/*) return 0 ;; esac\n}\n' > "$R/a.sh"
|
|
55
|
+
printf 'psa_low_allowlisted() {\n case "$1" in templates/*) return 0 ;; esac\n}\n' > "$R/b.sh"
|
|
56
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
57
|
+
printf 'psa_low_allowlisted() {\n case "$1" in templates/*|docs/*) return 0 ;; esac\n}\n' > "$R/a.sh"
|
|
58
|
+
( cd "$R" && git add a.sh )
|
|
59
|
+
expect "P1 sibling copy left unstaged" FLAG "$R" "b.sh"
|
|
60
|
+
|
|
61
|
+
# ── P2: three copies, one staged — both remaining siblings named ──
|
|
62
|
+
R="$T/p2"; mkrepo "$R"
|
|
63
|
+
for f in a b c; do printf 'validate_marker_fields_strictly() { :; }\n' > "$R/$f.sh"; done
|
|
64
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
65
|
+
printf 'validate_marker_fields_strictly() { echo fixed; }\n' > "$R/a.sh"
|
|
66
|
+
( cd "$R" && git add a.sh )
|
|
67
|
+
expect "P2 two siblings, both named" FLAG "$R" "c.sh"
|
|
68
|
+
|
|
69
|
+
# ── N3: THE DISCRIMINATOR — both copies staged means the fix propagated. Silence. ──
|
|
70
|
+
R="$T/n3"; mkrepo "$R"
|
|
71
|
+
printf 'psa_low_allowlisted() { :; }\n' > "$R/a.sh"; cp "$R/a.sh" "$R/b.sh"
|
|
72
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
73
|
+
printf 'psa_low_allowlisted() { echo fixed; }\n' > "$R/a.sh"; cp "$R/a.sh" "$R/b.sh"
|
|
74
|
+
( cd "$R" && git add a.sh b.sh )
|
|
75
|
+
expect "N3 both staged = propagated" SILENT "$R"
|
|
76
|
+
|
|
77
|
+
# ── N4: unique content — nothing to propagate to ──
|
|
78
|
+
R="$T/n4"; mkrepo "$R"
|
|
79
|
+
printf 'lonely_unique_helper_fn() { :; }\n' > "$R/a.sh"
|
|
80
|
+
printf 'something_entirely_different() { :; }\n' > "$R/b.sh"
|
|
81
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
82
|
+
printf 'lonely_unique_helper_fn() { echo x; }\n' > "$R/a.sh"
|
|
83
|
+
( cd "$R" && git add a.sh )
|
|
84
|
+
expect "N4 no sibling holds the symbol" SILENT "$R"
|
|
85
|
+
|
|
86
|
+
# ── N5: ubiquitous token — a word in 12 files is framework noise, not a half-fix ──
|
|
87
|
+
R="$T/n5"; mkrepo "$R"
|
|
88
|
+
for i in $(seq 1 12); do printf 'REPO_ROOT_DIRECTORY=/x\n' > "$R/f$i.sh"; done
|
|
89
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
90
|
+
printf 'REPO_ROOT_DIRECTORY=/y\n' > "$R/f1.sh"
|
|
91
|
+
( cd "$R" && git add f1.sh )
|
|
92
|
+
expect "N5 ubiquitous token suppressed" SILENT "$R"
|
|
93
|
+
|
|
94
|
+
# ── N6: short/common tokens must not anchor (would flag on `then`, `echo`, `return`) ──
|
|
95
|
+
R="$T/n6"; mkrepo "$R"
|
|
96
|
+
printf 'if [ -f x ]; then echo a; fi\n' > "$R/a.sh"
|
|
97
|
+
printf 'if [ -f y ]; then echo b; fi\n' > "$R/b.sh"
|
|
98
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
99
|
+
printf 'if [ -f x ]; then echo c; fi\n' > "$R/a.sh"
|
|
100
|
+
( cd "$R" && git add a.sh )
|
|
101
|
+
expect "N6 short shell keywords ignored" SILENT "$R"
|
|
102
|
+
|
|
103
|
+
# ── P7: filenames count as symbols too (the spec says "수정 심볼·파일명") ──
|
|
104
|
+
R="$T/p7"; mkrepo "$R"
|
|
105
|
+
printf 'bash scripts/sidecar_calibrate.sh --run\n' > "$R/a.sh"
|
|
106
|
+
printf 'bash scripts/sidecar_calibrate.sh --run\n' > "$R/doc.md"
|
|
107
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
108
|
+
printf 'bash scripts/sidecar_calibrate.sh --run --strict\n' > "$R/a.sh"
|
|
109
|
+
( cd "$R" && git add a.sh )
|
|
110
|
+
expect "P7 filename token propagates" FLAG "$R" "doc.md"
|
|
111
|
+
|
|
112
|
+
# ── N8: nothing staged at all ──
|
|
113
|
+
R="$T/n8"; mkrepo "$R"; printf 'x\n' > "$R/a.sh"; ( cd "$R" && git add . && git commit -qm base )
|
|
114
|
+
expect "N8 empty staging area" SILENT "$R"
|
|
115
|
+
|
|
116
|
+
# ── N9: opt-out ──
|
|
117
|
+
R="$T/n9"; mkrepo "$R"
|
|
118
|
+
printf 'psa_low_allowlisted() { :; }\n' > "$R/a.sh"; cp "$R/a.sh" "$R/b.sh"
|
|
119
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
120
|
+
printf 'psa_low_allowlisted() { echo x; } # noqa: half-fix\n' > "$R/a.sh"
|
|
121
|
+
( cd "$R" && git add a.sh )
|
|
122
|
+
expect "N9 noqa suppresses" SILENT "$R"
|
|
123
|
+
|
|
124
|
+
# ── N10: deleted file must not crash the scan ──
|
|
125
|
+
R="$T/n10"; mkrepo "$R"
|
|
126
|
+
printf 'psa_low_allowlisted() { :; }\n' > "$R/a.sh"; cp "$R/a.sh" "$R/b.sh"
|
|
127
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
128
|
+
( cd "$R" && git rm -q a.sh )
|
|
129
|
+
expect "N10 deletion does not crash" SILENT "$R"
|
|
130
|
+
|
|
131
|
+
echo "-- R2: whole-file copy divergence (found by LIVE calibration, not by the lanes above) --"
|
|
132
|
+
# The token rule alone MISSED this repo's real duplicate pair (scripts/degrade_direction_scan.sh ↔
|
|
133
|
+
# templates/degrade_direction_scan.sh) because a script's NAME appears in 11–18 files here — docs,
|
|
134
|
+
# CATALOG, the manifest, selfcheck references — so the ubiquity filter suppressed it, while an
|
|
135
|
+
# internal symbol like psa_low_allowlisted appears in 2. Measured 2026-07-31: the lanes were 10/10
|
|
136
|
+
# green and the live probe was silent. Lanes are necessary and not sufficient.
|
|
137
|
+
#
|
|
138
|
+
# The predicate is exact rather than heuristic: the sibling was BYTE-IDENTICAL at HEAD and only one
|
|
139
|
+
# side is staged. That is a divergence by construction, so it needs no threshold — and it stays
|
|
140
|
+
# silent for same-basename files that were never copies (CLAUDE.md vs templates/CLAUDE.md, the 40
|
|
141
|
+
# SKILL.md files), which a basename rule would have flooded.
|
|
142
|
+
R="$T/r2p"; mkrepo "$R"; mkdir -p "$R/scripts" "$R/templates"
|
|
143
|
+
printf 'a() { :; }\nb() { :; }\n' > "$R/scripts/x.sh"
|
|
144
|
+
cp "$R/scripts/x.sh" "$R/templates/x.sh"
|
|
145
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
146
|
+
printf 'a() { echo fixed; }\nb() { :; }\n' > "$R/scripts/x.sh"
|
|
147
|
+
( cd "$R" && git add scripts/x.sh )
|
|
148
|
+
expect "R2 byte-identical copy diverges" FLAG "$R" "templates/x.sh"
|
|
149
|
+
|
|
150
|
+
R="$T/r2n"; mkrepo "$R"; mkdir -p "$R/scripts" "$R/templates"
|
|
151
|
+
printf 'hub version, quite different\n' > "$R/scripts/x.sh"
|
|
152
|
+
printf 'template version, also different\n' > "$R/templates/x.sh"
|
|
153
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
154
|
+
printf 'hub version, edited\n' > "$R/scripts/x.sh"
|
|
155
|
+
( cd "$R" && git add scripts/x.sh )
|
|
156
|
+
expect "R2 same name but never a copy" SILENT "$R"
|
|
157
|
+
|
|
158
|
+
R="$T/r2b"; mkrepo "$R"; mkdir -p "$R/scripts" "$R/templates"
|
|
159
|
+
printf 'a() { :; }\n' > "$R/scripts/x.sh"; cp "$R/scripts/x.sh" "$R/templates/x.sh"
|
|
160
|
+
( cd "$R" && git add . && git commit -qm base )
|
|
161
|
+
printf 'a() { echo fixed; }\n' > "$R/scripts/x.sh"; cp "$R/scripts/x.sh" "$R/templates/x.sh"
|
|
162
|
+
( cd "$R" && git add scripts/x.sh templates/x.sh )
|
|
163
|
+
expect "R2 both staged = propagated" SILENT "$R"
|
|
164
|
+
|
|
165
|
+
echo
|
|
166
|
+
if [ "$fail" -eq 0 ]; then
|
|
167
|
+
echo "[halffix] ✅ all $pass known pairs hold"; exit 0
|
|
168
|
+
else
|
|
169
|
+
echo "[halffix] ❌ $fail/$((pass+fail)) lanes failed"; exit 1
|
|
170
|
+
fi
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test_node_check_lanes.sh — regression lanes for scripts/fh_node_check.sh.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS FILE EXISTS: across three adversarial rounds on the node check, every surviving defect
|
|
5
|
+
# was a NEGATIVE leg nobody was testing — "the floor does not apply here", "those are someone
|
|
6
|
+
# else's hooks", "that file is absent on a fresh clone". Each round's fix reverted a previous
|
|
7
|
+
# round's fix because no lane pinned it. The lanes below are that pin: they encode the *shape* of
|
|
8
|
+
# each defect, not just its instance.
|
|
9
|
+
#
|
|
10
|
+
# Usage: bash scripts/test_node_check_lanes.sh
|
|
11
|
+
# Exit: 0 = all lanes pass; 1 = at least one lane failed (prints which and why).
|
|
12
|
+
|
|
13
|
+
set -uo pipefail
|
|
14
|
+
|
|
15
|
+
FH_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
16
|
+
CHECK="$FH_REPO/scripts/fh_node_check.sh"
|
|
17
|
+
TMP="$(mktemp -d)"
|
|
18
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
19
|
+
|
|
20
|
+
PASS=0; FAIL=0
|
|
21
|
+
ok() { PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
|
|
22
|
+
bad() { FAIL=$((FAIL+1)); printf ' ❌ %s\n got: %s\n' "$1" "$(printf '%s' "$2" | tr '\n' '|' | cut -c1-220)"; }
|
|
23
|
+
|
|
24
|
+
# run <hubdir> <statefile> [env assignments...] → stdout of one check run
|
|
25
|
+
run() { local hub="$1" st="$2"; shift 2; env "$@" HUB_DIR="$hub" FH_NODE_STATE="$st" bash "$CHECK" 2>&1; }
|
|
26
|
+
|
|
27
|
+
mk_git_hub() { # a git repo with FH-style hooks installed and executable
|
|
28
|
+
local d="$1" fh_sentinel="${2:-yes}"
|
|
29
|
+
mkdir -p "$d" && git -C "$d" init -q && git -C "$d" config user.email t@t && git -C "$d" config user.name t
|
|
30
|
+
echo x > "$d/f" && git -C "$d" add f && git -C "$d" -c commit.gpgsign=false commit -qm init
|
|
31
|
+
mkdir -p "$d/.git/hooks"
|
|
32
|
+
local body='#!/bin/sh\nexit 0\n'
|
|
33
|
+
[ "$fh_sentinel" = "yes" ] && body='#!/bin/sh\n# FH 4-Axis Gate Pre-Commit Hook\nfh-gate\nexit 0\n'
|
|
34
|
+
printf "$body" > "$d/.git/hooks/pre-commit"; printf "$body" > "$d/.git/hooks/pre-push"
|
|
35
|
+
chmod +x "$d/.git/hooks/pre-commit" "$d/.git/hooks/pre-push"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
echo "── node-check lanes ──"
|
|
39
|
+
|
|
40
|
+
# LANE 1 (S3-1) — NOT a git repo. The git-hook floor cannot be installed here at all, so it is
|
|
41
|
+
# N/A, not missing. Reporting it would be an unfixable notice repeating every session forever.
|
|
42
|
+
mkdir -p "$TMP/nogit"
|
|
43
|
+
out="$(run "$TMP/nogit" "$TMP/s1" FH_MACHINE_ID=nogitbox)"
|
|
44
|
+
# POSITIVE CONTROL: assert what MUST appear alongside what must not. Absence-only lanes are
|
|
45
|
+
# satisfied by a script that prints nothing at all — an `exit 0` stub passed lanes 1, 4 and 7
|
|
46
|
+
# (cross-family mutation 2026-07-30), so each now pins an expected utterance too.
|
|
47
|
+
case "$out" in
|
|
48
|
+
*"Missing mechanical floor"*) bad "lane1 non-git: must NOT claim a missing floor (N/A, unfixable)" "$out" ;;
|
|
49
|
+
*"first session for this clone"*) ok "lane1 non-git: N/A on the git floor, but still reports the node event" ;;
|
|
50
|
+
*) bad "lane1 non-git: silent — a stub would pass this lane" "$out" ;;
|
|
51
|
+
esac
|
|
52
|
+
|
|
53
|
+
# LANE 2 (S3-2) — hooks exist but belong to another framework (husky/pre-commit). Executable ≠ FH's
|
|
54
|
+
# gate. Silence here is the exact accident this check was built for: FH gates absent, machine quiet.
|
|
55
|
+
mk_git_hub "$TMP/husky" no
|
|
56
|
+
out="$(run "$TMP/husky" "$TMP/s2" FH_MACHINE_ID=huskybox)"
|
|
57
|
+
case "$out" in
|
|
58
|
+
*"Missing mechanical floor"*) ok "lane2 foreign hooks: FH gate absence reported" ;;
|
|
59
|
+
*) bad "lane2 foreign hooks: non-FH hooks were accepted as FH floors" "$out" ;;
|
|
60
|
+
esac
|
|
61
|
+
|
|
62
|
+
# LANE 3 (S3-3) — Mode D user on a FRESH clone: companion store present, settings.local.json absent
|
|
63
|
+
# (it is gitignored, so a clone never has it). This is the MEASURED 2026-07-30 incident. Must speak.
|
|
64
|
+
mk_git_hub "$TMP/moded" yes
|
|
65
|
+
mkdir -p "$TMP/companion/.git"
|
|
66
|
+
out="$(run "$TMP/moded" "$TMP/s3" FH_MACHINE_ID=modedbox BE_DIR="$TMP/companion")"
|
|
67
|
+
case "$out" in
|
|
68
|
+
*companion-load*) ok "lane3 fresh Mode D clone: companion-load absence surfaced" ;;
|
|
69
|
+
*) bad "lane3 fresh Mode D clone: SILENT — the measured incident would recur" "$out" ;;
|
|
70
|
+
esac
|
|
71
|
+
|
|
72
|
+
# LANE 4 (M2-4) — public non-Mode-D user: no companion store anywhere. The companion item must not
|
|
73
|
+
# appear, or the majority path gets a false positive for a feature it does not use.
|
|
74
|
+
mk_git_hub "$TMP/public" yes
|
|
75
|
+
out="$(run "$TMP/public" "$TMP/s4" FH_MACHINE_ID=publicbox)"
|
|
76
|
+
case "$out" in
|
|
77
|
+
*companion-load*) bad "lane4a non-Mode-D: companion item shown to a user with no store" "$out" ;;
|
|
78
|
+
*"first session for this clone"*) ok "lane4a non-Mode-D: companion absent, node event still reported" ;;
|
|
79
|
+
*) bad "lane4a non-Mode-D: silent — a stub would pass this lane" "$out" ;;
|
|
80
|
+
esac
|
|
81
|
+
|
|
82
|
+
# LANE 4b (S4-1) — CLAUDE.local.md is Claude Code's STANDARD local-override file; anyone may keep
|
|
83
|
+
# one for any reason. Its mere EXISTENCE must not classify a user as Mode D, or the majority path
|
|
84
|
+
# gets a companion notice every session forever (state-based emission makes it permanent, not
|
|
85
|
+
# one-shot). Only the binding INSIDE the file counts.
|
|
86
|
+
printf '# my local notes\nuse tabs not spaces\n' > "$TMP/public/CLAUDE.local.md"
|
|
87
|
+
out="$(run "$TMP/public" "$TMP/s4b" FH_MACHINE_ID=publicbox)"
|
|
88
|
+
case "$out" in
|
|
89
|
+
*companion-load*) bad "lane4b plain CLAUDE.local.md: existence alone classified the user as Mode D" "$out" ;;
|
|
90
|
+
*) ok "lane4b plain CLAUDE.local.md: not treated as a Mode D signal" ;;
|
|
91
|
+
esac
|
|
92
|
+
|
|
93
|
+
# LANE 4c — the same file WITH a companion binding must classify as Mode D and speak. Without this
|
|
94
|
+
# leg, "never classify as Mode D" would also pass 4b.
|
|
95
|
+
# One fixture PER alternative: a single fixture like `BE_DIR=/some/companion-store` satisfies two
|
|
96
|
+
# alternatives at once, so either could be deleted and the lane would still pass. The store is a
|
|
97
|
+
# ROLE, not a repo layout (install-wizard SKILL.md: Obsidian vault / gbrain ingest target / *-be
|
|
98
|
+
# repo all qualify) — so the vocabulary variants are the documented user base, not hypotheticals.
|
|
99
|
+
i=0
|
|
100
|
+
# `backend: obsidian` carries no other keyword on purpose — with a `vault:` fixture only, the
|
|
101
|
+
# `obsidian` alternative is never exercised and could be deleted with the suite staying green
|
|
102
|
+
# (verified: removing it left 16/16). An untested alternative is an untested branch.
|
|
103
|
+
for binding in 'BE_DIR=/x/store' 'companion store: ~/notes' '컴패니언 스토어: ~/notes' \
|
|
104
|
+
'vault: ~/vaults/notes' 'gbrain ingest target: ~/gbrain' 'backend: obsidian'; do
|
|
105
|
+
i=$((i+1))
|
|
106
|
+
printf '# local\n%s\n' "$binding" > "$TMP/public/CLAUDE.local.md"
|
|
107
|
+
out="$(run "$TMP/public" "$TMP/s4c$i" FH_MACHINE_ID=publicbox)"
|
|
108
|
+
case "$out" in
|
|
109
|
+
*companion-load*) ok "lane4c.$i Mode D detected via: $binding" ;;
|
|
110
|
+
*) bad "lane4c.$i binding present but Mode D not detected: $binding" "$out" ;;
|
|
111
|
+
esac
|
|
112
|
+
done
|
|
113
|
+
rm -f "$TMP/public/CLAUDE.local.md"
|
|
114
|
+
|
|
115
|
+
# LANE 5 — emission model: a healthy machine speaks once (event) then goes silent.
|
|
116
|
+
mk_git_hub "$TMP/healthy" yes
|
|
117
|
+
r1="$(run "$TMP/healthy" "$TMP/s5" FH_MACHINE_ID=healthybox)"
|
|
118
|
+
r2="$(run "$TMP/healthy" "$TMP/s5" FH_MACHINE_ID=healthybox)"
|
|
119
|
+
if [ -n "$r1" ] && [ -z "$r2" ]; then ok "lane5 healthy: event once, then silent"
|
|
120
|
+
else bad "lane5 healthy: expected run1 non-empty and run2 empty" "r1=[$r1] r2=[$r2]"; fi
|
|
121
|
+
|
|
122
|
+
# LANE 6 (S2-4) — a missing floor is a CONDITION: it must be reported on every run, not once.
|
|
123
|
+
mk_git_hub "$TMP/broken" yes
|
|
124
|
+
rm -f "$TMP/broken/.git/hooks/pre-commit"
|
|
125
|
+
n=0
|
|
126
|
+
for i in 1 2 3; do
|
|
127
|
+
o="$(run "$TMP/broken" "$TMP/s6" FH_MACHINE_ID=brokenbox)"
|
|
128
|
+
case "$o" in *"Missing mechanical floor"*) n=$((n+1)) ;; esac
|
|
129
|
+
done
|
|
130
|
+
[ "$n" -eq 3 ] && ok "lane6 broken: reported on all 3 runs (condition, not event)" \
|
|
131
|
+
|| bad "lane6 broken: reported $n/3 runs — a broken machine went quiet" "n=$n"
|
|
132
|
+
|
|
133
|
+
# LANE 7 (M2-2) — linked worktree: .git is a FILE there, so a hand-built "$FH/.git/hooks" does not
|
|
134
|
+
# exist and working hooks read as missing.
|
|
135
|
+
mk_git_hub "$TMP/wt" yes
|
|
136
|
+
git -C "$TMP/wt" worktree add -q "$TMP/wt_linked" -b lane7 2>/dev/null
|
|
137
|
+
out="$(run "$TMP/wt_linked" "$TMP/s7" FH_MACHINE_ID=wtbox)"
|
|
138
|
+
case "$out" in
|
|
139
|
+
*"Missing mechanical floor"*) bad "lane7 worktree: false missing-floor (hooks resolve to the main gitdir)" "$out" ;;
|
|
140
|
+
*"first session for this clone"*) ok "lane7 worktree: hooks resolved correctly, node event reported" ;;
|
|
141
|
+
*) bad "lane7 worktree: silent — a stub would pass this lane" "$out" ;;
|
|
142
|
+
esac
|
|
143
|
+
|
|
144
|
+
# LANE 8 (M3-3) — python3 unavailable: the companion verdict is UNMEASURED, never silently "fine".
|
|
145
|
+
# Simulated by a PATH with no python3, for a Mode D hub (so the check is applicable).
|
|
146
|
+
mk_git_hub "$TMP/nopy" yes
|
|
147
|
+
mkdir -p "$TMP/emptybin" "$TMP/companion2/.git"
|
|
148
|
+
out="$(PATH="$TMP/emptybin:/usr/bin:/bin" run "$TMP/nopy" "$TMP/s8" FH_MACHINE_ID=nopybox BE_DIR="$TMP/companion2")"
|
|
149
|
+
if command -v python3 >/dev/null 2>&1 && [ -x /usr/bin/python3 ]; then
|
|
150
|
+
ok "lane8 skipped: /usr/bin/python3 exists so absence cannot be simulated via PATH"
|
|
151
|
+
else
|
|
152
|
+
case "$out" in
|
|
153
|
+
*UNMEASURED*|*unmeasured*) ok "lane8 no python3: reported UNMEASURED, not silence" ;;
|
|
154
|
+
*) bad "lane8 no python3: absence read as pass" "$out" ;;
|
|
155
|
+
esac
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
# LANE 9 (S4-2) — the sentinel regex is coupled to PROSE THAT LIVES IN ANOTHER FILE. Every other
|
|
159
|
+
# lane hands it a fixture containing the string it expects, so the suite would stay fully green
|
|
160
|
+
# while a purely cosmetic edit to the real hook headers (which no gate checks) made every FH machine
|
|
161
|
+
# report "not FH's gate" every session. Calibrate the instrument against the shipped article.
|
|
162
|
+
# DERIVE the regex from the script — never retype it. A hardcoded copy is the divergent-copy class
|
|
163
|
+
# this repo already paid for once (SYNC_EXCLUDES in three places, which needed its own parity
|
|
164
|
+
# checker): tighten the script's regex and a duplicated lane keeps validating the SHIPPED hooks
|
|
165
|
+
# against the OLD pattern, staying green while the real check drifts.
|
|
166
|
+
SENT="$(sed -n "s/.*grep -qE '\([^']*\)'.*/\1/p" "$FH_REPO/scripts/fh_node_check.sh" | head -1)"
|
|
167
|
+
if [ -z "$SENT" ]; then
|
|
168
|
+
bad "lane9 sentinel: could not derive the regex from fh_node_check.sh (extraction broke — not a pass)" "empty"
|
|
169
|
+
SENT='__never_matches__'
|
|
170
|
+
fi
|
|
171
|
+
for h in "$FH_REPO"/templates/.git-hooks/pre-commit "$FH_REPO"/templates/.git-hooks/pre-push; do
|
|
172
|
+
if [ ! -f "$h" ]; then bad "lane9 sentinel: shipped hook missing: $h" "absent"; continue; fi
|
|
173
|
+
if grep -qE "$SENT" "$h"; then ok "lane9 sentinel matches shipped $(basename "$h")"
|
|
174
|
+
else bad "lane9 sentinel does NOT match shipped $(basename "$h") — every FH machine would report 'not FH gate'" "$(head -3 "$h")"; fi
|
|
175
|
+
done
|
|
176
|
+
|
|
177
|
+
printf '\nnode-check lanes: %d passed, %d failed\n' "$PASS" "$FAIL"
|
|
178
|
+
[ "$FAIL" -eq 0 ] || exit 1
|
|
179
|
+
exit 0
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test_ollama_panel_lanes.sh — hermetic known pairs for the ollama panel leg of sidecar_calibrate.sh
|
|
3
|
+
#
|
|
4
|
+
# Hermetic by construction: every lane runs against a stub HTTP server on loopback. No network, no
|
|
5
|
+
# spend, no dependence on a node being powered on — the same property the CLI lanes get from stub
|
|
6
|
+
# binaries. A calibrator whose own tests need the thing it calibrates cannot fail honestly.
|
|
7
|
+
#
|
|
8
|
+
# WHAT MAKES THIS LEG DIFFERENT FROM codex/agy, AND WHY IT NEEDED ITS OWN LANES
|
|
9
|
+
# The CLI legs anchor PIN-OK on the model's SELF-REPORT. That anchor is invalid here and the
|
|
10
|
+
# measurement says so: asked which model it was, `gpt-oss:20b` answered "The underlying model is
|
|
11
|
+
# GPT-4 (likely)" (2026-07-31). Open-weight models do not reliably know their own name, so a
|
|
12
|
+
# self-report anchor would mark an EXACT pin as UNTRUSTED and drop it from the panel — an
|
|
13
|
+
# instrument that cannot separate known-positive from known-negative on its target. The anchor is
|
|
14
|
+
# therefore the SERVER's response envelope: a server-side fact, not a model's claim.
|
|
15
|
+
# Lane S2 is the reason that distinction has teeth — it is the agy failure in this protocol's
|
|
16
|
+
# spelling: the server quietly answers as a different model than the one pinned.
|
|
17
|
+
|
|
18
|
+
set -u
|
|
19
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
20
|
+
CAL="$ROOT/scripts/sidecar_calibrate.sh"
|
|
21
|
+
pass=0; fail=0
|
|
22
|
+
PORT="${FH_LANE_PORT:-18011}"
|
|
23
|
+
SRV_PID=""
|
|
24
|
+
|
|
25
|
+
# `wait` after the kill, so bash reaps the child quietly instead of printing "Terminated: 15" into
|
|
26
|
+
# the lane output — a harness that litters CI logs teaches people to stop reading them.
|
|
27
|
+
stop_stub() {
|
|
28
|
+
[ -n "$SRV_PID" ] || return 0
|
|
29
|
+
kill "$SRV_PID" 2>/dev/null
|
|
30
|
+
wait "$SRV_PID" 2>/dev/null
|
|
31
|
+
SRV_PID=""
|
|
32
|
+
}
|
|
33
|
+
trap 'stop_stub' EXIT
|
|
34
|
+
|
|
35
|
+
# The stub is written to a file so the heredoc cannot collide with the lane script's own quoting.
|
|
36
|
+
STUB="$(mktemp -d)/stub.py"
|
|
37
|
+
cat > "$STUB" <<'PY'
|
|
38
|
+
import json, sys
|
|
39
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
40
|
+
PORT = int(sys.argv[1]); MODE = sys.argv[2]
|
|
41
|
+
|
|
42
|
+
class H(BaseHTTPRequestHandler):
|
|
43
|
+
def log_message(self, *a): pass
|
|
44
|
+
def _send(self, obj, code=200):
|
|
45
|
+
b = json.dumps(obj).encode()
|
|
46
|
+
self.send_response(code); self.send_header("Content-Type", "application/json")
|
|
47
|
+
self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
|
|
48
|
+
def do_GET(self):
|
|
49
|
+
if self.path == "/api/version": self._send({"version": "stub"})
|
|
50
|
+
elif self.path == "/api/tags": self._send({"models": [{"name": "stub-model:1b"}]})
|
|
51
|
+
else: self._send({}, 404)
|
|
52
|
+
def do_POST(self):
|
|
53
|
+
n = int(self.headers.get("Content-Length", 0))
|
|
54
|
+
req = json.loads(self.rfile.read(n) or b"{}")
|
|
55
|
+
m = req.get("model", "")
|
|
56
|
+
if m.startswith("fh-calib-nonexistent"):
|
|
57
|
+
# BOGUS control. 'accept' mode is the dangerous server: it serves SOMETHING for a name
|
|
58
|
+
# that cannot exist, so the pin is never validated at all.
|
|
59
|
+
if MODE == "accept": self._send({"model": m, "response": "PASS"})
|
|
60
|
+
else: self._send({"error": f"model '{m}' not found"})
|
|
61
|
+
return
|
|
62
|
+
if MODE == "substitute": # the agy failure, in this protocol
|
|
63
|
+
self._send({"model": "some-other-model:9b", "response": "PASS"}); return
|
|
64
|
+
if MODE == "starved": # reasoning model spent the whole budget thinking
|
|
65
|
+
self._send({"model": m, "response": "", "thinking": "x" * 780}); return
|
|
66
|
+
if MODE == "prose": # cannot carry a machine-read verdict
|
|
67
|
+
self._send({"model": m, "response": "Well, I would say this looks like a PASS overall."}); return
|
|
68
|
+
self._send({"model": m, "response": "PASS"})
|
|
69
|
+
HTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
|
70
|
+
PY
|
|
71
|
+
# shellcheck disable=SC2016
|
|
72
|
+
start_stub() {
|
|
73
|
+
stop_stub
|
|
74
|
+
python3 "$STUB" "$PORT" "$1" >/dev/null 2>&1 &
|
|
75
|
+
SRV_PID=$!
|
|
76
|
+
for _ in $(seq 1 40); do
|
|
77
|
+
curl -sf --max-time 1 "http://127.0.0.1:$PORT/api/version" >/dev/null 2>&1 && return 0
|
|
78
|
+
sleep 0.25
|
|
79
|
+
done
|
|
80
|
+
return 1
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# expect <label> <mode> <expect-in-panel: YES|NO> [needle]
|
|
84
|
+
expect() {
|
|
85
|
+
local label="$1" mode="$2" want="$3" needle="${4:-}" out inpanel
|
|
86
|
+
start_stub "$mode" || { printf ' ❌ %-44s stub failed to start\n' "$label"; fail=$((fail+1)); return; }
|
|
87
|
+
out=$( FH_OLLAMA_HOST="http://127.0.0.1:$PORT" FH_OLLAMA_MODELS="stub-model:1b" \
|
|
88
|
+
bash "$CAL" --only ollama --quiet 2>&1 )
|
|
89
|
+
stop_stub
|
|
90
|
+
if printf '%s' "$out" | grep -q 'PANEL:.*ollama:stub-model:1b'; then inpanel=YES; else inpanel=NO; fi
|
|
91
|
+
if [ "$inpanel" = "$want" ] && { [ -z "$needle" ] || printf '%s' "$out" | grep -q "$needle"; }; then
|
|
92
|
+
printf ' ✅ %-44s in-panel=%s\n' "$label" "$inpanel"; pass=$((pass+1))
|
|
93
|
+
else
|
|
94
|
+
printf ' ❌ %-44s in-panel=%s (expected %s%s)\n' "$label" "$inpanel" "$want" "${needle:+, needle '$needle'}"
|
|
95
|
+
printf ' out: %s\n' "$(printf '%s' "$out" | head -4)"; fail=$((fail+1))
|
|
96
|
+
fi
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
echo "[ollama-panel] known pairs (hermetic stub server)"
|
|
100
|
+
expect "S1 envelope matches pin, verdict parses" ok YES "PIN-OK(envelope)"
|
|
101
|
+
expect "S2 server substitutes a DIFFERENT model" substitute NO "UNTRUSTED-PIN"
|
|
102
|
+
expect "S3 empty answer (budget starvation)" starved NO "larger FH_OLLAMA_NUM_PREDICT"
|
|
103
|
+
expect "S4 prose answer cannot carry a verdict" prose NO "VERDICT-UNPARSEABLE"
|
|
104
|
+
expect "S5 server accepts a bogus model name" accept YES "accepts-bogus"
|
|
105
|
+
|
|
106
|
+
# S6 — absence must be MEASURED, never assumed. No server at the host at all.
|
|
107
|
+
out=$( FH_OLLAMA_HOST="http://127.0.0.1:$((PORT+7))" bash "$CAL" --only ollama --quiet 2>&1 )
|
|
108
|
+
if printf '%s' "$out" | grep -q 'ABSENT — no server at the configured host'; then
|
|
109
|
+
printf ' ✅ %-44s reported ABSENT\n' "S6 no server at host"; pass=$((pass+1))
|
|
110
|
+
else
|
|
111
|
+
printf ' ❌ %-44s (expected a measured ABSENT line)\n' "S6 no server at host"
|
|
112
|
+
printf ' out: %s\n' "$(printf '%s' "$out" | head -3)"; fail=$((fail+1))
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
echo
|
|
116
|
+
if [ "$fail" -eq 0 ]; then
|
|
117
|
+
echo "[ollama-panel] ✅ all $pass known pairs hold"; exit 0
|
|
118
|
+
else
|
|
119
|
+
echo "[ollama-panel] ❌ $fail/$((pass+fail)) lanes failed"; exit 1
|
|
120
|
+
fi
|