diff --git a/app/c/[ws]/routines/page.jsx b/app/c/[ws]/routines/page.jsx index 7b40cef9..257d5f7f 100644 --- a/app/c/[ws]/routines/page.jsx +++ b/app/c/[ws]/routines/page.jsx @@ -2,6 +2,7 @@ // 루틴 — 크루에게 반복 지시를 예약하고, 원클릭으로 즉시 실행한다. // 템플릿 원클릭 생성 → 폼 프리필. 실행 결과는 vault 기억으로 남는다. import { use, useEffect, useState } from 'react'; +import Link from 'next/link'; import { Icon, Avatar, Spinner, Skeleton, useScrollLock, ConfirmModal, DropUp, api, imeGuard, timeAgo } from '../../../ui'; import { useLang } from '../../../i18n'; @@ -24,7 +25,7 @@ function scheduleLabel(s, t, DOW) { export default function Routines({ params }) { const { ws } = use(params); - const { t, lang } = useLang(); + const { t, lang, fmtMoney } = useLang(); const DOW = [t('routines.dow.sun'), t('routines.dow.mon'), t('routines.dow.tue'), t('routines.dow.wed'), t('routines.dow.thu'), t('routines.dow.fri'), t('routines.dow.sat')]; const TEMPLATES = [ { title: t('routines.template1.title'), prompt: t('routines.template1.prompt'), schedule: { type: 'daily', time: '09:00' } }, @@ -85,6 +86,7 @@ export default function Routines({ params }) { times: tpl?.schedule?.times ?? [tpl?.schedule?.time ?? '09:00'], dows: tpl?.schedule?.dows ?? [tpl?.schedule?.dow ?? 1], everyMinutes: tpl?.schedule?.everyMinutes ?? 30, + maxRuns: 20, maxUsd: '', }); } @@ -99,6 +101,7 @@ export default function Routines({ params }) { times: r.schedule?.times ?? [r.schedule?.time ?? '09:00'], dows: r.schedule?.dows ?? [r.schedule?.dow ?? 1], everyMinutes: r.schedule?.everyMinutes ?? 30, + maxRuns: r.loop?.maxRuns ?? 20, maxUsd: r.loop?.maxUsd ?? '', }); } @@ -115,6 +118,8 @@ export default function Routines({ params }) { // 한국 시간으로"). 안 보내면 서버가 자기 시간대로 해석하는데, 웹·클라우드 경로에선 그게 // 사용자 시간대가 아니다(UTC 서버면 09:00이 KST 18:00에 터진다). : { type: form.type, times: form.times, dows: form.dows.map(Number), tz: Intl.DateTimeFormat().resolvedOptions().timeZone }, + // 루프 상한 — interval에만 보낸다(서버는 다른 타입의 loop를 무시하지만, 보내지 않는 편이 의도가 분명하다) + ...(form.type === 'interval' ? { loop: { maxRuns: Number(form.maxRuns) || 20, maxUsd: form.maxUsd === '' ? null : Number(form.maxUsd) } } : {}), }; if (form.id) { const res = await fetch(`/api/companies/${ws}/routines`, { @@ -250,11 +255,24 @@ export default function Routines({ params }) { )} {form.type === 'interval' && ( - + <> + + {/* 루프 상한 — 회차(1~200)·루프 예산(USD, 비우면 월 예산만). 한국어 모드는 원화 병기(fmtMoney 규칙) */} + + + > )} {form.type !== 'interval' && (
| {t('routines.colTitle')} | {t('routines.colCrew')} | {t('routines.colSchedule')} | {t('routines.colLastRun')} | {t('routines.colState')} | |
|---|---|---|---|---|---|
| {t('routines.colTitle')} | {t('routines.colCrew')} | {t('routines.colSchedule')} | {t('routines.colLastRun')} | {t('routines.colState')} | {scheduleLabel(r.schedule, t, DOW)} | +
+ {scheduleLabel(r.schedule, t, DOW)}
+ {r.schedule?.type === 'interval' && r.loop && |
{r.lastRun ? ( @@ -358,8 +379,9 @@ export default function Routines({ params }) { ) : —} | - |
@@ -383,6 +405,27 @@ export default function Routines({ params }) {
);
}
+/** 루프 진행 — `3/20회 · $0.42` + 상태 칩. 막힘이면 사유와 결재함 안내(결재 카드는 데크에 있다). */
+function LoopStatus({ ws, loop, t, fmtMoney }) {
+ const reason = loop.stoppedReason;
+ const key = reason === 'done' ? 'done' : reason === 'blocked' ? 'blocked' : (reason === 'maxRuns' || reason === 'maxUsd') ? 'limit' : reason === 'manual' ? 'manual' : 'running';
+ const color = key === 'running' ? 'var(--primary-strong)' : key === 'done' ? 'var(--ok, var(--fg-2))' : key === 'blocked' ? 'var(--danger)' : 'var(--fg-3)';
+ return (
+
+
+ {t('routines.loop.progress', { runs: loop.runs ?? 0, max: loop.maxRuns, cost: fmtMoney(loop.spentUsd ?? 0) })}
+ {t(`routines.loop.${key}`)}
+
+ {key === 'blocked' && (
+
+ {t('routines.loop.blockedHint', { reason: loop.stoppedDetail ?? '' })}
+ {t('routines.loop.approveInbox')}
+
+ )}
+
+ );
+}
+
const selStyle = {
height: 34, padding: '0 10px', background: 'var(--card-2)',
border: '1px solid var(--border)', borderRadius: 8, outline: 'none', fontSize: 13, color: 'var(--fg)',
diff --git a/app/i18n.jsx b/app/i18n.jsx
index 03aa0060..003ea863 100644
--- a/app/i18n.jsx
+++ b/app/i18n.jsx
@@ -1117,6 +1117,19 @@ const DICT = {
'routines.runNow': ['지금 즉시 실행', 'Run now'],
'routines.running': ['실행 중 — 결과는 기억에도 남습니다', 'Running — the result is also saved to memory'],
'routines.resultTitle': ['결과 · 기억에 기록됨', 'Result · Recorded in Memory'],
+ // 루프(interval 루틴의 자율 반복)
+ 'routines.loop.maxRuns': ['최대 반복', 'Max runs'],
+ 'routines.loop.maxUsd': ['루프 예산(USD)', 'Loop budget (USD)'],
+ 'routines.loop.maxUsdHint': ['비우면 회사 월 예산만 적용', 'Leave empty to apply only the monthly budget'],
+ 'routines.loop.progress': ['{runs}/{max}회 · {cost}', '{runs}/{max} runs · {cost}'],
+ 'routines.loop.running': ['진행 중', 'Running'],
+ 'routines.loop.done': ['완료', 'Done'],
+ 'routines.loop.blocked': ['막힘', 'Blocked'],
+ 'routines.loop.limit': ['한도', 'Limit'],
+ 'routines.loop.manual': ['정지됨', 'Stopped'],
+ 'routines.loop.blockedHint': ['결정 필요: {reason}', 'Needs a decision: {reason}'],
+ 'routines.loop.approveInbox': ['결재함에서 승인', 'Approve in inbox'],
+ 'routines.loop.resume': ['지금 재개', 'Resume now'],
// ── 마켓
'market.topLabel': ['추천 · {source}', 'Recommended · {source}'],
diff --git a/src/approval-actions.mjs b/src/approval-actions.mjs
index 36f3c119..e1a20819 100644
--- a/src/approval-actions.mjs
+++ b/src/approval-actions.mjs
@@ -11,6 +11,15 @@ import { emitNotify } from './notify.mjs'; // 후속 턴 결과를 원 채널(
(capabilities.mjs). 옛 결재함에 남은 capability 항목은 여기서 특별 처리 없이 그냥 해소된다. */
export async function resolveWithFollowUp(wsId, id, approve) {
const item = await resolveApproval(wsId, id, approve);
+ if (item.kind === 'loop') {
+ // 루프 막힘(LOOP: blocked) 결재 — 승인이면 루틴을 다시 켠다(다음 틱에 재개), 거절이면 정지 유지.
+ // 후속 턴을 돌리지 않는다: 재개된 루프의 다음 회차가 곧 후속이고, 여기서 턴을 더 쓰면 비용 이중.
+ if (approve && item.payload?.routineId) {
+ const { resumeLoop } = await import('./routines.mjs');
+ await resumeLoop(wsId, item.payload.routineId).catch((e) => console.error('[argo] 루프 재개 실패:', e.message));
+ }
+ return item;
+ }
if (item.kind !== 'tool') {
followUp(wsId, item, approve).catch((e) => console.error('[argo] 결재 후속 턴 실패:', e.message));
}
diff --git a/src/chat.mjs b/src/chat.mjs
index cde1a45e..f1aff4a3 100644
--- a/src/chat.mjs
+++ b/src/chat.mjs
@@ -669,26 +669,30 @@ export function makeCrewServer(wsId, fromSlug, fromName, colleagues, hop = 0, ch
// 사장이 언제든 끄거나 고칠 수 있으므로(가시성) 결재 없이 실행한다 — hire_crew와 달리 되돌리기 쉽다.
const scheduleTask = tool(
'schedule_task',
- '나중에 할 일을 예약한다(예약 발송·리마인드·정기 보고·반복 루프). once=지정 날짜에 1회, daily=매일, weekly=지정 요일, interval=N분마다 반복(루프 작업 — 모니터링·주기 점검). 시각은 한국 시간 HH:MM. 때가 되면 지정 크루가 prompt를 새 턴으로 실행한다. 예약 후에는 "언제 무엇을 하도록 걸어두었다"고 한 줄로 알려라.',
+ '나중에 할 일을 예약한다(예약 발송·리마인드·정기 보고·반복 루프). once=지정 날짜에 1회, daily=매일, weekly=지정 요일, interval=N분마다 반복(루프 작업 — 모니터링·주기 점검). 시각은 한국 시간 HH:MM. 때가 되면 지정 크루가 prompt를 새 턴으로 실행한다. interval 루프는 매 회차 마지막 줄 `LOOP: continue|done|blocked`로 스스로 끝내며 maxRuns(기본 20)·maxUsd 상한에서 자동 정지한다. 예약 후에는 "언제 무엇을 하도록 걸어두었다"고 한 줄로 알려라.',
{
title: z.string().describe('예약 이름 — 루틴 목록에 보인다'),
prompt: z.string().describe('실행할 지시 — 지금이 아니라 그때 읽힌다는 전제로 자세히 쓴다. 루프면 매 회차가 이 지시를 새로 읽는다'),
type: z.enum(['once', 'daily', 'weekly', 'interval']).describe('once=1회, daily=매일, weekly=매주, interval=N분마다'),
time: z.string().optional().describe('실행 시각 HH:MM (한국 시간, 24시간제) — interval이 아니면 필수'),
everyMinutes: z.number().optional().describe('interval일 때 필수 — 반복 간격(분, 10~1440)'),
+ maxRuns: z.number().optional().describe('interval 루프의 최대 회차(1~200, 기본 20) — 도달하면 자동 정지'),
+ maxUsd: z.number().optional().describe('interval 루프의 누적 비용 상한(USD, 선택) — 없으면 회사 월 예산만 적용'),
date: z.string().optional().describe('once일 때 필수 — 실행 날짜 YYYY-MM-DD'),
dows: z.array(z.number()).optional().describe('weekly일 때 요일 배열(0=일 … 6=토), 예: 평일은 [1,2,3,4,5]'),
agentSlug: z.string().optional().describe('실행할 크루 slug(기본 = 나 자신)'),
},
- async ({ title, prompt, type, time, date, dows, everyMinutes, agentSlug }) => {
+ async ({ title, prompt, type, time, date, dows, everyMinutes, agentSlug, maxRuns, maxUsd }) => {
try {
const r = await addRoutine(wsId, {
agentSlug: agentSlug || fromSlug, title, prompt,
schedule: { type, ...(time ? { time } : {}), ...(date ? { date } : {}), ...(dows?.length ? { dows } : {}), ...(everyMinutes ? { everyMinutes } : {}) },
+ // interval = 자율 루프 — 회차·예산 상한을 기본으로 건다(무한 반복 방지). 다른 타입엔 addRoutine이 무시
+ ...(type === 'interval' ? { loop: { maxRuns: maxRuns ?? 20, ...(maxUsd != null ? { maxUsd } : {}) } } : {}),
});
const when = type === 'once' ? `${r.schedule.date} ${r.schedule.time}`
: type === 'weekly' ? `매주 ${(r.schedule.dows ?? []).join(',')} ${r.schedule.time}`
- : type === 'interval' ? `${r.schedule.everyMinutes}분마다`
+ : type === 'interval' ? `${r.schedule.everyMinutes}분마다, 최대 ${r.loop?.maxRuns ?? 20}회`
: `매일 ${r.schedule.time}`;
return text(`예약 완료 — "${title}" (${when}, 담당 ${agentSlug || fromSlug}). 루틴 화면에서 사장이 끄거나 고칠 수 있다. 사장에게 언제 무엇을 하도록 걸어뒀는지 한 줄로 알려라.`);
} catch (e) {
@@ -1190,6 +1194,7 @@ ${lang === 'en'
}
let reply = '';
+ let costUsd = null; // 이 턴의 청구 금액 — 루프 루틴의 예산 합산용. 구독(OAuth)·openrouter·CLI 턴은 null(=0으로 합산)
let creditTurn = false; // OpenRouter 402 턴 표식 — 일지 기록 제외용(2R N3: 오류 원문이 기억으로 정제되지 않게)
let sid = resumeId; // 새 세션이면 null에서 시작 — 외래 sessionId를 내 것으로 재스탬프하지 않는다
const toolCounts = {}; // 이 턴의 도구 사용 횟수 — 크루 프로필 "많이 쓴 도구"의 원천
@@ -1310,6 +1315,7 @@ ${lang === 'en'
// 틀린 금액 표시·예산 차감은 이번에 죽인 신고 계열의 재발이다. 실비(P2)는 /generation API로.
usage: msg.usage, costUsd: runner === 'openrouter' ? null : msg.total_cost_usd, ms: Date.now() - t0, tools: toolCounts, billed,
});
+ if (billed && runner !== 'openrouter' && Number.isFinite(msg.total_cost_usd)) costUsd = msg.total_cost_usd;
}
if (msg.subtype === 'success') reply = msg.result;
else {
@@ -1430,5 +1436,5 @@ ${lang === 'en'
// diff와 합집합 — 도구 관측(즉시성)과 파일시스템 diff(Bash·MCP 포함 완전성)를 합친다. 필터는
// servableArtifact 하나로 통일(칩=서빙 일치 — 탐색 G8), 상한·정렬은 artDiff와 같은 규칙.
for (const r of await artDiff()) artifacts.add(r);
- return { reply, sessionId: sid, handover, artifacts: capLatest(artAfter, [...artifacts].filter(servableArtifact)) }; // 합집합도 최신 우선 12(알파벳 컷이 최신을 떨구던 것 — 검수 LOW-2)
+ return { reply, sessionId: sid, handover, costUsd, artifacts: capLatest(artAfter, [...artifacts].filter(servableArtifact)) }; // 합집합도 최신 우선 12(알파벳 컷이 최신을 떨구던 것 — 검수 LOW-2)
}
diff --git a/src/cli-directives.mjs b/src/cli-directives.mjs
index f63eb62f..79b4cfbc 100644
--- a/src/cli-directives.mjs
+++ b/src/cli-directives.mjs
@@ -17,7 +17,7 @@ import { listAgents } from './hub.mjs';
import { callConnectorTool } from './connectors.mjs'; // 커넥터 단일 실행 경로 — SDK 표면과 같은 함수
/** 지시 블록 문법 — ```argo 펜스 안 JSON 1건. 여러 블록 허용.
- 스케줄: {"action":"schedule","every":"30m"|"time":"09:00","days":[1,3],"title":"...","prompt":"..."}
+ 스케줄: {"action":"schedule","every":"30m"|"time":"09:00","days":[1,3],"title":"...","prompt":"...","maxRuns":20,"maxUsd":5} (maxRuns/maxUsd는 every 루프에만·선택)
쪽지: {"action":"mail","to":"슬러그","cc":["..."],"message":"..."}
커넥터: {"action":"tool","server":"gmail","tool":"search_threads","args":{…}} */
const BLOCK_RE = /```argo[ \t]*\r?\n([\s\S]*?)```/g;
@@ -118,14 +118,17 @@ export async function runDirectives(wsId, fromSlug, directives, { lang = 'ko', b
const prompt = String(d.prompt ?? '').trim();
if (!prompt) throw new Error(en ? 'prompt is required' : 'prompt가 필요합니다');
const target = d.crew ? find(d.crew) : null;
+ const schedule = normalizeSchedule(toSchedule(d));
const r = await addRoutine(wsId, {
agentSlug: target?.slug ?? fromSlug,
title: String(d.title ?? prompt).replace(/\s+/g, ' ').slice(0, 60),
prompt,
- schedule: normalizeSchedule(toSchedule(d)),
+ schedule,
+ // SDK의 schedule_task와 패리티 — interval은 자율 루프(기본 20회 상한, maxRuns/maxUsd 지시 필드 수용)
+ ...(schedule.type === 'interval' ? { loop: { maxRuns: d.maxRuns ?? 20, ...(d.maxUsd != null ? { maxUsd: d.maxUsd } : {}) } } : {}),
});
const when = r.schedule.type === 'interval'
- ? (en ? `every ${r.schedule.everyMinutes} min` : `${r.schedule.everyMinutes}분마다`)
+ ? (en ? `every ${r.schedule.everyMinutes} min, up to ${r.loop?.maxRuns ?? 20} runs` : `${r.schedule.everyMinutes}분마다, 최대 ${r.loop?.maxRuns ?? 20}회`)
: (r.schedule.times ?? []).join('·');
notes.push(en ? `✓ Routine registered — ${r.title} (${when})` : `✓ 루틴 등록됨 — ${r.title} (${when})`);
} else if (action === 'mail') {
diff --git a/src/room.mjs b/src/room.mjs
index def8bd24..379a2611 100644
--- a/src/room.mjs
+++ b/src/room.mjs
@@ -310,10 +310,11 @@ export async function runRoomTurn(wsId, text, attachments = []) {
agentSlug: target.slug,
title: (prompt.replace(/@\S+/g, '').trim() || (en ? 'Room loop' : '회의실 루프')).slice(0, 60),
prompt, schedule: { type: 'interval', everyMinutes: dir.loop.everyMinutes },
+ loop: { maxRuns: 20 }, // 자율 루프 기본 상한 — 회차마다 LOOP 판정으로 스스로 끝내고, 20회에서 자동 정지
});
await sys('loop', en
- ? `Loop registered — @${target.slug} every ${r.schedule.everyMinutes} min. Manage it in Routines.`
- : `루프 등록 — @${target.slug}, ${r.schedule.everyMinutes}분마다. 관리는 '루틴' 화면에서.`);
+ ? `Loop registered — @${target.slug} every ${r.schedule.everyMinutes} min, up to ${r.loop?.maxRuns ?? 20} runs. Manage it in Routines.`
+ : `루프 등록 — @${target.slug}, ${r.schedule.everyMinutes}분마다, 최대 ${r.loop?.maxRuns ?? 20}회. 관리는 '루틴' 화면에서.`);
} catch (e) {
// 간격 하한(10분) 등 검증 실패 — 사유를 방에 그대로 돌려준다
await sys('loop', en ? `Loop not registered: ${String(e.message || e)}` : `루프 등록 실패: ${String(e.message || e)}`);
diff --git a/src/routines.mjs b/src/routines.mjs
index d5f7bced..076f7477 100644
--- a/src/routines.mjs
+++ b/src/routines.mjs
@@ -17,7 +17,8 @@ async function patchRoutine(wsId, id, patch) {
const routines = await loadRoutines(wsId);
const r = routines.find((x) => x.id === id);
if (!r) return null; // 실행 중 삭제됐으면 조용히 포기(부활 금지)
- Object.assign(r, patch, { id: r.id });
+ // 함수형 패치 — 현재 상태를 보고 결정해야 하는 변경(루프 수동 정지 사유 등)은 락 안에서 읽고 쓴다
+ Object.assign(r, typeof patch === 'function' ? patch(r) : patch, { id: r.id });
await saveRoutines(wsId, routines);
return { ...r };
});
@@ -81,21 +82,97 @@ export function normalizeSchedule(schedule = {}) {
return withTz({ type, time: times[0], times, dow: dows[0], ...(type === 'weekly' ? { dows } : {}) });
}
+/* ─── 루프(interval 루틴의 자율 반복) ─────────────────────────────────────── */
+
+/** loop 필드 정규화 — interval 루틴에만 유효(호출부가 타입을 보고 붙인다). 설정값(maxRuns/maxUsd)은
+ 클램프·기본값, 진행 카운터(runs/spentUsd/…)는 prev(디스크의 현재값)에서 이어받는다 — API 패치가
+ 회차·지출을 되돌리지 못하게. (export: 단위 테스트용 — 순수 함수) */
+export const LOOP_MAX_RUNS_CAP = 200;
+export function normalizeLoop(loop = {}, prev = null) {
+ const src = loop && typeof loop === 'object' ? loop : {};
+ let maxRuns = Math.floor(Number(src.maxRuns ?? prev?.maxRuns ?? 20));
+ if (!Number.isFinite(maxRuns)) maxRuns = 20;
+ maxRuns = Math.min(LOOP_MAX_RUNS_CAP, Math.max(1, maxRuns));
+ const rawUsd = 'maxUsd' in src ? src.maxUsd : prev?.maxUsd ?? null;
+ const usdNum = Number(rawUsd);
+ const maxUsd = rawUsd == null || rawUsd === '' || !Number.isFinite(usdNum) || usdNum <= 0 ? null : Math.round(usdNum * 100) / 100;
+ return {
+ maxRuns, maxUsd,
+ runs: Math.max(0, Math.floor(Number(prev?.runs) || 0)),
+ spentUsd: Math.max(0, Number(prev?.spentUsd) || 0),
+ lastVerdict: ['continue', 'done', 'blocked'].includes(prev?.lastVerdict) ? prev.lastVerdict : null,
+ stoppedReason: ['done', 'blocked', 'maxRuns', 'maxUsd', 'manual'].includes(prev?.stoppedReason) ? prev.stoppedReason : null,
+ missingVerdicts: Math.max(0, Math.floor(Number(prev?.missingVerdicts) || 0)),
+ stoppedDetail: String(prev?.stoppedDetail ?? '').slice(0, 300), // 정지 상세(blocked의 필요한 결정·done의 이유) — 화면 표시용
+ };
+}
+
+/** 회차 판정 마커 — 답변 **마지막 줄**. `LOOP: continue` / `LOOP: done <이유>` / `LOOP: blocked <필요한 결정>`.
+ (export: 테스트·프롬프트 문구 앵커) */
+export const LOOP_VERDICT_RE = /^\s*`?\s*LOOP\s*:\s*(continue|done|blocked)\b[\s.:\-—]*(.*?)\s*`?\s*[.。]?\s*$/i;
+const LOOP_MISSING_LIMIT = 3; // 마커 연속 누락 허용 — CLI 러너가 형식을 못 지켜도 조용히 죽지 않되, 영영 헛돌지도 않게
+
+/** 답변에서 판정 추출 — 마지막 비어있지 않은 줄만 본다. 마커가 없으면 { verdict:'continue', missing:true } —
+ 형식을 안 지킨 러너를 곧바로 정지시키지 않는다(연속 누락 상한은 runRoutine이 센다).
+ (export: 단위 테스트용 — 순수 함수) */
+export function parseLoopVerdict(reply) {
+ const lines = String(reply ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
+ const last = lines[lines.length - 1] ?? '';
+ const m = last.match(LOOP_VERDICT_RE);
+ if (!m) return { verdict: 'continue', reason: '', missing: true };
+ return { verdict: m[1].toLowerCase(), reason: (m[2] ?? '').trim().slice(0, 300), missing: false };
+}
+
+const isLoopRoutine = (r) => r?.schedule?.type === 'interval' && !!r.loop;
+
+/** 루프 프로토콜 문단 — 회차·상한·지난 결과를 주고 마지막 줄 마커를 요구한다(러너 무관 — 텍스트 규약). */
+function loopProtocol(r, lang) {
+ const n = (r.loop.runs ?? 0) + 1;
+ const last = String(r.lastResult ?? '').trim();
+ const budget = r.loop.maxUsd != null ? (lang === 'en' ? ` Loop budget: $${r.loop.spentUsd.toFixed(2)} of $${r.loop.maxUsd} used.` : ` 루프 예산: $${r.loop.maxUsd} 중 $${r.loop.spentUsd.toFixed(2)} 사용.`) : '';
+ if (lang === 'en') {
+ return `\n\n---\n[Loop protocol] This is run ${n} of at most ${r.loop.maxRuns} in a repeating loop.${budget}\nPrevious run summary: ${last || '(none — first run)'}\nDo the next step of the work. The VERY LAST line of your answer must be exactly one of:\n\`LOOP: continue\` — more to do next run\n\`LOOP: done |