@chrono-meta/fh-gate 1.4.77 → 1.4.78
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/fh_4axis_gate.md +63 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +96 -260
- package/CLAUDE.md +2 -7
- package/docs/codex-compat.md +4 -1
- package/knowledge/shared/harness-core/agents_md_runtime_details.md +233 -0
- package/knowledge/shared/harness-core/multi_model_sidecar_strategy.md +1 -1
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +14 -0
- package/knowledge/shared/rules/operational_adaptation.md +1 -130
- package/package.json +7 -5
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/skills/install-doctor/SKILL.md +88 -0
- package/plugins/fh-meta/skills/install-wizard/SKILL.md +1 -1
- package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +117 -3
- package/scripts/fh_node_check.sh +184 -0
- package/scripts/fh_session_load.sh +59 -28
- package/scripts/package_coverage_check.sh +22 -0
- package/scripts/selfcheck.sh +30 -15
- package/scripts/sidecar_calibrate.sh +190 -0
- package/scripts/test_node_check_lanes.sh +179 -0
- package/scripts/test_sidecar_calibrate_lanes.sh +218 -0
- package/templates/settings.SessionStart.snippet.json +54 -0
- package/scripts/consent_registry_check.sh +0 -390
- package/scripts/test_consent_registry.sh +0 -255
- package/templates/consent_classes.yaml.example +0 -75
|
@@ -93,8 +93,71 @@ IDX
|
|
|
93
93
|
WIRING="At session start: read \$BE_DIR/INDEX.md first; then ls -t paper-signals/ handoff/ digests/ and open anything newer than the session card (card=pointer, store commit=truth) — not only handoffs."
|
|
94
94
|
grep -qF "read \$BE_DIR/INDEX.md first" "$HUB_DIR/CLAUDE.local.md" 2>/dev/null \
|
|
95
95
|
|| printf '\n## Companion-store session-start read\n%s\n' "$WIRING" >> "$HUB_DIR/CLAUDE.local.md"
|
|
96
|
+
|
|
97
|
+
# 3. MECHANICAL FLOOR over that prose — register the SessionStart hooks (REQUIRED, not optional).
|
|
98
|
+
# WHY: step 2 writes an INSTRUCTION into CLAUDE.local.md. An instruction is salience: on task-first
|
|
99
|
+
# entry (the user's first message is a task, so the onboarding menu is correctly suppressed) or on a
|
|
100
|
+
# weaker tier, it silently does not fire and the session runs on stale local state. The two hooks
|
|
101
|
+
# below fire BEFORE turn 0 regardless of what the user types.
|
|
102
|
+
# - fh_session_load.sh → companion freshness (this section's own load)
|
|
103
|
+
# - fh_env_delta_scan.sh → undeployed-sibling-repo discovery (CLAUDE.md claim ②; rated
|
|
104
|
+
# PARTIAL/THEATER by the 2026-07-06 three-family audit while prose-only)
|
|
105
|
+
# Registration lives in the GITIGNORED .claude/settings.local.json — BE_DIR is an operator-private
|
|
106
|
+
# path and must never land in the project-shared settings.json. Idempotent: re-running replaces, never dups.
|
|
107
|
+
# Measured miss 2026-07-30 (n=2, a freshly installed second machine): neither hook was registered,
|
|
108
|
+
# so the Mode D load ran only because the operator happened to open with a greeting. A task-first
|
|
109
|
+
# first message would have skipped it entirely — on Opus, not merely on a weak tier.
|
|
110
|
+
if [ -n "${BE_DIR:-}" ] && [ -d "$HUB_DIR/scripts" ]; then
|
|
111
|
+
# The real $BE_DIR is passed IN and baked into the command — never a "<your-store>" placeholder
|
|
112
|
+
# for the user to swap later. A placeholder written into a config file is a hook that reports
|
|
113
|
+
# "registered" and then silently resolves to a nonexistent path; the Sonnet target-tier sim
|
|
114
|
+
# (2026-07-30) named exactly this failure — the script prints success, the operator stops, the
|
|
115
|
+
# hook is dead. If a value must be substituted, substitute it at write time or do not write.
|
|
116
|
+
python3 - "$HUB_DIR" "$BE_DIR" <<'PY'
|
|
117
|
+
import json, os, sys, collections
|
|
118
|
+
hub, be = sys.argv[1], sys.argv[2]
|
|
119
|
+
p = os.path.join(hub, ".claude", "settings.local.json")
|
|
120
|
+
d = collections.OrderedDict()
|
|
121
|
+
if os.path.exists(p):
|
|
122
|
+
with open(p) as fh:
|
|
123
|
+
d = json.load(fh, object_pairs_hook=collections.OrderedDict)
|
|
124
|
+
cmd = lambda s: {"type": "command",
|
|
125
|
+
"command": f'BE_DIR="{be}" bash "$CLAUDE_PROJECT_DIR/scripts/{s}"',
|
|
126
|
+
"timeout": 20}
|
|
127
|
+
if os.path.exists(p): # back up before rewriting someone's config; a traceback mid-write truncates
|
|
128
|
+
import shutil; shutil.copy2(p, p + ".prewizard")
|
|
129
|
+
hooks = d.setdefault("hooks", collections.OrderedDict())
|
|
130
|
+
# Filter at HOOK level, not GROUP level: a user hook sharing a group with an FH hook would otherwise
|
|
131
|
+
# be deleted with the group (cross-family review 2026-07-30 reproduced that loss).
|
|
132
|
+
FH_HOOKS = ("fh_session_load.sh", "fh_env_delta_scan.sh")
|
|
133
|
+
existing = []
|
|
134
|
+
for g in hooks.get("SessionStart", []):
|
|
135
|
+
survivors = [h for h in g.get("hooks", [])
|
|
136
|
+
if not any(n in h.get("command", "") for n in FH_HOOKS)]
|
|
137
|
+
if survivors:
|
|
138
|
+
g = dict(g); g["hooks"] = survivors; existing.append(g)
|
|
139
|
+
hooks["SessionStart"] = existing + [
|
|
140
|
+
{"matcher": "", "hooks": [cmd("fh_session_load.sh"), cmd("fh_env_delta_scan.sh")]}]
|
|
141
|
+
with open(p, "w") as fh:
|
|
142
|
+
json.dump(d, fh, indent=2, ensure_ascii=False); fh.write("\n")
|
|
143
|
+
print("SessionStart hooks registered ->", p, "(backup: .prewizard)")
|
|
144
|
+
PY
|
|
145
|
+
# VERIFY by running it once — a registration that was never executed is not a floor.
|
|
146
|
+
# Expected: a "companion-store freshness" line on stdout. If it prints nothing, BE_DIR is wrong.
|
|
147
|
+
BE_DIR="$BE_DIR" bash "$HUB_DIR/scripts/fh_session_load.sh" | head -3
|
|
148
|
+
fi
|
|
96
149
|
```
|
|
97
150
|
|
|
151
|
+
> **Known-pair check before calling this done** (per `measurement-integrity-checklist.md
|
|
152
|
+
> §Instrument-Calibration`) — **run both legs, do not copy this verdict**:
|
|
153
|
+
> - **known-positive** — `BE_DIR` set → a `companion-store freshness` line prints.
|
|
154
|
+
> - **known-negative** — `BE_DIR` unset → **no companion block prints** and the script exits 0.
|
|
155
|
+
> It is *not* fully silent: the frontier-digest section runs above the Mode-D guard and speaks to
|
|
156
|
+
> every user about their own local digest. That is intended. An earlier draft of this note claimed
|
|
157
|
+
> the negative leg was silent and made that a ship-blocker; running it showed 171 bytes of correct
|
|
158
|
+
> frontier output — the note was written without executing the check it prescribed.
|
|
159
|
+
> If the negative leg prints anything referencing the **companion store**, do not ship.
|
|
160
|
+
|
|
98
161
|
- **Raw / Wiki / Conversation ingest axis** (`sync_push_protocols.md`): classify each artifact by
|
|
99
162
|
processing stage — Raw (unprocessed capture) → stays raw; Wiki (distilled + `[[linked]]`) → the
|
|
100
163
|
compounding layer; Conversation (dialogue/decision log). The Raw→Wiki distill is where linking earns
|
|
@@ -102,9 +165,16 @@ grep -qF "read \$BE_DIR/INDEX.md first" "$HUB_DIR/CLAUDE.local.md" 2>/dev/null \
|
|
|
102
165
|
- **Backend note**: for an **Obsidian** backend the graph view is the *visual* observability surface
|
|
103
166
|
(free for that backend); for the recommended **git `*-be`** form, observability is the agent querying
|
|
104
167
|
INDEX + sections (no visualization needed). gbrain ingests the same markdown.
|
|
105
|
-
- **Salience
|
|
106
|
-
|
|
107
|
-
|
|
168
|
+
- **Salience → mechanical (corrected 2026-07-30)**: this used to read *"the session-start read is prose
|
|
169
|
+
(no SessionStart hook) — accepted limitation; revisit if a target-tier sim measures a miss."* **Both
|
|
170
|
+
halves were wrong by then.** The hook exists (`scripts/fh_session_load.sh`, shipped 2026-07-05), and
|
|
171
|
+
the revisit-trigger has fired: a freshly installed second machine ran with **neither** SessionStart
|
|
172
|
+
hook registered, on Opus — the load fired only because that session happened to open with a greeting
|
|
173
|
+
instead of a task. So step 3 above **registers the hooks**; the CLAUDE.local.md prose stays as the
|
|
174
|
+
human-readable layer *over* that floor, never as the floor itself.
|
|
175
|
+
**Residual (named, not closed)**: registration lives in the gitignored `settings.local.json`, so a
|
|
176
|
+
user who re-clones the hub without re-running the wizard silently loses it again — the honest
|
|
177
|
+
backstop is `install-doctor`'s check item, not the wizard alone.
|
|
108
178
|
|
|
109
179
|
|
|
110
180
|
---
|
|
@@ -446,6 +516,50 @@ source "$FH_DIR/templates/fh_audit_check.zsh"
|
|
|
446
516
|
EOF
|
|
447
517
|
fi
|
|
448
518
|
|
|
519
|
+
# Node floor check hook — ALL users, not Mode D only. Source of truth = the tracked snippet
|
|
520
|
+
# templates/settings.SessionStart.snippet.json (`project_settings_json` key). Registration itself
|
|
521
|
+
# cannot be tracked (every .claude/settings*.json path is gitignored), so the wizard is what wires it
|
|
522
|
+
# — which is exactly why this must not be skipped: without it, a user on a fresh machine gets no
|
|
523
|
+
# turn-0 signal that their floors are missing.
|
|
524
|
+
# NPM-INSTALL PRECONDITION: this block reads the snippet from disk, so both it and
|
|
525
|
+
# scripts/fh_node_check.sh must be in package.json `files[]`. They are (added 2026-07-30 after
|
|
526
|
+
# scripts/package_coverage_check.sh caught the omission — without it an npm-installed wizard hit
|
|
527
|
+
# FileNotFoundError here and registered nothing while reporting success upstream).
|
|
528
|
+
python3 - "$FH_DIR" <<'PY'
|
|
529
|
+
import json, os, sys, collections
|
|
530
|
+
hub = sys.argv[1]
|
|
531
|
+
snippet = os.path.join(hub, "templates", "settings.SessionStart.snippet.json")
|
|
532
|
+
target = os.path.join(hub, ".claude", "settings.json")
|
|
533
|
+
entry = json.load(open(snippet))["project_settings_json"]["hooks"]["SessionStart"]
|
|
534
|
+
d = collections.OrderedDict()
|
|
535
|
+
if os.path.exists(target):
|
|
536
|
+
d = json.load(open(target), object_pairs_hook=collections.OrderedDict)
|
|
537
|
+
import shutil; shutil.copy2(target, target + ".prewizard") # back up before rewriting
|
|
538
|
+
hooks = d.setdefault("hooks", collections.OrderedDict())
|
|
539
|
+
# Merge at HOOK level, not group level. A group-level filter drops the whole group when a user's own
|
|
540
|
+
# hook shares a group with the FH one — the common shape when someone hand-edits or appends to an
|
|
541
|
+
# older wizard's output. (Cross-family review 2026-07-30 reproduced the loss: a group holding
|
|
542
|
+
# [my_telemetry.sh, fh_session_load.sh] lost my_telemetry.sh entirely.)
|
|
543
|
+
kept = []
|
|
544
|
+
for g in hooks.get("SessionStart", []):
|
|
545
|
+
survivors = [h for h in g.get("hooks", []) if "fh_node_check" not in h.get("command", "")]
|
|
546
|
+
if survivors:
|
|
547
|
+
g = dict(g); g["hooks"] = survivors; kept.append(g)
|
|
548
|
+
hooks["SessionStart"] = kept + entry
|
|
549
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
550
|
+
with open(target, "w") as fh:
|
|
551
|
+
json.dump(d, fh, indent=2, ensure_ascii=False); fh.write("\n")
|
|
552
|
+
print("node-check SessionStart hook registered ->", target)
|
|
553
|
+
PY
|
|
554
|
+
chmod +x "$FH_DIR/scripts/fh_node_check.sh" 2>/dev/null
|
|
555
|
+
# VERIFY against a THROWAWAY state file (FH_NODE_STATE). Verifying against the real state would
|
|
556
|
+
# consume the user's one-shot event report, so their actual first session goes quiet and the notice
|
|
557
|
+
# is buried in install output instead (cross-family review 2026-07-30).
|
|
558
|
+
# known-pair: healthy machine → run 1 prints an event line, run 2 is SILENT.
|
|
559
|
+
# missing floor → prints EVERY run (a floor gap is a condition, not an event).
|
|
560
|
+
_T="$(mktemp)"; FH_NODE_STATE="$_T" bash "$FH_DIR/scripts/fh_node_check.sh"
|
|
561
|
+
FH_NODE_STATE="$_T" bash "$FH_DIR/scripts/fh_node_check.sh"; rm -f "$_T"
|
|
562
|
+
|
|
449
563
|
# 4-axis verification gate (Mode D / FH-self-development only — OPT-IN, double-confirm required)
|
|
450
564
|
# SCOPE (state this before asking): this gates commits IN YOUR FH CLONE ($FH_DIR) — git commit there is
|
|
451
565
|
# blocked until the 4-axis markers pass. It is FH-internal infra (hardcodes hub paths/markers) and is
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# fh_node_check.sh — per-NODE environment floor check, fired at SessionStart.
|
|
3
|
+
#
|
|
4
|
+
# WHY A NODE-SCOPED CHECK EXISTS:
|
|
5
|
+
# A user's context (companion store, memory, session card) travels between machines; the machine's
|
|
6
|
+
# own wiring does not. A rich context makes an unwired laptop read as "already configured".
|
|
7
|
+
# Measured 2026-07-30: a machine holding the full companion store and memory ran sessions with its
|
|
8
|
+
# SessionStart hooks unregistered; nothing surfaced it — it was found by accident.
|
|
9
|
+
#
|
|
10
|
+
# WHY IT IS NOT INSIDE fh_session_load.sh:
|
|
11
|
+
# That script is registered in the gitignored .claude/settings.local.json, so on a fresh clone it
|
|
12
|
+
# is not registered — the situation this check exists for is the one in which it could not run.
|
|
13
|
+
# This script carries no operator-private path, so it can be registered from
|
|
14
|
+
# templates/settings.SessionStart.snippet.json, which IS tracked and ships with a clone.
|
|
15
|
+
# HONEST SCOPE: registration still requires the wizard to merge that snippet — every
|
|
16
|
+
# .claude/settings*.json path in this repo is gitignored, so no SessionStart entry can be tracked.
|
|
17
|
+
# The chicken-and-egg is REDUCED (script + snippet ship), not ELIMINATED (a user who never runs
|
|
18
|
+
# the wizard still gets nothing). An earlier revision of this header claimed "survives a clone";
|
|
19
|
+
# that was false, and a cross-family review caught it before merge. Do not restore the claim
|
|
20
|
+
# without running `git check-ignore` on the settings paths first.
|
|
21
|
+
#
|
|
22
|
+
# EMISSION IS STATE-BASED, NOT EVENT-BASED — this is the load-bearing design decision:
|
|
23
|
+
# A missing floor is a PERSISTENT CONDITION, so it is reported EVERY session until fixed.
|
|
24
|
+
# A healthy machine is silent AFTER its one event line (first session / machine change / infra
|
|
25
|
+
# delta) — events report once, conditions report until they stop being true.
|
|
26
|
+
# Earlier revisions fired on events only (first session / machine change / idle >= 7 days / HEAD
|
|
27
|
+
# advanced >= 20 commits) and that was wrong in both directions: on this hub's measured velocity
|
|
28
|
+
# (~33 commits/week) the commit axis fired every ~4 days on a healthy machine (noise, and an
|
|
29
|
+
# ignored detector cannot be revived), while a broken machine reported once then went quiet
|
|
30
|
+
# forever — reproducing the very accident above.
|
|
31
|
+
#
|
|
32
|
+
# Detector, never a gate: always exits 0, and it RECOMMENDS — it cannot compel.
|
|
33
|
+
# State: tracks/_meta/.fh_node_state (gitignored; excluded from companion sync — it is machine-local
|
|
34
|
+
# by nature, and mirroring it would make node identity flap between machines).
|
|
35
|
+
# FH_NODE_STATE overrides the state path (used by the wizard's verification so that verifying does
|
|
36
|
+
# not consume a one-shot event report).
|
|
37
|
+
|
|
38
|
+
set -uo pipefail
|
|
39
|
+
|
|
40
|
+
FH="${HUB_DIR:-${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}}"
|
|
41
|
+
STATE="${FH_NODE_STATE:-$FH/tracks/_meta/.fh_node_state}"
|
|
42
|
+
|
|
43
|
+
NODE_ID="${FH_MACHINE_ID:-$(hostname -s 2>/dev/null || echo unknown)}"
|
|
44
|
+
HEAD_NOW="$(git -C "$FH" rev-parse --short HEAD 2>/dev/null || echo none)"
|
|
45
|
+
NOW="$(date +%s)"
|
|
46
|
+
|
|
47
|
+
PREV_ID=""; PREV_EPOCH=0; PREV_HEAD=""
|
|
48
|
+
if [ -f "$STATE" ]; then
|
|
49
|
+
IFS='|' read -r PREV_ID PREV_EPOCH PREV_HEAD < "$STATE" 2>/dev/null || true
|
|
50
|
+
fi
|
|
51
|
+
# Never feed a file-sourced value straight into arithmetic (bash arithmetic evaluates command
|
|
52
|
+
# substitution inside array subscripts).
|
|
53
|
+
case "${PREV_EPOCH:-}" in ''|*[!0-9]*) PREV_EPOCH=0 ;; esac
|
|
54
|
+
|
|
55
|
+
# ── floor probes — always run, before any decision about whether to speak ──────
|
|
56
|
+
MISS=""
|
|
57
|
+
|
|
58
|
+
# ① git-side floor. Probe the EXECUTABLE HOOK, not the config key: `core.hooksPath` unset is a
|
|
59
|
+
# normal working install when hooks live in .git/hooks, and a set-but-empty path is a broken
|
|
60
|
+
# install the key alone reports as fine. Resolve the directory with `git rev-parse --git-path`,
|
|
61
|
+
# which handles set/unset, relative/absolute, AND linked worktrees (where .git is a file, so a
|
|
62
|
+
# hand-built "$FH/.git/hooks" is simply wrong — FH runs worktree-isolated agents, so that path
|
|
63
|
+
# is reachable, not hypothetical).
|
|
64
|
+
if ! git -C "$FH" rev-parse --git-dir >/dev/null 2>&1; then
|
|
65
|
+
# NOT a git repo (plugin-only / marketplace install, or a non-repo directory). A git hook cannot
|
|
66
|
+
# be installed here at all, so this floor is N/A — not missing. Applicability is decided
|
|
67
|
+
# mechanically, per CLAUDE.md §Irreversibility Surface-Class: reporting it would print an
|
|
68
|
+
# UNFIXABLE notice every session (emission is state-based), training the reader to ignore the
|
|
69
|
+
# one check that must not be ignored.
|
|
70
|
+
:
|
|
71
|
+
else
|
|
72
|
+
# --path-format needs git >= 2.31; fall back to the relative form resolved against the repo root
|
|
73
|
+
# (still correct in a linked worktree, where a hand-built "$FH/.git/hooks" does not exist at all).
|
|
74
|
+
HD="$(git -C "$FH" rev-parse --path-format=absolute --git-path hooks 2>/dev/null)"
|
|
75
|
+
if [ -z "$HD" ]; then
|
|
76
|
+
_rel="$(git -C "$FH" rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
|
|
77
|
+
case "$_rel" in /*) HD="$_rel" ;; *) HD="$(git -C "$FH" rev-parse --show-toplevel 2>/dev/null || echo "$FH")/$_rel" ;; esac
|
|
78
|
+
fi
|
|
79
|
+
for h in pre-commit pre-push; do
|
|
80
|
+
if [ ! -x "$HD/$h" ]; then
|
|
81
|
+
MISS="${MISS}no executable ${h} hook · "
|
|
82
|
+
elif ! grep -qE 'fh-gate|FH .*Gate|4-Axis|4축' "$HD/$h" 2>/dev/null; then
|
|
83
|
+
# Executable is not the same proposition as OURS. husky and pre-commit-framework are standard
|
|
84
|
+
# equipment in the JS/Python projects FH maps, and they install an executable pre-commit that
|
|
85
|
+
# runs a linter — under an executable-only probe such a machine reports "floors present" and
|
|
86
|
+
# then goes silent, which is precisely the accident this check exists to prevent.
|
|
87
|
+
MISS="${MISS}${h} hook present but not FH's gate (another framework owns it) · "
|
|
88
|
+
fi
|
|
89
|
+
done
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
# ② companion-load hook (Mode D only). Reported as INFORMATION, never as a missing floor: this hook
|
|
93
|
+
# is registered for ALL users, and a public non-Mode-D user has no companion store to load, so
|
|
94
|
+
# listing it under ❌ would be a false positive for the majority path.
|
|
95
|
+
# APPLICABILITY: "is this a Mode D user", NOT "does settings.local.json exist". Keying on that
|
|
96
|
+
# file was wrong in the worst possible way — it is gitignored, so a FRESH CLONE never has it, and
|
|
97
|
+
# a fresh clone with a full companion store is EXACTLY the measured 2026-07-30 incident. The gate
|
|
98
|
+
# silenced its own flagship case. Mode D signals that survive a clone: an exported BE_DIR, or the
|
|
99
|
+
# operator's CLAUDE.local.md binding.
|
|
100
|
+
COMPANION_NOTE=""
|
|
101
|
+
_IS_MODE_D=""
|
|
102
|
+
{ [ -n "${BE_DIR:-}" ] && [ -d "$BE_DIR" ]; } && _IS_MODE_D=1
|
|
103
|
+
# CLAUDE.local.md is Claude Code's STANDARD local-override file — anyone may have one for any
|
|
104
|
+
# reason, and having one says nothing about a companion store. Keying on its EXISTENCE re-admitted
|
|
105
|
+
# the majority-path false positive through a second door, and state-based emission made it permanent
|
|
106
|
+
# rather than one-shot (cross-family review 2026-07-30). So key on the file MENTIONING a companion
|
|
107
|
+
# binding instead.
|
|
108
|
+
# The vocabulary spans every backend the wizard documents — the store is a ROLE, not a repo layout
|
|
109
|
+
# (Obsidian vault · gbrain ingest target · *-be repo all qualify), and an FH-flavoured regex would
|
|
110
|
+
# have silently excluded two first-class backends: the same "flagship case goes quiet" shape as the
|
|
111
|
+
# fresh-clone defect, one door over.
|
|
112
|
+
# HONEST SCOPE: this is a MENTION test, not a semantic one. "I do not use a companion store" also
|
|
113
|
+
# matches. The cost of that over-match is one informational line, never a floor claim — deliberately
|
|
114
|
+
# the cheap direction, since the expensive direction is silence.
|
|
115
|
+
[ -f "$FH/CLAUDE.local.md" ] \
|
|
116
|
+
&& grep -qiE 'BE_DIR|companion[ -]store|컴패니언|vault|gbrain|obsidian' "$FH/CLAUDE.local.md" 2>/dev/null \
|
|
117
|
+
&& _IS_MODE_D=1
|
|
118
|
+
if [ -n "$_IS_MODE_D" ] && ! command -v python3 >/dev/null 2>&1; then
|
|
119
|
+
# not found ≠ 0: without a JSON parser the registration verdict is unknown, not clean.
|
|
120
|
+
COMPANION_NOTE="companion-load registration UNMEASURED (no python3 — cannot parse the hook config; do not read this as 'registered')"
|
|
121
|
+
elif [ -n "$_IS_MODE_D" ]; then
|
|
122
|
+
python3 - "$FH" <<'PY' || COMPANION_NOTE="companion-load SessionStart not registered (Mode D — freshness + env-delta will not fire at turn 0)"
|
|
123
|
+
import json, os, sys
|
|
124
|
+
hub = sys.argv[1]
|
|
125
|
+
for p in (os.path.join(hub, ".claude", "settings.local.json"),
|
|
126
|
+
os.path.expanduser("~/.claude/settings.json")):
|
|
127
|
+
try:
|
|
128
|
+
groups = json.load(open(p)).get("hooks", {}).get("SessionStart", [])
|
|
129
|
+
except Exception:
|
|
130
|
+
continue
|
|
131
|
+
if any("fh_session_load" in h.get("command", "") for g in groups for h in g.get("hooks", [])):
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
PY
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# ── event: infra delta since the commit this clone last saw ───────────────────
|
|
138
|
+
# Reported ONCE per pull (it is an event). Distinguishes "no change" from "could not measure".
|
|
139
|
+
INFRA=""; INFRA_NOTE=""
|
|
140
|
+
if [ -n "$PREV_HEAD" ] && [ "$PREV_HEAD" != "$HEAD_NOW" ]; then
|
|
141
|
+
if INFRA_RAW="$(git -C "$FH" diff --name-only "${PREV_HEAD}..HEAD" 2>/dev/null)"; then
|
|
142
|
+
INFRA="$(printf '%s\n' "$INFRA_RAW" \
|
|
143
|
+
| grep -E '^(templates/\.git-hooks/|templates/settings\.|scripts/fh_|plugins/[^/]+/skills/install-(wizard|doctor)/)' \
|
|
144
|
+
| head -6)"
|
|
145
|
+
else
|
|
146
|
+
INFRA_NOTE="UNMEASURED — cannot reach the previously seen commit ($PREV_HEAD), so the infra delta was not computed (rebase, shallow clone, or GC). Not the same as 'nothing changed'."
|
|
147
|
+
fi
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
IDENTITY=""
|
|
151
|
+
if [ -z "$PREV_ID" ]; then IDENTITY="first session for this clone"
|
|
152
|
+
elif [ "$PREV_ID" != "$NODE_ID" ]; then IDENTITY="machine changed ($PREV_ID → $NODE_ID)"; fi
|
|
153
|
+
|
|
154
|
+
# ── state write — unconditional, and a failure is reported, never swallowed ────
|
|
155
|
+
# Writing only when the check speaks would make the recorded timestamp mean "last time it spoke",
|
|
156
|
+
# and a write failure would make this banner repeat forever with no explanation.
|
|
157
|
+
STATE_WARN=""
|
|
158
|
+
if ! { mkdir -p "$(dirname "$STATE")" 2>/dev/null \
|
|
159
|
+
&& printf '%s|%s|%s' "$NODE_ID" "$NOW" "$HEAD_NOW" > "$STATE" 2>/dev/null; }; then
|
|
160
|
+
STATE_WARN="could not record node state ($STATE) — this notice may repeat every session"
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
# ── emit: condition (every session) OR event (once) ───────────────────────────
|
|
164
|
+
[ -n "$MISS$COMPANION_NOTE$INFRA$INFRA_NOTE$IDENTITY$STATE_WARN" ] || exit 0
|
|
165
|
+
|
|
166
|
+
if [ -n "$MISS" ]; then
|
|
167
|
+
echo "🖥️ [node] Missing mechanical floor on this machine (node: $NODE_ID): ${MISS% · }"
|
|
168
|
+
echo " → Run /install-doctor, then /install-wizard. A rich context (memory, companion store)"
|
|
169
|
+
echo " is a different proposition from this machine being wired."
|
|
170
|
+
elif [ -n "$IDENTITY" ]; then
|
|
171
|
+
echo "🖥️ [node] $IDENTITY (node: $NODE_ID) — floors present."
|
|
172
|
+
fi
|
|
173
|
+
[ -n "$STATE_WARN" ] && echo " ⚠️ $STATE_WARN"
|
|
174
|
+
[ -n "$COMPANION_NOTE" ] && echo " ℹ️ $COMPANION_NOTE"
|
|
175
|
+
if [ -n "$INFRA_NOTE" ]; then
|
|
176
|
+
echo " ⚠️ $INFRA_NOTE"
|
|
177
|
+
elif [ -n "$INFRA" ]; then
|
|
178
|
+
echo " 🆕 Install-relevant assets changed since this clone last ran — being registered is not"
|
|
179
|
+
echo " the same as being current, and a pull moves files without wiring hooks:"
|
|
180
|
+
printf '%s\n' "$INFRA" | while IFS= read -r f; do [ -n "$f" ] && echo " - $f"; done
|
|
181
|
+
echo " → Re-run /install-wizard (idempotent)."
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
exit 0
|
|
@@ -27,6 +27,51 @@
|
|
|
27
27
|
set -uo pipefail
|
|
28
28
|
|
|
29
29
|
FH="${HUB_DIR:-${CLAUDE_PROJECT_DIR:-$HOME/projects/forge-harness}}"
|
|
30
|
+
BE="${BE_DIR:-}" # companion-store path — supplied by the gitignored hook registration; no public default.
|
|
31
|
+
# Resolved HERE (not at the Mode-D block below) because the frontier-digest check
|
|
32
|
+
# needs it: on a multi-node setup the digest producer may be a DIFFERENT machine.
|
|
33
|
+
|
|
34
|
+
# ── node re-entry floor check ────────────────────────────────────────────────
|
|
35
|
+
# 이 검사는 scripts/fh_node_check.sh 로 분리했다. 이유: 이 파일(fh_session_load.sh)은 gitignored
|
|
36
|
+
# settings.local.json 에 등록되므로 새 클론/새 기계에선 애초에 안 돈다 — 검사가 존재 이유가 되는
|
|
37
|
+
# 상황에서 도달 불가였다(Sonnet 타깃-티어 심 2026-07-30 지적).
|
|
38
|
+
# ⚠️ 분리해도 자동 배선은 아니다: .claude/settings.json 도 gitignored 라(.gitignore:3-4) 등록 자체는
|
|
39
|
+
# 추적될 수 없다. 추적되는 건 templates/settings.SessionStart.snippet.json 이고, 배선은 위자드가 한다.
|
|
40
|
+
# 여기서 다시 호출하지 않는다: 두 곳에서 부르면 같은 이벤트를 두 번 찍는다.
|
|
41
|
+
|
|
42
|
+
# ── §early-refresh: 컴패니언 refresh 를 frontier 판정보다 먼저 한다 ─────────────
|
|
43
|
+
# 왜 순서가 문제인가: frontier 판정은 러너가 아닌 노드에서 $BE/tracks-meta 를 본다. refresh 가
|
|
44
|
+
# 그 뒤에 있으면 **그날의 첫 세션**은 아직 안 끌어온 워킹트리를 읽어 오늘 digest 를 못 보고,
|
|
45
|
+
# 이 수정이 없애려던 바로 그 오경보를 그대로 낸다(하루 한 번, 가장 값진 시점에서 실패).
|
|
46
|
+
# cross-family 리뷰 2026-07-30 [HIGH] 지적. 비-Mode-D(공개) 사용자는 BE 가 비어 통째로 건너뛴다.
|
|
47
|
+
PULL_NOTE="(no companion store configured)"
|
|
48
|
+
if [ -d "$BE/.git" ]; then
|
|
49
|
+
export GIT_TERMINAL_PROMPT=0
|
|
50
|
+
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new}"
|
|
51
|
+
|
|
52
|
+
# Hard wall-clock deadline on the ONLY network step. ConnectTimeout bounds the handshake, but a
|
|
53
|
+
# slow/stalled TRANSFER after connect has no bound — measured 2026-07-12: SessionStart worst-case
|
|
54
|
+
# 17.5s with 2 hook-timeout kills, all attributable to the fetch. perl-alarm is the portable
|
|
55
|
+
# watchdog (macOS ships no coreutils `timeout`); on overrun the fetch dies and the offline branch
|
|
56
|
+
# reports honestly. FH_FETCH_DEADLINE overrides (seconds). If perl is absent the wrapper degrades
|
|
57
|
+
# to running the command with NO deadline — a missing watchdog must never become a permanently
|
|
58
|
+
# skipped fetch misreported as "offline" (challenger catch 2026-07-12).
|
|
59
|
+
if command -v perl >/dev/null 2>&1; then
|
|
60
|
+
_deadline() { perl -e 'alarm shift @ARGV; exec @ARGV' "$@"; }
|
|
61
|
+
else
|
|
62
|
+
_deadline() { shift; "$@"; }
|
|
63
|
+
fi
|
|
64
|
+
PULL_NOTE=""
|
|
65
|
+
if _deadline "${FH_FETCH_DEADLINE:-8}" git -C "$BE" fetch --quiet >/dev/null 2>&1; then
|
|
66
|
+
if git -C "$BE" merge --ff-only --quiet >/dev/null 2>&1; then
|
|
67
|
+
PULL_NOTE="fetched + fast-forwarded"
|
|
68
|
+
else
|
|
69
|
+
PULL_NOTE="fetched but NOT fast-forward (companion diverged — read local + newest remote)"
|
|
70
|
+
fi
|
|
71
|
+
else
|
|
72
|
+
PULL_NOTE="fetch skipped (offline or deadline hit — read local state)"
|
|
73
|
+
fi
|
|
74
|
+
fi
|
|
30
75
|
|
|
31
76
|
# ── frontier-digest: 부재를 0으로 읽지 않는다 ────────────────────────────────────
|
|
32
77
|
# 왜: digest 는 launchd 로 매일 09:00 에 돌지만 **31회 중 6회(19%) 산출물 없이 끝났다**
|
|
@@ -51,9 +96,19 @@ _FD_STAMP="$(date +%H:%M) 기준"
|
|
|
51
96
|
# 존재 판정은 러너 digest_ready 와 동일 술어(glob + -size +1k). 정확명 [ -f ] 는 러너와 관대함이
|
|
52
97
|
# 갈린다 — partial 파일(>0 <1k)이 성공으로 오독되고, suffix 착지가 영구 오경보가 된다
|
|
53
98
|
# (divergent-leniency: 같은 상태를 두 술어가 다르게 읽으면 한쪽 결과가 무음으로 샌다).
|
|
54
|
-
|
|
99
|
+
_fd_hit() { find "$1" -maxdepth 1 -name "frontier_digest_$(date +%Y_%m_%d)*.md" -size +1k 2>/dev/null | grep -q .; }
|
|
100
|
+
# 노드-로컬만 보면 **다른 머신이 만든 산출물이 구조적으로 안 보인다**. 멀티머신에선 러너가 한 대이고
|
|
101
|
+
# 나머지 노드는 컴패니언 스토어로만 그 산출물을 받는다 → 러너 아닌 노드가 매일 "실패다" 오경보를 낸다.
|
|
102
|
+
# (2026-07-30 실측: 프로가 07-24~30 매일 정상 생산 중인데 에어는 7일 연속 FAILED 를 띄웠다.
|
|
103
|
+
# 계기의 스코프가 대상보다 좁았던 케이스 — 대상은 '오늘 digest 가 있나'지 '이 디스크에 있나'가 아니다.)
|
|
104
|
+
# 술어는 로컬과 **동일**(glob + -size +1k) — divergent-leniency 를 만들지 않는다.
|
|
105
|
+
_fd_ready() { _fd_hit "$FH/tracks/_meta" || { [ -n "$BE" ] && _fd_hit "$BE/tracks-meta"; }; }
|
|
55
106
|
if _fd_ready; then
|
|
56
|
-
|
|
107
|
+
# 로컬엔 없고 컴패니언에만 있으면 = 이 노드는 러너가 아니다. 침묵하면 토폴로지가 안 보이므로 한 줄 알린다.
|
|
108
|
+
if ! _fd_hit "$FH/tracks/_meta"; then
|
|
109
|
+
echo "ℹ️ [frontier-digest] 오늘 digest 는 **다른 노드**가 생산했다(컴패니언 스토어 경유) — 이 노드는 러너가 아니다."
|
|
110
|
+
echo " 읽을 것: \$BE_DIR/tracks-meta/frontier_digest_$(date +%Y_%m_%d)*.md"
|
|
111
|
+
fi
|
|
57
112
|
elif [ "$((10#$_FD_NOW))" -lt "$_FD_SCHED" ]; then
|
|
58
113
|
echo "ℹ️ [frontier-digest] 오늘 digest 는 09:00 예정 — 아직 전이다($_FD_STAMP). 부재는 정상."
|
|
59
114
|
elif [ -f "$_FD_LOG" ]; then
|
|
@@ -77,7 +132,7 @@ elif [ -f "$_FD_LOG" ]; then
|
|
|
77
132
|
else
|
|
78
133
|
echo "⚠️ [frontier-digest] 스케줄(09:00) 지났는데 로그도 산출물도 없다($_FD_STAMP) — 잡이 아예 안 돌았을 수 있다(launchd 확인)."
|
|
79
134
|
fi
|
|
80
|
-
|
|
135
|
+
# (BE is resolved at the top of this script — the frontier-digest block above needs it too.)
|
|
81
136
|
|
|
82
137
|
# Non-Mode-D / no companion store → silent no-op (this is the majority path for public users).
|
|
83
138
|
[ -d "$BE/.git" ] || exit 0
|
|
@@ -89,31 +144,7 @@ BE="${BE_DIR:-}" # companion-store path — supplied by the gitignored hook re
|
|
|
89
144
|
# - fail-fast env: no credential/SSH/host-key prompts can hang SessionStart.
|
|
90
145
|
# - fetch + merge --ff-only: a fast-forward is the only safe hook mutation; a diverged companion
|
|
91
146
|
# simply does not advance (no merge commit, no conflict state left behind) and we say so.
|
|
92
|
-
|
|
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
|
|
147
|
+
# (컴패니언 refresh 는 위 §early-refresh 로 올렸다 — frontier 판정이 최신 트리를 보게 하려고.)
|
|
117
148
|
|
|
118
149
|
# 2) Session card date (the pointer the operator's close chain writes last).
|
|
119
150
|
CARD="$FH/tracks/_meta/reference_next_session_starter.md"
|
|
@@ -43,6 +43,10 @@ fi
|
|
|
43
43
|
# no shipped hook invokes it.
|
|
44
44
|
ACCEPTED_ABSENT=(
|
|
45
45
|
".claude/registry/LOCAL_SKILL_REGISTRY.md"
|
|
46
|
+
# An INSTALL DESTINATION the user creates (`cp templates/local_fh_context.md
|
|
47
|
+
# .claude/rules/local_fh_context.md`), not a file FH ships. Shipping it would overwrite the
|
|
48
|
+
# user's own cross-context wiring — the template it is copied FROM is what ships.
|
|
49
|
+
".claude/rules/local_fh_context.md"
|
|
46
50
|
".claude/regression/probes.md"
|
|
47
51
|
"scripts/sync-to-be.sh"
|
|
48
52
|
"scripts/sync_guard_check.sh"
|
|
@@ -83,6 +87,24 @@ for s in shipped:
|
|
|
83
87
|
# Only a path that REALLY EXISTS here but is left out of the tarball is this defect.
|
|
84
88
|
# A path that exists nowhere is the ordinary phantom-reference class the ref-path
|
|
85
89
|
# check above already owns; a path outside files[] that is also absent is nothing.
|
|
90
|
+
# EXISTENCE, not tracked-ness. A 2026-07-30 revision narrowed this to `git ls-files`
|
|
91
|
+
# to silence what looked like a machine-local false positive; measurement showed that was a
|
|
92
|
+
# WEAKENING — an existing-but-untracked path named by a shipped doc is exactly the defect
|
|
93
|
+
# (the npm user cannot have that file), and selfcheck's ref-path check SKIPs gitignored
|
|
94
|
+
# paths, so nothing else owns it. Reverted.
|
|
95
|
+
#
|
|
96
|
+
# WIDENING IS DEFERRED, AND THE REASON IS NOT A MEASUREMENT. Dropping `exists` entirely
|
|
97
|
+
# (flag every referenced ∧ ¬covered path) is arguably the correct predicate, but it cannot
|
|
98
|
+
# be evaluated while the extractor below is known-broken: its `(sh|py|js|md|json|…)`
|
|
99
|
+
# alternation puts `js` before `json`, so `settings.json` is captured as `settings.js`.
|
|
100
|
+
# A first pass at this comment cited a count of artifacts as evidence that `exists` is
|
|
101
|
+
# load-bearing — that count came FROM the broken extractor, i.e. an instrument was used to
|
|
102
|
+
# justify keeping a predicate before the instrument itself was validated (the circularity
|
|
103
|
+
# CLAUDE.md §Instrument-Calibration exists to forbid; a cross-family review caught it, and
|
|
104
|
+
# an independent extractor produced materially different numbers).
|
|
105
|
+
# HONEST STATE: fix the `js|json` alternation first, re-measure, then decide. Until then
|
|
106
|
+
# this check's true coverage is UNQUANTIFIED — treat a PASS as "no defect of the narrow
|
|
107
|
+
# exists-and-uncovered kind", not as "every shipped reference is sound".
|
|
86
108
|
if os.path.exists(m) and not covered(m):
|
|
87
109
|
if m in accepted:
|
|
88
110
|
exercised.add(m)
|
package/scripts/selfcheck.sh
CHANGED
|
@@ -132,21 +132,6 @@ fi
|
|
|
132
132
|
# prevent. test_card_drift_probe.sh had shipped with ZERO callers since it was written; wiring it
|
|
133
133
|
# here closes that, and the anchors are added to files[] in the same change so package mode runs
|
|
134
134
|
# them too rather than reporting a deleted anchor.
|
|
135
|
-
# consent-class registry floor. Its subject decides whether standing consent may skip an approval
|
|
136
|
-
# prompt, so an uncalibrated instrument there hands out autonomy the operator never granted. The
|
|
137
|
-
# anchor was written into tests/ with ZERO callers first — the same defect this file already
|
|
138
|
-
# records twice above; wiring it here is the fix, not a note about the fix.
|
|
139
|
-
if [ ! -f scripts/consent_registry_check.sh ]; then
|
|
140
|
-
echo "SKIP test_consent_registry.sh (subject scripts/consent_registry_check.sh absent)"
|
|
141
|
-
elif [ -f scripts/test_consent_registry.sh ]; then
|
|
142
|
-
if ! bash scripts/test_consent_registry.sh; then
|
|
143
|
-
fail=1
|
|
144
|
-
fi
|
|
145
|
-
else
|
|
146
|
-
echo "FAIL test_consent_registry.sh: consent_registry_check.sh present but its anchor is missing"
|
|
147
|
-
fail=1
|
|
148
|
-
fi
|
|
149
|
-
|
|
150
135
|
# sidecar_wait stdin plumbing. Its subject is dispatched by auto-decorrelation / steel-quench /
|
|
151
136
|
# sim-conductor / AGENTS.md as the REQUIRED wait form, so a regression there silently empties every
|
|
152
137
|
# cross-family verification. The anchor's first version shipped in tests/ with ZERO callers — the
|
|
@@ -162,6 +147,36 @@ else
|
|
|
162
147
|
fail=1
|
|
163
148
|
fi
|
|
164
149
|
|
|
150
|
+
# fh_node_check.sh gets the same treatment, and for the same reason: three adversarial rounds on it
|
|
151
|
+
# produced defects that were ALL negative legs (floor N/A on a non-git install · another framework's
|
|
152
|
+
# hooks counted as ours · the Mode D applicability gate silencing its own flagship case), and each
|
|
153
|
+
# round's fix reverted a previous one because no anchor pinned it. Subject-present-but-anchor-absent
|
|
154
|
+
# is a FAIL, not a skip — that is how an anchor gets quietly dropped.
|
|
155
|
+
# Same treatment for the sidecar calibrator, same reason: its verdicts are all distinctions between
|
|
156
|
+
# states that look identical from outside ("the sidecar ran" vs "the model I pinned answered",
|
|
157
|
+
# "absent" vs "unmeasured"), and its lanes are hermetic stubs, so running them costs nothing.
|
|
158
|
+
if [ ! -f scripts/sidecar_calibrate.sh ]; then
|
|
159
|
+
echo "SKIP test_sidecar_calibrate_lanes.sh (subject scripts/sidecar_calibrate.sh absent)"
|
|
160
|
+
elif [ -f scripts/test_sidecar_calibrate_lanes.sh ]; then
|
|
161
|
+
if ! bash scripts/test_sidecar_calibrate_lanes.sh; then
|
|
162
|
+
fail=1
|
|
163
|
+
fi
|
|
164
|
+
else
|
|
165
|
+
echo "FAIL test_sidecar_calibrate_lanes.sh: sidecar_calibrate.sh present but its anchor is missing"
|
|
166
|
+
fail=1
|
|
167
|
+
fi
|
|
168
|
+
|
|
169
|
+
if [ ! -f scripts/fh_node_check.sh ]; then
|
|
170
|
+
echo "SKIP test_node_check_lanes.sh (subject scripts/fh_node_check.sh absent)"
|
|
171
|
+
elif [ -f scripts/test_node_check_lanes.sh ]; then
|
|
172
|
+
if ! bash scripts/test_node_check_lanes.sh; then
|
|
173
|
+
fail=1
|
|
174
|
+
fi
|
|
175
|
+
else
|
|
176
|
+
echo "FAIL test_node_check_lanes.sh: fh_node_check.sh present but its anchor is missing"
|
|
177
|
+
fail=1
|
|
178
|
+
fi
|
|
179
|
+
|
|
165
180
|
for _anchor in scripts/test_session_close_lanes.sh scripts/test_card_drift_probe.sh; do
|
|
166
181
|
if [ ! -f scripts/session_close_check.sh ]; then
|
|
167
182
|
echo "SKIP ${_anchor##*/} (subject scripts/session_close_check.sh absent)"
|