axmap-cli 1.3.1 → 1.3.3

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/README.ko.md CHANGED
@@ -24,6 +24,18 @@ axmap setup
24
24
  MCP를 사용하려면 설정 후 AI 도구를 다시 시작한다.
25
25
  함께 작업하는 사람과 에이전트는 같은 원격 장부를 사용해야 서로의 선점을 볼 수 있다.
26
26
 
27
+ ## 1.3.3: Windows Codex 등록 오류 수정
28
+
29
+ Windows에서 npm으로 설치한 Codex도 `axmap setup`으로 정상 등록한다. 셸 실행 파일 대신 패키지의 Node 진입점을 직접 실행해 경로와 인수를 그대로 전달한다.
30
+
31
+ ## 1.3.2: 쪽지 표시 이름과 사용자 자동 공유
32
+
33
+ 대화형 `setup`에서 `장효준`, `John Smith`처럼 쪽지에 표시할 이름을 입력한다. 입력 없이 설정하거나 오타를 고치려면 `axmap setup --name "장효준"`을 실행한다. 재설치할 때는 저장된 이름을 유지하며 Git 이름·이메일 설정은 바꾸지 않는다.
34
+
35
+ 각 repo에서 axMap을 처음 사용하면 해당 repo의 Git 이메일을 읽고 기존 `axmap/users` 고아 브랜치에 표시 이름을 자동 공유한다. 코드 커밋이나 repo별 수동 등록은 필요 없다. 다른 팀원은 사용자 정보 동기화 후 수신자로 발견한다. 원격 push 권한이 필요하며, 공유 실패는 경고하고 다음 사용 때 재시도한다. `setup`만 실행하고 사용한 적 없는 repo에는 등록하지 않는다.
36
+
37
+ `axmap bus contacts`로 수신자 이름·이메일을 확인한다. 아는 이름으로 보낸 쪽지는 이메일 주소로 저장하므로 이름을 고쳐도 수신·읽음 기록이 이어진다. 동명이인은 이메일로 지정한다. 쪽지 자동 등록은 투표권이나 MR 승인 권한을 부여하지 않는다.
38
+
27
39
  자세한 설치·설정은 [docs/INSTALL.md](https://github.com/rl2aderjoon/axmap/blob/main/docs/INSTALL.md)를 참고한다.
28
40
 
29
41
  ## 1.3.1: 어느 폴더에서든 setup 한 번, 이후 repo 자동 연결
package/README.md CHANGED
@@ -24,8 +24,18 @@ axmap setup
24
24
  Restart your AI tool after setup to use MCP.
25
25
  People and agents working together must share the same remote ledger to see each other's claims.
26
26
 
27
+ During interactive setup, enter the name people should use for messages, such as `John Smith` or `장효준`. To set or correct it without a prompt, run `axmap setup --name "John Smith"`. Reinstallation keeps the saved name. Git's name and email settings are unchanged.
28
+
29
+ On first use in a repository, axMap reads that repository's Git email and shares your display name through the existing `axmap/users` branch. No code commit or per-repository manual registration is needed. Teammates discover it when their user information synchronizes. This requires push access; an offline or rejected update is reported and retried. Setup alone does not announce you to repositories you have never used.
30
+
31
+ Use `axmap bus contacts` to list known names and email addresses. Messages sent to a known name are addressed to its email, so correcting the name preserves delivery and read history. If multiple people use the same name, specify the recipient's email. Messaging registration does not grant voting or MR approval rights.
32
+
27
33
  See [docs/INSTALL.md](https://github.com/rl2aderjoon/axmap/blob/main/docs/INSTALL.md) for installation and configuration details (Korean).
28
34
 
35
+ ## 1.3.3: Windows Codex registration fix
36
+
37
+ `axmap setup` now registers Codex correctly when Codex is installed through npm on Windows. It launches the package's Node entry directly, preserving paths and arguments without a shell.
38
+
29
39
  ## Global setup and automatic repositories in 1.3.1
30
40
 
31
41
  Set up axMap once, then open Codex or another AI CLI in any Git repository. axMap selects that session's repository and prepares its ledger on the first status or claim request. There is no per-repository `axmap init` step.
package/bin/axmap.mjs CHANGED
@@ -47,6 +47,7 @@ import { stateDir } from '../src/repotarget.mjs'
47
47
  import { homeRegistrations } from '../src/mcpstate.mjs'
48
48
  import { compareBranchFor, formatOverlap } from '../src/overlap.mjs'
49
49
  import { withRepoLock } from '../src/repolock.mjs'
50
+ import { readProfile, syncConfiguredProfile } from '../src/identity.mjs'
50
51
 
51
52
  /** 이 CLI 가 들어 있는 axMap 폴더. 갱신 확인과 setup 이 자기 위치를 알아야 한다. */
52
53
  const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
@@ -453,9 +454,17 @@ function unreadNotes(root, me) {
453
454
  }
454
455
  if (!found.length) return []
455
456
  let seen = ''
457
+ const email = (process.env.AXMAP_AGENT_EMAIL || git(['config', 'user.email'], { cwd: root }).out).trim().toLowerCase()
458
+ const mine = new Set([me.toLowerCase(), email].filter(Boolean))
459
+ try { const p = readProfile(); if (p) mine.add(p.name.toLowerCase()) } catch { /* profile sync reports it */ }
460
+ try {
461
+ const state = JSON.parse(fs.readFileSync(path.join(usersFilesDir(root), `${email}.json`), 'utf8'))
462
+ for (const n of [state.name, ...(state.aliases ?? [])]) if (n) mine.add(n.toLowerCase())
463
+ seen = state.seen ?? ''
464
+ } catch { /* no shared state */ }
456
465
  try {
457
466
  const raw = JSON.parse(fs.readFileSync(path.join(root, '.axmap-bus-seen.json'), 'utf8'))
458
- seen = raw[me] ?? ''
467
+ for (const [key, id] of Object.entries(raw)) if (mine.has(key.toLowerCase()) && id > seen) seen = id
459
468
  } catch { /* 처음이면 아무것도 안 읽은 것 */ }
460
469
 
461
470
  const out = []
@@ -471,8 +480,8 @@ function unreadNotes(root, me) {
471
480
  const from = g('from')
472
481
  const to = g('to')
473
482
  // 내가 보낸 것은 뺀다 — 자기 쪽지에 자기가 놀라면 안 된다.
474
- if (from === me) continue
475
- if (to !== me && to !== 'all') continue
483
+ if (g('fromEmail') ? mine.has(g('fromEmail').toLowerCase()) : mine.has(from.toLowerCase())) continue
484
+ if (!to.split(',').some((v) => v.trim().toLowerCase() === 'all' || mine.has(v.trim().toLowerCase()))) continue
476
485
  out.push({ id, from, subject: g('subject') })
477
486
  }
478
487
  return out
@@ -504,6 +513,11 @@ function markBusSeen(root, me, id) {
504
513
  all[me] = id
505
514
  fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n')
506
515
  } catch { /* 조용히 */ }
516
+ // Use the same email-keyed receipt as MCP, while retaining the legacy file
517
+ // for older installations that still share this checkout.
518
+ spawnSync(process.execPath, [path.join(SELF_ROOT, 'tools', 'bus.mjs'), 'seen', '--mine', id], {
519
+ cwd: root, env: { ...process.env, AXMAP_AGENT: me }, encoding: 'utf8', windowsHide: true,
520
+ })
507
521
  }
508
522
 
509
523
  /**
@@ -722,18 +736,10 @@ function claimsDir(root) {
722
736
  return path.join(ledgerDir(root), 'claims')
723
737
  }
724
738
 
725
- /**
726
- * 쪽지함은 **일부러 `root` 그대로 둔다.**
727
- *
728
- * 장부와 같은 `.axmap/` 아래라 같은 문제가 있지만(링크된 worktree 에는 없다),
729
- * 고치려면 `tools/bus.mjs` 의 `SEEN_FILE`·쪽지함 경로까지 같이 옮겨야 한다.
730
- * 한쪽만 옮기면 **읽는 곳과 쓰는 곳이 갈려** 읽음 표시가 어긋난다 —
731
- * 쪽지가 안 보이는 것보다 나쁘다. 장부와 달리 여기서는 못 찾아도 죽지 않고
732
- * "새 쪽지 없음" 으로 조용히 지나가므로, 링크된 worktree 에서 쪽지가 안 보이는
733
- * 것은 남는 구멍으로 적어 둔다.
734
- */
739
+ /** Message and user worktrees follow the same common checkout as the ledger.
740
+ * Git identity is still read from the caller's working repository. */
735
741
  function busDir(root) {
736
- return path.join(root, BUS_REL)
742
+ return path.join(axmapHome(root), BUS_REL)
737
743
  }
738
744
 
739
745
  /** 쪽지 파일이 실제로 쌓이는 곳. 브랜치 루트에 흩뿌리지 않고 한 폴더 아래 모은다. */
@@ -742,7 +748,7 @@ function busMessagesDir(root) {
742
748
  }
743
749
 
744
750
  function usersDir(root) {
745
- return path.join(root, USERS_REL)
751
+ return path.join(axmapHome(root), USERS_REL)
746
752
  }
747
753
 
748
754
  /** 사람마다의 파일이 쌓이는 곳. `.gitattributes` 와 섞이지 않게 한 폴더 아래 모은다. */
@@ -1343,9 +1349,17 @@ function ensureOrphan(root, { branch, rel, dir, what, seed }) {
1343
1349
  const remote = r.source === 'ambiguous' ? null : r.name
1344
1350
 
1345
1351
  let base = null
1346
- if (remote && git(['fetch', '--quiet', remote, branch], { cwd: root }).code === 0) {
1347
- base = git(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
1348
- } else {
1352
+ if (r.source === 'ambiguous') return fail({ err: '원격이 모호합니다. git config axmap.remote <원격>을 지정하세요.' })
1353
+ if (remote) {
1354
+ const exists = git(['ls-remote', '--exit-code', remote, `refs/heads/${branch}`], { cwd: root })
1355
+ if (exists.code !== 0 && exists.code !== 2) return fail(exists)
1356
+ if (exists.code === 0) {
1357
+ const fetched = git(['fetch', '--quiet', remote, branch], { cwd: root })
1358
+ if (fetched.code !== 0) return fail(fetched)
1359
+ base = git(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
1360
+ }
1361
+ }
1362
+ if (!base) {
1349
1363
  // 빈 트리 -> 부모 없는 커밋 -> 브랜치. 작업 트리를 건드리지 않는 고아 브랜치.
1350
1364
  const tree = git(['mktree'], { cwd: root, input: '' })
1351
1365
  if (tree.code !== 0) return fail(tree)
@@ -1361,6 +1375,8 @@ function ensureOrphan(root, { branch, rel, dir, what, seed }) {
1361
1375
  const w = git(['worktree', 'add', '--quiet', dir, branch], { cwd: root })
1362
1376
  if (w.code !== 0) return fail(w)
1363
1377
 
1378
+ excludeAxmap(root)
1379
+
1364
1380
  seed(dir)
1365
1381
 
1366
1382
  // 🔴 고아 브랜치는 **자기 트리의 `.gitattributes` 만** 본다. 저장소 루트에
@@ -1386,6 +1402,16 @@ function ensureOrphan(root, { branch, rel, dir, what, seed }) {
1386
1402
  return 'created'
1387
1403
  }
1388
1404
 
1405
+ function excludeAxmap(root) {
1406
+ const file = path.resolve(root, gitOrDie(['rev-parse', '--git-path', 'info/exclude'], { cwd: root }).out)
1407
+ fs.mkdirSync(path.dirname(file), { recursive: true })
1408
+ let text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
1409
+ for (const entry of ['.axmap/', '.axmap-bus-seen.json']) {
1410
+ if (!text.split(/\r?\n/).includes(entry)) text += (text && !text.endsWith('\n') ? '\n' : '') + entry + '\n'
1411
+ }
1412
+ fs.writeFileSync(file, text)
1413
+ }
1414
+
1389
1415
  function ensureBus(root) {
1390
1416
  return ensureOrphan(root, {
1391
1417
  branch: BUS_BRANCH, rel: BUS_REL, dir: busDir(root), what: '쪽지함',
@@ -1424,7 +1450,7 @@ function cmdBusRepair() {
1424
1450
  const root = repoRoot()
1425
1451
  // .axmap 을 손으로 지웠어도 git 쪽에 worktree 등록이 남아 add 가 실패한다.
1426
1452
  git(['worktree', 'prune'], { cwd: root })
1427
- const r = ensureBus(root)
1453
+ const r = withRepoLock(path.join(commonDir(root), 'axmap-init.lock'), () => ensureBus(root))
1428
1454
  if (r === 'failed') process.exit(1)
1429
1455
  console.log(
1430
1456
  r === 'ready'
@@ -1437,7 +1463,7 @@ function cmdBusRepair() {
1437
1463
  function cmdUsersRepair() {
1438
1464
  const root = repoRoot()
1439
1465
  git(['worktree', 'prune'], { cwd: root })
1440
- const r = ensureUsers(root)
1466
+ const r = withRepoLock(path.join(commonDir(root), 'axmap-init.lock'), () => ensureUsers(root))
1441
1467
  if (r === 'failed') process.exit(1)
1442
1468
  console.log(
1443
1469
  r === 'ready'
@@ -2898,3 +2924,12 @@ switch (cmd) {
2898
2924
  console.log(HELP)
2899
2925
  process.exit(cmd ? 1 : 0)
2900
2926
  }
2927
+
2928
+ // Setup stores a label, never a repository pin. Publish it only after an
2929
+ // actual successful collaboration command in the selected working repository.
2930
+ if (['init', 'claim', 'release', 'renew', 'status'].includes(cmd)) {
2931
+ const warning = syncConfiguredProfile(process.cwd(), path.join(SELF_ROOT, 'tools', 'bus.mjs'), {
2932
+ ...process.env, ...(flags.agent ? { AXMAP_AGENT: flags.agent } : {}),
2933
+ })
2934
+ if (warning) console.error(warning)
2935
+ }
package/mcp/server.mjs CHANGED
@@ -45,6 +45,7 @@ import fs from 'node:fs'
45
45
  import path from 'node:path'
46
46
  import readline from 'node:readline'
47
47
  import { fileURLToPath } from 'node:url'
48
+ import { syncConfiguredProfile } from '../src/identity.mjs'
48
49
 
49
50
  const HERE = path.dirname(fileURLToPath(import.meta.url))
50
51
 
@@ -311,7 +312,7 @@ function unreadBanner(tool, text) {
311
312
  // 떼는 순간 여기가 조용히 낡는다 — 목록은 언제나 코드보다 먼저 낡는다.
312
313
  if (text.includes('안 읽은 쪽지')) return ''
313
314
 
314
- const r = bus(['list', '--to', AGENT, '--unread', '--no-mark',
315
+ const r = bus(['list', '--mine', '--unread', '--no-mark',
315
316
  '--throttle', String(BUS_POLL), '--quiet-if-empty'])
316
317
  if (r.code !== 0) return ''
317
318
  const rows = busRows(r.out)
@@ -331,7 +332,7 @@ function unreadBanner(tool, text) {
331
332
 
332
333
  // 🔴 배너를 다 만든 뒤에 찍는다. 위에서 죽으면 안 찍혀야 다음에 다시 뜬다.
333
334
  const ids = shown.map((s) => s.id).filter(Boolean)
334
- if (ids.length) bus(['seen', '--to', AGENT, ...ids])
335
+ if (ids.length) bus(['seen', '--mine', ...ids])
335
336
  return banner
336
337
  }
337
338
 
@@ -358,7 +359,7 @@ function cli(args) {
358
359
  cwd: REPO,
359
360
  encoding: 'utf8',
360
361
  windowsHide: true,
361
- env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION },
362
+ env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION, AXMAP_SKIP_PROFILE_SYNC: '1' },
362
363
  })
363
364
  const stdout = (r.stdout ?? '').trim()
364
365
  const stderr = (r.stderr ?? '').trim()
@@ -737,7 +738,7 @@ function releaseAllIn(dir) {
737
738
  cwd: dir,
738
739
  encoding: 'utf8',
739
740
  windowsHide: true,
740
- env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION },
741
+ env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_SESSION: SESSION, AXMAP_SKIP_PROFILE_SYNC: '1' },
741
742
  })
742
743
  return {
743
744
  code: r.status ?? 1,
@@ -859,7 +860,7 @@ function dispatch(name, args = {}) {
859
860
  // 겹치지만 `markSeen` 은 뒤로 가지 않으므로 결과가 같다.
860
861
  //
861
862
  // 전부 보려면 `all: true` 가 이미 있다 — 그쪽은 거르지도 찍지도 않는다.
862
- const a = args.id ? ['read', String(args.id)] : ['list', ...(args.all ? ['--all'] : ['--to', AGENT, '--unread'])]
863
+ const a = args.id ? ['read', String(args.id)] : ['list', ...(args.all ? ['--all'] : ['--mine', '--unread'])]
863
864
  const r = bus(a)
864
865
 
865
866
  // 🔴 여기도 **낸 것만 찍는다.** 목록을 자르지 않으므로 낸 것 전부다.
@@ -870,7 +871,7 @@ function dispatch(name, args = {}) {
870
871
  // id 로 찍으면 **나에게 온 안 읽은 쪽지가 화면에 뜬 적 없이 묻힌다.**
871
872
  if (r.code === 0 && !args.id && !args.all) {
872
873
  const ids = busRows(r.out).map((s) => s.id).filter(Boolean)
873
- if (ids.length) bus(['seen', '--to', AGENT, ...ids])
874
+ if (ids.length) bus(['seen', '--mine', ...ids])
874
875
  }
875
876
  return {
876
877
  ok: r.code === 0,
@@ -1007,7 +1008,8 @@ async function handle(req) {
1007
1008
  do { task = rootsTask; await task } while (task !== rootsTask)
1008
1009
  }
1009
1010
  const r = callTool(params?.name, params?.arguments ?? {})
1010
- return { content: [{ type: 'text', text: r.text + (r.ok ? unreadBanner(params?.name, r.text) : '') }], isError: !r.ok }
1011
+ const warning = r.ok ? syncConfiguredProfile(REPO, BUS, { ...process.env, AXMAP_AGENT: AGENT }) : ''
1012
+ return { content: [{ type: 'text', text: r.text + (warning ? '\n' + warning : '') + (r.ok ? unreadBanner(params?.name, r.text) : '') }], isError: !r.ok }
1011
1013
  }
1012
1014
  if (method === 'ping') return {}
1013
1015
  return null // 알림이거나 지원하지 않는 메서드
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": "1.3.1",
4
+ "version": "1.3.3",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
5
+ import { agentNameError } from './protocol.mjs'
6
+
7
+ // Only the display name is global. Email is resolved from the working repo so
8
+ // a work identity never silently becomes the address used in a personal repo.
9
+ export const profilePath = (home = os.homedir()) => path.join(home, '.axmap', 'profile.json')
10
+
11
+ export function displayNameError(name) {
12
+ if (typeof name !== 'string') return '표시 이름은 문자열이어야 합니다.'
13
+ return agentNameError(name) || (/[,;@\u007f]/.test(name)
14
+ ? '표시 이름에는 쉼표·세미콜론·@ 문자를 쓸 수 없습니다.'
15
+ : name.toLowerCase() === 'all' ? 'all은 전체 수신자를 뜻하므로 표시 이름으로 쓸 수 없습니다.' : null)
16
+ }
17
+
18
+ export function readProfile(home) {
19
+ const file = profilePath(home)
20
+ let data
21
+ try { data = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) {
22
+ if (e.code === 'ENOENT') return null
23
+ throw new Error(`표시 이름 설정을 읽지 못했습니다: ${file} (${e.message})`)
24
+ }
25
+ const error = displayNameError(data?.name)
26
+ if (data?.version !== 1 || error || (data.updatedAt && !Number.isFinite(Date.parse(data.updatedAt)))) throw new Error(`표시 이름 설정이 올바르지 않습니다: ${file}${error ? ` (${error})` : ''}`)
27
+ return data
28
+ }
29
+
30
+ export function writeProfile(name, home) {
31
+ const error = displayNameError(name)
32
+ if (error) throw new Error(error)
33
+ const previous = readProfile(home)
34
+ const file = profilePath(home)
35
+ const data = { ...previous, version: 1, name,
36
+ updatedAt: previous?.name === name && previous.updatedAt ? previous.updatedAt : new Date().toISOString() }
37
+ fs.mkdirSync(path.dirname(file), { recursive: true })
38
+ const tmp = `${file}.tmp-${process.pid}`
39
+ try {
40
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
41
+ fs.renameSync(tmp, file)
42
+ } finally { fs.rmSync(tmp, { force: true }) }
43
+ return data
44
+ }
45
+
46
+ export function emailError(email) {
47
+ // This address also becomes a filename on Windows; reject unsafe Git config
48
+ // values rather than rewriting two different addresses to the same file.
49
+ return typeof email !== 'string' || !/^[^\s@\\/:*?"<>|,;\u0000-\u001f]+@[^\s@\\/:*?"<>|,;\u0000-\u001f]+$/.test(email)
50
+ || /[.\s]$/.test(email) ? 'Git 이메일이 없거나 사용자 주소로 쓸 수 없습니다. git config user.email을 확인하세요.' : null
51
+ }
52
+
53
+ export function syncConfiguredProfile(repo, bus, env = process.env) {
54
+ if (env.AXMAP_SKIP_PROFILE_SYNC === '1') return ''
55
+ try {
56
+ if (!readProfile()) return ''
57
+ const r = spawnSync(process.execPath, [bus, 'profile'], {
58
+ cwd: repo, env, encoding: 'utf8', windowsHide: true,
59
+ })
60
+ return (r.stderr || (r.status !== 0 ? '경고: 쪽지 프로필 공유에 실패했습니다. 다음 사용 때 재시도합니다.' : '')).trim()
61
+ } catch (e) { return `경고: ${e.message}` }
62
+ }
package/tools/bus.mjs CHANGED
@@ -34,6 +34,10 @@ import { execFileSync, spawnSync } from 'node:child_process'
34
34
  import fs from 'node:fs'
35
35
  import path from 'node:path'
36
36
  import { fileURLToPath } from 'node:url'
37
+ import { agentNameError } from '../src/protocol.mjs'
38
+ import { readProfile, profilePath, emailError } from '../src/identity.mjs'
39
+ import { withRepoLock } from '../src/repolock.mjs'
40
+ import { randomUUID } from 'node:crypto'
37
41
 
38
42
  /** **이 프로그램**의 뿌리. 데이터가 아니라 코드를 찾을 때만 쓴다 (`who` 의 axmap.mjs). */
39
43
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
@@ -85,7 +89,11 @@ const REPO = (() => {
85
89
  })()
86
90
 
87
91
  /** 쪽지 worktree 의 루트. 커밋·push 는 여기서 돈다. */
88
- const BUS_WT = REPO ? path.join(REPO, '.axmap', 'bus') : null
92
+ const COMMON = REPO ? path.resolve(REPO, execFileSync('git', ['rev-parse', '--git-common-dir'], {
93
+ cwd: REPO, encoding: 'utf8', windowsHide: true,
94
+ }).trim()) : null
95
+ const DATA_HOME = COMMON ? (path.basename(COMMON) === '.git' ? path.dirname(COMMON) : REPO) : null
96
+ const BUS_WT = REPO ? path.join(DATA_HOME, '.axmap', 'bus') : null
89
97
  const BUS_BRANCH = 'axmap/bus'
90
98
 
91
99
  /** **쓰는 곳은 하나뿐이다.** */
@@ -273,14 +281,14 @@ const recipientsOf = (m) => String(m.to ?? 'all').split(',').map(norm).filter(Bo
273
281
  */
274
282
  function me() {
275
283
  const v = process.env.AXMAP_AGENT
276
- if (v && /^[\w.-]{1,64}$/.test(v)) return v
284
+ if (v && !agentNameError(v)) return v
277
285
  try {
278
286
  const n = execFileSync('git', ['config', 'user.name'], {
279
287
  cwd: REPO ?? process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
280
288
  }).trim()
281
289
  // 서버와 달리 여기서는 형식을 본다. 통과 못 하면 아래에서 죽는다 —
282
290
  // 못 읽는 이름을 그냥 쓰면 파일 이름과 필터가 조용히 어긋난다.
283
- if (n && /^[\w.-]{1,64}$/.test(n)) return n
291
+ if (n && !agentNameError(n)) return n
284
292
  } catch { /* 아래에서 죽는다 */ }
285
293
  console.error(
286
294
  'AXMAP_AGENT 를 먼저 정하세요 (선점과 같은 값).\n' +
@@ -319,30 +327,14 @@ function roster() {
319
327
  if (rosterCache) return rosterCache
320
328
  const byEmail = new Map()
321
329
 
322
- /**
323
- * 🔴 **이름 하나는 사람 하나에게만 붙는다. 먼저 붙은 쪽이 이긴다.**
324
- *
325
- * 이 줄이 없으면 어긋난 쪽지 한 통이 두 사람을 영구히 합친다. 실제로 그랬다 —
326
- * 도구 이름은 `alice` 인데 git 이메일이 `bob@x.com` 인 쪽지가 하나 있었고,
327
- * 그 뒤로 `alice` 가 bob 의 이름이 됐다. 그러면 bob 은 alice 가 보낸 쪽지를
328
- * **자기가 보낸 것으로 보고 안 읽음에서 지운다.**
329
- *
330
- * 이름이 어긋나 쪽지가 묻히는 것이 이 판에서 고치려던 바로 그 증상인데,
331
- * 신원을 합치는 방식으로 고치면 **자리만 바꿔서 되살아난다.**
332
- *
333
- * 순서가 곧 우선순위다: git 이력(1) → 쪽지 머리말(2) → 적어 둔 별칭(3).
334
- * git 이력이 가장 믿을 만하다 — 사람이 자기 PC 에 직접 설정한 값이다.
335
- */
336
- const nameOwner = new Map()
330
+ // Keep homonyms as separate email identities. Recipient resolution rejects
331
+ // an ambiguous label instead of silently assigning it to the first author.
337
332
  const add = (email, name) => {
338
333
  const e = norm(email)
339
334
  if (!e || !e.includes('@')) return
340
335
  if (!byEmail.has(e)) byEmail.set(e, { email: e, names: new Set() })
341
336
  const n = norm(name)
342
337
  if (!n) return
343
- const owner = nameOwner.get(n)
344
- if (owner && owner !== e) return // 이미 남의 이름이다. 합치지 않는다
345
- nameOwner.set(n, e)
346
338
  byEmail.get(e).names.add(n)
347
339
  }
348
340
 
@@ -383,6 +375,7 @@ function roster() {
383
375
  const st = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
384
376
  const email = f.replace(/\.json$/, '')
385
377
  add(email, st.name)
378
+ if (byEmail.has(norm(email))) byEmail.get(norm(email)).displayName = st.name
386
379
  for (const a of (st.aliases ?? [])) add(email, a)
387
380
  }
388
381
  } catch { /* 사람별 상태가 아직 없다. 1)·2)만으로도 명단은 선다 */ }
@@ -399,8 +392,13 @@ function roster() {
399
392
  */
400
393
  function addressesOf(who) {
401
394
  const w = norm(who)
402
- const p = roster().find((x) => x.email === w || x.names.has(w))
403
- return p ? new Set([p.email, ...p.names]) : new Set([w])
395
+ const people = roster()
396
+ const matches = people.filter((p) => p.email === w || p.names.has(w))
397
+ if (matches.length !== 1) return new Set([w])
398
+ const p = matches[0]
399
+ // A shared label must never make two mailboxes the same person. Known
400
+ // homonyms can still receive mail through their unambiguous email addresses.
401
+ return new Set([p.email, ...[...p.names].filter((n) => people.filter((x) => x.names.has(n)).length === 1)])
404
402
  }
405
403
 
406
404
  /**
@@ -466,8 +464,12 @@ function myAddresses() {
466
464
  const s = new Set()
467
465
  const e = myEmail()
468
466
  if (e) for (const a of addressesOf(e)) s.add(a)
469
- s.add(norm(me()))
470
- if (process.env.AXMAP_AGENT) s.add(norm(process.env.AXMAP_AGENT))
467
+ const ownName = (name) => {
468
+ if (roster().filter((p) => p.names.has(norm(name))).length < 2) s.add(norm(name))
469
+ }
470
+ ownName(me())
471
+ if (process.env.AXMAP_AGENT) ownName(process.env.AXMAP_AGENT)
472
+ try { const p = readProfile(); if (p) ownName(p.name) } catch { /* reported during profile sync */ }
471
473
  return s
472
474
  }
473
475
 
@@ -489,7 +491,7 @@ function myAddresses() {
489
491
  const SEEN_FILE = REPO ? path.join(REPO, '.axmap-bus-seen.json') : null
490
492
 
491
493
  /** 새 자리 — 고아 브랜치 `axmap/users` 의 worktree. 사람마다 파일 하나. */
492
- const USERS_WT = REPO ? path.join(REPO, '.axmap', 'users') : null
494
+ const USERS_WT = REPO ? path.join(DATA_HOME, '.axmap', 'users') : null
493
495
  const USERS_BRANCH = 'axmap/users'
494
496
  const usersReady = () => USERS_WT !== null && fs.existsSync(path.join(USERS_WT, '.git'))
495
497
 
@@ -528,8 +530,9 @@ function openUsers() {
528
530
  /** 이 사람의 파일 자리. **이메일이 정본**이고, 없는 사람만 이름으로 떨어진다. */
529
531
  function userKey(who) {
530
532
  const w = norm(who)
531
- const p = roster().find((x) => x.email === w || x.names.has(w))
532
- return p ? p.email : w
533
+ if (w.includes('@')) return w
534
+ const matches = roster().filter((x) => x.names.has(w))
535
+ return matches.length === 1 ? matches[0].email : w
533
536
  }
534
537
 
535
538
  const userFile = (who) => path.join(USERS_WT, 'users', `${userKey(who)}.json`)
@@ -539,19 +542,148 @@ function readUserState(who) {
539
542
  }
540
543
 
541
544
  /** 원격의 사람별 상태를 받아온다. 자주 물을 이유가 없어 느슨하게 맞춘다. */
545
+ function usersRemote() {
546
+ const configured = process.env.AXMAP_REMOTE || gitUsers(['config', '--get', 'axmap.remote'], { cwd: REPO }).out
547
+ if (configured) return configured
548
+ const names = gitUsers(['remote'], { cwd: REPO }).out.split('\n').filter(Boolean)
549
+ if (names.length === 1) return names[0]
550
+ if (names.includes('origin')) return 'origin'
551
+ if (names.length) throw new Error('사용자 정보를 공유할 원격이 모호합니다. git config axmap.remote <원격>을 지정하세요.')
552
+ return null
553
+ }
554
+
555
+ function mustUsers(args) {
556
+ const r = gitUsers(args)
557
+ if (r.code !== 0) throw new Error(r.err || r.out || `git ${args[0]} 실패`)
558
+ return r.out
559
+ }
560
+
561
+ function mergeState(local, incoming) {
562
+ const newest = (incoming.profileUpdatedAt ?? '') > (local.profileUpdatedAt ?? '') ? incoming : local
563
+ const aliases = [...new Set([...(local.aliases ?? []), ...(incoming.aliases ?? []), local.name, incoming.name].filter(Boolean))]
564
+ const seen = [local.seen ?? '', incoming.seen ?? ''].sort().at(-1)
565
+ return { ...incoming, ...local, ...newest, aliases: aliases.filter((a) => a !== newest.name), ...(seen ? { seen } : {}) }
566
+ }
567
+
568
+ // Never reset away unpublished profiles or read receipts. A rejected push is
569
+ // retried on the latest branch, merging only the dedicated JSON user records.
570
+ function syncUsers(remote) {
571
+ if (!remote) return
572
+ const exists = gitUsers(['ls-remote', '--exit-code', remote, `refs/heads/${USERS_BRANCH}`])
573
+ if (exists.code === 2) return
574
+ if (exists.code !== 0) throw new Error(exists.err || '사용자 정보 원격을 확인하지 못했습니다.')
575
+ mustUsers(['fetch', '--quiet', remote, USERS_BRANCH])
576
+ if (gitUsers(['merge', '--ff-only', '--quiet', 'FETCH_HEAD']).code === 0) return
577
+ const merged = gitUsers(['merge', '--no-commit', '--no-ff', '--allow-unrelated-histories', 'FETCH_HEAD'])
578
+ const conflicts = mustUsers(['diff', '--name-only', '--diff-filter=U']).split('\n').filter(Boolean)
579
+ try {
580
+ for (const f of conflicts) {
581
+ if (!/^users\/[^/]+\.json$/.test(f)) throw new Error(`사용자 정보 충돌을 자동으로 풀 수 없습니다: ${f}`)
582
+ const local = JSON.parse(mustUsers(['show', `:2:${f}`]))
583
+ const incoming = JSON.parse(mustUsers(['show', `:3:${f}`]))
584
+ fs.writeFileSync(path.join(USERS_WT, f), JSON.stringify(mergeState(local, incoming), null, 2) + '\n')
585
+ mustUsers(['add', '--', f])
586
+ }
587
+ if (merged.code !== 0 && !conflicts.length) throw new Error(merged.err || merged.out)
588
+ mustUsers(['commit', '--quiet', '--no-verify', '-m', 'axmap: merge user profiles'])
589
+ } catch (e) {
590
+ gitUsers(['merge', '--abort'])
591
+ throw e
592
+ }
593
+ }
594
+
595
+ function withUsers(action) {
596
+ if (!COMMON) return false
597
+ return withRepoLock(path.join(COMMON, 'axmap-users.lock'), () => {
598
+ if (!openUsers()) throw new Error('사용자 정보 브랜치를 준비하지 못했습니다.')
599
+ return action()
600
+ })
601
+ }
602
+
542
603
  function pullUsers() {
543
- if (!openUsers()) return
544
- const stampFile = path.join(REPO, '.axmap', '.users-lastfetch')
545
- // 🔴 60초. 쪽지함(기본 0초)보다 훨씬 느슨한 것은 **늦어도 손해가 없기 때문**이다.
546
- // 늦으면 다른 PC 에서 읽은 것이 여기서 한 번 더 뜬다. 그뿐이다.
547
- // 반대로 매번 물으면 도구 호출마다 통신이 한 번씩 더 붙는다 (실측 0.8초).
604
+ if (!REPO) return
605
+ const stampFile = path.join(DATA_HOME, '.axmap', '.users-lastfetch')
606
+ try { if (Date.now() - fs.statSync(stampFile).mtimeMs < 60_000) return } catch { /* first sync */ }
607
+ try {
608
+ withUsers(() => {
609
+ // A fresh local-only orphan still has its seed files unstaged.
610
+ if (gitUsers(['status', '--porcelain']).out) {
611
+ mustUsers(['add', '-A'])
612
+ mustUsers(['commit', '--quiet', '--no-verify', '-m', 'axmap: preserve user state'])
613
+ }
614
+ syncUsers(usersRemote())
615
+ fs.writeFileSync(stampFile, '')
616
+ })
617
+ } catch { /* Read receipts remain conservative when synchronization fails. */ }
618
+ }
619
+
620
+ function updateUser(email, change) {
621
+ if (emailError(email)) throw new Error(emailError(email))
622
+ return withUsers(() => {
623
+ const remote = usersRemote()
624
+ for (let attempt = 0; attempt < 5; attempt++) {
625
+ if (gitUsers(['status', '--porcelain']).out) {
626
+ mustUsers(['add', '-A'])
627
+ mustUsers(['commit', '--quiet', '--no-verify', '-m', 'axmap: preserve user state'])
628
+ }
629
+ syncUsers(remote)
630
+ const f = path.join(USERS_WT, 'users', `${email}.json`)
631
+ let cur = {}
632
+ try { cur = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) { if (e.code !== 'ENOENT') throw e }
633
+ const next = change(cur)
634
+ if (JSON.stringify(cur) !== JSON.stringify(next)) {
635
+ fs.mkdirSync(path.dirname(f), { recursive: true })
636
+ fs.writeFileSync(f, JSON.stringify(next, null, 2) + '\n')
637
+ mustUsers(['add', '--', `users/${email}.json`])
638
+ mustUsers(['commit', '--quiet', '--no-verify', '-m', `user: ${email}`])
639
+ }
640
+ if (!remote) return 'local'
641
+ const pushed = gitUsers(['push', '--quiet', remote, `HEAD:refs/heads/${USERS_BRANCH}`])
642
+ if (pushed.code === 0) {
643
+ fs.writeFileSync(path.join(DATA_HOME, '.axmap', '.users-lastfetch'), '')
644
+ return 'shared'
645
+ }
646
+ if (attempt === 4) throw new Error(pushed.err || '사용자 정보 push 실패')
647
+ }
648
+ })
649
+ }
650
+
651
+ function publishProfile() {
652
+ const profile = readProfile()
653
+ if (!profile || !REPO) return null
654
+ const email = myEmail()
655
+ if (emailError(email)) throw new Error(emailError(email))
656
+ // Successful unchanged profiles need no network more than once a minute.
657
+ const stamp = path.join(DATA_HOME, '.axmap', '.profile-shared.json')
658
+ const revision = profile.updatedAt || fs.statSync(profilePath()).mtime.toISOString()
659
+ const key = JSON.stringify([profile.name, revision, email, me(), usersRemote()])
548
660
  try {
549
- if (Date.now() - fs.statSync(stampFile).mtimeMs < 60_000) return
550
- } catch { /* 처음이다. 받아온다 */ }
551
- const remote = gitUsers(['config', '--get', 'axmap.remote'], { cwd: REPO }).out || 'origin'
552
- if (gitUsers(['fetch', '--quiet', remote, USERS_BRANCH]).code !== 0) return
553
- gitUsers(['reset', '--hard', '--quiet', 'FETCH_HEAD'])
554
- try { fs.writeFileSync(stampFile, '') } catch { /* 스탬프 실패는 치명적이지 않다 */ }
661
+ const last = JSON.parse(fs.readFileSync(stamp, 'utf8'))
662
+ if (last.key === key && Date.now() - last.at < 60_000) return last.result
663
+ } catch { /* first use / changed identity */ }
664
+ const result = updateUser(email, (cur) => {
665
+ // An older PC must not undo a name correction made on another PC just
666
+ // because it became active later. Revision belongs to setup, not to use.
667
+ const name = (cur.profileUpdatedAt ?? '') > revision ? cur.name : profile.name
668
+ const aliases = [...new Set([...(cur.aliases ?? []), cur.name, profile.name, me()].filter((n) => n && n !== name))]
669
+ return { ...cur, name, aliases,
670
+ profileUpdatedAt: (cur.profileUpdatedAt ?? '') > revision ? cur.profileUpdatedAt : revision }
671
+ })
672
+ rosterCache = null
673
+ fs.writeFileSync(stamp, JSON.stringify({ key, at: Date.now(), result }) + '\n')
674
+ if (result === 'local') console.error('※ 쪽지 프로필을 로컬에만 저장했습니다 — 원격이 없어 다른 팀원에게는 아직 보이지 않습니다.')
675
+ return result
676
+ }
677
+
678
+ function messageName() {
679
+ const profile = readProfile()
680
+ if (!profile) return me()
681
+ try {
682
+ const state = JSON.parse(fs.readFileSync(path.join(USERS_WT, 'users', `${myEmail()}.json`), 'utf8'))
683
+ const revision = profile.updatedAt || fs.statSync(profilePath()).mtime.toISOString()
684
+ if ((state.profileUpdatedAt ?? '') > revision && state.name) return state.name
685
+ } catch { /* unpublished profile / custom standalone mailbox */ }
686
+ return profile.name
555
687
  }
556
688
 
557
689
  function readSeen(who) {
@@ -564,7 +696,12 @@ function readSeen(who) {
564
696
  // 그건 이 도구가 고치려던 바로 그 상태다.
565
697
  let legacy = ''
566
698
  if (SEEN_FILE) {
567
- try { legacy = JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8'))[who] ?? '' } catch { /* 없다 */ }
699
+ try {
700
+ const known = addressesOf(who)
701
+ for (const [name, id] of Object.entries(JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8')))) {
702
+ if (known.has(norm(name)) && id > legacy) legacy = id
703
+ }
704
+ } catch { /* no legacy receipts */ }
568
705
  }
569
706
  return fromBranch > legacy ? fromBranch : legacy
570
707
  }
@@ -592,42 +729,15 @@ function markSeen(who, id) {
592
729
  } catch { /* 조용히 */ }
593
730
  }
594
731
 
595
- if (!openUsers()) return
732
+ const email = myEmail() || userKey(who)
596
733
  try {
597
- const f = userFile(who)
598
- const cur = readUserState(who)
599
- if ((cur.seen ?? '') >= id) return // 뒤로 가지 않는다
600
- fs.mkdirSync(path.dirname(f), { recursive: true })
601
-
602
- // 🔴 **별칭은 저절로 적힌다.** 손으로 관리하게 하면 아무도 안 한다.
603
- //
604
- // 지금 쓰고 있는 이름이 이메일도 아니고 git 이 아는 이름도 아니면, 그것은
605
- // 이 사람이 띄운 도구의 이름이다 (codex-jinmiri · jaehyeon-2 처럼). 적어
606
- // 두면 다음부터 그 이름으로 온 쪽지도 이 사람 것이 된다.
607
- const known = addressesOf(who)
608
- const now = norm(me())
609
- const aliases = [...new Set([...(cur.aliases ?? []), ...(known.has(now) ? [] : [now])])]
610
-
611
- // 이름도 같이 남긴다 — 파일 이름이 이메일이라 사람이 열었을 때 누구인지 보이게.
612
- fs.writeFileSync(f, JSON.stringify({ ...cur, name: me(), aliases, seen: id }, null, 2) + '\n')
613
-
614
- gitUsers(['add', '--', path.relative(USERS_WT, f).replace(/\\/g, '/')])
615
- // --no-verify: 연결된 worktree 는 훅을 공유한다. 이 브랜치는 사용자 코드가 아니다.
616
- gitUsers(['commit', '--quiet', '--no-verify', '-m', `seen: ${userKey(who)}`])
617
-
618
- // 🔴 push 는 **60초에 한 번**만 시도한다. 커밋은 이 PC 안이라 싸지만 push 는
619
- // 통신이고, 쪽지를 볼 때마다 붙으면 모든 도구 호출이 그만큼 느려진다.
620
- // 늦게 가도 손해가 없다 — 다른 PC 에서 그 쪽지가 한 번 더 뜰 뿐이다.
621
- const stamp = path.join(REPO, '.axmap', '.users-lastpush')
622
- let due = true
623
- try { due = Date.now() - fs.statSync(stamp).mtimeMs >= 60_000 } catch { /* 처음이다 */ }
624
- if (due) {
625
- const remote = gitUsers(['config', '--get', 'axmap.remote'], { cwd: REPO }).out || 'origin'
626
- if (gitUsers(['push', '--quiet', remote, `HEAD:${USERS_BRANCH}`]).code === 0) {
627
- try { fs.writeFileSync(stamp, '') } catch { /* 스탬프 실패는 치명적이지 않다 */ }
628
- }
629
- }
630
- } catch { /* 조용히 — 못 찍으면 다음에 한 번 더 뜰 뿐이다 */ }
734
+ updateUser(email, (cur) => {
735
+ const profile = readProfile()
736
+ const name = profile ? messageName() : (cur.name || me())
737
+ const aliases = [...new Set([...(cur.aliases ?? []), me()].filter((n) => n !== name))]
738
+ return { ...cur, name, aliases, seen: (cur.seen ?? '') > id ? cur.seen : id }
739
+ })
740
+ } catch { /* Failed read receipts cause a repeat, never a hidden message. */ }
631
741
  }
632
742
 
633
743
  const stamp = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
@@ -734,7 +844,7 @@ function stdin() {
734
844
  }
735
845
 
736
846
  function post({ to, subject, body, replyTo = null }) {
737
- const from = me()
847
+ const from = messageName()
738
848
  // 부르는 쪽이 하나를 주든 여럿을 주든 여기서는 목록 하나로 본다.
739
849
  let tos = (Array.isArray(to) ? to : (to == null ? [] : [to])).map((s) => String(s).trim()).filter(Boolean)
740
850
 
@@ -761,15 +871,17 @@ function post({ to, subject, body, replyTo = null }) {
761
871
  // 나중에 `ensureBus` 가 "정상적인 쪽지함이 아닙니다 — 지운 뒤 다시 하세요"
762
872
  // 라고 안내한다. **안 간 쪽지를 지우라고 시키는 것**이다. 여기서 멈추면
763
873
  // 사람은 init 한 번 하고 다시 보내면 된다 — 잃는 것이 없다.
764
- if (!process.env.AXMAP_BUS_DIR && !busReady()) {
765
- console.error(
766
- '쪽지함이 아직 없습니다. 한 번만 준비하면 됩니다:\n' +
767
- ' axmap init (MCP 에서는 ax_init)\n\n' +
768
- '준비 전에 쪽지를 쓰지 않습니다 — 여기 남으면 아무에게도 안 가고,\n' +
769
- '나중에 쪽지함을 만들 때 지워야 할 것으로 보입니다.',
770
- )
874
+ if (!openBus()) {
875
+ reportBusProblem()
771
876
  process.exit(1)
772
877
  }
878
+ for (const one of tos) {
879
+ const matches = roster().filter((p) => p.email === norm(one) || p.names.has(norm(one)))
880
+ if (norm(one) !== 'all' && !norm(one).includes('@') && matches.length > 1) {
881
+ console.error(`같은 이름의 수신자가 여러 명입니다: ${one}\n이메일로 지정하세요: ${matches.map((p) => p.email).join(', ')}`)
882
+ process.exit(1)
883
+ }
884
+ }
773
885
  // 🔴 **모르는 주소로는 안 보낸다.**
774
886
  //
775
887
  // 지금까지는 받는 사람 이름을 아무도 검사하지 않았다. 오타 하나면 그 쪽지는
@@ -785,7 +897,7 @@ function post({ to, subject, body, replyTo = null }) {
785
897
  if (norm(one) === 'all') continue
786
898
  const known = roster().some((p) => p.email === norm(one) || p.names.has(norm(one)))
787
899
  if (!known) {
788
- const near = nearMatches(one)
900
+ const near = norm(one).includes('@') ? [] : nearMatches(one)
789
901
  if (near.length) {
790
902
  // 🔴 **거부하는 것은 오타뿐이다.** 아는 이름과 한 글자 차이거나 그 이름의
791
903
  // 앞부분이면 새 사람이 아니라 손이 미끄러진 것이다. 실측에서 실제로
@@ -806,9 +918,15 @@ function post({ to, subject, body, replyTo = null }) {
806
918
  }
807
919
  }
808
920
 
921
+ // Resolve labels now: later profile corrections cannot redirect an old note.
922
+ tos = [...new Set(tos.map((one) => {
923
+ if (norm(one) === 'all') return 'all'
924
+ const matches = roster().filter((p) => p.email === norm(one) || p.names.has(norm(one)))
925
+ return matches.length === 1 ? matches[0].email : one
926
+ }))]
809
927
  fs.mkdirSync(BOX, { recursive: true })
810
928
  const now = new Date()
811
- const id = `${stamp(now)}-${from}-${slug(subject)}`
929
+ const id = `${stamp(now)}-${from}-${slug(subject)}-${randomUUID().slice(0, 8)}`
812
930
  // 🔴 `from` 은 **이름 그대로** 둔다. 파일 이름에 들어가는 값이라 바꾸면 예전
813
931
  // 쪽지와 형식이 갈린다. 대신 이메일을 한 줄 더 적는다 — 이름이 나중에
814
932
  // 바뀌어도 **누구였는지는 남는다.** 이것이 명단을 세우는 재료가 된다.
@@ -918,7 +1036,7 @@ function selfCmd() {
918
1036
  function list() {
919
1037
  pull()
920
1038
  const all = readAll()
921
- const to = has('--mine') ? me() : flag('--to')
1039
+ const to = has('--mine') ? (myEmail() || me()) : flag('--to')
922
1040
  const from = flag('--from')
923
1041
 
924
1042
  // 🔴 **한 사람에게는 주소가 여럿이다.** `--to yeaseung-lee` 로 물어도
@@ -958,7 +1076,7 @@ function list() {
958
1076
  // **내 안 읽은 쪽지로 다시 잡힌다.** 주소 전부와 견준다.
959
1077
  const mine = has('--mine') ? myAddresses() : addressesOf(who)
960
1078
  rows = rows.filter((m) => m.id > seen
961
- && !mine.has(norm(m.from)) && !(m.fromEmail && mine.has(norm(m.fromEmail))))
1079
+ && !(m.fromEmail ? mine.has(norm(m.fromEmail)) : mine.has(norm(m.from))))
962
1080
  }
963
1081
 
964
1082
  // 🔴 **못 읽었으면 "없음" 이라고 답하지 않는다.** 이 세 줄이 이 파일에서 제일
@@ -1054,11 +1172,25 @@ function seen() {
1054
1172
  console.error(`찍을 쪽지 id 를 주세요. 예: ${selfCmd()} seen <아이디> [<아이디>…]`)
1055
1173
  process.exit(1)
1056
1174
  }
1057
- markSeen(has('--mine') ? me() : (flag('--to') || me()),
1175
+ markSeen(has('--mine') ? (myEmail() || me()) : (flag('--to') || me()),
1058
1176
  ids.reduce((hi, id) => (id > hi ? id : hi), ''))
1059
1177
  }
1060
1178
 
1179
+ let profileResult = null
1180
+ if (['profile', 'contacts', 'post', 'reply', 'list', 'read'].includes(cmd)) {
1181
+ try { profileResult = publishProfile() } catch (e) {
1182
+ console.error(`경고: 쪽지 프로필을 공유하지 못했습니다 — ${e.message}`)
1183
+ if (cmd === 'profile') process.exit(1)
1184
+ }
1185
+ }
1186
+
1061
1187
  switch (cmd) {
1188
+ case 'profile':
1189
+ if (profileResult) console.log(`쪽지 프로필: ${profileResult}`)
1190
+ break
1191
+ case 'contacts':
1192
+ for (const p of roster()) console.log(`${p.displayName || [...p.names][0] || p.email}\t${p.email}`)
1193
+ break
1062
1194
  case 'post': post({ to: recipientArgs(), subject: flag('--subject'), body: stdin() }); break
1063
1195
  case 'reply': {
1064
1196
  const target = args[1]
@@ -1068,7 +1200,7 @@ switch (cmd) {
1068
1200
  const src = readAll().find((x) => x.id === target || x.id.includes(target))
1069
1201
  if (!src && busProblem) { reportBusProblem(); process.exit(1) }
1070
1202
  if (!src) { console.error(`없는 쪽지: ${target}`); process.exit(1) }
1071
- post({ to: src.from, subject: flag('--subject') ?? `Re: ${src.subject}`, body: stdin(), replyTo: src.id })
1203
+ post({ to: src.fromEmail || src.from, subject: flag('--subject') ?? `Re: ${src.subject}`, body: stdin(), replyTo: src.id })
1072
1204
  break
1073
1205
  }
1074
1206
  case 'list': list(); break
@@ -1087,6 +1219,7 @@ switch (cmd) {
1087
1219
  ${selfCmd()} seen <아이디> [<아이디>…] 보여준 쪽지만 읽음으로 찍는다
1088
1220
  ${selfCmd()} reply <아이디> < 본문.md
1089
1221
  ${selfCmd()} who 지금 누가 무엇을 잡고 있나
1222
+ ${selfCmd()} contacts 이 저장소의 쪽지 수신자 이름·이메일
1090
1223
 
1091
1224
  목록을 잘라서 보여주는 쪽은 --no-mark 로 읽기만 하고, 자기가 그린 아이디만
1092
1225
  seen 에 넘기세요. 안 그러면 화면에 뜬 적 없는 쪽지가 읽음이 되어 안 보입니다.
@@ -266,8 +266,27 @@ function snippetJson() {
266
266
  // 🔴 TOML 을 직접 파싱하지 않는다. 파서를 새로 짜는 것은 이 저장소의 "의존성 없음" 과
267
267
  // "판정을 복잡하게 만들지 않는다" 에 둘 다 어긋나고, 어설픈 파서가 남의 config.toml 을
268
268
  // 깨뜨리는 것이 이 작업에서 나올 수 있는 최악의 결과다. 쓰는 일은 codex 자신에게 맡긴다.
269
+ // Windows npm installs expose a shell script and .cmd shim, neither of which
270
+ // spawnSync can execute directly. Launch the package's declared Node entry
271
+ // without a shell so paths and environment values keep their exact contents.
272
+ export function codexInvocation(bin, platform = process.platform) {
273
+ if (platform === 'win32' && !/\.exe$/i.test(bin)) {
274
+ const packageDir = path.join(path.dirname(bin), 'node_modules', '@openai', 'codex')
275
+ try {
276
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'))
277
+ const entry = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.codex
278
+ if (entry) {
279
+ const script = path.resolve(packageDir, entry)
280
+ if (fs.statSync(script).isFile()) return [process.execPath, script]
281
+ }
282
+ } catch { /* Non-npm installations continue to use their original binary. */ }
283
+ }
284
+ return [bin]
285
+ }
286
+
269
287
  export function doCodex(bin, { run: injectedRun, dry = DRY } = {}) {
270
- const run = injectedRun ?? ((args) => spawnSync(bin, args, { cwd: os.homedir(), encoding: 'utf8', windowsHide: true }))
288
+ const [command, ...prefix] = codexInvocation(bin)
289
+ const run = injectedRun ?? ((args) => spawnSync(command, [...prefix, ...args], { cwd: os.homedir(), encoding: 'utf8', windowsHide: true }))
271
290
 
272
291
  const help = run(['mcp', '--help'])
273
292
  if (help.status !== 0 || !/\badd\b/.test(`${help.stdout}${help.stderr}`)) {
package/tools/setup.mjs CHANGED
@@ -11,7 +11,9 @@ import fs from 'node:fs'
11
11
  import os from 'node:os'
12
12
  import path from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
14
+ import { createInterface } from 'node:readline/promises'
14
15
 
16
+ import { displayNameError, readProfile, writeProfile, profilePath } from '../src/identity.mjs'
15
17
  import { agentNameError } from '../src/protocol.mjs'
16
18
 
17
19
  const HERE = path.dirname(fileURLToPath(import.meta.url))
@@ -58,7 +60,7 @@ axmap setup — 어느 폴더에서든 이 PC에 axMap을 전역 설치·등록
58
60
  --repo <경로> 선택 사항: 이 저장소의 장부·커밋 훅도 명시적으로 준비한다
59
61
  --remote <이름|URL> 장부를 둘 원격. git 주소를 주면, 그 주소를 가리키는 원격이
60
62
  없을 때 'axmap' 이라는 이름으로 하나 더한다
61
- --name <이름> 내 이름 (기본: 전역 git config user.name, 명시적 repo는 로컬 값)
63
+ --name <이름> 쪽지 표시 이름을 저장한다 (대화형 실행에서는 직접 입력)
62
64
  --dest <경로> 프로그램을 둘 자리 (기본: ~/.axmap/app)
63
65
  --commands <곳> 슬래시 명령을 둘 자리: global(기본) · project · none
64
66
  --hook 프롬프트마다 쪽지를 받는 하네스 훅을 settings.json 에 더한다
@@ -113,12 +115,14 @@ if (projectRequested) {
113
115
  }
114
116
 
115
117
  // ── 3. 이름 ─────────────────────────────────────────────────────────────────
116
- //
117
- // [!] 모두에게 같은 기본값을 주면 안 된다. 장부에 한 명만 존재하게 되고, 겹침
118
- // 판정은 자기 claim 을 겹침으로 보지 않으므로 팀 전원이 서로의 영역을 아무
119
- // 경고 없이 덮어쓴다. 그래서 이 PC 에서 이미 사람마다 다른 값만 후보로 쓴다.
118
+ // Display labels are independent of claim ownership. The saved label is the
119
+ // default on upgrades; a first setup starts from this person's Git identity.
120
120
  let name = flag('--name')
121
121
  let nameFrom = '--name'
122
+ if (has('--name') && !name) die('--name 뒤에 표시 이름을 지정하세요.')
123
+ let saved
124
+ try { saved = readProfile() } catch (e) { die(e.message) }
125
+ if (!name && saved) { name = saved.name; nameFrom = '저장된 표시 이름' }
122
126
  if (!name) {
123
127
  const g = git(REPO ? ['config', 'user.name'] : ['config', '--global', 'user.name'], REPO ?? os.homedir())
124
128
  if (g.code === 0 && g.out) { name = g.out; nameFrom = REPO ? 'git config user.name' : 'git config --global user.name' }
@@ -129,10 +133,36 @@ if (!name) {
129
133
  if (!name) { name = os.hostname(); nameFrom = '호스트 이름' }
130
134
  if (!name) die('이 PC 에서 쓸 이름을 정하지 못했습니다. --name "홍길동" 으로 직접 주세요.')
131
135
 
136
+ if (!has('--name') && !DRY && process.stdin.isTTY && process.stdout.isTTY) {
137
+ const input = createInterface({ input: process.stdin, output: process.stdout })
138
+ try {
139
+ for (;;) {
140
+ const answer = (await input.question(`쪽지에 표시할 이름 [${name}]: `)).trim().normalize('NFC')
141
+ const candidate = answer || name
142
+ const error = displayNameError(candidate)
143
+ if (error) { say(error); continue }
144
+ name = candidate
145
+ nameFrom = '표시 이름 입력'
146
+ break
147
+ }
148
+ } finally { input.close() }
149
+ }
150
+
132
151
  // 검사기를 다시 짜지 않는다. 여기서 통과시킨 이름을 서버가 거부하면 설치가 거짓말이 된다.
133
- const nameErr = agentNameError(String(name))
134
- if (nameErr) die(`이 이름은 장부에서 쓸 수 없습니다: ${name}\n\n${nameErr}`)
135
- ok('내 이름', `${name} (${nameFrom})`)
152
+ const nameErr = displayNameError(name)
153
+ if (nameErr) die(`이 표시 이름은 쓸 수 없습니다: ${name}\n\n${nameErr}`)
154
+ if (!DRY) {
155
+ try { writeProfile(name) } catch (e) { die(e.message) }
156
+ }
157
+ ok('쪽지 이름', `${name} (${nameFrom}${DRY ? ', 저장 예정' : ''})`)
158
+ const email = git(REPO ? ['config', 'user.email'] : ['config', '--global', 'user.email'], REPO ?? os.homedir()).out
159
+ if (email) ok('Git 이메일', `${email} — 실제 쪽지 주소는 작업 repo의 Git 설정을 사용합니다`)
160
+ else hm('Git 이메일', '작업 repo의 git config user.email을 사용합니다. 이메일은 별도로 저장하지 않습니다.')
161
+ say(` 이름 변경: axmap setup --name "새 이름" (${profilePath()})`)
162
+ // Changing a message label must not change ownership of existing claims.
163
+ const agentIdentity = git(REPO ? ['config', 'user.name'] : ['config', '--global', 'user.name'], REPO ?? os.homedir()).out
164
+ || (() => { try { return os.userInfo().username } catch { return os.hostname() } })()
165
+ if (agentNameError(agentIdentity)) die('Git 사용자 이름을 선점 이름으로 쓸 수 없습니다. git config user.name을 확인하세요.')
136
166
 
137
167
  // ── 4. 장부를 둘 원격 ───────────────────────────────────────────────────────
138
168
  //
@@ -211,8 +241,8 @@ function installAndRegister() {
211
241
 
212
242
  say('')
213
243
  const r = win
214
- ? spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Source', SOURCE_ROOT, '-Dest', DEST, '-Name', name], { cwd: os.homedir(), stdio: 'inherit', windowsHide: true })
215
- : spawnSync('bash', [script, '--source', SOURCE_ROOT, '--dest', DEST, '--name', name], { cwd: os.homedir(), stdio: 'inherit' })
244
+ ? spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Source', SOURCE_ROOT, '-Dest', DEST, '-Name', agentIdentity], { cwd: os.homedir(), stdio: 'inherit', windowsHide: true })
245
+ : spawnSync('bash', [script, '--source', SOURCE_ROOT, '--dest', DEST, '--name', agentIdentity], { cwd: os.homedir(), stdio: 'inherit' })
216
246
  say('')
217
247
 
218
248
  if (r.status === 0) ok('설치·등록', DEST)
@@ -241,7 +271,7 @@ function ledgerAndHook() {
241
271
  stdio: 'inherit',
242
272
  // 이름을 넘겨서 판정을 한 번으로 못 박는다. 여기서 통과한 이름과 나중에 CLI 가
243
273
  // 스스로 고르는 이름이 다르면, 자기가 잡은 것을 자기가 반납하지 못한다.
244
- env: { ...process.env, AXMAP_AGENT: name },
274
+ env: { ...process.env, AXMAP_AGENT: agentIdentity },
245
275
  windowsHide: true,
246
276
  })
247
277