@chrono-meta/fh-gate 1.4.94 → 1.4.95

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.
@@ -11,13 +11,13 @@
11
11
  "plugins": [
12
12
  {
13
13
  "name": "fh-meta",
14
- "version": "1.4.94",
14
+ "version": "1.4.95",
15
15
  "description": "Hub meta-operations toolkit — 35 skills + 7 agents. 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": "1.4.94",
20
+ "version": "1.4.95",
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/CLAUDE.md CHANGED
@@ -598,7 +598,7 @@ Proposal format: `"I see [X]. Want me to run /[skill] to [one-line description]?
598
598
  | "delete the branch", "브랜치 삭제", "브랜치 정리", "clean up branches", "force-push", "rewrite history", "지워도 돼?" (destructive intent — **proactive**, fire *before* the action) | **Destructive-Op Gate** (see above → enumerate → recover → destroy; `templates/predelete_check.sh`) |
599
599
  | **"새 기능 검증해줘", "test this feature", "이 TC 확인해줘" — verifying the user's PRODUCT/feature (not FH itself)** | **Route to the mapped field harness first** (Cross-Project Skill Bus / registry) — the field harness owns product verification. The harness-verification rows in this table (`verify-bidirectional` · `prompt-regression` · `sim-conductor` · `pipeline-conductor`) verify the *harness*, and must not shadow a product-verification ask (a field project's *harness assets* — its skills/rules — still use those FH verification rows) |
600
600
  | "지난주에 뭐 했지", "what did we do last week", "예전에 이거 한 적 있나" (recall intent) | **CATALOG-first recall** — read `CATALOG.md`, identify candidates by tag/date, then open only those files. Never scan session files one by one |
601
- | "add this MCP server", "mount this MCP", "mcp.json에 추가", "connect this tool server" (external-MCP mount intent — **proactive**, fire *before* first tool call; mount intent only — a failing/erroring mounted server is `/mcp-circuit-breaker`'s row above) | `templates/.claude/rules/mcp_tool_gating.md` (name-keyed ask/allow table — never trust server annotations or names; fill §3 at mount time) |
601
+ | "add this MCP server", "mount this MCP", "mcp.json에 추가", "connect this tool server" (external-MCP mount intent — **proactive**, fire *before* first tool call; mount intent only — a failing/erroring mounted server routes to `/mcp-circuit-breaker` via its own skill description; its table row was removed in the 2026-07-17 row diet, so this parenthetical no longer points "above") | `templates/.claude/rules/mcp_tool_gating.md` (name-keyed ask/allow table — never trust server annotations or names; fill §3 at mount time) |
602
602
  | "did my rule change break anything", "regression check", "test harness changes" | `/prompt-regression` |
603
603
  | "review for the team", "CTO review", "decision-maker", "share with leadership", "approval deck" | `/apex-review` |
604
604
  | "run full pipeline", "verify everything", "end-to-end sweep", "chain all verifications" | `/pipeline-conductor` |
@@ -353,10 +353,31 @@ Split honestly by moment; only one of the three is closable today.
353
353
 
354
354
  - **Registration moment — reachable.** The reviewer is inside the registry when it applies. Pointers
355
355
  from `.claude/registry/README.md` and `fh_detail_protocols.md §1-c` put the M1–M5 bar in front of
356
- them. **Named residual, not built**: a `scripts/capability_registry_check.sh` that validates the
357
- schema and runs each declared M4 pair would make registration *measured* rather than reviewed. It
358
- does not exist. Until it does, M1–M5 is a reviewed bar, and this file says so rather than implying a
359
- floor it does not have.
356
+ them. ~~**Named residual, not built**: a `scripts/capability_registry_check.sh`…~~ **built
357
+ 2026-08-11.** `scripts/capability_registry_check.sh` validates the schema (closed key list an
358
+ unknown key is a failure, never an ignore) and **runs each declared M4 pair**, so registration is
359
+ now *measured* on those axes rather than reviewed. Known-pair calibrated, 7 lanes, BLOCK/PASS
360
+ symmetric. Two capabilities are registered through it (`fh_psa_leak.cap` · `fh_degrade_verdict.cap`).
361
+
362
+ 🟥 **What the checker measures, and the axis it provably does NOT — learned by being bitten.**
363
+ M1–M5 answer *"is the declaration well-formed, and does the instrument separate a case whose
364
+ answer we already know?"*. They do **not** answer *"is the declaration true"*, and one axis made
365
+ that concrete the same day it was built: a capability declaring **`writes: read-only`** passed all
366
+ five criteria — executable entry, closed enum with a did-not-run value, `judge: mechanical`, M4
367
+ known-pair green ×2, valid `requires_cwd` — and its entry point then **`rm -rf`'d this repo's
368
+ `scripts/` directory** on a no-argument invocation (a cleanup `trap` whose variable was reassigned
369
+ to a real path after the trap was installed). Tracked files were recovered by `git checkout`;
370
+ three untracked new scripts were not, and the recovery checkout also reverted an unrelated
371
+ in-flight edit. Nothing in the bar could have caught it: M4 exercised the two *declared* arms, and
372
+ the destructive path was the *undeclared* default arm.
373
+ - **Partial fix applied** (entry-point discipline, both probes): the cleanup variable is never
374
+ reassigned, the scan target is a separate variable, and the trap re-checks that the path it is
375
+ about to delete is under a temp root. Calibrated with a canary file in an isolated repo.
376
+ - **Structural fix, not built**: run the M4 pair under a read-only mount / sandbox and *observe*
377
+ whether a write is attempted. Until that exists, `writes:` (and `reversibility:`) are the
378
+ **registrant's claim**, and the checker prints them as such rather than implying it verified
379
+ them. A bar that silently accepts an unverifiable axis is how a `read-only` capability deletes a
380
+ directory with every light green.
360
381
  - **Call moment — salience-only, no mechanical floor exists.** No hook can observe "a session is about
361
382
  to compose a capability call"; the trigger is intent, exactly like the Instrument-Calibration rule.
362
383
  The strongest available lever is structural: §ⓑ.3 makes the merged constraint set **step 2 of the
@@ -1806,3 +1806,27 @@
1806
1806
  outcome: accepted
1807
1807
  evidence: "④⑤⑥ 는 기술문서 축(같은 세션 후속) — ⑥ 이 **1차 콜드리드 수리본을 다시 읽힌 라운드**이고 거기서 «반례 흔한 단정 + 배치 오류» 가 나와 §7→§6 이동으로 이어졌다(그 경험이 스킬 Step 5 재콜드리드 규율의 근거다). ⑦ sim = 심은 결함 3/3 검출·오탐 0. ① 정본 Explore 가 «MTM=블박+화박 동시 실행, verdict 불변, 표기 4상태» 를 정본 인용으로 확정 — 이후 2-arm 실측(판정 51/51 동일 · mtm_cited 17)이 그 계약과 일치함을 확인하는 근거가 됐다. 일반 개념(«화이트박스 모드») 정규화를 사전 차단. ② 설계 Explore 가 relations.py 5곳·triage.py:258 닫힌 어휘·sourced 레인 선점 함정·데이터 리더 부재(known-positive 컨트롤 동반)를 특정 — 이번 세션은 그 능력을 안 지었으나(잔여 S1) 지도는 그대로 유효. ③ codex 1R: S/A/B/C 4축 반증 중 **헤드라인 반증 1건 수용** — 「품절 배지 검출」 주장이 로케일 축 오귀속임을 App.tsx/en.ts 근거로 지적, governor 가 소스 재확인 후 철회하고 attribution_risk 를 기계에 실었다(86dc8bf). 추가로 C-1(미생성 사유가 stdout 전용) 수용·수리, 잔여 4건은 명명"
1808
1808
  notes: "cross-family 가 **내 커밋 메시지의 주장** 을 반증한 사례 — 코드 결함이 아니라 «주장의 귀속» 이 틀린 경우라 레인·적대검증·되돌림 셋 다 못 잡았을 축이다([[feedback_grounding_audit_of_own_record]] 형). 자력 적발 0. 반대로 생성기 1차 산출의 오탐 공장 성질은 **손검사로 자력 적발**했다 — 기계 감사와 육안 표본이 서로 다른 결함을 잡았다"
1809
+
1810
+ - date: 2026-08-11
1811
+ session: plugin-reship-campaign-mtier-precheck
1812
+ agents_summary: "3 dispatches (parallel): fh-meta:challenger×3 — pmh-dev M티어 8스킬 선-적대검토 (배치1 게이트3종 · 배치2 감사/차단2종 · 배치3 오케스트레이션3종)"
1813
+ dispatch_count: 3
1814
+ outcome: accepted
1815
+ evidence: "M 22건·S 12건 회수, governor 소스 재검증 spot-check 9건 전건 CONFIRMED (marketplace plugin.json skills 키 부재 실측 · PSA 2층 패턴 스펙불일치 · MCB 계기 위치 stale · sidecar MEMORY.md 팬텀 · field-harvest 의무체인 Done When 미배선 등). 공통 M = 8스킬 전부 Done When check-class 0 (known-positive 컨트롤: 타 11스킬 히트) — 센서스 triage 와 일치. 배치3이 경계규칙(local_pmh_context.md:7 재인라인 금지) 발굴 → synced 7종 수리는 FH 상류 라우팅 확정"
1816
+ notes: "센서스(기계 grep triage) → 병렬 적대검토 → governor 재검증의 3단이 전수조사 표준 후보. 배치1이 known-pair 캘리브레이션을 자발 수행(BSD grep 오탐 후보를 스스로 기각) — challenger 에 계기 규율이 전이된 사례"
1817
+
1818
+ - date: 2026-08-11
1819
+ session: qasp-m1-tc-coordinates
1820
+ agents_summary: "1 dispatch: codex gpt-5.5 1R — M1 diff(좌표 3축·생성기 확장·entry NO_FORM·node 가드) 적대 감사, 낙관 방향(정상 FAIL/결함 PASS/BLOCKED 소실) 특정 지시"
1821
+ dispatch_count: 1
1822
+ outcome: accepted
1823
+ evidence: "5 findings 전건 수용·수리: F1(S — NO_FORM 이 «레시피 타깃 부재»를 «벽 부재»로 오판, SSO/타언어 벽 반례 → expect 증인 결박 + ENTRY_NO_FORM_UNVERIFIED fail-closed) · F2(venue 경유 basis 가 EVIDENCE_SPAN_SECTION 으로 오표기 — 시험관 선택이 기획서 지정으로 읽힘 → basis 분리) · F3(importer 가 generated_from 폐기 — 귀속위험이 하류 미결박 → 보존+report per_tc 표면화, exit 결박 잔여) · F4(무스코프 롤카운트 전역 오염/상쇄 → UNSCOPED_ROLE_COUNT typed, within= 잔여) · F5(부정 표시문 열거 추출 — codex 가 .venv 로 재현 실증 → 부정문 가드 양쪽). 수리 후 레인 69 + 전량 2,805 passed · 라이브 재검증 R9_1 BLOCKED→FAIL(typed)"
1824
+ notes: "F5 는 감사자가 처방 전에 재현을 직접 실행해 보인 사례(«짧은 함수 재현은 .venv 로 확인») — finding 수용의 소스검증 비용을 감사자가 선지불. F1 은 governor 의 «전원 부재 = 벽 부재» 가정을 반례(SSO/한국어 벽)로 무너뜨림 — 증인(positive witness) 요구가 처방으로 그대로 채택됨"
1825
+
1826
+ - date: 2026-08-11
1827
+ session: qasp-m1b-finding-identity
1828
+ agents_summary: "1 dispatch: codex gpt-5.5 1R — M1-b diff(finding 정체성 P6/P4→P7 관통) 적대 감사, **오귀속 방향** 지시(틀린 finding/화면 결박)"
1829
+ dispatch_count: 1
1830
+ outcome: accepted
1831
+ evidence: "4 findings 전건 수용·수리·되돌림 검증: F3(S — `references` 가 이미 5곳에서 4의미로 쓰임(finding_id·BVA source_defect_id·StateMatrix_combo_N·조인 id·row id) → 값만 보고 finding 해석 시 SM/BVA TC 오귀속, 감사자가 함수 실행으로 /orders 주입 재현 → reference_kind 선언 자격) · F2(S — sibling 위반은 행 쌍인데 조인이 첫 행에서 break, row1=/customers·row2=/orders 가 /customers 로 결박 → distinct 2+ 면 scalar 비움) · F1(S — 저신뢰 AREA_LABEL 이 선언된 유래를 이김 → 유예+충돌 typed, 유예가 좌표 손실이면 후퇴라 복원 경로 동반) · F4(M — 색인 raw / 소비 strip 불일치로 'dup'/'dup ' 미검출). 수리 4건 **각각 되돌려 정확히 그 앵커 1개씩 적색** 확인 후 커밋. 재생성 무회귀(app_id 40/51 · basis 분포 동일) · 전량 2,819 passed"
1832
+ notes: "감사자가 «일반 import 는 fitz/docx 부재로 막혀 P7·surface_inventory 를 직접 로드해 함수 단위로 확인» 이라고 **계기 한계를 스스로 명시**했다. 자체 적발 1건 — F3 게이트가 F 계열의 정당한 직결까지 막아 app_id 40→39(자격 게이트 도입은 기존 통과 경로에도 선언을 요구한다는 표준 회귀). peer 세션(로컬 다른 축)이 같은 라운드에 **두 축을 더** 잡았다: ⓐ 내 신규 앵커가 장식(생산자 배선 되돌려도 16 전건 초록 — 호출부 우회) ⓑ DISPLAY_MAPPING 의 case-insensitive 부분수리 함정(en.ts 직독 확인: 6종 중 5종만 통과). ★**갈림의 원인은 계열이 아니라 «무엇을 받았느냐» 다**(peer 정정 수용): codex 에게는 **diff** 를 줬고 → 코드 경로 결함 4건, peer 에게는 **내 보고서(주장)** 가 갔고 → 장치·주장 결함 4건. 계열이 같아도 diff 를 받았으면 같은 것을 봤을 것이다. 재사용 가능한 형태는 «탈상관 축은 누가 보느냐만이 아니라 **무엇을 받느냐**» — 적대검증을 아무리 붙여도 **전부 diff 를 주면 주장 축은 계속 빈다**([[feedback_grounding_audit_of_own_record]] 와 같은 축). peer 도 그 넷을 설계해서 잡은 게 아니라 **주장을 읽는 채널에 있었기 때문**이라고 자기 몫을 정직하게 낮췄다 — «피어 세션은 장치 결함을 잡는다» 가 아니라 «**주장을 받은 쪽**이 장치 결함을 잡는다» 가 맞는 모델이고, 그 차이가 다음에 누구에게 무엇을 보낼지를 바꾼다. 🟥**적발 비율을 지표로 읽지 마라**: 이 세션은 자력 1 · 외부 8 인데 그 8 = **codex(diff) 4 + peer(주장) 4** — 즉 «내가 못 봤다» 가 아니라 «**두 축을 열었다**» 다. 분모(어떤 채널을 몇 개 붙였나)를 안 적으면 리뷰를 많이 붙일수록 자력 비율이 나빠 보이고, 그 수를 성과로 읽는 순간 최적해가 «리뷰를 덜 붙이는 것» 이 된다(peer 지적). 채널을 같이 적어야 위의 «무엇을 받았느냐» 구분이 그 숫자 안에서 다시 접히지 않는다"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrono-meta/fh-gate",
3
- "version": "1.4.94",
3
+ "version": "1.4.95",
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": [
@@ -85,6 +85,7 @@
85
85
  "templates/goal-quench-hook-setup.md",
86
86
  "templates/.claude/rules/session.md",
87
87
  "scripts/below_floor_scan.sh",
88
+ "scripts/capability_registry_check.sh",
88
89
  "scripts/chamber_run.sh",
89
90
  "scripts/fh_env_delta_scan.sh",
90
91
  "scripts/substrate_jump_detector.sh",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-commons",
3
- "version": "1.4.94",
3
+ "version": "1.4.95",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -53,10 +53,20 @@ Default thresholds:
53
53
  Identify the failing tool and failure mode:
54
54
 
55
55
  ```bash
56
- # Check MCP server config
57
- cat .claude/settings.json 2>/dev/null | grep -A5 '"mcpServers"' || echo "No MCP config found"
56
+ # MCP mounts live in SEVERAL scopes — check all that this shell can see. Project settings.json is
57
+ # usually NOT one of them (it commonly holds hooks only), so a single-file read renders "no config"
58
+ # on a session with live MCP servers — a wrong-location instrument, not a measurement.
59
+ for f in .mcp.json .claude/settings.json .claude/settings.local.json; do
60
+ [ -e "$f" ] && { echo "== $f =="; grep -A5 '"mcpServers"' "$f" 2>/dev/null || echo "(present, no mcpServers key)"; }
61
+ done
62
+ [ -e ~/.claude.json ] && grep -c '"mcpServers"' ~/.claude.json | xargs echo "user-scope ~/.claude.json mcpServers blocks:"
58
63
  ```
59
64
 
65
+ An empty result above means **"no project/user-scope config found"** — never "no MCP mounted":
66
+ plugin-provided and remotely-managed servers appear in no local file. The live mount evidence is the
67
+ failing tool call itself (`mcp__{server}__{tool}` in this session); classify from that name even when
68
+ every config read comes back empty.
69
+
60
70
  Classify failure type:
61
71
 
62
72
  | Type | Symptom | Likely Cause |
@@ -98,6 +108,10 @@ Failure type: {TYPE} | Consecutive failures: {N}
98
108
  Further calls to this tool are blocked until circuit resets.
99
109
  ```
100
110
 
111
+ (Blocking is **session-level discipline** — this skill's protocol, not a mechanical hook; no
112
+ PreToolUse gate enforces it. State it that way if asked — a protocol honestly labeled beats a
113
+ phantom enforcement claim.)
114
+
101
115
  ---
102
116
 
103
117
  ### Step 3. Log Circuit State
@@ -106,17 +120,16 @@ Write state to session-local file (in-memory is insufficient — logs survive /c
106
120
 
107
121
  ```bash
108
122
  mkdir -p .claude/mcp_circuit/
109
- # Append to circuit log
110
- ```
111
-
112
- Log entry format:
113
- ```yaml
123
+ grep -qxF '.claude/mcp_circuit/' .gitignore 2>/dev/null \
124
+ || echo "NOTE: add '.claude/mcp_circuit/' to .gitignore — session state must not become a tracked file (the ignored-but-committed class public-surface-audit Step 3c hunts)"
125
+ cat >> .claude/mcp_circuit/circuit_log.yaml << EOF
114
126
  - tool: {tool-name}
115
- state: OPEN
127
+ state: OPEN # OPEN | HALF-OPEN | CLOSED — same enum as Done When
116
128
  failure_type: {TYPE}
117
129
  failure_count: {N}
118
130
  tripped_at: {ISO-8601}
119
131
  reset_at: null
132
+ EOF
120
133
  ```
121
134
 
122
135
  ---
@@ -133,7 +146,8 @@ Present the relevant fallback options ranked by effort (at least 3):
133
146
  | **3 — Pause and retry** | Wait for server recovery (HALF-OPEN probe after cooldown) | Transient failure (TIMEOUT, RATE_LIMIT) |
134
147
 
135
148
  > **Gating carries over to the substitute** (cross-ref the external-MCP tool-gating rule
136
- > `mcp_tool_gating.md`). A REST/API or
149
+ > `templates/.claude/rules/mcp_tool_gating.md` template path; if installed live, your repo's
150
+ > `.claude/rules/` copy). A REST/API or
137
151
  > workflow-automation tool adopted under Priority 1b is still an external-action surface: classify its
138
152
  > calls under the same ask/allow tiers — reads are `allow (untrusted-read)` only after behavior
139
153
  > confirmation; any write / send / delete / permission-change stays `ask`. Trading a gated MCP mount for
@@ -197,10 +211,13 @@ Recommendations:
197
211
 
198
212
  ## Done When
199
213
 
200
- - Failure pattern classified (type + count)
201
- - Circuit state logged (OPEN / HALF-OPEN / CLOSED)
202
- - At least 3 fallback alternatives proposed when circuit is OPEN
203
- - Recovery probe offered with reset path
214
+ - Failure pattern classified (type + count) — *judged* (adversarial pairing: an `UNKNOWN`
215
+ classification must state which Step 1 config reads ran and came back empty — "couldn't determine"
216
+ without the read evidence is not a classification)
217
+ - Circuit state logged to `.claude/mcp_circuit/circuit_log.yaml` (OPEN / HALF-OPEN / CLOSED) —
218
+ *mandatory-pass*
219
+ - At least 3 fallback alternatives proposed when circuit is OPEN — *measured* (count ≥3)
220
+ - Recovery probe offered with reset path — *mandatory-pass*
204
221
 
205
222
  ---
206
223
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: token-budget-gate
3
- description: Estimates token cost before a multi-step task and outputs a Green/Yellow/Red gate verdict. Tracks actual vs. estimated after completion for calibration. Triggers on "token budget", "how much will this cost", "will this be expensive", "estimate tokens", before long multi-agent tasks.
3
+ description: Estimates token cost before a multi-step task and outputs a 4-tier Green/Yellow/Orange/Red gate verdict. Records actual vs. estimated after completion when a mechanical usage source exists. Triggers on "token budget", "how much will this cost", "will this be expensive", "estimate tokens", before long multi-agent tasks.
4
4
  user-invocable: true
5
5
  allowed-tools: ["Read", "Bash"]
6
6
  model: sonnet
@@ -76,6 +76,14 @@ Base estimates per task type:
76
76
 
77
77
  Apply dimension multipliers from Step 1 to the base estimate.
78
78
 
79
+ **Calibration override (read leg)**: if `.claude/token_calibration/log.tsv` exists and holds ≥1 row
80
+ for this task type, derive the base as `median(actual_i / mult_i)` over its rows — each row's `mult`
81
+ column records the dimension-multiplier product that was in effect, so dividing recovers a
82
+ dimension-neutral base. **Never use raw `actual` as the base**: actual already contains that run's
83
+ multipliers, and re-applying this run's multipliers on top double-counts scope. The table above is the
84
+ uncalibrated default; measured local data outranks it. State which source the estimate used
85
+ (`base: table (uncalibrated)` or `base: calibration log, n={rows}`).
86
+
79
87
  **Final formula:**
80
88
  ```
81
89
  Estimated = base × file_multiplier × agent_multiplier × iteration_multiplier
@@ -131,35 +139,46 @@ Scope reduction options table (ORANGE/RED):
131
139
 
132
140
  ---
133
141
 
134
- ### Step 5. Post-Task Calibration (optional)
142
+ ### Step 5. Post-Task Calibration (offered, not required)
135
143
 
136
144
  After task completion, if user says "how much did that cost" or "calibrate":
137
145
 
146
+ **`Actual` must come from a mechanical source** — the user pasting `/cost` output, or `budget.spent()`
147
+ in a Workflow context. A model cannot read its own token consumption by introspection; a reconstructed
148
+ number written into the log poisons every future estimate that reads it (the read leg in Step 2).
149
+ No mechanical source available → render `Actual: UNMEASURED` and **write no row**.
150
+
138
151
  ```
139
152
  ## Calibration
140
153
 
141
154
  Estimated: ~16K tokens
142
- Actual: ~{actual}K tokens
143
- Error: {+/-N}%
144
-
145
- Calibration note saved → improves next estimate for this task type.
155
+ Actual: ~{actual}K tokens (source: /cost paste | budget.spent() | UNMEASURED)
156
+ Error: {+/-N}% (omit when UNMEASURED)
146
157
  ```
147
158
 
148
- Write calibration data:
159
+ Write calibration data (only when Actual is mechanically sourced):
149
160
  ```bash
150
161
  mkdir -p .claude/token_calibration/
151
- # Append: task_type, estimated, actual, date
162
+ # mult = this run's dimension-multiplier product (Step 1) — stored so the read leg can divide it
163
+ # back out; a log without it makes every future estimate double-count scope.
164
+ printf '%s\t%s\t%s\t%s\t%s\n' "{task_type}" "{estimated_k}" "{actual_k}" "{mult}" "$(date +%F)" \
165
+ >> .claude/token_calibration/log.tsv
152
166
  ```
153
167
 
154
- Calibration data improves future estimates for the same task type (no model training — local record only).
168
+ Recorded rows feed the Step 2 calibration override for the same task type (no model training — local record only).
155
169
 
156
170
  ---
157
171
 
158
172
  ## Done When
159
173
 
160
- - Gate verdict output (GREEN/YELLOW/ORANGE/RED) with estimated cost breakdown
161
- - For ORANGE/RED: scope reduction options presented and user decision recorded
162
- - Calibration offered after task completion (optional, not mandatory)
174
+ - Gate verdict output (GREEN/YELLOW/ORANGE/RED) with estimated cost breakdown, naming the estimate's
175
+ base source (table-uncalibrated or calibration log) *mandatory-pass*
176
+ - For ORANGE/RED: scope reduction options presented and user decision recorded — *mandatory-pass*
177
+ - Any calibration row written this run carries a mechanically-sourced `actual` (pasted `/cost` or
178
+ `budget.spent()`); no mechanical source → `Actual: UNMEASURED`, no row — *mandatory-pass*
179
+
180
+ (Post-task calibration is an offer in Step 5, not a completion condition — a completion condition
181
+ marked "optional" is not a completion condition.)
163
182
 
164
183
  ---
165
184
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-meta",
3
- "version": "1.4.94",
3
+ "version": "1.4.95",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -79,7 +79,22 @@ Classify each commit using the following criteria (commit message + changed-file
79
79
 
80
80
  Output the candidate list (format in §ModeA-Blocks): scanned count, FH absorption candidates with type/location/impact stars, field-only skipped count, then ask `[all / select number / skip]`. If 0 candidates, report "no absorption candidates" and exit.
81
81
 
82
- ## Step 4. PR Creation (upon user approval)
82
+ ## Step 3.5. contention-layer Collision Gate (mandatory when candidates ≥ 1)
83
+
84
+ Run `contention-layer` on the confirmed candidate list **before any PR is created** — a new pattern
85
+ must be checked for collision with existing skill role clusters before registration. Skip only when
86
+ Step 3 found 0 candidates.
87
+
88
+ | contention-layer result | Behavior |
89
+ |---|---|
90
+ | No collision | Proceed to Step 4 |
91
+ | Collision / blocked registration | **No PR** — verdict `FAIL` per the enum below |
92
+ | Role collision needing a human call | **No PR** — verdict `ESCALATE` |
93
+
94
+ (This step previously existed only as a line *below* Done When — a cold executor reading Steps 0–4
95
+ had no occasion to see it before `gh pr create`. It is now in the execution path it gates.)
96
+
97
+ ## Step 4. PR Creation (upon user approval, after Step 3.5)
83
98
 
84
99
  Create a `harvest/{project}-{date}` branch in FH, apply approved patterns, commit, push, and `gh pr create` (bash + PR body checklist in §ModeA-Blocks).
85
100
 
@@ -215,24 +230,32 @@ One pass per session; never blocks the Mode B commit.
215
230
 
216
231
  **Mode A (Pattern Harvest)**:
217
232
  ```
218
- All stages Step 0~4 complete
219
- + Step 3 harvest candidate list output (N candidates + M field-only)
220
- + Upon user approval, Step 4 PR creation complete (gh pr create executed)
221
- + When 0 candidates, "no absorption candidates" reported then exit
233
+ All stages through the exit point complete (Step 0~4; or
234
+ Step 0~3 + the 0-candidate exit, which is a valid terminal) — mandatory-pass
235
+ + Step 3 harvest candidate list output (N candidates + M field-only) — measured (counts)
236
+ + Step 3.5 contention-layer ran on the confirmed list (or its
237
+ 0-candidate skip recorded) and its verdict gated Step 4 — mandatory-pass
238
+ + Upon user approval, Step 4 PR creation complete (gh pr create ran) — mandatory-pass
239
+ + When 0 candidates, "no absorption candidates" reported then exit — mandatory-pass
222
240
  ```
223
241
 
224
242
  **Mode B (Session Log)**:
225
243
  ```
226
- All Steps 0-B ~ 5-B executed
227
- + Step 0-B.1 detection-skip ledger applied (already-logged commits filtered)
228
- + Session markdown file generated from git log (or "all commits already logged" exit)
229
- + Hub commit created (no auto-push)
230
- + Confirmation output with push offer
244
+ All Steps 0-B ~ 5-B executed — mandatory-pass
245
+ + Step 0-B.1 detection-skip ledger applied (already-logged filtered) — mandatory-pass
246
+ + Session markdown file generated from git log
247
+ (or "all commits already logged" exit) — mandatory-pass
248
+ + Hub commit created (no auto-push) — mandatory-pass
249
+ + Confirmation output with push offer — mandatory-pass
231
250
  ```
232
251
 
252
+ The harvest-worthiness classification itself (Steps 1–2) is *judged* — its adversarial pairing is
253
+ Step 3.5 `contention-layer` plus the PR-side `asset-placement-gate`; no candidate registers on the
254
+ author's judgment alone.
255
+
233
256
  Verdict: PASS (harvest candidates output and PR created, or 0 candidates confirmed) | CONDITIONAL_PASS (candidates found but PR pending user approval; or Mode B commit created, push pending) | FAIL (contention-layer blocked candidate registration; or hub path not found) | ESCALATE (role collision with existing skill requires human decision)
234
257
 
235
- **→ Mandatory next: `contention-layer`** — run immediately after Step 3 candidate list is confirmed, before Step 4 PR creation. New patterns must be checked for collision with existing skill role clusters before registration. Skip only when 0 candidates found.
258
+ **→ Mandatory next: `contention-layer`** — executed as Step 3.5 above (kept here in closed-chain form for chain auditors: run after Step 3 candidate confirmation, before Step 4 PR creation; skip only at 0 candidates).
236
259
 
237
260
  ## Linked Skills
238
261
 
@@ -84,7 +84,7 @@ Result: **PASS** / **PARTIAL** / **FAIL**
84
84
  ### Check 3 — Maintenance Signals
85
85
 
86
86
  ```bash
87
- cd "$REPO_PATH" 2>/dev/null || cd "$(pwd)"
87
+ cd "$REPO_PATH" 2>/dev/null || { echo "ABORT: cannot cd to $REPO_PATH — refusing to measure the current repo in its place"; exit 1; }
88
88
  git log -1 --format="Last commit: %ar (%ad)" --date=short 2>/dev/null
89
89
  ls CHANGELOG* 2>/dev/null && echo "CHANGELOG found" || echo "No CHANGELOG"
90
90
  git tag -l 2>/dev/null | tail -5
@@ -101,47 +101,66 @@ Result: **ACTIVE** / **STALE** (60–180 days) / **ABANDONED** (180+ days)
101
101
  ### Check 4 — Duplication / Conflict Detection
102
102
 
103
103
  ```bash
104
- # list skills in current repo (directory-based)
105
- find "$REPO_PATH" -name "SKILL.md" 2>/dev/null | xargs -I{} dirname {} | xargs -I{} basename {}
106
- # compare with existing FH skills (if FH_DIR is set)
107
- [ -n "$FH_DIR" ] && ls "$FH_DIR/plugins/fh-meta/skills/" 2>/dev/null
108
- # cross-check registered skills in plugin.json (SoT)
109
- [ -n "$FH_DIR" ] && python3 -c "
110
- import json, sys
111
- with open('$FH_DIR/plugins/fh-meta/.claude-plugin/plugin.json') as f:
112
- d = json.load(f)
113
- skills = [s['name'] for s in d.get('skills', [])]
114
- print('plugin.json registered skills (' + str(len(skills)) + '):', ', '.join(skills))
115
- " 2>/dev/null || echo "plugin.json parse failed (FH_DIR not set or path error)"
104
+ # list skills in target repo (directory-based — skills are directory-registered in this ecosystem;
105
+ # plugin.json carries no skills array, so a manifest read is NOT a skill list. An earlier version
106
+ # of this check read `plugin.json["skills"]`, a key that never exists, so it rendered every healthy
107
+ # repo as STALE a broken instrument, calibrated against this very repo.)
108
+ find "$REPO_PATH" -name "SKILL.md" 2>/dev/null | xargs -I{} dirname {} | xargs -I{} basename {} | sort > /tmp/_mkt_target_skills.txt
109
+ cat /tmp/_mkt_target_skills.txt
110
+ # compare with hub skills — SKIPPED must be visible, never silent. The readability test matters:
111
+ # FH_DIR set-but-wrong yields an empty ls through the pipe, which reads as "0 overlaps" — a silent
112
+ # skip wearing a pass. Set-but-unreadable is its own labeled state.
113
+ if [ -n "$FH_DIR" ] && [ -d "$FH_DIR/plugins" ]; then
114
+ ls "$FH_DIR"/plugins/*/skills/ 2>/dev/null | grep -v ':$' | grep -v '^$' | sort > /tmp/_mkt_hub_skills.txt
115
+ comm -12 /tmp/_mkt_target_skills.txt /tmp/_mkt_hub_skills.txt | sed 's/^/NAME-OVERLAP: /'
116
+ elif [ -n "$FH_DIR" ]; then
117
+ echo "SKIPPED: FH_DIR set but $FH_DIR/plugins unreadable — hub cross-check NOT run (not a CLEAN signal)"
118
+ else
119
+ echo "SKIPPED: FH_DIR unset — hub cross-check NOT run (this is not a CLEAN signal)"
120
+ fi
116
121
  ```
117
122
 
118
- **Duplication verdict**: If directory-based list and plugin.json list don't match → **STALE** warning.
123
+ **Duplication verdict**: name overlap with hub skills → **OVERLAP**/**CONFLICT** by role comparison.
124
+ Hub cross-check skipped → report `CLEAN (target-internal only — hub cross-check SKIPPED)`, never bare CLEAN.
119
125
 
120
126
  | Criterion | Check |
121
127
  |---|---|
122
- | No name conflict with existing FH skills | name comparison |
128
+ | No name conflict with existing FH skills | name comparison (or visible SKIPPED) |
123
129
  | No functional duplication | description keyword comparison |
124
- | plugin.json list matches directory list | cross-check (SoT consistency) |
125
130
 
126
131
  Result: **CLEAN** / **OVERLAP** (N candidates) / **CONFLICT** (direct conflict)
127
132
 
128
133
  ### Check 5 — Public Safety
129
134
 
135
+ **Primary path (no-reinvention)**: when `public-surface-audit` is installed, run it against
136
+ `$REPO_PATH` and map its verdict — `LEAK` → **BLOCKED** · `REVIEW` → **WARNING** · `CLEAN` → **SAFE**
137
+ · `NOT_CONFIGURED` → **WARNING(NOT_CONFIGURED)** (pattern source absent — not a clean bill). That
138
+ skill is the real token scanner; this check does not re-implement it. The `NOT_CONFIGURED` qualifier
139
+ survives into the Step 2 aggregate — see the 🟢 rule there (an unmeasured public surface must not be
140
+ absorbed into an ignorable ⚠️).
141
+
142
+ **Fallback (screening-grade only)** — when public-surface-audit is not installed:
143
+
130
144
  ```bash
131
- # detect hardcoded internal domains
132
- grep -r "<your-ghe-url>\|internal-domain\|sandbox\|internal-api" \
145
+ # placeholder-literal screening — catches template residue, NOT real internal hostnames or secrets
146
+ grep -r "<your-ghe-url>\|internal-domain\|internal-api" \
133
147
  "$REPO_PATH" --include="*.md" --include="*.json" --include="*.yaml" -l 2>/dev/null | head -10
134
- # sensitive information exposure
135
- grep -r "API_KEY\s*=\|SECRET\s*=\|PASSWORD\s*=" \
136
- "$REPO_PATH" --include="*.md" --include="*.json" -l 2>/dev/null | head -5
148
+ # sensitive information exposure (assignment shapes only)
149
+ grep -rE "API_KEY\s*=|SECRET\s*=|PASSWORD\s*=" \
150
+ "$REPO_PATH" --include="*.md" --include="*.json" --include="*.yaml" --include="*.yml" \
151
+ --include="*.sh" --include="*.env*" -l 2>/dev/null | head -5
137
152
  # license
138
153
  ls "$REPO_PATH"/LICENSE* 2>/dev/null && echo "LICENSE found" || echo "No LICENSE"
139
154
  ```
140
155
 
156
+ Fallback results are always labeled `(screening-grade — placeholder patterns; not a hostname/secret
157
+ scanner)`. A go-public action still owes the Pre-Publish Surface Gate's full chain regardless of a
158
+ SAFE here — this check screens listing readiness, it does not clear publication.
159
+
141
160
  | Criterion | Check |
142
161
  |---|---|
143
- | No hardcoded internal domains (or clearly marked as internal-only) | grep |
144
- | No sensitive information exposed | grep |
162
+ | No hardcoded internal domains (or clearly marked as internal-only) | public-surface-audit (or screening-grade grep, labeled) |
163
+ | No sensitive information exposed | public-surface-audit (or screening-grade grep, labeled) |
145
164
  | LICENSE file exists | ls |
146
165
 
147
166
  Result: **SAFE** / **WARNING** (N items to review) / **BLOCKED** (sensitive info exposed)
@@ -161,10 +180,14 @@ marketplace-gate — Listing Suitability Verdict
161
180
  Check 4 Duplication detection: ✅ CLEAN / ⚠️ OVERLAP({N}) / ❌ CONFLICT
162
181
  Check 5 Public safety : ✅ SAFE / ⚠️ WARNING({N}) / ❌ BLOCKED
163
182
 
164
- Overall verdict:
165
- 🟢 Recommended for listing — 0 FAIL + 0 BLOCKED
166
- 🟡 Conditional listing — ≤1 FAIL + 0 BLOCKED
167
- 🔴 Listing on hold 2+ FAIL or 1+ BLOCKED
183
+ Overall verdict — counted over the ❌-class {FAIL, ABANDONED, CONFLICT, BLOCKED}
184
+ (each check has its own vocabulary; the aggregate counts the ❌ column, not the token "FAIL" —
185
+ an ABANDONED or CONFLICT is a failure even though its word differs):
186
+ 🟢 Recommended for listing 0 ❌-class results, AND Check 5 does not carry the
187
+ NOT_CONFIGURED qualifier (an unmeasured surface caps
188
+ the verdict at 🟡 — unmeasured ≠ pass)
189
+ 🟡 Conditional listing — exactly 1 ❌-class result, and it is not BLOCKED
190
+ 🔴 Listing on hold — 2+ ❌-class results, or any BLOCKED
168
191
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
169
192
  ```
170
193
 
@@ -182,12 +205,17 @@ marketplace-gate — Listing Suitability Verdict
182
205
  ## Done When
183
206
 
184
207
  ```
185
- All steps 0–2 completed
186
- + Full 5-point check results output (Check 1–5 individual verdicts)
187
- + Overall verdict output (🟢 Recommended / 🟡 Conditional / 🔴 On hold)
208
+ All steps 0–2 completed — mandatory-pass
209
+ + Full 5-point check results output (Check 1–5 individual
210
+ verdicts, skipped legs rendered as visible SKIPPED) — mandatory-pass
211
+ + Overall verdict output (🟢/🟡/🔴) counted over the ❌-class — measured (❌-class count)
212
+ + Before any 🟢 Recommended verdict: phantom-quench ran over
213
+ the target's citations/URLs/path refs; phantom refs found
214
+ → verdict auto-downgrades to 🟡 Conditional — mandatory-pass
188
215
  ```
189
216
 
190
- **→ Mandatory before 🟢 Recommended verdict: `phantom-quench`** forward axis check on all citations, external URLs, and file path references in the asset being reviewed. A 🟢 verdict without phantom-quench is incomplete. If phantom-quench finds phantom refs → verdict downgrades to 🟡 Conditional automatically.
217
+ (The phantom-quench leg sits inside Done When on purpose an earlier version stated it below the
218
+ fence, so the fence alone could be satisfied without it.)
191
219
 
192
220
  > When `agent-composer` receives a "comprehensive marketplace listing audit" request,
193
221
  > recommend: Wave 0 `fact-checker` → Wave 1 `marketplace-gate` + `hub-persona-auditor` in parallel.
@@ -94,7 +94,11 @@ The four constituent skills use heterogeneous scope models. Translate the pipeli
94
94
 
95
95
  ## Step 0.5. return-path-gate — Pre-flight Chain Audit
96
96
 
97
- > Skip if return-path-gate is not installed or scope is a single non-pipeline skill.
97
+ > Skip only when scope is a single non-pipeline skill. **return-path-gate not installed is a recorded
98
+ > degrade, never a free skip**: proceed, but record `degraded: return-path-gate (not installed)` in the
99
+ > final report — and, symmetric with the user-override row below, the sweep **cannot reach
100
+ > `CLEAN (--full)`**. A missing tool must not pass more cheaply than an explicit refusal (that
101
+ > asymmetry trains uninstalling the gate).
98
102
 
99
103
  Run `/return-path-gate --skill [scope]`.
100
104
 
@@ -197,7 +201,8 @@ After all steps complete (or after chain halt), output the aggregated report (te
197
201
 
198
202
  | Condition | Overall |
199
203
  |---|---|
200
- | All steps PASS | `CLEAN ({mode})` |
204
+ | All steps PASS, zero `degraded:` entries | `CLEAN ({mode})` |
205
+ | All steps PASS but 1+ `degraded:` entries (skip/not-installed/override) | `CLEAN ({mode}, degraded: [list])` — and the mode may not render `--full` (the Step 0.5 "cannot reach CLEAN (--full)" rule binds HERE, in the aggregate, not only in prose) |
201
206
  | Any step CONDITIONAL_PASS or accepted ESCALATE; none FAIL | `PENDING` |
202
207
  | Any step FAIL or unresolved ESCALATE (option c) | `BLOCKED` |
203
208
 
@@ -263,7 +268,7 @@ complexity_routing:
263
268
 
264
269
  ```
265
270
  Step 0 scope confirmed and scope translation table applied
266
- + Step 0.5 return-path-gate pre-flight: PASS / CONDITIONAL_PASS / FAIL (halts sweep) / explicitly skipped / degraded (user override)
271
+ + Step 0.5 return-path-gate pre-flight: PASS / CONDITIONAL_PASS / FAIL (halts sweep) / explicitly skipped / degraded (user override | not installed)
267
272
  + All in-scope steps executed and verdicts emitted
268
273
  + Aggregated report output (Step 5 format)
269
274
  + Report saved to tracks/_meta/ (or skip warning issued)
@@ -273,6 +278,11 @@ Step 0 scope confirmed and scope translation table applied
273
278
  + If ESCALATE occurred: user presented three options; choice recorded in report
274
279
  ```
275
280
 
281
+ **Check classes**: every fence row above is *mandatory-pass*, except the completion-claim discipline
282
+ below, which is *measured* — its reconciliation (failed+skipped list length vs. in-scope − passed) is
283
+ the mechanical anchor. No Done When row here is judged: verdict aggregation is per-step-typed, not
284
+ discretionary.
285
+
276
286
  Verdict: PASS (all conditions met, sweep complete) | CONDITIONAL_PASS (sweep complete, pending items captured) | FAIL (chain halted, blocking items remain) | ESCALATE (chain paused, human decision required)
277
287
 
278
288
  A sweep is not done until the Step 5 report is output. Emitting per-step verdicts without the aggregated report is incomplete.
@@ -38,7 +38,9 @@ deliberately excluded — `CLAUDE.local.md`, the companion store, and local sess
38
38
  *correct* home for operator-private tokens, so finding them there is not a leak.
39
39
 
40
40
  ```bash
41
- REPO_PATH="${ARGUMENTS#--target }"
41
+ ARGS="${ARGUMENTS//--json/}" # strip flags first — "--json" alone must not become the path
42
+ REPO_PATH="${ARGS#--target }"
43
+ REPO_PATH="$(printf '%s' "$REPO_PATH" | xargs)"
42
44
  REPO_PATH="${REPO_PATH:-$(pwd)}"
43
45
  git -C "$REPO_PATH" rev-parse --is-inside-work-tree >/dev/null 2>&1 \
44
46
  || { echo "Not a git repo — public-surface-audit scans git-tracked files only. Aborting."; exit 1; }
@@ -50,12 +52,18 @@ git -C "$REPO_PATH" ls-files | wc -l | xargs echo "Tracked files:"
50
52
 
51
53
  ## Step 1. Pattern List (configurable)
52
54
 
53
- The patterns **are themselves operator-private** your real username and employer name must not be
54
- hardcoded *here*, on the public surface, or this skill would leak exactly what it hunts. So the literal
55
- values live in a **gitignored source you supply** (`.claude/rules/.public-surface-patterns`, or a
56
- section of `CLAUDE.local.md`) — one `severity<TAB>regex` per line. This SKILL.md carries only
57
- placeholders; the scan reads the gitignored file, never literals from this table. The skill dogfoods
58
- its own rule.
55
+ The pattern source is **two layers, matching the mechanical scan layer** (`scripts/psa_scan_lib.sh`,
56
+ shared with the pre-commit/pre-push/pre-publish hooks this skill must render the same verdict the
57
+ hooks would, or the same repo state gets two answers):
58
+
59
+ 1. **`.claude/rules/.public-surface-patterns.defaults`** committed, universal placeholder-grade
60
+ patterns (safe to publish; carries no operator literal).
61
+ 2. **`.claude/rules/.public-surface-patterns`** — **gitignored, operator literals** (or `PSA_PATTERNS`
62
+ env override). Your real username and employer name live *only* here — hardcoding them in a tracked
63
+ file would leak exactly what this skill hunts. One `severity<TAB>regex` per line.
64
+
65
+ The scan reads the **union of both layers**. This SKILL.md carries only placeholders; the skill
66
+ dogfoods its own rule.
59
67
 
60
68
  | # | Token class | Severity | Placeholder (real value goes in the gitignored source) | Why private |
61
69
  |:-:|---|:-:|---|---|
@@ -71,12 +79,16 @@ its own rule.
71
79
  - **LOW** — companion-store / private-wiring name. Methodology should be model-agnostic; naming a private
72
80
  store is drift, not a confidentiality breach.
73
81
 
74
- > **Setup**: put your real values in the gitignored pattern source (one `severity<TAB>regex` per line);
75
- > the scan reads that file, never literals from this SKILL.md. If the source is **absent**, the scan
76
- > reports **NOT CONFIGURED** — *not* CLEAN. A missing pattern file must never masquerade as a clean bill
77
- > of health (that would be a silent failure: "nothing scanned" misread as "nothing leaked"). To declare
78
- > "I genuinely have no private tokens", create the file **empty** an empty file is an explicit CLEAN,
79
- > an absent file is unconfigured.
82
+ > **Setup**: put your real values in the gitignored override (one `severity<TAB>regex` per line).
83
+ > Degrade states, by layer never collapse them:
84
+ > - **Neither layer present** → **NOT CONFIGURED** — *not* CLEAN. A missing pattern source must never
85
+ > masquerade as a clean bill of health ("nothing scanned" misread as "nothing leaked").
86
+ > - **Defaults only** (override absent) → scan runs, but the verdict carries the label
87
+ > `operator literals NOT CONFIGURED universal patterns only; identity/company classes UNSCANNED`.
88
+ > This mirrors what the hooks scan in the same state, instead of reporting NOT CONFIGURED while the
89
+ > hook path quietly scans defaults (two verdicts for one repo state — the divergence this line closes).
90
+ > - To declare "I genuinely have no private tokens", create the override **empty** — an empty file is
91
+ > an explicit declaration, an absent file is unconfigured.
80
92
 
81
93
  ---
82
94
 
@@ -86,6 +98,11 @@ Some tracked files legitimately reference otherwise-private tokens — the scan
86
98
  leaks. Maintain an allowlist of `file path :: token` pairs. A match is suppressed only when **both**
87
99
  the file and the token are on the allowlist row.
88
100
 
101
+ **Where the filled allowlist lives**: a populated allowlist row *names private tokens*, so it belongs
102
+ in a **gitignored** source beside the pattern override (`.claude/rules/.public-surface-allowlist`, or a
103
+ section of `CLAUDE.local.md`) — never in a tracked file. The table below shows generic shapes only; a
104
+ tracked copy of your real allowlist would itself be the leak this skill scans for.
105
+
89
106
  | Tracked file | Allowed tokens | Reason |
90
107
  |---|---|---|
91
108
  | `.gitignore` | companion-store name, sync-script name | Must name what it ignores |
@@ -172,11 +189,17 @@ class of leak). Diagnostic-only: this step never writes — it reports, the oper
172
189
  Report per-hit `file:line → matched token [class]` grouped by severity, then the overall verdict.
173
190
  **Verdict thresholds** (behavioral — these define the gate):
174
191
 
175
- - ⚪ **NOT CONFIGURED** — pattern source absent (nothing scanned — NOT a clean result; set up first)
176
- - 🟢 **CLEAN** — pattern source present (incl. empty), 0 HIGH + 0 MED + 0 LOW (after allowlist)
192
+ - ⚪ **NOT CONFIGURED** — no pattern layer present (nothing scanned — NOT a clean result; set up first)
193
+ - 🟢 **CLEAN** — pattern source present (incl. empty override), 0 HIGH + 0 MED + 0 LOW (after allowlist)
177
194
  - 🟡 **REVIEW** — 0 HIGH + 0 MED, LOW-only (drift, not a breach)
178
195
  - 🔴 **LEAK** — 1+ HIGH or 1+ MED (block publish / fix before commit)
179
196
 
197
+ **Every verdict line carries a `coverage:` qualifier** — `full` (both layers loaded) or
198
+ `defaults-only` (override absent: operator identity/company classes UNSCANNED). The qualifier exists
199
+ because the enum alone cannot preserve the defaults-only state: `CLEAN (coverage: defaults-only)` on
200
+ an operator-configured checkout is missing evidence, not a pass — publish-gating callers there must
201
+ require `coverage: full`.
202
+
180
203
  > **Detail**: See `SKILL_detail.md §Report-Template` — the full human-report ASCII layout (severity
181
204
  > buckets, allowlist-suppressed line) — read when formatting the Step 4 report.
182
205
 
@@ -207,7 +230,9 @@ enforceable (FH's "enforcement is a hook, not a prompt" principle). Imported fro
207
230
  authoritative — never gate on `counts` alone**: a counts-only check (`HIGH==0 && MED==0`) misreads
208
231
  `NOT_CONFIGURED` (which also has zero counts) as a pass. A caller blocks when `verdict` is `LEAK` **or**
209
232
  `NOT_CONFIGURED` — an unconfigured scan is not a pass (the same silent-failure guard as the human path:
210
- absence ≠ CLEAN).
233
+ absence ≠ CLEAN). The JSON carries the same `coverage` field as Step 4; on an operator-configured
234
+ checkout a publish-gating caller additionally blocks on `coverage: "defaults-only"` (identity/company
235
+ classes unscanned there — zero counts prove nothing about the classes that never ran).
211
236
 
212
237
  ---
213
238
 
@@ -236,15 +261,24 @@ Usable standalone — no hub clone required.
236
261
  ## Done When
237
262
 
238
263
  ```
239
- Step 1 pattern list confirmed (defaults shown / user-adapted)
240
- + Step 2 allowlist applied
241
- + Step 3 scan run for every pattern over git ls-files (tracked only gitignored excluded)
242
- + Step 4 report output: per-hit file:line + token + severity, plus overall verdict
243
- + "public-surface-audit Complete" declaration output
264
+ Step 1 pattern source resolved (both layers checked; absent/partial
265
+ states rendered as their labeled degrade state, never as CLEAN) — mandatory-pass
266
+ + Step 2 allowlist applied (from a gitignored source) — mandatory-pass
267
+ + Step 3 scan run for every pattern over git ls-files (tracked only
268
+ gitignored excluded), malformed pattern rows counted and reported,
269
+ never silently dropped — measured (hit + malformed counts)
270
+ + Step 3b/3c run (FP-hygiene on matched tokens; ignore-verification
271
+ for expected-private paths) or explicitly skipped with reason — mandatory-pass
272
+ + Step 4 report output: per-hit file:line + token + severity, plus
273
+ overall verdict — mandatory-pass
274
+ + "public-surface-audit Complete" declaration output — mandatory-pass
244
275
  ```
245
276
 
246
277
  Verdict: **CLEAN** (0 tokens after allowlist) | **REVIEW** (LOW-only — drift, prescriptions noted) |
247
- **LEAK** (1+ HIGH or 1+ MED — block publish, prescriptions attached).
278
+ **LEAK** (1+ HIGH or 1+ MED — block publish, prescriptions attached) | **NOT_CONFIGURED** (no pattern
279
+ source — nothing scanned, not a pass; same 4-value enum as Step 5 `--json`. An earlier version of this
280
+ enum had 3 values here, so a session following only Done When would force absent-config into CLEAN/
281
+ REVIEW/LEAK — re-opening the exact silent-pass the body closes).
248
282
 
249
283
  ---
250
284
 
@@ -11,19 +11,58 @@ cd "$REPO_PATH" || exit 1
11
11
  # Build the tracked-file list once.
12
12
  git ls-files > /tmp/_psa_tracked.txt
13
13
 
14
- # Load your real patterns from the gitignored source (one "severity<TAB>regex" per line).
15
- PATTERN_SRC="${PSA_PATTERNS:-.claude/rules/.public-surface-patterns}"
16
- # Absent file CLEAN. An absent file is unconfigured (silent-failure risk); an EMPTY file is an
17
- # explicit "no tokens to protect" → CLEAN. Distinguish the two.
18
- [ -e "$PATTERN_SRC" ] || { echo "⚪ NOT CONFIGURED: no pattern source at $PATTERN_SRC. Create it (empty = explicit CLEAN) before trusting any verdict. Not scanning."; exit 2; }
19
-
20
- # One grep pass per pattern row; the regex comes from the file, never hardcoded here.
21
- while IFS=$'\t' read -r severity regex; do
22
- [ -z "$regex" ] && continue
23
- grep -nIE "$regex" $(cat /tmp/_psa_tracked.txt) 2>/dev/null | sed "s/^/[$severity] /"
24
- done < "$PATTERN_SRC"
14
+ # ── Single-source preference: when the shared library exists, use it and write NO second loop ──
15
+ # scripts/psa_scan_lib.sh owns loading + row validation + exemptions for the hook layer; a hand-rolled
16
+ # copy here is a second normalizer with its own leniency (the divergence class this rewrite removed).
17
+ if [ -r "scripts/psa_scan_lib.sh" ]; then
18
+ . scripts/psa_scan_lib.sh
19
+ psa_load ".claude/rules/.public-surface-patterns.defaults" \
20
+ "${PSA_PATTERNS:-.claude/rules/.public-surface-patterns}"
21
+ { [ "$PSA_DEFAULTS_OK" -eq 1 ] || [ "$PSA_OVERRIDE_PRESENT" -eq 1 ]; } \
22
+ || { echo "⚪ NOT CONFIGURED: neither pattern layer present. Not scanning."; exit 2; }
23
+ [ "$PSA_BAD_ROWS" -gt 0 ] \
24
+ && { echo "HARNESS_ERROR: $PSA_BAD_ROWS unusable pattern row(s) — verdict cannot be CLEAN"; exit 10; }
25
+ # Feed every tracked file as path<TAB>line, the stream psa_scan_tagged consumes. Sourcing the lib
26
+ # without these calls is a no-op scan — measured on this repo (PSA_STREAM stayed unset), so the
27
+ # calls are spelled out here rather than pointed at.
28
+ while IFS= read -r f; do
29
+ awk -v p="$f" '{printf "%s\t%s\n", p, $0}' "$f" 2>/dev/null
30
+ done < /tmp/_psa_tracked.txt | psa_scan_tagged
31
+ [ "$PSA_OVERRIDE_PRESENT" -eq 1 ] \
32
+ || echo "coverage: defaults-only — operator literals NOT CONFIGURED (identity/company classes UNSCANNED)"
33
+ else
34
+ # ── Standalone fallback (no hub scripts in this repo) — validated loop, malformed rows COUNTED ──
35
+ PSA_DEFAULTS=".claude/rules/.public-surface-patterns.defaults"
36
+ PATTERN_SRC="${PSA_PATTERNS:-.claude/rules/.public-surface-patterns}"
37
+ SRC_LIST=""
38
+ [ -e "$PSA_DEFAULTS" ] && SRC_LIST="$PSA_DEFAULTS"
39
+ [ -e "$PATTERN_SRC" ] && SRC_LIST="$SRC_LIST $PATTERN_SRC"
40
+ [ -n "$SRC_LIST" ] || { echo "⚪ NOT CONFIGURED: no pattern source (neither defaults nor override). Not scanning."; exit 2; }
41
+
42
+ MALFORMED=0
43
+ cat $SRC_LIST > /tmp/_psa_rows.txt # no pipe into the loop — a piped while runs in a subshell and loses MALFORMED
44
+ # `|| [ -n "$severity" ]` keeps a final row that lacks a trailing newline — `read` alone drops it silently.
45
+ while IFS=$'\t' read -r severity regex || [ -n "$severity" ]; do
46
+ case "$severity" in ''|'#'*) continue ;; esac # blank / comment rows
47
+ if [ -z "$regex" ]; then # no tab separator → malformed, VISIBLE
48
+ MALFORMED=$((MALFORMED+1)); echo "MALFORMED ROW (no <TAB>): $severity" >&2; continue
49
+ fi
50
+ printf 'x\n' | grep -qE "$regex" 2>/dev/null
51
+ rc=$? # plain rc capture — `if !` would negate $?
52
+ if [ "$rc" -ge 2 ]; then # grep rc≥2 = invalid regex, not "no match"
53
+ MALFORMED=$((MALFORMED+1)); echo "MALFORMED ROW (bad regex): $regex" >&2; continue
54
+ fi
55
+ grep -nIE "$regex" $(cat /tmp/_psa_tracked.txt) | sed "s/^/[$severity] /"
56
+ done < /tmp/_psa_rows.txt
57
+ # Malformed rows poison the verdict: part of the pattern file never scanned → CLEAN is unprovable.
58
+ [ "$MALFORMED" -gt 0 ] && { echo "HARNESS_ERROR: $MALFORMED malformed pattern row(s) — verdict cannot be CLEAN"; exit 10; }
59
+ fi
25
60
  ```
26
61
 
62
+ **Why the fallback validates instead of skipping**: the previous loop dropped a malformed row with a
63
+ bare `continue` and discarded grep's stderr — a broken pattern file scanned "clean" by silently not
64
+ scanning. `not found ≠ 0`: a row that never ran is not a row with zero hits.
65
+
27
66
  For each pattern, run `grep -nIE "<regex>" $(git ls-files)`:
28
67
  - `-n` → line numbers (required for `file:line` output)
29
68
  - `-I` → skip binary files
@@ -66,7 +66,27 @@ find plugins/ -name "SKILL.md" | sort
66
66
  find plugins/ -path "*[name]/SKILL.md"
67
67
  ```
68
68
 
69
- If 0 SKILL.md files are in scope, output: "No SKILL.md files in scopeaudit skipped." and stop.
69
+ If 0 SKILL.md files are in scope, **distinguish the two causes before stopping**a failed scope
70
+ command and a genuinely empty scope both render as zero lines, and only one of them is skippable:
71
+
72
+ ```bash
73
+ git rev-parse --git-dir >/dev/null 2>&1 \
74
+ || { echo "HARNESS_ERROR: git unavailable — scope could NOT be measured (not an empty scope)"; exit 10; }
75
+
76
+ # Repo-liveness alone is NOT enough: a broken ref (e.g. missing origin/main in --pr mode) fails the
77
+ # scope command with rc=1 and empty output while rev-parse stays green. Capture the scope command's
78
+ # OWN exit status — before the grep, which legitimately returns 1 on no-match:
79
+ scope_out=$(git diff --name-only origin/main...HEAD 2>&1); scope_rc=$?
80
+ [ "$scope_rc" -ne 0 ] \
81
+ && { echo "HARNESS_ERROR: scope command failed (rc=$scope_rc): $scope_out"; exit 10; }
82
+ printf '%s\n' "$scope_out" | grep "SKILL\.md"
83
+ # (same rc-capture shape applies to the default and --all scope commands)
84
+ ```
85
+
86
+ With both controls green and genuinely 0 files in scope, output:
87
+ "No SKILL.md files in scope — audit skipped (control: git rev-parse OK)." and stop.
88
+ A HARNESS_ERROR is a distinct non-pass — it must never be reported as "audit skipped", because this
89
+ skill is pipeline-conductor's Step 0.5 pre-flight and a tooling-down skip would render as a pass there.
70
90
 
71
91
  ---
72
92
 
@@ -221,11 +241,19 @@ Next actions:
221
241
  ## Done When
222
242
 
223
243
  ```
224
- Step 0 scope determined; SKILL.md files identified
225
- + Step 1 two-pass extraction complete (Pass A caller-wait + Pass B callee-output evaluated separately)
226
- + Step 2 every (caller callee) pair classified CLOSED or OPEN with two-dimension severity
227
- + Step 3 fix prescriptions output for each OPEN chain (core FH skills: proposal-only)
228
- + Step 4 summary report with CLOSED/OPEN counts output
244
+ Step 0 scope determined; a failed scope command reported as
245
+ HARNESS_ERROR, never as empty scope — mandatory-pass
246
+ + Step 1 two-pass extraction complete (Pass A caller-wait +
247
+ Pass B callee-output evaluated separately) — mandatory-pass
248
+ + Step 2 every (caller callee) pair classified CLOSED or
249
+ OPEN with two-dimension severity — judged (adversarial pairing:
250
+ before ANY chain is reported OPEN, the ground-truth CLOSED
251
+ pair from §Operating Notes must classify CLOSED in the same
252
+ run — an instrument that cannot separate the known pair
253
+ reports nothing)
254
+ + Step 3 fix prescriptions output for each OPEN chain
255
+ (core FH skills: proposal-only) — mandatory-pass
256
+ + Step 4 summary report with CLOSED/OPEN counts output — measured (counts)
229
257
  ```
230
258
 
231
259
  > Fix prescriptions are text output only (Write not in allowed-tools). Prescription application is manual and out of scope for this skill. Verification of applied fixes requires re-running `/return-path-gate --skill [name]`.
@@ -254,4 +282,5 @@ Verdict: PASS (0 HIGH severity OPEN chains) | CONDITIONAL_PASS (MEDIUM/LOW sever
254
282
  - **CONDITIONAL_PASS gate is the highest-risk gap**: A chain where CONDITIONAL_PASS conditions are listed by the callee but the caller has no enforcement path is OPEN even if other verdict paths fold correctly.
255
283
  - **Scope default is narrow by design**: Default captures modified + newly added files (not just staged changes). Use `--pr` for PR-relative mode in worktrees, `--all` for periodic sweeps.
256
284
  - **Core FH skill prescriptions are proposals only**: Edits to harvest-loop, steel-quench, sim-conductor, and other core skills require deliberate review — output proposal notes, not standard prescriptions.
257
- - **Reference pattern**: `knowledge/shared/harness-core/return_path_gate.md` defines the canonical closed-loop structure and verified instances (apex-review → sim-conductor, agent-composer ↔ deliberation). These are the ground-truth CLOSED examples for calibrating classification.
285
+ - **Reference pattern**: `knowledge/shared/harness-core/return_path_gate.md` defines the canonical closed-loop structure and verified instances (apex-review → sim-conductor, agent-composer ↔ deliberation). These are the ground-truth CLOSED examples for calibrating classification — and that calibration is a Done When condition (Step 2's adversarial pairing), not an optional note.
286
+ - **The Pass A signal list is a closed English keyword list — know its degrade direction**: synonyms ("blocks until", "halts on") and non-English documentation will read as false OPEN. That direction is by design (over-report, never silent-close), but treat an OPEN verdict on unconventional wording as a candidate for human reading before prescribing a fix.
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env bash
2
+ # capability_registry_check.sh — 정체성 ① 의 «등록 시점» 검사기.
3
+ #
4
+ # ─────────────────────────────────────────────────────────────────────────────
5
+ # 왜 이게 지어졌나 — 스펙이 이름까지 붙여놓고 «없다» 고 적어둔 파일
6
+ # ─────────────────────────────────────────────────────────────────────────────
7
+ # `capability_composition_contract.md §Salience` 는 이렇게 적는다:
8
+ #
9
+ # "Named residual, not built: a scripts/capability_registry_check.sh that validates
10
+ # the schema and runs each declared M4 pair would make registration *measured*
11
+ # rather than reviewed. It does not exist."
12
+ #
13
+ # `relay_channel.sh` 헤더도 같은 말을 한다 — 자기는 **call moment** 만 보고, 선언이
14
+ # **진실인지**는 검사하지 않는다고. 이 파일이 그 나머지 절반이다.
15
+ #
16
+ # ─────────────────────────────────────────────────────────────────────────────
17
+ # 이 검사기가 증명하지 않는 것 (과잉주장 금지 — 명명된 잔여)
18
+ # ─────────────────────────────────────────────────────────────────────────────
19
+ # · **등록을 강제하지 않는다.** "지금 capability 를 등록하려 한다"를 관측하는 훅은 없다.
20
+ # 돌리면 판정하지만 돌리도록 강제하지는 못한다(relay_channel.sh 의 같은 잔여와 동형).
21
+ # · **M3(모델 독립성)은 선언 검사다.** reps 로 재지 않는다 — `judge:` 축 선언과, mechanical
22
+ # 선언인데 entry 가 LLM CLI 를 부르는 명백한 모순만 잡는다. M4 를 reps=2 로 돌려 부분 방어.
23
+ # · 🟥 **`writes:` 축은 검증 불가 — 그리고 그게 이 파일에서 실제로 터졌다.**
24
+ # 2026-08-11, 이 검사기를 통과한 capability(`writes: read-only` 선언)의 진입점이
25
+ # 정리 트랩 결함으로 **레포의 `scripts/` 를 rm -rf 했다.** M1(실행 가능)·M2(닫힌 enum)·
26
+ # M3(mechanical)·M4(known-pair 통과)·M5(cwd) 를 **전부 통과한 채로** 그랬다.
27
+ # 등록 바는 «선언이 형식에 맞나 · 답 아는 쌍을 가르나» 를 보지, **«선언이 사실인가»**
28
+ # 를 보지 않는다. read-only 선언의 진위는 여기서 닫히지 않는다.
29
+ # 부분 처방(오늘 적용): 진입점 쪽 트랩 규율(정리 대상 변수 재대입 금지 + 임시경로 검문).
30
+ # 구조 처방(미구축): 샌드박스/읽기전용 마운트에서 M4 를 돌려 쓰기 시도를 관측하는 것.
31
+ # 그 전까지 `writes: read-only` 는 **등록자 주장**이지 이 검사기의 판정이 아니다.
32
+ #
33
+ # 사용법
34
+ # capability_registry_check.sh <capfile> [<capfile> ...]
35
+ # capability_registry_check.sh --self-test
36
+ #
37
+ # exit code
38
+ # 0 REGISTRABLE 전 capfile 이 M1–M5 + 추가조항 통과
39
+ # 1 REJECTED 하나 이상 기준 미달 (등록 불가 — 그 표면은 dispatch 로 남는다)
40
+ # 10 HARNESS_ERROR capfile 도달 불가·파손·검사기 자신의 전제 파손
41
+ #
42
+ set -o pipefail
43
+ set -f # noglob — 선언 파일의 값은 데이터지 파일 패턴이 아니다 (relay_channel.sh 와 동일)
44
+
45
+ RC_OK=0; RC_REJECT=1; RC_HARNESS=10
46
+
47
+ CLOSED_KEYS="id entry requires_cwd verdict_channel verdict_enum verdict_stdout_key upstream_argv echoes_upstream approval reversibility residency degrade tier_floor writes judge verdict_binding calibration_positive_args calibration_positive_expect calibration_negative_args calibration_negative_expect"
48
+
49
+ # 「안 돌았다」를 뜻하는 이름들 — 추가조항(§ⓑ.4 B1)이 요구하는 구분항
50
+ DIDNOTRUN_NAMES="DID_NOT_RUN DIDNOTRUN NOT_RUN NO_TARGET SKIPPED UNMEASURED NOT_CONFIGURED HARNESS_ERROR"
51
+
52
+ FAILED=0 # 전 파일 누적 (종료코드용)
53
+ FILE_FAILED=0 # 현재 capfile 한정 — 파일마다 초기화한다. 이게 없으면 앞 파일의 실패가
54
+ # 뒤 파일의 M4 를 SKIPPED 로 만들어, 뒤 파일의 실제 결함이 안 보인다(보고 결함).
55
+ _fail() { printf ' ❌ %s — %s\n' "$1" "$2"; FAILED=1; FILE_FAILED=1; }
56
+ _ok() { printf ' ✅ %s — %s\n' "$1" "$2"; }
57
+ _die() { printf '❌ HARNESS_ERROR: %s\n' "$*" >&2; exit "$RC_HARNESS"; }
58
+
59
+ _parse() {
60
+ CAP_id=""; CAP_entry=""; CAP_requires_cwd=""; CAP_verdict_channel=""
61
+ CAP_verdict_enum=""; CAP_verdict_stdout_key=""; CAP_judge=""; CAP_writes=""
62
+ CAP_cal_pos_args=""; CAP_cal_pos_expect=""; CAP_cal_neg_args=""; CAP_cal_neg_expect=""
63
+ UNKNOWN_KEYS=""
64
+ local line key val
65
+ while IFS= read -r line || [ -n "$line" ]; do
66
+ case "$line" in ''|'#'*) continue ;; esac
67
+ case "$line" in *:*) ;; *) UNKNOWN_KEYS="$UNKNOWN_KEYS malformed-line"; continue ;; esac
68
+ key="${line%%:*}"; val="${line#*:}"
69
+ # POSIX 문자클래스만 쓴다. BSD sed 에서 `[ \t]` 는 탭이 아니라 «공백·역슬래시·문자 t»
70
+ # 집합이라 `exit` 의 끝 t 가 잘려 `exi` 가 된다 — 이 검사기의 known-positive 레인이
71
+ # 실제로 그걸 잡았다(2026-08-11, 자기 계기가 자기 버그를 적발한 사례).
72
+ key="$(printf '%s' "$key" | tr -d '[:space:]')"
73
+ val="$(printf '%s' "$val" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
74
+ case " $CLOSED_KEYS " in *" $key "*) ;; *) UNKNOWN_KEYS="$UNKNOWN_KEYS $key"; continue ;; esac
75
+ case "$key" in
76
+ id) CAP_id="$val" ;; entry) CAP_entry="$val" ;;
77
+ requires_cwd) CAP_requires_cwd="$val" ;; verdict_channel) CAP_verdict_channel="$val" ;;
78
+ verdict_enum) CAP_verdict_enum="$val" ;; verdict_stdout_key) CAP_verdict_stdout_key="$val" ;;
79
+ judge) CAP_judge="$val" ;; writes) CAP_writes="$val" ;;
80
+ calibration_positive_args) CAP_cal_pos_args="$val" ;;
81
+ calibration_positive_expect) CAP_cal_pos_expect="$val" ;;
82
+ calibration_negative_args) CAP_cal_neg_args="$val" ;;
83
+ calibration_negative_expect) CAP_cal_neg_expect="$val" ;;
84
+ esac
85
+ done < "$1"
86
+ }
87
+
88
+ _enum_name_of() { # $1=exit code, $2=enum string → 이름 or ""
89
+ local pair
90
+ for pair in $2; do [ "${pair%%=*}" = "$1" ] && { printf '%s' "${pair#*=}"; return 0; }; done
91
+ printf ''
92
+ }
93
+
94
+ # 🟥 capfile 은 **실행 신뢰경계**다 — M4 는 선언된 arm 을 «실제로 실행» 하는 것이 요점이므로,
95
+ # 검사기에 넘긴 capfile 은 그 자체로 "이 명령을 돌려도 된다"는 선언이다. 신뢰하지 않는
96
+ # capfile 을 이 검사기에 넘기지 마라. 배포 전 보안 패스가 실증한 것: `entry: /usr/bin/touch`
97
+ # + cal args 로 **REJECTED 판정이 나는 와중에도 부작용이 이미 발생**했다(판정 전에 arm 이 돈다).
98
+ # 아래 검문은 그 경계를 없애지 못한다 — 우발적 형태만 막는다.
99
+ _validate_arm_args() { # $1=args → 셸 메타문자/상위경로 탈출을 거부
100
+ case "$1" in
101
+ *'|'*|*';'*|*'&'*|*'>'*|*'<'*|*'`'*|*'$('*|*$'\n'*)
102
+ _fail "M4" "캘리브레이션 args 에 셸 메타문자가 있다(entry 와 같은 인젝션 표면): $1"; return 1 ;;
103
+ *'../'*)
104
+ _fail "M4" "캘리브레이션 args 가 상위 경로로 탈출한다: $1"; return 1 ;;
105
+ esac
106
+ return 0
107
+ }
108
+
109
+ _run_arm() { # $1=extra args → ARM_RC / ARM_NAME. 파이프로 읽지 않는다(PIPE-VERDICT).
110
+ ( cd "$CAP_requires_cwd" 2>/dev/null || exit 127
111
+ # shellcheck disable=SC2086 # argv 토큰 분리는 의도 (noglob 로 확장은 막혀 있다)
112
+ set -- $CAP_entry $1; "$@" ) > /dev/null 2>&1
113
+ ARM_RC=$?
114
+ ARM_NAME="$(_enum_name_of "$ARM_RC" "$CAP_verdict_enum")"
115
+ }
116
+
117
+ _check_one() {
118
+ local f="$1"
119
+ [ -r "$f" ] || _die "capfile 도달 불가: $f"
120
+ _parse "$f"
121
+ FILE_FAILED=0
122
+ printf '\n── %s (%s)\n' "${CAP_id:-<id 미선언>}" "$f"
123
+
124
+ [ -n "$UNKNOWN_KEYS" ] && _fail "SCHEMA" "닫힌 키 목록 밖:$UNKNOWN_KEYS (오타 축 무음드롭 방지 — 무시하지 않는다)"
125
+
126
+ # ── M1 실행 가능한 진입점 ──────────────────────────────────────────────────
127
+ local first second
128
+ first="$(printf '%s' "$CAP_entry" | awk '{print $1}')"
129
+ second="$(printf '%s' "$CAP_entry" | awk '{print $2}')"
130
+ case "$CAP_entry" in
131
+ *'|'*|*';'*|*'&&'*|*'>'*|*'`'*|*'$('*)
132
+ _fail "M1" "entry 가 argv 가 아니라 셸 문자열이다(파이프/리다이렉트/치환 포함) — 인젝션 표면" ;;
133
+ '') _fail "M1" "entry 미선언" ;;
134
+ *)
135
+ if [ -x "$first" ] 2>/dev/null; then _ok "M1" "실행 가능: $first"
136
+ elif command -v "$first" >/dev/null 2>&1 && [ -r "$second" ]; then
137
+ _ok "M1" "선언된 인터프리터($first) + 읽을 수 있는 스크립트: $second"
138
+ else _fail "M1" "entry 를 셸이 모델 없이 실행할 수 없다: $CAP_entry"; fi ;;
139
+ esac
140
+
141
+ # ── M2 닫힌 채널의 typed verdict + 추가조항(ran ≠ did-not-run) ─────────────
142
+ case "$CAP_verdict_channel" in
143
+ exit|stdout-key|both) ;;
144
+ *) _fail "M2" "verdict_channel 이 {exit|stdout-key|both} 밖: '${CAP_verdict_channel}'" ;;
145
+ esac
146
+ case "$CAP_verdict_channel" in
147
+ stdout-key|both) [ -n "$CAP_verdict_stdout_key" ] || _fail "M2" "channel 이 stdout-key 를 포함하는데 verdict_stdout_key 미선언" ;;
148
+ esac
149
+ if [ -z "$CAP_verdict_enum" ]; then
150
+ _fail "M2" "verdict_enum 미선언 — 열린 채널은 등록 불가"
151
+ else
152
+ local pair bad=0 has_dnr=0 nm
153
+ for pair in $CAP_verdict_enum; do
154
+ case "$pair" in *=*) ;; *) bad=1 ;; esac
155
+ nm="${pair#*=}"
156
+ case " $DIDNOTRUN_NAMES " in *" $nm "*) has_dnr=1 ;; esac
157
+ done
158
+ [ "$bad" -eq 1 ] && _fail "M2" "verdict_enum 항목이 N=NAME 형식이 아니다: $CAP_verdict_enum"
159
+ if [ "$has_dnr" -eq 1 ]; then _ok "M2" "typed enum + 「안 돌았다」 구분항: $CAP_verdict_enum"
160
+ else _fail "M2+" "enum 에 「안 돌았다」를 뜻하는 값이 없다 ($CAP_verdict_enum) — PASS 가 no-op 과 구분 불가(§ⓑ.4 B1). PASS 는 적극 증거지 실패의 부재가 아니다"; fi
161
+ fi
162
+
163
+ # ── M3 모델 독립성 (선언 검사 + 명백한 모순만) ─────────────────────────────
164
+ case "$CAP_judge" in
165
+ mechanical)
166
+ case "$CAP_entry" in
167
+ *claude*|*codex*|*gemini*|*copilot*|*ollama*|*llm*)
168
+ _fail "M3" "judge: mechanical 선언인데 entry 가 모델 CLI 를 부른다: $CAP_entry" ;;
169
+ *) _ok "M3" "judge: mechanical (모델 미개입 선언)" ;;
170
+ esac ;;
171
+ model) _ok "M3" "judge: model — 선언됨(합법). 조합에서 이 PASS 는 NON_CLEARING 이다" ;;
172
+ '') _fail "M3" "judge 축 미선언 — 모델 개입 여부가 불명이면 조합이 계산될 수 없다" ;;
173
+ *) _fail "M3" "judge 값이 {mechanical|model} 밖: '$CAP_judge'" ;;
174
+ esac
175
+
176
+ # ── M5 선언된 cwd (M4 를 그 자리에서 돌리므로 먼저) ────────────────────────
177
+ case "$CAP_requires_cwd" in
178
+ /*) [ -d "$CAP_requires_cwd" ] && _ok "M5" "requires_cwd 실재: $CAP_requires_cwd" \
179
+ || _fail "M5" "requires_cwd 가 절대경로지만 존재하지 않는다: $CAP_requires_cwd" ;;
180
+ '') _fail "M5" "requires_cwd 미선언 — 콕핏이 어디서 부를지 알 수 없다" ;;
181
+ *) _fail "M5" "requires_cwd 가 절대경로가 아니다: $CAP_requires_cwd" ;;
182
+ esac
183
+
184
+ # ── M4 캘리브레이션 쌍 — 선언 + **실행** ──────────────────────────────────
185
+ if [ -z "$CAP_cal_pos_expect" ] || [ -z "$CAP_cal_neg_expect" ]; then
186
+ _fail "M4" "캘리브레이션 쌍 미선언(양성·음성 expect 둘 다 필요) — 답을 아는 케이스를 못 가르는 계기는 재는 게 아니다"
187
+ elif [ "$FILE_FAILED" -eq 1 ] && [ -z "${CRC_FORCE_M4:-}" ]; then
188
+ printf ' ⏭ M4 — 앞선 축이 실패해 실행 생략(SKIPPED, PASS 아님)\n'
189
+ elif ! _validate_arm_args "$CAP_cal_pos_args" || ! _validate_arm_args "$CAP_cal_neg_args"; then
190
+ printf ' ⏭ M4 — args 검문 실패로 arm 을 실행하지 않았다(SKIPPED, PASS 아님)\n'
191
+ else
192
+ local pos_name neg_name pos_rc neg_rc pos_name2
193
+ _run_arm "$CAP_cal_pos_args"; pos_rc="$ARM_RC"; pos_name="$ARM_NAME"
194
+ [ "$pos_rc" = "127" ] && _fail "M4" "requires_cwd 로 진입 실패 — arm 을 돌릴 수 없다"
195
+ _run_arm "$CAP_cal_pos_args"; pos_name2="$ARM_NAME" # reps=2 (M3 부분 방어)
196
+ _run_arm "$CAP_cal_neg_args"; neg_rc="$ARM_RC"; neg_name="$ARM_NAME"
197
+
198
+ if [ -z "$pos_name" ]; then
199
+ _fail "M4" "양성 arm 의 exit $pos_rc 가 선언된 enum 밖 — enum 밖 값은 HARNESS_ERROR 이지 PASS 가 아니다"
200
+ elif [ "$pos_name" != "$CAP_cal_pos_expect" ]; then
201
+ _fail "M4" "양성 arm 이 선언과 다르다: expect=$CAP_cal_pos_expect actual=$pos_name (exit $pos_rc)"
202
+ elif [ "$pos_name" != "$pos_name2" ]; then
203
+ _fail "M4/M3" "같은 양성 arm 2회에 verdict 가 갈렸다: $pos_name vs $pos_name2 — 모델 독립성 미충족"
204
+ elif [ -z "$neg_name" ]; then
205
+ _fail "M4" "음성 arm 의 exit $neg_rc 가 선언된 enum 밖"
206
+ elif [ "$neg_name" != "$CAP_cal_neg_expect" ]; then
207
+ _fail "M4" "음성 arm 이 선언과 다르다: expect=$CAP_cal_neg_expect actual=$neg_name (exit $neg_rc)"
208
+ elif [ "$pos_name" = "$neg_name" ]; then
209
+ _fail "M4" "양성·음성이 같은 verdict($pos_name) — 답을 아는 두 케이스를 못 가른다"
210
+ else
211
+ _ok "M4" "known-pair 실행 통과: 양성→$pos_name(x2 일치) · 음성→$neg_name"
212
+ fi
213
+ fi
214
+
215
+ # ── 검증 불가 축의 정직한 표기 (판정 아님) ────────────────────────────────
216
+ [ "$CAP_writes" = "read-only" ] && \
217
+ printf ' ⚠️ writes: read-only 는 **등록자 주장**이다 — 이 검사기는 그 진위를 못 잰다(헤더 §잔여 참조)\n'
218
+ }
219
+
220
+ # ── self-test (known-pair: 통과해야 할 선언 1 · 막혀야 할 선언 6) ─────────────
221
+ _self_test() {
222
+ local T; T="$(mktemp -d)"; local pass=0 fail=0
223
+ _t() {
224
+ local o; o="$(bash "$0" "$3" 2>&1)"; local r=$?
225
+ if [ "$r" = "$2" ]; then pass=$((pass+1)); printf ' ✅ %-34s rc=%s\n' "$1" "$r"
226
+ else fail=$((fail+1)); printf ' ❌ %-34s rc=%s (기대 %s)\n%s\n' "$1" "$r" "$2" "$o"; fi
227
+ }
228
+ printf 'capability_registry_check --self-test\n'
229
+ cat > "$T/good.cap" <<EOF
230
+ id: selftest:true-false
231
+ entry: bash $T/probe.sh
232
+ requires_cwd: $T
233
+ verdict_channel: exit
234
+ verdict_enum: 0=PASS 1=FAIL 3=DID_NOT_RUN
235
+ approval: auto
236
+ reversibility: reversible
237
+ residency: public
238
+ degrade: fail-closed
239
+ tier_floor: none
240
+ writes: read-only
241
+ judge: mechanical
242
+ verdict_binding: FAIL
243
+ calibration_positive_args: ok
244
+ calibration_positive_expect: PASS
245
+ calibration_negative_args: no
246
+ calibration_negative_expect: FAIL
247
+ EOF
248
+ printf '#!/bin/sh\n[ "$1" = ok ] && exit 0\nexit 1\n' > "$T/probe.sh"
249
+ _t "known-positive: 온전한 선언" 0 "$T/good.cap"
250
+ sed 's/^verdict_enum: .*/verdict_enum: 0=PASS 1=FAIL/' "$T/good.cap" > "$T/no_dnr.cap"
251
+ _t "추가조항: 「안 돌았다」 없음" 1 "$T/no_dnr.cap"
252
+ sed 's/^calibration_negative_expect: .*/calibration_negative_expect: PASS/' "$T/good.cap" > "$T/badpair.cap"
253
+ _t "M4: 음성이 선언과 불일치" 1 "$T/badpair.cap"
254
+ grep -v '^calibration_' "$T/good.cap" > "$T/nocal.cap"
255
+ _t "M4: 캘리브레이션 쌍 미선언" 1 "$T/nocal.cap"
256
+ sed 's/^judge: .*/degrad: fail-closed/' "$T/good.cap" > "$T/typo.cap"
257
+ _t "D3: 오타 축은 무시 아닌 실패" 1 "$T/typo.cap"
258
+ sed "s|^entry: .*|entry: bash $T/probe.sh \| grep x|" "$T/good.cap" > "$T/shellstr.cap"
259
+ _t "M1: entry 가 셸 문자열" 1 "$T/shellstr.cap"
260
+ sed 's|^requires_cwd: .*|requires_cwd: relative/path|' "$T/good.cap" > "$T/relcwd.cap"
261
+ _t "M5: requires_cwd 상대경로" 1 "$T/relcwd.cap"
262
+ printf '\n 통과 %d · 실패 %d\n' "$pass" "$fail"
263
+ rm -rf "$T"
264
+ [ "$fail" -eq 0 ] || return 1
265
+ return 0
266
+ }
267
+
268
+ [ "${1:-}" = "--self-test" ] && { _self_test; exit $?; }
269
+ [ $# -ge 1 ] || _die "capfile 인자가 없다. 사용법: $0 <capfile> [<capfile>...]"
270
+
271
+ printf 'capability_registry_check — M1–M5 + 추가조항 (등록 시점)\n'
272
+ for f in "$@"; do _check_one "$f"; done
273
+
274
+ printf '\n'
275
+ if [ "$FAILED" -eq 0 ]; then
276
+ printf '✅ REGISTRABLE — 전 capfile 이 M1–M5 + 추가조항 통과\n'; exit "$RC_OK"
277
+ else
278
+ printf '❌ REJECTED — 등록 불가. 그 표면은 오늘 있던 자리(dispatch 엔트리)에 그대로 남는다 — 손실이 아니다\n'
279
+ exit "$RC_REJECT"
280
+ fi
@@ -48,11 +48,62 @@
48
48
  # would let a real token pass by merely CONTAINING a placeholder word.
49
49
  PSA_PLACEHOLDER='^(<[a-z0-9_-]+>|\{[a-z_]+\}|EXAMPLE|dummy|changeme|REDACTED|xxxx|/Users/(EXAMPLE|yourname|\{[a-z_]+\}|<[a-z0-9_-]+>)/|AKIAIOSFODNN7EXAMPLE)$'
50
50
 
51
+ # ── file::token allowlist (ALL severities) — the SKILL's Step 2, finally implemented ────────────
52
+ #
53
+ # public-surface-audit SKILL.md §Step 2 has always specified a `file path :: token` allowlist whose
54
+ # rule is severity-agnostic ("a hit on file F matching token T is suppressed iff a row exists with
55
+ # file == F and T in that row's allowed tokens"). The library implemented only the LOW subset below,
56
+ # so spec and implementation disagreed: a HIGH hit that the operator had deliberately published had
57
+ # **no expressible disposition at all** — the only ways out were weakening a severity class or
58
+ # editing the artifact. That is the divergent-normalizer shape, and it surfaced for real on
59
+ # 2026-08-11 (a published paper's author-contact line and a rename changelog entry).
60
+ #
61
+ # Why this does not weaken the floor:
62
+ # · rows live in a **gitignored** source (`.claude/rules/.public-surface-allowlist`), so writing one
63
+ # never puts an operator token on the public surface — the same two-layer discipline as patterns.
64
+ # · a row is `path<TAB>literal-token`. **Both sides are literal, whole-value comparisons** — the
65
+ # path with `=`, the token with `grep -qxF`. Neither is a pattern, so one row suppresses exactly
66
+ # one token on exactly one file.
67
+ # ⚠️ Two versions of this got it wrong, and the second is the instructive one. v1 claimed a row
68
+ # "cannot become a blanket mute" while the code used an UNANCHORED `grep -qE`: a `path<TAB>.*`
69
+ # row muted every hit in that file, silently, at every severity (pre-publish security pass proved
70
+ # it with a known pair). v2 force-anchored to `^(…)$` — and `^(.*)$` still matches everything, so
71
+ # the blanket mute survived the "fix". Only making the field a **literal** actually closes it.
72
+ # The lesson is the file's own doctrine: a claim is not a control, and neither is a repair that
73
+ # was not re-measured against the same known pair.
74
+ # · every suppression **prints a line**. A mute that leaves no trace is indistinguishable from a
75
+ # clean scan — the same `not found ≠ 0` shape the verdict enum exists to prevent.
76
+ # · absent file → no rows → behaviour is exactly as before (fail-closed by default).
77
+ # Each row is an operator decision, recorded where it can be audited, instead of an undocumented
78
+ # exception living in someone's head.
79
+ psa_pair_allowlisted() { # $1=path $2=matched token
80
+ local root="${PSA_REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || printf '.')}"
81
+ local src="${PSA_ALLOWLIST:-$root/.claude/rules/.public-surface-allowlist}"
82
+ [ -r "$src" ] || return 1
83
+ local apath atok
84
+ while IFS=$'\t' read -r apath atok || [ -n "$apath" ]; do
85
+ case "$apath" in ''|'#'*) continue ;; esac
86
+ [ -z "$atok" ] && continue
87
+ [ "$apath" = "$1" ] || continue
88
+ # LITERAL, whole-token (`-x -F`). Not a regex — anchoring alone was not enough: `^(.*)$` still
89
+ # matches everything, so an anchored `.*` row remained a blanket mute. Treating the field as a
90
+ # literal is what makes "one row = one token" true rather than merely asserted.
91
+ if printf '%s' "$2" | grep -qxF "$atok" 2>/dev/null; then
92
+ echo " ⚪ allowlisted — ${1}: '${2}' (row: ${apath} :: ${atok})"
93
+ return 0
94
+ fi
95
+ done < "$src"
96
+ return 1
97
+ }
98
+
51
99
  # Files that name wiring/companion tokens as part of doing their job. LOW severity only — HIGH/MED
52
100
  # block everywhere, including here. Kept as a function so the list has exactly one definition.
53
101
  psa_low_allowlisted() {
54
102
  case "$1" in
55
- .gitignore|scripts/sync-to-be.sh|.claude/rules/local_fh_context.md|templates/local_fh_context.md|templates/.claude/rules/*|templates/.git-hooks/*) return 0 ;;
103
+ # sync-from-be.sh 는 sync-to-be.sh **같은 일의 반대 방향**인데 목록에 없어서
104
+ # LOW 를 계속 냈다(2026-08-11, 두 렌즈 relay 런 첫 실사용에서 발화). 짝을 빠뜨린
105
+ # 목록은 그 짝만 상시 오탐이 된다.
106
+ .gitignore|scripts/sync-to-be.sh|scripts/sync-from-be.sh|.claude/rules/local_fh_context.md|templates/local_fh_context.md|templates/.claude/rules/*|templates/.git-hooks/*) return 0 ;;
56
107
  *) return 1 ;;
57
108
  esac
58
109
  }
@@ -174,6 +225,7 @@ psa_scan_tagged() {
174
225
  [ -z "$tok" ] && continue
175
226
  printf '%s' "$tok" | grep -qiE "$PSA_PLACEHOLDER" && continue
176
227
  if [ "$sev" = "LOW" ] && psa_low_allowlisted "$path"; then continue; fi
228
+ if psa_pair_allowlisted "$path" "$tok"; then continue; fi
177
229
  echo " ❌ $sev leak — ${path}: '$tok'"
178
230
  hit=1
179
231
  done <<PSA_TOK
@@ -92,7 +92,12 @@ ORDER_judge="mechanical model"
92
92
  # 집합 축
93
93
  AXES_SET="verdict_binding"
94
94
  # 선언 파일이 쓸 수 있는 비-축 키 (메타데이터)
95
- META_KEYS="id entry requires_cwd verdict_channel verdict_enum verdict_stdout_key upstream_argv echoes_upstream"
95
+ # 스펙 §ⓑ.2 `calibration:` 블록(known_positive/known_negative)은 등록 시점 필드다.
96
+ # 여기는 call moment 라 그 값을 **쓰지 않지만**, CLOSED 목록에서 빠뜨리면 스펙대로 쓴
97
+ # 선언이 VIOLATION 으로 거부된다 — 2026-08-11 실측: registry_check 를 통과한 capfile 2개가
98
+ # relay 에서 8건 VIOLATION. 같은 스펙에 대해 두 계기가 서로 다른 스키마를 든
99
+ # divergent-normalizer 이고, 관대함이 갈리면 한쪽만 통과하는 입력이 생긴다.
100
+ META_KEYS="id entry requires_cwd verdict_channel verdict_enum verdict_stdout_key upstream_argv echoes_upstream calibration_positive_args calibration_positive_expect calibration_negative_args calibration_negative_expect"
96
101
 
97
102
  _die() { printf '❌ %s\n' "$*" >&2; exit "$RC_HARNESS"; }
98
103
  _violation() { printf '⛔ COMPOSITION_VIOLATION — %s\n' "$*" >&2; VIOLATED=1; }