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:
DongHyeonka
2026-09-17 11:02:02 +09:00
co-authored by Claude Opus 5
parent 2109f726fe
commit ab59130196
1524 changed files with 3160026 additions and 8369 deletions
+193 -13
View File
@@ -36,10 +36,13 @@ import hashlib
import json
import os
import re
import subprocess
import sys
import uuid
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import techlog # noqa: E402
# 서버에 studio:publish 가 갈라져 들어오기 전에는 무인 저장을 켜지 않는다.
# 켜는 조건은 사람의 판단이지 이 파일의 기본값이 아니다 (A-studio-change-requests.md CR-001).
@@ -84,6 +87,12 @@ FIELD_MAP = {
# `decisionStatus`·`decidedOn` 은 frontmatter 에 있다 (`_decision_shape`)
"PROJECT_DECISION": {"결정문": "statement", "판단 이유": "rationale",
"영향": "consequences"},
# **SETUP 은 절 이름을 강제하지 않는다.** 「실행 절차·구성 값·확인 방법을 `##` 절로 적는다.
# 절 이름을 강제하지 않는다 — 프로젝트마다 셋업의 모양이 다르다」(`SetupInput.bodyMarkdown`).
# 그 절들은 전부 본문 안이라 `_sections` 가 세지 않는다. 본문 밖의 칸 `pinnedVersions` 는
# Concept 의 `basisVersion` 과 같이 **절이 아니라 frontmatter 에 있다** — `_setup_shape`
# 가 가져온다. 절만 보면 빠지고, required 라 빠지면 422 다
"SETUP": {"본문": "bodyMarkdown"},
}
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
@@ -423,6 +432,79 @@ def _titled(slug: str, field: str, raw) -> list[dict]:
return out
# `PinnedVersion` 은 `{name, version}` 둘뿐이다. `OrderedText` 와 달리 `id` 도 `order` 도
# 없고 `additionalProperties: false` 라 더 넣으면 거절된다 (`studio-api.openapi.yaml:964-973`).
PINNED_MAX_ITEMS = 30 # `SetupInput.pinnedVersions.maxItems`
PINNED_NAME_MAX, PINNED_VERSION_MAX = 60, 40
# 이름·버전을 따옴표나 백틱으로 감싸 적는 것이 자연스럽다. 값에는 넣지 않는다 —
# 화면이 표의 칸으로 그리므로 따옴표가 글자 그대로 남는다
_PINNED_TRIM = " \t`\"'\u201c\u201d\u2018\u2019"
def _pinned_versions(block) -> list[dict]:
"""frontmatter 의 `pinnedVersions:` 블록을 `PinnedVersion` 배열로.
pinnedVersions:
- name: Keycloak
version: 26.7.0
비어 있는 것은 정상이다 — 「비우면 화면에 표를 그리지 않습니다」. 그러나 **글이 있는데
항목이 0 개면 거절한다.** `_ordered` 와 같은 자리다: 비어 있는 것과 못 읽은 것은 다른 일이다.
"""
if not isinstance(block, str) or not block.strip():
return []
out: list[dict] = []
for line in block.splitlines():
item = line.strip()
if not item or item.startswith("#"):
continue
m = re.match(r"^-\s*name:\s*(.+)$", item)
if m:
out.append({"name": m.group(1).strip().strip(_PINNED_TRIM)})
continue
m = re.match(r"^version:\s*(.+)$", item)
if m and out and "version" not in out[-1]:
out[-1]["version"] = m.group(1).strip().strip(_PINNED_TRIM)
continue
raise Refused(f"고정한 버전을 읽지 못했다: {item!r} — frontmatter 에 "
"`- name: <이름>` 과 ` version: <버전>` 두 줄씩 적는다")
if not out:
raise Refused("pinnedVersions 에 글이 있는데 항목을 하나도 못 읽었다. "
"빈 칸으로 보내지 않는다 — 무엇이 사라졌는지 보고 고친다")
for item in out:
name, version = item.get("name", ""), item.get("version", "")
# **조용히 자르거나 채우지 않는다.** `minLength: 1` 이라 빈 쪽이 있으면 서버가 거절한다
if not name or not version:
raise Refused(f"고정한 버전의 한쪽이 비었다: {item}"
"이름과 버전 둘 다 있어야 한다 (minLength 1)")
if len(name) > PINNED_NAME_MAX or len(version) > PINNED_VERSION_MAX:
raise Refused(f"고정한 버전이 상한을 넘는다: 이름 {len(name)}"
f"(상한 {PINNED_NAME_MAX}) · 버전 {len(version)}"
f"(상한 {PINNED_VERSION_MAX}) — 기록에서 줄인다")
if len(out) > PINNED_MAX_ITEMS:
raise Refused(f"고정한 버전이 {len(out)}개다. 상한 {PINNED_MAX_ITEMS}"
"(studio-api.openapi.yaml:1011) — 기록에서 줄인다")
return out
def _setup_shape(doc: dict, fm: dict) -> dict:
"""SETUP 의 본문 밖 칸은 `pinnedVersions` 하나다.
**이 종류에는 검증일 칸이 없다.** `lastVerifiedOn` 도 `verifiedOn` 도 계약에 없고,
「낡음은 검증일이 아니라 `pinnedVersions` 가 말한다 — 어느 버전 위에서 이 절차가
성립했는지가 유효 범위다」(`SetupDetailResponse`). 그러니 다른 종류를 보고 검증일을
넣지 않는다 — `unevaluatedProperties: false` 라 보내면 거절된다.
"""
doc["pinnedVersions"] = _pinned_versions(fm.get("pinnedVersions"))
return doc
def _case_shape(doc: dict, fm: dict) -> dict:
"""CASE 의 `lastVerifiedOn` 은 절이 아니라 frontmatter 에 있다. 없으면 null 이다."""
doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None
return doc
def _concept_shape(doc: dict, fm: dict) -> dict:
"""`basisVersion` 은 frontmatter 에 있다. 절만 보면 빠진다."""
basis = (fm.get("basisVersion") or "").strip()
@@ -512,10 +594,23 @@ def _summary(text: str) -> str:
return ""
# 종류 → 그 종류의 칸을 서버 스키마의 **모양**으로 바꾸는 함수.
# 여섯 종류 전부에 자리가 있다 — 모양을 바꿀 것이 없는 종류도 함수를 둔다. 빈 자리를
# 두면 「없는 것」과 「빠뜨린 것」이 같아 보인다
SHAPES = {"CASE": _case_shape, "CONCEPT": _concept_shape, "REFERENCE": _reference_shape,
"QUESTION": _question_shape, "PROJECT_DECISION": _decision_shape,
"SETUP": _setup_shape}
def build_input(record_path: str) -> dict:
"""기록 `.md` 를 `WorkingCopyInput` 으로. 없는 칸을 지어내지 않는다."""
text = open(record_path, encoding="utf-8").read()
fm = _front_matter(text)
# `pinnedVersions:` 처럼 값이 아래 줄에 있는 칸은 한 줄 정규식이 빈 값으로 읽는다.
# 스칼라 칸에는 블록이 없으므로 그대로다
for key, value in list(fm.items()):
if not value:
fm[key] = techlog.front_matter_block(text, key)
kind = (fm.get("kind") or "").upper()
if kind not in FIELD_MAP:
raise Refused(f"모르는 kind: {fm.get('kind')!r}")
@@ -535,17 +630,9 @@ def build_input(record_path: str) -> dict:
for section, field in FIELD_MAP[kind].items():
if section in found:
doc[field] = found[section]
if kind == "CASE":
doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None
if kind == "QUESTION":
doc = _question_shape(doc, fm)
if kind == "CONCEPT":
doc = _concept_shape(doc, fm)
if kind == "REFERENCE":
doc = _reference_shape(doc, fm)
if kind == "PROJECT_DECISION":
doc = _decision_shape(doc, fm)
return doc
# **손으로 이은 if 사슬이 아니라 표다.** 사슬이었을 때 여섯 번째 종류가 붙을 자리가
# 보이지 않았다 — 종류를 더하면 `_kinds_line_up()` 이 import 할 때 걸린다
return SHAPES[kind](doc, fm)
VERDICTS = ("PASS", "FAIL", "UNKNOWN")
@@ -599,6 +686,33 @@ DELETE_PATHS = {
}
def _kinds_line_up() -> None:
"""세 표가 같은 여섯 종류를 말하는지 **import 할 때** 본다.
**손으로 나열한 목록에 새 종류를 빠뜨리는 일이 이 저장소에서 반복됐다.** 프론트엔드가
같은 실패를 먼저 적어 두었다 — 「여기 손으로 적어 두었던 동안 개념과 환경 구성이 빠져
있었고, 작업본 목록의 종류 필터는 그 둘을 아예 고를 수 없었다」
(`application/ports/studio-gateway.ts:8-12`).
빠뜨렸을 때 나는 일이 표마다 다르다. `FIELD_MAP` 이면 「모르는 kind」로 거절되고,
`SHAPES` 면 `KeyError` 로 죽고, `DELETE_PATHS` 면 **지울 수 없는 초안이 운영에 남는다.**
셋째는 되돌릴 수 없으므로 여기서 막는다.
"""
for other, name in ((SHAPES, "SHAPES"), (DELETE_PATHS, "DELETE_PATHS")):
diff = set(FIELD_MAP) ^ set(other)
if diff:
raise RuntimeError(
f"FIELD_MAP 과 {name} 이 다른 종류를 말한다: {sorted(diff)}"
"종류를 더할 때 세 표를 함께 채운다 (techlog.KIND_OF_DIR 가 종류의 정본)")
known = set(techlog.KIND_OF_DIR.values())
if set(FIELD_MAP) != known:
raise RuntimeError(
f"FIELD_MAP 이 techlog 의 종류와 다르다: {sorted(set(FIELD_MAP) ^ known)}")
_kinds_line_up()
def published_marks(record_path: str) -> list[str]:
"""이 기록이 게시된 것으로 보이는 표시. 비면 저장 대상이다.
@@ -750,11 +864,68 @@ def _review_gate(warnings: list[dict], verdict_path: str | None,
for w, v in passed]
# 고정한 해시가 지금 파일과 어긋날 때, 그것이 **이 파일의 과거 판**인지 **남의 묶음**인지.
# 양쪽 다 거절이지만 사람이 할 일이 다르다 — 앞은 다시 검토하는 것이고, 뒤는 묶음을
# 바로잡는 것이다. 하나로 뭉쳐 놓으면 낡은 검토를 「잘못 물린 묶음」으로 읽고 묶음을 고친다
PIN_PAST, PIN_ALIEN, PIN_UNSEEN = "PAST", "ALIEN", "UNSEEN"
PIN_HISTORY_LIMIT = 200
def _git_out(args: list[str]) -> str | None:
try:
r = subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True)
except OSError:
return None
return r.stdout if r.returncode == 0 else None
def _pin_verdict(record_path: str, want: str) -> tuple[str, str]:
"""고정값이 이 파일의 과거 상태였나, 어느 판에도 없나, 아니면 못 봤나.
`_echo_verdict` 와 같은 판정이다. 찾지 못한 것과 볼 수 없었던 것을 가른다 — git 이
없거나 이력 상한에 걸렸으면 `ALIEN` 이 아니라 `UNSEEN` 이다. **못 봤는데 남의
묶음이라고 쓰면 멀쩡한 검토를 버리게 된다.**
"""
rel = os.path.relpath(os.path.abspath(record_path), ROOT)
log = _git_out(["log", f"-{PIN_HISTORY_LIMIT + 1}", "--format=%H", "--", rel])
if log is None:
return PIN_UNSEEN, "git 이 없거나 이 저장소의 이력을 읽지 못했다"
commits = log.split()
if not commits:
return PIN_UNSEEN, "이 기록이 아직 커밋되지 않아 견줄 과거 판이 없다"
truncated = len(commits) > PIN_HISTORY_LIMIT
blind = False
for commit in commits[:PIN_HISTORY_LIMIT]:
blob = _git_out(["show", f"{commit}:{rel}"])
if blob is None:
blind = True
elif _sha256_text(blob) == want:
when = (_git_out(["log", "-1", "--format=%ad", "--date=short", commit]) or "").strip()
return PIN_PAST, f"{commit[:12]}{(' · ' + when) if when else ''}"
if truncated:
return PIN_UNSEEN, f"이력 상한 {PIN_HISTORY_LIMIT} 커밋까지 보고 못 찾았다"
if blind:
return PIN_UNSEEN, "이력의 일부를 읽지 못했다"
return PIN_ALIEN, f"커밋 {len(commits)}개를 다 봤다"
def approved(record_path: str, package_path: str) -> dict:
"""검토를 지난 최종본만 통과시킨다.
묶음의 `target.sha256` 은 검토가 본 파일의 해시다. 지금 디스크의 파일이 그것과 다르면
**검토 뒤에 바뀐 것**이라 그 판정을 이 파일에 붙일 수 없다.
어긋났을 때 무엇이 낡았는지는 `_pin_verdict` 가 git 으로 가른다. 셋 다 거절이지만
사람이 할 일이 다르다.
| 고정값이 | 뜻 | 할 일 |
|---|---|---|
| 이 파일의 과거 판이다 (`PAST`) | 검토는 진짜고 그 뒤에 파일이 바뀌었다 | 다시 검토해 묶음을 새로 뜬다 |
| 어느 판도 아니다 (`ALIEN`) | 다른 파일의 묶음을 물렸다 | 묶음을 바로잡는다 |
| 못 봤다 (`UNSEEN`) | git 이 없거나 이력 상한에 걸렸다 | 판정하지 않는다 |
**기록을 되돌리거나 묶음의 해시를 고쳐 쓰지 않는다.** 앞은 검사기에 답하는 것이고
뒤는 영수증 위조다. 런 원장의 `skillEcho` 와 같은 자리다.
"""
pkg = json.load(open(package_path, encoding="utf-8"))
now = _sha256_file(record_path)
@@ -762,9 +933,16 @@ def approved(record_path: str, package_path: str) -> dict:
if not want:
raise Refused(f"묶음에 target.sha256 이 없다: {package_path}")
if want != now:
kind, detail = _pin_verdict(record_path, want)
why = {
PIN_PAST: f"그 뒤에 이 파일이 바뀌었다 — 고정값은 {detail} 의 판이다.\n"
f" 검토는 진짜다. 바뀐 내용을 다시 검토해 묶음을 새로 뜬다",
PIN_ALIEN: f"고정값이 이 파일의 어느 판도 아니다 — 다른 파일의 묶음을 물렸다 ({detail})",
PIN_UNSEEN: f"고정값이 이 파일의 과거 판인지 못 봤다 — {detail}",
}[kind]
raise Refused(
"검토가 본 파일과 지금 파일이 다르다 — 그 판정을 이 파일에 붙일 수 없다\n"
f" 검토 시점 {want}\n 지금 {now}")
f" 검토 시점 {want}\n 지금 {now}\n {why}")
failed = [g["cmd"] for g in pkg.get("gates", []) if g.get("exit") not in (0, "0")]
if failed:
raise Refused("관문이 통과하지 못한 묶음이다:\n " + "\n ".join(failed))
@@ -1135,7 +1313,9 @@ def main() -> int:
ap.add_argument("--project-id",
help="PROJECT_DECISION 이 걸리는 프로젝트의 uuid. 사람이 Studio 목록에서 "
"읽은 값이어야 한다 — 이 어댑터는 이름을 uuid 로 바꾸지 않는다. "
"삭제 경로가 이 값을 쓴다")
"삭제 경로가 이 값을 쓴다. **SETUP 도 프로젝트가 필수다** "
"(「환경 구성은 프로젝트에 속합니다」 · project-public-render-model.ts:245) "
"— 다만 삭제 경로는 /setups/{id} 라 지우는 데는 안 쓴다")
ap.add_argument("--harness-test", action="store_true",
help=f"시험 초안이다. 제목 앞에 {HARNESS_PREFIX}, slug 앞에 {HARNESS_SLUG_PREFIX} 를 붙인다")
ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)")