axmap-cli 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -212,7 +212,7 @@ node axmap/governance/gate.mjs --source <소스브랜치> --target <타깃브랜
212
212
 
213
213
  | | | 안 지키면 |
214
214
  |---|---|---|
215
- | **G1** | **자기 표는 세지 않는다** — MR 소스 브랜치 커밋의 author 인 사람의 표는 무효 | 혼자 쓰고 혼자 승인한다 |
215
+ | **G1** | **자기 표는 세지 않는다** — MR 소스 브랜치 커밋의 author 인 사람의 표는 무효 (예외 하나 — 바로 아래) | 혼자 쓰고 혼자 승인한다 |
216
216
  | **G2** | **정책은 언제나 타깃 브랜치**(**합쳐 받는 쪽 브랜치**)**에서 읽는다** | 정족수를 1 로 낮추는 MR 이 **자기 자신의 낮춘 규칙으로** 통과한다 |
217
217
  | **G3** | **표는 커밋(sha)에 묶인다** — 헤드가 바뀌면 효력을 잃는다 | 승인받은 뒤 내용을 갈아끼운다 |
218
218
  | **G4** | **한 사람은 한 표** — id 유일, email 유일 | 명단에 자기를 두 줄 적으면 두 표다 |
@@ -225,6 +225,76 @@ node axmap/governance/gate.mjs --source <소스브랜치> --target <타깃브랜
225
225
  **G2 만 순수 판정 밖에 있다.** 어느 브랜치에서 정책을 읽어오는가는 git 을 만지는
226
226
  일이라 게이트의 책임이다. `src/governance.mjs` 는 **읽어온 정책이 성립하는지**만 본다.
227
227
 
228
+ ### 🔴 G1 의 예외 하나 — 던질 수 있는 사람이 정족수보다 적을 때
229
+
230
+ 오래 쌓는 `<파트>/dev` 브랜치에서는 시간이 갈수록 커밋한 사람이 늘어
231
+ **던질 수 있는 사람이 0 으로 수렴한다.** 실측 (팀 저장소 S15P21E201, 2026-09-07):
232
+
233
+ | 브랜치 | 투표권자 | 그중 소스 브랜치의 author | 던질 수 있는 사람 | 정족수 |
234
+ |---|---|---|---|---|
235
+ | `front/main` ← `front/dev` | 4명 | **4명 전원** | **0명** | 2 |
236
+ | `back/main` ← `back/dev` | 6명 | **6명 전원** | **0명** | 2 |
237
+
238
+ 이건 **"아직 표가 모자라다"(미달)가 아니다.** 표를 더 모아도 안 풀린다 — 던질 수
239
+ 있는 사람 자체가 없기 때문이다. 그 상태에 미달(종료 코드 2)을 내면 부르는 쪽에게
240
+ *"사람에게 부탁하면 된다"* 고 거짓말을 하는 것이고, 사람은 시스템 밖에서
241
+ 탈출한다 (`hotfix/` 를 코드 안에 둔 것과 같은 이유다).
242
+
243
+ 그래서 **그 판정 하나에 한해** G1 을 푼다. 조건 셋을 **모두** 만족할 때만이다.
244
+
245
+ | | |
246
+ |---|---|
247
+ | 1 | 이번 판정의 **정족수를 숫자로 알고 있다** (`judge` 가 `countVotes` 에 넘긴다) |
248
+ | 2 | **G1 이 실제로 명단에서 누군가를 뺐다** — 안 뺐으면 G1 이 만든 교착이 아니다 |
249
+ | 3 | 그러고 **남은 사람이 정족수보다 적다** |
250
+
251
+ 2번이 없으면 *"명단 자체가 정족수보다 작다"*(승계로도 못 채운 **막힌 저장소**)까지
252
+ 이 문으로 샌다. 그건 G1 을 풀어도 아무도 못 던지므로, 판정문에 거짓 사유만
253
+ 한 줄 남는다.
254
+
255
+ **바뀌지 않는 것 — 이게 절반이다.**
256
+
257
+ - **정족수 숫자.** `majorityThreshold` 는 그대로다. 문턱을 낮추면 *"최소 두 사람이
258
+ 본다"* 는 팀의 뜻이 조용히 사라진다. 여기서 푸는 것은 **누가 던질 수 있는가** 하나다
259
+ - **G3 · G4 · G5**, 브랜치 대조, committer(**표를 기록한 git 신원**) 대조,
260
+ `vote_note`, 미래 시각 — 전부 그대로 돈다
261
+ - **fail-closed.** 소스 브랜치의 author 목록을 못 받으면 여전히 판정 불가다
262
+ (`authors-missing`). **"저자를 모를 때" 는 해제 조건이 아니다** — 거기로 새면
263
+ G1 은 켜고 끄는 스위치가 되고, 그건 성질이 아니라 설정이다
264
+
265
+ 🔴 **해제는 조용히 발동하지 않는다.** 승계와 같은 규칙이다 — 판정문이 찍는다.
266
+
267
+ ```
268
+ 🔴 G1(자기 표 배제)이 해제된 판정입니다 — 유효 투표권자 0명 < 정족수 2.
269
+ 저자 본인의 표를 셌습니다.
270
+ ```
271
+
272
+ 해제 덕분에 세어진 사람의 이름까지 남는다. 기계가 읽을 표식은 `tally` 에 있다 —
273
+ `g1Waived` · `eligibleCount` · `threshold` · `selfVoted`. `gate --json` 이 그대로 낸다.
274
+
275
+ 🔴 **표를 쓰는 쪽도 같은 판단을 한다.** `governance/vote.mjs` 는 자기 표를 만나면
276
+ 게이트를 `--json` 으로 한 번 돌려 그 판정의 `tally.g1Waived` 를 읽는다. 해제가
277
+ 걸리면 **`--force` 없이 그냥 써 주고** 왜 걸렸는지(유효 투표권자 N명 < 정족수 M)를
278
+ 같이 찍는다. 안 걸리면 지금까지대로 경고하고 `--force` 를 요구한다.
279
+
280
+ > **왜 이 줄이 여기 있나** — 1.2.0 에서 이 예외를 세는 쪽에만 넣었더니, 쓰는 쪽이
281
+ > 그것을 모른 채 *"이 표는 자기 표라 세지지 않습니다"* 라며 막았다. **세어질 표를
282
+ > 막은 것이다.** 사람은 던져 보지도 않고 교착이라고 믿었다 (실측: 팀 저장소
283
+ > S15P21E201 의 `front/dev → front/main`, 2026-09-07 — `--force` 로 밀어 넣은
284
+ > 자기 표 둘이 판정문에서 "해제 덕분에 센 표" 로 세어져 종료 코드 0 이 났다).
285
+ >
286
+ > 그래서 쓰는 쪽은 조건을 **다시 세지 않는다.** 판정이 내놓은 표식을 읽을 뿐이다.
287
+ > 같은 규칙을 두 곳에 적으면 한쪽만 고치는 날 같은 어긋남이 또 난다 —
288
+ > `vote_note` 를 두 곳에서 같은 함수 하나로 보는 것과 같은 이유다.
289
+ >
290
+ > **fail-closed 는 쓰는 쪽에도 그대로다.** 게이트를 못 돌렸거나 판정이 `count`
291
+ > 단계까지 못 갔으면 해제하지 않는다. 즉 **모르면 여전히 `--force` 가 필요하다.**
292
+
293
+ **`self_vote` 정책(아래)과는 다른 층이다.** `self_vote` 는 *"누구까지를 자기로
294
+ 보는가"* 를 **사람이 미리 정하는 것**이고, 이 예외는 *"그렇게 정했더니 아무도 못
295
+ 던지게 됐을 때"* **코드가 그 판정에서만 푸는 것**이다. `self_vote: "off"` 면
296
+ 배제할 사람이 없으므로 이 예외는 아예 안 걸린다.
297
+
228
298
  ---
229
299
 
230
300
  ## 종료 코드
@@ -496,7 +566,9 @@ git log --format=%B -1 <커밋> | git interpret-trailers --parse
496
566
  살아 있는 투표권자가 필요 표보다 적으면, 최근 90일 안에 커밋한 author 상위 3명이
497
567
  **임시 투표권**을 갖는다.
498
568
 
499
- - 승계로 들어온 사람도 **자기 표는 못 센다** (G1 예외가 없다)
569
+ - 승계로 들어온 사람도 **자기 표는 못 센다** (G1). 예외는 하나 — 던질 수 있는
570
+ 사람이 정족수보다 적어 G1 이 해제된 판정에서는 승계로 들어온 사람의 자기 표도
571
+ 센다 (위 "G1 의 예외 하나")
500
572
  - 정책에 적힌 사람은 **살아 있지 않아도 투표권을 잃지 않는다.** 생사는 *승계를 켤지*만
501
573
  정한다. 두 달 자리를 비운 사람이 돌아와 던진 표가 안 세지면 그건 fail-closed 가
502
574
  아니라 그냥 틀린 판정이다
@@ -50,6 +50,11 @@
50
50
  * 언제나 게이트다. 여기서 미리 거르면 규칙이 두 군데로 갈라지고, 그때부터
51
51
  * 한 쪽만 고쳐지는 날이 온다.
52
52
  *
53
+ * 🔴 그래서 **자기 표를 막을지 말지도 여기서 판단하지 않는다.** 게이트를
54
+ * `--json` 으로 한 번 돌려 그 판정의 `tally.g1Waived` 를 그대로 읽는다
55
+ * (`g1WaiverFor`). 1.2.0 에서 세는 쪽이 얻은 G1 해제를 이 파일이 몰라
56
+ * **세어질 표를 "안 세어집니다" 라며 막던** 버그가 바로 그 갈라짐이었다.
57
+ *
53
58
  * ── 장부와 같은 방식 ─────────────────────────────────────────────────────
54
59
  *
55
60
  * 표 브랜치를 다루는 방법은 `bin/axmap.mjs` 의 장부(`axmap/claims`)와 같다:
@@ -222,6 +227,8 @@ const HELP = `표를 던진다 — 저장소 안의 파일 하나로
222
227
  --target 자기 표(G1) 경고를 위해 비교할 브랜치. 생략하면 main.
223
228
  변경량(agent.shown)을 재는 기준이기도 하다
224
229
  --force 자기 표라는 경고를 무릅쓰고 그래도 쓴다.
230
+ 🔴 던질 수 있는 사람이 정족수보다 적어 G1 이 해제되는 판정에서는
231
+ 필요 없다 — 그때는 자기 표도 세어지므로 그냥 써 준다
225
232
  🔴 이유(--note) 요구는 --force 로 못 넘긴다
226
233
  --remote 원격 이름. 없으면 git config axmap.remote → 유일한 원격 → origin
227
234
  --policy 정책 파일의 자리 (저장소 루트 기준). 없으면 AXMAP_POLICY_PATH
@@ -352,7 +359,38 @@ function selfVoteRange(branch, targetName) {
352
359
  return { args: ['-n', '200', branch], label: `${branch} 의 최근 200 커밋` }
353
360
  }
354
361
 
355
- function warnIfSelfVote(branch, targetName, email, force) {
362
+ /**
363
+ * **이 판정에서 G1 이 해제되는가 — 세는 쪽에 물어본다.**
364
+ *
365
+ * 🔴 왜 여기서 계산하지 않는가 — **이 함수가 없어서 버그가 났다.** 1.2.0 에서
366
+ * 세는 쪽(`src/governance.mjs` 의 `countVotes`)은 *"자기 표를 빼고 나면 던질
367
+ * 수 있는 사람이 정족수보다 적을 때"* 그 판정 하나에 한해 G1 을 푸는 예외를
368
+ * 얻었는데, **쓰는 쪽인 이 파일은 그 사실을 몰랐다.** 그래서 세어질 표를
369
+ * "안 세어집니다" 라며 막았고, 팀은 반나절을 교착이라고 믿었다
370
+ * (실측: 팀 저장소 S15P21E201 의 `front/dev → front/main`, 2026-09-07).
371
+ *
372
+ * 같은 규칙을 두 곳에 적으면 그 어긋남이 그대로 다시 난다. 그래서 여기서는
373
+ * 조건을 세지 않고 **판정이 내놓은 표식**(`tally.g1Waived` · `eligibleCount`
374
+ * · `threshold`)을 그대로 읽는다. `vote_note` 를 두 곳에서 같은 함수 하나로
375
+ * 보는 것과 같은 태도다.
376
+ *
377
+ * 🔴 **fail-closed.** 게이트를 못 돌렸거나 판정이 `count` 단계까지 못 갔으면
378
+ * `null` 을 돌려 **해제하지 않는다.** 모를 때 풀어 주면 G1 은 성질이 아니라
379
+ * 켜고 끄는 스위치가 된다 (GOVERNANCE.md 의 "G1 의 예외 하나").
380
+ *
381
+ * @returns {{eligibleCount:number, threshold:number}|null} 해제될 때만 숫자들
382
+ */
383
+ function g1WaiverFor(branch, targetName, policyFlag) {
384
+ const v = runGate(branch, targetName, policyFlag)
385
+ if (!v || v.stage !== 'count' || !v.tally) return null
386
+ const { g1Waived, eligibleCount, threshold } = v.tally
387
+ if (g1Waived !== true) return null
388
+ // 표식이 반쪽만 온 판정은 못 읽은 것으로 친다 — 숫자를 지어내서 안내하지 않는다.
389
+ if (!Number.isInteger(eligibleCount) || !Number.isInteger(threshold)) return null
390
+ return { eligibleCount, threshold }
391
+ }
392
+
393
+ function warnIfSelfVote(branch, targetName, email, force, policyFlag) {
356
394
  const range = selfVoteRange(branch, targetName)
357
395
  const r = git(['log', '--format=%aE', ...range.args])
358
396
  if (r.code !== 0) {
@@ -362,6 +400,18 @@ function warnIfSelfVote(branch, targetName, email, force) {
362
400
  const authors = new Set(r.out.split('\n').map((s) => s.trim().toLowerCase()).filter(Boolean))
363
401
  if (!authors.has(email.toLowerCase())) return
364
402
 
403
+ // 자기 표는 맞다. 그런데 **이 판정에서는 세어지는가?** 세는 쪽에 물어본다.
404
+ const waiver = g1WaiverFor(branch, targetName, policyFlag)
405
+ if (waiver) {
406
+ console.error(
407
+ '이 표는 자기 표지만 **이 판정에서는 세어집니다** (G1 의 예외 — 해제).\n'
408
+ + ` ${targetName} 기준 유효 투표권자 ${waiver.eligibleCount}명 < 정족수 ${waiver.threshold} —\n`
409
+ + ' 자기 표를 빼고 나면 던질 수 있는 사람이 정족수보다 적어, 이 판정 하나에 한해 G1 이 풀립니다.\n'
410
+ + ' --force 없이 그대로 씁니다. 판정문에는 "해제 덕분에 센 표" 로 이름까지 남습니다.\n',
411
+ )
412
+ return
413
+ }
414
+
365
415
  console.error(
366
416
  `이 표는 자기 표라 세지지 않습니다 (G1 — 자기가 쓴 코드에 자기가 찬성할 수 없습니다).\n`
367
417
  + ` ${email} 이(가) ${range.label} 의 author 목록에 있습니다.\n\n`
@@ -478,16 +528,21 @@ function measureDiff(targetName, branch) {
478
528
  }
479
529
 
480
530
  /**
481
- * **던지기 직전의 판정.** 게이트를 그대로 한 번 돌려 요약 한 줄로 줄인다.
531
+ * **세는 쪽에게 직접 물어본다.** 게이트를 `--json` 으로 한 번 돌리고 판정을
532
+ * 그대로 기억한다. 못 돌렸으면 `null`.
482
533
  *
483
- * 🔴 자신은 아직 없다. 즉 `"미달 1/2"` 는 *"내가 던지기 전에 1표였다"* 는
484
- * 뜻이다. 나중에 표가 번째였는지를 이력만으로 있게 하는 값이다.
534
+ * 🔴 **이 프로그램은 판정을 흉내내지 않는다.** "자기 표가 세어지는가" 여기서
535
+ * 다시 계산하면 규칙이 벌이 되고, 한쪽만 고치는 **세는 쪽은 세는데
536
+ * 쓰는 쪽은 막는** 어긋남이 난다. 그게 실제로 났다 (`g1WaiverFor` 주석).
485
537
  *
486
- * 판정문을 다시 해석하지 않고 `--json` `exit`·숫자만 쓴다. 문구를 다듬는
487
- * 커밋이 칸의 뜻을 바꾸면 되기 때문이다 (gate.mjs 의 관례 그대로).
488
- * 돌리면 `"잴 없음"` 표는 그래도 던져진다.
538
+ * 🔴 번만 돌린다. 결과를 쓰는 자리가 둘이기 때문이다 — 자기 표를 막을지
539
+ * 말지(`g1WaiverFor`)와 표에 적히는 `agent.shown.gate`. 돌리면 게이트가
540
+ * fetch 하고, 사이에 남이 표를 던지면 **같은 실행 안에서 두 판정이
541
+ * 갈린다.**
489
542
  */
490
- function measureGate(branch, targetName, policyFlag) {
543
+ let gateOnce // undefined = 아직 안 돌림 · null = 못 돌림
544
+ function runGate(branch, targetName, policyFlag) {
545
+ if (gateOnce !== undefined) return gateOnce
491
546
  const r = spawnSync(process.execPath, [
492
547
  path.join(HERE, 'gate.mjs'),
493
548
  '--source', branch,
@@ -502,10 +557,28 @@ function measureGate(branch, targetName, policyFlag) {
502
557
  maxBuffer: 64 * 1024 * 1024,
503
558
  timeout: GATE_TIMEOUT_MS,
504
559
  })
505
- if (r.error || r.status === null) return '잴 수 없음'
560
+ gateOnce = null
561
+ if (r.error || r.status === null) return gateOnce
506
562
  let verdict = null
507
- try { verdict = JSON.parse(r.stdout ?? '') } catch { return '잴 수 없음' }
508
- if (!verdict || typeof verdict !== 'object') return '잴 수 없음'
563
+ try { verdict = JSON.parse(r.stdout ?? '') } catch { return gateOnce }
564
+ if (!verdict || typeof verdict !== 'object') return gateOnce
565
+ gateOnce = verdict
566
+ return gateOnce
567
+ }
568
+
569
+ /**
570
+ * **던지기 직전의 판정** 한 줄. 위에서 받아 둔 판정을 요약할 뿐이다.
571
+ *
572
+ * 🔴 이 표 자신은 아직 없다. 즉 `"미달 1/2"` 는 *"내가 던지기 전에 1표였다"* 는
573
+ * 뜻이다. 나중에 이 표가 몇 번째였는지를 이력만으로 알 수 있게 하는 값이다.
574
+ *
575
+ * 판정문을 다시 해석하지 않고 `--json` 의 `exit`·숫자만 쓴다. 문구를 다듬는
576
+ * 커밋이 이 칸의 뜻을 바꾸면 안 되기 때문이다 (gate.mjs 의 관례 그대로).
577
+ * 못 돌리면 `"잴 수 없음"` — 표는 그래도 던져진다.
578
+ */
579
+ function measureGate(branch, targetName, policyFlag) {
580
+ const verdict = runGate(branch, targetName, policyFlag)
581
+ if (!verdict) return '잴 수 없음'
509
582
  if (verdict.stage === 'policy') return '정책 깨짐'
510
583
  if (verdict.stage === 'undecidable') return '판정 불가'
511
584
  if (!Number.isInteger(verdict.approvals) || !Number.isInteger(verdict.threshold)) return '잴 수 없음'
@@ -645,7 +718,7 @@ const nameErr = agentNameError(voter)
645
718
  if (nameErr) die(`${nameErr}\n git config user.name 을 고치세요.`)
646
719
 
647
720
  warnIfNotAVoter(roster, targetName, email)
648
- warnIfSelfVote(branch, targetName, email, flags.force === true)
721
+ warnIfSelfVote(branch, targetName, email, flags.force === true, str(flags.policy))
649
722
 
650
723
  // ── 이유 없는 표는 여기서 거부한다 ─────────────────────────────────────────
651
724
  //
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//name": "🔴 npm 의 'axmap' 은 2022년부터 남이 쓰고 있는 다른 꾸러미다(leozin_ 의 Map 라이브러리, 1.1.2). 그래서 `npx axmap` 은 우리 것이 아니고, 누구에게도 그렇게 안내하면 안 된다. 2026-08-28 에 axmap-cli 로 정했다 — 스코프(@사용자/이름)를 쓰지 않는 이유는 스코프가 npm 계정 이름에 묶여서 나중에 팀이나 조직으로 옮길 때 이름이 통째로 바뀌기 때문이다. 사람이 치는 **명령** 이름은 그대로 axmap 이다 (아래 bin) — 꾸러미 이름과 명령 이름은 달라도 된다.",
3
3
  "name": "axmap-cli",
4
- "version": "1.1.1",
4
+ "version": "1.2.1",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -929,9 +929,15 @@ const FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000
929
929
  * @param {string|null} [args.branch] 소스 브랜치 이름. 주면 표의 branch 와 대조한다
930
930
  * @param {string|null} [args.tipAuthorEmail] 맨 위 커밋의 author email.
931
931
  * `self_vote: "tip"` 일 때만 쓰이고, 그때는 **없으면 판정 불가**다
932
- * @returns {{approvals:number, counted:Array, rejections:Array, invalid:Array}}
932
+ * @param {number|null} [args.threshold] 이번 판정의 정족수. **주면** 자기 표 배제가
933
+ * 판정을 불가능하게 만드는지 볼 수 있다 (아래 waiver). 안 주면 해제는
934
+ * 영영 안 걸린다 — 모르면 안 푸는 쪽이 fail-closed 다. `judge` 가 넣어 준다
935
+ * @returns {{approvals:number, counted:Array, rejections:Array, invalid:Array,
936
+ * g1Waived:boolean, eligibleCount:number, threshold:number|null, selfVoted:Array}}
933
937
  */
934
- export function countVotes({ policy, votes, authorEmails, sha, now, voters = null, branch = null, tipAuthorEmail = null }) {
938
+ export function countVotes({
939
+ policy, votes, authorEmails, sha, now, voters = null, branch = null, tipAuthorEmail = null, threshold = null,
940
+ }) {
935
941
  const roster = voters ?? policy?.voters ?? null
936
942
  if (!Array.isArray(roster) || roster.length === 0) {
937
943
  throw new GovernanceError('투표권자 명단이 없습니다.', { exit: EXIT.POLICY_BROKEN, code: 'voters-missing' })
@@ -966,9 +972,41 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
966
972
  const authors = excludedAuthors(selfVote, authorEmails, tipAuthorEmail)
967
973
  const byEmail = new Map(roster.map((v) => [fold(v.email), v]))
968
974
 
975
+ // ── G1 해제(waiver) — 자기 표 배제가 **판정 자체를 불가능하게 만들 때만** ────
976
+ //
977
+ // 🔴 왜 필요한가 — 실측. 오래 쌓는 `<파트>/dev` 브랜치에서는 시간이 갈수록
978
+ // 커밋한 사람이 늘어 "던질 수 있는 사람" 이 0 으로 수렴한다. 팀 저장소의
979
+ // `front/main` 은 투표권자 4명이 **전원 저자**였고(정족수 2), `back/dev` 는
980
+ // 명단 6명 전원이 저자였다. 이건 "아직 표가 모자라다"(미달, exit 2)가 아니라
981
+ // **표를 더 모아도 영원히 안 풀리는 교착**이다. 재시도가 답이 아닌 상태에
982
+ // 미달 코드를 내는 것은 부르는 쪽에게 거짓말을 하는 것이다.
983
+ //
984
+ // 🔴 해제 조건을 좁게 잡는다. 셋을 **모두** 만족해야 한다.
985
+ // 1. 이번 판정의 정족수를 숫자로 알고 있다 (`threshold` 를 받았다)
986
+ // 2. G1 이 실제로 명단에서 누군가를 뺐다 (`excludedFromRoster > 0`)
987
+ // 3. 그러고 남은 사람이 정족수보다 적다 (`eligibleCount < threshold`)
988
+ // 2번이 없으면 "명단 자체가 정족수보다 작다"(승계로도 못 채운 막힌 저장소)
989
+ // 까지 이 문으로 새어 나온다. 그건 G1 이 만든 교착이 아니므로 G1 을 풀어도
990
+ // 아무도 못 던진다 — 판정문에 거짓 사유만 한 줄 남는다.
991
+ //
992
+ // 🔴 **정족수 숫자는 건드리지 않는다.** 문턱을 낮추면 "두 사람이 본다" 는 팀의
993
+ // 뜻이 조용히 사라진다. 여기서 바꾸는 것은 **누가 던질 수 있는가** 하나다.
994
+ //
995
+ // 🔴 fail-closed 는 그대로다. 저자 목록을 못 받으면 위에서 이미 던졌다
996
+ // (`authors-missing`). "저자를 모를 때" 는 해제 조건이 **아니다** —
997
+ // 모르는 것과 셀 수 없는 것을 통과로 바꾸면 이 층이 없는 것과 같다.
998
+ const eligible = roster.filter((v) => !authors.has(fold(v?.email)))
999
+ const eligibleCount = eligible.length
1000
+ const excludedFromRoster = roster.length - eligibleCount
1001
+ const need = isInt(threshold) && threshold >= 1 ? threshold : null
1002
+ const g1Waived = need !== null && excludedFromRoster > 0 && eligibleCount < need
1003
+
969
1004
  const counted = []
970
1005
  const rejections = []
971
1006
  const invalid = []
1007
+ // 해제 덕분에 세어진 표. **판정문에 이름까지 남긴다** — 해제가 조용히 발동하면
1008
+ // 그건 예외가 아니라 우회로다 (승계를 출력에 찍는 것과 같은 이유).
1009
+ const selfVoted = []
972
1010
  const seen = new Map() // fold(email) -> 이미 센 표
973
1011
 
974
1012
  for (const raw of votes) {
@@ -993,7 +1031,8 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
993
1031
  if (!isStr(v.committerEmail)) { bad('committer-unknown'); continue }
994
1032
  if (fold(v.committerEmail) !== email) { bad('committer-mismatch', v.committerEmail); continue }
995
1033
 
996
- if (authors.has(email)) { bad('self-vote'); continue } // G1
1034
+ // G1 · 자기 표는 센다. 단 해제된 판정에서는 센다 (위 waiver 주석).
1035
+ if (authors.has(email) && !g1Waived) { bad('self-vote'); continue }
997
1036
  if (toMs(v.at, 'votes[].at') > nowMs + FUTURE_TOLERANCE_MS) { bad('future-dated', v.at); continue }
998
1037
  // 🔴 이유 없는 표는 **찬성이든 반대든** 안 센다. 반대야말로 이유가 필요하다 —
999
1038
  // 이유 없는 반대는 판정문에 "누군가 막고 있다" 만 남기고 무엇을 고쳐야
@@ -1004,13 +1043,28 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
1004
1043
  if (seen.has(email)) { bad('duplicate', `이미 센 표: ${seen.get(email).voter}`); continue } // G4
1005
1044
 
1006
1045
  seen.set(email, v)
1046
+ // 해제가 없었으면 여기 못 왔을 표. 판정문이 이름을 찍는다.
1047
+ if (g1Waived && authors.has(email)) selfVoted.push({ voter: v.voter, email: v.email, vote: v.vote })
1007
1048
  if (v.vote === 'approve') counted.push(v)
1008
1049
  // 반대는 정족수 계산에 들어가지 않는다. 이 층이 답하는 질문은 "찬성이 몇인가"
1009
1050
  // 하나뿐이고, 거부권은 다른 제도다 — 만들려면 SPEC 부터 고쳐야 한다.
1010
1051
  else rejections.push(v)
1011
1052
  }
1012
1053
 
1013
- return { approvals: counted.length, counted, rejections, invalid }
1054
+ return {
1055
+ approvals: counted.length,
1056
+ counted,
1057
+ rejections,
1058
+ invalid,
1059
+ // ── 기계가 읽는 해제 표식 ────────────────────────────────────────────
1060
+ // `gate.mjs --json` 이 이 셋을 그대로 내보낸다. 판정문의 한 줄이 지워져도
1061
+ // 이 표식은 남는다 — 나중에 "이 판정이 예외였나" 를 사람이 아니라 코드가
1062
+ // 물어볼 수 있어야 한다.
1063
+ g1Waived,
1064
+ eligibleCount,
1065
+ threshold: need,
1066
+ selfVoted,
1067
+ }
1014
1068
  }
1015
1069
 
1016
1070
  /**
@@ -1084,7 +1138,14 @@ export function judge({
1084
1138
  : null
1085
1139
  const need = rulesFor(changed, policy, { majority })
1086
1140
  const roster = deriveVoters({ policy, contributors, now, threshold: need.threshold })
1087
- const tally = countVotes({ policy, votes, authorEmails, sha, now, voters: roster.voters, branch, tipAuthorEmail })
1141
+ // 🔴 정족수를 **분자 쪽에도** 넘긴다. `countVotes` 숫자가 있어야
1142
+ // "자기 표를 빼고 나면 던질 수 있는 사람이 정족수보다 적은가" 를 볼 수
1143
+ // 있다. 문턱은 여기서 정해지므로(`rulesFor`) 그 값을 그대로 준다 —
1144
+ // 분자가 다른 문턱을 보고 해제를 판단하면 판정만 조용히 틀린다.
1145
+ const tally = countVotes({
1146
+ policy, votes, authorEmails, sha, now, voters: roster.voters, branch, tipAuthorEmail,
1147
+ threshold: need.threshold,
1148
+ })
1088
1149
  const ok = tally.approvals >= need.threshold
1089
1150
  return {
1090
1151
  exit: ok ? EXIT.OK : EXIT.SHORT,
@@ -1141,6 +1202,22 @@ export function formatVerdict(verdict) {
1141
1202
  }
1142
1203
  L.push('')
1143
1204
 
1205
+ // 🔴 자기 표 배제 해제는 **조용히 발동하지 않는다.** 이 줄을 지우면 해제가
1206
+ // 예외가 아니라 우회로가 된다 — 승계 줄을 지울 수 없는 것과 같은 이유다.
1207
+ // 나중에 이력을 보는 사람이 "이 판정은 예외였다" 를 여기서 알아야 한다.
1208
+ if (tally?.g1Waived) {
1209
+ L.push(` 🔴 G1(자기 표 배제)이 해제된 판정입니다 — 유효 투표권자 ${tally.eligibleCount}명 < 정족수 ${threshold}. 저자 본인의 표를 셌습니다.`)
1210
+ L.push(' 오래 쌓인 브랜치는 투표권자가 전부 저자가 되어 던질 수 있는 사람이 0 이 됩니다.')
1211
+ L.push(' 그건 표가 모자란 것이 아니라 표를 더 모아도 안 풀리는 교착이라, 이 판정에 한해서만 풉니다.')
1212
+ L.push(' 🔴 정족수 숫자는 그대로입니다. G3(표는 커밋에 묶인다)·G4(같은 사람의 두 번째 표)·')
1213
+ L.push(' 브랜치 대조·committer(표를 기록한 git 신원) 대조도 전부 그대로 돕니다.')
1214
+ if (tally.selfVoted?.length) {
1215
+ L.push(' 해제 덕분에 센 표')
1216
+ for (const s of tally.selfVoted) L.push(` ! ${s.voter} <${s.email}> ${s.vote}`)
1217
+ }
1218
+ L.push('')
1219
+ }
1220
+
1144
1221
  if (tally.counted.length) {
1145
1222
  L.push(' 센 표')
1146
1223
  for (const c of tally.counted) L.push(` o ${c.voter} <${c.email}> ${c.at}`)
@@ -1173,7 +1250,10 @@ export function formatVerdict(verdict) {
1173
1250
  } else {
1174
1251
  L.push(' 그런데 창 안에 기여자가 없습니다.')
1175
1252
  }
1176
- L.push(' 승계로 들어온 사람도 자기 표는 세지 않습니다 (G1).')
1253
+ // 해제된 판정에서는 줄이 거짓이 된다. 틀린 안내는 안 하는 것만 못하다.
1254
+ L.push(tally?.g1Waived
1255
+ ? ' 이 판정은 자기 표 배제가 해제되어 있어 승계로 들어온 사람의 자기 표도 셉니다 (위 🔴).'
1256
+ : ' 승계로 들어온 사람도 자기 표는 세지 않습니다 (G1).')
1177
1257
  L.push('')
1178
1258
  }
1179
1259
  if (roster.short > 0) {