commitgate 0.7.0 → 0.8.0
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 +39 -0
- package/README.en.md +75 -3
- package/README.md +70 -3
- package/bin/init.ts +199 -28
- package/bin/uninstall.ts +5 -0
- package/package.json +73 -72
- package/req.config.json.sample +1 -0
- package/scripts/req/lib/config.ts +30 -0
- package/scripts/req/req-commit.ts +5 -4
- package/scripts/req/req-new.ts +35 -6
- package/scripts/req/req-next.ts +61 -0
- package/scripts/req/review-codex.ts +658 -31
- package/skills/ATTRIBUTION.md +85 -0
- package/skills/commitgate-diagnosing-bugs/SKILL.md +149 -0
- package/skills/commitgate-discovery/SKILL.md +93 -0
- package/skills/commitgate-research/SKILL.md +85 -0
- package/skills/commitgate-tdd/SKILL.md +113 -0
- package/workflow/machine.schema.json +5 -0
- package/workflow/req.config.schema.json +88 -13
- package/workflow/review-persona.md +34 -0
|
@@ -17,12 +17,21 @@
|
|
|
17
17
|
* req:review-codex --ticket <dir> # 임의 티켓 디렉터리
|
|
18
18
|
* 옵션: --handoff <path> (미지정 시 req.config.json의 handoffPath. 둘 다 없으면 handoff 블록 생략 — 코어 기본은 비활성)
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
21
|
-
|
|
20
|
+
import {
|
|
21
|
+
readFileSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
appendFileSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
realpathSync,
|
|
28
|
+
statSync,
|
|
29
|
+
} from 'node:fs'
|
|
30
|
+
import { resolve, join, relative, sep, dirname } from 'node:path'
|
|
22
31
|
import { pathToFileURL } from 'node:url'
|
|
23
32
|
import { createHash } from 'node:crypto'
|
|
24
33
|
import Ajv from 'ajv'
|
|
25
|
-
import { loadConfig, packageRoot, buildScriptInvocation, DEFAULTS, type ResolvedConfig, type PackageManager } from './lib/config'
|
|
34
|
+
import { loadConfig, packageRoot, buildScriptInvocation, DEFAULTS, type ResolvedConfig, type PackageManager, type ReviewBudget } from './lib/config'
|
|
26
35
|
import {
|
|
27
36
|
createGitAdapter,
|
|
28
37
|
createCodexReviewerAdapter,
|
|
@@ -39,8 +48,15 @@ export { isArchiveFileName, isAllowedResponsesScratch } from './lib/scratch'
|
|
|
39
48
|
|
|
40
49
|
// git·codex(reviewer) 경계 = 어댑터(Phase 3, D-017-3/4). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — config 부재 시 현재 동작 보존).
|
|
41
50
|
let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
|
|
42
|
-
// reviewer
|
|
43
|
-
|
|
51
|
+
// REQ-2026-027 D3: reviewer 주입 seam. gitAdapter와 같은 패턴(let + main 재할당)으로 near-e2e 테스트가
|
|
52
|
+
// main() 전체 경로를 fake reviewer로 돌려 (1) legacy에서 외부 호출 0회, (2) attempt 보존 배선을 검증한다.
|
|
53
|
+
// 기본값은 codex — 인자 없는 main(argv)·runCli은 프로덕션 동작 불변.
|
|
54
|
+
let reviewer: ReviewerAdapter = createCodexReviewerAdapter()
|
|
55
|
+
|
|
56
|
+
/** 테스트 전용: 현재 모듈 reviewer를 관측(복원 검증용). 프로덕션 경로는 이 함수를 쓰지 않는다. */
|
|
57
|
+
export function __getReviewerForTest(): ReviewerAdapter {
|
|
58
|
+
return reviewer
|
|
59
|
+
}
|
|
44
60
|
|
|
45
61
|
/** 구조화 응답 스키마 버전 (machine.schema.json과 동기). */
|
|
46
62
|
export const MACHINE_SCHEMA_VERSION = '1.1'
|
|
@@ -84,6 +100,9 @@ export interface ReviewPromptInput {
|
|
|
84
100
|
reviewKind?: ReviewKind
|
|
85
101
|
stagedDiff?: string
|
|
86
102
|
designDocs?: DesignDocs | null
|
|
103
|
+
// REQ-2026-033 B-2a: delta 표시(design kind 전용). 있으면 authority 블록을 문서별 [변경됨]/[승인 baseline]
|
|
104
|
+
// 태그로 렌더. 없으면(full 모드·phase) 기존 플레인 블록 그대로(바이트 무변경). persona는 안 바꾼다.
|
|
105
|
+
designDelta?: { changed: DesignDocKey[]; unchanged: DesignDocKey[] } | null
|
|
87
106
|
}
|
|
88
107
|
|
|
89
108
|
/**
|
|
@@ -96,7 +115,7 @@ export interface ReviewPromptInput {
|
|
|
96
115
|
* ⚠️ 이 함수는 파일을 읽지 않는다 — persona는 이미 읽힌 **본문**이다. 읽기·부재 판정은 `loadReviewPersona`가 한다.
|
|
97
116
|
*/
|
|
98
117
|
export function assembleReviewPrompt(input: ReviewPromptInput): string {
|
|
99
|
-
const { persona, handoff, reviewContext, reviewBaseSha, requestBody, stagedDiff, designDocs } = input
|
|
118
|
+
const { persona, handoff, reviewContext, reviewBaseSha, requestBody, stagedDiff, designDocs, designDelta } = input
|
|
100
119
|
const kind: ReviewKind = input.reviewKind ?? 'phase'
|
|
101
120
|
if (!reviewBaseSha) throw new Error('reviewBaseSha 필요')
|
|
102
121
|
if (!requestBody || !requestBody.trim()) throw new Error('codex-request.md 본문이 비어 있음')
|
|
@@ -121,17 +140,38 @@ export function assembleReviewPrompt(input: ReviewPromptInput): string {
|
|
|
121
140
|
blocks.push(`---\n${requestBody.trim()}`)
|
|
122
141
|
if (kind === 'design') {
|
|
123
142
|
if (!designDocs) throw new Error('design 리뷰 권위 아티팩트(00/01/02 designDocs) 필요')
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
143
|
+
if (designDelta) {
|
|
144
|
+
// REQ-2026-033 B-2a: delta 모드 — 문서별 태그. REQ-2026-036 B-3b: 변경 문서는 full 본문, 미변경(baseline)은
|
|
145
|
+
// DELTA_OMITTED_BODY로 생략(토큰 절감). 생략 문맥이 필요하면 리뷰어가 full_review_requested=yes(B-3a) 요청.
|
|
146
|
+
const tag = (k: DesignDocKey): string =>
|
|
147
|
+
designDelta.changed.includes(k) ? DELTA_CHANGED_TAG : DELTA_BASELINE_TAG
|
|
148
|
+
const body = (k: DesignDocKey, content: string): string =>
|
|
149
|
+
designDelta.changed.includes(k) ? content : DELTA_OMITTED_BODY
|
|
150
|
+
blocks.push(
|
|
151
|
+
[
|
|
152
|
+
'---\n# 권위 아티팩트 = 설계 문서 00/01/02 (delta review — 변경분 심사)',
|
|
153
|
+
`## 00-requirement.md ${tag('requirement')}`,
|
|
154
|
+
body('requirement', designDocs.requirement),
|
|
155
|
+
`## 01-design.md ${tag('design')}`,
|
|
156
|
+
body('design', designDocs.design),
|
|
157
|
+
`## 02-plan.md ${tag('plan')}`,
|
|
158
|
+
body('plan', designDocs.plan),
|
|
159
|
+
].join('\n'),
|
|
160
|
+
)
|
|
161
|
+
} else {
|
|
162
|
+
// full 모드(baseline 없음·첫/legacy) — B-1 이전과 바이트 동일 블록(R6).
|
|
163
|
+
blocks.push(
|
|
164
|
+
[
|
|
165
|
+
'---\n# 권위 아티팩트 = 설계 문서 00/01/02 (리뷰 대상 = 바인딩 대상)',
|
|
166
|
+
'## 00-requirement.md',
|
|
167
|
+
designDocs.requirement,
|
|
168
|
+
'## 01-design.md',
|
|
169
|
+
designDocs.design,
|
|
170
|
+
'## 02-plan.md',
|
|
171
|
+
designDocs.plan,
|
|
172
|
+
].join('\n'),
|
|
173
|
+
)
|
|
174
|
+
}
|
|
135
175
|
} else {
|
|
136
176
|
blocks.push(`---\n# 권위 아티팩트 = staged diff (리뷰 대상 = 바인딩 대상)\n${stagedDiff ?? ''}`)
|
|
137
177
|
}
|
|
@@ -238,6 +278,118 @@ export function captureDesignBinding(
|
|
|
238
278
|
return { designHash, paths }
|
|
239
279
|
}
|
|
240
280
|
|
|
281
|
+
/** design 문서 3종의 문서별 blob OID(REQ-2026-031 B-1). delta review(B-2)의 승인 baseline. */
|
|
282
|
+
export interface DesignDocBlobs {
|
|
283
|
+
requirement: string
|
|
284
|
+
design: string
|
|
285
|
+
plan: string
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* design 문서 3종의 **문서별 blob OID**를 git 인덱스에서 뽑는다(REQ-2026-031 B-1, R1).
|
|
290
|
+
* `git ls-files -s -- <3경로>` 출력의 각 줄 `<mode> <oid> <stage>\t<path>`에서 **mode·stage는 무시하고
|
|
291
|
+
* `path`로 문서 키를 매핑**한다 — 커스텀 designDocs면 ls-files가 경로 알파벳순으로 나와 위치가 문서 순서와
|
|
292
|
+
* 달라지므로, 위치가 아니라 path로 매핑해야 baseline이 오염되지 않는다(design-r01 P1).
|
|
293
|
+
* `captureDesignBinding`의 designHash와 **독립**(그것을 바꾸지 않는다, R6). 3개 중 하나라도 없으면 fail-closed.
|
|
294
|
+
*/
|
|
295
|
+
export function captureDesignDocBlobs(
|
|
296
|
+
ticketRelDir: string,
|
|
297
|
+
gitFn: GitFn = git,
|
|
298
|
+
designDocs: DesignDocs = DEFAULTS.designDocs,
|
|
299
|
+
): DesignDocBlobs {
|
|
300
|
+
const [reqP, designP, planP] = designDocPaths(ticketRelDir, designDocs)
|
|
301
|
+
const byPath = new Map<string, string>()
|
|
302
|
+
const out = gitFn(['ls-files', '-s', '--', reqP, designP, planP])
|
|
303
|
+
for (const line of out.split('\n').map((l) => l.trim()).filter(Boolean)) {
|
|
304
|
+
const tab = line.indexOf('\t')
|
|
305
|
+
if (tab < 0) continue
|
|
306
|
+
const path = line.slice(tab + 1)
|
|
307
|
+
const oid = line.slice(0, tab).split(/\s+/)[1] // [mode, oid, stage] — stage·mode 무시
|
|
308
|
+
if (oid) byPath.set(path, oid)
|
|
309
|
+
}
|
|
310
|
+
const pick = (p: string): string => {
|
|
311
|
+
const oid = byPath.get(p)
|
|
312
|
+
if (!oid) throw new Error(`design baseline 실패: ${p} 의 blob OID 없음(git 미추적, fail-closed).`)
|
|
313
|
+
return oid
|
|
314
|
+
}
|
|
315
|
+
return { requirement: pick(reqP), design: pick(designP), plan: pick(planP) }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* state에 유효한 design baseline(3 blob OID)이 있는지 판별(REQ-2026-031 B-1, R4·R5). 순수.
|
|
320
|
+
* B-1은 이 함수를 **제공만** 하고 아무 데서도 호출하지 않는다(R5) — B-2가 delta/full 분기에 쓴다.
|
|
321
|
+
* 부재(legacy·B-1 이전 승인) = false → B-2가 그런 티켓을 full review로 fallback한다.
|
|
322
|
+
*/
|
|
323
|
+
export function hasDesignBaseline(state: WorkflowState): boolean {
|
|
324
|
+
const b = (state as { design_baseline?: unknown }).design_baseline
|
|
325
|
+
if (!b || typeof b !== 'object') return false
|
|
326
|
+
const o = b as Record<string, unknown>
|
|
327
|
+
return (
|
|
328
|
+
typeof o.requirement === 'string' && o.requirement.length > 0 &&
|
|
329
|
+
typeof o.design === 'string' && o.design.length > 0 &&
|
|
330
|
+
typeof o.plan === 'string' && o.plan.length > 0
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 설계 문서 키(REQ-2026-033 B-2a). `DesignDocBlobs`·delta 표시의 문서 식별. */
|
|
335
|
+
export type DesignDocKey = 'requirement' | 'design' | 'plan'
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* delta review 문서 태그(REQ-2026-033 B-2a, R3). 변경/미변경 표시 — 코드 상수(오라클이 정확히 고정).
|
|
339
|
+
* 정보성 태그일 뿐이다: 리뷰 계약(재litigate 금지 지시)은 B-2b의 persona 몫. B-2a는 "무엇이 바뀌었나"만 표시.
|
|
340
|
+
*/
|
|
341
|
+
export const DELTA_CHANGED_TAG = '[변경됨 — 심사 대상]'
|
|
342
|
+
export const DELTA_BASELINE_TAG = '[승인 baseline — 변경 없음, 참조]'
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* delta 리뷰에서 **미변경 문서 본문 자리**의 생략 placeholder(REQ-2026-036 B-3b). 변경 문서는 full 본문,
|
|
346
|
+
* 미변경은 이 문자열로 대체해 토큰을 절감한다. 생략된 문맥이 필요하면 리뷰어는 `full_review_requested=yes`
|
|
347
|
+
* (B-3a)로 full review를 요청한다 — 그 안전판을 안내한다.
|
|
348
|
+
*/
|
|
349
|
+
export const DELTA_OMITTED_BODY =
|
|
350
|
+
'(본문 생략 — 승인 baseline·변경 없음. 전체가 필요하면 `full_review_requested: "yes"`로 full review를 요청하라.)'
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* baseline(승인 시점 문서별 blob OID)과 현재 인덱스 OID를 **키별** 비교(REQ-2026-033 B-2a, R1). 순수.
|
|
354
|
+
* 다르면 changed, 같으면 unchanged — 위치·순서가 아니라 키(requirement/design/plan)로 비교. 결정적 키 순서로
|
|
355
|
+
* 반환. delta 프롬프트가 어느 문서를 [변경됨]/[승인 baseline]으로 표시할지의 근거. baseline·current는 DesignDocBlobs.
|
|
356
|
+
*/
|
|
357
|
+
export function computeDesignDelta(
|
|
358
|
+
baseline: DesignDocBlobs,
|
|
359
|
+
current: DesignDocBlobs,
|
|
360
|
+
): { changed: DesignDocKey[]; unchanged: DesignDocKey[] } {
|
|
361
|
+
const keys: DesignDocKey[] = ['requirement', 'design', 'plan']
|
|
362
|
+
const changed: DesignDocKey[] = []
|
|
363
|
+
const unchanged: DesignDocKey[] = []
|
|
364
|
+
for (const k of keys) (baseline[k] === current[k] ? unchanged : changed).push(k)
|
|
365
|
+
return { changed, unchanged }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* design delta 리뷰의 **행동 계약**(REQ-2026-034 B-2b, R1). delta 모드 persona에 얹혀 "표시된 변경분·직접
|
|
370
|
+
* 영향만 심사, 승인 영역 재litigate 금지"를 건다. 태그 문구는 `DELTA_*_TAG` **상수 참조**(하드코딩 금지 —
|
|
371
|
+
* 태그가 바뀌면 계약도 자동 반영). B-2a 태그를 리뷰어가 어떻게 쓸지의 계약이다.
|
|
372
|
+
*/
|
|
373
|
+
export const DESIGN_DELTA_CONTRACT = [
|
|
374
|
+
'# Delta Review 계약',
|
|
375
|
+
`- ${DELTA_CHANGED_TAG} 표시 문서·섹션과 그 직접 영향 범위만 심사한다.`,
|
|
376
|
+
`- ${DELTA_BASELINE_TAG}은 직전 라운드에 승인되었다. 재심사·재litigate 금지 — 참조 문맥으로만 쓴다.`,
|
|
377
|
+
'- 단, 이번 변경이 승인 영역의 재고를 강제하면(모순·전제 붕괴) finding으로 명확히 밝혀라.',
|
|
378
|
+
// REQ-2026-035 B-3a: escalation 사용법. 신호(full_review_requested)만으론 언제 쓸지 모르므로 계약에 명시.
|
|
379
|
+
'- 변경이 너무 근본적이어서 delta(변경분만)로 판단할 수 없으면 `full_review_requested: "yes"`로 응답해 전체 설계 재리뷰를 요청하라(그때 `commit_approved: "no"`).',
|
|
380
|
+
].join('\n')
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* delta 모드 effective persona 계산(REQ-2026-034 B-2b, R2·R4). 순수. `deltaActive`(= main의 `designDelta`가
|
|
384
|
+
* 설정됨 = design + baseline)면 계약을 얹는다 — base 있으면 `base + '\n' + 계약`, **base null(reviewPersonaPath:null,
|
|
385
|
+
* 지원 설정)이면 계약 단독**. `deltaActive` 아니면 base 그대로(null이면 null — full·phase 무회귀).
|
|
386
|
+
* main은 이 결과 **하나**를 프롬프트와 review-call 로그 policy_version 양쪽에 흘린다(단일 배선, 032 r02·r03).
|
|
387
|
+
*/
|
|
388
|
+
export function applyDeltaPersona(base: string | null, deltaActive: boolean): string | null {
|
|
389
|
+
if (!deltaActive) return base
|
|
390
|
+
return base ? `${base}\n${DESIGN_DELTA_CONTRACT}` : DESIGN_DELTA_CONTRACT
|
|
391
|
+
}
|
|
392
|
+
|
|
241
393
|
/**
|
|
242
394
|
* 설계 문서 3종 본문을 git **인덱스**에서 읽는다(`git show :<path>`) — Codex P2.
|
|
243
395
|
* 프롬프트 본문(리뷰 대상)과 design 바인딩 해시(`captureDesignBinding`, 인덱스 기반)가 **동일 대상**을 가리키게 하여
|
|
@@ -292,6 +444,9 @@ export interface Verdict {
|
|
|
292
444
|
next_action?: string
|
|
293
445
|
// REQ-2026-005: optional 비차단 코멘트. classifyReview는 이 필드를 보지 않는다(findings 존재만으로 분류).
|
|
294
446
|
observations?: Observation[]
|
|
447
|
+
// REQ-2026-035 B-3a: design delta 리뷰에서 리뷰어가 full review를 요청하는 신호(yes/no, optional). yes면
|
|
448
|
+
// processResponse가 baseline을 비워 다음 리뷰를 full로 되돌린다. yes는 commit_approved=no·review_kind=design 필수.
|
|
449
|
+
full_review_requested?: string
|
|
295
450
|
}
|
|
296
451
|
|
|
297
452
|
const STATUS_VALUES = ['NEEDS_FIX', 'STEP_COMPLETE', 'COMPLETE']
|
|
@@ -343,6 +498,12 @@ export function validateVerdict(
|
|
|
343
498
|
if (v.commit_approved === 'yes' && Array.isArray(v.findings) && v.findings.length > 0)
|
|
344
499
|
errors.push('모순: commit_approved=yes 인데 findings가 비어있지 않음 (승인은 findings 0건 — 지적이 있으면 미승인)')
|
|
345
500
|
|
|
501
|
+
// REQ-2026-035 B-3a: full review 요청은 미승인·design 전용(교차필드). enum(yes/no)은 AJV가 강제.
|
|
502
|
+
if (v.full_review_requested === 'yes' && v.commit_approved !== 'no')
|
|
503
|
+
errors.push('모순: full_review_requested=yes 인데 commit_approved≠no (full review 요청은 미승인이어야 함)')
|
|
504
|
+
if (v.full_review_requested === 'yes' && v.review_kind !== 'design')
|
|
505
|
+
errors.push('모순: full_review_requested=yes 인데 review_kind≠design (delta/baseline은 design 전용)')
|
|
506
|
+
|
|
346
507
|
return { ok: errors.length === 0, errors }
|
|
347
508
|
}
|
|
348
509
|
|
|
@@ -382,6 +543,84 @@ export const REVIEW_EXIT_CODES: Record<ReviewOutcome, number> = {
|
|
|
382
543
|
'needs-fix': 3,
|
|
383
544
|
}
|
|
384
545
|
|
|
546
|
+
// ──────────────────────────────────── review-call 측정 로그 (REQ-2026-025) ──
|
|
547
|
+
|
|
548
|
+
/** 로그 경로(repo 루트 기준). `.gitignore`에 등재 — **커밋 대상이 아니다**(D3·R7). */
|
|
549
|
+
export const REVIEW_CALL_LOG_REL = 'workflow/.review-calls.jsonl'
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* persona 본문 → 로그 세그먼트 키(D2). `sha256(본문)` 앞 12자. persona 비활성(null)이면 `'none'`.
|
|
553
|
+
*
|
|
554
|
+
* 수동 상수 bump를 쓰지 않는 이유: persona를 고치고 상수 올리기를 잊으면 세그먼트가 **조용히 거짓**이 된다.
|
|
555
|
+
* 이 프로젝트는 사람이 손으로 적은 값이 실제와 어긋나 REQ 하나를 폐기한 이력이 있다(REQ-2026-019).
|
|
556
|
+
* 자동 파생은 잊을 수 없고, 사용자가 `reviewPersonaPath`로 바꾼 custom persona도 자동으로 구분한다.
|
|
557
|
+
*/
|
|
558
|
+
export function reviewPolicyVersion(persona: string | null): string {
|
|
559
|
+
if (persona === null) return 'none'
|
|
560
|
+
return createHash('sha256').update(persona, 'utf8').digest('hex').slice(0, 12)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** review-call 로그 1행(R6 최소 필드). REQ-A가 series/attempt/lineage를, REQ-B가 review_mode/full_review를 **확장**한다(R9·D6). */
|
|
564
|
+
export interface ReviewCallLogRow {
|
|
565
|
+
ticket_id: string
|
|
566
|
+
review_kind: ReviewKind
|
|
567
|
+
phase_id: string | null
|
|
568
|
+
/** 아카이브 round. 무효 응답은 아카이브를 남기지 않으므로 `null`(D4). */
|
|
569
|
+
archive_round: number | null
|
|
570
|
+
outcome: ReviewOutcome
|
|
571
|
+
findings_count: number
|
|
572
|
+
observations_count: number
|
|
573
|
+
timestamp: string
|
|
574
|
+
policy_version: string
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* verdict → 로그 행(순수). **내용 배제 경계가 여기다**(R7).
|
|
579
|
+
*
|
|
580
|
+
* verdict를 받지만 **개수만 꺼낸다** — `findings[].detail`·`observations[].detail`·`next_action`·`file`은
|
|
581
|
+
* 절대 행에 담지 않는다. 리뷰 프롬프트는 `git diff --cached` 전문을 담을 수 있고(AGENTS 계약 §6),
|
|
582
|
+
* 그 파생물을 gitignore된 로컬 파일에 복제하면 마스킹 없는 사본이 하나 더 생긴다. 개수만 세면 목적
|
|
583
|
+
* ("배칭이 라운드당 P1 수를 바꾸는가")이 달성된다.
|
|
584
|
+
*/
|
|
585
|
+
export function buildReviewCallLogRow(args: {
|
|
586
|
+
ticketId: string
|
|
587
|
+
kind: ReviewKind
|
|
588
|
+
phaseId: string | null
|
|
589
|
+
archiveRound: number | null
|
|
590
|
+
outcome: ReviewOutcome
|
|
591
|
+
verdict: Verdict
|
|
592
|
+
timestamp: string
|
|
593
|
+
policyVersion: string
|
|
594
|
+
}): ReviewCallLogRow {
|
|
595
|
+
return {
|
|
596
|
+
ticket_id: args.ticketId,
|
|
597
|
+
review_kind: args.kind,
|
|
598
|
+
phase_id: args.phaseId,
|
|
599
|
+
archive_round: args.archiveRound,
|
|
600
|
+
outcome: args.outcome,
|
|
601
|
+
findings_count: args.verdict.findings?.length ?? 0,
|
|
602
|
+
observations_count: args.verdict.observations?.length ?? 0,
|
|
603
|
+
timestamp: args.timestamp,
|
|
604
|
+
policy_version: args.policyVersion,
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* 로그 append(측정 전용). **실패를 삼킨다**(R8).
|
|
610
|
+
*
|
|
611
|
+
* fail-closed 원칙의 예외가 아니다 — 이 로그는 **승인 근거가 아니므로 게이트가 아니다.** 측정 실패가
|
|
612
|
+
* 리뷰 판정·exit code·state를 바꾸면 그것이 오히려 계약 위반이다. 아카이브 기록도 같은 패턴을 쓴다.
|
|
613
|
+
*/
|
|
614
|
+
export function appendReviewCallLog(rootAbs: string, row: ReviewCallLogRow): void {
|
|
615
|
+
try {
|
|
616
|
+
const abs = join(rootAbs, ...REVIEW_CALL_LOG_REL.split('/'))
|
|
617
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
618
|
+
appendFileSync(abs, `${JSON.stringify(row)}\n`, 'utf8')
|
|
619
|
+
} catch {
|
|
620
|
+
// 측정은 게이트가 아니다(R8). 리뷰 판정 경로는 로그 유무와 무관하게 동일하다.
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
385
624
|
export interface ProcessResponseResult {
|
|
386
625
|
ok: boolean
|
|
387
626
|
errors: string[]
|
|
@@ -588,11 +827,319 @@ export interface WorkflowState {
|
|
|
588
827
|
// REQ-016 A1: 승인 증거 핀(kind 격리) + grandfathering 트리거. 반대 kind 증거는 미오염.
|
|
589
828
|
approval_evidence?: ApprovalEvidence
|
|
590
829
|
design_approval_evidence?: ApprovalEvidence
|
|
830
|
+
// REQ-2026-031 B-1: 마지막 design 승인 시점의 문서별 blob OID(delta review baseline). top-level이라
|
|
831
|
+
// design 재리뷰의 evidence stale 제거(:1184)에 안 걸리고 NEEDS_FIX 사이클 내내 보존된다. 승인 시에만 갱신.
|
|
832
|
+
design_baseline?: DesignDocBlobs
|
|
591
833
|
blocked_review?: BlockedReviewMarker
|
|
592
834
|
approval_evidence_required?: boolean
|
|
835
|
+
// REQ-2026-027 D1·D2: review series 모델 버전 + series 레코드. 필드 부재 = legacy(무침습).
|
|
836
|
+
review_series_model_version?: number
|
|
837
|
+
review_series?: SeriesRecord[]
|
|
838
|
+
// REQ-2026-028 D2: 사람 예외 손기록(6~8회차). 소비되면 null(무이월).
|
|
839
|
+
review_exception_confirmed?: ReviewExceptionConfirmed | null
|
|
840
|
+
// REQ-2026-029 D3: 대체 REQ의 부모 lineage(--successor-of로만 채워짐).
|
|
841
|
+
successor_of?: SuccessorOf
|
|
593
842
|
[k: string]: unknown
|
|
594
843
|
}
|
|
595
844
|
|
|
845
|
+
/**
|
|
846
|
+
* review series 레코드(REQ-2026-027 D2). 키 `(review_kind, phase_id)`. 배열이라 이력을 지우지 않는다(R7).
|
|
847
|
+
* A-1의 `closed_reason`은 `'approved' | null`뿐 — A-2가 `'human-resolution'`을 추가한다(열린 확장).
|
|
848
|
+
*/
|
|
849
|
+
export interface SeriesRecord {
|
|
850
|
+
series_id: string
|
|
851
|
+
review_kind: ReviewKind
|
|
852
|
+
phase_id: string | null
|
|
853
|
+
attempts: number
|
|
854
|
+
// REQ-2026-029 A-2b: 'human-resolution' 추가(A-2a가 열린 확장으로 남긴 타입). approved와 재개방 규칙 정반대.
|
|
855
|
+
closed_reason: 'approved' | 'human-resolution' | null
|
|
856
|
+
// closed_reason='human-resolution'일 때만. 사람이 escalate된 series를 종료·대체로 결정한 손기록.
|
|
857
|
+
human_resolution?: HumanResolution
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** 사람의 series 종결 결정(REQ-2026-029 A-2b D1). accept-risk 없음(배분표 ④ — decision은 둘뿐). */
|
|
861
|
+
export interface HumanResolution {
|
|
862
|
+
decision: 'terminate' | 'replace'
|
|
863
|
+
method: string // 받은 승인 문장 그대로
|
|
864
|
+
decided_at: string // 실제 시계(REQ-019 날조 폐기 이력)
|
|
865
|
+
note?: string
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* legacy ticket 판정(REQ-2026-027 D1, 순수). 생성 시 stamp되는 `review_series_model_version` **부재** = legacy.
|
|
870
|
+
* "series 레코드 유무"로 판정하지 않는다 — 새 ticket도 첫 리뷰 전엔 레코드가 없어 오분류된다.
|
|
871
|
+
* `state.phase`를 쓰지 않는다(죽은 필드 — 티켓 전부 INTAKE). legacy면 자동 초기화 대신 사람에게 넘긴다.
|
|
872
|
+
*/
|
|
873
|
+
export function isLegacyTicket(state: WorkflowState): boolean {
|
|
874
|
+
return typeof state.review_series_model_version !== 'number'
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** `state.review_series`를 안전하게 읽는다(부재/비배열 → []). */
|
|
878
|
+
function readSeries(state: WorkflowState): SeriesRecord[] {
|
|
879
|
+
const raw = (state as { review_series?: unknown }).review_series
|
|
880
|
+
return Array.isArray(raw) ? (raw as SeriesRecord[]) : []
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* attempt 기록(REQ-2026-027 D2·D3, 순수). 같은 `(kind, phase_id)`에 **열린** series가 있으면 그 `attempts`를
|
|
885
|
+
* +1, 없으면 새 series를 연다(seq = 같은 키의 기존 레코드 수 + 1, attempts=1).
|
|
886
|
+
*
|
|
887
|
+
* **hash를 입력으로 받지 않는다**(R5) — design series는 hash가 바뀌어도 같은 series다. 이것이 REQ-020의
|
|
888
|
+
* 14라운드 병리(라운드마다 hash가 달라 계수가 초기화됨)를 막는 핵심이다. **아무것도 막지 않는다**(R11):
|
|
889
|
+
* attempts가 아무리 커도 여기서 거부하지 않는다 — 예산·상한은 A-2다.
|
|
890
|
+
*/
|
|
891
|
+
export function recordAttempt(state: WorkflowState, kind: ReviewKind, phaseId: string | null): WorkflowState {
|
|
892
|
+
const series = readSeries(state)
|
|
893
|
+
const openIdx = series.findIndex(
|
|
894
|
+
(r) => r.review_kind === kind && (r.phase_id ?? null) === phaseId && r.closed_reason === null,
|
|
895
|
+
)
|
|
896
|
+
if (openIdx >= 0) {
|
|
897
|
+
const next = series.map((r, i) => (i === openIdx ? { ...r, attempts: r.attempts + 1 } : r))
|
|
898
|
+
return { ...state, review_series: next }
|
|
899
|
+
}
|
|
900
|
+
const seq = series.filter((r) => r.review_kind === kind && (r.phase_id ?? null) === phaseId).length + 1
|
|
901
|
+
const rec: SeriesRecord = {
|
|
902
|
+
series_id: `${kind}:${phaseId ?? '-'}#${seq}`,
|
|
903
|
+
review_kind: kind,
|
|
904
|
+
phase_id: phaseId,
|
|
905
|
+
attempts: 1,
|
|
906
|
+
closed_reason: null,
|
|
907
|
+
}
|
|
908
|
+
return { ...state, review_series: [...series, rec] }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* 승인으로 series 종료(REQ-2026-027 D2, 순수). 같은 `(kind, phase_id)`의 **열린** 레코드를 `'approved'`로 닫는다.
|
|
913
|
+
* 열린 게 없으면 no-op(방어). **`approved`만이 A-1의 자동 종료 계기다**(R6) — needs-fix·blocked·invalid는
|
|
914
|
+
* 여기를 타지 않아 열린 채로 남고, 그래야 A-2가 얹을 상한이 의미를 갖는다.
|
|
915
|
+
*/
|
|
916
|
+
export function closeSeriesApproved(state: WorkflowState, kind: ReviewKind, phaseId: string | null): WorkflowState {
|
|
917
|
+
const series = readSeries(state)
|
|
918
|
+
const openIdx = series.findIndex(
|
|
919
|
+
(r) => r.review_kind === kind && (r.phase_id ?? null) === phaseId && r.closed_reason === null,
|
|
920
|
+
)
|
|
921
|
+
if (openIdx < 0) return state
|
|
922
|
+
const next = series.map((r, i) => (i === openIdx ? { ...r, closed_reason: 'approved' as const } : r))
|
|
923
|
+
return { ...state, review_series: next }
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ──────────────────────────────── review lineage — human-resolution (REQ-2026-029 A-2b) ──
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* 사람 종결 손기록 형식 검증(REQ-2026-029 D1, 순수, R3·배분표 ⑪). `decision ∈ {terminate,replace}` +
|
|
930
|
+
* 비어있지 않은 `method` + 유효 ISO `decided_at`(A-2a `isValidIsoInstant` 재사용 — 형식+달력).
|
|
931
|
+
* 검증 없으면 `{decision:'',decided_at:'x'}`도 통과해 날조 종결(REQ-019 부류).
|
|
932
|
+
*/
|
|
933
|
+
export function isValidHumanResolution(r: unknown): r is HumanResolution {
|
|
934
|
+
if (!r || typeof r !== 'object') return false
|
|
935
|
+
const h = r as Partial<HumanResolution>
|
|
936
|
+
if (h.decision !== 'terminate' && h.decision !== 'replace') return false
|
|
937
|
+
if (typeof h.method !== 'string' || h.method.trim().length === 0) return false
|
|
938
|
+
if (!isValidIsoInstant(h.decided_at)) return false
|
|
939
|
+
return true
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* 사람 종결로 series 닫기(REQ-2026-029 D1, 순수). 같은 `(kind,phase_id)`의 **열린** 레코드를
|
|
944
|
+
* `closed_reason='human-resolution'`+`human_resolution`으로 닫는다. 열린 게 없으면 throw(종결 대상이 없음).
|
|
945
|
+
* resolution 형식이 무효면 throw(fail-closed).
|
|
946
|
+
*/
|
|
947
|
+
export function closeSeriesHumanResolution(
|
|
948
|
+
state: WorkflowState,
|
|
949
|
+
kind: ReviewKind,
|
|
950
|
+
phaseId: string | null,
|
|
951
|
+
resolution: HumanResolution,
|
|
952
|
+
): WorkflowState {
|
|
953
|
+
if (!isValidHumanResolution(resolution)) throw new Error('human_resolution 형식 무효(decision·method·decided_at)')
|
|
954
|
+
const series = readSeries(state)
|
|
955
|
+
const openIdx = series.findIndex(
|
|
956
|
+
(r) => r.review_kind === kind && (r.phase_id ?? null) === phaseId && r.closed_reason === null,
|
|
957
|
+
)
|
|
958
|
+
if (openIdx < 0) throw new Error('human-resolution 종결 대상(열린 series)이 없다')
|
|
959
|
+
const next = series.map((r, i) =>
|
|
960
|
+
i === openIdx ? { ...r, closed_reason: 'human-resolution' as const, human_resolution: resolution } : r,
|
|
961
|
+
)
|
|
962
|
+
return { ...state, review_series: next }
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* series 키 terminal 판정(REQ-2026-029 D2, 순수, R4·배분표 ③). `(kind,phase_id)`에 `closed_reason=
|
|
967
|
+
* 'human-resolution'` 레코드가 **하나라도 있으면** true. `approved`만/null인 키는 false(재개방 정상).
|
|
968
|
+
*
|
|
969
|
+
* **approved와 재개방 규칙이 정반대다**: approved=문제 해결 → 새 series 정당. human-resolution=미해결·사람
|
|
970
|
+
* 개입 → 같은 키에서 계속하면 개입 무효화 → 자동으로 안 연다.
|
|
971
|
+
*/
|
|
972
|
+
export function isSeriesKeyTerminal(state: WorkflowState, kind: ReviewKind, phaseId: string | null): boolean {
|
|
973
|
+
return readSeries(state).some(
|
|
974
|
+
(r) => r.review_kind === kind && (r.phase_id ?? null) === phaseId && r.closed_reason === 'human-resolution',
|
|
975
|
+
)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* 대체 REQ의 부모 lineage(REQ-2026-029 D3). **`recorded_at`만 자식 생성 시각**이고 나머지는 부모에서 읽는다
|
|
980
|
+
* (design-r01 observation — provenance 명확화).
|
|
981
|
+
*/
|
|
982
|
+
export interface SuccessorOf {
|
|
983
|
+
req_id: string // 부모 REQ id
|
|
984
|
+
parent_attempts_total: number // 부모 **모든** series attempts 합
|
|
985
|
+
parent_replace_resolution: HumanResolution // 부모의 replace 종결 손기록 그대로
|
|
986
|
+
recorded_at: string // 자식 생성 시각(부모 값 아님)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* 부모에서 lineage를 읽어 채운다(REQ-2026-029 D3, 순수, R6·R7·배분표 ⑩). 부모에 `decision='replace'` +
|
|
991
|
+
* 유효 형식(`isValidHumanResolution`)인 human-resolution series가 **없으면 throw**(fail-closed — 티켓 미생성).
|
|
992
|
+
*
|
|
993
|
+
* **lineage 근거 세 필드(`req_id`·`parent_attempts_total`·`parent_replace_resolution`)는 부모 state에서 온다**
|
|
994
|
+
* — CLI로 받지 않는다(전사 오류·날조 통로 차단). `recorded_at`만 자식 생성 시각(부모 값 아님, 호출자가 넘김).
|
|
995
|
+
*/
|
|
996
|
+
export function resolveSuccessorLineage(parentState: WorkflowState, parentReqId: string, recordedAt: string): SuccessorOf {
|
|
997
|
+
const replace = readSeries(parentState).find(
|
|
998
|
+
(r) => r.closed_reason === 'human-resolution' && r.human_resolution?.decision === 'replace' && isValidHumanResolution(r.human_resolution),
|
|
999
|
+
)
|
|
1000
|
+
if (!replace || !replace.human_resolution)
|
|
1001
|
+
throw new Error(`--successor-of ${parentReqId}: 부모에 대체(replace)를 허용한 유효한 사람 결정 기록이 없다`)
|
|
1002
|
+
const parentAttemptsTotal = readSeries(parentState).reduce((sum, r) => sum + (typeof r.attempts === 'number' ? r.attempts : 0), 0)
|
|
1003
|
+
return {
|
|
1004
|
+
req_id: parentReqId,
|
|
1005
|
+
parent_attempts_total: parentAttemptsTotal,
|
|
1006
|
+
parent_replace_resolution: replace.human_resolution,
|
|
1007
|
+
recorded_at: recordedAt,
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// ──────────────────────────────── review 예산 게이트 (REQ-2026-028 A-2a) ──
|
|
1012
|
+
|
|
1013
|
+
/** 예산 판정(REQ-2026-028 D1). `attempt`는 **이 다음 호출의 회차**(= openAttempts + 1). */
|
|
1014
|
+
export type BudgetDecision =
|
|
1015
|
+
| { kind: 'allow' }
|
|
1016
|
+
| { kind: 'needs-exception'; attempt: number }
|
|
1017
|
+
| { kind: 'hard-blocked'; attempt: number }
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* 같은 `(kind, phase_id)`의 **열린** series의 attempts(없으면 0). 게이트 입력.
|
|
1021
|
+
* `escalated`나 직전 outcome을 보지 않는다 — 계수만이 기준이다(R2, 배분표 ⑤).
|
|
1022
|
+
*/
|
|
1023
|
+
export function openSeriesAttempts(state: WorkflowState, kind: ReviewKind, phaseId: string | null): number {
|
|
1024
|
+
const rec = openSeriesRecord(state, kind, phaseId)
|
|
1025
|
+
return rec ? rec.attempts : 0
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* 예산 게이트 판정(REQ-2026-028 D1, 순수). **기준은 `openAttempts`뿐**(R2).
|
|
1030
|
+
* - `openAttempts < autoBudget` → allow(자동)
|
|
1031
|
+
* - `autoBudget <= openAttempts < hardCap` → needs-exception(6~8회차, 사람 예외 필요)
|
|
1032
|
+
* - `openAttempts >= hardCap` → hard-blocked(9회차, 예외로도 차단)
|
|
1033
|
+
*/
|
|
1034
|
+
export function checkReviewBudget(openAttempts: number, budget: ReviewBudget): BudgetDecision {
|
|
1035
|
+
const attempt = openAttempts + 1 // 이 다음 호출의 회차
|
|
1036
|
+
if (openAttempts < budget.autoBudget) return { kind: 'allow' }
|
|
1037
|
+
if (openAttempts < budget.hardCap) return { kind: 'needs-exception', attempt }
|
|
1038
|
+
return { kind: 'hard-blocked', attempt }
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** ISO instant 형식(밀리초 선택). req-commit.ts의 ISO_RE와 같은 패턴(손기록 계약 통일). */
|
|
1042
|
+
const REVIEW_ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* ISO instant 유효성(REQ-2026-028 D2). **형식 + 달력 유효성 둘 다**(design-r02·r03).
|
|
1046
|
+
* `ISO_RE`만으론 `2026-99-99T99:99:99Z`가 통과하므로, 재파싱해 성분(연·월·일·시·분·초)이 보존되는지 확인.
|
|
1047
|
+
* 밀리초 표기 차(`08Z` vs `08.000Z`)는 비교에서 무시 — 성분이 맞으면 유효.
|
|
1048
|
+
*/
|
|
1049
|
+
export function isValidIsoInstant(s: unknown): boolean {
|
|
1050
|
+
if (typeof s !== 'string' || !REVIEW_ISO_RE.test(s)) return false
|
|
1051
|
+
const d = new Date(s)
|
|
1052
|
+
if (Number.isNaN(d.getTime())) return false
|
|
1053
|
+
// 재직렬화 후 초까지 성분 비교(밀리초 절단). `2026-99-99...`는 여기서 불일치로 걸린다.
|
|
1054
|
+
const canon = (x: string): string => x.replace(/\.\d+Z$/, 'Z').replace(/Z$/, '')
|
|
1055
|
+
return canon(d.toISOString()) === canon(s)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** 사람 예외 손기록(REQ-2026-028 D2). `user_commit_confirmed`와 같은 모양. */
|
|
1059
|
+
export interface ReviewExceptionConfirmed {
|
|
1060
|
+
confirmed: boolean
|
|
1061
|
+
method: string
|
|
1062
|
+
confirmed_at: string
|
|
1063
|
+
for_series_id: string
|
|
1064
|
+
for_attempt: number
|
|
1065
|
+
note?: string
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* 사람 예외 소비(REQ-2026-028 D2, 순수). 유효하면 소비된 state(`review_exception_confirmed=null`) 반환,
|
|
1070
|
+
* 무효면 throw(fail-closed). **series는 닫지 않는다**(R10, 배분표 ①) — `closed_reason` 미변경.
|
|
1071
|
+
*
|
|
1072
|
+
* 유효 조건: (1) 형식 — `confirmed===true` + 비어있지 않은 `method` + 유효 ISO `confirmed_at`(R8, 배분표 ⑪).
|
|
1073
|
+
* (2) 바인딩 — `for_series_id === seriesId` && `for_attempt === nextAttempt`(R9).
|
|
1074
|
+
*/
|
|
1075
|
+
export function consumeReviewException(
|
|
1076
|
+
state: WorkflowState,
|
|
1077
|
+
seriesId: string,
|
|
1078
|
+
nextAttempt: number,
|
|
1079
|
+
): WorkflowState {
|
|
1080
|
+
const raw = (state as { review_exception_confirmed?: unknown }).review_exception_confirmed
|
|
1081
|
+
if (!raw || typeof raw !== 'object') throw new Error(`review 예외 승인 없음 — ${nextAttempt}회차는 사람 승인이 필요하다`)
|
|
1082
|
+
const ex = raw as Partial<ReviewExceptionConfirmed>
|
|
1083
|
+
if (ex.confirmed !== true) throw new Error('review 예외: confirmed!==true (무효 손기록)')
|
|
1084
|
+
if (typeof ex.method !== 'string' || ex.method.trim().length === 0)
|
|
1085
|
+
throw new Error('review 예외: method 비어 있음 (무효 손기록)')
|
|
1086
|
+
if (!isValidIsoInstant(ex.confirmed_at)) throw new Error(`review 예외: confirmed_at 비-ISO (${String(ex.confirmed_at)})`)
|
|
1087
|
+
if (ex.for_series_id !== seriesId)
|
|
1088
|
+
throw new Error(`review 예외: for_series_id 불일치(${String(ex.for_series_id)} ≠ ${seriesId}) — 다른 series 예외 재사용 불가`)
|
|
1089
|
+
if (ex.for_attempt !== nextAttempt)
|
|
1090
|
+
throw new Error(`review 예외: for_attempt 불일치(${String(ex.for_attempt)} ≠ ${nextAttempt}) — 다른 회차 예외 재사용 불가`)
|
|
1091
|
+
const { review_exception_confirmed: _consumed, ...rest } = state // 1회 소비(무이월)
|
|
1092
|
+
return { ...rest, review_exception_confirmed: null }
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* attempt를 **외부 호출 직전**에 기록·`writeState`하고 `call()`을 부른다(REQ-2026-027 D3 + REQ-2026-028 D1).
|
|
1097
|
+
*
|
|
1098
|
+
* **순서가 계약이다**(R8): 예산 게이트 → (예외 소비) → 기록·writeState → `call()`. `call()`이 throw해도
|
|
1099
|
+
* 기록은 이미 디스크에 있어 되돌아가지 않는다 — 외부 호출은 이미 일어났고 비용도 발생했다.
|
|
1100
|
+
*
|
|
1101
|
+
* **예산 게이트가 `recordAttempt` 전이다**(REQ-2026-028 R5): 막을 거면 호출도 기록도 하기 전에 막는다.
|
|
1102
|
+
* throw 시 state는 바뀌지 않는다(예외 소비 성공 시에만 쓰기). **반환 `state`가 후처리의 유일한 base다**(R9).
|
|
1103
|
+
*/
|
|
1104
|
+
export function withAttemptRecorded<T>(
|
|
1105
|
+
ctx: { ticketDir: string; state: WorkflowState; kind: ReviewKind; phaseId: string | null; budget: ReviewBudget },
|
|
1106
|
+
call: () => T,
|
|
1107
|
+
): { result: T; state: WorkflowState } {
|
|
1108
|
+
// REQ-2026-029 D2: terminal 가드 — 예산 게이트보다 **앞**. human-resolution으로 종결된 키는 예산을 볼
|
|
1109
|
+
// 필요도 없이 막는다(배분표 ③). 가드 없으면 recordAttempt가 새 series(0회)를 열어 예산이 리셋된다.
|
|
1110
|
+
if (isSeriesKeyTerminal(ctx.state, ctx.kind, ctx.phaseId))
|
|
1111
|
+
throw new Error(
|
|
1112
|
+
'이 series는 human-resolution으로 종결됐다 — 같은 키에서 자동으로 재개하지 않는다. 대체가 필요하면 `req:new --successor-of <이 REQ>`로 만든다.',
|
|
1113
|
+
)
|
|
1114
|
+
// REQ-2026-028 D1: recordAttempt **전**에 예산 검사. 초과면 호출·기록 전에 throw.
|
|
1115
|
+
const openAttempts = openSeriesAttempts(ctx.state, ctx.kind, ctx.phaseId)
|
|
1116
|
+
const decision = checkReviewBudget(openAttempts, ctx.budget)
|
|
1117
|
+
let gated = ctx.state
|
|
1118
|
+
if (decision.kind === 'hard-blocked')
|
|
1119
|
+
throw new Error(
|
|
1120
|
+
`review 예산 소진 — ${decision.attempt}회차는 어떤 경로로도 실행하지 않는다(hardCap=${ctx.budget.hardCap}). 종료하거나 정합한 대체 REQ를 작성한다.`,
|
|
1121
|
+
)
|
|
1122
|
+
if (decision.kind === 'needs-exception') {
|
|
1123
|
+
// 🔴 열린 record의 series_id를 **직접** 쓴다(design-r01 P1). 재구성(`split('#')`)은 phase id에 `#`가
|
|
1124
|
+
// 들어가면 깨진다(`phase#alpha` → `NaN`). needs-exception이면 openAttempts>=autoBudget≥1이라 열린
|
|
1125
|
+
// record가 반드시 존재한다(attempts>=1).
|
|
1126
|
+
const open = openSeriesRecord(ctx.state, ctx.kind, ctx.phaseId)
|
|
1127
|
+
if (!open) throw new Error('review 예외: 열린 series를 찾을 수 없다(불변 위반)') // 방어(도달 불가)
|
|
1128
|
+
gated = consumeReviewException(ctx.state, open.series_id, decision.attempt) // 무효면 throw
|
|
1129
|
+
}
|
|
1130
|
+
const state = recordAttempt(gated, ctx.kind, ctx.phaseId)
|
|
1131
|
+
writeState(ctx.ticketDir, state) // 호출 **전** 영속 — throw해도 남는다
|
|
1132
|
+
const result = call() // throw면 그대로 전파(기록은 이미 선행)
|
|
1133
|
+
return { result, state }
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** 같은 `(kind, phase_id)`의 열린 series record(없으면 undefined). series_id를 재구성하지 않고 직접 얻는다. */
|
|
1137
|
+
export function openSeriesRecord(state: WorkflowState, kind: ReviewKind, phaseId: string | null): SeriesRecord | undefined {
|
|
1138
|
+
return readSeries(state).find(
|
|
1139
|
+
(r) => r.review_kind === kind && (r.phase_id ?? null) === phaseId && r.closed_reason === null,
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1142
|
+
|
|
596
1143
|
/** state.phases를 안전하게 PhaseEntry[]로 읽음(부재/비배열→[], id 문자열 항목만). */
|
|
597
1144
|
export function readPhases(state: WorkflowState): PhaseEntry[] {
|
|
598
1145
|
const raw = (state as { phases?: unknown }).phases
|
|
@@ -750,6 +1297,8 @@ export function processResponse(args: {
|
|
|
750
1297
|
// REQ-016 A1: 승인 시 증거 핀 정본(아카이브 경로+sha). 미제공 시 evidence 미부착(하위호환).
|
|
751
1298
|
archive?: { path: string; sha256: string }
|
|
752
1299
|
approvedAt?: string
|
|
1300
|
+
// REQ-2026-031 B-1: design 승인 시 저장할 문서별 blob OID. 승인+archive 분기에서만 state.design_baseline에 설정.
|
|
1301
|
+
designDocBlobs?: DesignDocBlobs
|
|
753
1302
|
}): ProcessResponseResult {
|
|
754
1303
|
const { ticketDir, state, binding, threadId, designHash, schemaPath } = args
|
|
755
1304
|
const kind: ReviewKind = args.kind ?? 'phase'
|
|
@@ -787,6 +1336,13 @@ export function processResponse(args: {
|
|
|
787
1336
|
const nextState = ok
|
|
788
1337
|
? applyVerdict({ base, binding, verdict, kind, designHash })
|
|
789
1338
|
: { ...base, design_approved: false, design_approved_hash: null }
|
|
1339
|
+
// REQ-2026-031 B-1: baseline은 **유효 design 승인마다** 갱신("마지막 승인된 설계 스냅샷") — archive(evidence
|
|
1340
|
+
// 핀)와 독립. archive 쓰기 실패로 evidence 없이 승인이 영속되는 경우(design_approved=true는 applyVerdict가
|
|
1341
|
+
// archive와 무관하게 설정)에도 baseline은 저장돼야 B-2가 정상 승인을 legacy로 오판하지 않는다(R2·R3, phase-r01 P1).
|
|
1342
|
+
// NEEDS_FIX·미승인은 이 분기를 안 타므로 stateRest(:1184)의 기존 design_baseline이 그대로 보존된다(NEEDS_FIX 생존).
|
|
1343
|
+
if (ok && nextState.design_approved === true && args.designDocBlobs) {
|
|
1344
|
+
nextState.design_baseline = args.designDocBlobs
|
|
1345
|
+
}
|
|
790
1346
|
// A1(D-016-2): design 승인 + archive 제공 시에만 design_approval_evidence 재부착(fresh). 그 외엔 위 omit으로 미부착.
|
|
791
1347
|
if (ok && nextState.design_approved === true && args.archive) {
|
|
792
1348
|
nextState.design_approval_evidence = buildApprovalEvidence({
|
|
@@ -800,6 +1356,12 @@ export function processResponse(args: {
|
|
|
800
1356
|
approvedAt: args.approvedAt ?? new Date().toISOString(),
|
|
801
1357
|
})
|
|
802
1358
|
}
|
|
1359
|
+
// REQ-2026-035 B-3a: 리뷰어가 full review를 요청하면 baseline을 비워 다음 design 리뷰를 full로 되돌린다
|
|
1360
|
+
// (hasDesignBaseline 게이트 재사용 — main·게이트 무변경). full_review_requested=yes는 commit_approved=no
|
|
1361
|
+
// (validateVerdict R2)라 위 승인-baseline 분기를 안 타고, stateRest에서 온 기존 baseline만 지운다. ordinary
|
|
1362
|
+
// NEEDS_FIX(full_review_requested≠yes)는 baseline을 보존한다(B-1 NEEDS_FIX 생존 무회귀).
|
|
1363
|
+
// `ok` 가드: 무효 응답(예: yes인데 commit_approved=yes 모순)은 baseline을 안 건드린다(응답 거부).
|
|
1364
|
+
if (ok && verdict.full_review_requested === 'yes') delete nextState.design_baseline
|
|
803
1365
|
return { ok, errors, nextState, verdict }
|
|
804
1366
|
}
|
|
805
1367
|
|
|
@@ -1203,12 +1765,32 @@ export function callReviewer(
|
|
|
1203
1765
|
return { threadId }
|
|
1204
1766
|
}
|
|
1205
1767
|
|
|
1206
|
-
export function main(argv: string[] = process.argv.slice(2)): void {
|
|
1768
|
+
export function main(argv: string[] = process.argv.slice(2), opts2?: { reviewer?: ReviewerAdapter }): void {
|
|
1769
|
+
// REQ-2026-027 D3: 주입한 reviewer는 이 호출에만 유효해야 한다 — 모듈 전역에 잔존하면 이후 인자 없는
|
|
1770
|
+
// main()도 그것을 쓴다(리뷰어 observation). CLI는 프로세스당 1회라 무해하나, programmatic 다중 호출
|
|
1771
|
+
// (near-e2e 테스트 등)에선 오염된다. finally로 기본값을 복원한다.
|
|
1772
|
+
const defaultReviewer = reviewer
|
|
1773
|
+
try {
|
|
1774
|
+
mainImpl(argv, opts2)
|
|
1775
|
+
} finally {
|
|
1776
|
+
reviewer = defaultReviewer
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function mainImpl(argv: string[], opts2?: { reviewer?: ReviewerAdapter }): void {
|
|
1207
1781
|
const opts = parseArgs(argv)
|
|
1208
1782
|
const cfg = loadConfig({ root: opts.root })
|
|
1209
1783
|
gitAdapter = createGitAdapter(cfg.root) // 모든 git 호출 cwd = config.root
|
|
1784
|
+
// REQ-2026-027 D3: 테스트 주입 seam(gitAdapter 선례). 미주입이면 기본 codex(프로덕션 불변).
|
|
1785
|
+
if (opts2?.reviewer) reviewer = opts2.reviewer
|
|
1210
1786
|
const ticketDir = resolveTicketDir(opts, cfg)
|
|
1211
1787
|
let state = loadState(ticketDir) // 부재 시 명확한 에러
|
|
1788
|
+
// REQ-2026-027 D1: legacy ticket(모델 버전 부재)은 외부 호출 **전에** fail-closed. AWAIT_HUMAN 안내는
|
|
1789
|
+
// req:next가, 강제 throw는 여기가 담당한다. 어떤 state 변경도·codex 호출도 하지 않는다(R2).
|
|
1790
|
+
if (isLegacyTicket(state))
|
|
1791
|
+
throw new Error(
|
|
1792
|
+
'legacy ticket(review_series_model_version 부재) — 자동 리뷰 불가. 사람이 이 티켓을 새 모델로 채택할지 결정해야 한다(req:next가 AWAIT_HUMAN으로 안내).',
|
|
1793
|
+
)
|
|
1212
1794
|
// --fresh-thread: blocked 회로차단기 명시적 회복 — 마커 제거(단락 해제) + resume 대신 새 스레드(고착 resume 끊기).
|
|
1213
1795
|
if (opts.freshThread) state = clearBlockedReview(state)
|
|
1214
1796
|
|
|
@@ -1249,13 +1831,25 @@ export function main(argv: string[] = process.argv.slice(2)): void {
|
|
|
1249
1831
|
let stagedDiff: string | undefined
|
|
1250
1832
|
let designDocs: DesignDocs | undefined
|
|
1251
1833
|
let designHash: string | undefined
|
|
1834
|
+
let designDocBlobs: DesignDocBlobs | undefined
|
|
1835
|
+
let designDelta: { changed: DesignDocKey[]; unchanged: DesignDocKey[] } | undefined
|
|
1252
1836
|
if (opts.kind === 'design') {
|
|
1253
1837
|
// 리뷰 본문·바인딩 해시 모두 git 인덱스에서 — "리뷰 대상 = 바인딩 대상"(결정#3, Codex P2). 누락 문서는 각 함수가 fail-closed.
|
|
1254
1838
|
designDocs = readDesignDocsFromIndex(ticketRel, git, cfg.designDocs)
|
|
1255
1839
|
designHash = captureDesignBinding(ticketRel, git, cfg.designDocs).designHash
|
|
1840
|
+
// REQ-2026-031 B-1: 같은 인덱스에서 문서별 blob OID도 뽑아 둔다(승인 시 processResponse가 baseline에 저장).
|
|
1841
|
+
designDocBlobs = captureDesignDocBlobs(ticketRel, git, cfg.designDocs)
|
|
1842
|
+
// REQ-2026-033 B-2a: design **재리뷰**(baseline 있음)면 delta 표시. 이 분기 안이라 phase는 구조적으로 delta 불가(kind 격리).
|
|
1843
|
+
// 게이트는 hasDesignBaseline이지 changed 수가 아니다 — 변경 0(baseline==current)이어도 delta 모드. persona는 안 바꾼다.
|
|
1844
|
+
const baseline = state.design_baseline
|
|
1845
|
+
if (hasDesignBaseline(state) && baseline) designDelta = computeDesignDelta(baseline, designDocBlobs)
|
|
1256
1846
|
} else {
|
|
1257
1847
|
stagedDiff = git(['diff', '--cached'])
|
|
1258
1848
|
}
|
|
1849
|
+
// REQ-2026-034 B-2b: delta 모드(designDelta 설정 = design + baseline)면 persona에 delta 계약을 얹는다.
|
|
1850
|
+
// 이 effectivePersona **하나**를 프롬프트와 review-call 로그 policy_version 양쪽에 흘린다(단일 배선, 032 r02·r03).
|
|
1851
|
+
// designDelta는 B-2a에서 design 전용이므로 phase·full은 base 그대로(kind 격리 구조적 재사용, 032 r06-2).
|
|
1852
|
+
const effectivePersona = applyDeltaPersona(persona, designDelta !== undefined)
|
|
1259
1853
|
const blockedTarget = buildBlockedReviewTarget({
|
|
1260
1854
|
kind: opts.kind,
|
|
1261
1855
|
phaseId,
|
|
@@ -1272,7 +1866,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
|
|
|
1272
1866
|
previousFindingsToClose: buildPreviousFindingsBlock(state, opts.kind, phaseId),
|
|
1273
1867
|
}
|
|
1274
1868
|
const prompt = assembleReviewPrompt({
|
|
1275
|
-
persona,
|
|
1869
|
+
persona: effectivePersona, // REQ-2026-034 B-2b: delta면 base+계약(null이면 계약 단독), 아니면 base 그대로.
|
|
1276
1870
|
handoff,
|
|
1277
1871
|
reviewContext,
|
|
1278
1872
|
reviewBaseSha,
|
|
@@ -1280,6 +1874,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
|
|
|
1280
1874
|
reviewKind: opts.kind,
|
|
1281
1875
|
stagedDiff,
|
|
1282
1876
|
designDocs,
|
|
1877
|
+
designDelta, // REQ-2026-033 B-2a: design 재리뷰(baseline 有)일 때만 값. 없으면 full 플레인 블록(무회귀).
|
|
1283
1878
|
})
|
|
1284
1879
|
const previewPath = join(ticketDir, '.review-preview.txt')
|
|
1285
1880
|
writeFileSync(previewPath, prompt, 'utf8')
|
|
@@ -1332,16 +1927,24 @@ export function main(argv: string[] = process.argv.slice(2)): void {
|
|
|
1332
1927
|
|
|
1333
1928
|
// ReviewerAdapter 경유(Phase 3). exec/resume 분기·--output-last-message·thread 파싱은 어댑터가 담당.
|
|
1334
1929
|
// resumeThreadId 있으면 resume(thread 상속), 없으면 exec(--sandbox read-only) → thread.started 파싱.
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1930
|
+
// REQ-2026-027 D3: attempt를 **호출 직전**에 기록·writeState(withAttemptRecorded). 반환 state(afterAttempt)가
|
|
1931
|
+
// 이후 모든 처리의 base다 — 호출 전 `state`를 다시 쓰면 최종 writeState가 attempt를 되돌린다(R9).
|
|
1932
|
+
const { result: callRes, state: afterAttempt } = withAttemptRecorded(
|
|
1933
|
+
{ ticketDir, state, kind: opts.kind, phaseId, budget: cfg.reviewBudget },
|
|
1934
|
+
() =>
|
|
1935
|
+
callReviewer(reviewer, {
|
|
1936
|
+
prompt,
|
|
1937
|
+
schemaPath: cfg.schemaPathAbs,
|
|
1938
|
+
resumeThreadId: isResume ? (state.codex_thread_id as string) : null,
|
|
1939
|
+
cwd: cfg.root,
|
|
1940
|
+
respPath,
|
|
1941
|
+
// REQ-2026-013 P1: 리뷰 모델·추론강도 override를 config에서 채워 어댑터로 전달(null이면 어댑터가 `-c` 생략).
|
|
1942
|
+
model: cfg.reviewModel,
|
|
1943
|
+
reasoningEffort: cfg.reviewReasoningEffort,
|
|
1944
|
+
}),
|
|
1945
|
+
)
|
|
1946
|
+
const { threadId } = callRes
|
|
1947
|
+
state = afterAttempt // 이후 baseArgs·finalState의 base
|
|
1345
1948
|
|
|
1346
1949
|
// 사후 리뷰어 무수정 검증: worktree 절대검사 + index(staged tree OID) 불변 (content 기반)
|
|
1347
1950
|
const postDirty = findUnstagedOrUntracked(gitStatusEntries(), SCRATCH, ticketRel)
|
|
@@ -1364,10 +1967,13 @@ export function main(argv: string[] = process.argv.slice(2)): void {
|
|
|
1364
1967
|
phaseId,
|
|
1365
1968
|
designValid,
|
|
1366
1969
|
schemaPath: cfg.schemaPathAbs,
|
|
1970
|
+
designDocBlobs, // REQ-2026-031 B-1: design kind일 때만 값 있음. 승인+archive 분기에서만 baseline에 저장.
|
|
1367
1971
|
}
|
|
1368
1972
|
const probe = processResponse(baseArgs) // 아카이브 없이 검증(진짜 승인 여부·유효성)
|
|
1369
1973
|
const decision = archiveDecision(probe, opts.kind) // null=아카이브 안 함(무효), 'approved'|'needs-fix'
|
|
1370
1974
|
let archiveDesc: { path: string; sha256: string } | undefined
|
|
1975
|
+
// REQ-2026-025 D4: 측정 로그의 archive_round. 무효 응답은 아카이브를 남기지 않으므로 null로 남는다.
|
|
1976
|
+
let archiveRound: number | null = null
|
|
1371
1977
|
if (decision) {
|
|
1372
1978
|
try {
|
|
1373
1979
|
const respBytes = readFileSync(respPath)
|
|
@@ -1375,9 +1981,11 @@ export function main(argv: string[] = process.argv.slice(2)): void {
|
|
|
1375
1981
|
const responsesDir = join(ticketDir, 'responses')
|
|
1376
1982
|
mkdirSync(responsesDir, { recursive: true })
|
|
1377
1983
|
const existing = readdirSync(responsesDir).filter((n) => isArchiveFileName(n))
|
|
1378
|
-
const
|
|
1984
|
+
const round = nextArchiveRound(existing, base)
|
|
1985
|
+
const archiveAbs = join(responsesDir, archiveFileName(base, round, decision))
|
|
1379
1986
|
writeFileSync(archiveAbs, respBytes)
|
|
1380
1987
|
archiveDesc = { path: repoRel(archiveAbs), sha256: createHash('sha256').update(respBytes).digest('hex') }
|
|
1988
|
+
archiveRound = round
|
|
1381
1989
|
} catch {
|
|
1382
1990
|
// 아카이브 기록 실패 — evidence 미부착(probe 결과 사용)
|
|
1383
1991
|
}
|
|
@@ -1400,7 +2008,26 @@ export function main(argv: string[] = process.argv.slice(2)): void {
|
|
|
1400
2008
|
blockedAt: approvedAt,
|
|
1401
2009
|
compareHash,
|
|
1402
2010
|
})
|
|
1403
|
-
|
|
2011
|
+
// REQ-2026-027 D2·R6: approved만이 series 자동 종료 계기. needs-fix·blocked·invalid는 열린 채 둔다
|
|
2012
|
+
// (그래야 A-2 상한이 의미를 갖는다). finalState는 afterAttempt 계보라 attempts 증가가 보존돼 있다.
|
|
2013
|
+
const persistedState = outcome === 'approved' ? closeSeriesApproved(finalState, opts.kind, phaseId) : finalState
|
|
2014
|
+
writeState(ticketDir, persistedState)
|
|
2015
|
+
|
|
2016
|
+
// REQ-2026-025 D4: 완료된 review call 1행 기록(측정 전용). `approvedAt`을 재사용해 같은 call의 다른
|
|
2017
|
+
// 기록과 시각이 어긋나지 않게 한다 — 새 시계를 읽지 않는다. 실패는 삼켜진다(R8).
|
|
2018
|
+
appendReviewCallLog(
|
|
2019
|
+
cfg.root,
|
|
2020
|
+
buildReviewCallLogRow({
|
|
2021
|
+
ticketId: String(state.id ?? ''),
|
|
2022
|
+
kind: opts.kind,
|
|
2023
|
+
phaseId,
|
|
2024
|
+
archiveRound,
|
|
2025
|
+
outcome,
|
|
2026
|
+
verdict: result.verdict,
|
|
2027
|
+
timestamp: approvedAt,
|
|
2028
|
+
policyVersion: reviewPolicyVersion(effectivePersona), // REQ-2026-034 B-2b: 전송 persona와 동일(단일 배선).
|
|
2029
|
+
}),
|
|
2030
|
+
)
|
|
1404
2031
|
|
|
1405
2032
|
console.log(`[req:review-codex] ${outcomeLabel(outcome)} thread=${threadId}`)
|
|
1406
2033
|
console.log(
|