Split View: AI 에이전트 하네스 해부 — 규칙이 스스로 지켜지게 만든 35개 파일
AI 에이전트 하네스 해부 — 규칙이 스스로 지켜지게 만든 35개 파일
모델이 아니라 주변이 결과를 정한다
같은 모델을 쓰는데도 어떤 저장소에서는 에이전트가 git stash 로 남의 편집을 날리고, 어떤 저장소에서는 그런 일이 나지 않는다. 차이는 모델이 아니라 모델을 둘러싼 것, 즉 하네스(harness) 에 있다. 규칙을 어디에 적는지, 어떤 명령을 막는지, 커밋이 어떤 관문을 지나는지, 세션이 끝나도 무엇이 남는지.
이 글은 LabHub 저장소의 하네스를 파일 단위로 뜯어 본 기록이다. 이 저장소는 2026년 8월 20일 첫 커밋 뒤 3주 동안 AI 에이전트가 사람과 함께 일했고, 그 결과가 숫자로 남아 있다.
| 항목 | 값 | 출처 |
|---|---|---|
| 전체 커밋 | 1,491 | git rev-list --count origin/main |
| AI 공동 저작 커밋 | 497 (Opus 354 · Fable 136 · Codex 7) | 커밋 본문의 Co-Authored-By |
| 운영 배포 커밋 | 350 (개발계 295) | deploy(prod): 접두사 |
| 최근 7일 사람·AI 커밋(배포 제외) | 377 | 9월 4일~11일 |
| 단위 시험 수 | 1,697 → 2,346 | 9월 7일 10:47 → 9월 11일 09:06 게이트 로그 |
| 이 Mac 에서 돈 게이트 | 161회 | 9월 7일 이후 게이트 로그 파일 수 |
마지막 줄이 이 글의 주제다. 게이트 161회 중 이 작업 세션의 로그에서 세어 보면 push 성공 127회, 게이트가 막은 것 41회, origin 이 앞서 나가 커밋을 다시 얹은 것 23회다. 하네스는 문서가 아니라 하루에도 수십 번 도는 기계다.
원칙 하나: 규칙은 한 곳에, 장치는 따로
하네스 전체는 2026년 9월 7일 커밋 하나(83821fa0, 35개 파일, 1,746줄)로 들어왔다. 커밋 메시지가 왜 만들었는지를 말한다.
규칙은 AGENTS.md 한 곳에 있었지만, 그것을 지키는 장치는 세션마다
스크래치패드에 다시 만들고 있었다(push.sh·gate.sh·bumpdigest.py).
세션이 끝나면 사라지고, 다음 세션은 같은 사고를 한 번 더 겪은 뒤에야
같은 도구를 다시 만들었다.
그래서 구조의 첫 원칙은 규칙의 원본은 하나 라는 것이다. 규칙은 AGENTS.md(707줄) 한 곳에 있고, Claude Code 가 읽는 CLAUDE.md 는 그 파일을 가리키는 포인터다. CLAUDE.md 의 문장을 그대로 옮긴다.
같은 내용을 두 곳에 나누어 적어 두면 반드시 한쪽만 낡은 상태로 남기 때문입니다.
.claude/README.md 는 .claude/ 아래에 무엇을 두는지를 이렇게 정한다.
규칙의 원본은 AGENTS.md 다. 여기에는 Claude Code 만 읽는 장치를 둔다 —
규칙을 다시 적는 것이 아니라, 규칙이 지켜지도록 자동으로 끼어드는 것들이다.
다섯 층과 두 가지 더
README 는 하네스를 다섯 층으로 나눈다. 실제 파일 수를 세어 붙였다.
| 층 | 무엇 | 파일 | 하는 일 |
|---|---|---|---|
| 영구 지침 | AGENTS.md, CLAUDE.md | 2 | 모든 세션이 먼저 읽는 규칙과 사고 기록 |
| 경로 규칙 | .claude/rules/*.md | 6 | 특정 경로를 건드릴 때만 붙는 지침 |
| 스킬 | .claude/skills/*/SKILL.md | 5 | 세 번 이상 반복한 절차 |
| 훅 | .claude/hooks/*.py | 2 | 도구 호출 전후에 끼어드는 검사 |
| 서브에이전트 | .claude/agents/*.md | 3 | 읽기만 하는 조사·검토 역할 |
| 슬래시 명령 | .claude/commands/*.md | 4 | 스킬로 들어가는 얇은 입구 |
| 플러그인 | settings.json 의 enabledPlugins | 13 | 공식 마켓플레이스의 도구 |
무엇을 어느 층에 넣는지의 기준도 README 에 적혀 있다. 훅에 대한 문장이 가장 분명하다.
훅에 넣는 기준은 하나다 — 실제로 일어났고, 눈으로는 알아채기 어려웠던 것.
일반적인 모범 사례는 넣지 않는다. 경고가 흔해지면 아무도 읽지 않는다.
스킬에 대해서는 "세 번 이상 반복한 절차" 를 넣되, "순서가 어긋나면 사고가 나는 부분은 본문이 아니라 scripts/agent/ 의 스크립트로 뺀다" 고 한다. 플러그인 13개 중 commit-commands 는 일부러 뺐다. /commit 이 작업 트리에서 git commit 을 치는데, 이 저장소에서는 그것이 금지이기 때문이다.
경로 규칙: 그 파일을 열 때만 나타나는 지침
rules/ 의 파일 6개는 각각 paths: 를 갖고, 그 경로를 건드릴 때만 문맥에 들어온다. 전체 규칙을 매번 읽히면 아무도 읽지 않는다는 같은 원리다.
app-js.md—backend/static/app.js는 파일 전체의 SHA-256 이 보안 검사 스크립트에 박혀 있어 "한 글자만 달라져도 배포가 막히고, 막히는 자리의 메시지는 엉뚱하다(streamSSE 관련 문구가 뜬다)".pipeline.md— "push 하면 Jenkins 가 매분 폴링해 승인 없이 운영까지 올린다(약 20분). 배포는 GitOps 로만."migrations.md— "이미 적용된 마이그레이션은 절대 고치지 않는다."generated.md— 손으로 고치면 다음 생성 때 사라지는 파일과 그 생성기의 표.curriculum.md— "채점기는 양방향으로 시험한다 — 정답이 통과하는 것만 보면 절반이다."tests.md— "시험의 문서 문자열에 왜 이 시험이 있는지, 어떤 사고가 있었는지를 적는다."
시험이 이 규칙들을 감시한다. paths: 가 실제 존재하는 파일과 맞는지, 본문이 AGENTS.md 나 scripts/ 를 다시 가리키는지를 tests/test_claude_harness.py 가 확인한다.
훅: 막는 것 넷, 묻는 것 둘
hooks/guard.py 는 도구가 실행되기 전에 표준 입력으로 호출 내용을 받고, 표준 출력으로 deny 나 ask 를 돌려준다. 안에 든 것은 정확히 네 가지이고, 각각 실제 사고에서 왔다.
GIT_STATE = re.compile(
r"\bgit\s+(?:stash|checkout|switch|restore|reset|rebase|merge|pull|clean)\b")
GIT_OK = re.compile(r"\bgit\s+(?:worktree|stash\s+list)\b")
KUBECTL_WRITE = re.compile(
r"\bkubectl\b[^|;&]*\s(?:apply|delete|patch|scale|edit|replace|annotate|label|cordon|drain)\b")
LOCKED = "backend/static/app.js"
- 작업 트리를 바꾸는 git 명령은 deny. 주석에 이유가 있다: "
git stash한 번이 다른 사람 넷의 편집을 되돌렸다." - 클러스터 상태를 바꾸는 kubectl 은 deny. 읽기와 임시 파드만 허용한다.
push.sh를 거치지 않는git push는 ask. 메시지에 근거를 붙인다: "오늘만 그 게이트가 CI 실패를 네 번 막았습니다."app.js편집은 ask. 다이제스트 갱신을 잊으면 배포가 조용히 막히기 때문이다.
hooks/after_edit.py 는 파일을 고친 직후 그 파일 하나만 본다. Python 은 ast.parse, JavaScript 는 node --check, JSON 은 json.loads. 전체 시험은 2분이 넘어 편집마다 돌릴 수 없으니 "지금 이 파일이 스스로 성립하는가" 까지만 잡고, 나머지는 게이트에 맡긴다.
훅의 위험은 README 의 한 문장에 있다: "훅은 조용히 죽는다 — 고친 뒤 시험을 돌리지 않으면 막으려던 사고가 그대로 난다." 그래서 tests/test_claude_hooks.py 13개가 훅에 JSON 을 직접 먹여 deny·ask·통과를 확인한다.
서브에이전트: 읽기만 하는 역할
agents/ 의 세 역할은 모두 쓰기 도구가 없다.
| 이름 | 도구 | 역할 | 하지 않는 것 |
|---|---|---|---|
| safe-researcher | Read, Grep, Glob, Bash | 코드·문서·클러스터를 읽고 요약 | Edit/Write, git 상태 변경, kubectl 쓰기 |
| grader-reviewer | Read, Grep, Glob, Bash | 채점기를 양방향으로 검토 | 채점기를 고치지 않는다, 판정과 반례만 |
| deploy-watcher | Read, Bash | push 뒤 빌드·운영 반영을 지켜봄 | kubectl 쓰기, 재시도 |
grader-reviewer 의 정의 파일에 있는 문장이 이 역할이 왜 있는지 말한다: "'무조건 통과하는 채점기' 가 실제로 여러 개 있었고, 그것은 없는 것보다 나쁘다." 이 글의 .claude/ 조사도 safe-researcher 에게 맡겼다. 부모의 문맥을 아끼고, 조사 중에 실수로 무엇을 고칠 길을 없앤다.
시험은 이것도 강제한다. 에이전트가 셋 이상인지, tools 에 Edit·Write·NotebookEdit 가 없는지, 설명에 "하지 않는다" 류의 부정 표현이 있는지.
절차는 스크립트로: push.sh 와 gate.sh
하네스의 무게 중심은 scripts/agent/ 의 스크립트 8개다. 그중 둘이 핵심이다.
push.sh 는 커밋과 push 의 유일한 길이다. 작업 트리에서 커밋하지 않는다. origin/main 에서 새 worktree 를 뜨고, 내가 지목한 파일만 그 안으로 복사하고, 거기서 게이트를 돌린 뒤 커밋·push 한다. 다른 사람이 같은 파일을 고치고 있으면 파일을 통째로 복사하는 대신 APPLY="스크립트:경로" 로 내 변경만 적용하는 스크립트를 넘긴다. 게이트를 도는 4분 사이에 CI 가 배포 커밋을 올려 origin 이 앞서면, 새 origin/main 위에 내 커밋 하나를 cherry-pick 으로 다시 얹는다. 최대 세 번. 이 세션 로그에서 그 재시도가 23회 있었다.
worktree 경로가 실행마다 다른 이유도 주석에 있다: "고정 경로를 쓰다가 두 push 가 겹쳐 서로의 디렉터리를 지웠고, 그때 시험 로그가 통째로 거짓이 되었다."
gate.sh 는 CI 의 Verify 단계와 같은 것을 로컬에서 돌린다. 보안 검사, 커리큘럼 검사, 채점기 감사를 차례로 돌린 뒤 단위 시험 전부를 돌린다. 만든 이유는 머리 주석에 있다.
빌드 441~446 이 여섯 번 같은 자리에서 실패한 뒤에 만들었다. push 마다
시험을 손으로 골라 적고 있었고, 커리큘럼을 고치면서 test_i18n_catalog 를
고르지 않았다. 고르는 순간 빠뜨린다. 그래서 전부 돌리고, 로컬에만 있는
실패(설치 안 된 의존성·DB 없음)는 gate_baseline.txt 와 대조해 걸러 낸다.
기준선과의 대조는 comm -13 으로 한다. 예전에는 diff | grep 이었는데, set -o pipefail 아래에서 diff 의 종료 코드가 파이프라인 전체를 실패로 만들어 새 실패를 찾고도 그냥 지나갔다. 그렇게 test_ko_source_is_current 가 빌드 451~453 세 번 새어 나갔다. 이 사고는 시험으로 못 박혀 있다: gate.sh 가 comm -13 을 쓰고 git diff 외의 diff 를 쓰지 않는지를 검사한다.
node 시험에는 300초 감시 프로세스가 붙어 있다. node --test 는 기본 시간 제한이 없어 "activation.test.js 가 맥에서 두 번 멈춰 push 를 통째로 붙잡았다".
잠긴 파일과 배포의 증거
bumpdigest.py 는 app.js 의 다이제스트를 갱신하되, 갱신할 때 세 가지를 함께 한다. 신뢰 경계의 개수(streamSSE, renderTrustedLessonMarkdown, innerHTML, eval( 등 11개 패턴)를 고치기 전 파일과 견주고, 달라진 것은 ALLOW 로 명시 승인해야 하며, 무엇을 왜 바꿨는지 적은 NOTE 를 이력 블록에 붙인다. 기대값을 숫자로 박아 두지 않는 이유는 "다른 사람의 정당한 변경 뒤에 거짓으로 막힌 적이 있다" 는 것이다. 시험 5개가 이 판정 로직에 임시 파일을 먹여 본다: 승인 없는 변화는 거부, 같은 이력 두 번 기록은 거부, 빈 NOTE 는 거부.
deploy_status.sh 는 "ArgoCD 의 Synced 초록불을 믿지 않는다". 두 앱 모두 selfHeal 이라 화면은 늘 초록이고, 실제로 사흘 묵은 이미지가 Synced 로 떠 있던 적이 있다. 그래서 네 가지를 각각 읽는다. Jenkins 마지막 빌드들의 결과와 시간, gitops 의 newTag 와 실제 Deployment 의 이미지 태그, ArgoCD 두 앱의 상태, 그리고 운영 사이트가 내주는 /app.js 가 그 커밋의 파일과 바이트 단위로 같은가. 정적 파일이 배포의 마지막 증거다.
하네스가 자기 자신을 시험한다
tests/test_claude_harness.py 의 21개 시험은 코드가 아니라 하네스의 구조를 검사한다. 몇 개만 옮긴다.
- 스킬의 description 은 80자를 넘고, "사용한다" 와 함께 언제 쓰지 않는지 를 담아야 한다.
- 스킬 본문에는
## 왜,## 절차,## 출력 형식,## 하지 말 것네 절이 모두 있어야 한다. - 스킬이 언급하는
scripts/agent/*는 실재해야 하고,.sh는 실행 권한과bash -n을,.py는compile()을 통과해야 한다. .claude/와scripts/agent/어디에도/Users/,/private/tmp/claude같은 기계 고유 경로가 없어야 한다.push.sh에git worktree add가 있고git stash가 없어야 하며,git checkout은 반드시origin/main과 함께여야 한다.- README 는 모든 스킬 디렉터리와
enabledPlugins의 모든 플러그인을 언급해야 하고,commit-commands는 거기 없어야 한다.
이 시험들 덕에 하네스는 도입 이후 구조가 바뀌지 않았다. .claude/ 와 scripts/agent/ 를 건드린 커밋은 도입 커밋과 기준선 두 줄을 정리한 커밋, 둘뿐이다.
세션 밖에 남는 것: 메모리
저장소 안의 하네스가 "규칙" 이라면, 저장소 밖에는 "경험" 이 남는다. 이 작업 환경의 메모리 디렉터리에는 파일 65개가 있다. 유형별로 프로젝트 사실 53개, 사용자 피드백 9개, 외부 참조 3개다. 각 파일은 사실 하나와 "왜", "어떻게 적용하는가" 를 담고, 색인 파일 한 줄이 세션 시작 때 읽힌다.
이번 주에 적힌 것 몇 개를 보면 무엇이 남는지 알 수 있다. 「게이트는 worktree 가 아니라 내 로컬 사본의 기준선을 읽는다」, 「만드는 것과 보이게 하는 것은 다른 일」, 「손으로 만든 Job 은 파드 라벨을 잃기 쉽다」, 「CronJob 실패는 ArgoCD 를 다음 성공까지 붙잡는다」. 전부 코드에는 적을 데가 없고, 문서에 적으면 낡는 종류의 사실이다.
이번 세션에서 실제로 겪은 것
하네스가 있어도 사고는 난다. 다만 사고가 막힌 자리에서 난다. 어제 검증기 한 줄을 고쳐 올리는 데 push 를 네 번 돌렸다.
push.sh가 첫 인자로 커밋 메시지 파일 을 받도록 바뀌어 있었다. 문자열을 주자 "커밋 메시지 파일이 없습니다" 로 끝났다.- 게이트가
test_production_cli_…_fails_unprovisioned를 새 실패로 잡았다. 내 변경과 무관한 환경 실패였다. 이 Mac 의 cryptography 50.0.1 이 잠금본 50.0.0 과 달라 다른 오류가 먼저 났다. - 같은 실패가 또 잡혔다. 원인은 gate.sh 가 worktree 가 아니라 로컬 사본 의
gate_baseline.txt를 읽는다는 것이었고, 로컬 사본이 낡아 그 파일이 비어 있었다. - 기준선을 맞추자 통과했고, 그 사이 CI 가 배포 커밋을 올려 "origin 이 앞서 있습니다 — 다시 얹습니다(1)" 를 거쳐 push 됐다.
네 번 중 한 번도 운영에 닿지 않았다. 게이트가 막은 41회가 전부 이런 것들이다. 그리고 3번의 교훈은 메모리 파일이 되어 다음 세션이 같은 자리에서 서지 않게 한다.
APPLY 스크립트에도 함정이 하나 있었다. 스크립트는 "이미 적용됐는가" 를 먼저 확인하는데, 그 확인의 열쇠를 원본에도 있는 문자열(resolve(demo.map(lang)))로 잡는 바람에 "이미 있습니다" 라며 아무것도 하지 않았다. 열쇠는 새로 들어가는 문자열이어야 한다.
한계
이 하네스는 완성이 아니다. 문서가 스스로 밝히는 한계 두 가지가 있다.
사람의 승인 단계가 없다. docs/CI-CD.md 의 문장이다: "main 에 밀린 것이 Verify 와 dev E2E 를 통과하면 같은 잡이 그대로 운영까지 간다. 운영을 지키는 것은 그 두 관문뿐이고, 그래서 관문에 무엇이 걸려 있는지가 중요하다." 승인 게이트는 fail-closed 설계의 목표로 적혀 있을 뿐 아직 돌지 않는다.
기준선은 기계마다 다르다. gate_baseline.txt 는 로컬 환경의 실패를 걸러 내는 파일인데, 저장소 안에 있으면서도 실제로는 기계마다 달라야 한다. 위의 3번 사고가 그 모순에서 왔다.
그리고 롤링 배포 중에는 두 파드가 다른 빌드를 내준다. 어제 브라우저가 옛 파드에서 받은 스크립트를 캐시에 남겨 새 기능이 잠시 안 보였다. 하네스는 커밋과 배포를 지키지만, 브라우저 캐시까지는 아직 지키지 않는다.
한 줄로
하네스는 규칙을 더 많이 적는 일이 아니라, 실제로 일어난 사고를 그 자리에서 자동으로 막는 장치를 하나씩 쌓는 일 이다. 규칙은 한 곳에, 장치는 도구 호출·커밋·배포의 길목에, 경험은 세션 밖 메모리에. 이 저장소에서는 그 세 층이 하루 수십 번 돌고, 그 결과가 게이트가 막은 41회로 남아 있다.
Anatomy of an AI agent harness — 35 files that make the rules enforce themselves
The surroundings decide the result, not the model
With the same model, in one repository an agent wipes out other people's edits with git stash, and in another that never happens. The difference is not the model but what surrounds it: the harness. Where the rules are written, which commands are blocked, which gates a commit passes through, what survives after the session ends.
This post is a file-by-file dissection of the harness in the LabHub repository. Since its first commit on 20 August 2026 an AI agent has worked there alongside a person for three weeks, and the result is on record as numbers.
| Item | Value | Source |
|---|---|---|
| Total commits | 1,491 | git rev-list --count origin/main |
| AI co-authored commits | 497 (Opus 354 · Fable 136 · Codex 7) | Co-Authored-By in commit bodies |
| Production deploy commits | 350 (dev 295) | the deploy(prod): prefix |
| Commits in the last 7 days (excluding deploys) | 377 | 4–11 September |
| Unit tests | 1,697 → 2,346 | gate logs, 7 Sep 10:47 → 11 Sep 09:06 |
| Gate runs on this Mac | 161 | gate log files since 7 September |
The last row is the subject of this post. Counting in the logs of this working session, out of those gate runs there were 127 successful pushes, 41 blocked by the gate, and 23 where origin had moved ahead and the commit was re-applied. The harness is not a document; it is a machine that runs dozens of times a day.
Principle one: rules in one place, mechanisms separate
The entire harness arrived in a single commit on 7 September 2026 (83821fa0, 35 files, 1,746 lines). The commit message says why.
The rules lived in one place, AGENTS.md, but the mechanisms that enforced
them were being rebuilt in a scratchpad every session (push.sh, gate.sh,
bumpdigest.py). They vanished when the session ended, and the next session
rebuilt the same tools only after suffering the same accident once more.
So the first structural principle is one source of truth for rules. The rules live in AGENTS.md (707 lines), and the CLAUDE.md that Claude Code reads is a pointer to that file. In CLAUDE.md's own words:
If the same content is written in two places, one of them is guaranteed to be left stale.
.claude/README.md defines what goes under .claude/:
The source of the rules is AGENTS.md. What lives here is only what Claude Code reads —
not a restatement of the rules, but the things that step in automatically so the rules are kept.
Five layers, plus two
The README divides the harness into five layers. I counted the actual files.
| Layer | What | Files | Job |
|---|---|---|---|
| Permanent guidance | AGENTS.md, CLAUDE.md | 2 | Rules and incident log every session reads first |
| Path rules | .claude/rules/*.md | 6 | Guidance attached only when touching certain paths |
| Skills | .claude/skills/*/SKILL.md | 5 | Procedures repeated three or more times |
| Hooks | .claude/hooks/*.py | 2 | Checks that intervene before and after tool calls |
| Subagents | .claude/agents/*.md | 3 | Read-only research and review roles |
| Slash commands | .claude/commands/*.md | 4 | Thin entry points into skills |
| Plugins | enabledPlugins in settings.json | 13 | Tools from the official marketplace |
The criteria for what goes into which layer are also in the README. The sentence about hooks is the clearest:
There is one criterion for putting something in a hook — it actually happened here,
and it was hard to notice by eye. General best practices are not added. Once warnings
become common, nobody reads them.
For skills: "procedures repeated three or more times", but "the parts where a wrong order causes an accident go into scripts under scripts/agent/, not into the prose". Of the 13 plugins, commit-commands was deliberately left out, because /commit runs git commit in the working tree, which is forbidden in this repository.
Path rules: guidance that appears only when you open that file
Each of the six files in rules/ has a paths: field and enters the context only when those paths are touched. Same principle: if the whole rulebook is read every time, nobody reads it.
app-js.md—backend/static/app.jshas its whole-file SHA-256 embedded in the security check script, so "a single changed character blocks the deploy, and the message at the blocking point is misleading (it mentions streamSSE)".pipeline.md— "A push is polled by Jenkins every minute and goes to production without approval (about 20 minutes). Deploy only through GitOps."migrations.md— "Never modify a migration that has already been applied."generated.md— a table of files that vanish on the next generation if edited by hand, with their generators.curriculum.md— "Test graders in both directions — checking only that the right answer passes is half the job."tests.md— "Write in the test's docstring why the test exists and what accident it came from."
Tests watch these rules. tests/test_claude_harness.py checks that each paths: matches real files and that each body points back to AGENTS.md or scripts/.
Hooks: four things blocked, two things asked
hooks/guard.py receives the tool call on standard input before it runs and returns deny or ask on standard output. It contains exactly four things, each from a real accident.
GIT_STATE = re.compile(
r"\bgit\s+(?:stash|checkout|switch|restore|reset|rebase|merge|pull|clean)\b")
GIT_OK = re.compile(r"\bgit\s+(?:worktree|stash\s+list)\b")
KUBECTL_WRITE = re.compile(
r"\bkubectl\b[^|;&]*\s(?:apply|delete|patch|scale|edit|replace|annotate|label|cordon|drain)\b")
LOCKED = "backend/static/app.js"
- Git commands that change the working tree are denied. The comment gives the reason: "One
git stashreverted the edits of four other people." - kubectl commands that change cluster state are denied. Only reads and temporary pods are allowed.
- A
git pushthat bypassespush.shgets ask, with evidence attached: "Today alone that gate blocked four CI failures." - Editing
app.jsgets ask, because forgetting the digest update blocks the deploy silently.
hooks/after_edit.py looks only at the file just edited: ast.parse for Python, node --check for JavaScript, json.loads for JSON. The full test suite takes over two minutes and cannot run on every edit, so this catches only "does this file still stand on its own" and leaves the rest to the gate.
The danger of hooks is in one README sentence: "Hooks die silently — if you change one and do not run the tests, the accident it was meant to prevent simply happens." So the 13 tests in tests/test_claude_hooks.py feed JSON directly to the hooks and confirm deny, ask, and pass.
Subagents: roles that only read
None of the three roles in agents/ has a writing tool.
| Name | Tools | Role | Does not |
|---|---|---|---|
| safe-researcher | Read, Grep, Glob, Bash | Reads code, docs, cluster state and summarises | Edit/Write, git state changes, kubectl writes |
| grader-reviewer | Read, Grep, Glob, Bash | Reviews graders in both directions | Fix the grader; returns verdicts and counterexamples only |
| deploy-watcher | Read, Bash | Watches build and rollout after a push | kubectl writes, retries |
A sentence in grader-reviewer's definition says why the role exists: "There really were several 'graders that always pass', and that is worse than none." The .claude/ survey for this post was itself delegated to safe-researcher. It saves the parent's context and removes any way to accidentally modify something during research.
Tests enforce this too: at least three agents, no Edit, Write or NotebookEdit in tools, and a negative phrase such as "does not" in the description.
Procedures as scripts: push.sh and gate.sh
The centre of gravity is the eight scripts in scripts/agent/. Two are the core.
push.sh is the only road to commit and push. It never commits in the working tree. It creates a fresh worktree from origin/main, copies only the files I named into it, runs the gate there, then commits and pushes. If someone else is editing the same file, instead of copying the whole file you pass APPLY="script:path", a script that applies only your change. If CI has pushed a deploy commit during the four minutes the gate runs and origin has moved ahead, your single commit is re-applied on the new origin/main by cherry-pick, up to three times. That retry happened 23 times in this session's logs.
The comment explains why the worktree path differs on every run: "With a fixed path two pushes overlapped and deleted each other's directories, and the test logs became entirely false."
gate.sh runs locally the same thing as CI's Verify stage: the security check, the curriculum check, the grader audit, then every unit test. The header comment explains why it exists.
Built after builds 441–446 failed six times at the same spot. Tests were being
hand-picked for every push, and while editing the curriculum test_i18n_catalog
was not picked. The moment you pick, you miss. So run everything, and filter
local-only failures (uninstalled dependencies, no DB) against gate_baseline.txt.
The comparison with the baseline uses comm -13. It used to be diff | grep, but under set -o pipefail diff's exit code failed the whole pipeline, so a new failure was found and then ignored. That is how test_ko_source_is_current leaked three times, builds 451–453. The incident is nailed down as a test: gate.sh must use comm -13 and must not use any diff other than git diff.
The node tests have a 300-second watchdog attached. node --test has no default time limit, and "activation.test.js hung twice on the Mac and held the whole push".
The locked file and the evidence of deployment
bumpdigest.py updates the digest of app.js, but always does three things together. It compares the count of trust boundaries (11 patterns such as streamSSE, renderTrustedLessonMarkdown, innerHTML, eval() against the file before the change; anything that differs must be explicitly approved with ALLOW; and a NOTE explaining what changed and why is attached to the history block. Expected values are not hard-coded as numbers because "it once blocked falsely after someone else's legitimate change". Five tests feed temporary files to this logic: unapproved changes rejected, the same history written twice rejected, an empty NOTE rejected.
deploy_status.sh "does not trust ArgoCD's green Synced". Both apps have selfHeal, so the screen is always green, and an image three days old really was shown as Synced once. So it reads four things separately: the results and durations of the last Jenkins builds, gitops newTag versus the actual Deployment image tag, the state of both ArgoCD apps, and whether the /app.js served by the production site is byte-for-byte identical to the file in that commit. The static file is the final evidence of a deploy.
The harness tests itself
The 21 tests in tests/test_claude_harness.py check the structure of the harness, not the code. A few:
- A skill's description must exceed 80 characters, contain "use", and say when not to use it.
- A skill body must have all four sections:
## Why,## Procedure,## Output format,## Do not. - Every
scripts/agent/*a skill mentions must exist;.shfiles must be executable and passbash -n,.pyfiles must passcompile(). - Nowhere in
.claude/orscripts/agent/may there be a machine-specific path such as/Users/or/private/tmp/claude. push.shmust containgit worktree addand nogit stash, and anygit checkoutmust be together withorigin/main.- The README must mention every skill directory and every plugin in
enabledPlugins, andcommit-commandsmust not be there.
Thanks to these tests the structure has not changed since it was introduced. Only two commits touched .claude/ and scripts/agent/: the introducing commit and one that tidied two lines of the baseline.
What survives outside the session: memory
If the harness inside the repository is "rules", what remains outside it is "experience". The memory directory of this working environment holds 65 files: 53 project facts, 9 pieces of user feedback, 3 external references. Each file holds one fact plus "why" and "how to apply", and one index line per file is read at the start of every session.
A few written this week show what gets kept: "the gate reads the baseline in my local copy, not the worktree", "making something is not the same as showing it", "a hand-made Job easily loses its pod labels", "a CronJob failure holds ArgoCD until the next success". None of these has a place in the code, and all of them would go stale in a document.
What actually happened in this session
Accidents still happen with a harness. They just happen at the blocking point. Yesterday, pushing a one-line validator fix took four push runs.
push.shhad been changed to take a commit message file as its first argument. Given a string, it ended with "commit message file not found".- The gate caught
test_production_cli_…_fails_unprovisionedas a new failure. It was an environmental failure unrelated to my change: cryptography 50.0.1 on this Mac differed from the locked 50.0.0, so a different error came first. - The same failure was caught again. The cause: gate.sh reads
gate_baseline.txtfrom the local copy, not the worktree, and the local copy was stale and the file empty. - With the baseline fixed it passed, and since CI had pushed a deploy commit in the meantime it went through "origin is ahead — re-applying (1)" before it was pushed.
None of the four reached production. That is what all 41 gate blocks look like. And the lesson from step 3 became a memory file so the next session does not stop at the same spot.
There was one trap in an APPLY script too. The script first checks "has this already been applied?", and the key for that check was a string that also existed in the original (resolve(demo.map(lang))), so it said "already there" and did nothing. The key must be a string that only the new version contains.
Limits
This harness is not finished. The documentation itself names two limits.
There is no human approval stage. From docs/CI-CD.md: "Whatever is pushed to main goes straight to production in the same job once it passes Verify and the dev E2E. Those two gates are all that protects production, which is why what hangs on them matters." The approval gate is written down as the goal of the fail-closed design and does not run yet.
The baseline differs per machine. gate_baseline.txt filters failures of the local environment, yet it lives in the repository while in practice it must differ from machine to machine. Incident 3 above came from that contradiction.
And during a rolling deploy two pods serve different builds. Yesterday the browser kept a script received from the old pod in its cache and a new feature was briefly invisible. The harness protects commits and deploys; it does not yet protect the browser cache.
In one line
A harness is not about writing more rules; it is about stacking, one at a time, mechanisms that automatically block accidents that actually happened, at the spot where they happened. Rules in one place, mechanisms at the chokepoints of tool calls, commits and deploys, experience in memory outside the session. In this repository those three layers run dozens of times a day, and the result is recorded as 41 gate blocks.