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/src/protocol.mjs CHANGED
@@ -58,6 +58,57 @@ export function myActiveClaim(claims, me, now) {
58
58
  return activeClaims(claims, now).find((c) => c.agent === me) ?? null
59
59
  }
60
60
 
61
+ /**
62
+ * 이 레코드를 만든 **세션**과 지금 실행하는 세션이 같은가.
63
+ *
64
+ * 🔴 왜 이름만으로는 부족한가 — 2026-08-28 에 두 번 재현된 사고.
65
+ *
66
+ * 한 PC 에서 Claude Code 세션 두 개가 같은 저장소를 만졌다. 둘 다
67
+ * `git config user.name` 이 `janghyojoon` 이라 장부에서 **한 사람**이었다.
68
+ * `checkOverlap` 은 자기 claim 을 겹침으로 보지 않으므로 세션 B 의 claim 이
69
+ * 그냥 통과했고, 레코드는 이름 하나에 파일 하나라 A 의 것이 B 의 것으로
70
+ * 덮였다. 두 세션이 같은 파일을 동시에 밀었고 장부는 아무 말도 안 했다.
71
+ *
72
+ * 이름은 **누구인가**이고 세션은 **어느 작업 주체인가**다. 한 사람이 동시에
73
+ * 두 주체일 수 있다. 그 둘을 같은 것으로 두면 락이 조용히 두 번 발급된다.
74
+ *
75
+ * ── 왜 "모르면 막는다" 가 아닌가 ──────────────────────────────────────────
76
+ *
77
+ * 세션 id 는 환경이 알려줄 때만 있다. 없을 때 막아버리면 세션 개념이 없던
78
+ * 시절에 잡아둔 레코드와 셸에서 쓰는 사람이 **자기 claim 을 못 늘리고 못 반납**
79
+ * 한다 — 고치려던 것보다 큰 고장이다. 그래서 셋으로 나눈다:
80
+ *
81
+ * same 양쪽 다 알고 같다 → 지금까지와 똑같이 돈다
82
+ * different 양쪽 다 알고 다르다 → **거부한다.** 이것이 위 사고다
83
+ * unverifiable 한쪽이라도 모른다 → 막지 않되 **조용히 넘어가지 않는다**
84
+ *
85
+ * 이 판정은 인증이 아니다. 환경변수를 지우면 `unverifiable` 로 내려온다.
86
+ * 막으려는 것은 악의가 아니라 **아무도 눈치 못 챈 사고**다.
87
+ *
88
+ * @returns {'none'|'same'|'different'|'unverifiable'}
89
+ */
90
+ export function sessionVerdict(prev, session) {
91
+ if (!prev) return 'none'
92
+ const held = prev.session ?? null
93
+ const mine = session ?? null
94
+ if (!held || !mine) return 'unverifiable'
95
+ return held === mine ? 'same' : 'different'
96
+ }
97
+
98
+ /** 거부 메시지에 실을 만큼만 담은 충돌 정보. 레코드 원본을 그대로 흘리지 않는다. */
99
+ function sessionConflictOf(prev, me, session) {
100
+ return {
101
+ agent: me,
102
+ heldSession: prev.session ?? null,
103
+ mySession: session ?? null,
104
+ task: prev.task ?? null,
105
+ intent: prev.intent ?? null,
106
+ paths: [...(prev.paths ?? [])],
107
+ since: prev.since,
108
+ expiresAt: claimExpiresAt(prev),
109
+ }
110
+ }
111
+
61
112
  /**
62
113
  * 이 프로토콜의 심장.
63
114
  *
@@ -108,9 +159,31 @@ function upsert(claims, record) {
108
159
 
109
160
  /**
110
161
  * claim 취득. 관문 2 의 판정이 여기서 일어난다.
111
- * @returns {{ok:true, record, claims, hadExpired}|{ok:false, blocks}}
162
+ *
163
+ * 관문 2 는 이제 질문이 둘이다.
164
+ * 2-a 남이 잡은 경로와 겹치는가 → checkOverlap
165
+ * 2-b 같은 이름인데 **다른 세션**이 잡았는가 → sessionVerdict
166
+ *
167
+ * 🔴 어느 쪽으로 막든 **아무것도 쓰지 않고 돌아간다.** 이 결함의 핵심 피해가
168
+ * "A 의 레코드가 사라졌다" 였으므로, 판정이 끝나기 전에는 레코드를 만들지 않는다.
169
+ * `--takeover` 로 넘겨받을 때조차 `paths` 는 합집합이라 경로는 하나도 안 없어진다.
170
+ *
171
+ * @returns {{ok:true, record, claims, hadExpired, session, tookOver}
172
+ * |{ok:false, blocks}
173
+ * |{ok:false, blocks:[], sessionConflict}}
112
174
  */
113
- export function applyClaim({ claims, me, requested, now, ttlMs, task = null, intent = null, actor = null }) {
175
+ export function applyClaim({
176
+ claims,
177
+ me,
178
+ requested,
179
+ now,
180
+ ttlMs,
181
+ task = null,
182
+ intent = null,
183
+ actor = null,
184
+ session = null,
185
+ takeover = false,
186
+ }) {
114
187
  const want = [...new Set(requested.map(normalizePath))]
115
188
 
116
189
  const verdict = checkOverlap({ requested: want, claims, me, now })
@@ -121,6 +194,18 @@ export function applyClaim({ claims, me, requested, now, ttlMs, task = null, int
121
194
  const prev = myActiveClaim(claims, me, now)
122
195
  const hadExpired = !prev && claims.some((c) => c.agent === me)
123
196
 
197
+ /**
198
+ * 경로가 안 겹쳐도 막는다.
199
+ *
200
+ * 겹칠 때만 막으면 안 되는 이유: 이름당 레코드는 하나뿐이라 경로가 안 겹쳐도
201
+ * 저쪽의 `task`·`intent`·`since`·TTL 이 이쪽 값으로 덮인다. 장부를 읽는 사람은
202
+ * 저 세션이 무엇을 왜 하고 있었는지를 잃는다 — 그게 "기록이 사라졌다" 의 실체다.
203
+ */
204
+ const sv = sessionVerdict(prev, session)
205
+ if (sv === 'different' && !takeover) {
206
+ return { ok: false, blocks: [], sessionConflict: sessionConflictOf(prev, me, session) }
207
+ }
208
+
124
209
  const record = {
125
210
  agent: me,
126
211
  task: task ?? prev?.task ?? null,
@@ -128,21 +213,42 @@ export function applyClaim({ claims, me, requested, now, ttlMs, task = null, int
128
213
  // 누가 잡았는지의 '종류'. 화면에서 사람·AI·백그라운드 에이전트를 색으로 나눈다.
129
214
  // 프로토콜 판정에는 쓰이지 않는다 — 표시용 정보다.
130
215
  actor: actor ?? prev?.actor ?? null,
216
+ /**
217
+ * 이 레코드를 만든 세션. **판정에 쓰인다** (actor 와 다른 점이다).
218
+ *
219
+ * 내가 세션을 모르면 앞 레코드의 값을 그대로 둔다. 모른다고 지워버리면
220
+ * 세션 없는 셸에서 한 번 부르는 것만으로 표식이 날아가고, 그다음부터
221
+ * 다른 세션이 아무 말 없이 덮을 수 있게 된다 — 막으려던 그 자리로 돌아간다.
222
+ */
223
+ session: session ?? prev?.session ?? null,
131
224
  since: new Date(now).toISOString(),
132
225
  ttlMs,
133
226
  paths: [...new Set([...(prev?.paths ?? []).map(normalizePath), ...want])].sort(),
134
227
  }
135
- return { ok: true, record, claims: upsert(claims, record), hadExpired }
228
+ return { ok: true, record, claims: upsert(claims, record), hadExpired, session: sv, tookOver: sv === 'different' }
136
229
  }
137
230
 
138
231
  /**
139
232
  * 반납. drop 이 비어 있으면 전부 반납한다.
140
233
  * 만료된 레코드도 반납할 수 있다 (단순 정리이므로 관문 2 가 필요 없다).
234
+ *
235
+ * 🔴 **세션 검사가 claim 뿐 아니라 여기에도 있어야 한다.**
236
+ * `release` 는 경로를 다 빼면 레코드 파일을 지운다. 같은 이름의 다른 세션이
237
+ * 이것을 부르면 claim 을 덮는 것보다 더 확실하게 기록이 사라진다.
238
+ *
239
+ * 다만 **만료된 레코드는 막지 않는다.** 만료된 것은 아무도 못 막는 껍데기라
240
+ * 지우는 것이 곧 정리다. 여기서까지 막으면 죽은 세션이 두고 간 레코드를
241
+ * 아무도 못 치운다. (`now` 를 안 주면 만료 여부를 모르므로 막는 쪽으로 둔다.)
141
242
  */
142
- export function applyRelease({ claims, me, drop }) {
243
+ export function applyRelease({ claims, me, drop, session = null, takeover = false, now = null }) {
143
244
  const mine = claims.find((c) => c.agent === me)
144
245
  if (!mine) return { claims, record: null, unheld: [], hadNothing: true }
145
246
 
247
+ const stillActive = now === null || !isExpired(mine, now)
248
+ if (stillActive && !takeover && sessionVerdict(mine, session) === 'different') {
249
+ return { claims, record: null, unheld: [], hadNothing: false, sessionConflict: sessionConflictOf(mine, me, session) }
250
+ }
251
+
146
252
  const held = mine.paths.map(normalizePath)
147
253
  const want = [...new Set(drop.map(normalizePath))]
148
254
  const unheld = want.filter((p) => !held.includes(p))
@@ -159,12 +265,21 @@ export function applyRelease({ claims, me, drop }) {
159
265
  * TTL 연장.
160
266
  * 만료된 claim 은 연장할 수 없다. renew 는 관문 2 를 거치지 않으므로,
161
267
  * 만료 이후를 허용하면 그 사이 남이 가져간 경로를 검사 없이 되찾게 된다.
268
+ *
269
+ * 🔴 **세션이 달라도 막지 않는다.** renew 는 아무것도 지우지 않고 아무것도 덮지
270
+ * 않는다 — 시간만 늘린다. 여기서 막으면 "한 사람이 자기 claim 을 갱신한다" 는
271
+ * 정상 흐름이 세션 표식 하나 때문에 끊긴다. 대신 어느 세션의 것을 늘리고
272
+ * 있는지는 `session` 으로 돌려주고, 부르는 쪽이 그것을 말한다.
273
+ *
274
+ * 세션 표식이 없던 레코드에는 이번 세션 값을 **채워 넣는다**(이관이 아니라
275
+ * 이주다). 이미 표식이 있으면 건드리지 않는다 — 바꾸면 그것이 곧 조용한 이관이다.
162
276
  */
163
- export function applyRenew({ claims, me, now, ttlMs }) {
277
+ export function applyRenew({ claims, me, now, ttlMs, session = null }) {
164
278
  const mine = myActiveClaim(claims, me, now)
165
279
  if (!mine) return { ok: false, reason: 'expired-or-missing' }
166
- const record = { ...mine, since: new Date(now).toISOString(), ttlMs }
167
- return { ok: true, record, claims: upsert(claims, record) }
280
+ const sv = sessionVerdict(mine, session)
281
+ const record = { ...mine, session: mine.session ?? session ?? null, since: new Date(now).toISOString(), ttlMs }
282
+ return { ok: true, record, claims: upsert(claims, record), session: sv }
168
283
  }
169
284
 
170
285
  // ---------------------------------------------------------------------------
@@ -257,6 +372,47 @@ export function formatBlocks(blocks, now) {
257
372
  return lines.join('\n')
258
373
  }
259
374
 
375
+ /** 세션 id 는 길다. 사람이 두 개를 눈으로 구분할 만큼만 보여준다. */
376
+ export function shortSession(id) {
377
+ const s = String(id ?? '')
378
+ if (!s) return '(모름)'
379
+ return s.length <= 12 ? s : `${s.slice(0, 8)}…${s.slice(-4)}`
380
+ }
381
+
382
+ /**
383
+ * "같은 이름, 다른 세션" 을 사람과 AI 가 함께 읽는 형태로.
384
+ *
385
+ * `formatBlocks` 와 같은 원칙이다 — 막혔다는 말만 하면 에이전트가 다음 행동을
386
+ * 고를 수 없다. 여기서 특히 중요한 것은 **왜 이름이 같은데 남이라고 하는지**를
387
+ * 설명하는 것이다. 설명이 없으면 에이전트는 이것을 버그로 보고 우회를 찾는다.
388
+ */
389
+ export function formatSessionConflict(c, now, command = 'claim') {
390
+ const lines = [
391
+ `${command} 거부 - 이름은 "${c.agent}" 로 같지만 그 claim 을 만든 세션이 다릅니다.`,
392
+ '',
393
+ ` 잡고 있는 세션 : ${shortSession(c.heldSession)}`,
394
+ ` 지금 이 세션 : ${shortSession(c.mySession)}`,
395
+ ]
396
+ if (c.task) lines.push(` 작업 : ${c.task}`)
397
+ if (c.intent) lines.push(` 의도 : ${c.intent}`)
398
+ lines.push(` 시작 : ${c.since}`)
399
+ lines.push(` TTL : ${humanDuration(c.expiresAt - now)} 남음`)
400
+ lines.push(' 잡힌 경로 :')
401
+ for (const p of c.paths) lines.push(` ${p}`)
402
+ lines.push('')
403
+ lines.push('같은 사람이라도 세션이 다르면 서로 다른 작업 주체입니다. 이름당 레코드는')
404
+ lines.push('하나뿐이라, 그대로 진행하면 저 세션의 작업·의도·시작 시각이 이 세션의')
405
+ lines.push('것으로 덮여 장부에서 사라집니다. 그래서 아무것도 쓰지 않고 멈췄습니다.')
406
+ lines.push('')
407
+ lines.push('다음 중 하나를 하세요:')
408
+ lines.push(' - 이 세션에 다른 이름을 준다 (권장)')
409
+ lines.push(` AXMAP_AGENT=${c.agent}-2 — 장부에서 둘로 갈라지고 겹침 검사가 실제로 돕니다`)
410
+ lines.push(' - 저 세션이 끝난 것이 확실하면 TTL 만료를 기다린다')
411
+ lines.push(` - 저 세션이 확실히 나 자신이면 --takeover 를 붙인다`)
412
+ lines.push(' 레코드를 이 세션으로 넘겨받습니다. 잡혀 있던 경로는 하나도 안 없어집니다.')
413
+ return lines.join('\n')
414
+ }
415
+
260
416
  // ---------------------------------------------------------------------------
261
417
  // 병합 합의 — 게이트는 거부권, 표는 판단 (SPEC 7-3)
262
418
  //
@@ -74,12 +74,102 @@ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
74
74
 
75
75
  // ── Claude Code ────────────────────────────────────────────────────────────
76
76
  //
77
- // 일이 없다. 저장소의 `.mcp.json` 스스로 발견하고 승인만 묻는다.
78
- // 그래도 찍는다 "내 CLI 는 왜 아무 말이 없지" 를 없애기 위해서다.
79
- function doClaude(repo) {
77
+ // 🔴 **2026-08-31 여기서 전역 등록을 하도록 바꿨다.** 그전에는 저장소의
78
+ // `.mcp.json` 있는지 확인만 하고 아무것도 하지 않았다.
79
+ //
80
+ // 그래서 Claude 만 **저장소에 파일을 커밋해야** 붙는 유일한 CLI 였다. npm 으로
81
+ // 설치해 남의 저장소에서 쓰는 사람은 그 파일을 넣을 권한이 없을 수도 있고,
82
+ // 넣더라도 "도구는 나가고 데이터만 남는다" 는 이 저장소의 방침에 어긋난다.
83
+ // `mcp/install.ps1` 은 이미 전역 등록을 하고 있었다 — 설치 경로 둘이 서로 다르게
84
+ // 굴어서, 둘 다 돌린 PC 에는 **범위가 충돌하는 항목**이 남았다(claude mcp list 가
85
+ // "Conflicting scopes" 로 알려 준다). 두 경로를 같은 동작으로 맞춘다.
86
+ //
87
+ // 설정: `~/.claude.json` 의 최상위 `mcpServers` (user 범위)
88
+ // 형식: `claude mcp add <이름> -s user [-e K=V] -- <명령> <인수...>`
89
+ //
90
+ // 🔴 `~/.claude.json` 을 **우리가 쓰지 않는다.** 그 파일에는 Claude Code 의 세션
91
+ // 상태까지 들어 있어 읽고 다시 쓰는 순간 남의 것을 깨뜨릴 위험이 생긴다.
92
+ // 있는지 **읽는 것만** 우리가 하고, 쓰는 일은 claude 자신에게 맡긴다 —
93
+ // doCodex 와 같은 태도다.
94
+
95
+ /** `~/.claude.json` 의 user 범위 axmap 항목. 없으면 null, **못 읽으면 undefined**. */
96
+ function claudeUserEntry() {
97
+ try {
98
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'))
99
+ return (cfg && cfg.mcpServers && cfg.mcpServers.axmap) || null
100
+ } catch (e) {
101
+ // 파일이 아예 없는 것은 "등록 안 됨"(null)이고, 있는데 못 읽는 것은 사고(undefined)다.
102
+ return e && e.code === 'ENOENT' ? null : undefined
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 이미 쓸 만한 항목인가.
108
+ *
109
+ * env 는 **우리가 넣는 키만** 본다. 남이 `AXMAP_AGENT` 를 손으로 넣어 뒀을 수 있고,
110
+ * 그건 그 사람의 이름이라 지우면 안 된다 (install.ps1 이 실제로 넣는다).
111
+ */
112
+ const sameEntry = (cur, want) =>
113
+ !!cur && cur.command === want.command &&
114
+ JSON.stringify(cur.args || []) === JSON.stringify(want.args) &&
115
+ Object.entries(want.env).every(([k, v]) => ((cur.env || {})[k] === v))
116
+
117
+ function doClaude(bin, repo) {
118
+ const run = (args) => spawnSync(bin, args, { encoding: 'utf8', windowsHide: true })
119
+ const want = entry()
120
+
121
+ const cur = claudeUserEntry()
122
+ if (cur === undefined) {
123
+ no('claude', '~/.claude.json 을 읽지 못했습니다 — 건드리지 않았습니다')
124
+ snippetClaude()
125
+ return
126
+ }
127
+ if (sameEntry(cur, want)) { ok('claude', '이미 등록돼 있습니다 (user 범위)'); return }
128
+
129
+ // 문서에 있다고 믿고 부르지 않는다 — 먼저 물어본다. doCodex 와 같다.
130
+ const help = run(['mcp', 'add', '--help'])
131
+ if (help.status !== 0 || !/--scope/.test(`${help.stdout}${help.stderr}`)) {
132
+ hm('claude', "'claude mcp add -s user' 를 확인하지 못했습니다 — 손으로 넣으세요")
133
+ snippetClaude()
134
+ return
135
+ }
136
+
137
+ const add = ['mcp', 'add', 'axmap', '-s', 'user',
138
+ ...Object.entries(want.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
139
+ '--', want.command, ...want.args]
140
+
141
+ if (DRY) {
142
+ hm('claude', `(dry-run) ${cur ? 'claude mcp remove axmap -s user 뒤 ' : ''}claude ${add.join(' ')}`)
143
+ return
144
+ }
145
+
146
+ // 자리를 비워야 add 가 덮어쓴다. 지우는 것은 **우리 이름(`axmap`)의 항목뿐**이다.
147
+ if (cur) run(['mcp', 'remove', 'axmap', '-s', 'user'])
148
+
149
+ const r = run(add)
150
+ if (r.status === 0 && sameEntry(claudeUserEntry(), want)) {
151
+ ok('claude', 'user 범위에 등록했습니다 — 어느 폴더에서 열어도 붙습니다')
152
+ } else {
153
+ no('claude', `claude mcp add 가 실패했습니다 (${(r.stderr || r.stdout || '').trim().split('\n')[0] || `exit ${r.status}`})`)
154
+ snippetClaude()
155
+ }
156
+
157
+ // 저장소에 `.mcp.json` 이 남아 있으면 **그쪽이 이긴다.** 조용히 두면 방금 등록한
158
+ // 것이 왜 안 쓰이는지 아무도 못 찾는다.
80
159
  const f = path.join(repo, '.mcp.json')
81
- if (fs.existsSync(f)) ok('claude', '.mcp.json 폴더를 열면 스스로 발견합니다 (승인만 누르면 됩니다)')
82
- else no('claude', `.mcp.json 이 없습니다: ${f}`)
160
+ if (fs.existsSync(f)) hm('claude', `저장소의 .mcp.json 이 먼저 쓰입니다 (같은 서버라면 문제 없습니다): ${f}`)
161
+ }
162
+
163
+ /** 손으로 넣을 한 줄. 경로에 공백이 있을 수 있어 겹따옴표로 감싼다. */
164
+ function snippetClaude() {
165
+ console.log('')
166
+ console.log(' 아래 한 줄을 치세요:')
167
+ console.log('')
168
+ console.log(` claude mcp add axmap -s user -e AXMAP_ACTOR=agent -e AXMAP_TTL=45m -- node "${SERVER}"`)
169
+ console.log('')
170
+ console.log(' AXMAP_AGENT 는 적지 마세요 — 여러 사람이 같은 이름이 되면')
171
+ console.log(' 서로의 선점을 겹침으로 보지 못합니다.')
172
+ console.log('')
83
173
  }
84
174
 
85
175
  // ── Antigravity CLI (agy) ──────────────────────────────────────────────────
@@ -234,7 +324,7 @@ function snippetToml() {
234
324
  // ── 본체 ───────────────────────────────────────────────────────────────────
235
325
 
236
326
  function main() {
237
- const repo = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: AXMAP_DIR, encoding: 'utf8', windowsHide: true })
327
+ const repo = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8', windowsHide: true })
238
328
  const root = repo.status === 0 ? path.resolve(repo.stdout.trim()) : path.resolve(AXMAP_DIR, '..')
239
329
 
240
330
  console.log('')
@@ -253,7 +343,7 @@ function main() {
253
343
  const bin = resolveBin(a.bin)
254
344
  if (!bin) continue
255
345
  any = true
256
- if (a.id === 'claude') doClaude(root)
346
+ if (a.id === 'claude') doClaude(bin, root)
257
347
  else if (a.id === 'agy') doAgy()
258
348
  else if (a.id === 'codex') doCodex(bin)
259
349
  }
package/tools/promote.mjs CHANGED
@@ -46,7 +46,7 @@
46
46
  import { spawnSync } from 'node:child_process'
47
47
  import path from 'node:path'
48
48
  import { fileURLToPath, pathToFileURL } from 'node:url'
49
- import { planPromotions, formatPlan, describeStep, STEPS } from '../src/promote.mjs'
49
+ import { planPromotions, formatPlan, describeStep, mergeCommitMessage, STEPS } from '../src/promote.mjs'
50
50
 
51
51
  /** 이 도구의 종료 코드. */
52
52
  export const EXIT = { OK: 0, ERROR: 1, POLICY_BROKEN: 4 }
@@ -54,9 +54,16 @@ export const EXIT = { OK: 0, ERROR: 1, POLICY_BROKEN: 4 }
54
54
  /** 게이트의 종료 코드. `src/governance.mjs` 의 EXIT 과 같은 값이다. */
55
55
  const GATE = { OK: 0, UNDECIDABLE: 1, SHORT: 2, POLICY_BROKEN: 4 }
56
56
 
57
- /** `axmap/` 폴더. 호출 위치에 기대지 않는다 (tools/version.mjs 같은 방식). */
57
+ /** `axmap/` 폴더. **프로그램**을 찾을 때만 쓴다 (옆에 있는 gate.mjs 같은 ). */
58
58
  const AXMAP = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
59
59
 
60
+ /**
61
+ * 🔴 git 은 **서 있는 저장소**에서 부른다. 프로그램이 있는 폴더가 아니다.
62
+ * npm 으로 설치하면 그 폴더는 `node_modules/axmap-cli/` 이고 저장소가 아니다.
63
+ * 근거는 tools/version.mjs 의 같은 자리 주석.
64
+ */
65
+ const REPO = process.cwd()
66
+
60
67
  // ---------------------------------------------------------------------------
61
68
  // 인자 — `--k v` · `--k=v` · `--k`(=true). gate.mjs 의 parseArgs 와 같은 규칙.
62
69
  // ---------------------------------------------------------------------------
@@ -123,7 +130,7 @@ function defaultGit(args) {
123
130
  const env = { ...process.env }
124
131
  for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE',
125
132
  'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_PREFIX']) delete env[k]
126
- const r = spawnSync('git', args, { cwd: AXMAP, env, encoding: 'utf8', windowsHide: true })
133
+ const r = spawnSync('git', args, { cwd: REPO, env, encoding: 'utf8', windowsHide: true })
127
134
  return { code: r.status ?? 1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }
128
135
  }
129
136
 
@@ -142,7 +149,7 @@ function defaultGate({ source, target }) {
142
149
  const r = spawnSync(
143
150
  process.execPath,
144
151
  [path.join(AXMAP, 'governance', 'gate.mjs'), '--source', source, '--target', target, '--json'],
145
- { cwd: AXMAP, encoding: 'utf8', windowsHide: true },
152
+ { cwd: REPO, encoding: 'utf8', windowsHide: true },
146
153
  )
147
154
  let verdict = null
148
155
  try { verdict = JSON.parse(r.stdout ?? '') } catch { verdict = null }
@@ -469,10 +476,18 @@ export async function run(argv, io = {}) {
469
476
  }
470
477
 
471
478
  // 4. 머지. 🔴 sha 를 반드시 싣는다 (CAS).
479
+ //
480
+ // 🔴 표의 이유를 **머지 커밋 본문**에 싣는다 (src/promote.mjs 의 근거 참고).
481
+ // MR 댓글에 두면 clone 에 안 따라오고 GitLab 을 떠나면 사라진다. 트레일러로
482
+ // 적으면 `git log --grep` 과 `git interpret-trailers --parse` 가 읽는다.
483
+ // 표가 하나도 없으면 null 이고, 그때는 필드를 아예 안 보내 GitLab 기본
484
+ // 메시지를 그대로 쓴다 — 빈 문자열을 보내면 기본 메시지를 덮어쓴다.
485
+ const message = mergeCommitMessage({ source, target, verdict })
472
486
  const merged = await gl('PUT', `/merge_requests/${mr.iid}/merge`, {
473
487
  body: {
474
488
  // 판정할 때 본 그 커밋. 지금 MR 헤드가 다르면 GitLab 이 409 로 거부한다.
475
489
  sha: verdict.sha,
490
+ ...(message ? { merge_commit_message: message } : {}),
476
491
  // 🔴 판정 시점과 머지 시점을 벌리지 않는다. 벌어진 사이에 들어온 커밋이
477
492
  // 옛 표로 머지되는 것이 이 파일이 막으려는 사고 전부다.
478
493
  merge_when_pipeline_succeeds: false,
package/tools/version.mjs CHANGED
@@ -29,8 +29,22 @@ import { planBump, latestVersion, formatVersion } from '../src/version.mjs'
29
29
 
30
30
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
31
31
 
32
+ /**
33
+ * 🔴 git 은 **서 있는 저장소**에서 부른다. 프로그램이 있는 폴더가 아니다.
34
+ *
35
+ * 예전에는 `cwd: ROOT` 였다. 저장소 안에 사본을 두고 쓸 때는 그게 맞았다 —
36
+ * 사본이 곧 저장소 안이었기 때문이다. **npm 으로 설치하면 틀린다.**
37
+ * `node_modules/axmap-cli/` 에서 git 을 부르게 되고, 거기는 저장소가 아니라서
38
+ * "not a git repository" 로 죽는다. 실측: 설치본으로 `version next` → 종료 코드 1.
39
+ *
40
+ * 쪽지함이 이미 같은 실수를 했다 — 프로그램 위치로 데이터를 찾다가 "쪽지가
41
+ * 있어도 없다" 고 답했다 (tools/bus.mjs 의 주석). 같은 답을 쓴다: 위치를
42
+ * 묻지 말고 **서 있는 곳**에게 묻는다.
43
+ */
44
+ const REPO = process.cwd()
45
+
32
46
  function git(args) {
33
- const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8', windowsHide: true })
47
+ const r = spawnSync('git', args, { cwd: REPO, encoding: 'utf8', windowsHide: true })
34
48
  return { code: r.status ?? 1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }
35
49
  }
36
50