Skip to content

내부 구조 ​

파이프라인이 바깥에서 건드리는 것은 셋입니다. GitHub, Claude, git. 셋 모두 orchestrator가 의존하는 interface인 port를 거쳐서 닿습니다. 덕분에 무인으로 도는 흐름 전체를 메모리 안에서 테스트할 수 있습니다. 구독 한도를 쓰지도, GitHub에 무언가를 쓰지도 않고요. port 뒤의 adapter는 얇게 유지합니다. 인자를 만들고 출력을 해석할 뿐이고, 판단은 그 위의 모듈이 맡습니다.

이 페이지는 소스를 읽거나 고치려는 분을 위한 것입니다. 파이프라인을 쓰기만 하려면 설치부터 보세요.

한눈에 보기 ​

Port떼어 내는 효과Adapter · 테스트용 fake
TrackerGitHub: issue, 라벨, 코멘트, pull request, CI, mergeGhTracker · FakeTracker
AgentRunnerClaude: Stage 세션 하나ClaudeAgentRunner · FakeAgentRunner
Workspacegit: worktree, Check, rebase, push, State와 Run lockGitWorkspace · FakeWorkspace

adapter를 만드는 곳은 CLI 하나뿐입니다. start.ts부터 아래는 모두 interface로 받습니다.

Adapter ​

GhTracker ​

GhTracker는 GitHub 호출을 전부 gh api로 합니다.

  • cloud Host가 GraphQL을 아예 막으니, 모든 Host에서 REST를 씁니다.
  • 예외는 pull request를 draft로 넣고 빼는 일 하나입니다. REST로는 할 수 없어서, workstation은 GraphQL mutation을, cloud Host는 proxy의 /pulls/{n}/ccr/convert_to_draft와 /ready_for_review 경로를 씁니다.
  • CI는 15초마다 확인합니다.

ClaudeAgentRunner ​

ClaudeAgentRunner는 Stage 하나를 자식 프로세스 하나로 실행합니다. --json-schema는 답할 schema가 있는 Stage에만 붙습니다.

text
claude --print <prompt> --output-format stream-json --verbose \
  --permission-prompts none --permission-mode … \
  --model … --effort … --max-turns … [--json-schema …]
  • 자식의 환경에 TICKET_RUNNER_STAGE를 설정합니다.
  • Stage가 시작되는 순간부터 <stage>.command, .stdout, .stderr, .transcript.jsonl을 씁니다.
  • maxMinutes가 지나면 자식을 죽이고, 실패를 rate-limited, timed-out, turn-capped, nonzero-exit, invalid-result로 분류합니다.

GitWorkspace ​

GitWorkspace는 .worktrees/ 아래의 worktree를 맡고, Check를 돌리고, rebase와 push를 하고, ticket-runner/state와 ticket-runner/lock branch를 관리합니다(멈추고 이어 하기).

  • git은 ref나 index lock이 잡혀 있으면 기다리지 않고 실패하므로, main checkout에서의 명령은 한 번에 하나씩 돌립니다.
  • worktree 안의 명령은 병렬로 둡니다.

작은 adapter들 ​

  • exec.ts는 timeout과 스트리밍 sink를 붙여 자식 프로세스를 띄웁니다. 명령이 스스로 124로 끝날 수도 있으니 timedOut을 따로 알려 줍니다.
  • repo-root.ts는 worktree 안에서 실행해도 main checkout을 찾습니다. 그래서 lock, .worktrees/, 로그가 clone마다 한 곳에 모입니다.
  • version.ts는 이 사본이 어느 Version인지 읽습니다. 설명하는 port는 없지만 git을 실행하니 adapter에 둡니다.

src/ 지도 ​

테스트는 각 모듈 옆에 *.test.ts로 있습니다.

진입점

모듈한 줄
cli.tsVersion을 읽고 init, run, stop, remove, -v로 나눔. adapter를 만듦
command-line.ts아무것도 보기 전에 인자를 해석하고, usage 문구를 가짐
stage-guard.tsStage의 셸 안에서는 모든 명령을 거절(Stage mark)
start.ts거절, Run lock, Stop 수신, 요약과 exit code
startup.tsmerge를 막을 관문이 없는 Run을 거절하고, 꺼 둔 관문을 경고

Run과 Ticket

모듈한 줄
run.tsStranded Ticket, 그다음 Frontier로 Lane을 채움. Release나 Stop이면 claim을 멈춤
orchestrator.tsTicket 하나를 guard부터 merge, Hand-off, Release까지. Fix budget 포함
frontier.ts후보를 Frontier와 막힌 것으로 나눔
guards.ts후보를 건너뛰는 이유: Guard와 거절
landing.tsLanding: rebase부터 pull까지 한 번에 한 Lane, 도착 순서대로
lifecycle.tsTicket이 어디서 실패했는지, fix Stage가 고칠 수 있는 실패는 무엇인지
base-branch.tsRun마다 한 번 Base branch를 정함
branch.tsagent/<n>-<slug>와 .worktrees/ticket-<n>
stranded.tsStranded Ticket을 찾는 sweep
resume.tsState file의 모양과 이름
handoff.tsTicket을 다시 가져갈 때 예전 hand-off 코멘트를 지난 일로 표시
stop.tsticket-runner stop, 그리고 Run 쪽에서 받는 SIGTERM
lock.tsRun lock 파일의 내용, 쥔 쪽이 살아 있는지
host.tsworkstation인지 cloud인지, 어느 것인지

Stage에 주는 것과 Stage가 답하는 것

모듈한 줄
prompts.ts각 Stage의 prompt. /mattpocock-skills:<skill>을 펼침
self-hosting.tsTarget이 파이프라인 자신의 저장소인지. Stage가 checkout에 대해 무엇을 듣는지 정함
verdict.tsVerdict schema와 통과 여부
title.tspull request 제목: Stage의 답, 그다음 commit, 그다음 Ticket
note-schema.ts, notes.tsNote: Stage가 적는 방식과 보내지는 곳
acceptance-criteria.ts, criteria.tscriterion의 모양, merge 뒤 met인 것에 체크
progress.tsTicket마다 하나인 Progress comment를 제자리에서 고침
templates.tsGitHub에 쓰는 모든 모양과 Run 요약. 원본은 docs/templates/
run-log.ts.ticket-runner/runs/<runId>/와, Hand-off가 그중 무엇을 남기는지

Target 준비

모듈한 줄
init.tsticket-runner init: 파일을 쓰고, GitHub를 설정하고, 나머지를 보고
readiness.tsTarget readiness. init이 갖추고 run이 확인
conventions.tsconventions 문서의 본문과 Version 표시
operator-skill.tsinit이 Target에 써 넣는 Operator의 skill
labels.tstriage 라벨 여섯 개와 그 색
config.tsticket-runner.json: schema와 기본값
remove.tsticket-runner remove

Version

모듈한 줄
version-number.ts번호 비교. 모든 Version 질문이 함께 씀
staleness.ts새 Version 안내 줄과 conventions 문서 경고
version-pr.tsVersion PR이 지켜야 할 것, GitHub Release에 올릴 notes

테스트 전용: testing/에는 아래에서 설명하는 fake와 도우미가 있습니다.

orchestrator를 테스트하는 방법 ​

어떤 테스트도 gh나 claude를 띄우지 않습니다. 층마다 맞는 경계에서 테스트합니다.

층테스트 대상
orchestrator.ts, run.ts, start.ts, stranded.ts …세 port 모두의 메모리 fake
나란히 도는 Lanefake에 Hold와 settle을 더해서
GitWorkspace진짜 git: bare remote를 붙인 임시 저장소
GhTracker, ClaudeAgentRunnerrun seam으로 넣는 녹화된 실행 결과
  • fake마다 순서가 있는 calls 로그를 둡니다. 테스트는 내부 helper가 아니라 이 로그와 결과 상태를 확인합니다. FakeWorkspace는 State와 lock을 메모리에 두고, THIS_HOST나 ANOTHER_HOST 역할을 할 수 있습니다.
  • Hold는 Ticket 하나를 원하는 지점(Stage 안, CI 대기 안)에 세워 두었다가 테스트가 풀어 줄 때 보냅니다. 그 사이 다른 Lane이 하는 일을 관찰할 수 있습니다.
  • GitWorkspace 테스트는 worktree, rebase, lease, state와 lock branch를 진짜로 돌립니다.
  • 나머지 두 adapter에는 testing/executions.ts가 프로세스가 돌려줬을 Execution을 만들어 줍니다.

그래서 파이프라인 자신의 기록까지 포함해 모든 바깥 효과가 port를 거칩니다. State와 lock도 remote에 대한 효과이니 Workspace의 메서드이고, fake가 이를 다룹니다(ADR-0004의 마지막 amendment).

Version ​

Version이 없던 때는 두 설치본이 같은 번호를 말하면서 몇 주씩 떨어진 코드를 돌릴 수 있었습니다. 그래서 transcript에서 찾은 버그를 commit과 이을 수 없었고, 낡은 설치본은 Target이 지난주 파이프라인처럼 굴 때에야 드러났습니다. 파이프라인이 Target에 남기는 것들도 어느 파이프라인이 썼는지 말해 주지 않아서, 설치본이 다른 두 머신이 같은 conventions 문서를 번갈아 다시 쓰곤 했습니다.

Version은 main이 v<x.y.z> tag로 달고, npm에 올리고, GitHub Release로 공개하는 번호입니다. 설치된 사본이 이 번호를 알려 줍니다. 머신에는 main이 아니라 Version을 설치하니, 0.5.2라고 말하는 두 머신은 같은 코드를 돌립니다(ADR-0007).

Version을 끊는 방법 ​

  • 번호. 지난 Version 뒤로 Spec이 하나라도 닫혔으면 minor를, 그 밖에는 작은 기능까지 포함해 모두 patch를 올립니다. 질문은 오직 "Spec이 닫혔나"뿐입니다. 언제 끊을지는 사람이 정합니다. Planning의 결정이니까요.
  • Version PR. package.json과 lock file의 번호를 올리고, CHANGELOG.md에 그 Version의 섹션을 더하고, npx tsx scripts/version.ts mark로 이 저장소의 conventions 문서에 새 표시를 합니다. 초안은 /cut-a-version skill이 만들고, merge는 하지 않습니다.
  • 검사는 이유를 한꺼번에 모두 알려 줍니다. 번호가 모든 tag보다 높지 않을 때, lock file이 다른 번호일 때, 섹션이 없을 때, 섹션에 ### After upgrading 제목이 없을 때입니다. 번호를 건드리지 않은 pull request는 그대로 통과합니다.
  • tag workflow는 merge commit에 tag를 달고, tag가 가리키는 commit을 trusted publishing으로 npm에 올린 뒤(npm token은 어디에도 없습니다), 그 섹션을 GitHub Release로 올립니다. 섹션이 없으면 GitHub이 만든 notes를 씁니다. 셋 중 빠진 것만 채우니, 반쯤 끊긴 Version도 다시 돌리면 온전해집니다.

release-please와 Changesets는 둘 다 채택하지 않았습니다. 앞의 것은 해마다 만료되는 token, 저장소 설정, 그리고 feat/fix만 Version을 끊는다는 규칙이 필요했습니다. 뒤의 것은 모든 Stage의 일에 changeset 파일 쓰기를 얹었을 것입니다.

찍히는 번호 ​

Version은 CLI에서 한 번 정해서 아래로 넘깁니다. 그래서 한 Run이 찍는 번호는 모두 같은 문자열입니다.

-v 출력언제
0.5.2설치된 사본. 번호가 전부
0.5.2+331d79c깨끗한 개발 checkout
0.5.2+331d79c.dirtycommit하지 않은 변경이 있는 개발 checkout
unknown읽을 수 있는 package.json이 없음

같은 문자열이 찍히는 곳:

어디왜
Run 요약의 머리, init 보고의 첫 줄어떤 파이프라인이 돌았는지 터미널이 말하도록
Progress comment의 Run별 섹션누가 썼는지 보드가 말하도록
각 State file의 version읽지 못하는 파일도 누가 썼는지 알 수 있도록
Run 디렉터리의 version.txttranscript가 그걸 쓴 파이프라인 곁에 있도록
conventions 문서의 첫 줄(번호만)init과 Run이 어느 쪽이 뒤처졌는지 알 수 있도록
설치본이 모르는 config key를 거절하는 문구낡은 설치본과 잘못된 config를 구별할 수 있도록

요약의 머리는 ticket-runner <version> run <runId> · <n>m 모양입니다. 파일은 .ticket-runner/runs/<runId>/version.txt이고, Hand-off는 이 파일을 transcript와 함께 남깁니다.

conventions 문서의 표시 <!-- ticket-runner:version <number> -->에는 방향이 있습니다. 더 오래된 파이프라인은 더 새 문서를 건드리지 않고 업그레이드를 권합니다. 문서보다 뒤처진 Run은 경고하고, 앞선 Run은 init을 권합니다. 어느 쪽도 거절하지는 않습니다.

새 Version 확인 ​

Run과 init은 파이프라인 자신의 저장소에서 공개된 가장 높은 Version을 찾아봅니다. 저장소는 package.json의 repository 필드에서 읽으니, fork는 자기 자신을 묻습니다. 조회는 tag가 아니라 GitHub Release를 읽되 draft와 prerelease는 뺍니다. tag workflow는 npm에 Version이 올라간 뒤에야 Release를 만드니, Release가 있는 Version은 업그레이드로 설치할 수 있습니다. 번호만 비교하므로, 최신 Version보다 앞선 commit을 돌리는 checkout도 낡은 것으로 보지 않습니다.

새 Version이 있으면 staleness.ts · newerVersionLine이 한 줄을 Run 로그 맨 위와 요약 머리에 찍습니다.

A newer Version is out: 0.6.0, and this is 0.5.2 — upgrade with `npm install -g ticket-runner`.

네트워크가 없거나, gh가 없거나, 아무도 볼 수 없는 저장소라서 조회하지 못하면 아무것도 찍지 않습니다. 이 때문에 무언가를 거절하는 일은 없습니다.

설치와 업그레이드는 npm install -g ticket-runner로 하고, 전역 설치 없이 써 보려면 npx를 쓰면 됩니다(설치 없이 써 보기). GitHub tag 범위 npm install -g "github:jjongs2/ticket-runner#semver:*"로도 가장 높은 Version tag가 설치됩니다. 설치를 참고하세요.

ADR 요약 ​

전문은 docs/adr/에 있습니다. 여러 ADR에 amendment가 붙어 있고, 아래는 지금 기준의 정리입니다.

ADR-0001: Humans plan, the pipeline executes ​

  • 결정: 계획은 사람이 하고, 무인으로 도는 것은 Execution뿐입니다.
  • 이유: grilling 질문에 잘못 답하면, 걸러 줄 관문 없이 Spec, Ticket, merge된 코드로 굳어 버립니다.
  • 비용: Planning의 품질은 사람 몫입니다. 파이프라인은 알려진 결함만 막습니다.

ADR-0002: One claude -p child process per Stage ​

  • 결정: Stage마다 claude -p 자식 프로세스를 하나씩 둡니다.
  • 이유: 사용자 호출 전용 /plugin:skill을 펼쳐 주는 건 headless 모드뿐입니다. 프로세스로 나누면 Stage별 한도, --json-schema, 다시 돌려 볼 명령줄도 얻습니다.
  • 비용: Stage마다 1–2초의 시작 비용이 듭니다. 문맥을 나누지 않으니 Stage마다 Ticket과 branch를 읽습니다.

ADR-0003: Trust only GitHub-native issue relations ​

  • 결정: GitHub 고유의 blocker와 sub-issue만 인정합니다.
  • 이유: 진실의 원천이 둘이면, 낡은 본문이 몰래 일을 막거나 풀 수 있습니다.
  • 비용: 사람이 고유 관계를 만들어야 합니다. 본문에만 적힌 blocker는 경고와 함께 건너뜁니다.

ADR-0004: Resume state lives on the Target's remote, not in a tracker comment ​

  • 결정: 이어받기 상태는 보드 밖의 State file로 두고, 지금은 Target의 remote에 있습니다. Hand-off도 이를 남깁니다.
  • 이유: 보드는 사람을 위한 곳입니다. cloud Host는 버려지니 상태가 Host보다 오래 살아야 합니다. 라벨 한 번 바꿔서 이어받을 수 있어야 합니다.
  • 비용: 예전 로컬 State는 옮겨 주지 않습니다. state branch는 Workspace를 거칩니다.

ADR-0005: Landing is a serialized section, not a merge queue ​

  • 결정: Landing은 merge queue가 아니라, 한 번에 Lane 하나만 들어가는 구간입니다.
  • 이유: CI를 두 번 치르지 않고도 CI가 채점한 그대로 merge됩니다. GitHub merge queue는 저장소 설정 하나를 Target readiness의 일부로 만들었을 것입니다.
  • 비용: 느린 CI나 Conflict Stage 하나가 다른 모든 Lane의 Landing을 붙잡습니다.

ADR-0006: Stop is a signal, and Ctrl+C stays a kill ​

  • 결정: Stop은 SIGTERM뿐이고, Run이 도는 Host에서만 보낼 수 있습니다. Ctrl+C는 kill로 둡니다.
  • 이유: 신호는 바로 닿고 치울 것을 남기지 않습니다. Stage는 Run의 process group을 같이 씁니다.
  • 비용: Stop은 되돌릴 수 없고, 다른 Host의 Run은 여기서 멈출 수 없습니다.

ADR-0007: A Version is cut by a human, and installs follow tags ​

  • 결정: Version은 사람이 Version PR을 merge해서 끊고, tag workflow가 npm에 올리며, 설치는 npm에서 합니다. GitHub tag 범위로 설치해도 됩니다.
  • 이유: 언제 끊을지는 Planning의 결정입니다. 같은 번호의 두 머신이 같은 코드를 돌려야 번호가 쓸모 있는 표시가 됩니다.
  • 비용: Version notes는 agent의 도움을 받아 손으로 씁니다.

ADR-0008: A cloud Host is a Claude Code cloud session, and the Run lock lives on GitHub ​

  • 결정: cloud Host는 Operator가 있는 Claude Code cloud 세션이고, Run lock은 GitHub에 둡니다.
  • 이유: Actions 시간 비용이 없고, 앱에서 Run을 조종할 수 있습니다. 모든 Host가 lock을 봐야 합니다.
  • 비용: REST만 씁니다. remote에 둔 어떤 것도 ref 삭제에 기대면 안 됩니다. 사라진 Host의 lock은 사람이 풀어야 합니다.

파이프라인 자체를 고치려면 ​

파이프라인을 고치려면 checkout에서 돌리면 됩니다. bin/ticket-runner.js가 tsx로 TypeScript를 바로 읽으니 빌드 단계가 없습니다.

bash
git clone https://github.com/jjongs2/ticket-runner && cd ticket-runner
npm install
npm run ticket-runner -- run         # drain this repository's Frontier
npm run ticket-runner -- run 3 7     # a Run narrowed to #3 and #7
npm test                             # vitest
npm run typecheck                    # tsc --noEmit

checkout은 번호 옆에 commit도 함께 알려 줍니다(찍히는 번호 참고). CI는 pull request마다 npm run typecheck와 npm test를 돌리고, 그 옆에서 Version PR 검사도 돌립니다.

이 저장소는 자기 파이프라인의 Target이기도 해서, 다른 Target과 같은 규칙을 따릅니다. commit하기 전에 여기 옮겨 적은 것이 아니라 원문을 읽어 주세요.

  • CONTRIBUTING.md: branch, commit, pull request, Version PR, issue, 그리고 코드 규칙(strict ESM TypeScript, 코드 옆의 테스트, 모든 효과는 port로, scripts/는 얇게)
  • docs/agents/pipeline-conventions.md: 파이프라인이 모든 Target에 요구하는 것
  • GLOSSARY.md: 이름은 용어집을 따릅니다. 새 단어가 필요한 개념은 거기부터 적습니다.
  • docs/templates/: 모양을 바꿀 때는 그걸 쓰는 코드보다 여기를 먼저 바꿉니다.

Stage는 파이프라인을 실행할 수 없습니다. Stage의 셸에는 TICKET_RUNNER_STAGE가 있고, 이 변수가 있으면 모든 명령이 거절되어 파이프라인은 그 셸을 띄운 Run의 몫으로 남습니다. 이 저장소에서는 Stage의 프롬프트가 대신 테스트와 fake로 파이프라인을 돌려 보라고 덧붙입니다.

참고 ​