@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.
@@ -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 caveat**: the CLAUDE.local.md session-start read is prose (no SessionStart hook) — on a weak
106
- tier it may silently not fire. Accepted limitation (mirrors `operational_adaptation.md §Guards`);
107
- revisit if a target-tier sim measures a miss.
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
- _fd_ready() { find "$FH/tracks/_meta" -maxdepth 1 -name "frontier_digest_$(date +%Y_%m_%d)*.md" -size +1k 2>/dev/null | grep -q .; }
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
- BE="${BE_DIR:-}" # companion-store path supplied by the gitignored hook registration; no public default.
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
- 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
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)
@@ -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)"