@chrono-meta/fh-gate 1.4.71 → 1.4.73

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 (38) hide show
  1. package/.claude/rules/.public-surface-patterns.defaults +44 -0
  2. package/.claude/rules/fh_4axis_gate.md +207 -0
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/AGENTS.md +26 -2
  5. package/CATALOG.md +59 -0
  6. package/README.ja.md +1 -1
  7. package/README.ko.md +1 -1
  8. package/README.md +1 -1
  9. package/README.zh.md +1 -1
  10. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
  12. package/package.json +21 -1
  13. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  14. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  15. package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
  16. package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
  17. package/plugins/fh-meta/skills/salience-splitter/SKILL.md +1 -1
  18. package/scripts/chamber_candidate_collect.sh +223 -0
  19. package/scripts/degrade_direction_scan.sh +222 -0
  20. package/scripts/fh-gate.sh +76 -2
  21. package/scripts/fh_session_load.sh +202 -0
  22. package/scripts/gate_pathspec_check.sh +166 -0
  23. package/scripts/prepush_guard_check.sh +374 -0
  24. package/scripts/psa_scan_lib.sh +153 -0
  25. package/scripts/public_surface_scan_files.sh +157 -0
  26. package/scripts/selfcheck.sh +16 -0
  27. package/scripts/session_close_check.sh +171 -0
  28. package/scripts/test_degrade_scan_shell_probes.sh +185 -0
  29. package/scripts/test_fh_gate_regressions.sh +46 -2
  30. package/scripts/test_prepush_stdin_integrity.sh +119 -0
  31. package/scripts/universal_guard_check.sh +280 -0
  32. package/templates/.claude/rules/mcp_tool_gating.md +157 -0
  33. package/templates/.git-hooks/pre-commit +848 -0
  34. package/templates/.git-hooks/pre-push +585 -0
  35. package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
  36. package/templates/degrade_direction_scan.sh +222 -0
  37. package/templates/predelete_check.sh +72 -0
  38. package/templates/regression_guard.sh +563 -0
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bash
2
+ # psa_scan_lib.sh — the ONE implementation of public-surface pattern loading and matching.
3
+ #
4
+ # WHY THIS FILE EXISTS
5
+ # Three copies of this logic existed (pre-commit, pre-push, public_surface_scan_files.sh). Across a
6
+ # single 2026-07-26 cross-family audit, EVERY confidentiality defect found was a divergence between
7
+ # them, not a flaw in the idea:
8
+ # • the publish copy checked that the override was readable and non-empty; the commit copy did not
9
+ # • the commit copy failed closed when no patterns loaded; the publish copy did too; the push copy
10
+ # had to be told separately
11
+ # • a row with a SPACE instead of a TAB failed closed in two copies and was silently skipped in the third
12
+ # • `head -1` per line (a placeholder shielding a real token) was fixed in one copy and not the others
13
+ # • the LOW-severity file allowlist existed in one copy only — which is how the push gate blocked
14
+ # its own first real push
15
+ # Each was repaired where it was found. The prescription this repo already had recorded for that
16
+ # pattern — [[feedback_divergent_leniency_duplicate_normalizers]]: single source, visible drops,
17
+ # fail-closed — was quoted during that session and then not applied; the duplication was repaired
18
+ # three times instead of removed. This file removes it.
19
+ #
20
+ # WHAT IS SHARED vs WHAT STAYS WITH THE CALLER — the split is deliberate, do not "unify" further:
21
+ # SHARED (here): loading the two pattern layers, validating rows, and deciding whether a given
22
+ # token on a given path is a reportable hit.
23
+ # CALLER'S: WHICH content is scanned (staged diff / pushed range / packed file set), and the
24
+ # DEGRADE DIRECTION when the instrument is incomplete. Those genuinely differ:
25
+ # a commit is local and re-committable, so an absent operator override warns; a push
26
+ # and a publish are the acts that make content public, so the same state blocks.
27
+ # Collapsing that would either block every fresh clone's first commit (training the
28
+ # override into reflex) or let a real leak reach a public remote. This library
29
+ # therefore REPORTS state and never exits.
30
+ #
31
+ # Usage:
32
+ # . "$REPO_ROOT/scripts/psa_scan_lib.sh"
33
+ # psa_load "$defaults_path" "$override_path" # sets PSA_STREAM + the three state flags below
34
+ # psa_scan_tagged <<< "$path<TAB>line"… # prints hits, returns 1 if any hit was reported
35
+ #
36
+ # State set by psa_load (read-only for callers):
37
+ # PSA_STREAM validated rows only (invalid/malformed rows are dropped AND counted)
38
+ # PSA_DEFAULTS_OK 1 = committed defaults present, readable, non-empty
39
+ # PSA_OVERRIDE_PRESENT 1 = operator override present, readable, non-empty
40
+ # PSA_BAD_ROWS count of rows dropped as unusable (uncompilable regex, or no TAB)
41
+ # A caller that treats PSA_BAD_ROWS>0 or PSA_DEFAULTS_OK=0 as "clean" has a hole: those states mean
42
+ # the instrument is incomplete, and an incomplete instrument cannot certify anything.
43
+
44
+ # Placeholder shapes that must never count as a leak. Anchored whole-token: a substring match here
45
+ # would let a real token pass by merely CONTAINING a placeholder word.
46
+ PSA_PLACEHOLDER='^(<[a-z0-9_-]+>|\{[a-z_]+\}|EXAMPLE|dummy|changeme|REDACTED|xxxx|/Users/(EXAMPLE|yourname|\{[a-z_]+\}|<[a-z0-9_-]+>)/|AKIAIOSFODNN7EXAMPLE)$'
47
+
48
+ # Files that name wiring/companion tokens as part of doing their job. LOW severity only — HIGH/MED
49
+ # block everywhere, including here. Kept as a function so the list has exactly one definition.
50
+ psa_low_allowlisted() {
51
+ case "$1" in
52
+ .gitignore|scripts/sync-to-be.sh|.claude/rules/local_fh_context.md|templates/local_fh_context.md|templates/.claude/rules/*|templates/.git-hooks/*) return 0 ;;
53
+ *) return 1 ;;
54
+ esac
55
+ }
56
+
57
+ # psa_load <defaults_path> <override_path>
58
+ # Prints diagnostics for every degraded state; sets the flags above. NEVER exits — the caller owns
59
+ # the degrade direction, because it differs by surface (see the header).
60
+ psa_load() {
61
+ local defaults="$1" override="$2" raw ov
62
+ PSA_STREAM=""; PSA_DEFAULTS_OK=0; PSA_OVERRIDE_PRESENT=0; PSA_BAD_ROWS=0
63
+
64
+ # Layer 1 — committed defaults. This file ships with the repo, so missing/unreadable/empty is a
65
+ # BROKEN INSTRUMENT, not a configuration choice. A valid override used to mask its absence, and the
66
+ # scan then reported a clean "full pattern set" with every universal pattern silently gone.
67
+ if [ -f "$defaults" ] && [ -r "$defaults" ]; then
68
+ raw=$(cat "$defaults" 2>/dev/null || true)
69
+ if [ -n "$(printf '%s' "$raw" | grep -vE '^[[:space:]]*(#|$)' || true)" ]; then
70
+ PSA_STREAM="$raw"; PSA_DEFAULTS_OK=1
71
+ fi
72
+ fi
73
+ [ "$PSA_DEFAULTS_OK" -eq 0 ] && \
74
+ echo " ❌ committed pattern defaults missing/unreadable/empty — universal patterns NOT loaded ($defaults)"
75
+
76
+ # Layer 2 — the gitignored operator literals. Present ONLY when a readable, non-empty read actually
77
+ # contributed patterns: an empty file must not masquerade as a configured gate.
78
+ if [ -f "$override" ]; then
79
+ if [ -r "$override" ]; then
80
+ ov=$(cat "$override" 2>/dev/null || true)
81
+ if [ -n "$(printf '%s' "$ov" | grep -vE '^[[:space:]]*(#|$)' || true)" ]; then
82
+ PSA_STREAM="$PSA_STREAM
83
+ $ov"; PSA_OVERRIDE_PRESENT=1
84
+ else
85
+ echo " ⚠️ operator pattern override is EMPTY — treated as absent."
86
+ fi
87
+ else
88
+ echo " ⚠️ operator pattern override exists but is UNREADABLE — treated as absent."
89
+ fi
90
+ fi
91
+
92
+ # Row validation. Two ways a row can be present and still protect nothing, both previously silent:
93
+ # • no TAB → the whole line lands in the severity field and no detector is defined
94
+ # • bad ERE → grep exits >=2 and the error is swallowed downstream as a no-match
95
+ # Both are DROPPED and COUNTED here so the caller can fail closed on an incomplete instrument.
96
+ local valid="" line re rc
97
+ while IFS= read -r line; do
98
+ line="${line%$'\r'}" # CRLF: a trailing CR welds onto the regex and it matches nothing
99
+ case "$line" in ''|\#*) continue;; esac
100
+ re="${line#*$'\t'}"
101
+ if [ "$re" = "$line" ]; then
102
+ echo " ❌ unusable pattern row (no TAB between severity and regex): ${line%%$'\t'*}"
103
+ PSA_BAD_ROWS=$((PSA_BAD_ROWS+1)); continue
104
+ fi
105
+ printf '' | grep -E "$re" >/dev/null 2>&1; rc=$? # 0/1 = compiled; >=2 = regex error
106
+ if [ "$rc" -ge 2 ]; then
107
+ echo " ❌ unusable pattern (invalid regex) — this detector would match nothing: [${line%%$'\t'*}]"
108
+ PSA_BAD_ROWS=$((PSA_BAD_ROWS+1)); continue
109
+ fi
110
+ valid="$valid
111
+ $line"
112
+ done <<PSA_ROWS
113
+ $PSA_STREAM
114
+ PSA_ROWS
115
+ PSA_STREAM="$valid"
116
+ }
117
+
118
+ # psa_scan_tagged — reads "path<TAB>content" lines on stdin, prints one line per reportable hit.
119
+ # Returns 0 = nothing reportable, 1 = at least one hit.
120
+ #
121
+ # Why path-tagged input: the LOW allowlist is per FILE, so a scanner that flattens content loses the
122
+ # only information needed to apply it. The push copy did exactly that and blocked its own first real
123
+ # push on a companion-store name inside the script whose job is to sync to the companion store.
124
+ psa_scan_tagged() {
125
+ local input hit=0 row sev re path body tok
126
+ input=$(cat)
127
+ [ -n "$input" ] || return 0
128
+ while IFS= read -r row; do
129
+ case "$row" in ''|\#*) continue;; esac
130
+ re="${row#*$'\t'}"; [ "$re" = "$row" ] && continue
131
+ sev="${row%%$'\t'*}"
132
+ while IFS= read -r line; do
133
+ [ -z "$line" ] && continue
134
+ path="${line%%$'\t'*}"; body="${line#*$'\t'}"
135
+ # EVERY match on the line. Taking only the first let a documented placeholder earlier on the
136
+ # line shield a real token later on it.
137
+ while IFS= read -r tok; do
138
+ [ -z "$tok" ] && continue
139
+ printf '%s' "$tok" | grep -qiE "$PSA_PLACEHOLDER" && continue
140
+ if [ "$sev" = "LOW" ] && psa_low_allowlisted "$path"; then continue; fi
141
+ echo " ❌ $sev leak — ${path}: '$tok'"
142
+ hit=1
143
+ done <<PSA_TOK
144
+ $(printf '%s' "$body" | grep -oiE "$re" 2>/dev/null || true)
145
+ PSA_TOK
146
+ done <<PSA_LINES
147
+ $(printf '%s\n' "$input" | grep -iE "$re" 2>/dev/null || true)
148
+ PSA_LINES
149
+ done <<PSA_PAT
150
+ $PSA_STREAM
151
+ PSA_PAT
152
+ return $hit
153
+ }
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bash
2
+ # public_surface_scan_files.sh — Pre-Publish confidentiality floor for `npm publish`.
3
+ #
4
+ # Mechanizes the npm-publish half of the Pre-Publish Surface Gate (CLAUDE.md). The pre-commit
5
+ # confidentiality scan sees only the ADDED LINES of this repo's commits; a token committed before
6
+ # that scan existed, or carried in a files[] entry, would otherwise reach the registry unscanned.
7
+ # This scans the FULL CONTENT of the exact npm-published file set (npm pack --dry-run) against the
8
+ # same operator-private patterns, and blocks `npm publish` on a HIGH/MED hit.
9
+ #
10
+ # Honest scope: covers `npm publish` only (wired via package.json prepublishOnly). The separate-repo
11
+ # go-public surface (gh repo create --public / visibility flip / first push to a new public remote) is
12
+ # not an npm or git op against this repo, so no hook here sees it — it stays prose + PRE-PUBLISH-CHECKLIST.md
13
+ # (genuinely un-hookable). Same shape as the Destructive-Op story: git/npm surface mechanized, the
14
+ # separate-repo go-public surface stays prose.
15
+ #
16
+ # Degrade direction: irreversible surface (publish) → fail-CLOSED. Patterns absent, or the published
17
+ # file set unresolved → BLOCK (proceed only on an explicit, logged PUBLIC_SURFACE_OK=1). Never silent-allow.
18
+ #
19
+ # Override (explicit, logged — mirrors the pre-commit PUBLIC_SURFACE_OK channel):
20
+ # PUBLIC_SURFACE_OK=1 npm publish … ← after conscious review of the flagged hit(s).
21
+ #
22
+ # Patterns are single-source (shared with the pre-commit scan): .claude/rules/.public-surface-patterns.defaults
23
+ # (committed, universal) + .claude/rules/.public-surface-patterns (gitignored, operator literals).
24
+
25
+ set -uo pipefail
26
+
27
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
28
+ PSA_DEFAULTS="$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults"
29
+ PSA_OVERRIDE="${PSA_PATTERNS:-$REPO_ROOT/.claude/rules/.public-surface-patterns}"
30
+ # PSA_PLACEHOLDER and the default psa_low_allowlisted now come from scripts/psa_scan_lib.sh.
31
+ # A second copy here is exactly the divergence this refactor removed; do not reintroduce one.
32
+
33
+ # LOW-severity allowlist by file (HIGH/MED still block). For the PUBLISH scan this is nearly vacuous:
34
+ # a published file should carry NO operator-private token at all. Deliberately names no operator-private
35
+ # file literal here (the pre-commit copy of this list does, but it lives in a git-hooks-allowlisted path;
36
+ # THIS script is public-tracked, so hardcoding e.g. a companion-script name would itself be a LOW leak —
37
+ # caught by the pre-commit confidentiality scan, 2026-06-27). Generic template paths only.
38
+
39
+ echo "[Pre-Publish] public-surface scan (npm-published file content)..."
40
+
41
+ # ── Load patterns via the shared library (scripts/psa_scan_lib.sh) ──
42
+ # One implementation of loading + row validation + exemption decisions, shared with pre-commit and
43
+ # pre-push. What this file KEEPS for itself, deliberately:
44
+ # • the file-level `grep -a` scan. It forces every published file to be read as TEXT; the library's
45
+ # line-stream interface would drop back to text-only handling and re-open the hole a challenger
46
+ # found earlier — a token inside an SVG / null-byte file (docs/pillars.svg ships) scanning clean.
47
+ # • a STRICTER LOW allowlist (below). A published artifact should carry no operator-private token
48
+ # at all, so the commit-time list of files that legitimately name wiring tokens does not apply.
49
+ # Sourcing the library and then redefining the function is how that difference stays visible
50
+ # instead of being buried as a divergence.
51
+ PSA_LIB="$REPO_ROOT/scripts/psa_scan_lib.sh"
52
+ if [ ! -r "$PSA_LIB" ]; then
53
+ echo " ❌ scripts/psa_scan_lib.sh missing — the confidentiality scanner cannot run."
54
+ [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] || { echo " Fail-closed on the publish boundary."; exit 1; }
55
+ else
56
+ . "$PSA_LIB"
57
+ fi
58
+
59
+ # Stricter than the library default — see the note above. Named here so the difference is legible.
60
+ psa_low_allowlisted() {
61
+ case "$1" in
62
+ templates/*) return 0 ;;
63
+ *) return 1 ;;
64
+ esac
65
+ }
66
+
67
+ psa_load "$PSA_DEFAULTS" "$PSA_OVERRIDE"
68
+
69
+ # Publish is an irreversible surface: EVERY incomplete-instrument state blocks, including the merely
70
+ # absent operator override (which only warns at commit time — see the library header for why the two
71
+ # surfaces degrade differently).
72
+ _psa_why=""
73
+ [ "$PSA_DEFAULTS_OK" -eq 0 ] && _psa_why="committed pattern defaults missing/unreadable/empty"
74
+ [ "$PSA_BAD_ROWS" -gt 0 ] && _psa_why="${_psa_why:+$_psa_why; }$PSA_BAD_ROWS unusable pattern row(s)"
75
+ [ "$PSA_OVERRIDE_PRESENT" -eq 0 ] && _psa_why="${_psa_why:+$_psa_why; }operator-literal override absent/empty (HIGH company/companion literals NOT scanned)"
76
+ if [ -n "$_psa_why" ]; then
77
+ echo " ❌ incomplete confidentiality instrument — $_psa_why"
78
+ if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
79
+ echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1 (conscious — the scan is incomplete)"
80
+ echo "$(date +%Y-%m-%dT%H:%M:%S) PUBLIC_SURFACE_OK override (npm publish, incomplete instrument)" \
81
+ >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
82
+ else
83
+ echo " Fail-closed on an irreversible surface: an incomplete instrument cannot certify clean."
84
+ exit 1
85
+ fi
86
+ fi
87
+
88
+
89
+ # Named residual (cross-family audit 2026-06-27, deferred): this scans the WORKING-TREE content of the
90
+ # packed file list, not the final tarball bytes. A content-generating publish lifecycle (prepack/prepare
91
+ # that writes files AFTER this prepublishOnly scan) could ship bytes this never saw, and a path containing
92
+ # a newline would mis-split the list. The robust fix is to scan the actual `npm pack` tarball; deferred
93
+ # because THIS package's lifecycle is content-neutral (prepare = chmod only, no prepack). Re-open if a
94
+ # content-generating lifecycle is ever added.
95
+ # ── Resolve the exact npm-published file set (fail-closed if unresolved OR partial) ──
96
+ FILES=$(npm pack --dry-run --json 2>/dev/null \
97
+ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{JSON.parse(s)[0].files.forEach(f=>console.log(f.path))}catch(e){process.exit(3)}})' 2>/dev/null || true)
98
+ if [ -z "$FILES" ]; then
99
+ echo " ❌ could not resolve the npm-published file set (npm pack --dry-run failed)."
100
+ [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] && { echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1"; exit 0; }
101
+ echo " Fail-closed on an irreversible surface. Fix npm pack or override with PUBLIC_SURFACE_OK=1."
102
+ exit 1
103
+ fi
104
+
105
+ # Wrong-set guard (challenger M6): a future npm --json shape change could yield a NON-empty but PARTIAL
106
+ # file list (forEach iterates a renamed/nested structure without throwing) → files silently unscanned.
107
+ # npm always ships package.json in the tarball; its absence means the parse got a wrong set → fail-closed.
108
+ if ! printf '%s\n' "$FILES" | grep -qx "package.json"; then
109
+ echo " ❌ published file set looks wrong — 'package.json' (always shipped) is absent from the parse."
110
+ [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] && { echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1"; exit 0; }
111
+ echo " Fail-closed (possible npm --json shape change). Verify npm pack output or PUBLIC_SURFACE_OK=1."
112
+ exit 1
113
+ fi
114
+
115
+ # ── Scan full content of each published file ──
116
+ LEAK=0
117
+ while IFS= read -r f; do
118
+ [ -z "$f" ] && continue
119
+ path="$REPO_ROOT/$f"
120
+ [ -f "$path" ] || continue
121
+ while IFS=$'\t' read -r sev regex; do
122
+ [ -z "$regex" ] && continue
123
+ case "$sev" in \#*) continue;; esac
124
+ while IFS= read -r tok; do
125
+ [ -z "$tok" ] && continue
126
+ printf '%s' "$tok" | grep -qiE "$PSA_PLACEHOLDER" && continue
127
+ if [ "$sev" = "LOW" ] && psa_low_allowlisted "$f"; then continue; fi
128
+ echo " ❌ $sev leak — $f: '$tok' would ship to the registry"
129
+ LEAK=1
130
+ # -a forces every published file to scan as TEXT (challenger S1): -I skipped binary-classified
131
+ # files, so a token in an SVG / null-byte file (docs/pillars.svg ships) would slip unscanned.
132
+ done <<< "$(grep -aoiE "$regex" "$path" 2>/dev/null | sort -u || true)"
133
+ done <<< "$PSA_STREAM"
134
+ done <<< "$FILES"
135
+
136
+ if [ "$LEAK" -eq 1 ]; then
137
+ if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
138
+ echo " ⚠️ public-surface hit(s) allowed by PUBLIC_SURFACE_OK=1 (conscious, reviewed intent)"
139
+ echo "$(date +%Y-%m-%dT%H:%M:%S) PUBLIC_SURFACE_OK override (npm publish) — suppressed hit(s) above" \
140
+ >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
141
+ exit 0
142
+ fi
143
+ echo " An operator-private token would ship in the npm package. Generalize it (real home path → '~'"
144
+ echo " or '{project}'; companion/corp name → a generic phrase), or PUBLIC_SURFACE_OK=1 npm publish …"
145
+ exit 1
146
+ fi
147
+
148
+ # Renamed with the refactor: the flag is PSA_OVERRIDE_PRESENT, set by psa_load. The bare name was a
149
+ # leftover that referenced nothing under `set -u` and aborted the scan at its final line.
150
+ if [ "$PSA_OVERRIDE_PRESENT" -eq 0 ]; then
151
+ echo " ⚠️ PASS (DEFAULTS-ONLY) — no home-path leak, but HIGH operator literals were NOT scanned (override absent)."
152
+ else
153
+ echo " ✅ PASS — no operator-private token in the published file set (full pattern set)."
154
+ fi
155
+ # Residual (named, not closed): the scan is a DENYLIST against loaded patterns — an un-patterned secret
156
+ # shape (an API key the patterns don't describe) still ships. This gate is not a general secret-detector.
157
+ exit 0
@@ -83,6 +83,22 @@ else
83
83
  fail=1
84
84
  fi
85
85
 
86
+ # degrade-scan shell probes — the anchor was written 2026-07-28 and shipped with ZERO callers,
87
+ # reproducing [[feedback_built_but_not_wired]] in the same session that cited it. The subject
88
+ # (scripts/degrade_direction_scan.sh) and the anchor both ship, so this runs in package mode too;
89
+ # only a missing SUBJECT is a legitimate skip.
90
+ if [ ! -f scripts/degrade_direction_scan.sh ]; then
91
+ echo "SKIP degrade-scan shell probes (subject scripts/degrade_direction_scan.sh absent)"
92
+ elif [ -f scripts/test_degrade_scan_shell_probes.sh ]; then
93
+ if ! bash scripts/test_degrade_scan_shell_probes.sh; then
94
+ fail=1
95
+ fi
96
+ else
97
+ # subject present, anchor gone => the calibration was deleted. Real failure, not a skip.
98
+ echo "FAIL degrade-scan shell probes: scan present but scripts/test_degrade_scan_shell_probes.sh missing"
99
+ fail=1
100
+ fi
101
+
86
102
  # Referenced-path existence is a source-tree check. The npm package intentionally
87
103
  # ships a narrower runtime surface, so package-mode selfcheck skips this section.
88
104
  if [ -d ".claude/rules" ]; then
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bash
2
+ # session_close_check.sh — mechanical checklist for the session close chain (CLAUDE.md §Session Wrap-up ①–⑥).
3
+ #
4
+ # WHY (loop_engineering.md census, 2026-07-10): the close chain's complete/persist legs were PROSE —
5
+ # card-last ordering and step coverage lived on salience alone, and the measured misses (card
6
+ # staleness class) all landed on exactly these legs. This script is the MECH floor: it VERIFIES
7
+ # state, it does not perform the steps (the session still runs them; the script catches skips).
8
+ # Built on operator instruction 2026-07-10 (strengthen-the-weak pass; evidence-threshold override
9
+ # recorded — the miss class was already measured, only the build trigger was overridden).
10
+ #
11
+ # READ-ONLY. Exit 0 = close state consistent · exit 1 = a close invariant is violated (card-last
12
+ # broken, or a required artifact missing). Advisory lines are prefixed ⚠️ , violations ❌ .
13
+ #
14
+ # Usage: bash scripts/session_close_check.sh [repo_root] (run at close time, before ⑥ push)
15
+
16
+ set -uo pipefail
17
+
18
+ FH="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
19
+ TODAY=$(date +%Y-%m-%d)
20
+ CARD="$FH/tracks/_meta/reference_next_session_starter.md"
21
+ FAIL=0
22
+
23
+ _mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
24
+
25
+ echo "── session close check: $FH ($TODAY) ──"
26
+
27
+ # ① status snapshot — uncommitted / unpushed work must be known, not forgotten
28
+ DIRTY=$(git -C "$FH" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
29
+ UNPUSHED=$(git -C "$FH" log --oneline @{u}.. 2>/dev/null | wc -l | tr -d ' ')
30
+ [ "$DIRTY" -gt 0 ] && echo "⚠️ ① $DIRTY uncommitted path(s) — decide: commit or leave deliberately"
31
+ [ "$UNPUSHED" -gt 0 ] && echo "⚠️ ① $UNPUSHED unpushed commit(s) — push before close or record why"
32
+ [ "$DIRTY" -eq 0 ] && [ "$UNPUSHED" -eq 0 ] && echo "✅ ① working tree clean, nothing unpushed"
33
+
34
+ # ①-b open-PR sweep (surface-not-auto — requires gh; skip silently offline)
35
+ if command -v gh >/dev/null 2>&1; then
36
+ PRS=$(gh pr list --author "@me" --state open --json number 2>/dev/null | grep -c '"number"' || true)
37
+ [ "${PRS:-0}" -gt 0 ] && echo "⚠️ ①-b $PRS open PR(s) by you — classify: self-mergeable vs awaiting-external"
38
+ fi
39
+
40
+ # ② FH assets changed today → harvest-loop owed
41
+ # NOTE: no `grep -q` here — under `set -o pipefail`, -q's early exit SIGPIPEs git log (141),
42
+ # masking a real match as pipeline failure so the warning NEVER fired on true positives
43
+ # (caught by a Sonnet blind probe 2026-07-10, 5/5 deterministic repro). `grep -c` reads the
44
+ # whole stream; `|| true` guards its exit-1-on-zero.
45
+ FH_CHANGED=$(git -C "$FH" log --since="today 00:00" --name-only --pretty=format: 2>/dev/null \
46
+ | grep -cE '^(plugins/.*SKILL\.md|\.claude/rules/|templates/|CLAUDE\.md|knowledge/)' || true)
47
+ if [ "${FH_CHANGED:-0}" -gt 0 ]; then
48
+ echo "⚠️ ② FH assets changed today ($FH_CHANGED path-touch(es)) — harvest-loop (or an explicit skip note) is owed"
49
+ fi
50
+
51
+ # ④ real-time completion log — required whenever any commit landed today
52
+ COMMITS_TODAY=$(git -C "$FH" log --since="today 00:00" --oneline 2>/dev/null | wc -l | tr -d ' ')
53
+ FC="$FH/tracks/_meta/fh_completed_${TODAY}.md"
54
+ if [ "$COMMITS_TODAY" -gt 0 ] && [ ! -f "$FC" ]; then
55
+ echo "❌ ④ commits landed today but tracks/_meta/fh_completed_${TODAY}.md is missing"
56
+ FAIL=1
57
+ fi
58
+
59
+ # ④-b npm freshness — files[] assets changed since last version tag → republish owed.
60
+ # Patterns are NARROWED to the actually-shipped subpaths (package.json files[]): knowledge/ ships only
61
+ # shared/{harness-core,dialogue,rules}; docs/ ships only {codex-compat,CONTRIBUTING,pillars}. A broad
62
+ # ^knowledge/ / ^docs/ over-matched git-tracked-but-UNshipped files (e.g. knowledge/shared/learnings/
63
+ # subagent_invocations_log.yaml, which changes almost every self-dev session) → guaranteed per-session
64
+ # false positive that trains the runner to ignore the line (Axis-2 challenger catch 2026-07-13).
65
+ SHIP_RE='^(plugins/|knowledge/shared/(harness-core|dialogue|rules)/|docs/(codex-compat|CONTRIBUTING|pillars)|README|AGENTS\.md|CLAUDE\.md|CHEATSHEET|CATALOG\.md)'
66
+ LAST_TAG=$(git -C "$FH" describe --tags --abbrev=0 2>/dev/null || true)
67
+ if [ -n "$LAST_TAG" ] && [ -f "$FH/package.json" ]; then
68
+ CHANGED_SINCE_TAG=$(git -C "$FH" diff --name-only "$LAST_TAG"..HEAD 2>/dev/null)
69
+ if printf '%s\n' "$CHANGED_SINCE_TAG" | grep -qE "$SHIP_RE"; then
70
+ echo "⚠️ ④-b npm-shipped assets changed since $LAST_TAG — propose lockstep republish (never auto)"
71
+ fi
72
+ # ④-b-drift: auto-FIRE a drift-CANDIDATE reminder (not a parity verdict) — **in BOTH directions**.
73
+ # The two entry points are read by DIFFERENT runtimes (CLAUDE.md → Claude Code · AGENTS.md/codex-compat
74
+ # → Codex/OpenCode and other non-CC runtimes). A rule that lands in only one is INVISIBLE to the other,
75
+ # so either file changing alone is a candidate — not just the CC→Codex direction.
76
+ # HONEST SCOPE: this tests file co-occurrence, not topical parity — false-positive (a genuinely
77
+ # runtime-specific change needs no mirror) and false-negative (file touched for an unrelated reason)
78
+ # are both possible. The reminder is mechanized; the drift DETERMINATION stays judged (sync, or record
79
+ # drift:none).
80
+ # Origin (2026-07-19): the REVERSE direction was unwired and a real miss slipped through — a field
81
+ # harness's boundary-crossing behavior rules landed in AGENTS.md only, leaving Claude Code sessions
82
+ # unaware of a rule whose violation destroys a downstream harness's identity. Half a check caught none
83
+ # of it, because the miss happened to travel the unwired way.
84
+ _ENTRY_CC='^(CLAUDE\.md|knowledge/shared/(harness-core|dialogue|rules)/)'
85
+ _ENTRY_CX='^(AGENTS\.md|docs/codex-compat)'
86
+ if printf '%s\n' "$CHANGED_SINCE_TAG" | grep -qE "$_ENTRY_CC" \
87
+ && ! printf '%s\n' "$CHANGED_SINCE_TAG" | grep -qE "$_ENTRY_CX"; then
88
+ echo "⚠️ ④-b drift candidate (CC→Codex): shipped CLAUDE.md/knowledge changed but AGENTS.md/docs/codex-compat did not — JUDGE entry-point parity (sync, or record drift:none if genuinely unaffected)"
89
+ fi
90
+ if printf '%s\n' "$CHANGED_SINCE_TAG" | grep -qE "$_ENTRY_CX" \
91
+ && ! printf '%s\n' "$CHANGED_SINCE_TAG" | grep -qE "$_ENTRY_CC"; then
92
+ echo "⚠️ ④-b drift candidate (Codex→CC): AGENTS.md/docs/codex-compat changed but CLAUDE.md/knowledge did not — JUDGE entry-point parity (a rule living only in AGENTS.md is invisible to Claude Code sessions)"
93
+ fi
94
+ fi
95
+
96
+ # ⑤ CARD-LAST invariant — the card must be the NEWEST close artifact. A card older than
97
+ # fh_completed / signal files written this session = ⑤ ran before ①–④ finished (the bug class).
98
+ if [ -f "$CARD" ]; then
99
+ CARD_E=$(_mtime "$CARD")
100
+ NEWER=$(find "$FH/tracks/_meta" -maxdepth 1 -type f \( -name "fh_completed_*.md" -o -name "fh_signal_*.md" \) -newer "$CARD" 2>/dev/null | wc -l | tr -d ' ')
101
+ if [ "$NEWER" -gt 0 ]; then
102
+ echo "❌ ⑤ card-last violated — $NEWER close artifact(s) newer than the session card; re-run ⑤ (delta update)"
103
+ FAIL=1
104
+ else
105
+ echo "✅ ⑤ card is the newest close artifact (card-last holds)"
106
+ fi
107
+ else
108
+ echo "❌ ⑤ session card missing: $CARD"
109
+ FAIL=1
110
+ fi
111
+
112
+ # ⑤-b card-drift probe — 카드의 "부재 주장"을 실물과 대조 (advisory, never FAIL).
113
+ # WHY (N=3, 2026-07-22 주간감사 🟥1): 카드 🔴 "frontier-digest 미가동 — 로그도 산출물도 0"이
114
+ # 오판정이었다(실측 launchd 14/14 발화·산출 12/14). 07-20 S-4 재발에 이어 3회째 →
115
+ # operations.md §Recurrence escalation: 습관 규칙이 아니라 기계 프로브.
116
+ # HONEST SCOPE: 카드 🔴/🟡 줄에서 부재-주장 키워드를 잡고, 그 줄의 이름/경로 토큰으로
117
+ # 실물을 글롭 검색한다. 어휘가 안 겹치면 못 잡는다(무음 FN) — 앵커지 floor 가 아니다.
118
+ # 방향은 advisory: 가역 표면에서 하드 블록은 --no-verify 를 학습시킨다(#165 HIGH-1 동일 원리).
119
+ _ABSENCE_RE='미가동|산출물[^가-힣]*0|로그[^가-힣]*0|0건|부재|안 돌|미생성|not running|no output|zero output'
120
+ # 부정/정정 문맥 가드 — 부재-주장을 **인용하며** 정정하는 줄만 건너뛴다. 판별자는 debunk
121
+ # 어휘 단독이 아니라 **부재-키워드가 인용부호 안에 있는가** — challenger A-1 실측: 살아있는
122
+ # 주장 + 무관한 '정정 필요' 가 같은 줄이면 debunk-단독 가드가 진짜 경고를 무음 삼켰다(FN).
123
+ _DEBUNK_RE='오판정|정정|거짓|아니었|반증'
124
+ _QUOTED_ABSENCE_RE='["“”'"'"'『][^"“”'"'"'』]*(미가동|부재|미생성)'
125
+ if [ -f "$CARD" ]; then
126
+ DRIFT_HITS=0
127
+ while IFS= read -r line; do
128
+ # 부재-주장 줄에서 검증 가능한 토큰 2계층 추출:
129
+ # (a) 명시 경로/글롭 (슬래시나 * 포함) — 그대로 글롭 확장
130
+ # (b) 이름 토큰 (하이픈/언더스코어 포함 ≥6자, e.g. frontier-digest) — -/_ 정규화 후
131
+ # tracks/_meta{,/logs} 파일명 부분일치 검색
132
+ # `*` 는 추출 클래스에서 제외 — 마크다운 강조(**tok**)가 토큰에 붙어 글롭 분기로
133
+ # 오폭한다(known-pair P 픽스처가 잡은 계기 불량, 2026-07-23). 경로 판정은 슬래시로만.
134
+ tokens=$(printf '%s\n' "$line" | grep -oE '[A-Za-z0-9_./-]+' \
135
+ | sed 's/^[.-]*//; s/[.-]*$//' \
136
+ | grep -E '(/|[A-Za-z0-9]+[-_][A-Za-z0-9]+)' | grep -E '.{6,}' \
137
+ | grep -vE '^[0-9._-]+$' | sort -u) # 날짜/숫자 토큰 제외 (실카드 FP)
138
+ [ -z "$tokens" ] && continue
139
+ while IFS= read -r tok; do
140
+ found=0
141
+ case "$tok" in
142
+ */*) # (a) 경로 — **파일만** 인정(-f). 디렉토리를 세면 카드의 위치-언급
143
+ # (tracks/_meta/ 등)이 "실물"로 잡힌다(challenger A-2 FP). 인프라 루트 제외.
144
+ case "$tok" in tracks/_meta|tracks/_meta/|tracks/_meta/logs|tracks/_meta/logs/) continue ;; esac
145
+ # shellcheck disable=SC2086
146
+ for f in $FH/$tok $FH/${tok}*; do [ -f "$f" ] && found=$((found+1)); done ;;
147
+ *) # (b) 이름 토큰 — 양쪽 구분자 변형으로 검색
148
+ norm_u=$(printf '%s' "$tok" | tr '-' '_'); norm_h=$(printf '%s' "$tok" | tr '_' '-')
149
+ found=$(find "$FH/tracks/_meta" -maxdepth 2 -type f ! -name "$(basename "$CARD")" \( -name "*${norm_u}*" -o -name "*${norm_h}*" \) 2>/dev/null | wc -l | tr -d ' ') ;;
150
+ esac
151
+ if [ "${found:-0}" -gt 0 ]; then
152
+ echo "⚠️ ⑤-b card-drift: 카드가 부재를 주장하는데 실물이 있다 — 토큰 '$tok' 매치 ${found}건. 줄: $(printf '%s' "$line" | cut -c1-80)…"
153
+ echo " → 주장을 손검증하라 (미가동≠산출누락 — 07-22 오판정 클래스). advisory, 차단 아님."
154
+ DRIFT_HITS=$((DRIFT_HITS+1)); break
155
+ fi
156
+ done <<CARD_TOK_EOF
157
+ $tokens
158
+ CARD_TOK_EOF
159
+ done <<CARD_LINE_EOF
160
+ $(grep -E '(🔴|🟡)' "$CARD" 2>/dev/null | grep -E "$_ABSENCE_RE" | grep -v '^description:' \
161
+ | while IFS= read -r _l; do
162
+ if printf '%s\n' "$_l" | grep -qE "$_DEBUNK_RE" \
163
+ && printf '%s\n' "$_l" | grep -qE "$_QUOTED_ABSENCE_RE"; then continue; fi
164
+ printf '%s\n' "$_l"
165
+ done || true)
166
+ CARD_LINE_EOF
167
+ [ "$DRIFT_HITS" -eq 0 ] && echo "✅ ⑤-b no card absence-claim contradicted by on-disk artifacts"
168
+ fi
169
+
170
+ echo "── close check: $([ "$FAIL" -eq 0 ] && echo CONSISTENT || echo VIOLATIONS) ──"
171
+ exit "$FAIL"