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,501 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 표를 던진다 — **저장소 안의 파일 하나로.**
4
+ *
5
+ * node axmap/governance/vote.mjs --branch <소스브랜치> [--sha <커밋>]
6
+ * [--vote approve|reject] [--note "..."] [--force]
7
+ *
8
+ * GitLab 의 Approve 버튼을 쓰지 않는 이유는 [GOVERNANCE.md](GOVERNANCE.md) 와
9
+ * docs/DECISIONS.md 의 D18 에 있다 — 판정을 GitLab API(**다른 프로그램이 GitLab 에
10
+ * 물어보는 창구**)에 걸면, 이 저장소를 다른 곳으로 clone(**저장소를 통째로
11
+ * 내려받는 것**)한 사람에게는 이 층이 **존재하지 않는다.** 파일이면 clone 이 곧
12
+ * 이관이다.
13
+ *
14
+ * ── 자리와 모양 ──────────────────────────────────────────────────────────
15
+ *
16
+ * 브랜치: axmap/votes 코드와 섞지 않는다
17
+ * 경로 : votes/<소스브랜치>/<voter>-<sha앞8자>.json
18
+ *
19
+ * 브랜치 이름의 `/` 는 그대로 디렉터리 구분자로 둔다
20
+ * (`votes/feat/S15P21E201-144-login/…`). 치환하면 서로 다른 두 브랜치가 같은
21
+ * 디렉터리를 가리킬 수 있고, 이 저장소는 락에서 **치환이 곧 소유권 충돌**이라는
22
+ * 이유로 그것을 금지해 왔다 (SPEC §3).
23
+ *
24
+ * **한 사람이 한 커밋에 정확히 파일 하나.** 중복이 파일시스템 수준에서 불가능하다.
25
+ *
26
+ * ── 여기서 하지 않는 것 ───────────────────────────────────────────────────
27
+ *
28
+ * 🔴 `committerEmail` 을 파일에 적지 않는다. 그 칸은 게이트가 git 에서 채워
29
+ * 표에 적힌 신원과 **대조**하는 자리다. 표를 쓰는 사람이 적으면 대조가 아니라
30
+ * 자기 신고가 되고, 그러면 남의 이름으로 표를 만드는 데 아무 비용이 안 든다.
31
+ *
32
+ * 🔴 판정을 하지 않는다. 자기 표인지·투표권자인지 **경고는 하지만**, 세는 것은
33
+ * 언제나 게이트다. 여기서 미리 거르면 규칙이 두 군데로 갈라지고, 그때부터
34
+ * 한 쪽만 고쳐지는 날이 온다.
35
+ *
36
+ * ── 장부와 같은 방식 ─────────────────────────────────────────────────────
37
+ *
38
+ * 표 브랜치를 다루는 방법은 `bin/axmap.mjs` 의 장부(`axmap/claims`)와 같다:
39
+ * 고아 브랜치(**아무 이력에도 붙지 않은 브랜치**)를 worktree(**같은 저장소의 다른
40
+ * 브랜치를 별도 폴더에 펼쳐 둔 작업 공간**)로 붙여 두고, fetch → reset --hard →
41
+ * 쓰기 → 커밋 → push 순으로 간다. push 가 거부되면 그 사이에 누가 먼저 쓴 것이므로
42
+ * 최신을 받아 다시 한다 (CAS — **Compare-And-Swap**, "내가 읽은 뒤로 바뀐 게
43
+ * 없을 때만 쓴다". git push 가 원래 이렇게 동작한다).
44
+ */
45
+
46
+ import { spawnSync } from 'node:child_process'
47
+ import fs from 'node:fs'
48
+ import path from 'node:path'
49
+ import { agentNameError } from '../src/protocol.mjs'
50
+ import { EXIT, DEFAULT_POLICY_PATH } from '../src/governance.mjs'
51
+
52
+ const VOTES_BRANCH = 'axmap/votes'
53
+ const VOTES_REL = path.join('.axmap', 'votes')
54
+
55
+ /** push 가 거부될 때(= 누가 먼저 도착) 다시 해보는 횟수. bin/axmap.mjs 와 같은 값. */
56
+ const MAX_CAS_RETRIES = 5
57
+ /** 50 → 1600ms. bin/axmap.mjs 의 계단을 그대로 쓴다. */
58
+ const BACKOFF_MS = [50, 100, 200, 400, 800, 1600]
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // git — bin/axmap.mjs 의 관례 그대로
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * git 이 훅을 실행할 때 심어 놓는 환경변수들. cwd 보다 우선하므로, 남겨 두면
66
+ * 표 worktree 를 다루는 명령이 **커밋 중인 저장소**를 가리켜 깨진다.
67
+ */
68
+ const GIT_ENV_KEYS = [
69
+ 'GIT_DIR',
70
+ 'GIT_WORK_TREE',
71
+ 'GIT_COMMON_DIR',
72
+ 'GIT_INDEX_FILE',
73
+ 'GIT_OBJECT_DIRECTORY',
74
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
75
+ 'GIT_PREFIX',
76
+ ]
77
+
78
+ function cleanEnv() {
79
+ const e = { ...process.env }
80
+ for (const k of GIT_ENV_KEYS) delete e[k]
81
+ return e
82
+ }
83
+
84
+ let ROOT = null
85
+
86
+ function git(args, opts = {}) {
87
+ const r = spawnSync('git', args, {
88
+ cwd: opts.cwd ?? ROOT ?? undefined,
89
+ input: opts.input,
90
+ env: cleanEnv(),
91
+ encoding: 'utf8',
92
+ windowsHide: true,
93
+ })
94
+ return { code: r.status ?? 1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }
95
+ }
96
+
97
+ function gitOrDie(args, opts = {}) {
98
+ const r = git(args, opts)
99
+ if (r.code !== 0) die(`git ${args.join(' ')} 실패\n${r.err || r.out}`)
100
+ return r
101
+ }
102
+
103
+ function die(msg, code = EXIT.UNDECIDABLE) {
104
+ console.error(msg)
105
+ process.exit(code)
106
+ }
107
+
108
+ /**
109
+ * 의존성 없는 동기 sleep. `Atomics.wait` 은 아무도 깨우지 않는 SharedArrayBuffer
110
+ * 에서 timeout 만큼 정확히 멈춘다. spawnSync 로 짜인 이 파일에 async 를 들이면
111
+ * 호출부 전체가 물든다 (bin/axmap.mjs 와 같은 이유).
112
+ */
113
+ function sleepMs(ms) {
114
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
115
+ }
116
+
117
+ /**
118
+ * 백오프에 난수를 안 쓰고 **pid** 를 쓴다. 흩어놓는 목적은 pid 로도 달성되고,
119
+ * 난수와 달리 같은 입력에서 같은 타이밍이 재현되므로 테스트가 확인할 수 있다.
120
+ */
121
+ function backoffFor(attempt) {
122
+ const b = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]
123
+ return b + Math.floor((b * (process.pid % 64)) / 64)
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // 인자
128
+ // ---------------------------------------------------------------------------
129
+
130
+ function parseArgs(argv) {
131
+ const positional = []
132
+ const flags = {}
133
+ for (let i = 0; i < argv.length; i++) {
134
+ const a = argv[i]
135
+ if (!a.startsWith('--')) { positional.push(a); continue }
136
+ const eq = a.indexOf('=')
137
+ if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1)
138
+ else if (argv[i + 1] && !argv[i + 1].startsWith('--')) flags[a.slice(2)] = argv[++i]
139
+ else flags[a.slice(2)] = true
140
+ }
141
+ return { positional, flags }
142
+ }
143
+
144
+ const str = (v) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null)
145
+
146
+ const HELP = `표를 던진다 — 저장소 안의 파일 하나로
147
+
148
+ node axmap/governance/vote.mjs --branch <소스브랜치> [옵션]
149
+
150
+ --branch 표를 줄 브랜치 (필수)
151
+ --sha 그 브랜치의 어느 커밋에 주는가. 생략하면 지금 헤드
152
+ --vote approve | reject. 생략하면 approve
153
+ --note 한 줄 메모 (사람이 읽는다)
154
+ --target 자기 표(G1) 경고를 위해 비교할 브랜치. 생략하면 main
155
+ --force 자기 표라는 경고를 무릅쓰고 그래도 쓴다
156
+ --remote 원격 이름. 없으면 git config axmap.remote → 유일한 원격 → origin
157
+ --policy 정책 파일의 자리 (저장소 루트 기준). 없으면 AXMAP_POLICY_PATH
158
+ → ${DEFAULT_POLICY_PATH}. 명단 경고에만 쓴다 (판정은 게이트가 한다)
159
+
160
+ 표는 ${VOTES_BRANCH} 브랜치의 votes/<브랜치>/<이름>-<sha8>.json 에 쌓인다.
161
+ reject 는 정족수 계산에 들어가지 않는다 — 이 층은 "찬성이 몇인가" 만 센다.
162
+ 다만 판정문에는 반드시 보인다.
163
+ `
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // 표 worktree — 장부와 같은 방식
167
+ // ---------------------------------------------------------------------------
168
+
169
+ function repoRoot() {
170
+ const r = git(['rev-parse', '--show-toplevel'], { cwd: process.cwd() })
171
+ if (r.code !== 0) die('git 저장소 안에서 실행해야 합니다.')
172
+ return r.out
173
+ }
174
+
175
+ const votesDir = () => path.join(ROOT, VOTES_REL)
176
+
177
+ /** bin/axmap.mjs 의 `resolveRemote` 와 같은 순서. 못 고르면 null 이다. */
178
+ function resolveRemote(flags) {
179
+ const all = git(['remote']).out.split('\n').map((s) => s.trim()).filter(Boolean)
180
+ const given = str(flags.remote) ?? str(process.env.AXMAP_REMOTE)
181
+ if (given) {
182
+ if (!all.includes(given)) {
183
+ die(`지정한 원격이 없습니다: ${given}${all.length ? ` (있는 것: ${all.join(', ')})` : ''}`)
184
+ }
185
+ return given
186
+ }
187
+ const cfg = git(['config', '--get', 'axmap.remote']).out
188
+ if (cfg && all.includes(cfg)) return cfg
189
+ if (all.length === 1) return all[0]
190
+ if (all.includes('origin')) return 'origin'
191
+ return null
192
+ }
193
+
194
+ /**
195
+ * 표 worktree 를 준비한다. `bin/axmap.mjs` 의 `cmdInit` 과 같은 순서다.
196
+ *
197
+ * 원격에 이미 표 브랜치가 있으면 그것을 이어 쓰고, 없으면 빈 트리 → 부모 없는
198
+ * 커밋 → 브랜치로 고아 브랜치를 만든다. 작업 트리를 건드리지 않는 방법이다.
199
+ */
200
+ function ensureVotesWorktree(remote) {
201
+ const dir = votesDir()
202
+ // 폴더를 손으로 지웠어도 git 쪽에는 등록이 남아 `worktree add` 가
203
+ // "already registered" 로 실패한다. 먼저 정리한다.
204
+ git(['worktree', 'prune'])
205
+
206
+ if (fs.existsSync(path.join(dir, '.git'))) return dir
207
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
208
+ die(`${VOTES_REL} 가 이미 있지만 정상적인 worktree 가 아닙니다.\n지운 뒤 다시 시도하세요: rm -rf ${VOTES_REL}`)
209
+ }
210
+
211
+ let base = null
212
+ if (remote && git(['fetch', '--quiet', remote, VOTES_BRANCH]).code === 0) {
213
+ base = gitOrDie(['rev-parse', 'FETCH_HEAD']).out
214
+ } else if (git(['rev-parse', '--verify', '--quiet', `refs/heads/${VOTES_BRANCH}`]).code === 0) {
215
+ base = git(['rev-parse', `refs/heads/${VOTES_BRANCH}`]).out
216
+ } else {
217
+ const tree = gitOrDie(['mktree'], { input: '' }).out
218
+ base = gitOrDie(['commit-tree', tree, '-m', 'axmap: votes init']).out
219
+ console.error(`표 브랜치를 새로 만듭니다: ${VOTES_BRANCH}`)
220
+ }
221
+
222
+ if (git(['rev-parse', '--verify', '--quiet', `refs/heads/${VOTES_BRANCH}`]).code !== 0) {
223
+ gitOrDie(['branch', VOTES_BRANCH, base])
224
+ }
225
+ gitOrDie(['worktree', 'add', '--quiet', dir, VOTES_BRANCH])
226
+ return dir
227
+ }
228
+
229
+ /**
230
+ * 원격 표를 그대로 가져와 로컬을 덮어쓴다.
231
+ *
232
+ * `reset --hard` 인 이유는 장부와 같다 — 표는 병합 대상이 아니라 **원격이 진실**
233
+ * 이다. 이전 시도에서 push 가 거부되어 남은 로컬 커밋도 여기서 함께 버려진다.
234
+ */
235
+ function syncVotes(remote) {
236
+ if (!remote) return
237
+ const dir = votesDir()
238
+ // fetch 는 반드시 표 worktree 안에서. FETCH_HEAD 는 worktree 별로 따로
239
+ // 보관되므로, 본 저장소에서 fetch 하면 여기서는 그것을 볼 수 없다.
240
+ if (git(['fetch', '--quiet', remote, VOTES_BRANCH], { cwd: dir }).code !== 0) {
241
+ // 브랜치가 아직 없을 뿐일 수 있다. 원격 자체에 닿는지를 따로 묻는다.
242
+ const probe = git(['ls-remote', '--exit-code', remote, 'HEAD'], { cwd: dir })
243
+ if (probe.code === 0 || probe.code === 2) return
244
+ die(
245
+ '원격에 닿을 수 없어 표를 던지지 않았습니다.\n'
246
+ + ' 로컬에만 남긴 표는 CI 가 보지 못합니다 — 던졌다고 믿는 동안 아무도 안 셉니다.',
247
+ )
248
+ }
249
+ const r = git(['reset', '--hard', '--quiet', 'FETCH_HEAD'], { cwd: dir })
250
+ if (r.code !== 0) die(`표 브랜치 동기화 실패\n${r.err || r.out}`)
251
+ }
252
+
253
+ /** 표 worktree 의 현재 HEAD. push 가 실패하면 여기로 되돌린다. */
254
+ function votesHead() {
255
+ const r = git(['rev-parse', 'HEAD'], { cwd: votesDir() })
256
+ return r.code === 0 ? r.out : null
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // 경고 — 조용히 무효표를 쌓지 않는다
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /**
264
+ * 이 브랜치 커밋의 author 중에 내가 있는가 (G1 — 자기 표는 안 세진다).
265
+ *
266
+ * 게이트는 `<타깃>..<소스>` 로 정확히 계산하지만, 여기서는 타깃을 모른다.
267
+ * 그래서 `--target`(기본 `main`)과의 갈림점부터 센다. 그 브랜치가 없으면
268
+ * 최근 200 커밋으로 떨어지고, **어느 범위를 봤는지 경고에 함께 적는다** —
269
+ * 근거를 안 적으면 사람이 경고를 믿을지 말지 판단할 수 없다.
270
+ */
271
+ function selfVoteRange(branch, targetName) {
272
+ const t = git(['rev-parse', '--verify', '--quiet', `${targetName}^{commit}`])
273
+ if (t.code === 0 && t.out) {
274
+ const mb = git(['merge-base', t.out, branch])
275
+ if (mb.code === 0 && mb.out) return { args: [`${mb.out}..${branch}`], label: `${targetName}..${branch}` }
276
+ }
277
+ return { args: ['-n', '200', branch], label: `${branch} 의 최근 200 커밋` }
278
+ }
279
+
280
+ function warnIfSelfVote(branch, targetName, email, force) {
281
+ const range = selfVoteRange(branch, targetName)
282
+ const r = git(['log', '--format=%aE', ...range.args])
283
+ if (r.code !== 0) {
284
+ console.error(`경고: 소스 브랜치의 author 를 읽지 못해 자기 표인지 확인하지 못했습니다.\n ${r.err.split('\n')[0]}`)
285
+ return
286
+ }
287
+ const authors = new Set(r.out.split('\n').map((s) => s.trim().toLowerCase()).filter(Boolean))
288
+ if (!authors.has(email.toLowerCase())) return
289
+
290
+ console.error(
291
+ `이 표는 자기 표라 세지지 않습니다 (G1 — 자기가 쓴 코드에 자기가 찬성할 수 없습니다).\n`
292
+ + ` ${email} 이(가) ${range.label} 의 author 목록에 있습니다.\n\n`
293
+ + ' 그래도 남기려면 --force 를 붙이세요. 파일은 남고 판정문에는 "안 센 표" 로 보입니다.\n'
294
+ + ' 조용히 무효표를 쌓지 않는 이유: 던졌다고 믿는 사람이 아무도 안 세는 표를 기다립니다.',
295
+ )
296
+ if (!force) process.exit(EXIT.SHORT)
297
+ console.error(' --force 가 있어 그래도 씁니다.\n')
298
+ }
299
+
300
+ /**
301
+ * 정책 파일의 자리. `governance/gate.mjs` 의 `resolvePolicyPath` 와 **같은 순서**다:
302
+ * `--policy` 플래그 → 환경변수 `AXMAP_POLICY_PATH` → `DEFAULT_POLICY_PATH`.
303
+ * 순서가 갈리면 게이트가 보는 정책과 여기서 보는 명단이 서로 다른 파일이 된다.
304
+ *
305
+ * 🔴 여기서도 옛 자리로 되돌아가는 폴백은 없다. 못 읽으면 경고를 접을 뿐이다 —
306
+ * 이 CLI 는 판정하지 않으므로 못 읽는 것이 통과로 이어지지 않는다.
307
+ */
308
+ function resolvePolicyPath(flags) {
309
+ const given = str(flags.policy) ?? str(process.env.AXMAP_POLICY_PATH)
310
+ const raw = given ?? DEFAULT_POLICY_PATH
311
+ return raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '')
312
+ }
313
+
314
+ /**
315
+ * 명단에 없는 사람이면 알려준다. **막지는 않는다** — 승계(**투표권자가 모자랄 때
316
+ * 최근 기여자에게 임시 투표권을 주는 것**)로 세질 수 있고, 그 판단은 게이트의
317
+ * 몫이기 때문이다. 여기서 막으면 규칙이 두 군데로 갈라진다.
318
+ */
319
+ function rosterOf(targetName, policyPath) {
320
+ const r = git(['show', `${targetName}:${policyPath}`])
321
+ if (r.code !== 0) return null // 타깃을 모르거나 정책이 아직 없다.
322
+ try {
323
+ const policy = JSON.parse(r.out)
324
+ return Array.isArray(policy?.voters) ? policy.voters : []
325
+ } catch { return null }
326
+ }
327
+
328
+ /** 명단에서 내 email 에 해당하는 줄. email 이 유일 키다 (GOVERNANCE.md). */
329
+ function rosterEntry(roster, email) {
330
+ if (!Array.isArray(roster)) return null
331
+ const want = email.trim().toLowerCase()
332
+ return roster.find((v) => String(v?.email ?? '').trim().toLowerCase() === want) ?? null
333
+ }
334
+
335
+ function warnIfNotAVoter(roster, targetName, email) {
336
+ if (roster === null) return // 경고는 부가 기능이다. 못 읽으면 조용히 넘어간다.
337
+ if (rosterEntry(roster, email)) return
338
+ console.error(
339
+ `경고: ${email} 은(는) ${targetName} 의 정책 명단에 없습니다.\n`
340
+ + ' 승계가 발동한 경우에만 세집니다. 평소에는 "투표권자가 아님" 으로 안 세집니다.',
341
+ )
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // 본체
346
+ // ---------------------------------------------------------------------------
347
+
348
+ const { flags } = parseArgs(process.argv.slice(2))
349
+ if (flags.help || flags.h) {
350
+ console.log(HELP)
351
+ process.exit(0)
352
+ }
353
+
354
+ ROOT = repoRoot()
355
+
356
+ const branch = str(flags.branch)
357
+ if (!branch) {
358
+ die(`어느 브랜치에 표를 주는지 말해야 합니다.\n\n${HELP}`)
359
+ }
360
+
361
+ const targetName = str(flags.target) ?? 'main'
362
+
363
+ // 대상 커밋. 생략하면 그 브랜치의 지금 헤드다. 40자 전체를 적는다 — 표는 커밋에
364
+ // 묶이므로(G3) 축약형을 적어 두면 나중에 어느 커밋이었는지가 흐려진다.
365
+ const shaFlag = str(flags.sha)
366
+ const shaRef = shaFlag ?? branch
367
+ const shaRes = git(['rev-parse', '--verify', '--quiet', `${shaRef}^{commit}`])
368
+ if (shaRes.code !== 0 || !shaRes.out) {
369
+ die(
370
+ `커밋을 찾을 수 없습니다: ${shaRef}\n`
371
+ + (shaFlag ? ' --sha 값을 확인하세요.' : ` 브랜치 ${branch} 가 로컬에 있는지 확인하세요 (git fetch 가 필요할 수 있습니다).`),
372
+ )
373
+ }
374
+ const sha = shaRes.out
375
+
376
+ const voteValue = str(flags.vote) ?? 'approve'
377
+ if (voteValue !== 'approve' && voteValue !== 'reject') {
378
+ die(`--vote 는 approve 나 reject 여야 합니다: ${voteValue}`)
379
+ }
380
+ const note = str(flags.note)
381
+
382
+ // 신원. 🔴 안전한 기본값을 두지 않는다 — 이름이 겹치면 두 사람의 표가 같은 파일을
383
+ // 가리키고, 나중에 던진 쪽이 앞의 표를 덮어쓴다.
384
+ let voter = git(['config', 'user.name']).out
385
+ const email = git(['config', 'user.email']).out
386
+ if (!email) {
387
+ die(
388
+ 'git 에 email 이 설정돼 있지 않습니다. 표의 유일 키는 email 입니다.\n'
389
+ + ' git config user.email <내-email>\n'
390
+ + '기본값으로 채우지 않는 이유: 사람을 가리키는 것은 이름이 아니라 email 이고,\n'
391
+ + '틀린 email 로 던진 표는 게이트가 "투표권자가 아님" 으로 버립니다.',
392
+ )
393
+ }
394
+ if (!voter) {
395
+ die('git 에 이름이 설정돼 있지 않습니다.\n git config user.name <내-이름>')
396
+ }
397
+ /**
398
+ * 🔴 `voter` 는 **명단이 정한다.** `git config user.name` 이 아니다.
399
+ *
400
+ * 실측(2026-08-26): 이 PC 의 `user.name` 은 `janghyojoon` 인데 정책 명단의 `id` 는
401
+ * `rleaderjoon`(GitLab 계정 이름)이었다. email 은 같았다. 게이트는 명단에 적힌
402
+ * 사람의 표는 **id 까지 맞아야** 세므로, 표는 정상적으로 쓰이고 판정에서만
403
+ * `표의 이름과 email 이 명단과 어긋남` 으로 조용히 빠졌다.
404
+ *
405
+ * 던진 사람은 자기가 던졌다고 믿고, 게이트는 안 셌다. **락에서 최악은 조용한
406
+ * 통과지만 투표에서 최악은 조용한 누락이다** — 아무도 왜 안 세지는지 못 푼다.
407
+ *
408
+ * email 이 유일 키이므로(GOVERNANCE.md) email 로 명단을 찾아 그 줄의 `id` 를 쓴다.
409
+ * 명단에 없으면(승계로 들어올 사람) `user.name` 을 그대로 둔다 — 승계로 들어온
410
+ * 사람은 저장소가 이름을 정해 준 적이 없어 게이트도 id 를 대조하지 않는다.
411
+ */
412
+ const roster = rosterOf(targetName, resolvePolicyPath(flags))
413
+ const mine = rosterEntry(roster, email)
414
+ if (mine && String(mine.id ?? '').trim() && mine.id !== voter) {
415
+ console.error(`이름을 명단에 맞춥니다: ${voter} → ${mine.id} (${targetName} 의 정책 기준)`)
416
+ voter = String(mine.id).trim()
417
+ }
418
+
419
+ // 이름은 그대로 파일명이 된다. 위험한 문자를 치환해서 통과시키면 서로 다른 두
420
+ // 이름이 같은 파일을 가리킨다 — 장부가 에이전트 이름에 쓰는 검사를 그대로 쓴다.
421
+ const nameErr = agentNameError(voter)
422
+ if (nameErr) die(`${nameErr}\n git config user.name 을 고치세요.`)
423
+
424
+ warnIfNotAVoter(roster, targetName, email)
425
+ warnIfSelfVote(branch, targetName, email, flags.force === true)
426
+
427
+ const remote = resolveRemote(flags)
428
+ if (!remote) {
429
+ console.error(
430
+ '경고: 원격이 없어 표가 이 저장소 안에만 남습니다.\n'
431
+ + ' CI 는 원격의 표를 보므로, 이대로면 아무도 이 표를 세지 못합니다.',
432
+ )
433
+ }
434
+
435
+ ensureVotesWorktree(remote)
436
+
437
+ const relPath = `votes/${branch}/${voter}-${sha.slice(0, 8)}.json`
438
+ // 시각은 루프 밖에서 한 번 정한다. CAS 재시도마다 바뀌면 같은 표가 매번 다른
439
+ // 내용이 되어, 무엇 때문에 다시 쓰는지가 이력에서 안 보인다.
440
+ const record = {
441
+ voter,
442
+ email,
443
+ branch,
444
+ sha,
445
+ vote: voteValue,
446
+ at: new Date().toISOString(),
447
+ ...(note ? { note } : {}),
448
+ // 🔴 committerEmail 은 여기 없다. 게이트가 git 에서 채운다 (파일 머리말 참고).
449
+ }
450
+
451
+ for (let attempt = 1; attempt <= MAX_CAS_RETRIES; attempt++) {
452
+ syncVotes(remote)
453
+ const dir = votesDir()
454
+ const before = votesHead()
455
+
456
+ const full = path.join(dir, ...relPath.split('/'))
457
+ fs.mkdirSync(path.dirname(full), { recursive: true })
458
+ fs.writeFileSync(full, `${JSON.stringify(record, null, 2)}\n`)
459
+
460
+ const add = git(['add', '-A'], { cwd: dir })
461
+ if (add.code !== 0) die(`git add 실패\n${add.err || add.out}`)
462
+ if (git(['diff', '--cached', '--quiet'], { cwd: dir }).code === 0) {
463
+ console.log(`이미 같은 표가 있습니다: ${relPath}`)
464
+ process.exit(0)
465
+ }
466
+ // --no-verify: 연결된 worktree 는 훅 디렉터리를 공유하므로, 사용자가 설치한
467
+ // pre-commit 훅이 표 커밋에도 발동한다. 표는 사용자 코드가 아니다.
468
+ const c = git(['commit', '--quiet', '--no-verify', '-m', `vote(${voter}): ${voteValue} ${branch}@${sha.slice(0, 8)}`], { cwd: dir })
469
+ if (c.code !== 0) die(`git commit 실패\n${c.err || c.out}`)
470
+
471
+ if (!remote) {
472
+ console.log(`표를 남겼습니다 (로컬 전용): ${relPath}`)
473
+ process.exit(0)
474
+ }
475
+
476
+ const p = git(['push', '--quiet', remote, `HEAD:${VOTES_BRANCH}`], { cwd: dir })
477
+ if (p.code === 0) {
478
+ console.log(`표를 던졌습니다: ${relPath}`)
479
+ console.log(` ${voter} <${email}> ${voteValue} ${branch}@${sha.slice(0, 8)}`)
480
+ if (voteValue === 'reject') {
481
+ console.log(' reject 는 정족수 계산에 들어가지 않습니다 — 판정문에 보이기만 합니다.')
482
+ }
483
+ process.exit(0)
484
+ }
485
+
486
+ // push 가 거부됐다. 그 사이에 누가 먼저 표를 올린 것이다(CAS 실패).
487
+ // 로컬 커밋을 되돌린다 — 남겨두면 다음 syncVotes 의 reset --hard 가 조용히
488
+ // 지우고, 사람은 표가 어디로 갔는지 모른다.
489
+ if (before) git(['reset', '--hard', '--quiet', before], { cwd: dir })
490
+ console.error(`push 거부됨 (누가 먼저 표를 올림). 재시도 ${attempt}/${MAX_CAS_RETRIES}`)
491
+ if (attempt < MAX_CAS_RETRIES) {
492
+ const ms = backoffFor(attempt - 1)
493
+ console.error(` ${ms}ms 기다렸다 다시 시도합니다.`)
494
+ sleepMs(ms)
495
+ }
496
+ }
497
+
498
+ die(
499
+ `표 브랜치 경합이 심해 ${MAX_CAS_RETRIES}회 재시도 후 포기했습니다.\n`
500
+ + ' (로컬은 되돌렸습니다 — 표는 남지 않았습니다. 잠시 후 다시 시도하세요.)',
501
+ )