@chrono-meta/fh-gate 2.15.1 → 3.1.0

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 (134) hide show
  1. package/.claude/regression/probes_live.yaml +137 -0
  2. package/.claude/rules/.residency-patterns.defaults +7 -0
  3. package/.claude/rules/fh_4axis_gate.md +88 -1
  4. package/.claude-plugin/marketplace.json +8 -2
  5. package/AGENTS.md +27 -0
  6. package/CATALOG.md +17 -0
  7. package/CLAUDE.md +13 -3
  8. package/README.ja.md +51 -7
  9. package/README.ko.md +48 -7
  10. package/README.md +43 -6
  11. package/README.zh.md +45 -8
  12. package/docs/STANDARDS_ALIGNMENT.md +61 -0
  13. package/docs/USER_GUIDE.md +3 -0
  14. package/docs/USE_CASES.md +50 -0
  15. package/docs/model_tier_expectations.md +60 -0
  16. package/knowledge/shared/harness-core/fh_three_layer_canon.md +77 -10
  17. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +18 -0
  18. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +9 -0
  19. package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +139 -0
  20. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +35 -1
  21. package/knowledge/shared/harness-core/ship_readiness_gate.md +19 -2
  22. package/knowledge/shared/learnings/subagent_invocations_log.yaml +489 -3
  23. package/package.json +48 -2
  24. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  25. package/plugins/fh-commons/skills/preprep/README.md +4 -1
  26. package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
  27. package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
  28. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  29. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  30. package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
  31. package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
  32. package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
  33. package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
  34. package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
  35. package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
  36. package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
  37. package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
  38. package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
  39. package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
  40. package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
  41. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
  42. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
  43. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  44. package/plugins/fh-meta/CHANGELOG.md +67 -0
  45. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
  46. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
  47. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
  48. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
  49. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
  50. package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
  51. package/plugins/fh-qp/README.md +71 -0
  52. package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
  53. package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
  54. package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
  55. package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
  56. package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
  57. package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
  58. package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
  59. package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
  60. package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
  61. package/plugins/fh-qp/qp_profile.example.yaml +29 -0
  62. package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
  63. package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
  64. package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
  65. package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
  66. package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
  67. package/scripts/backtick_guard.sh +194 -0
  68. package/scripts/chamber_run.sh +14 -5
  69. package/scripts/com.forge-harness.live-eval.plist +84 -0
  70. package/scripts/compaction_probe.sh +9 -35
  71. package/scripts/context_continuity_score.sh +49 -7
  72. package/scripts/directional_diff_gate.sh +14 -2
  73. package/scripts/fh-gate.sh +3 -3
  74. package/scripts/files_manifest_shipping_check.sh +19 -0
  75. package/scripts/frontier_digest_autopilot.sh +4 -1
  76. package/scripts/gate_pathspec_check.sh +1 -1
  77. package/scripts/map_postprocess.py +90 -0
  78. package/scripts/outbound_query_guard.sh +131 -0
  79. package/scripts/outbound_query_hook.sh +373 -0
  80. package/scripts/package_coverage_check.sh +79 -18
  81. package/scripts/pipe_verdict_guard.sh +41 -1
  82. package/scripts/probe_live_eval.sh +240 -0
  83. package/scripts/probe_live_eval_lib.py +579 -0
  84. package/scripts/proposal_hook.sh +192 -0
  85. package/scripts/public_surface_scan_files.sh +11 -2
  86. package/scripts/push_zone_check.sh +78 -0
  87. package/scripts/residency_closure_scan.py +252 -0
  88. package/scripts/revert_probe.sh +250 -0
  89. package/scripts/selfcheck.sh +106 -3
  90. package/scripts/session_close_check.sh +100 -0
  91. package/scripts/sim_isolated_run.sh +195 -9
  92. package/scripts/test_action_yml_lanes.sh +97 -0
  93. package/scripts/test_backtick_guard_lanes.sh +115 -0
  94. package/scripts/test_degrade_scan_shell_probes.sh +7 -7
  95. package/scripts/test_fh_qp_lanes.sh +105 -0
  96. package/scripts/test_files_manifest_shipping_lanes.sh +5 -5
  97. package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
  98. package/scripts/test_heavy_classifier_lanes.sh +1 -1
  99. package/scripts/test_lane_runner_lanes.sh +59 -33
  100. package/scripts/test_map_postprocess_lanes.sh +143 -0
  101. package/scripts/test_mapped_tracks_lanes.sh +1 -1
  102. package/scripts/test_marker_affected_lanes.sh +93 -0
  103. package/scripts/test_marker_crossfamily_lanes.sh +90 -6
  104. package/scripts/test_marker_oracle_lanes.sh +136 -0
  105. package/scripts/test_marker_soul_check_lanes.sh +24 -0
  106. package/scripts/test_node_check_lanes.sh +34 -34
  107. package/scripts/test_outbound_query_hook_lanes.sh +433 -0
  108. package/scripts/test_outbound_query_lanes.sh +87 -0
  109. package/scripts/test_package_coverage_lanes.sh +53 -27
  110. package/scripts/test_pipe_verdict_guard_lanes.sh +31 -5
  111. package/scripts/test_precommit_pointer_index_lanes.sh +33 -0
  112. package/scripts/test_preprep_diagram_lanes.sh +87 -0
  113. package/scripts/test_preprep_drift_anchor.sh +16 -7
  114. package/scripts/test_preprep_drift_anchor_lanes.sh +23 -0
  115. package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
  116. package/scripts/test_probe_live_eval_lanes.sh +437 -0
  117. package/scripts/test_proposal_hook_lanes.sh +57 -0
  118. package/scripts/test_push_zone_lanes.sh +304 -0
  119. package/scripts/test_residency_closure_lanes.sh +70 -0
  120. package/scripts/test_revert_probe_lanes.sh +146 -0
  121. package/scripts/test_session_close_lanes.sh +3 -5
  122. package/scripts/test_sim_isolated_run_lanes.sh +136 -0
  123. package/scripts/test_utterance_intake_lanes.sh +414 -0
  124. package/scripts/test_worktree_reclaim_lanes.sh +70 -0
  125. package/scripts/transcript_utterances.py +222 -0
  126. package/scripts/utterance_intake.sh +424 -0
  127. package/scripts/utterance_landing_check.sh +2 -2
  128. package/scripts/validate_yaml.sh +27 -0
  129. package/scripts/worktree_reclaim.sh +95 -0
  130. package/templates/.git-hooks/pre-commit +380 -5
  131. package/templates/.git-hooks/pre-push +91 -0
  132. package/templates/RED_TEAM_REPORT.md +49 -0
  133. package/templates/settings.PreToolUse.snippet.json +101 -1
  134. package/plugins/fh-commons/README.md +0 -38
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ # test_worktree_reclaim_lanes.sh — known pairs for scripts/worktree_reclaim.sh (2026-09-05).
3
+ # Fixture: a real `git init` main checkout + a real `git worktree add`, gitignored tracks/ on both sides.
4
+ # The lanes assert the ORDER (list file before copy) as well as the verdicts — the list is the evidence
5
+ # that survives the copy, which is the whole reason the script exists.
6
+ set -uo pipefail
7
+ cd "$(dirname "$0")/.." || exit 1
8
+ ROOT="$(pwd -P)"
9
+ SUT="$ROOT/scripts/worktree_reclaim.sh" # own line: new_code_anchor_check resolves the variable per line
10
+ PASS=0; FAIL=0
11
+ ok() { echo " ✅ $1"; PASS=$((PASS+1)); }
12
+ ng() { echo " ❌ $1"; FAIL=$((FAIL+1)); }
13
+ [ -f "$SUT" ] || { echo "ⓘ worktree_reclaim.sh absent — subject missing when we looked (NOT a pass)"; exit 2; }
14
+ T="$(mktemp -d 2>/dev/null)" || { echo "ⓘ mktemp failed (NOT a pass)"; exit 10; }
15
+ trap 'rm -rf "$T"' EXIT INT TERM
16
+
17
+ mk_pair() { # $1=root → creates $1/main (checkout) + $1/wt (worktree); echoes nothing
18
+ mkdir -p "$1" && ( cd "$1" && git init -q main && cd main \
19
+ && git config user.email l@example.invalid && git config user.name lane \
20
+ && mkdir -p tracks/_meta && printf 'tracks/\n' > .gitignore && echo x > README.md \
21
+ && git add .gitignore README.md && git commit -qm init && git worktree add -q ../wt -b wt-branch ) 2>/dev/null
22
+ }
23
+
24
+ echo "── W1 enumerate: only-in files are listed, list file written, rc=1, nothing copied ──"
25
+ mk_pair "$T/a"; M="$T/a/main"; W="$T/a/wt"
26
+ mkdir -p "$W/tracks/_meta/sub"; echo "signal body" > "$W/tracks/_meta/fh_signal_lost.md"; echo nested > "$W/tracks/_meta/sub/deep.yaml"
27
+ OUT="$(bash "$SUT" "$W" 2>&1)"; RC=$?
28
+ [ "$RC" -eq 1 ] && ok "W1 rc=1 (only-in exists, not yet reclaimed)" || { ng "W1 rc=$RC (기대 1)"; printf '%s\n' "$OUT" | sed 's/^/ /'; }
29
+ N=$(printf '%s\n' "$OUT" | grep -c ' ONLY '); [ "$N" -eq 2 ] && ok "W1b 2 only-in files listed (nested dir walked)" || ng "W1b listed $N (기대 2)"
30
+ L=$(ls "$M"/tracks/_meta/dispatch/reclaim_wt_*.txt 2>/dev/null | wc -l | tr -d ' '); [ "$L" -eq 1 ] && ok "W1c list file written in MAIN tracks/_meta/dispatch" || ng "W1c list files: $L (기대 1)"
31
+ grep -q $'^ONLY\t_meta/sub/deep.yaml' "$M"/tracks/_meta/dispatch/reclaim_wt_*.txt && ok "W1d list carries the relative path" || ng "W1d list lacks the nested path"
32
+ [ ! -e "$M/tracks/_meta/fh_signal_lost.md" ] && ok "W1e enumerate mode copies nothing (known-negative)" || ng "W1e enumerate mode copied a file"
33
+
34
+ echo "── W2 --apply: copied, byte-verified, rc=0; re-run says nothing left ──"
35
+ OUT="$(bash "$SUT" "$W" --apply 2>&1)"; RC=$?
36
+ [ "$RC" -eq 0 ] && ok "W2 --apply rc=0" || { ng "W2 rc=$RC (기대 0)"; printf '%s\n' "$OUT" | sed 's/^/ /'; }
37
+ cmp -s "$W/tracks/_meta/fh_signal_lost.md" "$M/tracks/_meta/fh_signal_lost.md" && cmp -s "$W/tracks/_meta/sub/deep.yaml" "$M/tracks/_meta/sub/deep.yaml" \
38
+ && ok "W2b both files byte-identical in main" || ng "W2b copies differ or missing"
39
+ OUT="$(bash "$SUT" "$W" 2>&1)"; RC=$?
40
+ [ "$RC" -eq 0 ] && ok "W2c re-run after reclaim → rc=0 (idempotent)" || ng "W2c re-run rc=$RC"
41
+ case "$OUT" in *"safe to: git worktree remove"*) ok "W2d prints the next (human) step, never runs it" ;; *) ng "W2d no next-step line" ;; esac
42
+ [ -d "$W" ] && ok "W2e worktree still exists (script never removes)" || ng "W2e worktree vanished"
43
+
44
+ echo "── W3 differ-both-sides: listed, NOT copied, rc=1 even with --apply ──"
45
+ echo same > "$M/tracks/_meta/shared.md"; echo "changed in wt" > "$W/tracks/_meta/shared.md"
46
+ OUT="$(bash "$SUT" "$W" --apply 2>&1)"; RC=$?
47
+ [ "$RC" -eq 1 ] && ok "W3 rc=1 (needs a human)" || ng "W3 rc=$RC (기대 1)"
48
+ case "$OUT" in *"DIFF _meta/shared.md"*) ok "W3b the differing file is named" ;; *) ng "W3b DIFF not listed" ;; esac
49
+ grep -q '^same$' "$M/tracks/_meta/shared.md" && ok "W3c main copy untouched (known-negative: no overwrite)" || ng "W3c main copy was overwritten"
50
+
51
+ echo "── W4 list-before-copy: unwritable list dir → rc=10 and NO copy happened ──"
52
+ mk_pair "$T/b"; M="$T/b/main"; W="$T/b/wt"
53
+ mkdir -p "$W/tracks/_meta" && echo body > "$W/tracks/_meta/only.md"; rm -rf "$M/tracks/_meta/dispatch"; touch "$M/tracks/_meta/dispatch" # a FILE where the dir must be
54
+ [ -f "$W/tracks/_meta/only.md" ] && ok "W4-FIXTURE the only-in file exists in the worktree (potency)" || ng "W4-FIXTURE fixture file missing — W4b would pass vacuously"
55
+ OUT="$(bash "$SUT" "$W" --apply 2>&1)"; RC=$?
56
+ [ "$RC" -eq 10 ] && ok "W4 rc=10 when the evidence file cannot be written" || ng "W4 rc=$RC (기대 10)"
57
+ [ ! -e "$M/tracks/_meta/only.md" ] && ok "W4b nothing was copied before the list failed (order holds)" || ng "W4b copy happened without a list"
58
+
59
+ echo "── W5 argument guards ──"
60
+ OUT="$(bash "$SUT" "$M" 2>&1)"; RC=$?; [ "$RC" -eq 2 ] && ok "W5 main checkout as argument → rc=2" || ng "W5 rc=$RC (기대 2)"
61
+ OUT="$(bash "$SUT" "$T" 2>&1)"; RC=$?; [ "$RC" -eq 2 ] && ok "W5b non-repo dir → rc=2" || ng "W5b rc=$RC (기대 2)"
62
+ OUT="$(bash "$SUT" "$W" --bogus 2>&1)"; RC=$?; [ "$RC" -eq 2 ] && ok "W5c unknown flag → rc=2" || ng "W5c rc=$RC (기대 2)"
63
+ OUT="$(bash "$SUT" 2>&1)"; RC=$?; [ "$RC" -eq 2 ] && ok "W5d no argument → rc=2" || ng "W5d rc=$RC (기대 2)"
64
+ mk_pair "$T/c"; rm -rf "$T/c/wt/tracks"; OUT="$(bash "$SUT" "$T/c/wt" 2>&1)"; RC=$?
65
+ [ "$RC" -eq 0 ] && ok "W5e worktree without tracks/ → rc=0 nothing to reclaim" || ng "W5e rc=$RC (기대 0)"
66
+
67
+ echo
68
+ echo "── worktree-reclaim lanes: PASS=$PASS FAIL=$FAIL ──"
69
+ [ "$FAIL" -eq 0 ] || exit 1
70
+ exit 0
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env python3
2
+ """transcript_utterances.py — Claude Code 전사본(jsonl) → 운영자 발화.
3
+
4
+ WHY THIS FILE EXISTS. 이 추출 로직은 `compaction_probe.sh` 안에 heredoc 으로 인라인돼 있었고,
5
+ 「받는 절반」(발화 → 기록 착지) 을 배선하려면 두 번째 소비처가 필요했다. 사본을 두 벌 두는 것은
6
+ `[[feedback_divergent_leniency_duplicate_normalizers]]` — 전처리가 갈리면 **한쪽만 통과하는
7
+ 입력이 다른 쪽에서 무음 드롭된다**. 그래서 파일 하나로 꺼낸다.
8
+
9
+ 🟥 SEAL 출력은 바이트 동일해야 한다. `--format seal` 은 인라인 판본을 **그대로** 재현한다
10
+ (줄 형식 · 합계/제외 문구 · 카운터 이름까지). `scripts/test_utterance_intake_lanes.sh` L10 이
11
+ 골든으로 고정하고, L12/L12b 가 «이 파일이 정말 seal 경로에 있는가» 를 되돌림으로 잰다.
12
+
13
+ ── 무엇을 발화로 세는가 (그리고 왜) ──────────────────────────────────────────
14
+ `type=user` 는 **사람 발화가 아니다** — 툴 결과 · peer 메시지 · task 알림이 같은 타입으로 온다
15
+ (`[[feedback_type_user_is_not_human_utterance.md]]`: 39 vs 실제 30). 실측(2026-09-05, 이 레포의
16
+ 전사본 전수): `<task-notification` 3791 · `<cross-session-message` 1467 · `<local-command-*` 232 —
17
+ 전부 `<` 로 시작하므로 봉투 접두 규칙이 잡는다.
18
+ · content 가 str → 발화 후보
19
+ · content 가 list → tool_result 블록이 하나라도 있으면 **툴 결과**(제외).
20
+ 아니면 text 블록만 이어붙인다. 🟥 이미지 첨부 실발화가 list 다 —
21
+ 「list = 툴결과」로 가르면 그런 발화가 구조적으로 안 보인다.
22
+ · text 블록이 없음 → 이미지 전용 등. **세되 싣지 않는다**(지우지 않는다).
23
+ · `<` 또는 `/` 로 시작 → 메타 봉투 · 슬래시 커맨드(제외)
24
+ · `--min-chars N` 미만 → 짧은 응답(«ㄱㄱ» «응» «승인»). **별도 카운터**로 센다.
25
+ · `--strip-system-markers` → 아래 §시스템 마커. **기본 OFF** — 켜면 seal 출력이 바뀐다.
26
+
27
+ 🟥 제외분을 반드시 인쇄한다. 합계만 찍으면 그 원장이 완전한 것처럼 읽힌다 —
28
+ `[[feedback_not_found_is_not_zero_family]]`.
29
+
30
+ ── 사용 ──────────────────────────────────────────────────────────────────────
31
+ python3 scripts/transcript_utterances.py <transcript.jsonl> [--min-chars N]
32
+ [--format tsv|seal] [--strip-system-markers]
33
+ tsv (기본) : `n<TAB>ts<TAB>text` 한 줄 한 발화. ts 가 없으면 `-`.
34
+ seal : compaction_probe seal 블록 (바이트 동일)
35
+ 종료코드: 0 정상 · 2 인자/파일 문제(호출부의 «파싱 실패» 폴백이 여기서 뜬다)
36
+
37
+ 이식성: 표준 라이브러리만. python3.6+ (f-string).
38
+ """
39
+ import json
40
+ import re
41
+ import sys
42
+
43
+
44
+ # ── 시스템 마커 (실측 2026-09-05, 이 레포 전사본 전수 — 대괄호 접두 171 건) ──────────────
45
+ # `type=user` 로 오지만 **운영자가 친 것이 아닌** 것들. 개수를 세서 골랐다:
46
+ # [Cross-session delivery notice] 66 · [Request interrupted by user] 45 ·
47
+ # [automated-run: launchd] 31 · [Image #N] 17 · [Cross-session idle notice] 4
48
+ # 정책 두 갈래 — ⓐ **통째로 버릴 것**(발신자가 사람이 아니다) ⓑ **마커만 벗길 것**(뒤에 실발화가 온다).
49
+ # 🟥 `[자율 루프 …]` 8 건은 **일부러 안 넣었다** — 운영자가 쓴 무인 실행 프롬프트라 과포함(안전)
50
+ # 쪽에 둔다. 과소포함은 무음 손실이라 방향이 다르다.
51
+ # 🟥 기본 OFF. 켜면 seal 출력이 바뀌므로 `--strip-system-markers` 를 준 소비처(intake)만 받는다.
52
+ # 🟥 압축 이어붙임 프리앰블도 `type=user` 로 온다 — 실측 21 건, 그리고 **첫 실사용에서
53
+ # 미착지 목록에 그대로 떴다**(2026-09-05). 런타임이 주입한 요약이지 운영자 발화가 아니다.
54
+ # 괄호로 시작하지 않아 앞의 대괄호 규칙이 구조적으로 못 잡는다 — 별도 접두로 센다.
55
+ _DROP_PREFIXES = ('[Cross-session delivery notice]', '[Cross-session idle notice]',
56
+ '[automated-run:',
57
+ 'This session is being continued from a previous conversation')
58
+ _STRIP_MARKER = re.compile(r'^\[(?:Image|Request interrupted by user)[^\]]*\]\s*')
59
+
60
+
61
+ def _is_slash_command(t):
62
+ """슬래시 커맨드인가, 절대경로로 시작하는 실발화인가.
63
+
64
+ 🟥 실측 2026-09-05, 이 레포 전사본 전수: `<` 봉투가 아닌 `/` 접두 발화 **28 건 중 13 건**
65
+ (46%)이 **절대경로로 시작하는 진짜 발화**였다 — «/Users/…/OT.pptx.pdf 이건 발표자 ot자료인…»
66
+ 처럼 지시가 통째로 딸려 있다. 옛 규칙(`startswith('/')` → 커맨드)은 그 13 건을 무음 삭제했다.
67
+ 판별자: **첫 낱말의 `/` 개수**. `/compact` `/install-wizard --dry-run` = 1 개(커맨드) ·
68
+ `/Users/<name>/…` = 2 개 이상(경로). 이 코퍼스에서 15/13 으로 깨끗이 갈린다.
69
+
70
+ 🟥 이 규칙은 `--strip-system-markers` 를 준 소비처에만 적용된다. seal 은 안 준다 —
71
+ 바꾸면 기존 원장 출력이 달라지고, 그건 리팩터가 아니라 **행동 변경**이라 운영자 판단이다.
72
+ 즉 seal 쪽에는 이 결함이 **아직 남아 있다**(보고서에 이름으로 남긴다).
73
+ """
74
+ return t.split(' ', 1)[0].count('/') == 1
75
+
76
+
77
+ def _apply_system_markers(t):
78
+ """→ (text, dropped). dropped=True 면 이 레코드는 발화가 아니다."""
79
+ for pref in _DROP_PREFIXES:
80
+ if t.startswith(pref):
81
+ return '', True
82
+ prev = None
83
+ while prev != t:
84
+ prev = t
85
+ t = _STRIP_MARKER.sub('', t)
86
+ if not t.strip():
87
+ return '', True
88
+ return t, False
89
+
90
+
91
+ def _iter_records(path):
92
+ with open(path, errors='replace') as fh:
93
+ for line in fh:
94
+ try:
95
+ yield json.loads(line)
96
+ except Exception:
97
+ continue
98
+
99
+
100
+ def extract(path, min_chars=0, strip_system=False):
101
+ """→ (utterances, counters). utterances = [(n, ts, text), ...] (1-based n)."""
102
+ out = []
103
+ n = 0
104
+ c = {'tool': 0, 'meta': 0, 'other': 0, 'short': 0, 'sys': 0}
105
+ for d in _iter_records(path):
106
+ if d.get('type') != 'user':
107
+ continue
108
+ # Runtime-flagged provenance: isMeta (harness-injected — command expansions, caveats,
109
+ # image placeholders) and isSidechain (a sub-agent's conversation) are not operator
110
+ # utterances. A channel property of the record itself, not a content judgment. Measured on
111
+ # a live transcript 2026-09-05: 1 of 379 user records is isMeta with plain text (the image
112
+ # placeholder), 0 isSidechain — so the seal ledger loses exactly that line. (codex finding 4)
113
+ if d.get('isMeta') or d.get('isSidechain'):
114
+ c['meta'] += 1
115
+ continue
116
+ m = d.get('message') or {}
117
+ content = m.get('content')
118
+ if isinstance(content, str):
119
+ t = content
120
+ elif isinstance(content, list):
121
+ if any(isinstance(b, dict) and b.get('type') == 'tool_result' for b in content):
122
+ c['tool'] += 1
123
+ continue
124
+ parts = [b.get('text', '') for b in content
125
+ if isinstance(b, dict) and b.get('type') == 'text']
126
+ if not parts:
127
+ c['other'] += 1 # 이미지 전용 등 — 셈에서 지우지 않고 센다
128
+ continue
129
+ t = ' '.join(parts)
130
+ else:
131
+ c['other'] += 1
132
+ continue
133
+ t = ' '.join(t.split())
134
+ if not t:
135
+ c['other'] += 1
136
+ continue
137
+ if t.startswith('<'): # 메타 봉투
138
+ c['meta'] += 1
139
+ continue
140
+ if t.startswith('/'):
141
+ # strip_system 이 꺼져 있으면 옛 규칙 그대로 — seal 출력 바이트 동일의 근거다.
142
+ if not (strip_system and not _is_slash_command(t)):
143
+ c['meta'] += 1
144
+ continue
145
+ if strip_system:
146
+ t, dropped = _apply_system_markers(t)
147
+ if dropped:
148
+ c['sys'] += 1
149
+ continue
150
+ if min_chars and len(t) < min_chars:
151
+ c['short'] += 1
152
+ continue
153
+ n += 1
154
+ out.append((n, d.get('timestamp') or '-', t))
155
+ return out, c
156
+
157
+
158
+ def _seal(utterances, c):
159
+ # 🟥 인라인 판본과 **바이트 동일**. 문구·구두점·카운터 이름을 바꾸지 마라 (L10 골든).
160
+ for n, _ts, t in utterances:
161
+ print(f"{n}. {t[:200]}")
162
+ total = len(utterances)
163
+ print(f"\n합계: {total}건" if total else "- (발화 0건)")
164
+ print(f"제외: tool_result {c['tool']} · 메타/커맨드 {c['meta']} · 텍스트없음 {c['other']}")
165
+
166
+
167
+ def _tsv(utterances, c):
168
+ for n, ts, t in utterances:
169
+ # 탭·개행은 위에서 이미 접혔다. 그래도 방어적으로 한 번 더 — 한 줄 = 한 발화가 계약이다.
170
+ print(f"{n}\t{ts}\t" + t.replace('\t', ' '))
171
+ # 제외분은 stderr 로. stdout 은 «한 줄 한 발화» 계약이라 소비처가 `wc -l` 로 세도 안전해야 한다.
172
+ sys.stderr.write(
173
+ f"제외: tool_result {c['tool']} · 메타/커맨드 {c['meta']} · "
174
+ f"텍스트없음 {c['other']} · 짧음 {c['short']} · 시스템마커 {c['sys']}\n")
175
+
176
+
177
+ def main(argv):
178
+ if len(argv) < 2:
179
+ sys.stderr.write("usage: transcript_utterances.py <transcript.jsonl> "
180
+ "[--min-chars N] [--format tsv|seal]\n")
181
+ return 2
182
+ path = argv[1]
183
+ min_chars = 0
184
+ fmt = 'tsv'
185
+ strip_system = False
186
+ i = 2
187
+ while i < len(argv):
188
+ a = argv[i]
189
+ if a == '--min-chars' and i + 1 < len(argv):
190
+ try:
191
+ min_chars = int(argv[i + 1])
192
+ except ValueError:
193
+ sys.stderr.write(f"transcript_utterances: --min-chars 값이 정수가 아니다: {argv[i+1]}\n")
194
+ return 2
195
+ i += 2
196
+ elif a == '--format' and i + 1 < len(argv):
197
+ fmt = argv[i + 1]
198
+ if fmt not in ('tsv', 'seal'):
199
+ sys.stderr.write(f"transcript_utterances: --format 은 tsv|seal: {fmt}\n")
200
+ return 2
201
+ i += 2
202
+ elif a == '--strip-system-markers':
203
+ strip_system = True
204
+ i += 1
205
+ else:
206
+ sys.stderr.write(f"transcript_utterances: 알 수 없는 인자: {a}\n")
207
+ return 2
208
+ try:
209
+ utterances, counters = extract(path, min_chars, strip_system)
210
+ except OSError as e:
211
+ # 호출부(compaction_probe seal)의 «파싱 실패» 폴백이 이 비영 종료로 뜬다.
212
+ sys.stderr.write(f"transcript_utterances: 전사본을 열 수 없다: {e}\n")
213
+ return 2
214
+ if fmt == 'seal':
215
+ _seal(utterances, counters)
216
+ else:
217
+ _tsv(utterances, counters)
218
+ return 0
219
+
220
+
221
+ if __name__ == '__main__':
222
+ sys.exit(main(sys.argv))