commitgate 0.9.8 → 0.9.10
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 +34 -0
- package/bin/dispatch.mjs +3 -0
- package/bin/init.ts +21 -3
- package/bin/migrate.ts +29 -11
- package/bin/sync.ts +200 -17
- package/bin/uninstall.ts +75 -6
- package/package.json +76 -76
- package/scripts/req/lib/adapters.ts +113 -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 +8 -1
- 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 +72 -0
- package/scripts/req/lib/scratch.ts +14 -1
- package/scripts/req/lib/state-checkpoint.ts +78 -0
- package/scripts/req/req-close.ts +222 -0
- package/scripts/req/req-commit.ts +805 -623
- package/scripts/req/req-doctor.ts +58 -1
- package/scripts/req/req-new.ts +304 -255
- package/scripts/req/req-next.ts +848 -813
- package/scripts/req/req-reconstruct.ts +224 -0
- package/scripts/req/req-review-exception.ts +197 -0
- package/scripts/req/review-codex.ts +2547 -2146
- package/workflow/req.config.schema.json +3 -0
- package/workflow/review-persona.md +8 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* req:reconstruct — 소실된 close-proof lifecycle 행을 **HEAD-committed immutable evidence만으로** 복원한다
|
|
4
|
+
* (REQ-2026-052 phase-4·DEC-D2). 복원 가능성 매트릭스는 `lib/reconstruct`(순수)가 판정하고, 이 CLI는 evidence를
|
|
5
|
+
* HEAD blob에서 뽑아 넣고 부작용(durable commit)을 낸다.
|
|
6
|
+
*
|
|
7
|
+
* 🔴 **HEAD blob만** 읽는다 — runtime state·워킹트리·미커밋 원장·추정 phase 목록을 근거로 쓰지 않는다.
|
|
8
|
+
* 🔴 `verifyCommittedEvidenceIntegrity`가 실패한(손상) 티켓은 **복원하지 않고 fail-closed**한다.
|
|
9
|
+
* 🔴 **dev-complete·phase_design_ref·design/phase archive는 절대 합성하지 않는다.** state.json을 고치지 않는다.
|
|
10
|
+
* 🔴 기본 **dry-run**. `--run` + **`--confirm`(사람 확인)** 후에만 write. 새 행은 `reconstructed:true` +
|
|
11
|
+
* 비어있지 않은 `evidence_basis`. append-only·자연키 멱등 → 재시도가 중복 행/추가 커밋을 만들지 않는다.
|
|
12
|
+
*
|
|
13
|
+
* 사용: req:reconstruct <REQ> [--run] [--confirm] [--root <dir>]
|
|
14
|
+
*/
|
|
15
|
+
import { writeFileSync } from 'node:fs'
|
|
16
|
+
import { join, relative } from 'node:path'
|
|
17
|
+
import { pathToFileURL } from 'node:url'
|
|
18
|
+
import { loadConfig, packageRoot } from './lib/config'
|
|
19
|
+
import { createGitAdapter, quietGitRunner, type GitAdapter } from './lib/adapters'
|
|
20
|
+
import { createEvidencePorts } from './lib/evidence-ports'
|
|
21
|
+
import { verifyCommittedEvidenceIntegrity } from './lib/evidence'
|
|
22
|
+
import { parseCloseProof, appendCloseProofRow, closeProofPath, type CloseProofRow } from './lib/close-proof'
|
|
23
|
+
import { planReconstruction, type SuccessorEvidence, type ReconstructPlan } from './lib/reconstruct'
|
|
24
|
+
import { listHeadTicketIds } from './lib/intake'
|
|
25
|
+
import { isValidHumanResolution } from './review-codex'
|
|
26
|
+
|
|
27
|
+
let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
|
|
28
|
+
/**
|
|
29
|
+
* **부재가 정상인 HEAD 조회 전용** 어댑터(REQ-2026-058 F-5). 이 명령의 HEAD 조회는 "아직 없음"이 정상이라
|
|
30
|
+
* git stderr의 `fatal: … does not exist in 'HEAD'`가 사용자에게 실패로 보인다. 판정은 그대로 `catch → null`.
|
|
31
|
+
*/
|
|
32
|
+
let quietGitAdapter: GitAdapter = createGitAdapter(packageRoot(), quietGitRunner)
|
|
33
|
+
function git(args: string[]): string {
|
|
34
|
+
return gitAdapter.exec(args)
|
|
35
|
+
}
|
|
36
|
+
/** `HEAD:<repoRel>` blob 텍스트(없으면 null). 부재가 정상이므로 quiet 어댑터를 쓴다(F-5). */
|
|
37
|
+
function headBlob(repoRel: string): string | null {
|
|
38
|
+
try {
|
|
39
|
+
return quietGitAdapter.exec(['show', `HEAD:${repoRel}`])
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Opts {
|
|
46
|
+
reqId: string | null
|
|
47
|
+
run: boolean
|
|
48
|
+
confirm: boolean
|
|
49
|
+
root: string | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 인자 파싱(fail-closed): 값 누락·알 수 없는 옵션은 즉시 throw. */
|
|
53
|
+
export function parseArgs(argv: string[]): Opts {
|
|
54
|
+
const o: Opts = { reqId: null, run: false, confirm: false, root: null }
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
const a = argv[i]
|
|
57
|
+
if (a === undefined) continue
|
|
58
|
+
if (a === '--') continue
|
|
59
|
+
else if (a === '--run') o.run = true
|
|
60
|
+
else if (a === '--confirm') o.confirm = true
|
|
61
|
+
else if (a === '--root') {
|
|
62
|
+
const v = argv[++i]
|
|
63
|
+
if (v === undefined) throw new Error('--root 값 필요')
|
|
64
|
+
o.root = v
|
|
65
|
+
} else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
|
|
66
|
+
else o.reqId = a
|
|
67
|
+
}
|
|
68
|
+
return o
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 이 티켓을 replace 부모로 지목하는 committed successor 증거를 HEAD tree에서 수집(read-only).
|
|
73
|
+
* 🔴 구식 successor_of(`parent_series_id` 없음)·비-replace·형식 무효는 제외한다(복원 불가 — 수집 안 함).
|
|
74
|
+
*/
|
|
75
|
+
export function collectSuccessorEvidence(workflowDirRel: string, parentReqId: string, gitFn: (a: string[]) => string): SuccessorEvidence[] {
|
|
76
|
+
const headBlobVia = (rel: string): string | null => {
|
|
77
|
+
try {
|
|
78
|
+
return gitFn(['show', `HEAD:${rel}`])
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const out: SuccessorEvidence[] = []
|
|
84
|
+
for (const id of listHeadTicketIds(workflowDirRel, gitFn)) {
|
|
85
|
+
if (id === parentReqId) continue
|
|
86
|
+
const rel = `${workflowDirRel}/${id}`
|
|
87
|
+
const stateText = headBlobVia(`${rel}/state.json`)
|
|
88
|
+
if (stateText === null) continue
|
|
89
|
+
let so: unknown
|
|
90
|
+
try {
|
|
91
|
+
so = (JSON.parse(stateText) as { successor_of?: unknown }).successor_of
|
|
92
|
+
} catch {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (!so || typeof so !== 'object') continue
|
|
96
|
+
const s = so as { req_id?: unknown; parent_series_id?: unknown; parent_replace_resolution?: unknown }
|
|
97
|
+
if (s.req_id !== parentReqId) continue
|
|
98
|
+
if (typeof s.parent_series_id !== 'string' || !s.parent_series_id) continue // 구식 → 복원 불가
|
|
99
|
+
const hr = s.parent_replace_resolution
|
|
100
|
+
if (!isValidHumanResolution(hr) || (hr as { decision?: unknown }).decision !== 'replace') continue
|
|
101
|
+
out.push({
|
|
102
|
+
successorTicketId: id,
|
|
103
|
+
successorStatePath: `${rel}/state.json`,
|
|
104
|
+
parentSeriesId: s.parent_series_id,
|
|
105
|
+
resolution: 'replace', // collect는 decision==='replace'만 통과시킨다(material field로 명시).
|
|
106
|
+
at: (hr as { decided_at: string }).decided_at,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderPlan(reqId: string, plan: ReconstructPlan): string {
|
|
113
|
+
const lines: string[] = [`[req:reconstruct] ${reqId} — 복원 계획(HEAD-committed 증거 기준)`]
|
|
114
|
+
if (plan.candidates.length) {
|
|
115
|
+
lines.push(' 복원 예정 행:')
|
|
116
|
+
for (const c of plan.candidates)
|
|
117
|
+
lines.push(` - series-terminal series_id=${c.row.series_id} resolution=${c.row.resolution} at=${c.row.at} · evidence_basis=[${c.evidenceBasis.join(', ')}]`)
|
|
118
|
+
} else {
|
|
119
|
+
lines.push(' 복원 예정 행: 없음')
|
|
120
|
+
}
|
|
121
|
+
if (plan.refusals.length) {
|
|
122
|
+
lines.push(' 복원 불가·불필요·모호:')
|
|
123
|
+
for (const r of plan.refusals) lines.push(` - ${r}`)
|
|
124
|
+
}
|
|
125
|
+
if (plan.conflicts.length) {
|
|
126
|
+
lines.push(' 🔴 fail-closed conflict(HEAD 모순 — write 0):')
|
|
127
|
+
for (const c of plan.conflicts) lines.push(` - ${c}`)
|
|
128
|
+
}
|
|
129
|
+
return lines.join('\n')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function main(argv: string[] = process.argv.slice(2)): void {
|
|
133
|
+
const o = parseArgs(argv)
|
|
134
|
+
if (!o.reqId) throw new Error('REQ 필요 (예: req:reconstruct 2026-029)')
|
|
135
|
+
const cfg = loadConfig({ root: o.root })
|
|
136
|
+
gitAdapter = createGitAdapter(cfg.root)
|
|
137
|
+
quietGitAdapter = createGitAdapter(cfg.root, quietGitRunner) // HEAD 부재 조회 전용(F-5)
|
|
138
|
+
const reqId = o.reqId.startsWith('REQ-') ? o.reqId : `REQ-${o.reqId}`
|
|
139
|
+
const ticketDir = join(cfg.workflowDirAbs, reqId)
|
|
140
|
+
const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
|
|
141
|
+
const workflowDirRel = relative(cfg.root, cfg.workflowDirAbs).replace(/\\/g, '/')
|
|
142
|
+
|
|
143
|
+
// 🔴 1. 손상 티켓은 복원하지 않는다(fail-closed) — verifyCommittedEvidenceIntegrity(design+phase HEAD 무결성).
|
|
144
|
+
const ports = createEvidencePorts(cfg.root, `${ticketRel}/responses`)
|
|
145
|
+
const manifestText = ports.headText(`${ticketRel}/responses/approvals.jsonl`)
|
|
146
|
+
const integrity = verifyCommittedEvidenceIntegrity({ ticketRel, manifestText, ports })
|
|
147
|
+
if (integrity.problems.length)
|
|
148
|
+
throw new Error(`${reqId}: committed 증거 손상 — 복원 거부(fail-closed): ${integrity.problems.slice(0, 3).join('; ')}`)
|
|
149
|
+
|
|
150
|
+
// 2. 이 티켓 HEAD close-proof(모순/중복 판정 기준). 손상이면 거부.
|
|
151
|
+
const cpRel = closeProofPath(ticketRel)
|
|
152
|
+
const cpText = headBlob(cpRel)
|
|
153
|
+
const parsed = cpText ? parseCloseProof(cpText) : { rows: [], problems: [] }
|
|
154
|
+
if (parsed.problems.length)
|
|
155
|
+
throw new Error(`${reqId}: HEAD close-proof 손상 — 복원 거부: ${parsed.problems.slice(0, 3).join('; ')}`)
|
|
156
|
+
|
|
157
|
+
// 3. successor 증거 수집(HEAD tree) → 매트릭스(순수) 판정.
|
|
158
|
+
// 🔴 quiet 어댑터를 넘긴다 — 이 함수의 git 호출은 전부 **HEAD 조회**이고 부재가 정상이다(F-5).
|
|
159
|
+
const successors = collectSuccessorEvidence(workflowDirRel, reqId, (a) => quietGitAdapter.exec(a))
|
|
160
|
+
const plan = planReconstruction({ ticketId: reqId, existingRows: parsed.rows, successors })
|
|
161
|
+
console.log(renderPlan(reqId, plan))
|
|
162
|
+
|
|
163
|
+
// 🔴 conflict(HEAD 모순)는 dry-run/`--run` 무관하게 **명령 전체를 fail-closed**한다 — 멱등으로 숨기지 않는다.
|
|
164
|
+
if (plan.conflicts.length)
|
|
165
|
+
throw new Error(`${reqId}: HEAD close-proof와 모순되는 복원 대상(conflict) — fail-closed(write 0): ${plan.conflicts.join('; ')}`)
|
|
166
|
+
|
|
167
|
+
// 4. 실행 모델.
|
|
168
|
+
if (!o.run) {
|
|
169
|
+
console.log('[req:reconstruct] DRY-RUN — write 없음(--run 시 실행).')
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (!o.confirm) {
|
|
173
|
+
console.log('[req:reconstruct] --run 지정됐으나 사람 확인 없음 — write 0. 위 계획을 확인했으면 `--confirm` 을 추가하세요.')
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (plan.candidates.length === 0) {
|
|
177
|
+
console.log('[req:reconstruct] 복원 가능한 행이 없습니다 — no-op(write 0).')
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 🔴 5. 쓰기 전 안전: 대상 close-proof가 **HEAD와 동일**(워킹트리·인덱스에 미커밋 변경 없음)이어야 한다.
|
|
182
|
+
// 아니면 HEAD 기반으로 덮어쓰다 사용자의 미커밋 close-proof 행을 **잃는다**(리뷰 P1). 불일치면 fail-closed.
|
|
183
|
+
const cpDirty = git(['status', '--porcelain', '--', cpRel]).trim()
|
|
184
|
+
if (cpDirty)
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${reqId}: close-proof(${cpRel})에 미커밋 변경이 있어 복원을 거부합니다(fail-closed) — HEAD 기반 쓰기가 미커밋 행을 덮어쓸 수 있습니다.\n` +
|
|
187
|
+
` 먼저 미커밋 close-proof 변경을 커밋하거나 정리한 뒤 다시 실행하세요.`,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
// 6. 쓰기: append(자연키 멱등) → durable commit(pathspec). 재시도는 duplicate라 커밋 diff 없음.
|
|
191
|
+
let content = cpText ?? ''
|
|
192
|
+
let appended = 0
|
|
193
|
+
for (const c of plan.candidates) {
|
|
194
|
+
const res = appendCloseProofRow(content, c.row)
|
|
195
|
+
if (res.outcome === 'appended') {
|
|
196
|
+
content = res.content
|
|
197
|
+
appended++
|
|
198
|
+
} else if (res.outcome === 'conflict') {
|
|
199
|
+
throw new Error(`복원 충돌(fail-closed): ${res.problems.join('; ')}`)
|
|
200
|
+
}
|
|
201
|
+
// duplicate → 이미 존재(멱등 skip).
|
|
202
|
+
}
|
|
203
|
+
if (appended === 0) {
|
|
204
|
+
console.log('[req:reconstruct] 대상 행이 이미 모두 존재 — no-op(멱등).')
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
writeFileSync(join(cfg.root, cpRel), content, 'utf8')
|
|
208
|
+
git(['add', '--', cpRel])
|
|
209
|
+
git(['commit', '-m', `chore(${reqId}): reconstruct series-terminal close proof (evidence-based)`, '--', cpRel])
|
|
210
|
+
console.log(`[req:reconstruct] ✅ ${appended}행 복원·durable commit(reconstructed:true·evidence_basis).`)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). */
|
|
214
|
+
export function runCli(argv: string[]): void {
|
|
215
|
+
try {
|
|
216
|
+
main(argv)
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
|
|
219
|
+
process.exitCode = 1
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
|
|
224
|
+
if (isMain) main()
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* req:review-exception — needs-exception 구간의 사람 예외를 **검증·원자 기록**한다(REQ-2026-055·DEC-RE).
|
|
4
|
+
*
|
|
5
|
+
* 지금까지 `state.json`의 `review_exception_confirmed`를 손으로 편집하던 것을 대체한다: 현재 예산이 실제
|
|
6
|
+
* needs-exception인지·어느 series·회차인지 **소비 게이트와 같은 함수**로 계산하고, 구조화 rationale을 durable하게
|
|
7
|
+
* 남긴 **뒤에만** 소비 가능한 state를 기록한다(부분 실패 시 rationale 없는 예외 방지).
|
|
8
|
+
*
|
|
9
|
+
* 🔴 durable 먼저(review-exceptions.jsonl 커밋) → state.json 마지막. 🔴 confirmed_at 실시계(날조 금지).
|
|
10
|
+
* 🔴 소비 로직(consumeReviewException)·예산 게이트 무변경 — 이 명령은 **기록만** 한다.
|
|
11
|
+
*
|
|
12
|
+
* 사용: req:review-exception <REQ> --kind design|phase [--phase <id>] --method "<승인문장>" --rationale-file <path> [--run]
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
16
|
+
import { pathToFileURL } from 'node:url'
|
|
17
|
+
import { loadConfig, packageRoot, type ReviewBudget } from './lib/config'
|
|
18
|
+
import { createGitAdapter, type GitAdapter } from './lib/adapters'
|
|
19
|
+
import {
|
|
20
|
+
loadState,
|
|
21
|
+
writeState,
|
|
22
|
+
openSeriesRecord,
|
|
23
|
+
openSeriesAttempts,
|
|
24
|
+
checkReviewBudget,
|
|
25
|
+
isSeriesKeyTerminal,
|
|
26
|
+
type WorkflowState,
|
|
27
|
+
type ReviewKind,
|
|
28
|
+
type ReviewExceptionConfirmed,
|
|
29
|
+
} from './review-codex'
|
|
30
|
+
import {
|
|
31
|
+
exceptionsPath,
|
|
32
|
+
appendExceptionGrant,
|
|
33
|
+
findExistingGrant,
|
|
34
|
+
parseRationale,
|
|
35
|
+
type ExceptionGrantRow,
|
|
36
|
+
} from './lib/review-exception'
|
|
37
|
+
|
|
38
|
+
let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
|
|
39
|
+
function git(args: string[]): string {
|
|
40
|
+
return gitAdapter.exec(args)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Opts {
|
|
44
|
+
reqId: string | null
|
|
45
|
+
kind: ReviewKind | null
|
|
46
|
+
phase: string | null
|
|
47
|
+
method: string | null
|
|
48
|
+
rationaleFile: string | null
|
|
49
|
+
run: boolean
|
|
50
|
+
root: string | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 인자 파싱(fail-closed): 값 누락·알 수 없는 옵션은 즉시 throw. */
|
|
54
|
+
export function parseArgs(argv: string[]): Opts {
|
|
55
|
+
const o: Opts = { reqId: null, kind: null, phase: null, method: null, rationaleFile: null, run: false, root: null }
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i]
|
|
58
|
+
if (a === undefined) continue
|
|
59
|
+
if (a === '--') continue
|
|
60
|
+
else if (a === '--run') o.run = true
|
|
61
|
+
else if (a === '--kind') {
|
|
62
|
+
const v = argv[++i]
|
|
63
|
+
if (v !== 'design' && v !== 'phase') throw new Error(`--kind 값은 design 또는 phase여야 함 (받음: ${v ?? '(없음)'})`)
|
|
64
|
+
o.kind = v
|
|
65
|
+
} else if (a === '--phase') {
|
|
66
|
+
const v = argv[++i]
|
|
67
|
+
if (v === undefined) throw new Error('--phase 값 필요')
|
|
68
|
+
o.phase = v
|
|
69
|
+
} else if (a === '--method') {
|
|
70
|
+
const v = argv[++i]
|
|
71
|
+
if (v === undefined) throw new Error('--method 값 필요')
|
|
72
|
+
o.method = v
|
|
73
|
+
} else if (a === '--rationale-file') {
|
|
74
|
+
const v = argv[++i]
|
|
75
|
+
if (v === undefined) throw new Error('--rationale-file 값 필요')
|
|
76
|
+
o.rationaleFile = v
|
|
77
|
+
} else if (a === '--root') {
|
|
78
|
+
const v = argv[++i]
|
|
79
|
+
if (v === undefined) throw new Error('--root 값 필요')
|
|
80
|
+
o.root = v
|
|
81
|
+
} else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
|
|
82
|
+
else o.reqId = a
|
|
83
|
+
}
|
|
84
|
+
return o
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type ReviewExceptionPlan =
|
|
88
|
+
| { ok: true; seriesId: string; forAttempt: number }
|
|
89
|
+
| { ok: false; reason: string; hint: string }
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 예외 부여 자격 판정(순수·DEC-RE1). 🔴 회차를 **소비 게이트와 같은 함수**(openSeriesAttempts·checkReviewBudget)로
|
|
93
|
+
* 계산 → consumeReviewException의 for_attempt 검증과 정확히 일치(REQ-2026-054 유효 회차 기준).
|
|
94
|
+
*/
|
|
95
|
+
export function planReviewException(state: WorkflowState, kind: ReviewKind, phaseId: string | null, budget: ReviewBudget): ReviewExceptionPlan {
|
|
96
|
+
if (isSeriesKeyTerminal(state, kind, phaseId))
|
|
97
|
+
return { ok: false, reason: '이 series는 human-resolution으로 종결됨', hint: '예산 예외가 아니라 대체 REQ가 필요(req:new --successor-of)' }
|
|
98
|
+
const open = openSeriesRecord(state, kind, phaseId)
|
|
99
|
+
if (!open) return { ok: false, reason: '열린 series가 없음 — 예외 걸 대상이 없다', hint: '먼저 req:review-codex로 리뷰를 시작하세요' }
|
|
100
|
+
const openAttempts = openSeriesAttempts(state, kind, phaseId)
|
|
101
|
+
const decision = checkReviewBudget(openAttempts, budget)
|
|
102
|
+
if (decision.kind === 'allow')
|
|
103
|
+
return { ok: false, reason: `아직 예외 불요(유효 회차 ${openAttempts} < autoBudget ${budget.autoBudget})`, hint: '그냥 req:review-codex로 리뷰하세요' }
|
|
104
|
+
if (decision.kind === 'hard-blocked')
|
|
105
|
+
return { ok: false, reason: `예산 소진 — 예외로도 불가(hardCap ${budget.hardCap})`, hint: '종료하거나 정합한 대체 REQ를 작성하세요' }
|
|
106
|
+
return { ok: true, seriesId: open.series_id, forAttempt: decision.attempt }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function main(argv: string[] = process.argv.slice(2)): void {
|
|
110
|
+
const o = parseArgs(argv)
|
|
111
|
+
if (!o.reqId) throw new Error('REQ 필요 (예: req:review-exception 2026-001 --kind design --method "…" --rationale-file r.md)')
|
|
112
|
+
if (!o.kind) throw new Error('--kind design|phase 필요')
|
|
113
|
+
if (o.kind === 'phase' && !o.phase) throw new Error('--kind phase는 --phase <id> 필요')
|
|
114
|
+
if (!o.method || o.method.trim() === '') throw new Error('--method "<승인문장>" 필요')
|
|
115
|
+
if (!o.rationaleFile) throw new Error('--rationale-file <path> 필요')
|
|
116
|
+
|
|
117
|
+
const cfg = loadConfig({ root: o.root })
|
|
118
|
+
gitAdapter = createGitAdapter(cfg.root)
|
|
119
|
+
const reqId = o.reqId.startsWith('REQ-') ? o.reqId : `REQ-${o.reqId}`
|
|
120
|
+
const ticketDir = join(cfg.workflowDirAbs, reqId)
|
|
121
|
+
const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
|
|
122
|
+
const state = loadState(ticketDir)
|
|
123
|
+
const phaseId = o.kind === 'phase' ? o.phase : null
|
|
124
|
+
|
|
125
|
+
// 1. 자격 판정 + rationale 검증(순수 — write 前 모든 거부 조건).
|
|
126
|
+
const plan = planReviewException(state, o.kind, phaseId, cfg.reviewBudget)
|
|
127
|
+
const rp = parseRationale(readFileSync(resolve(o.rationaleFile), 'utf8'))
|
|
128
|
+
if (!plan.ok) throw new Error(`${reqId} 예외 부여 불가: ${plan.reason}\n → ${plan.hint}`)
|
|
129
|
+
if (!rp.ok) throw new Error(`rationale 검증 실패(--rationale-file):\n - ${rp.problems.join('\n - ')}`)
|
|
130
|
+
|
|
131
|
+
console.log(`[req:review-exception] ${reqId} 예외 부여 계획: series=${plan.seriesId} for_attempt=${plan.forAttempt} (kind=${o.kind}${phaseId ? ` phase=${phaseId}` : ''})`)
|
|
132
|
+
if (!o.run) {
|
|
133
|
+
console.log('[req:review-exception] DRY-RUN — write 없음(--run 시 실행). rationale 4섹션 검증 OK.')
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 2. 🔴 clean 가드 — state 변경 前(DEC-RE4·r01 P1). 미커밋 review-exceptions를 HEAD 기반이 덮지 않게.
|
|
138
|
+
const exRel = exceptionsPath(ticketRel)
|
|
139
|
+
const exDirty = git(['status', '--porcelain', '--', exRel]).trim()
|
|
140
|
+
if (exDirty)
|
|
141
|
+
throw new Error(
|
|
142
|
+
`${reqId}: ${exRel}에 미커밋 변경이 있어 예외 기록을 거부합니다(fail-closed) — HEAD 기반 append가 미커밋 행을 덮을 수 있습니다.\n` +
|
|
143
|
+
` 먼저 미커밋 변경을 커밋/정리한 뒤 다시 실행하세요.`,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// 3. 🔴 durable rationale 먼저. 기존 행이 있으면 confirmed_at 재사용(재실행 복구·conflict 아님).
|
|
147
|
+
const abs = join(cfg.root, exRel)
|
|
148
|
+
const existing = existsSync(abs) ? readFileSync(abs, 'utf8') : ''
|
|
149
|
+
const naturalKey = { ticket_id: reqId, series_id: plan.seriesId, for_attempt: plan.forAttempt }
|
|
150
|
+
const prior = findExistingGrant(existing, naturalKey)
|
|
151
|
+
const confirmedAt = prior ? prior.confirmed_at : new Date().toISOString()
|
|
152
|
+
const row: ExceptionGrantRow = {
|
|
153
|
+
ticket_id: reqId,
|
|
154
|
+
review_kind: o.kind,
|
|
155
|
+
phase_id: phaseId,
|
|
156
|
+
series_id: plan.seriesId,
|
|
157
|
+
for_attempt: plan.forAttempt,
|
|
158
|
+
method: o.method,
|
|
159
|
+
confirmed_at: confirmedAt,
|
|
160
|
+
rationale: rp.rationale,
|
|
161
|
+
reconstructed: false,
|
|
162
|
+
}
|
|
163
|
+
const res = appendExceptionGrant(existing, row)
|
|
164
|
+
if (res.outcome === 'conflict') throw new Error(`${reqId}: 예외 원장 충돌(fail-closed): ${res.problems.join('; ')}`)
|
|
165
|
+
if (res.outcome === 'appended') {
|
|
166
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
167
|
+
writeFileSync(abs, res.content, 'utf8')
|
|
168
|
+
git(['add', '--', exRel])
|
|
169
|
+
git(['commit', '-m', `chore(${reqId}): review exception grant ${plan.seriesId} #${plan.forAttempt}`, '--', exRel])
|
|
170
|
+
}
|
|
171
|
+
// res.outcome === 'duplicate' → durable 이미 존재(멱등·커밋 없음).
|
|
172
|
+
|
|
173
|
+
// 4. durable 확정 후에만 소비 가능한 state 기록(scratch). confirmed_at은 durable 행과 동일 값.
|
|
174
|
+
const exception: ReviewExceptionConfirmed = {
|
|
175
|
+
confirmed: true,
|
|
176
|
+
method: o.method,
|
|
177
|
+
confirmed_at: confirmedAt,
|
|
178
|
+
for_series_id: plan.seriesId,
|
|
179
|
+
for_attempt: plan.forAttempt,
|
|
180
|
+
note: rp.rationale.prev_findings.split('\n')[0]?.slice(0, 200) ?? '',
|
|
181
|
+
}
|
|
182
|
+
writeState(ticketDir, { ...state, review_exception_confirmed: exception } as WorkflowState)
|
|
183
|
+
console.log(`[req:review-exception] ✅ ${reqId} 예외 기록 완료 — rationale durable(${res.outcome === 'appended' ? '신규 커밋' : '기존 재사용'}) + review_exception_confirmed 기록. 이제 req:review-codex를 실행하세요.`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). */
|
|
187
|
+
export function runCli(argv: string[]): void {
|
|
188
|
+
try {
|
|
189
|
+
main(argv)
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
|
|
192
|
+
process.exitCode = 1
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
|
|
197
|
+
if (isMain) main()
|