/* * **승인하는 사람에게 트윈이 지금 보고 있는 것을 함께 준다.** * * ── 왜 필요한가 (2026-09-01, 사용자 지적) ──────────────────────────────── * 사용자가 물었다 — 「보통 AI 는 분명하지 않으면 되묻고, 잘못된 명령이면 확인이나 역제안을 하는데 * 왜 이 기능에는 그것이 없나.」 * * 확인해 보니 대화하는 AI 는 되묻는다(`twin-ai` §assistant 프롬프트). 그런데 **현장에 지시를 내는 * 경로는 아무것도 묻지 않는다.** 사람이 「볼트 30개 추가」를 올리면 그대로 결재선에 올라간다. 트윈이 * 도금 대기가 여섯 배인 것을 알고 있어도 그 말을 할 자리가 없다. * * 말하는 쪽은 안전한 경로이고, 말 못 하는 쪽이 공장을 움직인다. * * ── 판정하지 않는다 ────────────────────────────────────────────────────── * 트윈이 「이 지시는 틀렸다」고 말하지 않는다. 무엇이 무엇과 어긋나는지 판정하려면 그 규칙을 선언해야 * 하고, 선언 없이 판정하면 지어낸 규칙이 사람의 지시를 막는다. * * **트윈은 지금 보이는 것을 내놓고, 승인하는 사람이 판단한다.** * * ── 이름을 정확히 쓴다 ─────────────────────────────────────────────────── * 이 값의 이름은 `twinNow` 다. `related` 나 `relevant` 가 아니다 — **이 지시와 관련 있다고 판정한 것이 * 아니고**, 이 트윈에서 지금 서 있는 것 전부 중 심각한 순서로 몇 개다. 이름이 관련성을 주장하면 승인자가 * 「트윈이 이 지시 때문에 이것을 골랐다」로 읽는다. */ import { digestAttentions } from '../../engine/attention-digest.js' import { TwinEngine } from '../../engine/twin-engine.js' /** 승인 화면이 읽는 한 줄 — 커널 낱말 그대로. 사람의 말은 표현계층이 만든다. */ export interface TwinNowAttention { id: string kind: string severity: string anchor?: Record params?: Record timeStamp?: string /** 커널이 권하는 방향 — 실행 가능한 것과 권고가 섞여 있다(`command` 가 있으면 자극이다). */ recommendedActions?: { code: string; command?: string; args?: unknown }[] /** 이 조치가 이것 때문에 올라왔나 — 규칙이 낸 것만 표시된다. */ isCause?: boolean } export interface TwinNow { /** 심각한 순서로 몇 개. 전부가 아니다. */ attentions: TwinNowAttention[] /** 지금 서 있는 것의 전체 수 — **잘랐으면 몇을 잘랐는지 알 수 있게.** */ total: number /** 이 조치를 낸 근거가 아직 서 있나 — 규칙이 낸 것에만 값이 있다. */ causeStanding?: boolean /** * 트윈의 상태를 못 읽었으면 그 이유. * * 빈 목록으로 답하지 않는다 — 「지금 아무 문제 없다」와 「트윈이 멈춰 있다」는 다른 사실이고, * 승인하는 사람에게 그 차이가 크다. */ unavailable?: string } /** 승인 화면에 몇 개까지 싣나 — 사람이 읽는 수. 스물여섯 줄은 아무도 안 읽는다. */ export const TWIN_NOW_LIMIT = 5 /** * 이 트윈이 지금 무엇을 보고 있나 — 결재선 입력에 실어 보낼 모양으로. * * `causeRef` 를 주면 그것이 아직 서 있는지도 함께 답한다. 승인하는 사람이 물어야 하는 것 중 가장 중요한 * 것이 「지금도 그런가」이고, 조건이 사라진 뒤 승인하면 필요 없는 것을 만든다. */ export function twinNow(domainId: string, instanceId: string, causeRef?: string, limit = TWIN_NOW_LIMIT): TwinNow { if (!TwinEngine.owns(domainId, instanceId)) { return { attentions: [], total: 0, unavailable: '이 서버가 이 트윈을 들고 있지 않다 — 멈춰 있거나 다른 서버가 돌고 있다' } } const kernel = TwinEngine.kernel(domainId, instanceId) const snap = kernel?.getSnapshot?.() ?? TwinEngine.snapshot(domainId, instanceId) if (!snap) { return { attentions: [], total: 0, unavailable: '이 트윈의 상태를 읽을 수 없다 — 아직 서지 않았거나 멈췄다' } } const all = (snap.attentions ?? []) as TwinNowAttention[] /* 자리 색은 쓰지 않는다 — 승인 화면은 지도가 아니다. 자른 뒤의 수는 반드시 함께 낸다. */ const digest = digestAttentions(all as any, limit) const attentions = (digest.attentions as TwinNowAttention[]).map(a => ({ ...a, ...(causeRef && a.id === causeRef ? { isCause: true } : {}) })) return { attentions, total: digest.attentionTotal, /* * 근거가 서 있나 — **자른 목록이 아니라 전량에서 찾는다.** 상위 다섯에 안 들었다고 사라진 것이 * 아니다. 잘린 목록에서 찾으면 심각한 것이 여럿일 때 「사라졌다」로 답한다. */ ...(causeRef ? { causeStanding: all.some(a => a.id === causeRef) } : {}) } }