EffortTool 소단계 진행 표(소단계/예상/실제/상태)를 프롬프트 위 띠와 /steps 패널로 보여 주고, plan 없는 편집·Agent 를 막는다

Claude Code 가 남긴 세션 기록(JSONL)만 읽어 무슨 일에 시간과 토큰이 얼마나 들었는지 재고, 다음 일의 예상 공수 표를 뽑는 단일 실행 파일이다. LLM 을 부르지 않는다. 상주 프로세스도 없다.
CGO_ENABLED=0).%USERPROFILE%\.effort\ 아래에 둔다. cache/ 는 지워도 effort scan --rebuild 로 되살아나는 파생물이고, 사람이 고치는 정본은 rules.txt(분류 규칙)와 groups.txt(소단계 경계) 둘이다.agent_wall_ms 에 따로 남아 참고값으로 보여주기만 하고 총계에 안 든다 (권한 승인을 안 눌러 갈래가 몇 시간씩 살아 있던 예외에 눈금이 끌려가기 때문. 결정 2026-09-04).turn_duration)의 합. 벽시계를 넘으면 벽시계로 자르고 순수시간잘림 을 단다.rules.txt 의 wait min 분(기본 3)을 넘게 걸린 틈의 합. 그림 생성·긴 빌드처럼 「기다리기만 한 구간」을 따로 보여 준다. 벽시계·표본에서 빼지 않는다 — 어긋난 까닭을 설명하는 칸이다. 도구 이름으로 가르지 않고, 나란히 부른 도구의 틈이 겹치면 한 번만 센다. 결과가 없는 도구(끊긴 판)는 안 센다. show 벽시계 줄 · 에이전트 표 · list --group · mark stop/show/list 에 칸이 있다. 서브에이전트 대기는 본줄 대기와 따로다. 캐시 판 5 이전에 쌓인 작업은 대기 칸이 「—」다. 판이 바뀐 뒤 처음 훑기를 --project 로 했으면 다른 프로젝트는 scan --all 로 다시 훑는다.서브에이전트는 effort mark 로 자기 판을 잰다. 시작할 때 mark start, 끝날 때 mark stop 을 부르고, mark stop 이 찍는 「기록 구간」을 공수 표 「실제」 칸에 쓴다.
.\bin\effort.exe mark start "2-타일그림" # 이름은 판마다 다르게 — 소단계 번호를 넣는다
# … 일 …
.\bin\effort.exe mark stop # 이 판에 묶인 mark 하나를 닫고 값을 찍는다 (둘 이상이면 id·이름을 준다 — 같은 이름이 둘 이상 열려 있으면 id 만)
.\bin\effort.exe mark show # 도는 중에 지금까지 값 (진행중)
mark start "<이름>" 을 부른 tool_use 줄을 찾는다. 그래서 같은 이름을 두 갈래가 동시에 쓰면 묶기모호 가 되어 기록 구간이 빈다.묶기없음 이어도 stop·show·list 때 다시 찾아 묶는다. 메인 세션은 mark start 를 부른 줄을 몇 초 늦게 기록 파일에 쓴다. 다시 찾을 때는 tool_use 시각이 mark 시작 +5초 이하인 같은 이름의 mark start 줄 중 그 mark 몫(같은 이름 앞 mark 몫이 아닌 것)을 본다 (끝 256KB 에 없으면 파일 전체). 찾으면 marks.txt 에 bind 줄을 덧붙여 다음부터는 안 찾는다. 닫힌 mark 를 2분 넘게 못 찾으면 포기를 적는다 — 다시 찾으려면 mark show --rebind <id>. 꼴·조건은 설계 2026-09-23 「미뤄 묶기」 절. 메인 세션의 인자 없는 show 는 지금 프로젝트 메인 세션 파일에 묶인 안 닫힌 mark 하나를 고른다. 인자 없는 stop 은 메인에서는 id 를 받는다. (옛 exe 는 bind 줄을 「모르는 줄 머리」로 경고하고 건너뛴다.)어긋남, 기록 구간이 sub max 를 넘으면 상한넘음 을 단다. stop 을 잊은 채 파일이 30분 넘게 안 바뀌면 그 파일의 마지막 본줄을 끝으로 보고 끝자동 을 단다.marks.txt 에는 프로젝트 폴더 이름과 파일 이름만 남고 --projects 뿌리는 안 남는다. show·stop·list 는 그때 준 뿌리(기본 ~/.claude/projects)로 다시 잰다.~/.effort/marks.txt 에 덧붙이기만 한다. rules.txt·groups.txt 와 같은 정본이라 scan --rebuild 로 안 지워진다.show·stats·list 총계와 따로 된 값이다. 총계의 뜻은 안 바뀐다.mark stop m1005-6b1a → mark start "3-타일검사"). stop 은 보고를 쓰기 직전이 아니라 일이 끝난 바로 그때 친다. 보고 쓰기는 mark 밖이다.기록 구간을 못 쟀습니다 — 까닭 : mark start 를 부른 기록 파일을 못 찾음 (start 때도, stop·show 때 다시 찾아도)(묶기없음). 까닭은 다섯이다 : 묶기없음 · 묶기모호 · 파일못엶 · 파일못읽음 · 구간안본줄없음(start~stop 사이 본줄이 없음. 진행 중이면 「아직 start 뒤 본줄이 없음」).기록 구간 : 3분 (2분 43초)). mark list 표 칸은 분만 그대로다. 갈래 기록 전체 : 4분 (17:55:04~17:59:04) · mark 앞 1분 미만 · stop 뒤 1분 · 압축 없음
갈래는 mark 앞(작업서 읽기)과 stop 뒤(보고 쓰기)에도 일하므로 파일 전체가 기록 구간보다 길다. 압축(compact_boundary) 뒤에도 같은 파일에 이어 쓰므로 압축 시각만 같이 보여 준다. 「실제」 칸은 기록 구간이다. mark 밖 본줄이 2분과 갈래 전체의 30% 를 둘 다 넘으면 주의 : 줄이 뜬다 — 그때는 「갈래 전체 N분(참고)」 을 같이 적는다. mark stop 그 순간에는 「stop 뒤」가 거의 0 이다. 보고를 쓴 뒤 mark show <id> 로 다시 보면 찬다. 같은 갈래의 다른 mark 구간은 mark 밖에서 뺀다 (겹친 곳은 한 번만). 뺀 것이 있으면 · 다른 mark N분 칸이 붙고, 경고도 뺀 뒤의 값으로 본다. stop 이 없는 mark(진행중·끝자동)는 「stop 뒤 —」, 0 인 앞·뒤는 「없음」 이다. 첫·끝 본줄의 날짜가 다르면 시각에 날짜(10-05 23:58:00)가 붙는다. 메인 세션 파일은 세션 전체가 한 파일이라 이 줄을 안 찍는다.
mark 를 안 쓴 판은 끝난 뒤 메인 세션이 잰다. 도는 동안에는 그 세션 기록에 아직 cost-state 줄이 없고, scan 은 그런 세션을 「진행 중」으로 보아 마지막 작업 한 건에 cost-state없음 표시를 달아 예상 표본에서 뺀다 (끝 시각이 없어 시간이 짧게 잡히면 배율을 낮춰 버리기 때문이다).
.\bin\effort.exe scan # 끝난 판의 기록을 캐시에 넣는다
.\bin\effort.exe list --since 2026-09-21
.\bin\effort.exe show <작업id앞자리> # 「서브」 칸 · 에이전트 표의 「벽시계」
show 의 벽시계 줄 「서브」 칸과 에이전트 표의 「벽시계」 열에 나온다.show·stats·list 총계에는 안 든다. estimate·actual 의 기본(--metric total)만 서브 몫을 상한까지 넣는다. 공수 표에 적을 때는 「서브(참고) N분」 으로 어디서 온 값인지 밝힌다.group --add 는 소단계 경계를 표시할 뿐이고, actual 은 잰 값만 읽는다. mark 도 이름만 받는다 — 시각·길이 인자는 사용법 오류다. 값이 안 나오면 지어내지 말고 「못 쟀다」고 적는다..\build.ps1 # bin\effort.exe 를 만든다
.\build.ps1 -Test # go vet · go test 까지 돌리고 만든다
EffortTool 폴더 안에서 친다. build.ps1 은 코드(cmd · internal · mod · go.mod)를 마지막으로 바꾼 커밋과 빌드 시각을 실행 파일에 박으므로 effort --version 이 어느 소스로 구운 것인지 말해 준다. 그 경로에 미커밋 변경이 있으면 -dirty 가 붙고, git 이 없거나 .git 이 없으면 (dev) 로 찍는다.
| 어디서 쓰나 | 명령 앞자리 |
|---|---|
| EffortTool 저장소 단독에서 | .\bin\effort.exe … |
| 스튜디오(Officina) 저장소에서 | .\EffortTool\bin\effort.exe … |
| 서브모듈로 붙인 저장소에서 | .\Tools\EffortTool\bin\effort.exe … |
bin/ 은 git 에 안 들어간다. 서브모듈을 당긴 뒤에는 그 폴더에서 .\build.ps1 로 다시 빌드한다 (옛 exe 는 시각을 UTC 로 찍고 옛 버그를 그대로 갖는다).
아래 예시는 단독 저장소 기준(.\bin\…)으로 적는다.
.\bin\effort.exe scan --all # 기록을 훑어 캐시에 넣는다
.\bin\effort.exe stats --by class # 분류별 건수·시간·토큰
.\bin\effort.exe estimate 조사:M 설계:M 구현:L
estimate 가 내는 표는 그대로 보고의 공수 표에 붙는다.
| 명령 | 하는 일 | 읽는 것 / 쓰는 것 |
|---|---|---|
scan | JSONL 을 훑어 작업 레코드를 만든다. 기본은 바뀐 파일만, --rebuild 는 통째로. rules.txt 가 바뀌었으면 알아서 통째로 | 읽기 ~/.claude/projects/** · 쓰기 .effort/cache/* |
stats | 분류·에이전트·모델·세션별 건수 · 벽시계 · 순수시간 · 토큰 · 토큰당 시간 | 캐시만 읽는다 |
estimate | 소단계 목록을 받아 예상·범위 표를 낸다. 표본 단위는 묶음이 기본(--unit task 로 옛 셈) | 캐시 + rules.txt |
show | 작업 하나 상세 (서브에이전트 · 부모 · 모델별 토큰 · 대기 · 세션 검산) | 캐시만 읽는다 |
list | 작업 목록 한 줄씩. --group KEY 를 주면 묶음 표(표본은 estimate 와 같다) | 캐시만 읽는다 |
group | 소단계 경계를 사람이 표시한다. --add "<이름>" <작업id…> · --drop <묶음id> | 캐시 + 쓰기 groups.txt |
actual | 예상 표(--from)와 실제 묶음을 나란히 놓아 배율(실제÷예상)을 낸다 | 캐시 + groups.txt |
mark | 서브에이전트가 자기 판을 잰다. start "<이름>" · stop · show · list. 시각만 찍고 값은 기록 파일에서 잰다 | 읽기 기록 파일 끝쪽 (start 때 못 묶었으면 stop·show·list 때 파일 전체, 한 번에 512MB 까지) · 쓰기 marks.txt (start·stop 줄, stop·show·list 의 bind 줄) |
rules | 분류 규칙 · 크기 배율 · 시드를 보여준다. --check 는 문법 검사, --measure 는 캐시로 배율·시드를 다시 재서 붙여 넣을 줄만 찍는다 | rules.txt (+--measure 는 캐시) |
mod | exe 에 담긴 Claude Code mod(effort-steps)를 install · uninstall · status 한다 (아래 「Claude Code mod」 절) | 쓰기 ~/.claude/skills/effort-steps · ~/.claude/mod-backups |
version · help | 판 · 도움말 | — |
stats · estimate · show · list · actual 은 절대 쓰기를 안 한다. 캐시가 없으면 그렇게 말하고 종료 2 다. 쓰기를 하는 것은 scan(캐시·rules.txt 덧붙이기), group --add/--drop(groups.txt), mark start/stop(marks.txt 덧붙이기) 뿐이다.
actual 이 짝을 못 찾은 소단계는 — 로 두고 합에서 뺀다. 없는 값을 지어내지 않는다.
effort scan [--home DIR] [--projects DIR] [--project NAME|--all] [--rebuild] [--titles=false] [--quiet]
effort stats [--class C] [--since YYYY-MM-DD] [--by class|agent|model|session|group] [--group KEY] [--wide|--json]
effort estimate [--from FILE|-] [--unit group|task] [--group KEY] [--human] [--metric total|wall|pure] [--days N] [--no-x2] [--freeze] [소단계...]
effort show <promptId|접두사> [--json]
effort list [--class C] [--since D] [--session ID] [--group KEY] [--limit N] [--sort wall|pure|tok]
effort group [--add <이름>] [--class C] [--drop <묶음id>] [--since D] [--session ID] [--group KEY] [작업id...]
effort actual [--from FILE|-] [--since D] [--session ID] [--group KEY] [--match name|order] [--metric total|wall|pure]
effort rules [--check]
KEY(묶는 열쇠) = mark(기본 — 사람 표시 먼저, 남은 것은 세션 안 30분 간격) · gap:30m · class · session · none.effort estimate --human 조사:S (거꾸로 쓰면 종료 1).--human 은 --from 표의 「사람눈금」 열에 분을 적은 줄에만 값이 찬다 (적은 값 × rules.txt 의 human 배율). 인자 꼴에는 적을 자리가 없어 칸이 — 로 남는다.[이름:]분류[:크기] 다. 예 : 조사:M · 시험:구현:L · 구현(크기 M).대화 · 도구실행 · 잡무)이 있다. stats·list 기본 표에서 빠지고 --all 로만 보이며, 예상 공수 표본에는 --all 로도 안 들어간다.Get-Content 소단계.md | .\bin\effort.exe estimate --from - --human
| 소단계 | 분류 | 크기 | 사람눈금 |
| --- | --- | --- | --- |
| 1. JSONL 파서 | 구현 | M | 60 |
| 2. 실측·검산 | 실측 | M | |
인자와 --from 을 같이 주면 사용법 오류(종료 1)다.
Claude Code 안에서 공수 표를 다루는 mod 다. 모델이 plan 도구로 소단계를 넣으면 estimate 로 예상을 채우고, mark 기록으로 실제 칸을 채워 /steps 패널에 보인다. 완료 보고의 공수 표는 table 도구로 뽑는다. plan 없는 메인 세션의 Edit · Write · NotebookEdit · Agent 는 막는다 (서브에이전트 · claude -p 는 안 막는다). 소단계가 전부 끝 · 건너뜀이면 「plan 없음」으로 보고 다시 막는다. Bash · PowerShell 로 파일을 고치는 것은 막지 못한다 — 셸 명령이 파일을 쓰는지 가려낼 수 없어서다 (설계 한계). 소스는 mod/effort-steps/ 이고, 빌드 때 exe 안에 담긴다.
.\bin\effort.exe mod install # ~/.claude/skills/effort-steps 에 푼다 (있던 것은 mod-backups 로 옮김)
.\bin\effort.exe mod status # 설치본과 exe 에 담긴 판을 견준다. 「같음」이 아니면 종료 2
.\bin\effort.exe mod uninstall # 설치본을 mod-backups 로 옮긴다 (지우지 않는다)
/steps allow (다음 사람 말 한 번까지 막기 풀기, 사람이 친 것만) · /steps status (mode · plan 있음 · 허용 중)./config → effort-steps 의 mode : deny(기본) · warn(경고만) · off(강제 끔). effortPath 도 여기서 덮어쓴다.고칠 때 흐름. ① mod/effort-steps 를 그 세션의 ~/.claude/dev-mods/<세션id>/effort-steps/ 로 복사해 핫리로드로 고친다 ② claude plugin validate · tsc -p · claude plugin test ③ 저장소 mod/effort-steps 로 되돌려 복사한다 (정본은 저장소) ④ 그 dev-mods 사본을 지운다 ⑤ .\build.ps1 -Test ⑥ 개발 세션을 닫은 뒤 mod install → mod status 로 「같음」 확인.
상세(판정 규칙 · 막는 글 · 종료 코드)는 Docs/Design/2026-10-14-effort-steps이관·강제·embed설계.md 를 본다.
| 코드 | 뜻 |
|---|---|
| 0 | 성공. rules.txt 의 모르는 종류·이름 줄, marks.txt 의 잘못된 줄은 경고하고 건너뛰며 종료 0 |
| 1 | 사용법 오류 (모르는 옵션, 인자와 --from 동시, rules.txt 칸·숫자 오류, rules --check 의 모르는 종류, 캐시가 이 exe 보다 새 판, mark 에 시각·길이 인자) |
| 2 | 낼 것이 없음 (캐시 없음 · 표본 0) — 왜 0건인지 한 줄 말한다 |
| 3 | 읽기 실패 (경로 감옥 위반 · 폴더 없음 · 권한) |
| 4 | 쓰기 · 락 실패 |
| 5 | 손상 줄 비율이 상한(2%)을 넘음 |
rules.txt).effort\rules.txt 는 없을 때 한 번만 기본값으로 만들어진다. 있으면 절대 안 덮는다. 고친 뒤에는 그냥 effort scan 만 돌리면 된다 — 파일 지문(sha256)이 스캔 기록에 남아 있어, 한 글자라도 다르면 「규칙 파일이 바뀌어 전부 다시 읽습니다」를 찍고 캐시 분류를 통째로 다시 매긴다. 탭으로 나누고 # 이 주석이다.
이 exe 가 모르는 종류·이름(분류·도구 집합) 줄은 경고하고 그 줄만 건너뛴다. ~/.effort/ 는 기계에 하나라 더 새 exe 가 더한 줄을 옛 exe 가 볼 수 있어서다. 경고는 stderr 에 「몇 번째 줄 · 무엇을 몰랐나 · 어느 exe 가 더했나」를 찍는다. 멈추는 것은 rules --check 뿐이다 — 사람이 고친 뒤 확인하는 명령이라 모르는 것도 오류(종료 1)로 본다. 칸이 모자라거나 숫자가 틀린 줄은 사람 실수라 어느 명령이든 실패한다.
word 조사 조사 설명 끝말·포함 낱말
agent 설계 Plan agentType
size L 2.35 크기 배율 (S 0.35 · M 1.0 · L 2.35 · XL 6.49)
seed 조사 2 1 4 분류 p50분 p20분 p80분 (묶음 단위)
human 구현 0.25 사람 눈금 × 배율
min sample 5 이 아래면 시드만 쓴다
blend sample 12 이 위면 실측만 쓴다
tool 쓰기 Write 도구 집합. 쓰기·웹·읽기·셸 넷뿐
runpre ‹bash-input› 제목이 이걸로 시작하면 도구실행
runin loop wakeup 제목에 이게 들어 있으면 도구실행
chore 커밋 이 낱말이 있고 쓰기 도구가 없으면 잡무
shell max 2 이 수 이하 셸 호출만 도구실행으로 본다
contfirst 좋아 제목이 이걸로 시작하면 앞 일을 이어가는 말로 본다
contstop 혹시 제목에 이게 있으면 이어가지 않는다
cont max 10 작업 사이가 이 분을 넘으면 이어짐 사슬이 끊긴다
gap max 30 자동 소단계 묶기의 간격 컷 (분)
sub max 120 estimate 표본에서 서브 구간이 묶음·작업 하나에 더할 상한 (분)
wait min 3 도구 한 번이 이 분을 넘게 걸린 틈을 「대기」로 보여 준다 (총계에서 안 뺀다)
새 종류가 늘면 scan 이 빠진 종류의 기본 줄만 파일 끝에 더한다. 날짜와 더한 exe 판을 적은 주석 (# --- 2026-09-23 자동으로 더한 기본값 (sub) · effort 35864f5 ---)을 머리에 달고 덧붙이기만 하며, 사람이 쓴 줄은 읽지도 고치지도 않는다. 방아쇠는 「그 종류가 0줄」일 때뿐이라 두 번 더해지지 않는다. 규칙이 늘어난 그 판은 캐시를 통째로 다시 읽어 옛 분류를 새 규칙으로 다시 매긴다. rules --check 는 읽기 명령이라 알리기만 하고 파일을 안 고친다. 새 종류는 줄이 없어도 기본값으로 똑같이 도는 것만 만든다. 옛 exe 가 건너뛰어도 뜻이 안 바뀌게 하려는 것이다. 뜻을 바꿔야 하면 옛 종류에 새 뜻을 붙이지 않고 새 종류 이름을 만든다 (R8). 있는 종류의 칸 뜻·칸 수도 안 바꾼다.
scan 이 규칙·캐시 때문에 멈추면 끝에 「이 exe 는 <판> 판입니다 … .\build.ps1 로 다시 빌드하세요」를 찍는다. 캐시가 이 exe 보다 새 판이면 덮지 않고 종료 1 로 멈춘다 (옛 exe·새 exe 가 서로 덮는 핑퐁을 막는다).
위가 이긴다.
| 순위 | 보는 것 | 결과 |
|---|---|---|
| 1 | 알림인데 본문에 <tool-use-id> 가 없음 (Monitor 감시 신호) | 도구실행 |
| 2 | 알림 본문 <task-id> 로 그 세션의 서브에이전트를 정확히 찾음 | 그 갈래의 분류 (짝짓기) |
| 3 | 알림인데 짝을 못 찾음 | 같은 세션 직전 일 칸 분류를 물려받는다. 앞이 없으면 미분류 |
| 4 | 제목이 runpre 접두이거나 runin 낱말을 담음 | 도구실행 |
| 5 | 서브에이전트 description 끝말 → 포함 낱말 → agentType | 토큰을 가장 많이 쓴 서브가 이긴다 |
| 6 | 작업 제목의 같은 낱말 | 그 분류 |
| 7 | 잡무 낱말이 있고 쓰기 도구가 없음 | 잡무 |
| 8 | 웹 도구를 썼고 나머지가 전부 읽기·셸 도구 | 조사 |
| 9 | 셸만 shell max 회 이하 · 서브 없음 / 도구 0 · 서브 0 | 도구실행 / 대화 |
| 10 | 사람이 직접 친 말이고 contfirst 로 시작 · contstop·잡무 낱말 없음 · 이어짐 사슬이 살아 있음 | 가장 가까운 앞 일 칸 분류 (앞말이어짐) |
| 11 | 그 밖 | 미분류 |
SendMessage · ToolSearch · AskUserQuestion · Monitor · TaskStop · ListAgents · ScheduleWakeup)로, 코드에 박혀 있다. 파일을 건네는 SendUserFile 은 산출물 전달이라 일로 친다.cont max 분을 안 넘었으면 살아 있다. 바로 앞 작업하고만 재면 대화를 징검다리 삼아 몇 시간 전 일 칸을 물려받고, 앞 일 칸에서 바로 재면 대화가 몇 마디 오간 뒤의 「좋아 그러면」을 놓친다. 사슬로 보면 둘 다 막는다. 일 칸을 물려받은 알림도 사슬을 잇는다 — 서브에이전트를 기다린 시간은 대화가 끊긴 것이 아니다.contfirst 낱말(「아」「어」)은 뒤에 빈칸이나 부호가 와야 잡는다. 「아직」「어디」까지 물면 너무 넓다.이어받기·짝짓기·앞말이어짐)은 다시 물려주지 않는다. 세션이 한 분류로 물드는 것을 막는다.list 는 물려받은 작업을 문서← 처럼 화살표로 표시한다.<task-id>·<tool-use-id> 값만 꺼내 쓰고 본문 자체는 저장하지 않는다.rules.txt 로는 낱말·도구 이름·접두·상한만 고친다.--metric 으로 고른다 — total(기본) = 본줄 ∪ 서브에이전트 구간 · wall = 본줄만(09-23 전 기본) · pure = 턴 합. total 은 서브 몫(합집합 − 본줄)을 표본 하나당 rules.txt 의 sub max 분(기본 120) 까지만 더한다 — 승인 대기로 몇 시간 산 갈래가 p80 을 끌어올리지 않게. sub 줄이 없으면 scan 이 덧붙인다. 서브를 넣는 것은 estimate 와 actual 뿐이다 — 예상과 실제는 같은 잣대여야 해서 actual 도 기본이 total 이고, 실제 칸도 같은 sub max 로 자른다. show·stats·list 의 총계는 그대로 본줄만이다(09-04 결정). 메인 쪽 사람 대기는 안 들지만 서브 구간 안의 대기(승인 등)는 상한까지 든다 — 사람 시간이 아니다.min sample 보다 적으면 시드값만, blend sample 이상이면 실측만, 사이면 섞는다. 근거 칸에 시드 (n=3) · 섞음 n=8 · 실측 n=31 로 어느 쪽을 썼는지 적는다.실측이면 ×2(--no-x2 로 끔), --freeze 면 ×1.5 를 더 건다.실측 n=113 · 서브 포함 묶음 중앙 4.1분 (본줄 1.8분). 채택한 쪽이 앞, 다른 쪽이 괄호다 (--metric wall 이면 본줄 … (서브 포함 …)). 중앙값은 배율을 안 탄 표본 값이다. 표본이 0건이면 둘 다 빼고, 두 중앙이 같거나 --metric pure 면 괄호를 뺀다. 표 밑 안내는 total 이면 「본줄과 서브를 합친 시간 · 하나당 최대 N분 · 상한에 걸린 수」, wall·pure 면 그 뜻으로 바뀐다. --json 에는 Unit · SampleN · MainlineP50Ms(본줄) · WithAgentP50Ms(본줄 ∪ 서브, 상한 적용) · Capped(상한에 걸린 수) 로 나온다. 까닭 : Docs/Research/2026-09-23-estimate표본단위조사.md — 메인이 맡기고 검토만 하면 일이 서브 구간에서 돈다.estimate 는 묶음 단위로 잰다. 경계는 group --add 로 사람이 표시하고, 없는 자리만 자동으로 묶는다.turn_duration 에는 사람을 기다린 시간이 섞여 있다(9시간짜리 턴이 실제로 있다). 순수시간이 벽시계를 넘으면 벽시계로 자르고 순수시간잘림 표시를 달아 --metric pure 표본에서 뺀다. 배경 갈래가 도는 동안 닫힌 턴이 있었으면 배경갈래 표시가 붙는다.cost-state 줄은 세션의 20%도 안 되는 곳에만 있다 (실물 111개 중 19개). 없는 세션은 cost-state없음 표시를 달고 검산 칸을 — 로 둔다.promptId 는 user 줄에만 있다. assistant · turn_duration 줄은 파일 순서대로 앞선 promptId 를 이어받는다.(requestId, message.id) 로 여러 줄이 나오는 것은 스트리밍 스냅숏이다. 마지막 것만 센다 (그냥 더하면 2.7배로 부푼다). 단조 증가가 깨진 자리는 scan 끝 줄에 건수를 찍는다.list 머리의 날짜(+09:00) 와 show 의 -07:00 꼴이 로컬 시간대를 말해 준다. --json 값은 원본 그대로 UTC 라 기계끼리 견줄 때 쓴다.?덈뒗 처럼 보이면 원본 세션 기록이 이미 깨진 것이다. 툴이 깨뜨린 것이 아니다 (claude -p 에 한글을 CP949 셸 파이프로 넘긴 세션이다).head -c · cut -c)로 자르면 한글이 깨진다. 줄 단위(head -n)로 자른다.서브모듈로 붙인 저장소의 AGENTS.md 나 세션 시작 훅 글에 아래 한 줄을 넣는다. 훅을 설치해 주는 기능은 만들지 않았다 — 이 툴이 쓰는 자리는 .effort 뿐이라는 원칙 때문이다.
공수 재기 : .\Tools\EffortTool\bin\effort.exe scan → estimate <분류:크기…> (예상) · list/show (실제). 서브에이전트는 mark start "<번호-이름>" … mark stop 의 「기록 구간」을 실제 칸에. 규칙 : Tools/EffortTool/.claude/skills/effort-usage/SKILL.md
서브에이전트에서는 스킬 목록에 안 뜬다. SKILL.md 를 파일로 직접 읽게 한다.
[가려짐:규칙이름] 만 남는다.--titles=false 로 제목 저장을 통째로 끌 수 있다. 우회 옵션은 만들지 않았다.gofmt -l .
go vet ./...
go test -count=1 ./...
시험 자료는 testdata/ 에 있다. 실물이 아니라 익명화 규칙으로 새로 쓴 것이다 (testdata/README.md).
hooks/main.tsx 867 lines1// effort-steps : 소단계 진행 표를 띠와 /steps 패널로 보여 준다.
2// 여기서는 이벤트를 잇기만 한다. 파싱·짝짓기·표·그림은 다른 파일에 있다.
3//
4// 절대 규칙 : 이 mod 는 effort mark start · stop · scan 을 부르지 않는다.
5// Claude(메인·서브)가 친 명령을 tool.call 에서 「보기만」 하고, 값은 mark show 로 읽는다.
6//
7// 강제 (설계 2026-10-14 3절) : 메인 대화형 세션에서 plan 없이 Edit·Write·NotebookEdit·Agent 를 부르면 막는다.
8// 판정·글은 gate.ts(순수)에 있다. 강제 훅은 이 mod 몫(판정·기록)만 failOpen 으로 감싼다 — 이 mod 가 터져 일이 멈추면 안 된다.
9// next(e) 는 감싸지 않는다 : .catch(($,e,next)=>next(e)) 꼴은 next 가 던질 때 도구·띄우기를 한 번 더 돌린다.
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, ProcessRunResult, Register, Timer, ToolCallResult } from 'claude-code'
12
13import type { ExeInfo, MarkInfo, Plan, Step, StepStatus } from '../types'
14import {
15 ESTIMATE_TIMEOUT_MS, MARK_ID_RE, SHOW_TIMEOUT_MS, estimateArgs, exeCandidates, exeMissing, isAllowedArgs,
16 marksPathFrom, readEstimate, readShow,
17} from './effort'
18import type { EstimateOut, ShowOut } from './effort'
19import {
20 allDone, denyText, hasOpenMark, isSkipPath, judgeGate, noMarkLine, statusText, subPlanText, unclosedMarks,
21 unclosedToast, warnLine,
22} from './gate'
23import { attach, newMark, pairOf, pairOnReplan, pickFromMarksFile, settle } from './pair'
24import { clean, parseBlocks, parseCommand, parseMarksFile } from './parse'
25import type { ShowBlock } from './parse'
26import { PANE, readSettings } from './state'
27import type { Settings } from './state'
28import { markdownTable, totals } from './table'
29import { bandTree, countRows, paneTree } from './view'
30import type { Els, ViewData } from './view'
31
32type $ = EngineInterface
33
34// $.state 참조. 모양은 types/index.d.ts 계약에 있다.
35const planAtom = atom({ plugin: 'effort-steps', key: 'plan' } as const, null)
36const marksAtom = atom({ plugin: 'effort-steps', key: 'marks' } as const, {})
37const exeAtom = atom({ plugin: 'effort-steps', key: 'exe' } as const, { path: null, why: null })
38// 띠는 기본으로 숨긴다 (공수 표는 /steps 패널로 본다, 2026-10-07). /steps show 로 켜면 prefs 에 남아 다음 세션도 켜진다.
39const uiAtom = atom({ plugin: 'effort-steps', key: 'ui' } as const, { bandHidden: true, expanded: false })
40const pausedAtom = atom({ plugin: 'effort-steps', key: 'paused' } as const, false)
41const interactiveAtom = atom({ plugin: 'effort-steps', key: 'interactive' } as const, false)
42const allowAtom = atom({ plugin: 'effort-steps', key: 'allow' } as const, null)
43const turnAtom = atom({ plugin: 'effort-steps', key: 'turn' } as const, { warned: false, noMarkWarned: false })
44const toastedAtom = atom({ plugin: 'effort-steps', key: 'toasted' } as const, [])
45
46
47const CLASSES = ['조사', '설계', '구현', '실측', '문서', '검토'] as const
48const SIZES = ['S', 'M', 'L', 'XL'] as const
49const STATUSES: readonly StepStatus[] = ['할 일', '하는 중', '끝', '건너뜀']
50const MAX_STEPS = 30
51const MAX_OPEN_SHOWS = 8
52const MAX_FAILS = 3
53const HISTORY_KEEP = 30
54const PLAN_KEYS_KEEP = 20
55/** 계획 사본에 같이 남길 짝 없는 mark 수 (최근 것부터) */
56const ORPHANS_KEEP = 10
57const PANE_TITLE = '공수'
58
59const PLAN_TOOL = 'mcp__effort-steps__plan'
60const STEP_TOOL = 'mcp__effort-steps__step'
61const TABLE_TOOL = 'mcp__effort-steps__table'
62
63const PLAN_SCHEMA = {
64 type: 'object',
65 properties: {
66 title: { type: 'string', description: '작업 이름 (띠 첫 줄에 나온다)', maxLength: 80 },
67 steps: {
68 type: 'array',
69 minItems: 1,
70 maxItems: MAX_STEPS,
71 items: {
72 type: 'object',
73 properties: {
74 n: { type: 'integer', minimum: 1, maximum: 999, description: '소단계 번호. mark 이름 앞 번호와 같다 (예 3 → mark start "3-검사기")' },
75 name: { type: 'string', maxLength: 40, description: '소단계 이름 (짧게)' },
76 class: { type: 'string', enum: [...CLASSES] },
77 size: { type: 'string', enum: [...SIZES] },
78 },
79 required: ['n', 'name', 'class'],
80 },
81 },
82 },
83 required: ['title', 'steps'],
84}
85
86const STEP_SCHEMA = {
87 type: 'object',
88 properties: {
89 n: { type: 'integer', minimum: 1, maximum: 999 },
90 status: { type: 'string', enum: [...STATUSES] },
91 note: { type: 'string', maxLength: 120, description: '까닭 한 줄 (예 어긋난 까닭, 건너뛴 까닭)' },
92 },
93 required: ['n'],
94}
95
96// 모듈 변수 : 리로드하면 처음으로 돌아간다. 그래서 여러 이벤트에서 armTimer 를 부른다.
97// ($ 를 받는 함수는 엔진 검사 때문에 이 파일 맨 위 함수로만 둔다.)
98let cfg: Settings = readSettings({})
99let timer: Timer | null = null
100let busy = false
101/** exe 실행 자체가 잇달아 실패한 횟수. 이것만 전체 갱신을 멈춘다 */
102let exeFails = 0
103
104// ── 상태 쓰기 도우미 ─────────────────────────────────────────
105
106/**
107 * effort 를 부르는 유일한 길. estimate 와 mark show 밖의 인자는 여기서 막는다
108 * (mark start · stop · scan 은 절대 부르지 않는다).
109 */
110async function runEffort($: $, exe: string, args: readonly string[], timeoutMs: number): Promise<ProcessRunResult | string> {
111 if (!isAllowedArgs(args)) return `부르지 않는 effort 인자 : ${args.slice(0, 2).join(' ')}`
112 try {
113 return await $.process.run([exe, ...args], { timeoutMs })
114 } catch (err) {
115 return `실행 실패 : ${String(err)}`
116 }
117}
118
119/** exe 찾기 : 후보를 차례로 fs.exists 로 본다. */
120async function findExe($: $, configured: string): Promise<ExeInfo> {
121 const bases: string[] = []
122 const cwd = await $.session.cwd().catch(() => '')
123 if (cwd !== '') bases.push(cwd)
124 const root = await $.session.root().catch(() => '')
125 if (root !== '' && root !== cwd) bases.push(root)
126 const pathVar = (await $.env.get('PATH')) ?? (await $.env.get('Path')) ?? ''
127 for (const p of exeCandidates(configured, bases, pathVar)) {
128 const ok = await $.fs.exists(p).catch(() => false)
129 if (ok) return { path: p, why: null }
130 }
131 return { path: null, why: exeMissing(configured) }
132}
133
134async function estimate($: $, exe: string | null, steps: Step[]): Promise<EstimateOut> {
135 if (exe === null) return readEstimate(steps, 'effort.exe 를 못 찾아 예상을 못 받음')
136 return readEstimate(steps, await runEffort($, exe, estimateArgs(steps), ESTIMATE_TIMEOUT_MS))
137}
138
139/** show 결과 + exe 를 아예 못 돌렸는지 (그때만 전체 갱신을 멈춘다) */
140async function show($: $, exe: string, id: string): Promise<ShowOut & { isExeFail: boolean }> {
141 if (!MARK_ID_RE.test(id)) return { block: null, error: `mark id 꼴이 아님 : ${id}`, isExeFail: false }
142 const ran = await runEffort($, exe, ['mark', 'show', id], SHOW_TIMEOUT_MS)
143 return { ...readShow(id, ran), isExeFail: typeof ran === 'string' }
144}
145
146/** ~/.effort/marks.txt 를 읽기만 한다. 없거나 못 읽으면 null. */
147async function readMarks($: $): Promise<string | null> {
148 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
149 const p = marksPathFrom(await $.env.get('EFFORT_HOME'), home)
150 if (p === null) return null
151 return $.fs.read(p).catch(() => null)
152}
153
154/** 그릴 때 읽는 값 모음 (읽기만 한다) */
155async function viewData($: $): Promise<ViewData> {
156 return {
157 plan: await read($, planAtom),
158 marks: await read($, marksAtom),
159 exe: await read($, exeAtom),
160 paused: await read($, pausedAtom),
161 ui: await read($, uiAtom),
162 now: await $.clock.now(),
163 redRatio: cfg.redRatio,
164 }
165}
166
167/** 손으로 다시 재기 (/steps refresh · 패널 버튼). 다른 갱신이 돌고 있어 못 했으면 false. */
168async function refreshNow($: $): Promise<boolean> {
169 await update($, pausedAtom, () => false)
170 exeFails = 0
171 // 실패로 뺐던 mark 도 한 번 더 기회를 준다
172 await update($, marksAtom, m => {
173 const out: Record<string, MarkInfo> = {}
174 for (const [id, one] of Object.entries(m)) out[id] = one.fails >= MAX_FAILS ? { ...one, fails: 0 } : one
175 return out
176 })
177 await syncFromFile($)
178 const ran = await refresh($)
179 await saveCopy($)
180 return ran
181}
182
183async function sessionId($: $): Promise<string> {
184 return $.session.id().catch(() => 'unknown')
185}
186
187async function ensureExe($: $): Promise<string | null> {
188 const cur = await read($, exeAtom)
189 if (cur.path !== null) return cur.path
190 const found = await findExe($, cfg.effortPath)
191 await update($, exeAtom, () => found)
192 return found.path
193}
194
195/** 계획과 그 mark 를 세션 키로 store 에 남긴다 (--resume 때 되살림). */
196async function saveCopy($: $): Promise<void> {
197 const plan = await read($, planAtom)
198 if (plan === null) return
199 const marks = await read($, marksAtom)
200 const own: Record<string, MarkInfo> = {}
201 for (const s of plan.steps) {
202 for (const id of s.markIds) {
203 const m = marks[id]
204 if (m !== undefined) own[id] = m
205 }
206 }
207 // 짝 없는 mark 도 최근 것 몇 개는 같이 남긴다. 빼면 리로드 뒤 사라져, 소단계를 더해 plan 을 다시 불러도 붙일 것이 없다
208 const orphans = Object.values(marks).filter(m => m.n === null).sort((a, b) => a.seenAt - b.seenAt).slice(-ORPHANS_KEEP)
209 for (const m of orphans) own[m.id] = m
210 try {
211 const mine = `plan:${plan.sessionId}`
212 await $.store.set(mine, { plan, marks: own })
213 // 오래된 남의 세션 사본만 지운다 (지금 세션 키는 고르지 않는다)
214 const others = (await $.store.keys()).filter(k => k.startsWith('plan:') && k !== mine)
215 for (const k of others.slice(0, Math.max(0, others.length - (PLAN_KEYS_KEEP - 1)))) await $.store.delete(k)
216 } catch {
217 // store 가 가득 차거나 막혀도 띠는 그대로 돈다
218 }
219}
220
221/** mark 하나를 marks 에 넣고, 계획 번호가 맞으면 붙인다. */
222async function addMark($: $, mark: MarkInfo, startedAt: number): Promise<void> {
223 const plan = await read($, planAtom)
224 const n = pairOf(plan, mark.name, startedAt)
225 const one: MarkInfo = { ...mark, n }
226 const marks = await update($, marksAtom, m => {
227 const old = m[one.id]
228 // 이미 아는 mark 면 잰 값은 두고 짝·갈래만 고친다
229 return { ...m, [one.id]: old === undefined ? one : { ...old, n, agentId: old.agentId ?? one.agentId } }
230 })
231 if (plan !== null && n !== null) {
232 await update($, planAtom, p => (p === null ? p : settle(attach(p, n, one.id), marks)))
233 }
234}
235
236/** show·stop 덩이를 아는 mark 에 옮긴다. */
237async function applyShow($: $, block: ShowBlock, now: number): Promise<void> {
238 const marks = await update($, marksAtom, m => {
239 const old = m[block.id]
240 if (old === undefined) return m
241 const same = old.recordText === block.recordText ? old.same + 1 : 0
242 return {
243 ...m,
244 [block.id]: {
245 ...old,
246 open: block.open,
247 recordText: block.recordText,
248 recordMs: block.recordMs,
249 waitText: block.waitText,
250 flags: block.flags,
251 warn: block.warn,
252 error: null,
253 fails: 0,
254 same,
255 lastAt: now,
256 },
257 }
258 })
259 await update($, planAtom, p => (p === null ? p : settle(p, marks)))
260}
261
262async function markFailed($: $, id: string, error: string): Promise<number> {
263 let fails = 0
264 await update($, marksAtom, m => {
265 const old = m[id]
266 if (old === undefined) return m
267 fails = old.fails + 1
268 return { ...m, [id]: { ...old, error, fails } }
269 })
270 return fails
271}
272
273// ── 다시 재기 (타이머·명령·관찰이 부른다) ─────────────────────
274
275/**
276 * marks.txt 보조 길 : 이 세션이 계획 뒤에 연 번호 붙은 mark 중 못 본 것을 붙인다. 읽기만 한다.
277 * 열린 줄 알았던 mark 가 파일에서 stop 됐으면 다음 refresh 의 show 가 닫힌 값을 읽는다.
278 */
279async function syncFromFile($: $): Promise<void> {
280 const plan = await read($, planAtom)
281 if (plan === null) return
282 const text = await readMarks($)
283 if (text === null) return
284 const known = await read($, marksAtom)
285 for (const l of pickFromMarksFile(parseMarksFile(text), plan.sessionId, plan, known)) {
286 const agent = l.file.includes('/') ? l.file.slice(l.file.indexOf('/') + 1) : null
287 // 파일에서 처음 본 닫힌 mark 는 값(recordText)이 없어 다음 refresh 대상에 든다 (dueIds)
288 await addMark($, { ...newMark(l.id, l.name, null, agent, l.at), open: l.stopAt === null }, l.at)
289 }
290}
291
292/**
293 * 다시 잴 mark : 열린 것, 그리고 닫혔는데 값을 아직 못 읽은 것 (marks.txt 에서 처음 본 것 등).
294 * show 가 MAX_FAILS 번 잇달아 실패한 mark 는 뺀다.
295 */
296function dueIds(marks: Record<string, MarkInfo>): string[] {
297 return Object.values(marks)
298 .filter(m => (m.open || m.recordText === null) && m.fails < MAX_FAILS)
299 .map(m => m.id)
300}
301
302/** 다시 재야 할 mark(또는 고른 mark)를 mark show 로 다시 잰다. 다른 갱신이 돌고 있어 건너뛰었으면 false. */
303async function refresh($: $, only?: readonly string[]): Promise<boolean> {
304 if (busy) return false
305 busy = true
306 try {
307 if (await read($, pausedAtom)) return true
308 const marks = await read($, marksAtom)
309 const ids = (only ?? dueIds(marks)).slice(0, MAX_OPEN_SHOWS)
310 if (ids.length === 0) return true
311 const exe = await ensureExe($)
312 if (exe === null) return true
313 for (const id of ids) {
314 const out = await show($, exe, id)
315 const now = await $.clock.now()
316 if (out.block !== null) {
317 exeFails = 0
318 await applyShow($, out.block, now)
319 continue
320 }
321 const fails = await markFailed($, id, out.error ?? 'show 실패')
322 if (out.isExeFail) {
323 // exe 를 아예 못 돌렸다 : 다른 mark 도 같을 테니 잇달아 실패하면 전체를 멈춘다
324 exeFails++
325 if (exeFails >= MAX_FAILS) {
326 await update($, pausedAtom, () => true)
327 $.ui.toast(`effort-steps: effort.exe 실행이 ${MAX_FAILS}번 잇달아 실패해 갱신을 멈췄다. /steps refresh 로 다시 켠다`)
328 break
329 }
330 continue
331 }
332 exeFails = 0
333 if (fails === MAX_FAILS) {
334 // 이 mark 하나만 갱신에서 뺀다. 다른 mark 는 계속 잰다
335 $.ui.toast(`effort-steps: ${id} 의 mark show 가 ${MAX_FAILS}번 잇달아 실패해 이 mark 만 갱신에서 뺐다. /steps refresh 로 다시 잰다`)
336 }
337 }
338 return true
339 } finally {
340 busy = false
341 }
342}
343
344async function tick($: $): Promise<void> {
345 try {
346 await syncFromFile($)
347 await refresh($)
348 await saveCopy($)
349 } catch {
350 // 타이머 한 번이 실패해도 다음 주기는 돈다
351 }
352}
353
354/** 타이머를 건다. 리로드 뒤에는 모듈 변수가 비므로 여러 이벤트에서 부른다. */
355function armTimer($: $): void {
356 if (timer !== null) return
357 timer = $.clock.every(cfg.refreshMs, () => {
358 void tick($)
359 })
360}
361
362// ── 관찰 : Claude 가 친 effort mark 명령의 결과를 읽는다 ─────────
363
364async function observe($: $, agentId: string | null, command: string, ran: ToolCallResult): Promise<void> {
365 if (ran.deny !== undefined) return
366 const calls = parseCommand(command)
367 if (calls.length === 0) return
368 const blocks = parseBlocks(outputText(ran))
369 const now = await $.clock.now()
370 const known = await read($, marksAtom)
371 const fresh: string[] = []
372 const hasStop = calls.some(c => c.verb === 'stop')
373 const stopIds = calls.filter(c => c.verb === 'stop' && c.arg !== null).map(c => c.arg!)
374
375 for (const b of blocks) {
376 if (b.kind === 'start') {
377 await addMark($, newMark(b.id, b.name, null, agentId, now), now)
378 fresh.push(b.id)
379 } else if (known[b.id] !== undefined || fresh.includes(b.id)) {
380 await applyShow($, b, now)
381 } else if (hasStop) {
382 // 모르던 mark 의 stop (리로드 전이나 관찰 밖에서 열린 것) : 시작 시각으로 짝을 정한다
383 await addMark($, { ...newMark(b.id, b.name, null, agentId, now), open: b.open }, b.startAt ?? now)
384 await applyShow($, b, now)
385 }
386 }
387
388 // 보조 길 : start 를 쳤는데 결과에서 못 읽었으면 marks.txt 에서 같은 이름을 찾는다
389 const startNames = calls.filter(c => c.verb === 'start' && c.arg !== null).map(c => c.arg!)
390 if (startNames.length > blocks.filter(b => b.kind === 'start').length) {
391 const fileText = await readMarks($)
392 const sid = await sessionId($)
393 if (fileText !== null) {
394 const have = await read($, marksAtom)
395 const lines = parseMarksFile(fileText).filter(l =>
396 l.stopAt === null && have[l.id] === undefined && startNames.includes(l.name) &&
397 (l.file === sid || l.file.startsWith(`${sid}/`)))
398 for (const l of lines.slice(-startNames.length)) {
399 await addMark($, newMark(l.id, l.name, null, agentId, l.at), l.at)
400 fresh.push(l.id)
401 }
402 }
403 }
404
405 // 새로 연 mark 는 바로 한 번 재고, 결과를 못 읽은 stop 은 show 로 마지막 값을 읽는다
406 const shown = new Set(blocks.map(b => b.id))
407 const later = [...fresh, ...stopIds.filter(id => !shown.has(id) && known[id] !== undefined)]
408 if (later.length > 0) {
409 // 도구 결과를 붙잡지 않으려고 타이머로 미룬다
410 $.clock.after(50, () => {
411 void refresh($, later).then(() => saveCopy($)).catch(() => undefined)
412 })
413 } else {
414 await saveCopy($)
415 }
416}
417
418// ── 강제 : plan 없는 편집·Agent ─────────────────────────────
419
420/** 붙일 경고 줄과 그 종류 (턴마다 종류별 한 번) */
421type GateLine = { text: string; kind: 'warned' | 'noMarkWarned' }
422
423/**
424 * 편집·Agent 한 번의 판정. path 는 편집 대상(Agent·spawn 이면 null).
425 * 막을 글, 아니면 돈 뒤에 붙일 경고 줄을 돌려준다.
426 */
427async function gateCall($: $, tool: string, agentId: string | undefined, path: string | null): Promise<{ deny: string | null; line: GateLine | null }> {
428 // 서브·비대화형·off 는 env 도 안 읽고 바로 지나간다 (판정 표 첫 칸)
429 if (agentId !== undefined || cfg.mode === 'off' || !(await read($, interactiveAtom))) return { deny: null, line: null }
430 let skip = false
431 if (path !== null) {
432 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? null
433 const temp = (await $.env.get('TEMP')) ?? (await $.env.get('TMP')) ?? null
434 skip = isSkipPath(path, home, temp)
435 }
436 const verdict = judgeGate({
437 interactive: true, mode: cfg.mode, agentId, skip,
438 plan: await read($, planAtom), sessionId: await sessionId($), allowed: (await read($, allowAtom)) !== null,
439 })
440 if (verdict === 'deny') return { deny: denyText(tool), line: null }
441 if (verdict === 'warn') return { deny: null, line: { text: warnLine(tool), kind: 'warned' } }
442 // 계획은 있는데 열린 mark 가 없으면 메인 편집에만 한 줄 (Agent 띄우기는 서브가 자기 판에서 mark 를 연다)
443 if (verdict === 'planned' && path !== null && !hasOpenMark(await read($, marksAtom))) {
444 return { deny: null, line: { text: noMarkLine(), kind: 'noMarkWarned' } }
445 }
446 return { deny: null, line: null }
447}
448
449const OPEN_GATE: { deny: string | null; line: GateLine | null } = { deny: null, line: null }
450
451/**
452 * 이 mod 몫의 일(판정·기록)만 감싼다. 던지면 fallback 을 돌려준다(fail-open).
453 * next(e) 는 이 밖에서 정확히 한 번 부르고, 그 예외는 그대로 올린다.
454 */
455async function failOpen<T>(work: () => Promise<T>, fallback: T): Promise<T> {
456 try {
457 return await work()
458 } catch {
459 return fallback
460 }
461}
462
463/** 돈 뒤 : 다른 쪽이 막지 않았고 이번 턴 그 종류 첫 번이면 도구 결과에 한 줄을 붙인다 */
464async function warnOnce($: $, ran: ToolCallResult, line: GateLine): Promise<ToolCallResult> {
465 if (ran.deny !== undefined) return ran
466 // 첫 번 판정은 update 콜백 안에서 한다. 따로 read 하면 병렬 편집 둘이 둘 다 「첫」 으로 읽는다
467 let first = false
468 await update($, turnAtom, t => {
469 first = !t[line.kind]
470 return { ...t, [line.kind]: true }
471 })
472 if (!first) return ran
473 return { ...ran, context: [...(ran.context ?? []), line.text] } as ToolCallResult
474}
475
476/** 편집 셋(Edit·Write·NotebookEdit) 공용 몸통 */
477async function gatedEdit($: $, tool: string, agentId: string | undefined, path: string, run: () => Promise<ToolCallResult>): Promise<ToolCallResult> {
478 const g = await failOpen(() => gateCall($, tool, agentId, path), OPEN_GATE)
479 if (g.deny !== null) return { deny: g.deny } as ToolCallResult
480 const ran = await run()
481 return g.line === null ? ran : warnOnce($, ran, g.line).catch(() => ran)
482}
483
484/** 턴 끝 : 그 갈래(메인이면 null)가 연 mark 중 안 닫힌 것을 toast 한다. mark 하나에 한 번만 */
485async function toastUnclosed($: $, agentId: string | null): Promise<void> {
486 if (cfg.mode === 'off' || !(await read($, interactiveAtom))) return
487 const open = unclosedMarks(await read($, marksAtom), agentId, await read($, toastedAtom))
488 if (open.length === 0) return
489 await update($, toastedAtom, t => [...t, ...open.map(m => m.id)].slice(-200))
490 $.ui.toast(unclosedToast(open))
491}
492
493/** /steps status 글 */
494async function gateStatus($: $): Promise<string> {
495 const plan = await read($, planAtom)
496 const turn = await read($, turnAtom)
497 return statusText({
498 mode: cfg.mode,
499 interactive: await read($, interactiveAtom),
500 hasPlan: plan !== null,
501 planSessionOk: plan !== null && plan.sessionId === (await sessionId($)),
502 allDone: plan !== null && allDone(plan),
503 allowed: (await read($, allowAtom)) !== null,
504 warned: turn.warned,
505 noMarkWarned: turn.noMarkWarned,
506 })
507}
508
509// ── 도구 (모델이 부른다) ─────────────────────────────────────
510
511async function planTool($: $, input: Record<string, unknown>): Promise<string> {
512 const title = clean(input.title, 80) || '이름 없는 작업'
513 const raw: unknown[] = Array.isArray(input.steps) ? input.steps.slice(0, MAX_STEPS) : []
514 const seen = new Set<number>()
515 const steps: Step[] = []
516 for (const r of raw) {
517 if (r === null || typeof r !== 'object') continue
518 const o = r as Record<string, unknown>
519 const n = typeof o.n === 'number' && Number.isInteger(o.n) && o.n >= 1 && o.n <= 999 ? o.n : steps.length + 1
520 if (seen.has(n)) return `소단계 번호 ${n} 이 두 번 나온다. 번호는 서로 달라야 한다.`
521 seen.add(n)
522 const cls = CLASSES.find(c => c === o.class)
523 if (cls === undefined) return `소단계 ${n} 의 class 「${clean(o.class, 10)}」 를 모른다. ${CLASSES.join('·')} 중 하나.`
524 const size = SIZES.find(s => s === o.size) ?? 'M'
525 steps.push({
526 n, name: clean(o.name, 40) || `소단계${n}`, cls, size,
527 estText: '—', estMs: null, markIds: [], status: '할 일', note: null,
528 })
529 }
530 if (steps.length === 0) return '소단계가 없다. steps 에 하나 이상 넣는다.'
531
532 const exe = await ensureExe($)
533 const est = await estimate($, exe, steps)
534 const now = await $.clock.now()
535 const sid = await sessionId($)
536 const old = await read($, planAtom)
537 if (old !== null) await archive($, old)
538
539 let plan: Plan = {
540 id: `p${now.toString(36)}`, title, createdAt: now, sessionId: sid, steps: est.steps, estNote: est.note,
541 ...(old !== null ? { prevAt: old.createdAt } : {}),
542 }
543 // 갈아 끼우기 : 옛 계획 시절의 mark(붙어 있던 것·짝 없이 남은 것, 열림·닫힘 모두)는 번호로 다시 짝짓는다.
544 // 나머지는 계획 바로 앞에 연 mark (짝 문턱 안) 만 붙인다. 규칙은 pair.ts pairOnReplan.
545 const pairNow = (p: Plan, m: MarkInfo) => pairOnReplan(old, p, m)
546 const marks = await read($, marksAtom)
547 for (const m of Object.values(marks)) {
548 const n = pairNow(plan, m)
549 if (n !== null) plan = attach(plan, n, m.id)
550 }
551 plan = settle(plan, marks)
552 const fixed = plan
553 await update($, planAtom, () => fixed)
554 await update($, marksAtom, m => {
555 const out: Record<string, MarkInfo> = {}
556 for (const [id, one] of Object.entries(m)) out[id] = { ...one, n: pairNow(fixed, one) }
557 return out
558 })
559 await update($, pausedAtom, () => false)
560 // 상태에 없는 mark(리로드로 잃은 짝 없는 mark 등)를 marks.txt 에서 바로 주워 온다.
561 // 값은 다음 갱신(바로 아래 타이머)의 mark show 가 채운다
562 const before = Object.keys(await read($, marksAtom)).length
563 await syncFromFile($)
564 if (Object.keys(await read($, marksAtom)).length > before) {
565 $.clock.after(50, () => {
566 void refresh($).then(() => saveCopy($)).catch(() => undefined)
567 })
568 }
569 await saveCopy($)
570 armTimer($)
571 if (cfg.autoPane) void $.ui.open({ id: PANE, title: PANE_TITLE })
572
573 return [
574 markdownTable((await read($, planAtom)) ?? fixed, await read($, marksAtom), cfg.redRatio),
575 '',
576 '소단계마다 `effort mark start "<번호>-<이름>"` 을 치면 띠의 실제 칸이 채워진다 (번호가 위 표의 번호와 같아야 한다).',
577 ].join('\n')
578}
579
580async function stepTool($: $, input: Record<string, unknown>): Promise<string> {
581 const plan = await read($, planAtom)
582 if (plan === null) return '계획이 없다. 먼저 plan 도구를 부른다.'
583 const n = typeof input.n === 'number' ? input.n : NaN
584 if (!plan.steps.some(s => s.n === n)) return `계획에 ${clean(input.n, 10)} 번 소단계가 없다.`
585 const status = STATUSES.find(s => s === input.status)
586 const note = input.note === undefined ? undefined : clean(input.note, 120)
587 // 손으로 정한 상태(끝 말고)는 mark 보다 앞선다 : 붙은 mark 를 떼어 짝 없는 mark 로 돌린다.
588 // 끝이면 mark 가 있어도 그대로 둔다 (mark 가 닫혀 있으면 어차피 끝이다)
589 const detach = status !== undefined && status !== '끝'
590 const freed = detach ? (plan.steps.find(s => s.n === n)?.markIds ?? []) : []
591 const marks = freed.length === 0
592 ? await read($, marksAtom)
593 : await update($, marksAtom, m => {
594 const out = { ...m }
595 for (const id of freed) if (out[id] !== undefined) out[id] = { ...out[id]!, n: null }
596 return out
597 })
598 const next = await update($, planAtom, p => {
599 if (p === null) return p
600 const steps = p.steps.map(s =>
601 s.n === n
602 ? {
603 ...s,
604 markIds: detach ? [] : s.markIds,
605 status: status ?? s.status,
606 note: note === undefined ? s.note : note || null,
607 }
608 : s,
609 )
610 // 뗀 소단계는 markIds 가 비어 settle 이 손 상태를 그대로 둔다
611 return settle({ ...p, steps }, marks)
612 })
613 await saveCopy($)
614 const after = next?.steps.find(s => s.n === n)
615 const freedText = freed.length > 0 ? ` · 붙어 있던 mark ${freed.length}개를 뗐다` : ''
616 return `${n}번 소단계 : ${after?.status ?? '?'}${after?.note ? ` · ${after.note}` : ''}${freedText} (실제 값은 mark 로만 채운다)`
617}
618
619async function tableText($: $): Promise<string> {
620 const plan = await read($, planAtom)
621 if (plan === null) return '계획이 없다. plan 도구로 소단계를 넣는다.'
622 return markdownTable(plan, await read($, marksAtom), cfg.redRatio)
623}
624
625/** 끝난 계획을 store history 로 옮긴다 (최근 30개). */
626async function archive($: $, plan: Plan): Promise<void> {
627 const t = totals(plan, await read($, marksAtom))
628 try {
629 const prev = await $.store.get('history')
630 const list: unknown[] = Array.isArray(prev) ? prev : []
631 const row = { title: plan.title, at: plan.createdAt, steps: plan.steps.length, done: t.done, estMs: t.estMs, actualMs: t.actualMs }
632 await $.store.set('history', [...list, row].slice(-HISTORY_KEEP))
633 await $.store.delete(`plan:${plan.sessionId}`)
634 } catch {
635 // 기록 실패는 띠에 영향 없음
636 }
637}
638
639export const register: Register = (on, options) => {
640 cfg = readSettings(options)
641 timer = null
642 busy = false
643 exeFails = 0
644
645 // ── 이벤트 잇기 ──────────────────────────────────────────────
646
647 on('session.start', async ($, e, next) => {
648 await $.tool.register({
649 name: 'plan',
650 description:
651 '작업을 소단계로 나눈 계획을 넣는다. effort estimate 로 예상을 채운 「소단계/예상/실제/상태」 표를 돌려주고, 프롬프트 위 띠에 진행을 보여 준다. ' +
652 '그 뒤 소단계마다 `effort mark start "<번호>-<이름>"`·`mark stop <id>` 를 치면 실제 칸이 자동으로 채워진다. 다시 부르면 계획을 갈아 끼운다. ' +
653 '편집(Edit·Write·NotebookEdit)·Agent 를 쓰기 전에 먼저 부른다 — 계획이 없거나 소단계가 전부 끝·건너뜀이면 그 도구가 막힌다. 메인 세션만 부른다.',
654 inputSchema: PLAN_SCHEMA,
655 })
656 await $.tool.register({
657 name: 'step',
658 description: 'mark 없이 한 소단계의 상태(끝·건너뜀·하는 중·할 일)와 까닭 한 줄을 적는다. 시간은 받지 않는다 — 실제 값은 mark 로만 채운다. ' +
659 '끝 말고 상태를 주면 그 소단계에 붙은 mark 를 떼어 짝 없는 mark 로 돌린다 (잘못 붙은 mark 를 뗄 때). 끝이면 mark 는 그대로 둔다.',
660 inputSchema: STEP_SCHEMA,
661 })
662 await $.tool.register({
663 name: 'table',
664 description: '지금 공수 표를 마크다운으로 돌려준다. 완료 보고의 공수 표로 그대로 쓴다.',
665 inputSchema: { type: 'object', properties: {} },
666 })
667 await $.command.register({
668 name: 'steps',
669 description: '공수 소단계 패널 열기/닫기 · table · refresh · hide · show · clear · allow(다음 말 한 번 plan 없이 허용) · status',
670 argumentHint: '[table|refresh|hide|show|clear|allow|status]',
671 })
672 await update($, interactiveAtom, () => e.isInteractive === true)
673
674 // --resume 이면 store 의 계획 사본을 되살린다
675 if ((await read($, planAtom)) === null) {
676 const sid = await sessionId($)
677 const copy: unknown = await $.store.get(`plan:${sid}`).catch(() => undefined)
678 if (copy !== null && typeof copy === 'object' && 'plan' in copy && 'marks' in copy) {
679 const c = copy as { plan: Plan; marks: Record<string, MarkInfo> }
680 await update($, marksAtom, m => ({ ...c.marks, ...m }))
681 await update($, planAtom, () => c.plan)
682 }
683 }
684 // prefs 에 사람이 /steps show|hide 로 고른 값이 있을 때만 기본(숨김)을 덮는다
685 const prefs: unknown = await $.store.get('prefs').catch(() => undefined)
686 if (prefs !== null && typeof prefs === 'object' && 'bandHidden' in prefs) {
687 const hidden = (prefs as { bandHidden: unknown }).bandHidden === true
688 await update($, uiAtom, u => ({ ...u, bandHidden: hidden }))
689 }
690 await ensureExe($)
691 armTimer($)
692 return next(e)
693 })
694
695 on('turn.start', ($, e, next) => {
696 armTimer($)
697 return next(e)
698 })
699
700 // 자기 도구는 스스로 답한다. 안에서 무엇이 터져도 모델에게는 글 하나로 답한다.
701 const failed = { result: 'effort-steps: 처리 중 오류가 났다. /steps refresh 뒤 다시 부른다.' }
702 // 서브에이전트의 plan 은 거절한다 : 작업서에 「plan 넣어라」 를 적은 서브가 메인 계획을 덮지 않게
703 on('tool.call', { tool: PLAN_TOOL }, async ($, e) =>
704 e.agentId !== undefined ? { result: subPlanText() } : { result: await planTool($, e as unknown as Record<string, unknown>) })
705 .catch(() => failed)
706 // step 도 같다 : 서브가 메인 소단계를 끝냄으로 바꾸면 메인 Edit·Agent 가 막힌다
707 on('tool.call', { tool: STEP_TOOL }, async ($, e) =>
708 e.agentId !== undefined ? { result: subPlanText() } : { result: await stepTool($, e as unknown as Record<string, unknown>) })
709 .catch(() => failed)
710 on('tool.call', { tool: TABLE_TOOL }, async $ => ({ result: await tableText($) }))
711 .catch(() => failed)
712
713 // Bash · PowerShell 결과를 본다. 결과는 언제나 손대지 않고 그대로 돌려준다.
714 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
715 const ran = await next(e)
716 await failOpen(async () => {
717 armTimer($)
718 await observe($, e.agentId ?? null, e.command, ran)
719 }, undefined)
720 return ran
721 })
722 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
723 const ran = await next(e)
724 await failOpen(async () => {
725 armTimer($)
726 await observe($, e.agentId ?? null, e.command, ran)
727 }, undefined)
728 return ran
729 })
730
731 // ── 강제 훅 : 모두 fail-open ─────────────────────────────────
732
733 // 사람이 터미널에서 친 말(슬래시 아님)이면 이번 턴 경고 표시를 지운다.
734 // 허용은 그 말 한 번을 살아남는다 : 아직 안 쓴 허용이면 「씀」 으로 두고, 이미 쓴 허용이면 지운다
735 on('prompt.submit', async ($, e, next) => {
736 if (e.origin.kind === 'composer' && !e.text.trimStart().startsWith('/')) {
737 await failOpen(async () => {
738 await update($, allowAtom, a => (a !== null && !a.used ? { ...a, used: true } : null))
739 await update($, turnAtom, () => ({ warned: false, noMarkWarned: false }))
740 }, undefined)
741 }
742 return next(e)
743 })
744
745 on('tool.call', { tool: 'Edit' }, ($, e, next) => gatedEdit($, 'Edit', e.agentId, e.file_path, () => next(e)))
746 on('tool.call', { tool: 'Write' }, ($, e, next) => gatedEdit($, 'Write', e.agentId, e.file_path, () => next(e)))
747 on('tool.call', { tool: 'NotebookEdit' }, ($, e, next) => gatedEdit($, 'NotebookEdit', e.agentId, e.notebook_path, () => next(e)))
748 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
749 const g = await failOpen(() => gateCall($, 'Agent', e.agentId, null), OPEN_GATE)
750 if (g.deny !== null) return { deny: g.deny }
751 const ran = await next(e)
752 return g.line === null ? ran : warnOnce($, ran, g.line).catch(() => ran)
753 })
754 // Agent 도구를 안 지나는 띄우기(workflow agent() · $.agent.spawn). 메인에서 난 것은 parentAgentId 가 없다.
755 // 경고 줄을 붙일 도구 결과가 없어 warn 이면 그냥 지나간다
756 on('agent.spawn', async ($, e, next) => {
757 const g = await failOpen(() => gateCall($, 'Agent', e.parentAgentId, null), OPEN_GATE)
758 if (g.deny !== null) return { deny: g.deny }
759 return next(e)
760 })
761
762 on('turn.complete', async ($, e, next) => {
763 await failOpen(() => toastUnclosed($, e.agentId ?? null), undefined)
764 return next(e)
765 })
766
767 // /clear 는 session.start 없이 세션 id 만 바뀐다. 판정은 세션 id 로 하므로 plan 은 저절로 「없음」, 허용은 여기서 지운다
768 on('session.end', async ($, e, next) => {
769 await failOpen(async () => {
770 await update($, allowAtom, () => null)
771 await update($, turnAtom, () => ({ warned: false, noMarkWarned: false }))
772 }, undefined)
773 return next(e)
774 })
775
776 on('command.run', { command: 'steps' }, async ($, e) => {
777 armTimer($)
778 const arg = e.args.trim().split(/\s+/)[0] ?? ''
779 switch (arg) {
780 case '': {
781 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
782 if (isOpen) {
783 await $.ui.close({ id: PANE })
784 return { text: '공수 패널을 닫았다.' }
785 }
786 await $.ui.open({ id: PANE, title: PANE_TITLE })
787 return { text: '공수 패널을 열었다.' }
788 }
789 case 'table':
790 return { text: await tableText($) }
791 case 'refresh': {
792 await update($, exeAtom, x => (x.path === null ? { path: null, why: null } : x))
793 const ran = await refreshNow($)
794 const exe = await read($, exeAtom)
795 if (exe.path === null) return { text: `갱신 못 함 : ${exe.why ?? 'effort.exe 없음'}` }
796 return { text: ran ? '열린 mark 를 다시 쟀다.' : '다른 갱신 중이라 이번엔 건너뛰었다. 잠시 뒤 다시 친다.' }
797 }
798 case 'hide':
799 case 'show': {
800 const hidden = arg === 'hide'
801 await update($, uiAtom, u => ({ ...u, bandHidden: hidden }))
802 await $.store.set('prefs', { bandHidden: hidden }).catch(() => undefined)
803 return { text: hidden ? '공수 띠를 껐다. /steps show 로 켠다.' : '공수 띠를 켰다. /steps hide 로 끈다.' }
804 }
805 case 'clear': {
806 const plan = await read($, planAtom)
807 if (plan === null) return { text: '지울 계획이 없다.' }
808 await archive($, plan)
809 await update($, planAtom, () => null)
810 await update($, marksAtom, () => ({}))
811 await update($, pausedAtom, () => false)
812 exeFails = 0
813 return { text: `「${plan.title}」 계획을 기록으로 옮기고 띠를 비웠다.` }
814 }
815 case 'allow': {
816 // 모델이 스스로 풀 수 없게 사람이 터미널에서 친 것만 받는다
817 if (e.origin.kind !== 'composer') return { text: '/steps allow 는 사람이 터미널에서 친 것만 받는다.' }
818 const now = await $.clock.now()
819 await update($, allowAtom, () => ({ at: now, used: false }))
820 return { text: '다음 사람 말 한 번(그 턴 끝까지) plan 없이 편집·Agent 를 허용했다. 그 다음 말에 다시 막는다.' }
821 }
822 case 'status':
823 return { text: await gateStatus($) }
824 default:
825 return { text: '쓰는 법 : /steps [table|refresh|hide|show|clear|allow|status] — 인자 없이 치면 패널을 열고 닫는다. allow = 다음 사람 말 한 번 plan 없이 허용, status = 강제 상태.' }
826 }
827 })
828
829 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
830 if (e.props.hasSurvey) return next(e)
831 // 띠 자리는 플러그인 전체에 하나다. 아래 플러그인(prompt-gauge 등)의 띠가 있으면 내 것 아래에 포갠다.
832 // 엔진 자리(type 'engine')는 비켜 줄 때만 쓰는 것이라 버린다. next 는 한 번만 부른다.
833 // 아래 띠가 있으면 그 띠가 실제로 차지하는 줄 수(countRows)를 내 띠의 줄 수에서 먼저 뺀다.
834 const below = await next(e)
835 const hasBelow = below !== null && below !== undefined && below.type !== 'engine'
836 const els = $.ui.resolve(e) as unknown as Els
837 const tree = bandTree(els, await viewData($), { ...e.props, belowRows: hasBelow ? countRows(below) : 0 })
838 if (tree === null) return below
839 if (!hasBelow) return tree
840 const { Box } = els
841 return (
842 <Box key="effort-steps-stack" flexDirection="column">
843 <Box key="effort-steps" flexDirection="column">{tree}</Box>
844 <Box key="below" flexDirection="column">{below}</Box>
845 </Box>
846 )
847 })
848
849 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
850 const els = $.ui.resolve(e) as unknown as Els
851 return paneTree(els, await viewData($), e.props.bodyColumns, {
852 toggleExpanded: () => update($, uiAtom, u => ({ ...u, expanded: !u.expanded })),
853 refreshNow: async () => {
854 if (!(await refreshNow($))) $.ui.toast('effort-steps: 다른 갱신 중이라 이번엔 건너뛰었다. 잠시 뒤 다시 누른다')
855 },
856 })
857 })
858}
859
860/** 도구 결과에서 글을 꺼낸다 : core 가 주는 text, 없으면 stdout. */
861function outputText(ran: ToolCallResult): string {
862 if (typeof ran.text === 'string' && ran.text !== '') return ran.text
863 const r: unknown = ran.result
864 if (r !== null && typeof r === 'object' && 'stdout' in r && typeof r.stdout === 'string') return r.stdout
865 return typeof r === 'string' ? r : ''
866}
867hooks/effort.ts 104 lines1// effort.exe 부르기의 순수한 반쪽 : 인자 만들기, 결과 풀기, exe 후보, marks.txt 경로.
2// 실제 process.run · fs 호출은 main.tsx 에 있다 (엔진 검사가 $ 를 import 너머로 넘기지 못하게 한다).
3//
4// 이 mod 는 effort 를 「읽기」로만 부른다. mark start · stop · scan 을 부르면 묶기가 깨지거나
5// (묶기없음) 캐시를 다시 짓는다 — 그래서 isAllowedArgs 가 그 밖의 인자를 막는다.
6import type { ProcessRunResult } from 'claude-code'
7
8import type { Step } from '../types'
9import { firstLine, minutesText, parseBlocks, parseEstimate } from './parse'
10import type { ShowBlock } from './parse'
11
12export const SHOW_TIMEOUT_MS = 5_000
13export const ESTIMATE_TIMEOUT_MS = 10_000
14
15/** 부를 수 있는 인자인지. estimate 와 mark show 만 된다. */
16export function isAllowedArgs(args: readonly string[]): boolean {
17 if (args[0] === 'estimate') return true
18 return args[0] === 'mark' && args[1] === 'show' && args.length === 3
19}
20
21function join(dir: string, name: string): string {
22 return dir.endsWith('/') || dir.endsWith('\\') ? dir + name : `${dir}/${name}`
23}
24
25/**
26 * exe 후보 차례 : ① 설정 effortPath ② <base>/EffortTool/bin ③ <base>/Tools/EffortTool/bin ④ PATH.
27 * base 는 cwd, 다르면 저장소 뿌리도. 일반 <base>/bin 은 남의 effort 를 잡을 수 있어 보지 않는다
28 * (EffortTool 저장소 안에서 쓸 때는 effortPath 설정에 그 exe 를 적는다).
29 * 적힌 effortPath 에 파일이 없어도 ②~④ 를 이어서 본다 — 저장소를 옮기면 설치본 default 가 낡기 때문이다.
30 */
31export function exeCandidates(configured: string, bases: readonly string[], pathVar: string): string[] {
32 const out: string[] = []
33 if (configured.trim() !== '') out.push(configured.trim())
34 for (const base of bases) {
35 for (const rel of ['EffortTool/bin', 'Tools/EffortTool/bin']) {
36 for (const file of ['effort.exe', 'effort']) out.push(join(join(base, rel), file))
37 }
38 }
39 // 드라이브 글자(C:)로 시작하거나 ; 가 있으면 Windows 꼴이라 ; 로 자른다 (C: 의 : 로 자르면 안 된다)
40 const sep = /^[A-Za-z]:/.test(pathVar.trim()) || pathVar.includes(';') ? ';' : ':'
41 for (const dir of pathVar.split(sep)) {
42 if (dir.trim() === '') continue
43 for (const file of ['effort.exe', 'effort']) out.push(join(dir.trim(), file))
44 }
45 return out
46}
47
48/** exe 를 못 찾았을 때 띠에 띄울 글 */
49export function exeMissing(configured: string): string {
50 return configured.trim() !== ''
51 ? `설정 effortPath 에 파일이 없고 다른 후보에도 없음 : ${configured.trim()} — EffortTool 에서 effort mod install 을 다시 치거나 /config 의 effortPath 를 고친다`
52 : 'effort.exe 를 못 찾음 — EffortTool 에서 effort mod install 을 치거나 /config 에서 effort-steps 의 effortPath 를 적는다'
53}
54
55/** marks.txt 경로. EFFORT_HOME 이 있으면 그 아래, 아니면 ~/.effort. */
56export function marksPathFrom(effortHome: string | undefined, home: string | undefined): string | null {
57 if (effortHome !== undefined && effortHome !== '') return join(effortHome, 'marks.txt')
58 if (home === undefined || home === '') return null
59 return join(join(home, '.effort'), 'marks.txt')
60}
61
62/** estimate 인자. 이름에 「:」 이 들어가면 estimate 가 못 받으므로 번호만 넘긴다 (「3:구현:M」). */
63export function estimateArgs(steps: readonly Step[]): string[] {
64 return ['estimate', '--json', ...steps.map(s => `${s.n}:${s.cls}:${s.size}`)]
65}
66
67/** estimate 결과 : 소단계마다 예상 값을 채운 목록과, 못 채웠을 때 까닭. */
68export type EstimateOut = { steps: Step[]; note: string | null }
69
70/** estimate 결과를 푼다. out 이 글이면 실행 실패 까닭이다. 실패하면 예상 칸은 「—」. */
71export function readEstimate(steps: readonly Step[], out: ProcessRunResult | string): EstimateOut {
72 const blank = steps.map(s => ({ ...s, estText: '—', estMs: null }))
73 if (typeof out === 'string') return { steps: blank, note: out }
74 const map = out.exitCode === 0 ? parseEstimate(out.stdout) : new Map<string, number>()
75 if (map.size === 0) {
76 const why = firstLine(out.stderr) || firstLine(out.stdout) || `종료 ${out.exitCode}`
77 const scan = out.exitCode === 2 || /scan/.test(why) ? ' — effort scan 을 한 번 돌려야 한다 (이 mod 는 scan 을 부르지 않는다)' : ''
78 return { steps: blank, note: `estimate 실패 : ${why}${scan}` }
79 }
80 return {
81 steps: steps.map(s => {
82 const ms = map.get(String(s.n)) ?? null
83 return { ...s, estMs: ms, estText: minutesText(ms) }
84 }),
85 note: null,
86 }
87}
88
89export const MARK_ID_RE = /^m\d{4}-[0-9a-f]{4,8}$/
90
91/** mark show 결과 : 덩이 하나, 또는 실패 까닭. */
92export type ShowOut = { block: ShowBlock | null; error: string | null }
93
94/** mark show 결과를 푼다. out 이 글이면 실행 실패 까닭이다. */
95export function readShow(id: string, out: ProcessRunResult | string): ShowOut {
96 if (typeof out === 'string') return { block: null, error: `show 실패: ${firstLine(out)}` }
97 const block = parseBlocks(out.stdout).find((b): b is ShowBlock => b.kind === 'show' && b.id === id) ?? null
98 if (out.exitCode !== 0 || block === null || block.recordText === null) {
99 const why = firstLine(out.stderr) || firstLine(out.stdout) || `종료 ${out.exitCode}`
100 return { block: null, error: `show 실패: ${why}` }
101 }
102 return { block, error: null }
103}
104hooks/gate.ts 170 lines1// (순수) plan 없는 편집·위임을 막는 판정과 그 글. 엔진($)을 쓰지 않는다.
2// 설계 : EffortTool/Docs/Design/2026-10-14-effort-steps이관·강제·embed설계.md 3절
3//
4// 판정 차례 (설계 3절 표 그대로) :
5// 대화형 아님 · mode off · agentId 있음 · 빼는 자리 → 지나감
6// plan 있음 (같은 세션 id · 소단계가 전부 끝·건너뜀은 아님) → 지나감 (메인 편집이면 열린 mark 경고 검사)
7// /steps allow (다음 사람 말 한 번) → 지나감
8// mode warn → 지나감 + 그 턴 첫 번에 경고 한 줄
9// mode deny → 막음
10import type { MarkInfo, Plan } from '../types'
11
12export type Mode = 'deny' | 'warn' | 'off'
13
14export const PLAN_TOOL = 'mcp__effort-steps__plan'
15
16/** 판정에 드는 값 모음. main.tsx 가 $.state 와 이벤트에서 채워 넘긴다. */
17export type GateInput = {
18 interactive: boolean
19 mode: Mode
20 /** tool.call 의 agentId · agent.spawn 의 parentAgentId. 메인이면 undefined */
21 agentId: string | undefined
22 /** 빼는 자리(편집 대상이 ~/.claude · TEMP 아래)인가 */
23 skip: boolean
24 plan: Pick<Plan, 'sessionId' | 'steps'> | null
25 /** 지금 $.session.id() */
26 sessionId: string
27 /** /steps allow 가 살아 있나 (다음 사람 말 한 번을 살아남는다) */
28 allowed: boolean
29}
30
31/**
32 * 판정 결과.
33 * pass = 볼 대상이 아님 · planned = 계획 있음 · allowed = 사람이 /steps allow 로 풂 · warn = 지나가되 경고 · deny = 막음
34 */
35export type Verdict = 'pass' | 'planned' | 'allowed' | 'warn' | 'deny'
36
37/**
38 * 계획의 소단계가 모두 끝났나 (「끝」 또는 「건너뜀」 — 건너뛴 소단계도 더 할 일이 없다).
39 * 새 판 시작 때 plan 을 다시 넣게 하려고 이때는 「plan 없음」으로 본다
40 */
41export function allDone(plan: Pick<Plan, 'steps'>): boolean {
42 return plan.steps.length > 0 && plan.steps.every(s => s.status === '끝' || s.status === '건너뜀')
43}
44
45/** 「plan 있음」 = 같은 세션 id 의 계획이 있고 소단계가 전부 끝(끝·건너뜀)은 아님. /clear 뒤에는 세션 id 가 바뀌어 저절로 없음이 된다 */
46export function planLive(plan: Pick<Plan, 'sessionId' | 'steps'> | null, sessionId: string): boolean {
47 return plan !== null && plan.sessionId === sessionId && !allDone(plan)
48}
49
50export function judgeGate(g: GateInput): Verdict {
51 if (!g.interactive || g.mode === 'off' || g.agentId !== undefined || g.skip) return 'pass'
52 if (planLive(g.plan, g.sessionId)) return 'planned'
53 if (g.allowed) return 'allowed'
54 return g.mode === 'warn' ? 'warn' : 'deny'
55}
56
57/** 설정 칸 mode 풀기. 모르는 값은 deny */
58export function readMode(v: unknown): Mode {
59 return v === 'warn' || v === 'off' ? v : 'deny'
60}
61
62// ── 빼는 자리 ─────────────────────────────────────────────────
63
64/** 역슬래시→슬래시, /c/x → c:/x, 끝 슬래시 떼기, 소문자. 비교 열쇠로만 쓴다 */
65function key(p: string): string {
66 let s = p.trim().replace(/\\/g, '/')
67 const msys = /^\/([A-Za-z])(\/|$)/.exec(s)
68 if (msys !== null) s = `${msys[1]!}:/${s.slice(3)}`
69 return s.replace(/\/+$/, '').toLowerCase()
70}
71
72function under(p: string, root: string): boolean {
73 const r = key(root)
74 return r !== '' && (p === r || p.startsWith(`${r}/`))
75}
76
77/**
78 * 편집 대상이 빼는 자리인가 : `<홈>/.claude` 아래(내장 기억 · 계획 파일) 또는 TEMP 아래(스크래치패드).
79 * 절대 경로만 본다. `..` 조각이 끼거나 상대 경로·`~` 면 빼지 않는다(= 판정 쪽) — 흉내 낸 자리로 빠져나가지 못하게.
80 */
81export function isSkipPath(path: string, home: string | null, temp: string | null): boolean {
82 const p = key(path)
83 if (!/^[a-z]:\//.test(p) && !p.startsWith('/')) return false
84 if (p.split('/').some(part => part === '..')) return false
85 if (home !== null && home.trim() !== '' && under(p, `${key(home)}/.claude`)) return true
86 return temp !== null && temp.trim() !== '' && under(p, temp)
87}
88
89// ── 글 ───────────────────────────────────────────────────────
90
91/** 막을 때 모델이 받는 글 (설계 3절 그대로). tool 은 막힌 도구 이름 */
92export function denyText(tool: string): string {
93 return [
94 `effort-steps : 이 세션에 공수 계획이 없어 막았다 (${tool}).`,
95 `먼저 plan 도구로 소단계를 넣는다 — 도구 이름 ${PLAN_TOOL},`,
96 `목록에 이름만 있으면 ToolSearch 로 "select:${PLAN_TOOL}" 을 먼저 불러온다.`,
97 '질문에 답만 하는 턴이면 편집·Agent 를 쓰지 않는다.',
98 '사람이 다음 말 한 번만 풀려면 /steps allow, 늘 경고로만 두려면 /config 의 mode.',
99 ].join('\n')
100}
101
102/** mode warn 일 때 그 턴 첫 번에 도구 결과에 붙이는 한 줄 */
103export function warnLine(tool: string): string {
104 return `effort-steps : 이 세션에 공수 계획이 없다 (${tool}) — 지금은 경고만 한다 (mode warn). 먼저 plan 도구(${PLAN_TOOL})로 소단계를 넣는다.`
105}
106
107/** 계획은 있는데 열린 mark 가 하나도 없을 때 메인 편집에 붙이는 한 줄 */
108export function noMarkLine(): string {
109 return 'effort-steps : 계획은 있는데 열린 mark 가 없다. 소단계를 시작할 때 `effort mark start "<번호>-<이름>"` 을 친다 (실제 칸이 안 채워진다).'
110}
111
112/** 서브에이전트가 plan 도구를 부를 때 돌려주는 글. 서브가 메인 계획을 덮지 않게 한다 */
113export function subPlanText(): string {
114 return 'effort-steps : plan 은 메인 몫이다 — 서브에이전트는 계획을 넣거나 갈아 끼우지 않는다. 소단계 공수는 `effort mark start "<번호>-<이름>"`·`mark stop <id>` 로만 잰다.'
115}
116
117/** 안 닫힌 mark 알림 (toast). marks 는 비어 있지 않다 */
118export function unclosedToast(marks: readonly Pick<MarkInfo, 'id' | 'name'>[]): string {
119 const list = marks.map(m => `${m.name} (${m.id})`).join(', ')
120 return `effort-steps : 안 닫힌 mark — ${list}. 끝났으면 effort mark stop <id>`
121}
122
123/** mark 의 갈래가 그 agentId 인가. marks.txt 에서 주운 mark 는 「agent-<id>」 꼴로 적혀 있다 */
124function sameAgent(markAgent: string | null, agentId: string | null): boolean {
125 if (agentId === null) return markAgent === null
126 return markAgent === agentId || markAgent === `agent-${agentId}`
127}
128
129/**
130 * toast 할 안 닫힌 mark : 그 갈래(메인이면 null)가 연 것 중 열린 것, 이미 알린 것은 뺀다.
131 * 메인 턴 끝에 서브 mark 가 열려 있는 것은 정상이라 메인은 메인 것만 본다.
132 */
133export function unclosedMarks(marks: Readonly<Record<string, MarkInfo>>, agentId: string | null, toasted: readonly string[]): MarkInfo[] {
134 return Object.values(marks)
135 .filter(m => m.open && sameAgent(m.agentId, agentId) && !toasted.includes(m.id))
136 .sort((a, b) => a.seenAt - b.seenAt)
137}
138
139/** 세션 안에 열린 mark 가 하나라도 있나 (갈래 상관없이) */
140export function hasOpenMark(marks: Readonly<Record<string, MarkInfo>>): boolean {
141 return Object.values(marks).some(m => m.open)
142}
143
144/** /steps status 글 */
145export type StatusInput = {
146 mode: Mode
147 interactive: boolean
148 hasPlan: boolean
149 planSessionOk: boolean
150 allDone: boolean
151 allowed: boolean
152 warned: boolean
153 noMarkWarned: boolean
154}
155
156export function statusText(s: StatusInput): string {
157 const plan = s.hasPlan
158 ? s.planSessionOk
159 ? s.allDone ? '있음 (소단계 전부 끝 — 「없음」으로 본다)' : '있음 (세션 id 같음)'
160 : '있음 (세션 id 다름 — 「없음」으로 본다)'
161 : '없음'
162 return [
163 `mode : ${s.mode}`,
164 `대화형 : ${s.interactive ? '예' : '아니오 (강제 안 함)'}`,
165 `plan : ${plan}`,
166 `허용 중 : ${s.allowed ? '예 (/steps allow — 다음 사람 말 한 번, 그 턴 끝까지)' : '아니오'}`,
167 `이번 턴 경고 : 계획 없음 ${s.warned ? 1 : 0} · 열린 mark 없음 ${s.noMarkWarned ? 1 : 0}`,
168 ].join('\n')
169}
170hooks/pair.ts 163 lines1// mark 이름 ↔ 소단계 번호 짝짓기. 순수 함수만 둔다.
2import type { MarkInfo, Plan, Step, StepStatus } from '../types'
3import type { MarkLine } from './parse'
4import { minutesText } from './parse'
5
6/** 계획보다 이만큼 먼저 열린 mark 까지는 그 계획 것으로 본다 (plan 을 start 바로 뒤에 부른 경우). */
7export const PLAN_SLACK_MS = 60_000
8/**
9 * 이름 짝의 시각 하한 : 계획보다 이만큼 먼저 연 mark 까지만 이름으로 붙인다.
10 * 없으면 같은 세션의 옛 작업 mark(같은 소단계 이름)가 새 계획에 붙는다.
11 */
12export const NAME_SLACK_MS = 10 * 60_000
13
14/**
15 * 이름 앞 번호. 「3-검사기」 → 3, 「4a-리뷰」 → 4, 「2A-조사」 → 2.
16 * 번호로 시작하지 않으면 null (짝 없는 mark 로 남는다).
17 */
18export function stepNumber(name: string): number | null {
19 const m = /^(\d{1,3})[A-Za-z]?(?:[-_.·: ]|$)/.exec(name.trim())
20 return m ? Number(m[1]) : null
21}
22
23/** 이름 비교용 : 앞 번호(「5-」 「4a.」)와 빈칸을 빼고 소문자로 */
24function bareName(s: string): string {
25 return s.trim().replace(/^\d{1,3}[A-Za-z]?(?:[-_.·: ]+|$)/, '').replace(/\s+/g, '').toLowerCase()
26}
27
28/**
29 * 이 mark 를 계획의 몇 번 소단계에 붙일지. 붙일 곳이 없으면 null.
30 * ① 번호가 계획에 있고 계획 시각 − 60초 뒤에 열었으면 붙인다.
31 * ② 그보다 먼저 열었어도 번호 뒤 이름이 그 소단계 이름과 같으면 붙인다 (「5-패널 손질」 ↔ 5번 「패널 손질」).
32 * 계획을 나중에 다시 넣거나(소단계를 더함) 리로드로 상태를 잃은 뒤 marks.txt 에서 다시 주울 때 쓰인다.
33 * 번호만 같고 이름이 다른 옛 mark(앞 계획의 다른 일)는 붙이지 않는다.
34 * 단 시각 하한이 있다 : 계획 − 10분, 이전 계획이 있으면(prevAt) 그 계획 − 10분까지 넓힌다 (nameFloor).
35 */
36export function pairOf(plan: Plan | null, name: string, startedAt: number): number | null {
37 if (plan === null) return null
38 const n = stepNumber(name)
39 if (n === null) return null
40 const step = plan.steps.find(s => s.n === n)
41 if (step === undefined) return null
42 if (startedAt >= plan.createdAt - PLAN_SLACK_MS) return n
43 if (startedAt < nameFloor(plan)) return null
44 return sameName(name, step.name) ? n : null
45}
46
47/** 이름 짝의 시각 하한 : 계획 − 10분. 이전 계획이 있으면 이전 계획 − 10분까지 (둘 중 이른 쪽) */
48export function nameFloor(plan: Plan): number {
49 const base = plan.createdAt - NAME_SLACK_MS
50 return plan.prevAt === undefined ? base : Math.min(base, plan.prevAt - NAME_SLACK_MS)
51}
52
53/** mark 이름(번호 뗀 것)이 소단계 이름과 같은가 */
54function sameName(markName: string, stepName: string): boolean {
55 return bareName(markName) !== '' && bareName(markName) === bareName(stepName)
56}
57
58/**
59 * 시각은 보지 않고 번호만으로 짝짓는다. plan 을 다시 불러 계획을 갈아 끼울 때,
60 * 옛 계획에 이미 붙어 있던 mark 를 새 계획에 다시 붙이는 데 쓴다.
61 */
62export function pairByNumber(plan: Plan, name: string): number | null {
63 const n = stepNumber(name)
64 return n !== null && plan.steps.some(s => s.n === n) ? n : null
65}
66
67/**
68 * plan 을 다시 불러 계획을 갈아 끼울 때, marks 상태의 mark 하나를 새 계획 몇 번에 붙일지.
69 * 옛 계획 시절에 본 mark(옛 계획에 붙어 있던 것, 또는 옛 계획 짝 문턱 뒤에 본 것)는 열린 것·닫힌 것 모두
70 * 시각을 보지 않고 번호로 짝짓는다 — **옛·새 계획에서 그 번호의 소단계 이름이 같을 때만**.
71 * 번호가 같아도 이름이 바뀌었으면 다른 일이라 번호로는 안 붙인다 (그 mark 는 이미 잰 옛 일의 값이다).
72 * 옛 계획에 그 번호가 아예 없었으면(짝 없이 남은 mark) 번호로 붙인다 — 소단계를 더해 다시 부른 경우다.
73 * 단 새 계획이 옛 계획을 이어 쓴 것(같은 번호·같은 이름 소단계가 하나라도 있음)일 때만이다.
74 * 다른 일로 통째 갈아 끼운 계획에 옛 계획 밖 번호의 mark(앞 일의 「7-…」)를 붙이면 안 된다 (2026-10-10 실물 버그).
75 * 그 밖(이름이 바뀐 번호 · 이어 쓰지 않은 계획 · 옛 계획이 없음 · 그보다 먼저 연 mark)은
76 * 처음 계획 때와 같게 pairOf 로 본다 (짝 문턱 · 이름 짝 + 시각 하한).
77 */
78export function pairOnReplan(old: Plan | null, plan: Plan, m: MarkInfo): number | null {
79 if (old !== null) {
80 const wasAttached = old.steps.some(s => s.markIds.includes(m.id))
81 const n = pairByNumber(plan, m.name)
82 const before = n === null ? undefined : old.steps.find(s => s.n === n)
83 const now = n === null ? undefined : plan.steps.find(s => s.n === n)
84 const kept = now !== undefined &&
85 (before === undefined ? continues(old, plan) : bareName(before.name) === bareName(now.name))
86 if (kept && (wasAttached || m.seenAt >= old.createdAt - PLAN_SLACK_MS)) return n
87 }
88 return pairOf(plan, m.name, m.seenAt)
89}
90
91/** 새 계획이 옛 계획을 이어 쓴 것인가 : 같은 번호에 같은 이름인 소단계가 하나라도 있다 */
92function continues(old: Plan, plan: Plan): boolean {
93 return plan.steps.some(s => old.steps.some(o => o.n === s.n && bareName(o.name) === bareName(s.name)))
94}
95
96/** mark 하나를 계획에 붙인 새 계획. 이미 붙어 있으면 그대로. */
97export function attach(plan: Plan, n: number, markId: string): Plan {
98 return {
99 ...plan,
100 steps: plan.steps.map(s =>
101 s.n === n && !s.markIds.includes(markId) ? { ...s, markIds: [...s.markIds, markId] } : s,
102 ),
103 }
104}
105
106/** 소단계의 실제 값. mark 하나면 그 원문, 여럿이면 합을 effort 글꼴로. */
107export function stepActual(step: Step, marks: Record<string, MarkInfo>): {
108 text: string
109 ms: number | null
110 isOpen: boolean
111 isUnsure: boolean
112} {
113 const list = step.markIds.map(id => marks[id]).filter((m): m is MarkInfo => m !== undefined)
114 if (list.length === 0) return { text: '—', ms: null, isOpen: false, isUnsure: false }
115 const isOpen = list.some(m => m.open)
116 const isUnsure = list.some(m => m.error !== null)
117 const known = list.filter(m => m.recordMs !== null)
118 const ms = known.length === 0 ? null : known.reduce((a, m) => a + (m.recordMs ?? 0), 0)
119 let text: string
120 if (list.length === 1) text = list[0]!.recordText ?? '—'
121 else text = ms === null ? '—' : minutesText(ms)
122 return { text, ms, isOpen, isUnsure }
123}
124
125/** mark 상태로 소단계 상태를 다시 셈한다. 건너뜀은 손으로 정한 것이라 그대로 둔다. */
126export function statusOf(step: Step, marks: Record<string, MarkInfo>): StepStatus {
127 if (step.status === '건너뜀') return step.status
128 const list = step.markIds.map(id => marks[id]).filter((m): m is MarkInfo => m !== undefined)
129 if (list.length === 0) return step.status
130 return list.some(m => m.open) ? '하는 중' : '끝'
131}
132
133/** 모든 소단계 상태를 marks 에 맞춘다. */
134export function settle(plan: Plan, marks: Record<string, MarkInfo>): Plan {
135 return { ...plan, steps: plan.steps.map(s => ({ ...s, status: statusOf(s, marks) })) }
136}
137
138/**
139 * marks.txt 보조 길 : 이 세션(갈래 포함)이 열었고, pairOf 로 짝이 맞고(계획 뒤에 시작했거나 이름이 같고),
140 * 아직 모르는 mark 를 고른다. 묶기 실패(file 「-」) 줄은 어느 세션 것인지 몰라 건너뛴다.
141 */
142export function pickFromMarksFile(
143 lines: readonly MarkLine[],
144 sessionId: string,
145 plan: Plan,
146 known: Record<string, MarkInfo>,
147): MarkLine[] {
148 return lines.filter(l =>
149 known[l.id] === undefined &&
150 (l.file === sessionId || l.file.startsWith(`${sessionId}/`)) &&
151 pairOf(plan, l.name, l.at) !== null,
152 )
153}
154
155/** 새로 본 mark 의 처음 값 */
156export function newMark(id: string, name: string, n: number | null, agentId: string | null, now: number): MarkInfo {
157 return {
158 id, name, n, agentId, open: true,
159 recordText: null, recordMs: null, waitText: null, flags: [], warn: null,
160 error: null, fails: 0, same: 0, seenAt: now, lastAt: now,
161 }
162}
163hooks/parse.ts 200 lines1// effort.exe 출력 파싱. 순수 함수만 둔다 ($ 를 받지 않는다).
2// 출력 꼴의 근거 : EffortTool/cmd/effort/cmd_mark.go (start 127행, printMarkValue 746행),
3// internal/render/dur.go (Minutes), internal/store/marks.go (startLine).
4
5const MARK_ID = 'm\\d{4}-[0-9a-f]{4,8}'
6const SHOW_HEAD = new RegExp(`^mark (${MARK_ID}) · (.+)$`)
7const START_HEAD = new RegExp(`^mark (${MARK_ID}) 시작 : (.+) \\(\\d{4}-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d\\)$`)
8const RECORD = /^기록 구간\s*:\s*(.+?)(?:\s{2,}←.*)?$/
9const WAIT = /^대기\s*:\s*(.+?)(?:\s{2,}\(.*)?$/
10const FLAGS = /^표시\s*:\s*(.+)$/
11const END = /^끝\s*:\s*(.+)$/
12const WARN = /^주의\s*:\s*(.+)$/
13const STARTED = /^시작\s*:\s*(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d) ([+-]\d\d:\d\d)$/
14/** mark 이름 길이 상한 (바깥 출력·파일에서 온 글이라 다듬어 둔다) */
15const MARK_NAME_MAX = 80
16const FAILED = /^기록 구간을 못 쟀습니다(?: — 까닭 : (.+?))?\./
17
18/** mark start 출력 한 덩이 */
19export type StartBlock = { kind: 'start'; id: string; name: string; warn: string | null }
20
21/** mark show·stop 출력 한 덩이 (둘은 같은 꼴이다) */
22export type ShowBlock = {
23 kind: 'show'
24 id: string
25 name: string
26 /** 시작 줄의 시각 (ms). 못 읽으면 null */
27 startAt: number | null
28 /** 끝 줄이 「(지금 — 진행중)」 이거나 표시에 진행중이 있으면 true */
29 open: boolean
30 recordText: string | null
31 recordMs: number | null
32 waitText: string | null
33 flags: string[]
34 warn: string | null
35 /** 「기록 구간을 못 쟀습니다」 줄이 있으면 그 까닭 (없으면 빈 글) */
36 failReason: string | null
37}
38
39export type Block = StartBlock | ShowBlock
40
41/** 밀리초를 effort 와 같은 글로 바꾼다 (render.Minutes 와 같은 반올림). */
42export function minutesText(ms: number | null): string {
43 if (ms === null || ms <= 0) return '—'
44 if (ms < 60_000) return '1분 미만'
45 const min = Math.floor((ms + 30_000) / 60_000)
46 if (min < 60) return `${min}분`
47 const h = Math.floor(min / 60)
48 const m = min % 60
49 return m === 0 ? `${h}시간` : `${h}시간 ${m}분`
50}
51
52/**
53 * 「3분 (2분 54초)」 같은 구간 글을 밀리초로 바꾼다. 합계용이다.
54 * 괄호 안 분·초가 있으면 그것을 쓰고, 「1분 미만」 은 30초, 「—」 는 null.
55 */
56export function textToMs(text: string | null): number | null {
57 if (text === null) return null
58 const t = text.trim()
59 if (t === '' || t.startsWith('—')) return null
60 const paren = /\((?:(\d+)분\s*)?(?:(\d+)초)?\)/.exec(t)
61 if (paren && (paren[1] !== undefined || paren[2] !== undefined)) {
62 return (Number(paren[1] ?? 0) * 60 + Number(paren[2] ?? 0)) * 1000
63 }
64 if (t.startsWith('1분 미만')) return 30_000
65 const h = /(\d+)시간/.exec(t)
66 const m = /(\d+)분/.exec(t)
67 if (!h && !m) return null
68 return (Number(h?.[1] ?? 0) * 60 + Number(m?.[1] ?? 0)) * 60_000
69}
70
71/** 출력 전체를 덩이로 나눈다. stop 뒤 start 를 한 번에 친 출력도 차례대로 나온다. */
72export function parseBlocks(text: string): Block[] {
73 const blocks: Block[] = []
74 let cur: Block | null = null
75 for (const raw of text.split(/\r?\n/)) {
76 const line = raw.trim()
77 if (line === '') continue
78 const start = START_HEAD.exec(line)
79 if (start) {
80 cur = { kind: 'start', id: start[1]!, name: clean(start[2], MARK_NAME_MAX), warn: null }
81 blocks.push(cur)
82 continue
83 }
84 const head = SHOW_HEAD.exec(line)
85 if (head) {
86 cur = {
87 kind: 'show', id: head[1]!, name: clean(head[2], MARK_NAME_MAX), startAt: null, open: false,
88 recordText: null, recordMs: null, waitText: null, flags: [], warn: null, failReason: null,
89 }
90 blocks.push(cur)
91 continue
92 }
93 if (cur === null) continue
94 const warn = WARN.exec(line)
95 if (warn) {
96 cur.warn = cur.warn ?? warn[1]!
97 continue
98 }
99 if (cur.kind !== 'show') continue
100 let m: RegExpExecArray | null
101 if ((m = RECORD.exec(line))) {
102 cur.recordText = m[1]!
103 cur.recordMs = textToMs(m[1]!)
104 } else if ((m = STARTED.exec(line))) {
105 const at = Date.parse(`${m[1]}T${m[2]}${m[3]}`)
106 cur.startAt = Number.isNaN(at) ? null : at
107 } else if ((m = WAIT.exec(line))) {
108 cur.waitText = m[1]!
109 } else if ((m = FLAGS.exec(line))) {
110 cur.flags = m[1]!.split('·').map(s => s.trim()).filter(s => s !== '')
111 if (cur.flags.includes('진행중')) cur.open = true
112 } else if ((m = END.exec(line))) {
113 if (m[1]!.includes('진행중')) cur.open = true
114 } else if ((m = FAILED.exec(line))) {
115 cur.failReason = m[1] ?? ''
116 }
117 }
118 return blocks
119}
120
121/** estimate --json 결과에서 소단계 이름(번호) → 중앙값 ms. 꼴이 틀리면 빈 Map. */
122export function parseEstimate(json: string): Map<string, number> {
123 const out = new Map<string, number>()
124 let data: unknown
125 try {
126 data = JSON.parse(json)
127 } catch {
128 return out
129 }
130 if (!Array.isArray(data)) return out
131 for (const row of data) {
132 if (row === null || typeof row !== 'object') continue
133 const r = row as Record<string, unknown>
134 if (typeof r.Name === 'string' && typeof r.P50Ms === 'number' && r.P50Ms > 0) {
135 out.set(r.Name, r.P50Ms)
136 }
137 }
138 return out
139}
140
141/** 명령 글 속 effort mark 호출 하나 */
142export type MarkCall = { verb: 'start' | 'stop' | 'show'; arg: string | null }
143
144/**
145 * Bash·PowerShell 명령 글에서 effort mark 호출을 차례대로 뽑는다.
146 * 경로 붙은 exe(.\EffortTool\bin\effort.exe)도 잡는다. effort 낱말이 없으면 빈 목록.
147 */
148export function parseCommand(command: string): MarkCall[] {
149 if (!/effort/i.test(command)) return []
150 const out: MarkCall[] = []
151 const re = /\bmark\s+(start|stop|show)\b((?:\s+--\S+(?:\s+[^\s"'-]\S*)?)*)(?:\s+(?:"([^"\r\n]*)"|'([^'\r\n]*)'|([^\s"';|&]+)))?/g
152 let m: RegExpExecArray | null
153 while ((m = re.exec(command)) !== null) {
154 const arg = m[3] ?? m[4] ?? m[5] ?? null
155 out.push({ verb: m[1] as MarkCall['verb'], arg: arg === null ? null : arg.trim() })
156 }
157 return out
158}
159
160/** marks.txt 의 start 줄 하나 (stop 이 있으면 stopAt 채움) */
161export type MarkLine = { id: string; at: number; file: string; name: string; stopAt: number | null }
162
163/**
164 * ~/.effort/marks.txt 를 읽는다. 줄 꼴 : start\tid\t시각\tslug\tfile\tname[\tbind] · stop\tid\t시각 · bind\tid\t시각\tslug\tfile.
165 * 모르는 줄은 건너뛴다 (EffortTool 도 그렇게 한다).
166 */
167export function parseMarksFile(text: string): MarkLine[] {
168 const byId = new Map<string, MarkLine>()
169 for (const raw of text.split(/\r?\n/)) {
170 if (raw.startsWith('#') || raw.trim() === '') continue
171 const f = raw.split('\t')
172 if (f[0] === 'start' && f.length >= 6) {
173 const at = Date.parse(f[2]!)
174 if (Number.isNaN(at)) continue
175 byId.set(f[1]!, { id: f[1]!, at, file: f[4]!, name: clean(f[5], MARK_NAME_MAX), stopAt: null })
176 } else if (f[0] === 'stop' && f.length >= 3) {
177 const one = byId.get(f[1]!)
178 const at = Date.parse(f[2]!)
179 if (one && !Number.isNaN(at)) one.stopAt = at
180 } else if (f[0] === 'bind' && f.length >= 5 && f[3] !== '-' && f[4] !== '-') {
181 // 늦게 묶인 mark 는 start 줄 파일 칸이 '-' 다 — 첫 bind 가 진짜 파일이다 (EffortTool applyBind 와 같다).
182 const one = byId.get(f[1]!)
183 if (one && (one.file === '-' || one.file === '')) one.file = f[4]!
184 }
185 }
186 return [...byId.values()]
187}
188
189/** 바깥에서 온 글을 줄 하나로 다듬는다 : 제어 글자 빼기, 앞뒤 공백, 길이 자르기. */
190export function clean(text: unknown, max: number): string {
191 const s = typeof text === 'string' ? text : String(text ?? '')
192 // eslint-disable-next-line no-control-regex
193 return s.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max)
194}
195
196/** 출력의 첫 줄 (실패 알림용) */
197export function firstLine(text: string): string {
198 return clean(text.split(/\r?\n/).find(l => l.trim() !== '') ?? '', 120)
199}
200hooks/state.ts 30 lines1// 설정 값과 이름. $.state 참조(atom)는 엔진 검사 때문에 main.tsx 에 있다 (참조는 쓰는 파일 안에 있어야 한다).
2
3import { readMode } from './gate'
4import type { Mode } from './gate'
5
6export const PLUGIN = 'effort-steps'
7export const PANE = 'effort-steps'
8
9/** 설정 값 (userConfig). register 의 options 에서 한 번 읽는다. */
10export type Settings = {
11 effortPath: string
12 refreshMs: number
13 redRatio: number
14 autoPane: boolean
15 /** plan 없는 편집·Agent 를 어떻게 다루나 : deny(막음) · warn(경고만) · off(안 봄). 모르는 값은 deny */
16 mode: Mode
17}
18
19export function readSettings(options: Readonly<Record<string, unknown>>): Settings {
20 const sec = typeof options.refreshSec === 'number' && Number.isFinite(options.refreshSec) ? options.refreshSec : 30
21 const ratio = typeof options.redRatio === 'number' && options.redRatio > 1 ? options.redRatio : 1.5
22 return {
23 effortPath: typeof options.effortPath === 'string' ? options.effortPath : '',
24 refreshMs: Math.max(10, Math.min(600, sec)) * 1000,
25 redRatio: ratio,
26 autoPane: options.autoPane === true,
27 mode: readMode(options.mode),
28 }
29}
30hooks/table.ts 89 lines1// 마크다운 공수 표. 순수 함수만 둔다.
2import type { MarkInfo, Plan } from '../types'
3import { stepActual } from './pair'
4import { minutesText } from './parse'
5
6/** 실제/예상 비. 둘 중 하나라도 없으면 null. */
7export function ratioOf(estMs: number | null, actualMs: number | null): number | null {
8 if (estMs === null || actualMs === null || estMs <= 0) return null
9 return actualMs / estMs
10}
11
12/** 어긋남 한 마디. 문턱 안이면 null. */
13export function driftText(ratio: number | null, redRatio: number): string | null {
14 if (ratio === null) return null
15 if (ratio > redRatio) return `예상의 ${ratio.toFixed(1)}배`
16 if (ratio < 1 / redRatio) return `예상의 ${Math.round(ratio * 100)}%`
17 return null
18}
19
20/** 표 칸에 넣을 글 : 세로줄이 표를 깨지 않게 바꾼다. */
21function cell(text: string): string {
22 return text.replace(/\|/g, '|')
23}
24
25/** 합계 : 예상 합과 끝난·하는 중 실제 합. */
26export function totals(plan: Plan, marks: Record<string, MarkInfo>): {
27 estMs: number | null
28 actualMs: number | null
29 done: number
30 hasOpen: boolean
31} {
32 let est = 0
33 let hasEst = false
34 let act = 0
35 let hasAct = false
36 let done = 0
37 let hasOpen = false
38 for (const s of plan.steps) {
39 if (s.estMs !== null) {
40 est += s.estMs
41 hasEst = true
42 }
43 const a = stepActual(s, marks)
44 if (a.ms !== null) {
45 act += a.ms
46 hasAct = true
47 }
48 if (a.isOpen) hasOpen = true
49 if (s.status === '끝' || s.status === '건너뜀') done += 1
50 }
51 return { estMs: hasEst ? est : null, actualMs: hasAct ? act : null, done, hasOpen }
52}
53
54/**
55 * 「소단계 / 예상 / 실제 / 상태」 표. 어긋난 줄이나 까닭이 있는 줄이 있으면 「까닭」 칸을 더한다.
56 * 끝에 어긋난 까닭은 mem history 로 남기라는 한 줄을 붙인다 (설계 11절).
57 */
58export function markdownTable(plan: Plan, marks: Record<string, MarkInfo>, redRatio: number): string {
59 const rows = plan.steps.map(s => {
60 const a = stepActual(s, marks)
61 const drift = a.isOpen ? null : driftText(ratioOf(s.estMs, a.ms), redRatio)
62 const why = [s.note, drift].filter((x): x is string => x !== null && x !== '').join(' · ')
63 return {
64 name: `${s.n}. ${s.name}`,
65 est: s.estText,
66 act: a.text + (a.isUnsure ? ' ?' : ''),
67 status: s.status,
68 why,
69 }
70 })
71 const hasWhy = rows.some(r => r.why !== '')
72 const head = hasWhy ? ['소단계', '예상', '실제', '상태', '까닭'] : ['소단계', '예상', '실제', '상태']
73 const lines = [`| ${head.join(' | ')} |`, `| ${head.map(() => '---').join(' | ')} |`]
74 for (const r of rows) {
75 const cols = [r.name, r.est, r.act, r.status]
76 if (hasWhy) cols.push(r.why)
77 lines.push(`| ${cols.map(cell).join(' | ')} |`)
78 }
79 const t = totals(plan, marks)
80 const sum = ['합계', minutesText(t.estMs), minutesText(t.actualMs) + (t.hasOpen ? ' (+하는 중)' : ''), `${t.done}/${plan.steps.length} 끝`]
81 if (hasWhy) sum.push('')
82 lines.push(`| ${sum.map(cell).join(' | ')} |`)
83
84 const out = [`**${cell(plan.title)}**`, '', ...lines, '']
85 if (plan.estNote !== null) out.push(`예상 : ${plan.estNote}`)
86 out.push('실제 칸은 effort mark 의 「기록 구간」 그대로다. 어긋난 까닭은 `mem add --type history` 로 남긴다.')
87 return out.join('\n')
88}
89hooks/view.tsx 571 lines1// 띠(AbovePrompt)와 패널(Pane) 그림. 순수 함수다 : 상태 읽기·$.ui.resolve 는 main.tsx 가 하고
2// 여기에는 읽은 값과 요소 표만 넘어온다 (엔진 검사가 $ 를 import 너머로 넘기지 못하게 한다).
3import type { Color, Elements, RenderElement } from 'claude-code'
4
5import type { ExeInfo, MarkInfo, Plan, Step, StepStatus, UiPrefs } from '../types'
6import { stepActual } from './pair'
7import { minutesText, textToMs } from './parse'
8import { markdownTable, ratioOf, totals } from './table'
9
10/** 그릴 때 쓰는 요소 표 (어느 면이든 이 넷은 있다) */
11export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Markdown'>
12
13/** 그릴 때 읽은 값 */
14export type ViewData = {
15 plan: Plan | null
16 marks: Record<string, MarkInfo>
17 exe: ExeInfo
18 paused: boolean
19 ui: UiPrefs
20 now: number
21 redRatio: number
22}
23
24/** belowRows : 아래에 포갤 다른 플러그인 띠가 차지할 줄 수 (없으면 0) */
25export type BandSize = { bodyColumns: number; maxRows: number; isWorking: boolean; belowRows?: number }
26
27const MAX_BAND_ROWS = 4
28/** 띠 이름 열의 최대 폭 (칸). 넘는 이름은 끝을 「…」 로 자른다 */
29const NAME_MAX = 20
30/** 띠 소단계 줄에서 이름 열 말고 나머지가 대략 차지하는 칸 (좁은 폭에서 이름 열을 줄일 때 쓴다) */
31const LINE_REST = 26
32
33/**
34 * 그린 트리가 차지하는 줄 수. 아래에 포갤 다른 플러그인 띠의 몫을 셀 때 쓴다.
35 * 세로 Box 는 자식 합, 가로 Box(기본)는 자식 최댓값, Text 는 줄바꿈 수 + 1, 엔진 자리는 0.
36 * Box 는 여기에 테두리(borderStyle 이 있으면 위아래 +2)와 위아래 padding(paddingTop/Bottom > paddingY > padding)을 더한다.
37 * 줄 넘김(wrap)은 폭을 몰라 세지 않는다 — 긴 글도 1줄로 센다. 띠 줄은 모두 한 줄로 자르는(truncate) 것을 전제한다.
38 */
39export function countRows(node: unknown): number {
40 if (node === null || node === undefined || typeof node === 'boolean') return 0
41 if (typeof node === 'string' || typeof node === 'number') return String(node).split('\n').length
42 if (Array.isArray(node)) return node.reduce((sum: number, k: unknown) => sum + countRows(k), 0)
43 if (typeof node !== 'object') return 0
44 const el = node as { type?: unknown; props?: Record<string, unknown>; children?: unknown }
45 const kids: unknown[] = Array.isArray(el.children) ? el.children : []
46 if (el.type === 'Text') return newlines(kids) + 1
47 if (el.type === 'Box') {
48 const counts = kids.map(countRows)
49 const isColumn = el.props?.flexDirection === 'column' || el.props?.flexDirection === 'column-reverse'
50 const inner = isColumn ? counts.reduce((a, b) => a + b, 0) : counts.length === 0 ? 0 : Math.max(...counts)
51 return inner + frameRows(el.props ?? {})
52 }
53 // 엔진 자리('engine')·버튼 같은 나머지는 띠 줄로 세지 않는다
54 return 0
55}
56
57/** Box 의 테두리·위아래 padding 이 더하는 줄 수 */
58function frameRows(p: Record<string, unknown>): number {
59 const num = (v: unknown): number | undefined => (typeof v === 'number' ? Math.max(0, v) : undefined)
60 const all = num(p.padding) ?? 0
61 const y = num(p.paddingY) ?? all
62 const top = num(p.paddingTop) ?? y
63 const bottom = num(p.paddingBottom) ?? y
64 const border = typeof p.borderStyle === 'string' && p.borderStyle !== '' ? 2 : 0
65 return top + bottom + border
66}
67
68/** Text 안 글(안쪽 Text 포함)의 줄바꿈 수 */
69function newlines(kids: readonly unknown[]): number {
70 let n = 0
71 for (const k of kids) {
72 if (typeof k === 'string') n += k.split('\n').length - 1
73 else if (k !== null && typeof k === 'object' && Array.isArray((k as { children?: unknown }).children)) {
74 n += newlines((k as { children: unknown[] }).children)
75 }
76 }
77 return n
78}
79
80/** 터미널 칸 수 (한글·CJK·전각은 2칸). 띠의 열을 맞출 때 쓴다. */
81export function cellWidth(s: string): number {
82 let w = 0
83 for (const ch of s) {
84 const c = ch.codePointAt(0) ?? 0
85 const wide = (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
86 (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)
87 w += wide ? 2 : 1
88 }
89 return w
90}
91
92/** w 칸 안에 들게 자른다. 넘치면 끝 한 칸을 「…」 로 바꾼다. */
93export function fitCells(s: string, w: number): string {
94 if (cellWidth(s) <= w) return s
95 let out = ''
96 let used = 0
97 for (const ch of s) {
98 const cw = cellWidth(ch)
99 if (used + cw > w - 1) break
100 out += ch
101 used += cw
102 }
103 return out + '…'
104}
105
106/** 오른쪽에 빈칸을 채워 w 칸으로 (한글 2칸 셈) */
107export function padCellsEnd(s: string, w: number): string {
108 return s + ' '.repeat(Math.max(0, w - cellWidth(s)))
109}
110
111/** 왼쪽에 빈칸을 채워 w 칸으로 (한글 2칸 셈) */
112export function padCellsStart(s: string, w: number): string {
113 return ' '.repeat(Math.max(0, w - cellWidth(s))) + s
114}
115
116const MARK_OF: Record<StepStatus, string> = { '끝': '✓', '하는 중': '▶', '할 일': '·', '건너뜀': '–' }
117/** 띠 접은 줄에 세는 차례 */
118const FOLD_ORDER: readonly StepStatus[] = ['끝', '하는 중', '할 일', '건너뜀']
119
120/** 패널 한 칸의 색. 끝은 흐리게, 하는 중은 강조, 어긋남은 노랑, 문턱 넘으면 빨강. */
121function tone(step: Step, marks: Record<string, MarkInfo>, redRatio: number): { color?: Color; dimColor?: boolean; bold?: boolean } {
122 const a = stepActual(step, marks)
123 const ratio = ratioOf(step.estMs, a.ms)
124 if (ratio !== null && ratio > redRatio) return { color: 'error', bold: step.status === '하는 중' }
125 const drift = step.markIds.some(id => marks[id]?.flags.includes('어긋남')) || a.isUnsure
126 if (drift) return { color: 'warning' }
127 if (step.status === '하는 중') return { color: 'claude', bold: true }
128 if (step.status === '끝' || step.status === '건너뜀') return { dimColor: true }
129 return {}
130}
131
132function ago(now: number, at: number): string {
133 return `${agoShort(now, at)} 갱신`
134}
135
136/** 마지막 갱신이 얼마 전인가 : 「10초 전」 「3분 전」 */
137function agoShort(now: number, at: number): string {
138 const sec = Math.max(0, Math.round((now - at) / 1000))
139 return sec < 60 ? `${sec}초 전` : `${Math.round(sec / 60)}분 전`
140}
141
142/** 「3분 (2분 45초)」 → 「3분」 : 띠에는 괄호 속 초를 뺀 짧은 꼴만 쓴다 */
143function shortRecord(text: string): string {
144 return text.replace(/ \(.*\)$/, '')
145}
146
147/** 띠 소단계 한 줄에 쓸 값 */
148type StepCells = { step: Step; actual: string; isRed: boolean; tail: string; startedAt: number }
149
150function stepCells(step: Step, marks: Record<string, MarkInfo>, now: number, redRatio: number): StepCells {
151 const a = stepActual(step, marks)
152 const ratio = ratioOf(step.estMs, a.ms)
153 const open = step.markIds.map(id => marks[id]).filter((m): m is MarkInfo => m !== undefined && m.open)
154 let tail = ''
155 if (step.status === '하는 중' && open.length > 0) {
156 tail = ` · ${agoShort(now, Math.max(...open.map(m => m.lastAt)))}`
157 const waitMs = open.reduce((sum, m) => sum + (textToMs(m.waitText) ?? 0), 0)
158 if (waitMs >= 60_000) tail += ` · 대기 ${minutesText(waitMs)}`
159 }
160 return {
161 step,
162 actual: shortRecord(a.text) + (a.isUnsure ? '?' : ''),
163 isRed: ratio !== null && ratio > redRatio,
164 tail,
165 startedAt: startedAtOf(step, marks),
166 }
167}
168
169/** 소단계가 언제 시작했나 : 열린 mark 가운데 가장 늦게 본 시각. 열린 mark 가 없으면 -1 */
170function startedAtOf(step: Step, marks: Record<string, MarkInfo>): number {
171 const open = step.markIds.map(id => marks[id]).filter((m): m is MarkInfo => m !== undefined && m.open)
172 return open.length === 0 ? -1 : Math.max(...open.map(m => m.seenAt))
173}
174
175/** 하는 중이 여럿일 때 가장 최근에 시작한 것 하나 (같으면 뒤의 것). 띠와 패널 머리글이 같이 쓴다. */
176function latestStarted<T extends { startedAt: number }>(list: readonly T[]): T | undefined {
177 if (list.length === 0) return undefined
178 return list.reduce((a, c) => (c.startedAt >= a.startedAt ? c : a))
179}
180
181/**
182 * 띠에 낼 소단계 줄 고르기. budget 줄 안에 다 들면 전부.
183 * 넘치면 하는 중 줄만 두고 나머지는 접은 줄 하나로. 그래도 넘치면 하는 중은 가장 최근 것 하나만.
184 * fold 가 null 이면 접은 줄을 그리지 않는다.
185 */
186export function pickStepLines<T extends { step: Step; startedAt: number }>(cells: readonly T[], budget: number): { shown: T[]; fold: Step[] | null } {
187 if (cells.length <= budget) return { shown: [...cells], fold: null }
188 if (budget <= 0) return { shown: [], fold: null }
189 let keep = cells.filter(c => c.step.status === '하는 중')
190 const latest = latestStarted(keep)
191 if (keep.length + 1 > budget && latest !== undefined) keep = [latest]
192 const rest = cells.filter(c => !keep.includes(c)).map(c => c.step)
193 if (keep.length + 1 > budget) return { shown: keep, fold: null } // budget 1 : 하는 중 한 줄만
194 return { shown: keep, fold: rest }
195}
196
197/** 접은 줄 글 : 「✓ 2 끝 · · 5 할 일 · – 1 건너뜀」 */
198export function foldText(steps: readonly Step[]): string {
199 return FOLD_ORDER
200 .map(st => ({ st, n: steps.filter(s => s.status === st).length }))
201 .filter(x => x.n > 0)
202 .map(x => `${MARK_OF[x.st]} ${x.n} ${x.st}`)
203 .join(' · ')
204}
205
206/** 띠 소단계 한 줄 : 「 ▶ 13 코드리뷰 3분 / 8분 · 10초 전」 */
207function stepLine(els: Els, c: StepCells, w: { num: number; name: number; act: number }): RenderElement {
208 const { Text } = els
209 const s = c.step
210 const isActive = s.status === '하는 중'
211 const isDim = s.status === '할 일' || s.status === '건너뜀'
212 const markColor: { color?: Color } = s.status === '끝' ? { color: 'success' } : isActive ? { color: 'warning' } : {}
213 const name = padCellsEnd(fitCells(s.name, w.name), w.name)
214 return (
215 <Text key={`s${s.n}`} wrap="truncate-end" {...(isActive ? { bold: true } : {})} {...(isDim ? { dimColor: true } : {})}>
216 <Text {...markColor}>{` ${MARK_OF[s.status]}`}</Text>
217 {` ${padCellsStart(String(s.n), w.num)} ${name} `}
218 <Text {...(c.isRed ? { color: 'error' as Color } : {})}>{padCellsStart(c.actual, w.act)}</Text>
219 {` / ${s.estText}${c.tail}`}
220 </Text>
221 )
222}
223
224/** 열린 mark 한 줄 (계획 없음 · 짝 없는 mark) : 「 ▶ 이름 3분 · 10초 전」 */
225function markLine(els: Els, m: MarkInfo, nameW: number, now: number): RenderElement {
226 const { Text } = els
227 const rec = m.recordText === null ? '재는 중' : shortRecord(m.recordText)
228 return (
229 <Text key={`live-${m.id}`} wrap="truncate-end" bold>
230 <Text color="warning"> ▶</Text>
231 {` ${padCellsEnd(fitCells(m.name, nameW), nameW)} ${rec} · ${agoShort(now, m.lastAt)}`}
232 </Text>
233 )
234}
235
236/** 띠. 그릴 것이 없으면 null (엔진 몫으로 넘긴다). */
237export function bandTree(els: Els, d: ViewData, size: BandSize): RenderElement | null {
238 const { plan, marks, exe, paused, ui, now, redRatio } = d
239 if (ui.bandHidden) return null
240 const open = Object.values(marks).filter(m => m.open)
241 if (plan === null && open.length === 0) return null
242
243 const { Box, Text } = els
244 const cols = size.bodyColumns
245 // 아래 띠를 포갤 때는 그 몫(줄)을 먼저 뺀다. 줄은 모두 한 줄 높이라 줄 수만 센다.
246 const room = Math.max(1, Math.min(MAX_BAND_ROWS, size.maxRows) - (size.belowRows ?? 0))
247 const warns: RenderElement[] = []
248 if (exe.path === null && exe.why !== null) warns.push(<Text key="exe" color="warning" wrap="truncate-end">{exe.why}</Text>)
249 if (paused) warns.push(<Text key="paused" color="warning" wrap="truncate-end">갱신 멈춤 — /steps refresh 로 다시 켠다</Text>)
250 // 머리 줄과 경고 줄을 뺀 나머지가 소단계 줄 몫이다
251 const budget = Math.max(0, room - 1 - warns.length)
252 const nameCap = Math.max(4, Math.min(NAME_MAX, cols - LINE_REST))
253 const rows: RenderElement[] = []
254
255 // 짝 없는 열린 mark 는 가장 최근 것부터 남는 줄에 넣는다
256 const markRows = (list: MarkInfo[], left: number) => {
257 const picked = [...list].sort((a, b) => a.seenAt - b.seenAt).slice(-Math.max(0, left))
258 if (picked.length === 0) return []
259 const nameW = Math.min(nameCap, Math.max(...picked.map(m => cellWidth(m.name))))
260 return picked.map(m => markLine(els, m, nameW, now))
261 }
262
263 if (plan === null) {
264 rows.push(<Text key="head" bold wrap="truncate-end">공수 · (계획 없음)</Text>)
265 rows.push(...markRows(open, budget))
266 } else {
267 const t = totals(plan, marks)
268 rows.push(
269 <Box key="head" flexDirection="row" justifyContent="space-between" width={cols}>
270 <Box flexShrink={1}>
271 <Text bold wrap="truncate-end">공수 · {plan.title}</Text>
272 </Box>
273 <Box flexShrink={0}>
274 <Text dimColor wrap="truncate">
275 {' '}{t.done}/{plan.steps.length} 끝 · 예상 {minutesText(t.estMs)} · 실제 {minutesText(t.actualMs)}
276 </Text>
277 </Box>
278 </Box>,
279 )
280 const cells = plan.steps.map(s => stepCells(s, marks, now, redRatio))
281 const pick = pickStepLines(cells, budget)
282 if (pick.shown.length > 0) {
283 const w = {
284 num: Math.max(...pick.shown.map(c => String(c.step.n).length)),
285 name: Math.min(nameCap, Math.max(...pick.shown.map(c => cellWidth(c.step.name)))),
286 act: Math.max(...pick.shown.map(c => cellWidth(c.actual))),
287 }
288 for (const c of pick.shown) rows.push(stepLine(els, c, w))
289 }
290 if (pick.fold !== null && pick.fold.length > 0) {
291 rows.push(<Text key="fold" dimColor wrap="truncate-end"> {foldText(pick.fold)}</Text>)
292 }
293 const used = rows.length - 1
294 rows.push(...markRows(open.filter(m => m.n === null), budget - used))
295 }
296
297 rows.push(...warns)
298 return <Box flexDirection="column">{rows.slice(0, room)}</Box>
299}
300
301/** 패널 표 바깥 테두리(Box borderStyle) 좌우 2칸 + 안쪽 여백(paddingX 1) 좌우 2칸 */
302export const PANE_FRAME = 4
303/** 칸 사이 구분자 : 넉넉하면 양옆 빈칸을 두고, 좁으면 세로줄만 */
304const SEP_WIDE = ' │ '
305const SEP_TIGHT = '│'
306/** 가로줄에서 구분자 자리에 놓는 글자 (구분자와 같은 폭) */
307const CROSS_OF: Record<string, string> = { [SEP_WIDE]: '─┼─', [SEP_TIGHT]: '┼' }
308
309/**
310 * 패널 표 모양 : 칸 너비(0 이면 그 칸을 뺀다)와 구분자. inner 는 테두리·여백 안쪽 폭이다.
311 * plain 이면 표가 안 들어가는 폭이라 칸·가로줄 없이 「이름 · 상태」 맨 줄로 그린다.
312 */
313export type PaneLayout = { inner: number; sep: string; name: number; cls: number; size: number; est: number; act: number; status: number; plain: boolean }
314
315/** 예상·실제 칸의 최소 폭 : 「1시간 16분」(10칸)이 잘리지 않는 폭 */
316const TIME_MIN = 10
317/** 실제 칸이 넉넉할 때의 폭 : 「3분 (2분 40초)」 같은 괄호 초까지 */
318const ACT_WIDE = 16
319/** 상태 칸 : 「하는 중」(7칸) + 1 */
320const STATUS_W = 8
321/** 이름 칸이 이만큼은 되어야 실제 칸을 넓힌다 (이름 칸이 먼저) */
322const NAME_WANT = 24
323/** 넓은 구분자 「 │ 」 를 쓸 때 이름 칸의 최소 폭 */
324const MID_NAME_MIN = 16
325/** 좁은 표에서도 이름 칸이 이만큼은 있어야 한다. 모자라면 맨 줄로 (plain) */
326const NAME_MIN = 4
327/** 이 폭(패널 bodyColumns) 미만이면 분류·크기 칸을 뺀다 */
328const CLS_MIN_COLS = 100
329
330/**
331 * 패널 폭에 맞춰 표 모양을 고른다. 이름 칸이 먼저다.
332 * ① 100칸 이상이고 이름 칸이 넉넉하면 여섯 칸 + 「 │ 」 ② 분류·크기를 빼고 네 칸 + 「 │ 」
333 * ③ 네 칸 + 「│」. 예상·실제 칸은 TIME_MIN 을 지키고, 실제 칸은 이름 칸이 NAME_WANT 를 넘는 만큼만 넓힌다.
334 * ④ 그것도 안 들어가면(bodyColumns 39 미만) 표를 버리고 「이름 · 상태」 맨 줄 (plain).
335 * 표일 때 칸 너비 + 구분자 폭의 합은 언제나 inner 와 같다 (가로줄이 표 폭과 꼭 맞게).
336 */
337export function paneLayout(bodyColumns: number): PaneLayout {
338 const inner = Math.max(1, bodyColumns - PANE_FRAME)
339 const est = TIME_MIN
340 const status = STATUS_W
341 const sepW = cellWidth(SEP_WIDE)
342 // 이름 칸이 NAME_WANT 를 넘는 만큼만 실제 칸을 ACT_WIDE 까지 넓힌다
343 const actFor = (fixed: number) => Math.max(TIME_MIN, Math.min(ACT_WIDE, inner - fixed - NAME_WANT))
344 if (bodyColumns >= CLS_MIN_COLS) {
345 const full = est + ACT_WIDE + status + 4 + 4 + 5 * sepW
346 if (inner - full >= NAME_WANT) return { inner, sep: SEP_WIDE, name: inner - full, cls: 4, size: 4, est, act: ACT_WIDE, status, plain: false }
347 }
348 const midFixed = est + status + 3 * sepW
349 const midAct = actFor(midFixed)
350 // 「 │ 」 셋(6칸)을 「│」 로 줄이면 이름 칸이 6칸 는다. 이름이 MID_NAME_MIN 도 안 되면 좁은 구분자로 간다
351 if (inner - midFixed - midAct >= MID_NAME_MIN) return { inner, sep: SEP_WIDE, name: inner - midFixed - midAct, cls: 0, size: 0, est, act: midAct, status, plain: false }
352 const fixed = est + status + 3 * cellWidth(SEP_TIGHT)
353 const act = actFor(fixed)
354 const name = inner - fixed - act
355 if (name >= NAME_MIN) return { inner, sep: SEP_TIGHT, name, cls: 0, size: 0, est, act, status, plain: false }
356 // 칸을 다 줄여도 넘친다 : 맨 줄 (칸 너비는 쓰지 않는다)
357 return { inner, sep: SEP_TIGHT, name: inner, cls: 0, size: 0, est: 0, act: 0, status: 0, plain: true }
358}
359
360/** 가로줄 한 줄 : 칸마다 「─」, 구분자 자리에 「┼」. 폭은 inner 와 같다. */
361export function ruleText(l: PaneLayout): string {
362 const cross = CROSS_OF[l.sep] ?? '┼'
363 return [l.name, l.cls, l.size, l.est, l.act, l.status].filter(w => w > 0).map(w => '─'.repeat(w)).join(cross)
364}
365
366/**
367 * 패널 머리글 두 줄에 쓸 값. 줄 1 = head + body, 줄 2 = 막대 (찬 칸 filled · 전체 barWidth).
368 * 지금 단계는 하는 중 가운데 가장 최근 시작한 것, 없으면 첫 할 일. 둘 다 없으면 전부 끝(끝·건너뜀)이다.
369 * 번호는 소단계 번호(n)가 아니라 계획 안 차례(1부터)다 — 「3/7」 의 앞뒤가 같은 눈금이 되게.
370 * 줄 1 은 width 칸 안에 든다 : 이름 쪽을 먼저 자르고 「 · 예상 …」 은 남긴다.
371 */
372export type PaneHeader = { head: string; body: string; current: Step | null; filled: number; barWidth: number }
373
374export function paneHeader(plan: Plan, marks: Record<string, MarkInfo>, width: number): PaneHeader {
375 const total = plan.steps.length
376 const barWidth = Math.max(1, width)
377 const done = totals(plan, marks).done
378 const filled = total === 0 ? 0 : Math.min(barWidth, Math.round((done / total) * barWidth))
379 const active = plan.steps.filter(s => s.status === '하는 중').map(s => ({ step: s, startedAt: startedAtOf(s, marks) }))
380 const current = latestStarted(active)?.step ?? plan.steps.find(s => s.status === '할 일') ?? null
381 if (current === null) {
382 const head = fitCells(`✓ ${total}/${total}`, barWidth)
383 return { head, body: fitOrEmpty(' 모두 끝', barWidth - cellWidth(head)), current, filled, barWidth }
384 }
385 // 할 일을 고른 때도 「▶」 : 다음에 할 단계라는 뜻 (색은 paneTree 가 tone 으로 달리한다)
386 const head = fitCells(`▶ ${plan.steps.indexOf(current) + 1}/${total}`, barWidth)
387 const room = barWidth - cellWidth(head)
388 // 예상이 비었으면(— 는 estimate 전 자리) 「 · 예상 …」 를 뺀다
389 const hasEst = current.estText !== '' && current.estText !== '—'
390 const est = hasEst ? ` · 예상 ${current.estText}` : ''
391 const nameRoom = room - 2 - cellWidth(est)
392 // 이름 칸이 너무 좁으면(2칸 미만) 예상까지 한 덩어리로 자른다
393 const body = nameRoom >= 2 ? ` ${fitCells(current.name, nameRoom)}${est}` : fitOrEmpty(` ${current.name}${est}`, room)
394 return { head, body, current, filled, barWidth }
395}
396
397/** fitCells 와 같되, 자리가 1칸도 없으면 빈 글 (fitCells 는 0칸에도 「…」 를 낸다) */
398function fitOrEmpty(s: string, w: number): string {
399 return w <= 0 ? '' : fitCells(s, w)
400}
401
402/** 패널 버튼이 부르는 처리기 (main 이 $ 를 품은 채로 넘긴다) */
403export type PaneActions = { toggleExpanded: () => unknown; refreshNow: () => unknown }
404
405type CellStyle = { color?: Color; dimColor?: boolean; bold?: boolean }
406
407/** 패널 */
408export function paneTree(els: Els, d: ViewData, bodyColumns: number, act: PaneActions): RenderElement {
409 const { Box, Text, Button, Markdown } = els
410 const { plan, marks, ui, exe, now, redRatio } = d
411 const l = paneLayout(bodyColumns)
412 const sepW = cellWidth(l.sep)
413
414 // 한 줄 = 칸 Box 들 사이에 옅은 세로 구분자. 너비 0 인 칸은 뺀다.
415 const cellRow = (key: string, cells: { text: string; width: number; style?: CellStyle }[]) => {
416 const kids: RenderElement[] = []
417 cells.filter(c => c.width > 0).forEach((c, i) => {
418 if (i > 0) {
419 kids.push(
420 <Box key={`${key}-sep${i}`} width={sepW} flexShrink={0}>
421 <Text dimColor>{l.sep}</Text>
422 </Box>,
423 )
424 }
425 kids.push(
426 <Box key={`${key}-${i}`} width={c.width}>
427 <Text wrap="truncate" {...(c.style ?? {})}>{c.text}</Text>
428 </Box>,
429 )
430 })
431 return <Box key={key} flexDirection="row">{kids}</Box>
432 }
433 // 머리글 아래·합계 위는 진한 가로줄, 소단계 사이는 옅은 가로줄
434 const rule = (key: string, isDim: boolean) => (
435 <Text key={key} wrap="truncate" {...(isDim ? { dimColor: true } : {})}>{ruleText(l)}</Text>
436 )
437
438 if (plan === null) {
439 return (
440 <Box flexDirection="column">
441 <Text dimColor>계획이 없다. Claude 가 plan 도구로 소단계를 넣으면 여기에 표가 뜬다.</Text>
442 {exe.path === null && exe.why !== null ? <Text color="warning">{exe.why}</Text> : null}
443 {orphanLines(marks).map((line, i) => <Text key={`o${i}`} dimColor wrap="truncate">{line}</Text>)}
444 </Box>
445 )
446 }
447
448 const rows: RenderElement[] = []
449 if (l.plain) {
450 // 좁은 폭 : 칸 없이 「이름 · 상태」 한 줄씩. 상태는 남기고 이름 쪽을 자른다
451 plan.steps.forEach(s => {
452 const tail = ` · ${s.status}`
453 const line = fitCells(`${fitCells(`${s.n}. ${s.name}`, Math.max(1, l.inner - cellWidth(tail)))}${tail}`, l.inner)
454 rows.push(<Text key={`s${s.n}`} wrap="truncate" {...tone(s, marks, redRatio)}>{line}</Text>)
455 })
456 const t = totals(plan, marks)
457 rows.push(<Text key="sum" bold wrap="truncate">{fitCells(`합계 · ${t.done}/${plan.steps.length} 끝`, l.inner)}</Text>)
458 } else {
459 rows.push(
460 cellRow('head', [
461 { text: '소단계', width: l.name, style: { bold: true } },
462 { text: '분류', width: l.cls, style: { bold: true } },
463 { text: '크기', width: l.size, style: { bold: true } },
464 { text: '예상', width: l.est, style: { bold: true } },
465 { text: '실제', width: l.act, style: { bold: true } },
466 { text: '상태', width: l.status, style: { bold: true } },
467 ]),
468 )
469 rows.push(rule('rule-head', false))
470 plan.steps.forEach((s, i) => {
471 if (i > 0) rows.push(rule(`rule-${s.n}`, true))
472 const a = stepActual(s, marks)
473 const style = tone(s, marks, redRatio)
474 // 표 줄은 모두 한 줄이다. 이름이 넘치면 끝을 자르되 앞 번호는 남는다
475 rows.push(
476 cellRow(`s${s.n}`, [
477 { text: fitCells(`${s.n}. ${s.name}`, l.name), width: l.name, style },
478 { text: s.cls, width: l.cls, style },
479 { text: s.size, width: l.size, style },
480 { text: s.estText, width: l.est, style },
481 { text: a.text + (a.isUnsure ? ' ?' : ''), width: l.act, style },
482 { text: s.status, width: l.status, style },
483 ]),
484 )
485 })
486 rows.push(rule('rule-sum', false))
487 const t = totals(plan, marks)
488 // 합계 첫 칸은 소단계·분류·크기 칸과 그 사이 구분자를 합친 너비다
489 const front = [l.name, l.cls, l.size].filter(w => w > 0)
490 rows.push(
491 cellRow('sum', [
492 { text: '합계', width: front.reduce((a, b) => a + b, 0) + (front.length - 1) * sepW, style: { bold: true } },
493 { text: minutesText(t.estMs), width: l.est, style: { bold: true } },
494 { text: minutesText(t.actualMs) + (t.hasOpen ? ' (+하는 중)' : ''), width: l.act, style: { bold: true } },
495 { text: `${t.done}/${plan.steps.length} 끝`, width: l.status, style: { bold: true } },
496 ]),
497 )
498 }
499
500 // 머리글 두 줄 : 지금 단계 한 줄 + 진행 막대 (찬 칸 초록 █ · 빈 칸 흐린 ░)
501 const hd = paneHeader(plan, marks, bodyColumns)
502 const headTone: CellStyle = hd.current === null ? { color: 'success' } : tone(hd.current, marks, redRatio)
503 return (
504 <Box flexDirection="column">
505 <Text bold wrap="truncate">공수 · {plan.title}</Text>
506 <Text key="now" bold wrap="truncate">
507 <Text {...headTone}>{hd.head}</Text>
508 {hd.body}
509 </Text>
510 <Text key="bar" wrap="truncate">
511 <Text color="success">{'█'.repeat(hd.filled)}</Text>
512 <Text dimColor>{'░'.repeat(hd.barWidth - hd.filled)}</Text>
513 </Text>
514 <Box key="grid" flexDirection="column" borderStyle="single" borderDimColor paddingX={1} width={bodyColumns}>
515 {rows}
516 </Box>
517 {detailLines(els, plan, marks, now)}
518 {orphanLines(marks).map((line, i) => <Text key={`o${i}`} dimColor wrap="truncate">{line}</Text>)}
519 {plan.estNote !== null ? <Text color="warning" wrap="truncate">예상 : {plan.estNote}</Text> : null}
520 {exe.path === null && exe.why !== null ? <Text color="warning" wrap="truncate">{exe.why}</Text> : null}
521 <Box flexDirection="row" columnGap={2}>
522 <Button
523 key="copy"
524 label={ui.expanded ? '표 접기' : '표 복사용 글'}
525 onPress={act.toggleExpanded}
526 />
527 <Button key="refresh" label="지금 갱신" onPress={act.refreshNow} />
528 </Box>
529 {ui.expanded ? <Markdown key="md" text={'```\n' + markdownTable(plan, marks, redRatio) + '\n```'} /> : null}
530 </Box>
531 )
532}
533
534/**
535 * 표 아래 상세 줄. 표 줄을 한 줄로 두려고 mark 상세·주의·까닭을 여기로 뺐다.
536 * 열린 mark : 「하는 중 : m1007-xxxx · 대기 — · 14초 전 갱신」 (열린 mark 가 여럿이면 앞에 「2. 」 처럼 소단계 번호)
537 * show 실패 : 「2. 확인 못 함 : m1007-xxxx · <까닭>」 (노랑) · mark 주의 : 「2. 주의 : …」 · 까닭 : 「2. 까닭 : …」
538 * 열린 mark 가 없으면 하는 중 줄은 그리지 않는다.
539 */
540function detailLines(els: Els, plan: Plan, marks: Record<string, MarkInfo>, now: number): RenderElement[] {
541 const { Text } = els
542 const open = plan.steps.flatMap(s => s.markIds.map(id => ({ n: s.n, m: marks[id] })))
543 .filter((x): x is { n: number; m: MarkInfo } => x.m !== undefined && x.m.open)
544 const isMany = open.length > 1
545 const out: RenderElement[] = []
546 for (const { n, m } of open) {
547 const bits = [`${isMany ? `${n}. ` : ''}하는 중 : ${m.id}`, `대기 ${m.waitText ?? '—'}`]
548 if (m.agentId !== null) bits.push(`갈래 ${m.agentId.slice(0, 14)}…`)
549 bits.push(ago(now, m.lastAt))
550 out.push(<Text key={`m-${m.id}`} dimColor wrap="truncate">{bits.join(' · ')}</Text>)
551 }
552 for (const s of plan.steps) {
553 for (const id of s.markIds) {
554 const m = marks[id]
555 if (m === undefined) continue
556 if (m.error !== null) out.push(<Text key={`e-${id}`} color="warning" wrap="truncate">{s.n}. 확인 못 함 : {m.id} · {m.error}</Text>)
557 if (m.warn !== null) out.push(<Text key={`w-${id}`} color="warning" wrap="truncate">{s.n}. 주의 : {m.warn}</Text>)
558 }
559 if (s.note !== null) out.push(<Text key={`n-${s.n}`} dimColor wrap="truncate">{s.n}. 까닭 : {s.note}</Text>)
560 }
561 return out
562}
563
564/** 짝 없는 mark 줄 (번호가 없거나 계획에 없는 번호, 계획 전에 연 mark). 최근 3개만. */
565export function orphanLines(marks: Record<string, MarkInfo>): string[] {
566 return Object.values(marks)
567 .filter(m => m.n === null)
568 .slice(-3)
569 .map(m => `짝 없는 mark: ${m.id} "${m.name}" ${m.recordText ?? '—'}${m.open ? ' (열림)' : ''}`)
570}
571types/index.d.ts 87 lines1// effort-steps 의 $.state 계약. 값 모양은 여기 한 곳에만 적는다.
2
3/** 소단계 상태. AGENTS 1-1 표의 「상태」 칸 글 그대로다. */
4export type StepStatus = '할 일' | '하는 중' | '끝' | '건너뜀'
5
6/** 계획의 소단계 하나. 실제 값은 여기 두지 않고 marks 에서 그때그때 모은다. */
7export type Step = {
8 n: number
9 name: string
10 cls: string
11 size: string
12 /** estimate 가 준 중앙값 글. 못 받았으면 「—」 */
13 estText: string
14 estMs: number | null
15 /** 이 소단계에 짝지은 mark id. 같은 번호로 여러 번 열면 덧붙는다 */
16 markIds: string[]
17 status: StepStatus
18 /** step 도구로 남긴 까닭 한 줄 */
19 note: string | null
20}
21
22export type Plan = {
23 id: string
24 title: string
25 /** $.clock.now() 밀리초. 이보다 먼저 시작된 mark 는 짝짓지 않는다 */
26 createdAt: number
27 /** 갈아 끼운 계획이면 이전 계획의 createdAt. 이름 짝 시각 하한을 여기까지 넓힌다 (pair.ts nameFloor) */
28 prevAt?: number
29 sessionId: string
30 steps: Step[]
31 /** estimate 를 못 불렀을 때 까닭 (예 「scan 한 번 필요」) */
32 estNote: string | null
33}
34
35/** 본 mark 하나. start 출력이나 show·stop 출력에서 채운다. */
36export type MarkInfo = {
37 id: string
38 name: string
39 /** 이름 앞 번호. 번호가 없거나 계획보다 먼저 열린 mark 는 null (짝 없음) */
40 n: number | null
41 agentId: string | null
42 open: boolean
43 /** show·stop 의 「기록 구간」 원문 */
44 recordText: string | null
45 recordMs: number | null
46 waitText: string | null
47 flags: string[]
48 /** show 출력의 「주의 :」 줄 */
49 warn: string | null
50 /** 마지막 show 실패 까닭 */
51 error: string | null
52 fails: number
53 /** 같은 recordText 가 이어진 횟수 (도구 하나가 오래 돌 때 알리려고) */
54 same: number
55 seenAt: number
56 lastAt: number
57}
58
59export type ExeInfo = { path: string | null; why: string | null }
60
61export type UiPrefs = { bandHidden: boolean; expanded: boolean }
62
63declare module 'claude-code' {
64 interface PluginState {
65 'effort-steps': {
66 plan: Plan | null
67 marks: Record<string, MarkInfo>
68 exe: ExeInfo
69 ui: UiPrefs
70 /** 타이머가 멈췄는지 (show 세 번 잇달아 실패) */
71 paused: boolean
72 /** session.start 의 isInteractive. 거짓이면 강제(막기·경고)를 안 한다 */
73 interactive: boolean
74 /**
75 * /steps allow (사람이 터미널에서 친 것). 다음 사람 말(슬래시 아님) 한 번을 살아남는다.
76 * 그 말에서 used 를 true 로 두고, 그 다음 사람 말에 지운다.
77 * (allow 는 막혀 턴이 끝난 뒤 치므로, 바로 다음 「계속」 에 지우면 쓸모가 없다)
78 */
79 allow: { at: number; used: boolean } | null
80 /** 이번 턴에 경고 줄을 이미 붙였나. 사람 말에 지운다 */
81 turn: { warned: boolean; noMarkWarned: boolean }
82 /** 안 닫힌 mark 로 이미 toast 한 mark id (mark 하나에 한 번) */
83 toasted: string[]
84 }
85 }
86}
87