Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/role-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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줄만 보여 주므로 집계에는 쓰지 않는다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## 지침 요약 (이번 우편은 요약본입니다)

- 최신 사용자 지시·승인 범위·보류를 지킨다. 이 지침은 새 권한·발령·업무 완료를 만들지 않으며, 전달·저장·확인을 착수·성공·완료로 확대하지 않는다.
- 행동 전에 해당 규칙 파일의 그 절을 다시 읽는다. 전문: 아래 '참고할 실제 파일'의 common.md · conductor.md.
- 전문은 새 세대 첫 우편·현재 발령·규칙 변경·전달 불명확·마지막 전문 전달 뒤 1시간 경과 때 다시 온다.
15 changes: 14 additions & 1 deletion src/cli-guarded-send.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -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와
Expand All @@ -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} : {}),
Expand Down
2 changes: 1 addition & 1 deletion src/mail-generation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
}

// 이전 세대 미확인: 읽음 미확인이면서 기록용 알림이 아니고 새 세대가 시작되기 전의 편지.
Expand Down
52 changes: 49 additions & 3 deletions src/role-instructions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,22 @@ 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));
const templateRoot = path.join(root,'skills/kadan-conductor/references/role-templates');
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;
Expand Down Expand Up @@ -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}: {})}};
Expand Down Expand Up @@ -228,12 +269,17 @@ export function composeRoleInstructions({home,role,message='',profile,raw=false,
lines.push('마커 형식: KADAN:DONE <현재카드id> <ok|failed>. 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=['<!-- kadan:receiver-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=['<!-- kadan:receiver-instructions -->',...body,...lines,
'참고할 실제 파일 (이번 역할과 행동에 필요한 절만 적용):',...templates.map(t=>t.path),...referencePaths(selected.profile),'<!-- /kadan:receiver-instructions -->'].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};
}
Expand Down
Loading
Loading