963 lines
48 KiB
Python
963 lines
48 KiB
Python
#!/usr/bin/env python3
|
|
"""Standalone enforcement tests: run the hooks against good/bad fixtures and
|
|
assert exit codes. No pytest needed. Exit 0 = all pass.
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
HOOKS = os.path.join(ROOT, ".claude", "hooks")
|
|
FIX = os.path.join(ROOT, ".claude", "tests", "fixtures")
|
|
PY = sys.executable
|
|
os.makedirs(FIX, exist_ok=True)
|
|
|
|
passed, failed = 0, 0
|
|
|
|
|
|
def check(name, ok):
|
|
global passed, failed
|
|
if ok:
|
|
passed += 1
|
|
print(f" PASS {name}")
|
|
else:
|
|
failed += 1
|
|
print(f" FAIL {name}")
|
|
|
|
|
|
def run(script, args=None, stdin=None, env=None):
|
|
e = dict(os.environ)
|
|
e["CLAUDE_PROJECT_DIR"] = ROOT
|
|
if env:
|
|
e.update(env)
|
|
return subprocess.run(
|
|
[PY, os.path.join(HOOKS, script)] + (args or []),
|
|
input=stdin, capture_output=True, text=True, env=e,
|
|
)
|
|
|
|
|
|
def _inject_identity(text, path):
|
|
"""P0-3/P0-5 migration: report fixtures now need report-type + identity. Inject sane
|
|
defaults into any fixture that looks like a report and lacks them, so evidence/synthesis
|
|
tests keep exercising their intended violation without each fixture repeating boilerplate.
|
|
Non-report files (evidence logs) and fixtures already carrying the fields are untouched.
|
|
Use write_raw() to bypass injection when a test deliberately omits report-type."""
|
|
if "report-header:" not in text and "synthesized-by:" not in text:
|
|
return text
|
|
pre = []
|
|
if "report-type:" not in text:
|
|
pre.append("report-type: work")
|
|
if "work-summary:" not in text:
|
|
pre.append('work-summary: "fixture"')
|
|
if "report-id:" not in text:
|
|
base = os.path.basename(path)
|
|
rid = base[:-len(".report.yaml")] if base.endswith(".report.yaml") else "fix"
|
|
pre.append(f"report-id: {rid}")
|
|
if "workflow-id:" not in text:
|
|
pre.append("workflow-id: wf-test")
|
|
if "role-id:" not in text:
|
|
pre.append("role-id: TST-GENERIC-FIXTURE") # P3-B: 테스트 전용 역할(계약 강제 면제, 활성화 무관)
|
|
return ("\n".join(pre) + "\n" + text.lstrip("\n")) if pre else text
|
|
|
|
|
|
def write(path, text):
|
|
text = _inject_identity(text, path)
|
|
with open(path, "w") as f:
|
|
f.write(text)
|
|
return path
|
|
|
|
|
|
def write_raw(path, text):
|
|
"""Write a fixture WITHOUT identity injection (for tests that deliberately omit report-type)."""
|
|
with open(path, "w") as f:
|
|
f.write(text)
|
|
return path
|
|
|
|
|
|
# real evidence artifact (exists)
|
|
EV = write(os.path.join(FIX, "evidence.log"), "test run: 12 passed\n")
|
|
EV_REL = ".claude/tests/fixtures/evidence.log"
|
|
|
|
GOOD = write(os.path.join(FIX, "good.report.yaml"), f"""
|
|
report-header:
|
|
bottom-line: "가격 A안을 권고한다."
|
|
decision-needed: {{ needed: false, approver: EXEC-CEO }}
|
|
confidence: {{ value: Med, derived-from: evidence }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
""")
|
|
BAD_BLUF = write(os.path.join(FIX, "bad_bluf.report.yaml"), f"""
|
|
report-header:
|
|
bottom-line: ""
|
|
decision-needed: {{ needed: false }}
|
|
confidence: {{ value: Med }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
""")
|
|
BAD_EVID = write(os.path.join(FIX, "bad_evid.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "배포하자."
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence:
|
|
- source-uri: does/not/exist.log
|
|
grade: E4
|
|
""")
|
|
BAD_CONF = write(os.path.join(FIX, "bad_conf.report.yaml"), f"""
|
|
report-header:
|
|
bottom-line: "확실히 된다."
|
|
decision-needed: {{ needed: false }}
|
|
confidence: {{ value: High }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E1
|
|
""")
|
|
GOOD_E5 = write(os.path.join(FIX, "good_e5.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "테스트 통과, 배포 가능."
|
|
decision-needed: { needed: false }
|
|
confidence: { value: High }
|
|
risks: []
|
|
evidence:
|
|
- command: "pytest -q"
|
|
exit-code: 0
|
|
grade: E5
|
|
""")
|
|
BAD_INFLATE = write(os.path.join(FIX, "bad_inflate.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "됐다고 본다."
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence:
|
|
- command: "flaky-check"
|
|
exit-code: 1
|
|
grade: E5
|
|
""")
|
|
|
|
SYNTH_GOOD = write(os.path.join(FIX, "synth_good.report.yaml"), f"""
|
|
synthesized-by: Orchestrator
|
|
linked-reports: [{os.path.relpath(GOOD, ROOT)}, {os.path.relpath(BAD_CONF, ROOT)}]
|
|
conflicts:
|
|
- "A는 X, B는 Y로 갈림(보존)"
|
|
report-header:
|
|
bottom-line: "종합 결론"
|
|
decision-needed: {{ needed: false }}
|
|
confidence: {{ value: Med }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
""")
|
|
SYNTH_BAD = write(os.path.join(FIX, "synth_bad.report.yaml"), f"""
|
|
synthesized-by: Orchestrator
|
|
linked-reports: [{os.path.relpath(GOOD, ROOT)}]
|
|
report-header:
|
|
bottom-line: "종합 결론(이견 삭제됨)"
|
|
decision-needed: {{ needed: false }}
|
|
confidence: {{ value: Med }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
""")
|
|
|
|
URL_E2 = write(os.path.join(FIX, "url_e2.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "외부 시장자료 기반 결론"
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence:
|
|
- source-uri: https://example.com/market-report
|
|
grade: E2
|
|
""")
|
|
URL_E4 = write(os.path.join(FIX, "url_e4.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "URL을 E4로 인플레"
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence:
|
|
- source-uri: https://example.com/market-report
|
|
grade: E4
|
|
""")
|
|
|
|
print("== validate_report ==")
|
|
missing_report = run(
|
|
"validate_report.py", [os.path.join(ROOT, "does-not-exist.report.yaml")])
|
|
check("explicit nonexistent report path -> 2 (not silent no-op)",
|
|
missing_report.returncode == 2 and "존재하지" in missing_report.stderr)
|
|
check("good report -> 0", run("validate_report.py", [GOOD]).returncode == 0)
|
|
check("URL evidence @E2 -> 0 (외부 근거 인정)", run("validate_report.py", [URL_E2]).returncode == 0)
|
|
check("URL evidence @E4 -> 2 (URL은 강한근거 불가)", run("validate_report.py", [URL_E4]).returncode == 2)
|
|
check("missing BLUF -> 2", run("validate_report.py", [BAD_BLUF]).returncode == 2)
|
|
check("fake evidence -> 2", run("validate_report.py", [BAD_EVID]).returncode == 2)
|
|
check("overconfident -> 2", run("validate_report.py", [BAD_CONF]).returncode == 2)
|
|
# --- #6 receipt 기반 evidence (C5/C6): 자기신고 E5는 ledger receipt로만 검증된다 ---
|
|
import shutil as _shx
|
|
EVWS = os.path.join(FIX, "evws")
|
|
_shx.rmtree(EVWS, ignore_errors=True)
|
|
|
|
|
|
def _mk_ws(name):
|
|
ws = os.path.join(EVWS, name)
|
|
os.makedirs(os.path.join(ws, "evidence"), exist_ok=True)
|
|
return ws
|
|
|
|
|
|
def _seed_report(ws, wf, fname, text):
|
|
d = os.path.join(ws, "completion-records", wf)
|
|
os.makedirs(d, exist_ok=True)
|
|
return write(os.path.join(d, fname), text)
|
|
|
|
|
|
def _seed_ledger(ws, receipts):
|
|
with open(os.path.join(ws, "evidence", "ledger.jsonl"), "w") as f:
|
|
for r in receipts:
|
|
f.write(json.dumps(r) + "\n")
|
|
|
|
|
|
E5_CMD_BODY = """report-header:
|
|
bottom-line: "테스트 통과, 배포 가능."
|
|
decision-needed: { needed: false }
|
|
confidence: { value: High }
|
|
risks: []
|
|
evidence:
|
|
- command: "pytest -q"
|
|
exit-code: 0
|
|
grade: E5
|
|
"""
|
|
# (A) 실행 receipt 없는 자기신고 E5 -> 이제 차단 (예전 line~90 fixture는 통과했음)
|
|
_WS_NO = _mk_ws("e5_noreceipt")
|
|
_E5_NO = _seed_report(_WS_NO, "wfE", "role-x-20260101T000000Z.report.yaml", E5_CMD_BODY)
|
|
check("self-reported E5 (command+exit0, NO ledger receipt) -> 2 (semantic block)",
|
|
run("validate_report.py", [_E5_NO]).returncode == 2)
|
|
# (B) 일치하는 receipt를 <evidence_dir>/ledger.jsonl 에 시드하면 통과
|
|
_WS_OK = _mk_ws("e5_receipt")
|
|
_E5_OK = _seed_report(_WS_OK, "wfE", "role-x-20260101T000000Z.report.yaml", E5_CMD_BODY)
|
|
_seed_ledger(_WS_OK, [{"tool_use_id": "t1", "tool_name": "Bash",
|
|
"ts": "2026-07-10T00:00:00Z", "cwd": _WS_OK,
|
|
"command": "pytest -q", "exit_code": 0, "stdout_sha256": "deadbeef",
|
|
"workflow_id": "wf-test", "session_id": "fixture-session",
|
|
"agent_id": "fixture-agent", "receipt_type": "test-run",
|
|
"assertion_status": "passed"}])
|
|
check("receipt-backed E5 (matching ledger.jsonl) -> 0 (verified)",
|
|
run("validate_report.py", [_E5_OK]).returncode == 0)
|
|
# (B') ledger에 있어도 exit_code!=0 receipt는 뒷받침이 아니다 -> 차단
|
|
_WS_FAIL = _mk_ws("e5_failreceipt")
|
|
_E5_FAIL = _seed_report(_WS_FAIL, "wfE", "role-x-20260101T000000Z.report.yaml", E5_CMD_BODY)
|
|
_seed_ledger(_WS_FAIL, [{"tool_use_id": "t2", "tool_name": "Bash",
|
|
"ts": "2026-07-10T00:00:00Z", "cwd": _WS_FAIL,
|
|
"command": "pytest -q", "exit_code": 1,
|
|
"workflow_id": "wf-test", "session_id": "fixture-session",
|
|
"agent_id": "fixture-agent", "receipt_type": "test-run",
|
|
"assertion_status": "failed"}])
|
|
check("E5 with only failing receipt (exit!=0) -> 2",
|
|
run("validate_report.py", [_E5_FAIL]).returncode == 2)
|
|
# 예전 flat GOOD_E5 fixture(line~90)도 이제 차단 — 빈 워크스페이스로 결정적 확인
|
|
EMPTY_WS = os.path.join(FIX, "empty_ws")
|
|
_shx.rmtree(EMPTY_WS, ignore_errors=True)
|
|
os.makedirs(os.path.join(EMPTY_WS, "evidence"), exist_ok=True)
|
|
check("flat GOOD_E5 self-report (no receipt) -> 2 (was PASS, now BLOCK)",
|
|
run("validate_report.py", [GOOD_E5], env={"ORGOS_WORKSPACE": EMPTY_WS}).returncode == 2)
|
|
# 기존 파일(CLAUDE.md) 단순 참조만으로 E5 불가(존재 ≠ 산출 receipt)
|
|
CLAUDEMD_E5 = write(os.path.join(FIX, "claudemd_e5.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "CLAUDE.md 있으니 근거 충분(주장)"
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence:
|
|
- source-uri: CLAUDE.md
|
|
grade: E5
|
|
""")
|
|
check("plain pre-existing CLAUDE.md as E5 -> 2 (존재≠산출 receipt)",
|
|
run("validate_report.py", [CLAUDEMD_E5], env={"ORGOS_WORKSPACE": EMPTY_WS}).returncode == 2)
|
|
|
|
# --- #5 회사 문맥 상한: company-context 미채움 상태에서 회사 네임스페이스를 E3+로 인용 불가 ---
|
|
print("== #5 company-context confidence cap ==")
|
|
_CCE3 = write(os.path.join(FIX, "company_e3.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "회사 전략상 이 방향이 맞다(빈 회사문맥 인용)"
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence:
|
|
- source-uri: org-os/01-company/company-context.yaml
|
|
grade: E3
|
|
""")
|
|
check("#5 empty company-context cited as E3 -> 2 (일반론은 E1/E2 상한)",
|
|
run("validate_report.py", [_CCE3], env={"ORGOS_WORKSPACE": EMPTY_WS}).returncode == 2)
|
|
# 같은 회사 경로를 E2로 낮추면 통과(참고 근거로는 허용)
|
|
_CCE2 = write(os.path.join(FIX, "company_e2.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "회사 문맥 참고(E2)"
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence:
|
|
- source-uri: org-os/01-company/company-context.yaml
|
|
grade: E2
|
|
""")
|
|
check("#5 same company path at E2 -> 0 (참고 근거 허용)",
|
|
run("validate_report.py", [_CCE2], env={"ORGOS_WORKSPACE": EMPTY_WS}).returncode == 0)
|
|
# 회사 네임스페이스 밖(README) E3 는 영향 없음(회사문맥 규칙은 타깃된 범위만)
|
|
_NONCC = write(os.path.join(FIX, "noncompany_e3.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "일반 파일 E3 근거"
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence:
|
|
- source-uri: README.md
|
|
grade: E3
|
|
""")
|
|
check("#5 non-company E3 (README) unaffected -> 0",
|
|
run("validate_report.py", [_NONCC], env={"ORGOS_WORKSPACE": EMPTY_WS}).returncode == 0)
|
|
|
|
# --- #5 status 어휘: _company_context_populated() 는 operating(+구 populated 읽기호환)만 True ---
|
|
# 실제 SoT 파일을 건드리지 않도록 모듈 상수 VR._COMPANY_CTX 를 임시파일로 monkeypatch.
|
|
import tempfile # noqa: E402
|
|
import yaml as _yaml_vocab # noqa: E402
|
|
sys.path.insert(0, os.path.join(ROOT, ".claude", "hooks"))
|
|
import validate_report as VR # noqa: E402
|
|
_orig_ctx = VR._COMPANY_CTX
|
|
|
|
|
|
def _set_ctx(status):
|
|
fd, p = tempfile.mkstemp(suffix=".yaml")
|
|
os.close(fd)
|
|
with open(p, "w", encoding="utf-8") as fh:
|
|
_yaml_vocab.safe_dump({"schema-version": 2, "status": status,
|
|
"company": {"facts": [], "strategic-decisions": [], "hypotheses": [],
|
|
"validation-state": {"stage": "pre-traction", "validated": [], "open": [], "refuted": []}}, "projects": []},
|
|
fh, allow_unicode=True)
|
|
VR._COMPANY_CTX = p
|
|
|
|
|
|
def _restore_ctx():
|
|
VR._COMPANY_CTX = _orig_ctx
|
|
|
|
|
|
_set_ctx("operating")
|
|
check("operating -> populated True", VR._company_context_populated() is True)
|
|
_set_ctx("provisional")
|
|
check("provisional -> populated False(회사인용 상한 유지)", VR._company_context_populated() is False)
|
|
_set_ctx("template")
|
|
check("template -> populated False", VR._company_context_populated() is False)
|
|
_restore_ctx()
|
|
|
|
# --- Task 14: _is_hypothesis_company_ref — source-uri anchor 문자열만 파싱(파일 접근 없음) ---
|
|
check("HYP anchor -> hypothesis ref True",
|
|
VR._is_hypothesis_company_ref("org-os/01-company/company-context.yaml#HYP-001") is True)
|
|
check("FACT anchor -> hypothesis ref False",
|
|
VR._is_hypothesis_company_ref("org-os/01-company/company-context.yaml#FACT-001") is False)
|
|
check("no anchor -> False",
|
|
VR._is_hypothesis_company_ref("org-os/01-company/company-context.yaml") is False)
|
|
|
|
# --- Task 14 review gap: E2E 회귀(design test #10) — company-context 를 populated(operating)로
|
|
# monkeypatch 한 뒤, #HYP anchor @E3 는 상한(cap)에 걸리고 #FACT anchor 는 걸리지 않는지
|
|
# validate() 를 in-process 로 직접 호출해 diff 로 검증한다(다른 공통 에러는 양쪽에 동일하게 나오므로 차감됨).
|
|
_set_ctx("operating")
|
|
|
|
|
|
def _rep_anchor(anchor):
|
|
return {
|
|
"report-type": "work",
|
|
"report-header": {
|
|
"bottom-line": "x",
|
|
"decision-needed": {"needed": False},
|
|
"confidence": {"value": "Med"},
|
|
"risks": [],
|
|
"evidence": [{"source-uri": f"org-os/01-company/company-context.yaml#{anchor}", "grade": "E3"}],
|
|
},
|
|
}
|
|
|
|
|
|
_errs_hyp = VR.validate(_rep_anchor("HYP-001"))
|
|
_errs_fact = VR.validate(_rep_anchor("FACT-001"))
|
|
_extra = [e for e in _errs_hyp if e not in _errs_fact]
|
|
check("E2E: #HYP @E3 under operating -> capped (extra error vs #FACT)", len(_extra) >= 1)
|
|
check("E2E: the extra error is the company-context hypothesis cap",
|
|
any("hypothesis 기반" in e for e in _extra))
|
|
_restore_ctx()
|
|
|
|
check("grade inflation (E5, exit!=0) -> 2", run("validate_report.py", [BAD_INFLATE]).returncode == 2)
|
|
check("synthesis WITH conflicts -> 0", run("validate_report.py", [SYNTH_GOOD]).returncode == 0)
|
|
check("synthesis WITHOUT conflicts -> 2 (dissent 보존 강제)", run("validate_report.py", [SYNTH_BAD]).returncode == 2)
|
|
# 워커가 출처로 linked-reports를 달아도 (synthesized-by 없으면) 종합으로 오인 금지
|
|
WORKER_LINKED = write(os.path.join(FIX, "worker_linked.report.yaml"), f"""
|
|
role-id: TST-GENERIC-FIXTURE
|
|
linked-reports: [a.report.yaml, b.report.yaml]
|
|
report-header:
|
|
bottom-line: "워커 판정(출처 인용)"
|
|
decision-needed: {{ needed: false }}
|
|
confidence: {{ value: Med }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
""")
|
|
check("worker citing linked-reports (no synthesized-by) -> 0", run("validate_report.py", [WORKER_LINKED]).returncode == 0)
|
|
|
|
# --- 종합 dissent/linked 강화: 존재하지 않는 링크·conflicts:null 차단 ---
|
|
SYNTH_BADLINK = write(os.path.join(FIX, "synth_badlink.report.yaml"), f"""
|
|
synthesized-by: Orchestrator
|
|
linked-reports: [does/not/exist-a.report.yaml, does/not/exist-b.report.yaml]
|
|
conflicts:
|
|
- "이견 보존됨"
|
|
report-header:
|
|
bottom-line: "종합인데 링크가 허위"
|
|
decision-needed: {{ needed: false }}
|
|
confidence: {{ value: Med }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
""")
|
|
check("synthesis with nonexistent linked-report -> 2 (허위 종합 차단)",
|
|
run("validate_report.py", [SYNTH_BADLINK]).returncode == 2)
|
|
SYNTH_NULLCONF = write(os.path.join(FIX, "synth_nullconf.report.yaml"), f"""
|
|
synthesized-by: Orchestrator
|
|
linked-reports: [{os.path.relpath(GOOD, ROOT)}]
|
|
conflicts: null
|
|
report-header:
|
|
bottom-line: "이견을 null로 뭉갬"
|
|
decision-needed: {{ needed: false }}
|
|
confidence: {{ value: Med }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
""")
|
|
check("synthesis conflicts: null -> 2 (dissent 보존 미증명; null != [])",
|
|
run("validate_report.py", [SYNTH_NULLCONF]).returncode == 2)
|
|
|
|
# --- role-id 정합: 미등록/소문자 role은 lens 판별 불가 -> 차단 ---
|
|
LOWROLE = write(os.path.join(FIX, "lowrole.report.yaml"), f"""
|
|
role-id: eng-backend
|
|
report-header:
|
|
bottom-line: "소문자/미등록 role"
|
|
decision-needed: {{ needed: false }}
|
|
confidence: {{ value: Med }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
""")
|
|
check("unknown/lowercase role-id (lens=0, 판별 불가) -> 2",
|
|
run("validate_report.py", [LOWROLE]).returncode == 2)
|
|
# (등록 role 통과는 아래 LOWREG=arch-solution 로 확인. 일반 fixture 는 TST-GENERIC-FIXTURE 로 계약 강제 격리.)
|
|
|
|
# --- P2: 등록 role의 소문자(agent-card 이름) 표기도 accept — 대소문자 무관 등록 매칭 ---
|
|
# 근거: context_package.target-role-agent 는 소문자 카드명(arch-solution.md)을, validate_report 는
|
|
# 등록 role-id 를 요구한다. 두 축이 같은 역할의 다른 표기라 case 만 달라도 거부되면 매 fan-out spawn 이
|
|
# 오탐 거부된다(실측: /design 라운드). registered set 대조를 대소문자 무관으로 하되 '미등록'은 그대로 차단.
|
|
LOWREG = write(os.path.join(FIX, "lowreg_registered.report.yaml"), f"""
|
|
report-type: work
|
|
role-id: arch-solution
|
|
tier: light
|
|
work-summary: "등록된 ARCH-SOLUTION 의 소문자(카드명) 표기"
|
|
report-header:
|
|
bottom-line: "등록된 role 의 소문자 표기(카드명)는 유효해야 한다"
|
|
decision-needed: {{ needed: false }}
|
|
confidence: {{ value: Med, derived-from: evidence }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
""")
|
|
check("lowercase form of a REGISTERED role (arch-solution -> ARCH-SOLUTION) -> 0 (대소문자 무관)",
|
|
run("validate_report.py", [LOWREG]).returncode == 0)
|
|
|
|
# --- report-type 판별자 + JSON Schema 유형별 필수필드 ---
|
|
BLOCKED_OK = write(os.path.join(FIX, "blocked_ok.report.yaml"), f"""
|
|
report-type: blocked
|
|
blocker: "외부 API 키 부재 — 사람 승인 필요"
|
|
resume-condition: "유효한 API 키 receipt 확보"
|
|
report-header:
|
|
bottom-line: "블로커로 중단"
|
|
decision-needed: {{ needed: true, approver: HUMAN-001 }}
|
|
confidence: {{ value: Low }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E2
|
|
""")
|
|
check("report-type=blocked WITH blocker+resume-condition -> 0 (schema/gate aligned)",
|
|
run("validate_report.py", [BLOCKED_OK]).returncode == 0)
|
|
BLOCKED_BAD = write(os.path.join(FIX, "blocked_bad.report.yaml"), f"""
|
|
report-type: blocked
|
|
report-header:
|
|
bottom-line: "blocker 필드 누락"
|
|
decision-needed: {{ needed: true, approver: HUMAN-001 }}
|
|
confidence: {{ value: Low }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E2
|
|
""")
|
|
check("report-type=blocked WITHOUT blocker -> 2 (type schema 필수필드)",
|
|
run("validate_report.py", [BLOCKED_BAD]).returncode == 2)
|
|
UNKNOWN_TYPE = write(os.path.join(FIX, "unknown_type.report.yaml"), f"""
|
|
report-type: banana
|
|
report-header:
|
|
bottom-line: "미지 유형은 이제 거부된다(P0-5 위장/오타 차단)"
|
|
decision-needed: {{ needed: false }}
|
|
confidence: {{ value: Med }}
|
|
risks: []
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
""")
|
|
# finding P0-5: 미지/오타 report-type 은 이제 거부한다(typed-schema 우회·위장 차단).
|
|
check("unknown report-type -> BLOCK (P0-5)",
|
|
run("validate_report.py", [UNKNOWN_TYPE]).returncode == 2)
|
|
|
|
# --- render_report 게이트: 검증 실패 보고서는 대표용 MD로 렌더 거부(nonzero) ---
|
|
RG_BAD = write(os.path.join(FIX, "render_gate_bad.report.yaml"), """
|
|
report-header:
|
|
bottom-line: ""
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence: []
|
|
""")
|
|
_RG_OUT = os.path.join(FIX, "rg_bad.md")
|
|
if os.path.exists(_RG_OUT):
|
|
os.remove(_RG_OUT)
|
|
_rg = run("render_report.py", [RG_BAD, "--type", "work", "--out", _RG_OUT])
|
|
check("render_report REFUSES invalid report (nonzero)", _rg.returncode != 0)
|
|
check("render_report gate produced no MD for invalid report", not os.path.exists(_RG_OUT))
|
|
|
|
print("== guard_tools ==")
|
|
def gt(tool, ti):
|
|
return run("guard_tools.py", stdin=json.dumps({"tool_name": tool, "tool_input": ti})).returncode
|
|
check("Read -> 0", gt("Read", {"file_path": "x"}) == 0)
|
|
check("Bash pytest -> 0", gt("Bash", {"command": "pytest -q"}) == 0)
|
|
check("gh pr create -> 2", gt("Bash", {"command": "gh pr create --title x"}) == 2)
|
|
check("git push -> 2", gt("Bash", {"command": "git push origin main"}) == 2)
|
|
check("cat .env -> 2", gt("Bash", {"command": "cat .env"}) == 2)
|
|
check("rm -rf -> 2", gt("Bash", {"command": "rm -rf build/"}) == 2)
|
|
check("kubectl -> 2", gt("Bash", {"command": "kubectl apply -f d.yaml"}) == 2)
|
|
check("write .env -> 2", gt("Write", {"file_path": "config/.env"}) == 2)
|
|
check("write normal -> 0", gt("Write", {"file_path": "org-os/x.yaml"}) == 0)
|
|
|
|
print("== stop_validate (via CLAUDE_REPORT_PATH) ==")
|
|
check("good -> 0", run("stop_validate.py", stdin="{}", env={"CLAUDE_REPORT_PATH": GOOD}).returncode == 0)
|
|
check("bad -> 2", run("stop_validate.py", stdin="{}", env={"CLAUDE_REPORT_PATH": BAD_BLUF}).returncode == 2)
|
|
check("no report -> 0", run("stop_validate.py", stdin="{}", env={"CLAUDE_REPORT_PATH": "/nonexistent"}).returncode == 0)
|
|
|
|
print("== gen_agents ==")
|
|
r = run("gen_agents.py", ["--check"])
|
|
check("gen_agents --check -> 0 (75 concrete agents)", r.returncode == 0 and "75 concrete agents" in r.stdout)
|
|
check("gen_agents emits concrete roles and no family cards",
|
|
"19 collapse workers" in r.stdout and "family metadata cards=0" in r.stdout)
|
|
|
|
print("== notify_slack (redact + format + outbox) ==")
|
|
import glob as _glob
|
|
NTMP = os.path.join(FIX, "notifytest")
|
|
os.makedirs(NTMP, exist_ok=True)
|
|
SEC = write(os.path.join(FIX, "sec.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "가격 승인 요청"
|
|
decision-needed: { needed: true, approver: HUMAN-001 }
|
|
confidence: { value: Med }
|
|
risks: ["담당자 theorose49@gmail.com, 토큰 xoxb-1-secretval 노출 주의"]
|
|
evidence: []
|
|
""")
|
|
_env = dict(os.environ); _env["CLAUDE_PROJECT_DIR"] = NTMP; _env["ORGOS_WORKSPACE"] = "test-labs-documents"
|
|
_r = subprocess.run([PY, os.path.join(HOOKS, "notify_slack.py"), "human-review", SEC, "--title", "가격 승인"],
|
|
capture_output=True, text=True, env=_env)
|
|
_out = _r.stdout
|
|
check("notify emits :raising_hand:", ":raising_hand:" in _out)
|
|
check("notify has BLUF section", "핵심(BLUF)" in _out)
|
|
check("notify redacts email", "@gmail" not in _out)
|
|
check("notify redacts token", "xoxb-1-secretval" not in _out)
|
|
check("notify enqueues to outbox", len(_glob.glob(os.path.join(NTMP, "test-labs-documents/slack-outbox/*.json"))) >= 1)
|
|
|
|
# agent-report 템플릿 (템플릿 3): 직무정체성·BLUF·결정필요·동료 cc
|
|
REP = write(os.path.join(FIX, "agentrep.report.yaml"), f"""
|
|
role-id: TST-GENERIC-FIXTURE
|
|
role-name: Pricing Strategist AI
|
|
lens: LENS-FINANCE
|
|
tags: [doc-mgmt-app, monetization]
|
|
report-header:
|
|
bottom-line: "가격 3-tier 개편 권고"
|
|
decision-needed: {{ needed: true, approver: FAM-CFO }}
|
|
confidence: {{ value: Med }}
|
|
risks: ["엔터프라이즈 연간계약군 반발 가능"]
|
|
evidence:
|
|
- source-uri: {EV_REL}
|
|
grade: E3
|
|
findings: ["가격탄력성 회귀 -0.6", "몬테카를로 5k회"]
|
|
""")
|
|
_rr = subprocess.run([PY, os.path.join(HOOKS, "notify_slack.py"), "report", REP, "--title", "가격 개편"],
|
|
capture_output=True, text=True, env=_env)
|
|
_ro = _rr.stdout
|
|
check("report emits :round_pushpin:", ":round_pushpin:" in _ro)
|
|
check("report shows role+lens identity", "role: Pricing Strategist AI" in _ro and "lens: LENS-FINANCE" in _ro)
|
|
check("report has BLUF + evidence grade", "*BLUF*" in _ro and "E3" in _ro)
|
|
check("report shows approver (DACI)", "승인자: *FAM-CFO*" in _ro)
|
|
check("report cc's peer tags", "cc" in _ro and "#monetization" in _ro)
|
|
|
|
print("== collaboration-default classification ==")
|
|
import yaml as _yaml
|
|
_fams = _yaml.safe_load(open(os.path.join(ROOT, "org-os/00-role-registry/capability-families.yaml")))["capability-families"]["families"]
|
|
_cd = [f.get("collaboration-default") for f in _fams]
|
|
_byid = {f["family-id"]: f for f in _fams}
|
|
check("28 families each have valid collaboration-default", len(_cd) == 28 and all(c in ("fan-out", "collapse", "n/a") for c in _cd))
|
|
check("fan-out=21", _cd.count("fan-out") == 21)
|
|
check("collapse=6", _cd.count("collapse") == 6)
|
|
check("n/a=1 (ORCH)", _cd.count("n/a") == 1 and _byid["FAM-ORCH"]["collaboration-default"] == "n/a")
|
|
check("FAM-ENG-BACKEND collapse", _byid["FAM-ENG-BACKEND"]["collaboration-default"] == "collapse")
|
|
check("FAM-UX-RESEARCH fan-out", _byid["FAM-UX-RESEARCH"]["collaboration-default"] == "fan-out")
|
|
check("FAM-GTM-SALES fan-out", _byid["FAM-GTM-SALES"]["collaboration-default"] == "fan-out")
|
|
|
|
print("== collaboration-map ==")
|
|
_cm = _yaml.safe_load(open(os.path.join(ROOT, "org-os/06-agent-work/collaboration-map.yaml")))["collaboration-map"]
|
|
_famids = set(_byid.keys())
|
|
def _refs(node, acc):
|
|
if isinstance(node, str):
|
|
if node.startswith("FAM-") and all(c not in node for c in "*/ "):
|
|
acc.add(node)
|
|
elif isinstance(node, list):
|
|
for x in node:
|
|
_refs(x, acc)
|
|
elif isinstance(node, dict):
|
|
for x in node.values():
|
|
_refs(x, acc)
|
|
return acc
|
|
_missing = _refs(_cm, set()) - _famids
|
|
check("collaboration-map family-ids all exist", not _missing)
|
|
_edges = _cm["cross-group-edges"]["edges"]
|
|
check("6 cross-group edges", len(_edges) == 6)
|
|
check("edges bidirectional (a,b,a-to-b,b-to-a)", all(e.get("a") and e.get("b") and e.get("a-to-b") and e.get("b-to-a") for e in _edges))
|
|
check("cascade has DECIDE/DESIGN/BUILD", {p["phase"] for p in _cm["cascade-phases"]} >= {"DECIDE", "DESIGN", "BUILD"})
|
|
|
|
print("== render_report (YAML -> MD) ==")
|
|
RMD = os.path.join(FIX, "render_out.md")
|
|
_r = run("render_report.py", [GOOD, "--type", "decision", "--title", "T", "--out", RMD])
|
|
check("render single -> 0", _r.returncode == 0)
|
|
_md = open(RMD).read() if os.path.exists(RMD) else ""
|
|
check("md has BLUF 결론", "결론" in _md and "가격 A안" in _md)
|
|
check("md has 근거 table + grade", "## 📎 근거" in _md and "E3" in _md)
|
|
# #13: render_report 는 배지를 코드에 하드코딩하지 않고 report-templates.yaml(SSOT)에서 읽는다.
|
|
import importlib as _il # noqa: E402
|
|
import yaml as _yaml # noqa: E402
|
|
sys.path.insert(0, os.path.join(ROOT, ".claude", "hooks"))
|
|
_RR = _il.import_module("render_report")
|
|
_rbraw = _yaml.safe_load(open(os.path.join(ROOT, "org-os/06-agent-work/report-templates.yaml")))
|
|
_rb = _rbraw["report-templates"]["human-md-rendering"]["render-badges"]
|
|
check("#13 render_report consumes report-templates.yaml render-badges (not hardcoded)",
|
|
all(tuple(map(str, v)) == _RR.TYPE_BADGE.get(k) for k, v in _rb.items()) and "spec" in _RR.TYPE_BADGE)
|
|
MA = write(os.path.join(FIX, "mem_a.report.yaml"), """
|
|
role-name: CFO
|
|
lens: LENS-FINANCE
|
|
report-header:
|
|
bottom-line: "재무 관점 결론 A"
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence: []
|
|
findings:
|
|
- "재무발견_ALPHA 단위마진 하한 필요"
|
|
next-actions:
|
|
- "COGS 연동 단가 시뮬레이션"
|
|
""")
|
|
MB = write(os.path.join(FIX, "mem_b.report.yaml"), """
|
|
role-name: CPO
|
|
report-header:
|
|
bottom-line: "제품 관점 결론 B"
|
|
decision-needed: { needed: false }
|
|
confidence: { value: High }
|
|
risks: []
|
|
evidence: []
|
|
findings:
|
|
- "제품발견_BETA activation 문턱"
|
|
""")
|
|
RMD2 = os.path.join(FIX, "render_fanout.md")
|
|
run("render_report.py", [GOOD, "--type", "decision", "--members", MA, MB, "--out", RMD2])
|
|
_md2 = open(RMD2).read() if os.path.exists(RMD2) else ""
|
|
check("md has 역할별 요약 table", "## 👥 역할별 핵심 결론" in _md2)
|
|
check("role table has both members", "| CFO |" in _md2 and "| CPO |" in _md2)
|
|
check("member bottom-lines preserved (no summary loss)", "재무 관점 결론 A" in _md2 and "제품 관점 결론 B" in _md2)
|
|
check("per-role 상세 section present", "## 📋 역할별 상세" in _md2)
|
|
check("member findings embedded (not just linked)", "재무발견_ALPHA" in _md2 and "제품발견_BETA" in _md2)
|
|
check("member body sections rendered", "🔎 핵심 발견" in _md2 and "➡️ 다음 액션" in _md2)
|
|
# 단일 보고서도 자체 본문(findings)을 embed
|
|
SGL = write(os.path.join(FIX, "single_body.report.yaml"), """
|
|
report-header:
|
|
bottom-line: "단일 결론"
|
|
decision-needed: { needed: false }
|
|
confidence: { value: Med }
|
|
risks: []
|
|
evidence:
|
|
- source-uri: .claude/tests/fixtures/evidence.log
|
|
grade: E3
|
|
findings:
|
|
- "단일발견_GAMMA"
|
|
""")
|
|
RMD3 = os.path.join(FIX, "render_single_body.md")
|
|
run("render_report.py", [SGL, "--type", "completion", "--out", RMD3])
|
|
_md3 = open(RMD3).read() if os.path.exists(RMD3) else ""
|
|
check("single report embeds its own body", "단일발견_GAMMA" in _md3 and "🔎 핵심 발견" in _md3)
|
|
|
|
print("== agent split: per-role fan-out workers vs collapse family ==")
|
|
_AG = os.path.join(ROOT, ".claude/agents")
|
|
check("75 concrete agent files", len(_glob.glob(os.path.join(_AG, "*.md"))) == 75)
|
|
check("family agent cards are absent", _glob.glob(os.path.join(_AG, "fam-*.md")) == [])
|
|
# collapse family = planner metadata pool + selected concrete worker, never an integrated identity.
|
|
check("eng-be is executable collapse concrete role",
|
|
"collaboration-role: collapse-primary-candidate" in open(os.path.join(_AG, "eng-be.md")).read())
|
|
# fan-out family도 concrete role worker만 노출한다.
|
|
check("ux-researcher.md exists (role worker)", os.path.exists(os.path.join(_AG, "ux-researcher.md")))
|
|
check("data-analyst.md exists (role worker)", os.path.exists(os.path.join(_AG, "data-analyst.md")))
|
|
_uxr = open(os.path.join(_AG, "ux-researcher.md")).read()
|
|
check("ux-researcher carries fan-out-worker contract", "collaboration-role: fan-out-worker" in _uxr and "## Fan-out 워커 계약" in _uxr)
|
|
check("ux-researcher has own role perspective", "## 나의 관점·시야·책임" in _uxr and "관점:" in _uxr)
|
|
check("ux-researcher does not self-synthesize", "종합·최종결정은 내가 하지 않는다" in _uxr)
|
|
# P3: working-method(방법론·근거·출처)는 method-skill로 분리. 카드=spine+skills 참조, 전체=skill 파일.
|
|
check("ux-researcher card has method spine + skill ref (not full embed)",
|
|
"## 핵심 작업 방법" in _uxr and "ux-researcher-method" in _uxr and "## 일하는 방식" not in _uxr)
|
|
_uxr_skill = open(os.path.join(ROOT, ".claude/skills/ux-researcher-method/SKILL.md")).read()
|
|
# P3-B: v1 render("주요 프레임워크"/"판단 근거 자료") 또는 v2 provenance 꼬리("프레임워크 계보"/"근거 종류")
|
|
# 둘 중 하나로 프레임워크·근거 provenance 를 보존한다(역할이 v2 계약으로 승격돼도 계보 유지).
|
|
check("method-skill cites frameworks + evidence",
|
|
("주요 프레임워크" in _uxr_skill or "프레임워크 계보" in _uxr_skill)
|
|
and ("판단 근거 자료" in _uxr_skill or "근거 종류" in _uxr_skill))
|
|
check("method-skill cites web sources (http)", "참고 출처" in _uxr_skill and "http" in _uxr_skill)
|
|
_rwm_idx = _yaml.safe_load(open(os.path.join(ROOT, "org-os/00-role-registry/role-working-methods/index.yaml")))["role-method-contracts"]
|
|
_rwm_merged = {}
|
|
for _inc in _rwm_idx["includes"]:
|
|
_rwm_merged.update((_yaml.safe_load(open(os.path.join(
|
|
ROOT, "org-os/00-role-registry/role-working-methods", _inc))) or {}).get("role-working-methods") or {})
|
|
check("role-working-methods covers 75 roles (파일분리 병합)", len(_rwm_merged) == 75)
|
|
_be2 = open(os.path.join(_AG, "eng-be.md")).read()
|
|
check("collapse concrete worker loads only its own method-skill",
|
|
"eng-be-method" in _be2 and "eng-begen-method" not in _be2 and "## 일하는 방식" not in _be2)
|
|
|
|
print("== immutable reports (guard + new_report + index) ==")
|
|
import shutil as _sh
|
|
IMWS = os.path.join(FIX, "imws") # 격리 워크스페이스(실제 프로젝트 오염 방지)
|
|
_imenv = {"ORGOS_WORKSPACE": IMWS}
|
|
IMDIR = os.path.join(IMWS, "completion-records", "wf-test-immutable")
|
|
os.makedirs(IMDIR, exist_ok=True)
|
|
IMREP = os.path.join(IMDIR, "role-x-20260101T000000Z.report.yaml")
|
|
write(IMREP, "report-id: x\ncreated-at: 2026-01-01T00:00:00Z\nreport-header: { bottom-line: t, decision-needed: { needed: false }, confidence: { value: Med }, risks: [], evidence: [] }\n")
|
|
check("guard blocks overwrite of existing report", gt("Write", {"file_path": IMREP}) == 2)
|
|
check("guard blocks Edit of existing report", gt("Edit", {"file_path": IMREP}) == 2)
|
|
check("guard allows NEW report path", gt("Write", {"file_path": os.path.join(IMDIR, "role-y-20990101T000000Z.report.yaml")}) == 0)
|
|
_r1 = run("new_report.py", ["--workflow", "wf-test-immutable", "--role", "role-mint", "--stub"], env=_imenv).stdout.strip()
|
|
_r2 = run("new_report.py", ["--workflow", "wf-test-immutable", "--role", "role-mint", "--stub"], env=_imenv).stdout.strip()
|
|
check("new_report mints under completion-records/<wf>/", "completion-records/wf-test-immutable/role-mint" in _r1)
|
|
check("new_report never collides (2 calls -> 2 files)", _r1 != _r2)
|
|
run("render_report.py", ["--index"], env=_imenv)
|
|
_idx = open(os.path.join(IMWS, "reports", "INDEX.md")).read()
|
|
check("INDEX is append-only + workflow-grouped", "append-only" in _idx and "created-at" in _idx and "## wf-test-immutable" in _idx)
|
|
_sh.rmtree(IMWS, ignore_errors=True)
|
|
|
|
print("== token budget (#1) + lens cap (#2) ==")
|
|
TOKPROJ = os.path.join(FIX, "tokproj")
|
|
os.makedirs(TOKPROJ, exist_ok=True)
|
|
_tlenv = {"CLAUDE_PROJECT_DIR": TOKPROJ, "ORGOS_WORKSPACE": "test-labs-documents"} # 실제 원장과 격리(기본 예산 fallback: standard=500k)
|
|
run("token_ledger.py", ["log", "--workflow", "wfx", "--role", "r1", "--tokens", "100000", "--tier", "standard"], env=_tlenv)
|
|
check("token check under budget -> 0", run("token_ledger.py", ["check", "--workflow", "wfx", "--tier", "standard"], env=_tlenv).returncode == 0)
|
|
check("token check over budget (+450k) -> 2", run("token_ledger.py", ["check", "--workflow", "wfx", "--tier", "standard", "--add", "450000"], env=_tlenv).returncode == 2)
|
|
run("token_ledger.py", ["dashboard"], env=_tlenv)
|
|
check("dashboard renders TOKENS.md", os.path.exists(os.path.join(TOKPROJ, "test-labs-documents/reports/TOKENS.md")))
|
|
# #19: 예산은 per-wave. 여러 정상 wave가 쌓여도(합 > 예산) 각 wave check는 통과해야 한다.
|
|
run("token_ledger.py", ["log", "--workflow", "wfw", "--role", "r1", "--tokens", "300000", "--tier", "standard", "--wave", "1"], env=_tlenv)
|
|
run("token_ledger.py", ["log", "--workflow", "wfw", "--role", "r2", "--tokens", "300000", "--tier", "standard", "--wave", "2"], env=_tlenv)
|
|
check("#19 per-wave: wave1 under budget -> 0", run("token_ledger.py", ["check", "--workflow", "wfw", "--tier", "standard", "--wave", "1"], env=_tlenv).returncode == 0)
|
|
check("#19 per-wave: wave2 under budget -> 0 (no cross-wave overage)", run("token_ledger.py", ["check", "--workflow", "wfw", "--tier", "standard", "--wave", "2"], env=_tlenv).returncode == 0)
|
|
check("#19 per-wave: single wave over budget still -> 2", run("token_ledger.py", ["check", "--workflow", "wfw", "--tier", "standard", "--wave", "1", "--add", "300000"], env=_tlenv).returncode == 2)
|
|
check("#19 per-wave: no --wave sums only '-' bucket (no bleed from wave1/2) -> 0", run("token_ledger.py", ["check", "--workflow", "wfw", "--tier", "standard"], env=_tlenv).returncode == 0)
|
|
_sh.rmtree(TOKPROJ, ignore_errors=True)
|
|
check("lens_cap standard distinct-subspecialty same-lens -> 0", run("lens_cap.py", ["--tier", "standard", "--roles", "SEC-ENGINEER,SEC-APPSEC"]).returncode == 0) # #12: 같은 LENS-SECURITY라도 security-eng vs appsec는 다른 전문분야(삭제 금지)
|
|
check("lens_cap standard true-duplicate (same role x2) -> 2", run("lens_cap.py", ["--tier", "standard", "--roles", "SEC-ENGINEER,SEC-ENGINEER"]).returncode == 2) # 진짜 중복(동일 sub-specialty)은 여전히 차단
|
|
check("lens_cap heavy dup-lens -> 0", run("lens_cap.py", ["--tier", "heavy", "--roles", "SEC-ENGINEER,SEC-APPSEC"]).returncode == 0)
|
|
check("lens_cap standard distinct-lens -> 0", run("lens_cap.py", ["--tier", "standard", "--roles", "PROD-PM,UX-RESEARCHER"]).returncode == 0)
|
|
|
|
print("== report_tags (peer discovery) ==")
|
|
TAGPROJ = os.path.join(FIX, "tagproj")
|
|
os.makedirs(os.path.join(TAGPROJ, "test-labs-documents/completion-records/wfT"), exist_ok=True)
|
|
write(os.path.join(TAGPROJ, "test-labs-documents/completion-records/wfT/r.report.yaml"),
|
|
"role-id: PROD-PM\ntags: [topicX, product]\nreport-header: { bottom-line: hi, decision-needed: { needed: false }, confidence: { value: Med }, risks: [], evidence: [] }\n")
|
|
_tg = run("report_tags.py", ["--tag", "topicX"], env={"CLAUDE_PROJECT_DIR": TAGPROJ, "ORGOS_WORKSPACE": "test-labs-documents"})
|
|
check("report_tags finds tagged peer report", "topicX" in _tg.stdout and "PROD-PM" in _tg.stdout)
|
|
_sh.rmtree(TAGPROJ, ignore_errors=True)
|
|
|
|
print("== slack_inbox (pre-work read) ==")
|
|
IBPROJ = os.path.join(FIX, "ibproj")
|
|
os.makedirs(IBPROJ, exist_ok=True)
|
|
_ib = run("slack_inbox.py", ["--workflow", "wfI"], stdin='{"messages":[{"user":"CEO","text":"좁은 ICP로","ts":"1783440000"}]}', env={"CLAUDE_PROJECT_DIR": IBPROJ, "ORGOS_WORKSPACE": "test-labs-documents"})
|
|
check("slack_inbox writes inbox md", os.path.exists(os.path.join(IBPROJ, "test-labs-documents/slack-inbox/wfI.md")))
|
|
_sh.rmtree(IBPROJ, ignore_errors=True)
|
|
|
|
print("== consulting layer (FAM-CONSULTING / LENS-ADVISORY / render) ==")
|
|
_ga = run("gen_agents.py", ["--check"])
|
|
check("gen_agents --check ok (75 concrete agents)", _ga.returncode == 0 and "75 concrete agents" in _ga.stdout)
|
|
check("consult-em generated as synthesis-lead",
|
|
os.path.exists(os.path.join(_AG, "consult-em.md")) and
|
|
"synthesis-lead" in open(os.path.join(_AG, "consult-em.md")).read())
|
|
check("doc-lead generated as synthesis-lead (FAM-DOC-CONSULT)",
|
|
os.path.exists(os.path.join(_AG, "doc-lead.md")) and
|
|
"synthesis-lead" in open(os.path.join(_AG, "doc-lead.md")).read())
|
|
check("doc-visual generated as fan-out-worker",
|
|
os.path.exists(os.path.join(_AG, "doc-visual.md")) and
|
|
"fan-out-worker" in open(os.path.join(_AG, "doc-visual.md")).read())
|
|
check("consult-strat generated as fan-out-worker",
|
|
os.path.exists(os.path.join(_AG, "consult-strat.md")) and
|
|
"fan-out-worker" in open(os.path.join(_AG, "consult-strat.md")).read())
|
|
_lens = _yaml.safe_load(open(os.path.join(ROOT, "org-os/00-role-registry/lens-registry.yaml")))["lens-registry"]["lenses"]
|
|
_lids = [l["lens-id"] for l in _lens]
|
|
check("lens-registry has 12 lenses", len(_lids) == 12)
|
|
check("LENS-ADVISORY present", "LENS-ADVISORY" in _lids)
|
|
_fams = _yaml.safe_load(open(os.path.join(ROOT, "org-os/00-role-registry/capability-families.yaml")))["capability-families"]["families"]
|
|
_fc = [f for f in _fams if f["family-id"] == "FAM-CONSULTING"]
|
|
check("FAM-CONSULTING lead=CONSULT-EM + 6 members",
|
|
len(_fc) == 1 and _fc[0].get("lead-role-id") == "CONSULT-EM" and len(_fc[0]["member-role-ids"]) == 6)
|
|
_ex = run("consult_exhibits.py")
|
|
check("consult_exhibits 7 signature types", _ex.returncode == 0 and "7 types" in _ex.stdout)
|
|
|
|
print("== render_consult (storyline -> 문서 + 덱) ==")
|
|
CDIR = os.path.join(FIX, "consult")
|
|
os.makedirs(CDIR, exist_ok=True)
|
|
_crep = write(os.path.join(CDIR, "syn.report.yaml"),
|
|
"synthesized-by: CONSULT-EM\ntitle: T\n"
|
|
"report-header:\n bottom-line: 결론 한 줄.\n decision-needed: { needed: true, approver: HUMAN-001 }\n"
|
|
" confidence: { value: Med, derived-from: evidence }\n risks: [r1]\n"
|
|
" evidence:\n - { source-uri: org-os/00-role-registry/capability-families.yaml, grade: E3 }\n"
|
|
"storyline:\n title: 렌더테스트\n client: ca-tmpl\n"
|
|
" scqa: { situation: s, complication: c, question: q, answer: a }\n"
|
|
" slides:\n"
|
|
" - action-title: 준수도는 4개 레버로 상승한다\n"
|
|
" exhibit: { type: waterfall, unit: '%', start: ['현재',35], deltas: [['역전',18]], end: ['목표',72] }\n"
|
|
" body: [b1]\n evidence: [E3]\n"
|
|
"conflicts: [ '[속도 vs 완결성] 이견 보존' ]\n"
|
|
"linked-reports: [ org-os/00-role-registry/capability-families.yaml ]\n")
|
|
_rc = run("render_consult.py", [_crep, "--outdir", os.path.join(CDIR, "out")])
|
|
_docmd = os.path.join(CDIR, "out", "렌더테스트-report.md")
|
|
_deckhtml = os.path.join(CDIR, "out", "렌더테스트-deck.html")
|
|
check("render_consult exit 0", _rc.returncode == 0)
|
|
check("document.md carries action-title (horizontal logic)",
|
|
os.path.exists(_docmd) and "준수도는 4개 레버로 상승한다" in open(_docmd).read())
|
|
check("offline HTML deck embeds inline <svg>",
|
|
os.path.exists(_deckhtml) and "<svg" in open(_deckhtml).read())
|
|
check("exhibit SVG written to img/",
|
|
os.path.isdir(os.path.join(CDIR, "out", "img")) and
|
|
any(f.endswith(".svg") for f in os.listdir(os.path.join(CDIR, "out", "img"))))
|
|
check("EM synthesis(with conflicts+linked) validates", run("validate_report.py", [_crep]).returncode == 0)
|
|
_bad = write(os.path.join(CDIR, "noconf.report.yaml"),
|
|
open(_crep).read().replace("conflicts: [ '[속도 vs 완결성] 이견 보존' ]\n", ""))
|
|
check("synthesis missing conflicts -> blocked(2)", run("validate_report.py", [_bad]).returncode == 2)
|
|
# mermaid exhibit(실제 diagram-as-code) 경로 — mmdc 없이도 폴백 SVG로 파이프라인 유지
|
|
_mrep = write(os.path.join(CDIR, "merm.report.yaml"),
|
|
"synthesized-by: DOC-LEAD\ntitle: M\n"
|
|
"report-header:\n bottom-line: mermaid.\n decision-needed: { needed: false }\n"
|
|
" confidence: { value: Med, derived-from: evidence }\n risks: []\n"
|
|
" evidence:\n - { source-uri: org-os/00-role-registry/lens-registry.yaml, grade: E3 }\n"
|
|
"storyline:\n title: MermaidRender\n slides:\n"
|
|
" - action-title: 흐름을 mermaid로 실제 렌더한다\n"
|
|
" exhibit: { type: mermaid, code: \"flowchart TB\\n A --> B\\n B --> C\" }\n"
|
|
" body: [b]\n")
|
|
_rm = run("render_consult.py", [_mrep, "--outdir", os.path.join(CDIR, "mout")],
|
|
env={"RENDER_CONSULT_NO_MMDC": "1"})
|
|
_mhtml = os.path.join(CDIR, "mout", "mermaidrender-deck.html")
|
|
_mimg = os.path.join(CDIR, "mout", "img")
|
|
check("mermaid exhibit routes + produces svg (fallback)",
|
|
_rm.returncode == 0 and os.path.isdir(_mimg) and
|
|
any(f.endswith(".svg") for f in os.listdir(_mimg)) and
|
|
os.path.exists(_mhtml) and "<svg" in open(_mhtml).read())
|
|
# D2 exhibit(1급 diagram-as-code) 경로 — d2 없이도 폴백 SVG로 파이프라인 유지(라우팅 검증)
|
|
_drep = write(os.path.join(CDIR, "d2.report.yaml"),
|
|
"synthesized-by: DOC-LEAD\ntitle: D\n"
|
|
"report-header:\n bottom-line: d2.\n decision-needed: { needed: false }\n"
|
|
" confidence: { value: Med, derived-from: evidence }\n risks: []\n"
|
|
" evidence:\n - { source-uri: org-os/00-role-registry/lens-registry.yaml, grade: E3 }\n"
|
|
"storyline:\n title: D2Render\n slides:\n"
|
|
" - action-title: 계층 의존성을 d2로 실제 렌더한다\n"
|
|
" exhibit: { type: d2, code: \"direction: right\\n a -> b: dep\", layout: dagre }\n"
|
|
" body: [b]\n")
|
|
_rd = run("render_consult.py", [_drep, "--outdir", os.path.join(CDIR, "dout")],
|
|
env={"RENDER_CONSULT_NO_D2": "1"})
|
|
_dhtml = os.path.join(CDIR, "dout", "d2render-deck.html")
|
|
_dimg = os.path.join(CDIR, "dout", "img")
|
|
check("d2 exhibit routes + produces svg (fallback)",
|
|
_rd.returncode == 0 and os.path.isdir(_dimg) and
|
|
any(f.endswith(".svg") for f in os.listdir(_dimg)) and
|
|
os.path.exists(_dhtml) and "<svg" in open(_dhtml).read())
|
|
_sh.rmtree(CDIR, ignore_errors=True)
|
|
|
|
print("== design-craft (DESIGN.md 패턴 + skill + D2 우선) ==")
|
|
_dbs = _yaml.safe_load(open(os.path.join(ROOT, "org-os/06-agent-work/design-brief-spec.yaml")))["design-brief-spec"]
|
|
check("design-brief-spec has 5 required anchors (brief-first)",
|
|
_dbs["required-anchors"] == ["brief", "references", "tokens", "decisions", "donts"])
|
|
check("design-brief engine-priority = D2 > excalidraw > mermaid",
|
|
[list(x)[0] for x in _dbs["diagram-extension"]["engine-priority"]] == ["d2", "excalidraw", "mermaid"])
|
|
_cps = open(os.path.join(ROOT, "org-os/06-agent-work/context-package-spec.yaml")).read()
|
|
check("context-package wires design-brief", "design-brief" in _cps and "design-brief-spec.yaml" in _cps)
|
|
for _sk in ("design-craft", "diagram-craft"):
|
|
_skp = os.path.join(ROOT, ".claude/skills", _sk, "SKILL.md")
|
|
_skt = open(_skp).read() if os.path.exists(_skp) else ""
|
|
check(f"skill {_sk} exists with frontmatter", _skt.startswith("---") and f"name: {_sk}" in _skt)
|
|
check("diagram-craft skill demotes Mermaid to fallback",
|
|
"폴백" in open(os.path.join(ROOT, ".claude/skills/diagram-craft/SKILL.md")).read())
|
|
_wmr = _yaml.safe_load(open(os.path.join(ROOT, "org-os/00-role-registry/role-working-methods.yaml")))["role-working-methods"]
|
|
check("DES-PROD working-method is reference-driven constraint (design-brief, ban 'modern')",
|
|
any("design-brief" in s for s in _wmr["DES-PROD"]["working-method"]) and
|
|
any("modern" in s for s in _wmr["DES-PROD"]["working-method"]))
|
|
_dvwm = " ".join(_wmr["DOC-VISUAL"]["working-method"])
|
|
check("DOC-VISUAL working-method D2-first, Mermaid demoted",
|
|
"D2" in _dvwm and "폴백" in _dvwm and _dvwm.index("D2") < _dvwm.index("Mermaid"))
|
|
for _a in ("des-prod", "des-platform", "des-internal", "doc-visual"):
|
|
check(f"agent {_a} carries craft standard",
|
|
"디자인 craft 표준" in open(os.path.join(ROOT, ".claude/agents", _a + ".md")).read())
|
|
_dva = open(os.path.join(ROOT, ".claude/agents/doc-visual.md")).read()
|
|
check("doc-visual agent D2-first (Mermaid fallback)", "D2" in _dva and "폴백" in _dva)
|
|
for _l in ("consult-em", "doc-lead"):
|
|
check(f"lead {_l} storyline recommends D2 exhibit",
|
|
"type: d2" in open(os.path.join(ROOT, ".claude/agents", _l + ".md")).read())
|
|
# P3: skills = method-skill + capability-skill(design-craft/build-loop) 조합(정확일치 아님, 포함 검사)
|
|
_dp_fm = open(os.path.join(ROOT, ".claude/agents/des-prod.md")).read().split("---\n")[1]
|
|
check("des-prod skills include method + design-craft",
|
|
"des-prod-method" in _dp_fm and "design-craft" in _dp_fm)
|
|
_dv_fm = open(os.path.join(ROOT, ".claude/agents/doc-visual.md")).read().split("---\n")[1]
|
|
check("doc-visual skills include method + design-craft + diagram-craft",
|
|
all(s in _dv_fm for s in ("doc-visual-method", "design-craft", "diagram-craft")))
|
|
check("every agent now has method-skill (arch-app has arch-app-method)",
|
|
"arch-app-method" in open(os.path.join(ROOT, ".claude/agents/arch-app.md")).read().split("---\n")[1])
|
|
# #17: tier -> model/effort (강한 tier = 강한 추론), SoT = governance-tiers.model-effort-by-tier
|
|
sys.path.insert(0, os.path.join(ROOT, ".claude", "hooks"))
|
|
_CP = _il.import_module("context_package")
|
|
check("#17 light -> sonnet/low", _CP.model_effort_for_tier("light") == {"model": "sonnet", "effort": "low"})
|
|
check("#17 heavy -> opus/high (strongest reasoning)", _CP.model_effort_for_tier("heavy") == {"model": "opus", "effort": "high"})
|
|
check("#17 heavy != light (tier changes reasoning, not just agent count)",
|
|
_CP.model_effort_for_tier("heavy") != _CP.model_effort_for_tier("light"))
|
|
check("#17 synthesis-lead bumps effort (consult-em standard -> high)",
|
|
_CP.model_effort_for_tier("standard", "consult-em")["effort"] == "high")
|
|
_gt = _yaml.safe_load(open(os.path.join(ROOT, "org-os/06-agent-work/governance-tiers.yaml")))["governance-tiers"]
|
|
check("#17 governance-tiers.yaml is the SoT (model-effort-by-tier present)",
|
|
isinstance(_gt.get("model-effort-by-tier"), dict) and "heavy" in _gt["model-effort-by-tier"])
|
|
|
|
print("== design-system pipeline (preview_ui + /design-system 커맨드) ==")
|
|
_pv = run("preview_ui.py", ["--help"])
|
|
check("preview_ui.py runs (--help)", _pv.returncode == 0 and "preview_ui" in (_pv.stdout + _pv.stderr))
|
|
check("/design-system command exists",
|
|
os.path.exists(os.path.join(ROOT, ".claude/commands/design-system.md")))
|
|
# 참고: 예제 design-system 슬라이스(구 ca-tmpl/design-system, 테스트 전용 폴더)는 제거됨.
|
|
# 파이프라인 로직·품질게이트(discovery·토큰순수성·대비·포커스·preview 게이트)는
|
|
# test_p1_design.py·test_p2_cascade_design.py 가 폴더 없이 커버한다.
|
|
|
|
print(f"\n{passed} passed, {failed} failed")
|
|
sys.exit(1 if failed else 0)
|