SLOPSHOPPER

long-run

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

newstatusprocess
v0.2.0no licenseupdated 2026-10-07aydenden/cc-plugins/plugins/long-run
A shopper browsing a rack in a slop shop
README

long-run

여러 세션에 걸치는 작업을 끝까지 민다.

스킬은 세션 안에서만 살아서 세션 경계를 못 넘는다. 그래서 이 플러그인은 세션 밖의 셋으로 사슬을 굴린다.

층무엇왜 세션을 넘나
계약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 와의 경계

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티켓 규약으로 굴린다.

강제 — mod 와 Stop hook

맵을 선언하지 않은 세션에는 아무 일도 하지 않는다.

선언한 세션에서는 멈춤 시도마다 한 번만 되민다 — 프론티어가 남았으면 「곧장 다음 티켓을 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·터미널. 인계가 새 탭을 띄우는 경로다
  • Node.js 18+ (내장 모듈만 쓴다. 런타임 의존성 0)
  • 상태줄과 mod 판정은 Claude Code v2.1.287 이상. 그보다 낮으면 Stop hook 만으로 동작한다
  • 인계는 이 세션이 Orca 터미널에서 돌고 있을 때 자기 탭을 닫는다(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.mdHANDOFF_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 계약에 기대는 지점

탭 기동과 제출 판정은 화면을 읽지 않는다. 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 부터 본다
  • 같은 worktree 에서 세션 둘을 동시에 굴리면 둘 다 같은 맵으로 판정된다. 인계가 마커를 지우지 않는 설계의 대가다
  • 구 orca 호스트에서는 제출을 증명할 수 없다(observation: old-host). 그때는 증명 없이 통과시키고 수령증에 그 사실을 남긴다 — 없는 증거를 만들지 않는다
Source 3 files
hooks/register.mjs 85 lines
1/**
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}
85
scripts/lib/guard-core.mjs 154 lines
1/**
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}
154
scripts/lib/mod-view.mjs 73 lines
1/**
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