@chrono-meta/fh-gate 1.4.85 → 1.4.87

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,79 @@
1
+ #!/usr/bin/env bash
2
+ # test_tag_version_lanes.sh — known-pair anchor for the pre-push tag/version consistency guard.
3
+ #
4
+ # WHY (measured 2026-08-02): a release tag was pushed onto the WRONG commit. `git pull --ff-only`
5
+ # had failed with a diverging-branches error that went unread, so `main` was still the pre-release
6
+ # tree; the tag landed there and `npm publish` was then attempted from that same tree. The ONLY
7
+ # thing that stopped the publish was npm's own "cannot publish over 1.4.84" collision check.
8
+ # Publishing does not un-happen — being saved by the registry's bookkeeping is luck, not a floor.
9
+ #
10
+ # Built at N=1 on purpose. This repo's escalation rule ("1-2 occurrences -> prose, N>=3 -> mechanize")
11
+ # is scoped to REVERSIBLE surfaces; a wrong tag on a public repo plus a publish from the wrong tree
12
+ # is the irreversible class, where the Surface-Class Degrade Invariant says fail-CLOSED immediately.
13
+ #
14
+ # The lanes drive the REAL hook through its real stdin contract (`<local_ref> <local_sha>
15
+ # <remote_ref> <remote_sha>`), not a re-spelling of its predicate — a hand-copied condition is a
16
+ # divergent normalizer and drifts lenient.
17
+ #
18
+ # Exit 0 = the guard discriminates · 1 = it would let a mismatched tag through, or block a good one.
19
+
20
+ set -uo pipefail
21
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
+ REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
23
+ HOOK="$REPO/templates/.git-hooks/pre-push"
24
+ FAILED=0; PASS=0
25
+ chk() { if [ "$1" -eq 0 ]; then PASS=$((PASS+1)); echo " ✅ $2"; else FAILED=1; echo " ❌ $2"; fi; }
26
+
27
+ [ -f "$HOOK" ] || { echo "FAIL tag-version lanes: subject $HOOK missing"; exit 1; }
28
+ ZERO=0000000000000000000000000000000000000000
29
+
30
+ # A throwaway repo so the lanes never depend on this repo's live history.
31
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
32
+ ( cd "$T" && git init -q . && git config user.email a@b && git config user.name a
33
+ printf '{\n "name": "x",\n "version": "1.0.0"\n}\n' > package.json
34
+ git add -A && git commit -qm v100
35
+ printf '{\n "name": "x",\n "version": "1.1.0"\n}\n' > package.json
36
+ git add -A && git commit -qm v110
37
+ printf 'no manifest here\n' > README.md && rm -f package.json
38
+ git add -A && git commit -qm nomanifest ) >/dev/null 2>&1
39
+
40
+ C_100=$(cd "$T" && git rev-parse HEAD~2)
41
+ C_110=$(cd "$T" && git rev-parse HEAD~1)
42
+ C_NONE=$(cd "$T" && git rev-parse HEAD)
43
+
44
+ # Drive the hook exactly as git does. Run it INSIDE the scratch repo so its rev-parse/cat-file
45
+ # resolve against that history. Only the tag verdict is under test here, so a non-zero exit from an
46
+ # unrelated guard would be a false red — assert on the MESSAGE, and on rc only for the clean lane.
47
+ run() { # $1=tag $2=sha -> prints hook output
48
+ printf '%s %s %s %s\n' "refs/tags/$1" "$2" "refs/tags/$1" "$ZERO" \
49
+ | ( cd "$T" && bash "$HOOK" origin 2>&1 )
50
+ }
51
+ says_mismatch() { printf '%s' "$1" | grep -q "TAG/VERSION MISMATCH"; }
52
+ blocks() { printf '%s' "$1" | grep -q "FH Tag/Version Consistency"; }
53
+
54
+ echo "── the measured failure, reproduced ──"
55
+ out=$(run v1.1.0 "$C_100")
56
+ says_mismatch "$out" ; chk $? "known-POSITIVE: v1.1.0 on a commit whose package.json says 1.0.0 → MISMATCH"
57
+ blocks "$out" ; chk $? "…and it BLOCKS (detection wired to a blocker, not just printed)"
58
+
59
+ echo "── the good case must not be blocked (over-blocking trains the override) ──"
60
+ out=$(run v1.1.0 "$C_110")
61
+ if says_mismatch "$out"; then chk 1 "known-NEGATIVE: matching tag/version is silent"; else chk 0 "known-NEGATIVE: matching tag/version is silent"; fi
62
+ if blocks "$out"; then chk 1 "…and does not block"; else chk 0 "…and does not block"; fi
63
+
64
+ echo "── scope: not every tag is a release tag ──"
65
+ out=$(run v1.0.0 "$C_NONE")
66
+ if says_mismatch "$out"; then chk 1 "no package.json at that commit → not applicable, not a block"; else chk 0 "no package.json at that commit → not applicable, not a block"; fi
67
+ out=$(run sprint-42 "$C_100")
68
+ if says_mismatch "$out"; then chk 1 "non-version tag name (sprint-42) is out of scope"; else chk 0 "non-version tag name (sprint-42) is out of scope"; fi
69
+
70
+ echo "── the override channel exists and is explicit ──"
71
+ grep -q 'TAG_VERSION_OK' "$HOOK" ; chk $? "TAG_VERSION_OK override present (mirrors MAIN_PUSH_OK / DESTRUCTIVE_OP_OK)"
72
+ out=$(printf '%s %s %s %s\n' "refs/tags/v1.1.0" "$C_100" "refs/tags/v1.1.0" "$ZERO" \
73
+ | ( cd "$T" && TAG_VERSION_OK=1 bash "$HOOK" origin 2>&1 ))
74
+ if blocks "$out"; then chk 1 "override actually lets the push through"; else chk 0 "override actually lets the push through"; fi
75
+
76
+ echo ""
77
+ if [ "$FAILED" -ne 0 ]; then echo "TAG-VERSION LANES: FAIL"; exit 1; fi
78
+ echo "TAG-VERSION LANES: PASS ($PASS/$PASS)"
79
+ exit 0
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env bash
2
+ # test_wizard_snippet_merge_lanes.sh — lanes for the SessionStart snippet → settings.json merge.
3
+ #
4
+ # SUBJECT: the python block in plugins/fh-meta/skills/install-wizard/SKILL_detail.md
5
+ # §Step4-Baseline-Bash that reads templates/settings.SessionStart.snippet.json and merges its
6
+ # `project_settings_json` entry into the user's .claude/settings.json.
7
+ #
8
+ # The subject is EXTRACTED FROM THE SHIPPED FILE at run time, never retyped. A retyped copy is the
9
+ # divergent-copy class this repo has already paid for: the lane would keep validating the OLD merge
10
+ # while the shipped one drifted, and stay green throughout. Extraction failure is a FAIL, not a skip.
11
+ #
12
+ # WHY THIS MATTERS MORE THAN AN ORDINARY PARSER LANE: the hook being registered here is
13
+ # fh_node_check.sh — the one thing that tells an operator at turn 0 that their machine's floors are
14
+ # missing. If the registration silently does not happen, the detector that would have said so is the
15
+ # detector that did not get installed. That loop has to be broken from outside, and the lanes below
16
+ # measure whether anything does.
17
+ #
18
+ # CALIBRATION: every absence lane names its known-positive and builds it in the same run.
19
+ # ⓘ GAP lanes pin behaviour believed wrong without failing the suite.
20
+ # Exit 0 = calibrated · 1 = the instrument (or the extraction) is wrong.
21
+
22
+ set -uo pipefail
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
25
+ WIZ="$ROOT/plugins/fh-meta/skills/install-wizard/SKILL_detail.md"
26
+ DOC="$ROOT/plugins/fh-meta/skills/install-doctor/SKILL.md"
27
+ SNIPPET="$ROOT/templates/settings.SessionStart.snippet.json"
28
+
29
+ TMPROOT=$(mktemp -d "${TMPDIR:-/tmp}/fh_snippet_lanes.XXXXXX")
30
+ trap 'rm -rf "$TMPROOT"' EXIT
31
+ FAILED=0; PASSED=0; GAPS=0
32
+ _pass() { echo "✅ $1"; PASSED=$((PASSED+1)); }
33
+ _fail() { echo "❌ $1"; FAILED=1; }
34
+ _rc() { if [ "$2" = "$3" ]; then _pass "$1 (exit=$2, expected=$3)"; else _fail "$1 — exit=$2, expected=$3"; fi; }
35
+ _eq() { if [ "$2" = "$3" ]; then _pass "$1 ($2)"; else _fail "$1 — got [$2], expected [$3]"; fi; }
36
+ _gap() { if [ "$2" = "1" ]; then echo "ⓘ GAP (still open) $1"; echo " ↳ $3"; GAPS=$((GAPS+1));
37
+ else echo "🎉 GAP CLOSED — $1 : behaviour changed, promote this lane"; fi; }
38
+
39
+ for f in "$WIZ" "$DOC" "$SNIPPET"; do
40
+ [ -f "$f" ] || { echo "❌ subject missing: $f"; exit 1; }
41
+ done
42
+
43
+ echo "══ extraction (the instrument itself) ══"
44
+ MERGE="$TMPROOT/merge.py"
45
+ awk '/^python3 - "\$FH_DIR" <<.PY.$/{f=1;next} f&&/^PY$/{exit} f' "$WIZ" > "$MERGE"
46
+ N=$(wc -l < "$MERGE" | tr -d ' ')
47
+ if [ "$N" -lt 10 ]; then
48
+ _fail "EX-1 could not extract the merge block from SKILL_detail.md (got $N lines) — NOT a pass"
49
+ echo "──────────────────────────────────────────────"; echo "SNIPPET MERGE LANES: FAIL (extraction)"; exit 1
50
+ fi
51
+ _pass "EX-1 merge block extracted from the shipped SKILL_detail.md ($N lines)"
52
+ grep -q 'project_settings_json' "$MERGE" && _pass "EX-2 extracted block reads project_settings_json" \
53
+ || _fail "EX-2 extracted block does not mention project_settings_json — wrong region captured"
54
+
55
+ DOCPY="$TMPROOT/doctor.py"
56
+ # NOTE the leading-whitespace tolerance: this block is indented inside an `if` in the SKILL, and a
57
+ # ^-anchored pattern extracted 0 lines. That failure surfaced as a red lane rather than a green
58
+ # vacuous one only because EX-3 treats an empty extraction as FAIL — keep it that way.
59
+ awk '/^[[:space:]]*python3 - "\$FH" <<.PY.$/{f=1;next} f&&/^[[:space:]]*PY$/{exit} f' "$DOC" > "$DOCPY"
60
+ DN=$(wc -l < "$DOCPY" | tr -d ' ')
61
+ [ "$DN" -ge 5 ] && _pass "EX-3 install-doctor registration check extracted ($DN lines)" \
62
+ || _fail "EX-3 could not extract install-doctor's registration check (got $DN lines) — NOT a pass"
63
+
64
+ # ── fixture ─────────────────────────────────────────────────────────────────────
65
+ _hub() { # $1=name $2=snippet-content ("@SHIPPED" = the real tracked file) → echoes path
66
+ local h="$TMPROOT/$1"
67
+ mkdir -p "$h/templates" "$h/.claude"
68
+ if [ "$2" = "@SHIPPED" ]; then cp "$SNIPPET" "$h/templates/settings.SessionStart.snippet.json"
69
+ else printf '%s' "$2" > "$h/templates/settings.SessionStart.snippet.json"; fi
70
+ printf '%s' "$h"
71
+ }
72
+ _merge() { OUT=$(python3 "$MERGE" "$1" 2>&1); RC=$?; } # RC read directly off python, no pipe
73
+ _registered() { # 1 = fh_node_check present in the written settings.json
74
+ [ -f "$1/.claude/settings.json" ] && grep -q 'fh_node_check' "$1/.claude/settings.json" && echo 1 || echo 0
75
+ }
76
+ _claims_ok() { printf '%s\n' "$OUT" | grep -q 'hook registered' && echo 1 || echo 0; }
77
+
78
+ echo
79
+ echo "══ the happy path (known-positive for every absence lane below) ══"
80
+ H=$(_hub good @SHIPPED); _merge "$H"
81
+ _rc "MG-P shipped snippet → exit 0" "$RC" 0
82
+ _eq "MG-P → fh_node_check actually present in settings.json" "$(_registered "$H")" 1
83
+ _eq "MG-P → success line printed" "$(_claims_ok)" 1
84
+
85
+ # the documented merge promise (SKILL_detail.md: 'Merge at HOOK level, not group level') —
86
+ # a foreign hook sharing a group with the FH one must survive.
87
+ H=$(_hub coexist @SHIPPED)
88
+ cat > "$H/.claude/settings.json" <<'J'
89
+ {"hooks":{"SessionStart":[{"matcher":"","hooks":[
90
+ {"type":"command","command":"bash my_telemetry.sh"},
91
+ {"type":"command","command":"bash $CLAUDE_PROJECT_DIR/scripts/fh_node_check.sh"}]}]}}
92
+ J
93
+ _merge "$H"
94
+ grep -q 'my_telemetry' "$H/.claude/settings.json" && _pass "MG-P2 foreign hook in the FH group survives the merge" \
95
+ || _fail "MG-P2 foreign hook was dropped — the documented hook-level merge is not happening"
96
+ _eq "MG-P2 → and fh_node_check is (re)registered" "$(_registered "$H")" 1
97
+ DUP=$(grep -c 'fh_node_check' "$H/.claude/settings.json")
98
+ _eq "MG-P2 → old FH entry replaced, not duplicated" "$DUP" 1
99
+
100
+ echo
101
+ echo "══ malformed snippet: fails loudly, writes nothing ══"
102
+ # These four are the F10 question as posed (malformed / truncated / empty). The merge turns out to
103
+ # be fail-CLOSED here — worth pinning precisely so a future 'robustness' refactor that swallows the
104
+ # exception is caught as the regression it would be.
105
+ SHIPPED_TXT=$(cat "$SNIPPET")
106
+ i=0
107
+ for name in empty notjson truncated missingkey; do
108
+ i=$((i+1))
109
+ case "$name" in
110
+ empty) body="" ;;
111
+ notjson) body="oops not json at all" ;;
112
+ truncated) body=$(printf '%s' "$SHIPPED_TXT" | head -c 400) ;;
113
+ missingkey) body='{"hooks":{"SessionStart":[]}}' ;;
114
+ esac
115
+ H=$(_hub "bad_$name" "$body"); _merge "$H"
116
+ _rc "MG-N$i $name snippet → non-zero exit" "$RC" 1
117
+ _eq "MG-N$i $name → nothing registered (paired with MG-P)" "$(_registered "$H")" 0
118
+ _eq "MG-N$i $name → no success line" "$(_claims_ok)" 0
119
+ done
120
+
121
+ echo
122
+ echo "══ malformed EXISTING settings.json: the user's file is not destroyed ══"
123
+ H=$(_hub badtarget @SHIPPED); printf '{ oops' > "$H/.claude/settings.json"
124
+ _merge "$H"
125
+ _rc "MG-T unparsable target → non-zero exit" "$RC" 1
126
+ _eq "MG-T → the user's file is left byte-identical" "$(cat "$H/.claude/settings.json")" '{ oops'
127
+ [ -f "$H/.claude/settings.json.prewizard" ] \
128
+ && _fail "MG-T → a .prewizard backup of an unread file was written (misleading artifact)" \
129
+ || _pass "MG-T → no misleading backup artifact left behind"
130
+
131
+ echo
132
+ echo "══ ⓘ GAP lanes ══"
133
+
134
+ # GAP 1 — the ONLY validation is what `kept + entry` incidentally requires: that entry is a list.
135
+ # Any list-shaped value is written to the user's real settings.json and reported as success. The
136
+ # success message is not conditioned on what was actually written.
137
+ BADSCHEMA_HITS=0; BADSCHEMA_TOTAL=0; DETAIL=""
138
+ for name in emptylist nocommand wrongscript juststring; do
139
+ case "$name" in
140
+ emptylist) body='{"project_settings_json":{"hooks":{"SessionStart":[]}}}' ;;
141
+ nocommand) body='{"project_settings_json":{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command"}]}]}}}' ;;
142
+ wrongscript) body='{"project_settings_json":{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"bash /some/other/script.sh"}]}]}}}' ;;
143
+ juststring) body='{"project_settings_json":{"hooks":{"SessionStart":["not even an object"]}}}' ;;
144
+ esac
145
+ H=$(_hub "schema_$name" "$body"); _merge "$H"
146
+ BADSCHEMA_TOTAL=$((BADSCHEMA_TOTAL+1))
147
+ if [ "$RC" = 0 ] && [ "$(_claims_ok)" = 1 ] && [ "$(_registered "$H")" = 0 ]; then
148
+ BADSCHEMA_HITS=$((BADSCHEMA_HITS+1)); DETAIL="$DETAIL $name"
149
+ fi
150
+ done
151
+ _g=0; [ "$BADSCHEMA_HITS" -gt 0 ] && _g=1
152
+ _gap "valid-JSON / invalid-SCHEMA snippet → written, reported as success, node hook absent" "$_g" \
153
+ "$BADSCHEMA_HITS of $BADSCHEMA_TOTAL cases:$DETAIL. exit 0 and 'node-check SessionStart hook \
154
+ registered ->' are printed while fh_node_check is NOT in the file that was just written. The success \
155
+ message is unconditional — it does not re-read what it wrote. One-line fix: after the write, assert \
156
+ fh_node_check appears in the serialized result, else exit non-zero."
157
+
158
+ # GAP 2 — the corruption from GAP 1 is LATENT: the bad value lands in the user's settings.json and
159
+ # detonates on the NEXT wizard run, in the kept-loop, far from where it was introduced.
160
+ H=$(_hub latent '{"project_settings_json":{"hooks":{"SessionStart":["not even an object"]}}}')
161
+ _merge "$H"; FIRST_RC=$RC
162
+ _merge "$H"; SECOND_RC=$RC
163
+ _g=0; { [ "$FIRST_RC" = 0 ] && [ "$SECOND_RC" != 0 ]; } && _g=1
164
+ _gap "the bad write detonates on the NEXT run, not the run that made it" "$_g" \
165
+ "run1 exit=$FIRST_RC (reported success), run2 exit=$SECOND_RC (AttributeError in the kept-loop: \
166
+ g.get on a str). The user's settings.json is now un-mergeable and the traceback points at the \
167
+ survivor filter, not at the snippet that caused it."
168
+
169
+ # GAP 3 — INSTRUMENT COVERAGE, measured not asserted. Build the exact post-failure state (companion
170
+ # hook registered, node hook absent) and run install-doctor's registration check on it. The
171
+ # known-positive is in the same run: the companion line must fire, proving the check executed.
172
+ H="$TMPROOT/doctor_state"; mkdir -p "$H/.claude"
173
+ cat > "$H/.claude/settings.local.json" <<'J'
174
+ {"hooks":{"SessionStart":[{"matcher":"","hooks":[
175
+ {"type":"command","command":"bash $CLAUDE_PROJECT_DIR/scripts/fh_session_load.sh"}]}]}}
176
+ J
177
+ printf '{}' > "$H/.claude/settings.json"
178
+ DOUT=$(python3 "$DOCPY" "$H" 2>&1); DRC=$?
179
+ if printf '%s\n' "$DOUT" | grep -q 'companion-load SessionStart registered'; then
180
+ _pass "DR-P install-doctor's check RAN on this fixture (known-positive: companion line fired, exit=$DRC)"
181
+ else
182
+ _fail "DR-P install-doctor's check did not fire at all — the GAP lane below would pass vacuously"
183
+ fi
184
+ _g=0; printf '%s\n' "$DOUT" | grep -qi 'node_check\|node check' || _g=1
185
+ _gap "nothing downstream verifies that fh_node_check is registered" "$_g" \
186
+ "The node hook is absent from this fixture and install-doctor's registration check says nothing \
187
+ about it — it only greps for fh_session_load. install-doctor's ① checks the git hook FILES, not the \
188
+ SessionStart REGISTRATION of the script that reports on them. So the wizard's unconditional success \
189
+ message (GAP 1) is never contradicted by anything, and the check whose job is announcing an unwired \
190
+ machine can itself be unwired silently."
191
+
192
+ echo
193
+ echo "──────────────────────────────────────────────"
194
+ if [ "$FAILED" -ne 0 ]; then
195
+ echo "SNIPPET MERGE LANES: FAIL — instrument miscalibrated (do not trust its verdict)"
196
+ echo " asserting lanes passed: $PASSED · open gaps: $GAPS"
197
+ exit 1
198
+ fi
199
+ echo "SNIPPET MERGE LANES: PASS ($PASSED asserting lanes) · $GAPS KNOWN GAP(S) open (see ⓘ above)"
200
+ echo " Green covers: happy path, hook-level merge promise, malformed-snippet fail-closed,"
201
+ echo " unparsable-target preservation. Green does NOT cover the three gaps above."
202
+ exit 0
@@ -79,7 +79,10 @@ EOF
79
79
 
80
80
  ### Automatic Response to Issues
81
81
 
82
- <!-- [CUSTOMIZE] Adjust report tool/path to match your project -->
82
+ <!-- [CUSTOMIZE] DOMAIN-SCOPED delete this whole section if the project has no test-report
83
+ artifact to analyse. The old wording here said "adjust report tool/path", which assumes the
84
+ tool exists and reads as a config note rather than a delete instruction; the same measurement
85
+ that found the two sections below inert found this one inert for the same reason. -->
83
86
 
84
87
  #### Automatic Check Trigger
85
88
 
@@ -98,9 +101,27 @@ When the user mentions a problem, **automatically** locate and analyze the lates
98
101
 
99
102
  ### Code Writing Principles
100
103
 
101
- <!-- [CUSTOMIZE] Adjust to match your project's coding conventions. The 5 principles below are universal and valid for any project. -->
102
-
103
- Be conscious of all 5 principles **before** writing code directly reduces back-and-forth where Claude rushes to create something and the user has to correct it.
104
+ <!-- [CUSTOMIZE] Adjust to match your project's coding conventions.
105
+ THREE of the five below are universal (#1 #2 #5). TWO are DOMAIN-SCOPED and belong only to
106
+ UI/mobile-QA projects (#3 locator stability, #4 flakiness)DELETE them outright if this
107
+ project has no UI automation. Do not keep them "just in case": measured 2026-08-03 across
108
+ THREE repos that inherited this file. One (a bash/python wiki engine) carried both for its
109
+ entire 11-day life before being pruned; the other two are byte-identical to this template
110
+ right now and still carry them.
111
+ If you delete a section, also fix any sentence that counts them — the wiki engine shipped
112
+ "all 5 principles" over three for an hour before an outside reviewer caught it.
113
+ What the fix here does and does NOT do, measured as a before/after pair (Sonnet, reps=3).
114
+ The gain depends on how explicitly the asker names what the project lacks:
115
+ * question naming no-UI/no-mobile only -> before: #3 3/3, #4 **1/3**; after: both 3/3
116
+ * question also naming no-test-reports -> before: 8 of 9 cells; after: 9 of 9
117
+ So the honest claim is narrow: the old wording is mostly adequate for someone who spells out
118
+ every absence, and loses a section for someone who does not. Both numbers are recorded because
119
+ citing only the first would overstate this edit.
120
+ THE FIELD CAUSE IS STILL OPEN, and it is a different moment: the wiki engine did not prune
121
+ either section, which means nobody was asked. `light-harness init` copies this file whole,
122
+ with no pruning step. A marker only works on a reader who has stopped to read it. -->
123
+
124
+ Be conscious of every principle below **before** writing code — directly reduces back-and-forth where Claude rushes to create something and the user has to correct it.
104
125
 
105
126
  #### 1. Reference Existing Code (Consistency First)
106
127
 
@@ -116,7 +137,7 @@ Be conscious of all 5 principles **before** writing code — directly reduces ba
116
137
 
117
138
  #### 3. Locator and Identifier Stability (UI code only)
118
139
 
119
- <!-- [CUSTOMIZE] Can be removed for non-mobile QA / non-web QA projects -->
140
+ <!-- [CUSTOMIZE] DOMAIN-SCOPED delete this whole section for non-UI projects (see the note above #1) -->
120
141
 
121
142
  - Do not depend on dynamically generated attributes (auto-generated id, timestamps in content-desc)
122
143
  - Avoid absolute XPath — fragile to structural changes
@@ -125,6 +146,8 @@ Be conscious of all 5 principles **before** writing code — directly reduces ba
125
146
 
126
147
  #### 4. Flakiness Risk Management
127
148
 
149
+ <!-- [CUSTOMIZE] DOMAIN-SCOPED — delete this whole section for projects without UI automation -->
150
+
128
151
  - **No `time.sleep`** — use explicit waits (implicit/explicit wait) + condition-based polling
129
152
  - No unbounded waits without a timeout
130
153
  - Allow tolerance in screenshot-based assertions
@@ -706,8 +706,15 @@ run_universal_guards
706
706
  # not a reversible-surface concern — it disables the commit gate itself.
707
707
  GATE_IMPL=$(echo "$STAGED" \
708
708
  | grep -E '(templates/\.git-hooks/pre-commit|templates/regression_guard\.sh|\.claude/rules/fh_4axis_gate\.md|scripts/gate_pathspec_check\.sh)' || true)
709
- if [ -n "$GATE_IMPL" ]; then
710
- echo "[Gate] gate implementation staged path-coverage known-pair anchor..."
709
+ # CLAUDE.md carries a canonical asset list that gate_pathspec_check §5 verifies, so editing it must
710
+ # RUN the anchor. It is deliberately NOT added to GATE_IMPL: that variable also feeds $LOADBEARING,
711
+ # which demands a cross-family acknowledgment line in the marker. Enrolling every CLAUDE.md prose
712
+ # edit into cross-family review is a different job from path coverage, and over-blocking is how an
713
+ # override becomes muscle memory (CLAUDE.md §Destructive-Op — the same reasoning that keeps the
714
+ # session-close check advisory on ordinary pushes). Separate trigger, same anchor.
715
+ ASSETLIST_IMPL=$(echo "$STAGED" | grep -E '^CLAUDE\.md$' || true)
716
+ if [ -n "$GATE_IMPL" ] || [ -n "$ASSETLIST_IMPL" ]; then
717
+ echo "[Gate] gate implementation or asset list staged — path-coverage known-pair anchor..."
711
718
  PSCHECK="$REPO_ROOT/scripts/gate_pathspec_check.sh"
712
719
  if [ ! -f "$PSCHECK" ]; then
713
720
  echo " ❌ FAIL — scripts/gate_pathspec_check.sh missing while a gate file is being changed."
@@ -54,6 +54,7 @@ DEL_OTHER="" # refs/tags/* refs/notes/* etc. being deleted
54
54
  FORCED_REFS=""
55
55
  UNCLASSIFIED="" # force-check impossible (remote tip not fetched)
56
56
  DIRECT_MAIN="" # non-delete update pushed straight at the integration branch (PR-only policy)
57
+ TAG_MISMATCH="" # vX.Y.Z tag whose commit's package.json disagrees (irreversible-adjacent)
57
58
  SEP=$'\n' # ranges are newline-separated and evaluated ONE REF AT A TIME: concatenating
58
59
  # them into one arg string let `--not` from ref A flip polarity for ref B, so a
59
60
  # multi-ref push could return an empty or wrong commit set (R6 audit 2026-07-26).
@@ -184,6 +185,40 @@ while read -r local_ref local_sha remote_ref remote_sha; do
184
185
  fi ;;
185
186
  esac
186
187
  fi
188
+ # TAG/VERSION CONSISTENCY — a `vX.Y.Z` tag must point at a commit whose package.json says X.Y.Z.
189
+ # WHY (measured 2026-08-02): a release tag was pushed onto the WRONG commit because `git pull`
190
+ # had failed with a diverging-branches error that went unread, so `main` was still the pre-release
191
+ # tree. `npm publish` was then attempted from that same tree — and the ONLY thing that stopped it
192
+ # was npm's own "cannot publish over 1.4.84" collision check. Publishing is irreversible; being
193
+ # saved by the registry's bookkeeping is luck, not a floor.
194
+ # This is N=1 and it is built anyway: the repetition rule ("1-2 occurrences -> prose") is scoped to
195
+ # REVERSIBLE surfaces. A wrong tag on a public repo plus a publish from the wrong tree is the
196
+ # irreversible class, where this repo's own Surface-Class Degrade Invariant says fail-CLOSED now.
197
+ # Scope is deliberately narrow: only a NEW `refs/tags/v<digits>` push, only when package.json
198
+ # exists at that commit. Absent package.json -> not applicable (a tag in a non-npm repo is fine);
199
+ # unreadable package.json at a commit that HAS one -> BLOCK (cannot decide != allowed).
200
+ if [ "$local_sha" != "$ZERO" ] && [ "${remote_sha:-$ZERO}" = "$ZERO" ]; then
201
+ case "$remote_ref" in
202
+ refs/tags/v[0-9]*)
203
+ _tv="${remote_ref#refs/tags/v}"
204
+ _commit=$(git rev-parse "${local_sha}^{commit}" 2>/dev/null)
205
+ if [ -z "$_commit" ]; then
206
+ echo " ⛔ TAG/VERSION: $remote_ref does not resolve to a commit — cannot verify, refusing"
207
+ TAG_MISMATCH="$TAG_MISMATCH $remote_ref"
208
+ elif git cat-file -e "${_commit}:package.json" 2>/dev/null; then
209
+ _pv=$(git show "${_commit}:package.json" 2>/dev/null \
210
+ | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
211
+ if [ -z "$_pv" ]; then
212
+ echo " ⛔ TAG/VERSION: package.json exists at ${_commit} but its version is unreadable — refusing"
213
+ TAG_MISMATCH="$TAG_MISMATCH $remote_ref"
214
+ elif [ "$_pv" != "$_tv" ]; then
215
+ echo " ⛔ TAG/VERSION MISMATCH: $remote_ref points at ${_commit}, whose package.json says $_pv"
216
+ TAG_MISMATCH="$TAG_MISMATCH $remote_ref"
217
+ fi
218
+ fi ;;
219
+ esac
220
+ fi
221
+
187
222
  # PR-only policy: a non-delete update aimed straight at the integration branch.
188
223
  # Detected INSIDE this loop because this is the only place git's ref list is readable.
189
224
  if [ "$local_sha" != "$ZERO" ]; then
@@ -403,6 +438,27 @@ fi
403
438
  # instead of a bare server rejection, and it keeps working if the server setting is ever relaxed.
404
439
  # It is deliberately NOT the hard floor — a client-side hook is bypassable by `--no-verify`.
405
440
  # Scope: blocks the PUSH, not the merge — `gh pr merge` operates server-side and is unaffected.
441
+ if [ -n "$TAG_MISMATCH" ] && [ "${TAG_VERSION_OK:-0}" != "1" ]; then
442
+ echo ""
443
+ echo "══════════════════════════════════════════════"
444
+ echo " ⛔ FH Tag/Version Consistency (pre-push)"
445
+ echo "══════════════════════════════════════════════"
446
+ echo " Tag(s) whose commit disagrees with package.json:$TAG_MISMATCH"
447
+ echo ""
448
+ echo " A release tag is the thing a human and a script both trust to answer \"what shipped\"."
449
+ echo " Measured 2026-08-02: a tag went onto the pre-release commit because a failed \`git pull\`"
450
+ echo " went unread, and \`npm publish\` was then attempted from that same tree. Only npm's own"
451
+ echo " version-collision check stopped it. Publishing does not un-happen; that was luck."
452
+ echo ""
453
+ echo " Fix: git fetch origin && git switch main && git pull --ff-only"
454
+ echo " git tag -d <tag> && git push origin :refs/tags/<tag> # if already pushed"
455
+ echo " # then re-tag on the commit whose package.json really carries that version"
456
+ echo ""
457
+ echo " Deliberate exception (explicit, logged — mirrors MAIN_PUSH_OK / DESTRUCTIVE_OP_OK):"
458
+ echo " TAG_VERSION_OK=1 git push …"
459
+ exit 1
460
+ fi
461
+
406
462
  if [ -n "$DIRECT_MAIN" ] && [ "${MAIN_PUSH_OK:-0}" != "1" ]; then
407
463
  echo ""
408
464
  echo "══════════════════════════════════════════════"
@@ -0,0 +1,17 @@
1
+ {
2
+ "_readme": "FH dispatch-tally hook. Merge the `hooks.SubagentStop` block below into your .claude/settings.json (that file is gitignored, which is why this template exists). It feeds session_close_check.sh ④-e, which blocks a close push when sub-agents were dispatched and the day's invocation-log entries are zero. The hook TALLIES ONLY — it never writes a log entry, because a fabricated outcome/evidence field would poison the 60/40 promotion gate worse than a missing one.",
3
+ "hooks": {
4
+ "SubagentStop": [
5
+ {
6
+ "matcher": "",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "bash -c 'HUB=\"${CLAUDE_PROJECT_DIR:-$HOME/projects/forge-harness}\"; T=\"$HUB/tracks/_meta/.subagent_dispatch_tally\"; mkdir -p \"$(dirname \"$T\")\" 2>/dev/null; printf \"%s\\n\" \"$(date +%Y-%m-%d)\" >> \"$T\" 2>/dev/null; exit 0'",
11
+ "timeout": 5
12
+ }
13
+ ]
14
+ }
15
+ ]
16
+ }
17
+ }