gajae-code · Gajae 고유: 이중 영수증·워크플로 상태·모호성 게이팅
gajae-code · Gajae 고유: 이중 영수증·워크플로 상태·모호성 게이팅
한 줄 요약
gajae-code는 긴 작업의 기억을 프로젝트 안 .gjc/ 서랍에 저장하면서, 화면에 찍는 짧은 확인증과 디스크에 박는 위·변조 방지 도장 두 가지 영수증을 분업시키고, 워크플로마다 “함부로 못 넘어가게” 게이트를 건다.
왜 배우나 — AI 에이전트가 상태를 안전하게 저장하고, 토큰을 낭비하지 않으며, 사람의 승인 없이 폭주하지 않게 만드는 “기억과 잠금” 설계를 한눈에 익히기 위해서다.
그림
flowchart LR
T[명령 트리거] --> W["state-writer<br/>승인된 단일 작성자"]
W -->|"WorkflowStateReceipt 박기 + sha256 도장"| G{"RequiredOnWrite<br/>엄격 검증"}
G -->|실패| X["쓰기 거부 throw<br/>fail-closed"]
G -->|통과| D["(.gjc/** 안전 저장<br/>atomic rename)"]
T --> S["renderCliWriteReceipt<br/>화면용 짧은 확인증<br/>본문 에코 금지"]
D -->|다음 턴 너그럽게 읽기| R["fresh/stale 재판정<br/>→ HUD 표시"]
쉽게 풀기
영수증이 왜 두 개일까
가게에서 카드를 긁으면 영수증이 두 장 나온다고 생각해 보자. 한 장은 손님이 받는 짧은 확인증(“결제 OK, 승인번호 1234”), 다른 한 장은 가게가 보관하는 정식 매출 전표(누가·언제·얼마·서명). gajae-code도 똑같이 두 가지 영수증을 쓰는데, 이름은 둘 다 “receipt”지만 역할이 정반대다.
CliWriteReceipt(손님용 짧은 확인증) — 명령을 실행한 뒤 화면(stdout)에 찍는다. 핵심은 “방금 저장한 본문을 다시 토해내지 않는다”는 것. 손님은 이미 영수증을 들고 있는데 가게가 거래 내역 전체를 다시 읽어주면 시간 낭비이듯, 모델에게 이미 가진 내용을 되돌려주면 토큰 낭비이자 누수다. 그래서run_id,state_path,sha256같은 “어디에 뭐가 저장됐는지 가리키는 표지판”만 압축해서 보낸다.WorkflowStateReceipt(가게 보관용 정식 전표) — 디스크에 저장되는 상태 봉투(envelope) 안에 박히는 도장이다. 누가(owner)·언제(mutated_at)·어떤 명령으로(command) 썼는지, 내용이 변조되지 않았다는 지문(content_sha256), 그리고 “이 기록이 아직 신선한가, 상했는가(status)“를 담는다.
”신선도”라는 개념
우유에 유통기한이 있듯, 상태 기록에도 신선도가 있다. 쓸 때 도장에 “30분 뒤까지 신선”(fresh_until)이라고 찍어두고, 나중에 읽을 때 현재 시각과 비교해 30분이 지났으면 stale(상함) 로 다시 판정한다. 상한 상태는 HUD(화면 상태표시)에 경고로 떠서 “이 기억은 오래됐으니 믿고 진행하기 전에 다시 확인하라”고 알린다.
읽기는 너그럽게, 쓰기는 깐깐하게
이게 이 설계의 시그니처다. 비유하면 들어올 때는 프리패스, 나갈 때는 검색대다.
- 읽기(lenient) — 옛날 버전이거나 필드가 좀 빠진 상태 파일이라도 거부하지 않고 일단 받아들인다. 파일이 깨졌으면 크래시 대신
{ok:false}로 조용히 넘기고 로그만 남긴다(fail-open). 구버전과의 호환을 위해서다. - 쓰기(strict) — 저장하기 직전에는 체크섬 도장을 포함한 필수 필드를 전부 확인하고, 하나라도 틀리면 아예 쓰기를 거부하고 멈춘다(fail-closed). 잘못된 기록이 디스크에 남는 것이 제일 위험하기 때문이다.
모호성 게이팅 — “헷갈리면 다음 단계로 못 간다”
deep-interview 워크플로는 사람을 인터뷰해 요구사항을 또렷하게 만드는 과정이다. 여기엔 수학적 규칙이 하나 있다. 질문 라운드에 “아직 모호함이 남았다(active)“는 신호가 달려 있으면, 직전보다 모호성 점수가 반드시 올라야 하고 해당 항목의 명확도는 좋아지면 안 된다. 거꾸로 들리지만 뜻은 분명하다 — “모호함이 줄어들었다는 게 증명되지 않으면 실행 승인 게이트를 못 연다.” 즉 진행을 함부로 통과시키지 않는 안전장치다.
핵심 정리
| 영수증 | 어디에 | 핵심 규약 |
|---|---|---|
CliWriteReceipt | 화면(stdout) | 라우팅 필드만 압축, 본문 에코 금지 |
WorkflowStateReceipt | 디스크 봉투 안 | sha256 도장 + 신선도 + 출처 기록 |
[!note] 읽기 vs 쓰기 게이트 (이 설계의 시그니처)
- 읽기 = lenient / fail-open:
.passthrough()+ 거의 모든 필드 optional → 진화·구버전 상태를 거부하지 않음. 깨진 파일은{ok:false}로 크래시 없이 정규화/로그.- 쓰기 = strict / fail-closed:
RequiredOnWriteEnvelopeSchema가content_sha256포함 필수 필드를 anchor. 검증 실패 시 throw로 쓰기 거부.
[!note] 4개 워크플로의 게이트 한 줄 정리
- deep-interview — active 트리거면 ambiguity 단조증가 강제, corrupt 상태는 덮지 않고 throw로 보존.
- ralplan — Planner→Architect→Critic 산출물을
pending-approval.md로 떼어내 사람 승인 대기.- ultragoal — 모든 사건을
ledger.jsonl원장에 append, complete는 엄격한 quality-gate 통과 필요.- team — 작업 본문도 stdout에 에코하지 않는 동일 규약 적용.
실제 예시
(a) CliWriteReceipt — 화면용 짧은 확인증 (본문 에코 금지)
| 이름 | 타입 | 필수 | 설명 |
|---|---|---|---|
ok | boolean | 명령 성공 여부 | |
[field: string] | unknown | 라우팅/감사용 임의 필드(예: run_id, goal_id, state_path, sha256). undefined는 직렬화 시 제거 |
핵심 규약(파일 주석): 영속 본문(state 봉투 전체, ultragoal plan, team task 본문, ralplan task)을 절대 다시 echo 하지 않는다. 호출자는 이미 그 내용을 들고 있으므로 되돌려주면 토큰 누수다. 이것은 영속 스키마가 아니며, 디스크 봉투를 이걸로 검증해서도 안 된다.
// packages/coding-agent/src/gjc-runtime/cli-write-receipt.ts
export interface CliWriteReceipt {
ok: boolean;
[field: string]: unknown;
}
export function renderCliWriteReceipt(receipt: Record<string, unknown>): string {
const out: Record<string, unknown> = {};
for (const key of Object.keys(receipt)) {
if (receipt[key] !== undefined) out[key] = receipt[key];
}
return `${JSON.stringify(out)}\n`;
}
(b) WorkflowStateReceipt — 디스크 봉투의 무결성 도장
| 이름 | 타입 | 필수(write strict) | 설명 |
|---|---|---|---|
version | number | 영수증 스키마 버전 (WORKFLOW_STATE_RECEIPT_VERSION = 1) | |
skill | enum deep-interview·ralplan·ultragoal·team | 어느 워크플로가 썼나 | |
owner | enum gjc-state-cli·gjc-runtime·gjc-hook | 어느 주체가 썼나 | |
command | string | 어떤 명령(gjc ralplan seed 등) | |
state_path | string | 활성 상태 파일 경로 | |
storage_path | string | 스킬별 저장 경로 | |
mutated_at | string(ISO) | 쓴 시각 | |
fresh_until | string(ISO) | mutated_at + 30분까지 신선 | |
status | enum fresh·stale | 쓸 때 항상 fresh로 박힘; 읽을 때 시각 비교로 재판정 | |
mutation_id | string | 변경 식별자(기본 ${skill}:${mutatedAt}) | |
content_sha256 | object(아래) | (write) / (read) | 봉투 본문 sha256 도장 |
verb/from_phase/to_phase/forced/paths | 다양 | 부가 감사 필드 |
content_sha256 하위(WorkflowStateContentChecksumSchema): algorithm(리터럴 "sha256"), value(string), covered_path(string), computed_at(string).
// packages/coding-agent/src/gjc-runtime/state-schema.ts
export const RequiredOnWriteEnvelopeSchema = z
.object({
skill: skillEnum,
version: z.literal(WORKFLOW_STATE_VERSION), // = 2
updated_at: z.string(),
current_phase: z.string(),
active: z.boolean(),
receipt: RequiredWorkflowStateReceiptSchema, // content_sha256 REQUIRED
})
.passthrough();
// packages/coding-agent/src/gjc-runtime/state-writer.ts (쓰기 게이트의 핵심)
export async function writeWorkflowEnvelopeAtomic(targetPath, value, options) {
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options)); // .gjc/** 밖이면 throw
const withReceipt = withWorkflowReceipt(value, buildReceipt(options));
const stamped = stampWorkflowEnvelopeChecksum(withReceipt, filePath); // sha256 도장
const parsed = RequiredOnWriteEnvelopeSchema.safeParse(stamped);
if (!parsed.success) {
throw new Error(`Refusing to write invalid workflow state envelope to ${filePath}: ...`);
}
await atomicWrite(filePath, jsonText(stamped)); // tmp 쓰고 atomic rename (락 없음)
await maybeAudit(filePath, options);
return filePath;
}
체크섬 계산은 receipt.content_sha256 자신을 제외(withoutReceiptChecksum)한 뒤 키 정렬 정규화(canonicalizeJson)해서 sha256 — 그래서 도장이 도장을 덮는 순환을 피하고 키 순서에 안정적이다. 신선도 상수: WORKFLOW_STATE_RECEIPT_FRESH_MS = 30 * 60 * 1000(30분).
deep-interview 모호성 단조증가 검증 (시그니처 게이트)
한 라운드가 active 트리거를 달고 있으면(=아직 모호함이 남았다는 신호), 직전 scored 라운드 대비 전체 ambiguity가 반드시 올라야 하고 해당 dimension 점수는 개선되면 안 된다. 못 올렸거나 메트릭이 없으면 violation → 통과 불가. disputed/unresolved는 면제되지만 rationale 필수.
// packages/coding-agent/src/gjc-runtime/deep-interview-recorder.ts
if (!(next.ambiguity > prior.ambiguity)) {
violations.push(`active trigger ${trigger.kind} did not raise ambiguity (${prior.ambiguity} -> ${next.ambiguity})`);
}
// ...
} else if (nextDim > priorDim) {
violations.push(`active trigger ${trigger.kind} on dimension "${trigger.dimension}" improved clarity ${priorDim} -> ${nextDim}`);
}
corrupt 상태는 fail-closed: 손상/변조된 deep-interview 상태는 덮어쓰지 않고 throw 해 복구용으로 보존한다(absent만 기본값으로 시작).
.gjc/ 영속 레이아웃 (실제 경로)
| 경로 | 무엇 |
|---|---|
.gjc/state/active/<skill>.json, .gjc/state/sessions/<id>/active/<skill>.json | 권위(authoritative) per-skill 활성 엔트리 |
.gjc/state/skill-active-state.json | 위에서 재생성되는 파생 캐시(스냅샷) |
.gjc/state/audit.jsonl | 모든 변경 감사 로그 |
.gjc/state/transactions/<mutation_id>.json | per-mutation 트랜잭션 저널(복구 증거, 글로벌 락 아님) |
.gjc/state/ralplan-state.json | ralplan run-state(run_id·phase) |
.gjc/plans/ralplan/<run-id>/stage-NN-<stage>.md + index.jsonl + pending-approval.md | ralplan 산출물·인덱스·승인 대기 게이트 |
.gjc/ultragoal/brief.md · goals.json · ledger.jsonl | ultragoal 계획·목표·원장 |
직접 만들 때 템플릿 — 봉투 + 무결성 도장 쓰기(최소)
import { stampWorkflowEnvelopeChecksum, workflowEnvelopeContentSha256 } from "./state-writer";
import { RequiredOnWriteEnvelopeSchema } from "./state-schema";
const envelope = {
skill: "ralplan",
version: 2, // WORKFLOW_STATE_VERSION
updated_at: new Date().toISOString(),
current_phase: "planner",
active: true,
receipt: { // owner/command/state_path/storage_path/mutated_at/fresh_until/status/mutation_id 필수
version: 1, skill: "ralplan", owner: "gjc-runtime",
command: "my-tool write", state_path: "...", storage_path: "...",
mutated_at: new Date().toISOString(),
fresh_until: new Date(Date.now() + 30*60*1000).toISOString(),
status: "fresh", mutation_id: "ralplan:demo",
},
};
const stamped = stampWorkflowEnvelopeChecksum(envelope, "/abs/.gjc/state/x.json");
if (!RequiredOnWriteEnvelopeSchema.safeParse(stamped).success) throw new Error("fail-closed");
compact stdout 영수증:
process.stdout.write(renderCliWriteReceipt({ ok: true, run_id, state_path, sha256 })); // 본문 X
요약 & 셀프체크
3줄 요약:
- 영수증은 두 개 — 화면용 짧은 확인증(
CliWriteReceipt, 본문 에코 금지)과 디스크용 무결성 도장(WorkflowStateReceipt, sha256 + 신선도)이다. - 게이트는 “읽기는 너그럽게(fail-open), 쓰기는 깐깐하게(fail-closed)” — 잘못된 기록이 디스크에 남는 걸 막는다.
- 워크플로마다 안전장치가 있다 — deep-interview는 모호성 단조증가, ralplan은
pending-approval.md승인 게이트, ultragoal은ledger.jsonl원장.
스스로 답해보기:
- 화면용 영수증에 저장한 본문을 다시 찍으면 왜 문제가 되나? (힌트: 토큰)
- 같은 상태 파일을 “읽을 때”와 “쓸 때” 검증 강도가 다른 이유는?
- deep-interview에서 “모호성이 올라가야 통과”가 왜 안전장치가 되나?
체크리스트:
- 모든
.gjc/**쓰기를 sanctioned writer 한 곳으로(경로는.gjc/안인지 검증, atomic rename, 락 없음). - 쓰기 = strict(체크섬 필수, 실패 시 throw). 읽기 = lenient(
.passthrough(), 깨지면 fail-open). - sha256은
content_sha256자신을 빼고 키 정렬 후 계산. - fresh_until = mutated_at + 30분; 읽을 때 status 재판정.
- stdout 영수증에 영속 본문(state/plan/task) 절대 에코 금지.
- deep-interview: active 트리거면 ambiguity 단조증가 + dimension 비개선 강제; corrupt면 throw.
- ralplan final 스테이지는
pending-approval.md로 게이팅. - ultragoal: 사건은 ledger append, complete는 quality-gate + 신선 스냅샷.
연결
근거 파일
- packages/coding-agent/src/gjc-runtime/cli-write-receipt.ts
- packages/coding-agent/src/gjc-runtime/state-schema.ts
- packages/coding-agent/src/gjc-runtime/state-writer.ts
- packages/coding-agent/src/gjc-runtime/ralplan-runtime.ts
- packages/coding-agent/src/gjc-runtime/deep-interview-recorder.ts
- packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts
- packages/coding-agent/src/gjc-runtime/workflow-manifest.ts
- packages/coding-agent/src/skill-state/workflow-state-contract.ts
- packages/coding-agent/src/skill-state/workflow-state-version.ts
- packages/coding-agent/src/defaults/gjc/skills/{deep-interview,ralplan,ultragoal,team}/SKILL.md
[!tip] Codex 교차검증 보존 원문 노트에는 별도의 Codex 교차검증 섹션이 존재하지 않았다. 이중 영수증 분업(쓰기=무결성 도장 / stdout=본문 에코 금지), 읽기 lenient·쓰기 strict 게이트, deep-interview 모호성 단조증가, ralplan
pending-approval.md게이트, ultragoalledger.jsonl원장은 모두 위 근거 파일에 직접 근거한 내용이며 추측으로 추가·변경하지 않았다.