commitgate 0.9.6 → 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 +40 -0
- package/bin/dispatch.mjs +3 -0
- package/bin/init.ts +16 -3
- package/bin/migrate.ts +29 -11
- package/bin/sync.ts +338 -30
- package/bin/uninstall.ts +8 -2
- package/package.json +76 -76
- 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-ports.ts +116 -0
- package/scripts/req/lib/evidence.ts +813 -0
- 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 +215 -256
- package/scripts/req/req-doctor.ts +134 -16
- package/scripts/req/req-new.ts +53 -1
- package/scripts/req/req-next.ts +59 -5
- package/scripts/req/req-reconstruct.ts +217 -0
- package/scripts/req/req-review-exception.ts +197 -0
- package/scripts/req/review-codex.ts +578 -117
- package/templates/workflow.gitignore +4 -0
- package/workflow/req.config.schema.json +3 -0
- package/workflow/review-persona.md +8 -0
|
@@ -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을 잡았고,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `EvidencePorts`의 실제 구현(fs + git) — REQ-2026-048 phase-3.
|
|
3
|
+
*
|
|
4
|
+
* `lib/evidence.ts`는 leaf(순수)라 fs·git을 모른다. 부수효과는 전부 여기로 모아 두 호출자
|
|
5
|
+
* (`review-codex`의 정상 승인 경로, `req:commit --finalize-design`의 복구 경로)가 **같은 포트**를 쓰게 한다.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ 이 모듈도 `review-codex`·`req-commit`·`req-doctor`를 import하지 않는다(leaf 유지).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { execFileSync } from 'node:child_process'
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
import { join, dirname } from 'node:path'
|
|
13
|
+
import { isArchiveFileName } from './scratch'
|
|
14
|
+
import type { EvidencePorts } from './evidence'
|
|
15
|
+
|
|
16
|
+
/** repo-상대 경로 → 절대 경로(구분자 정규화). */
|
|
17
|
+
function abs(root: string, repoRel: string): string {
|
|
18
|
+
return join(root, ...repoRel.replace(/\\/g, '/').split('/'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 실 파일시스템·git 기반 포트.
|
|
23
|
+
*
|
|
24
|
+
* @param root 소비 저장소 루트(=`cfg.root`).
|
|
25
|
+
* @param responsesDirRel 티켓 `responses/` repo-상대 경로(아카이브 목록용).
|
|
26
|
+
*/
|
|
27
|
+
export function createEvidencePorts(root: string, responsesDirRel: string): EvidencePorts {
|
|
28
|
+
/** 짧은 문자열 출력을 내는 git 호출(파일 내용용 아님). */
|
|
29
|
+
const gitText = (args: string[]): string =>
|
|
30
|
+
execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
readText(repoRel) {
|
|
34
|
+
const p = abs(root, repoRel)
|
|
35
|
+
return existsSync(p) ? readFileSync(p, 'utf8') : null
|
|
36
|
+
},
|
|
37
|
+
writeText(repoRel, content) {
|
|
38
|
+
const p = abs(root, repoRel)
|
|
39
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
40
|
+
writeFileSync(p, content, 'utf8')
|
|
41
|
+
},
|
|
42
|
+
listArchiveNames() {
|
|
43
|
+
const d = abs(root, responsesDirRel)
|
|
44
|
+
return existsSync(d) ? readdirSync(d).filter(isArchiveFileName) : []
|
|
45
|
+
},
|
|
46
|
+
sha256(repoRel) {
|
|
47
|
+
return createHash('sha256').update(readFileSync(abs(root, repoRel))).digest('hex')
|
|
48
|
+
},
|
|
49
|
+
headText(repoRel) {
|
|
50
|
+
try {
|
|
51
|
+
return gitText(['show', `HEAD:${repoRel}`])
|
|
52
|
+
} catch {
|
|
53
|
+
return null // HEAD에 없는 경로
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
/**
|
|
57
|
+
* 🔴 **바이트 그대로** 읽어 해시한다.
|
|
58
|
+
*
|
|
59
|
+
* `GitAdapter.exec`는 계약상 결과의 **후행 공백을 제거**한다(`git status --porcelain` 선행 공백 보존이 목적).
|
|
60
|
+
* 그 변환은 파일 내용 해시를 **망가뜨린다**(끝 개행이 사라져 sha가 달라진다). 그래서 이 한 곳만
|
|
61
|
+
* `execFileSync(..., {encoding:'buffer'})`로 원문 바이트를 받는다 — 어댑터 우회가 아니라 **다른 계약**이 필요해서다.
|
|
62
|
+
*
|
|
63
|
+
* 또한 워킹 파일이 아니라 **blob**을 읽는 것이 핵심이다. `core.autocrlf` 환경에서 워킹 파일은 CRLF로
|
|
64
|
+
* 변환될 수 있고, 그러면 커밋된 내용과 sha가 달라져 거짓 불일치가 난다.
|
|
65
|
+
*/
|
|
66
|
+
headBlobSha256(repoRel) {
|
|
67
|
+
try {
|
|
68
|
+
const buf = execFileSync('git', ['cat-file', 'blob', `HEAD:${repoRel}`], {
|
|
69
|
+
cwd: root,
|
|
70
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
71
|
+
})
|
|
72
|
+
return createHash('sha256').update(buf).digest('hex')
|
|
73
|
+
} catch {
|
|
74
|
+
return null // HEAD에 없는 경로
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
/**
|
|
78
|
+
* `HEAD`에 있는 해당 디렉터리의 아카이브 **repo-상대 경로**(REQ-2026-049 DEC-4).
|
|
79
|
+
*
|
|
80
|
+
* 🔴 `git ls-tree`는 **커밋 트리**를 읽는다 — 워킹 디렉터리를 전혀 보지 않는다. 그래서 "워킹 트리만
|
|
81
|
+
* 고치고 HEAD는 손상된" 경우를 잡는다. `-z`로 NUL 구분해 공백·비ASCII 경로에서도 안전하다
|
|
82
|
+
* (`--name-only`의 기본 출력은 특수문자를 인용해 경로가 변형된다).
|
|
83
|
+
*/
|
|
84
|
+
headArchivePaths(responsesDirRel) {
|
|
85
|
+
try {
|
|
86
|
+
const out = execFileSync('git', ['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', responsesDirRel], {
|
|
87
|
+
cwd: root,
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
90
|
+
})
|
|
91
|
+
return out
|
|
92
|
+
.split('\0')
|
|
93
|
+
.map((p) => p.trim())
|
|
94
|
+
.filter((p) => p !== '' && isArchiveFileName(p.split('/').pop() ?? ''))
|
|
95
|
+
} catch {
|
|
96
|
+
return [] // HEAD에 그 경로가 없음
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
headCommitSha() {
|
|
100
|
+
return gitText(['rev-parse', 'HEAD']).trim()
|
|
101
|
+
},
|
|
102
|
+
/**
|
|
103
|
+
* 🔴 **pathspec 범위 커밋**. `git add <paths>` 로 새 파일을 추적 대상에 넣은 뒤,
|
|
104
|
+
* `git commit -m <msg> -- <paths>` 로 **그 경로만** 커밋한다.
|
|
105
|
+
*
|
|
106
|
+
* `-- <paths>` 가 핵심이다: pathspec을 주면 git은 그 경로들의 내용만으로 커밋을 만들고
|
|
107
|
+
* **나머지 index는 건드리지 않는다**. 설계 문서를 stage한 채 design 리뷰를 돌리는 정상 경로에서도
|
|
108
|
+
* 그 staged 변경은 evidence 커밋에 섞이지 않고 index에 그대로 남는다.
|
|
109
|
+
*/
|
|
110
|
+
commitPaths(paths, message) {
|
|
111
|
+
if (paths.length === 0) return
|
|
112
|
+
execFileSync('git', ['add', '--', ...paths], { cwd: root, encoding: 'utf8' })
|
|
113
|
+
execFileSync('git', ['commit', '-m', message, '--', ...paths], { cwd: root, encoding: 'utf8' })
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|