@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.
- package/.claude/rules/.public-surface-patterns.defaults +44 -0
- package/.claude/rules/fh_4axis_gate.md +207 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +26 -2
- package/CATALOG.md +59 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
- package/package.json +21 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
- package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
- package/plugins/fh-meta/skills/salience-splitter/SKILL.md +1 -1
- package/scripts/chamber_candidate_collect.sh +223 -0
- package/scripts/degrade_direction_scan.sh +222 -0
- package/scripts/fh-gate.sh +76 -2
- package/scripts/fh_session_load.sh +202 -0
- package/scripts/gate_pathspec_check.sh +166 -0
- package/scripts/prepush_guard_check.sh +374 -0
- package/scripts/psa_scan_lib.sh +153 -0
- package/scripts/public_surface_scan_files.sh +157 -0
- package/scripts/selfcheck.sh +16 -0
- package/scripts/session_close_check.sh +171 -0
- package/scripts/test_degrade_scan_shell_probes.sh +185 -0
- package/scripts/test_fh_gate_regressions.sh +46 -2
- package/scripts/test_prepush_stdin_integrity.sh +119 -0
- package/scripts/universal_guard_check.sh +280 -0
- package/templates/.claude/rules/mcp_tool_gating.md +157 -0
- package/templates/.git-hooks/pre-commit +848 -0
- package/templates/.git-hooks/pre-push +585 -0
- package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
- package/templates/degrade_direction_scan.sh +222 -0
- package/templates/predelete_check.sh +72 -0
- package/templates/regression_guard.sh +563 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# degrade_direction_scan.sh — mechanical pre-screen for the "default-toward-PASS" smell
|
|
3
|
+
#
|
|
4
|
+
# The correlated blind spot measured 2026-07-03 across 3 harnesses (qasp/the-bible/pmh):
|
|
5
|
+
# "When a verdict surface cannot mechanically ground its judgment, it defaults toward
|
|
6
|
+
# PASS instead of safe-fail." Same-family review (even frontier + target-tier sim)
|
|
7
|
+
# shares the author's optimistic reading of that discretion and misses it; a
|
|
8
|
+
# different-family auditor catches it. This script is the cheap MECHANICAL pre-screen
|
|
9
|
+
# that runs BEFORE the cross-family pass — it flags the code shapes where a permissive
|
|
10
|
+
# value lands on an unconstrained branch, so the reviewer's attention goes there first.
|
|
11
|
+
#
|
|
12
|
+
# IT IS A REVIEW SURFACE, NOT A HARD GATE. Grep-heuristic → false positives are expected.
|
|
13
|
+
# A hit means "prove this is not default-toward-PASS", not "this is a bug". It never
|
|
14
|
+
# blocks a commit on its own (advisory exit code). The terminal verdict is the
|
|
15
|
+
# cross-family adversarial review + governor source-grounding, never this scan alone.
|
|
16
|
+
# (Irreversibility-gate note: because it is advisory, a degraded/empty run is a no-op,
|
|
17
|
+
# not a free pass — the cross-family review is the load-bearing check it feeds.)
|
|
18
|
+
#
|
|
19
|
+
# NAMED RECALL RESIDUALS (cross-family audit, gpt-5.5, 2026-07-28 — accepted, not closed):
|
|
20
|
+
# * Indirection defeats every probe. `allow() { exit 0; }` … `check || allow` is the same
|
|
21
|
+
# fail-open shape one function call away, and a line-oriented grep cannot follow it. This is
|
|
22
|
+
# inherent to the heuristic, not a bug to patch — it is why the terminal verdict is the
|
|
23
|
+
# cross-family review, and why a clean run is never evidence of safety.
|
|
24
|
+
# * The regression anchor proves the probes on the fixture GRAMMAR it ships, not on every
|
|
25
|
+
# spelling of each class (e.g. `if ! cmd; then :; fi`, arithmetic-context defaults).
|
|
26
|
+
#
|
|
27
|
+
# Usage:
|
|
28
|
+
# bash scripts/degrade_direction_scan.sh [path ...] # scan dirs/files (default: .)
|
|
29
|
+
# git diff --name-only main..HEAD -- '*.py' | xargs bash scripts/degrade_direction_scan.sh
|
|
30
|
+
# Exit: 0 = no smells found; 2 = smells found (ADVISORY signal — do not hard-block on it)
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
TARGETS=("$@")
|
|
34
|
+
[ ${#TARGETS[@]} -eq 0 ] && TARGETS=(".")
|
|
35
|
+
|
|
36
|
+
# Permissive values a verdict/gate surface must never land on by *default* / fall-through.
|
|
37
|
+
PASS='(True|"PASS"|'"'"'PASS'"'"'|"ALLOW"|'"'"'ALLOW'"'"'|"OK"|'"'"'OK'"'"'|"VALID"|'"'"'VALID'"'"'|"GRANTED"|'"'"'GRANTED'"'"'|"PASSED"|'"'"'PASSED'"'"'|allow|ALLOW)'
|
|
38
|
+
|
|
39
|
+
# Collect target files. Scannable = py + sh (the smell probes are Python-shaped but bash surfaces —
|
|
40
|
+
# incl. this gate's own pre-push/pre-commit-hook trigger category — must not be invisibly dropped).
|
|
41
|
+
# Anything else is tracked as UNSCANNABLE so a load-bearing surface in another language is reported
|
|
42
|
+
# as "not covered", never silently folded into an "advisory clean" (M#2, steel-quench 2026-07-03).
|
|
43
|
+
FILES=(); UNSCANNABLE=()
|
|
44
|
+
for t in "${TARGETS[@]}"; do
|
|
45
|
+
if [ -d "$t" ]; then
|
|
46
|
+
while IFS= read -r f; do FILES+=("$f"); done < <(find "$t" -type f \( -name '*.py' -o -name '*.sh' \) 2>/dev/null)
|
|
47
|
+
# Shebang pass — this is what makes git hooks visible at all. Measured 2026-07-28:
|
|
48
|
+
# `templates/.git-hooks` (files named `pre-push`, no extension, under a dotted directory) —
|
|
49
|
+
# FH's own mechanical floor — reported "no scannable (py/sh) target files", exit 0.
|
|
50
|
+
# Shebang pass. Deliberately NOT restricted to extensionless names: a cross-family audit
|
|
51
|
+
# (2026-07-28, gpt-5.5) found that an earlier draft skipped any dotted basename, so a shell
|
|
52
|
+
# file named `helper.bash` carrying an identical known-positive was dropped from a DIRECTORY
|
|
53
|
+
# target in silence — while the explicit-file branch reported the same file as UNSCANNABLE.
|
|
54
|
+
# Silent-drop on one path and honest-report on the other is the fail-open half. Confirmed by
|
|
55
|
+
# running both paths on the same fixture before accepting the finding.
|
|
56
|
+
while IFS= read -r f; do
|
|
57
|
+
b="${f##*/}" # basename — a dotted DIRECTORY (.git-hooks) is not an extension
|
|
58
|
+
case "$b" in
|
|
59
|
+
*.py|*.sh) continue ;; # already collected above
|
|
60
|
+
*.md|*.json|*.yaml|*.yml|*.txt|*.lock|*.png|*.jpg|*.svg|*.pdf|*.zip) continue ;;
|
|
61
|
+
esac
|
|
62
|
+
head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(ba|z|k)?sh\b' && FILES+=("$f")
|
|
63
|
+
done < <(find "$t" -type f 2>/dev/null)
|
|
64
|
+
elif [ -f "$t" ]; then
|
|
65
|
+
tb="${t##*/}"
|
|
66
|
+
case "$tb" in
|
|
67
|
+
*.py|*.sh) FILES+=("$t") ;;
|
|
68
|
+
*.*) UNSCANNABLE+=("$t") ;;
|
|
69
|
+
*) if head -n1 "$t" 2>/dev/null | grep -qE '^#!.*\b(ba|z|k)?sh\b'; then FILES+=("$t"); else UNSCANNABLE+=("$t"); fi ;;
|
|
70
|
+
esac
|
|
71
|
+
fi
|
|
72
|
+
done
|
|
73
|
+
if [ ${#FILES[@]} -eq 0 ]; then
|
|
74
|
+
if [ ${#UNSCANNABLE[@]} -gt 0 ]; then
|
|
75
|
+
echo "degrade-scan: ${#UNSCANNABLE[@]} changed file(s) are OUTSIDE the scannable set (py/sh) — NOT scanned, NOT 'clean':"
|
|
76
|
+
printf ' (unscannable) %s\n' "${UNSCANNABLE[@]}"
|
|
77
|
+
echo "A load-bearing surface in another language must go straight to cross-family review."
|
|
78
|
+
exit 2 # advisory non-clean — an orchestrator keying on exit code must not read this as clean
|
|
79
|
+
fi
|
|
80
|
+
echo "degrade-scan: no scannable (py/sh) target files"; exit 0
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
hits=0
|
|
84
|
+
emit() { printf ' %s:%s\n [%s] %s\n' "$1" "$2" "$3" "$4"; hits=$((hits+1)); }
|
|
85
|
+
|
|
86
|
+
for f in "${FILES[@]}"; do
|
|
87
|
+
# ---- Shell-shaped probes (S*) -------------------------------------------------------------
|
|
88
|
+
# Calibration finding (2026-07-28, known-pair): every probe below the S-block is PYTHON-shaped
|
|
89
|
+
# (`except:` / `.get(k, True)` / `if not x:` / `.split()`), none of which exist in bash. A .sh file
|
|
90
|
+
# was still COLLECTED and counted, so a fail-open shell gate printed "no smells in 1 scanned py/sh
|
|
91
|
+
# file" — a FALSE CLEAN, which is worse than honest non-coverage. A known-positive .sh carrying four
|
|
92
|
+
# distinct default-toward-PASS shapes scored 0/4. These probes close that; they run on any file
|
|
93
|
+
# whose basename ends in .sh OR that carries a shell shebang (see the is_sh test below).
|
|
94
|
+
is_sh=""; fb="${f##*/}"
|
|
95
|
+
case "$fb" in
|
|
96
|
+
*.sh) is_sh=1 ;;
|
|
97
|
+
*.py) ;;
|
|
98
|
+
# Any other collected file reached FILES only via the shebang pass, or is a dotted shell name
|
|
99
|
+
# like `helper.bash`. Re-check the shebang rather than keying on the extension — keying on the
|
|
100
|
+
# extension is what produced the collect-but-never-probe false clean this whole block exists to
|
|
101
|
+
# close (n+10). Collected-but-unprobed must not be reachable again.
|
|
102
|
+
*) head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(ba|z|k)?sh\b' && is_sh=1 ;;
|
|
103
|
+
esac
|
|
104
|
+
if [ -n "$is_sh" ]; then
|
|
105
|
+
# S1 — permissive short-circuit on a FAILING CHECK: `scan=$(...) || return 0`, `verify … || exit 0`.
|
|
106
|
+
# The check errored and the surface reports success. Safe-fail is `|| return 1` / `|| exit 1`.
|
|
107
|
+
# SCOPED to check-shaped left-hand sides (command substitution, or a verb like
|
|
108
|
+
# scan/check/verify/grep/audit/validate/gate). A PRECONDITION guard — `[ -d x ] || exit 0`,
|
|
109
|
+
# `[[ $d =~ … ]] || return 0` — is deliberately excluded: "this run does not apply here" is not
|
|
110
|
+
# the same claim as "this check passed". Hand-measured 2026-07-28: unscoped, 6/6 sampled hits
|
|
111
|
+
# were false positives, 4 of them precondition guards.
|
|
112
|
+
# EXCEPTION, re-added after an adversarial pass on this very scoping: a `-f`/`-x` test is a
|
|
113
|
+
# DEPENDENCY check, not a scope check. `[ -f "$GUARD_LIB" ] || exit 0` means "my guard library
|
|
114
|
+
# is missing, therefore allow" — the fail-open shape that bit qasp on 2026-07-28. Excluding it
|
|
115
|
+
# with the scope guards would have hidden exactly the class this scan exists to find.
|
|
116
|
+
while IFS= read -r m; do
|
|
117
|
+
emit "$f" "${m%%:*}" "S1:||→PASS(sh)" "failing check short-circuits to a permissive result (\`|| return 0\` / \`|| exit 0\` / \`|| true\`) — an errored check must fail closed, not report success"
|
|
118
|
+
done < <(grep -nE '\|\|[[:space:]]*(return[[:space:]]+0|exit[[:space:]]+0|true)([[:space:]]*(#|;|$))' "$f" 2>/dev/null \
|
|
119
|
+
| grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade' \
|
|
120
|
+
| grep -vE '^[0-9]+:[[:space:]]*(if[[:space:]]+)?\[\[?[[:space:]]*(-[dznN][[:space:]]|[^]]*=~)' \
|
|
121
|
+
| grep -E '(\$\(|`|\[[[:space:]]*-[fx][[:space:]]|\b(scan|check|verify|validate|audit|grep|gate|assert|lint|test_)[A-Za-z_]*[[:space:](])')
|
|
122
|
+
|
|
123
|
+
# S2 — `else` fall-through to a permissive exit/return within 2 lines (unenumerated case → allow).
|
|
124
|
+
while IFS= read -r ln; do
|
|
125
|
+
emit "$f" "$ln" "S2:else→PASS(sh)" "else/fall-through branch exits permissively — the unenumerated case should fail closed"
|
|
126
|
+
done < <(grep -nE -A2 '^[[:space:]]*else[[:space:]]*$' "$f" 2>/dev/null \
|
|
127
|
+
| grep -E '^[0-9]+[-:][[:space:]]*(exit[[:space:]]+0|return[[:space:]]+0)[[:space:]]*(#.*)?$' \
|
|
128
|
+
| grep -oE '^[0-9]+' | sort -u)
|
|
129
|
+
|
|
130
|
+
# S3 — empty/unset defaulted to a permissive VERDICT: `${V:-PASS}` / `V="PASS"` after a failed read.
|
|
131
|
+
# "the value never arrived" must not be spelled the same way as "the value said PASS".
|
|
132
|
+
# `${V:-0}` and `${V:-true}` are NOT flagged: numeric defaulting is the prescribed integer
|
|
133
|
+
# sanitization against the pipefail-fallback class (see S5), and flagging it would push an
|
|
134
|
+
# author to delete the remedy. Measured 2026-07-28 — `${PRS:-0}` in session_close_check.sh is
|
|
135
|
+
# the fix, not the defect. Only explicit verdict words count.
|
|
136
|
+
while IFS= read -r m; do
|
|
137
|
+
emit "$f" "${m%%:*}" "S3:default→PASS(sh)" "unset/empty defaults to a permissive verdict — absent is not clean (\`not found\` ≠ \`0\`); default to the blocking value"
|
|
138
|
+
done < <(grep -nE "(\\$\{[A-Za-z_][A-Za-z0-9_]*:?-[[:space:]]*(PASS|OK|ALLOW|GRANTED|VALID|PASSED)\}|\|\|[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=[\"']?(PASS|OK|ALLOW|GRANTED|VALID))" "$f" 2>/dev/null \
|
|
139
|
+
| grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade')
|
|
140
|
+
|
|
141
|
+
# S4 — empty-output guard treated as clean: `if [ -z "$out" ]; then return 0/exit 0`.
|
|
142
|
+
# Identical to Probe E's falsy-sentinel class, in shell spelling: an errored scan produces empty
|
|
143
|
+
# output, so "found nothing" and "never ran" become indistinguishable.
|
|
144
|
+
while IFS= read -r ln; do
|
|
145
|
+
emit "$f" "$ln" "S4:empty→PASS(sh)" "empty output treated as clean — a scan that errored also produces empty output; distinguish 'errored/absent' from 'verified clean'"
|
|
146
|
+
done < <(grep -nE -A2 '^[[:space:]]*(if|elif)[[:space:]]+\[+[[:space:]]*-z[[:space:]]' "$f" 2>/dev/null \
|
|
147
|
+
| grep -E '^[0-9]+[-:][[:space:]]*(exit[[:space:]]+0|return[[:space:]]+0)[[:space:]]*(#.*)?$' \
|
|
148
|
+
| grep -oE '^[0-9]+' | sort -u)
|
|
149
|
+
|
|
150
|
+
# S5 — the pipefail-fallback disarm: `... | grep -c ... || echo 0` appends a SECOND line under
|
|
151
|
+
# `set -o pipefail`, so the later `-gt` integer test becomes a bash error (= false) and the guard
|
|
152
|
+
# passes silently, with the error going only to stderr. Measured class, 2026-07-26.
|
|
153
|
+
while IFS= read -r m; do
|
|
154
|
+
emit "$f" "${m%%:*}" "S5:pipefail-fallback(sh)" "\`|| echo 0\` fallback on a pipeline — under \`set -o pipefail\` this yields a multi-line value whose integer comparison errors out and silently passes the guard; split the pipeline and sanitize to an integer"
|
|
155
|
+
done < <(grep -nE '\|[^|]+\|\|[[:space:]]*echo[[:space:]]+[\"'"'"']?0' "$f" 2>/dev/null \
|
|
156
|
+
| grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade')
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
# Probe A — except/else/finally block returning a permissive value within 2 lines.
|
|
160
|
+
# The classic "swallow the error → report success". A safe-fail returns BLOCK/None/raise.
|
|
161
|
+
while IFS= read -r line; do
|
|
162
|
+
ln="${line%%:*}"
|
|
163
|
+
emit "$f" "$ln" "A:except/else→PASS" "permissive return on an error/fall-through branch — safe-fail must return BLOCK/None or re-raise"
|
|
164
|
+
done < <(grep -nE -A2 '^[[:space:]]*(except([[:space:]][^:]*)?|else|finally)[[:space:]]*:' "$f" 2>/dev/null \
|
|
165
|
+
| grep -E "return[[:space:]]+$PASS([[:space:],)]|$)" | grep -oE '^[0-9]+' | sort -u | sed 's/$/:/')
|
|
166
|
+
|
|
167
|
+
# Probe B — dict default / setdefault to a permissive value (unknown key → PASS).
|
|
168
|
+
while IFS= read -r m; do
|
|
169
|
+
emit "$f" "${m%%:*}" "B:default→PASS" "unknown-key default is permissive — unenumerated case should default to safe-fail"
|
|
170
|
+
done < <(grep -nE "(\.get\([^,]+,[[:space:]]*$PASS[[:space:])]|setdefault\([^,]+,[[:space:]]*$PASS[[:space:])])" "$f" 2>/dev/null)
|
|
171
|
+
|
|
172
|
+
# Probe C — substring membership on a grounding/verdict/state line (loose match, not exact).
|
|
173
|
+
# `if tok in text` masks paid⊂prepaid / 완료⊂미완료. Exact/word-boundary is the safe form.
|
|
174
|
+
while IFS= read -r m; do
|
|
175
|
+
emit "$f" "${m%%:*}" "C:substring-grounding" "substring 'in' on a verdict/state/present line — use exact or word-boundary match, not containment"
|
|
176
|
+
done < <(grep -nE '\b(verdict|present|ground|state|match|expected|assert)\w*\b' "$f" 2>/dev/null \
|
|
177
|
+
| grep -vE ':[[:space:]]*(#|//|from |import )' | grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade' \
|
|
178
|
+
| grep -E '[^._a-zA-Z]in[[:space:]]' | grep -vE '\bfor\b|__contains__|not in|in \(|in \[|in \{|in range|in enumerate|in [A-Z_]+\b' \
|
|
179
|
+
| grep -oE '^[0-9]+' | sed 's/$/:/')
|
|
180
|
+
|
|
181
|
+
# Probe C2 — bare `VAR in VAR` in an if/return/assert/while context, WITHOUT a grounding keyword.
|
|
182
|
+
# Probe C is keyword-gated (low-noise) and therefore misses the doc's own headline example
|
|
183
|
+
# `tok in text` (paid⊂prepaid) when the variables aren't named verdict/state (M#4, steel-quench).
|
|
184
|
+
# C2 closes that: simple var-in-var (not a collection literal / range / for) = a likely
|
|
185
|
+
# containment check that should be exact/word-boundary if it grounds a verdict. Higher noise; advisory.
|
|
186
|
+
while IFS= read -r m; do
|
|
187
|
+
emit "$f" "${m%%:*}" "C2:substring-boolean" "bare 'X in Y' in if/return/assert — if this grounds a presence/verdict check, use exact/word-boundary match, not containment"
|
|
188
|
+
done < <(grep -nE '^[[:space:]]*(if|elif|return|assert|while)[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]+in[[:space:]]+[A-Za-z_][A-Za-z0-9_.]*[[:space:]]*[:)]?[[:space:]]*$' "$f" 2>/dev/null \
|
|
189
|
+
| grep -vE '\bfor\b|in range|in enumerate|not in' \
|
|
190
|
+
| grep -vE '\b(verdict|present|ground|state|match|expected)\w*\b')
|
|
191
|
+
|
|
192
|
+
# Probe E — negated-falsy guard returning permissive (dominance-benchmark round-2 f2 class): an error
|
|
193
|
+
# SENTINEL (None / {} / "" / []) is falsy, so `if not X: return <PASS>` treats "the check errored / never
|
|
194
|
+
# ran" identically to "the check ran and found nothing clean". Distinguish errored from clean before allowing.
|
|
195
|
+
while IFS= read -r ln; do
|
|
196
|
+
emit "$f" "$ln" "E:falsy-sentinel→PASS" "negated-falsy guard returns permissive — a falsy error sentinel (None/{}/'') masquerades as 'clean'; a gate must distinguish 'errored/absent' from 'verified clean'"
|
|
197
|
+
done < <(grep -nE -A2 '^[[:space:]]*if[[:space:]]+not[[:space:]]+[A-Za-z_][A-Za-z0-9_.]*[[:space:]]*:' "$f" 2>/dev/null \
|
|
198
|
+
| grep -E "return[[:space:]]+$PASS([[:space:],)]|$)" | grep -oE '^[0-9]+' | sort -u | sed 's/$/:/')
|
|
199
|
+
|
|
200
|
+
# Probe F — positional field-select from a split result feeding a decision (round-2 c3 class): taking the
|
|
201
|
+
# decision from `parts[-1]`/`parts[0]` of an attacker-influenceable split lets a crafted field (e.g. a
|
|
202
|
+
# signed DENY whose free-form comment ends "::ALLOW") negate the verdict. Validate structure, don't select by position.
|
|
203
|
+
if grep -qE '\.r?split\(' "$f" 2>/dev/null; then
|
|
204
|
+
while IFS= read -r m; do
|
|
205
|
+
emit "$f" "${m%%:*}" "F:split-positional-verdict" "decision taken by position ([-1]/[0]) from a split result — an attacker-controlled trailing/leading field can negate the verdict; validate structure, don't select by position"
|
|
206
|
+
done < <(grep -nE '\[[[:space:]]*-?[01][[:space:]]*\]' "$f" 2>/dev/null \
|
|
207
|
+
| grep -iE 'decision|verdict|allow|deny|approv|grant|status|result|policy')
|
|
208
|
+
fi
|
|
209
|
+
done
|
|
210
|
+
|
|
211
|
+
echo "----"
|
|
212
|
+
[ ${#UNSCANNABLE[@]} -gt 0 ] && printf 'note: %s changed file(s) outside py/sh — NOT covered by this scan (send to cross-family directly).\n' "${#UNSCANNABLE[@]}"
|
|
213
|
+
if [ "$hits" -gt 0 ]; then
|
|
214
|
+
echo "degrade-scan: $hits smell(s) — ADVISORY. Each = 'prove this is not default-toward-PASS'."
|
|
215
|
+
echo "Terminal verdict = cross-family adversarial review (auto-decorrelation), not this scan."
|
|
216
|
+
exit 2
|
|
217
|
+
fi
|
|
218
|
+
# Scope-honest clean message (M#2): "clean" means only "no py/sh-pattern smells in the SCANNED set" —
|
|
219
|
+
# it does NOT assert the changed load-bearing surface is safe (other languages, non-code surfaces,
|
|
220
|
+
# and the lint's own recall gaps are out of scope). The load-bearing check is the cross-family review.
|
|
221
|
+
echo "degrade-scan: no default-toward-PASS smells in ${#FILES[@]} scanned py/sh file(s) — does NOT cover other languages / non-code surfaces / the cross-family check (advisory)."
|
|
222
|
+
exit 0
|
package/scripts/fh-gate.sh
CHANGED
|
@@ -59,13 +59,87 @@ FH_TASK_DESCRIPTION="${FH_TASK_DESCRIPTION:-}"
|
|
|
59
59
|
FH_DIFF_PATH="${FH_DIFF_PATH:-}"
|
|
60
60
|
|
|
61
61
|
case "$FH_BACKEND" in
|
|
62
|
-
claude|codex|auto) ;;
|
|
62
|
+
claude|codex|auto|cross) ;;
|
|
63
63
|
*)
|
|
64
|
-
echo "ERROR: FH_BACKEND must be 'claude', 'codex', or '
|
|
64
|
+
echo "ERROR: FH_BACKEND must be 'claude', 'codex', 'auto', or 'cross' (got: $FH_BACKEND)" >&2
|
|
65
65
|
exit $EXIT_ARG_ERROR
|
|
66
66
|
;;
|
|
67
67
|
esac
|
|
68
68
|
|
|
69
|
+
# ── FH_BACKEND=cross — decorrelated review (2026-07-26) ───────────────────────────────────────
|
|
70
|
+
# `auto` is a FALLBACK: it picks codex if present, else claude, and runs ONE leg. That is backend
|
|
71
|
+
# SELECTION, not decorrelation. A same-family reviewer shares the author's optimistic reading, which
|
|
72
|
+
# is the failure mode the FH load-bearing gate exists to catch — and a 7-round cross-family audit of
|
|
73
|
+
# that gate found 30 issues an in-family pass had not.
|
|
74
|
+
#
|
|
75
|
+
# `cross` runs BOTH families and UNIONs their findings. Union, not vote: detection is the task, and a
|
|
76
|
+
# finding only one leg saw is still a finding (majority voting would discard exactly the decorrelated
|
|
77
|
+
# signal this mode is for). The verdict is the most severe across legs.
|
|
78
|
+
#
|
|
79
|
+
# THE INVARIANT THAT MATTERS MOST: the output declares WHICH LEGS ACTUALLY RAN (`FH_GATE_LEGS:`).
|
|
80
|
+
# `cross` is a REQUEST, not a guarantee — a machine may have only one family installed. When a leg is
|
|
81
|
+
# unavailable or errors, this degrades to the surviving leg and SAYS SO, loudly, in both the human and
|
|
82
|
+
# the machine-readable output. A single-leg result that reads as if it were cross-checked is the same
|
|
83
|
+
# class of defect as a check that did not run reading as PASS.
|
|
84
|
+
#
|
|
85
|
+
# COST: cross is ~2x. It is deliberately NOT a default — [[feedback_decorrelation_not_fanout_cost_boundary]]
|
|
86
|
+
# says match the parallelisation axis to the failure mode rather than multiply. Callers should request
|
|
87
|
+
# it for load-bearing verdict/gate/irreversible-surface changes, which is exactly the trigger the
|
|
88
|
+
# pre-commit load-bearing leg already computes.
|
|
89
|
+
if [[ "$FH_BACKEND" == "cross" ]]; then
|
|
90
|
+
_CROSS_LEGS=""; _CROSS_OUT=""; _CROSS_WORST="PASS"; _CROSS_RAN=0; _CROSS_ERR=""
|
|
91
|
+
_sev_rank() { case "$1" in PASS) echo 0 ;; PENDING) echo 1 ;; ESCALATE) echo 2 ;; BLOCKED) echo 3 ;; *) echo -1 ;; esac; }
|
|
92
|
+
for _leg in claude codex; do
|
|
93
|
+
command -v "$_leg" >/dev/null 2>&1 || { _CROSS_ERR="${_CROSS_ERR}${_CROSS_ERR:+, }$_leg (not installed)"; continue; }
|
|
94
|
+
# `set -e` is on: an assignment from a command substitution carries the child's exit status, and
|
|
95
|
+
# this child EXITS NON-ZERO BY DESIGN for every non-PASS verdict (PENDING 1 / BLOCKED 2 /
|
|
96
|
+
# ESCALATE 3). Written bare, the first leg that found anything would kill the whole cross run —
|
|
97
|
+
# i.e. cross would work only when there was nothing to find. `|| _legrc=$?` keeps the status
|
|
98
|
+
# without letting it abort. Verified against a BLOCKED leg, not assumed.
|
|
99
|
+
_legrc=0
|
|
100
|
+
_legout=$(FH_BACKEND="$_leg" "$0" "$@" 2>/dev/null) || _legrc=$?
|
|
101
|
+
if [ "$_legrc" -ge 10 ]; then
|
|
102
|
+
_CROSS_ERR="${_CROSS_ERR}${_CROSS_ERR:+, }$_leg (harness error $_legrc)"; continue
|
|
103
|
+
fi
|
|
104
|
+
_legverdict=$(printf '%s' "$_legout" | grep -m1 '^FH_GATE_VERDICT:' | awk '{print $2}')
|
|
105
|
+
[ -z "$_legverdict" ] && { _CROSS_ERR="${_CROSS_ERR}${_CROSS_ERR:+, }$_leg (no verdict line)"; continue; }
|
|
106
|
+
_CROSS_RAN=$((_CROSS_RAN+1))
|
|
107
|
+
_CROSS_LEGS="${_CROSS_LEGS}${_CROSS_LEGS:+,}$_leg"
|
|
108
|
+
[ "$(_sev_rank "$_legverdict")" -gt "$(_sev_rank "$_CROSS_WORST")" ] && _CROSS_WORST="$_legverdict"
|
|
109
|
+
_CROSS_OUT="${_CROSS_OUT}
|
|
110
|
+
# ── leg: $_leg (verdict $_legverdict) ──
|
|
111
|
+
$(printf '%s' "$_legout" | sed -n '/^---$/,$p')"
|
|
112
|
+
done
|
|
113
|
+
|
|
114
|
+
if [ "$_CROSS_RAN" -eq 0 ]; then
|
|
115
|
+
echo "ERROR: FH_BACKEND=cross — no leg produced a verdict (${_CROSS_ERR:-none available}). Failing closed." >&2
|
|
116
|
+
exit $EXIT_HARNESS_ERROR
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
printf 'FH_STATUS: SUCCESS\n'
|
|
120
|
+
printf 'FH_GATE_VERDICT: %s\n' "$_CROSS_WORST"
|
|
121
|
+
printf 'FH_GATE_MODE: cross\n'
|
|
122
|
+
printf 'FH_GATE_LEGS: %s\n' "$_CROSS_LEGS"
|
|
123
|
+
if [ "$_CROSS_RAN" -lt 2 ]; then
|
|
124
|
+
# Degraded, and it must be impossible to mistake this for a two-family result.
|
|
125
|
+
printf 'FH_GATE_DECORRELATED: no\n'
|
|
126
|
+
printf 'FH_GATE_DEGRADED: %s\n' "$_CROSS_ERR"
|
|
127
|
+
echo "WARN: FH_BACKEND=cross ran a SINGLE leg ($_CROSS_LEGS) — $_CROSS_ERR." >&2
|
|
128
|
+
echo " This verdict is NOT decorrelated. Treat it as $_CROSS_LEGS alone." >&2
|
|
129
|
+
else
|
|
130
|
+
printf 'FH_GATE_DECORRELATED: yes\n'
|
|
131
|
+
fi
|
|
132
|
+
printf '%s\n' "$_CROSS_OUT"
|
|
133
|
+
echo "→ fh-gate: cross mode — legs [$_CROSS_LEGS], union verdict $_CROSS_WORST" >&2
|
|
134
|
+
case "$_CROSS_WORST" in
|
|
135
|
+
PASS) exit $EXIT_PASS ;;
|
|
136
|
+
PENDING) exit $EXIT_PENDING ;;
|
|
137
|
+
BLOCKED) exit $EXIT_BLOCKED ;;
|
|
138
|
+
ESCALATE) exit $EXIT_ESCALATE ;;
|
|
139
|
+
*) exit $EXIT_HARNESS_ERROR ;;
|
|
140
|
+
esac
|
|
141
|
+
fi
|
|
142
|
+
|
|
69
143
|
# FH_TIMEOUT lands in command position via the unquoted ${_TIMEOUT_CMD} idiom below.
|
|
70
144
|
# `timeout DURATION COMMAND [ARG]...` treats the word after the duration as the command,
|
|
71
145
|
# so an unvalidated value word-splits into arbitrary execution with no shell metacharacters
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# fh_session_load.sh — Mode D SessionStart companion-store freshness load (mechanical).
|
|
3
|
+
#
|
|
4
|
+
# WHY: the session-start companion-store load (refresh the private companion store + read its
|
|
5
|
+
# INDEX + card-vs-commit freshness) is documented in CLAUDE.local.md / modes_and_value.md
|
|
6
|
+
# §Session-start freshness as PROSE. Prose is salience-dependent: when the operator opens a
|
|
7
|
+
# session with an immediate task, the load silently does not fire and the agent operates on
|
|
8
|
+
# stale local memory. (Measured miss 2026-07-05: stale sidecar-tool version + a missed standing
|
|
9
|
+
# instruction, both because the companion refresh was skipped on task-first entry.) A
|
|
10
|
+
# SessionStart hook fires BEFORE the first user turn regardless of what the user types — so it
|
|
11
|
+
# closes the salience gap mechanically. This is the deferred hook in operational_adaptation.md
|
|
12
|
+
# §Guards whose measured revisit-trigger has now fired.
|
|
13
|
+
#
|
|
14
|
+
# WHAT: refresh the companion store, then emit a SHORT, IMPERATIVE freshness delta to stdout.
|
|
15
|
+
# A SessionStart hook's stdout is injected into the session context, so this block becomes
|
|
16
|
+
# unavoidable context the agent sees at turn 0.
|
|
17
|
+
#
|
|
18
|
+
# Graceful: if no companion store is configured (non-Mode-D user / ephemeral clone), emit
|
|
19
|
+
# nothing and exit 0 — this hook is a silent no-op outside Mode D. Offline-safe: refresh
|
|
20
|
+
# failure never blocks the session.
|
|
21
|
+
#
|
|
22
|
+
# Config (operator-local, opt-in): register in .claude/settings.local.json SessionStart and pass
|
|
23
|
+
# the companion-store path via the BE_DIR env in that gitignored registration (the public script
|
|
24
|
+
# hard-codes no private path). HUB_DIR overrides the hub path. Never commit the registration to
|
|
25
|
+
# the public settings.json — the hook is Mode-D-only.
|
|
26
|
+
|
|
27
|
+
set -uo pipefail
|
|
28
|
+
|
|
29
|
+
FH="${HUB_DIR:-${CLAUDE_PROJECT_DIR:-$HOME/projects/forge-harness}}"
|
|
30
|
+
|
|
31
|
+
# ── frontier-digest: 부재를 0으로 읽지 않는다 ────────────────────────────────────
|
|
32
|
+
# 왜: digest 는 launchd 로 매일 09:00 에 돌지만 **31회 중 6회(19%) 산출물 없이 끝났다**
|
|
33
|
+
# (2026-07-21 실측: exit 1 / 재시도 후에도 없음 / Attempt 에서 hang — 세 형태).
|
|
34
|
+
# 그런데 세션 시작은 "있으면 읽는다"만 했다 → **실패가 '오늘은 뉴스 없음'으로 읽혔다.**
|
|
35
|
+
# 부재와 실패는 0이 아니다. 로그가 있는데 산출물이 없으면 그건 부재가 아니라 FAILED 다.
|
|
36
|
+
# Portable mtime (epoch). GNU-first: on GNU/coreutils `stat -f %m` exits 0 with filesystem-format
|
|
37
|
+
# output (never reaching a BSD fallback), so probe `stat -c %Y` FIRST — on BSD/macOS it errors and
|
|
38
|
+
# falls through to `-f %m`. Always echoes a numeric value (0 on total failure) so `-gt` never breaks.
|
|
39
|
+
# (codex cross-family review 2026-07-05 [MED]: BSD-first order silently mis-parsed on GNU.)
|
|
40
|
+
_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
|
|
41
|
+
|
|
42
|
+
_FD_LOG="$FH/tracks/_meta/logs/frontier_digest_$(date +%Y_%m_%d).log"
|
|
43
|
+
_FD_LOCK="$FH/tracks/_meta/logs/.frontier_digest.lock"
|
|
44
|
+
# 스케줄 전 부재 ≠ 실패: 잡은 launchd 로 09:00 에 돈다(com.forge-harness.frontier-digest.plist).
|
|
45
|
+
# 2026-07-23 08:07 세션이 "잡이 안 돌았을 수 있다" 경고를 받았고 잡은 09:00 에 정상 완주 —
|
|
46
|
+
# 경고가 나중에 읽히면 오보처럼 보인다. 그래서 ① 스케줄 전엔 경고하지 않고 ② 모든 분기에
|
|
47
|
+
# 발화 시각을 스탬프하고 ③ 러너 생존 신호(로그 갱신 or 락)가 있는 동안은 실패 판정을 보류한다.
|
|
48
|
+
_FD_SCHED=900 # 09:00, HHMM 를 10진 정수로
|
|
49
|
+
_FD_NOW="${FD_NOW_HHMM:-$(date +%H%M)}" # FD_NOW_HHMM = known-pair 캘리브레이션 전용(숫자 4자리만)
|
|
50
|
+
_FD_STAMP="$(date +%H:%M) 기준"
|
|
51
|
+
# 존재 판정은 러너 digest_ready 와 동일 술어(glob + -size +1k). 정확명 [ -f ] 는 러너와 관대함이
|
|
52
|
+
# 갈린다 — partial 파일(>0 <1k)이 성공으로 오독되고, suffix 착지가 영구 오경보가 된다
|
|
53
|
+
# (divergent-leniency: 같은 상태를 두 술어가 다르게 읽으면 한쪽 결과가 무음으로 샌다).
|
|
54
|
+
_fd_ready() { find "$FH/tracks/_meta" -maxdepth 1 -name "frontier_digest_$(date +%Y_%m_%d)*.md" -size +1k 2>/dev/null | grep -q .; }
|
|
55
|
+
if _fd_ready; then
|
|
56
|
+
:
|
|
57
|
+
elif [ "$((10#$_FD_NOW))" -lt "$_FD_SCHED" ]; then
|
|
58
|
+
echo "ℹ️ [frontier-digest] 오늘 digest 는 09:00 예정 — 아직 전이다($_FD_STAMP). 부재는 정상."
|
|
59
|
+
elif [ -f "$_FD_LOG" ]; then
|
|
60
|
+
_FD_LOG_AGE=$(( $(date +%s) - $(_mtime "$_FD_LOG") ))
|
|
61
|
+
# 러너 생존 신호 2종 — 어느 쪽이든 있으면 FAILED 대신 보류:
|
|
62
|
+
# ① 로그 최근 갱신(임계 2100s > 러너 최장 침묵창 = watchdog 1800s)
|
|
63
|
+
# ② 락 존재이면서 비-stale — stale 임계는 러너 자신의 락-브레이크 술어(-mmin +240)와 동일.
|
|
64
|
+
# 락은 슬립-복귀 직후(로그는 오래됐지만 러너가 살아 재개하는 케이스)를 커버한다.
|
|
65
|
+
_FD_ALIVE=""
|
|
66
|
+
if [ -d "$_FD_LOCK" ] && [ -z "$(find "$_FD_LOCK" -maxdepth 0 -mmin +240 2>/dev/null)" ]; then
|
|
67
|
+
_FD_ALIVE="락 존재"
|
|
68
|
+
fi
|
|
69
|
+
[ "$_FD_LOG_AGE" -lt 2100 ] && _FD_ALIVE="${_FD_ALIVE:+$_FD_ALIVE · }로그 ${_FD_LOG_AGE}s 전 갱신"
|
|
70
|
+
if [ -n "$_FD_ALIVE" ]; then
|
|
71
|
+
echo "ℹ️ [frontier-digest] 잡이 아직 돌고 있는 중일 수 있다($_FD_STAMP, $_FD_ALIVE) — 실패 판정 보류, 나중에 재확인."
|
|
72
|
+
else
|
|
73
|
+
echo "⚠️ [frontier-digest] 오늘 잡은 돌았는데 **산출물이 없다**($_FD_STAMP) — 부재가 아니라 실패다."
|
|
74
|
+
echo " 마지막 로그: $(tail -1 "$_FD_LOG" 2>/dev/null | cut -c1-90)"
|
|
75
|
+
echo " → 수동 재실행하거나 실패 원인을 보라. '오늘은 뉴스 없음'으로 읽지 말 것."
|
|
76
|
+
fi
|
|
77
|
+
else
|
|
78
|
+
echo "⚠️ [frontier-digest] 스케줄(09:00) 지났는데 로그도 산출물도 없다($_FD_STAMP) — 잡이 아예 안 돌았을 수 있다(launchd 확인)."
|
|
79
|
+
fi
|
|
80
|
+
BE="${BE_DIR:-}" # companion-store path — supplied by the gitignored hook registration; no public default.
|
|
81
|
+
|
|
82
|
+
# Non-Mode-D / no companion store → silent no-op (this is the majority path for public users).
|
|
83
|
+
[ -d "$BE/.git" ] || exit 0
|
|
84
|
+
|
|
85
|
+
# (_mtime is defined above the frontier-digest block — single definition, both sections use it.)
|
|
86
|
+
|
|
87
|
+
# 1) Refresh the companion store — fail-fast, never block the first turn, never mutate into a
|
|
88
|
+
# merge/conflict. (codex cross-family review 2026-07-05 [HIGH]/[MED].)
|
|
89
|
+
# - fail-fast env: no credential/SSH/host-key prompts can hang SessionStart.
|
|
90
|
+
# - fetch + merge --ff-only: a fast-forward is the only safe hook mutation; a diverged companion
|
|
91
|
+
# simply does not advance (no merge commit, no conflict state left behind) and we say so.
|
|
92
|
+
export GIT_TERMINAL_PROMPT=0
|
|
93
|
+
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new}"
|
|
94
|
+
|
|
95
|
+
# Hard wall-clock deadline on the ONLY network step. ConnectTimeout bounds the handshake, but a
|
|
96
|
+
# slow/stalled TRANSFER after connect has no bound — measured 2026-07-12: SessionStart worst-case
|
|
97
|
+
# 17.5s with 2 hook-timeout kills, all attributable to the fetch. perl-alarm is the portable
|
|
98
|
+
# watchdog (macOS ships no coreutils `timeout`); on overrun the fetch dies and the offline branch
|
|
99
|
+
# reports honestly. FH_FETCH_DEADLINE overrides (seconds). If perl is absent the wrapper degrades
|
|
100
|
+
# to running the command with NO deadline — a missing watchdog must never become a permanently
|
|
101
|
+
# skipped fetch misreported as "offline" (challenger catch 2026-07-12).
|
|
102
|
+
if command -v perl >/dev/null 2>&1; then
|
|
103
|
+
_deadline() { perl -e 'alarm shift @ARGV; exec @ARGV' "$@"; }
|
|
104
|
+
else
|
|
105
|
+
_deadline() { shift; "$@"; }
|
|
106
|
+
fi
|
|
107
|
+
PULL_NOTE=""
|
|
108
|
+
if _deadline "${FH_FETCH_DEADLINE:-8}" git -C "$BE" fetch --quiet >/dev/null 2>&1; then
|
|
109
|
+
if git -C "$BE" merge --ff-only --quiet >/dev/null 2>&1; then
|
|
110
|
+
PULL_NOTE="fetched + fast-forwarded"
|
|
111
|
+
else
|
|
112
|
+
PULL_NOTE="fetched but NOT fast-forward (companion diverged — read local + newest remote)"
|
|
113
|
+
fi
|
|
114
|
+
else
|
|
115
|
+
PULL_NOTE="fetch skipped (offline or deadline hit — read local state)"
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# 2) Session card date (the pointer the operator's close chain writes last).
|
|
119
|
+
CARD="$FH/tracks/_meta/reference_next_session_starter.md"
|
|
120
|
+
CARD_EPOCH=0
|
|
121
|
+
[ -f "$CARD" ] && CARD_EPOCH="$(_mtime "$CARD")"
|
|
122
|
+
|
|
123
|
+
# 3) Companion files NEWER than the card, in the surfaces that carry landed results/handoffs.
|
|
124
|
+
# (paper-signals = completed experiments; handoff = cross-session/cross-machine; tracks-meta
|
|
125
|
+
# = synced session meta.) These are exactly what a stale card fails to point at.
|
|
126
|
+
NEWER=""
|
|
127
|
+
for sub in paper-signals handoff tracks-meta digests; do
|
|
128
|
+
d="$BE/$sub"
|
|
129
|
+
[ -d "$d" ] || continue
|
|
130
|
+
while IFS= read -r f; do
|
|
131
|
+
[ -n "$f" ] || continue
|
|
132
|
+
fe="$(_mtime "$f")"
|
|
133
|
+
if [ "${fe:-0}" -gt "${CARD_EPOCH:-0}" ]; then
|
|
134
|
+
NEWER="${NEWER} - ${f#$BE/}\n"
|
|
135
|
+
fi
|
|
136
|
+
done <<EOF
|
|
137
|
+
$(find "$d" -type f -name '*.md' -maxdepth 2 2>/dev/null)
|
|
138
|
+
EOF
|
|
139
|
+
done
|
|
140
|
+
|
|
141
|
+
# 3b) Handoff/signal STATUS map — mtime-INDEPENDENT (patched 2026-07-10).
|
|
142
|
+
# WHY: the NEWER-than-card list (step 3) has a permanent blind spot — a status stamp
|
|
143
|
+
# (DONE/SUPERSEDED/RESOLVED) can land in the companion store, then the card gets rewritten
|
|
144
|
+
# WITHOUT reconciling that item; from then on the stamped file is "older than the card"
|
|
145
|
+
# forever and step 3 never surfaces it again. (Measured miss 2026-07-10: a Qwen heavy
|
|
146
|
+
# handoff stamped DONE 07-09 stayed listed as "awaiting RUN" in the card through a later
|
|
147
|
+
# card rewrite — company sessions push results to the companion store but never run the
|
|
148
|
+
# local close chain, so the card's ⑤ update is the ONLY reconcile point and it was prose.)
|
|
149
|
+
# FIX: emit ALL frontmatter status lines from handoff/ + paper-signals/ every session,
|
|
150
|
+
# regardless of mtime, so the turn-0 agent can mechanically cross-check card carry items.
|
|
151
|
+
STATUS_MAP=""
|
|
152
|
+
for sub in handoff paper-signals; do
|
|
153
|
+
d="$BE/$sub"
|
|
154
|
+
[ -d "$d" ] || continue
|
|
155
|
+
while IFS= read -r f; do
|
|
156
|
+
[ -n "$f" ] || continue
|
|
157
|
+
s="$(head -15 "$f" 2>/dev/null | grep -iE '^ *status:' | head -1 | sed 's/^ *//')"
|
|
158
|
+
[ -n "$s" ] || continue
|
|
159
|
+
# Match on the status VALUE's leading word only — a substring match anywhere in the line
|
|
160
|
+
# false-positives on prose like "Remaining for DONE:" inside a PARTIAL status.
|
|
161
|
+
v="$(printf '%s' "$s" | sed -E 's/^[Ss][Tt][Aa][Tt][Uu][Ss]: *//')"
|
|
162
|
+
case "$v" in
|
|
163
|
+
DONE*|SUPERSEDED*|RESOLVED*|CLOSED*) STATUS_MAP="${STATUS_MAP} - ${f#$BE/} → ${s}\n" ;;
|
|
164
|
+
esac
|
|
165
|
+
done <<EOF
|
|
166
|
+
$(find "$d" -type f -name '*.md' -maxdepth 2 2>/dev/null)
|
|
167
|
+
EOF
|
|
168
|
+
done
|
|
169
|
+
|
|
170
|
+
# 4) INDEX.md live pointers (the operator's wiki TOC — read-first per CLAUDE.local.md).
|
|
171
|
+
INDEX_HEAD=""
|
|
172
|
+
if [ -f "$BE/INDEX.md" ]; then
|
|
173
|
+
INDEX_HEAD="$(grep -iE 'live pointer|Live pointers' -A 8 "$BE/INDEX.md" 2>/dev/null | head -10)"
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
# 5) Emit the freshness block (short + imperative). Only speak if there is something to say.
|
|
177
|
+
{
|
|
178
|
+
echo "🔄 [FH SessionStart] companion-store freshness — $PULL_NOTE."
|
|
179
|
+
if [ -n "$NEWER" ]; then
|
|
180
|
+
echo "⚠️ NEWER THAN SESSION CARD — READ THESE BEFORE ACTING (card may be stale):"
|
|
181
|
+
printf "%b" "$NEWER"
|
|
182
|
+
else
|
|
183
|
+
echo " (no companion files newer than the session card)"
|
|
184
|
+
fi
|
|
185
|
+
if [ -n "$STATUS_MAP" ]; then
|
|
186
|
+
echo "── handoff/signal STATUS map (mtime-independent — closed items) ──"
|
|
187
|
+
printf "%b" "$STATUS_MAP"
|
|
188
|
+
echo "→ CROSS-CHECK: any item above that the session card still lists as open/awaiting = stale card line. Fix it in this session's card update (⑤)."
|
|
189
|
+
fi
|
|
190
|
+
if [ -n "$INDEX_HEAD" ]; then
|
|
191
|
+
echo "── INDEX.md live pointers ──"
|
|
192
|
+
echo "$INDEX_HEAD"
|
|
193
|
+
fi
|
|
194
|
+
echo "Reminder: this is the Mode D auto-read (CLAUDE.local.md §Session-start companion load) —"
|
|
195
|
+
echo "it fires even when the first user message is a task. Do not treat 'pulled' as 'read'."
|
|
196
|
+
} 2>/dev/null
|
|
197
|
+
|
|
198
|
+
# 6) Substrate-jump detection (structure-enforcing — version drift lives outside any session's
|
|
199
|
+
# context boundary; silent when nothing changed). Detector, never a gate.
|
|
200
|
+
[ -x "$FH/scripts/substrate_jump_detector.sh" ] && bash "$FH/scripts/substrate_jump_detector.sh" "$FH" 2>/dev/null
|
|
201
|
+
|
|
202
|
+
exit 0
|