@chrono-meta/fh-gate 1.4.89 → 1.4.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.claude/judgment_circuits.txt +14 -0
  2. package/.claude/rules/fh_4axis_gate.md +7 -0
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/AGENTS.md +25 -0
  5. package/CLAUDE.md +179 -12
  6. package/knowledge/shared/harness-core/dispatch_conditional_prohibition.md +105 -0
  7. package/knowledge/shared/harness-core/fh_three_layer_canon.md +165 -0
  8. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +100 -0
  9. package/knowledge/shared/harness-core/onboarding_acceleration_autopilot.md +3 -1
  10. package/knowledge/shared/harness-core/ship_readiness_gate.md +181 -13
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +528 -0
  12. package/package.json +16 -2
  13. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  14. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  15. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +56 -8
  16. package/plugins/fh-meta/skills/install-wizard/SKILL.md +33 -0
  17. package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +104 -15
  18. package/scripts/chamber_run.sh +64 -2
  19. package/scripts/chamber_witness.sh +439 -0
  20. package/scripts/compaction_probe.sh +456 -0
  21. package/scripts/digest_landing_check.sh +385 -0
  22. package/scripts/directional_diff_gate.sh +459 -0
  23. package/scripts/judgment_circuit_lint.sh +239 -0
  24. package/scripts/novelty_claim_check.sh +193 -0
  25. package/scripts/relay_channel.sh +645 -0
  26. package/scripts/reviewer_capability_corpus.tsv +124 -0
  27. package/scripts/selfcheck.sh +66 -0
  28. package/scripts/test_marker_crossfamily_lanes.sh +132 -0
  29. package/scripts/test_marker_floor_lanes.sh +9 -8
  30. package/scripts/test_relay_channel_lanes.sh +583 -0
  31. package/scripts/test_reviewer_capability_conformance.sh +173 -0
  32. package/scripts/test_wizard_snippet_merge_lanes.sh +104 -11
  33. package/scripts/utterance_landing_check.sh +209 -0
  34. package/templates/.git-hooks/pre-commit +253 -13
  35. package/templates/settings.Compaction.snippet.json +56 -0
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env bash
2
+ # directional_diff_gate.sh — PRE-push loss gate for FILE-REPLACEMENT pushes.
3
+ # (It said "post-push detector" while it had no callers. Wired into rest_push.sh on
4
+ # 2026-08-09 it runs BEFORE tree/commit/ref, so the name had to stop lying.)
5
+ #
6
+ # WHY (pmh-dev #42, measured 2026-08-08): a push that REPLACES a file with local bytes
7
+ # (REST Contents API, mirror sync, any upload-the-whole-file path) silently drops lines
8
+ # that existed only on the remote. Three defenses were green while the file was damaged:
9
+ #
10
+ # tool warning "remote differs (remote=X local=Y) — overwriting" → says THAT it differs,
11
+ # never WHAT disappears. Reads as normal if you believe local is canonical.
12
+ # additions/deletions total 1 deletion + 91 additions looks like net growth. The
13
+ # previous round passed 141 = 141 with this method — it cannot see DIRECTION.
14
+ # rc rc=0, correctly: the replace succeeded. The defect was in the request.
15
+ #
16
+ # The lost line was a mirror banner ("do not edit here, next sync overwrites"). Losing it
17
+ # does not cost one line — it costs the only marker telling the next session the file is
18
+ # not canonical. Loss classes are silent by nature; that is why this runs as a gate.
19
+ #
20
+ # WHAT THIS ADDS: direction. It does not replace the total-count check; totals stay useful
21
+ # and blind to sign.
22
+ #
23
+ # ── Which deletions are the DEFECT, and which are just editing (measured 2026-08-09) ──
24
+ # The first version blocked on EVERY deletion. Measured against the real remote that day:
25
+ #
26
+ # local file identical to remote → CLEAN rc=0 (correct)
27
+ # one comment line deliberately removed, two lines added → BLOCK rc=1 (over-block)
28
+ #
29
+ # i.e. every routine refactor push goes red, the operator sets DIRECTIONAL_DIFF_ACK by
30
+ # reflex, and the gate is disarmed. That is the #33 lineage this repo has already paid for:
31
+ # a tool that always blocks teaches the bypass. An always-red gate is not a strict gate.
32
+ #
33
+ # The verdict stays what it was: **any line the remote has and the local upload does not**
34
+ # is a loss, and it blocks. That is `R − L` as a MULTISET (`comm` over sorted files), so a
35
+ # remote process appending a SECOND copy of a line the file already had once is caught too;
36
+ # a set-membership test waves that through.
37
+ #
38
+ # ── Why the over-block is answered by the ACK, not by a smarter verdict ─────────
39
+ # A three-way was built and MEASURED on 2026-08-09 and then withdrawn. It took
40
+ # B = merge-base(HEAD, <base_sha>) and blocked only on `(R − B) − (L − B)`, so your own
41
+ # deletions never blocked. It was withdrawn because a cross-family review produced a
42
+ # counterexample the author had not found:
43
+ #
44
+ # HEAD == remote tip ⇒ B == R ⇒ R − B = ∅ ⇒ NOTHING can ever be reported,
45
+ # including a genuine loss when the local file is stale or foreign bytes rather than
46
+ # an edit of HEAD.
47
+ #
48
+ # merge-base proves a common ANCESTOR; it does not prove "the version this upload was
49
+ # derived from". Git cannot tell an edit of HEAD from foreign bytes pasted over it, and a
50
+ # file-replacement push is exactly the surface where the bytes come from somewhere else.
51
+ # So the precise mode needs the caller to DECLARE its edit base, and until something can
52
+ # supply that declaration the gate does not guess. Trading a measured over-block for an
53
+ # unmeasurable fail-open is the wrong direction on a delete-class surface.
54
+ #
55
+ # What answers the over-block instead: the acknowledgement is bound to the exact loss set
56
+ # (see below). You cannot keep one ACK in your shell history and wave every push through —
57
+ # the token changes with the content, so an ACK always costs a LOOK at what is being lost.
58
+ # That is the property the reflex-ACK failure mode actually needs; a looser verdict was
59
+ # never the only way to get it.
60
+ #
61
+ # ── Verdict / exit codes ────────────────────────────────────────────────────────
62
+ # 0 CLEAN no deleted lines (or every deletion acknowledged, see ack below)
63
+ # 1 DELETIONS lines the remote has are missing from the upload, unacknowledged
64
+ # 10 UNDECIDABLE the comparison could not be MADE (decode/API/instrument failure).
65
+ # Deliberately NOT 0. A file-replacement push is a delete-class surface,
66
+ # so an un-runnable check fails CLOSED (§Irreversibility Surface-Class
67
+ # Degrade Invariant). "Could not compare" is not "nothing was lost".
68
+ #
69
+ # ── Acknowledging an intentional deletion ───────────────────────────────────────
70
+ # Removing a remote-only line is sometimes correct (retiring a bot banner). Blocking
71
+ # unconditionally would make this an always-red gate and teach --no-verify — the #33
72
+ # lineage this repo has already paid for. So deletions are ACK-gated, not forbidden:
73
+ #
74
+ # DIRECTIONAL_DIFF_ACK="LOSS-<token> <what is being deleted and why>" …
75
+ #
76
+ # TWO conditions, and the second is the one that matters:
77
+ # · non-vacuous prose (≥20 chars) — the original defect was a warning that announced a
78
+ # difference without naming it; an approval that also declines to say what is the same
79
+ # failure wearing a different hat. "ok" / "1" / "yes" is rejected.
80
+ # · the LOSS-<token> printed by THIS run. The token hashes the repo, the base sha, and
81
+ # every (path, lost line) pair — so an ack is valid for exactly one loss set, on one
82
+ # repo, at one base. It cannot be parked in shell history and reused: the same line
83
+ # lost from a different file is a different token, and so is the same file at a
84
+ # different base. Every acknowledgement therefore costs one LOOK at the list. That is
85
+ # what actually defuses reflex-ACK; a looser verdict never had to.
86
+ # It also ends the blanket ACK — one file's explanation no longer clears a second
87
+ # file's loss, because the token covers the union of both.
88
+ #
89
+ # ── Usage ───────────────────────────────────────────────────────────────────────
90
+ # bash scripts/directional_diff_gate.sh <owner/repo> <base_sha> <path> [<path>...]
91
+ # bash scripts/directional_diff_gate.sh --self-test # known-pair calibration
92
+ #
93
+ # <base_sha> is the commit the push was based on — the state to compare AGAINST.
94
+
95
+ set -uo pipefail
96
+
97
+ # ── portable base64 decode (BSD `-D` vs GNU `-d`) ───────────────────────────────
98
+ # A BSD-first `-D || -d` chain is how this repo previously shipped a script that was
99
+ # green on macOS and 66/70 red on Linux CI. Probe once, both directions, no assumption.
100
+ b64_decode() {
101
+ if printf 'YQ==' | base64 -d >/dev/null 2>&1; then base64 -d
102
+ elif printf 'YQ==' | base64 -D >/dev/null 2>&1; then base64 -D
103
+ else return 127; fi
104
+ }
105
+
106
+ TMP=$(mktemp -d) || { echo "HARNESS-ERROR: mktemp failed"; exit 10; }
107
+ trap 'rm -rf "$TMP"' EXIT
108
+
109
+ # ── compute_lost: the verdict, as multiset arithmetic ───────────────────────────
110
+ # $TMP/lost = R − L (occurrences the remote has that the upload does not)
111
+ # ONE implementation, called by check_path AND by --self-test — two copies of the same
112
+ # normalisation is how an input passes one and is silently dropped by the other.
113
+ #
114
+ # rc: 0 = computed, 10 = the computation itself failed. `sort`/`comm` failures used to be
115
+ # invisible: a truncated sort leaves an empty file, comm then produces an empty `lost`, and
116
+ # nlost=0 renders as CLEAN. An instrument that failed must not answer "nothing was lost".
117
+ compute_lost() { # $1=R file $2=L file
118
+ sort "$1" > "$TMP/rs" || return 10
119
+ sort "$2" > "$TMP/ls" || return 10
120
+ comm -23 "$TMP/rs" "$TMP/ls" > "$TMP/lost" || return 10
121
+ return 0
122
+ }
123
+
124
+ # ── loss_token: the ack binding ─────────────────────────────────────────────────
125
+ # Material = repo + base + every (path, lost line). One implementation, called by main
126
+ # AND by --self-test: a calibration that hashes a *replica* of the material proves nothing
127
+ # about the token the operator is actually asked to paste.
128
+ loss_token() { # $1=repo $2=base (reads $TMP/lost_all)
129
+ { printf '%s\n%s\n' "$1" "$2"; sort "$TMP/lost_all"; } 2>/dev/null | shasum 2>/dev/null | cut -c1-12
130
+ }
131
+
132
+ # ── one file → verdict on stdout, status in return code ─────────────────────────
133
+ check_path() { # $1=repo $2=base_sha $3=path
134
+ local repo="$1" base="$2" path="$3"
135
+ local prev="$TMP/prev" raw="$TMP/raw"
136
+
137
+ # A missing local file is NOT "nothing to compare". On a delete-class surface it is the
138
+ # signature of a mis-wired caller (wrong cwd, cwd-relative path passed where a repo-root
139
+ # one was meant) — and the previous version returned 0 here, so a wiring mistake read as
140
+ # CLEAN and the whole gate silently measured nothing. Unmeasured is not clean.
141
+ if [ ! -f "$path" ]; then
142
+ echo " 🟥 $path — UNDECIDABLE: not present locally (wrong cwd, or a path that is not"
143
+ echo " repo-root-relative). A file this gate cannot read is not a file it cleared."
144
+ return 10
145
+ fi
146
+
147
+ # The path goes into a URL query-bearing request unencoded. A legitimate filename with
148
+ # `?`, `#` or `%` therefore addresses a DIFFERENT resource, and the likely answer is a
149
+ # 404 — which this gate reads as "new file at base → nothing to lose". A wrong request
150
+ # that returns a clean verdict is worse than no request. Refuse instead of guessing.
151
+ # ── the bytes I judged must be the bytes you upload ─────────────────────────
152
+ # A caller that materialises a fixed payload into a scratch tree still hands this gate a
153
+ # MUTABLE file. The verdict then binds to "whatever was in that file when I read it",
154
+ # while the upload binds to the fixed object — swap the scratch copy mid-run and the two
155
+ # diverge (cross-family R5). So the caller may DECLARE the object id per path, and this
156
+ # gate refuses to judge anything whose bytes do not hash to the declared id.
157
+ # Absent declaration = standalone/manual use; no binding is claimed and none is enforced.
158
+ if [ -n "${DIRECTIONAL_DIFF_PINS:-}" ] && [ -f "${DIRECTIONAL_DIFF_PINS}" ]; then
159
+ local want got
160
+ want=$(awk -F'\t' -v p="$path" '$2==p{print $1; exit}' "$DIRECTIONAL_DIFF_PINS")
161
+ if [ -z "$want" ]; then
162
+ echo " 🟥 $path — UNDECIDABLE: pins declared but this path is not among them."
163
+ echo " Judging a file the caller did not pin means the verdict binds to nothing."
164
+ return 10
165
+ fi
166
+ got=$(git hash-object --no-filters "$path" 2>/dev/null)
167
+ if [ "$got" != "$want" ]; then
168
+ echo " 🟥 $path — UNDECIDABLE: bytes do not match the declared object."
169
+ echo " declared ${want:0:12} · read ${got:0:12} — the thing I would judge is not"
170
+ echo " the thing you would upload. Refusing to produce a verdict for it."
171
+ return 10
172
+ fi
173
+ fi
174
+
175
+ case "$path" in
176
+ *'?'*|*'#'*|*'%'*)
177
+ echo " 🟥 $path — UNDECIDABLE: path contains a URL-significant character (? # %)."
178
+ echo " The contents request would address a different resource and its 404"
179
+ echo " would read as 'new file'. Not encoding it silently."
180
+ return 10 ;;
181
+ esac
182
+
183
+ # Trap #1 (measured): an unquoted ?ref= is eaten by zsh as a glob → "no matches found",
184
+ # which reads like a repo/path error and is a shell error. The URL is quoted here so a
185
+ # caller cannot reintroduce it.
186
+ local http
187
+ http=$(gh api "repos/${repo}/contents/${path}?ref=${base}" --jq .content > "$raw" 2>"$TMP/err"; echo $?)
188
+
189
+ if [ "$http" -ne 0 ]; then
190
+ # 404 = the file did NOT exist at base → this push creates it → every line is an
191
+ # addition and deletions are genuinely 0. Any OTHER failure is undecidable, and the
192
+ # two must not share an exit: treating an auth/network error as "new file" is exactly
193
+ # the not-found-is-not-zero collapse this gate exists to stop.
194
+ # NOT a loose `not found` grep: `gh: command not found` and `repository not found`
195
+ # both match it, and both would be rendered as "new file at base → del=0" — a
196
+ # fail-open dressed as a 404. Require the HTTP status literal.
197
+ if grep -q 'HTTP 404' "$TMP/err"; then
198
+ echo " ✅ $path — new file at base (404) → del=0"
199
+ return 0
200
+ fi
201
+ echo " 🟥 $path — UNDECIDABLE: API call failed"
202
+ sed 's/^/ /' "$TMP/err" | head -3
203
+ return 10
204
+ fi
205
+
206
+ # Trap #2 (measured, and it actually fired): `--jq .content` returns base64 WITH
207
+ # embedded newlines. Decoding without stripping them dies and leaves a 0-line file —
208
+ # and then diff reports "all additions", del=0, and the loss check passes while having
209
+ # measured nothing. The line-count assertion below is the whole point: a 0-line decode
210
+ # of a non-empty payload is UNDECIDABLE, never a clean verdict.
211
+ if ! tr -d '\n' < "$raw" | b64_decode > "$prev" 2>/dev/null; then
212
+ echo " 🟥 $path — UNDECIDABLE: base64 decode failed (no usable base64 on PATH?)"
213
+ return 10
214
+ fi
215
+ local raw_bytes prev_lines
216
+ raw_bytes=$(wc -c < "$raw" | tr -d ' ')
217
+ prev_lines=$(wc -l < "$prev" | tr -d ' ')
218
+ if [ "$raw_bytes" -gt 8 ] && [ "$prev_lines" -eq 0 ]; then
219
+ echo " 🟥 $path — UNDECIDABLE: payload was ${raw_bytes}B but decoded to 0 lines"
220
+ echo " (this is the measured trap: 0 lines makes diff report del=0 and the"
221
+ echo " check passes without comparing anything — unmeasured, not clean)"
222
+ return 10
223
+ fi
224
+
225
+ # A binary payload has no lines to reason about, and a line-wise verdict over it is
226
+ # noise that reads as a measurement. Refuse rather than produce a number.
227
+ # NOT `grep -q $'\x00'`: bash cannot hold a NUL in a string, so `$'\x00'` collapses to
228
+ # the EMPTY pattern, grep matches every line, and every text file is declared binary —
229
+ # a total over-block that looks like a strict check. Strip-and-compare instead.
230
+ has_nul() { LC_ALL=C tr -d '\000' < "$1" | cmp -s - "$1" || return 0; return 1; }
231
+ if has_nul "$prev" || has_nul "$path"; then
232
+ echo " 🟥 $path — UNDECIDABLE: binary content (NUL bytes); a line-wise direction"
233
+ echo " verdict over binary is not a measurement."
234
+ return 10
235
+ fi
236
+
237
+ if ! compute_lost "$prev" "$path"; then
238
+ echo " 🟥 $path — UNDECIDABLE: the comparison itself failed (sort/comm)."
239
+ echo " A failed instrument does not get to answer 'nothing was lost'."
240
+ return 10
241
+ fi
242
+ local nlost
243
+ nlost=$(wc -l < "$TMP/lost" | tr -d ' ')
244
+ if [ "$nlost" -eq 0 ]; then
245
+ echo " ✅ $path — 0 lost (compared ${prev_lines} remote lines)"
246
+ return 0
247
+ fi
248
+ echo " ❌ $path — ${nlost} remote line(s) would be LOST:"
249
+ head -10 "$TMP/lost" | sed 's/^/ < /'
250
+ [ "$nlost" -gt 10 ] && echo " … and $((nlost-10)) more"
251
+ # Accumulate WITH the path so the token distinguishes "line X lost from a" from
252
+ # "line X lost from b" — without it, an ack earned on one file clears the other.
253
+ # rc is checked: a partial accumulation would hash to a token that acknowledges less
254
+ # than what is actually being lost.
255
+ if ! sed "s|^|${path}\t|" "$TMP/lost" >> "$TMP/lost_all"; then
256
+ echo " 🟥 $path — UNDECIDABLE: could not record the loss set for the ack token"
257
+ return 10
258
+ fi
259
+ return 1
260
+ }
261
+
262
+ # ── known-pair calibration ──────────────────────────────────────────────────────
263
+ # An instrument is a claim about the world only after it separates a case whose answer
264
+ # you already know. This runs offline against fabricated pairs — no network, no repo.
265
+ self_test() {
266
+ local f=0 n=0
267
+ t() { n=$((n+1)); if [ "$2" = "$3" ]; then echo "✅ $1 → $3"; else echo "❌ $1 → $3 (expected $2)"; f=1; fi; }
268
+
269
+ printf 'a\nb\nc\n' > "$TMP/p"; printf 'a\nb\nc\n' > "$TMP/l"
270
+ diff "$TMP/p" "$TMP/l" >/dev/null 2>&1 && r=CLEAN || r=DIFF
271
+ t "identical files" CLEAN "$r"
272
+
273
+ printf 'a\nb\nc\n' > "$TMP/p"; printf 'a\nb\nc\nd\n' > "$TMP/l"
274
+ [ "$(diff "$TMP/p" "$TMP/l" | grep -c '^<')" -eq 0 ] && r=CLEAN || r=DEL
275
+ t "pure addition (del must be 0)" CLEAN "$r"
276
+
277
+ # The measured event: 1 deletion buried under 91 additions. Totals call this net growth.
278
+ printf 'BANNER\na\nb\n' > "$TMP/p"; { echo a; echo b; for i in $(seq 1 91); do echo "new$i"; done; } > "$TMP/l"
279
+ [ "$(diff "$TMP/p" "$TMP/l" | grep -c '^<')" -gt 0 ] && r=DEL || r=CLEAN
280
+ t "banner dropped under 91 additions (the measured event)" DEL "$r"
281
+ local tot_add tot_del
282
+ tot_add=$(diff "$TMP/p" "$TMP/l" | grep -c '^>'); tot_del=$(diff "$TMP/p" "$TMP/l" | grep -c '^<')
283
+ [ "$tot_add" -gt "$tot_del" ] && r=NETGROWTH || r=NETLOSS
284
+ t " └ and a total-only check reads it as" NETGROWTH "$r"
285
+
286
+ # Trap #2 reproduced: a 0-line "previous" makes deletions unmeasurable, not zero.
287
+ : > "$TMP/p0"; printf 'a\nb\n' > "$TMP/l"
288
+ [ "$(diff "$TMP/p0" "$TMP/l" | grep -c '^<')" -eq 0 ] && r=LOOKS_CLEAN || r=DEL
289
+ t "0-line decode reads as clean (why 0 lines must be UNDECIDABLE)" LOOKS_CLEAN "$r"
290
+
291
+ printf 'YQ==' | b64_decode >/dev/null 2>&1 && r=OK || r=BROKEN
292
+ t "base64 decode available on this platform" OK "$r"
293
+
294
+ # Ack non-vacuity, both directions.
295
+ for pair in "ok:VACUOUS" "1:VACUOUS" "yes:VACUOUS" "removing the retired mirror banner, superseded by frontmatter:SUBSTANTIVE"; do
296
+ local val="${pair%:*}" want="${pair##*:}"
297
+ if printf '%s' "$val" | grep -qE '.{20,}'; then r=SUBSTANTIVE; else r=VACUOUS; fi
298
+ t "ack '${val:0:24}'" "$want" "$r"
299
+ done
300
+
301
+ # ── the verdict arithmetic, against the SHIPPED compute_lost ──────────────────
302
+ lostn() { compute_lost "$TMP/tr" "$TMP/tl" || { echo ERR; return; }; wc -l < "$TMP/lost" | tr -d ' '; }
303
+
304
+ # (a) the measured incident: a banner the remote has and the upload does not.
305
+ printf 'BANNER\na\nb\n' > "$TMP/tr"; printf 'a\nb\n' > "$TMP/tl"
306
+ t "remote line missing from the upload → lost" 1 "$(lostn)"
307
+
308
+ # (b) pure addition must not block — an always-red gate teaches the bypass (#33).
309
+ printf 'a\nb\n' > "$TMP/tr"; printf 'a\nb\nnew\n' > "$TMP/tl"
310
+ t "pure addition → 0 lost (over-block guard)" 0 "$(lostn)"
311
+
312
+ # (c) multiplicity: a SECOND copy of a line the file already had once is a real loss.
313
+ # Set membership reports 0 here; that is why comm (multiset) is used.
314
+ printf 'DUP\nDUP\n' > "$TMP/tr"; printf 'DUP\n' > "$TMP/tl"
315
+ t "remote has 2 copies, upload has 1 → 1 lost (set-test would say 0)" 1 "$(lostn)"
316
+
317
+ # (d) a block of remote-only lines counts as a block, not as one.
318
+ printf 'x\nP\nQ\nR\n' > "$TMP/tr"; printf 'x\n' > "$TMP/tl"
319
+ t "remote-only block of 3 → 3 lost" 3 "$(lostn)"
320
+
321
+ # (e) reordering is NOT loss under a line-frequency contract — pinned so the contract is
322
+ # explicit rather than accidental. (Position/section context is a named non-goal:
323
+ # this gate preserves line occurrences, not their placement.)
324
+ printf 'a\nb\n' > "$TMP/tr"; printf 'b\na\n' > "$TMP/tl"
325
+ t "reordered lines → 0 lost (frequency contract, not a position check)" 0 "$(lostn)"
326
+
327
+ # (f) instrument failure must not answer "nothing was lost".
328
+ compute_lost "$TMP/__no_such_file__" "$TMP/tl" >/dev/null 2>&1 && r=SILENT || r=RC_NONZERO
329
+ t "unreadable input → compute_lost fails loudly (not a clean 0)" RC_NONZERO "$r"
330
+
331
+ # (g) ACK binding, against the SHIPPED loss_token. The token must move with EVERY part
332
+ # of the material — content, path, repo, base — or a parked ack clears the wrong loss.
333
+ : > "$TMP/lost_all"; printf 'a.sh\tX\n' >> "$TMP/lost_all"
334
+ g_content=$(loss_token o/r base1)
335
+ : > "$TMP/lost_all"; printf 'a.sh\tY\n' >> "$TMP/lost_all"
336
+ g_other=$(loss_token o/r base1)
337
+ t "token moves when the lost LINE differs" DIFFERENT "$([ "$g_content" != "$g_other" ] && echo DIFFERENT || echo SAME)"
338
+
339
+ : > "$TMP/lost_all"; printf 'b.sh\tX\n' >> "$TMP/lost_all"
340
+ g_path=$(loss_token o/r base1)
341
+ t "token moves when the same line is lost from a DIFFERENT FILE" DIFFERENT \
342
+ "$([ "$g_content" != "$g_path" ] && echo DIFFERENT || echo SAME)"
343
+
344
+ : > "$TMP/lost_all"; printf 'a.sh\tX\n' >> "$TMP/lost_all"
345
+ t "token moves with the REPO" DIFFERENT "$([ "$g_content" != "$(loss_token o/other base1)" ] && echo DIFFERENT || echo SAME)"
346
+ t "token moves with the BASE" DIFFERENT "$([ "$g_content" != "$(loss_token o/r base2)" ] && echo DIFFERENT || echo SAME)"
347
+ t "token is stable for identical material" STABLE \
348
+ "$([ "$g_content" = "$(loss_token o/r base1)" ] && echo STABLE || echo UNSTABLE)"
349
+ t "token is 12 hex chars (8 is 32 bits on a value that must not collide by accident)" 12 \
350
+ "$(printf '%s' "$g_content" | wc -c | tr -d ' ')"
351
+ : > "$TMP/lost_all"
352
+
353
+ # (h) NUL detection. The first draft used `grep -q $'"'"'\x00'"'"'`, which bash collapses to the
354
+ # EMPTY pattern — every text file matched and every push would have been declared
355
+ # binary/UNDECIDABLE. Both directions are pinned so that regression cannot return.
356
+ has_nul() { LC_ALL=C tr -d '\000' < "$1" | cmp -s - "$1" || return 0; return 1; }
357
+ printf 'plain text\n' > "$TMP/tn"
358
+ has_nul "$TMP/tn" && r=BINARY || r=TEXT
359
+ t "plain text is not misread as binary" TEXT "$r"
360
+ printf 'a\000b\n' > "$TMP/tn"
361
+ has_nul "$TMP/tn" && r=BINARY || r=TEXT
362
+ t "a file with NUL bytes is detected as binary" BINARY "$r"
363
+
364
+ # (i) 404 must be the HTTP status, not any stderr containing "not found".
365
+ # `gh: command not found` used to be accepted as "new file at base" → fail-open.
366
+ for pair in "gh: Not Found (HTTP 404):IS404" "gh: command not found:NOT404" "repository not found:NOT404"; do
367
+ msg="${pair%:*}"; want="${pair##*:}"
368
+ printf '%s\n' "$msg" | grep -q 'HTTP 404' && r=IS404 || r=NOT404
369
+ t "404 discrimination '${msg:0:26}'" "$want" "$r"
370
+ done
371
+
372
+ # (j) structural: an unrecognised per-file rc must not aggregate to CLEAN.
373
+ if grep -q '\*) echo " 🟥 \$p — HARNESS-ERROR: unrecognised check rc' "$0"; then r=HAS_DEFAULT; else r=NO_DEFAULT; fi
374
+ t "aggregator has a default branch for unknown rc" HAS_DEFAULT "$r"
375
+
376
+ echo; [ "$f" -eq 0 ] && echo "✅ calibration passed ($n pairs)" || echo "❌ calibration FAILED ($n pairs)"
377
+ return "$f"
378
+ }
379
+
380
+ # ── main ────────────────────────────────────────────────────────────────────────
381
+ [ "${1:-}" = "--self-test" ] && { self_test; exit $?; }
382
+
383
+ if [ "$#" -lt 3 ]; then
384
+ echo "usage: $0 <owner/repo> <base_sha> <path> [<path>...]"
385
+ echo " $0 --self-test"
386
+ exit 10
387
+ fi
388
+ REPO="$1"; BASE="$2"; shift 2
389
+
390
+ echo "── directional diff vs ${REPO}@${BASE:0:12} ──"
391
+ : > "$TMP/lost_all"
392
+ WORST=0
393
+ for p in "$@"; do
394
+ check_path "$REPO" "$BASE" "$p"; rc=$?
395
+ # 10 (undecidable) outranks 1 (deletions): an unmeasured file is a worse verdict than a
396
+ # measured bad one, because only the first is invisible.
397
+ # An UNRECOGNISED rc must not fall through as clean. check_path returns 0/1/10 today,
398
+ # but a future edit (or a `set -u` abort, a 127, a 128 from git) returning anything else
399
+ # would leave WORST at 0 and the run would end "✅ CLEAN" having decided nothing. The
400
+ # aggregator is where that becomes invisible, so the default lives here.
401
+ case "$rc" in
402
+ 0) : ;;
403
+ 1) [ "$WORST" -ne 10 ] && WORST=1 ;;
404
+ 10) WORST=10 ;;
405
+ *) echo " 🟥 $p — HARNESS-ERROR: unrecognised check rc=$rc (treated as undecidable)"
406
+ WORST=10 ;;
407
+ esac
408
+ done
409
+
410
+ echo
411
+ case "$WORST" in
412
+ 0) echo "✅ CLEAN — no remote-only lines lost"; exit 0 ;;
413
+ 10) echo "🟥 UNDECIDABLE — the comparison could not be made for ≥1 file."
414
+ echo " Fails closed: a file-replacement push is a delete-class surface, and"
415
+ echo " 'could not compare' is not 'nothing was lost'. Fix the instrument and re-run."
416
+ exit 10 ;;
417
+ 1) ACK="${DIRECTIONAL_DIFF_ACK:-}"
418
+ # Token over the UNION of every lost line, sorted so the same loss set always yields
419
+ # the same token regardless of the order files were given on the command line.
420
+ # Material = repo + base + every (path, lost line). An ack is then valid for exactly
421
+ # one loss, on one repo, at one base — not for "some line X somewhere". 12 hex chars
422
+ # rather than 8: an 8-char prefix is 32 bits, which is a collision surface on a value
423
+ # whose whole job is to be unforgeable-by-accident.
424
+ TOKEN=$(loss_token "$REPO" "$BASE")
425
+ if [ -z "$TOKEN" ]; then
426
+ # No hash tool ⇒ the ACK cannot be bound to anything ⇒ do not fall back to an
427
+ # unbound ACK. An unbindable acknowledgement on a delete-class surface is the
428
+ # reflex-ACK failure this token exists to stop.
429
+ echo "🟥 UNDECIDABLE — cannot compute the loss token (no shasum on PATH)."
430
+ echo " Refusing to accept an unbound acknowledgement. Fix the instrument."
431
+ exit 10
432
+ fi
433
+ if [ -z "$ACK" ]; then
434
+ echo "❌ DELETIONS — lines present on the remote would be lost, unacknowledged."
435
+ echo " Look at the list above. If every one of those removals is intended, say WHAT"
436
+ echo " is being removed and include this run's token:"
437
+ echo " DIRECTIONAL_DIFF_ACK=\"LOSS-$TOKEN <what is deleted and why>\" $0 $REPO $BASE <paths>"
438
+ echo " The token is derived from the lost lines themselves — it changes with the"
439
+ echo " content, so a previously-used ack will not clear a different loss."
440
+ exit 1
441
+ fi
442
+ if ! printf '%s' "$ACK" | grep -q "LOSS-$TOKEN"; then
443
+ echo "❌ DELETIONS — ack does not carry THIS run's token (expected LOSS-$TOKEN)."
444
+ echo " An ack from an earlier run acknowledges earlier content. Re-read the list above."
445
+ exit 1
446
+ fi
447
+ # ⚠️ `.{20,}` alone passes on 20 spaces. Require 20 NON-space characters — the point
448
+ # is a stated reason, and whitespace states nothing.
449
+ if [ "$(printf '%s' "$ACK" | sed "s/LOSS-$TOKEN//" | tr -cd '[:graph:]' | wc -c | tr -d ' ')" -lt 20 ]; then
450
+ echo "❌ DELETIONS — token present but no substantive reason."
451
+ echo " Name the removed content, not the fact of removal. The token proves you saw"
452
+ echo " the list; the prose proves you decided about it."
453
+ exit 1
454
+ fi
455
+ echo "⚠️ DELETIONS acknowledged — proceeding (recorded, not silent)"
456
+ echo " token: LOSS-$TOKEN"
457
+ echo " ack: $ACK"
458
+ exit 0 ;;
459
+ esac