axmap-cli 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -135,7 +135,19 @@ GPG 서명(**커밋에 암호학적 서명을 붙여 작성자를 증명하는
135
135
  "sha": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
136
136
  "vote": "approve",
137
137
  "at": "2026-08-25T05:00:00Z",
138
- "note": "여행 상세 화면 확인함"
138
+
139
+ "note": "정족수와 투표권자 명단을 안 건드리는 것을 확인했다.",
140
+
141
+ "agent": {
142
+ "id": "claude-opus-5",
143
+ "shown": {
144
+ "files": 3, "insertions": 12, "deletions": 0,
145
+ "paths": ["governance/policy.json", "governance/GOVERNANCE.md", "src/governance.mjs"],
146
+ "gate": "미달 1/2",
147
+ "axmap": { "commit": "6d9fe68050baed93b06b7c563b15ac638defd65e", "behind": 0 }
148
+ },
149
+ "summary": "policy.json 에 self_vote 한 줄 추가. 정족수·명단 불변."
150
+ }
139
151
  }
140
152
  ```
141
153
 
@@ -150,10 +162,39 @@ GPG 서명(**커밋에 암호학적 서명을 붙여 작성자를 증명하는
150
162
  `reject` 는 **정족수 계산에 들어가지 않는다.** 이 층은 "찬성이 몇인가" 만 세고,
151
163
  거부권은 다른 제도다. 다만 출력에는 반드시 보인다 — 사람이 읽어야 하기 때문이다.
152
164
 
165
+ #### 🔴 이유 칸 — 칸마다 주인이 다르다
166
+
167
+ `note` 와 `agent` 는 **누가 채우는가가 서로 다르고, 그게 설계의 전부다.**
168
+ 한 사람이 다 채울 수 있으면 그건 근거가 아니라 자기 신고다.
169
+
170
+ | 칸 | 채우는 주체 | 어떻게 |
171
+ |---|---|---|
172
+ | `note` | **사람** | `--note "..."` |
173
+ | `agent.summary` | **AI** | `--agent-summary "..."` |
174
+ | `agent.id` | 부르는 쪽 | `--agent <이름>`. 안 주면 `"none"`(= 사람이 혼자 던졌다) |
175
+ | `agent.shown` | 🔴 **`vote.mjs` 가 직접 잰다** | **플래그가 없다. 아무도 못 적는다** |
176
+
177
+ `agent.shown` 에 플래그를 두지 않은 이유는 `committerEmail` 을 표에 못 적게 한
178
+ 이유와 **완전히 같다** — 재는 사람과 재어지는 사람이 같으면 측정이 아니다.
179
+ `--agent` 를 안 줘도 이 칸은 채워진다. 사람이 혼자 던진 표에도 *"그때 무엇이
180
+ 보였나"* 는 남아야 하기 때문이다.
181
+
182
+ | `shown` 안 | |
183
+ |---|---|
184
+ | `files` · `insertions` · `deletions` · `paths` | `git diff --numstat <타깃>...<소스>` 를 그대로 잰 값. 세 점(`...`)은 **갈림점부터의 차이**라 타깃에 새 커밋이 들어와도 안 흔들린다. 못 재면 **`null`** 이다 — 0 은 "안 바뀌었다" 는 뜻이라 못 잰 것과 완전히 다른 말이다 |
185
+ | `gate` | **던지기 직전**의 판정 요약(`"미달 1/2"`). 이 표 자신은 아직 없다 — 즉 *"내가 던지기 전에 1표였다"* 는 뜻이다. 못 돌리면 `"잴 수 없음"` |
186
+ | `axmap` | 이 저장소가 쓰는 axMap **사본**의 출처 커밋과, 원본이 몇 커밋 앞서 있는지. 🔴 **못 재면 `behind: null` 이고 표는 그대로 던져진다** — axMap 이 죽어도 팀이 멈추면 안 된다. `null` 은 *"안 뒤처졌다"* 가 아니라 *"못 쟀다"* 로 읽는다 |
187
+
188
+ `shown` 은 **판정에 안 쓰인다.** 게이트는 이 칸을 쳐다보지 않는다 — 사람이
189
+ 나중에 *"왜 이걸 통과시켰나"* 를 되물을 수 있게 하는 기록이다. 그래서 모양이
190
+ 늘어도 옛 게이트가 표를 깨진 것으로 보지 않는다.
191
+
153
192
  ### 표를 던지는 법
154
193
 
155
194
  ```bash
156
- node axmap/governance/vote.mjs --branch <소스브랜치> [--sha <커밋>] [--vote approve|reject] [--note "..."]
195
+ node axmap/governance/vote.mjs --branch <소스브랜치> --note "왜 찬성/반대하는가" \
196
+ [--sha <커밋>] [--vote approve|reject] \
197
+ [--agent <AI 이름>] [--agent-summary "AI 가 요약한 한 줄"]
157
198
 
158
199
  # 판정을 손으로 미리 돌려본다 (CI 밖에서도 된다)
159
200
  node axmap/governance/gate.mjs --source <소스브랜치> --target <타깃브랜치>
@@ -302,6 +343,135 @@ axMap 은 이제 범용 도구라 남의 저장소는 정책을 다른 자리에
302
343
  출력에도 과반이라는 사실과 분모가 함께 찍힌다. 숫자만 보이면 왜 이 문턱인지 아무도
303
344
  모르고, 명단이 하나 늘어난 날 그 숫자가 조용히 달라진다.
304
345
 
346
+ ### 자기 표 배제의 범위 — `self_vote` (선택 필드)
347
+
348
+ G1(자기 표는 안 센다)이 **누구까지를 "자기" 로 보는가**를 정하는 값이다.
349
+ 안 적으면 `"authors"` — 이 필드가 없던 시절과 **완전히 같은 동작**이다.
350
+
351
+ | 값 | 배제되는 사람 |
352
+ |---|---|
353
+ | `"authors"` (기본) | 소스 브랜치의 **모든** 커밋 author |
354
+ | `"tip"` | 소스 브랜치 **맨 위 커밋(tip)의 author 한 명**. 그 커밋이 표가 묶이는 sha 자신이다 (G3) |
355
+ | `"off"` | 아무도 배제하지 않는다 |
356
+
357
+ **왜 생겼나 — 실측.** 오래 쌓는 `<파트>/dev` 브랜치에서는 시간이 갈수록 커밋한
358
+ 사람이 늘어 **던질 수 있는 사람이 0 으로 수렴한다.** 팀 저장소의 `common/dev` 는
359
+ 투표권자 4명 중 3명이 커밋해서 던질 수 있는 사람이 1명인데 필요한 표는 2표였다.
360
+ 이건 *"아직 표가 모자라다"*(미달, 2)가 아니라 **영구히 잠긴 것**이고, 표를 더
361
+ 모아도 풀리지 않는다.
362
+
363
+ **그래도 기본값을 `"off"` 로 두지 않는다.** G1 이 실제로 막는 것은 *"혼자 올리고
364
+ 혼자 통과"* 하나이고, `"tip"` 은 그것을 여전히 막는다 — 머지될 커밋을 만든 사람은
365
+ 못 던진다. `"off"` 는 그 마지막 한 겹까지 없앤다.
366
+
367
+ | | |
368
+ |---|---|
369
+ | **분자와 분모가 같은 값을 본다** | 문턱(`majorityThreshold`)과 표(`countVotes`)가 **같은 함수 하나**(`excludedAuthors`)로 배제 집합을 만든다. 어긋나면 아무 에러 없이 판정만 틀린다 |
370
+ | **이상한 값은 판정 불가(1)** | 조용히 기본값으로 떨어뜨리지 않는다. 오타 하나가 배제 범위를 바꾸면 그건 미달이 아니라 **틀린 답**이다 (G5) |
371
+ | **`"tip"` 인데 맨 위 커밋의 author 를 못 읽으면 판정 불가(1)** | 누구를 뺄지 모르면 세지 않는다. 게이트가 `git log -1 --format=%aE <소스커밋>` 으로 채운다 |
372
+ | **`"off"` 일 때만 author 목록이 없어도 된다** | `"authors"` · `"tip"` 에서는 author 를 못 모으면 여전히 `authors-missing` 으로 판정 불가다 |
373
+
374
+ ### 표에 이유를 요구한다 — `vote_note` (선택 필드)
375
+
376
+ ```json
377
+ "vote_note": { "min_chars": 20, "since": "2026-09-01" }
378
+ ```
379
+
380
+ 이유(`note`)를 안 적었거나 너무 짧은 표를 **안 세게** 만드는 값이다.
381
+ 안 적으면 **아무것도 요구하지 않는다** — 이 필드가 없던 시절과 완전히 같은 동작이다.
382
+
383
+ | 칸 | |
384
+ |---|---|
385
+ | `min_chars` | 몇 글자 이상 적어야 하는가. 앞뒤 공백을 뗀 뒤에 센다 (공백으로 길이를 못 채운다). 안 적으면 `1`(= 뭐든 한 글자) |
386
+ | `since` | **시행일.** 이 시각 **이후**에 던져진 표(`at` 기준)에만 요구한다. 안 적으면 시행일 제한 없음 |
387
+
388
+ **왜 생겼나 — 실측.** `--note` 라는 칸은 원래도 있었다. 그런데 (1) 필수가 아니었고,
389
+ (2) **세는 코드가 그 칸을 쳐다보지도 않았다.** 그래서 실제로 머지된 표에
390
+ `"note": "내용 확인함"` 네 글자짜리가 남아 있다. **그건 이유가 아니라 서명이다** —
391
+ 무엇을 봤고 왜 괜찮다고 판단했는지가 하나도 안 남는다.
392
+
393
+ | | |
394
+ |---|---|
395
+ | 🔴 **강제는 두 곳에서 한다** | 쓰는 쪽(`governance/vote.mjs`)은 `--note` 없이 **쓰기를 거부**하고, 세는 쪽(`src/governance.mjs` 의 `countVotes`)은 이유 없는 표를 **무효**로 센다 |
396
+ | **왜 두 곳인가** | 쓰는 쪽만 막으면 **우회된다.** `git pull` 을 안 한 사람의 옛 `vote.mjs` 는 이 규칙을 모른 채 이유 없는 표를 그냥 쓴다. **진짜 문은 세는 쪽**이다 — CI 에서 돌기 때문이다. 쓰는 쪽의 거부는 던지기 전에 알려주는 **친절함**이다 |
397
+ | **규칙은 함수 하나다** | 두 곳이 같은 `voteNoteProblem()` 을 부른다. 갈리면 던질 때는 통과하고 셀 때는 무효인 표가 생기고, 던진 사람은 던졌다고 믿는다 (**투표에서 최악은 조용한 누락이다**) |
398
+ | 🔴 **`reject` 에도 요구한다** | 반대야말로 이유가 필요하다. 이유 없는 반대는 판정문에 *"누군가 막고 있다"* 만 남기고 **무엇을 고쳐야 하는지는 안 남긴다** |
399
+ | 🔴 **소급하지 않는다** | `since` 이전에 던져진 표는 이유가 없어도 **그대로 유효하다.** 규칙을 고치는 사람이 남의 지난 표를 지울 수 있으면 그건 규칙이 아니다 |
400
+ | **`--force` 로 못 넘긴다** | `--force` 는 자기 표(G1) 경고 전용이다 |
401
+ | **이상한 값은 판정 불가(1)** | `self_vote` 와 같은 등급이다. 이 값이 이상하면 **어떤 표가 유효한지 자체를 모른다** (G5) |
402
+
403
+ 판정문에는 왜 안 셌는지가 `이유(note)를 안 적은 표` · `이유(note)가 정책의 최소
404
+ 길이보다 짧음` 으로 보인다. **안 보이면 아무도 못 고친다.**
405
+
406
+ > 🔴 **켜는 것은 별도의 MR 이다.** axMap 은 이 필드를 **읽을 줄 알게** 됐을 뿐,
407
+ > 어느 저장소의 정책에도 자동으로 들어가지 않는다. 실제로 켜려면 팀 저장소의
408
+ > `governance/policy.json` 에 `vote_note` 줄을 넣어야 하고, 그 파일은
409
+ > `amendment.paths` 안이라 **개정 문턱을 지나는 MR** 로만 바뀐다.
410
+ > 그때 `since` 는 **그 MR 이 머지되는 날 이후**로 잡는다 — 그래야 이미 던져진
411
+ > 표가 소급해서 무효가 되지 않는다.
412
+
413
+ #### 그 이유는 어디에 남는가 — 머지 커밋
414
+
415
+ 표에 적은 이유는 **머지 커밋 본문**으로 들어간다. 승격 봇이 GitLab 에 머지를
416
+ 요청할 때 함께 보낸다.
417
+
418
+ ```
419
+ Merge branch 'front/dev' into 'front/main'
420
+
421
+ 정족수 2/2 · 판정 커밋 a1b2c3d4e5f6
422
+
423
+ Reviewed-by: 찬성 — yeaseung-lee <yeaseung.lee96@example.com> · ERD 칼럼 이름을 대조했습니다
424
+ Reviewed-by: 찬성 — jinmiri <wlsalfl321@example.com>
425
+ Rejected-by: 반대 — bob <bob@example.com> · 마이그레이션이 빠졌습니다
426
+ ```
427
+
428
+ 맨 아래 세 줄을 **트레일러**(trailer — 커밋 메시지 끝에 `이름: 값` 꼴로 붙이는 줄.
429
+ `Signed-off-by:` 가 대표적이다)라고 한다. git 에는 그 줄들만 뽑아내는 기능이 있어서,
430
+ 나중에 "이 커밋에 반대가 있었나" 를 **사람이 눈으로 훑지 않고 기계가 찾을 수 있다.**
431
+
432
+ ```bash
433
+ git log --format=%B -1 <커밋> | git interpret-trailers --parse
434
+ ```
435
+
436
+ 🔴 **왜 MR 댓글이 아닌가.** 댓글은 셋이 나쁘다 — clone 에 안 따라오고(다음 사람이
437
+ `git log` 로 못 읽는다), GitLab 을 떠나면 사라지고, MR 이 닫히면 아무도 안 본다.
438
+ 장부(`axmap/claims`)와 쪽지(`axmap/bus`)를 서버가 아니라 저장소 안에 둔 것과
439
+ 같은 판단이다.
440
+
441
+ 🔴 **반대표도 싣는다.** 반대는 정족수 계산에 안 들어가므로(거부권은 다른 제도다)
442
+ **반대가 있어도 머지될 수 있다.** 그때 그 반대가 아무 데도 안 남으면 나중에
443
+ 문제가 터졌을 때 "아무도 몰랐다" 로 기록된다. 알았던 사람이 있었다는 것이 남아야 한다.
444
+
445
+ ##### 🔴 콜론 앞자리는 영문이어야 한다 — 2026-08-28 실측
446
+
447
+ 한글이 먼저 오게 쓰고 싶지만 **그 자리는 사람에게 보여주는 칸이 아니라 기계가
448
+ 찾는 열쇠 칸**이다. 한글이나 괄호가 섞이면 git 은 그 줄을 트레일러가 아니라
449
+ 평범한 본문으로 보고 **조용히 지나친다. 오류도 안 난다.**
450
+
451
+ | 넣어 본 것 | git 이 뽑아내나 |
452
+ |---|---|
453
+ | `Reviewed-by: bob <b@x.com>` | ✅ |
454
+ | `Reviewed-by: bob <b@x.com> (찬성) — 이유` | ✅ |
455
+ | `찬성(Reviewed-by): bob <b@x.com>` | ❌ 무시 |
456
+ | `Reviewed-by(찬성): bob <b@x.com>` | ❌ 무시 |
457
+ | `찬성: bob <b@x.com>` | ❌ 무시 |
458
+
459
+ 그래서 열쇠는 영문으로 두고 **한글을 값의 맨 앞**에 놓았다. 눈으로 읽을 때
460
+ 먼저 보이는 것은 여전히 `찬성` · `반대` 다.
461
+
462
+ 라벨을 한글로 바꾸는 것은 자연스러운 개선처럼 보이므로 **테스트로 막아 뒀다**
463
+ (`test/promote.test.mjs` 의 "진짜 git" 절). 언젠가 git 이 이걸 지원하면 그 테스트가
464
+ 빨개지고, 그때 다시 정하면 된다 — 근거가 바뀌면 결정도 다시 본다.
465
+
466
+ 이유가 비면 꼬리(`· …`)를 안 붙인다. `vote_note` 를 안 켠 저장소에서는 이유가
467
+ 없는 것이 정상이고, 빈 꼬리는 "이유 칸이 깨졌다" 처럼 보인다. 표가 하나도 없으면
468
+ 아무것도 안 보내 GitLab 기본 메시지를 그대로 쓴다.
469
+
470
+ > 사람이 손으로 누른 머지에는 안 붙는다. 봇이 부르는 API 경로에만 실린다.
471
+ > 첫 줄은 GitLab 기본형 그대로 둔다 — 봇 머지와 사람 머지가 이력에서 다르게
472
+ > 보이면, 훑는 사람이 **다르게 보이는 것 자체를 신호로 오해한다.**
473
+
474
+
305
475
  ### 계층 — `개정 ≥ 기본 ≥ 파트별 규칙`
306
476
 
307
477
  어기면 exit 4 다. 이유는 하나다.
@@ -324,6 +324,29 @@ function sourceAuthors(targetRev, sourceRev) {
324
324
  return [...new Set(r.out.split('\n').map((s) => s.trim()).filter(Boolean))]
325
325
  }
326
326
 
327
+ /**
328
+ * 소스 브랜치 **맨 위 커밋의 author email.** 정책이 `self_vote: "tip"` 일 때 배제할
329
+ * 한 사람이다.
330
+ *
331
+ * 🔴 `targetRev..sourceRev` 범위가 아니라 `sourceRev` 자신을 본다. 그 커밋이 곧
332
+ * 표가 묶이는 sha(G3)이고 이번에 머지될 커밋이다. 범위의 "가장 최근" 을 쓰면
333
+ * 범위가 비었을 때(소스가 타깃보다 앞서지 않을 때) 답이 없어지는데, 배제할
334
+ * 사람은 그때도 명확하다.
335
+ *
336
+ * 🔴 정책을 안 보고 **언제나** 읽는다. 여기서 `self_vote` 를 해석하면 배제 규칙이
337
+ * 게이트와 판정 층 두 곳에 생기고, 두 곳이 어긋난 날 아무도 못 찾는다.
338
+ * 게이트는 재료만 모으고 규칙은 `src/governance.mjs` 한 곳에 둔다.
339
+ */
340
+ function tipAuthor(sourceRev) {
341
+ const r = git(['log', '-1', '--format=%aE', sourceRev])
342
+ if (r.code !== 0 || !r.out.trim()) {
343
+ // 빈 문자열을 그럴듯하게 넘기지 않는다. `"tip"` 이면 판정 층이 판정 불가로
344
+ // 던지고, 그 밖의 모드에서는 어차피 안 쓰인다 (fail-closed).
345
+ return null
346
+ }
347
+ return r.out.trim()
348
+ }
349
+
327
350
  /**
328
351
  * 승계(**투표권자가 모자랄 때 최근 기여자에게 임시 투표권을 주는 것**)용 재료.
329
352
  *
@@ -478,6 +501,7 @@ const policyPath = resolvePolicyPath(flags)
478
501
  const policy = readPolicy(targetRev, targetName, policyPath)
479
502
  const { base, paths: changed } = changedPaths(targetRev, sourceRev)
480
503
  const authorEmails = sourceAuthors(targetRev, sourceRev)
504
+ const tipAuthorEmail = tipAuthor(sourceRev)
481
505
  const contributors = contributorsFor(policy, targetRev, sourceRev)
482
506
  const votesRef = resolveVotesRef(remote, flags)
483
507
  const votes = readVotes(votesRef, sourceName)
@@ -490,6 +514,9 @@ console.error([
490
514
  ` 갈림점 : ${base}`,
491
515
  ` 정책 : ${targetName}:${policyPath} (🔴 타깃에서 읽습니다 — G2)`,
492
516
  ` 바뀐 파일 : ${changed.length}개`,
517
+ // 자기 표 배제(G1)가 왜 그렇게 셌는지는 판정문에 안 나온다. 그런데 "던질 수
518
+ // 있는 사람이 몇 명이었나" 가 미달의 제일 흔한 원인이라 재료에 남겨 둔다.
519
+ ` 자기 표 : self_vote=${policy?.self_vote ?? '(안 적힘 → authors)'} author ${authorEmails.length}명 · tip ${tipAuthorEmail ?? '(못 읽음)'}`,
493
520
  ` 표 : ${votesRef ? `${votes.length}장 (${votesRef.from})` : `0장 (${VOTES_BRANCH} 브랜치가 아직 없습니다)`}`,
494
521
  ].join('\n'))
495
522
 
@@ -505,6 +532,7 @@ const verdict = judge({
505
532
  changed,
506
533
  votes,
507
534
  authorEmails,
535
+ tipAuthorEmail,
508
536
  sha: sourceRev,
509
537
  contributors,
510
538
  now: Date.now(),