@chrono-meta/fh-gate 1.4.77 → 1.4.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.claude/rules/fh_4axis_gate.md +63 -0
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/AGENTS.md +96 -260
  4. package/CLAUDE.md +2 -7
  5. package/docs/codex-compat.md +4 -1
  6. package/knowledge/shared/harness-core/agents_md_runtime_details.md +233 -0
  7. package/knowledge/shared/harness-core/loop_engineering.md +1 -1
  8. package/knowledge/shared/harness-core/multi_model_sidecar_strategy.md +1 -1
  9. package/knowledge/shared/learnings/subagent_invocations_log.yaml +14 -0
  10. package/knowledge/shared/rules/operational_adaptation.md +1 -130
  11. package/package.json +14 -5
  12. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  13. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  14. package/plugins/fh-meta/skills/install-doctor/SKILL.md +88 -0
  15. package/plugins/fh-meta/skills/install-wizard/SKILL.md +1 -1
  16. package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +117 -3
  17. package/scripts/fh_node_check.sh +184 -0
  18. package/scripts/fh_session_load.sh +101 -31
  19. package/scripts/halffix_propagation_scan.sh +126 -0
  20. package/scripts/package_coverage_check.sh +40 -1
  21. package/scripts/pipe_verdict_guard.sh +93 -0
  22. package/scripts/selfcheck.sh +114 -21
  23. package/scripts/session_close_check.sh +15 -1
  24. package/scripts/sidecar_calibrate.sh +275 -0
  25. package/scripts/test_card_drift_probe.sh +55 -0
  26. package/scripts/test_halffix_lanes.sh +170 -0
  27. package/scripts/test_node_check_lanes.sh +179 -0
  28. package/scripts/test_ollama_panel_lanes.sh +120 -0
  29. package/scripts/test_package_coverage_lanes.sh +250 -0
  30. package/scripts/test_pipe_verdict_guard_lanes.sh +96 -0
  31. package/scripts/test_sidecar_calibrate_lanes.sh +218 -0
  32. package/scripts/test_sidecar_wait_stdin.sh +13 -1
  33. package/templates/.git-hooks/pre-commit +9 -0
  34. package/templates/settings.PreToolUse.snippet.json +49 -0
  35. package/templates/settings.SessionStart.snippet.json +54 -0
  36. package/scripts/consent_registry_check.sh +0 -390
  37. package/scripts/test_consent_registry.sh +0 -255
  38. 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 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,35 @@ _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"; }; }
106
+ # THE SECOND HALF OF THE SAME SCOPE BUG (2026-07-31). The comment above got the principle right —
107
+ # "대상은 '오늘 digest 가 있나'지 '이 디스크에 있나'가 아니다" — and then widened the predicate by
108
+ # exactly ONE surface (the companion store), leaving it file-only. There are TWO live producers:
109
+ # the launchd file runner AND an app routine that posts the digest as a comment on GitHub issue
110
+ # #102 (measured 2026-07-31: 47 comments, one per day, including today's at 09:09 KST). So on a day
111
+ # when the file runner fails, today's digest EXISTS and the hook said "부재가 아니라 실패다" — a
112
+ # true statement about this disk stated as a claim about the digest.
113
+ # The fix is NOT to call the network from a SessionStart hook (it must never block turn 0 and must
114
+ # never fail on an offline node). It is to stop over-claiming: report the scope actually measured
115
+ # ("this node's file output failed") and NAME the surface not measured, so the reader checks it in
116
+ # one step instead of re-running a job whose output already exists elsewhere.
117
+ _fd_issue_note() {
118
+ echo " ⓘ 파일만 본 판정이다 — 오늘치는 **GH issue #102**(앱 routine, 매일 ~09:09 KST)에 이미 있을 수 있다."
119
+ echo " 확인: gh issue view 102 --repo chrono-meta/forge-harness --comments | tail -40"
120
+ echo " → 파일 재생성이 필요한지는 그걸 보고 판단하라. '오늘은 뉴스 없음'으로 읽지 말 것."
121
+ }
55
122
  if _fd_ready; then
56
- :
123
+ # 로컬엔 없고 컴패니언에만 있으면 = 이 노드는 러너가 아니다. 침묵하면 토폴로지가 안 보이므로 한 줄 알린다.
124
+ if ! _fd_hit "$FH/tracks/_meta"; then
125
+ echo "ℹ️ [frontier-digest] 오늘 digest 는 **다른 노드**가 생산했다(컴패니언 스토어 경유) — 이 노드는 러너가 아니다."
126
+ echo " 읽을 것: \$BE_DIR/tracks-meta/frontier_digest_$(date +%Y_%m_%d)*.md"
127
+ fi
57
128
  elif [ "$((10#$_FD_NOW))" -lt "$_FD_SCHED" ]; then
58
129
  echo "ℹ️ [frontier-digest] 오늘 digest 는 09:00 예정 — 아직 전이다($_FD_STAMP). 부재는 정상."
59
130
  elif [ -f "$_FD_LOG" ]; then
@@ -70,14 +141,37 @@ elif [ -f "$_FD_LOG" ]; then
70
141
  if [ -n "$_FD_ALIVE" ]; then
71
142
  echo "ℹ️ [frontier-digest] 잡이 아직 돌고 있는 중일 수 있다($_FD_STAMP, $_FD_ALIVE) — 실패 판정 보류, 나중에 재확인."
72
143
  else
73
- echo "⚠️ [frontier-digest] 오늘 잡은 돌았는데 **산출물이 없다**($_FD_STAMP) — 부재가 아니라 실패다."
144
+ echo "⚠️ [frontier-digest] **이 노드의 파일 산출**이 실패했다($_FD_STAMP) — 부재가 아니라 실패다."
74
145
  echo " 마지막 로그: $(tail -1 "$_FD_LOG" 2>/dev/null | cut -c1-90)"
75
- echo " → 수동 재실행하거나 실패 원인을 보라. '오늘은 뉴스 없음'으로 읽지 말 것."
146
+ _fd_issue_note
76
147
  fi
77
148
  else
78
- echo "⚠️ [frontier-digest] 스케줄(09:00) 지났는데 로그도 산출물도 없다($_FD_STAMP) — 잡이 아예 안 돌았을 수 있다(launchd 확인)."
149
+ echo "⚠️ [frontier-digest] 스케줄(09:00) 지났는데 로그도 파일 산출물도 없다($_FD_STAMP) — 이 노드에서 잡이 아예 안 돌았을 수 있다(launchd 확인)."
150
+ _fd_issue_note
151
+ fi
152
+ # (BE is resolved at the top of this script — the frontier-digest block above needs it too.)
153
+
154
+ # 1-b) Weekly-audit cadence — mechanical, for the same reason the frontier-digest block above is.
155
+ # MEASURED 2026-07-31: operations.md §Session start auto-detection (L1) promises "propose the audit
156
+ # if 7+ days elapsed", and the audit had not run for FIFTY days. The frontier-digest cadence, which
157
+ # is instrumented, never went a day unnoticed over the same window. The difference is not
158
+ # importance; it is that one cadence is a hook and the other is prose, and this repo's own N=3
159
+ # escalation rule says to instrument rather than to add a habit. One line in a hook that already
160
+ # runs, not a new mechanism. Advisory by design: an overdue audit is not an irreversible surface,
161
+ # so it surfaces and never blocks. Silent when current, and silent when the dir does not exist
162
+ # (a fresh clone has no audit history and must not be nagged about one).
163
+ _AUDIT_DIR="$FH/tracks/_audit"
164
+ if [ -d "$_AUDIT_DIR" ]; then
165
+ _LATEST_AUDIT="$(find "$_AUDIT_DIR" -maxdepth 1 -name 'weekly_audit_*.md' -print 2>/dev/null \
166
+ | sort | tail -1)"
167
+ if [ -n "$_LATEST_AUDIT" ]; then
168
+ _AUDIT_AGE_D=$(( ( $(date +%s) - $(_mtime "$_LATEST_AUDIT") ) / 86400 ))
169
+ if [ "$_AUDIT_AGE_D" -ge 7 ]; then
170
+ echo "🗓️ [weekly-audit] 마지막 감사가 ${_AUDIT_AGE_D}일 전이다($(basename "$_LATEST_AUDIT")) — 캐던스는 7일."
171
+ echo " → /harvest-loop (lightweight) 또는 operations.md §Weekly Improvement Cycle 수동 절차."
172
+ fi
173
+ fi
79
174
  fi
80
- BE="${BE_DIR:-}" # companion-store path — supplied by the gitignored hook registration; no public default.
81
175
 
82
176
  # Non-Mode-D / no companion store → silent no-op (this is the majority path for public users).
83
177
  [ -d "$BE/.git" ] || exit 0
@@ -89,31 +183,7 @@ BE="${BE_DIR:-}" # companion-store path — supplied by the gitignored hook re
89
183
  # - fail-fast env: no credential/SSH/host-key prompts can hang SessionStart.
90
184
  # - fetch + merge --ff-only: a fast-forward is the only safe hook mutation; a diverged companion
91
185
  # 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
186
+ # (컴패니언 refresh 는 위 §early-refresh 로 올렸다 — frontier 판정이 최신 트리를 보게 하려고.)
117
187
 
118
188
  # 2) Session card date (the pointer the operator's close chain writes last).
119
189
  CARD="$FH/tracks/_meta/reference_next_session_starter.md"
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ # halffix_propagation_scan.sh — pre-commit advisory: this fix may have landed in only one copy.
3
+ #
4
+ # THE DEFECT — "반쪽-수리" (half-fix)
5
+ # A defect class lives in N sibling copies. The fix lands in ONE and nothing says so. Measured 3x
6
+ # in this project, and the shape is worse than the count: every one of the three occurred INSIDE
7
+ # an edit that was itself repairing an earlier half-fix. scripts/psa_scan_lib.sh's header records
8
+ # five such divergences found in a single 2026-07-26 audit — every confidentiality defect that
9
+ # audit found was a divergence between duplicated copies, not a flaw in the idea.
10
+ #
11
+ # WHAT IT DOES
12
+ # Takes the distinctive symbols and path literals touched by the staged diff, re-greps the tree,
13
+ # and NAMES the tracked files that carry the same token but are not staged. That is the whole
14
+ # contribution: the author already knows what they fixed; what they lose is the sibling.
15
+ #
16
+ # MARK, DO NOT BLOCK — this is mandated, not preferred. The spec for this debt is explicit:
17
+ # "표시(차단 아님 — 정당한 복제도 있다)". Legitimate duplication exists (templates/ ships a
18
+ # field-propagated copy of scripts/ ON PURPOSE, and selfcheck asserts they stay byte-identical).
19
+ # A detector that blocks on correct duplication is a detector that gets disabled.
20
+ #
21
+ # THE DISCRIMINATOR — if every copy is staged, the fix propagated and this stays SILENT.
22
+ # Without that, the scan fires loudest exactly when the author did the right thing. Two prior
23
+ # claims on this same mistake in this repo: S5 (9/9 false positives, narrowed 2026-07-28) and
24
+ # S6 (0 true positives on the planned surface, retargeted 2026-07-31). Lane N3 pins it.
25
+ #
26
+ # Usage: bash scripts/halffix_propagation_scan.sh # reads the staged diff of $PWD
27
+ # Opt out: put `noqa: half-fix` on any added line in the commit.
28
+
29
+ set -u
30
+ cd "$(git rev-parse --show-toplevel 2>/dev/null || echo .)" || exit 0
31
+ git rev-parse --git-dir >/dev/null 2>&1 || exit 0
32
+
33
+ # Deletions are excluded (ACM): a removed file's symbols surviving elsewhere is not a half-fix,
34
+ # it is the normal state of a deletion, and flagging it would be pure noise.
35
+ STAGED=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)
36
+ [ -n "$STAGED" ] || exit 0
37
+
38
+ DIFF=$(git diff --cached -U0 --diff-filter=ACM 2>/dev/null)
39
+ printf '%s' "$DIFF" | grep -qE '^\+.*noqa:?[[:space:]]*half-fix' && exit 0
40
+
41
+ # Anchor tokens, from CHANGED lines only (added and removed — a removed spelling is exactly what a
42
+ # sibling may still carry). Two shapes:
43
+ # · identifiers >= 10 chars — long enough that a collision is meaningful. Shell/py keywords and
44
+ # the everyday vocabulary (`then`, `echo`, `return`, `local`) are all shorter, so the length
45
+ # floor does the keyword filtering without a denylist to maintain. (Lane N6.)
46
+ # · path literals `a/b` — the spec names filenames as anchors alongside symbols. (Lane P7.)
47
+ # The ENCLOSING function counts as a changed symbol even when the edited line itself carries no
48
+ # distinctive token — and a half-fix is a function-level thing, so this is the common case, not an
49
+ # edge one. git already hands it over in the hunk header (`@@ -2 +2 @@ psa_low_allowlisted() {`),
50
+ # so the context comes for free rather than from a hand-rolled scope parser.
51
+ # Found by lane P1 failing: the fix edited only a `case` line, whose longest token was 9 chars, and
52
+ # the scan went silent on a textbook two-copy divergence.
53
+ CHANGED=$( { printf '%s\n' "$DIFF" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)'
54
+ printf '%s\n' "$DIFF" | sed -n 's/^@@ .* @@ //p'
55
+ } )
56
+ TOKENS=$( { printf '%s\n' "$CHANGED" | grep -oE '[A-Za-z_][A-Za-z0-9_-]{9,}'
57
+ printf '%s\n' "$CHANGED" | grep -oE '[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+'
58
+ } | sort -u )
59
+ # NO early exit here. R1 (tokens) and R2 (whole-file copies) are INDEPENDENT rules, and an empty
60
+ # token set is the normal state for a short edit — `a() { :; }` → `a() { echo fixed; }` carries no
61
+ # 10-char anchor at all. An early `exit 0` on empty tokens silently disabled R2 for exactly the
62
+ # edits R2 exists to catch. (Caught by lane R2p, 2026-07-31, after the same shape had already
63
+ # passed 10/10 in the other lanes — a rule can be correct and unreachable.)
64
+
65
+ # Cap, and SAY SO when it bites. A silent truncation reads as "checked everything" when it did not.
66
+ MAX_TOKENS="${HALFFIX_MAX_TOKENS:-60}"
67
+ TOTAL=$(printf '%s\n' "$TOKENS" | grep -c .)
68
+ if [ "$TOTAL" -gt "$MAX_TOKENS" ]; then
69
+ echo " ℹ️ half-fix scan: $TOTAL anchor tokens in this diff, examining the first $MAX_TOKENS (raise with HALFFIX_MAX_TOKENS)." >&2
70
+ TOKENS=$(printf '%s\n' "$TOKENS" | head -n "$MAX_TOKENS")
71
+ fi
72
+
73
+ # A token in many files is framework vocabulary, not a duplicated fix site. (Lane N5.)
74
+ MAX_FILES="${HALFFIX_MAX_FILES:-8}"
75
+ staged_has() { printf '%s\n' "$STAGED" | grep -qxF "$1"; }
76
+
77
+ hits=""
78
+ while IFS= read -r tok; do
79
+ [ -n "$tok" ] || continue
80
+ files=$(git grep -l --fixed-strings -e "$tok" -- . 2>/dev/null)
81
+ [ -n "$files" ] || continue
82
+ n=$(printf '%s\n' "$files" | grep -c .)
83
+ [ "$n" -le "$MAX_FILES" ] || continue
84
+ others=""
85
+ while IFS= read -r f; do
86
+ [ -n "$f" ] || continue
87
+ staged_has "$f" || others="${others}${others:+, }$f"
88
+ done <<< "$files"
89
+ [ -n "$others" ] || continue # every copy staged → propagated → silent (lane N3)
90
+ hits="${hits} ⚠️ HALF-FIX \`$tok\` also lives in: $others
91
+ "
92
+ done <<< "$TOKENS"
93
+
94
+ # ── R2 — whole-file copy divergence. ─────────────────────────────────────────────────────────
95
+ # The token rule alone missed this repo's real duplicate pair: a script's NAME lives in 11–18 files
96
+ # here (docs, CATALOG, the manifest, selfcheck refs), so the ubiquity filter suppressed it, while an
97
+ # internal symbol like psa_low_allowlisted lives in 2. Measured 2026-07-31 — lanes 10/10 green, live
98
+ # probe silent. The lanes were necessary and not sufficient.
99
+ #
100
+ # Exact, not heuristic: the sibling was BYTE-IDENTICAL at HEAD and only one side is staged, so it is
101
+ # a divergence by construction and needs no threshold. Same-basename files that were never copies
102
+ # (CLAUDE.md vs templates/CLAUDE.md, the 40 SKILL.md files) stay silent — a basename rule would have
103
+ # flooded on exactly those.
104
+ while IFS= read -r sf; do
105
+ [ -n "$sf" ] || continue
106
+ base=$(basename "$sf")
107
+ head_blob=$(git rev-parse "HEAD:$sf" 2>/dev/null) || continue
108
+ while IFS= read -r cand; do
109
+ [ -n "$cand" ] && [ "$cand" != "$sf" ] || continue
110
+ staged_has "$cand" && continue
111
+ cand_blob=$(git rev-parse "HEAD:$cand" 2>/dev/null) || continue
112
+ [ "$cand_blob" = "$head_blob" ] || continue # were they the SAME file before this edit?
113
+ hits="${hits} ⚠️ HALF-FIX \`$sf\` was byte-identical to \`$cand\` at HEAD — only one side is staged
114
+ "
115
+ done <<< "$(git ls-files -- "*/$base" "$base" 2>/dev/null)"
116
+ done <<< "$STAGED"
117
+
118
+ [ -n "$hits" ] || exit 0
119
+
120
+ {
121
+ echo "⚠️ HALF-FIX PROPAGATION — symbols you changed also exist in files you did NOT stage."
122
+ echo " Not a verdict: templates/ ships deliberate copies of scripts/. Judge each, then proceed."
123
+ printf '%s' "$hits"
124
+ echo " Silence this commit with a \`noqa: half-fix\` comment on any added line."
125
+ } >&2
126
+ exit 0