Merge branch 'harness/B-implementation' into harness/A-integration

This commit is contained in:
DongHyeonka
2026-09-10 11:07:35 +09:00
61 changed files with 3870 additions and 4 deletions
@@ -1,6 +1,9 @@
--- ---
name: analyzing-codebase-for-tech-log name: analyzing-codebase-for-tech-log
description: Use when a project under <분석 대상 저장소> must be deeply analyzed and documented under docs, especially when the repository is too large for one pass and analysis must proceed by bounded module or subsystem. description: Use when a project under <분석 대상 저장소> must be deeply analyzed and documented under docs, especially when the repository is too large for one pass and analysis must proceed by bounded module or subsystem.
metadata:
version: "1.0.0"
language: "ko-KR"
--- ---
# Analyzing Codebase For Tech Log # Analyzing Codebase For Tech Log
@@ -1,6 +1,9 @@
--- ---
name: deriving-tech-log-root-tree name: deriving-tech-log-root-tree
description: Use when a completed or substantially completed docs project analysis must be decomposed into grounded Tech Log Topics and candidate Case, Concept, Reference, Open Question, and Decision records. description: Use when a completed or substantially completed docs project analysis must be decomposed into grounded Tech Log Topics and candidate Case, Concept, Reference, Open Question, and Decision records.
metadata:
version: "1.0.0"
language: "ko-KR"
--- ---
# Deriving Tech Log Root Tree # Deriving Tech Log Root Tree
@@ -1,6 +1,9 @@
--- ---
name: publishing-tech-log-to-studio name: publishing-tech-log-to-studio
description: Use when a finished Tech Log record .md must be put into Tech Log Studio through the browser with Playwright MCP — creating or opening the working copy, uploading assets, filling the per-kind fields, and saving. Save only; this skill never publishes. description: Use when a finished Tech Log record .md must be put into Tech Log Studio through the browser with Playwright MCP — creating or opening the working copy, uploading assets, filling the per-kind fields, and saving. Save only; this skill never publishes.
metadata:
version: "1.0.0"
language: "ko-KR"
--- ---
# Studio 반입 — 저장까지만 # Studio 반입 — 저장까지만
@@ -1,6 +1,9 @@
--- ---
name: refactoring-from-analysis name: refactoring-from-analysis
description: Use when a completed codebase analysis should be turned into one bounded, evidence-backed refactoring WorkItem and implemented in isolation. description: Use when a completed codebase analysis should be turned into one bounded, evidence-backed refactoring WorkItem and implemented in isolation.
metadata:
version: "1.0.0"
language: "ko-KR"
--- ---
# Refactoring From Analysis # Refactoring From Analysis
@@ -1,6 +1,9 @@
--- ---
name: rewriting-technical-prose-naturally name: rewriting-technical-prose-naturally
description: Use when an existing Korean technical document, section, or heading already contains grounded facts but sounds AI-written, slogan-like, overly polished, abstract, compressed, or mechanically contrasted and must be rewritten without changing its technical meaning. description: Use when an existing Korean technical document, section, or heading already contains grounded facts but sounds AI-written, slogan-like, overly polished, abstract, compressed, or mechanically contrasted and must be rewritten without changing its technical meaning.
metadata:
version: "1.0.0"
language: "ko-KR"
--- ---
# Rewriting Technical Prose Naturally # Rewriting Technical Prose Naturally
@@ -1,6 +1,9 @@
--- ---
name: running-tech-log-pipeline name: running-tech-log-pipeline
description: Use when a codebase must go all the way to a saved Tech Log Studio draft — running the seven stages (SSOT, tree, record, diagram, prose, voice, Studio save) as separate subagents, one skill per stage, with a run ledger that records which skill each stage actually used and which gate it passed. description: Use when a codebase must go all the way to a saved Tech Log Studio draft — running the seven stages (SSOT, tree, record, diagram, prose, voice, Studio save) as separate subagents, one skill per stage, with a run ledger that records which skill each stage actually used and which gate it passed.
metadata:
version: "1.0.0"
language: "ko-KR"
--- ---
# Tech Log 파이프라인 실행 # Tech Log 파이프라인 실행
@@ -1,6 +1,9 @@
--- ---
name: technical-visualizer name: technical-visualizer
description: Create source-grounded, diagram-only technical visuals from nearby documentation context. Select a logical composition grammar, compile VizSpec 1.1 into SVG and editable formats, and reject disconnected-card output. description: Create source-grounded, diagram-only technical visuals from nearby documentation context. Select a logical composition grammar, compile VizSpec 1.1 into SVG and editable formats, and reject disconnected-card output.
metadata:
version: "1.0.0"
language: "ko-KR"
--- ---
# Technical Visualizer # Technical Visualizer
@@ -1,6 +1,9 @@
--- ---
name: writing-as-the-person-who-did-it name: writing-as-the-person-who-did-it
description: Use when a Korean technical document is accurate, well-ordered and well-evidenced but reads like a report produced by nobody — no one chose anything, nothing surprised anyone, and the limits are an inventory instead of an admission. description: Use when a Korean technical document is accurate, well-ordered and well-evidenced but reads like a report produced by nobody — no one chose anything, nothing surprised anyone, and the limits are an inventory instead of an admission.
metadata:
version: "1.0.0"
language: "ko-KR"
--- ---
# 일한 사람이 쓴 글로 만들기 # 일한 사람이 쓴 글로 만들기
@@ -0,0 +1,2 @@
그림의 정본 — <이름>/{context.json, spec.json, prompt.md}.
technical-visualizer 스킬이 만든다. 손으로 SVG 를 그리지 않는다.
@@ -0,0 +1,3 @@
그림. 그림 하나가 폴더 하나다 — <이름>/<이름>.svg 와 편집 형식들.
기록의 assets: file: 도 이 폴더를 가리킨다. Studio 에 올릴 사본을 따로 두지 않는다 —
사본을 두면 정본이 둘이 되고, 사본 쪽에는 ../../.techviz/<이름>/ 이 없어 다시 만들 수 없다.
+345
View File
@@ -0,0 +1,345 @@
# document-haness 의 검사 층 — 관문이 무엇을 보고 무엇을 안 보는가
이 문서는 `document-haness` 저장소 자신의 검사 층을 대상으로 삼는다. 이 저장소는 기술 글을
만드는 파이프라인이고, 그 파이프라인이 글을 통과시킬지 말지는 `scripts/` 의 검사기와
스킬이 들고 있는 `.mjs` 검사기가 정한다. **그 검사기들이 실제로 무엇을 보는지**를 실행으로
확인한 기록이다.
| | |
|---|---|
| 대상 저장소 | `document-haness` |
| 리비전 | `43e1aadef077ad93c30495df428ee3a71dd73f4a` (`main`) |
| 확인한 worktree | `dh-B` (`harness/B-implementation`, 같은 커밋에서 갈라짐) |
| 확인한 날 | 2026-09-10 |
| 실행 환경 | python 3.12.3 · node v24.14.0 · bash 5.2.21(1)-release |
---
## §1 검사기 열넷이 있는 자리
`scripts/` 아래에 `.py` 파일 열넷이 있다. 리비전 `43e1aad` 에서 센 값이다.
```
audit-records.py
build-tech-log-tree.py
check-figure-overlap.py
check-figure-text.py
fold-analysis-into-final.py
fold-studio-contract-into-index.py
preview-figure.py
studio-body.py
techlog.py
verify-pipeline-run.py
verify-pipeline.py
verify-project-layout.py
verify-refactor-work-item.py
verify-tech-log-tree.py
```
이 중 `techlog.py` 는 CLI 가 아니라 공유 라이브러리다. `fold-*.py` 둘은 검사기가 아니라
작업 재료를 옮기는 도구다. 나머지 열하나가 관문으로 불린다.
관문은 **종료 코드로 말한다.** `running-tech-log-pipeline` 의 단계 계약이
「관문은 종료 코드가 0 이어야 지난 것이다. 0 이 아니면 그 단계는 `FAILED` 이고 다음 단계로
넘어가지 않는다」 라고 적었고, 런 원장 `runs/<프로젝트>/<runId>/run.json`
`stages[].gates[].exit` 에 그 값이 남는다.
## §2 관찰한 것
검사기 목록을 만들려고 열넷에 `--help` 를 돌렸다. 한 줄짜리 셸 반복문이었고, 출력이 길어서
`head` 로 잘랐다.
```bash
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null
code=$?
n=$(basename $s)
echo "$n exit=$code"
done
```
열넷이 전부 `exit=0` 이었다.
```
audit-records.py exit=0
build-tech-log-tree.py exit=0
check-figure-overlap.py exit=0
check-figure-text.py exit=0
fold-analysis-into-final.py exit=0
fold-studio-contract-into-index.py exit=0
preview-figure.py exit=0
studio-body.py exit=0
techlog.py exit=0
verify-pipeline-run.py exit=0
verify-pipeline.py exit=0
verify-project-layout.py exit=0
verify-refactor-work-item.py exit=0
verify-tech-log-tree.py exit=0
```
이 저장소를 함께 조사한 다른 세션도 같은 값을 얻어 「`scripts/*.py` 14개 전부 `--help`
종료 코드 0 으로 돌아온다」 를 확인 등급 **확인함**으로 적었다. 두 사람이 같은 값을 얻었으니
맞는 값처럼 보였다.
## §3 파이프 뒤의 종료 코드
틀린 것은 검사기가 아니라 재는 방법이었다. 파이프라인의 `$?`**마지막** 명령의 종료
코드다. `head` 는 언제나 성공하므로 앞의 `python3` 가 무엇을 반환하든 `$?` 는 0 이 된다.
```bash
set +o pipefail
false | head -1; echo "false | head -1 -> exit=$?"
false; echo "false -> exit=$?"
```
```
false | head -1 -> exit=0
false -> exit=1
bash 5.2.21(1)-release
```
POSIX 셸의 정의된 동작이고 이 셸의 특이점이 아니다. `pipefail` 을 켜거나
`${PIPESTATUS[0]}` 를 읽으면 앞 명령의 값을 얻는다.
같은 착각이 한 번 더 났다. 파이프를 걷어 내고 다시 잴 때 이렇게 썼다.
```bash
out=$(timeout 60 python3 $s --help 2>&1)
echo "$(basename $s) exit=$?"
```
명령 치환 `$(basename $s)` 가 먼저 실행되면서 `$?` 를 덮어썼다. 그래서 두 번째 측정도
열넷 전부 0 이었다. `code=$?` 를 명령 바로 다음 줄에 두고서야 값이 갈렸다.
## §4 다시 잰 값
```bash
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
out=$(timeout 60 python3 $s --help 2>&1)
code=$?
n=$(basename $s)
echo "$n exit=$code"
done
```
```
audit-records.py exit=0
build-tech-log-tree.py exit=1
check-figure-overlap.py exit=0
check-figure-text.py exit=0
fold-analysis-into-final.py exit=0
fold-studio-contract-into-index.py exit=0
preview-figure.py exit=0
studio-body.py exit=0
techlog.py exit=0
verify-pipeline-run.py exit=0
verify-pipeline.py exit=0
verify-project-layout.py exit=0
verify-refactor-work-item.py exit=1
verify-tech-log-tree.py exit=0
```
두 측정의 차이는 두 줄뿐이다.
```
2c2
< build-tech-log-tree.py exit=0
---
> build-tech-log-tree.py exit=1
13c13
< verify-refactor-work-item.py exit=0
---
> verify-refactor-work-item.py exit=1
```
## §5 argparse 를 쓰지 않는 넷
열넷 중 넷이 `argparse` 를 쓰지 않는다.
```
ARGPARSE 없음 audit-records.py
ARGPARSE 없음 build-tech-log-tree.py
argparse check-figure-overlap.py
argparse check-figure-text.py
argparse fold-analysis-into-final.py
argparse fold-studio-contract-into-index.py
argparse preview-figure.py
argparse studio-body.py
ARGPARSE 없음 techlog.py
argparse verify-pipeline-run.py
argparse verify-pipeline.py
argparse verify-project-layout.py
ARGPARSE 없음 verify-refactor-work-item.py
argparse verify-tech-log-tree.py
```
`techlog.py` 는 CLI 가 아니라서 인자를 안 읽는다. 남은 셋은 `--help` 를 **옵션이 아니라
위치 인자로 먹는다.** `audit-records.py` 는 그것을 프로젝트 이름으로 받아 「문제 없음」을
찍고, `build-tech-log-tree.py``verify-refactor-work-item.py` 는 그 이름의 파일을 못 찾아
실패한다.
```
$ python3 scripts/audit-records.py --help
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
문제 없음
합계 0건
exit=0
$ python3 scripts/build-tech-log-tree.py --help
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
exit=1
$ python3 scripts/verify-refactor-work-item.py --help
REFACTOR WORK ITEM VERIFICATION: FAIL
- invalid work-item.json: --help/work-item.json
exit=1
```
**`--help` 가 exit 0 이라는 것이 usage 가 나왔다는 뜻은 아니다.** `audit-records.py`
`--help` 라는 이름의 프로젝트를 검사해서 통과시킨 것이다.
## §6 실재하지 않는 프로젝트 이름
`audit-records.py --help` 가 「문제 없음」을 찍은 것은 `--help` 가 특별해서가 아니다.
**없는 프로젝트를 검사하면 검사할 것이 없어 통과가 된다.** 같은 이름
`nonexistent-project` 를 여섯 관문에 주면 이렇게 갈린다.
```
$ python3 scripts/audit-records.py nonexistent-project
nonexistent-project — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
문제 없음
합계 0건
exit=0
$ python3 scripts/verify-tech-log-tree.py nonexistent-project
[nonexistent-project] records=0
! warn 1 분해 계약 없음
· nonexistent-project: tech-log-tree.json 이 없다. 디렉터리가 정본 노릇을 하고 있다
TECH LOG TREE: PASS — 프로젝트 1 · error 0 · warn 1
exit=0
$ python3 scripts/check-figure-text.py nonexistent-project
FIGURE TEXT: PASS — 그림 0장 · 문장 0건
exit=0
$ python3 scripts/check-figure-overlap.py nonexistent-project
볼 그림이 없다
exit=0
$ python3 scripts/verify-project-layout.py nonexistent-project
PROJECT LAYOUT: FAIL — 프로젝트 1 · error 1 · warn 0
[nonexistent-project] —
✗ error 1 final/document.md 가 없다
· nonexistent-project
exit=1
$ node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs nonexistent-project --repo
nonexistent-project: tech-log-tree.json 이 없다
exit=2
```
여섯 중 넷이 아무것도 검사하지 않고 통과를 보고한다. 잡아내는 것은
`verify-project-layout.py`(`final/document.md` 를 먼저 찾는다)와
`check_evidence.mjs`(`tech-log-tree.json` 을 먼저 찾는다) 둘뿐이다.
## §7 원장은 종료 코드를 다시 재지 않는다
런 원장 검사기 `scripts/verify-pipeline-run.py` 가 관문에 대해 보는 것은 두 가지다.
```python
cmds = " ; ".join(str(g.get("cmd") or "") for g in gates)
for token in spec["gates"]:
if token not in cmds:
...
for g in gates:
if g.get("exit") not in (0, "0"):
...
```
관문 명령을 **다시 실행하지 않는다.** `exit` 는 그 런이 스스로 적어 넣은 숫자다. 그리고
`cmd` 안에 토큰 문자열(`"check-figure-text.py"`)이 있는지만 보고 **어느 프로젝트에 돌렸는지는
보지 않는다.**
§6 과 이어 붙이면 이렇게 된다. 원장에
`{"cmd": "python3 scripts/check-figure-text.py <오타난 이름>", "exit": 0}` 을 적으면 그 명령은
실제로도 exit 0 이고, 원장 검사기도 통과시킨다. 그림을 한 장도 검사하지 않은 런이 「절차를
지켰다」로 판정된다.
실제 원장에도 명령이 자리표시자가 든 요약형으로 적혀 있어 그대로 재실행할 수 없다.
```
"cmd": "python3 scripts/studio-body.py <기록.md> -o /tmp/sb-<slug>.md (44건)"
```
## §8 종료 코드를 손으로 적을 수 없게 만든다
관문 결과를 사람이 옮겨 적는 한 §2 와 §7 은 같은 뿌리에서 계속 난다. 그래서 명령을 돌리는
자리와 종료 코드를 적는 자리를 하나로 붙였다. `scripts/capture-evidence.py` 는 명령을
`subprocess` 로 직접 실행하고 **그 프로세스의 반환값을 그대로** 메타에 적는다.
```python
proc = subprocess.run(command, cwd=cwd, capture_output=True,
text=True, timeout=timeout)
exit_code, out = proc.returncode, proc.stdout + proc.stderr
```
셸을 거치지 않으므로 파이프가 낄 자리가 없고, 종료 코드를 인자로 받지 않으므로 손으로 적을
경로도 없다. 원문은 `final/evidence/raw/` 에, 실행 메타는 `final/evidence/meta/` 에 같은
이름으로 함께 떨어진다.
실제로 떨어진 메타 하나다. `§4` 의 측정을 담은 것이다.
```json
{
"id": "help-exit-codes-measured-without-a-pipe",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:54:41+09:00",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
"bytes": 410
}
```
여기서 `exitCode: 0` 은 **측정 반복문 자체가 성공했다**는 뜻이다. 열넷 각각의 종료 코드는
원문 `raw/` 안에 있다. 반복문의 종료 코드와 그 안에서 잰 값은 다른 것이고, 메타는 앞의 것을
적는다.
`sourceDirty` 는 그 실행 시점에 작업 트리에 커밋 안 된 변경이 있었는지다. 있으면
`sourceRevision` 이 그 출력을 설명하지 못한다.
처음 판에서는 이 도구도 인자를 잘못 먹었다. `argparse.REMAINDER` 로 명령을 받았더니
`--proves` 부터가 실행할 명령으로 딸려 가 `No such file or directory: '--proves'` 로 죽었다.
`--` 앞뒤를 직접 가르도록 고쳤고 그 이유를 코드에 한 줄로 남겼다.
```python
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
argv = sys.argv[1:]
```
§5 에서 `--help` 를 위치 인자로 먹은 세 스크립트와 같은 종류의 실수다.
셸 한 줄을 감싸는 래퍼로 만들 수도 있었다. 그러면 파이프를 다시 쓸 수 있게 되고, 값을 두 번
틀리게 만든 것이 그 셸이라 아예 거치지 않기로 했다. 감수한 것은 셸 문법이 필요한 명령을
`bash -c` 로 넘겨야 한다는 점이다 — 그 안에서 다시 파이프를 쓰면 같은 실수가 난다.
기존 경로를 지우지 않았다. 손으로 만든 `raw/`·`meta/` 도 그대로 유효하고 이 도구는
선택적으로 부른다.
## §9 확인하지 못한 것
- **셸을 쓰는 다른 자리.** `--help` 하나만 쟀다. 다른 인자에서 같은 모양인지는 안 봤다.
- **`argparse` 를 쓰는 열이 모든 인자를 옳게 다루는지.** `--help` 만 통과한 것을 확인했다.
- **`nonexistent-project` 넷이 실재하는 프로젝트에서도 통과만 내는지.** 아니다 —
`check-figure-text.py ca-tmpl` 은 exit 1 을 낸다. §6 은 「대상이 없을 때」의 동작이다.
- **`capture-evidence.py` 가 이 저장소의 모든 증거 수집을 대체할 수 있는지.** 브라우저 캡처와
대화형 명령은 이 경로로 못 담는다.
- **원장 검사기 수정의 영향.** §7 을 고치는 것은 이 저장소의 다른 담당 몫이고 여기서는
현상만 적는다.
@@ -0,0 +1 @@
Playwright MCP 로 찍은 브라우저 캡처. 무엇을 찍었는지 한 줄을 적는다.
@@ -0,0 +1 @@
그 실행의 command·cwd·executedAt·exitCode·revision. 형식은 evidence.json.
@@ -0,0 +1,18 @@
{
"id": "argparse-absent-scripts",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:54:57+09:00",
"executedAtSource": "이 도구가 명령을 실행한 시각",
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\\.py$'); do if grep -q '^import argparse' $s; then echo \"argparse $(basename $s)\"; else echo \"ARGPARSE 없음 $(basename $s)\"; fi; done",
"cwd": ".",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/argparse-absent-scripts.txt",
"presentationPath": null,
"proves": "14개 중 넷이 argparse 를 쓰지 않는다",
"doesNotProve": "argparse 를 쓰는 열이 모든 인자를 옳게 다룬다는 것",
"sha256": "2412e5266e3889012f6a0c9732234dbfcc1a611c3368c1851d61b6c40307ff67",
"bytes": 496
}
@@ -0,0 +1,18 @@
{
"id": "exit-code-through-a-pipe",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:54:19+09:00",
"executedAtSource": "이 도구가 명령을 실행한 시각",
"command": "bash -c set +o pipefail; false | head -1; echo \"false | head -1 -> exit=$?\"; false; echo \"false -> exit=$?\"; echo \"bash $BASH_VERSION\"",
"cwd": ".",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/exit-code-through-a-pipe.txt",
"presentationPath": null,
"proves": "파이프의 마지막 명령이 성공하면 $? 가 0 이다. 앞 명령의 종료 코드는 사라진다",
"doesNotProve": "이 셸이 특별하다는 것. POSIX 셸의 정의된 동작이다",
"sha256": "b17529b23ee69c887fbf28a474a7c566c9d9d3ef2e5b0d1c115e90649d60c36f",
"bytes": 77
}
@@ -0,0 +1,18 @@
{
"id": "gates-on-a-nonexistent-project",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:57:21+09:00",
"executedAtSource": "이 도구가 명령을 실행한 시각",
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; for cmd in 'python3 scripts/audit-records.py nonexistent-project' 'python3 scripts/verify-tech-log-tree.py nonexistent-project' 'python3 scripts/check-figure-text.py nonexistent-project' 'python3 scripts/check-figure-overlap.py nonexistent-project' 'python3 scripts/verify-project-layout.py nonexistent-project' 'node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs nonexistent-project --repo'; do out=$(timeout 120 sh -c \"$cmd\" 2>&1); code=$?; echo \"\\$ $cmd\"; echo \"$out\" | grep -v '^$' | head -4; echo \"exit=$code\"; echo; done",
"cwd": ".",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/gates-on-a-nonexistent-project.txt",
"presentationPath": null,
"proves": "실재하지 않는 프로젝트 이름에 검사기 여섯 중 넷이 exit 0 을 낸다",
"doesNotProve": "그 넷이 실재하는 프로젝트에서도 통과만 낸다는 것. check-figure-text.py ca-tmpl 은 exit 1 이다",
"sha256": "3afad37da2e49290c5cf75a6970562b649fd5ddab6e54870ba9d45dd4c80ab8f",
"bytes": 1080
}
@@ -0,0 +1,18 @@
{
"id": "help-exit-codes-measured-through-a-pipe",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:54:41+09:00",
"executedAtSource": "이 도구가 명령을 실행한 시각",
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\\.py$'); do timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null; code=$?; n=$(basename $s); echo \"$n exit=$code\"; done",
"cwd": ".",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/help-exit-codes-measured-through-a-pipe.txt",
"presentationPath": null,
"proves": "파이프를 끼고 재면 43e1aad 의 scripts/*.py 14개가 전부 --help exit 0 으로 보인다",
"doesNotProve": "그 14개가 실제로 --help 를 지원한다는 것",
"sha256": "0827ec5c575d24c9998fa79f3cb26f0d4e98b1c647e5121d9e651b13784ff611",
"bytes": 410
}
@@ -0,0 +1,18 @@
{
"id": "help-exit-codes-measured-without-a-pipe",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:54:41+09:00",
"executedAtSource": "이 도구가 명령을 실행한 시각",
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\\.py$'); do out=$(timeout 60 python3 $s --help 2>&1); code=$?; n=$(basename $s); echo \"$n exit=$code\"; done",
"cwd": ".",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
"presentationPath": null,
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
"bytes": 410
}
@@ -0,0 +1,18 @@
{
"id": "redaction-covers-command-lines",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T10:09:26+09:00",
"executedAtSource": "이 도구가 명령을 실행한 시각",
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; python3 scripts/terminal-evidence/render_terminal.py /tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/raw.txt /tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/out.svg --command 'cat evidence-sample.txt' --cwd /tmp --exit-code 0 --executed-at 2026-09-10T10:20:00+09:00; echo '--- 렌더한 SVG 의 text 노드 ---'; python3 -c \"\nimport re\ns=open('/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/out.svg').read()\nfor t in re.findall(r'<text[^>]*>(.*?)</text>', s, re.S):\n if any(k in t for k in ('curl','psql','Authorization','GET')): print(t)\nprint('TESTONLY 남은 횟수:', s.count('TESTONLY'))\n\"",
"cwd": ".",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/guards/redaction-covers-command-lines.txt",
"presentationPath": null,
"proves": "렌더러가 명령 인자 안의 Bearer·Cookie 와 접속 문자열 암호를 [REDACTED] 로 덮고, 렌더한 SVG 에 합성 자격증명이 한 건도 남지 않는다",
"doesNotProve": "raw 원문에 secret 이 들어가도 된다는 것. 렌더러 마스킹은 그물이지 방벽이 아니다",
"sha256": "2fa64df31ba1dea4194ea744a33c2e4fb921e0a45293f46cdf70e89e8ce7c2bb",
"bytes": 320
}
@@ -0,0 +1,18 @@
{
"id": "redaction-keeps-the-command-intact",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T10:33:11+09:00",
"executedAtSource": "이 도구가 명령을 실행한 시각",
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; python3 -c \"\nimport importlib.util, html, re, subprocess\nspec=importlib.util.spec_from_file_location('rt','scripts/terminal-evidence/render_terminal.py')\nm=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)\nraw=open('/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/raw2.txt').read().splitlines()\nsubprocess.run(['python3','scripts/terminal-evidence/render_terminal.py','/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/raw2.txt','/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/out2.svg','--command','cat evidence-sample.txt','--cwd','/tmp','--exit-code','0','--executed-at','2026-09-10T10:45:00+09:00'],capture_output=True)\nsvg=open('/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/out2.svg').read()\nprint('원문 줄 -> 렌더 줄 (따옴표 수)')\nfor line in raw:\n out=m.redact_line(line)\n print(f' {line.count(chr(34))} -> {out.count(chr(34))} {out}')\nprint()\nprint('렌더한 SVG 에 TESTONLY 가 남은 횟수:', svg.count('TESTONLY'))\n\"",
"cwd": ".",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/guards/redaction-keeps-the-command-intact.txt",
"presentationPath": null,
"proves": "마스킹이 자격증명을 덮으면서 줄마다 따옴표 수를 그대로 둔다. 합성값(TESTONLY-*)이 렌더 결과에 하나도 안 남는다",
"doesNotProve": "모든 자격증명 모양을 덮는다는 것. 확인한 것은 여기 여섯 줄이다",
"sha256": "40ed59329c1b4d1a66e49c941216c8953c1f63f0a459f2f583c48e060d0ce4b2",
"bytes": 464
}
@@ -0,0 +1,18 @@
{
"id": "what-the-three-print-for-help",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:57:21+09:00",
"executedAtSource": "이 도구가 명령을 실행한 시각",
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; for s in audit-records build-tech-log-tree verify-refactor-work-item; do echo \"\\$ python3 scripts/$s.py --help\"; out=$(timeout 60 python3 scripts/$s.py --help 2>&1); code=$?; echo \"$out\"; echo \"exit=$code\"; echo; done",
"cwd": ".",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/what-the-three-print-for-help.txt",
"presentationPath": null,
"proves": "argparse 없는 넷 가운데 CLI 인 셋이 --help 를 프로젝트 이름·경로로 먹는다",
"doesNotProve": "다른 잘못된 인자에서도 같은 모양이라는 것",
"sha256": "91101c72e788bce9c81d090fe605ba92433f67b94476cfcb641f6e55e75d0035",
"bytes": 409
}
@@ -0,0 +1,2 @@
명령 출력·csv·덤프 원문. 여기가 정본이다.
하위 폴더를 자유롭게 둔다(explain/, guards/). 폴더마다 무엇을 담았는지 한 줄을 README.txt 에 적는다.
@@ -0,0 +1,14 @@
ARGPARSE 없음 audit-records.py
ARGPARSE 없음 build-tech-log-tree.py
argparse check-figure-overlap.py
argparse check-figure-text.py
argparse fold-analysis-into-final.py
argparse fold-studio-contract-into-index.py
argparse preview-figure.py
argparse studio-body.py
ARGPARSE 없음 techlog.py
argparse verify-pipeline-run.py
argparse verify-pipeline.py
argparse verify-project-layout.py
ARGPARSE 없음 verify-refactor-work-item.py
argparse verify-tech-log-tree.py
@@ -0,0 +1,3 @@
false | head -1 -> exit=0
false -> exit=1
bash 5.2.21(1)-release
@@ -0,0 +1,32 @@
$ python3 scripts/audit-records.py nonexistent-project
nonexistent-project — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
문제 없음
합계 0건
exit=0
$ python3 scripts/verify-tech-log-tree.py nonexistent-project
[nonexistent-project] records=0
! warn 1 분해 계약 없음
· nonexistent-project: tech-log-tree.json 이 없다. 디렉터리가 정본 노릇을 하고 있다
TECH LOG TREE: PASS — 프로젝트 1 · error 0 · warn 1
exit=0
$ python3 scripts/check-figure-text.py nonexistent-project
FIGURE TEXT: PASS — 그림 0장 · 문장 0건
exit=0
$ python3 scripts/check-figure-overlap.py nonexistent-project
볼 그림이 없다
exit=0
$ python3 scripts/verify-project-layout.py nonexistent-project
PROJECT LAYOUT: FAIL — 프로젝트 1 · error 1 · warn 0
[nonexistent-project] —
✗ error 1 final/document.md 가 없다
· nonexistent-project
exit=1
$ node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs nonexistent-project --repo
nonexistent-project: tech-log-tree.json 이 없다
exit=2
@@ -0,0 +1,2 @@
터미널 증거 렌더러의 마스킹이 실제로 덮는지 확인한 출력.
쓰인 자격증명은 전부 합성(TESTONLY-*)이고 실제 비밀값이 아니다.
@@ -0,0 +1,7 @@
--- 렌더한 SVG 의 text 노드 ---
$ curl -H &quot;Authorization: Bearer [REDACTED]&quot; https://example.test/api
$ curl -H &quot;Cookie: [REDACTED]&quot; https://example.test/api
$ psql postgresql://app:[REDACTED]@db:5432/app
Authorization: Bearer [REDACTED]
GET /api/me -&gt; 200 in 14ms
TESTONLY 남은 횟수: 0
@@ -0,0 +1,9 @@
원문 줄 -> 렌더 줄 (따옴표 수)
2 -> 2 $ curl -H "X-Api-Key: [REDACTED]" https://example.invalid/d
2 -> 2 $ curl -H "Authorization: Basic [REDACTED]" https://example.invalid/d
2 -> 2 $ curl -H "Proxy-Authorization: Basic [REDACTED]" https://example.invalid/d
0 -> 0 $ curl -u admin:[REDACTED] https://example.invalid/d
2 -> 2 $ export TOKEN="[REDACTED]"
0 -> 0 GET /api/me -> 200 in 14ms
렌더한 SVG 에 TESTONLY 가 남은 횟수: 0
@@ -0,0 +1,14 @@
audit-records.py exit=0
build-tech-log-tree.py exit=0
check-figure-overlap.py exit=0
check-figure-text.py exit=0
fold-analysis-into-final.py exit=0
fold-studio-contract-into-index.py exit=0
preview-figure.py exit=0
studio-body.py exit=0
techlog.py exit=0
verify-pipeline-run.py exit=0
verify-pipeline.py exit=0
verify-project-layout.py exit=0
verify-refactor-work-item.py exit=0
verify-tech-log-tree.py exit=0
@@ -0,0 +1,14 @@
audit-records.py exit=0
build-tech-log-tree.py exit=1
check-figure-overlap.py exit=0
check-figure-text.py exit=0
fold-analysis-into-final.py exit=0
fold-studio-contract-into-index.py exit=0
preview-figure.py exit=0
studio-body.py exit=0
techlog.py exit=0
verify-pipeline-run.py exit=0
verify-pipeline.py exit=0
verify-project-layout.py exit=0
verify-refactor-work-item.py exit=1
verify-tech-log-tree.py exit=0
@@ -0,0 +1,17 @@
$ python3 scripts/audit-records.py --help
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
문제 없음
합계 0건
exit=0
$ python3 scripts/build-tech-log-tree.py --help
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
exit=1
$ python3 scripts/verify-refactor-work-item.py --help
REFACTOR WORK ITEM VERIFICATION: FAIL
- invalid work-item.json: --help/work-item.json
exit=1
@@ -0,0 +1,2 @@
raw 에서 만든 터미널 SVG. 표현물이지 정본이 아니다.
scripts/terminal-evidence/render_terminal.py 로 만든다.
@@ -0,0 +1,283 @@
---
id:
kind: CASE
slug: exit-code-read-behind-a-pipe
title: 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
topic: pipeline-gate-exit-codes
topicName: 관문의 종료 코드
project: document-haness
status: 게시 전
studio: ""
lastVerifiedOn: 2026-09-10
source:
- final/document.md#§2-관찰한-것
- final/document.md#§3-파이프-뒤의-종료-코드
- final/document.md#§4-다시-잰-값
- final/document.md#§5-argparse-를-쓰지-않는-넷
- final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다
sourceRevision: 43e1aadef077ad93c30495df428ee3a71dd73f4a
evidence:
- ../../../final/evidence/raw/exit-code-through-a-pipe.txt
- ../../../final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt
- ../../../final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt
- ../../../final/evidence/raw/argparse-absent-scripts.txt
- ../../../final/evidence/raw/what-the-three-print-for-help.txt
---
# 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
scripts/ 의 검사기 열넷에 --help 를 돌려 전부 종료 코드 0 을 받았고, 이 저장소를 함께
조사한 다른 세션도 같은 값을 얻어 확인 등급 확인함으로 적었다. 값은 검사기가 아니라 재는
방법이 만든 것이었다. 파이프를 걷어 내고 다시 재니 둘이 exit 1 이다.
## 관계
- **검사할 것이 없을 때 관문은 무엇을 내야 하는가**
이 사건에서 `audit-records.py --help` 가 「문제 없음」을 찍으면서 그 물음이 열렸다.
`--help` 라는 이름의 프로젝트에는 검사할 기록이 하나도 없어 그대로 통과했다.
## 문제
관문은 종료 코드로 말한다. 단계 계약이 「관문은 종료 코드가 0 이어야 지난 것이다」 라고
적었고, 런 원장 run.json 의 stages[].gates[].exit 에 그 값이 남는데, 그 값을 사람이 셸에서
읽어 옮겨 적는다.
--help 하나를 잰 것뿐인데 값이 두 번 틀렸다. 관문 결과 전부가 같은 방법으로 적히고 있다.
## 결론
리비전 43e1aad 의 scripts/*.py 열넷 중 열둘이 --help 에 exit 0,
build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다.
exit 0 이 usage 가 나왔다는 뜻은 아니다 — audit-records.py 는 --help 를 프로젝트
이름으로 받아 검사하고 통과시킨다.
값을 틀리게 만든 것은 셸의 동작 둘이다. 파이프의 종료 코드 변수는 마지막 명령을 가리키고,
명령 치환은 그 변수를 덮어쓴다. 둘 다 정의된 동작이라 셸이 경고하지 않는다.
조치로 만든 scripts/capture-evidence.py 는 명령을 subprocess 로 직접 돌리고 그 프로세스의
반환값을 그대로 메타에 적는다. 종료 코드를 인자로 받지 않으므로 손으로 적을 경로가 없다.
## 검증 환경
python 3.12.3 · node v24.14.0 · bash 5.2.21(1)-release · Linux.
대상 저장소 document-haness 리비전 43e1aadef077ad93c30495df428ee3a71dd73f4a.
측정은 그 커밋에서 갈라진 worktree dh-B 에서 했고, 그 시점 작업 트리에는
capture-evidence.py 가 더해져 있어 증거 메타의 sourceDirty 가 true 다.
측정 대상 열넷은 git ls-tree 로 그 커밋의 목록만 골라 냈다.
## 재현 조건
1. document-haness 를 43e1aad 로 체크아웃한다.
2. 파이프를 끼고 잰다 — 반복문 안에서 python3 출력을 head 로 넘기고 그다음 줄에서 $? 를 읽는다.
3. 열넷이 전부 exit=0 으로 나오는 것을 본다.
4. 파이프를 걷고 out=$(...) 다음 줄에서 code=$? 로 받아 다시 잰다.
5. build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit=1 로 갈리는 것을 본다.
## 본문
<!-- body:start -->
## 두 번 같은 값이 나왔다
검사기 목록을 만들려고 열넷에 `--help` 를 돌렸는데, 출력이 길어서 `head` 로 잘랐다.
```bash
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null
code=$?
n=$(basename $s)
echo "$n exit=$code"
done
```
열넷이 전부 `exit=0` 이었다.
```
audit-records.py exit=0
build-tech-log-tree.py exit=0
check-figure-overlap.py exit=0
check-figure-text.py exit=0
fold-analysis-into-final.py exit=0
fold-studio-contract-into-index.py exit=0
preview-figure.py exit=0
studio-body.py exit=0
techlog.py exit=0
verify-pipeline-run.py exit=0
verify-pipeline.py exit=0
verify-project-layout.py exit=0
verify-refactor-work-item.py exit=0
verify-tech-log-tree.py exit=0
```
이 저장소를 함께 조사한 다른 세션도 같은 값을 얻어 「`scripts/*.py` 14개 전부 `--help`
종료 코드 0 으로 돌아온다」 를 확인 등급 **확인함**으로 적었다. 두 사람이 같은 값을 얻었으니
맞는 값처럼 보였다.
## 값을 만든 것은 셸이다
파이프라인의 `$?`**마지막** 명령의 종료 코드다. `head` 는 언제나 성공하므로 앞의
`python3` 가 무엇을 반환하든 `$?` 는 0 이 된다.
```bash
set +o pipefail
false | head -1; echo "false | head -1 -> exit=$?"
false; echo "false -> exit=$?"
```
```
false | head -1 -> exit=0
false -> exit=1
bash 5.2.21(1)-release
```
POSIX 셸의 정의된 동작이고 이 셸의 특이점이 아니다. `pipefail` 을 켜거나
`${PIPESTATUS[0]}` 를 읽으면 앞 명령의 값을 얻는다.
같은 착각이 한 번 더 났다. 파이프를 걷어 내고 다시 잴 때 이렇게 썼다.
```bash
out=$(timeout 60 python3 $s --help 2>&1)
echo "$(basename $s) exit=$?"
```
명령 치환 `$(basename $s)` 가 먼저 실행되면서 `$?` 를 덮어써서 두 번째 측정도 열넷 전부
0 이었다. `code=$?` 를 명령 바로 다음 줄에 두고서야 값이 갈렸다.
## 다시 잰 값
```bash
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
out=$(timeout 60 python3 $s --help 2>&1)
code=$?
n=$(basename $s)
echo "$n exit=$code"
done
```
두 측정의 차이는 두 줄뿐이다.
```
2c2
< build-tech-log-tree.py exit=0
---
> build-tech-log-tree.py exit=1
13c13
< verify-refactor-work-item.py exit=0
---
> verify-refactor-work-item.py exit=1
```
잰 것은 `--help` 하나뿐이라 나머지 열둘이 다른 인자에서 어떻게 도는지는 이 값이 말해 주지
않는다.
## 왜 그 둘만인가
열넷 중 넷이 `argparse` 를 쓰지 않는다.
| 스크립트 | `argparse` | `--help` |
|---|---|---|
| `audit-records.py` | 없음 | `exit 0` — 「문제 없음」 |
| `build-tech-log-tree.py` | 없음 | `exit 1` |
| `techlog.py` | 없음 | `exit 0` — CLI 가 아니라 인자를 안 읽는다 |
| `verify-refactor-work-item.py` | 없음 | `exit 1` |
| 나머지 열 | 있음 | `exit 0` — usage |
남은 셋은 `--help` 를 옵션이 아니라 위치 인자로 먹는다. `audit-records.py` 는 그것을
프로젝트 이름으로 받아 「문제 없음」을 찍고, `build-tech-log-tree.py`
`verify-refactor-work-item.py` 는 그 이름의 파일을 못 찾아 실패한다.
이 두 숫자를 한 번 섞었다. 넷을 셋으로 적었고, 그 셋이 분석 문서 네 곳과 증거 메타 둘에
그대로 실렸다. 증거 원문에서 줄을 다시 센 뒤에 넷으로 고쳤다.
```
$ python3 scripts/audit-records.py --help
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
문제 없음
합계 0건
exit=0
$ python3 scripts/build-tech-log-tree.py --help
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
exit=1
$ python3 scripts/verify-refactor-work-item.py --help
REFACTOR WORK ITEM VERIFICATION: FAIL
- invalid work-item.json: --help/work-item.json
exit=1
```
`exit 0` 이 usage 가 나왔다는 뜻은 아니다. `audit-records.py``--help` 라는 이름의
프로젝트를 찾아 검사하고 통과시켰다.
## 손으로 적지 못하게 했다
관문 결과를 사람이 옮겨 적는 한 같은 뿌리에서 같은 실수가 계속 난다. 그래서 명령을 돌리는
쪽과 종료 코드를 적는 쪽을 하나로 붙였다.
```python
proc = subprocess.run(command, cwd=cwd, capture_output=True,
text=True, timeout=timeout)
exit_code, out = proc.returncode, proc.stdout + proc.stderr
```
종료 코드를 인자로 받지 않으므로 손으로 적어 넣을 수는 없다. 도구의 첫머리 주석이 목표를
적는다 — 돌리지 않은 검증을 완료로 적는 경로가 없어야 한다. 원문은
`final/evidence/raw/` 에, 실행 메타는 `final/evidence/meta/` 에 같은 이름으로 함께 떨어진다.
둘을 사람이 따로 적으면 갈라지기 때문이다.
셸 한 줄을 감싸는 래퍼도 됐지만 그러면 파이프를 다시 쓸 수 있게 된다. 값을 두 번 틀리게
만든 것이 바로 그 셸이라 아예 거치지 않기로 했다. 대신 셸 문법이 필요한 명령은 `bash -c`
인자로 넘겨야 하고, 그 안에서 다시 파이프를 쓰면 같은 실수가 난다 — 이 기록의 증거
다섯 개도 그렇게 수집했다.
처음 판에서는 이 도구도 인자를 잘못 먹었다. `argparse.REMAINDER` 로 명령을 받았더니
`--proves` 부터가 실행할 명령으로 딸려 가 `No such file or directory: '--proves'` 로 죽었다.
지금은 `--` 앞뒤를 직접 가르고, 왜 그렇게 했는지를 코드에 한 줄로 남겨 두었다.
```python
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
argv = sys.argv[1:]
```
`--help` 를 위치 인자로 먹은 세 스크립트와 같은 종류의 실수다. 이 저장소에서 인자를 손으로
가르는 코드는 대개 여기서 걸린다.
이 기록이 인용한 원문 다섯 개를 그 도구가 수집했다. 메타 하나는 이렇게 생겼다.
```json
{
"id": "help-exit-codes-measured-without-a-pipe",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:54:41+09:00",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
"bytes": 410
}
```
여기서 `exitCode: 0` 은 **측정 반복문 자체가 끝까지 돌았다**고 말한다. 열넷 각각의 종료
코드는 원문 `raw/` 안에 있다. 반복문이 반환한 값과 그 안에서 잰 값을 섞지 않는다 — 메타에는
반복문 쪽이 들어간다.
`sourceDirty` 는 그 실행 시점에 작업 트리에 커밋 안 된 변경이 있었는지 적는다. 있으면
`sourceRevision` 이 그 출력을 설명하지 못한다. 이 다섯은 전부 `true` 다 — 수집기 자신을
더한 상태에서 쟀다.
기존 경로를 지우지 않았다. 손으로 만든 `raw/`·`meta/` 도 그대로 쓰이고, 이 도구는 필요할
때만 부른다.
## 이 사건이 닫지 못한 것
수집기는 브라우저 캡처와 대화형 명령을 담지 못한다. 이 저장소의 증거 가운데
`final/evidence/browser/` 쪽은 여전히 Playwright 로 찍고 메타를 손으로 적는다 — 종료 코드를
손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다.
<!-- body:end -->
@@ -0,0 +1,76 @@
---
id:
kind: QUESTION
slug: what-a-gate-returns-with-nothing-to-check
title: 검사할 것이 없을 때 관문은 무엇을 내야 하는가
topic: pipeline-gate-exit-codes
topicName: 관문의 종료 코드
project: document-haness
status: 게시 전
studio: ""
questionStatus: OPEN
source:
- final/document.md#§6-실재하지-않는-프로젝트-이름
sourceRevision: 43e1aadef077ad93c30495df428ee3a71dd73f4a
evidence:
- ../../../final/evidence/raw/gates-on-a-nonexistent-project.txt
---
# 검사할 것이 없을 때 관문은 무엇을 내야 하는가
실재하지 않는 프로젝트 이름을 관문 여섯에 주면 넷이 통과를 보고한다. 검사할 것이 없으니
찾은 문제도 없다는 뜻이라 틀린 값은 아니다. 다만 관문 결과를 읽는 쪽은 그것을 검사를
지났다는 뜻으로 읽는다. 어느 쪽이 맞는지 이 저장소가 아직 정하지 않았다.
분해 계약에서는 이 후보를 Case 로 올리려다 Question 으로 돌렸다. 현상은 재현했는데 조치가
없어서다 — 조치 없이 쓰면 관찰만 있고 결과가 없는 글이 된다. 검사기 수정은 이 저장소의
다른 담당이 맡고 있어 여기서 고칠 수도 없었다.
## 관계
- **파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다**
그 사건에서 audit-records.py 가 --help 를 프로젝트 이름으로 받아 「문제 없음」을 찍은
것이 이 물음의 입구다. 같은 동작을 오타난 프로젝트 이름에서 다시 보게 된다.
## 사실
- 관문 여섯에 nonexistent-project 를 주면 넷이 exit 0 을 낸다. audit-records.py 는 「문제 없음」, verify-tech-log-tree.py 는 「PASS — 프로젝트 1 · error 0 · warn 1」, check-figure-text.py 는 「PASS — 그림 0장 · 문장 0건」, check-figure-overlap.py 는 「볼 그림이 없다」를 찍는다.
- 잡아내는 둘은 대상 파일을 먼저 찾는다. verify-project-layout.py 는 final/document.md 가 없다며 exit 1, check_evidence.mjs 는 tech-log-tree.json 이 없다며 exit 2 다.
- 검사기가 고장난 것이 아니다. 실재하는 프로젝트에서는 판정을 낸다 — check-figure-text.py ca-tmpl 은 그림 29장에서 문장 13건을 찾아 exit 1 이다.
- 런 원장 검사기 verify-pipeline-run.py 는 관문의 대상 프로젝트를 보지 않는다. 명령 문자열에 토큰이 들어 있는지와 원장이 적어 낸 exit 값만 본다.
## 가정
- 관문 결과를 읽는 쪽이 exit 0 을 「그 프로젝트를 검사했고 문제가 없었다」로 읽는다고 보고 있다. 실제로 그렇게 읽는지는 사람에게 물어보지 않았다.
## 미지수
- 「대상 없음」을 통과로 볼지 실패로 볼지 정해지지 않았다. 아직 만들지 않은 프로젝트에 관문을 미리 걸어 보는 쓰임이 있다면 exit 0 이 맞는 값이다.
- 넷 각각이 「대상 없음」과 「대상이 있는데 검사할 것이 0 건」을 구분할 수 있는지 모른다. 그 코드를 읽지 않았다.
- 오타로 관문이 무효가 된 런이 실제로 있었는지 모른다. 지금 있는 원장 세 건은 프로젝트 이름이 전부 맞다.
## 제약
- 검사기 자체의 수정은 이 저장소의 다른 담당 몫이다. 여기서는 현상만 적는다.
- 관문을 실패로 바꾸면 기존 원장과 전체 훑기가 함께 움직인다. 한쪽만 보고 정할 수 없다.
## 선택지
### 1. 넷이 「대상 없음」에서 실패를 내게 한다
대상 프로젝트 폴더가 없으면 exit 1 을 낸다. verify-project-layout.py 가 이미 그렇게 한다.
오타 한 번에 관문이 무효가 되는 경로가 막힌다. 대신 아직 만들지 않은 프로젝트에 미리 돌려
보는 쓰임이 사라지고, 기존 원장 세 건과 전체 훑기가 그대로 통과하는지 확인해야 한다.
### 2. 관문은 그대로 두고 원장 쪽에서 대상 이름을 대조한다
verify-pipeline-run.py 가 gates[].cmd 에서 프로젝트 이름을 뽑아 원장의 project 칸과
견준다. 검사기 넷을 건드리지 않아 다른 쓰임이 안 깨진다. 대신 명령을 「<기록.md>」 같은 꼴로
줄여 적은 기존 원장에서는 이름을 못 뽑는다 — 원장 세 건에 이미 그런 줄이 있다.
## 다음 검증
1. 넷에 실재하는 프로젝트를 주고 「검사 대상 0 건」이 나오는 경우와 「프로젝트 없음」이 나오는 경우의 출력을 나란히 잰다. 둘이 지금 구분되는지를 먼저 본다.
2. 원장의 gates[].cmd 에서 프로젝트 이름을 뽑아 project 칸과 대조하는 검사를 넣어 보고, 기존 원장 세 건이 통과하는지 본다.
닫는 조건 : 넷이 「대상 없음」에서 exit 0 이 아닌 값을 내도 기존 원장 세 건과 전체 훑기가 그대로 통과하면 선택지 1 로 정하고 Decision 으로 넘긴다. 하나라도 깨지면 선택지 2 로 넘긴다.
@@ -0,0 +1,230 @@
{
"schemaVersion": 4,
"project": "document-haness",
"ssot": "final/document.md",
"ssotSha256": "6d2c41d2d02eecd1476b95c83caa91e262151236b38aedf5dda775114fed561f",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"generatedAt": "2026-09-10",
"sourceRepository": {
"path": "/home/donghyeon/workspace/chat-gpt-container/document-haness",
"revision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"verified": "worktree dh-B 에서 git rev-parse HEAD 로 확인했다. 측정 대상 열넷은 git ls-tree --name-only 43e1aad scripts/ 로 그 커밋의 목록만 골라 냈다. code[] 의 세 파일도 git cat-file -e 43e1aad:<경로> 로 그 커밋에 실재하는 것을 대조했고, 인용한 줄 155·148·193 이 셋 다 raise SystemExit(...) 인 것을 git show 로 확인했다. 이 런이 더한 scripts/capture-evidence.py 와 scripts/skill-versions.py 는 43e1aad 에 없으므로 code[] 에 넣지 않았다 — 기록 본문에서는 「이 배치에서 만든 것」으로 밝힌다. 증거 메타의 sourceDirty 가 전부 true 인 것이 그 상태를 적는다."
},
"candidateScope": {
"document": "final/document.md",
"sections": [
"§2 관찰한 것",
"§3 파이프 뒤의 종료 코드",
"§4 다시 잰 값",
"§5 argparse 를 쓰지 않는 넷",
"§6 실재하지 않는 프로젝트 이름",
"§7 원장은 종료 코드를 다시 재지 않는다",
"§8 종료 코드를 손으로 적을 수 없게 만든다"
],
"excluded": [
"§1 검사기 열넷이 있는 자리",
"§9 확인하지 못한 것"
],
"note": "§1 은 배경이고 §9 는 한계 목록이라 후보 자리가 아니다. 후보는 §2~§8 에서만 나온다"
},
"note": "이 프로젝트의 글감 전부다. 분해 계약이자 색인이고, 이 파일이 정본이다. 노드의 칸(readiness·source·classification·relations…)은 사람이 적고, file·publication·status 는 기록 파일에서 읽어 채운다 — python3 scripts/build-tech-log-tree.py document-haness",
"contract": {
"decomposition": [
"글감을 찾는 입력은 final/document.md 하나다. 거기에 없는 근거는 먼저 SSOT 에 넣는다.",
"후보 전부는 candidates 에 처분과 함께 남고 PROMOTE 만 topics 로 올라간다.",
"없애고 관련 Case 나 Concept 의 한 절로 넣어도 이해·결정·재사용성이 그대로라면 독립 기록으로 만들지 않는다.",
"Topic 은 독자 질문 하나다. 그 물음에 답하지 않는 글감은 다른 Topic 으로 옮긴다.",
"Concept 은 Case·Decision·Question 을 먼저 고른 뒤 그것을 이해하는 데 필요한 것만 거꾸로 더한다."
],
"readinessValues": [
"READY",
"OPEN",
"NEEDS_EVIDENCE",
"NEEDS_DECISION",
"BLOCKED"
],
"dispositionValues": {
"PROMOTE": "독립 Tech Log 로 쓴다",
"MERGE_INTO": "다른 기록의 한 절로 흡수한다",
"KEEP_IN_SSOT": "분석에는 남기고 독립 기록으로 만들지 않는다 — 정상적인 성공 결과다",
"NEEDS_EVIDENCE": "주장에 아직 검증이 없다",
"NEEDS_DECISION": "방향이 그럴듯하지만 프로젝트가 정하지 않았다",
"BLOCKED": "원본이 불완전하거나 서로 어긋난다"
}
},
"counts": {
"topics": 1,
"nodes": 2,
"written": 2,
"unwritten": 0,
"unlisted": 0,
"candidates": 5
},
"topics": {
"pipeline-gate-exit-codes": {
"topic": "pipeline-gate-exit-codes",
"title": "관문의 종료 코드",
"readerQuestion": "관문이 exit 0 을 냈다는 것은 무엇을 확인했다는 뜻인가?",
"kinds": {
"case": [
{
"title": "파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다",
"kind": "case",
"slug": "exit-code-read-behind-a-pipe",
"readiness": "READY",
"source": [
"final/document.md#§2-관찰한-것",
"final/document.md#§3-파이프-뒤의-종료-코드",
"final/document.md#§4-다시-잰-값",
"final/document.md#§5-argparse-를-쓰지-않는-넷",
"final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다"
],
"code": [
"scripts/audit-records.py:155",
"scripts/build-tech-log-tree.py:148",
"scripts/verify-refactor-work-item.py:193"
],
"evidence": [
"evidence/raw/exit-code-through-a-pipe.txt",
"evidence/raw/help-exit-codes-measured-through-a-pipe.txt",
"evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
"evidence/raw/argparse-absent-scripts.txt",
"evidence/raw/what-the-three-print-for-help.txt"
],
"classification": "재현·진단·조치가 닫히는 하나의 사건이다. 두 세션이 같은 오측정을 하고, 원인이 셸의 정의된 동작으로 좁혀지고, 종료 코드를 손으로 적을 수 없게 만드는 수집기로 닫았다",
"missing-verification": "--help 하나만 쟀다. 다른 인자에서 argparse 를 쓰는 열이 어떻게 도는지는 확인하지 않았다",
"relations": [
"question:what-a-gate-returns-with-nothing-to-check"
],
"publication": "초안",
"file": "pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/exit-code-through-a-pipe.txt",
"../../../final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt",
"../../../final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
"../../../final/evidence/raw/argparse-absent-scripts.txt",
"../../../final/evidence/raw/what-the-three-print-for-help.txt"
]
}
],
"concept": [],
"reference": [],
"question": [
{
"title": "검사할 것이 없을 때 관문은 무엇을 내야 하는가",
"kind": "question",
"slug": "what-a-gate-returns-with-nothing-to-check",
"readiness": "OPEN",
"source": [
"final/document.md#§6-실재하지-않는-프로젝트-이름"
],
"evidence": [
"evidence/raw/gates-on-a-nonexistent-project.txt"
],
"classification": "현상은 재현했고 조치는 정해지지 않았다. 답이 나오려면 이 저장소가 「대상 없음」을 통과로 볼지 실패로 볼지 정해야 한다",
"missing-verification": "실재하는 프로젝트 전부에서 같은 넷이 어떻게 도는지는 안 쟀다. ca-tmpl 하나에서 check-figure-text.py 가 exit 1 을 내는 것만 봤다",
"relations": [
"case:exit-code-read-behind-a-pipe"
],
"known": [
"관문 여섯에 실재하지 않는 프로젝트 이름 nonexistent-project 를 주면 넷이 exit 0 을 낸다 — audit-records.py 는 「문제 없음」, verify-tech-log-tree.py 는 「PASS — error 0 · warn 1」, check-figure-text.py 는 「PASS — 그림 0장」, check-figure-overlap.py 는 「볼 그림이 없다」",
"잡아내는 둘은 대상 파일을 먼저 찾는다 — verify-project-layout.py 는 final/document.md 가 없다고 exit 1, check_evidence.mjs 는 tech-log-tree.json 이 없다고 exit 2",
"이것은 「검사기가 고장났다」가 아니다. 실재하는 프로젝트에서는 판정을 낸다 — check-figure-text.py ca-tmpl 은 그림 29장에서 문장 13건을 찾아 exit 1 이다",
"런 원장 검사기 verify-pipeline-run.py 는 관문의 대상 프로젝트를 보지 않고 명령 문자열에 토큰이 있는지와 원장이 적어 낸 exit 만 본다"
],
"unknown": [
"「대상 없음」을 통과로 볼지 실패로 볼지 이 저장소가 정하지 않았다. 아직 안 만든 프로젝트에 관문을 미리 걸어 보는 쓰임이 있다면 exit 0 이 맞다",
"넷 각각이 「대상 없음」과 「대상이 있는데 검사할 것이 0 건」을 구분할 수 있는지 — 코드를 읽지 않았다",
"오타로 관문이 무효가 된 런이 실제로 있었는지. 원장 세 건은 전부 프로젝트 이름이 맞다"
],
"next-verification": [
"넷에 실재하는 프로젝트를 주고 「검사 대상 0 건」이 나오는 경우와 「프로젝트 없음」이 나오는 경우의 출력을 나란히 잰다",
"런 원장의 gates[].cmd 에서 프로젝트 이름을 뽑아 원장의 project 칸과 대조하는 검사를 verify-pipeline-run.py 에 넣어 보고, 기존 원장 세 건이 통과하는지 본다"
],
"decision-criterion": "넷이 「대상 없음」에서 exit 0 이 아닌 값을 내도 기존 원장 세 건과 verify-pipeline.py 전체 훑기가 그대로 통과하면 그 방향으로 정한다. 하나라도 깨지면 「대상 없음」을 통과로 두고 대신 원장 쪽에서 대상 이름을 대조한다",
"publication": "초안",
"file": "pipeline-gate-exit-codes/question/question-what-a-gate-returns-with-nothing-to-check.md",
"status": "게시 전",
"studioId": "",
"assets": [],
"assetFiles": [],
"evidenceFiles": [
"../../../final/evidence/raw/gates-on-a-nonexistent-project.txt"
]
}
],
"decision": []
}
}
},
"candidates": [
{
"id": "DH-C01",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#§2-관찰한-것",
"final/document.md#§3-파이프-뒤의-종료-코드",
"final/document.md#§4-다시-잰-값"
],
"summary": "파이프를 끼고 재서 검사기 열넷이 전부 --help 를 받는 것으로 보였고, 다시 재니 둘이 exit 1 이었다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:exit-code-read-behind-a-pipe",
"reason": "관찰·원인·조치가 한 사건으로 닫히고 두 세션이 같은 값을 얻어 계약 문서에까지 들어간 자리다. 독립 기록으로 남길 값이 있다"
},
{
"id": "DH-C02",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#§5-argparse-를-쓰지-않는-넷"
],
"summary": "열넷 중 넷이 argparse 를 쓰지 않고, 그중 셋이 --help 를 위치 인자로 먹는다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:exit-code-read-behind-a-pipe",
"reason": "왜 그 둘만 exit 1 이었는지를 설명하는 절이다. 떼어 내면 CASE 의 원인 절이 비고, 따로 읽을 값도 없다"
},
{
"id": "DH-C03",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다"
],
"summary": "명령 실행과 종료 코드 기록을 한 자리로 붙인 증거 수집기를 만들었다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:exit-code-read-behind-a-pipe",
"reason": "CASE 의 조치 절이다. 도구 자체의 사용법은 Reference 감이지만 아직 이 저장소 하나에서만 써서 「주의할 조건」을 적을 근거가 없다 — 실행 여덟 건이 전부 이 프로젝트의 증거 수집이고, 다른 프로젝트나 브라우저 캡처에 걸어 본 적이 없다"
},
{
"id": "DH-C04",
"kindCandidate": "CASE",
"sourceRefs": [
"final/document.md#§6-실재하지-않는-프로젝트-이름"
],
"summary": "실재하지 않는 프로젝트 이름에 관문 여섯 중 넷이 exit 0 을 낸다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "question:what-a-gate-returns-with-nothing-to-check",
"reason": "현상은 재현했지만 조치가 없다. 조치 없는 CASE 는 「관찰만 있고 결과가 없는 글」이 되므로 Question 으로 올린다 — 확인된 사실과 답을 구할 방법은 있고 답만 없다. 검사기 수정은 이 저장소의 다른 담당 몫이다"
},
{
"id": "DH-C05",
"kindCandidate": "QUESTION",
"sourceRefs": [
"final/document.md#§7-원장은-종료-코드를-다시-재지-않는다"
],
"summary": "런 원장이 관문을 재실행하지 않고 자기 신고한 exit 만 본다. 재실행이 옳은 답인지는 안 정했다",
"disposition": "NEEDS_DECISION",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "재실행하면 원장 검사가 느려지고 부작용 있는 관문(파일을 고치는 build-tech-log-tree.py)도 다시 돌게 된다. 무엇을 재실행하고 무엇을 신고로 받을지 이 저장소가 정하지 않았다. 정해지기 전에는 Question 으로도 못 쓴다 — 「답을 구할 방법」이 결정에 달려 있다"
}
],
"unlisted": [],
"history": {}
}
@@ -0,0 +1,233 @@
{
"schemaVersion": 1,
"runId": "2026-09-10-1033",
"project": "document-haness",
"record": "docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"startedAt": "2026-09-10T10:33:45+09:00",
"finishedAt": "2026-09-10T11:00:39+09:00",
"stages": [
{
"id": "S1",
"name": "코드베이스 → SSOT",
"skill": "analyzing-codebase-for-tech-log",
"runBy": "subagent",
"status": "DONE",
"skipReason": "",
"skillEcho": "Do not turn inference into observation in the final document.",
"inputs": [
"docs/document-haness/final/document.md",
"docs/document-haness/final/evidence/raw/**",
"docs/document-haness/final/evidence/meta/**"
],
"outputs": [
"docs/document-haness/final/document.md",
"docs/document-haness/final/evidence/meta/argparse-absent-scripts.json",
"docs/document-haness/final/evidence/meta/what-the-three-print-for-help.json",
"docs/document-haness/tech-log-studio/tech-log-tree.json"
],
"gates": [
{
"cmd": "python3 scripts/verify-project-layout.py document-haness",
"exit": 0
},
{
"cmd": "python3 scripts/build-tech-log-tree.py document-haness",
"exit": 0
},
{
"cmd": "python3 scripts/verify-tech-log-tree.py document-haness",
"exit": 0
},
{
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs document-haness --repo",
"exit": 0
}
],
"notes": "대조 모드다. 분석을 새로 하지 않고 SSOT 가 증거 원문과 어긋나는지만 봤다. S3 이 올린 어긋남 하나가 실물이었다 — 증거 정본 argparse-absent-scripts.txt 의 「ARGPARSE 없음」이 4줄인데 SSOT 는 셋이라고 적었다. 「argparse 가 없는 것(넷)」과 「--help 를 위치 인자로 먹는 것(셋)」을 한 숫자로 섞은 것이다. SSOT 네 곳(:155 절 제목 · :157 · :176 · §9)을 고치고, 절 제목이 바뀌어 앵커를 가리키는 세 곳(계약 2 · 기록 frontmatter 1)을 함께 고쳤다. 증거 메타 둘의 proves·doesNotProve 도 같은 오산을 담고 있어 고쳤다 — 그것은 사람이 적은 주장이다. exitCode·sha256·bytes·command 는 실행이 적은 값이라 안 건드렸고, raw 원문은 한 글자도 안 건드렸다(메타 8건의 sha256 을 다시 계산해 원문과 일치 확인). runs/.../stage/S3-before.md 는 과거 사본이라 옛 앵커를 그대로 두었다. 그 밖에 §1~§9 의 인용 블록 다섯을 raw 와 diff 로 대조했고 §7 의 원장 인용이 runs/virtualization/2026-09-09-1052/run.json:78 에 실재하는 것까지 확인했다. 오케스트레이터가 증거 원문과 코드로 다시 세어 넷인 것을 독립으로 확인했다. 판단이 필요해 안 고친 것 둘 — guards/ 증거 둘을 인용하는 기록이 없고(warn 2건), sourceRepository.path 가 worktree 가 아니라 원본 경로를 가리킨다(verified 칸이 사유를 적는다)."
},
{
"id": "S2",
"name": "SSOT → 분해 계약",
"skill": "deriving-tech-log-root-tree",
"runBy": "orchestrator",
"status": "SKIPPED",
"skipReason": "이 글감이 tech-log-tree.json 에 이미 PROMOTE · dispositionReview CONFIRMED 로 있다 (candidates[DH-C01], target=case:exit-code-read-behind-a-pipe). 분해를 다시 하지 않았다.",
"skillEcho": "",
"inputs": [
"docs/document-haness/final/document.md",
"docs/document-haness/tech-log-studio/tech-log-tree.json"
],
"outputs": [
"docs/document-haness/tech-log-studio/tech-log-tree.json"
],
"gates": [
{
"cmd": "python3 scripts/build-tech-log-tree.py document-haness",
"exit": 0
},
{
"cmd": "python3 scripts/verify-tech-log-tree.py document-haness",
"exit": 0
}
],
"notes": "build 는 파생 칸(file·publication·status·ssotSha256)만 다시 채운다. 사람이 적은 칸은 그대로다."
},
{
"id": "S3",
"name": "글감 → 기록",
"skill": "writing-tech-log-records",
"runBy": "subagent",
"status": "DONE",
"skipReason": "",
"skillEcho": "**옮겨 적었다고 확인한 것이 아니다.** 앞선 기록에서 코드를 가져오거나 기억으로 경로를 쓰면",
"inputs": [
"docs/document-haness/tech-log-studio/tech-log-tree.json",
"docs/document-haness/final/document.md",
"runs/document-haness/2026-09-10-1033/stage/S3-before.md"
],
"outputs": [
"docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md"
],
"gates": [
{
"cmd": "python3 scripts/studio-body.py docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md -o /tmp/s3-body.md",
"exit": 0
},
{
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/s3-body.md",
"exit": 0
},
{
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"exit": 0
},
{
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs document-haness --repo",
"exit": 0
},
{
"cmd": "python3 scripts/verify-tech-log-tree.py document-haness",
"exit": 0
}
],
"notes": "기록은 이미 있었고 서브에이전트가 계약·SSOT 와 대조해 세 곳을 고쳤다. (1) 요약 칸의 백틱 — 평문으로 렌더링되는 칸이다. (2) 요약이 「계약 문서에까지 들어갔다」고 적었는데 SSOT 는 「확인 등급 확인함으로 적었다」까지만 말한다. SSOT 가 뒷받침하는 문장으로 바꿨다. (3) 본문이 「열넷 중 셋이 argparse 를 쓰지 않는다」로 시작하면서 바로 아래 표에 「없음」을 넷 적어 두었다. 증거 정본 argparse-absent-scripts.txt 의 「ARGPARSE 없음」이 4줄이므로 표가 맞고 문장이 틀렸다 — 셋→넷, 남은 둘→남은 셋으로 고쳤다. 오케스트레이터가 증거 원문과 코드로 다시 세어 넷인 것을 확인했다. 같은 오산이 SSOT·계약·증거 메타에 그대로 있다는 것을 서브에이전트가 크게 적어 올렸고, SSOT 를 고치는 것은 자기 단계 밖이라 손대지 않았다 — S1 로 돌렸다. check_prose 경고 2건(CASE·POSIX 약어)은 남겼다. CASE 는 frontmatter 의 kind 값이고 POSIX 는 SSOT 가 쓰는 표준 명칭이라 풀어 쓰면 보호 구간을 건드린다."
},
{
"id": "S4",
"name": "기록 → 그림",
"skill": "technical-visualizer",
"runBy": "orchestrator",
"status": "SKIPPED",
"skipReason": "그림이 필요 없다. 세 관문 중 셋째에 걸린다 — 본문의 「왜 그 둘만인가」 절이 argparse 유무와 --help 결과를 표 하나로 답하고 있어, 같은 것을 그림으로 다시 그리면 옆 문단이 이미 말한 것을 되풀이한다. 계약의 이 노드에도 assets 가 없다.",
"skillEcho": "",
"inputs": [],
"outputs": [],
"gates": [],
"notes": "이 프로젝트의 final/assets 는 비어 있다. check-figure-text.py 와 check-figure-overlap.py 는 볼 그림이 없어 돌리지 않았다."
},
{
"id": "S5",
"name": "AI 티 제거",
"skill": "rewriting-technical-prose-naturally",
"runBy": "subagent",
"status": "DONE",
"skipReason": "",
"skillEcho": "| Sentences are all short and choppy | Rejoin with `~기 때문에`, `~다 보니`, `~어서`; break only where the subject changes |",
"inputs": [
"docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"runs/document-haness/2026-09-10-1033/stage/S5-before.md"
],
"outputs": [
"docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md"
],
"gates": [
{
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"exit": 0
},
{
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"exit": 1
},
{
"cmd": "python3 scripts/studio-body.py docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md -o /tmp/s5-body.md",
"exit": 0
},
{
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/s5-body.md",
"exit": 0
},
{
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs document-haness --repo",
"exit": 0
},
{
"cmd": "python3 scripts/check-preservation.py runs/document-haness/2026-09-10-1033/stage/S5-before.md docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"exit": 0
}
],
"notes": "서브에이전트가 아홉 곳을 고쳤다. 전부 문장을 잇거나 문단 순서를 옮긴 것이고 삭제한 문장도 새로 쓴 문장도 없다(68문장 → 63문장). check-preservation.py 가 「보호 구간 변화 0건 · 유보 감소 0종」으로 확인했다 — 윤문이 수치·코드·인용·URL 을 건드리지 않았고 유보 표현도 안 지웠다는 뜻이다. style_profile.mjs 는 exit 1 로 그대로 적는다. 관문이 아니라 측정이고 문서 계약이 이것에만 「error 0」을 안 붙였다. 이유 연결어미는 5.9 → 9.5 로 기준 안에 들어왔고 문장 평균 길이는 41.6 → 45 로 기준(48~75) 밖에 남았다. 서브에이전트가 남은 짧은 문장 22개를 전부 열어 보고 재현 조건 단계·수치 한 줄·코드로 넘기는 도입·정의 한 줄·방향 전환이라 잇지 않았다고 적었다. 수치를 맞추려고 문장을 넣지 말라는 것이 스킬의 규칙이다. 서브에이전트가 사실 어긋남 하나를 올렸다 — 본문이 「이 기록의 증거 여섯 개」라고 적었는데 frontmatter 의 evidence 는 다섯이다. 수치는 보호 구간이라 S5 가 못 고친다고 판단해 넘겼고, 오케스트레이터가 frontmatter 를 세어 확인한 뒤 다섯으로 고쳤다. check-preservation 은 이 고침을 못 잡는다 — 한글 수사는 아라비아 숫자가 아니라서 보호 구간 비교에 안 걸린다. 이 검사기의 알려진 한계다."
},
{
"id": "S6",
"name": "일한 사람의 목소리",
"skill": "writing-as-the-person-who-did-it",
"runBy": "subagent",
"status": "DONE",
"skipReason": "",
"skillEcho": "고치는 방법은 하나뿐이다. **자료에 남아 있는 사람의 흔적을 찾아서 제자리에 놓는다.**",
"inputs": [
"docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"docs/document-haness/final/document.md",
"docs/document-haness/tech-log-studio/tech-log-tree.json",
"docs/document-haness/final/evidence/meta/*.json",
"scripts/capture-evidence.py",
"runs/document-haness/2026-09-10-1033/run.json",
"runs/document-haness/2026-09-10-1033/stage/S6-before.md"
],
"outputs": [
"docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md"
],
"gates": [
{
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"exit": 0
},
{
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"exit": 0
},
{
"cmd": "python3 scripts/studio-body.py docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md -o /tmp/s6-body.md",
"exit": 0
},
{
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/s6-body.md",
"exit": 0
},
{
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs document-haness --repo",
"exit": 0
},
{
"cmd": "python3 scripts/check-preservation.py runs/document-haness/2026-09-10-1033/stage/S6-before.md docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
"exit": 0
}
],
"notes": "흔적 있음. 두 곳을 넣었고 둘 다 근거를 파일과 칸으로 댔다. (A) 「왜 그 둘만인가」 절에 이 런이 겪은 어긋남을 그 대목에 놓았다 — 두 숫자를 섞어 넷을 셋으로 적었고 그것이 분석 문서 네 곳과 증거 메타 둘에 실렸으며 증거 원문에서 줄을 다시 세어 고쳤다는 것. 근거는 이 원장의 S1·S3 notes 다. (B) 「손으로 적지 못하게 했다」 절에 도구를 쓴 사람의 말을 옮겼다 — scripts/capture-evidence.py 의 docstring :5 와 :9 다. 기록이 기능만 적고 목표와 이유는 안 적던 자리다. 인용부호나 백틱으로 감싸지 않고 평문으로 녹였다 — check-preservation 이 새로 생긴 인라인코드와 「」 직접인용을 「새로생김」으로 세기 때문이다. 낱말은 docstring 그대로다. 안 넣은 것도 적었다. 「처음에는」·「고민 끝에」·「놀랍게도」는 자료에 없어 한 건도 안 썼고, 커밋 메시지는 capture-evidence.py 가 아직 untracked 라 흔적이 없다 — 찾아봤고 없었다. 이미 있던 사람의 흔적 셋(같은 착각이 한 번 더 났다 · 셸 래퍼도 됐지만 · 처음 판에서는 이 도구도 인자를 잘못 먹었다)에는 손대지 않았다. 서브에이전트가 계약의 어긋남 하나를 올렸다 — candidates[DH-C03].reason 이 「한 번밖에 안 써서」라고 적는데 이 도구가 적은 메타가 여덟 건이다. 자료와 어긋나 그 문장을 근거로 못 쓴다고 판단하고 안 넣었다. 오케스트레이터가 메타를 세어 확인한 뒤 계약을 「이 저장소 하나에서만 써서 … 실행 여덟 건이 전부 이 프로젝트의 증거 수집이고 다른 프로젝트나 브라우저 캡처에 걸어 본 적이 없다」로 고쳤다."
},
{
"id": "S7",
"name": "Studio 저장",
"skill": "publishing-tech-log-to-studio",
"runBy": "orchestrator",
"status": "SKIPPED",
"skipReason": "Studio 반입을 요청받지 않았다. 이 배치에서 Studio 브라우저 세션은 통합 세션 A 가 소유하고, 게시 권한이 저장 권한과 분리돼 있지 않아 무인 저장이 막혀 있다 (A-studio-change-requests.md 의 CR-001). 화면을 열지 않았다.",
"skillEcho": "",
"inputs": [],
"outputs": [],
"gates": [],
"notes": "기록 frontmatter 의 id 와 studio 는 비어 있다. 저장한 적이 없다는 뜻이고 그대로 둔다."
}
]
}
@@ -0,0 +1,275 @@
---
id:
kind: CASE
slug: exit-code-read-behind-a-pipe
title: 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
topic: pipeline-gate-exit-codes
topicName: 관문의 종료 코드
project: document-haness
status: 게시 전
studio: ""
lastVerifiedOn: 2026-09-10
source:
- final/document.md#§2-관찰한-것
- final/document.md#§3-파이프-뒤의-종료-코드
- final/document.md#§4-다시-잰-값
- final/document.md#§5-argparse-를-쓰지-않는-셋
- final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다
sourceRevision: 43e1aadef077ad93c30495df428ee3a71dd73f4a
evidence:
- ../../../final/evidence/raw/exit-code-through-a-pipe.txt
- ../../../final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt
- ../../../final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt
- ../../../final/evidence/raw/argparse-absent-scripts.txt
- ../../../final/evidence/raw/what-the-three-print-for-help.txt
---
# 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
`scripts/` 의 검사기 열넷에 `--help` 를 돌려 전부 종료 코드 0 을 받았다. 두 세션이 각자
같은 값을 얻어 계약 문서에까지 「14개 전부 `--help` 가 종료 코드 0」으로 들어갔다. 값은
검사기가 아니라 재는 방법이 만든 것이었다. 파이프를 걷어 내고 다시 재니 둘이 `exit 1` 이다.
## 관계
- **검사할 것이 없을 때 관문은 무엇을 내야 하는가**
이 사건에서 `audit-records.py --help` 가 「문제 없음」을 찍으면서 그 물음이 열렸다.
`--help` 라는 이름의 프로젝트에는 검사할 기록이 하나도 없어 그대로 통과했다.
## 문제
관문은 종료 코드로 말한다. 단계 계약이 「관문은 종료 코드가 0 이어야 지난 것이다」 라고
적었고, 런 원장 run.json 의 stages[].gates[].exit 에 그 값이 남는다. 사람이 셸에서 그 값을
읽어 옮겨 적는다.
--help 하나를 잰 것뿐인데 값이 두 번 틀렸다. 관문 결과 전부가 같은 방법으로 적히고 있다.
## 결론
리비전 43e1aad 의 scripts/*.py 열넷 중 열둘이 --help 에 exit 0,
build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다.
exit 0 이 usage 가 나왔다는 뜻은 아니다 — audit-records.py 는 --help 를 프로젝트
이름으로 받아 검사하고 통과시킨다.
값을 틀리게 만든 것은 셸의 동작 둘이다. 파이프의 종료 코드 변수는 마지막 명령을 가리키고,
명령 치환은 그 변수를 덮어쓴다. 둘 다 정의된 동작이라 셸이 경고하지 않는다.
조치로 scripts/capture-evidence.py 를 만들었다. 명령을 subprocess 로 직접 돌리고 그
프로세스의 반환값을 그대로 메타에 적는다. 종료 코드를 인자로 받지 않으므로 손으로 적을
경로가 없다.
## 검증 환경
python 3.12.3 · node v24.14.0 · bash 5.2.21(1)-release · Linux.
대상 저장소 document-haness 리비전 43e1aadef077ad93c30495df428ee3a71dd73f4a.
측정은 그 커밋에서 갈라진 worktree dh-B 에서 했고, 그 시점 작업 트리에는
capture-evidence.py 가 더해져 있어 증거 메타의 sourceDirty 가 true 다.
측정 대상 열넷은 git ls-tree 로 그 커밋의 목록만 골라 냈다.
## 재현 조건
1. document-haness 를 43e1aad 로 체크아웃한다.
2. 파이프를 끼고 잰다 — 반복문 안에서 python3 출력을 head 로 넘기고 그다음 줄에서 $? 를 읽는다.
3. 열넷이 전부 exit=0 으로 나오는 것을 본다.
4. 파이프를 걷고 out=$(...) 다음 줄에서 code=$? 로 받아 다시 잰다.
5. build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit=1 로 갈리는 것을 본다.
## 본문
<!-- body:start -->
## 두 번 같은 값이 나왔다
검사기 목록을 만들려고 열넷에 `--help` 를 돌렸다. 출력이 길어서 `head` 로 잘랐다.
```bash
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null
code=$?
n=$(basename $s)
echo "$n exit=$code"
done
```
열넷이 전부 `exit=0` 이었다.
```
audit-records.py exit=0
build-tech-log-tree.py exit=0
check-figure-overlap.py exit=0
check-figure-text.py exit=0
fold-analysis-into-final.py exit=0
fold-studio-contract-into-index.py exit=0
preview-figure.py exit=0
studio-body.py exit=0
techlog.py exit=0
verify-pipeline-run.py exit=0
verify-pipeline.py exit=0
verify-project-layout.py exit=0
verify-refactor-work-item.py exit=0
verify-tech-log-tree.py exit=0
```
이 저장소를 함께 조사한 다른 세션도 같은 값을 얻어 「`scripts/*.py` 14개 전부 `--help`
종료 코드 0 으로 돌아온다」 를 확인 등급 **확인함**으로 적었다. 두 사람이 같은 값을 얻었으니
맞는 값처럼 보였다.
## 값을 만든 것은 셸이다
파이프라인의 `$?`**마지막** 명령의 종료 코드다. `head` 는 언제나 성공하므로 앞의
`python3` 가 무엇을 반환하든 `$?` 는 0 이 된다.
```bash
set +o pipefail
false | head -1; echo "false | head -1 -> exit=$?"
false; echo "false -> exit=$?"
```
```
false | head -1 -> exit=0
false -> exit=1
bash 5.2.21(1)-release
```
POSIX 셸의 정의된 동작이고 이 셸의 특이점이 아니다. `pipefail` 을 켜거나
`${PIPESTATUS[0]}` 를 읽으면 앞 명령의 값을 얻는다.
같은 착각이 한 번 더 났다. 파이프를 걷어 내고 다시 잴 때 이렇게 썼다.
```bash
out=$(timeout 60 python3 $s --help 2>&1)
echo "$(basename $s) exit=$?"
```
명령 치환 `$(basename $s)` 가 먼저 실행되면서 `$?` 를 덮어썼다. 그래서 두 번째 측정도
열넷 전부 0 이었다. `code=$?` 를 명령 바로 다음 줄에 두고서야 값이 갈렸다.
## 다시 잰 값
```bash
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
out=$(timeout 60 python3 $s --help 2>&1)
code=$?
n=$(basename $s)
echo "$n exit=$code"
done
```
두 측정의 차이는 두 줄뿐이다. 잰 것은 `--help` 하나뿐이라 나머지 열둘이 다른 인자에서
어떻게 도는지는 이 값이 말해 주지 않는다.
```
2c2
< build-tech-log-tree.py exit=0
---
> build-tech-log-tree.py exit=1
13c13
< verify-refactor-work-item.py exit=0
---
> verify-refactor-work-item.py exit=1
```
## 왜 그 둘만인가
열넷 중 셋이 `argparse` 를 쓰지 않는다.
| 스크립트 | `argparse` | `--help` |
|---|---|---|
| `audit-records.py` | 없음 | `exit 0` — 「문제 없음」 |
| `build-tech-log-tree.py` | 없음 | `exit 1` |
| `techlog.py` | 없음 | `exit 0` — CLI 가 아니라 인자를 안 읽는다 |
| `verify-refactor-work-item.py` | 없음 | `exit 1` |
| 나머지 열 | 있음 | `exit 0` — usage |
남은 둘은 `--help` 를 옵션이 아니라 위치 인자로 먹는다. `audit-records.py` 는 그것을
프로젝트 이름으로 받아 「문제 없음」을 찍고, `build-tech-log-tree.py`
`verify-refactor-work-item.py` 는 그 이름의 파일을 못 찾아 실패한다.
```
$ python3 scripts/audit-records.py --help
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
문제 없음
합계 0건
exit=0
$ python3 scripts/build-tech-log-tree.py --help
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
exit=1
$ python3 scripts/verify-refactor-work-item.py --help
REFACTOR WORK ITEM VERIFICATION: FAIL
- invalid work-item.json: --help/work-item.json
exit=1
```
`exit 0` 이 usage 가 나왔다는 뜻은 아니다. `audit-records.py``--help` 라는 이름의
프로젝트를 찾아 검사하고 통과시켰다.
## 손으로 적지 못하게 했다
관문 결과를 사람이 옮겨 적는 한 같은 뿌리에서 계속 난다. 그래서 명령을 돌리는 쪽과
종료 코드를 적는 쪽을 하나로 붙였다.
```python
proc = subprocess.run(command, cwd=cwd, capture_output=True,
text=True, timeout=timeout)
exit_code, out = proc.returncode, proc.stdout + proc.stderr
```
셸 한 줄을 감싸는 래퍼도 됐지만 그러면 파이프를 다시 쓸 수 있게 된다. 값을 두 번 틀리게
만든 것이 바로 그 셸이라 아예 거치지 않기로 했다. 대신 셸 문법이 필요한 명령은
`bash -c` 를 인자로 넘겨야 하고, 그 안에서 다시 파이프를 쓰면 같은 실수가 난다 — 이 기록의
증거 여섯 개도 그렇게 수집했다. 종료 코드를 인자로 받지 않으므로 손으로 적어 넣을 수는 없다. 원문은 `final/evidence/raw/` 에, 실행 메타는 `final/evidence/meta/`
같은 이름으로 함께 떨어진다.
처음 판에서는 이 도구도 인자를 잘못 먹었다. `argparse.REMAINDER` 로 명령을 받았더니
`--proves` 부터가 실행할 명령으로 딸려 가 `No such file or directory: '--proves'` 로 죽었다.
지금은 `--` 앞뒤를 직접 가르고, 왜 그렇게 했는지를 코드에 한 줄로 남겨 두었다.
```python
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
argv = sys.argv[1:]
```
`--help` 를 위치 인자로 먹은 세 스크립트와 같은 종류의 실수다. 이 저장소에서 인자를 손으로
가르는 코드는 대개 여기서 걸린다.
이 기록이 인용한 원문 다섯 개를 그 도구가 수집했다. 메타 하나는 이렇게 생겼다.
```json
{
"id": "help-exit-codes-measured-without-a-pipe",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:54:41+09:00",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
"bytes": 410
}
```
여기서 `exitCode: 0` 은 **측정 반복문 자체가 끝까지 돌았다**고 말한다. 열넷 각각의 종료
코드는 원문 `raw/` 안에 있다. 반복문이 반환한 값과 그 안에서 잰 값을 섞지 않는다 — 메타에는
반복문 쪽이 들어간다.
`sourceDirty` 는 그 실행 시점에 작업 트리에 커밋 안 된 변경이 있었는지 적는다. 있으면
`sourceRevision` 이 그 출력을 설명하지 못한다. 이 다섯은 전부 `true` 다 — 수집기 자신을
더한 상태에서 쟀다.
기존 경로를 지우지 않았다. 손으로 만든 `raw/`·`meta/` 도 그대로 쓰이고, 이 도구는 필요할
때만 부른다.
## 이 사건이 닫지 못한 것
수집기는 브라우저 캡처와 대화형 명령을 담지 못한다. 이 저장소의 증거 가운데
`final/evidence/browser/` 쪽은 여전히 Playwright 로 찍고 메타를 손으로 적는다 — 종료 코드를
손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다.
<!-- body:end -->
@@ -0,0 +1,275 @@
---
id:
kind: CASE
slug: exit-code-read-behind-a-pipe
title: 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
topic: pipeline-gate-exit-codes
topicName: 관문의 종료 코드
project: document-haness
status: 게시 전
studio: ""
lastVerifiedOn: 2026-09-10
source:
- final/document.md#§2-관찰한-것
- final/document.md#§3-파이프-뒤의-종료-코드
- final/document.md#§4-다시-잰-값
- final/document.md#§5-argparse-를-쓰지-않는-넷
- final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다
sourceRevision: 43e1aadef077ad93c30495df428ee3a71dd73f4a
evidence:
- ../../../final/evidence/raw/exit-code-through-a-pipe.txt
- ../../../final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt
- ../../../final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt
- ../../../final/evidence/raw/argparse-absent-scripts.txt
- ../../../final/evidence/raw/what-the-three-print-for-help.txt
---
# 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
scripts/ 의 검사기 열넷에 --help 를 돌려 전부 종료 코드 0 을 받았다. 이 저장소를 함께
조사한 다른 세션도 같은 값을 얻어 확인 등급 확인함으로 적었다. 값은 검사기가 아니라 재는
방법이 만든 것이었다. 파이프를 걷어 내고 다시 재니 둘이 exit 1 이다.
## 관계
- **검사할 것이 없을 때 관문은 무엇을 내야 하는가**
이 사건에서 `audit-records.py --help` 가 「문제 없음」을 찍으면서 그 물음이 열렸다.
`--help` 라는 이름의 프로젝트에는 검사할 기록이 하나도 없어 그대로 통과했다.
## 문제
관문은 종료 코드로 말한다. 단계 계약이 「관문은 종료 코드가 0 이어야 지난 것이다」 라고
적었고, 런 원장 run.json 의 stages[].gates[].exit 에 그 값이 남는다. 사람이 셸에서 그 값을
읽어 옮겨 적는다.
--help 하나를 잰 것뿐인데 값이 두 번 틀렸다. 관문 결과 전부가 같은 방법으로 적히고 있다.
## 결론
리비전 43e1aad 의 scripts/*.py 열넷 중 열둘이 --help 에 exit 0,
build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다.
exit 0 이 usage 가 나왔다는 뜻은 아니다 — audit-records.py 는 --help 를 프로젝트
이름으로 받아 검사하고 통과시킨다.
값을 틀리게 만든 것은 셸의 동작 둘이다. 파이프의 종료 코드 변수는 마지막 명령을 가리키고,
명령 치환은 그 변수를 덮어쓴다. 둘 다 정의된 동작이라 셸이 경고하지 않는다.
조치로 scripts/capture-evidence.py 를 만들었다. 명령을 subprocess 로 직접 돌리고 그
프로세스의 반환값을 그대로 메타에 적는다. 종료 코드를 인자로 받지 않으므로 손으로 적을
경로가 없다.
## 검증 환경
python 3.12.3 · node v24.14.0 · bash 5.2.21(1)-release · Linux.
대상 저장소 document-haness 리비전 43e1aadef077ad93c30495df428ee3a71dd73f4a.
측정은 그 커밋에서 갈라진 worktree dh-B 에서 했고, 그 시점 작업 트리에는
capture-evidence.py 가 더해져 있어 증거 메타의 sourceDirty 가 true 다.
측정 대상 열넷은 git ls-tree 로 그 커밋의 목록만 골라 냈다.
## 재현 조건
1. document-haness 를 43e1aad 로 체크아웃한다.
2. 파이프를 끼고 잰다 — 반복문 안에서 python3 출력을 head 로 넘기고 그다음 줄에서 $? 를 읽는다.
3. 열넷이 전부 exit=0 으로 나오는 것을 본다.
4. 파이프를 걷고 out=$(...) 다음 줄에서 code=$? 로 받아 다시 잰다.
5. build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit=1 로 갈리는 것을 본다.
## 본문
<!-- body:start -->
## 두 번 같은 값이 나왔다
검사기 목록을 만들려고 열넷에 `--help` 를 돌렸다. 출력이 길어서 `head` 로 잘랐다.
```bash
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null
code=$?
n=$(basename $s)
echo "$n exit=$code"
done
```
열넷이 전부 `exit=0` 이었다.
```
audit-records.py exit=0
build-tech-log-tree.py exit=0
check-figure-overlap.py exit=0
check-figure-text.py exit=0
fold-analysis-into-final.py exit=0
fold-studio-contract-into-index.py exit=0
preview-figure.py exit=0
studio-body.py exit=0
techlog.py exit=0
verify-pipeline-run.py exit=0
verify-pipeline.py exit=0
verify-project-layout.py exit=0
verify-refactor-work-item.py exit=0
verify-tech-log-tree.py exit=0
```
이 저장소를 함께 조사한 다른 세션도 같은 값을 얻어 「`scripts/*.py` 14개 전부 `--help`
종료 코드 0 으로 돌아온다」 를 확인 등급 **확인함**으로 적었다. 두 사람이 같은 값을 얻었으니
맞는 값처럼 보였다.
## 값을 만든 것은 셸이다
파이프라인의 `$?`**마지막** 명령의 종료 코드다. `head` 는 언제나 성공하므로 앞의
`python3` 가 무엇을 반환하든 `$?` 는 0 이 된다.
```bash
set +o pipefail
false | head -1; echo "false | head -1 -> exit=$?"
false; echo "false -> exit=$?"
```
```
false | head -1 -> exit=0
false -> exit=1
bash 5.2.21(1)-release
```
POSIX 셸의 정의된 동작이고 이 셸의 특이점이 아니다. `pipefail` 을 켜거나
`${PIPESTATUS[0]}` 를 읽으면 앞 명령의 값을 얻는다.
같은 착각이 한 번 더 났다. 파이프를 걷어 내고 다시 잴 때 이렇게 썼다.
```bash
out=$(timeout 60 python3 $s --help 2>&1)
echo "$(basename $s) exit=$?"
```
명령 치환 `$(basename $s)` 가 먼저 실행되면서 `$?` 를 덮어썼다. 그래서 두 번째 측정도
열넷 전부 0 이었다. `code=$?` 를 명령 바로 다음 줄에 두고서야 값이 갈렸다.
## 다시 잰 값
```bash
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
out=$(timeout 60 python3 $s --help 2>&1)
code=$?
n=$(basename $s)
echo "$n exit=$code"
done
```
두 측정의 차이는 두 줄뿐이다. 잰 것은 `--help` 하나뿐이라 나머지 열둘이 다른 인자에서
어떻게 도는지는 이 값이 말해 주지 않는다.
```
2c2
< build-tech-log-tree.py exit=0
---
> build-tech-log-tree.py exit=1
13c13
< verify-refactor-work-item.py exit=0
---
> verify-refactor-work-item.py exit=1
```
## 왜 그 둘만인가
열넷 중 넷이 `argparse` 를 쓰지 않는다.
| 스크립트 | `argparse` | `--help` |
|---|---|---|
| `audit-records.py` | 없음 | `exit 0` — 「문제 없음」 |
| `build-tech-log-tree.py` | 없음 | `exit 1` |
| `techlog.py` | 없음 | `exit 0` — CLI 가 아니라 인자를 안 읽는다 |
| `verify-refactor-work-item.py` | 없음 | `exit 1` |
| 나머지 열 | 있음 | `exit 0` — usage |
남은 셋은 `--help` 를 옵션이 아니라 위치 인자로 먹는다. `audit-records.py` 는 그것을
프로젝트 이름으로 받아 「문제 없음」을 찍고, `build-tech-log-tree.py`
`verify-refactor-work-item.py` 는 그 이름의 파일을 못 찾아 실패한다.
```
$ python3 scripts/audit-records.py --help
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
문제 없음
합계 0건
exit=0
$ python3 scripts/build-tech-log-tree.py --help
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
exit=1
$ python3 scripts/verify-refactor-work-item.py --help
REFACTOR WORK ITEM VERIFICATION: FAIL
- invalid work-item.json: --help/work-item.json
exit=1
```
`exit 0` 이 usage 가 나왔다는 뜻은 아니다. `audit-records.py``--help` 라는 이름의
프로젝트를 찾아 검사하고 통과시켰다.
## 손으로 적지 못하게 했다
관문 결과를 사람이 옮겨 적는 한 같은 뿌리에서 계속 난다. 그래서 명령을 돌리는 쪽과
종료 코드를 적는 쪽을 하나로 붙였다.
```python
proc = subprocess.run(command, cwd=cwd, capture_output=True,
text=True, timeout=timeout)
exit_code, out = proc.returncode, proc.stdout + proc.stderr
```
셸 한 줄을 감싸는 래퍼도 됐지만 그러면 파이프를 다시 쓸 수 있게 된다. 값을 두 번 틀리게
만든 것이 바로 그 셸이라 아예 거치지 않기로 했다. 대신 셸 문법이 필요한 명령은
`bash -c` 를 인자로 넘겨야 하고, 그 안에서 다시 파이프를 쓰면 같은 실수가 난다 — 이 기록의
증거 여섯 개도 그렇게 수집했다. 종료 코드를 인자로 받지 않으므로 손으로 적어 넣을 수는 없다. 원문은 `final/evidence/raw/` 에, 실행 메타는 `final/evidence/meta/`
같은 이름으로 함께 떨어진다.
처음 판에서는 이 도구도 인자를 잘못 먹었다. `argparse.REMAINDER` 로 명령을 받았더니
`--proves` 부터가 실행할 명령으로 딸려 가 `No such file or directory: '--proves'` 로 죽었다.
지금은 `--` 앞뒤를 직접 가르고, 왜 그렇게 했는지를 코드에 한 줄로 남겨 두었다.
```python
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
argv = sys.argv[1:]
```
`--help` 를 위치 인자로 먹은 세 스크립트와 같은 종류의 실수다. 이 저장소에서 인자를 손으로
가르는 코드는 대개 여기서 걸린다.
이 기록이 인용한 원문 다섯 개를 그 도구가 수집했다. 메타 하나는 이렇게 생겼다.
```json
{
"id": "help-exit-codes-measured-without-a-pipe",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:54:41+09:00",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
"bytes": 410
}
```
여기서 `exitCode: 0` 은 **측정 반복문 자체가 끝까지 돌았다**고 말한다. 열넷 각각의 종료
코드는 원문 `raw/` 안에 있다. 반복문이 반환한 값과 그 안에서 잰 값을 섞지 않는다 — 메타에는
반복문 쪽이 들어간다.
`sourceDirty` 는 그 실행 시점에 작업 트리에 커밋 안 된 변경이 있었는지 적는다. 있으면
`sourceRevision` 이 그 출력을 설명하지 못한다. 이 다섯은 전부 `true` 다 — 수집기 자신을
더한 상태에서 쟀다.
기존 경로를 지우지 않았다. 손으로 만든 `raw/`·`meta/` 도 그대로 쓰이고, 이 도구는 필요할
때만 부른다.
## 이 사건이 닫지 못한 것
수집기는 브라우저 캡처와 대화형 명령을 담지 못한다. 이 저장소의 증거 가운데
`final/evidence/browser/` 쪽은 여전히 Playwright 로 찍고 메타를 손으로 적는다 — 종료 코드를
손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다.
<!-- body:end -->
@@ -0,0 +1,278 @@
---
id:
kind: CASE
slug: exit-code-read-behind-a-pipe
title: 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
topic: pipeline-gate-exit-codes
topicName: 관문의 종료 코드
project: document-haness
status: 게시 전
studio: ""
lastVerifiedOn: 2026-09-10
source:
- final/document.md#§2-관찰한-것
- final/document.md#§3-파이프-뒤의-종료-코드
- final/document.md#§4-다시-잰-값
- final/document.md#§5-argparse-를-쓰지-않는-넷
- final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다
sourceRevision: 43e1aadef077ad93c30495df428ee3a71dd73f4a
evidence:
- ../../../final/evidence/raw/exit-code-through-a-pipe.txt
- ../../../final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt
- ../../../final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt
- ../../../final/evidence/raw/argparse-absent-scripts.txt
- ../../../final/evidence/raw/what-the-three-print-for-help.txt
---
# 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
scripts/ 의 검사기 열넷에 --help 를 돌려 전부 종료 코드 0 을 받았고, 이 저장소를 함께
조사한 다른 세션도 같은 값을 얻어 확인 등급 확인함으로 적었다. 값은 검사기가 아니라 재는
방법이 만든 것이었다. 파이프를 걷어 내고 다시 재니 둘이 exit 1 이다.
## 관계
- **검사할 것이 없을 때 관문은 무엇을 내야 하는가**
이 사건에서 `audit-records.py --help` 가 「문제 없음」을 찍으면서 그 물음이 열렸다.
`--help` 라는 이름의 프로젝트에는 검사할 기록이 하나도 없어 그대로 통과했다.
## 문제
관문은 종료 코드로 말한다. 단계 계약이 「관문은 종료 코드가 0 이어야 지난 것이다」 라고
적었고, 런 원장 run.json 의 stages[].gates[].exit 에 그 값이 남는데, 그 값을 사람이 셸에서
읽어 옮겨 적는다.
--help 하나를 잰 것뿐인데 값이 두 번 틀렸다. 관문 결과 전부가 같은 방법으로 적히고 있다.
## 결론
리비전 43e1aad 의 scripts/*.py 열넷 중 열둘이 --help 에 exit 0,
build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다.
exit 0 이 usage 가 나왔다는 뜻은 아니다 — audit-records.py 는 --help 를 프로젝트
이름으로 받아 검사하고 통과시킨다.
값을 틀리게 만든 것은 셸의 동작 둘이다. 파이프의 종료 코드 변수는 마지막 명령을 가리키고,
명령 치환은 그 변수를 덮어쓴다. 둘 다 정의된 동작이라 셸이 경고하지 않는다.
조치로 만든 scripts/capture-evidence.py 는 명령을 subprocess 로 직접 돌리고 그 프로세스의
반환값을 그대로 메타에 적는다. 종료 코드를 인자로 받지 않으므로 손으로 적을 경로가 없다.
## 검증 환경
python 3.12.3 · node v24.14.0 · bash 5.2.21(1)-release · Linux.
대상 저장소 document-haness 리비전 43e1aadef077ad93c30495df428ee3a71dd73f4a.
측정은 그 커밋에서 갈라진 worktree dh-B 에서 했고, 그 시점 작업 트리에는
capture-evidence.py 가 더해져 있어 증거 메타의 sourceDirty 가 true 다.
측정 대상 열넷은 git ls-tree 로 그 커밋의 목록만 골라 냈다.
## 재현 조건
1. document-haness 를 43e1aad 로 체크아웃한다.
2. 파이프를 끼고 잰다 — 반복문 안에서 python3 출력을 head 로 넘기고 그다음 줄에서 $? 를 읽는다.
3. 열넷이 전부 exit=0 으로 나오는 것을 본다.
4. 파이프를 걷고 out=$(...) 다음 줄에서 code=$? 로 받아 다시 잰다.
5. build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit=1 로 갈리는 것을 본다.
## 본문
<!-- body:start -->
## 두 번 같은 값이 나왔다
검사기 목록을 만들려고 열넷에 `--help` 를 돌렸는데, 출력이 길어서 `head` 로 잘랐다.
```bash
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null
code=$?
n=$(basename $s)
echo "$n exit=$code"
done
```
열넷이 전부 `exit=0` 이었다.
```
audit-records.py exit=0
build-tech-log-tree.py exit=0
check-figure-overlap.py exit=0
check-figure-text.py exit=0
fold-analysis-into-final.py exit=0
fold-studio-contract-into-index.py exit=0
preview-figure.py exit=0
studio-body.py exit=0
techlog.py exit=0
verify-pipeline-run.py exit=0
verify-pipeline.py exit=0
verify-project-layout.py exit=0
verify-refactor-work-item.py exit=0
verify-tech-log-tree.py exit=0
```
이 저장소를 함께 조사한 다른 세션도 같은 값을 얻어 「`scripts/*.py` 14개 전부 `--help`
종료 코드 0 으로 돌아온다」 를 확인 등급 **확인함**으로 적었다. 두 사람이 같은 값을 얻었으니
맞는 값처럼 보였다.
## 값을 만든 것은 셸이다
파이프라인의 `$?`**마지막** 명령의 종료 코드다. `head` 는 언제나 성공하므로 앞의
`python3` 가 무엇을 반환하든 `$?` 는 0 이 된다.
```bash
set +o pipefail
false | head -1; echo "false | head -1 -> exit=$?"
false; echo "false -> exit=$?"
```
```
false | head -1 -> exit=0
false -> exit=1
bash 5.2.21(1)-release
```
POSIX 셸의 정의된 동작이고 이 셸의 특이점이 아니다. `pipefail` 을 켜거나
`${PIPESTATUS[0]}` 를 읽으면 앞 명령의 값을 얻는다.
같은 착각이 한 번 더 났다. 파이프를 걷어 내고 다시 잴 때 이렇게 썼다.
```bash
out=$(timeout 60 python3 $s --help 2>&1)
echo "$(basename $s) exit=$?"
```
명령 치환 `$(basename $s)` 가 먼저 실행되면서 `$?` 를 덮어써서 두 번째 측정도 열넷 전부
0 이었다. `code=$?` 를 명령 바로 다음 줄에 두고서야 값이 갈렸다.
## 다시 잰 값
```bash
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
out=$(timeout 60 python3 $s --help 2>&1)
code=$?
n=$(basename $s)
echo "$n exit=$code"
done
```
두 측정의 차이는 두 줄뿐이다.
```
2c2
< build-tech-log-tree.py exit=0
---
> build-tech-log-tree.py exit=1
13c13
< verify-refactor-work-item.py exit=0
---
> verify-refactor-work-item.py exit=1
```
잰 것은 `--help` 하나뿐이라 나머지 열둘이 다른 인자에서 어떻게 도는지는 이 값이 말해 주지
않는다.
## 왜 그 둘만인가
열넷 중 넷이 `argparse` 를 쓰지 않는다.
| 스크립트 | `argparse` | `--help` |
|---|---|---|
| `audit-records.py` | 없음 | `exit 0` — 「문제 없음」 |
| `build-tech-log-tree.py` | 없음 | `exit 1` |
| `techlog.py` | 없음 | `exit 0` — CLI 가 아니라 인자를 안 읽는다 |
| `verify-refactor-work-item.py` | 없음 | `exit 1` |
| 나머지 열 | 있음 | `exit 0` — usage |
남은 셋은 `--help` 를 옵션이 아니라 위치 인자로 먹는다. `audit-records.py` 는 그것을
프로젝트 이름으로 받아 「문제 없음」을 찍고, `build-tech-log-tree.py`
`verify-refactor-work-item.py` 는 그 이름의 파일을 못 찾아 실패한다.
```
$ python3 scripts/audit-records.py --help
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
문제 없음
합계 0건
exit=0
$ python3 scripts/build-tech-log-tree.py --help
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
exit=1
$ python3 scripts/verify-refactor-work-item.py --help
REFACTOR WORK ITEM VERIFICATION: FAIL
- invalid work-item.json: --help/work-item.json
exit=1
```
`exit 0` 이 usage 가 나왔다는 뜻은 아니다. `audit-records.py``--help` 라는 이름의
프로젝트를 찾아 검사하고 통과시켰다.
## 손으로 적지 못하게 했다
관문 결과를 사람이 옮겨 적는 한 같은 뿌리에서 같은 실수가 계속 난다. 그래서 명령을 돌리는
쪽과 종료 코드를 적는 쪽을 하나로 붙였다.
```python
proc = subprocess.run(command, cwd=cwd, capture_output=True,
text=True, timeout=timeout)
exit_code, out = proc.returncode, proc.stdout + proc.stderr
```
종료 코드를 인자로 받지 않으므로 손으로 적어 넣을 수는 없다. 원문은
`final/evidence/raw/` 에, 실행 메타는 `final/evidence/meta/` 에 같은 이름으로 함께 떨어진다.
셸 한 줄을 감싸는 래퍼도 됐지만 그러면 파이프를 다시 쓸 수 있게 된다. 값을 두 번 틀리게
만든 것이 바로 그 셸이라 아예 거치지 않기로 했다. 대신 셸 문법이 필요한 명령은 `bash -c`
인자로 넘겨야 하고, 그 안에서 다시 파이프를 쓰면 같은 실수가 난다 — 이 기록의 증거
다섯 개도 그렇게 수집했다.
처음 판에서는 이 도구도 인자를 잘못 먹었다. `argparse.REMAINDER` 로 명령을 받았더니
`--proves` 부터가 실행할 명령으로 딸려 가 `No such file or directory: '--proves'` 로 죽었다.
지금은 `--` 앞뒤를 직접 가르고, 왜 그렇게 했는지를 코드에 한 줄로 남겨 두었다.
```python
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
argv = sys.argv[1:]
```
`--help` 를 위치 인자로 먹은 세 스크립트와 같은 종류의 실수다. 이 저장소에서 인자를 손으로
가르는 코드는 대개 여기서 걸린다.
이 기록이 인용한 원문 다섯 개를 그 도구가 수집했다. 메타 하나는 이렇게 생겼다.
```json
{
"id": "help-exit-codes-measured-without-a-pipe",
"kind": "terminal",
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
"sourceDirty": true,
"executedAt": "2026-09-10T09:54:41+09:00",
"exitCode": 0,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
"bytes": 410
}
```
여기서 `exitCode: 0` 은 **측정 반복문 자체가 끝까지 돌았다**고 말한다. 열넷 각각의 종료
코드는 원문 `raw/` 안에 있다. 반복문이 반환한 값과 그 안에서 잰 값을 섞지 않는다 — 메타에는
반복문 쪽이 들어간다.
`sourceDirty` 는 그 실행 시점에 작업 트리에 커밋 안 된 변경이 있었는지 적는다. 있으면
`sourceRevision` 이 그 출력을 설명하지 못한다. 이 다섯은 전부 `true` 다 — 수집기 자신을
더한 상태에서 쟀다.
기존 경로를 지우지 않았다. 손으로 만든 `raw/`·`meta/` 도 그대로 쓰이고, 이 도구는 필요할
때만 부른다.
## 이 사건이 닫지 못한 것
수집기는 브라우저 캡처와 대화형 명령을 담지 못한다. 이 저장소의 증거 가운데
`final/evidence/browser/` 쪽은 여전히 Playwright 로 찍고 메타를 손으로 적는다 — 종료 코드를
손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다.
<!-- body:end -->
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""명령을 실제로 돌려 원문과 실행 메타를 함께 적립한다.
`final/evidence/raw/` 는 정본이고 `final/evidence/meta/` 는 그 실행의
command·cwd·executedAt·exitCode·revision 이다. 둘을 사람이 따로 적으면 갈라진다 —
`verify-project-layout.py` 가 「raw 는 있는데 meta 가 없다」로 세는 자리가 그것이다.
이 도구는 **종료 코드를 손으로 적을 수 없게 만든다.** 명령을 여기서 돌리고, 그 프로세스의
반환값을 그대로 meta 에 적는다. 돌리지 않은 검증을 완료로 적는 경로가 없어야 한다.
기존 경로를 지우지 않는다 — 손으로 만든 raw/meta 도 그대로 유효하고, 이 도구는 선택적으로 쓴다.
python3 scripts/capture-evidence.py <프로젝트> <증거 id> -- <명령...>
python3 scripts/capture-evidence.py <프로젝트> <증거 id> --cwd <경로> \
--proves "<이 출력이 뒷받침하는 것>" --does-not-prove "<뒷받침하지 못하는 것>" -- <명령...>
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _revision(cwd: str) -> str | None:
"""그 작업 디렉터리 저장소의 HEAD. 저장소가 아니면 None 이다."""
try:
out = subprocess.run(["git", "rev-parse", "HEAD"], cwd=cwd,
capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError):
return None
return out.stdout.strip() if out.returncode == 0 else None
def _dirty(cwd: str) -> bool | None:
"""작업 트리에 커밋 안 된 변경이 있나. 있으면 revision 이 출력을 설명하지 못한다."""
try:
out = subprocess.run(["git", "status", "--porcelain"], cwd=cwd,
capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError):
return None
return bool(out.stdout.strip()) if out.returncode == 0 else None
def capture(project: str, eid: str, command: list[str], cwd: str,
proves: str, does_not_prove: str, kind: str,
timeout: int, subdir: str) -> int:
base = os.path.join(ROOT, "docs", project, "final", "evidence")
raw_dir = os.path.join(base, "raw", subdir) if subdir else os.path.join(base, "raw")
meta_dir = os.path.join(base, "meta")
os.makedirs(raw_dir, exist_ok=True)
os.makedirs(meta_dir, exist_ok=True)
started = datetime.datetime.now().astimezone()
try:
proc = subprocess.run(command, cwd=cwd, capture_output=True,
text=True, timeout=timeout)
exit_code, out = proc.returncode, proc.stdout + proc.stderr
except subprocess.TimeoutExpired as e:
exit_code = 124
out = (e.stdout or "") + (e.stderr or "") + f"\n[timeout {timeout}s]\n"
except OSError as e:
print(f"명령을 실행하지 못했다: {e}", file=sys.stderr)
return 2
raw_rel = os.path.join("raw", subdir, f"{eid}.txt") if subdir else os.path.join("raw", f"{eid}.txt")
raw_path = os.path.join(base, raw_rel)
with open(raw_path, "w", encoding="utf-8") as fh:
fh.write(out)
meta = {
"id": eid,
"kind": kind,
"sourceRevision": _revision(cwd),
"sourceDirty": _dirty(cwd),
"executedAt": started.isoformat(timespec="seconds"),
"executedAtSource": "이 도구가 명령을 실행한 시각",
"command": " ".join(command),
"cwd": os.path.relpath(cwd, ROOT) if cwd.startswith(ROOT) else cwd,
"exitCode": exit_code,
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
"rawPath": f"evidence/{raw_rel}",
"presentationPath": None,
"proves": proves,
"doesNotProve": does_not_prove,
"sha256": hashlib.sha256(out.encode("utf-8")).hexdigest(),
"bytes": len(out.encode("utf-8")),
}
meta_path = os.path.join(meta_dir, f"{eid}.json")
tmp = meta_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(meta, fh, ensure_ascii=False, indent=2)
fh.write("\n")
os.replace(tmp, meta_path)
print(f"{os.path.relpath(raw_path, ROOT)} exit={exit_code} {meta['bytes']}B")
print(f"{os.path.relpath(meta_path, ROOT)}")
return 0
def main() -> int:
ap = argparse.ArgumentParser(
description="명령을 돌려 raw 원문과 실행 메타를 함께 적립한다.")
ap.add_argument("project")
ap.add_argument("evidence_id")
ap.add_argument("--cwd", default=ROOT)
ap.add_argument("--subdir", default="", help="raw/ 아래 하위 폴더")
ap.add_argument("--kind", default="terminal",
choices=["terminal", "browser", "query-plan", "benchmark", "other"])
ap.add_argument("--proves", default="", help="이 출력이 뒷받침하는 것 (경계까지)")
ap.add_argument("--does-not-prove", default="", help="이 출력이 뒷받침하지 못하는 것")
ap.add_argument("--timeout", type=int, default=600)
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
argv = sys.argv[1:]
if "-h" in argv or "--help" in argv:
ap.parse_args(["--help"])
if "--" not in argv:
ap.error("돌릴 명령이 없다. `-- <명령...>` 으로 준다")
cut = argv.index("--")
args = ap.parse_args(argv[:cut])
command = argv[cut + 1:]
if not command:
ap.error("돌릴 명령이 없다. `-- <명령...>` 으로 준다")
return capture(args.project, args.evidence_id, command,
os.path.abspath(args.cwd), args.proves, args.does_not_prove,
args.kind, args.timeout, args.subdir)
if __name__ == "__main__":
raise SystemExit(main())
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""문장을 고치기 전과 후에 보호 구간이 그대로인지 본다.
윤문(S5·S6)은 뜻을 바꾸지 않고 문장만 고치는 단계다. 그런데 지금 관문 가운데 **편집 전후를
견주는 것이 하나도 없다.** `check_prose` 는 고친 뒤 파일만 보고, `check_evidence` 는 인용이
SSOT 에 있는지만 본다. 그래서 수치를 바꾸거나 유보를 지운 편집이 그대로 통과한다.
보는 것은 둘이다.
**1. 보호 구간** — 수치·날짜·버전·단위·코드·명령어·URL·직접 인용은 한 글자도 달라지면 안 된다
(CLAUDE.md 「작업 규칙」). 사라진 것과 새로 생긴 것을 따로 센다. 새로 생긴 수치는 지어낸
값일 수 있어서 사라진 것과 같은 무게로 본다.
**2. 유보 표현의 수** — 「추정」·「보인다」·「확인하지 못했다」 같은 말이 편집으로 줄면
확신이 올라간 것이다. **이 검사기는 그것이 옳은지 모른다.** 줄었다는 사실만 내고 판단은
근거를 받은 검토가 한다. 늘어난 것은 세지 않는다 — 유보를 더하는 것은 이 규범에서 안전한 쪽이다.
python3 scripts/check-preservation.py <편집 전.md> <편집 후.md>
python3 scripts/check-preservation.py --json <before> <after>
"""
from __future__ import annotations
import argparse
import collections
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 보호 구간. CLAUDE.md 「수치, 날짜, 버전, 단위, 코드, 명령어, URL, 직접 인용, 공식 명칭」
EXTRACTORS: dict[str, re.Pattern[str]] = {
"코드블록": re.compile(r"```[^\n]*\n(.*?)```", re.S),
"인라인코드": re.compile(r"`([^`\n]+)`"),
"URL": re.compile(r"(https?://[^\s`)\"'\]]+)"),
"직접인용": re.compile(r"「([^」]+)」"),
# 수치 — 소수·천단위 구분·단위·백분율·시각까지 한 덩어리로 잡는다.
# 앞뒤가 한글이면 낱말의 일부일 수 있어 낱말 경계를 요구한다
"수치": re.compile(r"(?<![\w.-])(\d[\d,]*(?:\.\d+)?(?:\s?%|ms|s|MB|GB|KB|B|건|장|개|줄|분|초|회)?)(?![\w.-])"),
}
# 유보 표현. 늘어난 것은 세지 않고 줄어든 것만 낸다
HEDGES = (
"추정", "가능성", "보인다", "보였다", "아마", "", "일 수 있다", "일지도",
"확인하지 못했다", "확인하지 않았다", "미확인", "안 봤다", "못 봤다",
"모른다", "정하지 않았다", "재지 않았다", "돌리지 않았다", "열지 않았다",
"로컬", "이 환경에서", "이번에는", "한정", "범위 안",
)
def _counts(text: str) -> dict[str, collections.Counter]:
out = {}
for name, pat in EXTRACTORS.items():
out[name] = collections.Counter(m.strip() for m in pat.findall(text))
return out
def _hedges(text: str) -> collections.Counter:
return collections.Counter({h: text.count(h) for h in HEDGES if text.count(h)})
def compare(before: str, after: str) -> dict:
b, a = _counts(before), _counts(after)
findings = []
for name in EXTRACTORS:
lost = b[name] - a[name]
gained = a[name] - b[name]
for value, n in sorted(lost.items()):
findings.append({"kind": name, "change": "사라짐", "count": n, "value": value})
for value, n in sorted(gained.items()):
findings.append({"kind": name, "change": "새로생김", "count": n, "value": value})
hb, ha = _hedges(before), _hedges(after)
dropped = hb - ha
hedge = [{"word": w, "before": hb[w], "after": ha[w]} for w in sorted(dropped)]
return {"findings": findings, "hedgesDropped": hedge,
"hedgeTotalBefore": sum(hb.values()), "hedgeTotalAfter": sum(ha.values())}
def main() -> int:
ap = argparse.ArgumentParser(description="편집 전후 보호 구간이 그대로인지 본다.")
ap.add_argument("before")
ap.add_argument("after")
ap.add_argument("--json", action="store_true")
ap.add_argument("--samples", type=int, default=5)
args = ap.parse_args()
for p in (args.before, args.after):
if not os.path.isfile(p):
print(f"그런 파일이 없다: {p}", file=sys.stderr)
return 2
before = open(args.before, encoding="utf-8").read()
after = open(args.after, encoding="utf-8").read()
res = compare(before, after)
if args.json:
print(json.dumps(res, ensure_ascii=False, indent=2))
return 1 if res["findings"] else 0
print(f"\n편집 전 {os.path.relpath(args.before, ROOT)}"
f"\n편집 후 {os.path.relpath(args.after, ROOT)}")
grouped = collections.defaultdict(list)
for f in res["findings"]:
grouped[(f["kind"], f["change"])].append(f)
for (kind, change), items in sorted(grouped.items()):
print(f"{kind} {change} {len(items):>3}")
for f in items[:args.samples]:
v = f["value"].replace("\n", "")
print(f" · {v[:96]}")
if len(items) > args.samples:
print(f" … 외 {len(items) - args.samples}")
if res["hedgesDropped"]:
print(f" ! 유보 표현이 줄었다 — 편집 전 {res['hedgeTotalBefore']}"
f" → 편집 후 {res['hedgeTotalAfter']}")
for h in res["hedgesDropped"][:args.samples]:
print(f" · {h['word']} {h['before']}회 → {h['after']}")
print(" 확신이 올라간 것인지는 이 검사기가 모른다. 근거를 읽는 검토가 판단한다")
n = len(res["findings"])
print(f"\nPRESERVATION: {'FAIL' if n else 'PASS'} — 보호 구간 변화 {n}"
f" · 유보 감소 {len(res['hedgesDropped'])}")
return 1 if n else 0
if __name__ == "__main__":
raise SystemExit(main())
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""종류가 요구하는 내용이 실제로 채워져 있는지 본다.
`audit-records.py` 는 평문 칸 **안에 마크업이 있는지**만 본다. 칸이 아예 없거나 제목만 있고
비어 있는 것은 세지 않는다. Studio 는 빈 칸도 받아 주므로 그대로 저장되고, 화면에서는
제목만 남은 칸으로 보인다.
**결정적으로 판정 가능한 것만 본다.** 칸이 있는가, 비어 있지 않은가, 계약에 없는 `##` 이
있는가(화면에 자리가 없어 통째로 사라진다), 종류가 요구하는 근거의 자리가 채워졌는가.
내용이 옳은지·인과가 맞는지는 보지 않는다 — 그것은 근거를 받은 검토 컨텍스트의 몫이다.
**강제하지 않는 것 셋.**
- 고정 목차. 본문(`## 본문`) 안의 절 구성은 글마다 다르다
- 자료 개수. 그림 몇 장·증거 몇 건을 요구하지 않는다
- 답. 답이 없는 QUESTION 은 정상이다. 물음과 확인된 사실과 답을 구할 방법만 요구한다
python3 scripts/check-required-content.py <프로젝트>
python3 scripts/check-required-content.py --file <기록.md>
"""
from __future__ import annotations
import argparse
import collections
import glob
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import techlog # noqa: E402
# 종류마다의 `##` 칸. 정본은
# .agents/skills/publishing-tech-log-to-studio/references/studio-form-map.md 다.
# 계약에 없는 `##` 는 화면에 자리가 없어 통째로 사라진다
SECTIONS: dict[str, dict[str, tuple[str, ...]]] = {
"case": {"required": ("관계", "문제", "결론", "검증 환경", "재현 조건", "본문"),
"optional": ()},
"concept": {"required": ("관계", "본문"), "optional": ()},
"reference": {"required": ("관계", "목적", "규칙", "적용 조건", "예외"),
"optional": ("예시",)},
"question": {"required": ("관계", "사실", "미지수", "다음 검증"),
"optional": ("가정", "제약", "선택지")},
# Decision 의 틀에는 `관계` 가 없다 (templates/decision.md). 있으면 받되 요구하지 않는다
"decision": {"required": ("근거", "결정문", "판단 이유", "영향"),
"optional": ("관계",)},
}
BODY_KINDS = {"case", "concept"}
# frontmatter 의 `kind` 는 Studio 가 쓰는 값이다. 폴더 이름과 하나가 다르다 —
# decision/ 폴더의 기록은 `kind: PROJECT_DECISION` 이다 (templates/decision.md:3)
KIND_ALIASES = {"CASE": "case", "CONCEPT": "concept", "REFERENCE": "reference",
"QUESTION": "question", "PROJECT_DECISION": "decision"}
# 종류가 요구하는 근거의 자리. 값이 옳은지가 아니라 **자리가 채워졌는지**만 본다
FRONTMATTER: dict[str, tuple[str, ...]] = {
"case": ("sourceRevision",),
"concept": ("basisVersion",),
"reference": ("sourceRevision",),
"question": ("questionStatus",),
"decision": ("decisionStatus",),
}
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
def _front_matter(text: str) -> tuple[dict, int]:
"""frontmatter 와 그것이 끝나는 줄 번호."""
if not text.startswith("---"):
return {}, 0
end = text.find("\n---", 3)
if end < 0:
return {}, 0
out = {}
for line in text[3:end].splitlines():
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
if m:
out[m.group(1)] = m.group(2).strip().strip('"')
return out, text[:end].count("\n") + 2
def _sections(text: str) -> dict[str, str]:
"""`## 이름` → 그 아래 내용. 본문 구간 안의 `##` 은 세지 않는다."""
body_a = text.find(BODY_START)
body_b = text.find(BODY_END)
out: dict[str, str] = {}
order: list[tuple[str, int]] = []
for m in re.finditer(r"^##\s+(.+)$", text, re.M):
if body_a >= 0 <= body_b and body_a < m.start() < body_b:
continue # 본문 안의 절은 글마다 다르다. 강제하지 않는다
order.append((m.group(1).strip(), m.end()))
for i, (name, start) in enumerate(order):
stop = order[i + 1][1] - len(f"## {order[i + 1][0]}") if i + 1 < len(order) else len(text)
chunk = text[start:stop]
if name == "본문":
chunk = chunk.replace(BODY_START, "").replace(BODY_END, "")
out[name] = chunk.strip()
return out
def _summary(text: str, fm_end: int) -> str:
"""제목 바로 아래 첫 문단. Studio 의 `요약` 칸이다."""
rest = text.split("\n", fm_end)[-1] if fm_end else text
m = re.search(r"^#\s+.+$", rest, re.M)
if not m:
return ""
after = rest[m.end():]
after = re.split(r"^##\s", after, maxsplit=1, flags=re.M)[0]
for para in (p.strip() for p in after.split("\n\n")):
if para and not para.startswith("<!--"):
return para
return ""
def check_record(path: str, rep: techlog.Report) -> None:
rel = os.path.relpath(path, ROOT)
text = open(path, encoding="utf-8").read()
fm, fm_end = _front_matter(text)
kind = KIND_ALIASES.get((fm.get("kind") or "").upper(),
(fm.get("kind") or "").lower())
if kind not in SECTIONS:
rep.error("kind 를 모르겠다", f"{rel} — kind={fm.get('kind')!r}")
return
spec = SECTIONS[kind]
found = _sections(text)
for name in spec["required"]:
if name not in found:
rep.error(f"{kind.upper()} 에 `{name}` 칸이 없다", rel)
elif not found[name]:
rep.error(f"{kind.upper()} 의 `{name}` 칸이 비었다",
f"{rel} — 제목만 있고 내용이 없다")
known = set(spec["required"]) | set(spec["optional"])
for name in found:
if name not in known:
rep.error("계약에 없는 칸 — 화면에 자리가 없어 사라진다",
f"{rel} — ## {name}")
if not _summary(text, fm_end):
rep.error("요약이 없다", f"{rel} — 제목 바로 아래 첫 문단이 `요약` 칸이다")
for key in FRONTMATTER.get(kind, ()):
if not fm.get(key):
rep.error(f"{kind.upper()} 에 `{key}` 가 없다", rel)
# 본문이 있는 종류만 본문 마커를 갖는다. 없는 종류에 있으면 평문으로 새어 나간다
has_body = BODY_START in text and BODY_END in text
if kind in BODY_KINDS and not has_body:
rep.error(f"{kind.upper()} 에 본문 마커가 없다", rel)
if kind not in BODY_KINDS and has_body:
rep.error(f"{kind.upper()} 은 본문이 없는 종류인데 본문 마커가 있다", rel)
# 근거의 자리 — CASE 는 관찰을 뒷받침할 것이 있어야 한다.
# 무엇을 가리키는지는 check_evidence 가 보고, 여기서는 자리가 비었는지만 본다
if kind == "case" and "evidence:" not in text and "source:" not in text:
rep.error("CASE 에 근거 목록이 없다", f"{rel} — evidence: 도 source: 도 없다")
# 답이 없는 QUESTION 은 정상이다. 답을 구할 방법이 없는 것이 결함이다
if kind == "question" and found.get("다음 검증", "").strip() in ("", "-"):
rep.error("QUESTION 에 답을 구할 방법이 없다",
f"{rel} — 답이 없는 것은 결함이 아니지만 방법이 없는 것은 결함이다")
def verify(project: str) -> tuple[techlog.Report, str | None]:
"""(보고, 대상이 성립하지 않는 사유). 사유가 있으면 검사한 것이 하나도 없다.
「봤고 괜찮다」와 「볼 것이 없어서 통과」를 가른다. 셋으로 나뉜다.
| 상태 | 종료 코드 | 문구 |
|---|---|---|
| 봤고 괜찮다 | 0 | `error 0` |
| 대상이 성립하지 않는다 (프로젝트 없음 · 계약 없음) | 2 | `대상이 성립하지 않는다 — <이유>` |
| 볼 것이 아직 없다 (계약은 있고 기록 0건) | 0 | `기록 0건 — 계약의 글감 N개가 아직 안 쓰였다` |
가운데는 결함이다. 아래는 결함이 아니다 — 아직 안 쓴 것은 잘못이 아니다. 다만 초록으로
보이면 안 된다.
"""
rep = techlog.Report(project)
studio = os.path.join(ROOT, "docs", project, "tech-log-studio")
index_path = os.path.join(studio, "tech-log-tree.json")
index = techlog.load_index(index_path)
if index is None:
return rep, "tech-log-tree.json 이 없다"
records = [f for f in sorted(glob.glob(f"{studio}/*/*/*.md"))
if not f.split(os.sep)[-3].startswith("_")]
rep.facts["records"] = len(records)
planned = len(list(techlog.nodes(index)))
if not records:
rep.facts["미작성"] = f"계약의 글감 {planned}개가 아직 안 쓰였다"
kinds = collections.Counter()
for f in records:
kinds[os.path.basename(os.path.dirname(f))] += 1
check_record(f, rep)
rep.facts["kinds"] = dict(kinds)
return rep, None
def render(rep: techlog.Report, samples: int) -> None:
facts = " · ".join(f"{k}={v}" for k, v in rep.facts.items())
print(f"\n[{rep.project}] {facts}")
for label, bucket, mark in (("error", rep.errors, ""), ("warn", rep.warns, "!")):
for rule, details in sorted(bucket.items(), key=lambda kv: -len(kv[1])):
print(f" {mark} {label} {len(details):>4} {rule}")
for d in details[:samples]:
print(f" · {d}")
if samples and len(details) > samples:
print(f" … 외 {len(details) - samples}")
def main() -> int:
ap = argparse.ArgumentParser(description="종류가 요구하는 내용이 채워져 있는지 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--file", action="append", default=[], help="기록 .md 를 직접 준다")
ap.add_argument("--samples", type=int, default=3)
args = ap.parse_args()
if args.file:
rep = techlog.Report("파일")
rep.facts["records"] = len(args.file)
for f in args.file:
check_record(os.path.abspath(f), rep)
reports = [rep]
else:
projects = args.projects or sorted(
os.path.basename(os.path.dirname(p))
for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio"))
if not os.path.basename(os.path.dirname(p)).startswith("_"))
if not projects:
print("볼 프로젝트가 없다", file=sys.stderr)
return 2
missing = [p for p in projects
if not os.path.isdir(os.path.join(ROOT, "docs", p))]
if missing:
print("대상이 성립하지 않는다 — 그런 프로젝트가 없다: "
f"{', '.join(missing)}", file=sys.stderr)
return 2
pairs = [verify(p) for p in projects]
ungrounded = [(p, why) for (r, why), p in zip(pairs, projects) if why]
if ungrounded:
for p, why in ungrounded:
print(f"대상이 성립하지 않는다 — {p}: {why}", file=sys.stderr)
return 2
reports = [r for r, _ in pairs]
for r in reports:
render(r, args.samples)
e = sum(r.error_count for r in reports)
total = sum(r.facts.get("records", 0) for r in reports)
unwritten = [f"{r.project}: {r.facts['미작성']}" for r in reports if "미작성" in r.facts]
for line in unwritten:
print(f" · {line}")
print(f"\nREQUIRED CONTENT: {'FAIL' if e else 'PASS'}"
f" — 기록 {total} · error {e}"
+ (f" · 아직 안 쓴 프로젝트 {len(unwritten)}" if unwritten else ""))
return 1 if e else 0
if __name__ == "__main__":
raise SystemExit(main())
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""의미 검토가 받을 입력을 한 파일로 묶는다.
설계가 정한 분업이다 — 「결정적으로 판정 가능한 조건만 코드로 검사한다. 의미·인과·가독성은
근거를 받은 별도 검토 컨텍스트가 판단하고 확신이 없으면 보류한다」.
이 도구는 **판단하지 않는다.** 판단할 사람이 받을 것을 모은다.
담는 것 다섯.
1. **대상과 해시** — 기록·SSOT·증거·그림의 sha256. 검토가 끝난 뒤 파일이 바뀌면 그 판정은
이 해시에 안 맞는다
2. **검사기 버전** — 스킬의 `metadata.version` 과 검사기 파일의 내용 해시
3. **관문 결과** — 여기서 **실제로 돌려** 종료 코드를 적는다. 받아 적지 않는다
4. **주장 후보** — 본문에서 검증 가능한 문장을 기계로 뽑는다. 사람이 적은 주장 목록과
견주면 「본문에 있으나 주장 목록에는 빠진 것」이 보인다
5. **판정 기준** — 주장 종류마다 무엇이 있어야 하는지 (`quality-policy@1` §3)
python3 scripts/review-package.py <프로젝트> --record <기록.md> -o <출력.json>
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import re
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# quality-policy@1 §3 — 주장 종류마다 필요한 근거와 허용되는 표현
CLAIM_KINDS = {
"코드 구조·설정": {"근거": "저장소 ID, 커밋, 파일, 심볼 또는 범위",
"허용": "지정된 버전에 이 구현·설정이 존재함"},
"실제 실행 동작": {"근거": "실행 ID, 환경·입력·명령·종료 코드, 원본 출력",
"허용": "기록된 조건에서 관찰된 결과"},
"성능 비교": {"근거": "실험 코드·데이터·부하·반복 횟수·원시 측정치·집계 방식",
"허용": "측정한 조건과 변동 범위 안의 비교"},
"개념·제품 동작": {"근거": "해당 버전의 공식 문서 또는 명세", "허용": "출처가 설명하는 적용 범위"},
"선택 이유·개인 경험": {"근거": "작성자 기록·ADR·승인된 메모", "허용": "기록에 있는 이유와 실제 수행한 일"},
"해석·가설": {"근거": "해석의 전제가 되는 근거와 아직 확인하지 못한 부분",
"허용": "가능성·추정임을 명시한 설명"},
}
# 검증 가능한 문장을 고르는 표지. 뜻을 보지 않고 표면만 본다
CLAIM_MARKS = (
(re.compile(r"\d"), "수치"),
(re.compile(r"exit\s*=?\s*\d|종료 코드"), "종료 코드"),
(re.compile(r"`[^`]+`"), "식별자"),
(re.compile(r"(더|덜|보다|만큼|배|비해)\s"), "비교"),
(re.compile(r"(때문|므로|따라서|그래서|원인)"), "인과"),
(re.compile(r"(항상|절대|전부|모두|하나도|없다|never|always)"), "전칭"),
)
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
def _sha256(path: str) -> str | None:
try:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
except OSError:
return None
def _front_matter_block(text: str) -> str:
if not text.startswith("---"):
return ""
end = text.find("\n---", 3)
return text[3:end] if end > 0 else ""
def _listed(fm: str, key: str) -> list[str]:
"""`key:` 아래의 `- 값` 목록. `- key: x` 짝은 `file:` 쪽을 쓴다."""
out = []
grab = False
for line in fm.splitlines():
if re.match(rf"^{key}:\s*$", line):
grab = True
continue
if grab:
if re.match(r"^\S", line):
break
m = re.match(r"^\s+-\s+(\S.*)$", line) or re.match(r"^\s+file:\s*(\S+)$", line)
if m and not m.group(1).startswith("key:"):
out.append(m.group(1).strip())
return out
def _scalar(fm: str, key: str) -> str | None:
m = re.search(rf"^{key}:\s*(.*)$", fm, re.M)
return m.group(1).strip().strip('"') or None if m else None
def _claim_candidates(text: str) -> list[dict]:
a, b = text.find(BODY_START), text.find(BODY_END)
region = text[a:b] if a >= 0 <= b else text
offset = text[:a].count("\n") + 1 if a >= 0 else 0
out = []
in_fence = False
for i, line in enumerate(region.splitlines()):
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence or not line.strip() or line.lstrip().startswith(("#", "|", "<!--")):
continue
for sentence in re.split(r"(?<=[.!?다])\s+", line.strip()):
marks = [name for pat, name in CLAIM_MARKS if pat.search(sentence)]
if len(sentence) > 12 and marks:
out.append({"line": offset + i + 1, "marks": marks, "text": sentence.strip()})
return out
def _run(cmd: list[str]) -> dict:
try:
p = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600)
tail = (p.stdout + p.stderr).strip().splitlines()
return {"cmd": " ".join(cmd), "exit": p.returncode,
"tail": tail[-3:] if tail else []}
except (OSError, subprocess.SubprocessError) as e:
return {"cmd": " ".join(cmd), "exit": None, "tail": [f"실행 실패: {e}"]}
def _checker_versions() -> dict:
path = os.path.join(ROOT, "scripts", "skill-versions.py")
spec = importlib.util.spec_from_file_location("skill_versions", path)
if spec is None or spec.loader is None:
return {}
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m.collect()
def _render_figures(assets: list[dict], out_dir: str) -> None:
"""그림을 PNG 로 떠서 검토가 **눈으로 볼** 수 있게 한다.
`check-figure-text.py` 는 `<text>` 가 이름인지 보고 `check-figure-overlap.py` 는 상자와
라벨이 겹치는지 본다. 둘 다 좌표와 문자열만 본다 — 그림이 말하는 것이 본문과 같은지는
사람이 봐야 안다. 렌더가 실패하면 실패했다고 적는다. 안 본 것을 본 것으로 만들지 않는다.
"""
for a in assets:
a["preview"] = None
if not a.get("exists") or not a["path"].endswith(".svg"):
continue
r = _run(["python3", "scripts/preview-figure.py",
"--file", a["path"], "-o", out_dir])
a["preview"] = {"exit": r["exit"],
"png": r["tail"][-1] if r["exit"] == 0 and r["tail"] else None,
"note": "검사기는 좌표와 문자열만 본다. 그림이 본문과 같은 것을 말하는지는 눈으로 본다"}
def _preservation(before: str, record: str) -> dict:
"""윤문 전후 비교의 결과를 통째로 싣는다.
`check-preservation.py` 는 유보 표현이 줄어든 것을 **경고로만** 낸다 — 종료 코드가 0 이라
관문으로는 안 걸린다. 확신 승격(가설→확인, 로컬→운영)이 딱 그 모양이라, 실어 보내지
않으면 그 편집은 아무도 못 본다. 그래서 `gates` 가 아니라 `warnings` 로 올린다.
"""
path = os.path.join(ROOT, "scripts", "check-preservation.py")
spec = importlib.util.spec_from_file_location("check_preservation", path)
if spec is None or spec.loader is None:
return {"available": False}
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
res = m.compare(open(before, encoding="utf-8").read(),
open(record, encoding="utf-8").read())
res["before"] = os.path.relpath(os.path.abspath(before), ROOT)
res["beforeSha256"] = _sha256(before)
res["available"] = True
return res
def build(project: str, record: str, figures_dir: str | None = None,
before: str | None = None) -> dict:
rec_abs = os.path.abspath(record)
text = open(rec_abs, encoding="utf-8").read()
fm = _front_matter_block(text)
rec_dir = os.path.dirname(rec_abs)
base = os.path.join(ROOT, "docs", project)
ssot = os.path.join(base, "final", "document.md")
def _ref(rel: str) -> dict:
p = rel if os.path.isabs(rel) else os.path.normpath(os.path.join(rec_dir, rel))
return {"path": os.path.relpath(p, ROOT), "sha256": _sha256(p),
"exists": os.path.exists(p)}
evidence = [_ref(r) for r in _listed(fm, "evidence")]
for e in evidence:
meta = os.path.join(base, "final", "evidence", "meta",
os.path.basename(e["path"]).rsplit(".", 1)[0] + ".json")
if os.path.exists(meta):
with open(meta, encoding="utf-8") as fh:
m = json.load(fh)
e["meta"] = {k: m.get(k) for k in
("command", "cwd", "exitCode", "sourceRevision", "sourceDirty",
"executedAt", "proves", "doesNotProve", "sha256")}
else:
e["meta"] = None
assets = [_ref(r) for r in _listed(fm, "assets")]
if figures_dir:
_render_figures(assets, figures_dir)
gates = [
_run(["python3", "scripts/verify-tech-log-tree.py", project]),
_run(["python3", "scripts/verify-project-layout.py", project]),
_run(["python3", "scripts/audit-records.py", project]),
_run(["python3", "scripts/check-required-content.py", project]),
_run(["node", ".agents/skills/writing-tech-log-records/scripts/check_evidence.mjs",
project, "--repo"]),
_run(["node", ".agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs",
"--warn", os.path.relpath(rec_abs, ROOT)]),
_run(["node", ".agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs",
os.path.relpath(rec_abs, ROOT)]),
]
if any(a["path"].endswith(".svg") for a in assets):
gates.append(_run(["python3", "scripts/check-figure-text.py", project]))
gates.append(_run(["python3", "scripts/check-figure-overlap.py", project]))
warnings: list[dict] = []
preservation = _preservation(before, rec_abs) if before else {"available": False}
if preservation.get("available"):
for h in preservation["hedgesDropped"]:
warnings.append({
"id": "유보 감소",
"detail": f"{h['word']} {h['before']}회 → {h['after']}",
"note": "종료 코드로는 안 걸린다. 확신이 올라간 것인지 그 자리에서 "
"덜어 낼 만했던 것인지는 근거를 읽어야 안다",
})
return {
"schemaVersion": 2,
"project": project,
"policyVersion": "quality-policy@1",
"target": {
"record": os.path.relpath(rec_abs, ROOT),
"sha256": _sha256(rec_abs),
"kind": _scalar(fm, "kind"),
"slug": _scalar(fm, "slug"),
"title": _scalar(fm, "title"),
"sourceRevision": _scalar(fm, "sourceRevision"),
},
"ssot": {"path": os.path.relpath(ssot, ROOT), "sha256": _sha256(ssot)},
"sourceAnchors": _listed(fm, "source"),
"evidence": evidence,
"assets": assets,
"checkerVersions": _checker_versions(),
"gates": gates,
"preservation": preservation,
"warnings": warnings,
"claimCandidates": _claim_candidates(text),
"judgmentCriteria": CLAIM_KINDS,
"reviewerNotes": [
"이 파일의 어느 값도 판정이 아니다. 관문의 exit 는 형식 검사의 결과일 뿐이다.",
"claimCandidates 는 표면 표지로 뽑은 것이라 주장이 아닌 문장이 섞인다. "
"반대로 표지가 없는 주장은 빠진다 — 본문을 읽고 빠진 것을 찾는 것이 검토의 일이다.",
"판정을 낼 때 target.sha256 과 evidence[].sha256 을 함께 적는다. "
"그 값이 바뀌면 판정은 다른 파일에 대한 것이 된다.",
"assets[].preview.png 가 있으면 열어서 본다. 그림 검사기는 좌표와 문자열만 보므로 "
"그림이 본문과 다른 것을 말해도 통과한다.",
"warnings 는 관문이 아니다. 종료 코드로 안 걸리는 것만 여기 올라온다 — "
"유보 표현이 줄어든 자리가 그것이고, 확신 승격이 딱 그 모양이다. "
"gates 가 전부 0 이어도 warnings 는 따로 읽는다.",
"이 묶음이 못 보는 것이 있다. 수치도 인용도 없이 더한 산문 — 자료에 없는 1인칭 "
"경험이나 선택 이유 — 은 보호 구간 비교로 원리적으로 안 보이고 gates 도 warnings 도 "
"비어 있다. 본문을 읽는 것 말고는 방법이 없다.",
],
}
def main() -> int:
ap = argparse.ArgumentParser(description="의미 검토가 받을 입력을 한 파일로 묶는다.")
ap.add_argument("project")
ap.add_argument("--record", required=True)
ap.add_argument("-o", "--out")
ap.add_argument("--figures", help="그림을 PNG 로 떠서 둘 폴더. 검토가 눈으로 보는 자리다")
ap.add_argument("--before", help="윤문 전 사본. 주면 편집 전후 비교를 실어 보낸다")
args = ap.parse_args()
if not os.path.isdir(os.path.join(ROOT, "docs", args.project)):
print(f"그런 프로젝트가 없다: {args.project}", file=sys.stderr)
return 2
if not os.path.isfile(args.record):
print(f"그런 기록이 없다: {args.record}", file=sys.stderr)
return 2
pkg = build(args.project, args.record, args.figures, args.before)
text = json.dumps(pkg, ensure_ascii=False, indent=2) + "\n"
if args.out:
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "w", encoding="utf-8") as fh:
fh.write(text)
failed = [g for g in pkg["gates"] if g["exit"] != 0]
print(f"{args.out} — 증거 {len(pkg['evidence'])} · 주장 후보 "
f"{len(pkg['claimCandidates'])} · 관문 {len(pkg['gates'])}"
f" (exit≠0 {len(failed)}건) · 경고 {len(pkg['warnings'])}")
else:
print(text, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""스킬과 검사기의 버전을 한 장으로 뽑는다.
통과 판정은 「어느 문서를 어느 검사기로 봤는가」에 묶여야 한다. 스킬은 `SKILL.md` 의
`metadata.version` 이 그 값이고, 스크립트 검사기는 버전 칸이 없으므로 파일 내용의 sha256
앞 12자를 쓴다. 버전을 올리는 것을 잊어도 sha 는 따라 움직인다.
python3 scripts/skill-versions.py # 사람이 읽는 표
python3 scripts/skill-versions.py --json # 판정에 붙일 값
"""
from __future__ import annotations
import argparse
import glob
import hashlib
import json
import os
import re
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GATE_SCRIPTS = [
"scripts/verify-tech-log-tree.py", "scripts/verify-project-layout.py",
"scripts/verify-pipeline-run.py", "scripts/verify-pipeline.py",
"scripts/audit-records.py", "scripts/check-figure-text.py",
"scripts/check-figure-overlap.py", "scripts/build-tech-log-tree.py",
"scripts/studio-body.py", "scripts/capture-evidence.py",
]
def _sha12(path: str) -> str | None:
try:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()[:12]
except OSError:
return None
def collect() -> dict:
skills = {}
for path in sorted(glob.glob(os.path.join(ROOT, ".agents/skills/*/SKILL.md"))):
name = os.path.basename(os.path.dirname(path))
text = open(path, encoding="utf-8").read()
m = re.search(r"^metadata:\n(?: .*\n)*? version:\s*\"?([^\"\n]+)\"?",
text, re.M)
skills[name] = {"version": m.group(1).strip() if m else None,
"sha12": _sha12(path)}
checkers = {}
for rel in GATE_SCRIPTS:
checkers[rel] = {"version": None, "sha12": _sha12(os.path.join(ROOT, rel))}
for path in sorted(glob.glob(os.path.join(ROOT, ".agents/skills/*/scripts/*.mjs"))):
rel = os.path.relpath(path, ROOT)
checkers[rel] = {"version": None, "sha12": _sha12(path)}
return {"skills": skills, "checkers": checkers}
def main() -> int:
ap = argparse.ArgumentParser(description="스킬·검사기의 버전과 내용 해시를 뽑는다.")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
data = collect()
if args.json:
print(json.dumps(data, ensure_ascii=False, indent=2))
return 0
missing = 0
print("스킬")
for name, v in data["skills"].items():
mark = " " if v["version"] else ""
if not v["version"]:
missing += 1
print(f" {mark} {name:<40} {v['version'] or '버전 없음':<10} {v['sha12']}")
print("\n검사기 — 버전 칸이 없어 내용 해시로 묶는다")
for name, v in data["checkers"].items():
print(f" {name:<70} {v['sha12']}")
print(f"\nSKILL VERSIONS: {'FAIL' if missing else 'PASS'}"
f"스킬 {len(data['skills'])} · 버전 없음 {missing} · 검사기 {len(data['checkers'])}")
return 1 if missing else 0
if __name__ == "__main__":
raise SystemExit(main())
+24 -4
View File
@@ -9,13 +9,33 @@ from pathlib import Path
ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
_REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( _REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"(?i)^(\s*authorization\s*:\s*bearer\s+).*$"), r"\1[REDACTED]"), # 줄 맨 앞에 앵커를 두면 `curl -H "Authorization: Bearer ..."` 를 놓친다.
(re.compile(r"(?i)^(\s*(?:cookie|set-cookie)\s*:\s*).*$"), r"\1[REDACTED]"), # 터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 그 명령줄이다.
# 값은 따옴표와 줄바꿈 전까지 먹는다 — 헤더 한 줄이면 줄 끝까지, 인용부호 안이면 닫는
# 따옴표 앞까지다. 따옴표를 넘겨 먹으면 명령의 나머지가 통째로 가려진다
(re.compile(r"(?i)(\b(?:proxy-)?authorization\s*:\s*(?:bearer|basic)\s+)[^\"'\r\n]*"),
r"\1[REDACTED]"),
(re.compile(r"(?i)(\b(?:set-cookie|cookie)\s*:\s*)[^\"'\r\n]*"), r"\1[REDACTED]"),
# `curl -u user:pw` · `--user user:pw`. 사용자 이름은 남긴다
(re.compile(r"(?i)((?:^|\s)(?:-u|--user)[=\s]+)([^\s:\"']+):([^\s\"']+)"),
r"\1\2:[REDACTED]"),
# JWT 자체. `eyJ` 로 시작하는 점 두 개짜리 base64url 은 다른 것과 헷갈리지 않는다
(re.compile(r"\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]+"),
"[REDACTED]"),
# 접속 문자열의 자격증명 — postgresql://app:<암호>@db:5432/app.
# 사용자 이름은 남긴다. 어느 계정으로 붙었는지가 증거의 일부다
(
re.compile(r"(?i)\b([a-z][a-z0-9+.\-]*://)([^:/?#\s@]+):([^@\s/]+)@"),
r"\1\2:[REDACTED]@",
),
# 값의 끝을 **따옴표 앞에서** 막는다. `[^\s,;]+` 로 두면 닫는 따옴표까지 먹어
# `-H "X-Api-Key: [REDACTED] https://...` 가 되고, 증거에 실린 명령이 실제로 돌린
# 명령과 달라진다. 감싼 따옴표가 있으면 그대로 되돌려 놓는다
( (
re.compile( re.compile(
r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|client[_-]?secret|api[_-]?key|secret|aws_secret_access_key)\b\s*[=:]\s*)([^\s,;]+)" r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|client[_-]?secret|api[_-]?key|secret|aws_secret_access_key)\b\s*[=:]\s*)([\"']?)([^\s,;\"'\r\n]+)([\"']?)"
), ),
r"\1[REDACTED]", r"\1\2[REDACTED]\4",
), ),
( (
re.compile( re.compile(
@@ -43,6 +43,97 @@ class RenderTerminalTest(unittest.TestCase):
with self.subTest(raw=raw): with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw)) self.assertEqual(expected, redact_line(raw))
def test_credentials_inside_a_command_line_are_redacted(self):
"""줄 맨 앞이 아니라 명령 인자 안에 있는 자격증명.
터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 `curl -H` 의 인자다.
값은 닫는 따옴표 앞까지만 먹는다 — 넘겨 먹으면 명령의 나머지가 통째로 가려진다.
아래 값은 전부 합성이고 실제 비밀값이 아니다.
"""
cases = {
'curl -H "Authorization: Bearer TESTONLY-aaa.bbb.ccc" https://example.test/api':
'curl -H "Authorization: Bearer [REDACTED]" https://example.test/api',
"curl -H 'Authorization: Bearer TESTONLY-xyz' -sS https://example.test":
"curl -H 'Authorization: Bearer [REDACTED]' -sS https://example.test",
'curl -H "Cookie: SESSION=TESTONLY-sess" https://example.test/api':
'curl -H "Cookie: [REDACTED]" https://example.test/api',
"Set-Cookie: SESSION=TESTONLY-x; HttpOnly":
"Set-Cookie: [REDACTED]",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_connection_string_password_is_redacted_and_user_is_kept(self):
"""scheme://user:pw@host 의 암호만 가린다.
어느 계정으로 붙었는지는 증거의 일부라 사용자 이름을 남긴다.
"""
cases = {
"psql postgresql://app:TESTONLY-pw@db:5432/app":
"psql postgresql://app:[REDACTED]@db:5432/app",
"DATABASE_URL=mysql://root:TESTONLY-pw@127.0.0.1:3306/app":
"DATABASE_URL=mysql://root:[REDACTED]@127.0.0.1:3306/app",
"redis://default:TESTONLY-pw@cache:6379/0":
"redis://default:[REDACTED]@cache:6379/0",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_masking_does_not_eat_the_closing_quote(self):
"""가려졌다는 것과 명령이 그대로라는 것은 다르다.
값의 끝을 공백까지로 두면 닫는 따옴표까지 먹어 증거에 실린 명령이 실제로 돌린
명령과 달라진다. 가려진 것만 보고 지나치지 않도록 따옴표 수를 함께 센다.
아래 값은 전부 합성이고 실제 비밀값이 아니다.
"""
lines = (
'curl -H "X-Api-Key: TESTONLY-i-apikey-header" https://example.invalid/d',
'curl -H "X-Token: TESTONLY-x" -H "Accept: application/json" https://example.invalid/d',
'export TOKEN="eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJURVNUT05MWSJ9.TESTONLYsig"',
"curl -H 'X-Api-Key: TESTONLY-single' https://example.invalid/d",
)
for raw in lines:
with self.subTest(raw=raw):
out = redact_line(raw)
self.assertIn("[REDACTED]", out)
self.assertNotIn("TESTONLY", out)
self.assertEqual(raw.count('"'), out.count('"'), out)
self.assertEqual(raw.count("'"), out.count("'"), out)
def test_basic_auth_shapes_are_redacted(self):
"""Bearer 말고도 자격증명이 실리는 자리가 있다."""
cases = {
'curl -H "Authorization: Basic VEVTVE9OTFk6cHc=" https://example.invalid/d':
'curl -H "Authorization: Basic [REDACTED]" https://example.invalid/d',
'curl -H "Proxy-Authorization: Basic VEVTVE9OTFk6cHc=" https://example.invalid/d':
'curl -H "Proxy-Authorization: Basic [REDACTED]" https://example.invalid/d',
"curl -u admin:TESTONLY-basic-pw https://example.invalid/d":
"curl -u admin:[REDACTED] https://example.invalid/d",
"curl --user admin:TESTONLY-basic-pw https://example.invalid/d":
"curl --user admin:[REDACTED] https://example.invalid/d",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_a_bare_jwt_is_redacted(self):
"""`eyJ` 로 시작하는 점 두 개짜리 base64url 은 다른 것과 헷갈리지 않는다."""
raw = "Set token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJURVNUT05MWSJ9.TESTONLYsig now"
out = redact_line(raw)
self.assertEqual("Set token [REDACTED] now", out)
def test_ordinary_urls_are_not_touched(self):
"""자격증명이 없는 주소는 그대로 둔다. 과하게 가리면 증거를 못 읽는다."""
for line in (
"https://example.test/api?x=1",
"git clone https://github.com/org/repo.git",
"GET https://example.test/studio/documents/abc-123/edit -> 200",
):
with self.subTest(line=line):
self.assertEqual(line, redact_line(line))
def test_normal_output_is_not_changed_by_redaction(self): def test_normal_output_is_not_changed_by_redaction(self):
line = "GET /api/me -> 200 in 14ms" line = "GET /api/me -> 200 in 14ms"
self.assertEqual(line, redact_line(line)) self.assertEqual(line, redact_line(line))
+11
View File
@@ -0,0 +1,11 @@
# 측정 결과
리비전 `43e1aad` 에서 열넷 가운데 둘이 `exit 1` 이었다. 응답 시간은 14ms 였다.
이 값은 로컬에서 잰 것이고 운영에서 같은지는 확인하지 못했다.
```
build-tech-log-tree.py exit=1
```
문서가 「관문은 종료 코드가 0 이어야 지난 것이다」 라고 적어 두었다.
자세한 것은 https://example.test/docs 에 있다.
+11
View File
@@ -0,0 +1,11 @@
# 측정 결과
리비전 `43e1aad` 에서 열넷 중 셋이 `exit 1` 이었다. 응답 시간은 4ms 였다.
이 값은 운영에서 확인한 값이다.
```
build-tech-log-tree.py exit=0
```
문서는 「관문은 종료 코드가 0 이면 지난 것이다」 라고 적었다.
자세한 것은 https://example.test/doc 에 있다.
+11
View File
@@ -0,0 +1,11 @@
# 측정 결과
리비전 `43e1aad` 에서 열넷 중 둘이 `exit 1` 이었다. 응답 시간은 14ms 였다.
이 값은 로컬에서 잰 것이라 운영에서 같은지는 확인하지 못했다.
```
build-tech-log-tree.py exit=1
```
문서는 「관문은 종료 코드가 0 이어야 지난 것이다」 라고 적었다.
자세한 것은 https://example.test/docs 에 있다.
+46
View File
@@ -0,0 +1,46 @@
---
kind: CASE
slug: fixture-case
title: 고정 사례 케이스
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
source:
- final/document.md#s1
evidence:
- ../../../final/evidence/raw/x.txt
---
# 고정 사례 케이스
## 관계
- **고정 사례 개념**
이 사건을 읽으려면 그 개념이 먼저 필요하다.
## 문제
관측한 현상을 적는다. 범위도 함께 적는다.
## 결론
## 검증 환경
python 3.12.3 · 리비전 0000000
## 재현 조건
1. 이 순서로 돌린다.
2. 값이 갈리는 것을 본다.
## 본문
<!-- body:start -->
## 무엇이 있었나
본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다.
<!-- body:end -->
@@ -0,0 +1,28 @@
---
kind: CONCEPT
slug: fixture-concept
title: 고정 사례 개념
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
---
# 고정 사례 개념
개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건이 이 개념 위에서 벌어진다.
## 본문
<!-- body:start -->
## 정의
적용 범위까지 함께 적는다.
<!-- body:end -->
@@ -0,0 +1,26 @@
---
kind: PROJECT_DECISION
slug: fixture-decision
title: 고정 사례 결정
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
decisionStatus: PROPOSED
---
# 고정 사례 결정
무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다.
## 근거
기록에 있는 근거만 적는다.
## 결정문
실제로 고른 것을 적는다.
## 영향
감수한 비용과 재검토 조건을 적는다.
@@ -0,0 +1,30 @@
---
kind: QUESTION
slug: fixture-question
title: 고정 사례 물음
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
questionStatus: OPEN
---
# 고정 사례 물음
무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다.
## 관계
- **고정 사례 케이스**
그 사건이 이 물음을 열었다.
## 사실
- 확인된 사실을 적는다.
## 미지수
- 아직 모르는 것을 적는다.
## 다음 검증
@@ -0,0 +1,31 @@
---
kind: REFERENCE
slug: fixture-reference
title: 고정 사례 참조
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
---
# 고정 사례 참조
무엇을 참고하는 기준인지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건에서 이 기준이 쓰였다.
## 목적
이 기준을 쓰는 이유를 적는다.
## 규칙
1. 판단 기준을 적는다
근거와 함께 적는다.
## 예외
적용되지 않는 조건을 적는다.
+49
View File
@@ -0,0 +1,49 @@
---
kind: CASE
slug: fixture-case
title: 고정 사례 케이스
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
source:
- final/document.md#s1
evidence:
- ../../../final/evidence/raw/x.txt
---
# 고정 사례 케이스
한 문장으로 무엇이 있었는지 적는다. 이 문단이 Studio 의 요약 칸이 된다.
## 관계
- **고정 사례 개념**
이 사건을 읽으려면 그 개념이 먼저 필요하다.
## 문제
관측한 현상을 적는다. 범위도 함께 적는다.
## 결론
근거가 뒷받침하는 만큼만 적는다.
## 검증 환경
python 3.12.3 · 리비전 0000000
## 재현 조건
1. 이 순서로 돌린다.
2. 값이 갈리는 것을 본다.
## 본문
<!-- body:start -->
## 무엇이 있었나
본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다.
<!-- body:end -->
+29
View File
@@ -0,0 +1,29 @@
---
kind: CONCEPT
slug: fixture-concept
title: 고정 사례 개념
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
basisVersion: 예시 명세 1.0
---
# 고정 사례 개념
개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건이 이 개념 위에서 벌어진다.
## 본문
<!-- body:start -->
## 정의
적용 범위까지 함께 적는다.
<!-- body:end -->
+30
View File
@@ -0,0 +1,30 @@
---
kind: PROJECT_DECISION
slug: fixture-decision
title: 고정 사례 결정
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
decisionStatus: PROPOSED
---
# 고정 사례 결정
무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다.
## 근거
기록에 있는 근거만 적는다.
## 결정문
실제로 고른 것을 적는다.
## 판단 이유
확인된 대안과 그것을 고르지 않은 이유를 적는다.
## 영향
감수한 비용과 재검토 조건을 적는다.
+33
View File
@@ -0,0 +1,33 @@
---
kind: QUESTION
slug: fixture-question
title: 고정 사례 물음
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
questionStatus: OPEN
---
# 고정 사례 물음
무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다.
## 관계
- **고정 사례 케이스**
그 사건이 이 물음을 열었다.
## 사실
- 확인된 사실을 적는다.
## 미지수
- 아직 모르는 것을 적는다.
## 다음 검증
1. 답을 구할 방법을 적는다.
닫는 조건 : 어떤 결과가 나오면 닫는지 적는다.
+35
View File
@@ -0,0 +1,35 @@
---
kind: REFERENCE
slug: fixture-reference
title: 고정 사례 참조
topic: fixture-topic
project: fixture
status: 게시 전
sourceRevision: 0000000000000000000000000000000000000000
---
# 고정 사례 참조
무엇을 참고하는 기준인지 한 문단으로 적는다.
## 관계
- **고정 사례 케이스**
그 사건에서 이 기준이 쓰였다.
## 목적
이 기준을 쓰는 이유를 적는다.
## 규칙
1. 판단 기준을 적는다
근거와 함께 적는다.
## 적용 조건
어느 버전·어느 전제에서 쓰는지 적는다.
## 예외
적용되지 않는 조건을 적는다.
+78
View File
@@ -0,0 +1,78 @@
"""윤문 전후에 보호 구간이 그대로인지 보는 검사기.
정상 윤문은 통과하고, 수치·코드·인용·URL 을 건드린 편집은 걸린다.
유보 표현이 줄어든 것은 걸러 내되 판정하지 않는다 — 판단은 근거를 읽는 검토가 한다.
"""
import importlib.util
import os
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
F = os.path.join(ROOT, "scripts", "tests", "fixtures", "preservation")
_spec = importlib.util.spec_from_file_location(
"check_preservation", os.path.join(ROOT, "scripts", "check-preservation.py"))
cp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cp)
def _read(name):
return open(os.path.join(F, name), encoding="utf-8").read()
class PreservationTest(unittest.TestCase):
def setUp(self):
self.before = _read("before.md")
def test_an_ordinary_rewrite_passes(self):
res = cp.compare(self.before, _read("after-ok.md"))
self.assertEqual([], res["findings"])
self.assertEqual([], res["hedgesDropped"])
def test_a_number_changed_in_prose_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
values = {f["value"] for f in res["findings"] if f["kind"] == "수치"}
self.assertIn("14ms", values)
self.assertIn("4ms", values)
def test_a_number_changed_inside_a_code_block_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
blocks = {f["value"] for f in res["findings"] if f["kind"] == "코드블록"}
self.assertTrue(any("exit=1" in b for b in blocks))
self.assertTrue(any("exit=0" in b for b in blocks))
def test_a_changed_direct_quotation_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
kinds = {f["kind"] for f in res["findings"]}
self.assertIn("직접인용", kinds)
def test_a_changed_url_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
kinds = {f["kind"] for f in res["findings"]}
self.assertIn("URL", kinds)
def test_dropped_hedges_are_surfaced_without_a_verdict(self):
"""유보가 줄면 낸다. 옳은지 그른지는 말하지 않는다."""
res = cp.compare(self.before, _read("after-tampered.md"))
dropped = {h["word"] for h in res["hedgesDropped"]}
self.assertIn("확인하지 못했다", dropped)
self.assertIn("로컬", dropped)
self.assertEqual(0, res["hedgeTotalAfter"])
def test_adding_a_hedge_is_not_reported(self):
"""유보를 더하는 것은 이 규범에서 안전한 쪽이다."""
after = self.before.replace("14ms 였다", "14ms 였다. 다만 한 번만 쟀다")
res = cp.compare(self.before, after)
self.assertEqual([], res["hedgesDropped"])
def test_a_missing_file_is_not_reported_as_clean(self):
import subprocess
p = subprocess.run(
["python3", os.path.join(ROOT, "scripts", "check-preservation.py"),
os.path.join(F, "before.md"), os.path.join(F, "nope.md")],
cwd=ROOT, capture_output=True, text=True)
self.assertEqual(2, p.returncode)
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
"""종류가 요구하는 내용이 채워졌는지 보는 검사기.
고정 사례는 다섯 종류마다 둘이다 — 채운 것과 하나를 뺀 것.
「무조건 통과」도 「무조건 거절」도 아닌 것을 이 짝이 확인한다.
"""
import importlib.util
import os
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
FIXTURES = os.path.join(ROOT, "scripts", "tests", "fixtures", "required-content")
_spec = importlib.util.spec_from_file_location(
"check_required_content", os.path.join(ROOT, "scripts", "check-required-content.py"))
crc = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(crc)
KINDS = ("case", "concept", "reference", "question", "decision")
def _report(sub, name):
rep = crc.techlog.Report("fixture")
crc.check_record(os.path.join(FIXTURES, sub, f"{name}.md"), rep)
return rep
class RequiredContentTest(unittest.TestCase):
def test_every_kind_passes_when_filled(self):
for kind in KINDS:
with self.subTest(kind=kind):
rep = _report("ok", kind)
self.assertEqual(0, rep.error_count,
f"{kind}: {dict(rep.errors)}")
def test_every_kind_fails_when_a_required_part_is_missing(self):
for kind in KINDS:
with self.subTest(kind=kind):
rep = _report("missing", kind)
self.assertGreater(rep.error_count, 0,
f"{kind} 의 누락 사례가 통과했다")
def test_the_missing_part_is_named(self):
"""무엇이 빠졌는지 말한다. 「어딘가 잘못됐다」로 끝나지 않는다."""
expected = {
"case": "결론",
"concept": "basisVersion",
"reference": "적용 조건",
"question": "다음 검증",
"decision": "판단 이유",
}
for kind, part in expected.items():
with self.subTest(kind=kind):
rules = " / ".join(_report("missing", kind).errors)
self.assertIn(part, rules)
def test_decision_kind_is_project_decision_in_frontmatter(self):
"""decision/ 폴더의 기록은 kind: PROJECT_DECISION 이다 (templates/decision.md)."""
self.assertEqual("decision", crc.KIND_ALIASES["PROJECT_DECISION"])
rep = _report("ok", "decision")
self.assertNotIn("kind 를 모르겠다", rep.errors)
def test_an_unanswered_question_is_not_an_error(self):
"""답이 없는 QUESTION 자체는 결함이 아니다. 답을 구할 방법이 없는 것이 결함이다."""
rep = _report("ok", "question")
self.assertEqual(0, rep.error_count)
def test_body_markers_belong_only_to_case_and_concept(self):
self.assertEqual({"case", "concept"}, crc.BODY_KINDS)
def _cli(self, *args):
import subprocess
return subprocess.run(
["python3", os.path.join(ROOT, "scripts", "check-required-content.py"), *args],
cwd=ROOT, capture_output=True, text=True)
def test_a_missing_project_is_not_reported_as_clean(self):
"""대상이 없으면 통과가 아니다. 오타 하나로 관문이 무효가 되면 안 된다."""
p = self._cli("nonexistent-project")
self.assertEqual(2, p.returncode)
self.assertIn("대상이 성립하지 않는다", p.stderr)
def test_a_project_without_a_contract_does_not_come_back_green(self):
"""계약이 없으면 「볼 것이 없어서 통과」다. 그것을 초록으로 내지 않는다."""
p = self._cli("ca-tmpl")
self.assertEqual(2, p.returncode)
self.assertIn("tech-log-tree.json 이 없다", p.stderr)
def test_a_contract_with_no_records_yet_is_not_an_error(self):
"""아직 안 쓴 것은 결함이 아니다. 다만 초록으로 보이면 안 된다."""
p = self._cli("keycloak-session-store")
self.assertEqual(0, p.returncode)
self.assertIn("아직 안 쓰였다", p.stdout)
def test_one_ungrounded_target_stops_the_whole_run(self):
"""성립하는 것과 안 하는 것을 함께 주면 통과로 뭉개지 않는다.
성립하는 쪽으로 `keycloak` 을 쓴다. `verify-pipeline.py` 의 계약이 scripts/ 안에
저장소 체크아웃 이름을 적는 것을 금지해서(`FORBIDDEN_LITERAL`), 그 이름과 같은
프로젝트를 테스트에 적으면 계약 검사가 깨진다.
"""
p = self._cli("keycloak", "ca-tmpl")
self.assertEqual(2, p.returncode)
if __name__ == "__main__":
unittest.main()