commitgate 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,22 +15,28 @@
15
15
  * ⚠️ **강제(enforcement)가 아니라 자문(advisory)이다.** 승인 게이트는 여전히 `req:review-codex`/`req:commit`에 있다.
16
16
  * `req:next`가 틀려도 게이트는 뚫리지 않는다. 그래서 애매하면 `RUN` 쪽으로 기운다(fail-forward).
17
17
  *
18
- * 사용: npm run req:next -- <REQ-id> [--json] [--root <path>] [--ticket <dir>]
18
+ * 사용: req:next <REQ-id> [--json] [--root <path>] [--ticket <dir>] (저장소 패키지매니저의 실행 형식으로)
19
19
  */
20
20
  import { resolve, join, relative } from 'node:path'
21
21
  import { pathToFileURL } from 'node:url'
22
22
  import { loadConfig, buildScriptInvocation, type PackageManager } from './lib/config'
23
23
  import { createGitAdapter, type GitAdapter } from './lib/adapters'
24
+ import { parseStatusZ, STATUS_Z_ARGS } from './lib/porcelain'
25
+ import { reviewScratchPaths } from './lib/scratch'
24
26
  import {
25
27
  loadState,
26
28
  readPhases,
27
29
  captureDesignBinding,
28
30
  captureIndexHash,
29
31
  findUnstagedOrUntracked,
32
+ isLegacyTicket,
33
+ openSeriesAttempts,
34
+ isSeriesKeyTerminal,
30
35
  type WorkflowState,
31
36
  type ReviewKind,
32
37
  type LastReviewMarker,
33
38
  } from './review-codex'
39
+ import type { ReviewBudget } from './lib/config'
34
40
 
35
41
  // ─────────────────────────────────────────────── 읽기 전용 git 경계 (D6-1) ──
36
42
 
@@ -128,6 +134,8 @@ export interface NextInput {
128
134
  worktreeReviewClean: boolean
129
135
  /** 현재 인덱스 전체 해시(`captureIndexHash`). 계산 불가면 null. */
130
136
  currentIndexHash: string | null
137
+ /** REQ-2026-028 A-2a: review 예산(G3 escalated 판정용). main이 cfg에서 채운다. */
138
+ reviewBudget: ReviewBudget
131
139
  }
132
140
 
133
141
  /** `consumed_approvals[]`에서 phase_id를 안전하게 읽는다. */
@@ -317,6 +325,14 @@ function commitCmd(pm: PackageManager, target: NextTarget): string {
317
325
  return buildScriptInvocation(pm, 'req:commit', [...targetArgs(target), '--run']).join(' ')
318
326
  }
319
327
 
328
+ /**
329
+ * 대상(REQ id 또는 `--ticket`) 미지정 에러 문구(DEC-011-1). **config 로드 이후**라 pm별로 파생한다.
330
+ * 리터럴을 박으면 다른 pm 프로젝트의 사용자가 그대로 따라 할 수 없는 명령을 안내받는다.
331
+ */
332
+ export function missingTargetHint(pm: PackageManager): string {
333
+ return `REQ id 또는 --ticket <dir> 필요 (예: ${buildScriptInvocation(pm, 'req:next', ['2026-010']).join(' ')})`
334
+ }
335
+
320
336
  interface RunCandidate {
321
337
  command: string
322
338
  kind: ReviewKind
@@ -345,6 +361,48 @@ function gateRunCandidate(input: NextInput, cand: RunCandidate): NextAction {
345
361
  '워킹트리에 unstaged/untracked 변경이 있어 리뷰(D10)가 실패한다. 의도한 변경은 `git add`, 그 외는 정리한 뒤 다시 req:next.',
346
362
  }
347
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
+
348
406
  // G2
349
407
  const lr = input.state.last_review as LastReviewMarker | undefined
350
408
  const sameTarget =
@@ -428,6 +486,18 @@ export function resolveNext(input: NextInput): NextAction {
428
486
  approvalSentence: 'req:commit --run 승인',
429
487
  }
430
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
+
431
501
  // 2. 설계 문서가 인덱스에 없으면 3번의 freshness 판정(captureDesignBinding)이 throw한다. 여기서 먼저 거른다.
432
502
  if (!input.designDocsInIndex)
433
503
  return {
@@ -553,7 +623,11 @@ export function parseArgs(argv: string[]): Opts {
553
623
  for (let i = 0; i < argv.length; i++) {
554
624
  const a = argv[i]
555
625
  if (a === undefined) continue
556
- if (a === '--json') o.json = true
626
+ // bare `--`는 옵션이 아니라 POSIX end-of-options 마커다(DEC-011-3). npm은 `npm run x -- a`에서
627
+ // 이를 제거하지만 pnpm/yarn은 그대로 넘긴다 → 흡수한다. 이후 인자도 계속 옵션으로 파싱한다
628
+ // (전부 위치인자로 삼키면 `req:commit <id> -- --run`이 조용히 dry-run이 된다).
629
+ if (a === '--') continue
630
+ else if (a === '--json') o.json = true
557
631
  else if (a === '--ticket') o.ticket = argv[++i] ?? null
558
632
  else if (a === '--root') {
559
633
  const v = argv[++i]
@@ -578,15 +652,14 @@ export function renderAction(displayId: string, a: NextAction): string {
578
652
  return lines.join('\n')
579
653
  }
580
654
 
581
- function main(): void {
582
- const opts = parseArgs(process.argv.slice(2))
655
+ export function main(argv: string[] = process.argv.slice(2)): void {
656
+ const opts = parseArgs(argv)
583
657
  const cfg = loadConfig({ root: opts.root })
584
658
  const roGit = createReadOnlyGit(createGitAdapter(cfg.root))
585
659
 
586
660
  // target은 **사용자가 티켓을 지목한 방식 그대로** 보존한다(R5). `--ticket`으로 읽었으면 후속 명령도 `--ticket`을 쓴다 —
587
661
  // 그러지 않으면 `req:review-codex -- <reqId>`가 기본 위치의 **다른 티켓**을 리뷰한다.
588
- if (!opts.ticket && !opts.reqId)
589
- throw new Error('REQ id 또는 --ticket <dir> 필요 (예: npm run req:next -- 2026-010)')
662
+ if (!opts.ticket && !opts.reqId) throw new Error(missingTargetHint(cfg.packageManager))
590
663
  const target: NextTarget = opts.ticket
591
664
  ? { kind: 'ticket', ticketDir: opts.ticket }
592
665
  : { kind: 'req', reqId: (opts.reqId as string).replace(/^REQ-/, '') }
@@ -605,16 +678,9 @@ function main(): void {
605
678
  currentDesignHash = null
606
679
  }
607
680
 
608
- const statusLines = roGit(['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all'])
609
- .split('\n')
610
- .filter(Boolean)
611
- // review-codex/doctor와 동일한 스크래치 집합 — 워크플로 도구가 쓰는 메타데이터는 D10 대상이 아니다.
612
- const repoRel = (abs: string) => relative(cfg.root, abs).replace(/\\/g, '/')
613
- const scratch = [
614
- repoRel(join(ticketDir, 'codex-response.json')),
615
- repoRel(join(ticketDir, '.review-preview.txt')),
616
- repoRel(join(ticketDir, 'state.json')),
617
- ]
681
+ const statusEntries = parseStatusZ(roGit([...STATUS_Z_ARGS]))
682
+ // review-codex/doctor와 동일한 스크래치 집합(lib/scratch SSOT) — 워크플로 도구가 쓰는 메타데이터는 D10 대상이 아니다.
683
+ const scratch = reviewScratchPaths(ticketRel)
618
684
 
619
685
  const action = resolveNext({
620
686
  target,
@@ -623,8 +689,9 @@ function main(): void {
623
689
  designDocsInIndex: currentDesignHash !== null,
624
690
  currentDesignHash,
625
691
  hasStagedChanges: roGit(['diff', '--cached', '--name-only']).trim().length > 0,
626
- worktreeReviewClean: findUnstagedOrUntracked(statusLines, scratch, ticketRel).length === 0,
692
+ worktreeReviewClean: findUnstagedOrUntracked(statusEntries, scratch, ticketRel).length === 0,
627
693
  currentIndexHash: captureIndexHash(roGit),
694
+ reviewBudget: cfg.reviewBudget,
628
695
  })
629
696
 
630
697
  if (opts.json) console.log(JSON.stringify({ req_id: state.id, ...action }, null, 2))
@@ -634,5 +701,15 @@ function main(): void {
634
701
  if (code !== 0) process.exit(code)
635
702
  }
636
703
 
704
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
705
+ export function runCli(argv: string[]): void {
706
+ try {
707
+ main(argv)
708
+ } catch (err) {
709
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
710
+ process.exitCode = 1
711
+ }
712
+ }
713
+
637
714
  const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
638
715
  if (isMain) main()