@chrono-meta/fh-gate 1.4.89 → 1.4.90

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 (35) hide show
  1. package/.claude/judgment_circuits.txt +14 -0
  2. package/.claude/rules/fh_4axis_gate.md +7 -0
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/AGENTS.md +25 -0
  5. package/CLAUDE.md +179 -12
  6. package/knowledge/shared/harness-core/dispatch_conditional_prohibition.md +105 -0
  7. package/knowledge/shared/harness-core/fh_three_layer_canon.md +165 -0
  8. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +100 -0
  9. package/knowledge/shared/harness-core/onboarding_acceleration_autopilot.md +3 -1
  10. package/knowledge/shared/harness-core/ship_readiness_gate.md +181 -13
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +528 -0
  12. package/package.json +16 -2
  13. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  14. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  15. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +56 -8
  16. package/plugins/fh-meta/skills/install-wizard/SKILL.md +33 -0
  17. package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +104 -15
  18. package/scripts/chamber_run.sh +64 -2
  19. package/scripts/chamber_witness.sh +439 -0
  20. package/scripts/compaction_probe.sh +456 -0
  21. package/scripts/digest_landing_check.sh +385 -0
  22. package/scripts/directional_diff_gate.sh +459 -0
  23. package/scripts/judgment_circuit_lint.sh +239 -0
  24. package/scripts/novelty_claim_check.sh +193 -0
  25. package/scripts/relay_channel.sh +645 -0
  26. package/scripts/reviewer_capability_corpus.tsv +124 -0
  27. package/scripts/selfcheck.sh +66 -0
  28. package/scripts/test_marker_crossfamily_lanes.sh +132 -0
  29. package/scripts/test_marker_floor_lanes.sh +9 -8
  30. package/scripts/test_relay_channel_lanes.sh +583 -0
  31. package/scripts/test_reviewer_capability_conformance.sh +173 -0
  32. package/scripts/test_wizard_snippet_merge_lanes.sh +104 -11
  33. package/scripts/utterance_landing_check.sh +209 -0
  34. package/templates/.git-hooks/pre-commit +253 -13
  35. package/templates/settings.Compaction.snippet.json +56 -0
@@ -469,6 +469,144 @@ marker_recreate_hint() {
469
469
  echo " printf 'axis2-engine: quench-challenger\\naxis2-model: opus\\nfloor-status: at-floor\\naxis2-evidence: PASS no-S\\n' > \"$1\""
470
470
  }
471
471
 
472
+ # ── Cross-family leg — typed verdict (see the load-bearing block below for WHY) ──
473
+ # Grammar: crossfamily: <VALUE>[ — <reason>]
474
+ # VALUE is a closed enum; the three degrade values additionally require a non-vacuous reason
475
+ # on the SAME line (keeping the established one-line marker convention rather than adding a
476
+ # second field). Returns 0 = accepted, 1 = block.
477
+ validate_crossfamily_leg() { # $1 = marker path
478
+ local m="$1" line val reason fams
479
+ if [ ! -f "$m" ] || ! grep -qE '^[[:space:]]*crossfamily:[[:space:]]*[^[:space:]]' "$m"; then
480
+ echo " ❌ FAIL — load-bearing file staged with no 'crossfamily:' line in the Axes 2-3 marker."
481
+ echo " Verdict/gate/irreversible-surface code shares the author's blind spot with a"
482
+ echo " same-family reviewer. State the answer, whatever it is — the gate blocks silence:"
483
+ echo " crossfamily: panel(codex, gemini) — R1..R4, 16 findings, 15 fixed 1 refuted"
484
+ echo " crossfamily: DEGRADED_SINGLE_FAMILY — probed codex/agy/gemini, 0 capable reachable"
485
+ echo " crossfamily: DEGRADED_PANEL_UNUSED — codex+agy reachable, not recruited (<why>)"
486
+ echo " crossfamily: UNKNOWN — panel not probed this round (<why>)"
487
+ echo " crossfamily: declined — operator declined sidecars (UAP), chosen floor"
488
+ echo " Append one line to: $m"
489
+ return 1
490
+ fi
491
+ # Duplicate guard (cross-family finding, codex 2026-08-08): a marker is an APPEND-ONLY
492
+ # file in practice, and `grep -m1` takes the FIRST match — so a later corrected/degraded
493
+ # value is shadowed by a stale permissive one and the lane reads the wrong verdict. Two
494
+ # crossfamily lines is an ambiguous marker, not a resolvable one; fail closed rather than
495
+ # pick a winner, because either choice silently discards a stated verdict.
496
+ if [ "$(grep -cE '^[[:space:]]*crossfamily:' "$m")" -gt 1 ]; then
497
+ echo " ❌ FAIL — marker carries MORE THAN ONE 'crossfamily:' line."
498
+ grep -nE '^[[:space:]]*crossfamily:' "$m" | sed 's/^/ /'
499
+ echo " A later line does not supersede an earlier one here — the reader takes the"
500
+ echo " first, so an appended correction would be silently shadowed. Delete the stale"
501
+ echo " line and leave exactly one."
502
+ return 1
503
+ fi
504
+ line=$(grep -m1 -E '^[[:space:]]*crossfamily:' "$m" \
505
+ | sed -E 's/^[[:space:]]*crossfamily:[[:space:]]*//')
506
+ # Split on the em-dash separator, NOT on first whitespace: a family list legitimately
507
+ # contains ", " (`panel(codex, gemini)`), and splitting on whitespace truncated it to
508
+ # `panel(codex,` — a well-formed panel rejected as malformed. An over-block on the
509
+ # correct shape is the failure that teaches --no-verify, so it is a defect, not strictness.
510
+ val=$(printf '%s' "$line" | sed -E 's/[[:space:]]*(—|--).*$//' | sed -E 's/[[:space:]]+$//')
511
+ reason=$(printf '%s' "$line" | sed -E 's/^[^—-]*(—|--)[[:space:]]*//')
512
+ [ "$reason" = "$line" ] && reason=""
513
+
514
+ case "$val" in
515
+ declined)
516
+ echo " ✅ cross-family leg: declined (operator-chosen floor, first-class — not a degrade)" ;;
517
+ panel*)
518
+ if ! printf '%s' "$val" | grep -qE '^panel\([^)]+\)$'; then
519
+ echo " ❌ FAIL — panel claim without a family list: '$val'"
520
+ echo " Write panel(<families>), e.g. panel(codex, gemini). Source the list from"
521
+ echo " scripts/sidecar_calibrate.sh's PANEL line rather than recall — that line"
522
+ echo " records a pin verified for THIS run."
523
+ return 1
524
+ fi
525
+ fams=$(printf '%s' "$val" | sed -E 's/^panel\(//; s/\)$//')
526
+ # (a) AUTHOR-FAMILY EXCLUSION. The point of this field is decorrelation; a panel of
527
+ # the author's own family is a same-family pass wearing a panel label — the exact
528
+ # substitution the load-bearing block exists to forbid. FH's governor is Claude.
529
+ if printf '%s' "$fams" | grep -qiE '(^|[,[:space:]])(claude|opus|sonnet|haiku|fable)'; then
530
+ echo " ❌ FAIL — panel names the AUTHOR'S OWN family: '$fams'"
531
+ echo " A Claude-family auditor shares the governor's blind spots, so it is not"
532
+ echo " decorrelation. If that is genuinely all that was reachable, the honest value"
533
+ echo " is DEGRADED_SINGLE_FAMILY with a reason — not a panel claim."
534
+ return 1
535
+ fi
536
+ # (b) INELIGIBLE-FIRST. Tokens overlap: GLM-OCR matches both `ocr` and `glm`,
537
+ # Qwen3-Embedding matches both `embed` and `qwen`. Test capability-eligibility first
538
+ # and both pass silently (pmh-dev #41 design decision 3, anchored there and here).
539
+ if printf '%s' "$fams" | grep -qiE 'embed|rerank|[^a-z]rank|ocr|safeguard|moderat|classif|whisper|dall-e|-rm([^a-z]|$)|bge-|voyage|gte-|minilm'; then
540
+ echo " ❌ FAIL — panel names a review-INCAPABLE member: '$fams'"
541
+ echo " Embedding / reranker / reward-model / moderation / OCR / speech classes emit"
542
+ echo " vectors, scores or labels — they cannot return an adversarial finding, so"
543
+ echo " counting them inflates BOTH panel size and family-diversity. If nothing capable"
544
+ echo " remains, the honest value is DEGRADED_SINGLE_FAMILY with a reason."
545
+ return 1
546
+ fi
547
+ # (c) DEFAULT DENY on unknown families (pmh-dev #41 design decision 1: unmeasured is
548
+ # not eligible). A denylist alone cannot know `voyage-3` or `armorm` are not reviewers,
549
+ # so an unrecognised token is UNDECIDABLE, never assumed capable. Matching is by FAMILY
550
+ # PATTERN, not id enumeration (#41 decision 2), so a new build of a known family passes
551
+ # without rewiring — which is the reason the CLI route was chosen at all.
552
+ local unknown="" tok
553
+ for tok in $(printf '%s' "$fams" | tr ',' ' '); do
554
+ printf '%s' "$tok" | grep -qiE 'codex|gpt|openai|o[0-9]|gemini|agy|antigravity|glm|qwen|deepseek|mistral|llama|grok|command-|cohere|kimi|copilot|ollama|phi-|yi-' \
555
+ || unknown="${unknown:+$unknown }$tok"
556
+ done
557
+ if [ -n "$unknown" ]; then
558
+ echo " ❌ FAIL — panel names unrecognised family/families: '$unknown'"
559
+ echo " Default-deny: an unrecognised token is UNDECIDABLE, not capable — that is how"
560
+ echo " 'panel(none)' and 'panel(unprobed)' would otherwise launder a non-run into a"
561
+ echo " pass. Either use the family pattern this runtime belongs to, or add the"
562
+ echo " pattern here in the same commit that first uses it."
563
+ return 1
564
+ fi
565
+ echo " ✅ cross-family leg: $val${reason:+ — $(printf '%s' "$reason" | cut -c1-60)}" ;;
566
+ DEGRADED_SINGLE_FAMILY|DEGRADED_PANEL_UNUSED|UNKNOWN)
567
+ # A degrade is allowed; being silent about WHICH degrade, or about its grounds, is not.
568
+ # Two conditions, because either alone is porous: enough substance to be a statement
569
+ # (length) AND a token naming what was actually looked at.
570
+ if [ "${#reason}" -lt 20 ] \
571
+ || ! printf '%s' "$reason" | grep -qiE 'prob(e|ed|ing)|reachab|unreach|not reachable|0 |none reachable|empty|absent|declin|consent|codex|gemini|agy|copilot|gateway|cli|panel|scope|budget|offline'; then
572
+ echo " ❌ FAIL — $val without substantive grounds on the same line."
573
+ case "$val" in
574
+ UNKNOWN) echo " UNKNOWN = the panel was never probed. Undecidable, not zero." ;;
575
+ DEGRADED_SINGLE_FAMILY) echo " = probed, nothing CAPABLE reachable (a constraint)." ;;
576
+ DEGRADED_PANEL_UNUSED) echo " = a capable panel WAS reachable and was not recruited (a choice)." ;;
577
+ esac
578
+ echo " Name what you probed and what came back — the defect this field exists for"
579
+ echo " was a line that announced a state without naming its grounds:"
580
+ echo " crossfamily: $val — probed codex/agy/gemini, <what came back>"
581
+ return 1
582
+ fi
583
+ echo " ⚠️ cross-family leg: $val — recorded, not silent (weekly audit re-queues these)"
584
+ echo " $(printf '%s' "$reason" | cut -c1-80)" ;;
585
+ single-family|none|none-this-round|N/A|n/a)
586
+ # `single-family` is deliberately NOT accepted HERE. This block runs only on
587
+ # load-bearing changes, where "decorrelation not required" is a contradiction — and
588
+ # as a no-ack, no-warning pass it was a free bypass of the whole lane (any degrade
589
+ # could take this exit instead of DEGRADED_*). `none` is worse: it merges three states.
590
+ echo " ❌ FAIL — '$val' is not an accepted value on a LOAD-BEARING change."
591
+ echo " This block only runs when gate/verdict/irreversible-surface code is staged, so"
592
+ echo " 'decorrelation not required' does not apply — and a no-ack pass here would let"
593
+ echo " any degrade take this exit instead of naming itself. Say which state holds:"
594
+ echo " DEGRADED_SINGLE_FAMILY probed, nothing capable reachable (could not)"
595
+ echo " DEGRADED_PANEL_UNUSED panel reachable, not recruited (did not)"
596
+ echo " UNKNOWN never probed (did not look)"
597
+ echo " declined operator declined sidecars (chosen floor)"
598
+ return 1 ;;
599
+ *)
600
+ echo " ❌ FAIL — invalid crossfamily value: '$val'"
601
+ echo " Allowed: panel(<families>) | declined |"
602
+ echo " DEGRADED_SINGLE_FAMILY | DEGRADED_PANEL_UNUSED | UNKNOWN"
603
+ echo " Free prose is rejected on purpose: a prose verdict cannot be consumed by a gate,"
604
+ echo " and a later session cites it as though it were measured. That has happened."
605
+ return 1 ;;
606
+ esac
607
+ return 0
608
+ }
609
+
472
610
  validate_marker_floor() {
473
611
  local m="$1" fs model
474
612
  # Leading whitespace tolerated on field lines (defense-in-depth vs indented writes).
@@ -801,9 +939,28 @@ fi
801
939
  # the Surface-Class Degrade Invariant the fail-closed leg of this gate belongs at the MERGE boundary,
802
940
  # not here; forcing a sidecar dispatch per commit would over-block and train the escape into reflex.
803
941
  # It DOES forbid being SILENT about it. The marker must carry a `crossfamily:` line — either naming
804
- # the engine and its verdict, or explicitly recording `none` with a reason. Same shape as the existing
805
- # below-floor-ack: the gate blocks on an unstated answer, never on the answer itself. Non-vacuity is
806
- # the marker's job; provenance is not (a fabricated line is the weekly-audit residual, as elsewhere).
942
+ # the engine and its verdict, or explicitly recording the degrade with a reason. Same shape as the
943
+ # existing below-floor-ack: the gate blocks on an unstated answer, never on the answer itself.
944
+ # Non-vacuity is the marker's job; provenance is not (a fabricated line is the weekly-audit residual).
945
+ #
946
+ # ── TYPED (2026-08-08) — why presence-only was not enough ─────────────────────────────────────
947
+ # The original check accepted any non-empty value, so the answer was FREE PROSE. That is how a
948
+ # false one got in: a sibling harness's marker recorded `crossfamily: none this round — 도달 불가`
949
+ # (unreachable), the claim was later found FALSE, and a subsequent session CITED it as grounds.
950
+ # A prose verdict laundered itself into evidence — the failure `[[feedback_typed_verdict_channel]]`
951
+ # names. Presence-checking catches silence; it cannot catch a confident wrong answer.
952
+ #
953
+ # The load-bearing split is that `none` is THREE different states, and merging them hides a
954
+ # different thing each time:
955
+ # DEGRADED_SINGLE_FAMILY probed, nothing CAPABLE reachable — could not
956
+ # DEGRADED_PANEL_UNUSED panel reachable, not recruited — did not (a choice, not a limit)
957
+ # UNKNOWN never probed — did not look
958
+ # `UNKNOWN` collapsed into `none` renders an unrun probe as a zero finding (not-found-is-not-zero).
959
+ # `DEGRADED_PANEL_UNUSED` collapsed into it renders an unused panel as an unavailable one — the
960
+ # distinction a sibling harness stated about itself in its own words: "못 한 것이 아니라 안 한 것이다".
961
+ #
962
+ # Scope is unchanged: still load-bearing-only, still reversible-surface (blocks the unstated answer,
963
+ # never demands a dispatch). Only the ANSWER is now typed. Fixtures: scripts/test_marker_crossfamily_lanes.sh
807
964
  # The degrade lint runs as an ADVISORY pre-screen, per its own doctrine (FP-tolerant, never a solo block).
808
965
  LOADBEARING=$(printf '%s\n%s\n' "$GATE_IMPL" "$UGUARD_IMPL" | grep -v '^[[:space:]]*$' | sort -u || true)
809
966
  if [ -n "$LOADBEARING" ]; then
@@ -823,18 +980,101 @@ if [ -n "$LOADBEARING" ]; then
823
980
  echo " ⚠️ degrade lint unavailable (scripts/degrade_direction_scan.sh missing) — advisory leg skipped"
824
981
  fi
825
982
  MARKER_LB="$REPO_ROOT/tracks/_meta/.axes_23_passed_${BRANCH_SLUG}_${TODAY}.marker"
826
- if [ -f "$MARKER_LB" ] && grep -qE '^[[:space:]]*crossfamily:[[:space:]]*[^[:space:]]' "$MARKER_LB"; then
827
- echo " ✅ cross-family leg recorded: $(grep -m1 -E '^[[:space:]]*crossfamily:' "$MARKER_LB" | cut -c1-90)"
828
- else
829
- echo " ❌ FAIL — load-bearing file staged with no 'crossfamily:' line in the Axes 2-3 marker."
830
- echo " Verdict/gate/irreversible-surface code shares the author's blind spot with a same-family"
831
- echo " reviewer. State the answer, whatever it is the gate blocks silence, not a 'none':"
832
- echo " crossfamily: codex/gpt-5.5 R1..R4, 16 findings, 15 fixed 1 refuted, CONVERGED"
833
- echo " crossfamily: none <why no different-family auditor was reachable/needed>"
834
- echo " Append one line to: $MARKER_LB"
835
- FAILED=1
983
+ if validate_crossfamily_leg "$MARKER_LB"; then :; else FAILED=1; fi
984
+ fi
985
+
986
+ # ── staged-blob materializer (advisory linters) ───────────────────────────────
987
+ # advisory 린터는 파일을 직접 grep/sed 한다. 워킹트리 경로를 넘기면 **커밋되는 내용이 아니라
988
+ # 지금 디스크에 있는 내용**을 재게 된다스테이징 워킹트리에서 고치면 훅은 깨끗하다 보고하고
989
+ # 문제 있는 blob 커밋된다(high 리뷰 #6, 낙관 방향 fail-open). 훅의 다른 슬라이스는 전부
990
+ # `git diff --cached` / `git show :file` 추론한다. 여기만 예외였다.
991
+ _stage_blob() { # $1=repo-relative path echoes temp file holding the STAGED content
992
+ local _p="$1" _t
993
+ _t="$(mktemp "${TMPDIR:-/tmp}/fh_staged.XXXXXX")" || return 1
994
+ if git -C "$REPO_ROOT" show ":$_p" > "$_t" 2>/dev/null; then printf '%s' "$_t"
995
+ else rm -f "$_t"; return 1; fi
996
+ }
997
+ _STAGE_TMPS=()
998
+ _STAGE_MAP=() # "tmpfile=repopath" — 보고를 사람이 읽을 수 있게 되돌리는 데 쓴다
999
+ # 히트를 익명 mktemp 경로로 인쇄하면, 그 파일은 _stage_cleanup 이 곧 지워서 **읽는 순간 이미 없다**.
1000
+ # 대상이 둘 이상이면 어느 파일 얘긴지 귀속조차 안 된다. advisory 의 목적은 사람을 그 줄로 보내는 것이다.
1001
+ _unmap() { local _o="$1" _m; for _m in "${_STAGE_MAP[@]:-}"; do
1002
+ [ -n "$_m" ] && _o="${_o//${_m%%=*}/${_m#*=}}"; done; printf '%s' "$_o"; }
1003
+ _stage_cleanup() { [ "${#_STAGE_TMPS[@]}" -gt 0 ] && rm -f "${_STAGE_TMPS[@]}" 2>/dev/null; }
1004
+
1005
+ # ── Judgment-circuit lint (advisory — the PROMPT-layer sibling of the degrade lint above) ─────
1006
+ # Same proposition, different corpus: degrade_direction_scan reads .py/.sh verdict code and skips
1007
+ # .md by construction, so identity/circuit DOCUMENTS had no instrument at all. Measured basis
1008
+ # (105 runs, 2026-08-07~08): role-assignment ("너는 ~이다") is a net loss on the weak tier and
1009
+ # intensity orders ("꼼꼼히") multiply phantoms 3.1x — both are prose granting discretion with an
1010
+ # unconstrained degrade direction, the doctrine's prompt-layer form.
1011
+ #
1012
+ # Trigger is an EXPLICIT REGISTRY, not a guess: `.claude/judgment_circuits.txt` (one path per line).
1013
+ # Which documents are judgment circuits is a decision someone makes, not something a glob infers —
1014
+ # an inferred trigger here would fire on every .md and get itself disabled.
1015
+ # ADVISORY by construction: grep-heuristic, false positives expected, never blocks a commit.
1016
+ JC_REG="$REPO_ROOT/.claude/judgment_circuits.txt"
1017
+ JC_LINT="$REPO_ROOT/scripts/judgment_circuit_lint.sh"
1018
+ if [ -f "$JC_REG" ] && [ -f "$JC_LINT" ]; then
1019
+ # Array, not a space-joined string: a tracked path CAN contain a space, and the string form
1020
+ # silently splits `docs/first known.md` into two nonexistent args (cross-family, 2026-08-08).
1021
+ _jc_targets=()
1022
+ while IFS= read -r _jc_line; do
1023
+ case "$_jc_line" in ''|'#'*) continue ;; esac
1024
+ if printf '%s\n' "$STAGED" | grep -qxF "$_jc_line" 2>/dev/null; then
1025
+ _b="$(_stage_blob "$_jc_line")" && { _jc_targets+=("$_b"); _STAGE_TMPS+=("$_b"); _STAGE_MAP+=("$_b=$_jc_line"); }
1026
+ fi
1027
+ done < "$JC_REG"
1028
+ if [ "${#_jc_targets[@]}" -gt 0 ]; then
1029
+ echo "[Gate] registered judgment-circuit doc staged — circuit lint (advisory)..."
1030
+ _jc_out="$(bash "$JC_LINT" "${_jc_targets[@]}" 2>&1)"; _jc_rc=$?
1031
+ printf '%s\n' "$(_unmap "$_jc_out")" | sed 's/^/ /'
1032
+ echo " (경로는 스테이징 blob 을 레포 경로로 되돌려 표기했다 — 줄번호는 staged 내용 기준)"
1033
+ # exit 10 = the scanner's own HARNESS-ERROR (bad input / unreadable target). Neither PASS nor
1034
+ # FAIL — surfacing it is the whole point of having a typed exit; swallowing it would render a
1035
+ # dead instrument as a clean run.
1036
+ [ "$_jc_rc" = "10" ] && echo " 🟥 circuit lint HARNESS-ERROR (rc=10) — instrument did not measure"
1037
+ echo " (advisory — does not block. A hit means 'prove this is a circuit, not a persona'.)"
1038
+ fi
1039
+ fi
1040
+
1041
+ # ── Novelty-claim ledger (advisory) ───────────────────────────────────────────
1042
+ # "Ask the world before asserting novelty" is an INTENT trigger — un-hookable, like the
1043
+ # repo-go-public surface. So this does not force the asking; it makes NOT having asked visible
1044
+ # at commit time (shift-left ledger, not one more rule: the rule already existed and still failed).
1045
+ # Direction matters: defect claims (FAIL) have long required source-grounding while novelty/strength
1046
+ # claims (PASS) passed unchecked — the optimistic direction is the one that leaks. And an absence
1047
+ # claim is structurally worse: with nothing cited, phantom-detection cannot engage at all.
1048
+ # Scope = the same substantive doc surface the 4-axis carve-out already treats as claim-bearing.
1049
+ NC_CHECK="$REPO_ROOT/scripts/novelty_claim_check.sh"
1050
+ if [ -f "$NC_CHECK" ]; then
1051
+ _nc_targets=()
1052
+ while IFS= read -r _nc_line; do
1053
+ _b="$(_stage_blob "$_nc_line")" && { _nc_targets+=("$_b"); _STAGE_TMPS+=("$_b"); _STAGE_MAP+=("$_b=$_nc_line"); }
1054
+ done < <(printf '%s\n' "$STAGED" | grep -E '^(knowledge/|docs/).*\.md$' || true)
1055
+ if [ "${#_nc_targets[@]}" -gt 0 ]; then
1056
+ _nc_raw=$(bash "$NC_CHECK" "${_nc_targets[@]}" 2>&1); _nc_rc=$?
1057
+ # Same typed-exit rule as above: rc=10 is a dead instrument, not a clean corpus.
1058
+ [ "$_nc_rc" = "10" ] && echo " 🟥 novelty check HARNESS-ERROR (rc=10) — instrument did not measure"
1059
+ _nc_raw="$(_unmap "$_nc_raw")"
1060
+ # 부분 미스캔 경고를 **같이** 건진다. 호출부가 `🟥 무앵커` 만 grep 해서, 스크립트가 옳게 낸
1061
+ # "전수 스캔이 아니다" 를 통째로 버렸다 — 수리가 스크립트 안으로 옮겨졌을 뿐 이 표면에선
1062
+ # 미스캔 문서가 여전히 깨끗한 문서와 구분 불가였다(high 재리뷰 #5).
1063
+ _nc_warn=$(printf '%s\n' "$_nc_raw" | grep -F '전수 스캔이 아니다' | head -3 || true)
1064
+ if [ -n "$_nc_warn" ]; then
1065
+ echo "[Gate] novelty check: 부분 미스캔 (advisory):"
1066
+ printf '%s\n' "$_nc_warn" | sed 's/^/ /'
1067
+ echo " → 이 문서는 전수 스캔이 아니다. 제외된 줄에 실제 주장이 있을 수 있다."
1068
+ fi
1069
+ _nc_out=$(printf '%s\n' "$_nc_raw" | grep -E '🟥 무앵커' | head -5 || true)
1070
+ if [ -n "$_nc_out" ]; then
1071
+ echo "[Gate] novelty/absence claim without an external anchor (advisory):"
1072
+ printf '%s\n' "$_nc_out" | sed 's/^/ /'
1073
+ echo " Each = 'you asserted this about the world without asking it'. Does not block."
1074
+ fi
836
1075
  fi
837
1076
  fi
1077
+ _stage_cleanup
838
1078
 
839
1079
  # SKILL.md ONLY here, deliberately (2026-07-26): the 07-26 sweep widened `SKILL\.md` →
840
1080
  # `SKILL(_detail)?\.md` in the HEAVY and doc-coupling terms above, but NOT here. This slice asks
@@ -0,0 +1,56 @@
1
+ {
2
+ "_README": [
3
+ "Compaction hook snippet — TRACKED, so it arrives with a clone. Every .claude/settings*.json path",
4
+ "in this repo is gitignored (confirm with `git check-ignore -v .claude/settings.json`), so the hook",
5
+ "registration itself cannot be tracked. This is the tracked SOURCE that install-wizard merges into",
6
+ "the user's local settings. Without the merge step the hooks do not run — shipping the script is not",
7
+ "the same proposition as wiring it.",
8
+ "",
9
+ "What it does: seals a POINTER ledger of what the session holds just BEFORE compaction, then",
10
+ "re-injects it once on the next prompt. FH's structural advantage over generic compaction memory is",
11
+ "that the originals are already on disk — what compaction destroys is not the context but the",
12
+ "pointers to it, and a pointer is cheap to restore.",
13
+ "",
14
+ "Why the second hook is UserPromptSubmit and not PostCompact — this is a MEASURED constraint, not a",
15
+ "style choice: PostCompact does NOT support `additionalContext` (official hooks reference, verified",
16
+ "2026-08-08), so a PostCompact hook physically cannot put anything back into the session. It can only",
17
+ "write files. UserPromptSubmit is one of the three events whose exit-0 stdout becomes context Claude",
18
+ "can read. Wiring the re-injection to PostCompact would install a hook that runs and changes nothing.",
19
+ "",
20
+ "Both entries carry NO private path, so both belong in .claude/settings.json (project-local).",
21
+ "Merge, do not overwrite: preserve any PreCompact / UserPromptSubmit entries the user already has;",
22
+ "replace only the FH ones, keyed by script name.",
23
+ "",
24
+ "Never blocks: PreCompact CAN block compaction (exit 2) and this deliberately does not — compaction",
25
+ "is a reversible surface, and a gate that fires in the user's way is a gate that gets switched off.",
26
+ "The script always exits 0 for the same reason a non-zero hook exit discards its own stdout."
27
+ ],
28
+ "project_settings_json": {
29
+ "hooks": {
30
+ "PreCompact": [
31
+ {
32
+ "matcher": "",
33
+ "hooks": [
34
+ {
35
+ "type": "command",
36
+ "command": "bash -c 'HUB=\"${CLAUDE_PROJECT_DIR:-$HOME/projects/forge-harness}\"; bash \"$HUB/scripts/compaction_probe.sh\" seal; exit 0'",
37
+ "timeout": 15
38
+ }
39
+ ]
40
+ }
41
+ ],
42
+ "UserPromptSubmit": [
43
+ {
44
+ "matcher": "",
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "bash -c 'HUB=\"${CLAUDE_PROJECT_DIR:-$HOME/projects/forge-harness}\"; bash \"$HUB/scripts/compaction_probe.sh\" digest; exit 0'",
49
+ "timeout": 10
50
+ }
51
+ ]
52
+ }
53
+ ]
54
+ }
55
+ }
56
+ }