commitgate 0.9.5 → 0.9.8
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 +30 -0
- package/README.en.md +6 -0
- package/README.md +6 -0
- package/bin/init.ts +3 -2
- package/bin/sync.ts +143 -18
- package/package.json +1 -1
- package/scripts/req/lib/evidence-ports.ts +116 -0
- package/scripts/req/lib/evidence.ts +658 -0
- package/scripts/req/req-commit.ts +623 -806
- package/scripts/req/req-doctor.ts +76 -15
- package/scripts/req/req-new.ts +255 -252
- package/scripts/req/req-next.ts +813 -775
- package/scripts/req/review-codex.ts +2146 -2065
- package/skills/ATTRIBUTION.md +3 -1
- package/skills/commitgate-quality/SKILL.md +126 -0
- package/templates/CLAUDE.template.md +1 -0
- package/templates/workflow.gitignore +4 -0
|
@@ -1,806 +1,623 @@
|
|
|
1
|
-
#!/usr/bin/env tsx
|
|
2
|
-
/**
|
|
3
|
-
* req:commit — AI REQ 워크플로우 Phase B (REQ-2026-016). 승인된 phase를 커밋하는 래퍼.
|
|
4
|
-
*
|
|
5
|
-
* 설계 근거: 본 티켓 01-design.md D-016-3·3b·7·8·9.
|
|
6
|
-
* 책임(전체): req:doctor 통과 게이트 → HIGH 사람확인 게이트 → source 커밋(승인 코드만) →
|
|
7
|
-
* commit_allowed 소비 → evidence-finalize(approvals.jsonl 매니페스트 append + responses chore 커밋) → 2-커밋.
|
|
8
|
-
* 복구/finalize 모드(pending_evidence_for)·design-finalize 포함.
|
|
9
|
-
*
|
|
10
|
-
* **B1: 순수 기반/매니페스트 모델**. **B2(현재): 정상 flow** — doctor 게이트→HIGH→source 커밋→evidence-finalize→소비(2-커밋).
|
|
11
|
-
* 복구/finalize 모드(pending_evidence_for)·design-finalize는 **B3**. main()은 `--run` 없으면 dry-run(부작용 없음).
|
|
12
|
-
* ⚠️ B2 도구 자체 커밋은 부트스트랩 수기(req:commit dogfood는 Phase C부터).
|
|
13
|
-
*/
|
|
14
|
-
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
type
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
} from './
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
):
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
): string[] {
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
if (!
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (!
|
|
299
|
-
return
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
):
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
return
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
ticketDir
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const existingProblems = validateManifest(existing, opts)
|
|
625
|
-
if (existingProblems.length) throw new Error(`기존 approvals.jsonl 무결성 실패(fail-closed): ${existingProblems.join('; ')}`)
|
|
626
|
-
}
|
|
627
|
-
const headSha = git(['rev-parse', 'HEAD'])
|
|
628
|
-
const entry = buildManifestEntry(dev, { consumedAt: new Date().toISOString(), consumedByCommitSha: headSha, userCommitConfirmed: null })
|
|
629
|
-
const newContent = existing + serializeManifestLine(entry)
|
|
630
|
-
const problems = validateManifest(newContent, opts)
|
|
631
|
-
// 기존이 이미 클린이므로 newContent의 문제는 후보(candidate) 때문. **오직 중복뿐**일 때만 멱등 skip, 하나라도 다른 문제면 fail.
|
|
632
|
-
if (problems.length) {
|
|
633
|
-
if (problems.every((p) => p.includes('중복'))) {
|
|
634
|
-
console.log('[req:commit] design 승인 이미 기록됨(멱등 skip)')
|
|
635
|
-
return
|
|
636
|
-
}
|
|
637
|
-
throw new Error(`design-finalize 매니페스트 검증 실패: ${problems.join('; ')}`)
|
|
638
|
-
}
|
|
639
|
-
const responsePath = typeof dev.response_path === 'string' ? dev.response_path : ''
|
|
640
|
-
mkdirSync(args.responsesDir, { recursive: true })
|
|
641
|
-
writeFileSync(args.manifestPath, newContent, 'utf8')
|
|
642
|
-
git(['add', responsePath, `${args.ticketRel}/responses/approvals.jsonl`])
|
|
643
|
-
const leak = stagedNames().filter((p) => !p.startsWith(`${args.ticketRel}/responses/`))
|
|
644
|
-
if (leak.length) throw new Error(`design-finalize 커밋에 responses 외 staged 금지: ${leak.join(', ')}`)
|
|
645
|
-
git(['commit', '-m', `chore(${args.state.id}): design-finalize — design 승인 approvals.jsonl 기록`])
|
|
646
|
-
console.log('[req:commit] ✅ design-finalize 완료 — approvals.jsonl 기록')
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
export function main(argv: string[] = process.argv.slice(2)): void {
|
|
650
|
-
const opts = parseArgs(argv)
|
|
651
|
-
const cfg = loadConfig({ root: opts.root })
|
|
652
|
-
gitRoot = cfg.root // runDoctor(pnpm/npm) cwd
|
|
653
|
-
pkgManager = cfg.packageManager
|
|
654
|
-
gitAdapter = createGitAdapter(cfg.root)
|
|
655
|
-
const { ticketDir, doctorArgs } = resolveCommitTarget(opts, cfg)
|
|
656
|
-
const { run, message, messageFile, finalize, finalizeDesign } = opts
|
|
657
|
-
const state = loadState(ticketDir)
|
|
658
|
-
const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
|
|
659
|
-
const responsesDir = join(ticketDir, 'responses')
|
|
660
|
-
const manifestPath = join(responsesDir, 'approvals.jsonl')
|
|
661
|
-
const ev = (state.approval_evidence as ApprovalEvidence | undefined) ?? null
|
|
662
|
-
const validPhaseIds = readPhases(state).map((p) => p.id)
|
|
663
|
-
|
|
664
|
-
// ── DRY-RUN(부작용 없음): 게이트/계획 미리보기 ──
|
|
665
|
-
if (!run) {
|
|
666
|
-
const gate = userConfirmGate(state)
|
|
667
|
-
const mode = finalizeDesign ? 'finalize-design' : finalize ? 'finalize(복구)' : '정상'
|
|
668
|
-
console.log(`[req:commit] DRY-RUN (모드=${mode}; 실제 실행은 --run)`)
|
|
669
|
-
console.log(` ticket=${ticketRel} commit_allowed=${String(state.commit_allowed)} risk=${String(state.risk_level)}`)
|
|
670
|
-
console.log(` HIGH 게이트: ${gate.blocked ? `차단 — ${gate.reason}` : 'OK(또는 비-HIGH)'}`)
|
|
671
|
-
if (finalize) {
|
|
672
|
-
// P2-a: pending 마커 없어도 HEAD가 승인 source면 orphaned 복구 가능 → dry-run에도 반영.
|
|
673
|
-
const head = (() => {
|
|
674
|
-
try {
|
|
675
|
-
return { sha: git(['rev-parse', 'HEAD']), tree: git(['rev-parse', 'HEAD^{tree}']) }
|
|
676
|
-
} catch {
|
|
677
|
-
return null
|
|
678
|
-
}
|
|
679
|
-
})()
|
|
680
|
-
const rec = resolveRecoverySource(state, head)
|
|
681
|
-
let core = { valid: false, reason: rec.reason }
|
|
682
|
-
if (rec.sourceSha) {
|
|
683
|
-
let sourceTree: string | null = null
|
|
684
|
-
try {
|
|
685
|
-
sourceTree = git(['rev-parse', `${rec.sourceSha}^{tree}`])
|
|
686
|
-
} catch {
|
|
687
|
-
sourceTree = null
|
|
688
|
-
}
|
|
689
|
-
core = recoveryCoreValid(state, sourceTree)
|
|
690
|
-
}
|
|
691
|
-
console.log(
|
|
692
|
-
` finalize 적용 가능성: ${core.valid ? `valid${rec.viaOrphan ? '(orphaned 복구)' : ''}` : `invalid — ${core.reason}`}`,
|
|
693
|
-
)
|
|
694
|
-
}
|
|
695
|
-
if (ev) {
|
|
696
|
-
const archiveNames = existsSync(responsesDir) ? readdirSync(responsesDir).filter(isArchiveFileName) : []
|
|
697
|
-
const expected = expectedArchivePaths(archiveNames, ev.review_kind, ev.phase_id ?? null, ticketRel)
|
|
698
|
-
console.log(` approval_evidence: ${ev.review_kind} ${ev.phase_id ?? ''} → evidence-finalize 아카이브 ${expected.length}건`)
|
|
699
|
-
} else {
|
|
700
|
-
console.log(' approval_evidence 없음(review-codex 승인 후 실행)')
|
|
701
|
-
}
|
|
702
|
-
if (existsSync(manifestPath)) {
|
|
703
|
-
const problems = validateManifest(readFileSync(manifestPath, 'utf8'), { ticketRel, validPhaseIds })
|
|
704
|
-
console.log(` approvals.jsonl: ${problems.length ? `문제 ${problems.length} — ${problems.join('; ')}` : 'OK'}`)
|
|
705
|
-
}
|
|
706
|
-
return
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// ── B3: design-finalize(source/consume 없음) ──
|
|
710
|
-
if (finalizeDesign) {
|
|
711
|
-
designFinalize({ ticketDir, ticketRel, responsesDir, manifestPath, doctorArgs, state, validPhaseIds })
|
|
712
|
-
return
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const existing = existsSync(manifestPath) ? readFileSync(manifestPath, 'utf8') : ''
|
|
716
|
-
const archiveNames = existsSync(responsesDir) ? readdirSync(responsesDir).filter(isArchiveFileName) : []
|
|
717
|
-
|
|
718
|
-
// ── B3: finalize(복구) — source 재커밋 없이 evidence/consume만 복구 ──
|
|
719
|
-
if (finalize) {
|
|
720
|
-
// P2-a: pending 마커가 없을 수 있다(source 커밋 성공 후 markPendingEvidence 전에 crash). HEAD가 승인 source면 마커를 재구성해 복구.
|
|
721
|
-
let fstate = state
|
|
722
|
-
if (!pendingSourceSha(fstate)) {
|
|
723
|
-
const head = (() => {
|
|
724
|
-
try {
|
|
725
|
-
return { sha: git(['rev-parse', 'HEAD']), tree: git(['rev-parse', 'HEAD^{tree}']) }
|
|
726
|
-
} catch {
|
|
727
|
-
return null
|
|
728
|
-
}
|
|
729
|
-
})()
|
|
730
|
-
const rec = resolveRecoverySource(fstate, head)
|
|
731
|
-
if (!rec.sourceSha) throw new Error(`finalize 거부: ${rec.reason}`)
|
|
732
|
-
fstate = markPendingEvidence(fstate, rec.sourceSha) // crash가 막은 마커 재구성(승인 tree 대조로 안전 — 우회 아님)
|
|
733
|
-
writeState(ticketDir, fstate)
|
|
734
|
-
console.warn(`[req:commit] pending 마커 없음 — HEAD(${rec.sourceSha.slice(0, 8)})가 승인 source(tree==approved)라 orphaned 복구용 마커 재구성`)
|
|
735
|
-
}
|
|
736
|
-
const sourceSha = pendingSourceSha(fstate) as string
|
|
737
|
-
const sourceTree = git(['rev-parse', `${sourceSha}^{tree}`])
|
|
738
|
-
const rc = recoveryClassify(fstate, sourceTree)
|
|
739
|
-
if (!rc.valid) throw new Error(`finalize 거부: ${rc.reason}`)
|
|
740
|
-
if (!ev) throw new Error('approval_evidence 없음') // rc.valid가 보장하나 TS narrowing
|
|
741
|
-
// doctor --finalize: D9를 source 커밋 tree로 교체(우회 아님), 나머지 검사 정상.
|
|
742
|
-
runDoctor([...doctorArgs, '--finalize'])
|
|
743
|
-
const gate = userConfirmGate(fstate)
|
|
744
|
-
if (gate.blocked) throw new Error(gate.reason)
|
|
745
|
-
finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state: fstate, ev, existing, archiveNames, validPhaseIds, sourceSha })
|
|
746
|
-
console.log(`[req:commit] ✅ finalize 복구 완료 — source=${sourceSha.slice(0, 8)} · evidence/consume 복구`)
|
|
747
|
-
return
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// ── LIVE (B2 정상 flow) — ⚠️ B2 도구 자체 커밋엔 쓰지 않음(부트스트랩). Phase C부터 dogfood. ──
|
|
751
|
-
const responsePathExists = !!ev && typeof ev.response_path === 'string' && existsSync(resolve(cfg.root, ev.response_path))
|
|
752
|
-
|
|
753
|
-
// 1) doctor 게이트(fail-closed)
|
|
754
|
-
runDoctor(doctorArgs)
|
|
755
|
-
// 2) HIGH 사람확인 게이트
|
|
756
|
-
const gate = userConfirmGate(state)
|
|
757
|
-
if (gate.blocked) throw new Error(gate.reason)
|
|
758
|
-
// 3) 전제: 승인 존재 + staged tree == approved_diff_hash + staged=코드만(state/responses 금지)
|
|
759
|
-
if (state.commit_allowed !== true) throw new Error('commit_allowed=true 아님 — 승인된 phase 없음(req:review-codex 승인 필요)')
|
|
760
|
-
if (!ev) throw new Error('approval_evidence 없음 — 승인 증거 미기록')
|
|
761
|
-
if (typeof state.approved_diff_hash !== 'string') throw new Error('approved_diff_hash 없음')
|
|
762
|
-
const stagedTree = git(['write-tree'])
|
|
763
|
-
if (stagedTree !== state.approved_diff_hash)
|
|
764
|
-
throw new Error(`staged tree(${stagedTree}) != approved_diff_hash(${state.approved_diff_hash}) — stale 승인, 재리뷰 필요`)
|
|
765
|
-
const srcStaged = stagedNames()
|
|
766
|
-
if (srcStaged.length === 0) throw new Error('staged 변경 없음 — 승인 코드를 stage 후 실행')
|
|
767
|
-
const nonCode = srcStaged.filter((p) => p === `${ticketRel}/state.json` || p.startsWith(`${ticketRel}/responses/`))
|
|
768
|
-
if (nonCode.length) throw new Error(`source 커밋에 비-코드 staged 금지(state/responses): ${nonCode.join(', ')}`)
|
|
769
|
-
// REQ-018: 메시지 출처 해소(-m 또는 --message-file/env) — 정상 source-커밋 flow에서만. fail-closed(상호배타·필수·존재검증).
|
|
770
|
-
const msgSource = resolveMessageSource({ message, messageFile }, process.env.REQ_COMMIT_MESSAGE_FILE, existsSync)
|
|
771
|
-
// 3b) evidence preflight(B2-block1/2) — source 커밋 전 잡을 수 있는 evidence 실패 전부 차단. 실패 시 git commit 안 함.
|
|
772
|
-
const pre = evidencePreflight({
|
|
773
|
-
existingManifest: existing,
|
|
774
|
-
approvalEvidence: ev,
|
|
775
|
-
archiveNames,
|
|
776
|
-
ticketRel,
|
|
777
|
-
validPhaseIds,
|
|
778
|
-
responsePathExists,
|
|
779
|
-
userCommitConfirmed: (state.user_commit_confirmed as unknown) ?? null,
|
|
780
|
-
placeholderCommitSha: PREFLIGHT_PLACEHOLDER_OID,
|
|
781
|
-
placeholderConsumedAt: PREFLIGHT_PLACEHOLDER_ISO,
|
|
782
|
-
})
|
|
783
|
-
if (pre.length) throw new Error(`evidence preflight 실패(source 커밋 안 함): ${pre.join('; ')}`)
|
|
784
|
-
// 4) source 커밋(승인 코드만) — 여기서부터 부작용. preflight 통과로 source 후 실패 창 최소화.
|
|
785
|
-
// REQ-018: -m(메시지) 또는 -F(파일). messageFile 경로는 pnpm/Windows argv newline 이스케이프를 회피.
|
|
786
|
-
git(buildCommitArgs(msgSource))
|
|
787
|
-
const sourceSha = git(['rev-parse', 'HEAD'])
|
|
788
|
-
// 4b) B3 복구 마커 — source 커밋됨, evidence 미완. 이후 중단 시 `req:commit <id> --finalize --run`으로 복구.
|
|
789
|
-
writeState(ticketDir, markPendingEvidence(state, sourceSha))
|
|
790
|
-
// 5) evidence-finalize(멱등) + 소비(마지막).
|
|
791
|
-
finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state, ev, existing, archiveNames, validPhaseIds, sourceSha })
|
|
792
|
-
console.log(`[req:commit] ✅ 완료 — source=${sourceSha.slice(0, 8)} · evidence-finalize · commit_allowed 소비됨`)
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
|
|
796
|
-
export function runCli(argv: string[]): void {
|
|
797
|
-
try {
|
|
798
|
-
main(argv)
|
|
799
|
-
} catch (err) {
|
|
800
|
-
console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
|
|
801
|
-
process.exitCode = 1
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
|
|
806
|
-
if (isMain) main()
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* req:commit — AI REQ 워크플로우 Phase B (REQ-2026-016). 승인된 phase를 커밋하는 래퍼.
|
|
4
|
+
*
|
|
5
|
+
* 설계 근거: 본 티켓 01-design.md D-016-3·3b·7·8·9.
|
|
6
|
+
* 책임(전체): req:doctor 통과 게이트 → HIGH 사람확인 게이트 → source 커밋(승인 코드만) →
|
|
7
|
+
* commit_allowed 소비 → evidence-finalize(approvals.jsonl 매니페스트 append + responses chore 커밋) → 2-커밋.
|
|
8
|
+
* 복구/finalize 모드(pending_evidence_for)·design-finalize 포함.
|
|
9
|
+
*
|
|
10
|
+
* **B1: 순수 기반/매니페스트 모델**. **B2(현재): 정상 flow** — doctor 게이트→HIGH→source 커밋→evidence-finalize→소비(2-커밋).
|
|
11
|
+
* 복구/finalize 모드(pending_evidence_for)·design-finalize는 **B3**. main()은 `--run` 없으면 dry-run(부작용 없음).
|
|
12
|
+
* ⚠️ B2 도구 자체 커밋은 부트스트랩 수기(req:commit dogfood는 Phase C부터).
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { resolve, join, relative } from 'node:path'
|
|
17
|
+
import { pathToFileURL } from 'node:url'
|
|
18
|
+
import {
|
|
19
|
+
loadState,
|
|
20
|
+
writeState,
|
|
21
|
+
readPhases,
|
|
22
|
+
type ApprovalEvidence,
|
|
23
|
+
type ReviewKind,
|
|
24
|
+
type WorkflowState,
|
|
25
|
+
} from './review-codex'
|
|
26
|
+
import { isArchiveFileName } from './lib/scratch'
|
|
27
|
+
import { createEvidencePorts } from './lib/evidence-ports' // 아카이브 파일명 판정의 정본은 scratch(leaf)
|
|
28
|
+
// REQ-2026-048 phase-1: 매니페스트 모델·검증과 그 보조 술어는 leaf `lib/evidence.ts`가 정본.
|
|
29
|
+
// 여기서 **재수출**해 기존 import 경로(`from './req-commit'`)를 쓰던 호출부·테스트를 그대로 둔다.
|
|
30
|
+
import {
|
|
31
|
+
archiveBaseName,
|
|
32
|
+
buildArchiveInventory,
|
|
33
|
+
designEvidenceStagePaths,
|
|
34
|
+
durableDesignEvidence,
|
|
35
|
+
isConfinedArchivePath,
|
|
36
|
+
isValidIsoInstant,
|
|
37
|
+
buildManifestEntry,
|
|
38
|
+
expectedArchivePaths,
|
|
39
|
+
manifestHasConsumed,
|
|
40
|
+
serializeManifestLine,
|
|
41
|
+
userConfirmProblem,
|
|
42
|
+
validateManifest,
|
|
43
|
+
type ManifestEntry,
|
|
44
|
+
type UserCommitConfirmed,
|
|
45
|
+
} from './lib/evidence'
|
|
46
|
+
export {
|
|
47
|
+
buildArchiveInventory,
|
|
48
|
+
buildManifestEntry,
|
|
49
|
+
designEvidenceStagePaths,
|
|
50
|
+
expectedArchivePaths,
|
|
51
|
+
manifestHasConsumed,
|
|
52
|
+
serializeManifestLine,
|
|
53
|
+
userConfirmProblem,
|
|
54
|
+
validateManifest,
|
|
55
|
+
type ManifestEntry,
|
|
56
|
+
type UserCommitConfirmed,
|
|
57
|
+
type ArchiveInventoryItem,
|
|
58
|
+
} from './lib/evidence'
|
|
59
|
+
import { loadConfig, packageRoot, buildScriptInvocation, DEFAULTS, type PackageManager, type ResolvedConfig } from './lib/config'
|
|
60
|
+
import { createGitAdapter, safeSpawnSync, type GitAdapter } from './lib/adapters'
|
|
61
|
+
|
|
62
|
+
// git=GitAdapter 경유(D-017-3), 패키지매니저=config. runDoctor(pnpm/npm 실행)는 cwd=gitRoot 필요(비-git 호출). main()이 loadConfig 후 config.root로 설정.
|
|
63
|
+
let gitRoot = packageRoot()
|
|
64
|
+
let pkgManager: PackageManager = DEFAULTS.packageManager
|
|
65
|
+
let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* repo-상대 경로 파일의 sha256(hex). `lib/evidence`는 fs를 모르는 순수 모듈이라 여기서 주입한다.
|
|
69
|
+
* 🔴 `gitRoot`는 `main()`이 `cfg.root`로 세팅한 뒤에만 유효하다 — designFinalize는 그 이후에만 호출된다.
|
|
70
|
+
*/
|
|
71
|
+
function repoRelSha256(repoRel: string): string {
|
|
72
|
+
return createHash('sha256').update(readFileSync(join(gitRoot, ...repoRel.split('/')))).digest('hex')
|
|
73
|
+
}
|
|
74
|
+
// evidencePreflight 구조 사전검증용 placeholder(실제 sourceSha/consumedAt는 source 커밋 후 채움). valid OID/ISO 형식.
|
|
75
|
+
const PREFLIGHT_PLACEHOLDER_OID = '0'.repeat(40)
|
|
76
|
+
const PREFLIGHT_PLACEHOLDER_ISO = '2000-01-01T00:00:00.000Z'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
// ───────────────────────────────── approvals.jsonl 매니페스트 모델 (B1) ──
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// ─────────────────────────────────────── B2: HIGH 게이트 / 소비 / preflight(순수) ──
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* HIGH 사람확인 게이트(D-016-8, 순수). HIGH인데 유효한 `user_commit_confirmed`(confirmed=true·method·ISO confirmed_at)가 없으면 차단.
|
|
91
|
+
*/
|
|
92
|
+
export function userConfirmGate(state: WorkflowState): { blocked: boolean; reason?: string } {
|
|
93
|
+
if (state.risk_level !== 'HIGH') return { blocked: false }
|
|
94
|
+
const problem = userConfirmProblem(state.user_commit_confirmed)
|
|
95
|
+
if (!problem) return { blocked: false }
|
|
96
|
+
return {
|
|
97
|
+
blocked: true,
|
|
98
|
+
reason: `HIGH risk: user_commit_confirmed ${problem} — req:commit 차단(감사 기록이며 위조불가 증명 아님; 가장 강한 보장=사용자가 직접 실행).`,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 승인 소비(D-016-9, 순수). **evidence 커밋 성공 후 마지막**에만 호출.
|
|
104
|
+
* commit_allowed=false · approved_diff_hash=null · consumed_approvals[] append · user_commit_confirmed 초기화 · approval_evidence 핀 제거.
|
|
105
|
+
*/
|
|
106
|
+
export function consumeState(state: WorkflowState, opts: { sourceCommitSha: string; consumedAt: string }): WorkflowState {
|
|
107
|
+
const rawPrev = (state as { consumed_approvals?: unknown }).consumed_approvals
|
|
108
|
+
const prev = Array.isArray(rawPrev) ? rawPrev : []
|
|
109
|
+
const entry = {
|
|
110
|
+
approved_tree: typeof state.approved_diff_hash === 'string' ? state.approved_diff_hash : null,
|
|
111
|
+
phase_id: typeof state.current_phase === 'string' ? state.current_phase : null,
|
|
112
|
+
consumed_by_commit_sha: opts.sourceCommitSha,
|
|
113
|
+
approval_consumed_at: opts.consumedAt,
|
|
114
|
+
}
|
|
115
|
+
// approval_evidence(현재 pending 승인 핀) + pending_evidence_for(복구 마커)는 소비와 함께 제거(다음 리뷰가 재부착).
|
|
116
|
+
const { approval_evidence: _consumed, pending_evidence_for: _pending, ...rest } = state
|
|
117
|
+
return {
|
|
118
|
+
...rest,
|
|
119
|
+
commit_allowed: false,
|
|
120
|
+
approved_diff_hash: null,
|
|
121
|
+
consumed_approvals: [...prev, entry],
|
|
122
|
+
user_commit_confirmed: null,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─────────────────────────────────────────────── B3: 복구/finalize(순수) ──
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 복구 마커 부착(순수, B3). **source 커밋 직후·evidence-finalize 전**에 기록 → 이후 중단 시 finalize로 복구.
|
|
130
|
+
* approval_evidence는 그대로(소비 전), pending_evidence_for.source_commit_sha로 "source 커밋됨, evidence 미완"을 표시.
|
|
131
|
+
*/
|
|
132
|
+
export function markPendingEvidence(state: WorkflowState, sourceCommitSha: string): WorkflowState {
|
|
133
|
+
return { ...state, pending_evidence_for: { source_commit_sha: sourceCommitSha } }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** state.pending_evidence_for.source_commit_sha 추출(순수). 없으면 null. */
|
|
137
|
+
export function pendingSourceSha(state: WorkflowState): string | null {
|
|
138
|
+
const pending = (state as { pending_evidence_for?: unknown }).pending_evidence_for
|
|
139
|
+
if (!pending || typeof pending !== 'object') return null
|
|
140
|
+
const sha = (pending as { source_commit_sha?: unknown }).source_commit_sha
|
|
141
|
+
return typeof sha === 'string' && sha ? sha : null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `req:commit --finalize` 적용 가능성 사전판정(순수, B3). source 미커밋 등 비-복구 상태에서 finalize 오용 차단.
|
|
146
|
+
* ⚠️ B3-P1: HEAD가 아니라 **pending_evidence_for.source_commit_sha의 source 커밋 tree**를 approved와 대조.
|
|
147
|
+
* (evidence 커밋 후엔 HEAD=evidence 커밋이라 HEAD^{tree}≠approved → consume-only 복구창을 막아버리던 결함 수정.)
|
|
148
|
+
* valid 조건: pending 마커 존재 · commit_allowed===true · approval_evidence 존재 · approved_diff_hash 문자열 · **sourceCommitTree == approved_diff_hash**.
|
|
149
|
+
*/
|
|
150
|
+
export function recoveryClassify(state: WorkflowState, sourceCommitTree: string | null): { valid: boolean; reason: string } {
|
|
151
|
+
if (!pendingSourceSha(state)) return { valid: false, reason: 'pending_evidence_for.source_commit_sha 없음 — 복구할 미완 작업 없음' }
|
|
152
|
+
return recoveryCoreValid(state, sourceCommitTree)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 복구 유효성 코어(순수, pending 마커 유무와 무관): commit_allowed·approval_evidence·approved_diff_hash·**sourceCommitTree == approved_diff_hash**.
|
|
157
|
+
* recoveryClassify(마커 필수)와 resolveRecoverySource(orphan 복구)가 공용으로 쓴다.
|
|
158
|
+
*/
|
|
159
|
+
export function recoveryCoreValid(state: WorkflowState, sourceCommitTree: string | null): { valid: boolean; reason: string } {
|
|
160
|
+
if (state.commit_allowed !== true) return { valid: false, reason: 'commit_allowed=true 아님 — 복구할 미완 승인 없음' }
|
|
161
|
+
if (!state.approval_evidence) return { valid: false, reason: 'approval_evidence 없음' }
|
|
162
|
+
const approved = typeof state.approved_diff_hash === 'string' ? state.approved_diff_hash : null
|
|
163
|
+
if (!approved) return { valid: false, reason: 'approved_diff_hash 없음' }
|
|
164
|
+
if (sourceCommitTree !== approved)
|
|
165
|
+
return { valid: false, reason: `source 커밋 tree(${String(sourceCommitTree)}) != approved(${approved}) — 잘못된 복구 대상` }
|
|
166
|
+
return { valid: true, reason: 'finalize 유효: source 커밋 tree == approved, evidence/consume 복구 가능' }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* finalize 복구 대상 source SHA 해소(순수, P2-a — marker 기록 전 crash 복구창).
|
|
171
|
+
* ① pending 마커 있으면 그 SHA(viaOrphan=false).
|
|
172
|
+
* ② 마커 없어도 HEAD가 승인 source(head.tree == approved_diff_hash + commit_allowed + approval_evidence)면 orphaned source로 HEAD 복구(viaOrphan=true).
|
|
173
|
+
* ⚠️ 승인 tree 대조라 **승인 우회 아님** — source 커밋 성공 후 markPendingEvidence 전에 죽은 상태만 복구한다.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveRecoverySource(
|
|
176
|
+
state: WorkflowState,
|
|
177
|
+
head: { sha: string; tree: string } | null,
|
|
178
|
+
): { sourceSha: string | null; viaOrphan: boolean; reason: string } {
|
|
179
|
+
const pending = pendingSourceSha(state)
|
|
180
|
+
if (pending) return { sourceSha: pending, viaOrphan: false, reason: 'pending 마커' }
|
|
181
|
+
if (!head) return { sourceSha: null, viaOrphan: false, reason: 'pending 마커 없음 + HEAD 미상 — 복구할 미완 작업 없음' }
|
|
182
|
+
const approved = typeof state.approved_diff_hash === 'string' ? state.approved_diff_hash : null
|
|
183
|
+
if (state.commit_allowed === true && !!state.approval_evidence && approved !== null && head.tree === approved)
|
|
184
|
+
return { sourceSha: head.sha, viaOrphan: true, reason: 'orphaned source(HEAD tree == approved) 복구' }
|
|
185
|
+
return { sourceSha: null, viaOrphan: false, reason: 'pending 마커 없음 + HEAD가 승인 source 아님 — 복구할 미완 작업 없음' }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
export interface PreflightInput {
|
|
190
|
+
existingManifest: string // 현재 approvals.jsonl 내용('' = 없음)
|
|
191
|
+
approvalEvidence: ApprovalEvidence | null
|
|
192
|
+
archiveNames: string[] // readdir(responses).filter(isArchiveFileName)
|
|
193
|
+
ticketRel: string
|
|
194
|
+
validPhaseIds: string[]
|
|
195
|
+
responsePathExists: boolean // existsSync(approval_evidence.response_path)
|
|
196
|
+
userCommitConfirmed: unknown // state.user_commit_confirmed (후보 entry 구성용)
|
|
197
|
+
placeholderCommitSha: string // 구조 사전검증용 valid OID(실제 sourceSha는 source 후)
|
|
198
|
+
placeholderConsumedAt: string // 구조 사전검증용 valid ISO
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* **source 커밋 전** evidence preflight(순수, B2-block1/2). 커밋 없이 잡을 수 있는 모든 evidence 실패를 먼저 수집.
|
|
203
|
+
* 빈 배열 = 통과. 하나라도 있으면 git commit을 절대 실행하지 않는다(= source 후 실패 창 최소화).
|
|
204
|
+
* 검사: (a) 기존 매니페스트 무결성 · (b) approval_evidence 존재/형식 · (c) expected 아카이브에 approved≥1 & 전부 confined ·
|
|
205
|
+
* (d) response_path가 expected에 포함 · (e) response_path 파일 실제 존재 ·
|
|
206
|
+
* (f) placeholder sourceSha로 후보 entry 빌드+전체 매니페스트 재검증(중복/구조 사전 차단).
|
|
207
|
+
*/
|
|
208
|
+
export function evidencePreflight(inp: PreflightInput): string[] {
|
|
209
|
+
const problems: string[] = []
|
|
210
|
+
const opts = { ticketRel: inp.ticketRel, validPhaseIds: inp.validPhaseIds }
|
|
211
|
+
// (a) 기존 approvals.jsonl 무결성
|
|
212
|
+
if (inp.existingManifest.trim()) {
|
|
213
|
+
const p = validateManifest(inp.existingManifest, opts)
|
|
214
|
+
if (p.length) problems.push(`기존 approvals.jsonl 무결성 실패: ${p.join('; ')}`)
|
|
215
|
+
}
|
|
216
|
+
// (b) approval_evidence 존재/형식
|
|
217
|
+
const ev = inp.approvalEvidence
|
|
218
|
+
if (!ev) {
|
|
219
|
+
problems.push('approval_evidence 없음')
|
|
220
|
+
return problems
|
|
221
|
+
}
|
|
222
|
+
if (ev.review_kind !== 'phase' && ev.review_kind !== 'design')
|
|
223
|
+
problems.push(`approval_evidence.review_kind 비유효: ${String(ev.review_kind)}`)
|
|
224
|
+
if (typeof ev.response_path !== 'string' || !ev.response_path) {
|
|
225
|
+
problems.push('approval_evidence.response_path 없음')
|
|
226
|
+
return problems
|
|
227
|
+
}
|
|
228
|
+
// (c) expected 아카이브(target 한정)
|
|
229
|
+
const expected = expectedArchivePaths(inp.archiveNames, ev.review_kind, ev.phase_id ?? null, inp.ticketRel)
|
|
230
|
+
if (!expected.some((p) => /-r\d{2,}-approved\.json$/.test(p)))
|
|
231
|
+
problems.push('expectedArchivePaths에 approved 아카이브 없음(needs-fix만 존재 가능)')
|
|
232
|
+
for (const p of expected) if (!isConfinedArchivePath(p, inp.ticketRel)) problems.push(`archive 경로 비confined: ${p}`)
|
|
233
|
+
// (d) response_path가 expected에 포함
|
|
234
|
+
if (!expected.includes(ev.response_path)) problems.push(`approval_evidence.response_path가 expectedArchivePaths에 없음: ${ev.response_path}`)
|
|
235
|
+
// (e) response_path 파일 실제 존재
|
|
236
|
+
if (!inp.responsePathExists) problems.push(`approval_evidence.response_path 파일 부재: ${ev.response_path}`)
|
|
237
|
+
// (f) placeholder sourceSha로 후보 entry 빌드 + 전체 매니페스트 재검증(source 후 실패 최소화)
|
|
238
|
+
try {
|
|
239
|
+
const candidate = buildManifestEntry(ev, {
|
|
240
|
+
consumedAt: inp.placeholderConsumedAt,
|
|
241
|
+
consumedByCommitSha: inp.placeholderCommitSha,
|
|
242
|
+
userCommitConfirmed: (inp.userCommitConfirmed as UserCommitConfirmed | null) ?? null,
|
|
243
|
+
})
|
|
244
|
+
const p = validateManifest(inp.existingManifest + serializeManifestLine(candidate), opts)
|
|
245
|
+
if (p.length) problems.push(`후보 manifest entry 검증 실패: ${p.join('; ')}`)
|
|
246
|
+
} catch (e) {
|
|
247
|
+
problems.push(`buildManifestEntry 실패: ${(e as Error).message}`)
|
|
248
|
+
}
|
|
249
|
+
return problems
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─────────────────────────────────────────── CLI (B2: 정상 req:commit flow) ──
|
|
253
|
+
|
|
254
|
+
export interface CommitArgs {
|
|
255
|
+
ticket: string | null
|
|
256
|
+
reqId: string | null
|
|
257
|
+
run: boolean
|
|
258
|
+
message: string | null
|
|
259
|
+
messageFile: string | null // REQ-018: --message-file <path>(→ git commit -F). multi-line 메시지를 argv 거치지 않고 전달
|
|
260
|
+
finalize: boolean // B3: source 재커밋 없이 evidence/consume만 복구
|
|
261
|
+
finalizeDesign: boolean // B3: design 승인을 approvals.jsonl에 기록(source/consume 없음)
|
|
262
|
+
root: string | null // config 탐색 루트(--root)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** CLI 파싱(fail-closed). `--ticket`·`--run`·`--message/-m`·`--message-file`·`--finalize`·`--finalize-design`·`--root <dir>`. 값 누락·알 수 없는 옵션은 throw(메시지 상호배타/필수는 resolveMessageSource). */
|
|
266
|
+
export function parseArgs(argv: string[]): CommitArgs {
|
|
267
|
+
let ticket: string | null = null
|
|
268
|
+
let reqId: string | null = null
|
|
269
|
+
let run = false
|
|
270
|
+
let message: string | null = null
|
|
271
|
+
let messageFile: string | null = null
|
|
272
|
+
let finalize = false
|
|
273
|
+
let finalizeDesign = false
|
|
274
|
+
let root: string | null = null
|
|
275
|
+
for (let i = 0; i < argv.length; i++) {
|
|
276
|
+
const a = argv[i]
|
|
277
|
+
if (a === undefined) continue
|
|
278
|
+
// bare `--`는 POSIX end-of-options 마커(DEC-011-3). ⚠️ 이후 인자도 계속 옵션으로 파싱해야 한다 —
|
|
279
|
+
// 전부 위치인자로 삼키면 `req:commit <id> -- --run`이 조용히 dry-run으로 끝난다(가장 나쁜 실패).
|
|
280
|
+
if (a === '--') continue
|
|
281
|
+
else if (a === '--ticket') ticket = argv[++i] ?? null
|
|
282
|
+
else if (a === '--run') run = true
|
|
283
|
+
else if (a === '--message' || a === '-m') message = argv[++i] ?? null
|
|
284
|
+
else if (a === '--message-file') {
|
|
285
|
+
const v = argv[++i]
|
|
286
|
+
if (v === undefined) throw new Error('--message-file 값 필요')
|
|
287
|
+
messageFile = v
|
|
288
|
+
} else if (a === '--finalize') finalize = true
|
|
289
|
+
else if (a === '--finalize-design') finalizeDesign = true
|
|
290
|
+
else if (a === '--root') {
|
|
291
|
+
const v = argv[++i]
|
|
292
|
+
if (v === undefined) throw new Error('--root 값 필요')
|
|
293
|
+
root = v
|
|
294
|
+
} else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
|
|
295
|
+
else reqId = a
|
|
296
|
+
}
|
|
297
|
+
if (finalize && finalizeDesign) throw new Error('--finalize 와 --finalize-design 동시 사용 불가')
|
|
298
|
+
if (!ticket && !reqId) throw new Error('REQ id 또는 --ticket <dir> 필요')
|
|
299
|
+
return { ticket, reqId, run, message, messageFile, finalize, finalizeDesign, root }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 커밋 메시지 출처 해소(순수, REQ-018 D-018-3). **정상 source-커밋 flow 직전에만** 호출.
|
|
304
|
+
* env fallback(CLI 둘 다 없을 때만 REQ_COMMIT_MESSAGE_FILE) → 상호배타·필수 → **절대경로 정규화** → 존재검증.
|
|
305
|
+
* ⚠️ messageFile은 `resolve()`로 절대경로화: existsFn(=existsSync)은 process.cwd 기준, git `-F`는 cwd=gitRoot라
|
|
306
|
+
* 상대경로면 검증 위치와 git 읽기 위치가 어긋난다(CLI/env 동일 처리). existsFn 주입(테스트=fake).
|
|
307
|
+
*/
|
|
308
|
+
export function resolveMessageSource(
|
|
309
|
+
opts: { message: string | null; messageFile: string | null },
|
|
310
|
+
env: string | undefined,
|
|
311
|
+
existsFn: (p: string) => boolean,
|
|
312
|
+
): { message: string | null; messageFile: string | null } {
|
|
313
|
+
const { message } = opts
|
|
314
|
+
let messageFile = opts.messageFile
|
|
315
|
+
if (message === null && messageFile === null && env !== undefined && env !== '') messageFile = env // env fallback(CLI 우선)
|
|
316
|
+
if (message !== null && messageFile !== null)
|
|
317
|
+
throw new Error('-m/--message 와 --message-file/REQ_COMMIT_MESSAGE_FILE 동시 지정 불가')
|
|
318
|
+
if (message === null && messageFile === null)
|
|
319
|
+
throw new Error('커밋 메시지 필요 — -m <msg> 또는 --message-file <path>(또는 REQ_COMMIT_MESSAGE_FILE)')
|
|
320
|
+
if (messageFile !== null) {
|
|
321
|
+
const abs = resolve(messageFile) // 절대경로 정규화(existsFn↔git cwd 위치 일관)
|
|
322
|
+
if (!existsFn(abs)) throw new Error(`--message-file 경로 없음: ${abs}`)
|
|
323
|
+
messageFile = abs
|
|
324
|
+
}
|
|
325
|
+
return { message, messageFile }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* source 커밋 git args 빌더(순수, REQ-018 D-018-3). messageFile→`-F`(메시지 내용이 argv에 없음), message→`-m`.
|
|
330
|
+
* 둘 다/둘 다 아님 → throw(방어 — 정상 경로는 resolveMessageSource가 보장).
|
|
331
|
+
*/
|
|
332
|
+
export function buildCommitArgs(opts: { message: string | null; messageFile: string | null }): string[] {
|
|
333
|
+
if (opts.message !== null && opts.messageFile !== null)
|
|
334
|
+
throw new Error('buildCommitArgs: message·messageFile 동시 불가(방어)')
|
|
335
|
+
if (opts.messageFile !== null) return ['commit', '-F', opts.messageFile]
|
|
336
|
+
if (opts.message !== null) return ['commit', '-m', opts.message]
|
|
337
|
+
throw new Error('buildCommitArgs: message 또는 messageFile 필요')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 티켓 디렉터리 + req:doctor 인자 해소(순수, config.workflowDirAbs 기준).
|
|
342
|
+
* doctorArgs에 **--root cfg.root 전파** — 자식 req:doctor가 부모와 동일 root를 쓰도록(일관).
|
|
343
|
+
*/
|
|
344
|
+
export function resolveCommitTarget(opts: CommitArgs, cfg: ResolvedConfig): { ticketDir: string; doctorArgs: string[] } {
|
|
345
|
+
const rootArgs = ['--root', cfg.root]
|
|
346
|
+
if (opts.ticket) {
|
|
347
|
+
const ticketDir = resolve(opts.ticket)
|
|
348
|
+
return { ticketDir, doctorArgs: ['--ticket', ticketDir, ...rootArgs] }
|
|
349
|
+
}
|
|
350
|
+
const norm = (opts.reqId as string).replace(/^REQ-/, '')
|
|
351
|
+
return { ticketDir: join(cfg.workflowDirAbs, `REQ-${norm}`), doctorArgs: [norm, ...rootArgs] }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** git 실행(GitAdapter 경유, config.root 기준). 실패 시 throw(fail-closed). */
|
|
355
|
+
function git(args: string[]): string {
|
|
356
|
+
return gitAdapter.exec(args)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** req:doctor 게이트 — 별도 프로세스로 실행, exit≠0이면 throw(통과 못 하면 커밋 진입 불가). 패키지매니저별 argv는 buildScriptInvocation(npm은 `run --`). */
|
|
360
|
+
function runDoctor(doctorArgs: string[]): void {
|
|
361
|
+
const [cmd, ...rest] = buildScriptInvocation(pkgManager, 'req:doctor', doctorArgs)
|
|
362
|
+
if (!cmd) throw new Error('buildScriptInvocation: 빈 호출(패키지매니저 설정 오류)')
|
|
363
|
+
// shell 없이 안전 실행(P1): pkg manager는 Windows에서 .cmd라 과거 shell:true였고 doctorArgs(reqId·root 경로)의 메타문자로 주입 가능했음.
|
|
364
|
+
safeSpawnSync(cmd, rest, { cwd: gitRoot, stdio: 'inherit' })
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** `git diff --cached --name-only`를 정규화 경로 배열로. */
|
|
368
|
+
function stagedNames(): string[] {
|
|
369
|
+
return git(['diff', '--cached', '--name-only'])
|
|
370
|
+
.split('\n')
|
|
371
|
+
.map((p) => p.trim().replace(/\\/g, '/'))
|
|
372
|
+
.filter(Boolean)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
interface FinalizeCtx {
|
|
376
|
+
ticketDir: string
|
|
377
|
+
ticketRel: string
|
|
378
|
+
responsesDir: string
|
|
379
|
+
manifestPath: string
|
|
380
|
+
state: WorkflowState
|
|
381
|
+
ev: ApprovalEvidence
|
|
382
|
+
existing: string
|
|
383
|
+
archiveNames: string[]
|
|
384
|
+
validPhaseIds: string[]
|
|
385
|
+
sourceSha: string
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* evidence-finalize(**멱등**) + 소비 — 정상 flow와 `--finalize` 복구가 공유.
|
|
390
|
+
* 이미 sourceSha가 매니페스트에 있으면(=evidence 커밋은 됐고 consume만 못 함) append/chore를 skip하고 소비만 수행.
|
|
391
|
+
* 소비는 항상 마지막. state.json은 scratch 유지(커밋 안 함).
|
|
392
|
+
*/
|
|
393
|
+
function finalizeEvidenceAndConsume(ctx: FinalizeCtx): void {
|
|
394
|
+
// B3-R2: skip(소비-only) 경로 포함 — 변조된 매니페스트 위에서 consume 금지. 기존 무결성 먼저(복구 모드엔 preflight가 없으므로 필수).
|
|
395
|
+
if (ctx.existing.trim()) {
|
|
396
|
+
const ep = validateManifest(ctx.existing, { ticketRel: ctx.ticketRel, validPhaseIds: ctx.validPhaseIds })
|
|
397
|
+
if (ep.length) throw new Error(`기존 approvals.jsonl 무결성 실패(fail-closed): ${ep.join('; ')}`)
|
|
398
|
+
}
|
|
399
|
+
const already = manifestHasConsumed(ctx.existing, ctx.sourceSha, {
|
|
400
|
+
reviewKind: ctx.ev.review_kind,
|
|
401
|
+
phaseId: ctx.ev.phase_id ?? null,
|
|
402
|
+
responseSha256: ctx.ev.response_sha256,
|
|
403
|
+
})
|
|
404
|
+
if (!already) {
|
|
405
|
+
const entry = buildManifestEntry(ctx.ev, {
|
|
406
|
+
consumedAt: new Date().toISOString(),
|
|
407
|
+
consumedByCommitSha: ctx.sourceSha,
|
|
408
|
+
userCommitConfirmed: (ctx.state.user_commit_confirmed as UserCommitConfirmed | null) ?? null,
|
|
409
|
+
})
|
|
410
|
+
const newContent = ctx.existing + serializeManifestLine(entry)
|
|
411
|
+
const reproblems = validateManifest(newContent, { ticketRel: ctx.ticketRel, validPhaseIds: ctx.validPhaseIds })
|
|
412
|
+
if (reproblems.length)
|
|
413
|
+
throw new Error(`(예상외) 매니페스트 검증 실패: ${reproblems.join('; ')} — source=${ctx.sourceSha} 커밋됨, --finalize로 복구`)
|
|
414
|
+
mkdirSync(ctx.responsesDir, { recursive: true })
|
|
415
|
+
writeFileSync(ctx.manifestPath, newContent, 'utf8')
|
|
416
|
+
const archivePaths = expectedArchivePaths(ctx.archiveNames, ctx.ev.review_kind, ctx.ev.phase_id ?? null, ctx.ticketRel)
|
|
417
|
+
git(['add', ...archivePaths, `${ctx.ticketRel}/responses/approvals.jsonl`])
|
|
418
|
+
const choreLeak = stagedNames().filter((p) => !p.startsWith(`${ctx.ticketRel}/responses/`))
|
|
419
|
+
if (choreLeak.length) throw new Error(`evidence 커밋에 responses 외 staged 금지(코드/state 누수): ${choreLeak.join(', ')}`)
|
|
420
|
+
git(['commit', '-m', `chore(${ctx.state.id}): evidence-finalize — ${ctx.ev.review_kind} ${ctx.ev.phase_id ?? ''} 아카이브·approvals.jsonl`])
|
|
421
|
+
} else {
|
|
422
|
+
console.log('[req:commit] evidence 이미 finalize됨(멱등 skip) — 소비만 수행')
|
|
423
|
+
}
|
|
424
|
+
// 소비(마지막) — commit_allowed=false·approved_diff_hash=null·pending 마커 제거.
|
|
425
|
+
writeState(ctx.ticketDir, consumeState(ctx.state, { sourceCommitSha: ctx.sourceSha, consumedAt: new Date().toISOString() }))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* design-finalize(B3) — design 승인을 approvals.jsonl에 audit 기록(source 커밋·commit_allowed 소비 없음).
|
|
430
|
+
* 멱등: 동일 design 엔트리(kind/sha 중복)면 skip. doctor는 정상 실행(우회 아님).
|
|
431
|
+
*/
|
|
432
|
+
function designFinalize(args: {
|
|
433
|
+
ticketDir: string
|
|
434
|
+
ticketRel: string
|
|
435
|
+
responsesDir: string
|
|
436
|
+
manifestPath: string
|
|
437
|
+
doctorArgs: string[]
|
|
438
|
+
state: WorkflowState
|
|
439
|
+
validPhaseIds: string[]
|
|
440
|
+
}): void {
|
|
441
|
+
const dev = (args.state.design_approval_evidence as ApprovalEvidence | undefined) ?? null
|
|
442
|
+
if (!dev) throw new Error('design_approval_evidence 없음 — design 승인 후 실행')
|
|
443
|
+
if (dev.review_kind !== 'design') throw new Error(`design_approval_evidence.review_kind != design: ${String(dev.review_kind)}`)
|
|
444
|
+
runDoctor(args.doctorArgs) // design-finalize도 doctor 우회 금지(정상)
|
|
445
|
+
// REQ-2026-048 phase-3: 실제 내구화는 **공유 구현**에 위임한다. 정상 승인 경로(review-codex)와
|
|
446
|
+
// 이 복구 경로가 같은 함수를 부르므로 동작이 갈라질 수 없다(DEC-1·DEC-3).
|
|
447
|
+
const r = durableDesignEvidence({
|
|
448
|
+
ticketId: String(args.state.id ?? ''),
|
|
449
|
+
ticketRel: args.ticketRel,
|
|
450
|
+
evidence: dev,
|
|
451
|
+
validPhaseIds: args.validPhaseIds,
|
|
452
|
+
nowIso: new Date().toISOString(),
|
|
453
|
+
ports: createEvidencePorts(gitRoot, `${args.ticketRel}/responses`),
|
|
454
|
+
})
|
|
455
|
+
if (r.outcome === 'already-durable') {
|
|
456
|
+
console.log('[req:commit] design 승인 이미 내구화됨(HEAD 기준 멱등 skip)')
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
console.log(
|
|
460
|
+
r.outcome === 'recommitted'
|
|
461
|
+
? '[req:commit] ✅ design-finalize 복구 완료 — 매니페스트는 이미 있었고 커밋만 누락돼 재커밋했습니다'
|
|
462
|
+
: '[req:commit] ✅ design-finalize 완료 — approvals.jsonl 기록',
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function main(argv: string[] = process.argv.slice(2)): void {
|
|
467
|
+
const opts = parseArgs(argv)
|
|
468
|
+
const cfg = loadConfig({ root: opts.root })
|
|
469
|
+
gitRoot = cfg.root // runDoctor(pnpm/npm) cwd
|
|
470
|
+
pkgManager = cfg.packageManager
|
|
471
|
+
gitAdapter = createGitAdapter(cfg.root)
|
|
472
|
+
const { ticketDir, doctorArgs } = resolveCommitTarget(opts, cfg)
|
|
473
|
+
const { run, message, messageFile, finalize, finalizeDesign } = opts
|
|
474
|
+
const state = loadState(ticketDir)
|
|
475
|
+
const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
|
|
476
|
+
const responsesDir = join(ticketDir, 'responses')
|
|
477
|
+
const manifestPath = join(responsesDir, 'approvals.jsonl')
|
|
478
|
+
const ev = (state.approval_evidence as ApprovalEvidence | undefined) ?? null
|
|
479
|
+
const validPhaseIds = readPhases(state).map((p) => p.id)
|
|
480
|
+
|
|
481
|
+
// ── DRY-RUN(부작용 없음): 게이트/계획 미리보기 ──
|
|
482
|
+
if (!run) {
|
|
483
|
+
const gate = userConfirmGate(state)
|
|
484
|
+
const mode = finalizeDesign ? 'finalize-design' : finalize ? 'finalize(복구)' : '정상'
|
|
485
|
+
console.log(`[req:commit] DRY-RUN (모드=${mode}; 실제 실행은 --run)`)
|
|
486
|
+
console.log(` ticket=${ticketRel} commit_allowed=${String(state.commit_allowed)} risk=${String(state.risk_level)}`)
|
|
487
|
+
console.log(` HIGH 게이트: ${gate.blocked ? `차단 — ${gate.reason}` : 'OK(또는 비-HIGH)'}`)
|
|
488
|
+
if (finalize) {
|
|
489
|
+
// P2-a: pending 마커 없어도 HEAD가 승인 source면 orphaned 복구 가능 → dry-run에도 반영.
|
|
490
|
+
const head = (() => {
|
|
491
|
+
try {
|
|
492
|
+
return { sha: git(['rev-parse', 'HEAD']), tree: git(['rev-parse', 'HEAD^{tree}']) }
|
|
493
|
+
} catch {
|
|
494
|
+
return null
|
|
495
|
+
}
|
|
496
|
+
})()
|
|
497
|
+
const rec = resolveRecoverySource(state, head)
|
|
498
|
+
let core = { valid: false, reason: rec.reason }
|
|
499
|
+
if (rec.sourceSha) {
|
|
500
|
+
let sourceTree: string | null = null
|
|
501
|
+
try {
|
|
502
|
+
sourceTree = git(['rev-parse', `${rec.sourceSha}^{tree}`])
|
|
503
|
+
} catch {
|
|
504
|
+
sourceTree = null
|
|
505
|
+
}
|
|
506
|
+
core = recoveryCoreValid(state, sourceTree)
|
|
507
|
+
}
|
|
508
|
+
console.log(
|
|
509
|
+
` finalize 적용 가능성: ${core.valid ? `valid${rec.viaOrphan ? '(orphaned 복구)' : ''}` : `invalid — ${core.reason}`}`,
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
if (ev) {
|
|
513
|
+
const archiveNames = existsSync(responsesDir) ? readdirSync(responsesDir).filter(isArchiveFileName) : []
|
|
514
|
+
const expected = expectedArchivePaths(archiveNames, ev.review_kind, ev.phase_id ?? null, ticketRel)
|
|
515
|
+
console.log(` approval_evidence: ${ev.review_kind} ${ev.phase_id ?? ''} → evidence-finalize 아카이브 ${expected.length}건`)
|
|
516
|
+
} else {
|
|
517
|
+
console.log(' approval_evidence 없음(review-codex 승인 후 실행)')
|
|
518
|
+
}
|
|
519
|
+
if (existsSync(manifestPath)) {
|
|
520
|
+
const problems = validateManifest(readFileSync(manifestPath, 'utf8'), { ticketRel, validPhaseIds })
|
|
521
|
+
console.log(` approvals.jsonl: ${problems.length ? `문제 ${problems.length} — ${problems.join('; ')}` : 'OK'}`)
|
|
522
|
+
}
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── B3: design-finalize(source/consume 없음) ──
|
|
527
|
+
if (finalizeDesign) {
|
|
528
|
+
designFinalize({ ticketDir, ticketRel, responsesDir, manifestPath, doctorArgs, state, validPhaseIds })
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const existing = existsSync(manifestPath) ? readFileSync(manifestPath, 'utf8') : ''
|
|
533
|
+
const archiveNames = existsSync(responsesDir) ? readdirSync(responsesDir).filter(isArchiveFileName) : []
|
|
534
|
+
|
|
535
|
+
// ── B3: finalize(복구) — source 재커밋 없이 evidence/consume만 복구 ──
|
|
536
|
+
if (finalize) {
|
|
537
|
+
// P2-a: pending 마커가 없을 수 있다(source 커밋 성공 후 markPendingEvidence 전에 crash). HEAD가 승인 source면 마커를 재구성해 복구.
|
|
538
|
+
let fstate = state
|
|
539
|
+
if (!pendingSourceSha(fstate)) {
|
|
540
|
+
const head = (() => {
|
|
541
|
+
try {
|
|
542
|
+
return { sha: git(['rev-parse', 'HEAD']), tree: git(['rev-parse', 'HEAD^{tree}']) }
|
|
543
|
+
} catch {
|
|
544
|
+
return null
|
|
545
|
+
}
|
|
546
|
+
})()
|
|
547
|
+
const rec = resolveRecoverySource(fstate, head)
|
|
548
|
+
if (!rec.sourceSha) throw new Error(`finalize 거부: ${rec.reason}`)
|
|
549
|
+
fstate = markPendingEvidence(fstate, rec.sourceSha) // crash가 막은 마커 재구성(승인 tree 대조로 안전 — 우회 아님)
|
|
550
|
+
writeState(ticketDir, fstate)
|
|
551
|
+
console.warn(`[req:commit] pending 마커 없음 — HEAD(${rec.sourceSha.slice(0, 8)})가 승인 source(tree==approved)라 orphaned 복구용 마커 재구성`)
|
|
552
|
+
}
|
|
553
|
+
const sourceSha = pendingSourceSha(fstate) as string
|
|
554
|
+
const sourceTree = git(['rev-parse', `${sourceSha}^{tree}`])
|
|
555
|
+
const rc = recoveryClassify(fstate, sourceTree)
|
|
556
|
+
if (!rc.valid) throw new Error(`finalize 거부: ${rc.reason}`)
|
|
557
|
+
if (!ev) throw new Error('approval_evidence 없음') // rc.valid가 보장하나 TS narrowing
|
|
558
|
+
// doctor --finalize: D9를 source 커밋 tree로 교체(우회 아님), 나머지 검사 정상.
|
|
559
|
+
runDoctor([...doctorArgs, '--finalize'])
|
|
560
|
+
const gate = userConfirmGate(fstate)
|
|
561
|
+
if (gate.blocked) throw new Error(gate.reason)
|
|
562
|
+
finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state: fstate, ev, existing, archiveNames, validPhaseIds, sourceSha })
|
|
563
|
+
console.log(`[req:commit] ✅ finalize 복구 완료 — source=${sourceSha.slice(0, 8)} · evidence/consume 복구`)
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── LIVE (B2 정상 flow) — ⚠️ B2 도구 자체 커밋엔 쓰지 않음(부트스트랩). Phase C부터 dogfood. ──
|
|
568
|
+
const responsePathExists = !!ev && typeof ev.response_path === 'string' && existsSync(resolve(cfg.root, ev.response_path))
|
|
569
|
+
|
|
570
|
+
// 1) doctor 게이트(fail-closed)
|
|
571
|
+
runDoctor(doctorArgs)
|
|
572
|
+
// 2) HIGH 사람확인 게이트
|
|
573
|
+
const gate = userConfirmGate(state)
|
|
574
|
+
if (gate.blocked) throw new Error(gate.reason)
|
|
575
|
+
// 3) 전제: 승인 존재 + staged tree == approved_diff_hash + staged=코드만(state/responses 금지)
|
|
576
|
+
if (state.commit_allowed !== true) throw new Error('commit_allowed=true 아님 — 승인된 phase 없음(req:review-codex 승인 필요)')
|
|
577
|
+
if (!ev) throw new Error('approval_evidence 없음 — 승인 증거 미기록')
|
|
578
|
+
if (typeof state.approved_diff_hash !== 'string') throw new Error('approved_diff_hash 없음')
|
|
579
|
+
const stagedTree = git(['write-tree'])
|
|
580
|
+
if (stagedTree !== state.approved_diff_hash)
|
|
581
|
+
throw new Error(`staged tree(${stagedTree}) != approved_diff_hash(${state.approved_diff_hash}) — stale 승인, 재리뷰 필요`)
|
|
582
|
+
const srcStaged = stagedNames()
|
|
583
|
+
if (srcStaged.length === 0) throw new Error('staged 변경 없음 — 승인 코드를 stage 후 실행')
|
|
584
|
+
const nonCode = srcStaged.filter((p) => p === `${ticketRel}/state.json` || p.startsWith(`${ticketRel}/responses/`))
|
|
585
|
+
if (nonCode.length) throw new Error(`source 커밋에 비-코드 staged 금지(state/responses): ${nonCode.join(', ')}`)
|
|
586
|
+
// REQ-018: 메시지 출처 해소(-m 또는 --message-file/env) — 정상 source-커밋 flow에서만. fail-closed(상호배타·필수·존재검증).
|
|
587
|
+
const msgSource = resolveMessageSource({ message, messageFile }, process.env.REQ_COMMIT_MESSAGE_FILE, existsSync)
|
|
588
|
+
// 3b) evidence preflight(B2-block1/2) — source 커밋 전 잡을 수 있는 evidence 실패 전부 차단. 실패 시 git commit 안 함.
|
|
589
|
+
const pre = evidencePreflight({
|
|
590
|
+
existingManifest: existing,
|
|
591
|
+
approvalEvidence: ev,
|
|
592
|
+
archiveNames,
|
|
593
|
+
ticketRel,
|
|
594
|
+
validPhaseIds,
|
|
595
|
+
responsePathExists,
|
|
596
|
+
userCommitConfirmed: (state.user_commit_confirmed as unknown) ?? null,
|
|
597
|
+
placeholderCommitSha: PREFLIGHT_PLACEHOLDER_OID,
|
|
598
|
+
placeholderConsumedAt: PREFLIGHT_PLACEHOLDER_ISO,
|
|
599
|
+
})
|
|
600
|
+
if (pre.length) throw new Error(`evidence preflight 실패(source 커밋 안 함): ${pre.join('; ')}`)
|
|
601
|
+
// 4) source 커밋(승인 코드만) — 여기서부터 부작용. preflight 통과로 source 후 실패 창 최소화.
|
|
602
|
+
// REQ-018: -m(메시지) 또는 -F(파일). messageFile 경로는 pnpm/Windows argv newline 이스케이프를 회피.
|
|
603
|
+
git(buildCommitArgs(msgSource))
|
|
604
|
+
const sourceSha = git(['rev-parse', 'HEAD'])
|
|
605
|
+
// 4b) B3 복구 마커 — source 커밋됨, evidence 미완. 이후 중단 시 `req:commit <id> --finalize --run`으로 복구.
|
|
606
|
+
writeState(ticketDir, markPendingEvidence(state, sourceSha))
|
|
607
|
+
// 5) evidence-finalize(멱등) + 소비(마지막).
|
|
608
|
+
finalizeEvidenceAndConsume({ ticketDir, ticketRel, responsesDir, manifestPath, state, ev, existing, archiveNames, validPhaseIds, sourceSha })
|
|
609
|
+
console.log(`[req:commit] ✅ 완료 — source=${sourceSha.slice(0, 8)} · evidence-finalize · commit_allowed 소비됨`)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
|
|
613
|
+
export function runCli(argv: string[]): void {
|
|
614
|
+
try {
|
|
615
|
+
main(argv)
|
|
616
|
+
} catch (err) {
|
|
617
|
+
console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
|
|
618
|
+
process.exitCode = 1
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
|
|
623
|
+
if (isMain) main()
|