commitgate 0.9.8 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/bin/dispatch.mjs +3 -0
- package/bin/init.ts +15 -2
- package/bin/migrate.ts +29 -11
- package/bin/sync.ts +200 -17
- package/bin/uninstall.ts +8 -2
- package/package.json +1 -1
- package/scripts/req/lib/adapters.ts +99 -7
- package/scripts/req/lib/close-migrate.ts +135 -0
- package/scripts/req/lib/close-proof.ts +323 -0
- package/scripts/req/lib/config.ts +9 -0
- package/scripts/req/lib/evidence.ts +158 -3
- package/scripts/req/lib/intake.ts +175 -0
- package/scripts/req/lib/lockfile-diff.ts +127 -0
- package/scripts/req/lib/reconstruct.ts +118 -0
- package/scripts/req/lib/review-exception.ts +233 -0
- package/scripts/req/lib/review-ledger.ts +257 -0
- package/scripts/req/lib/review-target.ts +61 -0
- package/scripts/req/lib/scratch.ts +14 -1
- package/scripts/req/req-close.ts +222 -0
- package/scripts/req/req-commit.ts +765 -623
- package/scripts/req/req-doctor.ts +58 -1
- package/scripts/req/req-new.ts +304 -255
- package/scripts/req/req-next.ts +829 -813
- package/scripts/req/req-reconstruct.ts +217 -0
- package/scripts/req/req-review-exception.ts +197 -0
- package/scripts/req/review-codex.ts +2526 -2146
- package/workflow/req.config.schema.json +3 -0
- package/workflow/review-persona.md +8 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 커밋되는 **리뷰 예외 부여 원장** (REQ-2026-055·DEC-RE3).
|
|
3
|
+
*
|
|
4
|
+
* needs-exception 구간에서 사람이 부여한 예외의 **구조화 rationale**을 durable하게 남긴다. `review_exception_confirmed`
|
|
5
|
+
* (state.json scratch)는 소비 후 null이 되지만 이 파일은 남아 "왜 예산을 넘겼나"의 감사가 된다.
|
|
6
|
+
*
|
|
7
|
+
* 🔴 **B1 review-ledger 스키마를 건드리지 않는다**(전용 sibling 파일) — 릴리스된 원장에 필수 키를 더하면 기존
|
|
8
|
+
* 커밋 원장이 "필수 키 누락"으로 D5 fail-closed된다(B1 자체 경고). 이 파일은 자체 스키마다.
|
|
9
|
+
* 🔴 review-ledger가 본문을 뺀 것과 달리 이 파일은 rationale **본문을 담는다** — 이 파일의 목적이 그 내용이다.
|
|
10
|
+
*
|
|
11
|
+
* 순수 모듈 — fs·git을 모른다. 부작용은 호출부가 낸다(`lib/review-ledger`·`lib/close-proof`와 같은 태도).
|
|
12
|
+
*/
|
|
13
|
+
import { isValidIsoInstant } from './evidence'
|
|
14
|
+
import type { ReviewKind } from '../review-codex'
|
|
15
|
+
|
|
16
|
+
/** 예외 부여 파일의 basename. `review-ledger.jsonl`·`ticket-close.jsonl`과 같은 `responses/` 디렉터리. */
|
|
17
|
+
export const EXCEPTION_BASENAME = 'review-exceptions.jsonl'
|
|
18
|
+
|
|
19
|
+
/** 티켓 `responses/` 기준 예외 원장의 repo-상대 경로. */
|
|
20
|
+
export function exceptionsPath(ticketRel: string): string {
|
|
21
|
+
return `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/${EXCEPTION_BASENAME}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 구조화 rationale 4섹션(전부 비어있지 않아야 함). */
|
|
25
|
+
export interface Rationale {
|
|
26
|
+
prev_findings: string
|
|
27
|
+
changes: string
|
|
28
|
+
unresolved: string
|
|
29
|
+
retry_justification: string
|
|
30
|
+
}
|
|
31
|
+
export const RATIONALE_KEYS = ['prev_findings', 'changes', 'unresolved', 'retry_justification'] as const
|
|
32
|
+
|
|
33
|
+
export interface ExceptionGrantRow {
|
|
34
|
+
ticket_id: string
|
|
35
|
+
review_kind: ReviewKind
|
|
36
|
+
phase_id: string | null
|
|
37
|
+
series_id: string
|
|
38
|
+
/** 이 예외가 유효한 회차(= consumeReviewException의 for_attempt·checkReviewBudget의 attempt). */
|
|
39
|
+
for_attempt: number
|
|
40
|
+
/** 받은 승인 문장 그대로. */
|
|
41
|
+
method: string
|
|
42
|
+
confirmed_at: string
|
|
43
|
+
rationale: Rationale
|
|
44
|
+
/** 사후 복원 행인지(원본과 구별). 정상 부여는 false. */
|
|
45
|
+
reconstructed: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 직렬화 키 순서(고정) + 허용키 화이트리스트. */
|
|
49
|
+
export const EXCEPTION_KEYS = [
|
|
50
|
+
'ticket_id',
|
|
51
|
+
'review_kind',
|
|
52
|
+
'phase_id',
|
|
53
|
+
'series_id',
|
|
54
|
+
'for_attempt',
|
|
55
|
+
'method',
|
|
56
|
+
'confirmed_at',
|
|
57
|
+
'rationale',
|
|
58
|
+
'reconstructed',
|
|
59
|
+
] as const
|
|
60
|
+
|
|
61
|
+
/** 한 줄 직렬화(JSONL): 고정 키 순서(rationale 내부도 고정) + 끝 개행. */
|
|
62
|
+
export function serializeExceptionGrantRow(row: ExceptionGrantRow): string {
|
|
63
|
+
const rationale: Record<string, unknown> = {}
|
|
64
|
+
for (const k of RATIONALE_KEYS) rationale[k] = row.rationale[k]
|
|
65
|
+
const o: Record<string, unknown> = {}
|
|
66
|
+
for (const k of EXCEPTION_KEYS) o[k] = k === 'rationale' ? rationale : row[k]
|
|
67
|
+
return `${JSON.stringify(o)}\n`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 자연키 구분자 — US(0x1F). 식별자에 나타날 수 없는 제어문자(review-ledger와 같은 기법·소스에 리터럴 금지). */
|
|
71
|
+
const KEY_SEP = String.fromCharCode(31)
|
|
72
|
+
|
|
73
|
+
/** 자연키 — `(ticket, series, for_attempt)`. 한 series의 한 회차에 예외 1개. */
|
|
74
|
+
export function exceptionGrantRowKey(row: Pick<ExceptionGrantRow, 'ticket_id' | 'series_id' | 'for_attempt'>): string {
|
|
75
|
+
return [row.ticket_id, row.series_id, String(row.for_attempt)].join(KEY_SEP)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** rationale 객체 형식 문제(순수). 빈 배열=정상. 4섹션 전부 비어있지 않은 문자열. */
|
|
79
|
+
function rationaleProblems(raw: unknown): string[] {
|
|
80
|
+
const p: string[] = []
|
|
81
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['rationale가 객체가 아님']
|
|
82
|
+
const r = raw as Record<string, unknown>
|
|
83
|
+
const allowed = new Set<string>(RATIONALE_KEYS)
|
|
84
|
+
for (const k of Object.keys(r)) if (!allowed.has(k)) p.push(`rationale 알 수 없는 키: ${k}`)
|
|
85
|
+
for (const k of RATIONALE_KEYS) {
|
|
86
|
+
const v = r[k]
|
|
87
|
+
if (typeof v !== 'string' || v.trim() === '') p.push(`rationale.${k}가 비어 있음`)
|
|
88
|
+
}
|
|
89
|
+
return p
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 행 하나의 형식 문제 목록(순수). 빈 배열 = 정상. 모르는 top-level 키는 거부(주입 방어). */
|
|
93
|
+
export function exceptionGrantRowProblems(raw: unknown): string[] {
|
|
94
|
+
const p: string[] = []
|
|
95
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['객체가 아님']
|
|
96
|
+
const r = raw as Record<string, unknown>
|
|
97
|
+
const allowed = new Set<string>(EXCEPTION_KEYS)
|
|
98
|
+
for (const k of Object.keys(r)) if (!allowed.has(k)) p.push(`알 수 없는 키: ${k}`)
|
|
99
|
+
for (const k of EXCEPTION_KEYS) if (!(k in r)) p.push(`필수 키 누락: ${k}`)
|
|
100
|
+
if (p.length) return p
|
|
101
|
+
|
|
102
|
+
if (typeof r.ticket_id !== 'string' || r.ticket_id === '') p.push('ticket_id가 비어 있음')
|
|
103
|
+
if (r.review_kind !== 'design' && r.review_kind !== 'phase') p.push(`review_kind 부적합: ${String(r.review_kind)}`)
|
|
104
|
+
if (r.phase_id !== null && (typeof r.phase_id !== 'string' || r.phase_id === '')) p.push('phase_id는 null이거나 비지 않은 문자열')
|
|
105
|
+
if (typeof r.series_id !== 'string' || r.series_id === '') p.push('series_id가 비어 있음')
|
|
106
|
+
if (typeof r.for_attempt !== 'number' || !Number.isInteger(r.for_attempt) || r.for_attempt < 1) p.push('for_attempt는 1 이상 정수')
|
|
107
|
+
if (typeof r.method !== 'string' || r.method.trim() === '') p.push('method가 비어 있음')
|
|
108
|
+
if (!isValidIsoInstant(r.confirmed_at)) p.push('confirmed_at이 ISO instant가 아님')
|
|
109
|
+
if (typeof r.reconstructed !== 'boolean') p.push('reconstructed는 boolean')
|
|
110
|
+
p.push(...rationaleProblems(r.rationale))
|
|
111
|
+
return p
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ParsedExceptions {
|
|
115
|
+
rows: ExceptionGrantRow[]
|
|
116
|
+
problems: string[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 본문 파싱(순수). 빈 줄 무시, 파싱 불가·형식 위반·자연키 중복은 problems로 드러낸다(조용히 건너뛰지 않음). */
|
|
120
|
+
export function parseExceptions(content: string): ParsedExceptions {
|
|
121
|
+
const rows: ExceptionGrantRow[] = []
|
|
122
|
+
const problems: string[] = []
|
|
123
|
+
const seen = new Set<string>()
|
|
124
|
+
content.split('\n').forEach((line, i) => {
|
|
125
|
+
if (line.trim() === '') return
|
|
126
|
+
let raw: unknown
|
|
127
|
+
try {
|
|
128
|
+
raw = JSON.parse(line)
|
|
129
|
+
} catch {
|
|
130
|
+
problems.push(`line ${i + 1}: JSON 파싱 실패`)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
const ps = exceptionGrantRowProblems(raw)
|
|
134
|
+
if (ps.length) {
|
|
135
|
+
problems.push(...ps.map((m) => `line ${i + 1}: ${m}`))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const row = raw as ExceptionGrantRow
|
|
139
|
+
const key = exceptionGrantRowKey(row)
|
|
140
|
+
if (seen.has(key)) problems.push(`line ${i + 1}: 자연키 중복(${row.series_id} for_attempt=${row.for_attempt})`)
|
|
141
|
+
seen.add(key)
|
|
142
|
+
rows.push(row)
|
|
143
|
+
})
|
|
144
|
+
return { rows, problems }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** material 동일성(순수) — method + rationale만 비교(confirmed_at 제외). 재실행 멱등 판정용. */
|
|
148
|
+
export function materialEqual(a: ExceptionGrantRow, b: ExceptionGrantRow): boolean {
|
|
149
|
+
if (a.method !== b.method) return false
|
|
150
|
+
return RATIONALE_KEYS.every((k) => a.rationale[k] === b.rationale[k])
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 자연키로 기존 부여 행 조회(순수). 없으면 null. 호출부가 confirmed_at 재사용에 쓴다(재실행 복구). */
|
|
154
|
+
export function findExistingGrant(content: string, key: Pick<ExceptionGrantRow, 'ticket_id' | 'series_id' | 'for_attempt'>): ExceptionGrantRow | null {
|
|
155
|
+
const parsed = parseExceptions(content)
|
|
156
|
+
if (parsed.problems.length) return null // 손상은 append 단계가 conflict로 처리한다.
|
|
157
|
+
const k = exceptionGrantRowKey(key)
|
|
158
|
+
return parsed.rows.find((r) => exceptionGrantRowKey(r) === k) ?? null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type ExceptionAppendOutcome = 'appended' | 'duplicate' | 'conflict'
|
|
162
|
+
export interface ExceptionAppendResult {
|
|
163
|
+
outcome: ExceptionAppendOutcome
|
|
164
|
+
content: string
|
|
165
|
+
problems: string[]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 멱등 append(순수 — 새 본문 반환만). 🔴 **material 멱등**(DEC-RE3): 같은 자연키 + method·rationale 같으면
|
|
170
|
+
* duplicate(confirmed_at 달라도), material 다르면 conflict(같은 회차 다른 예외 — 덮지 않음·fail-closed).
|
|
171
|
+
* 기존 본문 손상이면 그대로 올리고 append하지 않는다(D5 태도).
|
|
172
|
+
*/
|
|
173
|
+
export function appendExceptionGrant(existingContent: string, row: ExceptionGrantRow): ExceptionAppendResult {
|
|
174
|
+
const rowProblems = exceptionGrantRowProblems(row)
|
|
175
|
+
if (rowProblems.length) return { outcome: 'conflict', content: existingContent, problems: rowProblems }
|
|
176
|
+
|
|
177
|
+
const parsed = parseExceptions(existingContent)
|
|
178
|
+
if (parsed.problems.length) return { outcome: 'conflict', content: existingContent, problems: parsed.problems }
|
|
179
|
+
|
|
180
|
+
const key = exceptionGrantRowKey(row)
|
|
181
|
+
const prior = parsed.rows.find((r) => exceptionGrantRowKey(r) === key)
|
|
182
|
+
if (prior) {
|
|
183
|
+
return materialEqual(prior, row)
|
|
184
|
+
? { outcome: 'duplicate', content: existingContent, problems: [] }
|
|
185
|
+
: {
|
|
186
|
+
outcome: 'conflict',
|
|
187
|
+
content: existingContent,
|
|
188
|
+
problems: [`같은 회차(${row.series_id} #${row.for_attempt})에 다른 예외가 이미 있음 — 덮지 않는다`],
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const base = existingContent === '' || existingContent.endsWith('\n') ? existingContent : `${existingContent}\n`
|
|
192
|
+
return { outcome: 'appended', content: base + serializeExceptionGrantRow(row), problems: [] }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ───────────────────────────────── rationale 파일 파서(순수) ──
|
|
196
|
+
|
|
197
|
+
/** rationale 마크다운 섹션 헤더 → 필드. */
|
|
198
|
+
const RATIONALE_SECTIONS: ReadonlyArray<{ header: string; field: keyof Rationale }> = [
|
|
199
|
+
{ header: '직전 findings', field: 'prev_findings' },
|
|
200
|
+
{ header: '이번 변경', field: 'changes' },
|
|
201
|
+
{ header: '미해결', field: 'unresolved' },
|
|
202
|
+
{ header: '재시도 근거', field: 'retry_justification' },
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
export type RationaleParse = { ok: true; rationale: Rationale } | { ok: false; problems: string[] }
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* rationale 마크다운 파싱(순수). `## <헤더>` 4섹션의 본문을 뽑아 전부 비어있지 않으면 ok.
|
|
209
|
+
* 형식은 자유(마크다운) — 존재·비-빔만 강제한다. 누락·빈 섹션은 어느 것인지 알린다.
|
|
210
|
+
*/
|
|
211
|
+
export function parseRationale(text: string): RationaleParse {
|
|
212
|
+
const lines = text.split('\n')
|
|
213
|
+
const bodies = new Map<keyof Rationale, string[]>()
|
|
214
|
+
let current: keyof Rationale | null = null
|
|
215
|
+
for (const line of lines) {
|
|
216
|
+
const m = /^##\s+(.+?)\s*$/.exec(line)
|
|
217
|
+
if (m) {
|
|
218
|
+
const sec = RATIONALE_SECTIONS.find((s) => m[1] === s.header)
|
|
219
|
+
current = sec ? sec.field : null // 알 수 없는 헤더는 어느 섹션에도 안 담는다.
|
|
220
|
+
if (current && !bodies.has(current)) bodies.set(current, [])
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
if (current) bodies.get(current)!.push(line)
|
|
224
|
+
}
|
|
225
|
+
const problems: string[] = []
|
|
226
|
+
const rationale = {} as Rationale
|
|
227
|
+
for (const { header, field } of RATIONALE_SECTIONS) {
|
|
228
|
+
const body = (bodies.get(field) ?? []).join('\n').trim()
|
|
229
|
+
if (body === '') problems.push(`rationale 섹션 "## ${header}"가 없거나 비어 있음`)
|
|
230
|
+
rationale[field] = body
|
|
231
|
+
}
|
|
232
|
+
return problems.length ? { ok: false, problems } : { ok: true, rationale }
|
|
233
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 커밋되는 **append-only 리뷰 원장** (REQ-2026-051).
|
|
3
|
+
*
|
|
4
|
+
* 왜 존재하나: `state.json`은 scratch로 설계돼 커밋되지 않는다(`req-commit.ts` — "state.json은 scratch 유지").
|
|
5
|
+
* 그래서 런타임 원장이 미커밋으로 남고, 다음 티켓의 `req:new`가 clean tree를 요구하는 순간 폐기된다.
|
|
6
|
+
* 소비자 저장소에서 실제로 한 REQ의 원장(설계 승인 + phase 3건 승인 이력)이 통째로 사라졌고,
|
|
7
|
+
* 이 저장소의 REQ-2026-049도 같은 상태다(`phases: []`·`review_series: 0`).
|
|
8
|
+
*
|
|
9
|
+
* 🔴 **`approvals.jsonl`을 중복하지 않는다.** 그쪽 `archive_inventory`가 아카이브된 전 라운드를
|
|
10
|
+
* `response_path`+`sha256`으로 이미 담는다. 이 원장은 **아카이브가 보여줄 수 없는 것만** 담는다:
|
|
11
|
+
* 1. 아카이브를 남기지 않은 시도(호출 실패·무효 응답) — attempt는 호출 **전**에 확정되고
|
|
12
|
+
* 아카이브는 유효 응답에만 생기므로, 그 차이가 어디에도 안 남는다.
|
|
13
|
+
* 2. 사람 예외 소비 — `review_exception_confirmed`는 소비 후 `null`로 지워진다.
|
|
14
|
+
* 3. series 종결 사유 · 4. lineage · 5. 재구성 여부.
|
|
15
|
+
*
|
|
16
|
+
* 🔴 **프롬프트·응답 본문을 저장하지 않는다.** 해시까지만. 응답 본문은 이미 아카이브에 있고
|
|
17
|
+
* `archive_sha256`이 그것을 가리킨다. 허용키 화이트리스트가 본문이 들어갈 자리 자체를 막는다.
|
|
18
|
+
*
|
|
19
|
+
* 이 모듈은 **순수**하다 — fs·git을 모른다. 부작용은 호출부가 낸다(`lib/evidence`와 같은 태도).
|
|
20
|
+
*/
|
|
21
|
+
import { isValidIsoInstant } from './evidence'
|
|
22
|
+
import type { ReviewKind } from '../review-codex'
|
|
23
|
+
|
|
24
|
+
/** 원장 파일의 티켓-상대 경로. `approvals.jsonl`과 같은 디렉터리 — 기존 내구화 경로 2곳을 그대로 재사용한다. */
|
|
25
|
+
export const LEDGER_BASENAME = 'review-ledger.jsonl'
|
|
26
|
+
|
|
27
|
+
/** 티켓 `responses/` 기준 원장의 repo-상대 경로. */
|
|
28
|
+
export function ledgerPath(ticketRel: string): string {
|
|
29
|
+
return `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/${LEDGER_BASENAME}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 이벤트 2종 (D2).
|
|
34
|
+
*
|
|
35
|
+
* attempt는 외부 호출 **전**에 확정되고 결과는 **후**에 나온다. 한 행에 담으면 나중에 그 행을 고쳐야 하는데
|
|
36
|
+
* 그것은 append-only가 아니다. 그래서 나눈다.
|
|
37
|
+
*
|
|
38
|
+
* 부수 효과가 이 설계의 핵심 이득이다: `attempt-opened`만 있고 `attempt-closed`가 없는 attempt가 곧
|
|
39
|
+
* **"예산은 깎였는데 완료되지 않은 호출"**이다. 별도 필드 없이 원장 **구조 자체로** 관측된다.
|
|
40
|
+
*/
|
|
41
|
+
export type LedgerEvent = 'attempt-opened' | 'attempt-closed'
|
|
42
|
+
|
|
43
|
+
/** 완료된 attempt의 판정. 미완(`attempt-opened`)이면 null. */
|
|
44
|
+
export type LedgerOutcome = 'approved' | 'needs-fix' | 'blocked' | 'invalid'
|
|
45
|
+
|
|
46
|
+
export interface LedgerRow {
|
|
47
|
+
ticket_id: string
|
|
48
|
+
series_id: string
|
|
49
|
+
review_kind: ReviewKind
|
|
50
|
+
phase_id: string | null
|
|
51
|
+
attempt: number
|
|
52
|
+
event: LedgerEvent
|
|
53
|
+
/**
|
|
54
|
+
* 호출 수명주기. **이 REQ는 `completed`만 쓴다.** `pre_dispatch_failed`·`dispatch_confirmed`·
|
|
55
|
+
* `dispatched_unknown` 및 예산 차감 규칙 변경은 후속 REQ 소관이다.
|
|
56
|
+
* `attempt-opened`에서는 null.
|
|
57
|
+
*/
|
|
58
|
+
lifecycle: string | null
|
|
59
|
+
outcome: LedgerOutcome | null
|
|
60
|
+
/** 이 attempt가 autoBudget 초과라 사람 예외를 소비했는지. scratch에서 지워지는 유일한 사실이라 여기서만 살아남는다. */
|
|
61
|
+
exception_consumed: boolean
|
|
62
|
+
// 🔴 **archive path·sha256을 담지 않는다**(phase-2 리뷰 P1). 아카이브된 라운드의 경로·해시는 이미
|
|
63
|
+
// `approvals.jsonl`의 `archive_inventory`가 단일 출처로 보관한다. 원장이 그걸 복제하면 두 사본이
|
|
64
|
+
// 갈라질 수 있고, 이 REQ의 헤드라인 원칙("approvals.jsonl을 중복하지 않는다")을 스스로 어긴다.
|
|
65
|
+
// 아카이브 존재 여부는 `outcome`이 이미 알려 준다(approved/needs-fix=아카이브됨, blocked/invalid=아님).
|
|
66
|
+
/** 🔴 프롬프트 **해시만**. 본문은 원장에 들어가지 않는다. */
|
|
67
|
+
prompt_sha256: string | null
|
|
68
|
+
at: string
|
|
69
|
+
/** 사후 복원한 기록인지. 원본과 구별할 수단이 없으면 재구성본이 원본으로 위장한다. */
|
|
70
|
+
reconstructed: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 직렬화 키 순서(고정 — deterministic). `serializeManifestLine`과 같은 방식.
|
|
75
|
+
* 허용키 화이트리스트로도 쓰인다 — 여기 없는 top-level 키는 오염 신호로 거부한다.
|
|
76
|
+
*
|
|
77
|
+
* 🔴 **릴리스 후 스키마 변경은 additive-only여야 한다.** 키를 **제거**하면, 옛 스키마로 커밋된 기존 원장이
|
|
78
|
+
* 새 검증기에서 "알 수 없는 키"로 거부되고 D5 fail-closed가 그 티켓의 모든 리뷰를 막는다(실제로 이
|
|
79
|
+
* REQ의 phase-2 개발 중 dogfood에서 발생했다 — 미커밋 원장이라 재생성으로 해결). 이 REQ는 **릴리스 전**이라
|
|
80
|
+
* archive_path·archive_sha256을 제거해도 야생에 옛 원장이 없어 안전하다. 릴리스 후에는 키 추가만 하고,
|
|
81
|
+
* 값 확장은 forward-compatible하게(모르는 값은 거부 안 함, `lifecycle` 참조).
|
|
82
|
+
*/
|
|
83
|
+
export const LEDGER_KEYS = [
|
|
84
|
+
'ticket_id',
|
|
85
|
+
'series_id',
|
|
86
|
+
'review_kind',
|
|
87
|
+
'phase_id',
|
|
88
|
+
'attempt',
|
|
89
|
+
'event',
|
|
90
|
+
'lifecycle',
|
|
91
|
+
'outcome',
|
|
92
|
+
'exception_consumed',
|
|
93
|
+
'prompt_sha256',
|
|
94
|
+
'at',
|
|
95
|
+
'reconstructed',
|
|
96
|
+
] as const
|
|
97
|
+
|
|
98
|
+
const OUTCOMES: readonly string[] = ['approved', 'needs-fix', 'blocked', 'invalid']
|
|
99
|
+
const EVENTS: readonly string[] = ['attempt-opened', 'attempt-closed']
|
|
100
|
+
|
|
101
|
+
/** 한 줄 직렬화(JSONL): 고정 키 순서 JSON + 끝 개행. */
|
|
102
|
+
export function serializeLedgerRow(row: LedgerRow): string {
|
|
103
|
+
const o: Record<string, unknown> = {}
|
|
104
|
+
for (const k of LEDGER_KEYS) o[k] = row[k]
|
|
105
|
+
return `${JSON.stringify(o)}\n`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 자연키 구분자 — **US(0x1F, unit separator)**.
|
|
110
|
+
*
|
|
111
|
+
* 왜 제어문자인가: 티켓 id·series id·phase id 어디에도 나타날 수 없어야 구분자 충돌로 서로 다른
|
|
112
|
+
* attempt가 같은 키로 접히지 않는다. 가시 문자(공백·`|`·`:`)는 series id(`design:-#1`)나 장래의
|
|
113
|
+
* phase id에 나타날 수 있다.
|
|
114
|
+
*
|
|
115
|
+
* 🔴 **소스에 제어문자 리터럴을 넣지 않는다** — `String.fromCharCode`로 만든다. 이 phase에서 실제로
|
|
116
|
+
* 원시 NUL 바이트가 소스에 박혀 git이 파일을 **binary로 취급**했고 grep·diff·리뷰가 전부 깨졌다.
|
|
117
|
+
* 테스트(`제어문자 리터럴 없음`)가 이 재발을 잠근다.
|
|
118
|
+
*/
|
|
119
|
+
const KEY_SEP = String.fromCharCode(31) // US(unit separator)
|
|
120
|
+
|
|
121
|
+
/** 자연키 — 멱등 판정의 단위(D5). */
|
|
122
|
+
export function ledgerRowKey(row: Pick<LedgerRow, 'ticket_id' | 'series_id' | 'attempt' | 'event'>): string {
|
|
123
|
+
return [row.ticket_id, row.series_id, String(row.attempt), row.event].join(KEY_SEP)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 행 하나의 형식 문제 목록(순수). 빈 배열 = 정상.
|
|
128
|
+
*
|
|
129
|
+
* 🔴 **비대칭이 의도다**(D3): 모르는 `lifecycle` **값**은 거부하지 않고(forward-compatible — 후속 REQ가
|
|
130
|
+
* 값을 늘리는 것이 정상 경로다), 모르는 top-level **키**는 거부한다(주입·오염 신호).
|
|
131
|
+
*/
|
|
132
|
+
export function ledgerRowProblems(raw: unknown): string[] {
|
|
133
|
+
const p: string[] = []
|
|
134
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['객체가 아님']
|
|
135
|
+
const r = raw as Record<string, unknown>
|
|
136
|
+
|
|
137
|
+
const allowed = new Set<string>(LEDGER_KEYS)
|
|
138
|
+
for (const k of Object.keys(r)) if (!allowed.has(k)) p.push(`알 수 없는 키: ${k}`)
|
|
139
|
+
for (const k of LEDGER_KEYS) if (!(k in r)) p.push(`필수 키 누락: ${k}`)
|
|
140
|
+
if (p.length) return p
|
|
141
|
+
|
|
142
|
+
if (typeof r.ticket_id !== 'string' || r.ticket_id === '') p.push('ticket_id가 비어 있음')
|
|
143
|
+
if (typeof r.series_id !== 'string' || r.series_id === '') p.push('series_id가 비어 있음')
|
|
144
|
+
if (r.review_kind !== 'design' && r.review_kind !== 'phase') p.push(`review_kind 부적합: ${String(r.review_kind)}`)
|
|
145
|
+
if (r.phase_id !== null && (typeof r.phase_id !== 'string' || r.phase_id === '')) p.push('phase_id는 null이거나 비지 않은 문자열')
|
|
146
|
+
if (typeof r.attempt !== 'number' || !Number.isInteger(r.attempt) || r.attempt < 1) p.push('attempt는 1 이상 정수')
|
|
147
|
+
if (typeof r.event !== 'string' || !EVENTS.includes(r.event)) p.push(`event 부적합: ${String(r.event)}`)
|
|
148
|
+
if (typeof r.exception_consumed !== 'boolean') p.push('exception_consumed는 boolean')
|
|
149
|
+
if (typeof r.reconstructed !== 'boolean') p.push('reconstructed는 boolean')
|
|
150
|
+
if (!isValidIsoInstant(r.at)) p.push('at이 ISO instant가 아님')
|
|
151
|
+
if (r.lifecycle !== null && typeof r.lifecycle !== 'string') p.push('lifecycle은 null이거나 문자열')
|
|
152
|
+
if (r.prompt_sha256 !== null && typeof r.prompt_sha256 !== 'string') p.push('prompt_sha256는 null이거나 문자열')
|
|
153
|
+
|
|
154
|
+
if (r.outcome !== null && (typeof r.outcome !== 'string' || !OUTCOMES.includes(r.outcome)))
|
|
155
|
+
p.push(`outcome 부적합: ${String(r.outcome)}`)
|
|
156
|
+
|
|
157
|
+
// 🔴 `attempt-opened`는 결과를 알 수 없는 시점이다 — 결과 필드가 채워져 있으면 순서가 뒤집힌 기록이다.
|
|
158
|
+
if (r.event === 'attempt-opened') {
|
|
159
|
+
for (const k of ['outcome', 'lifecycle'] as const) if (r[k] !== null) p.push(`attempt-opened인데 ${k}가 채워져 있음`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 🔴 `attempt-closed`는 판정이 끝난 시점이다 — 최소한 outcome·lifecycle은 있어야 한다(design 리뷰 observation).
|
|
163
|
+
// 이게 없으면 outcome=null인 closed 행이 통과해 "완료됐지만 판정 불명"이라는 자기모순 상태가 원장에 남는다.
|
|
164
|
+
if (r.event === 'attempt-closed') {
|
|
165
|
+
if (r.outcome === null) p.push('attempt-closed인데 outcome이 null')
|
|
166
|
+
if (r.lifecycle === null) p.push('attempt-closed인데 lifecycle이 null')
|
|
167
|
+
}
|
|
168
|
+
return p
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface ParsedLedger {
|
|
172
|
+
rows: LedgerRow[]
|
|
173
|
+
/** 파싱·검증 문제. 비어 있지 않으면 손상 — 조용히 건너뛰지 않는다(D5). */
|
|
174
|
+
problems: string[]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 원장 본문 파싱(순수). 빈 줄은 무시하고, 파싱 불가·형식 위반은 problems로 드러낸다. */
|
|
178
|
+
export function parseLedger(content: string): ParsedLedger {
|
|
179
|
+
const rows: LedgerRow[] = []
|
|
180
|
+
const problems: string[] = []
|
|
181
|
+
const lines = content.split('\n')
|
|
182
|
+
const seen = new Set<string>()
|
|
183
|
+
lines.forEach((line, i) => {
|
|
184
|
+
if (line.trim() === '') return
|
|
185
|
+
let raw: unknown
|
|
186
|
+
try {
|
|
187
|
+
raw = JSON.parse(line)
|
|
188
|
+
} catch {
|
|
189
|
+
problems.push(`line ${i + 1}: JSON 파싱 실패`)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
const ps = ledgerRowProblems(raw)
|
|
193
|
+
if (ps.length) {
|
|
194
|
+
problems.push(...ps.map((m) => `line ${i + 1}: ${m}`))
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
const row = raw as LedgerRow
|
|
198
|
+
const key = ledgerRowKey(row)
|
|
199
|
+
if (seen.has(key)) problems.push(`line ${i + 1}: 자연키 중복(${row.series_id} attempt=${row.attempt} ${row.event})`)
|
|
200
|
+
seen.add(key)
|
|
201
|
+
rows.push(row)
|
|
202
|
+
})
|
|
203
|
+
return { rows, problems }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 멱등 append 결과. `appended`=신규 기록, `duplicate`=동일 내용 재기록(no-op), `conflict`=같은 키 다른 내용(fail-closed). */
|
|
207
|
+
export type AppendOutcome = 'appended' | 'duplicate' | 'conflict'
|
|
208
|
+
|
|
209
|
+
export interface AppendResult {
|
|
210
|
+
outcome: AppendOutcome
|
|
211
|
+
/** append 후 본문. `duplicate`·`conflict`면 입력과 동일하다(쓰기 금지). */
|
|
212
|
+
content: string
|
|
213
|
+
problems: string[]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 멱등 append(순수 — 새 본문을 **반환**만 한다. 쓰기는 호출부).
|
|
218
|
+
*
|
|
219
|
+
* - 같은 자연키 + 동일 내용 → `duplicate`(행 수 불변). crash 후 재실행이 중복을 만들지 않는다.
|
|
220
|
+
* - 같은 자연키 + 다른 내용 → `conflict`. **append하지도 덮지도 않는다.** 조용한 덮어쓰기는
|
|
221
|
+
* append-only 원장의 신뢰를 무너뜨린다.
|
|
222
|
+
* - 기존 본문이 손상이면 그 문제를 그대로 올리고 append하지 않는다.
|
|
223
|
+
*/
|
|
224
|
+
export function appendLedgerRow(existingContent: string, row: LedgerRow): AppendResult {
|
|
225
|
+
const rowProblems = ledgerRowProblems(row)
|
|
226
|
+
if (rowProblems.length) return { outcome: 'conflict', content: existingContent, problems: rowProblems }
|
|
227
|
+
|
|
228
|
+
const parsed = parseLedger(existingContent)
|
|
229
|
+
if (parsed.problems.length) return { outcome: 'conflict', content: existingContent, problems: parsed.problems }
|
|
230
|
+
|
|
231
|
+
const key = ledgerRowKey(row)
|
|
232
|
+
const prior = parsed.rows.find((r) => ledgerRowKey(r) === key)
|
|
233
|
+
if (prior) {
|
|
234
|
+
const same = serializeLedgerRow(prior) === serializeLedgerRow(row)
|
|
235
|
+
return same
|
|
236
|
+
? { outcome: 'duplicate', content: existingContent, problems: [] }
|
|
237
|
+
: {
|
|
238
|
+
outcome: 'conflict',
|
|
239
|
+
content: existingContent,
|
|
240
|
+
problems: [`같은 자연키의 기존 행과 내용이 다름(${row.series_id} attempt=${row.attempt} ${row.event}) — 덮어쓰지 않는다`],
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const base = existingContent === '' || existingContent.endsWith('\n') ? existingContent : `${existingContent}\n`
|
|
245
|
+
return { outcome: 'appended', content: base + serializeLedgerRow(row), problems: [] }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `attempt-opened`만 있고 대응하는 `attempt-closed`가 없는 attempt (요구사항 #1).
|
|
250
|
+
*
|
|
251
|
+
* 이것이 곧 **"예산은 깎였는데 완료되지 않은 호출"**이다 — 외부 호출 실패나 응답 처리 실패로
|
|
252
|
+
* 아카이브도 측정 로그도 남지 않은 시도. 소비자 저장소에서 실측 1건(attempts 4 vs 아카이브 3)이 있었다.
|
|
253
|
+
*/
|
|
254
|
+
export function unclosedAttempts(rows: readonly LedgerRow[]): LedgerRow[] {
|
|
255
|
+
const closed = new Set(rows.filter((r) => r.event === 'attempt-closed').map((r) => [r.series_id, String(r.attempt)].join(KEY_SEP)))
|
|
256
|
+
return rows.filter((r) => r.event === 'attempt-opened' && !closed.has([r.series_id, String(r.attempt)].join(KEY_SEP)))
|
|
257
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 현재 리뷰의 **semantic identity** 계산 (REQ-2026-052 phase-2).
|
|
3
|
+
*
|
|
4
|
+
* 왜 필요한가: pre-call 원장 커밋(DEC-A)이 매 라운드 HEAD·인덱스를 바꾸므로, approval binding
|
|
5
|
+
* (`reviewBaseSha`/`reviewTree`)은 라운드마다 값이 달라진다. 그런데 두 판정은 **같은 리뷰의 반복**을
|
|
6
|
+
* 감지해야 하므로 audit bookkeeping 변화에 흔들리면 안 된다:
|
|
7
|
+
* - blocked-review circuit breaker(무한 재리뷰 차단)
|
|
8
|
+
* - `last_review.compare_hash` · req:next G2(바인딩 신선도)
|
|
9
|
+
*
|
|
10
|
+
* 그래서 approval binding과 **분리된** semantic identity를 둔다: 리뷰 대상(design 문서·phase 코드)은
|
|
11
|
+
* 반영하되, 티켓의 `responses/` audit 산출물(ledger·approvals·아카이브·close-proof·codex-response·
|
|
12
|
+
* preview)의 변화는 **무시**한다.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 **`responses/` 전체를 제외한다**(design-r03-delta P1): 처음엔 `review-ledger.jsonl` 한 줄만
|
|
15
|
+
* 제외했으나, evidence-finalize가 approvals·아카이브를 커밋하면 identity가 바뀌어 방금 승인한 리뷰를
|
|
16
|
+
* req:next G2가 stale로 오판했다(요구 #4 위반). `responses/`는 **순수 audit**이고 리뷰 대상은 절대
|
|
17
|
+
* 그 안에 없다(design 문서=티켓 루트 `0N-*.md`, phase 코드=`workflow/` 밖). 따라서 `responses/`를
|
|
18
|
+
* 통째로 제외해도 리뷰 대상 손실 없이 pre-call 커밋·evidence-finalize 양쪽에 identity가 불변이다.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 **읽기 전용**: `git ls-files -s`만 쓴다. `git write-tree`는 object DB에 tree를 쓰므로 금지
|
|
21
|
+
* (`captureIndexHash`와 같은 기법 — req:next가 재계산할 수 있어야 한다).
|
|
22
|
+
*
|
|
23
|
+
* 인터페이스는 이 함수 하나. git 필터링·정렬·hash 규칙을 전부 이 모듈에 숨긴다.
|
|
24
|
+
* 호출자: `review-codex`(생성) · `req:next`(G2 재계산) · 테스트.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
|
|
28
|
+
/** `git ...` 실행 경계(review-codex의 `GitFn`과 호환 — 주입 가능·테스트용). */
|
|
29
|
+
export type GitFn = (args: string[]) => string
|
|
30
|
+
|
|
31
|
+
/** `git ls-files -s` 한 줄에서 경로(탭 뒤)를 뽑는다. 형식: `<mode> <oid> <stage>\t<path>`. */
|
|
32
|
+
function pathOfLsFilesLine(line: string): string | null {
|
|
33
|
+
const tab = line.indexOf('\t')
|
|
34
|
+
return tab < 0 ? null : line.slice(tab + 1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 현재 리뷰의 semantic identity(hex SHA256).
|
|
39
|
+
*
|
|
40
|
+
* = SHA256( 정렬된 `git ls-files -s` 줄들 중, 경로가 `<ticketRel>/responses/` 아래인 줄을 제외 ).
|
|
41
|
+
*
|
|
42
|
+
* 원장·approvals·아카이브가 untracked/modified/committed 어느 상태든 `responses/` 경로라 제외되므로
|
|
43
|
+
* identity가 그 변화에 불변이다. 리뷰 대상(문서·코드)과 `responses/` 밖의 non-audit 변경은 반영된다.
|
|
44
|
+
*/
|
|
45
|
+
export function computeReviewSemanticIdentity(ticketRel: string, gitFn: GitFn): string {
|
|
46
|
+
const normTicket = ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
47
|
+
// 🔴 ticketRel 경계가 애매하면(빈 값) fail-closed — 잘못된 접두사로 무언가를 조용히 제외하지 않는다.
|
|
48
|
+
if (normTicket === '') throw new Error('computeReviewSemanticIdentity: ticketRel이 비어 있음(제외 경계 불명 — fail-closed)')
|
|
49
|
+
const responsesPrefix = `${normTicket}/responses/` // 🔴 정확히 이 티켓의 responses/ 하위만. 다른 workflow 파일·문서·코드 미제외.
|
|
50
|
+
const lines = gitFn(['ls-files', '-s'])
|
|
51
|
+
.split('\n')
|
|
52
|
+
.map((l) => l.replace(/\r$/, ''))
|
|
53
|
+
.filter((l) => l.trim() !== '')
|
|
54
|
+
.filter((l) => {
|
|
55
|
+
const p = pathOfLsFilesLine(l)
|
|
56
|
+
// 🔴 경로를 못 뽑으면(malformed) **보수적으로 포함**한다 — 모호한 경로를 제외하지 않는다(constraint 3).
|
|
57
|
+
// 제외는 명확히 `<ticketRel>/responses/` 하위일 때만.
|
|
58
|
+
return p === null ? true : !p.startsWith(responsesPrefix)
|
|
59
|
+
})
|
|
60
|
+
return createHash('sha256').update([...lines].sort().join('\n')).digest('hex')
|
|
61
|
+
}
|
|
@@ -20,6 +20,14 @@ import { isUntracked } from './porcelain'
|
|
|
20
20
|
/** 티켓 디렉터리 안의 순수 untracked 도구 산출물. 커밋된 적이 없고 승인 증거가 아니다. */
|
|
21
21
|
export const TOOL_OUTPUT_BASENAMES = ['codex-response.json', '.review-preview.txt'] as const
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* 리뷰 원장(REQ-2026-051)의 티켓-상대 경로. `state.json`과 **같은 범주**다 — 워크플로가 리뷰 중에
|
|
25
|
+
* (attempt-opened/closed) `responses/` 아래에 쓰는 메타데이터이고, 승인 시점에 커밋된다.
|
|
26
|
+
* 🔴 exact 경로로만 허용한다(rename/카피는 아래 responses/ 규칙으로 여전히 차단) — `responses/**` 전체를
|
|
27
|
+
* scratch로 열면 승인 아카이브 변조 구멍이 된다(REQ-2026-012 D8).
|
|
28
|
+
*/
|
|
29
|
+
export const REVIEW_LEDGER_RELNAME = 'responses/review-ledger.jsonl' as const
|
|
30
|
+
|
|
23
31
|
/** 경로 정규화: 역슬래시→슬래시(호출부가 넘기는 repo-상대는 이미 `/`지만 방어), 후행 슬래시 제거. */
|
|
24
32
|
function normDir(dirRel: string): string {
|
|
25
33
|
return dirRel.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
@@ -31,7 +39,12 @@ function normDir(dirRel: string): string {
|
|
|
31
39
|
*/
|
|
32
40
|
export function reviewScratchPaths(ticketDirRel: string): string[] {
|
|
33
41
|
const dir = normDir(ticketDirRel)
|
|
34
|
-
return [
|
|
42
|
+
return [
|
|
43
|
+
`${dir}/${TOOL_OUTPUT_BASENAMES[0]}`,
|
|
44
|
+
`${dir}/${TOOL_OUTPUT_BASENAMES[1]}`,
|
|
45
|
+
`${dir}/state.json`,
|
|
46
|
+
`${dir}/${REVIEW_LEDGER_RELNAME}`, // REQ-2026-051: 리뷰 중 append되는 원장(state.json과 동종). exact 경로만.
|
|
47
|
+
]
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
/** `REQ-<4자리>-<숫자>` 디렉터리명인가(문자열 분해 — 정규식 보간 금지, 설계 D7). */
|