@chrono-meta/fh-gate 3.0.0 → 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 (111) 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 +50 -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 +12 -2
  8. package/README.ja.md +51 -7
  9. package/README.ko.md +48 -7
  10. package/README.md +37 -5
  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 +30 -10
  17. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +8 -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 +20 -0
  21. package/knowledge/shared/learnings/subagent_invocations_log.yaml +441 -4
  22. package/package.json +40 -2
  23. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  24. package/plugins/fh-commons/skills/preprep/README.md +4 -1
  25. package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
  26. package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
  27. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  28. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  29. package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
  30. package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
  31. package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
  32. package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
  33. package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
  34. package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
  35. package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
  36. package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
  37. package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
  38. package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
  39. package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
  40. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
  41. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
  42. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  43. package/plugins/fh-meta/CHANGELOG.md +43 -1
  44. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
  45. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
  46. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
  47. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
  48. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
  49. package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
  50. package/plugins/fh-qp/README.md +71 -0
  51. package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
  52. package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
  53. package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
  54. package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
  55. package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
  56. package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
  57. package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
  58. package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
  59. package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
  60. package/plugins/fh-qp/qp_profile.example.yaml +29 -0
  61. package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
  62. package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
  63. package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
  64. package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
  65. package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
  66. package/scripts/chamber_run.sh +14 -5
  67. package/scripts/com.forge-harness.live-eval.plist +84 -0
  68. package/scripts/compaction_probe.sh +9 -35
  69. package/scripts/directional_diff_gate.sh +14 -2
  70. package/scripts/frontier_digest_autopilot.sh +4 -1
  71. package/scripts/map_postprocess.py +90 -0
  72. package/scripts/outbound_query_guard.sh +131 -0
  73. package/scripts/outbound_query_hook.sh +373 -0
  74. package/scripts/package_coverage_check.sh +55 -16
  75. package/scripts/pipe_verdict_guard.sh +41 -1
  76. package/scripts/probe_live_eval.sh +240 -0
  77. package/scripts/probe_live_eval_lib.py +579 -0
  78. package/scripts/proposal_hook.sh +120 -17
  79. package/scripts/push_zone_check.sh +78 -0
  80. package/scripts/residency_closure_scan.py +252 -0
  81. package/scripts/selfcheck.sh +41 -1
  82. package/scripts/session_close_check.sh +100 -0
  83. package/scripts/sim_isolated_run.sh +98 -2
  84. package/scripts/test_action_yml_lanes.sh +97 -0
  85. package/scripts/test_fh_qp_lanes.sh +105 -0
  86. package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
  87. package/scripts/test_map_postprocess_lanes.sh +143 -0
  88. package/scripts/test_marker_affected_lanes.sh +93 -0
  89. package/scripts/test_marker_crossfamily_lanes.sh +90 -6
  90. package/scripts/test_marker_oracle_lanes.sh +136 -0
  91. package/scripts/test_outbound_query_hook_lanes.sh +433 -0
  92. package/scripts/test_outbound_query_lanes.sh +87 -0
  93. package/scripts/test_pipe_verdict_guard_lanes.sh +26 -0
  94. package/scripts/test_preprep_diagram_lanes.sh +87 -0
  95. package/scripts/test_preprep_drift_anchor.sh +3 -3
  96. package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
  97. package/scripts/test_probe_live_eval_lanes.sh +437 -0
  98. package/scripts/test_proposal_hook_lanes.sh +22 -1
  99. package/scripts/test_push_zone_lanes.sh +304 -0
  100. package/scripts/test_residency_closure_lanes.sh +70 -0
  101. package/scripts/test_sim_isolated_run_lanes.sh +119 -0
  102. package/scripts/test_utterance_intake_lanes.sh +414 -0
  103. package/scripts/test_worktree_reclaim_lanes.sh +70 -0
  104. package/scripts/transcript_utterances.py +222 -0
  105. package/scripts/utterance_intake.sh +424 -0
  106. package/scripts/validate_yaml.sh +27 -0
  107. package/scripts/worktree_reclaim.sh +95 -0
  108. package/templates/.git-hooks/pre-commit +353 -1
  109. package/templates/.git-hooks/pre-push +91 -0
  110. package/templates/RED_TEAM_REPORT.md +49 -0
  111. package/templates/settings.PreToolUse.snippet.json +65 -1
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: qp-regress
3
+ description: Regression stage of QP — re-executes a previous run's test cases unchanged, computes surface_reach (how many TCs left the entry screen, as a ratio over ALL TCs), and reports the per-TC delta against the earlier run so a "no change" claim is per case, not an aggregate. Triggers on "did anything regress since last run", "re-run last week's QA pass", "compare this run with the previous one", "회귀 확인해줘", "지난 런이랑 비교해줘".
4
+ user-invocable: true
5
+ allowed-tools: ["Read", "Bash", "Write", "Glob", "Grep"]
6
+ model: sonnet
7
+ origin: chamber run fh-qa-generic (2026-09-05)
8
+ ---
9
+
10
+ # qp-regress — Regression (re-run unchanged → surface_reach → delta)
11
+
12
+ Input: a baseline `qp/run/<ts>/` and the same `qp/plan/tcs.tsv`. Output: `qp/regress/<ts>/`.
13
+
14
+ ## 1. Re-run unchanged
15
+ Execute the baseline's TCs with `qp-run` **without editing them**. Editing a TC to make it pass is a new run, not a regression — say so if the user asks for it.
16
+
17
+ ## 2. surface_reach — did the batch leave the entry screen?
18
+ ```
19
+ bash plugins/fh-qp/scripts/qp_tools.sh surface-reach qp/run/<new-ts>/evidence.tsv
20
+ ```
21
+ | result | meaning |
22
+ |---|---|
23
+ | `REACHED n/n` | every TC observed a screen other than the modal (entry) one |
24
+ | `PARTIAL k/n` | some did — report **which** did not, by tc_id |
25
+ | `NOT_REACHED 0/n` | the whole batch sat on one screen (a login wall, a dead env). Every FAIL in that run is **unmeasured**, not a detection — write that sentence in the report |
26
+ | `UNMEASURED` (rc 10) | no evidence file / empty — not a zero |
27
+ Denominator is **all TCs**, including ones that never executed; write `surface_reach.txt` with the raw line.
28
+
29
+ ## 3. Per-TC delta — `delta.tsv`
30
+ ```
31
+ tc_id baseline_status new_status changed note
32
+ ```
33
+ One row per TC. `changed=yes` on any status change **or** on a same-status verdict whose `closure` moved from MACHINE to JUDGMENT (the check got weaker). A run where every row is `changed=no` may say "no regression"; a run reported only as "12 PASS both times" may not — the aggregate hides a swap.
34
+
35
+ ## 4. Report — `report.md`
36
+ First 8 lines: target · engine/adapter/evidence · `#mtm:` state · surface_reach line · counts PASS/FAIL/BLOCKED/AMBIGUOUS new vs baseline · number of `changed=yes` · the sentence for NOT_REACHED if it applies · what was **not** measured (unexecuted TCs, dropped screenshots).
37
+
38
+ ## Done When
39
+ 1. `surface_reach.txt` holds a `REACHED|PARTIAL|NOT_REACHED|UNMEASURED` line produced by the tool · **[mandatory-pass]**.
40
+ 2. `delta.tsv` has one row per TC in the baseline · **[measured]** — row count equals distinct tc_ids.
41
+ 3. A NOT_REACHED run's report contains the "FAILs are unmeasured" sentence · **[measured]** — grep.
42
+ 4. The delta's `changed` column reflects real behavior change, not selector drift · **[judged — pairing: for each `changed=yes`, the before/after DOM snapshots are attached and a second session confirms the element moved, not the locator]**.
43
+
44
+ ## Independently executable
45
+ Yes — needs a baseline run dir in the documented shape and bash.
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: qp-run
3
+ description: Automation stage of QP — executes the test cases from qp-plan against the live app through the session's adapter (Playwright MCP for web, computer-use MCP for desktop), records one verdict per step with a closed status, an MTM branch, whether the step was closed by machine evidence or left to judgment, and masks emails/tokens/passwords out of the evidence before saving. Triggers on "run these test cases", "execute the QA plan against the site", "drive the app through the TCs", "TC 실행해줘", "플랜대로 돌려줘".
4
+ user-invocable: true
5
+ allowed-tools: ["Read", "Bash", "Write", "Glob", "Grep"]
6
+ model: sonnet
7
+ origin: chamber run fh-qa-generic (2026-09-05)
8
+ ---
9
+
10
+ # qp-run — Automation (execute → verdict → mask)
11
+
12
+ Input: `qp/plan/tcs.tsv` + the adapter/engine lines from `qp/plan/inventory.md`. Output dir: `qp/run/<YYYYMMDD-HHMM>/`.
13
+
14
+ ## 0. MTM state — decide once, write it first
15
+ `#mtm:` is the first line of `verdicts.tsv`:
16
+ | value | when |
17
+ |---|---|
18
+ | `ACTIVE` | a plan document was given **and** you opened it (path recorded) |
19
+ | `UNAVAILABLE` | no plan document — expected values come from the inventory; the branch *differs from plan* is **unreachable** and the vocabulary collapses to AS_PLANNED / CODE_DIFFERS |
20
+ | `FAILED` | a plan document was given but could not be read — say so; do not fall back silently |
21
+ | `DISABLED` | the user turned it off (reason recorded) |
22
+ `UNAVAILABLE` and `FAILED` are different events; never merge them.
23
+
24
+ ## 1. Precondition first — attribution, not blocking
25
+ Before step 1 of each TC, establish its `precondition`. Navigation you can do (`at:/about`) you do; state you cannot create (`logged-in`, `has-an-order`) you mark `UNMET_UNEXECUTABLE`. A step-1 failure under an unmet precondition is **`BLOCKED`**, not `FAIL` — you never reached the screen, so it is not a claim about the app. Only step 1 is re-attributed; later steps failed after arrival.
26
+
27
+ ## 2. Execute each step, observe before and after
28
+ web: accessibility snapshot before → action → snapshot after. desktop: screenshot before/after. Save each observation as `<tc_id>_step<N>_{before,after}.{md,png}` and append its stable id from `bash plugins/fh-qp/scripts/qp_tools.sh screen-id <file>` (a content hash — Playwright MCP ref tokens change every navigation, so a raw file hash is NOT stable; measured 2026-09-05) to `evidence.tsv` (`tc_id<TAB>step<TAB>screen_hash`) — this file is what `qp-regress` reads.
29
+
30
+ ## 3. Verdict row per step — `verdicts.tsv` (7 tab-separated fields)
31
+ ```
32
+ tc_id status branch closure verb assertion expected_source
33
+ ```
34
+ - `status` ∈ `PASS FAIL BLOCKED AMBIGUOUS` (AMBIGUOUS = could not map the step to one element: 0 candidates or a tie).
35
+ - `branch` ∈ `AS_PLANNED DIFFERS_FROM_PLAN CODE_DIFFERS NONE`. `DIFFERS_FROM_PLAN` only under `#mtm: ACTIVE` and only with `expected_source=PLAN_DOC`. `CODE_DIFFERS` needs `expected_source` `CODE` or `PLAN_DOC`. `NONE` only for BLOCKED/AMBIGUOUS.
36
+ - `closure` = `MACHINE` **only if** `assertion` names the check that closed it (`text-visible:About`, `url==…`, `element-count==3`). An empty assertion with `MACHINE` is invalid — that is the muscle-not-skeleton hole this field exists to close. Everything you decided by reading a screenshot is `JUDGMENT`.
37
+ - Validate before you finish: `bash plugins/fh-qp/scripts/qp_tools.sh mtm-check verdicts.tsv` must print `OK`; `run-verbs verdicts.tsv` must print `OK` (≥1 click/input actually executed).
38
+
39
+ ## 4. Mask, then save — original 0
40
+ Every text artifact goes through `qp_tools.sh mask <in> <out>` and only `<out>` is kept; delete `<in>`. `RESIDUE` (rc 5) → do not keep the file; report which artifact. Typed secret values (from the profile's `*_env`) are read from the environment at type-time and never written. Screenshots that show a filled password field are kept only if the field is masked in the image (crop or overlay); otherwise drop the screenshot and say so.
41
+
42
+ ## Done When
43
+ 1. `verdicts.tsv` passes `mtm-check` and `run-verbs` · **[mandatory-pass]** — rc 0 both.
44
+ 2. `evidence.tsv` has ≥1 row per executed TC · **[measured]**.
45
+ 3. Masking: `grep -cE '@|Bearer|eyJ' <saved artifacts>` is 0 outside `__REDACTED_*__` markers · **[measured]** — the lane reproduces it on a known-dirty fixture.
46
+ 4. Every BLOCKED verdict names its unmet precondition; every JUDGMENT closure says what was read · **[judged — pairing: `qp-regress` re-runs the same TCs and flags verdicts that flip without a DOM change; a second session reviews BLOCKED rows against the before-snapshots]**.
47
+
48
+ ## Independently executable
49
+ Yes, given a `tcs.tsv` in the documented shape (hand-written is fine) and an adapter.
@@ -168,12 +168,17 @@ fi
168
168
  _stamp "step-4-done"; echo " ✓ step 4: $NPERS blind persona sections present (isolation-gate satisfied)"
169
169
  _witness_record "$WS/SIM_NOTES.md"
170
170
 
171
- # STEP 5 — Emission Gate. Require a VERDICT: EMIT | PARTIAL-EMIT | KILL.
171
+ # STEP 5 — Emission Gate. Require a VERDICT: EMIT | PARTIAL-EMIT | KILL | CURATED | NOT-APPLICABLE.
172
+ # 🟥 2026-09-04: the doctrine re-routed two outcomes on 2026-08-17 (§3-SCREEN-2026-08-17 — net-new
173
+ # shortfall → CURATED, judgment-shaped candidate → NOT-APPLICABLE) and this regex kept parsing the
174
+ # OLD vocabulary for 18 days; run #16 had to file «KILL + CURATED 재료 동봉» to get past it, and the
175
+ # pre-registered taxonomy known-pair (5 old runs → CURATE) could not be replayed at all. A runner that
176
+ # rejects the doctrine's own words is [[feedback_rule_misdescribes_its_own_machine]] in the code half.
172
177
  if [ ! -f "$WS/EMISSION_VERDICT.md" ]; then
173
178
  cat > "$WS/EMISSION_VERDICT.md" <<EOF
174
179
  # Emission Gate Verdict — $SLUG (chamber run)
175
180
 
176
- VERDICT: <EMIT | PARTIAL-EMIT | KILL>
181
+ VERDICT: <EMIT | PARTIAL-EMIT | KILL | CURATED | NOT-APPLICABLE>
177
182
 
178
183
  ## Judged: does the simulation hold? (+ mechanical anchor: overlap grep / gate verdicts / reproduced flows)
179
184
 
@@ -183,11 +188,11 @@ EOF
183
188
  echo " ⛔ step 5 BLOCKED: decide WITH the operator (HITL), record VERDICT in $WS/EMISSION_VERDICT.md, re-run."; exit 1
184
189
  fi
185
190
  # PARTIAL-EMIT listed FIRST in every alternation so it is never mis-extracted as its EMIT substring.
186
- VERDICT=$(grep -ioE '^VERDICT:[[:space:]]*(PARTIAL-EMIT|EMIT|KILL)' "$WS/EMISSION_VERDICT.md" 2>/dev/null | head -1 | grep -ioE 'PARTIAL-EMIT|EMIT|KILL' | head -1 | tr 'a-z' 'A-Z')
191
+ VERDICT=$(grep -ioE '^VERDICT:[[:space:]]*(PARTIAL-EMIT|EMIT|KILL|CURATED|NOT-APPLICABLE)' "$WS/EMISSION_VERDICT.md" 2>/dev/null | head -1 | grep -ioE 'PARTIAL-EMIT|EMIT|KILL|CURATED|NOT-APPLICABLE' | head -1 | tr 'a-z' 'A-Z')
187
192
  # a bare "## Verdict:" prose line (run #3 style) also counts if it names KILL/EMIT
188
- [ -z "$VERDICT" ] && VERDICT=$(grep -ioE 'VERDICT[: *]+\**(PARTIAL-EMIT|EMIT|KILL)' "$WS/EMISSION_VERDICT.md" 2>/dev/null | grep -ioE 'PARTIAL-EMIT|EMIT|KILL' | head -1 | tr 'a-z' 'A-Z')
193
+ [ -z "$VERDICT" ] && VERDICT=$(grep -ioE 'VERDICT[: *]+\**(PARTIAL-EMIT|EMIT|KILL|CURATED|NOT-APPLICABLE)' "$WS/EMISSION_VERDICT.md" 2>/dev/null | grep -ioE 'PARTIAL-EMIT|EMIT|KILL|CURATED|NOT-APPLICABLE' | head -1 | tr 'a-z' 'A-Z')
189
194
  if [ -z "$VERDICT" ]; then
190
- echo " ⛔ step 5 BLOCKED: no VERDICT (EMIT|PARTIAL-EMIT|KILL) found in $WS/EMISSION_VERDICT.md, re-run."; exit 1
195
+ echo " ⛔ step 5 BLOCKED: no VERDICT (EMIT|PARTIAL-EMIT|KILL|CURATED|NOT-APPLICABLE) found in $WS/EMISSION_VERDICT.md, re-run."; exit 1
191
196
  fi
192
197
  _stamp "step-5-done"; echo " ✓ step 5: Emission Gate verdict = $VERDICT"
193
198
  _witness_record "$WS/EMISSION_VERDICT.md"
@@ -269,6 +274,10 @@ case "$VERDICT" in
269
274
  echo " existing asset / the skeleton (no new asset). Workspace stays as evidence." ;;
270
275
  KILL) echo "TERMINUS (KILL): first-class success — a cheap run prevented a speculative/reinvention build."
271
276
  echo " No emit. Workspace stays as the evidence record; seen-filter will skip re-listing it." ;;
277
+ CURATED) echo "TERMINUS (CURATED): net-new shortfall is not a kill (§3-SCREEN-2026-08-17) — hand the maker the"
278
+ echo " prior-art list, the closest existing asset, and the delta it does not cover." ;;
279
+ NOT-APPLICABLE) echo "TERMINUS (NOT-APPLICABLE): judgment-shaped candidate — not this incubator's output form."
280
+ echo " Route to doctrine (a rule/lens), not to a build. Workspace stays as evidence." ;;
272
281
  esac
273
282
  echo "chamber run '$SLUG' COMPLETE (STATUS: step-7-done, verdict $VERDICT)."
274
283
  # EMIT/PARTIAL-EMIT 인데 순서 증인이 없으면 **비영 종료**한다. KILL 은 영향 없다 —
@@ -0,0 +1,84 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!-- Replace every /path/to/... below with your own absolute paths before installing.
6
+ Follows the same template shape as scripts/com.forge-harness.daily-report.plist —
7
+ see that file's own comments for the general launchd-on-macOS caveats (sleep/wake
8
+ does not guarantee the exact minute, StandardOutPath dirs must pre-exist). -->
9
+ <key>Label</key>
10
+ <string>com.forge-harness.live-eval</string>
11
+
12
+ <key>ProgramArguments</key>
13
+ <array>
14
+ <string>/bin/bash</string>
15
+ <string>/path/to/forge-harness/scripts/probe_live_eval.sh</string>
16
+ <!-- 🟥 COST + API-KEY DECISION IS THE OPERATOR'S — this template ships with NO default
17
+ --subset/--ids limiter, i.e. installing it as-is runs the FULL selected set (12
18
+ probes x 2 calls = 24 live `claude -p` invocations) every night. Read
19
+ scripts/probe_live_eval.sh's own header (§COST) before installing, and add a
20
+ --subset N or --ids P1,P2 argument here if a nightly full-set run is not what you
21
+ want. This plist is a template, not a recommendation of scope. -->
22
+ <!-- 🟥 reps=3 (2026-09-06). 이 인자가 없으면 reps=1 이고, 그때의 pass_rate 로는 문턱을
23
+ 정할 수 없다 — 실측: 유효 런 3 개 재채점에서 12 프로브 중 5 개가 flaky, 같은 코퍼스
24
+ 15 분 간격 두 런에서 4 개가 뒤집혔고 관측 pass_rate 는 0.50/0.67/0.67 이었다(단일 rep
25
+ 노이즈 폭 > 문턱까지의 거리). 판정은 과반, 분산은 리포트 `reps(pass/ran)` 칸에 남는다.
26
+ ⚠️ 비용이 3 배다: 24 → **72** 회 `claude -p`. 줄이려면 --subset/--ids 를 같이 줘라. -->
27
+ <string>--reps</string>
28
+ <string>3</string>
29
+ </array>
30
+
31
+ <!-- 매일 02:30 (야간, 다른 cron 과 안 겹치는 시간대 — frontier-digest=09:00, daily-report=07:30
32
+ 과 분리). 잠자는 중이면 launchd 가 깨어난 뒤 한 번 돌린다(정각 보장 아님). -->
33
+ <key>StartCalendarInterval</key>
34
+ <dict>
35
+ <key>Hour</key><integer>2</integer>
36
+ <key>Minute</key><integer>30</integer>
37
+ </dict>
38
+
39
+ <key>RunAtLoad</key>
40
+ <false/>
41
+
42
+ <key>StandardOutPath</key>
43
+ <string>/path/to/forge-harness/tracks/_meta/logs/live_eval_out.log</string>
44
+ <key>StandardErrorPath</key>
45
+ <string>/path/to/forge-harness/tracks/_meta/logs/live_eval_err.log</string>
46
+
47
+ <key>EnvironmentVariables</key>
48
+ <dict>
49
+ <key>PATH</key>
50
+ <!-- must include wherever `claude` and `python3` resolve for this user.
51
+ /opt/homebrew/bin included so Homebrew's `gtimeout` (Apple Silicon prefix; Intel's
52
+ /usr/local/bin is already below) resolves — stock macOS ships no `timeout(1)` at
53
+ all, and scripts/probe_live_eval.sh's runner (sim_isolated_run.sh) falls back to a
54
+ bash-native watchdog if NEITHER resolves, so this is an optimization, not a hard
55
+ requirement: the run still enforces its timeout without this entry, just via the
56
+ slower fallback path. See sim_isolated_run.sh's own §timeout(1) RESOLUTION header
57
+ (measured 2026-09-05: the first unpatched run here died 12/12 FAILED-TO-RUN because
58
+ this PATH had neither `timeout` nor `gtimeout` AND the fallback did not exist yet). -->
59
+ <string>/path/to/home/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
60
+ <key>HOME</key>
61
+ <string>/path/to/home</string>
62
+ </dict>
63
+ </dict>
64
+ </plist>
65
+
66
+ <!--
67
+ INSTALL (manual — no launchd_wiring_check.sh render- helper exists for this plist yet; unlike
68
+ com.forge-harness.daily-report.plist, this one is not wired into that renderer, so every
69
+ /path/to/... above must be hand-edited before `launchctl load`):
70
+
71
+ 1. cp scripts/com.forge-harness.live-eval.plist ~/Library/LaunchAgents/com.forge-harness.live-eval.plist
72
+ 2. Edit every /path/to/... above to this checkout's absolute path and your $HOME.
73
+ 3. mkdir -p tracks/_meta/logs (StandardOutPath/StandardErrorPath dirs must pre-exist)
74
+ 4. launchctl load ~/Library/LaunchAgents/com.forge-harness.live-eval.plist
75
+ 5. Verify: launchctl list | grep com.forge-harness.live-eval
76
+
77
+ UNINSTALL:
78
+ launchctl unload ~/Library/LaunchAgents/com.forge-harness.live-eval.plist
79
+ rm ~/Library/LaunchAgents/com.forge-harness.live-eval.plist
80
+
81
+ The nightly run appends its record to tracks/_meta/live_eval_<date>.md (created fresh each run —
82
+ not append-only; a stale prior day's file is left alone, per date, exactly like
83
+ tracks/_meta/daily_report_<date>.md and tracks/_meta/frontier_digest_<date>.md).
84
+ -->
@@ -70,41 +70,15 @@ do_seal() {
70
70
 
71
71
  echo "## 운영자 발화 (이 세션)"
72
72
  if [ -f "$transcript" ]; then
73
- python3 - "$transcript" <<'PY' 2>/dev/null || echo "- (전사본 파싱 실패 — 원본: $transcript)"
74
- import json,sys
75
- n=0
76
- skipped_tool=skipped_meta=skipped_other=0
77
- for line in open(sys.argv[1], errors='replace'):
78
- try: d=json.loads(line)
79
- except Exception: continue
80
- if d.get('type')!='user': continue
81
- m=d.get('message') or {}
82
- c=m.get('content')
83
- # ⚠️ 초판은 "실발화=str · 툴결과=list" 로 갈랐다. **틀렸다** — 이미지/파일을 첨부한 실발화는
84
- # list 다(high 리뷰 실측: 전사본 25개에서 그런 발화 18건이 구조적으로 안 보였다). 더 나쁜 건
85
- # self-test 픽스처가 같은 가정을 인코딩해서 **초록이 그 결함을 보증**했다는 것이다.
86
- # 이제 list 는 text 블록을 꺼내 쓰고, tool_result 만 제외한다.
87
- if isinstance(c,str):
88
- t=c
89
- elif isinstance(c,list):
90
- if any(isinstance(b,dict) and b.get('type')=='tool_result' for b in c):
91
- skipped_tool+=1; continue
92
- parts=[b.get('text','') for b in c if isinstance(b,dict) and b.get('type')=='text']
93
- if not parts:
94
- skipped_other+=1; continue # 이미지-only 등 — 셈에서 지우지 않고 센다
95
- t=' '.join(parts)
96
- else:
97
- skipped_other+=1; continue
98
- t=' '.join(t.split())
99
- if not t: skipped_other+=1; continue
100
- if t.startswith('<') or t.startswith('/'): # 슬래시 커맨드·메타 봉투 제외
101
- skipped_meta+=1; continue
102
- n+=1
103
- print(f"{n}. {t[:200]}")
104
- # **제외분을 반드시 인쇄한다.** 합계만 찍으면 그 원장이 완전한 것처럼 읽힌다 — `not found ≠ 0`.
105
- print(f"\n합계: {n}건" if n else "- (발화 0건)")
106
- print(f"제외: tool_result {skipped_tool} · 메타/커맨드 {skipped_meta} · 텍스트없음 {skipped_other}")
107
- PY
73
+ # 🟥 추출 로직은 `scripts/transcript_utterances.py` **단일 소스**다 (2026-09-05).
74
+ # 인라인 heredoc 이었을 때 두 번째 소비처(`utterance_intake.sh` — 발화 착지 검사)를
75
+ # 붙이려면 사본이 생겼고, 전처리 두 벌은 «한쪽만 통과하는 입력이 다른 쪽에서 무음
76
+ # 드롭» 이다(`[[feedback_divergent_leniency_duplicate_normalizers]]`).
77
+ # 출력은 **바이트 동일**해야 한다 — `scripts/test_utterance_intake_lanes.sh` L10 이
78
+ # 골든으로 고정하고, L12/L12b 가 되돌림으로 «정말 이 파일을 통해 도는가» 를 잰다.
79
+ # 추출기가 없거나 죽으면 비영 종료 → 아래 폴백 문구가 그대로 뜬다(무음 아님).
80
+ python3 "$REPO_ROOT/scripts/transcript_utterances.py" "$transcript" --format seal 2>/dev/null \
81
+ || echo "- (전사본 파싱 실패 — 원본: $transcript)"
108
82
  else
109
83
  echo "- 🟥 전사본 경로 없음: $transcript"
110
84
  fi
@@ -106,6 +106,15 @@ b64_decode() {
106
106
  TMP=$(mktemp -d) || { echo "HARNESS-ERROR: mktemp failed"; exit 10; }
107
107
  trap 'rm -rf "$TMP"' EXIT
108
108
 
109
+ # ── has_nul: binary detection ──────────────────────────────────────────────────
110
+ # ONE implementation, called by check_path AND by --self-test. Until 2026-09-05 this was defined
111
+ # twice, byte-identical, once inside each caller — so the self-test exercised ITS OWN copy and a
112
+ # broken production copy stayed green (3-arm mutation: production-only break → rc=0). The file
113
+ # already pinned the one-implementation rule for compute_lost and loss_token; this was the one
114
+ # helper that escaped it. NOT `grep -q $'\x00'`: bash cannot hold a NUL in a string, so that
115
+ # collapses to the EMPTY pattern and every text file is declared binary. Strip-and-compare.
116
+ has_nul() { LC_ALL=C tr -d '\000' < "$1" | cmp -s - "$1" || return 0; return 1; }
117
+
109
118
  # ── compute_lost: the verdict, as multiset arithmetic ───────────────────────────
110
119
  # $TMP/lost = R − L (occurrences the remote has that the upload does not)
111
120
  # ONE implementation, called by check_path AND by --self-test — two copies of the same
@@ -234,7 +243,6 @@ check_path() { # $1=repo $2=base_sha $3=path
234
243
  # NOT `grep -q $'\x00'`: bash cannot hold a NUL in a string, so `$'\x00'` collapses to
235
244
  # the EMPTY pattern, grep matches every line, and every text file is declared binary —
236
245
  # a total over-block that looks like a strict check. Strip-and-compare instead.
237
- has_nul() { LC_ALL=C tr -d '\000' < "$1" | cmp -s - "$1" || return 0; return 1; }
238
246
  if has_nul "$prev" || has_nul "$path"; then
239
247
  echo " 🟥 $path — UNDECIDABLE: binary content (NUL bytes); a line-wise direction"
240
248
  echo " verdict over binary is not a measurement."
@@ -360,13 +368,17 @@ self_test() {
360
368
  # (h) NUL detection. The first draft used `grep -q $'"'"'\x00'"'"'`, which bash collapses to the
361
369
  # EMPTY pattern — every text file matched and every push would have been declared
362
370
  # binary/UNDECIDABLE. Both directions are pinned so that regression cannot return.
363
- has_nul() { LC_ALL=C tr -d '\000' < "$1" | cmp -s - "$1" || return 0; return 1; }
364
371
  printf 'plain text\n' > "$TMP/tn"
365
372
  has_nul "$TMP/tn" && r=BINARY || r=TEXT
366
373
  t "plain text is not misread as binary" TEXT "$r"
367
374
  printf 'a\000b\n' > "$TMP/tn"
368
375
  has_nul "$TMP/tn" && r=BINARY || r=TEXT
369
376
  t "a file with NUL bytes is detected as binary" BINARY "$r"
377
+ # (h2) The pair above measures PRODUCTION only if there is exactly ONE definition — a second,
378
+ # self-test-local copy made this suite green while the production copy was broken
379
+ # (2026-09-05, 3-arm mutation). Pin the count so the shadow copy cannot return.
380
+ t "has_nul is defined exactly once (the self-test measures the production copy)" 1 \
381
+ "$(grep -c '^has_nul() {' "$0" | tr -d ' ')"
370
382
 
371
383
  # (i) 404 must be the HTTP status, not any stderr containing "not found".
372
384
  # `gh: command not found` used to be accepted as "new file at base" → fail-open.
@@ -210,7 +210,10 @@ PROMPT="$(cat "$PROMPT_FILE")"
210
210
  rm -f "$PROMPT_FILE"
211
211
 
212
212
  _log "Stage 2 starting (timeout ${ATTEMPT_TIMEOUT_SECS}s)"
213
- "$CLAUDE_BIN" -p --permission-mode bypassPermissions "$PROMPT" >> "$LOG_FILE" 2>&1 &
213
+ # 프롬프트는 `-p` 바로 뒤(positional-first) Stage 1 러너와 같은 계약(2026-09-05, variadic 플래그 뒤
214
+ # positional 은 먹힌다). FD_MODEL 은 Stage 1 과 같은 변수로 핀한다: /model 로 저장한 기본 모델이
215
+ # `claude -p` 무인 런에도 적용되므로, 무인 잡의 모델은 plist 가 명시한다(운영자 결정 2026-09-05).
216
+ "$CLAUDE_BIN" -p "$PROMPT" --permission-mode bypassPermissions ${FD_MODEL:+--model "$FD_MODEL"} >> "$LOG_FILE" 2>&1 &
214
217
  CLAUDE_PID=$!
215
218
  DEADLINE=$((SECONDS + ATTEMPT_TIMEOUT_SECS))
216
219
  while kill -0 "$CLAUDE_PID" 2>/dev/null && [ "$SECONDS" -lt "$DEADLINE" ]; do
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """docs/map 의 archify 산출 HTML 에 붙이는 재생성-후 후처리 — 두 가지만 한다.
3
+
4
+ (1) reader-width floor 뷰어의 `MIN_READER_WIDTH = 960` 상수를 뷰포트 비례 하한으로 바꾼다.
5
+ archify 뷰어는 «스크롤 없이 다 담기» 를 목표로 폭을 줄이는데, 세로 예산이 모자라면
6
+ 1728px 화면에서도 960px 로 바닥을 친다(실측 2026-09-06: 1728×950 → 960). 지도는
7
+ «한 화면에 담기» 보다 «넓게 읽기» 가 목적이라 하한을 화면 비례로 올린다.
8
+ 실측 효과(1728×950, fh_process): 960 → 1382. 가로 오버플로 없음(1280×800 → 1216).
9
+
10
+ (2) SVG 재생성 HTML 안의 유일한 <svg> + 뷰어 <style> 을 심은 정적 벡터를 다시 만든다.
11
+ 기존 커밋본 두 장(fh_trust.dataflow.svg · fh_assets.architecture.svg)에 대해
12
+ 바이트 동일 재현을 known-pair 로 확인하고 만든 추출기다.
13
+
14
+ fail-closed: 기대한 리터럴이 없으면(렌더러 버전 드리프트) 종료코드 3 으로 멈춘다 —
15
+ 조용히 «패치할 게 없었다» 로 넘어가면 다음 발행이 옛 동작으로 나간다.
16
+
17
+ usage: map_postprocess.py <file.html> [...] 실제 적용
18
+ map_postprocess.py --check <file.html> [...] 적용 여부만 보고(쓰기 없음)
19
+ exit: 0 적용/이미적용 · 3 리터럴 부재(드리프트) · 4 인자/파일 오류
20
+ """
21
+ import re
22
+ import sys
23
+
24
+ OLD = 'var MIN_READER_WIDTH = 960;'
25
+ NEW = ('var MIN_READER_WIDTH = Math.min(1440, '
26
+ 'Math.max(960, Math.round(window.innerWidth * 0.80)));')
27
+
28
+
29
+ def build_svg(html: str) -> str:
30
+ styles = re.findall(r'<style[^>]*>(.*?)</style>', html, re.S)
31
+ if len(styles) != 1:
32
+ raise ValueError(f'expected exactly 1 <style> block, got {len(styles)}')
33
+ m = re.search(r'<svg\b.*?</svg>', html, re.S)
34
+ if not m:
35
+ raise ValueError('no <svg> block found')
36
+ svg = m.group(0).replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ', 1)
37
+ tag_end = svg.index('>') + 1
38
+ return ('<?xml version="1.0" encoding="UTF-8"?>\n'
39
+ + svg[:tag_end] + '<style><![CDATA[\n' + styles[0] + '\n]]></style>'
40
+ + svg[tag_end:])
41
+
42
+
43
+ def process(path: str, check_only: bool) -> int:
44
+ try:
45
+ html = open(path, encoding='utf-8').read()
46
+ except OSError as exc:
47
+ print(f'ERROR {path}: {exc}', file=sys.stderr)
48
+ return 4
49
+ has_old, has_new = OLD in html, NEW in html
50
+ if not has_old and not has_new:
51
+ print(f'DRIFT {path}: neither the original nor the patched reader-width '
52
+ f'literal is present — archify version drift, patch NOT applied',
53
+ file=sys.stderr)
54
+ return 3
55
+ if check_only:
56
+ print(f'{"PATCHED" if has_new else "UNPATCHED"} {path}')
57
+ return 0
58
+ patched = html.replace(OLD, NEW) if has_old else html
59
+ svg_path = re.sub(r'\.html$', '.svg', path)
60
+ # 쓰기 전에 둘 다 만들어 둔다 — 중간에 실패하면 «반쯤 적용된 트리»가 남고,
61
+ # 특히 open(...,'w') 은 예외가 나기 전에 이미 대상을 0바이트로 잘라 놓는다(L6 가 잡았다).
62
+ try:
63
+ svg = build_svg(patched)
64
+ except ValueError as exc:
65
+ print(f'ERROR {svg_path}: {exc} — nothing written', file=sys.stderr)
66
+ return 3
67
+ if has_old:
68
+ open(path, 'w', encoding='utf-8').write(patched)
69
+ print(f'PATCH {path}: reader-width floor -> viewport-proportional')
70
+ else:
71
+ print(f'SKIP {path}: already patched (idempotent)')
72
+ open(svg_path, 'w', encoding='utf-8').write(svg)
73
+ print(f'SVG {svg_path}')
74
+ return 0
75
+
76
+
77
+ def main(argv):
78
+ check_only = '--check' in argv
79
+ files = [a for a in argv if not a.startswith('--')]
80
+ if not files:
81
+ print(__doc__, file=sys.stderr)
82
+ return 4
83
+ worst = 0
84
+ for path in files:
85
+ worst = max(worst, process(path, check_only))
86
+ return worst
87
+
88
+
89
+ if __name__ == '__main__':
90
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bash
2
+ # outbound_query_guard.sh — 나가는 질의의 **위생 린트(accidental-leak lint)**.
3
+ # 🟥 **보안 통제가 아니다.** cross-family(codex, 2026-08-21) 지적: 토큰 분할·동의어·경로 일부·
4
+ # base64 는 전부 통과하고, 이 가드는 **어떤 outbound 실행기에도 배선돼 있지 않다.** 즉 «우연한
5
+ # 유출»은 잡고 «의도적 우회»는 못 잡는다. 보안 경계라 부르려면 실제 실행기에 강제 연결하고
6
+ # 정규화·승인·로깅의 우회 모델을 따로 설계해야 한다 — 하지 않았다. 그렇게 문서화한다.
7
+ #
8
+ # ── 왜 (챔버 런 consolidation-path-gate, S#6) ──
9
+ # 세션이 «세계에 물어본다» 를 하려면 문제를 이름으로 불러야 한다. 그 이름이 내부 자산명이면
10
+ # 질의 자체가 **유출**이다. 후보 스펙에 이 가드가 0줄이었고 블라인드 challenger 가 S 등급으로 지목했다.
11
+ # `CLAUDE.md §Field-Harness Diagnostic` 의 절대 규칙: 내부 자산명·경로는 로그·코멘트·붙여넣기에도
12
+ # 안 나간다. 외부 질의는 그 목록의 «붙여넣기» 와 같은 급이고, **되돌릴 수 없다.**
13
+ #
14
+ # ── 새 로직 0줄 ──
15
+ # `scripts/psa_scan_lib.sh` 가 이미 «패턴 두 층 로딩 · 행 검증 · 토큰 판정» 을 소유한다. 그 헤더가
16
+ # caller 의 몫을 명시했다: **무엇을 스캔하는가**, 그리고 **계기 불완전 시 degrade 방향**.
17
+ # 이 파일은 그 둘만 정한다 — 스캔 대상 = 질의 문자열, degrade = **fail-closed**.
18
+ #
19
+ # ── degrade 가 왜 block 인가 (커밋 caller 와 반대) ──
20
+ # 같은 라이브러리를 쓰는 pre-commit 은 override 부재 시 **경고**한다 — 커밋은 로컬이고 되돌릴 수
21
+ # 있으니까. 외부 질의는 push/publish 와 같은 부류다: 나가면 끝이다.
22
+ # `CLAUDE.md §Irreversibility Gates` 의 표면-클래스 degrade 불변식 그대로.
23
+ #
24
+ # Usage: bash scripts/outbound_query_guard.sh "<질의 문자열>"
25
+ # echo "<질의>" | bash scripts/outbound_query_guard.sh
26
+ # Exit: 0 = clean(내보내도 된다) · 1 = HIT(차단) · 3 = NOT SCANNED(계기 불완전 → 차단)
27
+ # Override: OUTBOUND_QUERY_OK=1 — 명시 승인. 로그를 남긴다. 🟥 유출이 없다고 «확인했을 때만».
28
+ set -uo pipefail
29
+ FH="$(cd "$(dirname "$0")/.." && pwd)"
30
+ # 🟥 주입 가능하게 둔다 — cross-family 가 «레인이 실물 라이브러리를 옮긴다» 를 HIGH 로 지목했다.
31
+ # 주입점이 있으면 레인이 레포를 안 건드리고, 오염 상태 분기에도 도달할 수 있다.
32
+ LIB="${PSA_LIB_FILE:-$FH/scripts/psa_scan_lib.sh}"
33
+ DEFAULTS="${PSA_DEFAULTS_FILE:-$FH/.claude/rules/.public-surface-patterns.defaults}"
34
+ OVERRIDE="${PSA_OVERRIDE_FILE:-$FH/.claude/rules/.public-surface-patterns}"
35
+ # 🟥 주입점 하나 더 — **같은 논거의 네 번째 적용**이다(위 세 개가 «레인이 레포를 안 건드리게»
36
+ # 있는 것과 동일). 실측 2026-09-05: 이 로그 경로가 박혀 있어서 L4 는 ⓐ 매 selfcheck 마다 실제
37
+ # 레포의 tracks/_meta 에 행을 쌓았고 ⓑ tracks/ 가 없는 트리(= npm 설치본)에서는 append 가
38
+ # 조용히 실패해 **`+1` 단언이 그대로 빨개졌다**. 즉 이 가드를 출하하려면 이 줄이 먼저다.
39
+ # 기본값은 안 바뀐다.
40
+ # ⚠️ 우회 채널로 읽지 마라: OUTBOUND_QUERY_OK 자체가 이미 env 로 켜는 것이라, 로그 경로를
41
+ # 옮기는 것이 «없던 우회»를 만들지는 않는다. 다만 «강행은 기록된다»는 성질이 env 하나에
42
+ # 더 의존하게 된 것은 사실이고, 그건 잔여로 이름 붙인다.
43
+ OVERRIDE_LOG="${OUTBOUND_QUERY_LOG:-$FH/tracks/_meta/.outbound_query_override_log}"
44
+
45
+ Q="${1:-}"; [ -n "$Q" ] || Q="$(cat)"
46
+ if [ -z "${Q// /}" ]; then echo " ⏭ 빈 질의 — 스캔할 것이 없다" >&2; exit 0; fi
47
+
48
+ if [ ! -r "$LIB" ]; then
49
+ echo "🚫 OUTBOUND BLOCKED — 스캐너 라이브러리 부재: $LIB" >&2
50
+ echo " 계기가 없으면 «유출 없음» 이 아니라 **미측정**이다. 비가역 표면이라 막는다." >&2
51
+ exit 3
52
+ fi
53
+ # shellcheck source=/dev/null
54
+ . "$LIB"
55
+
56
+ psa_load "$DEFAULTS" "$OVERRIDE" >/dev/null 2>&1 || true
57
+ # 🟥 **반환값이 아니라 상태 변수를 본다.** psa_scan_lib.sh 헤더가 이 함정을 명시한다:
58
+ # "A caller that treats PSA_BAD_ROWS>0 or PSA_DEFAULTS_OK=0 as 'clean' has a hole"
59
+ # 초판이 정확히 그 caller 였고 — `psa_load` 의 rc 만 보고 defaults 부재를 통과시켰다.
60
+ # known-pair 레인 L3 가 잡았다(`defaults 부재 → got=0 expect=3`). 레인이 없었으면 조용히 fail-open.
61
+ # 🟥 상태값은 `case` 로 본다. `[ -ne ]` 는 비수치 값에서 셸 에러 후 조건 false 로 흘러
62
+ # **fail-open** 이 된다 — 공유 라이브러리가 같은 결함을 이미 겪고 `case` 로 막아뒀다(psa_scan_lib.sh).
63
+ case "${PSA_DEFAULTS_OK:-}" in
64
+ 1) ;;
65
+ *)
66
+ echo "🚫 OUTBOUND BLOCKED — 공용 패턴층(defaults)이 없거나 비었다: $DEFAULTS" >&2
67
+ echo " 라이브러리 규정: 이 상태는 «설정 선택» 이 아니라 **BROKEN INSTRUMENT** 다." >&2
68
+ echo " fail-closed: 커밋 caller 는 경고하지만 외부 질의는 되돌릴 수 없다." >&2
69
+ exit 3 ;;
70
+ esac
71
+ case "${PSA_BAD_ROWS:-}" in
72
+ 0) ;;
73
+ *) echo "🚫 OUTBOUND BLOCKED — 패턴 파일 형식오류 행 '${PSA_BAD_ROWS:-?}' — 부분 로드는 미측정이다" >&2
74
+ exit 3 ;;
75
+ esac
76
+ # 🟥 **운영자 내부층(override)이 없으면 clean 을 말할 수 없다** (cross-family HIGH#1).
77
+ # 라이브러리 규정상 HIGH 내부 리터럴은 그 층에 산다. defaults 만으로 통과시키면 «보편 패턴만
78
+ # 본 것» 을 «내부 토큰 없음» 으로 렌더한다 — 초판이 그 caller 였다.
79
+ case "${PSA_OVERRIDE_PRESENT:-}" in
80
+ 1) ;;
81
+ *) echo "🚫 OUTBOUND BLOCKED — 운영자 내부 패턴층(override) 부재: $OVERRIDE" >&2
82
+ echo " defaults 만으로는 «내부 토큰 없음» 을 말할 수 없다 — 미측정이다." >&2
83
+ exit 3 ;;
84
+ esac
85
+ # 🟥 계기 생사 — 라이브러리가 카나리아 자체검증을 갖고 있다. 「초록인데 대상을 안 잰다」를 막는다.
86
+ if ! psa_require_live >/dev/null 2>&1; then
87
+ echo "🚫 OUTBOUND BLOCKED — psa_require_live 실패: 스캐너가 알려진 토큰도 못 잡는다" >&2
88
+ echo " 계기가 죽었다. 통과가 아니라 **미측정**이다." >&2
89
+ exit 3
90
+ fi
91
+
92
+ # 🟥 **개행·탭을 먼저 죽인다** (cross-family HIGH#3). `psa_scan_tagged` 는 줄마다 `path<TAB>body`
93
+ # 로 읽으므로, 질의에 개행+탭이 있으면 **토큰이 path 필드로 들어가 스캔 대상에서 빠진다.**
94
+ # 실제 우회 경로였다. 한 레코드로 눌러 넣는다.
95
+ Q_FLAT="$(printf '%s' "$Q" | tr '\n\t\r' ' ')"
96
+ # 🟥 P3 동형: 조건문 안의 대입이라야 `set -e` 아래서 안 죽는다.
97
+ if OUT="$(printf 'outbound-query\t%s' "$Q_FLAT" | psa_scan_tagged 2>&1)"; then RC=0; else RC=$?; fi
98
+ # 🟥 P2 (cross-family 3라운드): 초판은 «비영이면 override 로 통과» 였다. 그러면 **rc=3 미측정**
99
+ # 까지 승인된 것처럼 지나간다 — pre-commit·pre-push 에서 닫은 바로 그 구멍이 **세 번째
100
+ # 호출부에 그대로** 있었고, 나는 그것을 「호출부 셋 다 닫혔다」고 적었다(전수 주장이 틀렸다).
101
+ # override 는 «봤고 괜찮다» 는 뜻이라 **본 것(rc=1)에만** 쓸 수 있다. 안 본 것(rc=3)엔 못 쓴다.
102
+ # 🟥 R4-4/5: 판정점을 **하나로** 만든다. 초판은 «rc=3 선차단» + «override 에 `-le 1` 조건»
103
+ # 두 겹이었는데, 앞이 막아버려 뒤 술어는 **되돌려도 레인이 안 빨개졌다**(장식 앵커).
104
+ # 그리고 rc=2 는 어느 쪽도 안 잡아 **«유출 발견»(exit 1)으로 오분류**됐다 — 안 본 것을
105
+ # 본 것으로 렌더하는 방향이다. 「측정된 값」은 0 과 1 뿐이고 **나머지는 전부 미측정**이다.
106
+ case "$RC" in
107
+ 0|1) ;;
108
+ *) echo "🚫 OUTBOUND BLOCKED — 스캐너가 측정값을 안 냈다 (rc=$RC). NOT SCANNED 는 깨끗함이 아니다." >&2
109
+ echo " OUTBOUND_QUERY_OK 로도 통과 못 한다 — 승인은 «봤다» 는 뜻이고, 이건 안 본 것이다." >&2
110
+ echo "$OUT" >&2
111
+ exit 3 ;;
112
+ esac
113
+ if [ "$RC" -ne 0 ] || [ -n "$OUT" ]; then
114
+ # 여기 도달했다는 것은 위 `case` 에 의해 RC ∈ {0,1} 이 **구조적으로 보장**된 상태다.
115
+ # 초판의 `&& [ "$RC" -le 1 ]` 는 그래서 중복이었고, 중복은 앵커가 안 붙는다 — 지운다.
116
+ if [ "${OUTBOUND_QUERY_OK:-0}" = "1" ]; then
117
+ printf '%s\tOUTBOUND_QUERY_OK\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${Q:0:60}" \
118
+ >> "$OVERRIDE_LOG" 2>/dev/null || true
119
+ echo "⚠️ OUTBOUND — 히트가 있었으나 OUTBOUND_QUERY_OK=1 로 강행됨 (로그 기록됨)" >&2
120
+ echo "$OUT" >&2
121
+ exit 0
122
+ fi
123
+ echo "🚫 OUTBOUND BLOCKED — 나가는 질의에 내부 토큰이 있다:" >&2
124
+ echo "$OUT" >&2
125
+ echo " 고쳐라: 문제를 **일반 어휘**로 다시 써라 (내부 이름 대신 그 이름이 가리키는 «형태»)." >&2
126
+ echo " 예) «우리 X 게이트가…» → «커밋 전에 근거 필드를 강제하는 훅 패턴»" >&2
127
+ echo " 확인 후 강행: OUTBOUND_QUERY_OK=1 bash scripts/outbound_query_guard.sh \"<질의>\"" >&2
128
+ exit 1
129
+ fi
130
+ echo " ✅ outbound clean — 내부 토큰 없음" >&2
131
+ exit 0