axmap-cli 0.0.1 → 0.3.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.
- package/.claude/commands/ax.md +23 -7
- package/CLAUDE.md +23 -1
- package/README.md +8 -0
- package/bin/axmap.mjs +301 -18
- package/governance/GOVERNANCE.md +172 -2
- package/governance/gate.mjs +28 -0
- package/governance/vote.mjs +303 -12
- package/mcp/README.md +16 -0
- package/mcp/server.mjs +40 -5
- package/package.json +10 -2
- package/src/governance.mjs +378 -26
- package/src/promote.mjs +87 -0
- package/src/protocol.mjs +163 -7
- package/tools/mcp-register.mjs +97 -7
- package/tools/promote.mjs +19 -4
- package/tools/version.mjs +15 -1
package/governance/vote.mjs
CHANGED
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
*
|
|
5
5
|
* node axmap/governance/vote.mjs --branch <소스브랜치> [--sha <커밋>]
|
|
6
6
|
* [--vote approve|reject] [--note "..."] [--force]
|
|
7
|
+
* [--agent <이름>] [--agent-summary "..."]
|
|
8
|
+
*
|
|
9
|
+
* ── 표에는 이유가 있어야 한다 ─────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* 정책(`governance/policy.json`)에 `vote_note` 가 있으면 `--note` 없이는 표를
|
|
12
|
+
* 쓰지 않는다. **여기서 막는 것은 친절함이고 진짜 문은 세는 쪽에 있다** —
|
|
13
|
+
* `git pull` 을 안 한 사람의 옛 `vote.mjs` 는 이 규칙을 모르기 때문이다.
|
|
14
|
+
* 두 곳이 같은 함수 하나(`voteNoteProblem`)를 부른다.
|
|
15
|
+
*
|
|
16
|
+
* 🔴 **칸마다 주인이 다르다.**
|
|
17
|
+
*
|
|
18
|
+
* `note` 사람이 적는다 (`--note`)
|
|
19
|
+
* `agent.summary` AI 가 적는다 (`--agent-summary`)
|
|
20
|
+
* `agent.id` 부르는 쪽이 적는다 (`--agent`, 없으면 `"none"`)
|
|
21
|
+
* `agent.shown` 🔴 **아무도 못 적는다. 이 프로그램이 직접 잰다** —
|
|
22
|
+
* 변경량·경로·그 시점의 판정·axMap 사본이 몇 커밋 뒤처졌는지.
|
|
23
|
+
* `committerEmail` 을 못 적게 한 것과 같은 이유다 (아래).
|
|
7
24
|
*
|
|
8
25
|
* GitLab 의 Approve 버튼을 쓰지 않는 이유는 [GOVERNANCE.md](GOVERNANCE.md) 와
|
|
9
26
|
* docs/DECISIONS.md 의 D18 에 있다 — 판정을 GitLab API(**다른 프로그램이 GitLab 에
|
|
@@ -46,12 +63,46 @@
|
|
|
46
63
|
import { spawnSync } from 'node:child_process'
|
|
47
64
|
import fs from 'node:fs'
|
|
48
65
|
import path from 'node:path'
|
|
66
|
+
import { fileURLToPath } from 'node:url'
|
|
49
67
|
import { agentNameError } from '../src/protocol.mjs'
|
|
50
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
EXIT, DEFAULT_POLICY_PATH, voteNoteRule, voteNoteProblem,
|
|
70
|
+
} from '../src/governance.mjs'
|
|
51
71
|
|
|
52
72
|
const VOTES_BRANCH = 'axmap/votes'
|
|
53
73
|
const VOTES_REL = path.join('.axmap', 'votes')
|
|
54
74
|
|
|
75
|
+
/** 이 파일이 놓인 자리. 팀 저장소에서는 `ci/axmap/governance/` 다. */
|
|
76
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 사본이 놓이는 자리. **저장소 루트 기준**이고 `tools/vendor.mjs` 의 `VENDOR_REL`
|
|
80
|
+
* 과 같은 값이어야 한다. 여기가 갈리면 표에 적히는 출처가 조용히 비게 된다.
|
|
81
|
+
*/
|
|
82
|
+
const VENDOR_REL = 'ci/axmap'
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 벤더링(**남의 저장소 코드를 내 저장소 안에 복사해 두는 것**) 출처가 적힌 파일을
|
|
86
|
+
* 찾는 순서.
|
|
87
|
+
*
|
|
88
|
+
* 🔴 **저장소 루트 쪽을 먼저 본다.** 이 칸이 말하는 것은 "어느 스크립트가 돌았나"
|
|
89
|
+
* 가 아니라 **"표를 던지는 이 저장소가 어느 사본을 쓰고 있나"** 이기 때문이다.
|
|
90
|
+
* 개발 중에 axMap 저장소의 스크립트를 팀 저장소에서 직접 돌리더라도, 표에는
|
|
91
|
+
* 그 팀 저장소의 사본 출처가 적혀야 한다.
|
|
92
|
+
*
|
|
93
|
+
* 둘 다 없으면 `{ commit: null, behind: null }` — axMap 저장소 자신에서 돌릴 때가
|
|
94
|
+
* 그렇다. 사본이 아니니 출처도 없다.
|
|
95
|
+
*/
|
|
96
|
+
const axmapSourceCandidates = () => [
|
|
97
|
+
path.join(ROOT ?? '.', ...VENDOR_REL.split('/'), 'SOURCE.json'),
|
|
98
|
+
path.join(HERE, '..', 'SOURCE.json'),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
/** 게이트를 미리 한 번 돌려 보는 데 주는 시간. 넘기면 "잴 수 없음" 으로 적는다. */
|
|
102
|
+
const GATE_TIMEOUT_MS = 60_000
|
|
103
|
+
/** 원격에 axMap 이 몇 커밋 앞서 있는지 물어보는 데 주는 시간. */
|
|
104
|
+
const LS_REMOTE_TIMEOUT_MS = 15_000
|
|
105
|
+
|
|
55
106
|
/** push 가 거부될 때(= 누가 먼저 도착) 다시 해보는 횟수. bin/axmap.mjs 와 같은 값. */
|
|
56
107
|
const MAX_CAS_RETRIES = 5
|
|
57
108
|
/** 50 → 1600ms. bin/axmap.mjs 의 계단을 그대로 쓴다. */
|
|
@@ -83,17 +134,31 @@ function cleanEnv() {
|
|
|
83
134
|
|
|
84
135
|
let ROOT = null
|
|
85
136
|
|
|
137
|
+
/**
|
|
138
|
+
* git 한 번. `raw` 는 다듬지 않은 stdout 이다 — `-z`(**출력을 NUL 문자로 구분**)
|
|
139
|
+
* 로 받은 목록은 trim 하면 안 되므로 둘을 따로 돌려준다 (gate.mjs 와 같은 모양).
|
|
140
|
+
*/
|
|
86
141
|
function git(args, opts = {}) {
|
|
87
142
|
const r = spawnSync('git', args, {
|
|
88
143
|
cwd: opts.cwd ?? ROOT ?? undefined,
|
|
89
144
|
input: opts.input,
|
|
90
|
-
env: cleanEnv(),
|
|
145
|
+
env: { ...cleanEnv(), ...(opts.env ?? {}) },
|
|
91
146
|
encoding: 'utf8',
|
|
92
147
|
windowsHide: true,
|
|
148
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
149
|
+
...(opts.timeout ? { timeout: opts.timeout } : {}),
|
|
93
150
|
})
|
|
94
|
-
|
|
151
|
+
const out = r.stdout ?? ''
|
|
152
|
+
return { code: r.status ?? 1, out: out.trim(), raw: out, err: (r.stderr ?? '').trim() }
|
|
95
153
|
}
|
|
96
154
|
|
|
155
|
+
/**
|
|
156
|
+
* 남의 저장소에 물어볼 때 쓰는 환경. **자격증명을 물어보지 못하게 막는다** —
|
|
157
|
+
* 안 막으면 표를 던지려던 명령이 로그인 프롬프트에서 멈춰 선다. 못 닿으면
|
|
158
|
+
* 못 닿은 대로 적으면 되는 자리다 (axMap 이 죽어도 팀은 안 멈춘다).
|
|
159
|
+
*/
|
|
160
|
+
const NO_PROMPT_ENV = { GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '' }
|
|
161
|
+
|
|
97
162
|
function gitOrDie(args, opts = {}) {
|
|
98
163
|
const r = git(args, opts)
|
|
99
164
|
if (r.code !== 0) die(`git ${args.join(' ')} 실패\n${r.err || r.out}`)
|
|
@@ -150,16 +215,26 @@ const HELP = `표를 던진다 — 저장소 안의 파일 하나로
|
|
|
150
215
|
--branch 표를 줄 브랜치 (필수)
|
|
151
216
|
--sha 그 브랜치의 어느 커밋에 주는가. 생략하면 지금 헤드
|
|
152
217
|
--vote approve | reject. 생략하면 approve
|
|
153
|
-
--note
|
|
154
|
-
|
|
155
|
-
--
|
|
218
|
+
--note 🔴 왜 찬성/반대하는가. 사람이 적는다. 정책이 vote_note 를
|
|
219
|
+
켜 두었으면 이것 없이는 표가 안 써지고, 세는 쪽도 안 센다
|
|
220
|
+
--agent 이 표를 도운 AI 의 이름. 안 주면 "none"
|
|
221
|
+
--agent-summary 그 AI 가 요약한 한 줄. 사람의 --note 를 대신하지 않는다
|
|
222
|
+
--target 자기 표(G1) 경고를 위해 비교할 브랜치. 생략하면 main.
|
|
223
|
+
변경량(agent.shown)을 재는 기준이기도 하다
|
|
224
|
+
--force 자기 표라는 경고를 무릅쓰고 그래도 쓴다.
|
|
225
|
+
🔴 이유(--note) 요구는 --force 로 못 넘긴다
|
|
156
226
|
--remote 원격 이름. 없으면 git config axmap.remote → 유일한 원격 → origin
|
|
157
227
|
--policy 정책 파일의 자리 (저장소 루트 기준). 없으면 AXMAP_POLICY_PATH
|
|
158
|
-
→ ${DEFAULT_POLICY_PATH}. 명단
|
|
228
|
+
→ ${DEFAULT_POLICY_PATH}. 명단 경고와 이유 요구에 쓴다
|
|
229
|
+
(판정은 언제나 게이트가 한다)
|
|
159
230
|
|
|
160
231
|
표는 ${VOTES_BRANCH} 브랜치의 votes/<브랜치>/<이름>-<sha8>.json 에 쌓인다.
|
|
161
232
|
reject 는 정족수 계산에 들어가지 않는다 — 이 층은 "찬성이 몇인가" 만 센다.
|
|
162
233
|
다만 판정문에는 반드시 보인다.
|
|
234
|
+
|
|
235
|
+
표에는 던질 때 실제로 보였던 것(agent.shown)이 함께 적힌다 — 변경량·경로·
|
|
236
|
+
그 시점의 판정·axMap 사본이 몇 커밋 뒤처졌는지. 🔴 이 칸은 아무도 못 적는다.
|
|
237
|
+
이 프로그램이 직접 재서 채운다.
|
|
163
238
|
`
|
|
164
239
|
|
|
165
240
|
// ---------------------------------------------------------------------------
|
|
@@ -316,15 +391,21 @@ function resolvePolicyPath(flags) {
|
|
|
316
391
|
* 최근 기여자에게 임시 투표권을 주는 것**)로 세질 수 있고, 그 판단은 게이트의
|
|
317
392
|
* 몫이기 때문이다. 여기서 막으면 규칙이 두 군데로 갈라진다.
|
|
318
393
|
*/
|
|
319
|
-
function
|
|
394
|
+
function policyOf(targetName, policyPath) {
|
|
320
395
|
const r = git(['show', `${targetName}:${policyPath}`])
|
|
321
396
|
if (r.code !== 0) return null // 타깃을 모르거나 정책이 아직 없다.
|
|
322
397
|
try {
|
|
323
398
|
const policy = JSON.parse(r.out)
|
|
324
|
-
return Array.isArray(policy
|
|
399
|
+
return policy && typeof policy === 'object' && !Array.isArray(policy) ? policy : null
|
|
325
400
|
} catch { return null }
|
|
326
401
|
}
|
|
327
402
|
|
|
403
|
+
/** 정책에서 명단만. 못 읽었으면 `null`, 읽었는데 명단이 없으면 빈 배열이다. */
|
|
404
|
+
function rosterOf(policy) {
|
|
405
|
+
if (policy === null) return null
|
|
406
|
+
return Array.isArray(policy.voters) ? policy.voters : []
|
|
407
|
+
}
|
|
408
|
+
|
|
328
409
|
/** 명단에서 내 email 에 해당하는 줄. email 이 유일 키다 (GOVERNANCE.md). */
|
|
329
410
|
function rosterEntry(roster, email) {
|
|
330
411
|
if (!Array.isArray(roster)) return null
|
|
@@ -341,6 +422,134 @@ function warnIfNotAVoter(roster, targetName, email) {
|
|
|
341
422
|
)
|
|
342
423
|
}
|
|
343
424
|
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// 던질 때 실제로 보였던 것 — `agent.shown`
|
|
427
|
+
//
|
|
428
|
+
// 🔴 **이 칸은 아무도 못 적는다. 이 프로그램이 직접 잰다.**
|
|
429
|
+
// 사람이나 AI 가 "3 파일 12 줄 봤다" 라고 적을 수 있으면 그건 측정이 아니라
|
|
430
|
+
// 자기 신고다 — 표에 `committerEmail` 을 못 적게 한 것과 **같은 이유**다
|
|
431
|
+
// (파일 머리말). 자기 신고는 틀렸을 때 아무 흔적도 안 남긴다.
|
|
432
|
+
//
|
|
433
|
+
// 그래서 플래그가 없다. `--agent` 를 안 줘도 이 칸은 채워진다.
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* `<타깃>...<소스>` 의 변경량. 세 점(`...`)은 **갈림점부터의 차이**다 —
|
|
438
|
+
* 타깃에 새 커밋이 들어와도 이 표가 보고 있던 양이 흔들리지 않는다.
|
|
439
|
+
*
|
|
440
|
+
* `-z` 로 받는 이유: 이 저장소는 주석·문서가 전부 한국어라 파일 이름도 한국어가
|
|
441
|
+
* 된다. `-z` 가 없으면 git 이 그런 이름에 따옴표를 씌워 돌려주고, 표에는 실제로
|
|
442
|
+
* 없는 경로가 적힌다 (gate.mjs 의 `changedPaths` 가 같은 이유로 같은 옵션을 쓴다).
|
|
443
|
+
*
|
|
444
|
+
* 못 재면 **그럴듯한 0 으로 때우지 않고 `null`** 로 둔다. 0 은 "안 바뀌었다" 는
|
|
445
|
+
* 뜻이고, 그건 못 잰 것과 완전히 다른 말이다.
|
|
446
|
+
*/
|
|
447
|
+
function measureDiff(targetName, branch) {
|
|
448
|
+
const unknown = { files: null, insertions: null, deletions: null, paths: null }
|
|
449
|
+
const r = git(['-c', 'core.quotepath=false', 'diff', '--numstat', '-z', `${targetName}...${branch}`])
|
|
450
|
+
if (r.code !== 0) return unknown
|
|
451
|
+
|
|
452
|
+
const toks = r.raw.split('\0')
|
|
453
|
+
let files = 0
|
|
454
|
+
let insertions = 0
|
|
455
|
+
let deletions = 0
|
|
456
|
+
const paths = []
|
|
457
|
+
for (let i = 0; i < toks.length; i++) {
|
|
458
|
+
const t = toks[i]
|
|
459
|
+
if (t === '') continue
|
|
460
|
+
// `<더한 줄>\t<지운 줄>\t<경로>` — 바이너리 파일은 숫자 자리가 `-` 다.
|
|
461
|
+
const m = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(t)
|
|
462
|
+
if (!m) continue
|
|
463
|
+
const [, ins, del, tail] = m
|
|
464
|
+
let p = tail
|
|
465
|
+
if (tail === '') {
|
|
466
|
+
// 이름이 바뀐(rename) 파일. `-z` 에서는 옛 이름과 새 이름이 **다음 두
|
|
467
|
+
// 토큰**으로 따로 온다. 표에는 새 이름을 적는다.
|
|
468
|
+
p = toks[i + 2] ?? toks[i + 1] ?? ''
|
|
469
|
+
i += 2
|
|
470
|
+
}
|
|
471
|
+
files += 1
|
|
472
|
+
// 바이너리(`-`)는 줄 수가 없다. 0 으로 더한다 — 파일 수에는 그대로 센다.
|
|
473
|
+
insertions += ins === '-' ? 0 : Number(ins)
|
|
474
|
+
deletions += del === '-' ? 0 : Number(del)
|
|
475
|
+
if (p !== '') paths.push(p)
|
|
476
|
+
}
|
|
477
|
+
return { files, insertions, deletions, paths }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* **던지기 직전의 판정.** 게이트를 그대로 한 번 돌려 요약 한 줄로 줄인다.
|
|
482
|
+
*
|
|
483
|
+
* 🔴 이 표 자신은 아직 없다. 즉 `"미달 1/2"` 는 *"내가 던지기 전에 1표였다"* 는
|
|
484
|
+
* 뜻이다. 나중에 이 표가 몇 번째였는지를 이력만으로 알 수 있게 하는 값이다.
|
|
485
|
+
*
|
|
486
|
+
* 판정문을 다시 해석하지 않고 `--json` 의 `exit`·숫자만 쓴다. 문구를 다듬는
|
|
487
|
+
* 커밋이 이 칸의 뜻을 바꾸면 안 되기 때문이다 (gate.mjs 의 관례 그대로).
|
|
488
|
+
* 못 돌리면 `"잴 수 없음"` — 표는 그래도 던져진다.
|
|
489
|
+
*/
|
|
490
|
+
function measureGate(branch, targetName, policyFlag) {
|
|
491
|
+
const r = spawnSync(process.execPath, [
|
|
492
|
+
path.join(HERE, 'gate.mjs'),
|
|
493
|
+
'--source', branch,
|
|
494
|
+
'--target', targetName,
|
|
495
|
+
'--json',
|
|
496
|
+
...(policyFlag ? ['--policy', policyFlag] : []),
|
|
497
|
+
], {
|
|
498
|
+
cwd: ROOT,
|
|
499
|
+
env: { ...cleanEnv(), ...NO_PROMPT_ENV },
|
|
500
|
+
encoding: 'utf8',
|
|
501
|
+
windowsHide: true,
|
|
502
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
503
|
+
timeout: GATE_TIMEOUT_MS,
|
|
504
|
+
})
|
|
505
|
+
if (r.error || r.status === null) return '잴 수 없음'
|
|
506
|
+
let verdict = null
|
|
507
|
+
try { verdict = JSON.parse(r.stdout ?? '') } catch { return '잴 수 없음' }
|
|
508
|
+
if (!verdict || typeof verdict !== 'object') return '잴 수 없음'
|
|
509
|
+
if (verdict.stage === 'policy') return '정책 깨짐'
|
|
510
|
+
if (verdict.stage === 'undecidable') return '판정 불가'
|
|
511
|
+
if (!Number.isInteger(verdict.approvals) || !Number.isInteger(verdict.threshold)) return '잴 수 없음'
|
|
512
|
+
return `${verdict.ok ? '충족' : '미달'} ${verdict.approvals}/${verdict.threshold}`
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 이 저장소에 복사돼 있는 axMap 이 **어느 커밋의 사본이고 몇 커밋 뒤처졌는가.**
|
|
517
|
+
*
|
|
518
|
+
* 🔴 못 닿으면 `behind: null` 이고 **표는 그대로 던져진다.** axMap 이 잠깐
|
|
519
|
+
* 죽거나 접근 권한이 만료된 날 팀의 투표가 멈추면 안 된다 — 사본을 두는
|
|
520
|
+
* 이유(벤더링)와 정확히 같은 이유다.
|
|
521
|
+
*
|
|
522
|
+
* 🔴 **여기서 fetch 하지 않는다.** 표를 던지는 명령이 남의 저장소를 통째로
|
|
523
|
+
* 내려받아 이 저장소의 오브젝트를 불리는 것은 아무도 예상하지 않는 부수효과다.
|
|
524
|
+
* 그래서 `ls-remote` 로 끝을 물어보고, 셀 수 있을 때만(= 그 이력이 이미 이
|
|
525
|
+
* 저장소에 있을 때만) 센다. 못 세면 `null` 이다.
|
|
526
|
+
* ⚠ 그 결과 "원격에 못 닿음" 과 "닿았지만 셀 수 없음" 이 둘 다 `null` 이다.
|
|
527
|
+
* `behind: null` 은 **"뒤처지지 않았다" 가 아니라 "못 쟀다"** 로 읽는다.
|
|
528
|
+
*/
|
|
529
|
+
function measureAxmap() {
|
|
530
|
+
let src = null
|
|
531
|
+
for (const p of axmapSourceCandidates()) {
|
|
532
|
+
try { src = JSON.parse(fs.readFileSync(p, 'utf8')); break } catch { /* 다음 자리 */ }
|
|
533
|
+
}
|
|
534
|
+
if (!src) return { commit: null, behind: null }
|
|
535
|
+
|
|
536
|
+
const commit = str(src?.source?.commit)
|
|
537
|
+
const url = str(src?.source?.repository)
|
|
538
|
+
const branch = str(src?.source?.branch) ?? 'main'
|
|
539
|
+
if (!commit) return { commit: null, behind: null }
|
|
540
|
+
if (!url) return { commit, behind: null }
|
|
541
|
+
|
|
542
|
+
const ls = git(['ls-remote', url, branch], { env: NO_PROMPT_ENV, timeout: LS_REMOTE_TIMEOUT_MS })
|
|
543
|
+
if (ls.code !== 0 || !ls.out) return { commit, behind: null }
|
|
544
|
+
const tip = ls.out.split('\n')[0].split('\t')[0].trim()
|
|
545
|
+
if (!/^[0-9a-f]{40}$/.test(tip)) return { commit, behind: null }
|
|
546
|
+
if (tip === commit) return { commit, behind: 0 }
|
|
547
|
+
|
|
548
|
+
const n = git(['rev-list', '--count', `${commit}..${tip}`])
|
|
549
|
+
if (n.code !== 0 || !/^\d+$/.test(n.out)) return { commit, behind: null }
|
|
550
|
+
return { commit, behind: Number(n.out) }
|
|
551
|
+
}
|
|
552
|
+
|
|
344
553
|
// ---------------------------------------------------------------------------
|
|
345
554
|
// 본체
|
|
346
555
|
// ---------------------------------------------------------------------------
|
|
@@ -379,6 +588,18 @@ if (voteValue !== 'approve' && voteValue !== 'reject') {
|
|
|
379
588
|
}
|
|
380
589
|
const note = str(flags.note)
|
|
381
590
|
|
|
591
|
+
// 시각은 여기서 **한 번** 정한다. 두 군데서 쓰이기 때문이다 — 아래의 이유 요구
|
|
592
|
+
// (시행일 비교)와 표 파일 자신. CAS 재시도마다 바뀌면 같은 표가 매번 다른 내용이
|
|
593
|
+
// 되어, 무엇 때문에 다시 쓰는지가 이력에서 안 보인다.
|
|
594
|
+
const at = new Date().toISOString()
|
|
595
|
+
|
|
596
|
+
// AI 가 채우는 칸. 🔴 값 없이 `--agent` 만 쓰면 조용히 "none" 으로 떨어뜨리지
|
|
597
|
+
// 않는다 — 이름을 주려다 실패한 것과 안 준 것은 다른 일이다.
|
|
598
|
+
if (flags.agent === true) die('--agent 에는 이름을 붙여야 합니다. 예: --agent claude-opus-5')
|
|
599
|
+
if (flags['agent-summary'] === true) die('--agent-summary 에는 요약을 붙여야 합니다.')
|
|
600
|
+
const agentId = str(flags.agent) ?? 'none'
|
|
601
|
+
const agentSummary = str(flags['agent-summary'])
|
|
602
|
+
|
|
382
603
|
// 신원. 🔴 안전한 기본값을 두지 않는다 — 이름이 겹치면 두 사람의 표가 같은 파일을
|
|
383
604
|
// 가리키고, 나중에 던진 쪽이 앞의 표를 덮어쓴다.
|
|
384
605
|
let voter = git(['config', 'user.name']).out
|
|
@@ -409,7 +630,9 @@ if (!voter) {
|
|
|
409
630
|
* 명단에 없으면(승계로 들어올 사람) `user.name` 을 그대로 둔다 — 승계로 들어온
|
|
410
631
|
* 사람은 저장소가 이름을 정해 준 적이 없어 게이트도 id 를 대조하지 않는다.
|
|
411
632
|
*/
|
|
412
|
-
const
|
|
633
|
+
const policyPath = resolvePolicyPath(flags)
|
|
634
|
+
const policy = policyOf(targetName, policyPath)
|
|
635
|
+
const roster = rosterOf(policy)
|
|
413
636
|
const mine = rosterEntry(roster, email)
|
|
414
637
|
if (mine && String(mine.id ?? '').trim() && mine.id !== voter) {
|
|
415
638
|
console.error(`이름을 명단에 맞춥니다: ${voter} → ${mine.id} (${targetName} 의 정책 기준)`)
|
|
@@ -424,6 +647,52 @@ if (nameErr) die(`${nameErr}\n git config user.name 을 고치세요.`)
|
|
|
424
647
|
warnIfNotAVoter(roster, targetName, email)
|
|
425
648
|
warnIfSelfVote(branch, targetName, email, flags.force === true)
|
|
426
649
|
|
|
650
|
+
// ── 이유 없는 표는 여기서 거부한다 ─────────────────────────────────────────
|
|
651
|
+
//
|
|
652
|
+
// 🔴 **이건 문이 아니라 친절함이다.** 진짜 문은 세는 쪽(`src/governance.mjs` 의
|
|
653
|
+
// `countVotes`)에 있고 CI 에서 돈다. 여기서만 막으면 `git pull` 을 안 한
|
|
654
|
+
// 사람의 옛 `vote.mjs` 가 이 규칙을 모른 채 이유 없는 표를 그냥 쓴다.
|
|
655
|
+
//
|
|
656
|
+
// 두 곳 다 두는 이유는 다르다: 여기서 막으면 **던지기 전에** 알 수 있고,
|
|
657
|
+
// 세는 쪽에서만 막으면 던진 뒤 CI 가 빨개져야 안다. 같은 규칙을 두 번 적는
|
|
658
|
+
// 것이 아니라 **같은 함수 하나**(`voteNoteProblem`)를 두 곳에서 부른다 —
|
|
659
|
+
// 갈리면 던질 때는 통과하고 셀 때는 무효인 표가 생긴다.
|
|
660
|
+
//
|
|
661
|
+
// 🔴 정책을 못 읽었으면(`policy === null`) 아무것도 요구하지 않는다. 여기서
|
|
662
|
+
// fail-closed 로 막아 봐야 판정이 바뀌지 않고, 정책이 아직 없는 저장소에서
|
|
663
|
+
// 표를 못 던지게 만들 뿐이다. 판정은 언제나 게이트가 한다.
|
|
664
|
+
let noteRule = null
|
|
665
|
+
try {
|
|
666
|
+
noteRule = voteNoteRule(policy)
|
|
667
|
+
} catch (e) {
|
|
668
|
+
die(
|
|
669
|
+
`${targetName} 의 정책(${policyPath})에 적힌 \`vote_note\` 를 읽을 수 없습니다.\n ${e.message}\n`
|
|
670
|
+
+ ' 정책을 고치세요 — 이 상태로는 게이트도 셀 수 없습니다.',
|
|
671
|
+
e.exit ?? EXIT.UNDECIDABLE,
|
|
672
|
+
)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const noteBad = voteNoteProblem({ note, at, rule: noteRule })
|
|
676
|
+
if (noteBad) {
|
|
677
|
+
const min = noteRule.minChars
|
|
678
|
+
die(
|
|
679
|
+
(noteBad.reason === 'note-missing'
|
|
680
|
+
? `이 저장소는 표에 이유를 요구합니다. --note 없이는 던질 수 없습니다.\n`
|
|
681
|
+
: `이유가 너무 짧습니다 (${noteBad.detail}).\n`)
|
|
682
|
+
+ ` ${targetName} 의 정책: vote_note.min_chars = ${min}`
|
|
683
|
+
+ `${noteRule.since ? `, since = ${noteRule.since}` : ''}\n\n`
|
|
684
|
+
+ ` 다시 이렇게 부르세요:\n`
|
|
685
|
+
+ ` --branch ${branch} --vote ${voteValue} `
|
|
686
|
+
+ `--note "무엇을 확인했고 왜 ${voteValue === 'reject' ? '반대' : '찬성'}하는가"\n\n`
|
|
687
|
+
+ ' 🔴 --force 로는 못 넘깁니다. --force 는 자기 표(G1) 경고 전용입니다.\n'
|
|
688
|
+
+ ' 이유 없이 쓴 표는 세는 쪽에서도 안 세집니다 — 던졌다고 믿는 동안 아무도 안 셉니다.\n'
|
|
689
|
+
+ (voteValue === 'reject'
|
|
690
|
+
? ' 반대야말로 이유가 필요합니다. 이유 없는 반대는 무엇을 고쳐야 하는지를 안 남깁니다.'
|
|
691
|
+
: ' "확인함" 네 글자는 이유가 아니라 서명입니다.'),
|
|
692
|
+
EXIT.SHORT,
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
|
|
427
696
|
const remote = resolveRemote(flags)
|
|
428
697
|
if (!remote) {
|
|
429
698
|
console.error(
|
|
@@ -435,7 +704,17 @@ if (!remote) {
|
|
|
435
704
|
ensureVotesWorktree(remote)
|
|
436
705
|
|
|
437
706
|
const relPath = `votes/${branch}/${voter}-${sha.slice(0, 8)}.json`
|
|
438
|
-
|
|
707
|
+
|
|
708
|
+
// 던질 때 실제로 보였던 것. 재는 것은 언제나 이 프로그램이다 — 플래그가 없다.
|
|
709
|
+
// 🔴 `--agent` 를 안 줘도 잰다. 사람이 혼자 던진 표에도 "그때 무엇이 보였나" 는
|
|
710
|
+
// 남아야 한다. 이 칸이 있어야 나중에 "왜 이걸 통과시켰나" 를 되물을 수 있다.
|
|
711
|
+
const shown = {
|
|
712
|
+
...measureDiff(targetName, branch),
|
|
713
|
+
gate: measureGate(branch, targetName, str(flags.policy)),
|
|
714
|
+
axmap: measureAxmap(),
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// 시각(`at`)은 위에서 한 번 정했다. CAS 재시도마다 바뀌면 같은 표가 매번 다른
|
|
439
718
|
// 내용이 되어, 무엇 때문에 다시 쓰는지가 이력에서 안 보인다.
|
|
440
719
|
const record = {
|
|
441
720
|
voter,
|
|
@@ -443,8 +722,13 @@ const record = {
|
|
|
443
722
|
branch,
|
|
444
723
|
sha,
|
|
445
724
|
vote: voteValue,
|
|
446
|
-
at
|
|
725
|
+
at,
|
|
447
726
|
...(note ? { note } : {}),
|
|
727
|
+
agent: {
|
|
728
|
+
id: agentId, // --agent 를 안 주면 "none" — 사람이 혼자 던졌다는 뜻이다
|
|
729
|
+
shown, // 🔴 프로그램이 잰 것. 사람도 AI 도 못 적는다
|
|
730
|
+
...(agentSummary ? { summary: agentSummary } : {}),
|
|
731
|
+
},
|
|
448
732
|
// 🔴 committerEmail 은 여기 없다. 게이트가 git 에서 채운다 (파일 머리말 참고).
|
|
449
733
|
}
|
|
450
734
|
|
|
@@ -477,6 +761,13 @@ for (let attempt = 1; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
|
477
761
|
if (p.code === 0) {
|
|
478
762
|
console.log(`표를 던졌습니다: ${relPath}`)
|
|
479
763
|
console.log(` ${voter} <${email}> ${voteValue} ${branch}@${sha.slice(0, 8)}`)
|
|
764
|
+
// 잰 것을 화면에도 보여준다. 표에만 적고 안 보여주면 던진 사람은 자기 표에
|
|
765
|
+
// 무엇이 적혔는지 모르고, 모르는 칸은 아무도 안 읽는다.
|
|
766
|
+
console.log(
|
|
767
|
+
` 본 것: ${shown.files === null ? '못 쟀음' : `${shown.files}파일 +${shown.insertions}/-${shown.deletions}`}`
|
|
768
|
+
+ ` · 던지기 직전 판정: ${shown.gate}`
|
|
769
|
+
+ ` · axMap 사본: ${shown.axmap.behind === null ? '뒤처짐 못 쟀음' : `${shown.axmap.behind}커밋 뒤`}`,
|
|
770
|
+
)
|
|
480
771
|
if (voteValue === 'reject') {
|
|
481
772
|
console.log(' reject 는 정족수 계산에 들어가지 않습니다 — 판정문에 보이기만 합니다.')
|
|
482
773
|
}
|
package/mcp/README.md
CHANGED
|
@@ -145,9 +145,25 @@ Claude Code 에서는 `/ax`, `/ax-start`, `/ax-done`, `/ax-tell` 로도 부른
|
|
|
145
145
|
|---|---|
|
|
146
146
|
| `AXMAP_REPO` | 대상 저장소를 **한 곳으로 고정**한다. 전역 설치에서는 **비운다** (아래) |
|
|
147
147
|
| `AXMAP_AGENT` | 이 에이전트의 이름. 여럿 붙을 때 서로 달라야 한다 |
|
|
148
|
+
| `AXMAP_SESSION` | 이 세션의 id. **안 적어도 된다** — 아래 |
|
|
148
149
|
| `AXMAP_ACTOR` | `agent`(대화형) / `background`(백그라운드) / `human` / `team` — 화면 색이 갈린다 |
|
|
149
150
|
| `AXMAP_TTL` | 기본 선점 시간 (기본 45m) |
|
|
150
151
|
|
|
152
|
+
### `AXMAP_SESSION` 은 서버가 알아서 정한다 — 이름이 겹쳤을 때의 마지막 그물
|
|
153
|
+
|
|
154
|
+
이름은 "누구인가" 이고 세션은 "어느 작업 주체인가" 다. 한 사람이 세션을 둘 띄우면
|
|
155
|
+
이름이 같아도 서로 다른 주체이고, 장부는 이름당 레코드 하나라 **뒤에 온 쪽이 앞의
|
|
156
|
+
것을 덮는다.** 2026-08-28 에 두 번 재현됐다.
|
|
157
|
+
|
|
158
|
+
서버는 `AXMAP_SESSION` → `CLAUDE_CODE_SESSION_ID` → `mcp-<pid>` 순으로 정해
|
|
159
|
+
CLI 를 부를 때마다 넘긴다. 앞 두 칸은 CLI 와 순서가 같아서, MCP 로 잡은 것을 셸에서
|
|
160
|
+
반납해도 같은 세션으로 인식된다. 마지막 칸은 **이 서버 프로세스 하나가 곧 한 세션**
|
|
161
|
+
이라는 뜻이라, 도구를 두 개 띄우면 자동으로 갈린다.
|
|
162
|
+
|
|
163
|
+
그러면 같은 이름의 다른 세션이 claim·release 를 시도할 때 **거부되고**(종료 코드 2)
|
|
164
|
+
앞 세션의 레코드는 그대로 남는다. 이건 그물이지 해법이 아니다 — **해법은 아래처럼
|
|
165
|
+
사람마다·세션마다 `AXMAP_AGENT` 를 다르게 주는 것**이다.
|
|
166
|
+
|
|
151
167
|
### 🔴 `AXMAP_REPO` 는 비운다 — 대상은 부를 때마다 다시 정해진다
|
|
152
168
|
|
|
153
169
|
서버는 도구를 부를 때마다 대상 저장소를 다시 정한다. 순서는 넷이고 위가 이긴다:
|
package/mcp/server.mjs
CHANGED
|
@@ -163,6 +163,30 @@ const AGENT = resolveAgent()
|
|
|
163
163
|
const ACTOR = process.env.AXMAP_ACTOR ?? 'agent'
|
|
164
164
|
const TTL = process.env.AXMAP_TTL ?? '45m'
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* 이 서버가 **어느 세션의 것인가**.
|
|
168
|
+
*
|
|
169
|
+
* 🔴 2026-08-28 에 두 번 재현된 사고가 정확히 여기서 났다. 한 PC 에서 AI 도구
|
|
170
|
+
* 세션 두 개가 붙었고, 둘 다 `git config user.name` 이 같아 `AGENT` 가 같았다.
|
|
171
|
+
* 장부는 이름당 레코드 하나라 뒤에 온 쪽이 앞의 것을 덮었고, 아무 경고도 없었다.
|
|
172
|
+
* 이름은 "누구인가", 세션은 "어느 작업 주체인가" 다. 둘을 갈라야 서로를 막는다.
|
|
173
|
+
*
|
|
174
|
+
* 순서를 CLI(`resolveSessionId`)와 **앞 두 칸까지 똑같이** 맞춘다. 안 맞추면
|
|
175
|
+
* 같은 세션인데 MCP 로 잡은 것을 셸에서 반납하지 못한다 — 이름이 갈렸을 때와
|
|
176
|
+
* 똑같은 사고가 세션 쪽에서 되풀이된다.
|
|
177
|
+
*
|
|
178
|
+
* 1. AXMAP_SESSION 사람이 직접 지정
|
|
179
|
+
* 2. CLAUDE_CODE_SESSION_ID 도구가 세션마다 다르게 준다 (셸에서도 같은 값)
|
|
180
|
+
* 3. `mcp-<pid>` 둘 다 없을 때. **이 서버 프로세스 하나가 곧 한 세션**이다
|
|
181
|
+
*
|
|
182
|
+
* 🔴 3번에 난수를 쓰지 않는다. pid 는 서버가 사는 동안 안 변하므로 같은 세션의
|
|
183
|
+
* claim·release 가 짝이 맞고, 서버를 두 개 띄우면 반드시 다르다. 난수였다면
|
|
184
|
+
* 그것도 되지만 로그에서 어느 프로세스였는지 되짚을 수 없다.
|
|
185
|
+
* 서버가 재시작하면 pid 가 바뀌어 "다른 세션" 이 된다 — 그때는 CLI 가
|
|
186
|
+
* 거부하면서 `--takeover` 를 알려준다. 조용히 덮는 것보다 낫다.
|
|
187
|
+
*/
|
|
188
|
+
const SESSION = process.env.AXMAP_SESSION || process.env.CLAUDE_CODE_SESSION_ID || `mcp-${process.pid}`
|
|
189
|
+
|
|
166
190
|
/**
|
|
167
191
|
* 쪽지함 CLI 를 부른다. `cli` 와 달리 stdin 으로 본문을 넘긴다.
|
|
168
192
|
*
|
|
@@ -310,7 +334,7 @@ function cli(args) {
|
|
|
310
334
|
cwd: REPO,
|
|
311
335
|
encoding: 'utf8',
|
|
312
336
|
windowsHide: true,
|
|
313
|
-
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR },
|
|
337
|
+
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION },
|
|
314
338
|
})
|
|
315
339
|
const stdout = (r.stdout ?? '').trim()
|
|
316
340
|
const stderr = (r.stderr ?? '').trim()
|
|
@@ -416,12 +440,13 @@ const TOOLS = [
|
|
|
416
440
|
name: 'ax_inbox',
|
|
417
441
|
description:
|
|
418
442
|
'다른 에이전트가 나에게 보낸 쪽지를 읽는다. 세션을 시작할 때, 그리고 방향을 '
|
|
419
|
-
+ '바꾸기 전에 확인하십시오.
|
|
443
|
+
+ '바꾸기 전에 확인하십시오. **안 읽은 것만 냅니다** — 한 번 뜬 쪽지는 다음부터 '
|
|
444
|
+
+ '안 나오므로, 필요하면 그 자리에서 처리하십시오. id 를 주면 그 쪽지의 본문 전체를 냅니다.',
|
|
420
445
|
inputSchema: {
|
|
421
446
|
type: 'object',
|
|
422
447
|
properties: {
|
|
423
448
|
id: { type: 'string', description: '본문을 볼 쪽지 id. 없으면 목록만.' },
|
|
424
|
-
all: { type: 'boolean', description: '남에게 간 것까지 전부
|
|
449
|
+
all: { type: 'boolean', description: '읽은 것과 남에게 간 것까지 전부 본다. 응답이 커지므로 필요할 때만.' },
|
|
425
450
|
},
|
|
426
451
|
},
|
|
427
452
|
},
|
|
@@ -682,7 +707,7 @@ function releaseAllIn(dir) {
|
|
|
682
707
|
cwd: dir,
|
|
683
708
|
encoding: 'utf8',
|
|
684
709
|
windowsHide: true,
|
|
685
|
-
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR },
|
|
710
|
+
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION },
|
|
686
711
|
})
|
|
687
712
|
return {
|
|
688
713
|
code: r.status ?? 1,
|
|
@@ -821,7 +846,17 @@ function dispatch(name, args = {}) {
|
|
|
821
846
|
// 보내기는 멀쩡했던 것이 이 버그를 오래 살렸다 — `ax_send` 는 `AGENT` 를
|
|
822
847
|
// 넘긴다. 보낸 쪽은 성공을 보고 받는 쪽은 "쪽지 없음" 을 본다. 양쪽 다
|
|
823
848
|
// 오류가 없으므로 아무도 실패를 보지 못한다.
|
|
824
|
-
|
|
849
|
+
// 🔴 `--unread` 가 없으면 쪽지가 쌓인 만큼 응답이 커진다. 여기는 사람이 보는
|
|
850
|
+
// 화면이 아니라 **에이전트의 컨텍스트**라, 스크롤로 멈출 사람이 없다.
|
|
851
|
+
// 2026-08-28 팀 저장소 실측: 42건에 7,463자(≈3,380토큰)였고 상한이 없었다.
|
|
852
|
+
// `/ax-start` 한 번의 75% 가 이 한 줄에서 나왔다.
|
|
853
|
+
//
|
|
854
|
+
// `--unread` 는 `list` 안에서 `id > seen` 으로 거르고 **보여준 것을 스스로
|
|
855
|
+
// 찍는다** (bus.mjs 「그린 쪽이, 그린 것만 찍는다」). 아래의 `seen` 호출과
|
|
856
|
+
// 겹치지만 `markSeen` 은 뒤로 가지 않으므로 결과가 같다.
|
|
857
|
+
//
|
|
858
|
+
// 전부 보려면 `all: true` 가 이미 있다 — 그쪽은 거르지도 찍지도 않는다.
|
|
859
|
+
const a = args.id ? ['read', String(args.id)] : ['list', ...(args.all ? ['--all'] : ['--to', AGENT, '--unread'])]
|
|
825
860
|
const r = bus(a)
|
|
826
861
|
|
|
827
862
|
// 🔴 여기도 **낸 것만 찍는다.** 목록을 자르지 않으므로 낸 것 전부다.
|
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": "0.0
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
|
|
6
6
|
"//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
|
|
7
7
|
"publishConfig": {
|
|
@@ -14,7 +14,15 @@
|
|
|
14
14
|
"type": "git",
|
|
15
15
|
"url": "git+https://lab.ssafy.com/rleaderjoon/axmap.git"
|
|
16
16
|
},
|
|
17
|
-
"keywords": [
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai-agent",
|
|
19
|
+
"claim",
|
|
20
|
+
"lock",
|
|
21
|
+
"git",
|
|
22
|
+
"mcp",
|
|
23
|
+
"collaboration",
|
|
24
|
+
"preemption"
|
|
25
|
+
],
|
|
18
26
|
"bin": {
|
|
19
27
|
"axmap": "bin/axmap.mjs"
|
|
20
28
|
},
|