diff --git a/docs/role-instructions.md b/docs/role-instructions.md index e6d29cf6..6397d011 100644 --- a/docs/role-instructions.md +++ b/docs/role-instructions.md @@ -123,3 +123,44 @@ send의 바이트 수와 지문은 지침까지 포함한 실제 전달문 기 이미 떠 있는 watch·auto-run은 모듈을 메모리에 읽었으므로 **프로그램을 새로 시작한 뒤 적용된다.** 저장소 업데이트만으로 기존 프로세스에 적용됐다고 보장하지 않는다. 이 기능은 운영 프로세스를 자동 재시작하지 않는다. 기존 편지·과거 원장·카드를 소급 수정하지 않는다. + +## 요약 우편 시험 (kadan 판 conductor 한정) + +Why: 감독 창이 같은 규칙 전문의 반복으로 차서 압축이 잦았다. 결정 cc77fcc2, 시니어 자문 452d380f. + +`guardedSend`는 우편마다 전문(`full`)과 요약(`summary`) 중 하나를 고른다. +요약은 템플릿 전문만 빼고, 수신 역할·회신 대상·연결 업무/실행·완료 방식·참고 파일 경로는 그대로 둔다. +대신 `role-templates/<프로필>-summary.md`(지금은 `conductor-summary.md`)가 붙는다. 전문 경로는 참고 파일 목록에 남는다. +요약일 때도 템플릿 파일은 읽는다(지문 비교용). 줄어드는 것은 메시지뿐이다. +`SUMMARY_PROFILES`·`SUMMARY_REPO`·`FULL_INTERVAL_MS`(1시간)는 시험값 상수이며 설정·환경변수 스위치는 없다. 되돌리기는 PR 되돌리기다. + +전문이 되는 이유와 기록 이름(`instructions.reason`): + +| 조건 | 이유 값 | +|---|---| +| 시험 프로필(conductor)이 아님 | `profile-not-in-trial` | +| 업무·카드 사실을 못 읽음, 판 판별 근거가 없거나 어긋남 | `facts-unavailable` | +| 레포 kadan 판이 아님(예: 다른 레포 감독) | `board-not-in-trial` | +| 현재 발령(`--task`) | `dispatch` | +| 이 세대에 전문이 없음(첫 안내·새 대화 첫 우편 포함) | `generation-first` | +| 이 세대 마지막 전문이 `keyDelivery !== 'sent'` | `delivery-unclear` | +| 프로필 또는 `templateDigest`가 마지막 전문과 다름 | `profile-changed` · `template-changed` | +| 마지막 전달 성공 전문 뒤 1시간 이상 경과(정확히 60분 포함) | `interval` | +| 위에 해당 없음 | 요약(`within-interval`) | + +- 판 판별은 이름(`boardFromRole`)과 레포(연결 업무·카드의 `repo`, 없으면 start 사건 cwd의 `~/Dev/<레포>`)가 둘 다 kadan일 때만 인정한다. 둘이 어긋나거나 근거가 없으면 `facts-unavailable`이다. +- '전달 성공'은 `keyDelivery === 'sent'`만이다. 세대는 `mail-generation.mjs`의 start 경계를 그대로 쓴다. raw 전송은 `instructions`가 없어 건너뛴다. +- 실제 바닥은 전송 실패를 영수증이 아니라 예외로 던진다. 전문 시도가 던져지면(`not-sent`·`unknown`) 원장에 별도 kind `send-failed`(시스템 영역, `mailId` 없음, `keyDelivery`·`instructions`만)를 남기고 원래 오류를 다시 던진다. `send`가 아니라서 발령·우편함·감시 재안내·디코 중복 방지(`findMailBySourceKeyAny`)가 전달로 보지 않는다. 그 세대의 마지막 전문 시도가 `sent`가 아니면 다음 우편은 `delivery-unclear`이고, 전문이 한 번 `sent`로 가면 풀린다. raw·요약 시도의 실패는 남기지 않으며 기록 실패가 원래 오류를 가리지 않는다. 자동 재전송은 없다. +- 이 기능 전의 send 줄에는 `instructions`가 없다. 그래서 적용 뒤 각 감독의 첫 우편은 전문이다(정상). +- `mailbox read`가 보여 주는 지침은 요약하지 않고 항상 전문이다. + +측정(데이터를 뽑는 방법까지만; 누락·재작업 판단은 사람이 한다). send 원장 줄의 `instructions.mode`와 `bytes`를 쓴다. + +``` +node --input-type=module -e "import {readLedger} from './src/ledger.mjs'; +const rows=readLedger().filter(e=>e.kind==='send'&&e.instructions&&e.t>'2026-10-17'); +const by={};for(const e of rows){const k=e.role+' '+e.instructions.mode;by[k]??={n:0,bytes:0};by[k].n++;by[k].bytes+=e.bytes;} +console.table(by);" +``` + +기간은 `e.t` 문자열 비교로 거른다. 이유별 수는 키를 `e.instructions.reason`으로 바꿔 본다. `kadan log`는 최근 30줄만 보여 주므로 집계에는 쓰지 않는다. diff --git a/skills/kadan-conductor/references/role-templates/conductor-summary.md b/skills/kadan-conductor/references/role-templates/conductor-summary.md new file mode 100644 index 00000000..af414554 --- /dev/null +++ b/skills/kadan-conductor/references/role-templates/conductor-summary.md @@ -0,0 +1,5 @@ +## 지침 요약 (이번 우편은 요약본입니다) + +- 최신 사용자 지시·승인 범위·보류를 지킨다. 이 지침은 새 권한·발령·업무 완료를 만들지 않으며, 전달·저장·확인을 착수·성공·완료로 확대하지 않는다. +- 행동 전에 해당 규칙 파일의 그 절을 다시 읽는다. 전문: 아래 '참고할 실제 파일'의 common.md · conductor.md. +- 전문은 새 세대 첫 우편·현재 발령·규칙 변경·전달 불명확·마지막 전문 전달 뒤 1시간 경과 때 다시 온다. diff --git a/src/cli-guarded-send.mjs b/src/cli-guarded-send.mjs index 21336cf5..fd4c32e7 100644 --- a/src/cli-guarded-send.mjs +++ b/src/cli-guarded-send.mjs @@ -88,7 +88,7 @@ export function guardedSend({ const sourceCwd = entries.filter(e=>e.kind==='start'&&e.role===role&&e.cwd).at(-1)?.cwd ?? null; const originalCardPath = extractCardPath(message,sourceCwd); const composed = composeRoleInstructions({home:env.KADAN_HOME||env.KADAN_LITE_HOME||ledgerHome(), - role,message,profile:roleProfile,raw,taskId,mailContext,entries,cards}); + role,message,profile:roleProfile,raw,taskId,mailContext,entries,cards,slim:true}); message = composed.message; if (!raw) { const instructions=composeMailInstructions({mailId,recipient:role,notificationOnly,dispatch:taskId!=null,expectReply:mailContext?.expectReply===true,sender:by,env}); @@ -153,6 +153,17 @@ export function guardedSend({ receipt = selectedFloor.send(session, message, {cardIdentity, notificationGuard, busyMail}) || {}; } catch (error) { if (!["not-sent", "unknown"].includes(error.delivery)) error.delivery = "unknown"; + // 실제 바닥은 전송 실패를 영수증이 아니라 예외로 던진다. 전문 시도가 실패했다는 사실만 별도 kind로 남겨 + // 다음 판정이 delivery-unclear를 보게 한다. send가 아니므로 발령·우편함·재안내·중복 방지가 전달로 보지 않는다. + // 기록 실패는 원래 오류를 가리지 않는다. 자동 재전송은 없다. + const attempted = composed.metadata.status === "applied" && composed.metadata.instructionsMode === "full"; + if (attempted) { + try { + record({kind: "send-failed", role, session, keyDelivery: error.delivery, floor: selectedFloor.name, + instructions: {mode: "full", reason: composed.metadata.instructionsReason, profile: composed.metadata.profile, + templateDigest: composed.metadata.templateDigest, generation: composed.metadata.generation}}); + } catch { /* 원래 오류를 던진다 */ } + } throw error; } // --raw는 우편 ID·ack 안내를 붙이지 않고 본문 전체를 창에 넣는다. 창 전달이 성공했으면 수신자 직접 ack와 @@ -174,6 +185,8 @@ export function guardedSend({ role, session, roleInstructions: composed.metadata, + ...(composed.metadata.status==='applied'?{instructions:{mode:composed.metadata.instructionsMode,reason:composed.metadata.instructionsReason, + profile:composed.metadata.profile,templateDigest:composed.metadata.templateDigest,generation:composed.metadata.generation}}:{}), originalCardPath, ...(composed.metadata.profile ? {roleProfile:composed.metadata.profile} : {}), ...(runnerGuard?.ledger ? {runnerGuard:runnerGuard.ledger} : {}), diff --git a/src/mail-generation.mjs b/src/mail-generation.mjs index d0573cc5..b90845b0 100644 --- a/src/mail-generation.mjs +++ b/src/mail-generation.mjs @@ -36,7 +36,7 @@ export function createMailGenerations() { const ms = typeof at === 'number' ? at : Date.parse(at); return Number.isFinite(ms) && (boundTimes.get(role) ?? []).some(bound => bound > ms); }; - return {apply, isPrevious, isPreviousAt, hasStarted: role => seen.has(role)}; + return {apply, isPrevious, isPreviousAt, hasStarted: role => seen.has(role), generationOf: role => (bounds.get(role) ?? []).length}; } // 이전 세대 미확인: 읽음 미확인이면서 기록용 알림이 아니고 새 세대가 시작되기 전의 편지. diff --git a/src/role-instructions.mjs b/src/role-instructions.mjs index ac4b68bf..6c4375bb 100644 --- a/src/role-instructions.mjs +++ b/src/role-instructions.mjs @@ -12,6 +12,8 @@ import {activeHierarchyPath} from './hierarchy-register.mjs'; import {parseHierarchy} from './hierarchy.mjs'; import {effectiveCardRole,workEntries} from './handover-state.mjs'; import {shellQuote} from './mail-instructions.mjs'; +import {createMailGenerations} from './mail-generation.mjs'; +import {boardFromRole} from './board.mjs'; export const ROLE_PROFILES = ['secretary','super','conductor','worker','reviewer']; const root = fileURLToPath(new URL('..',import.meta.url)); @@ -19,6 +21,13 @@ const templateRoot = path.join(root,'skills/kadan-conductor/references/role-temp const hash = value => createHash('sha256').update(value).digest('hex'); const object = value => value && typeof value === 'object' && !Array.isArray(value); const roleName = value => typeof value === 'string' && /^[\p{L}\p{N}_-]+$/u.test(value); +// 시험값(kyle 결정 cc77fcc2, 시니어 자문 452d380f): 요약 우편은 일반감독(conductor)에게만 보내고, +// 마지막 전달 성공 전문 뒤 1시간이 지나면 전문을 다시 보낸다. 설정·환경변수 스위치는 두지 않는다(되돌리기는 PR 되돌리기). +export const SUMMARY_PROFILES = new Set(['conductor']); +export const FULL_INTERVAL_MS = 60*60*1000; +// 시험 대상 판은 레포 kadan뿐이다(감독 범위 조정, 슈퍼감독 우편 69d524ff). 판 판정은 이름(boardFromRole)과 +// 레포(연결 업무·카드의 repo, 없으면 수신자 start cwd의 ~/Dev/<레포>)가 둘 다 kadan일 때만 인정한다. +export const SUMMARY_REPO = 'kadan'; const finishedMarker = /KADAN:DONE\s+\S+\s+(?:ok|failed)(?=\s|$)/u; // Markdown의 탭·LF·CRLF만 허용한다. ANSI/C0/C1과 단독 CR은 터미널 표시를 바꿀 수 있다. const terminalControl = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]|\r(?!\n)/u; @@ -189,7 +198,39 @@ export function inferCardPhaseProfile({home,role,taskId,mailContext,entries=read } catch { return null; } } -export function composeRoleInstructions({home,role,message='',profile,raw=false,taskId,mailContext,entries=readLedger(home),cards}) { +// 전문(full)을 보낼지 요약(summary)을 보낼지 정한다. 이미 읽은 entries만 거르고 원장을 새로 읽지 않는다. +// '전달 성공'은 keyDelivery==='sent'만이다. 세대는 mail-generation의 start 경계를 그대로 쓴다. +function trialBoard(role,repos) { + const board=boardFromRole(role),set=new Set(repos.filter(Boolean)); + if (!board||set.size!==1) return 'unknown'; + const byName=board===SUMMARY_REPO||board.startsWith(`${SUMMARY_REPO}-`),byRepo=set.has(SUMMARY_REPO); + if (byName!==byRepo) return 'unknown'; + return byRepo?'kadan':'other'; +} +const cwdRepo=cwd=>{const m=typeof cwd==='string'&&cwd.match(/\/Dev\/([^/]+)/u);return m&&!m[1].startsWith('_')?m[1]:null;}; +function decideInstructionsMode({role,profile,templateDigest,entries,current,unavailable,now,repos=[]}) { + const gens=createMailGenerations(); + entries.forEach((e,i)=>gens.apply(e,i)); + const generation=gens.generationOf(role); + const full=reason=>({mode:'full',reason,generation}); + if (!SUMMARY_PROFILES.has(profile)) return full('profile-not-in-trial'); + if (unavailable.length) return full('facts-unavailable'); + const board=trialBoard(role,repos); + if (board==='unknown') return full('facts-unavailable'); + if (board==='other') return full('board-not-in-trial'); + if (current) return full('dispatch'); + const fulls=entries.map((e,i)=>({e,i})).filter(({e,i})=>(e.kind==='send'||e.kind==='send-failed')&&e.role===role&&e.instructions?.mode==='full'&&!gens.isPrevious(role,i)).map(({e})=>e); + const last=fulls.at(-1); + if (!last) return full('generation-first'); + if (last.keyDelivery!=='sent') return full('delivery-unclear'); + if (last.instructions.profile!==profile) return full('profile-changed'); + if (last.instructions.templateDigest!==templateDigest) return full('template-changed'); + const elapsed=now-Date.parse(last.t); + if (!Number.isFinite(elapsed)||elapsed>=FULL_INTERVAL_MS) return full('interval'); + return {mode:'summary',reason:'within-interval',generation}; +} + +export function composeRoleInstructions({home,role,message='',profile,raw=false,taskId,mailContext,entries=readLedger(home),cards,slim=false,now=Date.now()}) { try { const config=readRoleInstructionsConfig(home); if (raw) return {message,instructions:null,metadata:{status:'not-applied',reason:'explicit-raw',...(config.configDigest?{configDigest:config.configDigest}: {})}}; @@ -228,12 +269,17 @@ export function composeRoleInstructions({home,role,message='',profile,raw=false, lines.push('마커 형식: KADAN:DONE <현재카드id> . ID와 결과는 완료 시 조립한다.'); } else lines.push('이 편지만으로 새 실행을 시작하거나 과거 완료 ID의 DONE을 다시 출력하지 않는다. 연결 ID는 조회 근거이며 현재 발령을 뜻하지 않는다.'); if (['super','conductor'].includes(selected.profile)) lines.push('완료 수신 다음 행동: 근거 확인 → 아직 확정되지 않은 실행만 done 기록 → 빠진 검수·후속 연결. 전체 완료 조건 충족을 확인한 책임 owner만 최신 work show revision으로 work complete한다. PASS만으로 업무를 자동 종료하지 않는다.'); - const instructions=['',...templates.map(t=>t.text),...lines, + const templateDigest=hash(JSON.stringify(templates.map(t=>[t.name,t.digest]))); + const decision=slim?decideInstructionsMode({role,profile:selected.profile,templateDigest,entries,now,unavailable:context.unavailable, + current:Boolean(current),repos:[work?.repo,card?.repo,cwdRepo(entries.filter(e=>e.kind==='start'&&e.role===role&&e.cwd).at(-1)?.cwd)]}):{mode:'full',reason:'not-slim',generation:null}; + // 요약일 때도 템플릿은 위에서 읽었다(지문 비교용). 줄어드는 것은 메시지뿐이다. + const body=decision.mode==='summary'?[readFile(path.join(templateRoot,`${selected.profile}-summary.md`),{template:true}).text]:templates.map(t=>t.text); + const instructions=['',...body,...lines, '참고할 실제 파일 (이번 역할과 행동에 필요한 절만 적용):',...templates.map(t=>t.path),...referencePaths(selected.profile),''].join('\n\n'); if (finishedMarker.test(instructions)) throw failure('생성된 지침에 완성된 DONE 마커가 있습니다'); const metadata={status:'applied',profile:selected.profile,source:selected.source,digest:hash(instructions), ...(context.unavailable.length?{unavailable:context.unavailable}: {}), - templateDigest:hash(JSON.stringify(templates.map(t=>[t.name,t.digest]))),templates:templates.map(({name,path,digest})=>({name,path,digest})), + templateDigest,instructionsMode:decision.mode,instructionsReason:decision.reason,generation:decision.generation,templates:templates.map(({name,path,digest})=>({name,path,digest})), ...(config.configDigest?{configPath:config.configPath,configDigest:config.configDigest}: {})}; return {message:message+'\n\n'+instructions,instructions,metadata}; } diff --git a/tests/role-instructions-slim.test.mjs b/tests/role-instructions-slim.test.mjs new file mode 100644 index 00000000..a4f5a1bd --- /dev/null +++ b/tests/role-instructions-slim.test.mjs @@ -0,0 +1,237 @@ +// 역할 우편 지침 축소 시험(kadan 판 conductor만 요약). 운영 이름 대신 가짜 역할 이름과 격리 홈만 쓴다. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {composeRoleInstructions,FULL_INTERVAL_MS} from '../src/role-instructions.mjs'; +import {guardedSend} from '../src/cli.mjs'; +import {readLedger,readMailLedger,readTaskLedger,findMailBySourceKeyAny} from '../src/ledger.mjs'; +import {Mailbox} from '../src/mailbox.mjs'; + +const KADAN_ROLE_NAME = 'kadan-시험판-감독', OTHER_ROLE_NAME = '타레포-시험-감독'; +const NOW = Date.parse('2026-10-16T12:00:00Z'); +const iso = ms => new Date(ms).toISOString(); +const home = () => fs.mkdtempSync(path.join(os.tmpdir(), 'kadan-slim-')); +const start = (role, t = NOW - 3 * FULL_INTERVAL_MS, extra = {}) => ({kind: 'start', role, session: `kadan-${role}`, panePid: '100', cmd: 'x', cwd: '/x/Dev/kadan', t: iso(t), ...extra}); +const fullSend = (role, f, extra = {}) => ({kind: 'send', role, keyDelivery: 'sent', t: iso(NOW - 600_000), + instructions: {mode: 'full', reason: 'x', profile: 'conductor', templateDigest: f.digest, generation: 0}, ...extra}); +function compose(role, {entries = [], profile = 'conductor', home: h = home(), ...rest} = {}) { + return composeRoleInstructions({home: h, role, message: '본문', profile, entries, slim: true, now: NOW, ...rest}); +} +// 같은 격리 홈에서 지문을 얻는다. +const digestOf = (role, h) => composeRoleInstructions({home: h, role, profile: 'conductor', entries: [start(role)], slim: true, now: NOW}).metadata.templateDigest; +function setup(role = KADAN_ROLE_NAME) { + const h = home(), f = {digest: digestOf(role, h)}; + return {h, f, entries: [start(role), fullSend(role, f)]}; +} +const meta = r => [r.metadata.instructionsMode, r.metadata.instructionsReason]; + +test('1 세대에 full 없음 → full/generation-first', () => { + const r = compose(KADAN_ROLE_NAME, {entries: [start(KADAN_ROLE_NAME)]}); + assert.deepEqual(meta(r), ['full', 'generation-first']); + assert.match(r.message, /kadan:receiver-instructions/); +}); + +test('2 전달 성공 full 1시간 안 → summary(B 줄·금지선 두 줄·전문 경로 두 개)', () => { + const s = setup(), r = compose(KADAN_ROLE_NAME, {home: s.h, entries: s.entries}); + assert.deepEqual(meta(r), ['summary', 'within-interval']); + assert.match(r.instructions, /수신 역할: kadan-시험판-감독/); + assert.match(r.instructions, /최신 사용자 지시·승인 범위·보류를 지킨다/); + assert.match(r.instructions, /확대하지 않는다/); + assert.match(r.instructions, /role-templates\/common\.md/); + assert.match(r.instructions, /role-templates\/conductor\.md/); + assert.doesNotMatch(r.instructions, /herdr 화면 확인/); // 템플릿 전문은 빠진다 + assert.ok(r.metadata.templateDigest); +}); + +test('3 현재 발령 → full/dispatch', () => { + const s = setup(), h = s.h; + // 발령 카드: 격리 홈의 카드로 current 분기를 만든다 + const {CardStore} = awaitImport(); + const store = new CardStore(h), c = store.create({repo: 'kadan', id: 'cur', repoPath: h, body: '# 시험'}); + const a = store.update(c.key, {status: 'assigned', role: KADAN_ROLE_NAME, board: 'kadan-시험판', scope: 's', rallyId: 'r', rallyTitle: 't', rallyRound: '1', rallyStep: 'implementation'}, {revision: c.revision, note: '배정'}); + const r = compose(KADAN_ROLE_NAME, {home: h, entries: s.entries, taskId: a.id, mailContext: {executionKey: a.key}}); + assert.deepEqual(meta(r), ['full', 'dispatch']); +}); + +test('4 직전 full이 not-sent·unknown → full/delivery-unclear', () => { + for (const keyDelivery of ['not-sent', 'unknown']) { + const s = setup(), entries = [start(KADAN_ROLE_NAME), fullSend(KADAN_ROLE_NAME, s.f, {keyDelivery})]; + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries})), ['full', 'delivery-unclear']); + } +}); + +test('5 full 뒤 1시간 넘음 → full/interval', () => { + const s = setup(), old = fullSend(KADAN_ROLE_NAME, s.f, {t: iso(NOW - FULL_INTERVAL_MS - 1000)}); + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries: [start(KADAN_ROLE_NAME), old]})), ['full', 'interval']); +}); + +test('6 템플릿 수정 → full/template-changed', () => { + const s = setup(), custom = path.join(s.h, 'conductor.md'); + fs.writeFileSync(custom, '# 격리 conductor 지침 v2'); + fs.writeFileSync(path.join(s.h, 'role-instructions.json'), JSON.stringify({version: 1, templates: {conductor: custom}})); + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries: s.entries})), ['full', 'template-changed']); +}); + +test('7 프로필 변경 → full/profile-changed', () => { + const s = setup(), entries = [start(KADAN_ROLE_NAME), fullSend(KADAN_ROLE_NAME, s.f, {instructions: {mode: 'full', reason: 'x', profile: 'super', templateDigest: s.f.digest, generation: 0}})]; + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries})), ['full', 'profile-changed']); +}); + +test('8 같은 역할 새 start(새 대화) → full/generation-first', () => { + const s = setup(), restarted = start(KADAN_ROLE_NAME, NOW - 300_000, {panePid: '200', conversationMode: 'new'}); + const r = compose(KADAN_ROLE_NAME, {home: s.h, entries: [...s.entries, restarted]}); + assert.deepEqual(meta(r), ['full', 'generation-first']); + assert.equal(r.metadata.generation, 1); +}); + +test('9 worker·reviewer·super 수신자는 항상 full', () => { + for (const profile of ['worker', 'reviewer', 'super']) { + const role = 'kadan-시험판-' + profile, h = home(); + const f = {digest: composeRoleInstructions({home: h, role, profile, entries: [], slim: true, now: NOW}).metadata.templateDigest}; + const entries = [start(role), {...fullSend(role, f), instructions: {mode: 'full', reason: 'x', profile, templateDigest: f.digest, generation: 0}}]; + const r = compose(role, {home: h, entries, profile}); + assert.deepEqual(meta(r), ['full', 'profile-not-in-trial']); + assert.match(r.instructions, /herdr 화면 확인|## Why/); + } +}); + +test('10 raw 전송 → instructions 없음, 이력 영향 없음', () => { + const s = setup(), raw = compose(KADAN_ROLE_NAME, {home: s.h, entries: s.entries, raw: true}); + assert.equal(raw.metadata.status, 'not-applied'); + assert.equal(raw.metadata.instructionsMode, undefined); + // raw 줄(instructions 없음)이 사이에 있어도 요약 판정은 그대로다 + const rawEntry = {kind: 'send', role: KADAN_ROLE_NAME, keyDelivery: 'sent', t: iso(NOW - 1000), roleInstructions: raw.metadata}; + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries: [...s.entries, rawEntry]})), ['summary', 'within-interval']); +}); + +test('11 context.unavailable(원장 손상) → full/facts-unavailable', () => { + const s = setup(); + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries: [...s.entries, {broken: '손상'}]})), ['full', 'facts-unavailable']); +}); + +test('12 guardedSend 원장 줄에 instructions 객체와 keyDelivery', () => { + const h = home(), env = {KADAN_HOME: h}; + const floor = {name: 'fake', alive: () => true, pid: () => '100', send: () => ({keyDelivery: 'sent', inputAcceptance: 'accepted'})}; + const send = () => guardedSend({floor, session: `kadan-${KADAN_ROLE_NAME}`, role: KADAN_ROLE_NAME, message: '안녕', roleProfile: 'conductor', recordedPid: '100', env, + readEntries: () => readLedger(h), record: e => appendLedger(e, h), saveBody: () => {}}); + const prior = {kind: 'start', role: KADAN_ROLE_NAME, session: `kadan-${KADAN_ROLE_NAME}`, panePid: '100', cmd: 'x', cwd: '/x/Dev/kadan'}; + fs.mkdirSync(h, {recursive: true}); + appendEntry(h, prior); + const first = send(), second = send(); + assert.equal(first.keyDelivery, 'sent'); + assert.deepEqual(Object.keys(first.instructions).sort(), ['generation', 'mode', 'profile', 'reason', 'templateDigest']); + assert.deepEqual([first.instructions.mode, first.instructions.reason], ['full', 'generation-first']); + assert.deepEqual([second.instructions.mode, second.instructions.reason], ['summary', 'within-interval']); + assert.ok(second.bytes < first.bytes); + const stored = readLedger(h).filter(e => e.kind === 'send'); + assert.equal(stored.at(-1).instructions.mode, 'summary'); + assert.equal(stored.at(-1).keyDelivery, 'sent'); +}); + +test('14 다른 레포 판 conductor는 1시간 안이어도 full/board-not-in-trial', () => { + const h = home(), f = {digest: digestOf(OTHER_ROLE_NAME, h)}; + const entries = [start(OTHER_ROLE_NAME, undefined, {cwd: '/x/Dev/other-repo'}), fullSend(OTHER_ROLE_NAME, f)]; + assert.deepEqual(meta(compose(OTHER_ROLE_NAME, {home: h, entries})), ['full', 'board-not-in-trial']); +}); + +test('15 판정 불가(레포 근거 없음·이름과 레포 어긋남) → full/facts-unavailable', () => { + const s = setup(); + const noRepo = [start(KADAN_ROLE_NAME, undefined, {cwd: '/x/_worktrees/kadan-foo'}), fullSend(KADAN_ROLE_NAME, s.f)]; + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries: noRepo})), ['full', 'facts-unavailable']); + const mismatch = [start(KADAN_ROLE_NAME, undefined, {cwd: '/x/Dev/other-repo'}), fullSend(KADAN_ROLE_NAME, s.f)]; + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries: mismatch})), ['full', 'facts-unavailable']); + assert.deepEqual(meta(compose('감독없는이름', {home: s.h, entries: s.entries})), ['full', 'facts-unavailable']); +}); + +// 실제 바닥처럼 영수증이 아니라 delivery를 단 예외를 던지는 경로(F1). +function throwingFixture() { + const h = home(), role = KADAN_ROLE_NAME, session = `kadan-${role}`; + appendEntry(h, {kind: 'start', role, session, panePid: '100', cmd: 'x', cwd: '/x/Dev/kadan'}); + const state = {fail: null, messages: []}; + const floor = {name: 'fake', alive: () => true, pid: () => '100', send: (_s, m) => { + state.messages.push(m); + if (state.fail) { const e = new Error('바닥 전달 실패'); e.delivery = state.fail; throw e; } + return {keyDelivery: 'sent', inputAcceptance: 'accepted'}; + }}; + const send = (extra = {}) => guardedSend({floor, session, role, message: '본문', roleProfile: 'conductor', recordedPid: '100', + env: {KADAN_HOME: h}, readEntries: () => readLedger(h), record: e => appendLedger(e, h), saveBody: () => {}, ...extra}); + return {h, role, state, send}; +} + +test('16 던져진 not-sent·unknown 전문 실패 → 다음 일반 우편 full/delivery-unclear, 성공하면 요약으로 복귀', () => { + for (const delivery of ['not-sent', 'unknown']) { + const t = throwingFixture(); + assert.equal(t.send().instructions.mode, 'full'); + t.state.fail = delivery; + // 템플릿을 바꿔 두 번째도 전문 시도가 되게 한다 + const custom = path.join(t.h, 'conductor.md'); + fs.writeFileSync(custom, '# 격리 conductor 지침'); + fs.writeFileSync(path.join(t.h, 'role-instructions.json'), JSON.stringify({version: 1, templates: {conductor: custom}})); + assert.throws(() => t.send(), e => e.delivery === delivery); + const failed = readLedger(t.h).filter(e => e.kind === 'send-failed'); + assert.equal(failed.length, 1); + assert.equal(failed[0].keyDelivery, delivery); + assert.equal(failed[0].instructions.mode, 'full'); + t.state.fail = null; + const next = t.send(); + assert.deepEqual([next.instructions.mode, next.instructions.reason], ['full', 'delivery-unclear']); + assert.deepEqual([t.send().instructions.mode], ['summary']); + } +}); + +test('17 실패 기록 뒤에도 template-changed·interval은 마지막 성공 전문 기준으로 동작', () => { + const t = throwingFixture(); + t.send(); t.state.fail = 'unknown'; + fs.writeFileSync(path.join(t.h, 'c.md'), '# v2'); + fs.writeFileSync(path.join(t.h, 'role-instructions.json'), JSON.stringify({version: 1, templates: {conductor: path.join(t.h, 'c.md')}})); + assert.throws(() => t.send()); + t.state.fail = null; assert.equal(t.send().instructions.reason, 'delivery-unclear'); + fs.writeFileSync(path.join(t.h, 'c.md'), '# v3'); + assert.equal(t.send().instructions.reason, 'template-changed'); + // interval: 실패 줄이 끼어 있어도 마지막 성공 전문 뒤 1시간이 지나면 interval + const s = setup(), old = fullSend(KADAN_ROLE_NAME, s.f, {t: iso(NOW - FULL_INTERVAL_MS - 5)}); + const failedLine = {kind: 'send-failed', role: KADAN_ROLE_NAME, keyDelivery: 'unknown', t: iso(NOW - 1000), instructions: {mode: 'full', reason: 'x', profile: 'conductor', templateDigest: s.f.digest, generation: 0}}; + const ok = {...old, t: iso(NOW - FULL_INTERVAL_MS - 5)}; + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries: [start(KADAN_ROLE_NAME), ok, failedLine, {...ok, t: iso(NOW - FULL_INTERVAL_MS - 4)}]})), ['full', 'interval']); +}); + +test('18 raw·요약 실패는 기록하지 않고, 기록 실패는 원래 오류를 가리지 않는다', () => { + const t = throwingFixture(); + t.send(); t.state.fail = 'not-sent'; + assert.throws(() => t.send({raw: true}), e => e.delivery === 'not-sent'); + assert.throws(() => t.send(), e => e.delivery === 'not-sent'); // 요약 시도 + assert.equal(readLedger(t.h).filter(e => e.kind === 'send-failed').length, 0); + fs.writeFileSync(path.join(t.h, 'c.md'), '# v2'); + fs.writeFileSync(path.join(t.h, 'role-instructions.json'), JSON.stringify({version: 1, templates: {conductor: path.join(t.h, 'c.md')}})); + assert.throws(() => t.send({record: () => { throw new Error('기록 실패'); }}), e => e.delivery === 'not-sent' && e.message === '바닥 전달 실패'); +}); + +test('19 소비자 감사: send-failed는 우편함·발령·중복 방지·메일 영역에 잡히지 않는다', () => { + const t = throwingFixture(); + t.state.fail = 'unknown'; + assert.throws(() => t.send()); + const all = readLedger(t.h); + assert.equal(all.filter(e => e.kind === 'send').length, 0); + assert.equal(all.filter(e => e.kind === 'send-failed').length, 1); + assert.ok(!all.find(e => e.kind === 'send-failed').mailId); + assert.equal(readMailLedger(t.h).filter(e => e.kind === 'send-failed').length, 0); + assert.equal(readTaskLedger(t.h).filter(e => e.kind === 'send-failed').length, 0); + assert.equal(new Mailbox(t.h, t.role).list({all: true, view: 'sent'}).length, 0); + assert.equal(new Mailbox(t.h, t.role).list({all: true}).length, 0); + assert.equal(findMailBySourceKeyAny(t.h, 'x'), null); +}); + +test('20 60분 경계: -1ms 요약 / 정확히 60분 전문 / +1ms 전문', () => { + const s = setup(); + for (const [delta, expected] of [[-1, 'summary'], [0, 'full'], [1, 'full']]) { + const sent = fullSend(KADAN_ROLE_NAME, s.f, {t: iso(NOW - FULL_INTERVAL_MS - delta)}); + assert.equal(compose(KADAN_ROLE_NAME, {home: s.h, entries: [start(KADAN_ROLE_NAME), sent]}).metadata.instructionsMode, expected, `delta ${delta}`); + } +}); + +import {CardStore} from '../src/card-store.mjs'; +import {appendLedger} from '../src/ledger.mjs'; +function awaitImport() { return {CardStore}; } +function appendEntry(h, entry) { appendLedger(entry, h); }