@chrono-meta/fh-gate 1.4.96 → 1.4.98

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 (49) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CATALOG.md +19 -0
  3. package/CHEATSHEET.md +9 -1
  4. package/CLAUDE.md +39 -6
  5. package/README.ja.md +10 -0
  6. package/README.ko.md +10 -0
  7. package/README.md +30 -0
  8. package/README.zh.md +10 -0
  9. package/docs/pillars.svg +3 -7
  10. package/knowledge/shared/harness-core/fh_ecosystem_positioning.md +2 -0
  11. package/knowledge/shared/harness-core/fh_global_positioning_and_distribution_roadmap.md +136 -0
  12. package/knowledge/shared/harness-core/fh_three_layer_canon.md +20 -0
  13. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +215 -2
  14. package/knowledge/shared/harness-core/ship_readiness_gate.md +112 -0
  15. package/knowledge/shared/learnings/subagent_invocations_log.yaml +125 -0
  16. package/package.json +9 -2
  17. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  18. package/plugins/fh-commons/skills/ko-tech-writer/SKILL.md +63 -12
  19. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  20. package/plugins/fh-meta/CHANGELOG.md +65 -0
  21. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +30 -0
  22. package/scripts/compaction_probe.sh +153 -10
  23. package/scripts/consent_registry_check.sh +63 -0
  24. package/scripts/degrade_direction_scan.sh +10 -1
  25. package/scripts/digest_landing_check.sh +20 -4
  26. package/scripts/fh_node_check.sh +60 -1
  27. package/scripts/fh_session_load.sh +22 -2
  28. package/scripts/frontier_digest_autopilot.sh +229 -0
  29. package/scripts/lane_runner_check.sh +637 -0
  30. package/scripts/package_coverage_check.sh +199 -35
  31. package/scripts/postinstall_notice.js +34 -0
  32. package/scripts/psa_scan_lib.sh +164 -0
  33. package/scripts/public_surface_scan_files.sh +30 -0
  34. package/scripts/selfcheck.sh +442 -33
  35. package/scripts/test_consent_registry.sh +54 -0
  36. package/scripts/test_degrade_scan_shell_probes.sh +75 -0
  37. package/scripts/test_field_canon_lanes.sh +29 -5
  38. package/scripts/test_lane_runner_lanes.sh +221 -0
  39. package/scripts/test_marker_crossfamily_lanes.sh +10 -1
  40. package/scripts/test_marker_floor_lanes.sh +25 -1
  41. package/scripts/test_psa_singlefile_lanes.sh +288 -0
  42. package/scripts/test_reviewer_capability_conformance.sh +10 -1
  43. package/scripts/test_selfcheck_state_lanes.sh +173 -0
  44. package/scripts/test_stale_clone_guard_lanes.sh +21 -7
  45. package/scripts/test_version_lockstep_lanes.sh +62 -0
  46. package/scripts/version_lockstep_check.sh +143 -1
  47. package/templates/.git-hooks/pre-commit +22 -1
  48. package/templates/degrade_direction_scan.sh +10 -1
  49. package/templates/goal-quench-settings-merged.json +30 -0
@@ -21,28 +21,6 @@ set -uo pipefail
21
21
  REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
22
22
  cd "$REPO_ROOT" || exit 1
23
23
 
24
- # Source-checkout test uses `-e`, not `-d`. In a git WORKTREE `.git` is a FILE (a gitdir pointer),
25
- # so the old `-d` test read every worktree as "installed package" and skipped the check entirely —
26
- # silently, with exit 0. Measured 2026-07-31: a worktree created specifically to approximate CI
27
- # reported PASS while this check had not run at all, i.e. the instrument used to justify wiring CI
28
- # was itself fail-open on the surface it was standing in. `-e` covers both the ordinary checkout
29
- # (dir) and the worktree (file); genuine package mode has no `.git` of either kind, so it still
30
- # skips. Anchored by scripts/test_package_coverage_lanes.sh.
31
- if [ ! -e .git ]; then
32
- echo "SKIP package-coverage (not a source checkout)"
33
- exit 0
34
- fi
35
- # PREDICATE SPLIT (cross-family review, 2026-07-31). These were one condition, and folding them
36
- # together meant `.git` present + manifest missing returned SKIP + exit 0 — "we are in a checkout
37
- # and cannot read what ships" reported as "nothing to check here". Absence of the input is not
38
- # absence of the defect; `not found != 0` (CLAUDE.md §Instrument-Calibration). Only the no-.git
39
- # case is a legitimate skip (an installed package, where the un-shipped files are correctly gone).
40
- if [ ! -f package.json ]; then
41
- echo "FAIL package-coverage: source checkout with no package.json — the shipped file list is"
42
- echo " unreadable, so coverage is UNMEASURED, not clean"
43
- exit 1
44
- fi
45
-
46
24
  # ── Accepted-absent, with the reason each one is NOT a defect ────────────────────────────────
47
25
  # Adding a line here is a decision, not a silencer: each entry states why shipping it would be
48
26
  # wrong. If you cannot write that sentence, the file probably belongs in files[].
@@ -58,6 +36,8 @@ fi
58
36
  # a file of that name to exercise the LOW allowlist — not a pointer to this file at all.)
59
37
  # scripts/sync_guard_check.sh — anchor for that same operator-private mirror sync;
60
38
  # no shipped hook invokes it.
39
+ # scripts/sync_to_be_lanes.sh — forward-path lane suite for sync-to-be.sh, itself
40
+ # ACCEPTED_ABSENT above; added 2026-08-14, pmh-dev#69.
61
41
  ACCEPTED_ABSENT=(
62
42
  ".claude/registry/LOCAL_SKILL_REGISTRY.md"
63
43
  # An INSTALL DESTINATION the user creates (`cp templates/local_fh_context.md
@@ -79,6 +59,60 @@ ACCEPTED_ABSENT=(
79
59
  # anchor but guards on the subject's presence, so package mode SKIPs rather than falling through.
80
60
  "scripts/sync-from-be.sh"
81
61
  "scripts/sync_from_be_lanes.sh"
62
+ # Forward path's own lane suite (added 2026-08-14, pmh-dev#69). Same reason as its return-path
63
+ # sibling directly above: it exercises scripts/sync-to-be.sh, itself ACCEPTED_ABSENT — a lane
64
+ # suite for a script that never ships has nothing to verify on a consumer's machine either.
65
+ "scripts/sync_to_be_lanes.sh"
66
+ # Shared identity resolver for the two transports directly above (added to files[] by #368,
67
+ # REMOVED from it 2026-08-15). It was the odd one out: its own callers — sync-to-be.sh and
68
+ # sync-from-be.sh — are ACCEPTED_ABSENT here for a reason that applies to it verbatim (the
69
+ # namespacing it computes only means anything on a machine that HAS the operator's companion
70
+ # store), yet it alone was listed for shipping. The pre-publish confidentiality scan is what
71
+ # surfaced the misclassification: it carries the companion store's own directory vocabulary,
72
+ # which would have reached the registry for the first time in 1.4.98. The one shipped caller,
73
+ # fh_session_load.sh, sources it behind `[ -f ]` and documents its own degrade ("an unresolved
74
+ # identity degrades to the historical unsuffixed tracks-meta rather than erroring — that is the
75
+ # pre-fix behavior, not a new failure mode"), so a consumer loses nothing that consumer ever had.
76
+ "scripts/fh_hub_identity.sh"
77
+ # ── The three lane suites selfcheck.sh's DEBT-12 pair-loop names but does not ship ────────────
78
+ # Added 2026-08-13, and the way they got here is the point: this check CAUGHT them. Before that
79
+ # loop existed, these names lived in lane_runner_check.sh's DEBT array as bare basenames
80
+ # ("test_chamber_run_lanes.sh"), which the extractor's `scripts/…\.(sh|py)` pattern does not
81
+ # match. Writing the same names as full paths in a SHIPPED file (selfcheck.sh) is what turned
82
+ # them into phantoms — a shipped document pointing at a file the package omits. The source-tree
83
+ # selfcheck went red on the first full run after the wiring, exactly as an adversarial review had
84
+ # predicted from static reading alone. Recorded here rather than "fixed" by dropping the prefix,
85
+ # because the prefix is what makes lane_runner_check.sh's direct-invocation detector see the
86
+ # wiring at all; removing it would trade a loud failure for a silent blind spot.
87
+ #
88
+ # Why each is legitimately unshipped — and one of them is a NAMED RESIDUAL, not a clean answer:
89
+ # · test_frontier_digest_retry.sh — its subject, scripts/frontier_digest_daily.sh, is itself
90
+ # ACCEPTED_ABSENT (a launchd cadence runner for this operator's machine). Anchor follows
91
+ # subject; a consumer has nothing for it to measure.
92
+ # · test_residency_closure_lanes.sh — same shape: residency_closure_scan.py does not ship, so
93
+ # its calibration has no subject on a consumer machine.
94
+ # · test_chamber_run_lanes.sh — 🟥 NOT the same shape. Its subject scripts/chamber_run.sh DOES
95
+ # ship. So a consumer receives the chamber runner and no calibration for it: a shipped gate
96
+ # whose known-pair cannot execute on the machine that runs the gate. That is the defect class
97
+ # this whole campaign is closing, one layer over. It is entered here rather than shipped
98
+ # because adding an anchor to files[] is a consumer-facing change that needs its own
99
+ # tarball-mode run to prove it does not false-FAIL, and this delta is a WIRING delta. The
100
+ # honest state is "declared, and the declaration records a debt" — carried to the card.
101
+ "scripts/test_chamber_run_lanes.sh"
102
+ "scripts/test_frontier_digest_retry.sh"
103
+ "scripts/residency_closure_scan.py"
104
+ "scripts/test_residency_closure_lanes.sh"
105
+ # ── The two settings destinations, surfaced 2026-08-13 by fixing this file's own extractor ────
106
+ # These were invisible until the `js|json` alternation below was corrected: every `.json`
107
+ # reference in every shipped doc was being truncated to `.js`, a path that exists nowhere, so
108
+ # `os.path.exists()` dropped it in silence. `.claude/settings.json` alone is named by TWENTY-SIX
109
+ # shipped documents and had never once been examined by the check that exists to examine exactly
110
+ # this. Both are INSTALL DESTINATIONS the consumer owns — the whole point of the docs that name
111
+ # them is "put this in YOUR settings" — so shipping either would overwrite the reader's own
112
+ # configuration with this harness's. Same reasoning as .claude/rules/local_fh_context.md above:
113
+ # the template is what ships, the destination is what the user creates.
114
+ ".claude/settings.json"
115
+ ".claude/settings.local.json"
82
116
  # Its only input is `.claude/regression/probes.md`, itself ACCEPTED_ABSENT above (a consumer's
83
117
  # regression run must not compare against this harness's probe set). Shipping the reader without
84
118
  # its corpus would put a script in the package that can only ever report "instrument error".
@@ -99,10 +133,13 @@ ACCEPTED_ABSENT=(
99
133
  # selfcheck goes red on a subject they do not have.
100
134
  "scripts/test_probe_scope_lanes.sh"
101
135
  # Measures what the LIVE `claude` CLI does with several SessionStart hooks on one matcher —
102
- # so every run needs the CLI, auth, and spends tokens on the consumer's account. Same reason
103
- # `ablation_calibrate.sh` is absent: shipping a script whose only effect on a consumer's
104
- # machine is a bill is worse than omitting it. selfcheck reports it NOT EXERCISED (exit 2)
105
- # where the CLI is missing, so the package stays green without pretending the lanes ran.
136
+ # so every run needs the CLI, auth, and spends tokens on the consumer's account. This anchor DOES
137
+ # get a live run attempt when its subject ships and the file itself is present (selfcheck reports
138
+ # NOT EXERCISED, exit 2, when that run finds no CLI). This entry covers the OTHER case: the anchor
139
+ # FILE ITSELF is not shipped, so selfcheck never gets far enough to attempt the run at all — that
140
+ # case renders SKIP, not NOT EXERCISED (the two are for missing-file vs. present-file-no-CLI,
141
+ # not interchangeable; corrected 2026-08-12, cross-family review — the two exit paths were
142
+ # conflated in an earlier revision of this comment).
106
143
  "scripts/test_sessionstart_multihook_lanes.sh"
107
144
  # The launchd-driven daily cadence runner. Two independent reasons it must not ship: it is half of
108
145
  # a pair whose other half is a machine-local plist (`scripts/com.forge-harness.frontier-digest.plist`),
@@ -114,28 +151,142 @@ ACCEPTED_ABSENT=(
114
151
  "scripts/frontier_digest_daily.sh"
115
152
  )
116
153
 
117
- out=$(python3 - "${ACCEPTED_ABSENT[@]}" <<'PY'
118
- import re, os, json, sys
154
+ # `--list-accepted`: print the ACCEPTED_ABSENT paths, one per line, and exit — no git/package.json
155
+ # dependency, deliberately callable from a context this script's own coverage scan cannot run in
156
+ # (package mode, or a vendored tree with `.git` present but no `package.json` at this root). This is
157
+ # the single declared source of "known-legitimately-unshipped" for OTHER checks to consult instead of
158
+ # re-deriving the same judgment from an environment predicate (`.git` presence, directory existence)
159
+ # that answers a different question and can diverge from this list's actual coverage. Added
160
+ # 2026-08-12 (reship axis, cross-family review of card §🔱⑮ G/D) after selfcheck.sh's ref-path block
161
+ # was found re-deriving "is this legitimately absent" from `[ -e .git ]` — which reproduces the
162
+ # original bug in any git-tracked tree that vendors this package (a monorepo committing
163
+ # node_modules, or a consumer who runs `git init` after install): `.git` exists there, so the old
164
+ # predicate ran the check, found these exact paths missing, and FAILed — the same false-FAIL this
165
+ # array already declares correct to omit.
166
+ if [ "${1:-}" = "--list-accepted" ]; then
167
+ printf '%s\n' "${ACCEPTED_ABSENT[@]}"
168
+ exit 0
169
+ fi
170
+
171
+ # `--vs-tarball` swaps the coverage oracle from package.json files[] (a DECLARATION about the
172
+ # tarball) to `npm pack --dry-run --json` (the tarball). See the two-oracle comment in the python
173
+ # block. Kept as a flag rather than made the default for one reason: the default runs anywhere,
174
+ # offline, in ~1s, while this one shells out to npm and takes seconds — so the strict oracle belongs
175
+ # on the publish path, where the question "what does the consumer actually receive" is the one being
176
+ # asked, and the cheap one stays on every commit.
177
+ if [ "${1:-}" = "--vs-tarball" ]; then
178
+ export FH_PKG_ORACLE=tarball
179
+ shift
180
+ fi
181
+
182
+ # Source-checkout test uses `-e`, not `-d`. In a git WORKTREE `.git` is a FILE (a gitdir pointer),
183
+ # so the old `-d` test read every worktree as "installed package" and skipped the check entirely —
184
+ # silently, with exit 0. Measured 2026-07-31: a worktree created specifically to approximate CI
185
+ # reported PASS while this check had not run at all, i.e. the instrument used to justify wiring CI
186
+ # was itself fail-open on the surface it was standing in. `-e` covers both the ordinary checkout
187
+ # (dir) and the worktree (file); genuine package mode has no `.git` of either kind, so it still
188
+ # skips. Anchored by scripts/test_package_coverage_lanes.sh.
189
+ if [ ! -e .git ]; then
190
+ echo "SKIP package-coverage (not a source checkout)"
191
+ exit 0
192
+ fi
193
+ # PREDICATE SPLIT (cross-family review, 2026-07-31). These were one condition, and folding them
194
+ # together meant `.git` present + manifest missing returned SKIP + exit 0 — "we are in a checkout
195
+ # and cannot read what ships" reported as "nothing to check here". Absence of the input is not
196
+ # absence of the defect; `not found != 0` (CLAUDE.md §Instrument-Calibration). Only the no-.git
197
+ # case is a legitimate skip (an installed package, where the un-shipped files are correctly gone).
198
+ if [ ! -f package.json ]; then
199
+ echo "FAIL package-coverage: source checkout with no package.json — the shipped file list is"
200
+ echo " unreadable, so coverage is UNMEASURED, not clean"
201
+ exit 1
202
+ fi
203
+
204
+ out=$(FH_PKG_ORACLE="${FH_PKG_ORACLE:-declaration}" python3 - "${ACCEPTED_ABSENT[@]}" <<'PY'
205
+ import re, os, json, sys, subprocess
119
206
  accepted = set(sys.argv[1:])
120
207
  files = json.load(open('package.json'))['files']
121
208
 
209
+ # ── TWO ORACLES, and the difference between them is the whole reason the second one exists ────
210
+ # `declaration` (default): a path is covered if package.json files[] says so. That is a CLAIM about
211
+ # the tarball, checkable without npm, and it is what every caller before 2026-08-13 used.
212
+ # `tarball`: a path is covered if it is actually in `npm pack --dry-run --json`. That is the tarball
213
+ # ITSELF.
214
+ # They come apart, and this repo has measured them coming apart (card §🔱⑮ G/C: repo ✅ / files[] ❌
215
+ # for two paths). A files[] entry can name a file that does not pack — .npmignore precedence, a
216
+ # pattern that no longer matches, a file deleted while its declaration stayed. In every one of those
217
+ # the declaration oracle says PASS and the consumer gets a broken reference, because the consumer
218
+ # receives the tarball and not the manifest. This is the general solution the campaign card has
219
+ # carried as open under "참조 ↔ 실제 출하 파일셋": the earlier repairs (A–D) routed AROUND it by
220
+ # consulting declarations, which was correct for those cases and is not the same thing as building
221
+ # it.
222
+ # FAIL-CLOSED, and deliberately not "fall back to the declaration": npm missing, a non-zero exit, or
223
+ # JSON that does not parse means the tarball is UNKNOWN, and a lenient fallback would silently
224
+ # convert the stricter oracle back into the weaker one — while still printing the stricter one's
225
+ # name. That is the "미측정을 통과로 렌더" class this whole file is an instrument against.
226
+ ORACLE = os.environ.get('FH_PKG_ORACLE', 'declaration')
227
+ packed = None
228
+ if ORACLE == 'tarball':
229
+ try:
230
+ r = subprocess.run(['npm', 'pack', '--dry-run', '--json'],
231
+ capture_output=True, text=True, timeout=180)
232
+ if r.returncode != 0:
233
+ print(f"ORACLE_UNAVAILABLE\tnpm pack exited {r.returncode}")
234
+ raise SystemExit(2)
235
+ packed = {f['path'] for f in json.loads(r.stdout)[0]['files']}
236
+ except FileNotFoundError:
237
+ print("ORACLE_UNAVAILABLE\tnpm is not on PATH — the tarball cannot be read")
238
+ raise SystemExit(2)
239
+ except (json.JSONDecodeError, KeyError, IndexError) as e:
240
+ print(f"ORACLE_UNAVAILABLE\tnpm pack --json did not parse ({type(e).__name__})")
241
+ raise SystemExit(2)
242
+ except subprocess.TimeoutExpired:
243
+ print("ORACLE_UNAVAILABLE\tnpm pack timed out")
244
+ raise SystemExit(2)
245
+ # An empty or absurdly small packed set means the instrument broke, not that the package is
246
+ # empty — same impossible-zero rule the shipped-doc guard below applies to its own extractor.
247
+ if not packed or len(packed) < 10:
248
+ print(f"ORACLE_UNAVAILABLE\tnpm pack reported {len(packed or [])} files — implausible")
249
+ raise SystemExit(2)
250
+
122
251
  def covered(p):
252
+ if packed is not None:
253
+ return p in packed
123
254
  return any(p == f or p.startswith(f.rstrip('/') + '/') for f in files)
124
255
 
125
256
  shipped = []
126
- for f in files:
127
- if os.path.isfile(f):
128
- shipped.append(f)
129
- elif os.path.isdir(f):
130
- for root, _, names in os.walk(f):
131
- shipped.extend(os.path.join(root, n) for n in names)
257
+ if packed is not None:
258
+ # In tarball mode the set of SCANNED documents is the tarball too, not the declaration. Both
259
+ # halves have to move together: scanning a doc that does not actually ship would report a
260
+ # phantom no consumer can encounter, and that false positive is what would get the stricter
261
+ # oracle switched back off.
262
+ shipped = sorted(packed)
263
+ else:
264
+ for f in files:
265
+ if os.path.isfile(f):
266
+ shipped.append(f)
267
+ elif os.path.isdir(f):
268
+ for root, _, names in os.walk(f):
269
+ shipped.extend(os.path.join(root, n) for n in names)
132
270
 
133
271
  # Only text surfaces can carry a reference a human or agent would follow.
134
272
  shipped = [s for s in shipped if s.endswith(('.md', '.sh', '.js', '.json', '.yaml', '.yml'))]
135
273
 
136
274
  pat = re.compile(
137
275
  r'(?<![\w/.-])((?:scripts|templates|bin|docs|knowledge|plugins|\.claude)'
138
- r'/[A-Za-z0-9_./-]+\.(?:sh|py|js|md|yaml|yml|json|defaults))'
276
+ # LONGEST-FIRST, and the order is the whole fix. Python's `|` is leftmost-first, not
277
+ # longest-match, so the previous order `sh|py|js|md|yaml|yml|json|defaults` matched `.js`
278
+ # inside `.json` and truncated every JSON reference: `templates/settings.json` was extracted as
279
+ # `templates/settings.js`, a path that exists nowhere, so `os.path.exists()` said False and the
280
+ # reference was dropped in silence. This check has therefore NEVER examined a shipped doc's
281
+ # JSON references. Same trap in `yml|yaml` (harmless by luck: neither is a prefix of the other)
282
+ # and it would bite again for any future pair like `md`/`mdx`.
283
+ # Known-pair, run before the fix: `templates/settings.json` → `templates/settings.js` (broken)
284
+ # while `scripts/foo.sh` → `scripts/foo.sh` (control, intact). After: both intact.
285
+ # The `(?![A-Za-z0-9])` tail is the belt to the longest-first braces: it stops a correct
286
+ # alternative from matching a PREFIX of a longer real extension that nobody listed yet, so the
287
+ # next person who adds an extension cannot silently reintroduce this by putting it in the wrong
288
+ # place. Ordering alone is a convention; the lookahead is the mechanism.
289
+ r'/[A-Za-z0-9_./-]+\.(?:defaults|json|yaml|yml|sh|py|js|md)(?![A-Za-z0-9]))'
139
290
  )
140
291
 
141
292
  phantom = {}
@@ -192,6 +343,19 @@ PY
192
343
  )
193
344
  rc=$?
194
345
 
346
+ case "$out" in
347
+ ORACLE_UNAVAILABLE*)
348
+ # Only reachable with --vs-tarball. The stricter oracle could not be read, and the ONLY wrong
349
+ # answer here is to quietly re-run with the weaker one and print a pass — the caller asked
350
+ # "what does the consumer actually receive", and "I could not look" is not an answer to that.
351
+ echo "FAIL package-coverage (--vs-tarball): the tarball oracle is UNAVAILABLE, so coverage"
352
+ echo " against the real packed file set is UNMEASURED — not clean."
353
+ printf '%s\n' "$out" | sed 's/^ORACLE_UNAVAILABLE\t/ reason: /'
354
+ echo " Re-run without --vs-tarball to check against package.json files[] instead, but note"
355
+ echo " that is a DIFFERENT and weaker question (a declaration, not the tarball)."
356
+ exit 2 ;;
357
+ esac
358
+
195
359
  if [ "$rc" -eq 2 ] || [ "$out" = "EXTRACTOR_BROKE" ]; then
196
360
  echo "FAIL package-coverage: extractor scanned 0 shipped docs — the check broke, it did not pass"
197
361
  exit 1
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // postinstall_notice.js — one-line, stderr-only, CI-silent install notice.
4
+ //
5
+ // WHY: 16k+ npm downloads (measured live, 2026-08-15) vs 7 GitHub stars on the same day —
6
+ // people are using fh-gate and have no reason to ever see the GitHub repo, since `npx
7
+ // @chrono-meta/fh-gate` never surfaces it. This closes that gap at the one moment every
8
+ // installer passes through, without touching fh-gate.js's own stdout (that IS the CI
9
+ // contract per its own header comment — this notice must never risk polluting it).
10
+ //
11
+ // WHY stderr, not stdout: npm's own install-time output already goes to stderr by
12
+ // convention (npm's progress/warnings), and more importantly this keeps it structurally
13
+ // impossible for the notice to ever be mistaken for gate output — a script piping
14
+ // `npx @chrono-meta/fh-gate`'s stdout for FH_GATE_VERDICT: parsing never sees this line,
15
+ // even if this file were somehow invoked in the same process (it isn't — separate
16
+ // lifecycle script, separate process).
17
+ //
18
+ // WHY CI-silent: postinstall banners are a known source of npm-ecosystem noise complaints,
19
+ // and this package explicitly markets itself for CI use (CHEATSHEET.md §9.5). A line that
20
+ // reprints on every CI run install is exactly the annoyance that gets a package reported,
21
+ // not starred. Skip whenever a CI environment is plausible OR the operator opts out.
22
+ if (process.env.CI || process.env.FH_NO_BANNER || process.env.CONTINUOUS_INTEGRATION) {
23
+ process.exit(0);
24
+ }
25
+ try {
26
+ process.stderr.write(
27
+ '\n⭐ forge-harness (fh-gate) — if this is useful, a star helps others find it:\n' +
28
+ ' https://github.com/chrono-meta/forge-harness\n' +
29
+ ' (set FH_NO_BANNER=1 to silence this message)\n\n'
30
+ );
31
+ } catch (_) {
32
+ // Never fail an install over a courtesy message.
33
+ }
34
+ process.exit(0);
@@ -239,3 +239,167 @@ $PSA_STREAM
239
239
  PSA_PAT
240
240
  return $hit
241
241
  }
242
+
243
+ # _psa_can_assign <name> <value> — 0 = "this exact assignment will succeed", 1 = it will not.
244
+ #
245
+ # Rounds 4→7 walked this from a declaration parse to a proxy probe to the real thing:
246
+ # R4 parsed `readonly -p` for `NAME=` — missed `readonly FOO` (prints `declare -r FOO`, no `=`)
247
+ # and false-positived on an unrelated readonly whose VALUE contained the string.
248
+ # R5 replaced it with a behavioural probe — but the probe assigned the variable's OWN CURRENT
249
+ # VALUE, while the caller then assigns something else.
250
+ # R7 found the gap that leaves: `declare -i PSA_ALLOWLIST` makes the self-assignment succeed and
251
+ # `PSA_ALLOWLIST=/dev/null` fail. Measured — probe=writable, real=FAIL. The caller then died
252
+ # mid-run, and on the psa_scan_file path a hit that psa_scan_tagged WOULD have reported was
253
+ # lost with it.
254
+ # So the probe now takes the value: it tests the operation that is actually about to happen, in a
255
+ # subshell where failure cannot reach the caller. Every earlier version tested a stand-in for it.
256
+ _psa_can_assign() {
257
+ case "$1" in
258
+ ''|*[!A-Za-z0-9_]*|[0-9]*)
259
+ echo " ❌ _psa_can_assign: refusing non-identifier argument" >&2
260
+ return 1 ;; # cannot-assign → the caller refuses to mutate. Degrades away from mutating.
261
+ esac
262
+ ( eval "$1=\"\$2\"" ) 2>/dev/null
263
+ }
264
+
265
+ # psa_require_live — prove the scanner actually RUNS before a 0 from it is allowed to mean "clean".
266
+ # Returns 0 = alive, 1 = dead (and says so on stderr).
267
+ #
268
+ # Why this exists (measured 2026-08-12): a caller hand-built the path-tagged stream and piped it into
269
+ # psa_scan_tagged from a shell whose PATH lacked `cat`. The function died on its first line, printed
270
+ # `command not found` into the captured output, and returned 0 — so the caller's known-positive
271
+ # (a file dense with operator literals) reported **0 hits**, indistinguishable from clean. The control
272
+ # is what caught it: the run was only recognisable as dead because a file that MUST hit did not.
273
+ # This function makes that control intrinsic instead of remembered — it does not check that binaries
274
+ # exist (a presence check is the weaker instrument this repo keeps re-learning), it checks that a
275
+ # synthetic token which MUST be reported IS reported, end to end through the real matcher.
276
+ #
277
+ # Cross-family round 1 (2026-08-12) refuted three things about the first draft; all three are fixed
278
+ # here and named so the next reader does not re-introduce them:
279
+ # · it exercised only the stdin path, so a missing `awk` — the binary the file path actually needs —
280
+ # was outside what "alive" certified. The self-test now goes through the SAME tagging path
281
+ # `psa_scan_file` uses, from a real temp file.
282
+ # · the canary was allowlistable: a `psa/selftest<TAB>PSA_SELFTEST_CANARY` row would mute it and a
283
+ # healthy scanner would report itself dead. The self-test now runs with the allowlist disabled.
284
+ # · it was not `errexit`-safe: under `set -e` the nonzero return from the hit-reporting path exited
285
+ # the caller's shell before the restore line. Status capture is now inside an `if`.
286
+ psa_require_live() {
287
+ local saved_stream saved_allow saved_stream_set saved_allow_set tmpd out rc _canary
288
+ saved_stream="${PSA_STREAM-}"; saved_allow="${PSA_ALLOWLIST-}"
289
+ saved_stream_set=""; saved_allow_set=""
290
+ [ "${PSA_STREAM+x}" = "x" ] && saved_stream_set=1
291
+ [ "${PSA_ALLOWLIST+x}" = "x" ] && saved_allow_set=1
292
+ tmpd=$(mktemp -d 2>/dev/null) || { echo " ❌ INSTRUMENT DEAD — mktemp unavailable." >&2; return 1; }
293
+ # Round 4 refuted the round-3 fix: `|| :` does NOT rescue an assignment to a readonly variable —
294
+ # bash aborts the shell before the `||` is ever considered. So the only safe move is to REFUSE
295
+ # before mutating anything. Detected, reported as DEAD, and the caller survives.
296
+ _canary=$(printf 'HIGH\tPSA_SELFTEST_CANARY')
297
+ if ! _psa_can_assign PSA_STREAM "$_canary" || ! _psa_can_assign PSA_ALLOWLIST /dev/null; then
298
+ echo " ❌ INSTRUMENT DEAD — PSA_STREAM/PSA_ALLOWLIST cannot take the values the self-test needs." >&2
299
+ echo " (readonly, or an attribute such as \`declare -i\` that rejects the value)" >&2
300
+ echo " Not attempting it: a failed assignment aborts an errexit caller outright." >&2
301
+ rm -rf "$tmpd" 2>/dev/null || :
302
+ return 1
303
+ fi
304
+ PSA_STREAM="$_canary"
305
+ PSA_ALLOWLIST=/dev/null
306
+ printf 'PSA_SELFTEST_CANARY\n' > "$tmpd/canary" 2>/dev/null
307
+ # Same pipeline shape as psa_scan_file: awk tags the file, psa_scan_tagged matches it.
308
+ if out=$(awk -v P="psa/selftest" '{print P "\t" $0}' "$tmpd/canary" 2>&1 | psa_scan_tagged 2>&1); then
309
+ rc=0
310
+ else
311
+ rc=$?
312
+ fi
313
+ rm -rf "$tmpd" 2>/dev/null || :
314
+ # Restore EXACTLY, including the difference between unset and set-empty (round 2): the first draft
315
+ # always re-set PSA_STREAM, turning an unset variable into an empty one — which the very guard in
316
+ # psa_scan_file below keys on. A restore that changes state is not a restore.
317
+ if [ -n "${saved_stream_set:-}" ]; then PSA_STREAM="$saved_stream"; else unset PSA_STREAM; fi
318
+ if [ -n "${saved_allow_set:-}" ]; then PSA_ALLOWLIST="$saved_allow"; else unset PSA_ALLOWLIST; fi
319
+ case "$out" in
320
+ *PSA_SELFTEST_CANARY*) [ "$rc" -eq 1 ] && return 0 ;;
321
+ esac
322
+ echo " ❌ INSTRUMENT DEAD — the scanner did not report a synthetic known-positive." >&2
323
+ echo " self-test rc=$rc out=[$out]" >&2
324
+ echo " A 0 from this scanner is UNMEASURED, not clean — do not report or publish a count from it." >&2
325
+ return 1
326
+ }
327
+
328
+ # psa_scan_file <path> — scan ONE file through the shared matcher.
329
+ #
330
+ # Three-valued on purpose: 0 = scanned, nothing reportable · 1 = scanned, hit(s) reported ·
331
+ # 3 = NOT SCANNED (instrument dead, bad usage, or missing file). Folding 3 into 0 is the exact defect
332
+ # this repo keeps meeting — `not found` is not `0`, and a file that does not exist is not an empty file.
333
+ # Callers that only branch on `if psa_scan_file f; then clean; fi` would read 3 as dirty (safe) and
334
+ # never as clean; callers that check `-eq 0` must treat 3 as unmeasured.
335
+ #
336
+ # ⚠️ The caller still owns the DISPLAY. A hit line (❌) and an allowlisted line (⚪) are different
337
+ # outcomes, and a display filter that greps only ❌ renders "allowlisted" as "no match" — measured
338
+ # on 2026-08-12, which is how an existing operator allowlist decision was misread as an absent
339
+ # pattern. If you filter this function's output, keep both markers or state which you dropped.
340
+ psa_scan_file() {
341
+ local out rc
342
+ if [ "$#" -ne 1 ]; then
343
+ echo " ⚠️ USAGE — psa_scan_file <path> (got $# argument(s)); NOT SCANNED" >&2
344
+ return 3
345
+ fi
346
+ # Patterns-not-loaded is the highest-value guard here, and it was missing from the first draft.
347
+ # Cross-family round 1 found it by simply following THIS FILE'S OWN advertised usage line, which
348
+ # said `. psa_scan_lib.sh && psa_scan_file <path>` and omitted psa_load — reproduced: a file dense
349
+ # with a known-positive token returned rc=0 with empty output. An empty pattern stream scans
350
+ # everything and reports nothing, which is the false-clean this whole change exists to remove.
351
+ if [ -z "${PSA_STREAM:-}" ]; then
352
+ echo " ⚠️ PATTERNS NOT LOADED — call psa_load <defaults> <override> first; NOT SCANNED" >&2
353
+ echo " (an empty pattern stream reports nothing, which is indistinguishable from clean)" >&2
354
+ return 3
355
+ fi
356
+ # A NON-EMPTY stream is not a COMPLETE one (round 2). If the committed defaults failed to load, or
357
+ # rows were dropped as unusable, the stream still matches *something* and a clean verdict from it
358
+ # is a verdict about a partial instrument.
359
+ #
360
+ # `case`, not `[ -ne ]` (round 3): a non-numeric value made `[` return 2, the `if` read that as
361
+ # false, and the guard fell through — reproduced with PSA_DEFAULTS_OK=x, which scanned a file to
362
+ # rc=0 CLEAN using a stream that did not contain the token. A guard that a garbage value walks
363
+ # straight past is not a guard, and the shell's own error line scrolled by unnoticed.
364
+ local _incomplete=""
365
+ case "${PSA_DEFAULTS_OK:-}" in 1) ;; *) _incomplete="defaults_ok=${PSA_DEFAULTS_OK:-unset}" ;; esac
366
+ case "${PSA_BAD_ROWS:-0}" in 0) ;; *) _incomplete="${_incomplete:+$_incomplete }bad_rows=${PSA_BAD_ROWS}" ;; esac
367
+ # The operator override carries the HIGH company/companion literals. Its absence is survivable for a
368
+ # single-file query (the committed defaults still apply) but the caller must never read the result as
369
+ # a full clean — so it is announced, not silently folded in.
370
+ # Round 4: this used to WARN and still return 0. That is the exact false-clean this change exists to
371
+ # remove — the override carries the HIGH company/companion literals, so without it the scan did not
372
+ # look at the highest-severity class at all, and "clean" is a claim the run cannot support. It now
373
+ # joins _incomplete. (No existing caller breaks: psa_scan_file is new in this change.)
374
+ case "${PSA_OVERRIDE_PRESENT:-}" in
375
+ 1) ;;
376
+ *) _incomplete="${_incomplete:+$_incomplete }override_absent(HIGH operator literals unscanned)" ;;
377
+ esac
378
+ psa_require_live || return 3
379
+ if [ ! -f "$1" ]; then
380
+ echo " ⚠️ MISSING — $1 : NOT SCANNED (unmeasured, not 0 hits)" >&2
381
+ return 3
382
+ fi
383
+ # `awk | psa_scan_tagged` hides an awk read failure when the caller has no pipefail: awk fails,
384
+ # psa_scan_tagged sees an empty stream and returns 0 = clean. Materialise the tagged stream first
385
+ # so the read is a checkable step of its own (cross-family round 1).
386
+ local tmpf
387
+ tmpf=$(mktemp 2>/dev/null) || { echo " ⚠️ mktemp failed; NOT SCANNED" >&2; return 3; }
388
+ if ! awk -v P="$1" '{print P "\t" $0}' "$1" > "$tmpf" 2>/dev/null; then
389
+ rm -f "$tmpf" || :
390
+ echo " ⚠️ READ FAILED — $1 could not be tagged for scanning; NOT SCANNED" >&2
391
+ return 3
392
+ fi
393
+ if out=$(psa_scan_tagged < "$tmpf"); then rc=0; else rc=$?; fi
394
+ rm -f "$tmpf" || :
395
+ # Print findings FIRST, verdict second — round 3 caught the previous order suppressing real hits:
396
+ # an incomplete instrument returned 3 and emitted nothing, so a token the loaded patterns DID match
397
+ # was lost. "I could not certify this" and "I saw nothing" are different, and the fix for the second
398
+ # must not create the first. Partial evidence is reported; the verdict still refuses to say clean.
399
+ [ -n "$out" ] && printf '%s\n' "$out"
400
+ if [ -n "$_incomplete" ]; then
401
+ echo " ⚠️ INCOMPLETE PATTERN INSTRUMENT — $_incomplete : verdict is NOT SCANNED (any hits above are partial)" >&2
402
+ return 3
403
+ fi
404
+ return "$rc"
405
+ }
@@ -24,6 +24,36 @@
24
24
 
25
25
  set -uo pipefail
26
26
 
27
+ # ── Misuse fails CLOSED, before the banner ──────────────────────────────────────────────────────
28
+ # This script scans the npm-published file set (derived from `npm pack --dry-run`). It takes NO
29
+ # positional arguments, and until 2026-08-12 it did not parse any either — it silently ignored them
30
+ # and printed its normal green. That turned a misuse into a false certification: a caller who ran
31
+ # bash scripts/public_surface_scan_files.sh <some/path>
32
+ # to ask "is THIS file clean?" got `✅ PASS`, about a file set that never contained <some/path>.
33
+ # Measured known-pair: no args → rc=0 PASS; `/nonexistent/definitely_not_a_file_zzz.md` → byte-identical
34
+ # rc=0 PASS; `PSA_PATTERNS=/nonexistent/nope` → rc=1 (so the green was a live green, not a dead one).
35
+ # The first person to hit it was not the author but the first real user, who nearly recorded
36
+ # "paper/forge_harness_v1.0.html is clean" from a scan that never opened that file.
37
+ # On an irreversible surface (publish) an instrument that cannot answer the question it was asked
38
+ # must refuse, not answer a different question in the affirmative.
39
+ # rc=2 is deliberately distinct from rc=1 (a real leak/incomplete instrument) and rc=0 (clean):
40
+ # "you used it wrong" and "your content is dirty" are different states and must not be collapsed.
41
+ if [ "$#" -gt 0 ]; then
42
+ echo "[Pre-Publish] public-surface scan — ✋ USAGE ERROR: this script takes no arguments (got $#: $*)" >&2
43
+ echo " It scans the npm-published file set only (npm pack --dry-run), never a path you pass in." >&2
44
+ # The psa_load call is NOT optional and is spelled out here on purpose: the first draft of this
45
+ # message omitted it, and cross-family review found that following it verbatim scans a
46
+ # known-positive file to rc=0/clean (empty pattern stream). An instruction that produces a false
47
+ # clean is the same defect as the guard above, one layer out.
48
+ echo " To scan ONE file:" >&2
49
+ echo " . scripts/psa_scan_lib.sh \\" >&2
50
+ echo " && psa_load .claude/rules/.public-surface-patterns.defaults .claude/rules/.public-surface-patterns \\" >&2
51
+ echo " && psa_scan_file <path> # rc: 0 clean · 1 hit(s) · 3 NOT SCANNED" >&2
52
+ echo " To scan tracked files: use the /public-surface-audit skill." >&2
53
+ echo " Refusing rather than reporting a PASS about a different file set (fail-closed)." >&2
54
+ exit 2
55
+ fi
56
+
27
57
  REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
28
58
  PSA_DEFAULTS="$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults"
29
59
  PSA_OVERRIDE="${PSA_PATTERNS:-$REPO_ROOT/.claude/rules/.public-surface-patterns}"