commitgate 0.7.0 → 0.8.1
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 +45 -0
- package/README.en.md +87 -4
- package/README.md +82 -4
- 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
package/scripts/req/req-new.ts
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
* - 기본 dry-run(계획 출력), `--run` 시 실제 브랜치 생성·티켓 파일·스캐폴드 커밋.
|
|
8
8
|
* - REQ id 채번은 registry 미사용(1차) — workflow/REQ-* 디렉터리 스캔으로 max+1.
|
|
9
9
|
*
|
|
10
|
-
* 사용: req:new <slug> [--run] [--risk LOW|HIGH] [--title "..."]
|
|
10
|
+
* 사용: req:new <slug> [--run] [--risk LOW|HIGH] [--title "..."] [--successor-of <REQ-id>]
|
|
11
|
+
* --successor-of: 대체 REQ. 부모에 replace 종결(human-resolution) 기록이 있어야 하며, 없으면 fail-closed
|
|
12
|
+
* (티켓 미생성). lineage는 부모 state에서 읽는다(REQ-2026-029 A-2b).
|
|
11
13
|
*/
|
|
12
14
|
import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
|
|
13
15
|
import { join, relative } from 'node:path'
|
|
14
16
|
import { pathToFileURL } from 'node:url'
|
|
15
|
-
import { writeState, type WorkflowState } from './review-codex'
|
|
17
|
+
import { writeState, loadState, resolveSuccessorLineage, type WorkflowState, type SuccessorOf } from './review-codex'
|
|
16
18
|
import { loadConfig, packageRoot, buildScriptInvocation, type DesignDocs, type PackageManager } from './lib/config'
|
|
17
19
|
import { createGitAdapter, type GitAdapter } from './lib/adapters'
|
|
18
20
|
import { parseStatusZ, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
|
|
@@ -69,7 +71,7 @@ export function nextStepHint(pm: PackageManager, reqId: string): string {
|
|
|
69
71
|
return `코드 변경 → git add → ${buildScriptInvocation(pm, 'req:review-codex', [id, '--run']).join(' ')}`
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH'): WorkflowState {
|
|
74
|
+
export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH', successorOf?: SuccessorOf): WorkflowState {
|
|
73
75
|
return {
|
|
74
76
|
id: reqId,
|
|
75
77
|
branch,
|
|
@@ -87,7 +89,12 @@ export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | '
|
|
|
87
89
|
phases: [],
|
|
88
90
|
// REQ-016 A1(D-016-6): grandfathering 트리거 — 신규 REQ는 승인 증거를 강제(FAIL), legacy(필드 부재)는 WARN.
|
|
89
91
|
approval_evidence_required: true,
|
|
90
|
-
|
|
92
|
+
// REQ-2026-027 D1: review series 모델 버전. 첫 리뷰 전에도 존재해 "새 ticket(레코드 없음)"과
|
|
93
|
+
// "legacy(필드 부재)"를 구분한다. 필드 부재 = legacy → 새 재리뷰 시 AWAIT_HUMAN/throw(자동 초기화 금지).
|
|
94
|
+
review_series_model_version: 1,
|
|
95
|
+
// REQ-2026-029 D3: 대체 REQ면 부모 lineage(--successor-of). 빈 review_series로 새 예산 — 부모 이력만 보존.
|
|
96
|
+
...(successorOf ? { successor_of: successorOf } : {}),
|
|
97
|
+
} as WorkflowState
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
function listExistingReqIds(workflowDir: string): string[] {
|
|
@@ -103,11 +110,12 @@ export interface Opts {
|
|
|
103
110
|
title: string | null
|
|
104
111
|
run: boolean
|
|
105
112
|
root: string | null
|
|
113
|
+
successorOf: string | null // REQ-2026-029 D3: 부모 REQ id(대체 REQ lineage)
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
/** 인자 파싱(fail-closed): 잘못된 --risk·값 누락·알 수 없는 옵션은 즉시 throw(조용한 fallback 금지). */
|
|
109
117
|
export function parseArgs(argv: string[]): Opts {
|
|
110
|
-
const o: Opts = { slug: null, risk: 'LOW', title: null, run: false, root: null }
|
|
118
|
+
const o: Opts = { slug: null, risk: 'LOW', title: null, run: false, root: null, successorOf: null }
|
|
111
119
|
for (let i = 0; i < argv.length; i++) {
|
|
112
120
|
const a = argv[i]
|
|
113
121
|
if (a === undefined) continue
|
|
@@ -120,6 +128,10 @@ export function parseArgs(argv: string[]): Opts {
|
|
|
120
128
|
const v = argv[++i]
|
|
121
129
|
if (v === undefined) throw new Error('--root 값 필요')
|
|
122
130
|
o.root = v
|
|
131
|
+
} else if (a === '--successor-of') {
|
|
132
|
+
const v = argv[++i]
|
|
133
|
+
if (v === undefined) throw new Error('--successor-of 값 필요(부모 REQ id)')
|
|
134
|
+
o.successorOf = v
|
|
123
135
|
} else if (a === '--risk') {
|
|
124
136
|
const v = argv[++i]
|
|
125
137
|
if (v !== 'LOW' && v !== 'HIGH')
|
|
@@ -156,12 +168,29 @@ export function main(argv: string[] = process.argv.slice(2)): void {
|
|
|
156
168
|
const ticketRootRel = relative(cfg.root, cfg.workflowDirAbs).replace(/\\/g, '/')
|
|
157
169
|
const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
|
|
158
170
|
|
|
171
|
+
// REQ-2026-029 D3: --successor-of lineage 해소. **branch 생성·mkdir 前**에 검증(design-r01 observation) —
|
|
172
|
+
// 부모 없음·replace 기록 없음·형식 위반이면 여기서 throw해 티켓이 생성되지 않는다(R6 fail-closed).
|
|
173
|
+
let successorOf: SuccessorOf | undefined
|
|
174
|
+
if (o.successorOf !== null) {
|
|
175
|
+
const parentId = o.successorOf.startsWith('REQ-') ? o.successorOf : `REQ-${o.successorOf}`
|
|
176
|
+
const parentDir = join(cfg.workflowDirAbs, parentId)
|
|
177
|
+
let parentState: WorkflowState
|
|
178
|
+
try {
|
|
179
|
+
parentState = loadState(parentDir)
|
|
180
|
+
} catch {
|
|
181
|
+
throw new Error(`--successor-of ${parentId}: 부모 티켓 state를 읽을 수 없다(${parentDir})`)
|
|
182
|
+
}
|
|
183
|
+
// recorded_at은 자식 생성 시각(부모 값 아님). dry-run에선 검증만 하고 값은 버린다.
|
|
184
|
+
successorOf = resolveSuccessorLineage(parentState, parentId, new Date().toISOString())
|
|
185
|
+
}
|
|
186
|
+
|
|
159
187
|
if (!o.run) {
|
|
160
188
|
console.log('[req:new] DRY-RUN (--run 시 실제 생성)')
|
|
161
189
|
console.log(` REQ : ${reqId}`)
|
|
162
190
|
console.log(` branch : ${branch}`)
|
|
163
191
|
console.log(` ticket : ${ticketRel}/ (state.json·${dd.requirement}·${dd.design}·${dd.plan}·codex-request.md)`)
|
|
164
192
|
console.log(` risk : ${o.risk}`)
|
|
193
|
+
if (successorOf) console.log(` successor_of : ${successorOf.req_id} (부모 ${successorOf.parent_attempts_total}회 · replace 승인 확인)`)
|
|
165
194
|
return
|
|
166
195
|
}
|
|
167
196
|
|
|
@@ -182,7 +211,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
|
|
|
182
211
|
|
|
183
212
|
git(['checkout', '-b', branch]) // D11/DEC-WF-020: feat/req-* 생성·체크아웃
|
|
184
213
|
mkdirSync(ticketDir, { recursive: true })
|
|
185
|
-
writeState(ticketDir, buildInitialState(reqId, branch, o.risk))
|
|
214
|
+
writeState(ticketDir, buildInitialState(reqId, branch, o.risk, successorOf))
|
|
186
215
|
writeFileSync(join(ticketDir, dd.requirement), `# ${reqId} 요구사항\n\n${o.title ?? '(요구사항 작성)'}\n`, 'utf8')
|
|
187
216
|
// DEC-WF-027 design-first: design·plan 스캐폴드도 함께 생성 — 첫 --kind design 리뷰가 문서 누락으로 fail-closed 되지 않게.
|
|
188
217
|
writeFileSync(
|
package/scripts/req/req-next.ts
CHANGED
|
@@ -29,10 +29,14 @@ import {
|
|
|
29
29
|
captureDesignBinding,
|
|
30
30
|
captureIndexHash,
|
|
31
31
|
findUnstagedOrUntracked,
|
|
32
|
+
isLegacyTicket,
|
|
33
|
+
openSeriesAttempts,
|
|
34
|
+
isSeriesKeyTerminal,
|
|
32
35
|
type WorkflowState,
|
|
33
36
|
type ReviewKind,
|
|
34
37
|
type LastReviewMarker,
|
|
35
38
|
} from './review-codex'
|
|
39
|
+
import type { ReviewBudget } from './lib/config'
|
|
36
40
|
|
|
37
41
|
// ─────────────────────────────────────────────── 읽기 전용 git 경계 (D6-1) ──
|
|
38
42
|
|
|
@@ -130,6 +134,8 @@ export interface NextInput {
|
|
|
130
134
|
worktreeReviewClean: boolean
|
|
131
135
|
/** 현재 인덱스 전체 해시(`captureIndexHash`). 계산 불가면 null. */
|
|
132
136
|
currentIndexHash: string | null
|
|
137
|
+
/** REQ-2026-028 A-2a: review 예산(G3 escalated 판정용). main이 cfg에서 채운다. */
|
|
138
|
+
reviewBudget: ReviewBudget
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
/** `consumed_approvals[]`에서 phase_id를 안전하게 읽는다. */
|
|
@@ -355,6 +361,48 @@ function gateRunCandidate(input: NextInput, cand: RunCandidate): NextAction {
|
|
|
355
361
|
'워킹트리에 unstaged/untracked 변경이 있어 리뷰(D10)가 실패한다. 의도한 변경은 `git add`, 그 외는 정리한 뒤 다시 req:next.',
|
|
356
362
|
}
|
|
357
363
|
|
|
364
|
+
// terminal (REQ-2026-029 A-2b): human-resolution으로 종결된 키 → AWAIT_HUMAN. **G3보다 앞**(R4) — 종결된
|
|
365
|
+
// series는 예산 안내("고치고 예외 받으라")가 아니라 "이미 끝났다 — 대체 REQ" 안내가 맞다. G1보다는 뒤
|
|
366
|
+
// (dirty면 정리 먼저). 우선순위: G1 → terminal → G3 → G2.
|
|
367
|
+
if (isSeriesKeyTerminal(input.state, cand.kind, cand.phaseId))
|
|
368
|
+
return {
|
|
369
|
+
kind: 'AWAIT_HUMAN',
|
|
370
|
+
detail: '이 series는 human-resolution으로 종결됐다. 같은 키에서 자동으로 재개하지 않는다.',
|
|
371
|
+
controlPoint: 'human-resolution 종결됨',
|
|
372
|
+
approvalSentence: '대체가 필요하면 `req:new --successor-of <이 REQ>`로 만든다(종결 상태 유지 결정도 사람이 한다)',
|
|
373
|
+
diagnostics: ['종결 사유: human-resolution', '재개는 자동으로 일어나지 않는다 — 대체 REQ 또는 종결 유지.'],
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// G3 (REQ-2026-028 A-2a): 자동 예산 소진(escalated) → AWAIT_HUMAN. **G2보다 앞**(R13) — 5회차 NEEDS_FIX
|
|
377
|
+
// 직후엔 escalated와 같은 바인딩 needs-fix가 동시 성립하는데, G2가 먼저 "findings 고치고 다시 add"(AGENT)를
|
|
378
|
+
// 내면 그 조언이 거짓이다(고쳐도 사람 승인 없이 6회차가 안 열린다). escalated는 파생값(저장 안 함, R11).
|
|
379
|
+
const openAttempts = openSeriesAttempts(input.state, cand.kind, cand.phaseId)
|
|
380
|
+
const { autoBudget, hardCap } = input.reviewBudget
|
|
381
|
+
if (openAttempts >= autoBudget) {
|
|
382
|
+
const nextAttempt = openAttempts + 1
|
|
383
|
+
const lrOutcome = (input.state.last_review as LastReviewMarker | undefined)?.outcome ?? '(없음)'
|
|
384
|
+
const hardBlocked = openAttempts >= hardCap
|
|
385
|
+
// ⚠️ "위험 수용"은 어느 문구에도 넣지 않는다(배분표 ④ — 부정문으로도 금지). 긍정 선택지만 나열.
|
|
386
|
+
const options = hardBlocked
|
|
387
|
+
? '예외로도 진행 불가 — 종료하거나 정합한 대체 REQ를 작성한다.'
|
|
388
|
+
: '사람 승인 시 1회 예외 가능(review_exception_confirmed) · 종료 · 정합한 대체 REQ 작성.'
|
|
389
|
+
return {
|
|
390
|
+
kind: 'AWAIT_HUMAN',
|
|
391
|
+
detail: hardBlocked
|
|
392
|
+
? `이 series는 하드 상한(hardCap=${hardCap})에 도달했다. ${nextAttempt}회차는 어떤 경로로도 실행하지 않는다.`
|
|
393
|
+
: `이 series는 자동 예산(autoBudget=${autoBudget})을 소진했다. ${nextAttempt}회차는 사람 결정이 필요하다.`,
|
|
394
|
+
controlPoint: 'review 예산 소진(escalated)',
|
|
395
|
+
approvalSentence: hardBlocked
|
|
396
|
+
? 'review 하드 상한 도달 — 종료 또는 대체 REQ 작성(둘 중 하나를 사람이 결정)'
|
|
397
|
+
: `review ${nextAttempt}회차 예외 승인(또는 종료·대체 REQ 작성)`,
|
|
398
|
+
diagnostics: [
|
|
399
|
+
`series 시도 수(openAttempts)=${openAttempts} · 다음 회차=${nextAttempt}`,
|
|
400
|
+
`직전 리뷰 outcome=${lrOutcome}`,
|
|
401
|
+
`선택지: ${options}`,
|
|
402
|
+
],
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
358
406
|
// G2
|
|
359
407
|
const lr = input.state.last_review as LastReviewMarker | undefined
|
|
360
408
|
const sameTarget =
|
|
@@ -438,6 +486,18 @@ export function resolveNext(input: NextInput): NextAction {
|
|
|
438
486
|
approvalSentence: 'req:commit --run 승인',
|
|
439
487
|
}
|
|
440
488
|
|
|
489
|
+
// 1.5 legacy ticket(REQ-2026-027 D1): 모델 버전 부재 = legacy. 살아 있는 승인(1번)보다는 뒤 —
|
|
490
|
+
// 그건 소비만 하면 되고 새 외부 호출이 아니다. design/phase RUN 후보(2·3번)보다는 **앞** — 그 후보를
|
|
491
|
+
// 내면 사용자가 실행한 뒤에야 호출 지점에서 throw된다(R2는 AWAIT_HUMAN을 요구). 자동 초기화하지 않는다.
|
|
492
|
+
if (isLegacyTicket(state))
|
|
493
|
+
return {
|
|
494
|
+
kind: 'AWAIT_HUMAN',
|
|
495
|
+
detail:
|
|
496
|
+
'legacy ticket(review_series_model_version 부재)이다. 자동으로 새 모델로 초기화하지 않는다 — 사람이 이 티켓을 새 series 모델로 채택할지 결정해야 한다.',
|
|
497
|
+
controlPoint: 'legacy 티켓 채택',
|
|
498
|
+
approvalSentence: 'state.json에 review_series_model_version: 1 추가(이 티켓을 새 모델로 채택) 승인',
|
|
499
|
+
}
|
|
500
|
+
|
|
441
501
|
// 2. 설계 문서가 인덱스에 없으면 3번의 freshness 판정(captureDesignBinding)이 throw한다. 여기서 먼저 거른다.
|
|
442
502
|
if (!input.designDocsInIndex)
|
|
443
503
|
return {
|
|
@@ -631,6 +691,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
|
|
|
631
691
|
hasStagedChanges: roGit(['diff', '--cached', '--name-only']).trim().length > 0,
|
|
632
692
|
worktreeReviewClean: findUnstagedOrUntracked(statusEntries, scratch, ticketRel).length === 0,
|
|
633
693
|
currentIndexHash: captureIndexHash(roGit),
|
|
694
|
+
reviewBudget: cfg.reviewBudget,
|
|
634
695
|
})
|
|
635
696
|
|
|
636
697
|
if (opts.json) console.log(JSON.stringify({ req_id: state.id, ...action }, null, 2))
|