여러 세션에 걸치는 작업을 끝까지 민다 — 목표를 bd epic(맵)으로 세우고 mod 가 «다음으로 갈까요?» 를 막으며 맵·프론티어·컨텍스트를 상태줄에 띄우고, 컨텍스트 절반에서 새 orca 탭으로 소유권을 넘긴다. mod 가 안 뜨는 환경에서는 Stop hook 이 대신…

여러 세션에 걸치는 작업을 끝까지 민다.
스킬은 세션 안에서만 살아서 세션 경계를 못 넘는다. 그래서 이 플러그인은 세션 밖의 셋으로 사슬을 굴린다.
| 층 | 무엇 | 왜 세션을 넘나 |
|---|---|---|
| 계약 | bd epic 본문 (맵 헌장) | DB 에 있다. 세션이 죽어도 남는다 |
| 강제 | mod (hooks/register.mjs), 대비로 Stop hook (scripts/frontier-guard.mjs) | 플러그인에 달려 있어 어느 repo·worktree 에서 연 세션에도 붙는다 |
| 운반 | long-run:session-handoff | 인계 문서 하나로 소유권을 넘긴다 |
| 스킬 | 하는 일 |
|---|---|
long-run:map-run | 목표를 bd epic(맵)으로 세우고, 헌장·프론티어를 깔고, 가드를 걸고, 프론티어가 빌 때까지 민다 |
long-run:session-handoff | 인계 문서 경로를 할당하고, 본문 합성은 handoff 스킬에 위임하고, 새 orca 탭에 claude 를 띄워 소유권을 넘긴 뒤 자기 탭을 닫는다 |
| 필요한 판단 | 부르는 스킬 | 이 플러그인이 남기는 것 |
|---|---|---|
| 대화를 다음 세션이 읽을 문서로 압축 | handoff (mattpocock 스킬군) | 저장 위치·번호 사슬, 절 구성 계약, 탭 기동·제출 증명 |
| 무엇을 만들지 아직 결정에 걸려 있을 때 그 길 찾기 | wayfinder (같은 스킬군) | 그 결정을 물려받아 완료조건까지 실행하는 맵 |
handoff 는 OS 임시 디렉터리에 쓰는 것이 기본이라 저장 위치는 이 플러그인이 덮어쓴다 — 임시 디렉터리는 청소되고 번호도 안 붙어서 인계 사슬이 남지 않는다.
wayfinder 는 계획(판정 티켓을 풀어 길을 찾는다), map-run 은 실행(완료조건까지 민다)이다. 판정은 하나 — 완료 조건을 관측 가능한 문장으로 지금 쓸 수 있는가. 못 쓰면 남은 것은 작업이 아니라 결정이므로 wayfinder 가 먼저다.
닫힌 wayfinder 맵은 헌장으로 이관된다: Decisions so far → 확정된 결정 / Not yet specified → 아직 규정 못 한 것 · 초기 티켓 / Out of scope → 범위 밖.
🚨 두 맵을 한 맵으로 합치지 않는다. wayfinder 는 「세션당 티켓 하나」·「차팅 후 정지」가 규약이고 이 플러그인의 Stop hook 은 바로 그 정지를 되민다. 라벨도 갈라 둔다(long-run:map ↔ wayfinder:map) — 섞이면 wayfinder 의 「Work through the map」이 실행 맵을 집어 1티켓 규약으로 굴린다.
맵을 선언하지 않은 세션에는 아무 일도 하지 않는다.
선언한 세션에서는 멈춤 시도마다 한 번만 되민다 — 프론티어가 남았으면 「곧장 다음 티켓을 claim 하라」로, 컨텍스트가 창의 50% 를 넘었으면 「이어가지 말고 인계하라」로. 두 번째 멈춤은 무조건 통과시킨다(그러지 않으면 세션이 영영 안 끝난다). 가드가 터지면 막는 것이 아니라 조용히 통과시킨다.
판정은 둘 중 하나가 한다. 규칙은 같다 — scripts/lib/guard-core.mjs 의 decide(), 그 축과 근거는 그 파일 머리말에 있다.
mod (hooks/register.mjs) | Stop hook (frontier-guard.mjs --hook) | |
|---|---|---|
| 언제 판정하나 | mod 가 뜬 세션 (CC v2.1.287+) | mod 가 안 뜬 세션 — 구버전, 조직 정책, --safe-mode |
| 컨텍스트 | 세션이 잰 값 ($.session.usage() — 실제 창 크기) | 트랜스크립트 꼬리의 usage, 창은 모델 이름의 [1m] 으로 추정 |
| 상태줄 | long-run <맵> · 프론티어 N · 컨텍스트 N% — 턴이 끝날 때마다 갱신, 50% 를 넘으면 · 인계할 때 | 없음 |
둘이 같은 멈춤을 두 번 판정하지 않도록, mod 는 Stop 이벤트에 long_run_mod: true 를 실어 아래로 넘기고 Stop hook 은 그것을 보면 빠진다(guard-core.mjs 의 MOD_FLAG). 환경변수가 아니라 이벤트에 싣는 이유는 환경변수는 mod 가 꺼진 뒤에도 프로세스에 남아 대비까지 입을 다물게 하기 때문이다.
mod 는 마커 경로와 열린 자식을 직접 계산하지 않고 frontier-guard.mjs status 를 부른다 — mod 는 node: 모듈을 못 쓰므로 키 계산을 다시 짜면 같은 규칙이 두 벌이 된다. 마커 경로를 한 번 배운 뒤로는 마커 파일이 없는 동안 bd 를 부르지 않는다.
bd (beads) — 맵과 프론티어. PATH 에 있어야 한다orca — worktree·터미널. 인계가 새 탭을 띄우는 경로다ORCA_TERMINAL_HANDLE)handoff 스킬 — 인계 문서 본문 합성을 위임한다. 없으면 /setup-matt-pocock-skills 로 설치한다. references/handoff-doc.md 만으로도 문서는 쓸 수 있지만, 그때는 대화 압축·비밀 삭제 판단이 빠진다wayfinder 스킬 — 선행 계획 맵이 필요할 때만. 없으면 완료 조건을 사용자와 직접 합의한다bd·orca 에는 폴백이 없다. 없으면 그 사실을 보고하고 멈춘다.
작업 저장소가 아니라 홈 아래다 — 이 플러그인은 아무 repo 에서나 열린 세션에 붙으므로, 남의 repo 에 파일을 만들지 않는다.
| 무엇 | 기본 위치 | 환경변수 |
|---|---|---|
| 가드 마커·닫기 로그 | ~/.claude/long-run/ | LONG_RUN_STATE_ROOT |
| 인계 문서 | ~/.claude/handoff/<worktree 키>/<슬러그>/handoff-NN.md | HANDOFF_ROOT |
🚨 키는 worktree 마다 갈린다(<basename>-<경로해시8>). 머신에 하나로 두면 서로 무관한 저장소의 세션까지 같은 맵으로 판정한다. 인계는 같은 worktree 의 새 탭으로 가므로 받는 세션은 같은 키를 읽어 선언을 물려받는다.
# 이 세션이 그 맵을 민다고 선언 / 해제 / 지금 판정 보기
node scripts/frontier-guard.mjs claim <epic>
node scripts/frontier-guard.mjs clear
node scripts/frontier-guard.mjs status
# 인계 문서 경로 할당 → 문서를 쓴 뒤 → 넘기고 빠진다
node scripts/handoff.mjs allocate --slug <슬러그>
node scripts/handoff.mjs send --doc <절대경로> --close-self [--bd <epic>]
node --test "scripts/test/*.test.mjs"
claude plugin validate .
순수 로직만 테스트한다 — 판정(lib/guard-core.mjs), mod 의 입력 조립과 상태줄 문구(lib/mod-view.mjs), 문서 메타 대조(lib/doc-meta.mjs), 경로·키 계산(lib/handoff-path.mjs). orca·bd 호출은 실측 대상이라 테스트하지 않는다. hooks/register.mjs 는 배선뿐이라 claude plugin validate 가 읽어 내는 hooks·calls 목록으로 확인한다.
탭 기동과 제출 판정은 화면을 읽지 않는다. orca 가 관측해 주는 것만 쓴다.
| 무엇 | 근거 |
|---|---|
| TUI 가 입력을 받을 상태인가 | terminal wait --for tui-idle 의 wait.satisfied (출력이 있었다는 사실이 아니다) |
| 프롬프트가 제출됐는가 | terminal send --wait-submit 수령증의 단계가 turn_started 에 닿았는가 |
| 관측이 가능한 호스트인가 | 같은 수령증의 prompt.observation === 'supported' |
| 애매한 전송 실패 복구 | 같은 명령을 --retry-request <requestId> 로 재발행 (재전송이 아니다) |
accepted: true 는 입력이 받아들여진 것까지고 턴이 시작된 증거가 아니다. 증거가 없으면 exit 6 으로 죽고 재전송하지 않는다.
bd 가 잠깐 죽으면 가드가 마커를 지운다 — bd list 실패를 빈 프론티어와 구분하지 못한다. mod 가 뜬 세션에서는 상태줄이 사라지는 것으로 드러난다. 아니면 status 부터 본다observation: old-host). 그때는 증명 없이 통과시키고 수령증에 그 사실을 남긴다 — 없는 증거를 만들지 않는다hooks/register.mjs 85 lines1/**
2 * long-run mod — 맵을 미는 세션의 상태줄과 Stop 판정.
3 *
4 * 판정 규칙은 `scripts/lib/guard-core.mjs`, 입력 조립과 문구는 `scripts/lib/mod-view.mjs` 가 소유한다.
5 * 여기는 배선뿐이다: 마커·열린 자식은 `frontier-guard.mjs status` 로 묻고(키 계산을 두 벌로 만들지 않는다),
6 * 컨텍스트는 세션이 잰 값을 쓴다.
7 *
8 * mod 가 안 뜨는 환경(구버전, 조직 정책, `--safe-mode`)에서는 `hooks.json` 의 node Stop 훅이 혼자
9 * 판정한다. mod 가 뜨면 Stop 에 `MOD_FLAG` 를 실어 넘기고 node 훅은 그것을 보고 빠진다.
10 *
11 * 🚨 `classic.Stop` 훅에 `.catch` 를 달지 않는다. 엔진의 기본 처리가 원하는 동작이다 — `next` 전에
12 * 터지면 이 훅만 건너뛰어 표지 없는 Stop 이 node 훅까지 가서 판정되고, `next` 뒤에 터지면 그 결과(통과)가
13 * 선다. 어느 쪽도 막는 쪽으로 고장나지 않는다.
14 *
15 * @param {(event: string, ...rest: unknown[]) => unknown} on
16 */
17
18import { MOD_FLAG } from '../scripts/lib/guard-core.mjs';
19import { parseStatus, statusLine, stopVerdict } from '../scripts/lib/mod-view.mjs';
20
21/** 이 worktree 의 마커 경로. 첫 status 에서 배운다 — 마커가 없는 동안은 bd 를 부르지 않고 파일만 본다. */
22let markerPath = null;
23
24async function readStatus($, cwd) {
25 const cli = `${$.plugin.root}/scripts/frontier-guard.mjs`;
26 try {
27 const { exitCode, stdout } = await $.process.run(['node', cli, 'status'], cwd ? { cwd } : undefined);
28 if (exitCode !== 0) return null;
29 const status = parseStatus(stdout);
30 if (status?.markerPath) markerPath = status.markerPath;
31 return status;
32 } catch {
33 return null;
34 }
35}
36
37/** 선언이 없다고 확실할 때만 false. 경로를 아직 모르면 status 로 확인해야 한다. */
38async function mayBeClaimed($) {
39 if (!markerPath) return true;
40 try {
41 return await $.fs.exists(markerPath);
42 } catch {
43 return true;
44 }
45}
46
47async function refreshStatusLine($, usage) {
48 if (!(await mayBeClaimed($))) {
49 $.ui.status(undefined);
50 return;
51 }
52 $.ui.status(statusLine(await readStatus($), usage));
53}
54
55export function register(on) {
56 // 인계받은 세션은 시작부터 선언을 물려받으므로 첫 프롬프트 전에 그린다.
57 on('session.start', async ($, e, next) => {
58 await refreshStatusLine($, await $.session.usage());
59 return next(e);
60 });
61
62 // map-run 이 claim 한 다음 턴부터 나타나고, 가드가 마커를 지우면 사라진다.
63 on('session.measure', async ($, e, next) => {
64 await refreshStatusLine($, e);
65 return next(e);
66 });
67
68 on('classic.Stop', async ($, e, next) => {
69 const result = await next({ ...e, [MOD_FLAG]: true });
70 if (result?.block || !(await mayBeClaimed($))) return result;
71
72 const status = await readStatus($, e.cwd);
73 const verdict = stopVerdict({
74 status,
75 usage: await $.session.usage(),
76 stopHookActive: e.stop_hook_active === true,
77 });
78 if (verdict.clear) {
79 await $.process.run(['node', `${$.plugin.root}/scripts/frontier-guard.mjs`, 'clear'], e.cwd ? { cwd: e.cwd } : undefined);
80 $.ui.status(undefined);
81 }
82 return verdict.block ? { ...result, block: verdict.reason } : result;
83 });
84}
85scripts/lib/guard-core.mjs 154 lines1/**
2 * 프론티어 가드의 순수 로직 — «이 세션이 지금 멈춰도 되는가» 를 판정한다.
3 *
4 * 규약은 사람이 지키는 것이 아니라 하네스가 막는 것이다. 맵의 운영 규약이 «묻지 않고 완료조건까지
5 * 간다» 라고 적어 두었어도, 티켓 하나를 닫은 자리에서 «다음으로 갈까요?» 로 멈추는 일이 반복됐다.
6 * 그래서 Stop hook 이 그 자리를 막는다.
7 *
8 * ## 판정의 축은 셋이다
9 *
10 * | 축 | 근거 |
11 * |---|---|
12 * | 이 세션이 미는 맵이 있는가 | 마커 파일(`claim` 이 쓴다). 없으면 가드는 아무것도 안 한다 |
13 * | 컨텍스트가 얼마나 찼는가 | 트랜스크립트의 마지막 `usage` — 추정이 아니라 API 가 센 값이다 |
14 * | 프론티어가 남았는가 | `bd list --parent <epic>` 의 열린 자식 |
15 *
16 * 🚨 **`stop_hook_active` 면 무조건 통과시킨다.** 그 값이 참이라는 것은 이미 이 훅 때문에 한 번
17 * 되돌려 보냈다는 뜻이다. 거기서 또 막으면 세션이 영영 안 끝난다. 즉 가드의 힘은 «멈춤 시도마다
18 * 한 번 되민다» 이지 «못 멈추게 한다» 가 아니다 — 그 선을 넘으면 사용자가 Ctrl+C 로만 빠져나오게 된다.
19 */
20
21/** 컨텍스트가 이만큼 차면 이어가지 말고 넘긴다. 맵 운영 규약의 «50% 에서 session-handoff». */
22export const HANDOFF_RATIO = 0.5;
23
24/** 인계를 지시할 때 부를 스킬. 문구를 여기 한 곳에 둬서 스킬 이름이 바뀔 때 갈라지지 않게 한다. */
25export const HANDOFF_SKILL = '/long-run:session-handoff';
26
27/**
28 * mod 가 판정을 맡은 Stop 에 붙이는 필드. mod 는 `next({ ...e, [MOD_FLAG]: true })` 로 넘기고, 그 아래에서
29 * 도는 node 훅은 stdin 에서 이 필드를 보고 빠진다. 환경변수가 아니라 이벤트에 싣는 이유: 환경변수는
30 * mod 가 꺼진 뒤에도 프로세스에 남아 node 훅까지 입을 다물게 만든다.
31 */
32export const MOD_FLAG = 'long_run_mod';
33
34/** 이 Stop 을 mod 가 이미 판정했는가. 표지가 없으면 mod 가 안 뜬 환경이므로 node 훅이 판정한다. */
35export const isDelegatedToMod = (input) => input?.[MOD_FLAG] === true;
36
37/** 「멈춰도 되는 자리」 중 승인이 필요한 바깥 쓰기의 예시. */
38const EXTERNAL_WRITES = '이슈 트래커·문서·push·외부 시스템';
39
40/**
41 * 멈춰도 되는 네 자리. 문구는 헌장의 운영 규약과 **같아야 한다** — 훅이 되밀 때 헌장과 다른 목록을
42 * 내밀면 세션이 어느 쪽을 따를지 모른다.
43 *
44 * 넷째 항이 있는 이유: 목적지를 바꾸는 갈림길에서까지 「묻지 않는다」로 되밀면, 되돌리기 비싼 선택을
45 * 에이전트가 혼자 확정하게 된다. 반대로 국소 선택(어느 쪽이어도 완료 조건의 참/거짓이 같은 것)까지
46 * 물으면 맵이 굴러가지 않으므로, 그 둘을 가르는 기준은 헌장이 소유한다.
47 */
48const STOP_PLACES = (pct) =>
49 `프론티어가 비었다 · 컨텍스트 ${pct} 초과(그때는 session-handoff) · 승인이 필요한 바깥 쓰기(${EXTERNAL_WRITES}) · ` +
50 `목적지·완료조건을 바꾸는 갈림길(A/B). 국소 선택(A.1/A.2)은 묻지 않고 고르고 이유를 close reason 에 남긴다`;
51
52/**
53 * 모델 이름에 창 크기가 실려 온다 — `claude-opus-5[1m]`. 없으면 표준 창으로 본다.
54 *
55 * 🚨 **`message.model` 에는 그 접미사가 없다.** 거기에는 `claude-opus-5` 만 실리고, 창을 말하는 것은
56 * 트랜스크립트의 `attachment.identity.modelId` 다(실측: 같은 세션에서 앞은 `claude-opus-5`,
57 * 뒤는 `claude-opus-5[1m]`). 앞만 보면 100만 창 세션을 20만으로 재서 **한참 이른 인계**를 시킨다.
58 */
59export function contextLimitOf(model) {
60 if (typeof model === 'string' && /\[1m\]/i.test(model)) return 1_000_000;
61 return 200_000;
62}
63
64/**
65 * 한 턴이 실제로 점유한 컨텍스트. 캐시에서 읽은 토큰도 창을 차지한다 — `cache_read` 를 빼면
66 * 긴 세션이 영원히 «비어 있음» 으로 보인다.
67 */
68export function contextTokensOf(usage) {
69 if (!usage) return 0;
70 return (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
71}
72
73/**
74 * 트랜스크립트(jsonl)에서 마지막 `usage` 와 모델을 뽑는다.
75 *
76 * 줄 하나가 깨져 있어도 멈추지 않는다 — 훅이 죽으면 세션이 못 멈추는 것이 아니라 가드가 조용히
77 * 사라지므로, 파싱 실패는 «모름» 으로 떨어뜨린다.
78 */
79export function readTranscriptTail(text) {
80 let usage = null;
81 let model = null;
82 for (const line of String(text ?? '').split('\n')) {
83 if (!line) continue;
84 let json;
85 try {
86 json = JSON.parse(line);
87 } catch {
88 continue;
89 }
90 if (json?.message?.usage) usage = json.message.usage;
91 // 창을 아는 쪽이 이긴다 — `attachment.identity.modelId` 에만 `[1m]` 이 붙는다(위 함수 주석).
92 const identity = json?.attachment?.identity?.modelId;
93 if (identity) model = identity;
94 else if (json?.message?.model && !model) model = json.message.model;
95 }
96 return { usage, model };
97}
98
99/**
100 * 멈춰도 되는가.
101 *
102 * @param {{
103 * marker: {epic: string} | null,
104 * contextTokens: number,
105 * contextLimit: number,
106 * openChildren: {id: string, priority?: number}[],
107 * stopHookActive: boolean,
108 * isStopEvent?: boolean,
109 * handoffRatio?: number,
110 * }} input
111 * @returns {{block: boolean, reason?: string, clear?: boolean, ratio: number}}
112 */
113export function decide({ marker, contextTokens, contextLimit, openChildren, stopHookActive, isStopEvent = true, handoffRatio = HANDOFF_RATIO }) {
114 const ratio = contextLimit > 0 ? contextTokens / contextLimit : 0;
115 const pct = `${Math.round(ratio * 100)}%`;
116
117 // 🚨 Stop 이 아닌 이벤트에서는 아무 말도 하지 않는다. 배선이 `PostToolUse` 로 들어가면 파일을
118 // 고칠 때마다 판정이 터져 «작업 중» 을 «멈추려 함» 으로 오해한 잔소리가 된다(실측으로 밟았다).
119 if (!isStopEvent) return { block: false, ratio };
120 // 🚨 두 번째 막음은 없다(머리말).
121 if (stopHookActive) return { block: false, ratio };
122 // 가드는 선언한 세션에만 붙는다 — 맵을 밀고 있지 않은 세션까지 막으면 못 쓰는 도구가 된다.
123 if (!marker?.epic) return { block: false, ratio };
124
125 const open = openChildren ?? [];
126 if (open.length === 0) {
127 return { block: false, clear: true, ratio };
128 }
129
130 if (ratio >= handoffRatio) {
131 return {
132 block: true,
133 ratio,
134 reason:
135 `컨텍스트 ${pct} 다(${contextTokens.toLocaleString()} / ${contextLimit.toLocaleString()}). ${marker.epic} 의 프론티어가 ` +
136 `${open.length}건 남았지만 이 세션에서 더 밀지 않는다 — **${HANDOFF_SKILL} 로 넘긴다**(\`--bd ${marker.epic}\`). ` +
137 `🚨 마커는 지우지 않는다 — worktree 당 파일 하나라 지우면 새 세션이 가드 없이 출발한다. ` +
138 `보내는 세션은 지우지 않아도 멈출 수 있다(두 번째 멈춤은 무조건 통과한다). ` +
139 `사용자에게 넘길지 묻지 않는다 — 그것이 규약이다.`,
140 };
141 }
142
143 return {
144 block: true,
145 ratio,
146 reason:
147 `${marker.epic} 의 프론티어가 ${open.length}건 남았다(${open.map((c) => c.id).join(', ')}). ` +
148 `**곧장 다음 티켓을 \`bd update <id> --claim\` 하고 이어간다 — «다음으로 갈까요?» 라고 묻지 않는다.** ` +
149 `컨텍스트는 ${pct} 라 아직 넘길 자리가 아니다. 멈춰도 되는 자리는 넷뿐이다: ` +
150 `${STOP_PLACES(`${Math.round(handoffRatio * 100)}%`)}. ` +
151 `그 넷이 아니면 보고는 작업 사이가 아니라 세션 끝에 한 번 한다.`,
152 };
153}
154scripts/lib/mod-view.mjs 73 lines1/**
2 * mod(`hooks/register.mjs`)의 순수 로직 — `frontier-guard.mjs status` 출력과 세션의 컨텍스트 측정값을
3 * 상태줄 문구와 Stop 판정으로 바꾼다.
4 *
5 * 마커 경로·열린 자식은 CLI 가 계속 소유한다. mod 는 `node:` 모듈을 못 쓰므로 키 계산을 다시 짜면
6 * 같은 규칙이 두 벌이 된다. mod 가 새로 가져오는 것은 컨텍스트뿐이다 — 세션이 실제 창 크기와 점유를
7 * 알려주므로 트랜스크립트를 파싱하거나 모델 이름에서 창을 추측할 필요가 없다.
8 *
9 * `node:` import 를 두지 않는다. 이 파일은 hooks module 이 import 하므로 mod 의 실행 환경에서 돌아야 한다.
10 */
11
12import { HANDOFF_RATIO, contextLimitOf, decide } from './guard-core.mjs';
13
14/**
15 * `frontier-guard.mjs status` 의 stdout 을 읽는다. 못 읽으면 null(«모름»)이고, 가드는 모를 때 통과시킨다.
16 *
17 * @param {string | undefined} stdout
18 * @returns {{markerPath: string, marker: {epic: string} | null, open: string[]} | null}
19 */
20export function parseStatus(stdout) {
21 try {
22 const json = JSON.parse(stdout);
23 return json && typeof json === 'object' ? json : null;
24 } catch {
25 return null;
26 }
27}
28
29/** 세션 측정값에서 판정 입력의 컨텍스트 두 값. 측정 전이면 점유 0 — 인계가 아니라 다음 티켓으로 되민다. */
30function contextOf(usage) {
31 const context = usage?.context;
32 return {
33 contextTokens: context?.tokens ?? 0,
34 contextLimit: context?.window > 0 ? context.window : contextLimitOf(null),
35 };
36}
37
38/**
39 * 상태줄 한 줄. 선언하지 않은 세션이면 undefined(상태줄을 지운다).
40 *
41 * @param {ReturnType<typeof parseStatus>} status
42 * @param {{context?: {tokens?: number, window: number, percent?: number}} | null} usage
43 * @returns {string | undefined}
44 */
45export function statusLine(status, usage) {
46 const epic = status?.marker?.epic;
47 if (!epic) return undefined;
48 const parts = [`long-run ${epic}`, `프론티어 ${(status.open ?? []).length}`];
49 const percent = usage?.context?.percent;
50 if (typeof percent !== 'number') {
51 parts.push('컨텍스트 측정 전');
52 } else {
53 parts.push(`컨텍스트 ${percent}%`);
54 if (percent >= HANDOFF_RATIO * 100) parts.push('인계할 때');
55 }
56 return parts.join(' · ');
57}
58
59/**
60 * Stop 판정. 판정 규칙은 `decide()` 그대로고, 입력만 status 출력과 세션 측정값에서 만든다.
61 *
62 * @param {{status: ReturnType<typeof parseStatus>, usage: object | null, stopHookActive: boolean}} input
63 * @returns {{block: boolean, reason?: string, clear?: boolean, ratio: number}}
64 */
65export function stopVerdict({ status, usage, stopHookActive }) {
66 return decide({
67 marker: status?.marker ?? null,
68 ...contextOf(usage),
69 openChildren: (status?.open ?? []).map((id) => ({ id })),
70 stopHookActive,
71 });
72}
73