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
|
@@ -46,6 +46,38 @@ export function safeSpawnSync(file: string, args: readonly string[], opts: SafeS
|
|
|
46
46
|
return res.stdout ? res.stdout.toString('utf8') : ''
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* `safeSpawnSync`의 **exit code 보존** 변형 (REQ-2026-050 D5).
|
|
51
|
+
*
|
|
52
|
+
* `safeSpawnSync`는 non-zero를 전부 실패로 보고 throw한다. 그러나 **non-zero가 정상 신호**인 명령이 있다 —
|
|
53
|
+
* `git diff --no-index`는 두 파일 내용이 다르면 exit **1**을 낸다(정상 결과이지 오류가 아니다).
|
|
54
|
+
* 그런 명령에 `safeSpawnSync`를 쓰면 정상 결과를 오류로 오판한다.
|
|
55
|
+
*
|
|
56
|
+
* 🔴 **새 spawn 경로를 만드는 것이 아니다.** shell 없는 `cross-spawn` 단일 경로(주입 차단 경계)를 그대로
|
|
57
|
+
* 재사용하고, 달라지는 것은 **exit code 해석을 호출자에게 넘긴다**는 것뿐이다. 어떤 code가 정상인지는
|
|
58
|
+
* 명령마다 다르므로 여기서 정책을 갖지 않는다.
|
|
59
|
+
*
|
|
60
|
+
* spawn 자체의 실패(`res.error` — 예: git 부재 ENOENT)는 여기서도 throw한다. 그건 code 해석의 문제가 아니다.
|
|
61
|
+
*/
|
|
62
|
+
export function safeSpawnSyncStatus(
|
|
63
|
+
file: string,
|
|
64
|
+
args: readonly string[],
|
|
65
|
+
opts: SafeSpawnOptions = {},
|
|
66
|
+
): { status: number | null; stdout: string; stderr: string } {
|
|
67
|
+
const res = spawn.sync(file, args as string[], {
|
|
68
|
+
cwd: opts.cwd,
|
|
69
|
+
input: opts.input,
|
|
70
|
+
stdio: opts.stdio ?? 'pipe',
|
|
71
|
+
maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024,
|
|
72
|
+
})
|
|
73
|
+
if (res.error) throw res.error
|
|
74
|
+
return {
|
|
75
|
+
status: res.status,
|
|
76
|
+
stdout: res.stdout ? res.stdout.toString('utf8') : '',
|
|
77
|
+
stderr: res.stderr ? res.stderr.toString('utf8') : '',
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
// ──────────────────────────────────────────────────────────── Git ──
|
|
50
82
|
|
|
51
83
|
/** git 호출 경계(D-017-3). exec(args) = trim된 stdout, 실패 시 throw(fail-closed). */
|
|
@@ -106,12 +138,48 @@ export function parseThreadId(jsonl: string): string | null {
|
|
|
106
138
|
return null
|
|
107
139
|
}
|
|
108
140
|
|
|
109
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* 리뷰 호출 실패의 **타입된 분류**(REQ-2026-054·DEC-C1). 예산 환불·lifecycle 판정이 메시지 문자열 sniffing이
|
|
143
|
+
* 아니라 이 타입으로 결정된다.
|
|
144
|
+
* - `pre-dispatch`: reviewer subprocess가 **기동조차 못 함**(spawn 실패·ENOENT). 청구 불가 → 환불 대상.
|
|
145
|
+
* - `dispatched`: subprocess는 떴으나 사용 가능한 결과 없음(non-zero exit·thread_id 없음). 청구 가능성 → 차감.
|
|
146
|
+
*/
|
|
147
|
+
export class ReviewCallError extends Error {
|
|
148
|
+
readonly dispatchPhase: 'pre-dispatch' | 'dispatched'
|
|
149
|
+
constructor(dispatchPhase: 'pre-dispatch' | 'dispatched', message: string) {
|
|
150
|
+
super(message)
|
|
151
|
+
this.name = 'ReviewCallError'
|
|
152
|
+
this.dispatchPhase = dispatchPhase
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** codex 실행자(주입 가능 — 테스트). stdout 반환, 실패 시 throw(`ReviewCallError`로 dispatch 단계 분류). */
|
|
110
157
|
export type CodexRunner = (args: string[], input: string, cwd: string) => string
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
158
|
+
|
|
159
|
+
/** status 보존 spawn 함수 타입(주입 seam — `safeSpawnSyncStatus` 서명). */
|
|
160
|
+
export type StatusSpawn = (file: string, args: readonly string[], opts?: SafeSpawnOptions) => { status: number | null; stdout: string; stderr: string }
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* codex 러너 팩토리(REQ-2026-054·DEC-C1). spawn을 주입해 분류 로직을 테스트 가능하게 한다.
|
|
164
|
+
* - spawn 자체 실패(`res.error` throw — ENOENT 등): subprocess 미기동 = `pre-dispatch`(청구 불가·환불 대상).
|
|
165
|
+
* - `res.status !== 0`(subprocess는 떴고 non-zero — usage limit 등 모델 부분 실행 가능): `dispatched`(차감).
|
|
166
|
+
*/
|
|
167
|
+
export function makeCodexRunner(spawn: StatusSpawn): CodexRunner {
|
|
168
|
+
return (args, input, cwd) => {
|
|
169
|
+
// shell 없이 안전 실행(cross-spawn). 프롬프트는 stdin(input). 과거 `shell:true`의 명령 주입/공백 경로 결함 방지.
|
|
170
|
+
let res: { status: number | null; stdout: string; stderr: string }
|
|
171
|
+
try {
|
|
172
|
+
res = spawn('codex', args, { cwd, input, maxBuffer: 64 * 1024 * 1024 })
|
|
173
|
+
} catch (err) {
|
|
174
|
+
throw new ReviewCallError('pre-dispatch', `codex 실행(spawn) 실패 — subprocess 미기동: ${err instanceof Error ? err.message : String(err)}`)
|
|
175
|
+
}
|
|
176
|
+
if (res.status !== 0)
|
|
177
|
+
throw new ReviewCallError('dispatched', `codex 종료 코드 ${res.status ?? 'null'}: ${res.stderr.trim()}`.trim())
|
|
178
|
+
return res.stdout
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const defaultCodexRunner: CodexRunner = makeCodexRunner(safeSpawnSyncStatus)
|
|
115
183
|
|
|
116
184
|
/** unknown → 평범한 객체(배열·null 제외). 스키마 경로 탐색용. */
|
|
117
185
|
function asPlainObject(v: unknown): Record<string, unknown> | null {
|
|
@@ -202,13 +270,37 @@ export function createCodexReviewerAdapter(run: CodexRunner = defaultCodexRunner
|
|
|
202
270
|
}
|
|
203
271
|
|
|
204
272
|
/** 테스트 전용 ReviewerAdapter — canned 응답 반환 + 받은 요청 기록(live codex 없이 review-codex 플로 검증, 수용기준 #4). */
|
|
205
|
-
export function createFakeReviewerAdapter(
|
|
273
|
+
export function createFakeReviewerAdapter(
|
|
274
|
+
result: ReviewResult,
|
|
275
|
+
opts: {
|
|
276
|
+
/**
|
|
277
|
+
* REQ-2026-052: `true`(기본)면 응답의 `review_base_sha`를 **프롬프트의 `REVIEW_BASE_SHA`로 덮어쓴다**.
|
|
278
|
+
* pre-call 원장 커밋이 HEAD를 옮겨 실제 approval base가 테스트 setup 시점의 head와 달라지므로, 고정
|
|
279
|
+
* canned 응답의 base가 stale해진다. 프롬프트 base를 echo하면 near-e2e가 그 커밋을 신경 쓰지 않아도 된다.
|
|
280
|
+
* base 불일치(invalid)를 **의도적으로** 테스트하려면 `echoPromptBase:false`로 끈다.
|
|
281
|
+
*/
|
|
282
|
+
echoPromptBase?: boolean
|
|
283
|
+
} = {},
|
|
284
|
+
): ReviewerAdapter & { requests: ReviewRequest[] } {
|
|
206
285
|
const requests: ReviewRequest[] = []
|
|
286
|
+
const echo = opts.echoPromptBase !== false
|
|
207
287
|
return {
|
|
208
288
|
requests,
|
|
209
289
|
review(req: ReviewRequest): ReviewResult {
|
|
210
290
|
requests.push(req)
|
|
211
|
-
return result
|
|
291
|
+
if (!echo) return result
|
|
292
|
+
// 프롬프트에서 `REVIEW_BASE_SHA: <sha>` 추출.
|
|
293
|
+
const m = /^REVIEW_BASE_SHA:\s*([0-9a-f]{7,64})\s*$/m.exec(req.prompt)
|
|
294
|
+
if (!m) return result
|
|
295
|
+
let parsed: unknown
|
|
296
|
+
try {
|
|
297
|
+
parsed = JSON.parse(result.lastMessage)
|
|
298
|
+
} catch {
|
|
299
|
+
return result // JSON이 아니면 손대지 않는다.
|
|
300
|
+
}
|
|
301
|
+
if (!parsed || typeof parsed !== 'object' || !('review_base_sha' in parsed)) return result
|
|
302
|
+
const patched = { ...(parsed as Record<string, unknown>), review_base_sha: m[1] }
|
|
303
|
+
return { ...result, lastMessage: JSON.stringify(patched) }
|
|
212
304
|
},
|
|
213
305
|
}
|
|
214
306
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `req:close --migrate`의 **순수 판정기** (REQ-2026-053·DEC-M3/M7).
|
|
3
|
+
*
|
|
4
|
+
* close-proof/`phase_design_ref` regime **이전에** 완료·병합돼 dev-complete로 자기증명될 수 없는 레거시
|
|
5
|
+
* durable 티켓을, HEAD-committed 증거만으로 마이그레이션 종결 자격을 판정한다. IO는 호출부(`req-close.ts`)가 낸다 —
|
|
6
|
+
* 이 모듈은 fs·git을 모른다(`lib/close-proof`·`lib/reconstruct`와 같은 태도).
|
|
7
|
+
*
|
|
8
|
+
* 🔴 자격은 좁다: **손상 아님 + durable + 커밋된 design 승인 + phase 증거 ≥1 + 정상 dev-complete 불가 +
|
|
9
|
+
* integrated(본선 병합)**. 하나라도 어긋나면 fail-closed 거부. 이미 종결이면 거부가 아니라 성공 no-op(멱등).
|
|
10
|
+
*
|
|
11
|
+
* 🔴 **`migrated-complete` close 이벤트·기본 상태·파서·`deriveBaseState` 확장(dev-complete 아래·needs-recovery
|
|
12
|
+
* 위 비차단)은 phase-1(커밋 `3ed1b95` `close-proof.ts`)에 이미 landed.** 이 모듈(phase-2)은 그 이벤트를
|
|
13
|
+
* **발행할지 판정**만 한다. 종결→intake pass 전 파이프라인은 `req-close.test.ts` ⑮가 실 git으로 실증한다.
|
|
14
|
+
*/
|
|
15
|
+
import type { CloseProofRow, CloseProofEvent } from './close-proof'
|
|
16
|
+
|
|
17
|
+
/** 판정 입력 — 전부 HEAD-committed 사실 + integrated(git ancestry, 호출부가 계산). */
|
|
18
|
+
export interface MigrationFacts {
|
|
19
|
+
ticketId: string
|
|
20
|
+
ticketRel: string
|
|
21
|
+
/** HEAD scaffold marker(`isDurabilityRequired`). false면 legacy. */
|
|
22
|
+
durabilityRequired: boolean
|
|
23
|
+
/** HEAD approvals.jsonl 본문(없으면 null). */
|
|
24
|
+
manifestText: string | null
|
|
25
|
+
/** `validateManifest` 결과(빈 배열=정상). 비어있지 않으면 corrupt 거부. */
|
|
26
|
+
manifestProblems: readonly string[]
|
|
27
|
+
/** HEAD ticket-close.jsonl 파싱 problems. 비어있지 않으면 corrupt 거부. */
|
|
28
|
+
closeProblems: readonly string[]
|
|
29
|
+
/** HEAD ticket-close.jsonl 파싱 행(이미 종결 여부 판정). */
|
|
30
|
+
closeRows: readonly CloseProofRow[]
|
|
31
|
+
/** committed 증거(design+phase) 무결성 problems(DEC-B6·B7). 비어있지 않으면 corrupt 거부. */
|
|
32
|
+
evidenceIntegrityProblems: readonly string[]
|
|
33
|
+
/** 커밋된 design 승인 참조(design_hash). 없으면 null. */
|
|
34
|
+
committedDesignRef: string | null
|
|
35
|
+
/** 매니페스트의 **모든** phase-evidenced id(결속 무관). 완료 inventory 원천. */
|
|
36
|
+
evidencedPhaseIdsAll: readonly string[]
|
|
37
|
+
/** **현재 design_ref에 결속된** phase id(정상 dev-complete 가능성 판정). */
|
|
38
|
+
evidencedPhaseIdsBound: readonly string[]
|
|
39
|
+
/**
|
|
40
|
+
* 🔴 티켓의 **커밋된 phase 계획**(HEAD state.json `phases[].id`). r02 P1 대응 — integrated(마지막 매니페스트
|
|
41
|
+
* 커밋이 본선 조상)만으로는 "앞 phase만 병합되고 뒷 phase가 진행 중/중단"인 부분 완료를 못 거른다.
|
|
42
|
+
* 커밋된 계획이 있으면 그 **모든** phase가 증거로 있어야 완료로 본다. 계획이 비었으면(레거시 스캐폴드
|
|
43
|
+
* state.phases=[]) 이 검사는 vacuous — dev-completable(결속) 검사와 integrated가 남은 방어다.
|
|
44
|
+
*/
|
|
45
|
+
committedPlannedPhaseIds: readonly string[]
|
|
46
|
+
/** 티켓 증거가 본선(mainline)의 조상인가(완료성 증명 — DEC-M3.7). 호출부가 git ancestry로 계산. */
|
|
47
|
+
integrated: boolean
|
|
48
|
+
/** 발행 시각(ISO, 호출부가 실시계로 넣음). */
|
|
49
|
+
nowIso: string
|
|
50
|
+
/** 발행 행의 evidence_basis(마이그레이션 근거 아티팩트 경로 — 비어있으면 안 됨). */
|
|
51
|
+
evidenceBasis: readonly string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type MigrationPlan =
|
|
55
|
+
| { kind: 'stamp'; row: CloseProofRow }
|
|
56
|
+
/** 이미 terminal close(dev-complete/series-terminal/migrated-complete) — 성공 no-op(DEC-M7). */
|
|
57
|
+
| { kind: 'noop'; existingState: CloseProofEvent }
|
|
58
|
+
/** 자격 미달 — fail-closed 거부(비-스탬프). */
|
|
59
|
+
| { kind: 'refuse'; reason: string; hint: string }
|
|
60
|
+
|
|
61
|
+
function refuse(reason: string, hint: string): MigrationPlan {
|
|
62
|
+
return { kind: 'refuse', reason, hint }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 마이그레이션 종결 계획(순수·DEC-M3/M7). 판정 순서가 계약이다:
|
|
67
|
+
* corrupt 가드 → durability → **이미 종결이면 no-op** → design 승인 → phase 증거 → 정상 dev-complete 불가 →
|
|
68
|
+
* integrated. 마지막에만 stamp.
|
|
69
|
+
*/
|
|
70
|
+
export function planMigrationClose(f: MigrationFacts): MigrationPlan {
|
|
71
|
+
// 🔴 corrupt 가드 — pass 조건이 읽는 아티팩트가 손상됐으면 완료를 스탬프하지 않는다(fail-closed).
|
|
72
|
+
if (f.manifestText !== null && f.manifestProblems.length)
|
|
73
|
+
return refuse(`HEAD approvals.jsonl 손상: ${f.manifestProblems.slice(0, 3).join('; ')}`, '손상 증거에는 완료를 스탬프하지 않습니다 — 먼저 정정/복구')
|
|
74
|
+
if (f.closeProblems.length)
|
|
75
|
+
return refuse(`HEAD ticket-close.jsonl 손상: ${f.closeProblems.slice(0, 3).join('; ')}`, '손상 close-proof 정리 후 재시도')
|
|
76
|
+
if (f.evidenceIntegrityProblems.length)
|
|
77
|
+
return refuse(`committed 증거(design·phase archive) 손상/부재: ${f.evidenceIntegrityProblems.slice(0, 3).join('; ')}`, 'req:reconstruct 등으로 복구 후 재시도')
|
|
78
|
+
|
|
79
|
+
// durability marker 없음 = legacy → intake가 애초에 차단하지 않으므로 종결 불필요.
|
|
80
|
+
if (!f.durabilityRequired)
|
|
81
|
+
return refuse('legacy 티켓(durability marker 없음) — intake가 차단하지 않아 종결이 불필요', '조치 불필요')
|
|
82
|
+
|
|
83
|
+
// 🔴 DEC-M7: 이미 terminal close면 거부가 아니라 성공 no-op(재실행 멱등). 다른 검사보다 앞에 둬 재실행이
|
|
84
|
+
// 깨끗한 no-op이 되게 한다(기존 행의 at을 보존).
|
|
85
|
+
const terminal = f.closeRows.find(
|
|
86
|
+
(r) => r.event === 'series-terminal' || r.event === 'dev-complete' || r.event === 'migrated-complete',
|
|
87
|
+
)
|
|
88
|
+
if (terminal) return { kind: 'noop', existingState: terminal.event }
|
|
89
|
+
|
|
90
|
+
// 커밋된 design 승인 — 무엇에 대한 완료인지 불명이면 거부.
|
|
91
|
+
if (f.committedDesignRef === null)
|
|
92
|
+
return refuse('커밋된 design 승인(design_hash)이 없다 — 무엇에 대한 완료인지 불명', 'design 승인 증거 없이 마이그레이션 불가')
|
|
93
|
+
|
|
94
|
+
// phase 증거 ≥1 — 실제 phase를 거친 티켓만.
|
|
95
|
+
const inventory = [...new Set(f.evidencedPhaseIdsAll)].sort()
|
|
96
|
+
if (inventory.length === 0)
|
|
97
|
+
return refuse('커밋된 phase 증거가 없다 — 실제 phase를 거친 티켓만 마이그레이션', 'phase 리뷰·커밋을 먼저 완료')
|
|
98
|
+
|
|
99
|
+
// 🔴 r02 P1: **부분 완료(진행 중/중단) 배제** — 커밋된 phase 계획(state.phases)이 있으면 그 모든 phase가
|
|
100
|
+
// 증거로 있어야 한다. integrated는 "마지막 매니페스트 커밋이 본선 조상"만 봐서, 앞 phase만 병합되고 뒤
|
|
101
|
+
// phase가 아직 증거를 안 낸 티켓을 통과시킨다 — 이 committed 계획 검사가 그 틈을 닫는다.
|
|
102
|
+
const evidencedSet = new Set(inventory)
|
|
103
|
+
const missingPlanned = f.committedPlannedPhaseIds.filter((id) => !evidencedSet.has(id))
|
|
104
|
+
if (missingPlanned.length)
|
|
105
|
+
return refuse(
|
|
106
|
+
`커밋된 phase 계획 중 증거 없는 phase: ${missingPlanned.join(', ')} — 부분 완료(진행 중/중단) 티켓은 마이그레이션 불가`,
|
|
107
|
+
'모든 계획 phase를 완료·커밋(정상 dev-complete)하거나 종결한 뒤 재시도',
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// 🔴 정상 dev-complete가 가능하면 거부 — 마이그레이션으로 강한 경로를 우회하지 않는다(DEC-M3.6).
|
|
111
|
+
const bound = new Set(f.evidencedPhaseIdsBound)
|
|
112
|
+
if (inventory.every((id) => bound.has(id)))
|
|
113
|
+
return refuse('phase 증거가 현재 design_ref에 전부 결속됨 — 정상 dev-complete 가능', '`req:commit --finalize --run`(정상 완료 경로)을 사용')
|
|
114
|
+
|
|
115
|
+
// 🔴 완료성 증명 = integrated(DEC-M3.7·P1-1) — 본선 미병합이면 진행 중일 수 있어 거부.
|
|
116
|
+
if (!f.integrated)
|
|
117
|
+
return refuse('티켓 작업이 본선(mainline)에 병합되지 않음 — 미완료/진행 중 가능성', '완료·병합 후 재시도(마이그레이션은 병합된 완료 티켓만)')
|
|
118
|
+
|
|
119
|
+
// 방어: evidence_basis 비어있으면 스키마 위반(reconstructed:true는 근거 필수). 호출부가 항상 채운다.
|
|
120
|
+
if (f.evidenceBasis.length === 0)
|
|
121
|
+
return refuse('evidence_basis가 비어 있음(내부 오류 — 마이그레이션 근거 경로 미제공)', '버그 신고')
|
|
122
|
+
|
|
123
|
+
const row: CloseProofRow = {
|
|
124
|
+
ticket_id: f.ticketId,
|
|
125
|
+
event: 'migrated-complete',
|
|
126
|
+
series_id: null,
|
|
127
|
+
resolution: null,
|
|
128
|
+
phase_inventory: inventory,
|
|
129
|
+
design_ref: f.committedDesignRef,
|
|
130
|
+
at: f.nowIso,
|
|
131
|
+
reconstructed: true,
|
|
132
|
+
evidence_basis: [...f.evidenceBasis],
|
|
133
|
+
}
|
|
134
|
+
return { kind: 'stamp', row }
|
|
135
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 커밋되는 **티켓 lifecycle close proof** (REQ-2026-052).
|
|
3
|
+
*
|
|
4
|
+
* 왜 원장과 별도인가: `review-ledger.jsonl`(B1)은 **attempt 단위**(opened/closed) 감사다. close proof는
|
|
5
|
+
* **티켓/series 단위 lifecycle 전이**(replace·human-resolution 종결, 개발 완료)를 담는다. 요구가 "사람이
|
|
6
|
+
* 실행하는 종결 경로는 ledger와 close proof를 **함께** 내구화"라 명시하므로 둘을 섞지 않는다.
|
|
7
|
+
*
|
|
8
|
+
* 🔴 **모든 판정은 HEAD-committed 아티팩트만** 입력이다 — 워킹 state·워킹 승인은 절대 쓰지 않는다.
|
|
9
|
+
* scratch state가 사라져도 HEAD만으로 티켓 상태·req:new 허용·재구성 여부를 판별할 수 있어야 한다.
|
|
10
|
+
*
|
|
11
|
+
* 🔴 **prompt·응답 본문·민감 데이터를 저장하지 않는다.** 허용키 화이트리스트가 자리 자체를 막는다.
|
|
12
|
+
*
|
|
13
|
+
* 순수 모듈 — fs·git을 모른다. 부작용은 호출부가 낸다(`lib/evidence`·`lib/review-ledger`와 같은 태도).
|
|
14
|
+
*/
|
|
15
|
+
import { isValidIsoInstant } from './evidence'
|
|
16
|
+
|
|
17
|
+
/** close proof 파일의 basename. `approvals.jsonl`·`review-ledger.jsonl`과 같은 `responses/` 디렉터리. */
|
|
18
|
+
export const CLOSE_PROOF_BASENAME = 'ticket-close.jsonl'
|
|
19
|
+
|
|
20
|
+
/** 티켓 `responses/` 기준 close proof의 repo-상대 경로. */
|
|
21
|
+
export function closeProofPath(ticketRel: string): string {
|
|
22
|
+
return `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/${CLOSE_PROOF_BASENAME}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* lifecycle 전이 종류(DEC-B).
|
|
27
|
+
* - `series-terminal`: 사람이 한 series를 replace/human-resolution으로 종결(원장과 함께 커밋).
|
|
28
|
+
* - `dev-complete`: 모든 phase 증거가 durable해진 시점(마지막 evidence-finalize 직후 방출).
|
|
29
|
+
* - `migrated-complete`(REQ-2026-053): close-proof/`phase_design_ref` regime **이전에** 완료·병합돼
|
|
30
|
+
* dev-complete로 자기증명될 수 없는 레거시 티켓을, 운영자가 완료 확인 후 남기는 **사후 마이그레이션 종결**
|
|
31
|
+
* (`reconstructed:true` 강제 — self-verifying dev-complete와 구별). `req:close --migrate`가 발행.
|
|
32
|
+
*
|
|
33
|
+
* 🔴 `integrated`는 여기 없다 — git ancestry로 관측하는 오버레이이지 커밋되는 전이가 아니다(DEC-B).
|
|
34
|
+
*/
|
|
35
|
+
export type CloseProofEvent = 'series-terminal' | 'dev-complete' | 'migrated-complete'
|
|
36
|
+
|
|
37
|
+
/** series-terminal의 종결 사유(사람 결정). */
|
|
38
|
+
export type TerminalResolution = 'replace' | 'human-resolution'
|
|
39
|
+
|
|
40
|
+
export interface CloseProofRow {
|
|
41
|
+
ticket_id: string
|
|
42
|
+
event: CloseProofEvent
|
|
43
|
+
/** `series-terminal`일 때 그 series의 id. `dev-complete`이면 null. */
|
|
44
|
+
series_id: string | null
|
|
45
|
+
/** `series-terminal`일 때 종결 사유. `dev-complete`이면 null. */
|
|
46
|
+
resolution: TerminalResolution | null
|
|
47
|
+
/**
|
|
48
|
+
* 🔴 `dev-complete`일 때 **완료 대상으로 확정된 phase ID 목록**(정렬·중복 없음, DEC-B2). `series-terminal`이면 null.
|
|
49
|
+
* 이것이 "무엇이 완료인가"의 정본이다 — 미래 HEAD verifier는 runtime `state.phases`가 아니라 이 목록을 본다.
|
|
50
|
+
*/
|
|
51
|
+
phase_inventory: string[] | null
|
|
52
|
+
/**
|
|
53
|
+
* 🔴 `dev-complete`일 때 이 inventory가 묶인 **design 승인 참조**(= 발행 시점 committed design 승인의 design_hash).
|
|
54
|
+
* `series-terminal`이면 null. design 재승인으로 inventory가 달라지면 옛 design_ref와 섞인 proof는 무효(DEC-B2).
|
|
55
|
+
*/
|
|
56
|
+
design_ref: string | null
|
|
57
|
+
at: string
|
|
58
|
+
/** 사후 복원 행인지(DEC-D). 복원본을 원본으로 위장하지 않는다. */
|
|
59
|
+
reconstructed: boolean
|
|
60
|
+
/**
|
|
61
|
+
* 복원 행일 때 그 사실을 유도한 근거(어떤 아카이브·매니페스트). 원본(비복원) 행이면 null.
|
|
62
|
+
* 🔴 본문이 아니라 **경로/식별자 목록**이다 — 민감 데이터를 담지 않는다.
|
|
63
|
+
*/
|
|
64
|
+
evidence_basis: string[] | null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 직렬화 키 순서(고정 — deterministic) + 허용키 화이트리스트(여기 없는 top-level 키 = 오염 → 거부). */
|
|
68
|
+
export const CLOSE_PROOF_KEYS = [
|
|
69
|
+
'ticket_id',
|
|
70
|
+
'event',
|
|
71
|
+
'series_id',
|
|
72
|
+
'resolution',
|
|
73
|
+
'phase_inventory',
|
|
74
|
+
'design_ref',
|
|
75
|
+
'at',
|
|
76
|
+
'reconstructed',
|
|
77
|
+
'evidence_basis',
|
|
78
|
+
] as const
|
|
79
|
+
|
|
80
|
+
const EVENTS: readonly string[] = ['series-terminal', 'dev-complete', 'migrated-complete']
|
|
81
|
+
const RESOLUTIONS: readonly string[] = ['replace', 'human-resolution']
|
|
82
|
+
|
|
83
|
+
/** 한 줄 직렬화(JSONL): 고정 키 순서 JSON + 끝 개행. */
|
|
84
|
+
export function serializeCloseProofRow(row: CloseProofRow): string {
|
|
85
|
+
const o: Record<string, unknown> = {}
|
|
86
|
+
for (const k of CLOSE_PROOF_KEYS) o[k] = row[k]
|
|
87
|
+
return `${JSON.stringify(o)}\n`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 자연키(멱등 판정 단위). `series-terminal`은 `(ticket, event, series)`, `dev-complete`은 `(ticket, event)`.
|
|
92
|
+
* 구분자는 US(0x1F) — 식별자에 나타날 수 없는 제어문자(원장과 동일 기법, 소스에 리터럴 금지).
|
|
93
|
+
*/
|
|
94
|
+
const KEY_SEP = String.fromCharCode(31)
|
|
95
|
+
/**
|
|
96
|
+
* 자연키(멱등·supersede 단위).
|
|
97
|
+
* - `series-terminal`: `(ticket, event, series_id)` — series별 1행.
|
|
98
|
+
* - `dev-complete`: `(ticket, event, design_ref)` 🔴 **design_ref로 키잉한다**(phase-3a r02 P1). design 재승인으로
|
|
99
|
+
* design_ref가 바뀌면 **다른 자연키**가 되어 새 dev-complete 행이 append-only로 추가된다(옛 행은 supersede —
|
|
100
|
+
* 삭제하지 않고, verifier가 현재 design_ref에 맞는 행만 고른다). design_ref로 키잉하지 않으면 재완료가
|
|
101
|
+
* 자연키 충돌(conflict)로 영구 실패한다.
|
|
102
|
+
*/
|
|
103
|
+
export function closeProofRowKey(row: Pick<CloseProofRow, 'ticket_id' | 'event' | 'series_id' | 'design_ref'>): string {
|
|
104
|
+
// dev-complete: design_ref(재승인 supersede). series-terminal: series_id(series별 1행).
|
|
105
|
+
// migrated-complete(REQ-2026-053): discriminator 없음 — 티켓당 1행(마이그레이션은 일회성).
|
|
106
|
+
const discriminator =
|
|
107
|
+
row.event === 'dev-complete' ? (row.design_ref ?? '') : row.event === 'series-terminal' ? (row.series_id ?? '') : ''
|
|
108
|
+
return [row.ticket_id, row.event, discriminator].join(KEY_SEP)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `dev-complete`·`migrated-complete` 공유: phase_inventory 형식 검증(비지 않은 문자열·정렬·중복 없음). */
|
|
112
|
+
function phaseInventoryProblems(v: unknown, eventLabel: string): string[] {
|
|
113
|
+
const p: string[] = []
|
|
114
|
+
if (!Array.isArray(v)) {
|
|
115
|
+
p.push(`${eventLabel}인데 phase_inventory가 배열이 아님`)
|
|
116
|
+
return p
|
|
117
|
+
}
|
|
118
|
+
if (v.length === 0) p.push(`${eventLabel}인데 phase_inventory가 비어 있음`)
|
|
119
|
+
if (!v.every((x) => typeof x === 'string' && x !== '')) p.push('phase_inventory 항목은 비지 않은 문자열')
|
|
120
|
+
const sorted = [...v].sort()
|
|
121
|
+
if (JSON.stringify(sorted) !== JSON.stringify(v)) p.push('phase_inventory가 정렬돼 있지 않음')
|
|
122
|
+
if (new Set(v).size !== v.length) p.push('phase_inventory에 중복 있음')
|
|
123
|
+
return p
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 행 하나의 형식 문제 목록(순수). 빈 배열 = 정상.
|
|
128
|
+
* 🔴 모르는 top-level 키는 거부(주입·오염 방어). `dev-complete`·`migrated-complete`은 series/resolution이 null이어야 한다.
|
|
129
|
+
*/
|
|
130
|
+
export function closeProofRowProblems(raw: unknown): string[] {
|
|
131
|
+
const p: string[] = []
|
|
132
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['객체가 아님']
|
|
133
|
+
const r = raw as Record<string, unknown>
|
|
134
|
+
|
|
135
|
+
const allowed = new Set<string>(CLOSE_PROOF_KEYS)
|
|
136
|
+
for (const k of Object.keys(r)) if (!allowed.has(k)) p.push(`알 수 없는 키: ${k}`)
|
|
137
|
+
for (const k of CLOSE_PROOF_KEYS) if (!(k in r)) p.push(`필수 키 누락: ${k}`)
|
|
138
|
+
if (p.length) return p
|
|
139
|
+
|
|
140
|
+
if (typeof r.ticket_id !== 'string' || r.ticket_id === '') p.push('ticket_id가 비어 있음')
|
|
141
|
+
if (typeof r.event !== 'string' || !EVENTS.includes(r.event)) p.push(`event 부적합: ${String(r.event)}`)
|
|
142
|
+
if (typeof r.reconstructed !== 'boolean') p.push('reconstructed는 boolean')
|
|
143
|
+
if (!isValidIsoInstant(r.at)) p.push('at이 ISO instant가 아님')
|
|
144
|
+
|
|
145
|
+
if (r.event === 'series-terminal') {
|
|
146
|
+
if (typeof r.series_id !== 'string' || r.series_id === '') p.push('series-terminal인데 series_id가 비어 있음')
|
|
147
|
+
if (typeof r.resolution !== 'string' || !RESOLUTIONS.includes(r.resolution))
|
|
148
|
+
p.push(`series-terminal인데 resolution 부적합: ${String(r.resolution)}`)
|
|
149
|
+
if (r.phase_inventory !== null) p.push('series-terminal인데 phase_inventory가 null이 아님')
|
|
150
|
+
if (r.design_ref !== null) p.push('series-terminal인데 design_ref가 null이 아님')
|
|
151
|
+
} else if (r.event === 'dev-complete') {
|
|
152
|
+
if (r.series_id !== null) p.push('dev-complete인데 series_id가 null이 아님')
|
|
153
|
+
if (r.resolution !== null) p.push('dev-complete인데 resolution이 null이 아님')
|
|
154
|
+
// 🔴 dev-complete는 self-verifying — phase_inventory(정렬·중복 없음)와 design_ref가 필수(DEC-B2).
|
|
155
|
+
p.push(...phaseInventoryProblems(r.phase_inventory, 'dev-complete'))
|
|
156
|
+
if (typeof r.design_ref !== 'string' || r.design_ref === '') p.push('dev-complete인데 design_ref가 비어 있음')
|
|
157
|
+
} else if (r.event === 'migrated-complete') {
|
|
158
|
+
// 🔴 REQ-2026-053(DEC-M2): 운영자 확인 마이그레이션 종결. dev-complete와 같은 phase_inventory·design_ref를
|
|
159
|
+
// 담되 self-verifying이 아니라 **사후 스탬프**임을 스키마가 드러낸다 — reconstructed===true 강제.
|
|
160
|
+
if (r.series_id !== null) p.push('migrated-complete인데 series_id가 null이 아님')
|
|
161
|
+
if (r.resolution !== null) p.push('migrated-complete인데 resolution이 null이 아님')
|
|
162
|
+
p.push(...phaseInventoryProblems(r.phase_inventory, 'migrated-complete'))
|
|
163
|
+
if (typeof r.design_ref !== 'string' || r.design_ref === '') p.push('migrated-complete인데 design_ref가 비어 있음')
|
|
164
|
+
// 🔴 마이그레이션은 항상 사후 스탬프 — reconstructed:true 필수(evidence_basis 비-빔은 아래 공통 규칙이 강제).
|
|
165
|
+
if (r.reconstructed !== true) p.push('migrated-complete인데 reconstructed가 true가 아님(마이그레이션은 사후 스탬프)')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (r.evidence_basis !== null) {
|
|
169
|
+
if (!Array.isArray(r.evidence_basis)) p.push('evidence_basis는 null이거나 배열')
|
|
170
|
+
else if (!r.evidence_basis.every((x) => typeof x === 'string')) p.push('evidence_basis 항목은 문자열')
|
|
171
|
+
}
|
|
172
|
+
// 🔴 복원 행이면 근거가 있어야 하고, 비복원 행이면 근거가 없어야 한다(원본과 복원의 명확한 구별).
|
|
173
|
+
if (r.reconstructed === true && (r.evidence_basis === null || (Array.isArray(r.evidence_basis) && r.evidence_basis.length === 0)))
|
|
174
|
+
p.push('reconstructed:true인데 evidence_basis가 비어 있음(근거 없는 복원 금지)')
|
|
175
|
+
if (r.reconstructed === false && r.evidence_basis !== null) p.push('원본 행(reconstructed:false)인데 evidence_basis가 있음')
|
|
176
|
+
return p
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface ParsedCloseProof {
|
|
180
|
+
rows: CloseProofRow[]
|
|
181
|
+
problems: string[]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** close proof 본문 파싱(순수). 빈 줄 무시, 파싱 불가·형식 위반·자연키 중복은 problems로 드러낸다. */
|
|
185
|
+
export function parseCloseProof(content: string): ParsedCloseProof {
|
|
186
|
+
const rows: CloseProofRow[] = []
|
|
187
|
+
const problems: string[] = []
|
|
188
|
+
const seen = new Set<string>()
|
|
189
|
+
content.split('\n').forEach((line, i) => {
|
|
190
|
+
if (line.trim() === '') return
|
|
191
|
+
let raw: unknown
|
|
192
|
+
try {
|
|
193
|
+
raw = JSON.parse(line)
|
|
194
|
+
} catch {
|
|
195
|
+
problems.push(`line ${i + 1}: JSON 파싱 실패`)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
const ps = closeProofRowProblems(raw)
|
|
199
|
+
if (ps.length) {
|
|
200
|
+
problems.push(...ps.map((m) => `line ${i + 1}: ${m}`))
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
const row = raw as CloseProofRow
|
|
204
|
+
const key = closeProofRowKey(row)
|
|
205
|
+
if (seen.has(key)) problems.push(`line ${i + 1}: 자연키 중복(${row.event} ${row.series_id ?? ''})`)
|
|
206
|
+
seen.add(key)
|
|
207
|
+
rows.push(row)
|
|
208
|
+
})
|
|
209
|
+
return { rows, problems }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type CloseProofAppendOutcome = 'appended' | 'duplicate' | 'conflict'
|
|
213
|
+
export interface CloseProofAppendResult {
|
|
214
|
+
outcome: CloseProofAppendOutcome
|
|
215
|
+
content: string
|
|
216
|
+
problems: string[]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 멱등 append(순수 — 새 본문 반환만, 쓰기는 호출부). 원장과 같은 규칙(DEC-A3):
|
|
221
|
+
* 같은 자연키+동일 내용 → duplicate(no-op), 같은 자연키+다른 내용 → conflict(덮지 않음·fail-closed).
|
|
222
|
+
* 기존 본문 손상이면 그대로 올리고 append하지 않는다(D5 태도).
|
|
223
|
+
*/
|
|
224
|
+
export function appendCloseProofRow(existingContent: string, row: CloseProofRow): CloseProofAppendResult {
|
|
225
|
+
const rowProblems = closeProofRowProblems(row)
|
|
226
|
+
if (rowProblems.length) return { outcome: 'conflict', content: existingContent, problems: rowProblems }
|
|
227
|
+
|
|
228
|
+
const parsed = parseCloseProof(existingContent)
|
|
229
|
+
if (parsed.problems.length) return { outcome: 'conflict', content: existingContent, problems: parsed.problems }
|
|
230
|
+
|
|
231
|
+
const key = closeProofRowKey(row)
|
|
232
|
+
const prior = parsed.rows.find((r) => closeProofRowKey(r) === key)
|
|
233
|
+
if (prior) {
|
|
234
|
+
const same = serializeCloseProofRow(prior) === serializeCloseProofRow(row)
|
|
235
|
+
return same
|
|
236
|
+
? { outcome: 'duplicate', content: existingContent, problems: [] }
|
|
237
|
+
: { outcome: 'conflict', content: existingContent, problems: [`같은 자연키의 기존 행과 내용이 다름(${row.event}) — 덮어쓰지 않는다`] }
|
|
238
|
+
}
|
|
239
|
+
const base = existingContent === '' || existingContent.endsWith('\n') ? existingContent : `${existingContent}\n`
|
|
240
|
+
return { outcome: 'appended', content: base + serializeCloseProofRow(row), problems: [] }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ───────────────────────────────── 상태 파생(DEC-B) — 순수 ──
|
|
244
|
+
|
|
245
|
+
/** 순수 파생기의 입력. 🔴 전부 **HEAD-committed 사실**만. 호출부가 포트로 채운다. runtime state 절대 미사용(DEC-B4). */
|
|
246
|
+
export interface CloseStateInput {
|
|
247
|
+
/** HEAD scaffold state.json에 durability marker가 있는가(`isDurabilityRequired`). false면 legacy. */
|
|
248
|
+
durabilityRequired: boolean
|
|
249
|
+
/** HEAD close proof를 파싱한 행들(없으면 []). */
|
|
250
|
+
closeProofRows: readonly CloseProofRow[]
|
|
251
|
+
/**
|
|
252
|
+
* HEAD durable 원장이 이 티켓에 **approved인 attempt-closed**를 담고 있는가.
|
|
253
|
+
* needs-recovery 판정 입력 — 원장에 승인 흔적이 있는데 HEAD 증거가 불완전하면 recovery 필요.
|
|
254
|
+
*/
|
|
255
|
+
ledgerHasApprovedClose: boolean
|
|
256
|
+
/** HEAD 증거(approvals·아카이브)가 그 승인에 대해 완비됐는가(`verifyCommittedDesignEvidence` 계열). */
|
|
257
|
+
committedEvidenceComplete: boolean
|
|
258
|
+
/**
|
|
259
|
+
* 🔴 HEAD `approvals.jsonl`에서 **현재 committed design_ref에 결속된**(`phase_design_ref === committedDesignRef`)
|
|
260
|
+
* phase evidence의 phase ID 집합. dev-complete self-verify 입력(DEC-B2/B5).
|
|
261
|
+
*
|
|
262
|
+
* 🔴 **design-bound 필터는 호출부(manifest 읽는 경계)가 이미 적용**한다 — 이 순수 판정기는 manifest를 파싱하지
|
|
263
|
+
* 않는 leaf라 필터를 여기 넣으면 모듈 경계가 깨진다(`evidencedPhaseIdsFromManifest(content, designRef)`가
|
|
264
|
+
* 필터 지점). 단순 phase_id 존재가 아니라 **결속된 증거만** 담겨야 D1 검토분이 D2 완료에 새지 않는다(phase-3a P1).
|
|
265
|
+
* dev-complete proof의 `phase_inventory`의 모든 phase가 이 집합에 있어야 dev-complete다.
|
|
266
|
+
*/
|
|
267
|
+
evidencedPhaseIds: readonly string[]
|
|
268
|
+
/**
|
|
269
|
+
* 🔴 현재 HEAD의 committed design 승인 참조(design_hash). dev-complete proof의 `design_ref`와 일치해야 dev-complete다.
|
|
270
|
+
* design 재승인으로 값이 바뀌면 옛 design_ref proof는 무효(DEC-B2). 계산 불가면 null(→ dev-complete 아님).
|
|
271
|
+
*/
|
|
272
|
+
committedDesignRef: string | null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 기본 상태(배타·완결). `migrated-complete`(REQ-2026-053)는 dev-complete 아래·needs-recovery 위의 비차단 종결. */
|
|
276
|
+
export type CloseBaseState = 'legacy' | 'series-terminal' | 'dev-complete' | 'migrated-complete' | 'needs-recovery' | 'developing'
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 🔴 dev-complete self-verify(순수, DEC-B2). HEAD close proof의 dev-complete row가 **자기 완결적으로** 증명되는가:
|
|
280
|
+
* ① dev-complete row 존재 ② 그 row의 phase_inventory **모든 phase**가 HEAD 증거(evidencedPhaseIds)에 있음
|
|
281
|
+
* ③ row의 design_ref = 현재 committed design 참조. runtime state는 절대 안 본다.
|
|
282
|
+
*/
|
|
283
|
+
export function isDevCompleteVerified(input: CloseStateInput): boolean {
|
|
284
|
+
if (input.committedDesignRef === null) return false
|
|
285
|
+
// 🔴 **현재 design_ref에 맞는** dev-complete 행을 고른다(phase-3a r02 P1). design 재승인 후 옛 design_ref
|
|
286
|
+
// 행은 무시(supersede)되고, 새 design_ref의 행이 있으면 그것으로 검증한다.
|
|
287
|
+
const dc = input.closeProofRows.find((r) => r.event === 'dev-complete' && r.design_ref === input.committedDesignRef)
|
|
288
|
+
if (!dc || !Array.isArray(dc.phase_inventory) || dc.phase_inventory.length === 0) return false
|
|
289
|
+
const evidenced = new Set(input.evidencedPhaseIds)
|
|
290
|
+
return dc.phase_inventory.every((p) => evidenced.has(p))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 기본 상태 파생(순수·배타·완결 — 항상 정확히 하나). 우선순위:
|
|
295
|
+
* `legacy` > `series-terminal` > `dev-complete` > `migrated-complete` > `needs-recovery` > `developing`(기본값).
|
|
296
|
+
*
|
|
297
|
+
* 🔴 워킹 state·워킹 승인을 절대 입력으로 받지 않는다(design-r01 P1·B4). `integrated`는 여기서 내지 않는다
|
|
298
|
+
* — git ancestry 오버레이라 순수 파생 밖이다(design-r02 P1).
|
|
299
|
+
*/
|
|
300
|
+
export function deriveBaseState(input: CloseStateInput): CloseBaseState {
|
|
301
|
+
if (!input.durabilityRequired) return 'legacy'
|
|
302
|
+
const hasEvent = (e: CloseProofEvent): boolean => input.closeProofRows.some((r) => r.event === e)
|
|
303
|
+
if (hasEvent('series-terminal')) return 'series-terminal'
|
|
304
|
+
// 🔴 dev-complete는 self-verifying(DEC-B2): proof + committed evidence + design_ref로만 판정.
|
|
305
|
+
if (isDevCompleteVerified(input)) return 'dev-complete'
|
|
306
|
+
// 🔴 REQ-2026-053(DEC-M1): 마이그레이션 종결 — dev-complete **아래**(정상 완료가 이김), needs-recovery
|
|
307
|
+
// **위**(일단 마이그레이션되면 종결). req:close가 needs-recovery/corrupt엔 스탬프를 찍지 않으므로(DEC-M3)
|
|
308
|
+
// 이 우선순위가 recovery 필요를 가리지 않는다.
|
|
309
|
+
if (hasEvent('migrated-complete')) return 'migrated-complete'
|
|
310
|
+
// 승인 흔적(원장)은 있으나 HEAD 증거가 불완전 = 복구 필요.
|
|
311
|
+
if (input.ledgerHasApprovedClose && !input.committedEvidenceComplete) return 'needs-recovery'
|
|
312
|
+
return 'developing'
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** `reconstructed` 오버레이(순수·blob). close proof에 복원 행이 하나라도 있으면 true. */
|
|
316
|
+
export function isReconstructed(rows: readonly CloseProofRow[]): boolean {
|
|
317
|
+
return rows.some((r) => r.reconstructed === true)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** req:new 게이트가 **차단**하는 기본 상태(DEC-C). 오버레이는 무관 — 기본 상태만 본다. */
|
|
321
|
+
export function baseStateBlocksIntake(state: CloseBaseState): boolean {
|
|
322
|
+
return state === 'developing' || state === 'needs-recovery'
|
|
323
|
+
}
|
|
@@ -64,6 +64,8 @@ export interface RawConfig {
|
|
|
64
64
|
reviewBudget?: ReviewBudget
|
|
65
65
|
/** REQ-2026-037: phase 자동 커밋 정책. 미지정 = DEFAULTS(never = 현행 매 phase 정지). */
|
|
66
66
|
phaseCommit?: PhaseCommit
|
|
67
|
+
/** REQ-2026-056: true면 리뷰 프롬프트에 lockfile diff 전문을 담는다. 미지정/false = 요약(기본). */
|
|
68
|
+
lockfilePromptFull?: boolean
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/** 해소된 config(DEFAULTS 병합 + 파생 절대경로). */
|
|
@@ -81,6 +83,7 @@ export interface ResolvedConfig {
|
|
|
81
83
|
reviewReasoningEffort: ReviewReasoningEffort | null
|
|
82
84
|
reviewBudget: ReviewBudget
|
|
83
85
|
phaseCommit: PhaseCommit
|
|
86
|
+
lockfilePromptFull: boolean
|
|
84
87
|
// 파생(절대경로)
|
|
85
88
|
workflowDirAbs: string
|
|
86
89
|
schemaPathAbs: string
|
|
@@ -132,6 +135,8 @@ export const DEFAULTS = {
|
|
|
132
135
|
reviewBudget: { autoBudget: 5, hardCap: 8 } as ReviewBudget,
|
|
133
136
|
// REQ-2026-037: phase 자동 커밋은 opt-in. 코어 기본 never = 현행(매 phase 정지) — 업그레이드로 완화되지 않는다.
|
|
134
137
|
phaseCommit: { autoApprove: 'never' } as PhaseCommit,
|
|
138
|
+
// REQ-2026-056: lockfile 프롬프트 기본 요약(false). 전문이 필요하면 config에 true 명시(opt-in).
|
|
139
|
+
lockfilePromptFull: false,
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
const BASENAME_RE = '^[A-Za-z0-9][A-Za-z0-9._-]*$' // basename만(슬래시·백슬래시·선행 `.`(→`..`) 금지)
|
|
@@ -172,6 +177,8 @@ export const CONFIG_SCHEMA = {
|
|
|
172
177
|
autoApprove: { type: 'string', enum: ['never', 'low-only'] },
|
|
173
178
|
},
|
|
174
179
|
},
|
|
180
|
+
// REQ-2026-056: lockfile 프롬프트 전문 opt-in.
|
|
181
|
+
lockfilePromptFull: { type: 'boolean' },
|
|
175
182
|
designDocs: {
|
|
176
183
|
type: 'object',
|
|
177
184
|
additionalProperties: false,
|
|
@@ -259,6 +266,8 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
|
|
|
259
266
|
reviewBudget: raw.reviewBudget ?? DEFAULTS.reviewBudget,
|
|
260
267
|
// REQ-2026-037: 미지정 → DEFAULTS(never). `?? `로 충분(phaseCommit은 nullable 아님 — null 탈출구 없음).
|
|
261
268
|
phaseCommit: raw.phaseCommit ?? DEFAULTS.phaseCommit,
|
|
269
|
+
// REQ-2026-056: 미지정 → DEFAULTS(false = 요약).
|
|
270
|
+
lockfilePromptFull: raw.lockfilePromptFull ?? DEFAULTS.lockfilePromptFull,
|
|
262
271
|
}
|
|
263
272
|
|
|
264
273
|
// REQ-2026-028 R7: 교차검증(스키마가 표현 못 함). AJV가 이미 hardCap∈[1,8]·autoBudget≥1을 잡았고,
|