chore: 이전 세션이 남긴 변경을 커밋한다
이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다. 사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다. 대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 — final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인 final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다. 삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다. 그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개, writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물, scripts/check-ssot-facts.py 와 그 시험이 들어 있다. 이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2109f726fe
commit
ab59130196
@@ -8,6 +8,7 @@ import glob
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
@@ -18,6 +19,14 @@ _spec = importlib.util.spec_from_file_location("run_ledger", TOOL)
|
||||
rl = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(rl)
|
||||
|
||||
VERIFIER = os.path.join(ROOT, "scripts", "verify-pipeline-run.py")
|
||||
_vspec = importlib.util.spec_from_file_location("verify_pipeline_run", VERIFIER)
|
||||
vpr = importlib.util.module_from_spec(_vspec)
|
||||
_vspec.loader.exec_module(vpr)
|
||||
|
||||
# 지금 `writing-tech-log-records/SKILL.md` 에 있는 문장. 영수증으로 적으면 통과해야 한다.
|
||||
CURRENT_SENTENCE = "본문이 있는 종류는 Case·Concept·Setup 셋이다."
|
||||
|
||||
|
||||
def _cli(*args, **kw):
|
||||
return subprocess.run(["python3", TOOL, *args], cwd=ROOT,
|
||||
@@ -69,7 +78,7 @@ class LedgerTest(unittest.TestCase):
|
||||
self.assertEqual(2, _cli("end", self.led, "--stage", "S3",
|
||||
"--status", "DONE").returncode)
|
||||
self.assertEqual(0, _cli("end", self.led, "--stage", "S3", "--status", "DONE",
|
||||
"--echo", "본문이 있는 종류는 Case 와 Concept 둘뿐이다.").returncode)
|
||||
"--echo", CURRENT_SENTENCE).returncode)
|
||||
|
||||
def test_a_gate_must_carry_an_exit_code(self):
|
||||
"""종료 코드 없는 관문을 못 적는다. 돌리지 않고 적는 경로를 막는다."""
|
||||
@@ -189,10 +198,23 @@ class LedgerTest(unittest.TestCase):
|
||||
def test_the_added_fields_do_not_break_the_verifier(self):
|
||||
"""이 도구가 더한 칸이 있어도 검사기가 그대로 읽어야 한다."""
|
||||
# 프로젝트 이름을 적지 않는다 — verify-pipeline.py 의 FORBIDDEN_LITERAL 가드가
|
||||
# scripts/ 안에서 저장소 체크아웃 이름을 금지한다. 아무 실제 원장이나 하나 고른다
|
||||
found = sorted(glob.glob(os.path.join(ROOT, "runs", "*", "*", "run.json")))
|
||||
# scripts/ 안에서 저장소 체크아웃 이름을 금지한다. 실제 원장 하나를 고른다.
|
||||
#
|
||||
# **아무거나 고르면 안 된다.** 이 시험이 묻는 것은 「더한 칸이 검사기를 깨뜨리는가」
|
||||
# 이므로 밑바탕은 **원래 통과하는 원장**이어야 한다. 파이프라인을 돌리는 중에는
|
||||
# 단계가 RUNNING·PENDING 인 원장이 `runs/` 에 있고, 그걸 고르면 더한 칸과 아무
|
||||
# 상관없이 「끝나지 않은 단계가 있다」로 실패한다 — 실제로 그렇게 깨졌다.
|
||||
# 단계가 전부 닫힌 것만 고른다.
|
||||
found = []
|
||||
for cand in sorted(glob.glob(os.path.join(ROOT, "runs", "*", "*", "run.json"))):
|
||||
try:
|
||||
stages = json.load(open(cand, encoding="utf-8")).get("stages") or []
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if stages and all(s.get("status") in ("DONE", "SKIPPED") for s in stages):
|
||||
found.append(cand)
|
||||
if not found:
|
||||
self.skipTest("견줄 실제 원장이 없다")
|
||||
self.skipTest("단계가 전부 닫힌 실제 원장이 없다")
|
||||
real = found[-1]
|
||||
d = json.load(open(real, encoding="utf-8"))
|
||||
d.update({"revision": 1, "riders": [], "sessions": [], "updatedAt": "x"})
|
||||
@@ -208,5 +230,268 @@ class LedgerTest(unittest.TestCase):
|
||||
self.assertEqual(0, p.returncode, p.stdout + p.stderr)
|
||||
|
||||
|
||||
def _git(*args):
|
||||
"""시험이 저장소에 직접 묻는다. 검사기와 **다른 방법**으로 물어야 대조가 된다."""
|
||||
p = subprocess.run(["git", "-C", ROOT, *args], capture_output=True,
|
||||
encoding="utf-8", errors="replace")
|
||||
return p.stdout if p.returncode == 0 else None
|
||||
|
||||
|
||||
def _commit_that_still_had(skill, sentence):
|
||||
"""그 문장을 아직 담고 있던 가장 최근 커밋. 없으면 None.
|
||||
|
||||
검사기는 `ls-tree` + `cat-file` 로 본문을 모아 부분 문자열을 찾는다. 여기서는
|
||||
`git grep` 으로 묻는다 — 같은 코드로 확인하면 시험이 아무것도 안 보는 것이 된다.
|
||||
"""
|
||||
rel = f".agents/skills/{skill}"
|
||||
out = _git("log", "--max-count=200", "--format=%H", "--", rel) or ""
|
||||
for commit in out.split():
|
||||
got = subprocess.run(["git", "-C", ROOT, "grep", "-F", "-q", sentence,
|
||||
commit, "--", rel], capture_output=True)
|
||||
if got.returncode == 0:
|
||||
return commit
|
||||
return None
|
||||
|
||||
|
||||
def _a_sentence_from(skill):
|
||||
"""그 스킬의 SKILL.md 에서 지금 실재하는 한 줄. 문구를 시험에 박아 두지 않는다."""
|
||||
path = os.path.join(ROOT, ".agents", "skills", skill, "SKILL.md")
|
||||
for line in open(path, encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if len(line) >= 30 and not line.startswith(("#", "|", "-", ">", "`")):
|
||||
return line
|
||||
raise AssertionError(f"{skill}/SKILL.md 에서 쓸 만한 줄을 못 찾았다")
|
||||
|
||||
|
||||
@unittest.skipUnless(_git("rev-parse", "--git-dir"), "저장소가 아니라 과거를 볼 수 없다")
|
||||
class EchoAgainstSkillHistory(unittest.TestCase):
|
||||
"""영수증이 지금 스킬에 없을 때, 위조와 「그 뒤에 스킬이 고쳐졌다」를 가르는지 본다.
|
||||
|
||||
스킬은 고쳐진다. 2026-09-12 에 `writing-tech-log-records` 의 「본문이 있는 종류」
|
||||
문장을 고쳤고, 그 문장을 인용한 과거 원장 4건이 한꺼번에 error 가 됐다. **그 영수증은
|
||||
사실이다** — 그때 그 문장이 거기 있었다. 원장을 고쳐 쓰는 것은 위조이고 틀린 문장을
|
||||
스킬에 되살리는 것은 검사기에 답하는 것이라, 둘 다 하지 않고 검사기가 가른다.
|
||||
"""
|
||||
|
||||
SKILL = "writing-tech-log-records"
|
||||
# 2026-09-12 에 물러난 문장. Studio 의 여섯 번째 종류 SETUP 이 빠져 있던 것을 메우며
|
||||
# 바뀌었다. **이것을 현재 SKILL.md 에 되살리지 않는다** — 과거 커밋에만 있어야 한다
|
||||
RETIRED = "본문이 있는 종류는 Case 와 Concept 둘뿐이다."
|
||||
FABRICATED = "이 문장은 그 스킬의 어느 판에도 없다 한 글자도 없다 정말로"
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
|
||||
def _ledger(self, echo, revision="", carry_field=True):
|
||||
"""S3 의 영수증만 갈아 끼운, 그 밖에는 흠이 없는 원장."""
|
||||
run = json.load(open(vpr.TEMPLATE, encoding="utf-8"))
|
||||
run.update({"runId": "2026-01-01-0000", "project": "demo",
|
||||
"record": "CLAUDE.md", "startedAt": "2026-01-01T00:00:00+09:00"})
|
||||
for st in run["stages"]:
|
||||
spec = vpr.STAGES[st["id"]]
|
||||
if not carry_field:
|
||||
st.pop(vpr.REVISION_FIELD, None)
|
||||
elif st["id"] == "S3":
|
||||
st[vpr.REVISION_FIELD] = revision or None
|
||||
if spec["skippable"]:
|
||||
st.update({"status": "SKIPPED", "skipReason": "이 시험은 영수증만 본다"})
|
||||
continue
|
||||
st.update({
|
||||
"status": "DONE",
|
||||
"skillEcho": echo if st["id"] == "S3" else _a_sentence_from(st["skill"]),
|
||||
"gates": [{"cmd": tok, "exit": 0} for tok in spec["gates"]],
|
||||
})
|
||||
path = os.path.join(self.dir, f"run-{len(os.listdir(self.dir))}.json")
|
||||
json.dump(run, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
return path
|
||||
|
||||
def _run(self, path, *args):
|
||||
p = subprocess.run([sys.executable, VERIFIER, path, *args], cwd=ROOT,
|
||||
capture_output=True, text=True)
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
def test_현재_스킬에_있는_영수증은_통과한다(self):
|
||||
"""대조군. 이 자리가 통과하지 않으면 나머지 둘은 아무것도 말하지 않는다."""
|
||||
code, out = self._run(self._ledger(CURRENT_SENTENCE))
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("대조 못 한 영수증 0", out)
|
||||
self.assertNotIn("그 뒤에 스킬이 고쳐져", out)
|
||||
|
||||
def test_과거_판에만_있는_영수증은_error_가_아니라_warn_이다(self):
|
||||
code, out = self._run(self._ledger(self.RETIRED))
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("그 뒤에 스킬이 고쳐져 영수증을 대조할 수 없다", out)
|
||||
self.assertNotIn("스킬 영수증이 그 스킬의 문장이 아니다", out)
|
||||
self.assertIn("대조 못 한 영수증 1", out)
|
||||
# 어느 커밋에 있었는지 함께 적는다. 「과거 어딘가」로는 다시 찾아갈 수 없다
|
||||
commit = _commit_that_still_had(self.SKILL, self.RETIRED)
|
||||
self.assertIsNotNone(commit, "그 문장을 담은 커밋이 이력에 없다")
|
||||
self.assertIn(commit[:12], out)
|
||||
|
||||
def test_warn_은_통과가_아니다(self):
|
||||
"""초록으로 보이면 안 된다. `--strict` 에서는 이것이 실패다."""
|
||||
code, out = self._run(self._ledger(self.RETIRED), "--strict")
|
||||
self.assertEqual(1, code, out)
|
||||
|
||||
def test_어느_판에도_없는_영수증은_error_다(self):
|
||||
code, out = self._run(self._ledger(self.FABRICATED))
|
||||
self.assertEqual(1, code, out)
|
||||
self.assertIn("스킬 영수증이 그 스킬의 문장이 아니다", out)
|
||||
self.assertNotIn("그 뒤에 스킬이 고쳐져", out)
|
||||
|
||||
def test_원장이_적은_리비전이_있으면_그_커밋을_본다(self):
|
||||
"""새 원장은 대조를 싸게 만든다 — 이력을 훑지 않고 적힌 커밋만 본다."""
|
||||
commit = _commit_that_still_had(self.SKILL, self.RETIRED)
|
||||
self.assertIsNotNone(commit)
|
||||
code, out = self._run(self._ledger(self.RETIRED, revision=commit))
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("원장이 적은 리비전", out)
|
||||
|
||||
def test_그_칸이_없는_옛_원장도_같은_답을_낸다(self):
|
||||
"""`skillRevision` 을 모르는 원장은 이력 훑기로 떨어진다. 칸이 없다고 잡지 않는다."""
|
||||
path = self._ledger(self.RETIRED, carry_field=False)
|
||||
self.assertNotIn(vpr.REVISION_FIELD,
|
||||
json.load(open(path, encoding="utf-8"))["stages"][2])
|
||||
code, out = self._run(path)
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("그 뒤에 스킬이 고쳐져 영수증을 대조할 수 없다", out)
|
||||
|
||||
def test_git_이_없으면_못_봤다고_한다(self):
|
||||
"""과거를 볼 수 없는 것은 「없다」가 아니다. error 로 올리지 않는다."""
|
||||
empty = os.path.join(self.dir, "bin")
|
||||
os.makedirs(empty, exist_ok=True)
|
||||
env = dict(os.environ, PATH=empty)
|
||||
# PATH 를 비우면 python3 도 같이 사라진다. 해석기는 절대 경로로 부른다
|
||||
p = subprocess.run([sys.executable, VERIFIER, self._ledger(self.RETIRED)],
|
||||
cwd=ROOT, capture_output=True, text=True, env=env)
|
||||
out = p.stdout + p.stderr
|
||||
self.assertEqual(0, p.returncode, out)
|
||||
self.assertIn("스킬의 과거 본문을 못 봐서 영수증을 대조하지 못했다", out)
|
||||
self.assertIn("대조 못 한 영수증 1", out)
|
||||
|
||||
|
||||
class RunByNamesAManagedAgent(unittest.TestCase):
|
||||
"""단계를 **누가** 돌렸는지가 원장에 남는가.
|
||||
|
||||
`skillEcho` 는 「스킬을 열었다」를 증명하지만 누가 열었는지는 증명하지 않는다 — 매번
|
||||
새로 띄운 일반 에이전트도 SKILL.md 를 읽고 한 줄을 옮겨 적을 수 있다. 그동안 `runBy`
|
||||
는 `"subagent"` 라는 상수였고, 그래서 원장 5건이 전부 통과하는 동안에도 어느 에이전트가
|
||||
돌았는지는 아무 데도 없었다.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
|
||||
def _ledger(self, schema=2, run_by=None):
|
||||
"""runBy 만 갈아 끼운, 그 밖에는 흠이 없는 원장."""
|
||||
run = json.load(open(vpr.TEMPLATE, encoding="utf-8"))
|
||||
run.update({"runId": "2026-01-01-0000", "project": "demo",
|
||||
"record": "CLAUDE.md", "startedAt": "2026-01-01T00:00:00+09:00",
|
||||
"finishedAt": "2026-01-01T01:00:00+09:00", "schemaVersion": schema})
|
||||
for st in run["stages"]:
|
||||
spec = vpr.STAGES[st["id"]]
|
||||
if run_by is not None:
|
||||
st["runBy"] = run_by
|
||||
if spec["skippable"]:
|
||||
st.update({"status": "SKIPPED", "skipReason": "이 시험은 runBy 만 본다"})
|
||||
continue
|
||||
st.update({"status": "DONE", "skillEcho": _a_sentence_from(st["skill"]),
|
||||
"gates": [{"cmd": tok, "exit": 0} for tok in spec["gates"]]})
|
||||
path = os.path.join(self.dir, f"run-{len(os.listdir(self.dir))}.json")
|
||||
json.dump(run, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
return path
|
||||
|
||||
def _run(self, path, *args):
|
||||
p = subprocess.run([sys.executable, VERIFIER, path, *args], cwd=ROOT,
|
||||
capture_output=True, text=True)
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
# ── 계약이 스스로 맞는가 ─────────────────────────────────────────
|
||||
def test_모든_단계에_에이전트가_배정돼_있다(self):
|
||||
"""빠진 단계가 있으면 그 단계만 조용히 일반 에이전트로 돌아간다."""
|
||||
for sid in vpr.ORDER:
|
||||
with self.subTest(stage=sid):
|
||||
self.assertTrue(vpr.STAGES[sid].get("agent"),
|
||||
f"{sid} 에 agent 가 없다")
|
||||
|
||||
def test_배정된_에이전트가_실재한다(self):
|
||||
"""`.claude/agents/<이름>.md` 가 없으면 그 이름은 약속일 뿐이다."""
|
||||
for sid in vpr.ORDER:
|
||||
agent = vpr.STAGES[sid]["agent"]
|
||||
with self.subTest(stage=sid, agent=agent):
|
||||
self.assertTrue(
|
||||
os.path.exists(os.path.join(ROOT, ".claude", "agents", f"{agent}.md")),
|
||||
f"{sid} 이 가리키는 .claude/agents/{agent}.md 가 없다")
|
||||
|
||||
def test_틀과_검사기가_같은_에이전트를_말한다(self):
|
||||
"""틀에만 적어 두면 STAGES 와 갈린다. 갈린 채로는 둘 다 「계약」이라고 말한다."""
|
||||
run = json.load(open(vpr.TEMPLATE, encoding="utf-8"))
|
||||
for st in run["stages"]:
|
||||
with self.subTest(stage=st["id"]):
|
||||
self.assertEqual(vpr.STAGES[st["id"]]["agent"], st["runBy"])
|
||||
|
||||
# ── 판정 ────────────────────────────────────────────────────────
|
||||
def test_계약대로_적은_원장은_통과한다(self):
|
||||
"""대조군. 이 자리가 통과하지 않으면 나머지는 아무것도 말하지 않는다."""
|
||||
code, out = self._run(self._ledger())
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("누가 돌렸는지 모르는 단계 0", out)
|
||||
|
||||
def test_옛_판의_원장은_error_가_아니라_warn_이다(self):
|
||||
"""`schemaVersion` 1 에는 그 칸이 없었다. 위조가 아니라 그때의 계약이다."""
|
||||
code, out = self._run(self._ledger(schema=1, run_by=vpr.LEGACY_RUNBY))
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("옛 판의 원장이라 누가 돌렸는지 적혀 있지 않다", out)
|
||||
self.assertNotIn("단계를 맡은 에이전트가 계약과 다르다", out)
|
||||
|
||||
def test_옛_판이어도_초록으로_보이지_않는다(self):
|
||||
"""warn 은 통과가 아니다. 요약 줄이 그 수를 따로 센다."""
|
||||
code, out = self._run(self._ledger(schema=1, run_by=vpr.LEGACY_RUNBY))
|
||||
self.assertIn("누가 돌렸는지 모르는 단계 7", out)
|
||||
code, _ = self._run(self._ledger(schema=1, run_by=vpr.LEGACY_RUNBY), "--strict")
|
||||
self.assertEqual(1, code)
|
||||
|
||||
def test_새_판에서_상수를_적으면_error_다(self):
|
||||
"""유예는 옛 원장의 것이다. 지금 판으로 열고 상수를 적는 것은 다른 일이다."""
|
||||
code, out = self._run(self._ledger(schema=2, run_by=vpr.LEGACY_RUNBY))
|
||||
self.assertEqual(1, code, out)
|
||||
self.assertIn("단계를 맡은 에이전트가 계약과 다르다", out)
|
||||
|
||||
def test_다른_관리_에이전트를_적어도_error_다(self):
|
||||
"""실재하는 이름이라고 맞는 것은 아니다. 단계마다 맡은 역할이 다르다."""
|
||||
code, out = self._run(self._ledger(schema=2, run_by="fact-reviewer"))
|
||||
self.assertEqual(1, code, out)
|
||||
self.assertIn("단계를 맡은 에이전트가 계약과 다르다", out)
|
||||
|
||||
# ── 원장을 여는 도구가 계약값을 지우지 않는가 ───────────────────
|
||||
def test_런을_열면_계약이_적힌다(self):
|
||||
led = os.path.join(self.dir, "opened", "run.json")
|
||||
p = _cli("open", led, "--project", "demo", "--record", "docs/demo/x.md")
|
||||
self.assertEqual(0, p.returncode, p.stderr)
|
||||
run = json.load(open(led, encoding="utf-8"))
|
||||
self.assertEqual(vpr.AGENT_RUNBY_SCHEMA, run["schemaVersion"])
|
||||
for st in run["stages"]:
|
||||
self.assertEqual(vpr.STAGES[st["id"]]["agent"], st["runBy"])
|
||||
|
||||
def test_단계를_열어도_계약값이_남는다(self):
|
||||
"""`begin` 의 기본값이 계약값을 덮어쓰면 원장은 다시 누가 돌렸는지 잃는다."""
|
||||
led = os.path.join(self.dir, "begun", "run.json")
|
||||
_cli("open", led, "--project", "demo", "--record", "docs/demo/x.md")
|
||||
p = _cli("begin", led, "--stage", "S3")
|
||||
self.assertEqual(0, p.returncode, p.stderr)
|
||||
st = next(s for s in json.load(open(led, encoding="utf-8"))["stages"]
|
||||
if s["id"] == "S3")
|
||||
self.assertEqual(vpr.STAGES["S3"]["agent"], st["runBy"])
|
||||
|
||||
def test_사람이_지목하면_그것을_적는다(self):
|
||||
"""계약과 다르면 검사기가 잡는다. 도구가 값을 막지는 않는다 — 거짓말은 원장에 남아야 한다."""
|
||||
led = os.path.join(self.dir, "named", "run.json")
|
||||
_cli("open", led, "--project", "demo", "--record", "docs/demo/x.md")
|
||||
_cli("begin", led, "--stage", "S3", "--runby", "fact-reviewer")
|
||||
st = next(s for s in json.load(open(led, encoding="utf-8"))["stages"]
|
||||
if s["id"] == "S3")
|
||||
self.assertEqual("fact-reviewer", st["runBy"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user