axmap-cli 0.0.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.
Files changed (78) hide show
  1. package/.claude/commands/ax-done.md +10 -0
  2. package/.claude/commands/ax-setup.md +31 -0
  3. package/.claude/commands/ax-start.md +15 -0
  4. package/.claude/commands/ax-tell.md +14 -0
  5. package/.claude/commands/ax-update.md +19 -0
  6. package/.claude/commands/ax.md +13 -0
  7. package/CLAUDE.md +309 -0
  8. package/LICENSE +20 -0
  9. package/README.md +207 -0
  10. package/app/README.md +366 -0
  11. package/app/eval/edges.mjs +242 -0
  12. package/app/lib/adjacent.mjs +125 -0
  13. package/app/lib/agentcli.mjs +153 -0
  14. package/app/lib/analyze.mjs +1159 -0
  15. package/app/lib/cochange.mjs +421 -0
  16. package/app/lib/datanodes.mjs +127 -0
  17. package/app/lib/entry.mjs +192 -0
  18. package/app/lib/featuregraph.mjs +389 -0
  19. package/app/lib/features.mjs +645 -0
  20. package/app/lib/fetchrepo-run.mjs +37 -0
  21. package/app/lib/fetchrepo.mjs +164 -0
  22. package/app/lib/flow.mjs +1089 -0
  23. package/app/lib/ladder.mjs +387 -0
  24. package/app/lib/langs.mjs +630 -0
  25. package/app/lib/live.mjs +346 -0
  26. package/app/lib/llm.mjs +594 -0
  27. package/app/lib/newfile.mjs +126 -0
  28. package/app/lib/prdiff.mjs +651 -0
  29. package/app/lib/reveal.mjs +316 -0
  30. package/app/lib/roots.mjs +186 -0
  31. package/app/lib/scope.mjs +342 -0
  32. package/app/lib/session.mjs +389 -0
  33. package/app/lib/slots.mjs +233 -0
  34. package/app/lib/ssot.mjs +277 -0
  35. package/app/lib/teamview.mjs +962 -0
  36. package/app/lib/terms.ko.mjs +169 -0
  37. package/app/server.mjs +1959 -0
  38. package/app/web/shell.css +538 -0
  39. package/app/web/shell.html +197 -0
  40. package/app/web/shell.js +638 -0
  41. package/app/web/stage.js +347 -0
  42. package/app/web/words.js +85 -0
  43. package/bin/axmap.mjs +1918 -0
  44. package/governance/GOVERNANCE.md +433 -0
  45. package/governance/gate.mjs +526 -0
  46. package/governance/vote.mjs +501 -0
  47. package/mcp/README.md +254 -0
  48. package/mcp/SETUP-FOR-AI.md +186 -0
  49. package/mcp/install.ps1 +341 -0
  50. package/mcp/install.sh +339 -0
  51. package/mcp/server.mjs +969 -0
  52. package/package.json +48 -0
  53. package/src/closure.mjs +343 -0
  54. package/src/governance.mjs +839 -0
  55. package/src/invariants.mjs +226 -0
  56. package/src/mrtarget.mjs +284 -0
  57. package/src/promote.mjs +177 -0
  58. package/src/protocol.mjs +423 -0
  59. package/src/repotarget.mjs +81 -0
  60. package/src/update.mjs +177 -0
  61. package/src/version.mjs +186 -0
  62. package/tools/bus.mjs +520 -0
  63. package/tools/cluster-experiment.mjs +256 -0
  64. package/tools/cluster-sweep.mjs +226 -0
  65. package/tools/make-icon.mjs +108 -0
  66. package/tools/mcp-register.mjs +269 -0
  67. package/tools/mr-target.mjs +49 -0
  68. package/tools/persona-bench.mjs +362 -0
  69. package/tools/pick-repo.mjs +229 -0
  70. package/tools/promote.mjs +550 -0
  71. package/tools/reveal-demo.mjs +158 -0
  72. package/tools/run-tests.mjs +42 -0
  73. package/tools/setup.mjs +490 -0
  74. package/tools/shortcut.mjs +121 -0
  75. package/tools/smoke.mjs +166 -0
  76. package/tools/topicgraph.py +154 -0
  77. package/tools/vendor.mjs +382 -0
  78. package/tools/version.mjs +115 -0
@@ -0,0 +1,839 @@
1
+ /**
2
+ * 합의제 MR(**Merge Request — 내 브랜치의 변경을 다른 브랜치로 합쳐 달라는 요청**)의
3
+ * 순수 판정. 이 층이 답하는 질문은 **하나뿐**이다.
4
+ *
5
+ * "이 변경은 정족수(**통과에 필요한 최소 찬성 수**)를 채웠는가?"
6
+ *
7
+ * 리뷰 코멘트·담당자 배정·라벨은 GitLab 이 이미 한다. 여기서는 숫자 하나만 센다.
8
+ * 그 숫자가 무엇인지, 어떤 표가 왜 안 세졌는지를 사람과 에이전트가 함께 읽을 수
9
+ * 있는 형태로 돌려주는 것까지가 이 파일의 일이다.
10
+ *
11
+ * 🔴 여기에는 git 도 파일시스템도 `Date.now()` 도 없다. 시각은 인자로 받는다.
12
+ * `src/protocol.mjs` / `src/version.mjs` 와 같은 이유다 — 판정을 검증하려고
13
+ * 저장소를 만들거나 90일을 기다릴 수는 없다. 부수효과는 게이트
14
+ * (`governance/gate.mjs` — git 에서 재료를 모아 이 파일의 `judge()` 를 부르는
15
+ * 쪽. 2026-08-26 에 만들어졌다)가 전담한다.
16
+ *
17
+ * ── 왜 GitLab 이 아니라 저장소 안의 파일인가 ──────────────────────────────
18
+ *
19
+ * 표를 GitLab 의 Approve 버튼으로 세면, 이 저장소를 다른 곳으로 clone 한 사람에게는
20
+ * 이 층이 **존재하지 않는다.** 원격이 셋(`origin`·`personal`·`aws`)이고 오픈소스
21
+ * 이식이 목표인 저장소에서 그건 치명적이다. 파일로 두면 `git clone` 이 곧 이관이고,
22
+ * 표의 이력이 서버가 아니라 저장소에 남는다. 근거는 docs/DECISIONS.md 의 D18.
23
+ *
24
+ * ── 이 층이 지키는 성질 (G1~G5) ──────────────────────────────────────────
25
+ *
26
+ * G1 자기 표는 세지 않는다 — 소스 브랜치 커밋의 author 인 사람의 표는 무효
27
+ * G2 정책은 언제나 **타깃 브랜치**에서 읽는다 (이 파일의 밖 — 게이트의 책임)
28
+ * G3 표는 커밋(sha)에 묶인다 — 헤드가 바뀌면 효력을 잃는다. 사라지지는 않는다
29
+ * G4 한 사람은 한 표 — email 이 유일 키다
30
+ * G5 못 세면 통과가 아니다 — 판정 불가는 언제나 빨강
31
+ *
32
+ * G2 만 이 파일에 없다. 어느 브랜치에서 정책을 읽어오는가는 git 을 만지는 일이라
33
+ * 게이트가 한다. 대신 여기서는 **읽어온 정책이 성립하는가**만 본다.
34
+ */
35
+
36
+ import { normalizePath, pathsOverlap, coversPath } from './protocol.mjs'
37
+
38
+ /**
39
+ * 정책 파일의 **기본** 자리. `amendment.paths` 가 이 경로를 덮는지 검사하는 데 쓴다.
40
+ *
41
+ * 🔴 왜 `axmap/` 안이 아니라 저장소 루트인가 — **도구는 나가고 데이터는 남는다.**
42
+ * axMap 은 곧 자기 저장소로 떨어져 나가고, 그때 팀 저장소에서 `axmap/` 폴더는
43
+ * 사라진다. 그런데 "누가 투표권자인가" 는 도구가 정하는 것이 아니라 팀이
44
+ * 정하는 것이라 팀 저장소에 남아야 한다 — 장부 브랜치(`axmap/claims`)와
45
+ * 표 브랜치(`axmap/votes`)를 팀 저장소에 남기는 것과 같은 이유다.
46
+ *
47
+ * axMap 은 이제 범용 도구이므로 남의 저장소는 정책을 다른 자리에 둘 수 있다.
48
+ * 그래서 이것은 **기본값**이고, 실제 경로는 부르는 쪽이 정한다:
49
+ * `--policy <경로>` 플래그 → 환경변수 `AXMAP_POLICY_PATH` → 이 기본값.
50
+ * 이 층에서는 `validatePolicy(policy, { policyPath })` 로 받는다.
51
+ *
52
+ * 🔴 옛 자리를 자동으로 대신 찾아보는 폴백은 **없다.** 두 자리를 다 보면 어느
53
+ * 것이 진짜인지 아무도 모르게 된다 — 이 저장소가 "장부가 둘" 로 한 번 아프게
54
+ * 배운 모양이다. 못 찾으면 멈추고 어디를 봤는지 말한다.
55
+ */
56
+ export const DEFAULT_POLICY_PATH = 'governance/policy.json'
57
+
58
+ /**
59
+ * 종료 코드. SPEC.md §8 의 관례를 잇는다 (0 성공 / 1 환경 / 2 거부 / 4 깨짐).
60
+ *
61
+ * 🔴 **2 와 1 을 가르는 것이 이 층의 핵심이다.**
62
+ * 2 는 "사람이 아직 안 눌렀다" — 정상적인 상태다. 투표를 요청하면 된다.
63
+ * 1 은 "내가 판정을 못 했다" — 환경이 고장 났다. 고쳐야 한다.
64
+ * 둘을 뭉치면 에이전트가 고장을 "기다리면 되는 일" 로 읽고 영원히 기다린다.
65
+ */
66
+ export const EXIT = {
67
+ OK: 0, // 정족수 충족
68
+ UNDECIDABLE: 1, // 판정 불가 — 정책 못 읽음, 타깃 브랜치 모름, JSON 깨짐
69
+ SHORT: 2, // 정족수 미달 — 사람에게 투표를 요청한다
70
+ POLICY_BROKEN: 4, // 정책 자체가 깨짐 — 계층 위반, email 중복, threshold 상한 초과
71
+ }
72
+
73
+ /** 승계 설정을 안 적어 두었을 때의 기본값. D18 에서 정한 값이다. */
74
+ export const DEFAULT_SUCCESSION = { window_days: 90, top: 3 }
75
+
76
+ /**
77
+ * 판정을 **끝까지 못 간** 경우에만 던진다. 미달(2)은 예외가 아니라 정상 결과다.
78
+ *
79
+ * `exit` 를 예외에 붙여 두는 이유: 부르는 쪽(게이트)이 메시지를 다시 해석해서
80
+ * 코드를 고르면, 문구가 바뀌는 순간 판정이 조용히 달라진다.
81
+ */
82
+ export class GovernanceError extends Error {
83
+ constructor(message, { exit = EXIT.UNDECIDABLE, code = 'undecidable' } = {}) {
84
+ super(message)
85
+ this.name = 'GovernanceError'
86
+ this.exit = exit
87
+ this.code = code
88
+ }
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // 비교용 정규화
93
+ //
94
+ // 🔴 정규화한 값은 **비교에만** 쓰고 절대 되돌려 쓰지 않는다. 출력에는 언제나
95
+ // 원본이 나간다. 이 저장소가 락에서 지킨 "치환하지 말고 거부한다" 와 방향이
96
+ // 반대로 보이지만 그렇지 않다 — 락에서 치환이 위험했던 이유는 서로 다른 두
97
+ // 소유자를 하나로 만들기 때문이었다. 여기서는 대소문자만 접고, 접힌 두 신원이
98
+ // 실제로 존재하면 `validatePolicy` 가 **정책 단계에서 거부**한다(G4). 즉
99
+ // 충돌은 판정이 아니라 정책에서 터진다.
100
+ // ---------------------------------------------------------------------------
101
+
102
+ const fold = (v) => String(v ?? '').trim().toLowerCase()
103
+ const isInt = (v) => Number.isInteger(v)
104
+ const isStr = (v) => typeof v === 'string' && v.trim() !== ''
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // 과반(majority) 문턱
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * `threshold` 자리에 숫자 대신 적을 수 있는 값. **활성 투표권자의 과반**을 뜻한다.
112
+ *
113
+ * 문턱 = floor((작성자를 제외한 유효 투표권자 수) / 2) + 1
114
+ *
115
+ * 🔴 **분모에서 작성자를 뺀다.** 자기 표는 어차피 안 세는데(G1) 분모에 남겨 두면
116
+ * 투표권자가 둘일 때 과반이 2 라서 **영원히 못 채운다** — 한 명은 작성자라
117
+ * 셀 수 없기 때문이다. 명단이 둘인 팀에서는 첫날부터 막힌다.
118
+ *
119
+ * 🔴 승계로 들어온 사람은 **분모에 넣지 않는다.** 넣으면 "모자라서 부른 사람" 이
120
+ * 자기가 넘어야 할 문턱을 같이 올린다. 승계는 문턱을 채우는 쪽만 돕는다.
121
+ *
122
+ * 고정 숫자 대신 이것을 쓰는 이유: 명단이 늘고 줄 때마다 숫자를 손으로 고치면
123
+ * **고치는 것을 잊은 날** 문턱이 조용히 틀린 값이 된다.
124
+ */
125
+ export const MAJORITY = 'majority'
126
+
127
+ /** 정책에 적을 수 있는 문턱 값인가. 1 이상의 정수이거나 `"majority"`. */
128
+ const isThresholdValue = (v) => v === MAJORITY || (isInt(v) && v >= 1)
129
+
130
+ /**
131
+ * 계층 비교(개정 ≥ 기본 ≥ 규칙)에서의 크기.
132
+ * `"majority"` 는 **가장 높은 값**으로 친다 — 명단이 커지면 실제로 어떤 고정
133
+ * 숫자보다도 커질 수 있고, 계층 검사는 fail-closed 여야 하기 때문이다.
134
+ */
135
+ const thresholdRank = (v) => (v === MAJORITY ? Infinity : v)
136
+
137
+ /**
138
+ * 과반 문턱을 실제 숫자로 만든다.
139
+ *
140
+ * @param {Array} voters 정책 명단. 승계로 들어온 사람은 넣지 않는다 (위 주석)
141
+ * @param {string[]} authorEmails 소스 브랜치 커밋의 author email
142
+ */
143
+ export function majorityThreshold(voters, authorEmails) {
144
+ if (!Array.isArray(voters) || voters.length === 0) {
145
+ throw new GovernanceError('정책에 투표권자가 없어 과반을 셀 수 없습니다.', { exit: EXIT.POLICY_BROKEN, code: 'voters-missing' })
146
+ }
147
+ if (!Array.isArray(authorEmails) || authorEmails.length === 0 || !authorEmails.every(isStr)) {
148
+ // 작성자를 모르면 분모를 정할 수 없다. 미달이 아니라 판정 불가다 (G5).
149
+ throw new GovernanceError(
150
+ '과반을 세려면 소스 브랜치 커밋의 author email 이 필요합니다. 작성자를 못 빼면 문턱이 틀립니다.',
151
+ { code: 'authors-missing' },
152
+ )
153
+ }
154
+ const authors = new Set(authorEmails.map(fold))
155
+ const eligible = voters.filter((v) => !authors.has(fold(v?.email)))
156
+ // 명단이 전부 작성자라도 **0 으로 내려가지 않는다.** 문턱 0 은 문턱이 아니라
157
+ // 이 층이 없는 것이다. 그때는 승계로 들어온 남이 한 표를 줘야 통과한다.
158
+ return Math.floor(eligible.length / 2) + 1
159
+ }
160
+
161
+ /** 이 정책이 어디에서든 `"majority"` 를 쓰는가. */
162
+ function usesMajority(policy) {
163
+ if (policy?.default?.threshold === MAJORITY) return true
164
+ if (policy?.amendment?.threshold === MAJORITY) return true
165
+ return (policy?.rules ?? []).some((r) => r?.threshold === MAJORITY)
166
+ }
167
+
168
+ /** git 이 주는 커밋 해시. 축약형(7자)부터 전체(40자)까지 받는다. */
169
+ const SHA_RE = /^[0-9a-f]{7,40}$/
170
+
171
+ /**
172
+ * 두 sha 가 같은 커밋을 가리키는가.
173
+ *
174
+ * 표 파일 이름에는 8자만 들어가고(`<voter>-<sha8>.json`) 파이프라인이 주는 값은
175
+ * 40자 전체다. 그래서 접두사 비교를 허용하되 **8자 미만은 안 받는다** — 7자
176
+ * 접두사는 큰 저장소에서 실제로 충돌한 적이 있고, 표가 엉뚱한 커밋에 붙는 것은
177
+ * G3 가 막으려던 바로 그 일이다.
178
+ */
179
+ export function shaMatches(a, b) {
180
+ const x = fold(a)
181
+ const y = fold(b)
182
+ if (!SHA_RE.test(x) || !SHA_RE.test(y)) return false
183
+ if (x === y) return true
184
+ const [short, long] = x.length <= y.length ? [x, y] : [y, x]
185
+ return short.length >= 8 && long.startsWith(short)
186
+ }
187
+
188
+ /** ISO 문자열이든 ms 숫자든 ms 로. 못 읽으면 **0 으로 치지 않고 던진다.** */
189
+ function toMs(v, what) {
190
+ if (typeof v === 'number' && Number.isFinite(v)) return v
191
+ const t = Date.parse(String(v ?? ''))
192
+ if (Number.isNaN(t)) {
193
+ throw new GovernanceError(`시각을 읽을 수 없습니다 (${what}): ${JSON.stringify(v)}`)
194
+ }
195
+ return t
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // 1. validatePolicy — 정책 자체가 성립하는가
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /**
203
+ * 정책 검사. 문제를 **하나 찾고 멈추지 않고 전부 모아서** 돌려준다.
204
+ * 한 번에 하나씩 고치게 하면 사람이 네 번 왕복한다.
205
+ *
206
+ * @param {object} policy 타깃 브랜치에서 읽어온 정책 객체 (G2 는 게이트가 지킨다)
207
+ * @param {{policyPath?: string}} [opts]
208
+ * @returns {{ok: boolean, exit: number, problems: Array<{code:string,message:string,exit:number}>}}
209
+ */
210
+ export function validatePolicy(policy, { policyPath = DEFAULT_POLICY_PATH } = {}) {
211
+ const problems = []
212
+ const add = (code, message, exit = EXIT.POLICY_BROKEN) => problems.push({ code, message, exit })
213
+ const done = () => {
214
+ // 🔴 "못 읽었다"(1) 가 "깨졌다"(4) 보다 근본적이다. 정책이 아예 없으면
215
+ // 계층이 맞는지 따지는 것 자체가 무의미하므로 1 이 이긴다.
216
+ const exit = problems.length === 0
217
+ ? EXIT.OK
218
+ : (problems.some((p) => p.exit === EXIT.UNDECIDABLE) ? EXIT.UNDECIDABLE : EXIT.POLICY_BROKEN)
219
+ return { ok: problems.length === 0, exit, problems }
220
+ }
221
+
222
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) {
223
+ add('policy-missing', `정책이 객체가 아닙니다: ${JSON.stringify(policy)}`, EXIT.UNDECIDABLE)
224
+ return done()
225
+ }
226
+
227
+ // ── 투표권자 ──────────────────────────────────────────────────────────
228
+ const voters = policy.voters
229
+ let voterCount = null
230
+ if (!Array.isArray(voters) || voters.length === 0) {
231
+ add('voters-missing', '`voters` 가 비어 있습니다. 투표권자가 없으면 셀 수 있는 것이 없습니다.')
232
+ } else {
233
+ voterCount = voters.length
234
+ const seenId = new Map()
235
+ const seenEmail = new Map()
236
+ voters.forEach((v, i) => {
237
+ if (!v || typeof v !== 'object' || !isStr(v.id) || !isStr(v.email)) {
238
+ add('voter-malformed', `voters[${i}] 에 id 나 email 이 없습니다: ${JSON.stringify(v)}`)
239
+ return
240
+ }
241
+ if (!v.email.includes('@')) {
242
+ add('voter-email-malformed', `voters[${i}] 의 email 이 email 이 아닙니다: ${v.email}`)
243
+ }
244
+ const id = fold(v.id)
245
+ const email = fold(v.email)
246
+ // G4 · 한 사람은 한 표. 같은 사람이 두 줄로 들어가 있으면 두 표가 된다.
247
+ // 대소문자만 다른 중복도 잡는다 — git 신원은 대소문자가 흔들린다.
248
+ if (seenId.has(id)) add('voter-id-duplicate', `투표권자 id 가 중복입니다: ${v.id} (voters[${seenId.get(id)}] 과 voters[${i}])`)
249
+ else seenId.set(id, i)
250
+ if (seenEmail.has(email)) add('voter-email-duplicate', `투표권자 email 이 중복입니다: ${v.email} (voters[${seenEmail.get(email)}] 과 voters[${i}])`)
251
+ else seenEmail.set(email, i)
252
+ })
253
+ }
254
+
255
+ // ── 문턱 세 자리 ──────────────────────────────────────────────────────
256
+ const readThreshold = (label, holder) => {
257
+ if (!holder || typeof holder !== 'object') {
258
+ add('threshold-missing', `\`${label}\` 이 없습니다.`)
259
+ return null
260
+ }
261
+ const t = holder.threshold
262
+ // `"majority"` 는 명단에서 계산되는 값이라 여기서 상한을 따질 것이 없다.
263
+ if (t === MAJORITY) return t
264
+ if (!isInt(t) || t < 1) {
265
+ // 정족수 0 은 정족수가 아니라 **이 층이 없는 것**이다. 그렇게 하고 싶으면
266
+ // CI 잡을 지우는 것이 정직하고, 그건 이력에 남는다. 조용히 0 으로 두면
267
+ // 아무도 이 층이 꺼진 줄 모른 채 보호받고 있다고 믿는다.
268
+ add('threshold-invalid', `\`${label}.threshold\` 는 1 이상의 정수이거나 "majority" 여야 합니다: ${JSON.stringify(t)}`)
269
+ return null
270
+ }
271
+ if (voterCount !== null && t > voterCount) {
272
+ // 채울 수 없는 문턱은 "아직 아니다" 가 아니라 "영원히 아니다" 다.
273
+ add('threshold-over-voters', `\`${label}.threshold\` (${t}) 가 투표권자 수(${voterCount})보다 큽니다. 채울 수 없는 문턱입니다.`)
274
+ return null
275
+ }
276
+ return t
277
+ }
278
+
279
+ const dflt = readThreshold('default', policy.default)
280
+ const amendment = readThreshold('amendment', policy.amendment)
281
+
282
+ // ── 파트별 규칙 ──────────────────────────────────────────────────────
283
+ const rules = policy.rules ?? []
284
+ const ruleThresholds = []
285
+ if (!Array.isArray(rules)) {
286
+ add('rules-malformed', '`rules` 는 배열이어야 합니다.')
287
+ } else {
288
+ rules.forEach((r, i) => {
289
+ if (!r || typeof r !== 'object' || !Array.isArray(r.paths) || r.paths.length === 0) {
290
+ add('rule-malformed', `rules[${i}] 에 paths 가 없습니다: ${JSON.stringify(r)}`)
291
+ return
292
+ }
293
+ if (!r.paths.every(isStr)) {
294
+ add('rule-path-malformed', `rules[${i}].paths 에 문자열이 아닌 항목이 있습니다: ${JSON.stringify(r.paths)}`)
295
+ }
296
+ const t = readThreshold(`rules[${i}]`, r)
297
+ if (t !== null) ruleThresholds.push({ i, t })
298
+ })
299
+ }
300
+
301
+ // ── amendment.paths 는 정책 파일 자신을 덮어야 한다 ────────────────────
302
+ //
303
+ // 안 덮으면 정책을 고치는 MR 이 **기본 문턱**으로 통과한다. threshold 를 1 로
304
+ // 낮추는 변경이 1표로 지나가면 이 층은 그 순간 끝난다.
305
+ const aPaths = policy.amendment?.paths
306
+ if (!Array.isArray(aPaths) || aPaths.length === 0 || !aPaths.every(isStr)) {
307
+ add('amendment-paths-missing', '`amendment.paths` 가 비어 있거나 문자열이 아닌 항목이 있습니다.')
308
+ } else if (!coversPath(aPaths, policyPath)) {
309
+ add(
310
+ 'amendment-not-self-covering',
311
+ `\`amendment.paths\` 가 정책 파일 자신(${policyPath})을 덮지 않습니다: ${JSON.stringify(aPaths)}\n`
312
+ + ' 정책을 고치는 변경이 기본 문턱으로 통과하게 됩니다.',
313
+ )
314
+ }
315
+
316
+ // ── 계층: amendment ≥ default ≥ rules ─────────────────────────────────
317
+ //
318
+ // default 가 어떤 규칙보다 낮으면 **새 폴더를 만드는 것이 기존 폴더를 고치는
319
+ // 것보다 쉬워진다.** 규칙이 붙지 않은 새 경로는 기본값으로 판정되기 때문이다.
320
+ if (amendment !== null && dflt !== null && thresholdRank(amendment) < thresholdRank(dflt)) {
321
+ add('hierarchy-amendment', `\`amendment.threshold\` (${amendment}) 가 \`default.threshold\` (${dflt}) 보다 낮습니다.`)
322
+ }
323
+ if (dflt !== null) {
324
+ for (const { i, t } of ruleThresholds) {
325
+ if (thresholdRank(t) > thresholdRank(dflt)) {
326
+ add('hierarchy-rule', `\`rules[${i}].threshold\` (${t}) 가 \`default.threshold\` (${dflt}) 보다 높습니다. 그러면 규칙 없는 새 경로가 더 싸집니다.`)
327
+ }
328
+ }
329
+ }
330
+
331
+ // ── 규칙이 개정 경로에 걸치면 거부한다 ─────────────────────────────────
332
+ //
333
+ // 판정은 최댓값을 쓰므로 실제로 뚫리지는 않는다. 그런데 정책을 읽는 사람은
334
+ // "이 경로는 이 규칙의 문턱이다" 라고 믿는다. **거버넌스에서 믿음과 동작이
335
+ // 어긋나면 그것이 곧 우회로다.** 순서에 기대지 않고 정책 단계에서 막는다.
336
+ if (Array.isArray(aPaths) && Array.isArray(rules)) {
337
+ rules.forEach((r, i) => {
338
+ if (!r || !Array.isArray(r.paths)) return
339
+ for (const rp of r.paths) {
340
+ if (!isStr(rp)) continue
341
+ for (const ap of aPaths) {
342
+ if (!isStr(ap)) continue
343
+ if (pathsOverlap(rp, ap)) {
344
+ add('rule-overlaps-amendment', `rules[${i}].paths 의 ${rp} 가 개정 경로 ${ap} 와 겹칩니다. 개정 문턱이 이기므로 규칙이 거짓말을 하게 됩니다.`)
345
+ }
346
+ }
347
+ }
348
+ })
349
+ }
350
+
351
+ // ── 승계 설정 ────────────────────────────────────────────────────────
352
+ const s = policy.succession
353
+ if (s !== undefined && s !== null) {
354
+ if (typeof s !== 'object' || Array.isArray(s)) {
355
+ add('succession-malformed', '`succession` 은 객체여야 합니다.')
356
+ } else {
357
+ if (!isInt(s.window_days) || s.window_days < 1) {
358
+ add('succession-window', `\`succession.window_days\` 는 1 이상의 정수여야 합니다: ${JSON.stringify(s.window_days)}`)
359
+ }
360
+ if (!isInt(s.top) || s.top < 0) {
361
+ add('succession-top', `\`succession.top\` 은 0 이상의 정수여야 합니다: ${JSON.stringify(s.top)}`)
362
+ }
363
+ }
364
+ }
365
+
366
+ return done()
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // 2. rulesFor — 이 변경에는 몇 표가 필요한가
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * 경로 하나에 걸리는 문턱.
375
+ *
376
+ * 🔴 경로 매칭을 새로 만들지 않는다. `protocol.mjs` 의 `coversPath` 를 쓴다 —
377
+ * "규칙 경로가 이 파일을 덮는가" 는 pre-commit 이 "claim 이 이 파일을 덮는가"
378
+ * 를 묻는 것과 **같은 질문**이다. 한 저장소에 경로 규칙이 둘이면 어느 쪽이
379
+ * 맞는지 아무도 모르게 된다.
380
+ */
381
+ function thresholdForPath(p, policy, majority) {
382
+ const candidates = []
383
+ const matched = []
384
+
385
+ /**
386
+ * `"majority"` 를 실제 숫자로 바꾼다. 여기서 바꾸는 이유: 최댓값 비교를 숫자와
387
+ * 문자열이 섞인 채로 하면 규칙 하나가 문자열이라는 이유로 이기거나 지는,
388
+ * 아무도 설명 못 할 판정이 나온다.
389
+ */
390
+ const resolve = (t) => (t === MAJORITY ? majority : t)
391
+
392
+ const aPaths = policy.amendment?.paths ?? []
393
+ const isAmendment = coversPath(aPaths, p)
394
+ if (isAmendment) {
395
+ candidates.push(resolve(policy.amendment.threshold))
396
+ // 개정 경로에서는 기본값도 함께 센다. 검증을 안 거친 정책이 개정 문턱을
397
+ // 기본값 아래로 적어 두더라도 기본값 밑으로는 안 내려가게 하기 위해서다.
398
+ if (isThresholdValue(policy.default?.threshold)) candidates.push(resolve(policy.default.threshold))
399
+ }
400
+
401
+ for (const r of policy.rules ?? []) {
402
+ if (!r || !Array.isArray(r.paths)) continue
403
+ if (coversPath(r.paths, p)) {
404
+ candidates.push(resolve(r.threshold))
405
+ matched.push(r)
406
+ }
407
+ }
408
+
409
+ // 아무것도 안 걸리면 기본값. 규칙이 걸리면 **걸린 것들의 최댓값**이다.
410
+ // 최솟값이 아니라 최댓값인 이유가 fail-closed(**판단이 갈리면 안전한 쪽으로
411
+ // 닫는다**) 다 — 규칙 둘이 겹칠 때
412
+ // 싼 쪽을 고르면, 규칙을 하나 더 얹는 것이 문턱을 낮추는 수단이 된다.
413
+ if (candidates.length === 0) candidates.push(resolve(policy.default?.threshold))
414
+
415
+ const valid = candidates.filter(isInt)
416
+ if (valid.length === 0) {
417
+ throw new GovernanceError(
418
+ `${p} 에 적용할 문턱을 찾지 못했습니다. 정책의 default.threshold 를 확인하세요.`,
419
+ { exit: EXIT.POLICY_BROKEN, code: 'threshold-unresolved' },
420
+ )
421
+ }
422
+ const threshold = Math.max(...valid)
423
+ const source = isAmendment && threshold === resolve(policy.amendment.threshold)
424
+ ? 'amendment'
425
+ : (matched.length ? 'rules' : 'default')
426
+ return { path: normalizePath(p), threshold, source, matched }
427
+ }
428
+
429
+ /**
430
+ * 이 MR 에 필요한 찬성 수. 바뀐 경로 전부를 보고 **가장 높은 문턱**을 고른다.
431
+ *
432
+ * @param {string[]} paths 바뀐 파일 경로들 (`git diff --name-only`)
433
+ * @param {object} policy
434
+ * @param {{majority?: number|null}} [opts] `"majority"` 를 대신할 숫자.
435
+ * `judge` 가 `majorityThreshold()` 로 미리 계산해서 넣어 준다. 정책이
436
+ * `"majority"` 를 안 쓰면 필요 없다.
437
+ * @returns {{threshold:number, source:string, top:object, perPath:object[], amendment:boolean}}
438
+ */
439
+ export function rulesFor(paths, policy, { majority = null } = {}) {
440
+ if (!policy || typeof policy !== 'object') {
441
+ throw new GovernanceError('정책이 없어 문턱을 정할 수 없습니다.', { code: 'policy-missing' })
442
+ }
443
+ if (!Array.isArray(paths)) {
444
+ throw new GovernanceError('바뀐 경로 목록이 배열이 아닙니다.', { code: 'changed-missing' })
445
+ }
446
+ if (paths.length === 0) {
447
+ // 🔴 빈 diff 를 "아무 규칙도 안 걸림 → 기본값" 으로 처리하지 않는다.
448
+ // 실무에서 목록이 비는 원인은 "안 바뀐 MR" 이 아니라 **게이트가 타깃
449
+ // 브랜치를 못 찾아 diff 를 못 뜬 것**이다. 그건 판정 불가(1)지 미달(2)이
450
+ // 아니다. 환경을 고치라고 말해야 사람이 고친다.
451
+ throw new GovernanceError(
452
+ '바뀐 경로가 하나도 없습니다. 게이트가 타깃 브랜치를 못 찾아 diff 를 못 떴을 수 있습니다.',
453
+ { code: 'empty-diff' },
454
+ )
455
+ }
456
+
457
+ if (usesMajority(policy) && !isInt(majority)) {
458
+ // 과반을 쓰는 정책인데 분모를 못 받았다. 그럴듯한 숫자로 때우지 않는다 —
459
+ // 문턱이 틀리면 그 판정은 아무것도 지키지 않는다 (G5).
460
+ throw new GovernanceError(
461
+ '정책이 "majority" 를 쓰는데 과반 인원을 받지 못했습니다. rulesFor(paths, policy, { majority }) 로 넘기세요.',
462
+ { code: 'majority-unresolved' },
463
+ )
464
+ }
465
+ const perPath = paths.map((p) => thresholdForPath(p, policy, majority))
466
+ let top = perPath[0]
467
+ for (const e of perPath) if (e.threshold > top.threshold) top = e
468
+ return {
469
+ threshold: top.threshold,
470
+ source: top.source,
471
+ amendment: perPath.some((e) => e.source === 'amendment'),
472
+ // 과반으로 정해졌다는 사실은 출력에 드러나야 한다. 숫자만 보면 왜 2 표인지
473
+ // 아무도 모르고, 명단이 바뀌면 그 숫자가 조용히 달라진다.
474
+ majority: usesMajority(policy) ? majority : null,
475
+ top,
476
+ perPath,
477
+ }
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // 3. deriveVoters — 승계
482
+ // ---------------------------------------------------------------------------
483
+
484
+ /**
485
+ * 유효 투표권자를 정한다. 살아 있는 사람이 필요 표보다 적으면 **승계**가 돈다.
486
+ *
487
+ * 승계 = 최근 `window_days` 안에 커밋한 author 상위 `top` 명이 임시 투표권을 갖는 것.
488
+ *
489
+ * 🔴 **승계가 발동한 사실은 반드시 출력에 드러난다** (`formatVerdict` 가 찍는다).
490
+ * 조용히 발동하는 승계는 승계가 아니라 우회로다. 그래서 이 함수는 `triggered`
491
+ * 를 결과에 넣고, 포맷터에는 그 줄을 빼는 길을 두지 않았다.
492
+ *
493
+ * 🔴 정책에 적힌 사람은 **살아 있지 않아도 투표권을 잃지 않는다.** 생사는 승계를
494
+ * 켤지만 정한다. 두 달 자리를 비운 사람이 돌아와 던진 표가 안 세지면, 그건
495
+ * fail-closed 가 아니라 그냥 틀린 판정이다.
496
+ *
497
+ * 자기 표 배제(G1)는 여기서 하지 않는다 — `countVotes` 한 곳에만 둔다.
498
+ * 규칙이 두 군데 있으면 한 쪽만 고쳐지는 날이 온다.
499
+ *
500
+ * @param {object} args.policy
501
+ * @param {Array|null} args.contributors 커밋 하나가 항목 하나: `{email, name?, at}`.
502
+ * 승계가 필요한데 `null` 이면 판정 불가(1) 다 — 모르면서 넘기지 않는다.
503
+ * @param {number|string} args.now
504
+ * @param {number|null} [args.threshold] 이번 MR 의 필요 표. 없으면 `default.threshold`
505
+ */
506
+ export function deriveVoters({ policy, contributors, now, threshold = null }) {
507
+ const base = policy?.voters
508
+ if (!Array.isArray(base) || base.length === 0) {
509
+ throw new GovernanceError('정책에 투표권자가 없습니다.', { exit: EXIT.POLICY_BROKEN, code: 'voters-missing' })
510
+ }
511
+ const need = threshold ?? policy?.default?.threshold
512
+ if (!isInt(need) || need < 1) {
513
+ throw new GovernanceError(`필요 표를 정할 수 없습니다: ${JSON.stringify(need)}`, { exit: EXIT.POLICY_BROKEN, code: 'threshold-unresolved' })
514
+ }
515
+
516
+ const cfg = policy.succession ?? DEFAULT_SUCCESSION
517
+ const windowDays = isInt(cfg.window_days) ? cfg.window_days : DEFAULT_SUCCESSION.window_days
518
+ const topN = isInt(cfg.top) ? cfg.top : DEFAULT_SUCCESSION.top
519
+ const nowMs = toMs(now, 'now')
520
+ const since = nowMs - windowDays * 86400000
521
+
522
+ const roster = base.map((v) => ({ id: v.id, email: v.email, via: 'policy' }))
523
+ const byEmail = new Set(roster.map((v) => fold(v.email)))
524
+
525
+ /** 창 안에 커밋이 있는 사람. `contributors` 를 못 받았으면 판단을 미룬다. */
526
+ const recent = new Map() // fold(email) -> {email, name, commits, lastAt}
527
+ if (Array.isArray(contributors)) {
528
+ for (const c of contributors) {
529
+ if (!c || !isStr(c.email)) {
530
+ throw new GovernanceError(`기여 이력 항목에 email 이 없습니다: ${JSON.stringify(c)}`, { code: 'contributor-malformed' })
531
+ }
532
+ const at = toMs(c.at, 'contributors[].at')
533
+ if (at < since || at > nowMs) continue
534
+ const k = fold(c.email)
535
+ const hit = recent.get(k) ?? { email: c.email, name: c.name ?? null, commits: 0, lastAt: 0 }
536
+ hit.commits += 1
537
+ if (at > hit.lastAt) hit.lastAt = at
538
+ if (!hit.name && c.name) hit.name = c.name
539
+ recent.set(k, hit)
540
+ }
541
+ }
542
+
543
+ const alive = roster.filter((v) => recent.has(fold(v.email)))
544
+ const triggered = alive.length < need
545
+
546
+ let succeeded = []
547
+ if (triggered) {
548
+ if (!Array.isArray(contributors)) {
549
+ // 승계를 따져야 하는데 이력이 없다. 넘겨받지 못한 것을 "이력이 없다" 로
550
+ // 읽으면(= bus.mjs 의 `catch { return [] }`) 살아 있는 저장소가 죽은 것으로
551
+ // 보인다. 못 세면 통과가 아니다(G5) — 그리고 이건 미달이 아니라 판정 불가다.
552
+ throw new GovernanceError(
553
+ '승계를 판정해야 하는데 최근 기여 이력을 받지 못했습니다 (contributors=null).',
554
+ { code: 'contributors-missing' },
555
+ )
556
+ }
557
+ succeeded = [...recent.values()]
558
+ .filter((c) => !byEmail.has(fold(c.email)))
559
+ // 커밋 수 → 최근순 → email 순. 마지막 두 단계는 **결과를 결정론적으로**
560
+ // 만들기 위한 것이다. 같은 입력에 다른 답이 나오는 거버넌스는 못 쓴다.
561
+ .sort((a, b) => (b.commits - a.commits) || (b.lastAt - a.lastAt) || (fold(a.email) < fold(b.email) ? -1 : 1))
562
+ .slice(0, topN)
563
+ .map((c) => ({ id: c.name ?? c.email, email: c.email, via: 'succession', commits: c.commits }))
564
+ }
565
+
566
+ const voters = [...roster, ...succeeded]
567
+ const reachable = alive.length + succeeded.length
568
+ return {
569
+ voters,
570
+ base: roster,
571
+ alive,
572
+ succeeded,
573
+ triggered,
574
+ need,
575
+ // 승계로도 못 채운 몫. 0 이 아니면 이 저장소는 **막혀 있다.**
576
+ // 탈출구를 만들지 않는다 — 오픈소스에서 그 상태의 정답은 fork 다.
577
+ short: Math.max(0, need - reachable),
578
+ window: { days: windowDays, since: new Date(since).toISOString(), top: topN },
579
+ }
580
+ }
581
+
582
+ // ---------------------------------------------------------------------------
583
+ // 4. countVotes — 유효 찬성 수와 무효 사유
584
+ // ---------------------------------------------------------------------------
585
+
586
+ /**
587
+ * 무효 사유 → 사람이 읽는 한 줄.
588
+ *
589
+ * committer — **그 커밋을 실제로 기록한 git 신원.** 표에 적힌 email 과 대조하는 값이라
590
+ * 표를 쓰는 사람이 채우면 대조가 아니라 자기 신고가 된다. 그래서 게이트가 채운다.
591
+ */
592
+ export const REASONS = {
593
+ 'wrong-branch': '다른 브랜치의 표',
594
+ 'stale-sha': '헤드가 바뀐 뒤라 효력 없음 (G3)',
595
+ 'not-a-voter': '투표권자가 아님',
596
+ 'identity-mismatch': '표의 이름과 email 이 명단과 어긋남',
597
+ 'committer-unknown': '표를 만든 커밋의 committer 를 확인 못 함',
598
+ 'committer-mismatch': '표의 email 과 커밋 committer 가 다름 (위조 의심)',
599
+ 'self-vote': '자기 표 (G1)',
600
+ 'future-dated': '미래 시각으로 적힌 표',
601
+ duplicate: '같은 사람의 두 번째 표 (G4)',
602
+ }
603
+
604
+ /** 시계가 조금 어긋난 것까지 위조로 몰지 않는다. 하루면 충분히 넉넉하다. */
605
+ const FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000
606
+
607
+ /**
608
+ * 표를 센다.
609
+ *
610
+ * @param {object} args.policy `voters` 를 따로 안 넘길 때의 명단 출처
611
+ * @param {Array} args.votes 표 레코드들. **파일 하나 = 표 하나**
612
+ * @param {string[]} args.authorEmails 소스 브랜치 커밋들의 author email (G1 용)
613
+ * @param {string} args.sha 머지 대상 커밋
614
+ * @param {number|string} args.now
615
+ * @param {Array|null} [args.voters] `deriveVoters` 결과. 없으면 `policy.voters`
616
+ * @param {string|null} [args.branch] 소스 브랜치 이름. 주면 표의 branch 와 대조한다
617
+ * @returns {{approvals:number, counted:Array, rejections:Array, invalid:Array}}
618
+ */
619
+ export function countVotes({ policy, votes, authorEmails, sha, now, voters = null, branch = null }) {
620
+ const roster = voters ?? policy?.voters ?? null
621
+ if (!Array.isArray(roster) || roster.length === 0) {
622
+ throw new GovernanceError('투표권자 명단이 없습니다.', { exit: EXIT.POLICY_BROKEN, code: 'voters-missing' })
623
+ }
624
+ if (!Array.isArray(votes)) {
625
+ // 🔴 `tools/bus.mjs` 의 모양(파일 하나 = 레코드 하나)만 가져오고 그 파일의
626
+ // `catch { return [] }` 는 가져오지 않는다. 쪽지에서는 못 읽은 것과 없는
627
+ // 것을 같게 둬도 되지만, 표에서 그건 fail-open(**애매하면 통과시킨다**) 이다
628
+ // — 표가 통째로 사라진
629
+ // MR 이 "0표" 로 보이고, 그 자리에 깨진 표가 통과할 틈이 생긴다.
630
+ throw new GovernanceError('표 목록을 받지 못했습니다 (votes 가 배열이 아님).', { code: 'votes-missing' })
631
+ }
632
+ if (!SHA_RE.test(fold(sha))) {
633
+ throw new GovernanceError(`머지 대상 커밋(sha)을 읽을 수 없습니다: ${JSON.stringify(sha)}`, { code: 'sha-missing' })
634
+ }
635
+ if (!Array.isArray(authorEmails) || authorEmails.length === 0 || !authorEmails.every(isStr)) {
636
+ // 자기 표를 못 거르면(G1) 셀 자격이 없다. 미달이 아니라 판정 불가다.
637
+ throw new GovernanceError(
638
+ '소스 브랜치 커밋의 author email 을 받지 못했습니다. 자기 표를 걸러낼 수 없으면 세지 않습니다 (G1).',
639
+ { code: 'authors-missing' },
640
+ )
641
+ }
642
+
643
+ const nowMs = toMs(now, 'now')
644
+ const authors = new Set(authorEmails.map(fold))
645
+ const byEmail = new Map(roster.map((v) => [fold(v.email), v]))
646
+
647
+ const counted = []
648
+ const rejections = []
649
+ const invalid = []
650
+ const seen = new Map() // fold(email) -> 이미 센 표
651
+
652
+ for (const raw of votes) {
653
+ const v = readVote(raw) // 깨진 레코드는 여기서 던진다 (exit 1)
654
+ const email = fold(v.email)
655
+ const bad = (reason, detail = null) => invalid.push({ voter: v.voter, email: v.email, vote: v.vote, reason, detail })
656
+
657
+ if (branch && fold(v.branch) !== fold(branch)) { bad('wrong-branch', v.branch); continue }
658
+ // G3 · 표는 커밋에 묶인다. 만료된 claim 과 같은 취급이다 — 레코드는 남고
659
+ // 효력만 잃는다. 그래서 지우지 않고 무효 목록에 **보여준다.**
660
+ if (!shaMatches(v.sha, sha)) { bad('stale-sha', v.sha); continue }
661
+
662
+ const voter = byEmail.get(email)
663
+ if (!voter) { bad('not-a-voter'); continue }
664
+ // 정책에 적힌 사람은 id 까지 맞아야 한다. 승계로 들어온 사람은 정책에 id 가
665
+ // 없으므로(이름을 저장소가 정해 준 적이 없다) email 만 본다.
666
+ if (voter.via !== 'succession' && fold(voter.id) !== fold(v.voter)) {
667
+ bad('identity-mismatch', `명단은 ${voter.id}`); continue
668
+ }
669
+
670
+ if (!isStr(v.committerEmail)) { bad('committer-unknown'); continue }
671
+ if (fold(v.committerEmail) !== email) { bad('committer-mismatch', v.committerEmail); continue }
672
+
673
+ if (authors.has(email)) { bad('self-vote'); continue } // G1
674
+ if (toMs(v.at, 'votes[].at') > nowMs + FUTURE_TOLERANCE_MS) { bad('future-dated', v.at); continue }
675
+ if (seen.has(email)) { bad('duplicate', `이미 센 표: ${seen.get(email).voter}`); continue } // G4
676
+
677
+ seen.set(email, v)
678
+ if (v.vote === 'approve') counted.push(v)
679
+ // 반대는 정족수 계산에 들어가지 않는다. 이 층이 답하는 질문은 "찬성이 몇인가"
680
+ // 하나뿐이고, 거부권은 다른 제도다 — 만들려면 SPEC 부터 고쳐야 한다.
681
+ else rejections.push(v)
682
+ }
683
+
684
+ return { approvals: counted.length, counted, rejections, invalid }
685
+ }
686
+
687
+ /**
688
+ * 표 레코드 한 장을 읽는다. **못 읽으면 건너뛰지 않고 전체를 중단한다.**
689
+ * `CLAUDE.md` 의 fail-closed 규칙 그대로다 — 건너뛰면 깨진 표가 "없는 표" 가 되고,
690
+ * 없는 표는 아무 경고도 만들지 않는다.
691
+ */
692
+ function readVote(raw) {
693
+ const where = () => JSON.stringify(raw)?.slice(0, 200)
694
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
695
+ throw new GovernanceError(`표 레코드가 객체가 아닙니다: ${where()}`, { code: 'vote-malformed' })
696
+ }
697
+ for (const k of ['voter', 'email', 'sha', 'vote', 'at']) {
698
+ if (!isStr(raw[k])) throw new GovernanceError(`표에 \`${k}\` 가 없습니다: ${where()}`, { code: 'vote-malformed' })
699
+ }
700
+ if (!raw.email.includes('@')) {
701
+ throw new GovernanceError(`표의 email 이 email 이 아닙니다: ${raw.email}`, { code: 'vote-malformed' })
702
+ }
703
+ if (!SHA_RE.test(fold(raw.sha))) {
704
+ throw new GovernanceError(`표의 sha 를 읽을 수 없습니다: ${raw.sha}`, { code: 'vote-malformed' })
705
+ }
706
+ if (raw.vote !== 'approve' && raw.vote !== 'reject') {
707
+ throw new GovernanceError(`표의 vote 는 approve 나 reject 여야 합니다: ${JSON.stringify(raw.vote)}`, { code: 'vote-malformed' })
708
+ }
709
+ toMs(raw.at, 'votes[].at') // 못 읽으면 여기서 던진다
710
+ if (raw.branch !== undefined && !isStr(raw.branch)) {
711
+ throw new GovernanceError(`표의 branch 가 문자열이 아닙니다: ${JSON.stringify(raw.branch)}`, { code: 'vote-malformed' })
712
+ }
713
+ return raw
714
+ }
715
+
716
+ // ---------------------------------------------------------------------------
717
+ // 5. judge — 위의 넷을 묶어 종료 코드까지
718
+ // ---------------------------------------------------------------------------
719
+
720
+ /**
721
+ * 판정 전체. 게이트는 git 으로 재료를 모아 이 함수를 한 번 부르고, 결과의
722
+ * `exit` 를 그대로 종료 코드로 쓴다.
723
+ *
724
+ * 🔴 종료 코드를 여기(순수 로직)에 둔 이유: 이 층의 계약이 곧 종료 코드이고,
725
+ * 계약은 테스트할 수 있는 자리에 있어야 한다. 게이트가 메시지를 보고 코드를
726
+ * 고르면 문구를 다듬는 커밋이 판정을 바꾼다.
727
+ */
728
+ export function judge({
729
+ policy, changed, votes, authorEmails, sha, contributors = null, now, branch = null, policyPath = DEFAULT_POLICY_PATH,
730
+ }) {
731
+ const v = validatePolicy(policy, { policyPath })
732
+ if (!v.ok) {
733
+ return { exit: v.exit, ok: false, stage: 'policy', problems: v.problems }
734
+ }
735
+ try {
736
+ // 🔴 과반은 **정책 명단에서 작성자를 뺀 수**로 센다. 승계로 들어온 사람은
737
+ // 분모에 안 들어간다 — 모자라서 부른 사람이 문턱을 같이 올리면 안 된다.
738
+ // 그래서 이 계산이 `deriveVoters` 보다 먼저 온다.
739
+ const majority = usesMajority(policy) ? majorityThreshold(policy.voters, authorEmails) : null
740
+ const need = rulesFor(changed, policy, { majority })
741
+ const roster = deriveVoters({ policy, contributors, now, threshold: need.threshold })
742
+ const tally = countVotes({ policy, votes, authorEmails, sha, now, voters: roster.voters, branch })
743
+ const ok = tally.approvals >= need.threshold
744
+ return {
745
+ exit: ok ? EXIT.OK : EXIT.SHORT,
746
+ ok,
747
+ stage: 'count',
748
+ threshold: need.threshold,
749
+ approvals: tally.approvals,
750
+ need,
751
+ roster,
752
+ tally,
753
+ sha,
754
+ branch,
755
+ }
756
+ } catch (e) {
757
+ // GovernanceError 만 결과로 바꾼다. 그 밖의 예외는 우리 버그이므로 삼키지
758
+ // 않는다 — 삼키면 "판정 불가" 로 위장한 버그가 영원히 안 고쳐진다.
759
+ if (!(e instanceof GovernanceError)) throw e
760
+ return { exit: e.exit, ok: false, stage: 'undecidable', problems: [{ code: e.code, message: e.message, exit: e.exit }] }
761
+ }
762
+ }
763
+
764
+ /**
765
+ * 사람과 에이전트가 함께 읽는 출력.
766
+ *
767
+ * 메시지 생성을 순수 로직에 두는 것은 이 저장소의 관례다 (`protocol.mjs` 의
768
+ * `formatBlocks`). 덕분에 **"승계 발동 사실이 출력에 드러나는가" 를 테스트로
769
+ * 고정할 수 있다** — 그 줄을 지우면 테스트가 빨개진다.
770
+ */
771
+ export function formatVerdict(verdict) {
772
+ const L = []
773
+ if (verdict.stage === 'policy' || verdict.stage === 'undecidable') {
774
+ L.push(verdict.stage === 'policy'
775
+ ? '합의 판정 실패 — 정책 자체가 성립하지 않습니다.'
776
+ : '합의 판정 불가 — 셀 수 없었습니다. 못 세면 통과가 아닙니다 (G5).')
777
+ L.push('')
778
+ for (const p of verdict.problems) L.push(` x [${p.code}] ${p.message}`)
779
+ L.push('')
780
+ L.push(verdict.exit === EXIT.POLICY_BROKEN
781
+ ? '정책을 고치는 MR 은 개정 문턱을 지납니다. 그것부터 여세요.'
782
+ : '환경 문제입니다. 위 메시지를 고치고 다시 도세요. 투표로는 풀리지 않습니다.')
783
+ return L.join('\n')
784
+ }
785
+
786
+ const { threshold, approvals, need, roster, tally } = verdict
787
+ L.push(verdict.ok
788
+ ? `합의 충족 — 유효 찬성 ${approvals} / 필요 ${threshold}`
789
+ : `합의 미달 — 유효 찬성 ${approvals} / 필요 ${threshold}`)
790
+ L.push(` 대상 커밋 : ${verdict.sha}${verdict.branch ? ` (${verdict.branch})` : ''}`)
791
+ L.push(` 문턱 근거 : ${need.top.path} → ${labelSource(need.source)} ${threshold}표`)
792
+ // 🔴 과반이면 그 사실과 분모를 반드시 적는다. 숫자만 보이면 왜 이 문턱인지
793
+ // 아무도 모르고, 명단이 하나 늘어난 날 그 숫자가 조용히 달라진다.
794
+ if (isInt(need.majority)) {
795
+ L.push(` 과반 — 정책 명단에서 이 MR 의 작성자를 뺀 인원 기준 ${need.majority}표`)
796
+ }
797
+ L.push('')
798
+
799
+ if (tally.counted.length) {
800
+ L.push(' 센 표')
801
+ for (const c of tally.counted) L.push(` o ${c.voter} <${c.email}> ${c.at}`)
802
+ }
803
+ if (tally.rejections.length) {
804
+ L.push(' 반대 (정족수 계산에는 들어가지 않습니다 — 사람이 읽으라고 적습니다)')
805
+ for (const c of tally.rejections) L.push(` - ${c.voter} <${c.email}> ${c.at}`)
806
+ }
807
+ if (tally.invalid.length) {
808
+ L.push(' 안 센 표')
809
+ for (const b of tally.invalid) {
810
+ L.push(` x ${b.voter} <${b.email}> — ${REASONS[b.reason] ?? b.reason}${b.detail ? ` (${b.detail})` : ''}`)
811
+ }
812
+ }
813
+ if (tally.counted.length || tally.rejections.length || tally.invalid.length) L.push('')
814
+
815
+ // 🔴 승계는 조용히 발동하지 않는다. 이 줄을 지우면 승계가 우회로가 된다.
816
+ if (roster.triggered) {
817
+ L.push(` ⚠ 승계 발동 — 살아 있는 투표권자 ${roster.alive.length}명 < 필요 ${roster.need}명`)
818
+ L.push(` 최근 ${roster.window.days}일(${roster.window.since} 이후) 기여자 상위 ${roster.window.top}명에게 임시 투표권을 줍니다.`)
819
+ if (roster.succeeded.length) {
820
+ for (const s of roster.succeeded) L.push(` + ${s.id} <${s.email}> 커밋 ${s.commits}`)
821
+ } else {
822
+ L.push(' 그런데 창 안에 기여자가 없습니다.')
823
+ }
824
+ L.push(' 승계로 들어온 사람도 자기 표는 세지 않습니다 (G1).')
825
+ L.push('')
826
+ }
827
+ if (roster.short > 0) {
828
+ L.push(` 🔴 승계로도 ${roster.short}표가 모자랍니다. 이 저장소는 막혀 있습니다.`)
829
+ L.push(' 탈출구를 두지 않았습니다 — 이 상태의 정답은 fork 입니다.')
830
+ L.push('')
831
+ }
832
+
833
+ L.push(verdict.ok
834
+ ? '통과. 이 판정은 표 파일에만 근거합니다 (GitLab Approve 는 세지 않습니다).'
835
+ : `아직 ${threshold - approvals}표가 필요합니다. 사람에게 투표를 요청하세요 — 재시도로는 바뀌지 않습니다.`)
836
+ return L.join('\n')
837
+ }
838
+
839
+ const labelSource = (s) => ({ amendment: '개정(amendment)', rules: '파트 규칙(rules)', default: '기본(default)' }[s] ?? s)