@chrono-meta/fh-gate 2.2.0 → 2.3.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.
@@ -95,8 +95,11 @@ judge: mechanical
95
95
  verdict_binding: VIOLATION ARGS HARNESS_ERROR OUT_OF_SCOPE PEER_ABSENT
96
96
  #
97
97
  # known-pair — **합성 레포**에서 실행시점에 세운다. peer 히스토리를 안 쓰는 이유 셋:
98
- # ① peer 의 이 진입점은 **수리 중**이다(qasp PR #174, 브랜치
99
- # `fix/anchor-scan-out-of-scope-repairs` 쌍을 고른 시점의 체크아웃 상태다).
98
+ # ① peer 의 이 진입점은 **움직인다**. 2026-08-16 이 줄을 쓸 때는 qasp PR #174 가 열려
99
+ # 있었고(브랜치 `fix/anchor-scan-out-of-scope-repairs`), PR **2026-08-17 머지됐다**
100
+ # (qasp-dev main `cfd0e77`, 브랜치 삭제). 즉 이유는 살아 있고 사례만 닫혔다 —
101
+ # 그 브랜치를 고정 SHA 로 박았으면 squash 로 커밋이 orphan 이 되어 쌍이 지금 죽어 있다.
102
+ # ★머지 후 known-pair 재실행으로 확인: `--known-positive` rc=1 · `--known-negative` rc=0.
100
103
  # 고정 SHA 를 박으면 그 브랜치가 squash 머지되는 순간 커밋이 orphan 이 되어 쌍이 죽는다.
101
104
  # ② 상대 ref(`HEAD~N`)는 커밋이 쌓이면 가리키는 구간이 조용히 움직인다 — 같은 선언이
102
105
  # 다른 것을 재게 된다.
@@ -0,0 +1,130 @@
1
+ # ═════════════════════════════════════════════════════════════════════════════
2
+ # 어댑터 선언 — FH 가 **남의 하네스 능력을 자기 안에서** 선언한다
3
+ # ═════════════════════════════════════════════════════════════════════════════
4
+ # peer 하네스 `qasp`(로컬 별칭 해석: `qasp-dev`)의 `scripts/web_rules_pr_scan.sh` —
5
+ # 1.5막 **웹 셀렉터-안정성 rule 세트** — 를 FH 가 호출 가능하게 감싼 것이다.
6
+ # 어댑터임은 **id 네임스페이스**(`adapter/…`)와 이 헤더로 표시한다 — 🟥 **새 스키마 키를
7
+ # 만들지 않는다.** `.cap` 파서가 이 클러스터에 네 벌 있고(relay · registry · cluster_scan ·
8
+ # effect-probe), 키를 늘리면 네 곳을 동시에 고쳐야 하며 그게 이 레포의 재발 결함이다.
9
+ #
10
+ # 왜 여기 사나 (운영자 결정 2026-08-16): 어댑터가 **기본 경로**다. 남의 레포 안 `.cap`
11
+ # (소유 선언)은 그 하네스를 소유하고 동시에 노출을 결정한 특권적 경우이지 일반 경로가 아니다.
12
+ # ★이 노드는 그 결정의 **직접 산물**이다 — qasp-dev PR #173 이 대상 레포 안에 `.cap` 을
13
+ # 두려다 그 원칙에 걸려 CLOSED 됐고(2026-08-16), 그 자리를 이 파일이 대신한다.
14
+ #
15
+ # ── 무엇을 재나 ──────────────────────────────────────────────────────────────
16
+ # **웹 E2E 테스트 diff 에 로케이터/대기 규율 위반이 들어왔는가.** rule 5종
17
+ # (`web-coordinate-click` P1 · `web-wait-for-timeout` P2 등 — LOCATOR_DISCIPLINE ·
18
+ # ASSERTION_STRENGTH 렌즈). peer 헤더의 기원 실측: rule 테이블은 2026-07-24 부터 있었으나
19
+ # **프로덕션 호출자가 0건**이었다(2026-07-27 실측 — tests/ 와 자기 자신뿐).
20
+ # *"호출자가 없는 검출기는 산문이지 게이트가 아니다."*
21
+ #
22
+ # ── 왜 net-new 인가 ──────────────────────────────────────────────────────────
23
+ # FH 에 같은 계기가 **없다.** 인접 자산은 전부 다른 질문을 한다:
24
+ # · `adapter/qasp-new-code-anchor` — **신규 실행 코드에 앵커가 있나**(대상=diff 의 추가 파일)
25
+ # · `degrade_direction_scan.sh` — **판정이 어느 방향으로 무너지나**(대상=verdict 표면)
26
+ # · `adapter/mate-agent-boundary` — 모바일 에이전트 경계
27
+ # 이 능력은 **테스트 코드 자체의 로케이터/대기 규율**을 본다 — 대상이 «게이트» 도 «신규 파일»
28
+ # 도 아니고 **E2E 테스트의 작성 품질**이다. 그 렌즈는 FH 에 없다.
29
+ #
30
+ # ── 왜 진입점을 그대로 못 쓰나 ───────────────────────────────────────────────
31
+ # ⓐ peer 는 자기 부재를 뜻하는 값이 없다 — 없으면 「qasp 를 안 깔았다」가 ENGINE_ERROR
32
+ # (= qasp 가 깨졌다)로 렌더된다. 어댑터가 `PEER_ABSENT(20)` 을 더한다.
33
+ # ⓑ 🟥 **peer 의 문서화된 계약이 실제보다 좁다.** 셸 헤더는 `0/1/3` 만 적는데, 하위 python
34
+ # CLI 가 `parser.error()` 를 쓰고 argparse 가 **exit 2** 를 내며 셸이 그대로 전파한다
35
+ # (실측 2026-08-17). 어댑터는 **실제 계약**을 선언한다 — 안 그러면 인자 오류가
36
+ # 「계기가 깨졌다」로 렌더된다. peer 쪽 헤더 갱신은 별건으로 남긴다.
37
+ id: forge-harness:adapter/qasp-web-rules
38
+ summary: adapter to qasp web selector-stability rules — locator/wait discipline violations in web E2E test diffs
39
+ tags: adapter qasp web e2e playwright locator selector wait flaky static-review rules diff
40
+ entry: bash scripts/adapters/qasp_web_rules.sh
41
+ requires_cwd: SELF
42
+ verdict_channel: exit
43
+ #
44
+ # 🟥 「안 쟀다」가 **세 값**으로 갈라져 있다 — 전부 다른 명제다:
45
+ # ARGS(2) 인자가 틀렸다 (호출에 대한 진술)
46
+ # ENGINE_ERROR(3) 하네스는 있는데 못 쟀다 (계기에 대한 진술)
47
+ # 도달 경로(peer 실측): python 인터프리터 부재 · diff-json 읽기 실패 ·
48
+ # `gh api` 실패 · **스캔 대상 0개**(peer 가 fail-closed 로 못 박았다)
49
+ # HARNESS_ERROR(10) 계기 자신이 전제를 잃었다 (어댑터에 대한 진술)
50
+ # 도달 경로: `peer_resolve.sh` 부재 · peer 진입점 부재 ·
51
+ # peer 별칭 다중 히트(모호 — 고르지 않는다) · 캘리브레이션 픽스처 부재 ·
52
+ # peer 가 자기 계약 {0,1,2,3} 밖의 값 반환
53
+ # PEER_ABSENT(20) 그 노드가 이 머신에 없다 (클러스터에 대한 진술)
54
+ #
55
+ # 🟥 **`10` 은 초판 선언에서 빠져 있었다 — 스크립트는 내던 값인데 선언이 좁았다.**
56
+ # 등록 바(`capability_registry_check.sh` M2+)가 잡았다: *"enum 에 「안 돌았다」를 뜻하는
57
+ # 값이 없다"*. 자기 레인(W6)이 `rc=10` 을 이미 단언하고 있었는데도 선언에는 없었다 —
58
+ # **[[feedback_unedited_parts_of_my_own_file]]** 의 형태다(내 편집 범위 안, 자력 적발 0).
59
+ # ★peer 자신의 규율: *"exit 3 을 0 으로 뭉개지 않는다 — 못 잰 것을 깨끗하다고 보고하는
60
+ # 순간 게이트가 장식이 된다."* 어댑터는 하위 exit 을 전파하지 않고 **명시 case 로 재매핑**한다.
61
+ # ★등록 바는 아래 두 arm 만 돌린다 — ARGS·ENGINE_ERROR·PEER_ABSENT 는 선언돼 있으나
62
+ # 그 판정에서 **관측되지 않는다**. 셋은 `scripts/test_adapter_lanes.sh` 가 덮는다.
63
+ verdict_enum: 0=CLEAN 1=FINDINGS 2=ARGS 3=ENGINE_ERROR 10=HARNESS_ERROR 20=PEER_ABSENT
64
+ approval: auto
65
+ #
66
+ # `reversibility: reversible` — 이 계기는 **읽고 보고할 뿐** 아무것도 되돌릴 수 없게 만들지
67
+ # 않는다. peer 표면 자신도 advisory 다(PR 코멘트/요약 — 차단하지 않는다).
68
+ reversibility: reversible
69
+ #
70
+ # 🟥 `residency: company` — peer 레포가 company 다. `--diff-json` 모드에서 이 어댑터는
71
+ # **호출자가 준 diff 의 파일 경로와 코드 라인**을 출력에 그대로 싣는다. 회사 코드를
72
+ # 스캔하면 그 경로·라인이 조직 자산을 나른다.
73
+ # ⇒ 이 능력의 **출력**은 로컬에 남기고 외부 계열 디스패치에 그대로 넘기지 않는다.
74
+ # ⚠️ 캘리브레이션 arm 은 **우리가 지은 합성 diff** 만 만지므로 그 경로에 한해 조직 내용이
75
+ # 없다. 그러나 residency 는 **가장 넓게 관측된 값**으로 선언한다.
76
+ residency: company
77
+ degrade: fail-closed
78
+ tier_floor: none
79
+ #
80
+ # ═════════════════════════════════════════════════════════════════════════════
81
+ # 🟥 `writes: write-local` — `read-only` 가 **아니다.** cold 실측이 근거다
82
+ # ═════════════════════════════════════════════════════════════════════════════
83
+ # 진입점이 `python -m src.static_review.web_rules` 를 부르고, 그 import 폐포가
84
+ # `src/config/settings.py` 의 **import 시점 `ensure_dirs()`** 에 닿는다.
85
+ #
86
+ # warm(운영자 실 트리) : 트리 변화 **없음** ← 🟥 이것을 근거로 쓰면 안 된다
87
+ # cold(격리 클론) : 신규 엔트리 **25개**
88
+ # `__pycache__` 디렉토리 5 + `.pyc` 파일 18
89
+ # **빈 디렉토리 `./tmp` · `./uploads`** ← `mkdir(exist_ok=True)` 의 산물
90
+ #
91
+ # warm 이 깨끗해 보인 것은 그 디렉토리들이 **이미 있어서** mkdir 이 무음 no-op 이었기
92
+ # 때문이다 — **warm cache 아티팩트지 read-only 의 증거가 아니다.**
93
+ #
94
+ # 🟥 **이 값은 손 실측으로만 선다 — M6 는 이 노드의 선언을 판별하지 못한다.**
95
+ # known-pair 로 확인했다(2026-08-17): 같은 파일을 `writes: read-only` 로 **거짓 선언**해도
96
+ # 프로브는 `✅ VERIFIED · 관측=no-write` 를 낸다. 즉 이 축에서 **컨트롤이 죽어 있다.**
97
+ #
98
+ # 기전: 프로브의 감시면은 ⓐ 샌드박스(= **FH** 레포의 격리 클론) ⓑ 임시 카나리아 하나
99
+ # ⓒ `$HOME` 엔트리 목록이다. 그런데 **어댑터는 정의상 peer 트리에서 돈다** —
100
+ # `cd "$PEER_ROOT"` 가 이 파일의 전제다. 쓰기는 전부 감시면 **밖**에 떨어지고, 밖이되
101
+ # ⓑⓒ 어디도 아니라서 «샌드박스 밖이 바뀌었다» 분기에도 안 걸린다.
102
+ #
103
+ # ⚠️ **초판이 여기에 «이제 기계로 검증·반증된다» 고 적었는데 그건 거짓이었다.**
104
+ # FH PR #422 가 «빈 디렉토리» 축을 넣은 것은 사실이고 일반 명제로는 맞지만, **그 축은
105
+ # 샌드박스 안을 볼 때만 작동하므로 이 노드에는 닿지 않는다.** 참인 일반 명제로 거짓인
106
+ # 개별 주장을 덮은 형태다 — 대조군이 없었으면 그대로 실렸다.
107
+ #
108
+ # ⇒ 이 선언의 근거는 **위 cold 손 실측 하나뿐**이고, 재검증하려면 손으로 다시 재야 한다
109
+ # (격리 클론 → 진입점 1회 → `find` 순증 계수). 자동 앵커 없음.
110
+ # ⇒ **어댑터 클래스 전체의 성질**이다. 자매 노드 `adapter/qasp-new-code-anchor` 의
111
+ # `writes: read-only` 도 같은 이유로 M6 가 판별하지 못한다 — 그쪽은 결론이 맞지만
112
+ # (순수 bash, import 없음) **맞는 이유가 프로브 덕은 아니다.**
113
+ writes: write-local
114
+ judge: mechanical
115
+ # CLEAN 만 clearing. FINDINGS 는 위반, 나머지 셋은 「안 쟀다」의 서로 다른 얼굴 — 전부 막는다.
116
+ verdict_binding: FINDINGS ARGS ENGINE_ERROR HARNESS_ERROR PEER_ABSENT
117
+ #
118
+ # known-pair — **FH 소유 픽스처**(`scripts/adapters/fixtures/`), 오프라인·결정적.
119
+ # 양성: `page.wait_for_timeout(3000)` → `web-wait-for-timeout` 발화 → 실측 rc=1
120
+ # 음성: `get_by_role("button", name=…).click()` → 위반 0 → 실측 rc=0
121
+ # ★왜 peer 픽스처를 안 쓰나: peer 의 `tests/` 는 그 레포 사정으로 움직인다. **남의 트리에
122
+ # 있는 픽스처에 의존하는 계기는 별도 계기가 아니라 그 레포의 일부다.**
123
+ # ★왜 PR 번호 모드를 안 쓰나: `gh api` 가 필요해 네트워크·인증에 의존하고, 그러면 M4 가
124
+ # 「계기가 판별하는가」가 아니라 **「오늘 gh 가 되는가」**를 재게 된다.
125
+ # ★왜 고정 SHA 를 안 박나: 자매 노드(`adapter/qasp-new-code-anchor`)가 그 대가를 오늘 실증했다
126
+ # — 그 쌍이 가리키던 브랜치가 squash 머지되며 커밋이 orphan 이 됐다. 합성 픽스처는 안 움직인다.
127
+ calibration_positive_args: --known-positive
128
+ calibration_positive_expect: FINDINGS
129
+ calibration_negative_args: --known-negative
130
+ calibration_negative_expect: CLEAN
@@ -194,6 +194,36 @@ controls: n/a — no measurement in this delta (<reason>)
194
194
  — zero hook lines, no fixture suite. Recorded here so the gap is visible from the rules side rather
195
195
  than only from the hook's absence.
196
196
 
197
+ **`thirdparty:` — ⓓ3자 대면의 자기 필드 (2026-08-17 신설).** ⓑ가 `standpoint:` 를 갖는 것과
198
+ 같은 형태다: `axes-run` 에는 포인터(`ⓓ=→thirdparty`)만 두고 값은 이 필드가 나른다.
199
+
200
+ ```
201
+ thirdparty: checked(<무엇을 검색했고 무엇을 찾았나>)
202
+ thirdparty: none-found(<무엇을 검색했나>) ← 찾았는데 선행 없음. «안 봤다»와 다른 명제다
203
+ thirdparty: DEGRADED_NO_ACCESS(<사유>) ← 못 봤다 (검색 수단 부재)
204
+ thirdparty: DEGRADED_NOT_RUN(<사유>) ← 안 돌렸다
205
+ thirdparty: UNKNOWN ← 안 봤다
206
+ thirdparty: not-applicable(<사유>)
207
+ ```
208
+
209
+ 🟥 **왜 `axes-run` 한 칸으로 부족한가.** 그 칸은 **토큰**을 담는데, 이 축이 실제로 내는 것은
210
+ «무엇을 검색해서 무엇을 찾았나» 라 한 토큰에 안 들어간다. **자리가 없으면 안 적히고, 안 적히면
211
+ 다음 세션이 «이 축을 돌렸나»를 물을 수도 확인할 수도 없다.**
212
+ 실측 근거(2026-08-17): 이 축을 처음 제대로 돌린 세션에서 **발표 주장 6건이 선행자산에 걸렸다**
213
+ (mutation testing · Anthropic skill-creator 블라인드 · Claude Code auto mode · Self-Harness ·
214
+ 공식 cross-family 플러그인 · constrained decoding). 그 전까지 이 레포 전체 마크다운에서
215
+ `promptfoo|DeepEval|Inspect|garak|PyRIT|mutation testing` grep 히트는 **1줄**이었다 —
216
+ 축은 정본화돼 있었는데 **기록면이 없어서 습관이 안 생겼다.**
217
+
218
+ **degrade 3값 분리는 `crossfamily:` 를 그대로 상속한다** — 못 봤다 / 안 돌렸다 / 안 봤다는
219
+ 서로 다른 명제이고, 자유 산문은 그 셋을 접는다. 접히면 «안 돌린 축»이 «선행 없음»으로 읽힌다.
220
+
221
+ **검증 범위(오늘 기준)**: 훅은 ⓑ와 동일하게 **포인터가 가리키는 필드의 존재**만 본다.
222
+ enum 값 검증은 안 한다 — `standpoint:` 와 같은 유보이고, 같은 이유다(첫 거짓 값이 기록되면
223
+ 그때 기계화한다). ⚠️ ⓑ가 겪은 **화살표 fail-open 도 같이 상속해 미리 막았다**: 화살표를
224
+ 필수로 보지 않고 «ⓓ 값이 thirdparty 를 참조하는가»로 본다. 같은 명시 잔여(참조어가 다른
225
+ 토큰에 있으면 미검출)도 그대로 남는다.
226
+
197
227
  **Scope, unchanged**: form + non-vacuity + auditability, **never provenance**. A marker claiming
198
228
  `ⓐ=codex` when codex never ran passes — deliberately. Catching that is cross-family review *reading*
199
229
  the marker, which is not this hook's job.
@@ -11,13 +11,13 @@
11
11
  "plugins": [
12
12
  {
13
13
  "name": "fh-meta",
14
- "version": "2.2.0",
14
+ "version": "2.3.0",
15
15
  "description": "New in 2.2.0: BREAKING (gate): chamber step 6 now reads ACTUAL.md, not BUDGET.md — an in-flight chamber run whose actual cost sits in BUDGET.md blocks until the ACTUAL: line moves to tracks/_chamber/<slug>/ACTUAL.md (the runner prints the path). Why: BUDGET.md's pre-verdict hash IS the ordering witness, and step 6 hard-blocked until that same file changed, so every run that reached COMPLETE necessarily mutated a witnessed artifact and verify returned TAMPERED — the chamber's promotion condition was unsatisfiable by construction, not by strictness. Two roles (immutable witness / post-verdict calibration sink) had collided in one file; each was correct alone, so neither side's code showed the conflict. Also: ko-tech-writer Step 2/4-b scans are now calibration-backed (known-pair fixtures + reproducible command, shipped) — discrimination is proven, 'zero residue' is explicitly NOT; chamber lane suite 12 -> 33 including the runner x witness seam no test covered; chamber_run.sh now teaches the two-commit discipline (gate hashes and verdict hash must land in separate commits/PRs — it previously advised the opposite). New in 2.1.0: BREAKING (gate): `crossfamily: declined` in an Axes 2-3 marker now requires grounds naming a record path that RESOLVES on disk — bare `declined`, and `declined` justified by author judgment, are blocked at commit. Remedy: cite where the operator decision lives (e.g. `.. — operator declined sidecars, per knowledge/shared/rules/operational_adaptation.md`), or use `DEGRADED_PANEL_UNUSED` if a panel was reachable and you chose not to recruit it — which is what author judgment actually is. `declined` was the only enum value with no grounds requirement; a cross-family review then broke the first (vocabulary-grep) fix three ways — self-validating on the value's own token, vacuous keyword passes, and over-blocking real declinations in natural prose — so the check asserts a resolvable record instead of words. Also: standpoint axis gains `tier1b` (a STATIC read of a target repo, executed nothing) plus a decide-in-order procedure, after blind floor-tier sims graded pure cold-reads as `tier2` three rounds running; steel-quench Wave 1's sixth angle (gate-locality) gains the output-template row it never had, so a mandatory angle stops being structurally unreportable; verify-bidirectional gains category 5 (prescriptive doctrine statement); Sister Asset Protocol gains an active-adoption trigger; new resident doctrine — Mechanization Boundary, Local Execution First, Skeleton-not-Muscle, Expedition track, and this package's versioning policy. Hub meta-operations toolkit — 35 skills + 7 agents. New in 2.0.1: harness-doctor cadence hook, portability lint wired into pre-commit, branch_claim.sh claim-count-vs-tree-count warning, louder confidentiality-scan fail-open notice, fh-gate.sh missing-package.json survival, identity ① reclassified 🟢 (cross-harness adapters + relay argument channel). New in 1.4.53: `fh-codex-doctor` (npm bin) — Codex adapter drift scanner; reads the documented M1/M2/M3 skill tier map + skill/agent source and reports codex-native/adapter-required/claude-native/unclassified per unit, wired into `npm test`/`prepublishOnly` (fail-closed on unclassified Claude-native primitives). New in 1.4.49: steel-quench gains Step 0.6 Verdict-Invariance Probe (groundedness axis — a load-bearing judged gate's verdict must track behavior, not rubric phrasing; measured flip-count over cross-family paraphrases; arXiv:2605.06161 Policy Invariance anchor); multi_model_sidecar_strategy §Vendor-native harness (a model is strongest in its own vendor CLI — Claude/CC, GPT/codex, Gemini/Antigravity; a universal router degrades all of them, so it stays an autocomplete/QA sidecar, never orchestration); predelete_check.sh fail-closed rewrite; memory-hygiene A-TMA anchor. New in 1.4.48: phantom-quench + steel-quench gain external frontier anchors (arXiv:2607.02052 package-hallucination; arXiv:2607.02057 prompt-coverage-adequacy); README model-flat claim reframed from a per-release point-curve to structural invariants (operation flattens across tiers; depth tier-order fixed within a generation). New in 1.4.47: onboarding step ① surfaces the Mode D companion-store session-start load in the auto-read salience anchor (previously only in the local binding + rules, so a greeting could skip the load). New in 1.4.46: context-doctor command-output axis (route to rtk/proxy for verbose CLI stdout, complementing .claudeignore; risk-gated to token-scarce envs). New in 1.4.41: context-doctor 2026 trigger vocab (context engineering/rot/collapse) + phantom-citation hardening; hub measurement-integrity-checklist (cross-model measurement pre-flight: display-name pin/reps≥3/discriminating probe). New in 1.4.40: install-wizard queryable-wiki scaffold (INDEX + session-start read + R/W/C ingest). New in 1.4.39: auto-decorrelation (cross-family verifier sidecar recruitment) + video-ingest (capability-routed video ingestion). New in 1.4.x: verify-axis check-class taxonomy (mandatory-pass/measured/judged), no-reinvention Tier-0 inventory, 7-class failure taxonomy, Destructive-Op Gate, Wave-T (Temper), tier-floor governance, Mode D Model Notice, FC consent lane, default-Sonnet guidance. New in 1.3.0: public-surface-audit, field-harvest Mode B auto-trigger, 4-axis gate scope ext. Validated cross-CLI: Claude Code, Codex, Gemini.",
16
16
  "source": "./plugins/fh-meta"
17
17
  },
18
18
  {
19
19
  "name": "fh-commons",
20
- "version": "2.2.0",
20
+ "version": "2.3.0",
21
21
  "description": "Project-agnostic utility skills — 5 skills (convergence-loop · deliberation · mcp-circuit-breaker · token-budget-gate · ko-tech-writer) + 1 agent (quench-challenger). Domain-independent utilities transplantable into any project.",
22
22
  "source": "./plugins/fh-commons"
23
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrono-meta/fh-gate",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "FH runtime adapters — run FH governance, skills, and agents via Claude or Codex with machine-parseable gates.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -84,6 +84,9 @@
84
84
  "scripts/adapters/gstack_content_safety.sh",
85
85
  "scripts/adapters/mate_agent_boundary.sh",
86
86
  "scripts/adapters/qasp_new_code_anchor.sh",
87
+ "scripts/adapters/qasp_web_rules.sh",
88
+ "scripts/adapters/fixtures/qasp_web_rules_known_positive.json",
89
+ "scripts/adapters/fixtures/qasp_web_rules_known_negative.json",
87
90
  "scripts/test_adapter_lanes.sh",
88
91
  "scripts/test_fh_gate_regressions.sh",
89
92
  "templates/local_fh_context.md",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-commons",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-meta",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -10,6 +10,59 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
10
10
 
11
11
  ## Plugin Level
12
12
 
13
+ ### [2.3.0] — 2026-08-17
14
+
15
+ 🟥 **BREAKING (gate) ①**: the capability effect-probe (`M6`) now watches **directory existence and
16
+ non-regular nodes**, not files only. A `.cap` declaring `writes: read-only` whose entry point
17
+ creates an **empty directory**, a **symlink**, or a **fifo/socket/device node** now returns
18
+ `VIOLATION` where it previously returned `✅ VERIFIED`. Registration of such a capability
19
+ **blocks**.
20
+ - **Remedy**: declare the real value (`write-local` / `write-remote`), or stop creating the node.
21
+ The probe prints which surface changed.
22
+ - **Why**: `_snapshot()` used `find -type f`, so **empty directories were invisible** — git does not
23
+ track them, so they are absent from the isolated-clone sandbox too. Measured with a 3-arm
24
+ known-pair: write-file-to-new-path → 1, write-file-to-existing-dir → 1, **empty-dirs-only → 0**
25
+ (the false green). A cross-family review then found the same class still open one layer down:
26
+ `ln -sf /etc/passwd link` and `mkfifo pipe` **also passed** after the directory axis was added.
27
+ Both are now caught; symlinks are compared **by target**, since existence alone makes
28
+ `ln -sf other link` look like no change.
29
+ - ⚠️ **Named residual, unchanged**: metadata-only writes (`chmod`, xattr, hardlink topology) and
30
+ empty directories **under `.git`** remain invisible. And the probe **still cannot discriminate an
31
+ adapter-class capability** — its outside-watch is a temp canary plus `$HOME`, while an adapter by
32
+ definition runs in the peer tree. That value stands on hand measurement, not on the probe.
33
+
34
+ 🟥 **BREAKING (gate) ②**: writing `ⓓ=→thirdparty` in the Axes 2–3 marker now **requires a non-empty
35
+ `thirdparty:` line**. A marker with the pointer but no target **blocks the commit**.
36
+ - **Remedy**: the hook prints the accepted forms —
37
+ `checked(<what was surveyed>)` · `none-found(<what was searched>)` · `UNKNOWN` · `not-applicable`.
38
+ - **Why**: a pointer at nothing is not a record. Same failure the `ⓑ=→standpoint` guard closed.
39
+
40
+ **Added**
41
+ - `adapter/qasp-web-rules` — a cluster node wrapping a peer harness's web selector-stability rule
42
+ set. Enum `0=CLEAN 1=FINDINGS 2=ARGS 3=ENGINE_ERROR 10=HARNESS_ERROR 20=PEER_ABSENT`, calibration
43
+ pair from **FH-owned fixtures** (offline, deterministic), `writes: write-local` established by
44
+ cold-clone measurement. Registration bar M1–M6: **REGISTRABLE**.
45
+ 🟥 **Consumer 0** — declared and callable, nothing calls it yet. Do not round that to "wired".
46
+ 🟥 The declared enum is **wider than the peer's documented contract** (`0/1/3`): the underlying
47
+ CLI uses `parser.error()`, argparse exits **2**, and the shell wrapper propagates it verbatim.
48
+ The adapter declares the **real** contract, or an argument mistake renders as "the instrument broke".
49
+
50
+ **Fixed**
51
+ - The adapter's argument validation now runs **before** peer resolution. Previously the **same bad
52
+ call returned different values on different machines** — `ARGS(2)` where the peer was installed,
53
+ `HARNESS_ERROR(10)` where it was not — i.e. the check measured **what is installed on this box**
54
+ rather than **how it was called**. `ARGS` is a statement about the call; `PEER_ABSENT` is a
55
+ statement about the cluster; the two are independent facts.
56
+ - Capability entry points and their calibration fixtures are now in `files[]`. Consumers previously
57
+ received the **declaration without the instrument** — a `.cap` that passes the registration bar
58
+ and then returns `HARNESS_ERROR` inside the tarball.
59
+
60
+ **Note for consumers of the effect-probe**
61
+ If you hold `.cap` files, re-run the probe **before** upgrading: this release changes verdicts for
62
+ the three shapes above. A green result under 2.2.0 is not evidence under 2.3.0.
63
+
64
+ ---
65
+
13
66
  ### [2.2.0] — 2026-08-17
14
67
 
15
68
  🟥 **BREAKING (gate)**: chamber step 6 now reads `ACTUAL.md`, **not `BUDGET.md`**. An in-flight
@@ -0,0 +1,7 @@
1
+ [
2
+ {
3
+ "filename": "tests/e2e/test_checkout_web.py",
4
+ "status": "modified",
5
+ "patch": "@@ -1,3 +1,5 @@\n import pytest\n+\n+def test_pay(page):\n+ page.get_by_role(\"button\", name=\"결제\").click()\n"
6
+ }
7
+ ]
@@ -0,0 +1,7 @@
1
+ [
2
+ {
3
+ "filename": "tests/e2e/test_checkout_web.py",
4
+ "status": "modified",
5
+ "patch": "@@ -1,3 +1,6 @@\n import pytest\n+\n+def test_pay(page):\n+ page.wait_for_timeout(3000)\n+ page.click(\"text=결제\")\n"
6
+ }
7
+ ]
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bash
2
+ # qasp_web_rules.sh — FH 소유 **어댑터**. peer `qasp` 의
3
+ # `scripts/web_rules_pr_scan.sh`(1.5막 웹 셀렉터-안정성 rule 세트)를 클러스터 호출 가능한
4
+ # typed capability 로 감싼다.
5
+ #
6
+ # ─────────────────────────────────────────────────────────────────────────────
7
+ # 무엇을 재나
8
+ # ─────────────────────────────────────────────────────────────────────────────
9
+ # **웹 E2E 테스트 diff 에 로케이터/대기 규율 위반이 들어왔는가.** rule 5종
10
+ # (`web-coordinate-click` · `web-wait-for-timeout` 등 — LOCATOR_DISCIPLINE ·
11
+ # ASSERTION_STRENGTH 렌즈). peer 헤더의 기원 실측: 이 rule 테이블은 2026-07-24 부터
12
+ # 있었지만 **부르는 사람이 없었다**(2026-07-27 실측: 프로덕션 호출자 0건, tests/ 와
13
+ # 자기 자신뿐). *"호출자가 없는 검출기는 산문이지 게이트가 아니다."*
14
+ # 진입점은 **peer 소유 코드**이고 이 어댑터가 만든 것이 아니다.
15
+ #
16
+ # ─────────────────────────────────────────────────────────────────────────────
17
+ # VERDICT ENUM (선언과 정확히 일치해야 한다)
18
+ # ─────────────────────────────────────────────────────────────────────────────
19
+ # 0 CLEAN 위반 0건
20
+ # 1 FINDINGS 위반 존재
21
+ # 2 ARGS 인자 오류 — 판정이 아니다
22
+ # 3 ENGINE_ERROR 스캔이 못 돌았다(python 부재 · 입력 불가 · 대상 0개) — 판정이 아니다
23
+ # 20 PEER_ABSENT **peer 하네스가 이 머신에 없다**
24
+ #
25
+ # 🟥 **`2` 는 peer 셸 헤더에 안 적혀 있다** — 그 헤더는 `0/1/3` 만 적는다. 그러나 하위
26
+ # python CLI(`src/static_review/web_rules.py`)가 `parser.error()` 를 쓰고 argparse 는
27
+ # **exit 2** 를 내며, 셸 래퍼는 `exit "$RC"` 로 **그대로 전파**한다(실측 2026-08-17).
28
+ # 즉 문서화된 계약보다 실제 계약이 한 값 넓다. 어댑터는 **실제 계약**을 선언한다 —
29
+ # 안 그러면 argparse 오류가 `*)` 로 떨어져 HARNESS_ERROR 가 되고, 「인자를 틀렸다」가
30
+ # 「계기가 깨졌다」로 렌더된다.
31
+ #
32
+ # 🟥 값 셋이 「안 쟀다」의 서로 다른 얼굴이다:
33
+ # ARGS(2) 인자가 틀렸다 (호출에 대한 진술)
34
+ # ENGINE_ERROR(3) 하네스는 있는데 못 쟀다 (계기에 대한 진술)
35
+ # PEER_ABSENT(20) 그 노드가 이 머신에 없다 (클러스터에 대한 진술)
36
+ # peer 가 자기 규율로 이미 적어 뒀다 — *"exit 3 을 0 으로 뭉개지 않는다. 못 잰 것을
37
+ # 깨끗하다고 보고하는 순간 게이트가 장식이 된다."* 어댑터는 그 위에 PEER_ABSENT 만 더한다.
38
+ #
39
+ # ⚠️ 하위 exit 을 **전파하지 않는다** — 아래 case 가 명시 재매핑이다. 같은 숫자가 이
40
+ # 클러스터 안에서 다른 사건을 뜻한다(`new_code_anchor_scan.sh` 의 `4=OUT_OF_SCOPE` ·
41
+ # `judge_rer.sh` 의 `4=BLOCKED` · `pull_cli.py` 의 `4=NO_CORPUS`).
42
+ #
43
+ # ─────────────────────────────────────────────────────────────────────────────
44
+ # 🟥 `writes: write-local` — read-only 가 **아니다.** cold 실측이 근거다
45
+ # ─────────────────────────────────────────────────────────────────────────────
46
+ # 진입점이 `python -m src.static_review.web_rules` 를 부르고, 그 import 폐포가
47
+ # `src/config/settings.py` 의 **import 시점 `ensure_dirs()`** 에 닿는다.
48
+ #
49
+ # warm(운영자 실 트리) 실측 : 트리 변화 **없음** ← 🟥 이것을 근거로 쓰면 안 된다
50
+ # cold(격리 클론) 실측 : 신규 엔트리 **25개**
51
+ # · `__pycache__` 디렉토리 5 + `.pyc` 파일 18
52
+ # · **빈 디렉토리 `./tmp` · `./uploads`** ← `mkdir(exist_ok=True)` 의 산물
53
+ #
54
+ # warm 이 깨끗해 보인 이유는 그 디렉토리들이 **이미 있어서** mkdir 이 무음 no-op 이었기
55
+ # 때문이다 — **warm cache 아티팩트지 read-only 의 증거가 아니다.**
56
+ # ★그리고 저 «빈 디렉토리» 는 2026-08-17 이전의 M6 이펙트 프로브가 **구조적으로 못 보던**
57
+ # 형태다(`_snapshot` 이 `-type f` 라 파일만 셌다). FH PR #422 로 그 축을 넣었고,
58
+ # 그래서 이 선언은 **이제 기계로 검증·반증된다.** 그 전이었으면 거짓 `read-only` 로
59
+ # 선언해도 VERIFIED 를 받았다.
60
+ #
61
+ # ⚠️ **명시 잔여 — M6 는 peer 트리를 안 본다.** 프로브의 «샌드박스 밖» 감시면은 임시
62
+ # 카나리아 하나와 `$HOME` 엔트리 목록뿐이다. 어댑터는 **정의상 peer 트리에서 돌므로**,
63
+ # 이 capability 의 가장 실질적인 쓰기 표면이 프로브의 감시 밖에 있다. 위 cold 수치는
64
+ # 프로브 판정이 아니라 **손 실측**이다. 이 갭은 이 파일이 만든 게 아니라 어댑터 클래스
65
+ # 전체의 성질이고, 여기 이름으로 남긴다.
66
+ #
67
+ # ─────────────────────────────────────────────────────────────────────────────
68
+ # 캘리브레이션 쌍 — **FH 소유 픽스처**, 오프라인·결정적
69
+ # ─────────────────────────────────────────────────────────────────────────────
70
+ # 양성: `fixtures/qasp_web_rules_known_positive.json` — `page.wait_for_timeout(3000)`
71
+ # → `web-wait-for-timeout` 발화 → FINDINGS
72
+ # 음성: `fixtures/qasp_web_rules_known_negative.json` — `get_by_role(...).click()`
73
+ # → 위반 0 → CLEAN
74
+ # ★왜 peer 픽스처를 안 쓰나: peer 의 `tests/` 는 그 레포 사정으로 움직인다. 남의 트리에
75
+ # 있는 픽스처에 의존하는 계기는 **별도 계기가 아니라 그 레포의 일부**다.
76
+ # ★왜 PR 번호 모드를 안 쓰나: `gh api` 가 필요해 **네트워크·인증에 의존**하고, 그러면
77
+ # 등록 바의 M4 가 「계기가 판별하는가」가 아니라 「오늘 gh 가 되는가」를 재게 된다.
78
+ # ★residency: peer 는 company 다. 이 두 픽스처는 **우리가 지은 합성 diff** 라 조직
79
+ # 식별자가 없다 — 캘리브레이션 출력에 조직 경로가 실리지 않는다.
80
+ #
81
+ # 사용법
82
+ # qasp_web_rules.sh --diff-json <path> # peer 레포에서 실행, 실제 diff 스캔
83
+ # qasp_web_rules.sh --known-positive # expect FINDINGS
84
+ # qasp_web_rules.sh --known-negative # expect CLEAN
85
+ set -o pipefail
86
+
87
+ PEER_NAME="qasp"
88
+ RC_CLEAN=0; RC_FINDINGS=1; RC_ARGS=2; RC_ENGINE=3; RC_HARNESS=10; RC_PEER_ABSENT=20
89
+
90
+ _harness_error() { printf 'qasp-web-rules: HARNESS_ERROR — %s\n' "$1" >&2; exit "$RC_HARNESS"; }
91
+ _args_error() { printf 'qasp-web-rules: ARGS — %s\n' "$1" >&2; exit "$RC_ARGS"; }
92
+ _peer_absent() { printf 'qasp-web-rules: PEER_ABSENT — %s\n' "$1" >&2; exit "$RC_PEER_ABSENT"; }
93
+
94
+ SELF_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
95
+ _LIB="$SELF_DIR/peer_resolve.sh"
96
+ [ -r "$_LIB" ] || _harness_error "peer 해석 라이브러리 부재: $_LIB"
97
+ # shellcheck source=/dev/null
98
+ . "$_LIB"
99
+
100
+ # ── ① 인자 먼저 검문한다 — peer 해석보다 **앞**이다 ─────────────────────────
101
+ # 🟥 순서가 뒤집혀 있었다(2026-08-17 CI 실측). 초판은 peer 를 먼저 풀었고, 그래서
102
+ # **같은 잘못된 호출이 머신마다 다른 값**을 냈다:
103
+ # peer 있는 머신(내 로컬) : `qasp_web_rules.sh` (모드 없음) → ARGS(2)
104
+ # peer 없는 머신(CI) : 같은 호출 → HARNESS_ERROR(10)
105
+ # 즉 검정이 «어떻게 불렀나» 가 아니라 **«이 머신에 뭐가 깔렸나»** 를 재고 있었다.
106
+ # 이 레포가 오늘 출하 레인에서 이미 데인 클래스와 같다.
107
+ #
108
+ # ★원리: 이 파일 헤더가 스스로 갈라 놓은 대로 **ARGS 는 «호출에 대한 진술»** 이고
109
+ # **PEER_ABSENT 는 «클러스터에 대한 진술»** 이다. 두 사실은 독립이고, 호출이 틀렸다는
110
+ # 것은 클러스터를 보지 않고도 안다. 오타를 낸 사람에게 «qasp 를 깔아라» 라고 말하면
111
+ # 진짜 문제에서 멀어진다.
112
+ # ⚠️ 자매 어댑터(`qasp_new_code_anchor.sh`)는 아직 **peer-first** 다 — 그쪽 enum 레인이
113
+ # `_lane_peer` 로 가려져 있어서 이 어긋남이 안 보였다. 여기서만 고쳤고, 그쪽 정렬은
114
+ # 별건으로 남긴다(고치면 그쪽 레인의 SKIP 가드도 같이 걷어야 한다).
115
+ #
116
+ # ★경계는 «호출 + FH 로컬 파일» 이다. peer 를 봐야만 알 수 있는 것만 ② 로 내린다.
117
+ # 픽스처도 여기다 — `$SELF_DIR/fixtures/` 는 **우리 소유**라 peer 유무와 무관하다.
118
+ MODE="${1:-}"
119
+ DIFF_JSON=""
120
+ case "$MODE" in
121
+ --known-positive) DIFF_JSON="$SELF_DIR/fixtures/qasp_web_rules_known_positive.json" ;;
122
+ --known-negative) DIFF_JSON="$SELF_DIR/fixtures/qasp_web_rules_known_negative.json" ;;
123
+ --diff-json)
124
+ DIFF_JSON="${2:-}"
125
+ [ -n "$DIFF_JSON" ] || _args_error "--diff-json 뒤에 경로가 필요하다"
126
+ # 읽을 수 없는 입력은 **호출 오류**다 — peer 가 있든 없든 같은 값이어야 한다.
127
+ [ -r "$DIFF_JSON" ] || _args_error "읽을 수 없는 diff-json: $DIFF_JSON" ;;
128
+ '') _args_error "모드 미지정 — 돌지 않은 실행을 판정으로 렌더하지 않는다" ;;
129
+ *) _args_error "알 수 없는 모드 '$MODE'" ;;
130
+ esac
131
+
132
+ # 픽스처 부재는 **ARGS 가 아니라 HARNESS_ERROR** 다 — 캘리브레이션 쌍이 사라진 것은
133
+ # 「호출을 잘못했다」가 아니라 **「이 계기가 자기 판별력을 증명할 수 없다」**는 뜻이다.
134
+ case "$MODE" in
135
+ --known-positive|--known-negative)
136
+ [ -r "$DIFF_JSON" ] || _harness_error "캘리브레이션 픽스처 부재: $DIFF_JSON" ;;
137
+ esac
138
+
139
+ # ── ② peer 해석 ─────────────────────────────────────────────────────────────
140
+ PEER_ROOT="$(fh_peer_resolve "$PEER_NAME")"; _rrc=$?
141
+ case "$_rrc" in
142
+ 0) ;;
143
+ 1) _peer_absent "peer «$PEER_NAME» 가 이 머신에 없다 — 안 쟀다(통과가 아니다)" ;;
144
+ 2) _harness_error "peer «$PEER_NAME» 해석이 모호하다 — 어느 트리를 쟀는지 말할 수 없으므로 판정하지 않는다" ;;
145
+ *) _harness_error "peer 루트 목록 자체가 없다 — 전제 파손" ;;
146
+ esac
147
+
148
+ ENTRY="$PEER_ROOT/scripts/web_rules_pr_scan.sh"
149
+ [ -r "$ENTRY" ] || _harness_error "peer 진입점 부재: $ENTRY (하네스는 있으나 계기가 없다)"
150
+
151
+ # peer 의 진입점은 `git rev-parse --show-toplevel` 로 자기 루트를 잡으므로 **cwd 가 peer 여야**
152
+ # 한다. 판정은 파이프를 통과시키지 않는다(PIPE-VERDICT).
153
+ # `PYTHON` 을 명시로 넘긴다 — peer 의 기본값이 `$REPO_ROOT/.venv/bin/python` 이라 이미 맞지만,
154
+ # 그 폴백(`command -v python3`)이 발동하면 **의존성 없는 인터프리터**로 떨어져 ENGINE_ERROR 가
155
+ # 나고, 그건 「이 머신에 뭐가 깔렸나」를 재는 것이 된다. 명시하면 그 갈림이 안 생긴다.
156
+ _PY="$PEER_ROOT/.venv/bin/python"
157
+ if [ -x "$_PY" ]; then
158
+ out=$(cd "$PEER_ROOT" && PYTHON="$_PY" bash "$ENTRY" --files "$DIFF_JSON" 2>&1); rc=$?
159
+ else
160
+ out=$(cd "$PEER_ROOT" && bash "$ENTRY" --files "$DIFF_JSON" 2>&1); rc=$?
161
+ fi
162
+ printf '%s\n' "$out"
163
+
164
+ # ── 명시 재매핑 (같은 숫자, 다른 사건 — 전파가 아니다) ───────────────────────
165
+ case "$rc" in
166
+ 0) printf 'qasp-web-rules: CLEAN — 웹 로케이터/대기 규율 위반 0건\n'; exit "$RC_CLEAN" ;;
167
+ 1) printf 'qasp-web-rules: FINDINGS — 위반이 있다\n'; exit "$RC_FINDINGS" ;;
168
+ 2) printf 'qasp-web-rules: ARGS — peer 계기가 인자를 거부했다(판정이 아니다)\n' >&2; exit "$RC_ARGS" ;;
169
+ 3) printf 'qasp-web-rules: ENGINE_ERROR — 스캔이 돌지 못했다. «위반 0» 이 아니다\n' >&2; exit "$RC_ENGINE" ;;
170
+ *) _harness_error "peer 진입점이 자기 계약 {0,1,2,3} 밖의 $rc 를 반환했다" ;;
171
+ esac
@@ -105,6 +105,33 @@ _snapshot() { # $1=dir → "sha path" 목록 (정렬)
105
105
  | tr '\n' '\0' \
106
106
  | xargs -0 -n 400 shasum -a 256 2>/dev/null \
107
107
  | LC_ALL=C sort
108
+ # 🟥 **디렉토리 존재 축** (2026-08-17). 위 블록은 `-type f` 라 **파일만** 센다 — 빈
109
+ # 디렉토리는 관측면에 아예 없었고, `mkdir -p uploads outputs tmp` 만 하는 capability 가
110
+ # `writes: read-only` 선언으로 **✅ VERIFIED** 를 받았다(known-pair 3-arm 실측).
111
+ # git 은 빈 디렉토리를 추적하지 않으므로 격리 클론 샌드박스에도 없다 — 즉 이건 warm
112
+ # cache 가 아니라 **관측면의 구멍**이다(원정 §2 의 진단을 이 실측이 정정했다).
113
+ # ★존재만 센다, mtime 은 안 센다 — 안쪽 파일이 바뀔 때마다 디렉토리 mtime 이 움직여
114
+ # 읽기전용 capability 가 거짓 VIOLATION 을 받는다.
115
+ # ★`.git` 제외 경계는 위 파일 축과 **같다**. 갈라지면 한쪽만 고쳐지는 구조가 된다.
116
+ find "$1" -type d -not -path '*/.git/*' -not -path '*/.git' 2>/dev/null \
117
+ | LC_ALL=C sort | sed 's/^/dir /'
118
+ # 🟥 **비정규 노드 축** (2026-08-17, cross-family codex/gpt-5.5 지목 → 실측 확인).
119
+ # `-type f` 도 `-type d` 도 symlink·fifo·소켓·디바이스 노드를 안 센다. 실측:
120
+ # entry `ln -sf /etc/passwd leaked_link` + writes: read-only → ✅ VERIFIED
121
+ # entry `mkfifo backdoor_pipe` + writes: read-only → ✅ VERIFIED
122
+ # 위 디렉토리 축을 더한 **뒤에도** 둘 다 통과했다 — 같은 결함 클래스가 남아 있었다.
123
+ # ★symlink 는 **대상까지** 잰다. 존재만 세면 같은 경로를 다른 곳으로 다시 거는
124
+ # `ln -sf other link` 가 무변화로 보인다 — 그게 이 축에서 가장 위험한 형태다.
125
+ # ★`stat -f`/`-printf` 를 안 쓴다 — 전자는 BSD 전용, 후자는 GNU 전용이라 한쪽 CI 에서
126
+ # 조용히 죽는다([[feedback_wiring_surfaces_hidden_failures]] 의 실측 클래스).
127
+ # `-type` 술어와 `readlink` 만 쓰면 양쪽에서 돈다.
128
+ find "$1" -type l -not -path '*/.git/*' 2>/dev/null | LC_ALL=C sort \
129
+ | while IFS= read -r _l; do
130
+ printf 'link %s -> %s\n' "$_l" "$(readlink "$_l" 2>/dev/null)"
131
+ done
132
+ find "$1" -not -path '*/.git/*' -not -path '*/.git' \
133
+ ! -type f ! -type d ! -type l 2>/dev/null \
134
+ | LC_ALL=C sort | sed 's/^/node /'
108
135
  # ⚠️ 명명된 한계: 개행이 든 파일명은 `find` 출력에서 쪼개진다. 이 프로브가 도입될 때부터
109
136
  # 있던 성질이고 이번에 안 고쳤다 — 고치려면 `find -print0` 로 전 구간을 널 구분해야 한다.
110
137
  }
@@ -683,6 +710,83 @@ EOF
683
710
  fails=$((fails+1)); echo "❌ L15b rc=$rc (0 기대) — 정당한 비영 verdict 를 막는다"
684
711
  fi
685
712
 
713
+ # ── L16/L16b 빈 디렉토리 생성은 «쓰기» 다 ────────────────────────────────────
714
+ # 2026-08-17 known-pair 3-arm 실측: `_snapshot` 이 `-type f` 라 **파일만** 셌고,
715
+ # `mkdir -p uploads outputs tmp` 만 하는 capability 가 `writes: read-only` 선언으로
716
+ # ✅ VERIFIED 를 받았다. git 이 빈 디렉토리를 추적하지 않으니 격리 클론에도 없어서
717
+ # before/after 어느 쪽에도 안 나타난다 — warm cache 가 아니라 관측면의 구멍이다.
718
+ # 과녁이 실물인 이유: qasp 의 static-review 진입점이 정확히 이 모양이다
719
+ # (`settings.py` 가 import 시점에 `ensure_dirs()` 를 무조건 실행).
720
+ cat > "$d/emptydirs.cap" <<EOF
721
+ id: t:emptydirs
722
+ entry: bash -c 'mkdir -p uploads outputs tmp'
723
+ requires_cwd: $_rs
724
+ writes: read-only
725
+ EOF
726
+ out=$(probe_one "$d/emptydirs.cap" 2>&1); rc=$?
727
+ if [ "$rc" = "1" ]; then
728
+ pass=$((pass+1)); echo "✅ L16 빈 디렉토리 생성은 read-only 선언을 위반한다"
729
+ else
730
+ fails=$((fails+1)); echo "❌ L16 rc=$rc (1 기대) — 빈 디렉토리 생성이 «no-write» 로 렌더된다"
731
+ fi
732
+ # L16b 컨트롤(과차단 방지): 재는 것은 **상태**지 활동이 아니다. 만들었다가 지워
733
+ # 순증이 0 이면 위반이 아니다 — 여기서 빨개지면 mkdir 자체를 벌하는 것이고,
734
+ # 그러면 임시 작업공간을 쓰는 정당한 read-only capability 가 전부 막힌다.
735
+ cat > "$d/dir_net_zero.cap" <<EOF
736
+ id: t:dirnetzero
737
+ entry: bash -c 'mkdir -p scratch_tmp && rmdir scratch_tmp'
738
+ requires_cwd: $_rs
739
+ writes: read-only
740
+ EOF
741
+ out=$(probe_one "$d/dir_net_zero.cap" 2>&1); rc=$?
742
+ if [ "$rc" = "0" ]; then
743
+ pass=$((pass+1)); echo "✅ L16b 컨트롤 — 순증 0 인 디렉토리 왕복은 위반이 아니다(상태를 잰다)"
744
+ else
745
+ fails=$((fails+1)); echo "❌ L16b rc=$rc (0 기대) — 활동을 벌한다(상태가 아니라)"
746
+ fi
747
+
748
+ # ── L17/L17b/L18 비정규 노드도 «쓰기» 다 ────────────────────────────────────
749
+ # L16 을 넣은 **뒤에도** 통과하던 형태다(실측): `-type f`·`-type d` 어느 쪽도 안 센다.
750
+ cat > "$d/symlink.cap" <<EOF
751
+ id: t:symlink
752
+ entry: bash -c 'ln -sf /etc/passwd leaked_link'
753
+ requires_cwd: $_rs
754
+ writes: read-only
755
+ EOF
756
+ out=$(probe_one "$d/symlink.cap" 2>&1); rc=$?
757
+ if [ "$rc" = "1" ]; then
758
+ pass=$((pass+1)); echo "✅ L17 symlink 생성은 read-only 선언을 위반한다"
759
+ else
760
+ fails=$((fails+1)); echo "❌ L17 rc=$rc (1 기대) — symlink 가 «no-write» 로 렌더된다"
761
+ fi
762
+ # L17b — **대상 재지정**. 존재만 세면 이게 무변화로 보인다: 경로도 타입도 그대로다.
763
+ # 이 축에서 가장 조용한 형태라 별도 레인으로 둔다.
764
+ mkdir -p "$_rs" 2>/dev/null; ln -sf /dev/null "$_rs/preexisting_link" 2>/dev/null
765
+ cat > "$d/relink.cap" <<EOF
766
+ id: t:relink
767
+ entry: bash -c 'ln -sf /etc/passwd preexisting_link'
768
+ requires_cwd: $_rs
769
+ writes: read-only
770
+ EOF
771
+ out=$(probe_one "$d/relink.cap" 2>&1); rc=$?
772
+ if [ "$rc" = "1" ]; then
773
+ pass=$((pass+1)); echo "✅ L17b 기존 symlink 의 **대상 재지정**도 위반이다(존재만 세지 않는다)"
774
+ else
775
+ fails=$((fails+1)); echo "❌ L17b rc=$rc (1 기대) — 대상을 안 재고 존재만 센다"
776
+ fi
777
+ cat > "$d/fifo.cap" <<EOF
778
+ id: t:fifo
779
+ entry: bash -c 'mkfifo backdoor_pipe'
780
+ requires_cwd: $_rs
781
+ writes: read-only
782
+ EOF
783
+ out=$(probe_one "$d/fifo.cap" 2>&1); rc=$?
784
+ if [ "$rc" = "1" ]; then
785
+ pass=$((pass+1)); echo "✅ L18 fifo/비정규 노드 생성도 위반이다"
786
+ else
787
+ fails=$((fails+1)); echo "❌ L18 rc=$rc (1 기대) — 비정규 노드가 감시면 밖이다"
788
+ fi
789
+
686
790
  rm -rf "$d"
687
791
  echo "── capability_effect_probe lanes: $pass PASS / $fails FAIL ──"
688
792
  [ "$fails" = "0" ]
@@ -67,13 +67,14 @@ _lane() { # $1=id $2=class $3=설명 $4=expected_rc $5=actual_rc
67
67
 
68
68
  # 계기 전제 — 어댑터 3종이 **실제로 거기 있는지** 먼저 본다. 없으면 이 레인 묶음은
69
69
  # «전부 초록» 이 아니라 하네스 오류다(빈 대상에 대고 초록을 내는 것이 여기서 막으려는 형태).
70
- for a in gstack_content_safety mate_agent_boundary qasp_new_code_anchor peer_resolve; do
70
+ for a in gstack_content_safety mate_agent_boundary qasp_new_code_anchor qasp_web_rules peer_resolve; do
71
71
  [ -r "$ADAPTER_DIR/$a.sh" ] || { echo "❌ HARNESS-ERROR — $ADAPTER_DIR/$a.sh 부재. 빈 대상에 초록을 내지 않는다"; exit 10; }
72
72
  done
73
73
 
74
74
  G="$ADAPTER_DIR/gstack_content_safety.sh"
75
75
  M="$ADAPTER_DIR/mate_agent_boundary.sh"
76
76
  Q="$ADAPTER_DIR/qasp_new_code_anchor.sh"
77
+ W="$ADAPTER_DIR/qasp_web_rules.sh"
77
78
 
78
79
  # 「peer 가 안 보이는 세계」 = 잘 형성된 루트 목록인데 그 안에 이 peer 가 없는 것.
79
80
  # (빈 **문자열**이 아니다 — 그건 「어디를 볼지 자체가 없다」는 다른 명제이고 전제 파손이다.)
@@ -196,6 +197,41 @@ else
196
197
  fi
197
198
  echo
198
199
 
200
+ # ═══════════════════════════════════════════════════════════════════════════
201
+ # ④ qasp-web-rules (자매 노드 — 같은 peer, 다른 능력)
202
+ # ═══════════════════════════════════════════════════════════════════════════
203
+ # 🟥 ③ 과 **같은 peer 인데 다른 enum 이다**. 값을 옮겨 쓰면 안 된다 —
204
+ # ③ 의 `3=HARNESS_ERROR`·`4=OUT_OF_SCOPE` 와 여기의 `3=ENGINE_ERROR` 는 다른 사건이고,
205
+ # 여기엔 `4` 가 아예 없다. 두 노드를 한 눈에 보는 이 파일이 그 혼동의 1순위 발생지다.
206
+ echo "── qasp-web-rules (enum 0=CLEAN 1=FINDINGS 2=ARGS 3=ENGINE_ERROR 20=PEER_ABSENT)"
207
+ _lane W1 resolve-absent "peer 불가시 → 전용값 PEER_ABSENT(CLEAN 으로도 ENGINE_ERROR 로도 안 접힌다)" 20 \
208
+ "$(FH_CLUSTER_ROOTS="$EMPTY_WORLD" _rc bash "$W" --known-positive)"
209
+ _lane_peer "$QS_ROOT" W2 control "peer 가시 → 양성 arm 은 FINDINGS" 1 "$(_rc bash "$W" --known-positive)"
210
+ _lane_peer "$QS_ROOT" W3 control "peer 가시 → 음성 arm 은 CLEAN (판별력의 증거)" 0 "$(_rc bash "$W" --known-negative)"
211
+ _lane W4 enum "모드 미지정 → ARGS(2). 안 돈 실행을 판정으로 렌더하지 않는다" 2 "$(_rc bash "$W")"
212
+ _lane W5 enum "없는 diff-json → ARGS(2). 호출 오류지 계기 고장이 아니다" 2 \
213
+ "$(_rc bash "$W" --diff-json /nonexistent/nope.json)"
214
+ # 🟥 W6 — 픽스처 부재는 **ARGS 가 아니라 HARNESS_ERROR** 다. 캘리브레이션 쌍이 사라진 것은
215
+ # 「호출을 잘못했다」가 아니라 **「이 계기가 자기 판별력을 증명할 수 없다」**는 뜻이고,
216
+ # 그 둘을 같은 값으로 접으면 «쌍이 없는 계기»가 «인자 실수»로 보고된다.
217
+ if [ -n "$QS_ROOT" ]; then
218
+ FIXBK="$T/fixbk"; mkdir -p "$FIXBK"
219
+ FIXPOS="$ADAPTER_DIR/fixtures/qasp_web_rules_known_positive.json"
220
+ if [ -r "$FIXPOS" ]; then
221
+ mv "$FIXPOS" "$FIXBK/pos.json"
222
+ _lane W6 enum "캘리브레이션 픽스처 부재 → HARNESS_ERROR(10), ARGS 아님" 10 \
223
+ "$(_rc bash "$W" --known-positive)"
224
+ mv "$FIXBK/pos.json" "$FIXPOS"
225
+ # 복원 확인 — 레인이 자기가 옮긴 것을 되돌렸는지 **잰다**(안 하면 다음 레인이 거짓 적색)
226
+ [ -r "$FIXPOS" ] || { echo "❌ HARNESS-ERROR — W6 가 픽스처를 복원하지 못했다"; exit 10; }
227
+ else
228
+ echo " ⏭ W6 [enum ] 양성 픽스처 부재 — SKIP (통과 아님)"
229
+ fi
230
+ else
231
+ echo " ⏭ W6 [enum ] qasp 실물 루트 없음 — SKIP (통과 아님)"
232
+ fi
233
+ echo
234
+
199
235
  # ═══════════════════════════════════════════════════════════════════════════
200
236
  # 되돌림 프로브 — 앵커가 장식이 아님을 «되돌려 보고» 잰다 (적용확인 → 실행 → 복원)
201
237
  # ═══════════════════════════════════════════════════════════════════════════
@@ -168,6 +168,25 @@ controls: n/a — 측정 없음
168
168
  standpoint: tier1b(qasp)')
169
169
  check "$S7c" PASS "★컨트롤: 포인터에 대상이 붙어도 standpoint: 줄이 있으면 통과 (과차단 방지)"
170
170
 
171
+ # ── ⓓ3자대면 자기 필드 `thirdparty:` (2026-08-17 신설) ─────────────────────────
172
+ # ⓑ 와 같은 형태이므로 **ⓑ가 실측으로 겪은 fail-open 을 미리 막았는지**를 같이 고정한다.
173
+ S7d=$(mk "$D_SIX" 'axes-run: ⓐ=none ⓑ=none ⓒ=none ⓓ=→thirdparty ⓔ=none ⓕ=none
174
+ controls: n/a — 측정 없음')
175
+ check "$S7d" BLOCK "★ⓓ=→thirdparty 인데 thirdparty: 줄이 없음 → 차단 (죽은 포인터)"
176
+
177
+ S7e=$(mk "$D_SIX" 'axes-run: ⓐ=none ⓑ=none ⓒ=none ⓓ=thirdparty ⓔ=none ⓕ=none
178
+ controls: n/a — 측정 없음')
179
+ check "$S7e" BLOCK "★화살표 없는 ⓓ=thirdparty 도 차단 (ⓑ가 뚫렸던 그 표기 fail-open)"
180
+
181
+ S7f=$(mk "$D_SIX" 'axes-run: ⓐ=none ⓑ=none ⓒ=none ⓓ=→thirdparty(promptfoo·DeepEval) ⓔ=none ⓕ=none
182
+ controls: n/a — 측정 없음
183
+ thirdparty: none-found(promptfoo·DeepEval·mutation testing 훑음)')
184
+ check "$S7f" PASS "★컨트롤: 포인터에 대상이 붙어도 thirdparty: 줄이 있으면 통과 (과차단 방지)"
185
+
186
+ S7g=$(mk "$D_SIX" 'axes-run: ⓐ=none ⓑ=none ⓒ=none ⓓ=none ⓔ=none ⓕ=none
187
+ controls: n/a — 측정 없음')
188
+ check "$S7g" PASS "★컨트롤: ⓓ=none 이면 thirdparty: 줄이 없어도 통과 (포인터를 안 쓴 경우)"
189
+
171
190
  S8=$(mk "$D_SIX" 'axes-run: ⓐ=codex ⓑ=→standpoint ⓒ=none ⓓ=none ⓔ=실사용 ⓕ=되돌림
172
191
  axes-run: ⓐ=거짓말 ⓑ=x ⓒ=x ⓓ=x ⓔ=x ⓕ=x
173
192
  controls: alive — ok
@@ -866,6 +866,26 @@ validate_marker_axes_run() { # $1 = marker path
866
866
  echo " standpoint: not-applicable"
867
867
  return 1
868
868
  fi
869
+
870
+ # ⓓ3자대면도 같은 형태 — 값은 `thirdparty:` 자기 필드가 나른다 (2026-08-17 신설).
871
+ # 🟥 왜 자기 필드가 필요한가: `axes-run` 한 칸은 **토큰**만 담는다. ⓓ가 실제로 내는 것은
872
+ # «무엇을 검색해서 무엇을 찾았나» 라 한 토큰에 안 들어간다. 자리가 없으면 안 적히고,
873
+ # 안 적히면 다음 세션이 «이 축을 돌렸나»를 물을 수도 확인할 수도 없다.
874
+ # 실측(2026-08-17): 이 축을 처음 제대로 돌린 세션에서 발표 주장 **6건**이 선행자산에
875
+ # 걸렸는데, 그 결과를 기록할 필드가 없었다.
876
+ # ⚠️ ⓑ 와 **똑같은 fail-open 을 반복하지 않는다** — 화살표를 필수로 보지 않고
877
+ # «ⓓ 값이 thirdparty 를 참조하는가» 로 넓힌다(ⓑ가 그렇게 뚫렸던 실측이 바로 위에 있다).
878
+ # 같은 명시 잔여도 그대로 상속한다: 참조어가 다른 토큰에 있으면 안 잡힌다.
879
+ if echo "$line" | grep -qE "(^|[[:space:]])ⓓ=[^[:space:]]*thirdparty" \
880
+ && ! grep -qE '^[[:space:]]*thirdparty:[[:space:]]*[^[:space:]]' "$m" 2>/dev/null; then
881
+ echo " ❌ MARKER — 'ⓓ=→thirdparty' 라고 적었는데 'thirdparty:' 줄이 없다"
882
+ echo " 포인터가 가리키는 곳이 비어 있으면 그건 기록이 아니라 죽은 포인터다."
883
+ echo " thirdparty: checked(promptfoo·DeepEval 훑음 — 겹침 없음)"
884
+ echo " thirdparty: none-found(<무엇을 검색했나>) ← 찾았는데 없음. «안 봤다»와 다르다"
885
+ echo " thirdparty: UNKNOWN ← 안 봤다"
886
+ echo " thirdparty: not-applicable"
887
+ return 1
888
+ fi
869
889
  fi
870
890
 
871
891
  line=$(grep -m1 -E '^[[:space:]]*controls:' "$m" 2>/dev/null \