commitgate 0.9.8 โ†’ 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -91,6 +91,16 @@ export interface ManifestEntry {
91
91
  review_base_sha: string
92
92
  approved_tree?: string
93
93
  design_hash?: string
94
+ /**
95
+ * ๐Ÿ”ด REQ-2026-052 DEC-B5(phase-3a2): **phase ์ „์šฉ** โ€” ์ด phase๊ฐ€ ์Šน์ธ ์‹œ์ ์— ๊ฒฐ์†๋œ committed design ์ฐธ์กฐ
96
+ * (= `captureDesignBinding().designHash`, design ํ–‰ `design_hash`์™€ ๋™์ผ ๊ณ„์‚ฐยท๊ฐ’). dev-complete ์™„์ „์„ฑ์ด
97
+ * ์ด ๊ฐ’ == ํ˜„์žฌ committed design_ref์ธ phase ํ–‰๋งŒ ์‚ฐ์ž…ํ•œ๋‹ค(design-blind๋ฉด D1 ๊ฒ€ํ† ๋ถ„์ด D2 ์™„๋ฃŒ์— ์ƒŒ๋‹ค).
98
+ *
99
+ * **์„ ํƒ ํ•„๋“œ**๋‹ค: ๋ถ€์žฌ(๋ ˆ๊ฑฐ์‹œยท์ด ๋ณด์ • ์ด์ „ ์ปค๋ฐ‹๋ถ„)ํ•ด๋„ ๋งค๋‹ˆํŽ˜์ŠคํŠธ ๊ฒ€์ฆ์€ ํ†ต๊ณผํ•œ๋‹ค(๋ฌดํšŒ๊ท€). ๋‹จ durable ์™„๋ฃŒ
100
+ * ํŒ์ •์—์„œ๋Š” ๋ถ€์žฌ๋ฅผ **๋ถˆ์‚ฐ์ž…(fail-closed)** โ€” ๊ฒ€์ฆ์˜ ๊ด€๋Œ€ํ•จ๊ณผ ์™„๋ฃŒ์˜ ์—„๊ฒฉํ•จ์„ ๋ถ„๋ฆฌ(`archive_inventory`์™€ ๋™ํ˜•).
101
+ * design ํ–‰์—๋Š” **๊ธˆ์ง€**(kind ๊ฒฉ๋ฆฌ). ๋ฏธ์ง€์ • ์‹œ ํ‚ค ์ž์ฒด๋ฅผ ๋„ฃ์ง€ ์•Š์•„ ๊ธฐ์กด phase ํ–‰๊ณผ ๋ฐ”์ดํŠธ ๋™์ผ.
102
+ */
103
+ phase_design_ref?: string
94
104
  approved_at: string
95
105
  consumed_at: string
96
106
  consumed_by_commit_sha: string
@@ -118,6 +128,7 @@ const MANIFEST_KEYS = new Set([
118
128
  'review_base_sha',
119
129
  'approved_tree',
120
130
  'design_hash',
131
+ 'phase_design_ref', // REQ-2026-052 DEC-B5(์„ ํƒ โ€” phase ์ „์šฉยท๋ถ€์žฌํ•ด๋„ ์œ ํšจ)
121
132
  'approved_at',
122
133
  'consumed_at',
123
134
  'consumed_by_commit_sha',
@@ -165,12 +176,20 @@ export function designEvidenceStagePaths(
165
176
  inventory: readonly ArchiveInventoryItem[],
166
177
  responsePath: string,
167
178
  ticketRel: string,
179
+ /**
180
+ * ๋ฆฌ๋ทฐ ์›์žฅ์ด ๋””์Šคํฌ์— **์กด์žฌํ•˜๋Š”๊ฐ€**(REQ-2026-051 D7). ์กด์žฌํ•  ๋•Œ๋งŒ pathspec์— ํ•ฉ๋ฅ˜์‹œํ‚จ๋‹ค โ€”
181
+ * ์—†๋Š” ๊ฒฝ๋กœ๋ฅผ ๋„ฃ์œผ๋ฉด `commitPaths`๊ฐ€ ์‹คํŒจํ•ด ์Šน์ธ ์ฆ๊ฑฐ ์ปค๋ฐ‹ **์ „์ฒด**๊ฐ€ ๋ฌด์‚ฐ๋œ๋‹ค(์›์žฅ ๋•Œ๋ฌธ์— ์Šน์ธ
182
+ * ์ฆ๊ฑฐ๋ฅผ ์žƒ๋Š” ๊ฒƒ์€ ๋ณธ๋ง์ „๋„๋‹ค).
183
+ * ์ƒ๋žต ๊ฐ€๋Šฅ: ๊ธฐ์กด 3-arg ํ˜ธ์ถœ๋ถ€๋ฅผ ๊นจ์ง€ ์•Š๋Š”๋‹ค.
184
+ */
185
+ ledgerExists = false,
168
186
  ): string[] {
169
187
  const dir = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses`
170
188
  const archives = [...inventory.map((i) => i.response_path), responsePath].filter(
171
189
  (p) => typeof p === 'string' && p.length > 0 && isConfinedArchivePath(p, ticketRel),
172
190
  )
173
- return [...new Set([...archives, `${dir}/approvals.jsonl`])]
191
+ const tail = ledgerExists ? [`${dir}/approvals.jsonl`, `${dir}/review-ledger.jsonl`] : [`${dir}/approvals.jsonl`]
192
+ return [...new Set([...archives, ...tail])]
174
193
  }
175
194
 
176
195
  /** ์ธ๋ฒคํ† ๋ฆฌ ํ•ญ๋ชฉ์˜ ํ˜•์‹ ๋ฌธ์ œ ๋ชฉ๋ก(์ˆœ์ˆ˜). ๋นˆ ๋ฐฐ์—ด = ์ •์ƒ. `line N: ` ์ ‘๋‘๋Š” ํ˜ธ์ถœ๋ถ€๊ฐ€ ๋ถ™์ธ๋‹ค. */
@@ -248,7 +267,11 @@ export function buildManifestEntry(
248
267
  const approvedTree = ev.approved_tree
249
268
  if (typeof approvedTree !== 'string' || !approvedTree)
250
269
  throw new Error('buildManifestEntry: phase evidence์— approved_tree ๋ˆ„๋ฝ(fail-fast)')
251
- return { ...base, approved_tree: approvedTree, ...inv }
270
+ // REQ-2026-052 DEC-B5: phase_design_ref(์Šน์ธ ์‹œ์  design ๊ฒฐ์†)๊ฐ€ ์žˆ์œผ๋ฉด phase ํ–‰์— ํฌํ•จ. ๋ฏธ์ง€์ •์ด๋ฉด
271
+ // ํ‚ค ์ž์ฒด๋ฅผ ๋„ฃ์ง€ ์•Š๋Š”๋‹ค โ€” ๋ ˆ๊ฑฐ์‹œ phase ํ–‰๊ณผ ๋ฐ”์ดํŠธ ๋™์ผ(๋ฌดํšŒ๊ท€). durable ํ‹ฐ์ผ“์€ designValid ๊ฒŒ์ดํŠธ๊ฐ€
272
+ // ์Šน์ธ ์ „์ œ๋ผ ์ •์ƒ ๊ฒฝ๋กœ์—์„œ ํ•ญ์ƒ ์ฑ„์›Œ์ง€๊ณ , ์™„๋ฃŒ ํŒ์ •์ด ์ด ๊ฐ’์œผ๋กœ design-bound ํ•„ํ„ฐํ•œ๋‹ค.
273
+ const pdr = typeof ev.phase_design_ref === 'string' && ev.phase_design_ref ? { phase_design_ref: ev.phase_design_ref } : {}
274
+ return { ...base, approved_tree: approvedTree, ...pdr, ...inv }
252
275
  }
253
276
 
254
277
  /** ๋งค๋‹ˆํŽ˜์ŠคํŠธ ํ•œ ์ค„ ์ง๋ ฌํ™”(JSONL): JSON + ๋ ๊ฐœํ–‰. ๊ณ ์ • ํ‚ค ์ˆœ์„œ๋ผ deterministic. */
@@ -305,10 +328,15 @@ export function validateManifest(content: string, opts: { ticketRel: string; val
305
328
  problems.push(`line ${ln}: phase_id ๋น„์œ ํšจ: ${String(e.phase_id)}`)
306
329
  if (typeof e.approved_tree !== 'string' || !GIT_OID_RE.test(e.approved_tree)) problems.push(`line ${ln}: approved_tree ๋น„-OID`)
307
330
  if ('design_hash' in e) problems.push(`line ${ln}: phase entry์— design_hash ๊ธˆ์ง€`)
331
+ // REQ-2026-052 DEC-B5: phase_design_ref๋Š” **์„ ํƒ**(๋ถ€์žฌ=๋ ˆ๊ฑฐ์‹œ ๋ฌดํšŒ๊ท€). ์žˆ์œผ๋ฉด 64hex(design_hash์™€ ๋™ํ˜•).
332
+ if ('phase_design_ref' in e && (typeof e.phase_design_ref !== 'string' || !SHA256_RE.test(e.phase_design_ref)))
333
+ problems.push(`line ${ln}: phase_design_ref ๋น„-64hex`)
308
334
  } else if (kind === 'design') {
309
335
  if (e.phase_id !== null) problems.push(`line ${ln}: design entry๋Š” phase_id=null์ด์–ด์•ผ`)
310
336
  if (typeof e.design_hash !== 'string' || !SHA256_RE.test(e.design_hash)) problems.push(`line ${ln}: design_hash ๋น„-64hex`)
311
337
  if ('approved_tree' in e) problems.push(`line ${ln}: design entry์— approved_tree ๊ธˆ์ง€`)
338
+ // REQ-2026-052 DEC-B5: kind ๊ฒฉ๋ฆฌ โ€” design ํ–‰์—๋Š” phase_design_ref ๊ธˆ์ง€.
339
+ if ('phase_design_ref' in e) problems.push(`line ${ln}: design entry์— phase_design_ref ๊ธˆ์ง€`)
312
340
  }
313
341
  // archive_inventory(REQ-2026-048 DEC-2): **์„ ํƒ** โ€” ๋ถ€์žฌ๋Š” ์ •์ƒ(๊ธฐ์กด ํ–‰ ๋ฌดํšŒ๊ท€). ์žˆ์œผ๋ฉด ํ˜•์‹ ๊ฒ€์ฆ.
314
342
  if ('archive_inventory' in e) {
@@ -371,6 +399,89 @@ export function findEvidenceRow(
371
399
  return null
372
400
  }
373
401
 
402
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ๋งค๋‹ˆํŽ˜์ŠคํŠธ ์ˆœ์ˆ˜ ํŒŒ์„œ (REQ-2026-052 phase-3b: req-commit์—์„œ ์ด๋™) โ”€โ”€
403
+ // req-commit(command)๊ณผ intake ์Šค์บ”(leaf)์ด ๊ฐ™์€ ํŒŒ์„œ๋ฅผ ๊ณต์œ ํ•˜๋„๋ก ๋งค๋‹ˆํŽ˜์ŠคํŠธ ๋ชจ๋ธ์˜ ์ •๋ณธ์ธ ์—ฌ๊ธฐ๋กœ ์˜ฎ๊ฒผ๋‹ค.
404
+ // req-commit์ด ๊ธฐ์กด ๊ฒฝ๋กœ๋กœ re-exportํ•œ๋‹ค(ํ˜ธ์ถœ๋ถ€ ํ˜ธํ™˜).
405
+
406
+ /** ๋งค๋‹ˆํŽ˜์ŠคํŠธ(JSONL) ๋ณธ๋ฌธ์—์„œ ์•ˆ์ „ํ•˜๊ฒŒ ์—”ํŠธ๋ฆฌ ๋ฐฐ์—ด์„ ๋ฝ‘๋Š”๋‹ค(ํŒŒ์‹ฑ ๋ถˆ๊ฐ€ ํ–‰์€ ๊ฑด๋„ˆ๋›ด๋‹ค โ€” ๊ฒ€์ฆ์€ ๋ณ„๋„). */
407
+ export function parseManifestEntries(content: string): Array<Record<string, unknown>> {
408
+ const out: Array<Record<string, unknown>> = []
409
+ for (const line of content.split('\n')) {
410
+ if (line.trim() === '') continue
411
+ try {
412
+ const o = JSON.parse(line)
413
+ if (o && typeof o === 'object' && !Array.isArray(o)) out.push(o as Record<string, unknown>)
414
+ } catch {
415
+ /* skip */
416
+ }
417
+ }
418
+ return out
419
+ }
420
+
421
+ /**
422
+ * ๋งค๋‹ˆํŽ˜์ŠคํŠธ์—์„œ ์ปค๋ฐ‹๋œ **phase ์ฆ๊ฑฐ**๊ฐ€ ์žˆ๋Š” phase id ์ง‘ํ•ฉ(consumed phase ์—”ํŠธ๋ฆฌ).
423
+ *
424
+ * ๐Ÿ”ด REQ-2026-052 DEC-B5(phase-3a2): `designRef`๋ฅผ ์ฃผ๋ฉด **design-bound** โ€” ๊ทธ phase ํ–‰์˜ `phase_design_ref`๊ฐ€
425
+ * `designRef`์™€ **์ผ์น˜**ํ•˜๋Š” ๊ฒƒ๋งŒ ์‚ฐ์ž…ํ•œ๋‹ค. dev-complete ์™„์ „์„ฑ์ด ์ด ํ•„ํ„ฐ๋ฅผ ์จ์•ผ D1์—์„œ ๊ฒ€ํ† ๋œ phase๊ฐ€ D2
426
+ * ์žฌ์Šน์ธ ํ›„ D2 ์™„๋ฃŒ ์ฆ๋ช…์— ์ƒˆ์–ด๋“ค์ง€ ์•Š๋Š”๋‹ค. `phase_design_ref` ๋ถ€์žฌ ํ–‰(๋ ˆ๊ฑฐ์‹œยท๋ณด์ • ์ด์ „)์€ **๋ถˆ์‚ฐ์ž…**(fail-closed).
427
+ * `designRef` ๋ฏธ์ง€์ •(ํ•˜์œ„ํ˜ธํ™˜)์ด๋ฉด ๊ฒฐ์† ๋ฌด๊ด€ ์ „๋Ÿ‰(์˜› ๋™์ž‘) โ€” ์‹ ๊ทœ ์™„๋ฃŒ ๊ฒฝ๋กœ๋Š” ํ•ญ์ƒ designRef๋ฅผ ์ค€๋‹ค.
428
+ */
429
+ export function evidencedPhaseIdsFromManifest(content: string, designRef?: string | null): string[] {
430
+ return parseManifestEntries(content)
431
+ .filter((e) => e.kind === 'phase' && typeof e.phase_id === 'string')
432
+ .filter((e) => (designRef == null ? true : e.phase_design_ref === designRef))
433
+ .map((e) => e.phase_id as string)
434
+ }
435
+
436
+ /** ๋งค๋‹ˆํŽ˜์ŠคํŠธ์—์„œ ์ปค๋ฐ‹๋œ design ์Šน์ธ์˜ design_hash(๊ฐ€์žฅ ๋งˆ์ง€๋ง‰ design ์—”ํŠธ๋ฆฌ). ์—†์œผ๋ฉด null. */
437
+ export function designHashFromManifest(content: string): string | null {
438
+ const designs = parseManifestEntries(content).filter((e) => e.kind === 'design' && typeof e.design_hash === 'string')
439
+ return designs.length ? (designs[designs.length - 1]!.design_hash as string) : null
440
+ }
441
+
442
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ phase ์Šน์ธ archive ๋ฌด๊ฒฐ์„ฑ (REQ-2026-052 DEC-B6ยทphase-3b2) โ€” ๊ณต์œ  leaf ๋ชจ๋“ˆ โ”€โ”€
443
+
444
+ export interface PhaseArchiveProblem {
445
+ phaseId: string
446
+ responsePath: string
447
+ reason: 'missing' | 'sha-mismatch'
448
+ }
449
+
450
+ /**
451
+ * ๐Ÿ”ด phase ์Šน์ธ **archive blob ๋ฌด๊ฒฐ์„ฑ** ๊ฒ€์ฆ(์ˆœ์ˆ˜ + `headBlobSha256` ํฌํŠธ, DEC-B6). intake(`scanTicketIntake`)์™€
452
+ * req:commit ๋ฐœํ–‰ ํ›„ verifier(`verifyDevCompleteAtHead`)๊ฐ€ **์ด ํ•œ ๋ชจ๋“ˆ์„ ๊ณต์œ **ํ•œ๋‹ค โ†’ ๋‘ ๊ฒฝ๋กœ์˜ phase archive
453
+ * ๊ทœ์น™์ด ๊ฐˆ๋ผ์งˆ ์ˆ˜ ์—†๋‹ค(์š”๊ตฌ #3).
454
+ *
455
+ * ๊ฐ phase manifest ํ–‰์— ๋Œ€ํ•ด: `response_path` blob์ด **HEAD์— ์กด์žฌ**ํ•˜๊ณ (`headBlobSha256` non-null) ๊ทธ
456
+ * sha256์ด `response_sha256`๊ณผ **์ผ์น˜**ํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค. archive๋ฅผ ์‚ญ์ œํ•˜๋ฉด `missing`, ๋ฐ”์ดํŠธ๋ฅผ ๋ฐ”๊พธ๋ฉด
457
+ * `sha-mismatch` โ€” ๋‘˜ ๋‹ค archive ์†์ƒ(corrupt)์ด๋‹ค. manifest ํ–‰ ํ˜•์‹(`response_path`/`response_sha256`)์€ ์—ฌ๊ธฐ์„œ
458
+ * ์žฌ๊ฒ€์ฆํ•˜์ง€ ์•Š๋Š”๋‹ค(๊ทธ๊ฑด `validateManifest` ๋ชซ) โ€” ํ˜•์‹์ด ์ด๋ฏธ ํ†ต๊ณผํ•œ ํ–‰์˜ **blob ์ •ํ•ฉ**๋งŒ ๋ณธ๋‹ค.
459
+ *
460
+ * ๐Ÿ”ด **๊ฐ•ํ•œ ์ •์ฑ…(์š”๊ตฌ #1 ์šฐ์„ ์•ˆ)**: `onlyDesignRef`๋ฅผ ์ฃผ์ง€ ์•Š์œผ๋ฉด **๋ชจ๋“ ** phase ํ–‰์„ ๊ฒ€์ฆํ•œ๋‹ค(inventory ํ•œ์ • ์•„๋‹˜).
461
+ * ๊ฐ์‚ฌ ๋‚ด๊ตฌ์„ฑ์ƒ ์žฌ์Šน์ธ ์ด์ „ ๋ผ์šด๋“œ ํ–‰์˜ archive๊นŒ์ง€ ์˜จ์ „ํ•ด์•ผ ํ•œ๋‹ค. `onlyDesignRef` ์ง€์ • ์‹œ ๊ทธ design_ref์—
462
+ * ๊ฒฐ์†๋œ phase ํ–‰๋งŒ(๋ถ€๋ถ„ ๊ฒ€์ฆ โ€” ํ•„์š” ์‹œ).
463
+ *
464
+ * ๐Ÿ”ด on-diskยท์›Œํ‚นํŠธ๋ฆฌ๋ฅผ ์ ˆ๋Œ€ ์ฝ์ง€ ์•Š๋Š”๋‹ค โ€” `headBlobSha256`(HEAD blob ๋ฐ”์ดํŠธ sha)๋งŒ. leaf๋ผ git์„ ์ง์ ‘ ๋ชจ๋ฅธ๋‹ค.
465
+ */
466
+ export function verifyPhaseArchives(
467
+ content: string,
468
+ headBlobSha256: (repoRel: string) => string | null,
469
+ onlyDesignRef?: string | null,
470
+ ): PhaseArchiveProblem[] {
471
+ const problems: PhaseArchiveProblem[] = []
472
+ for (const e of parseManifestEntries(content)) {
473
+ if (e.kind !== 'phase' || typeof e.phase_id !== 'string' || e.phase_id === '') continue
474
+ if (onlyDesignRef != null && e.phase_design_ref !== onlyDesignRef) continue
475
+ const responsePath = typeof e.response_path === 'string' ? e.response_path : ''
476
+ const expectedSha = typeof e.response_sha256 === 'string' ? e.response_sha256 : ''
477
+ if (!responsePath || !expectedSha) continue // ํ˜•์‹ ๋ฌธ์ œ๋Š” validateManifest๊ฐ€ corrupt๋กœ ์žก๋Š”๋‹ค(์ค‘๋ณต ํŒ์ • ์•ˆ ํ•จ).
478
+ const actual = headBlobSha256(responsePath)
479
+ if (actual === null) problems.push({ phaseId: e.phase_id, responsePath, reason: 'missing' })
480
+ else if (actual !== expectedSha) problems.push({ phaseId: e.phase_id, responsePath, reason: 'sha-mismatch' })
481
+ }
482
+ return problems
483
+ }
484
+
374
485
  // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ design evidence ๋‚ด๊ตฌํ™” (REQ-2026-048 DEC-3) โ”€โ”€
375
486
 
376
487
  /**
@@ -483,7 +594,11 @@ export function durableDesignEvidence(args: {
483
594
  ports.writeText(manifestRel, candidate)
484
595
  }
485
596
 
486
- const stagePaths = designEvidenceStagePaths(inventory, ev.response_path, ticketRel)
597
+ // REQ-2026-051 D7: ์›์žฅ์ด ์žˆ์œผ๋ฉด ๊ฐ™์€ ์ปค๋ฐ‹์— ์‹ฃ๋Š”๋‹ค. `readText`๋กœ ์กด์žฌ๋ฅผ ํ™•์ธํ•œ๋‹ค โ€” ํฌํŠธ ๊ฒฝ๊ณ„๋ฅผ
598
+ // ๋„˜์ง€ ์•Š๊ณ (fs ์ง์ ‘ ์ ‘๊ทผ ๊ธˆ์ง€), ์—†์œผ๋ฉด pathspec์— ๋„ฃ์ง€ ์•Š์•„ ์ปค๋ฐ‹์ด ์‹คํŒจํ•˜์ง€ ์•Š๋Š”๋‹ค.
599
+ const ledgerRel = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/review-ledger.jsonl`
600
+ const ledgerExists = ports.readText(ledgerRel) !== null
601
+ const stagePaths = designEvidenceStagePaths(inventory, ev.response_path, ticketRel, ledgerExists)
487
602
  // ๐Ÿ”ด ๊ฐ€๋“œ๋Š” **์šฐ๋ฆฌ๊ฐ€ ์ปค๋ฐ‹ํ•  ๊ฒฝ๋กœ**์—๋งŒ ๊ฑด๋‹ค โ€” index ์ „์ฒด๊ฐ€ ์•„๋‹ˆ๋‹ค(phase-3 ๋ฆฌ๋ทฐ P1).
488
603
  // index ์ „์ฒด๋ฅผ ๋ณด๋ฉด ์„ค๊ณ„ ๋ฌธ์„œ๋ฅผ stageํ•œ ์ •์ƒ ์Šน์ธ ๊ฒฝ๋กœ์—์„œ ํ•ญ์ƒ ์‹คํŒจํ•œ๋‹ค. pathspec ๋ฒ”์œ„ ์ปค๋ฐ‹์ด๋ผ
489
604
  // ๋ฌด๊ด€ํ•œ staged ๋ณ€๊ฒฝ์€ ์• ์ดˆ์— ์ด ์ปค๋ฐ‹์— ๋“ค์–ด๊ฐˆ ์ˆ˜ ์—†๊ณ , ์ปค๋ฐ‹ ํ›„์—๋„ index์— ๊ทธ๋Œ€๋กœ ๋‚จ๋Š”๋‹ค.
@@ -628,6 +743,46 @@ export function verifyCommittedDesignEvidence(args: {
628
743
  return { durable: true, reason: 'design ์Šน์ธ ์ฆ๊ฑฐ๊ฐ€ HEAD์— ์™„๋น„๋จ' }
629
744
  }
630
745
 
746
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ committed ์ฆ๊ฑฐ ๋ฌด๊ฒฐ์„ฑ ์ข…ํ•ฉ(design+phase) (REQ-2026-052 DEC-B7ยทphase-3b3) โ€” ๊ณต์œ  deep ๋ชจ๋“ˆ โ”€โ”€
747
+
748
+ /**
749
+ * ๐Ÿ”ด durable ํ‹ฐ์ผ“์˜ **committed ์ฆ๊ฑฐ ๋ฌด๊ฒฐ์„ฑ ์ข…ํ•ฉ ํŒ์ •**(design + phase, DEC-B7). intake(`scanTicketIntake`)์™€
750
+ * req:commit ๋ฐœํ–‰ ํ›„ verifier(`verifyDevCompleteAtHead`)๊ฐ€ **์ด ํ•œ ํ•จ์ˆ˜๋ฅผ ๊ณต์œ **ํ•œ๋‹ค(์š”๊ตฌ #3) โ€” ๋‘ ๊ฒฝ๋กœ์˜
751
+ * ์ฆ๊ฑฐ ๋ฌด๊ฒฐ์„ฑ ๊ทœ์น™์ด ๊ฐˆ๋ผ์งˆ ์ˆ˜ ์—†๋‹ค. ํ˜ธ์ถœ์ž๋Š” designยทphase ๊ฒ€์ฆ ์ˆœ์„œยท์„ธ๋ถ€ ์กฐ๊ฑด์„ ๋ชฐ๋ผ๋„ ๋œ๋‹ค(์–•์€ ์ธํ„ฐํŽ˜์ด์Šค ๊ธˆ์ง€).
752
+ *
753
+ * ๋‚ด๋ถ€๋Š” **๊ธฐ์กด ํ•จ์ˆ˜ ์žฌ์‚ฌ์šฉ**(์ค‘๋ณต ๊ทœ์น™ ์—†์Œ):
754
+ * - **design**: manifest์— design ํ–‰์ด ์žˆ์œผ๋ฉด **`verifyCommittedDesignEvidence`**(REQ-2026-048 DONE ๊ฒŒ์ดํŠธ)๋กœ
755
+ * ์ตœ์‹  design ์Šน์ธ archive ์กด์žฌยทSHA + `archive_inventory` ์ „์ฒด(๊ณผ๊ฑฐ needs-fix ํฌํ•จ) ์กด์žฌยทSHA + HEAD ์ง‘ํ•ฉ ์ •ํ•ฉ์„
756
+ * ๊ฒ€์ฆ. `durable=false`๋ฉด ๋ฌด๊ฒฐ์„ฑ/์™„์ „์„ฑ ์‹คํŒจ.
757
+ * - **phase**: **`verifyPhaseArchives`**(DEC-B6)๋กœ ๋ชจ๋“  phase ํ–‰ archive ์กด์žฌยทSHA.
758
+ *
759
+ * ๐Ÿ”ด **๋ถˆ์™„์ „(incomplete) โ‰  ์†์ƒ(tampered)**: manifest์— **design ํ–‰์ด ์—†์œผ๋ฉด** design ๋ฌด๊ฒฐ์„ฑ ๊ฒ€์‚ฌ ๋Œ€์ƒ์ด ์•„๋‹ˆ๋‹ค
760
+ * (๋Œ€์ฒด๋œ ๋ฏธ์™„ ํ‹ฐ์ผ“ยทdesign ๋ฏธ์Šน์ธ์€ ์†์ƒ์ด ์•„๋‹ˆ๋ผ ๋ฏธ์™„ โ€” ํ†ต๊ณผ ๊ฐ€๋Šฅ, series-terminal). `designEvidenceComplete`์€
761
+ * needs-recovery ํŒ์ • ์ž…๋ ฅ(design ํ–‰ ์—†์œผ๋ฉด false = ๋ฏธ์™„).
762
+ *
763
+ * ๐Ÿ”ด **HEAD blob๋งŒ** โ€” on-diskยท์›Œํ‚นํŠธ๋ฆฌยทstate.json์„ ํŒ์ • ๊ทผ๊ฑฐ๋กœ ์“ฐ์ง€ ์•Š๋Š”๋‹ค(ํฌํŠธ ๊ฒฝ์œ ).
764
+ */
765
+ export function verifyCommittedEvidenceIntegrity(args: {
766
+ ticketRel: string
767
+ manifestText: string | null
768
+ ports: Pick<EvidencePorts, 'headText' | 'headBlobSha256' | 'headArchivePaths'>
769
+ }): { problems: string[]; designEvidenceComplete: boolean } {
770
+ const { ticketRel, manifestText, ports } = args
771
+ const problems: string[] = []
772
+ let designEvidenceComplete = false
773
+ if (manifestText === null) return { problems, designEvidenceComplete }
774
+ // design: design ํ–‰์ด ์žˆ์„ ๋•Œ๋งŒ ๊ฒ€์‚ฌ(์—†์œผ๋ฉด ์†์ƒ ์•„๋‹˜ โ€” ๋ถˆ์™„์ „โ‰ ์†์ƒ).
775
+ if (designHashFromManifest(manifestText) !== null) {
776
+ const d = verifyCommittedDesignEvidence({ ticketRel, ports })
777
+ designEvidenceComplete = d.durable
778
+ if (!d.durable) problems.push(`design ์ฆ๊ฑฐ ๋ฌด๊ฒฐ์„ฑ/์™„์ „์„ฑ ์‹คํŒจ: ${d.reason}`)
779
+ }
780
+ // phase: ๋ชจ๋“  phase ํ–‰ archive ๋ฌด๊ฒฐ์„ฑ.
781
+ for (const p of verifyPhaseArchives(manifestText, ports.headBlobSha256))
782
+ problems.push(`phase archive ${p.reason}: ${p.phaseId} (${p.responsePath})`)
783
+ return { problems, designEvidenceComplete }
784
+ }
785
+
631
786
  /**
632
787
  * approvals.jsonl์— **์ด evidence๊ฐ€** ์ด๋ฏธ finalize๋œ ์—”ํŠธ๋ฆฌ๊ฐ€ ์žˆ๋Š”์ง€(์ˆœ์ˆ˜, ๋ฉฑ๋“ฑ finalize์šฉ).
633
788
  * โš ๏ธ B3-R2: consumed_by_commit_sha๋งŒ์œผ๋กœ๋Š” ๋ถ€์กฑ โ€” ๊ฐ™์€ source SHA๋ฅผ ์“ฐ๋Š” design-finalize row ๋“ฑ์— ์˜ค์ธ๋  ์ˆ˜ ์žˆ์Œ.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * req:new intake gate (REQ-2026-052 phase-3b, DEC-C) โ€” ์ƒˆ REQ ์ƒ์„ฑ ์ „, **HEAD-committed durable ์ฆ๊ฑฐ๋งŒ์œผ๋กœ**
3
+ * ๊ฐ ๊ธฐ์กด ํ‹ฐ์ผ“์˜ ๊ธฐ๋ณธ ์ƒํƒœ๋ฅผ ํŒŒ์ƒํ•ด ๋ฏธ์ข…๊ฒฐ(developing/needs-recovery) durable ํ‹ฐ์ผ“์ด ์žˆ์œผ๋ฉด fail-closed๋กœ ๋ง‰๋Š”๋‹ค.
4
+ *
5
+ * ๐Ÿ”ด ํŒ์ • ์ž…๋ ฅ์€ **์˜ค์ง HEAD blob**: scaffold marker(`state.json`)ยท`approvals.jsonl`ยท`ticket-close.jsonl`ยท
6
+ * `review-ledger.jsonl`. ์›Œํ‚น `state.json`ยท์›Œํ‚นํŠธ๋ฆฌยท๋ฏธ์ปค๋ฐ‹ ์›์žฅ์€ ์ ˆ๋Œ€ ๋ณด์ง€ ์•Š๋Š”๋‹ค(DEC-B4). ์Šค์บ”์€ git
7
+ * **์กฐํšŒ๋งŒ**(read-only) โ€” write-treeยทcommitยทstate ์ˆ˜์ • ์—†์Œ.
8
+ * ๐Ÿ”ด **corrupt/๋ถ€๋ถ„ HEAD ์ฆ๊ฑฐ**(๋งค๋‹ˆํŽ˜์ŠคํŠธยทclose-proof ์†์ƒ)๋Š” pass ์กฐ๊ฑด(dev-complete/series-terminal)์ด ์ฝ๋Š”
9
+ * ์•„ํ‹ฐํŒฉํŠธ์ด๋ฏ€๋กœ, ์†์ƒ ์‹œ ํ†ต๊ณผ์‹œํ‚ค์ง€ ์•Š๊ณ  **block**ํ•œ๋‹ค(fail-closed). ์†์ƒ๋œ ์‹ ํ˜ธ๋กœ ์™„๋ฃŒ๋ฅผ ์œ„์žฅํ•  ์ˆ˜ ์—†๋‹ค.
10
+ * ๐Ÿ”ด ์˜ค๋ฒ„๋ ˆ์ด(`integrated`ยท`reconstructed`)๋Š” ๊ธฐ๋ณธ ์ƒํƒœ ํŒ์ •์„ ๋ฐ”๊พธ์ง€ ์•Š๋Š”๋‹ค โ€” ๊ฒŒ์ดํŠธ๋Š” `baseStateBlocksIntake`
11
+ * (๊ธฐ๋ณธ ์ƒํƒœ๋งŒ) ์œผ๋กœ ๊ฒฐ์ •ํ•œ๋‹ค. legacy(durability marker ์—†์Œ)๋Š” ์ฐจ๋‹จํ•˜์ง€ ์•Š๊ณ  ํ‘œ์‹œ๋งŒ ํ•œ๋‹ค.
12
+ *
13
+ * ์ˆœ์ˆ˜ ํŒ์ •(`classifyIntake`)๊ณผ IO ์ˆ˜์ง‘(`scanTicketIntake`ยท`scanIntake`)์„ ๋ถ„๋ฆฌํ•œ๋‹ค.
14
+ */
15
+ import {
16
+ deriveBaseState,
17
+ baseStateBlocksIntake,
18
+ isReconstructed,
19
+ parseCloseProof,
20
+ type CloseBaseState,
21
+ type CloseProofRow,
22
+ } from './close-proof'
23
+ import {
24
+ isDurabilityRequired,
25
+ verifyCommittedEvidenceIntegrity,
26
+ validateManifest,
27
+ evidencedPhaseIdsFromManifest,
28
+ designHashFromManifest,
29
+ } from './evidence'
30
+ import { parseLedger } from './review-ledger'
31
+ import { createEvidencePorts } from './evidence-ports'
32
+
33
+ export type IntakeVerdict = 'pass' | 'block' | 'legacy'
34
+
35
+ export interface IntakeTicketResult {
36
+ ticketId: string
37
+ ticketRel: string
38
+ /** ํŒŒ์ƒ ๊ธฐ๋ณธ ์ƒํƒœ. ๋งค๋‹ˆํŽ˜์ŠคํŠธ/close-proof ์†์ƒ์€ 5-์ƒํƒœ ๋ฐ–์˜ `corrupt`(ํ•ญ์ƒ block). */
39
+ baseState: CloseBaseState | 'corrupt'
40
+ verdict: IntakeVerdict
41
+ reason: string
42
+ /** `reconstructed` ์˜ค๋ฒ„๋ ˆ์ด(ํ‘œ์‹œ์šฉ โ€” ๊ฒŒ์ดํŠธ ํŒ์ •์—” ์˜ํ–ฅ ์—†์Œ). */
43
+ reconstructed: boolean
44
+ }
45
+
46
+ export interface IntakeFacts {
47
+ ticketId: string
48
+ ticketRel: string
49
+ /** HEAD scaffold marker(`isDurabilityRequired`). false๋ฉด legacy. */
50
+ durabilityRequired: boolean
51
+ /** HEAD approvals.jsonl ๋ณธ๋ฌธ(์—†์œผ๋ฉด null). */
52
+ manifestText: string | null
53
+ /** `validateManifest` ๊ฒฐ๊ณผ(๋นˆ ๋ฐฐ์—ด=์ •์ƒ). ๋น„์–ด์žˆ์ง€ ์•Š์œผ๋ฉด corrupt block. */
54
+ manifestProblems: string[]
55
+ /** HEAD ticket-close.jsonl ํŒŒ์‹ฑ ๊ฒฐ๊ณผ. problems ๋น„์–ด์žˆ์ง€ ์•Š์œผ๋ฉด corrupt block. */
56
+ closeParsed: { rows: CloseProofRow[]; problems: string[] }
57
+ /** ๐Ÿ”ด committed ์ฆ๊ฑฐ(design+phase) ๋ฌด๊ฒฐ์„ฑ ๋ฌธ์ œ(DEC-B6ยทB7). ๋น„์–ด์žˆ์ง€ ์•Š์œผ๋ฉด corrupt block(์‚ญ์ œ/๋ณ€์กฐ). */
58
+ evidenceIntegrityProblems: string[]
59
+ ledgerHasApprovedClose: boolean
60
+ committedEvidenceComplete: boolean
61
+ committedDesignRef: string | null
62
+ /** **design-bound**(ํ˜„์žฌ committed design_ref์— ๊ฒฐ์†๋œ) phase evidence์˜ phase id. */
63
+ evidencedPhaseIds: string[]
64
+ }
65
+
66
+ /**
67
+ * HEAD ์‚ฌ์‹ค โ†’ intake ํŒ์ •(์ˆœ์ˆ˜). corrupt(๋งค๋‹ˆํŽ˜์ŠคํŠธ/close-proof ์†์ƒ)๋Š” ๊ธฐ๋ณธ ์ƒํƒœ ๋ฐ–์˜ ๋ณ„๋„ block ์‚ฌ์œ ๋‹ค.
68
+ */
69
+ export function classifyIntake(facts: IntakeFacts): IntakeTicketResult {
70
+ const head = { ticketId: facts.ticketId, ticketRel: facts.ticketRel }
71
+ // legacy(durability marker ์—†์Œ)๋Š” ์ฐจ๋‹จํ•˜์ง€ ์•Š๊ณ  ํ‘œ์‹œ๋งŒ(DEC-Cยท์š”๊ตฌ).
72
+ if (!facts.durabilityRequired)
73
+ return { ...head, baseState: 'legacy', verdict: 'legacy', reason: 'legacy ํ‹ฐ์ผ“(durability marker ์—†์Œ) โ€” ํ‘œ์‹œ๋งŒ, ์ฐจ๋‹จ ์•ˆ ํ•จ', reconstructed: false }
74
+ // ๐Ÿ”ด pass ์กฐ๊ฑด(dev-complete/series-terminal)์ด ์ฝ๋Š” ์•„ํ‹ฐํŒฉํŠธ๊ฐ€ ์†์ƒ๋์œผ๋ฉด ํ†ต๊ณผ ๊ธˆ์ง€(fail-closed).
75
+ if (facts.manifestText !== null && facts.manifestProblems.length)
76
+ return { ...head, baseState: 'corrupt', verdict: 'block', reason: `HEAD approvals.jsonl ์†์ƒ โ€” ํ†ต๊ณผ ๋ถˆ๊ฐ€(fail-closed): ${facts.manifestProblems.slice(0, 3).join('; ')}`, reconstructed: false }
77
+ if (facts.closeParsed.problems.length)
78
+ return { ...head, baseState: 'corrupt', verdict: 'block', reason: `HEAD ticket-close.jsonl ์†์ƒ โ€” ํ†ต๊ณผ ๋ถˆ๊ฐ€(fail-closed): ${facts.closeParsed.problems.slice(0, 3).join('; ')}`, reconstructed: false }
79
+ // ๐Ÿ”ด DEC-B6ยทB7: committed ์ฆ๊ฑฐ(designยทphase archive) blob ๋ถ€์žฌ/๋ณ€์กฐ๋„ ํ†ต๊ณผ ์กฐ๊ฑด์ด ์ฝ๋Š” ์ฆ๊ฑฐ์˜ ์†์ƒ โ†’
80
+ // corrupt block(dev-completeยทseries-terminal ์œ„์žฅ ์ฐจ๋‹จ). design ํ–‰์ด ์—†๋Š” ๋ฏธ์™„ ํ‹ฐ์ผ“์€ ์†์ƒ ๋Œ€์ƒ ์•„๋‹˜(๋ถˆ์™„์ „โ‰ ์†์ƒ).
81
+ if (facts.evidenceIntegrityProblems.length)
82
+ return { ...head, baseState: 'corrupt', verdict: 'block', reason: `committed ์ฆ๊ฑฐ ์†์ƒ โ€” ํ†ต๊ณผ ๋ถˆ๊ฐ€(fail-closed): ${facts.evidenceIntegrityProblems.slice(0, 3).join('; ')}`, reconstructed: false }
83
+ const state = deriveBaseState({
84
+ durabilityRequired: true,
85
+ closeProofRows: facts.closeParsed.rows,
86
+ ledgerHasApprovedClose: facts.ledgerHasApprovedClose,
87
+ committedEvidenceComplete: facts.committedEvidenceComplete,
88
+ evidencedPhaseIds: facts.evidencedPhaseIds,
89
+ committedDesignRef: facts.committedDesignRef,
90
+ })
91
+ const blocked = baseStateBlocksIntake(state) // ๊ธฐ๋ณธ ์ƒํƒœ๋งŒ ๋ณธ๋‹ค โ€” ์˜ค๋ฒ„๋ ˆ์ด ๋ฌด๊ด€(์š”๊ตฌ).
92
+ const reason = blocked
93
+ ? state === 'developing'
94
+ ? '๋ฏธ์ข…๊ฒฐ durable ํ‹ฐ์ผ“(developing) โ€” ๋ชจ๋“  phase ์™„๋ฃŒยท์ปค๋ฐ‹ ๋˜๋Š” ์ข…๊ฒฐ ํ›„ ์žฌ์‹œ๋„'
95
+ : 'HEAD ์ฆ๊ฑฐ ๋ถˆ์ผ์น˜(needs-recovery) โ€” ์Šน์ธ ํ”์ ์€ ์žˆ์œผ๋‚˜ ์ปค๋ฐ‹๋œ ์ฆ๊ฑฐ ๋ถˆ์™„์ „, ๋ณต๊ตฌ ํ•„์š”'
96
+ : state === 'dev-complete'
97
+ ? '๊ฐœ๋ฐœ ์™„๋ฃŒ(dev-complete)'
98
+ : state === 'series-terminal'
99
+ ? 'series ์ข…๊ฒฐ(replace/human-resolution)'
100
+ : // ๐Ÿ”ด REQ-2026-053: migrated-complete๋Š” **phase-1(์ปค๋ฐ‹ 3ed1b95 close-proof.ts)**์—์„œ eventยทbase-stateยท
101
+ // ํŒŒ์„œยทderiveBaseState(dev-complete ์•„๋ž˜ยทneeds-recovery ์œ„ ๋น„์ฐจ๋‹จ)๋กœ ์ด๋ฏธ ํ™•์žฅ๋๋‹ค. ์ด phase-2 diff๋Š”
102
+ // ๋ช…๋ น(req:close)๊ณผ ์ด reason ์ผ€์ด์Šค๋งŒ ์ถ”๊ฐ€ํ•œ๋‹ค. ์ข…๊ฒฐโ†’pass ์ „ ํŒŒ์ดํ”„๋ผ์ธ์€ req-close.test.ts โ‘ฎ๊ฐ€ ์‹ค์ฆ.
103
+ state === 'migrated-complete'
104
+ ? '๊ฐœ๋ฐœ ์™„๋ฃŒ(๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ข…๊ฒฐยทmigrated-complete)'
105
+ : String(state)
106
+ return { ...head, baseState: state, verdict: blocked ? 'block' : 'pass', reason, reconstructed: isReconstructed(facts.closeParsed.rows) }
107
+ }
108
+
109
+ /**
110
+ * ํ•œ ํ‹ฐ์ผ“์˜ HEAD ์‚ฌ์‹ค์„ ๋ชจ์•„ ํŒ์ •(IO โ€” git ์กฐํšŒ๋งŒ, read-only). `ports`๊ฐ€ HEAD blob ์ ‘๊ทผ ์ •๋ณธ.
111
+ */
112
+ export function scanTicketIntake(root: string, ticketRel: string, ticketId: string): IntakeTicketResult {
113
+ const ports = createEvidencePorts(root, `${ticketRel}/responses`)
114
+ const durabilityRequired = isDurabilityRequired(ports.headText(`${ticketRel}/state.json`))
115
+ if (!durabilityRequired)
116
+ return classifyIntake({ ticketId, ticketRel, durabilityRequired: false, manifestText: null, manifestProblems: [], closeParsed: { rows: [], problems: [] }, evidenceIntegrityProblems: [], ledgerHasApprovedClose: false, committedEvidenceComplete: false, committedDesignRef: null, evidencedPhaseIds: [] })
117
+ const manifestText = ports.headText(`${ticketRel}/responses/approvals.jsonl`)
118
+ const closeText = ports.headText(`${ticketRel}/responses/ticket-close.jsonl`)
119
+ const ledgerText = ports.headText(`${ticketRel}/responses/review-ledger.jsonl`)
120
+ // validPhaseIds๋Š” ๋งค๋‹ˆํŽ˜์ŠคํŠธ ์ž์‹ ์˜ phase ํ–‰ id๋กœ ๋งŒ๋“ ๋‹ค(HEAD state.phases๋Š” ์„ค๊ณ„์ƒ []์ด๋ฏ€๋กœ ๋ฉค๋ฒ„์‹ญ ๊ฒ€์‚ฌ๋งŒ ๋ฌดํšจํ™” โ€”
121
+ // verifyCommittedDesignEvidence์™€ ๋™์ผ ๊ธฐ๋ฒ•). ๋‚˜๋จธ์ง€ ๊ตฌ์กฐยท๊ฒฝ๋กœยทshaยท์ฃผ์ž…ยทkind ๊ฒฉ๋ฆฌยทphase_design_ref ํ˜•์‹์€ ๊ฐ•์ œ๋œ๋‹ค.
122
+ const manifestPhaseIds = manifestText ? evidencedPhaseIdsFromManifest(manifestText) : []
123
+ const manifestProblems = manifestText ? validateManifest(manifestText, { ticketRel, validPhaseIds: manifestPhaseIds }) : []
124
+ const closeParsed = closeText ? parseCloseProof(closeText) : { rows: [], problems: [] }
125
+ const ledgerParsed = ledgerText ? parseLedger(ledgerText) : { rows: [], problems: [] }
126
+ const ledgerHasApprovedClose = ledgerParsed.rows.some((r) => r.event === 'attempt-closed' && r.outcome === 'approved')
127
+ const committedDesignRef = manifestText ? designHashFromManifest(manifestText) : null
128
+ const evidencedPhaseIds = manifestText ? evidencedPhaseIdsFromManifest(manifestText, committedDesignRef) : []
129
+ // ๐Ÿ”ด DEC-B7: committed ์ฆ๊ฑฐ(design+phase) ๋ฌด๊ฒฐ์„ฑ ์ข…ํ•ฉ โ€” intakeยทreq:commit ๊ณต์œ  ๋ชจ๋“ˆ. designEvidenceComplete๋กœ
130
+ // needs-recovery ํŒ์ • ์ž…๋ ฅ(committedEvidenceComplete)์„ ๊ฐ™์€ ์กฐํšŒ์—์„œ ์–ป๋Š”๋‹ค(์ค‘๋ณต ์กฐํšŒ ์—†์Œ).
131
+ const integrity = verifyCommittedEvidenceIntegrity({ ticketRel, manifestText, ports })
132
+ return classifyIntake({ ticketId, ticketRel, durabilityRequired: true, manifestText, manifestProblems, closeParsed, evidenceIntegrityProblems: integrity.problems, ledgerHasApprovedClose, committedEvidenceComplete: integrity.designEvidenceComplete, committedDesignRef, evidencedPhaseIds })
133
+ }
134
+
135
+ /**
136
+ * HEAD tree์˜ `workflow/REQ-*` ๋””๋ ‰ํ„ฐ๋ฆฌ ์ด๋ฆ„(์ •๋ ฌ). ๐Ÿ”ด **์›Œํ‚น ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ์ฝ์ง€ ์•Š๋Š”๋‹ค**(HEAD only).
137
+ *
138
+ * ๐Ÿ”ด **ํ›„ํ–‰ ์Šฌ๋ž˜์‹œ๊ฐ€ load-bearing์ด๋‹ค**: `git ls-tree -d --name-only HEAD workflow/`(์Šฌ๋ž˜์‹œ ๆœ‰)๋Š” `workflow/`์˜
139
+ * **์ง๊ณ„ ์ž์‹ tree**(= `workflow/REQ-*`)๋ฅผ ์—ด๊ฑฐํ•œ๋‹ค. ์Šฌ๋ž˜์‹œ๊ฐ€ **์—†์œผ๋ฉด**(`workflow`) ls-tree๋Š” ๊ทธ ํ•ญ๋ชฉ ์ž์‹ 
140
+ * (`workflow`) ํ•œ ์ค„๋งŒ ๋‚ธ๋‹ค โ†’ ์ž์‹์„ ๋ชป ๋ณด๊ณ  ์Šค์บ”์ด ํ†ต์งธ๋กœ ๋น„์–ด **๊ฒŒ์ดํŠธ๊ฐ€ ์šฐํšŒ๋œ๋‹ค**. ๊ทธ๋ž˜์„œ ์•„๋ž˜์—์„œ ํ•ญ์ƒ
141
+ * `${dir}/`๋กœ ์Šฌ๋ž˜์‹œ๋ฅผ ๋ถ™์ธ๋‹ค. ์ด ์ •์ƒ ๊ฒฝ๋กœ๋Š” `req-new-intake.test.ts`์˜ ์‹ค git ์—ด๊ฑฐยท์ƒ์„ฑ ์ „ ์ฐจ๋‹จ e2e๊ฐ€ ๊ณ ์ •ํ•œ๋‹ค
142
+ * (์—ด๊ฑฐ๊ฐ€ ๋น„๋ฉด developing ํ‹ฐ์ผ“์ด ์ฐจ๋‹จ๋˜์ง€ ์•Š์•„ ๊ทธ ํ…Œ์ŠคํŠธ๊ฐ€ ์‹คํŒจํ•œ๋‹ค).
143
+ */
144
+ export function listHeadTicketIds(workflowDirRel: string, gitFn: (a: string[]) => string): string[] {
145
+ const dir = workflowDirRel.replace(/\\/g, '/').replace(/\/+$/, '')
146
+ let out: string
147
+ try {
148
+ out = gitFn(['ls-tree', '-d', '--name-only', 'HEAD', `${dir}/`]) // ๐Ÿ”ด ํ›„ํ–‰ ์Šฌ๋ž˜์‹œ ํ•„์ˆ˜(์ง๊ณ„ ์ž์‹ ์—ด๊ฑฐ).
149
+ } catch {
150
+ return [] // workflow/ ๊ฐ€ HEAD์— ์—†์Œ(์ฒซ REQ ๋“ฑ) โ†’ ์Šค์บ” ๋Œ€์ƒ ์—†์Œ.
151
+ }
152
+ return out
153
+ .split('\n')
154
+ .map((l) => l.trim())
155
+ .filter(Boolean)
156
+ .map((p) => p.split('/').pop() ?? '')
157
+ .filter((n) => /^REQ-\d{4}-\d+$/.test(n))
158
+ .sort()
159
+ }
160
+
161
+ /**
162
+ * HEAD์˜ `workflow/REQ-*` ํ‹ฐ์ผ“ ์ „๋ถ€๋ฅผ ์Šค์บ”(read-only). `excludeTicketId`(๋Œ€์ฒด๋  ๋ถ€๋ชจ ๋“ฑ)๋Š” ์Šค์บ”์—์„œ ์ œ์™ธํ•œ๋‹ค โ€”
163
+ * ๊ทธ ํ‹ฐ์ผ“์€ ์ง€๊ธˆ ์ •๊ทœ replace ํ๋ฆ„์œผ๋กœ ์ข…๊ฒฐ๋˜๋ฏ€๋กœ ํ›„์† ์ƒ์„ฑ์„ ๋ง‰์•„์„  ์•ˆ ๋œ๋‹ค(์ œ์™ธ๋Š” ๋ถ€๋ชจ์˜ replace ๊ฒ€์ฆ์ด ์ด๋ฏธ ๋๋‚œ ๋’ค์—๋งŒ ์“ด๋‹ค).
164
+ */
165
+ export function scanIntake(
166
+ root: string,
167
+ workflowDirRel: string,
168
+ gitFn: (a: string[]) => string,
169
+ excludeTicketId?: string | null,
170
+ ): { tickets: IntakeTicketResult[]; blocked: IntakeTicketResult[] } {
171
+ const ticketIds = listHeadTicketIds(workflowDirRel, gitFn).filter((id) => id !== excludeTicketId)
172
+ const dir = workflowDirRel.replace(/\\/g, '/').replace(/\/+$/, '')
173
+ const tickets = ticketIds.map((id) => scanTicketIntake(root, `${dir}/${id}`, id))
174
+ return { tickets, blocked: tickets.filter((t) => t.verdict === 'block') }
175
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * lockfile diff **์š”์•ฝ** (REQ-2026-056ยทDEC-E1).
3
+ *
4
+ * `git diff --cached` ์ „๋ฌธ์ด ๋ฆฌ๋ทฐ ํ”„๋กฌํ”„ํŠธ์— ๋“ค์–ด๊ฐ€๋Š”๋ฐ, lockfile(`package-lock.json`ยท`pnpm-lock.yaml` ๋“ฑ)์€
5
+ * ์ˆ˜์ฒœ ์ค„ ๊ธฐ๊ณ„์ƒ์„ฑ์ด๋ผ ํ† ํฐ์„ ๋‚ญ๋น„ํ•˜๊ณ  ์‚ฌ๋žŒ/๋ฆฌ๋ทฐ์–ด ์‹ ํ˜ธ๋ฅผ ๋ฌป๋Š”๋‹ค. lockfile ๊ตฌํš์˜ hunk๋ฅผ **์š”์•ฝ**(๊ฒฝ๋กœยทํ—ค๋”
6
+ * ๋ณด์กด + ยฑN/M + ์ƒ๋žต๋ถ„ sha256)์œผ๋กœ ๋Œ€์ฒดํ•œ๋‹ค.
7
+ *
8
+ * ๐Ÿ”ด **ํ”„๋กฌํ”„ํŠธ๋งŒ ๋ฐ”๊พผ๋‹ค** โ€” ์Šน์ธ ๋ฐ”์ธ๋”ฉ(reviewTree = git write-tree)์€ ์ „์ฒด index๋ผ ์ด ๋ฌธ์ž์—ด ๋ณ€ํ˜•๊ณผ ๋ฌด๊ด€ํ•˜๋‹ค.
9
+ * ์Šน์ธ์€ ์—ฌ์ „ํžˆ ์ „์ฒด lockfile์„ ๊ฒฐ์†ํ•œ๋‹ค(์ด ๋ชจ๋“ˆ์€ ๊ทธ ์‚ฌ์‹ค์„ ๊ฐ•์ œํ•˜์ง€ ์•Š์ง€๋งŒ, ํ˜ธ์ถœ๋ถ€๊ฐ€ stagedDiff ๋ฌธ์ž์—ด์—๋งŒ
10
+ * ์ด ๋ณ€ํ˜•์„ ์ ์šฉํ•œ๋‹ค).
11
+ *
12
+ * ์ˆœ์ˆ˜ ๋ชจ๋“ˆ โ€” fsยทgit์„ ๋ชจ๋ฅธ๋‹ค(deterministicยท`createHash`๋งŒ).
13
+ */
14
+ import { createHash } from 'node:crypto'
15
+
16
+ /** lockfile๋กœ ์ธ์‹ํ•  basename(์ •ํ™• ์ผ์น˜๋งŒ โ€” ๊ฒฝ๋กœ ๋ถ€๋ถ„๋ฌธ์ž์—ด ์˜คํƒ ๋ฐฉ์ง€). */
17
+ export const LOCKFILE_NAMES: ReadonlySet<string> = new Set([
18
+ 'package-lock.json',
19
+ 'npm-shrinkwrap.json',
20
+ 'pnpm-lock.yaml',
21
+ 'yarn.lock',
22
+ 'bun.lockb',
23
+ ])
24
+
25
+ /** ๊ฒฝ๋กœ ์ ‘๋‘(`a/`ยท`b/`)ยทgit C-quote(๋”ฐ์˜ดํ‘œ)๋ฅผ ๋ฒ—๊ฒจ ์ •๊ทœํ™”ํ•œ๋‹ค. */
26
+ function stripPathDecoration(raw: string): string {
27
+ let t = raw.trim()
28
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) t = t.slice(1, -1) // git quoted path
29
+ if (t.startsWith('a/') || t.startsWith('b/')) t = t.slice(2)
30
+ return t
31
+ }
32
+
33
+ /**
34
+ * ํ•œ ๊ตฌํš์˜ **๋ชจ๋“  ํ›„๋ณด ๊ฒฝ๋กœ**๋ฅผ ํ—ค๋” ์ค„์—์„œ ๋ชจ์€๋‹ค(r01 P1). `diff --git`์˜ ์–‘์ชฝ ๊ฒฝ๋กœ๋Š” ๊ณต๋ฐฑ์—์„œ ๋ชจํ˜ธํ•˜๋ฏ€๋กœ
35
+ * `---`/`+++`/`rename from|to`/`copy from|to`/`Binary files โ€ฆ differ`์˜ ๋ช…์‹œ ๊ฒฝ๋กœ๋„ ํ•จ๊ป˜ ๋ณธ๋‹ค โ€”
36
+ * rename(ํ•œ์ชฝ๋งŒ lockfile)ยท๊ณต๋ฐฑ/๋”ฐ์˜ดํ‘œ ๊ฒฝ๋กœ์—์„œ๋„ ์ธ์‹๋˜๊ฒŒ.
37
+ */
38
+ function sectionPaths(section: string[]): string[] {
39
+ const paths: string[] = []
40
+ const add = (p: string | undefined): void => {
41
+ if (p === undefined) return
42
+ const s = stripPathDecoration(p)
43
+ if (s && s !== '/dev/null') paths.push(s)
44
+ }
45
+ for (const line of section) {
46
+ if (line.startsWith('--- ')) add(line.slice(4))
47
+ else if (line.startsWith('+++ ')) add(line.slice(4))
48
+ else if (line.startsWith('rename from ')) add(line.slice(12))
49
+ else if (line.startsWith('rename to ')) add(line.slice(10))
50
+ else if (line.startsWith('copy from ')) add(line.slice(10))
51
+ else if (line.startsWith('copy to ')) add(line.slice(8))
52
+ else if (line.startsWith('Binary files ')) {
53
+ const m = /^Binary files (.+) and (.+) differ$/.exec(line)
54
+ if (m) { add(m[1]); add(m[2]) }
55
+ } else if (line.startsWith('diff --git ')) {
56
+ const m = /^diff --git (.+) (.+)$/.exec(line) // best-effort(๊ณต๋ฐฑ ๊ฒฝ๋กœ๋ฉด ---/+++๊ฐ€ ๋ณด์™„)
57
+ if (m) { add(m[1]); add(m[2]) }
58
+ }
59
+ }
60
+ return paths
61
+ }
62
+
63
+ /** ๊ฒฝ๋กœ basename์ด lockfile์ธ๊ฐ€(์ •ํ™• ์ผ์น˜). */
64
+ export function isLockfilePath(path: string): boolean {
65
+ const base = path.replace(/\\/g, '/').split('/').pop() ?? ''
66
+ return LOCKFILE_NAMES.has(base)
67
+ }
68
+
69
+ /** ๊ตฌํš์ด lockfile์„ ๋‹ค๋ฃจ๋Š”๊ฐ€(์–‘์ชฝ ๊ฒฝ๋กœยทrenameยทbinary ํ—ค๋” ๋ชจ๋‘ ๊ฒ€์‚ฌ). */
70
+ function sectionIsLockfile(section: string[]): { yes: boolean; display: string } {
71
+ const paths = sectionPaths(section)
72
+ const lock = paths.find(isLockfilePath)
73
+ return { yes: lock !== undefined, display: lock ?? paths[paths.length - 1] ?? '(lockfile)' }
74
+ }
75
+
76
+ /** ํ•œ ํŒŒ์ผ ๊ตฌํš(`diff --git`์œผ๋กœ ์‹œ์ž‘ํ•˜๋Š” ์ค„ ๋ฐฐ์—ด)์„ ์š”์•ฝ(lockfile์ผ ๋•Œ๋งŒ). ๋น„-lockfile์€ ๊ทธ๋Œ€๋กœ. */
77
+ function summarizeSection(section: string[]): string[] {
78
+ const { yes, display: path } = sectionIsLockfile(section)
79
+ if (!yes) return section
80
+
81
+ const hunkIdx = section.findIndex((l) => l.startsWith('@@ '))
82
+ if (hunkIdx >= 0) {
83
+ const header = section.slice(0, hunkIdx)
84
+ const hunks = section.slice(hunkIdx)
85
+ // `+++`/`---`๋Š” ํŒŒ์ผ ํ—ค๋”๋ผ hunkIdx ์ดํ›„์—” ์—†์ง€๋งŒ ๋ฐฉ์–ด์ ์œผ๋กœ ์ œ์™ธํ•œ๋‹ค.
86
+ const plus = hunks.filter((l) => l.startsWith('+') && !l.startsWith('+++')).length
87
+ const minus = hunks.filter((l) => l.startsWith('-') && !l.startsWith('---')).length
88
+ const sha = createHash('sha256').update(hunks.join('\n'), 'utf8').digest('hex').slice(0, 12)
89
+ return [
90
+ ...header,
91
+ `# [lockfile ์ „๋ฌธ ์ƒ๋žต โ€” ์š”์•ฝ ๋ชจ๋“œ] ${path}: +${plus}/-${minus} lines ยท sha256(์ƒ๋žต๋ถ„)=${sha} ยท ์ „๋ฌธ์€ config lockfilePromptFull:true`,
92
+ ]
93
+ }
94
+ const binaryIdx = section.findIndex((l) => l.startsWith('Binary files ') && l.includes(' differ'))
95
+ if (binaryIdx >= 0) {
96
+ return [...section.slice(0, binaryIdx), `# [lockfile binary ๋ณ€๊ฒฝ โ€” ์š”์•ฝ ๋ชจ๋“œ] ${path} ยท ์ „๋ฌธ์€ config lockfilePromptFull:true`]
97
+ }
98
+ // hunk๋„ binary๋„ ์—†์Œ(์ˆœ์ˆ˜ rename/mode ๋ณ€๊ฒฝ ๋“ฑ) โ€” ํ—ค๋”๋งŒ ์ž‘์œผ๋‹ˆ ๊ทธ๋Œ€๋กœ ๋‘”๋‹ค.
99
+ return section
100
+ }
101
+
102
+ /**
103
+ * staged diff์˜ lockfile ๊ตฌํš์„ ์š”์•ฝํ•œ๋‹ค(์ˆœ์ˆ˜). `opts.full`์ด๋ฉด ์›๋ฌธ ๊ทธ๋Œ€๋กœ.
104
+ * ๐Ÿ”ด lockfile ๊ตฌํš์ด ํ•˜๋‚˜๋„ ์—†์œผ๋ฉด **์™„์ „ no-op**(์ž…๋ ฅ===์ถœ๋ ฅ) โ€” ๊ธฐ์กด byte-identical near-e2e ๋ฌดํšŒ๊ท€.
105
+ */
106
+ export function summarizeLockfileDiff(stagedDiff: string, opts: { full: boolean }): string {
107
+ if (opts.full || stagedDiff === '') return stagedDiff
108
+ const lines = stagedDiff.split('\n')
109
+ const starts: number[] = []
110
+ for (let i = 0; i < lines.length; i++) if (lines[i]!.startsWith('diff --git ')) starts.push(i)
111
+ if (starts.length === 0) return stagedDiff // ํ‘œ์ค€ ํŒŒ์ผ ๊ตฌํš ์—†์Œ โ†’ ์†๋Œ€์ง€ ์•Š๋Š”๋‹ค.
112
+
113
+ // ๊ตฌํš๋ณ„๋กœ ์š”์•ฝ(๋น„-lockfile์€ ๊ทธ๋Œ€๋กœ). ์š”์•ฝ์ด ํ•˜๋‚˜๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์œผ๋ฉด ์›๋ฌธ ๋ฐ˜ํ™˜(no-op ๋ณด์žฅ).
114
+ const result: string[] = []
115
+ if (starts[0]! > 0) result.push(...lines.slice(0, starts[0]!)) // ์ฒซ ๊ตฌํš ๅ‰ preamble(์žˆ์œผ๋ฉด) ๋ณด์กด.
116
+ let changed = false
117
+ for (let s = 0; s < starts.length; s++) {
118
+ const from = starts[s]!
119
+ const to = s + 1 < starts.length ? starts[s + 1]! : lines.length
120
+ const section = lines.slice(from, to)
121
+ const summarized = summarizeSection(section)
122
+ if (summarized !== section) changed = true // summarizeSection์€ ์š”์•ฝ ์‹œ์—๋งŒ ์ƒˆ ๋ฐฐ์—ด์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
123
+ result.push(...summarized)
124
+ }
125
+ if (!changed) return stagedDiff // lockfile ๊ตฌํš ์—†์Œ(๋˜๋Š” ์š”์•ฝ ๋Œ€์ƒ ์—†์Œ) โ†’ ์›๋ฌธ ๊ทธ๋Œ€๋กœ.
126
+ return result.join('\n')
127
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * req:reconstruct ๋ณต์› ๊ฐ€๋Šฅ์„ฑ ๋งคํŠธ๋ฆญ์Šค (REQ-2026-052 phase-4ยทDEC-D2) โ€” **์ˆœ์ˆ˜** ์‚ฐ์ถœ.
3
+ *
4
+ * ๐Ÿ”ด close-proof lifecycle event๋Š” `dev-complete`ยท`series-terminal` ๋‘˜๋ฟ์ด๋‹ค. ์ด ๋ชจ๋“ˆ์€ **HEAD-committed
5
+ * immutable evidence๊ฐ€ ํ–‰์˜ ๋ชจ๋“  ํ•„๋“œ๋ฅผ ๋ช…ํ™•ยท๋ชจํ˜ธ์—†์ด ๊ฒฐ์ •ํ•  ๋•Œ๋งŒ** ๋ณต์› ํ›„๋ณด๋ฅผ ๋‚ธ๋‹ค:
6
+ * - **dev-complete**: ๐Ÿ”ด **์ ˆ๋Œ€ ์‚ฐ์ถœํ•˜์ง€ ์•Š๋Š”๋‹ค.** `phase_inventory`๋ฅผ approvals.jsonl์˜ phase_id ์ง‘ํ•ฉ์œผ๋กœ
7
+ * ํ•ฉ์„ฑํ•˜๋ฉด ๊ณ„ํš๋์œผ๋‚˜ ๋ฏธ์ปค๋ฐ‹์ธ phase๋ฅผ ์กฐ์šฉํžˆ ๋นผ๋Š” DEC-B5 P1์ด ์žฌ๋ฐœํ•œ๋‹ค. inventory ๋…๋ฆฝ ๊ธฐ๋ก์ด ์—†์œผ๋ฏ€๋กœ
8
+ * self-verifying ํ–‰์ด ์—†์œผ๋ฉด ๋ณต์› ๊ทผ๊ฑฐ๊ฐ€ ์—†๋‹ค โ†’ ์žฌ๊ฒ€ํ† ยท์žฌ๋‚ด๊ตฌํ™”๊ฐ€ ์œ ์ผ ๊ฒฝ๋กœ.
9
+ * - **series-terminal(replace)**: ์ปค๋ฐ‹๋œ **successor** ํ‹ฐ์ผ“์˜ `successor_of`(parent_series_id ํฌํ•จ)๊ฐ€
10
+ * ์ด ํ‹ฐ์ผ“ replace ์ข…๊ฒฐ์„ ์™„์ „ํžˆ ์ฆ๋ช…ํ•  ๋•Œ๋งŒ.
11
+ * - **series-terminal(terminate)**: successor๋ฅผ ๋งŒ๋“ค์ง€ ์•Š์•„ ์ฆ๊ฑฐ๊ฐ€ ์—†๋‹ค โ†’ ๋ถˆ๊ฐ€.
12
+ *
13
+ * fsยทgitยทreview-codex๋ฅผ ๋ชจ๋ฅด๋Š” leaf(๋ถ€์ž‘์šฉยทevidence ์ถ”์ถœ์€ CLI๊ฐ€ ํ•œ๋‹ค).
14
+ */
15
+ import { type CloseProofRow, closeProofRowKey } from './close-proof'
16
+
17
+ /** ์ด ํ‹ฐ์ผ“์„ replace ๋ถ€๋ชจ๋กœ ์ง€๋ชฉํ•˜๋Š” committed successor์˜ ์ถ”์ถœ ์ฆ๊ฑฐ(CLI๊ฐ€ HEAD blob์—์„œ ๋ฝ‘์•„ ๋„ฃ๋Š”๋‹ค). */
18
+ export interface SuccessorEvidence {
19
+ /** successor ํ‹ฐ์ผ“ id(์ง„๋‹จ์šฉ). */
20
+ successorTicketId: string
21
+ /** successor `state.json`์˜ repo-์ƒ๋Œ€ ๊ฒฝ๋กœ โ€” evidence_basis. */
22
+ successorStatePath: string
23
+ /** `successor_of.parent_series_id` โ€” ๋ถ€๋ชจ์˜ replace ์ข…๊ฒฐ series_id. */
24
+ parentSeriesId: string
25
+ /** ์ข…๊ฒฐ ์‚ฌ์œ . successor๋Š” ํ•ญ์ƒ `replace`(collect๊ฐ€ ๊ทธ๋ ‡๊ฒŒ ๊ฑฐ๋ฅธ๋‹ค) โ€” material field๋กœ ๋ช…์‹œ ๊ฒ€์‚ฌํ•œ๋‹ค. */
26
+ resolution: 'replace'
27
+ /** `successor_of.parent_replace_resolution.decided_at` โ€” ์ข…๊ฒฐ ์‹œ์ (๋ถ€๋ชจ ๊ฐ’). */
28
+ at: string
29
+ }
30
+
31
+ export interface ReconstructCandidate {
32
+ row: CloseProofRow
33
+ evidenceBasis: string[]
34
+ }
35
+
36
+ export interface ReconstructPlan {
37
+ /** ๋ณต์› ์˜ˆ์ •(์‹ ๊ทœ) ํ–‰. */
38
+ candidates: ReconstructCandidate[]
39
+ /** ๋ณต์› ๋ถˆ๊ฐ€ยท๋ถˆํ•„์š”ยท๋ชจํ˜ธ ์‚ฌ์œ (์ง„๋‹จ ํ‘œ์‹œ์šฉ โ€” write๋Š” ์•ˆ ํ•˜์ง€๋งŒ ๋ช…๋ น์€ ๊ณ„์†). */
40
+ refusals: string[]
41
+ /** ๐Ÿ”ด fail-closed conflict(HEAD ๋ชจ์ˆœ) โ€” ํ•˜๋‚˜๋ผ๋„ ์žˆ์œผ๋ฉด CLI๋Š” **์ „์ฒด๋ฅผ write 0์œผ๋กœ ์ค‘๋‹จ**ํ•œ๋‹ค. */
42
+ conflicts: string[]
43
+ }
44
+
45
+ /**
46
+ * ์ด ํ‹ฐ์ผ“์˜ ๋ณต์› ๊ฐ€๋Šฅํ•œ close-proof ํ–‰์„ ๋งคํŠธ๋ฆญ์Šค(DEC-D2 multi-witness)๋Œ€๋กœ ์‚ฐ์ถœ(์ˆœ์ˆ˜). dev-complete๋Š” ์ ˆ๋Œ€ ์‚ฐ์ถœ ์•ˆ ํ•จ.
47
+ *
48
+ * ๐Ÿ”ด **parent_series_id๋ณ„ ๊ทธ๋ฃนํ™”**: ๊ฐ™์€ series๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š” ๋ณต์ˆ˜ witness์˜ material field(resolutionยทat)๊ฐ€ **์ „๋ถ€ ์ผ์น˜**ํ• 
49
+ * ๋•Œ๋งŒ ํ›„๋ณด 1๊ฐœ(evidence_basis=๋ชจ๋“  successor state ๊ฒฝ๋กœ ์ •๋ ฌยท์ค‘๋ณต์ œ๊ฑฐ). ๋ถˆ์ผ์น˜=ambiguity refusal(๊ทธ series write 0).
50
+ * ๐Ÿ”ด **HEAD ์ •ํ•ฉ**: ๊ฐ™์€ ์ž์—ฐํ‚ค ํ–‰์ด HEAD์— ์žˆ์œผ๋ฉด material ์ผ์น˜=๋ฉฑ๋“ฑ no-op, ๋ชจ์ˆœ=**conflict**(์ˆจ๊ธฐ์ง€ ์•Š๊ณ  fail-closed).
51
+ *
52
+ * @param existingRows ์ด ํ‹ฐ์ผ“ HEAD close-proof๋ฅผ ํŒŒ์‹ฑํ•œ ํ–‰๋“ค. ์†์ƒ ํ‹ฐ์ผ“์€ CLI๊ฐ€ ์ด ํ•จ์ˆ˜ **์ „์—** ๊ฑฐ๋ฅธ๋‹ค.
53
+ * @param successors ์ด ํ‹ฐ์ผ“์„ ๋ถ€๋ชจ๋กœ ์ง€๋ชฉํ•˜๋Š” committed successor ์ฆ๊ฑฐ๋“ค.
54
+ */
55
+ export function planReconstruction(args: {
56
+ ticketId: string
57
+ existingRows: readonly CloseProofRow[]
58
+ successors: readonly SuccessorEvidence[]
59
+ }): ReconstructPlan {
60
+ const candidates: ReconstructCandidate[] = []
61
+ const refusals: string[] = []
62
+ const conflicts: string[] = []
63
+
64
+ // โ‘  parent_series_id๋ณ„ ๊ทธ๋ฃนํ™”(๋นˆ ํ•„๋“œ๋Š” ๋ฏธ๊ฒฐ์ •์œผ๋กœ ๊ฐœ๋ณ„ refusal).
65
+ const groups = new Map<string, SuccessorEvidence[]>()
66
+ for (const s of args.successors) {
67
+ if (!s.parentSeriesId || !s.at || !s.resolution) {
68
+ refusals.push(`successor ${s.successorTicketId}: parent_series_idยทresolutionยทat ์ค‘ ๋ฏธ๊ฒฐ์ • โ†’ ๋ณต์› ๋ถˆ๊ฐ€(๋ชจํ˜ธ)`)
69
+ continue
70
+ }
71
+ const g = groups.get(s.parentSeriesId)
72
+ if (g) g.push(s)
73
+ else groups.set(s.parentSeriesId, [s])
74
+ }
75
+
76
+ for (const seriesId of [...groups.keys()].sort()) {
77
+ const witnesses = groups.get(seriesId)!
78
+ // โ‘ก material field(resolutionยทat) ์ „๋ถ€ ์ผ์น˜ํ•ด์•ผ ํ›„๋ณด. ๋ถˆ์ผ์น˜=ambiguity(๊ทธ series ํ›„๋ณด ์—†์Œ).
79
+ const ats = new Set(witnesses.map((w) => w.at))
80
+ const resolutions = new Set(witnesses.map((w) => w.resolution))
81
+ if (ats.size > 1 || resolutions.size > 1) {
82
+ refusals.push(
83
+ `series ${seriesId}: ๋ณต์ˆ˜ successor์˜ material field ๋ถˆ์ผ์น˜(at=[${[...ats].sort().join(', ')}] resolution=[${[...resolutions].sort().join(', ')}]) โ†’ ambiguity ๋ณต์› ๋ถˆ๊ฐ€(write 0)`,
84
+ )
85
+ continue
86
+ }
87
+ const at = witnesses[0]!.at
88
+ const resolution = witnesses[0]!.resolution
89
+ // ์™„์ „ ์ผ์น˜ํ•˜๋Š” ๋ณต์ˆ˜ witness โ†’ evidence_basis์— ๋ชจ๋“  ๊ฒฝ๋กœ ์ •๋ ฌยท์ค‘๋ณต์ œ๊ฑฐ.
90
+ const evidenceBasis = [...new Set(witnesses.map((w) => w.successorStatePath))].sort()
91
+ const row: CloseProofRow = {
92
+ ticket_id: args.ticketId,
93
+ event: 'series-terminal',
94
+ series_id: seriesId,
95
+ resolution,
96
+ phase_inventory: null,
97
+ design_ref: null,
98
+ at,
99
+ reconstructed: true,
100
+ evidence_basis: evidenceBasis,
101
+ }
102
+ // โ‘ข HEAD ์ •ํ•ฉ: ๊ฐ™์€ ์ž์—ฐํ‚ค ํ–‰์ด ์žˆ์œผ๋ฉด material ์ผ์น˜=๋ฉฑ๋“ฑ no-op, ๋ชจ์ˆœ=conflict(fail-closed).
103
+ const key = closeProofRowKey(row)
104
+ const prior = args.existingRows.find((r) => closeProofRowKey(r) === key)
105
+ if (prior) {
106
+ if (prior.resolution === resolution && prior.at === at) {
107
+ refusals.push(`series ${seriesId}: ๋™์ผ series-terminal ํ–‰์ด HEAD์— ์กด์žฌ โ†’ ๋ณต์› ๋ถˆํ•„์š”(๋ฉฑ๋“ฑ no-op)`)
108
+ } else {
109
+ conflicts.push(
110
+ `series ${seriesId}: HEAD ํ–‰๊ณผ ๋ชจ์ˆœ(HEAD at=${prior.at} resolution=${prior.resolution} โ‰  ํ›„๋ณด at=${at} resolution=${resolution}) โ†’ fail-closed(์ˆจ๊ธฐ์ง€ ์•Š์Œ)`,
111
+ )
112
+ }
113
+ continue
114
+ }
115
+ candidates.push({ row, evidenceBasis })
116
+ }
117
+ return { candidates, refusals, conflicts }
118
+ }