axmap-cli 1.0.0 → 1.0.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/bin/axmap.mjs CHANGED
@@ -108,6 +108,24 @@ const LEDGER_REL = path.join('.axmap', 'ledger')
108
108
  */
109
109
  const BUS_BRANCH = 'axmap/bus'
110
110
  const BUS_REL = path.join('.axmap', 'bus')
111
+
112
+ /**
113
+ * 사람별 상태가 사는 곳 — 어디까지 읽었는지, 이 사람이 쓰는 이름이 무엇무엇인지.
114
+ *
115
+ * 🔴 왜 쪽지함과 다른 브랜치인가. 쪽지 **본문**은 모두가 공유하는 한 벌이고,
116
+ * **어디까지 읽었는지**는 사람마다 다르다. 한 브랜치에 섞으면 쪽지를 읽기만
117
+ * 해도 공유 브랜치에 커밋이 쌓인다 — 읽는 행위가 남에게 보이는 흔적을 남기는
118
+ * 것은 쪽지함이 할 일이 아니다.
119
+ *
120
+ * 🔴 그리고 왜 각자 PC 의 파일이 아닌가. 지금까지 읽음 표시는 작업 폴더 안의
121
+ * 파일 하나였다. 그래서 **PC 를 바꾸면 전부 다시 안 읽음**이 됐다. 쪽지는
122
+ * 공유되는데 읽었다는 사실만 그 PC 에 갇혀 있었다.
123
+ *
124
+ * 파일은 사람마다 하나(`users/<이메일>.json`)다. 서로 다른 파일이라 여럿이
125
+ * 동시에 써도 부딪히지 않는다 — 쪽지가 파일 하나씩인 것과 같은 이유다.
126
+ */
127
+ const USERS_BRANCH = 'axmap/users'
128
+ const USERS_REL = path.join('.axmap', 'users')
111
129
  /**
112
130
  * `release` 가 아무것도 반납하지 못했을 때. SPEC 8절.
113
131
  * 0 이 아니어야 하는 이유는 그 주변 주석에 적혀 있다.
@@ -591,6 +609,15 @@ function busMessagesDir(root) {
591
609
  return path.join(busDir(root), 'messages')
592
610
  }
593
611
 
612
+ function usersDir(root) {
613
+ return path.join(root, USERS_REL)
614
+ }
615
+
616
+ /** 사람마다의 파일이 쌓이는 곳. `.gitattributes` 와 섞이지 않게 한 폴더 아래 모은다. */
617
+ function usersFilesDir(root) {
618
+ return path.join(usersDir(root), 'users')
619
+ }
620
+
594
621
  /**
595
622
  * 옛 쪽지함. **읽기만 한다.**
596
623
  *
@@ -1108,11 +1135,25 @@ function restoreClaimFile(file, prev) {
1108
1135
  *
1109
1136
  * @returns {'ready'|'created'|'failed'}
1110
1137
  */
1111
- function ensureBus(root) {
1112
- const dir = busDir(root)
1138
+ /**
1139
+ * 고아 브랜치(**코드 이력과 아무 조상도 공유하지 않는 별도 계보**) 하나를 만들고
1140
+ * 그것을 옆 폴더에 펼친다. 쪽지함과 사람별 상태가 **같은 코드**로 만들어진다.
1141
+ *
1142
+ * 🔴 이 함수가 있는 이유는 사본을 안 만들기 위해서다. 쪽지함 것을 복사해서
1143
+ * 사람 것을 만들면 두 벌이 되고, 두 벌은 반드시 어긋난다 — 이 저장소가 팀
1144
+ * 사본을 걷어낸 것과 같은 이유이고, 실제로 그 어긋남으로 쪽지가 묻혔다.
1145
+ *
1146
+ * @param what 실패 메시지에 쓸 사람 말 ("쪽지함" · "사람별 상태")
1147
+ * @param seed worktree 를 만든 직후 그 안을 채우는 함수. 브랜치마다 다른 유일한 곳
1148
+ */
1149
+ function ensureOrphan(root, { branch, rel, dir, what, seed }) {
1150
+ const fail = (r) => {
1151
+ console.error(`경고: ${what}을(를) 준비하지 못했습니다 - ${(r.err || r.out || '').split('\n')[0]}`)
1152
+ return 'failed'
1153
+ }
1113
1154
  if (fs.existsSync(path.join(dir, '.git'))) return 'ready'
1114
1155
  if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
1115
- console.error(`경고: ${BUS_REL} 가 있지만 쪽지함 worktree 가 아닙니다.\n 지운 뒤 다시 실행하세요: rm -rf ${BUS_REL}`)
1156
+ console.error(`경고: ${rel} 가 있지만 ${what} worktree 가 아닙니다.\n 지운 뒤 다시 실행하세요: rm -rf ${rel}`)
1116
1157
  return 'failed'
1117
1158
  }
1118
1159
 
@@ -1122,54 +1163,107 @@ function ensureBus(root) {
1122
1163
  const remote = r.source === 'ambiguous' ? null : r.name
1123
1164
 
1124
1165
  let base = null
1125
- if (remote && git(['fetch', '--quiet', remote, BUS_BRANCH], { cwd: root }).code === 0) {
1166
+ if (remote && git(['fetch', '--quiet', remote, branch], { cwd: root }).code === 0) {
1126
1167
  base = git(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
1127
1168
  } else {
1128
1169
  // 빈 트리 -> 부모 없는 커밋 -> 브랜치. 작업 트리를 건드리지 않는 고아 브랜치.
1129
1170
  const tree = git(['mktree'], { cwd: root, input: '' })
1130
- if (tree.code !== 0) return busSetupFailed(tree)
1131
- const c = git(['commit-tree', tree.out, '-m', 'axmap: bus init'], { cwd: root })
1132
- if (c.code !== 0) return busSetupFailed(c)
1171
+ if (tree.code !== 0) return fail(tree)
1172
+ const c = git(['commit-tree', tree.out, '-m', `axmap: ${branch} init`], { cwd: root })
1173
+ if (c.code !== 0) return fail(c)
1133
1174
  base = c.out
1134
1175
  }
1135
1176
 
1136
- if (git(['rev-parse', '--verify', '--quiet', BUS_BRANCH], { cwd: root }).code !== 0) {
1137
- const b = git(['branch', BUS_BRANCH, base], { cwd: root })
1138
- if (b.code !== 0) return busSetupFailed(b)
1177
+ if (git(['rev-parse', '--verify', '--quiet', branch], { cwd: root }).code !== 0) {
1178
+ const b = git(['branch', branch, base], { cwd: root })
1179
+ if (b.code !== 0) return fail(b)
1139
1180
  }
1140
- const w = git(['worktree', 'add', '--quiet', dir, BUS_BRANCH], { cwd: root })
1141
- if (w.code !== 0) return busSetupFailed(w)
1181
+ const w = git(['worktree', 'add', '--quiet', dir, branch], { cwd: root })
1182
+ if (w.code !== 0) return fail(w)
1142
1183
 
1143
- // git 은 빈 디렉터리를 추적하지 않는다. 쪽지가 0개인 동안에도 폴더가 살아 있게 한다.
1144
- fs.mkdirSync(busMessagesDir(root), { recursive: true })
1145
- fs.writeFileSync(path.join(busMessagesDir(root), '.gitkeep'), '')
1184
+ seed(dir)
1146
1185
 
1147
1186
  // 🔴 고아 브랜치는 **자기 트리의 `.gitattributes` 만** 본다. 저장소 루트에
1148
1187
  // 있는 것은 여기 안 닿으므로, 심어 두지 않으면 Windows(`core.autocrlf=true`)
1149
- // 에서 쪽지가 CRLF 로 체크아웃된다. 2026-08-26 에 실제로 그랬고, 머리말
1188
+ // 에서 파일이 CRLF 로 체크아웃된다. 2026-08-26 에 실제로 그랬고, 머리말
1150
1189
  // 파서가 `\r` 에 걸려 **쪽지가 목록에서 조용히 사라졌다.**
1151
1190
  // 파서도 함께 고쳤지만(`tools/bus.mjs`) 바이트가 플랫폼마다 달라지는 것
1152
1191
  // 자체를 막는 편이 낫다 — 해시도, diff 도, 파서도 전부 같은 것을 본다.
1153
1192
  fs.writeFileSync(
1154
1193
  path.join(dir, '.gitattributes'),
1155
- '# 쪽지는 어느 OS 에서 만들어도 같은 바이트여야 한다.\n' +
1194
+ '# 브랜치의 파일은 어느 OS 에서 만들어도 같은 바이트여야 한다.\n' +
1156
1195
  '# 저장소 루트의 .gitattributes 는 고아 브랜치에 닿지 않으므로 여기 따로 둔다.\n' +
1157
1196
  '* text=auto eol=lf\n',
1158
1197
  )
1159
1198
 
1160
1199
  if (remote) {
1161
1200
  git(['add', '-A'], { cwd: dir })
1162
- // --no-verify: 연결된 worktree 는 훅을 공유한다. 쪽지함은 사용자 코드가 아니다.
1163
- git(['commit', '--quiet', '--no-verify', '-m', 'axmap: bus init'], { cwd: dir })
1164
- const p = git(['push', '--quiet', remote, `HEAD:${BUS_BRANCH}`], { cwd: dir })
1165
- if (p.code !== 0) console.error(`경고: 쪽지함 push 실패 - ${(p.err ?? '').split('\n')[0]}`)
1201
+ // --no-verify: 연결된 worktree 는 훅을 공유한다. 브랜치는 사용자 코드가 아니다.
1202
+ git(['commit', '--quiet', '--no-verify', '-m', `axmap: ${branch} init`], { cwd: dir })
1203
+ const p = git(['push', '--quiet', remote, `HEAD:${branch}`], { cwd: dir })
1204
+ if (p.code !== 0) console.error(`경고: ${what} push 실패 - ${(p.err ?? '').split('\n')[0]}`)
1166
1205
  }
1167
1206
  return 'created'
1168
1207
  }
1169
1208
 
1170
- function busSetupFailed(r) {
1171
- console.error(`경고: 쪽지함을 준비하지 못했습니다 - ${(r.err || r.out || '').split('\n')[0]}`)
1172
- return 'failed'
1209
+ function ensureBus(root) {
1210
+ return ensureOrphan(root, {
1211
+ branch: BUS_BRANCH, rel: BUS_REL, dir: busDir(root), what: '쪽지함',
1212
+ seed: () => {
1213
+ // git 은 빈 디렉터리를 추적하지 않는다. 쪽지가 0개인 동안에도 폴더가 살아 있게 한다.
1214
+ fs.mkdirSync(busMessagesDir(root), { recursive: true })
1215
+ fs.writeFileSync(path.join(busMessagesDir(root), '.gitkeep'), '')
1216
+ },
1217
+ })
1218
+ }
1219
+
1220
+ function ensureUsers(root) {
1221
+ return ensureOrphan(root, {
1222
+ branch: USERS_BRANCH, rel: USERS_REL, dir: usersDir(root), what: '사람별 상태',
1223
+ seed: () => {
1224
+ fs.mkdirSync(usersFilesDir(root), { recursive: true })
1225
+ fs.writeFileSync(path.join(usersFilesDir(root), '.gitkeep'), '')
1226
+ },
1227
+ })
1228
+ }
1229
+
1230
+ /**
1231
+ * 쪽지함만 준비한다. **장부는 건드리지 않는다.**
1232
+ *
1233
+ * 🔴 왜 `init` 을 부르지 않고 이 명령이 따로 있는가. 쪽지를 **읽는** 길에서
1234
+ * 부를 자리가 필요해서다. `init` 은 장부(`axmap/claims`)까지 만들고 그것을
1235
+ * 원격에 push 한다 — "내 쪽지 좀 보자" 가 남의 저장소에 **선점 장부**를
1236
+ * 만드는 일이 되면 안 된다. 쪽지 브랜치는 쪽지함이 원래 사는 곳이므로
1237
+ * 거기까지는 간다. 선을 긋는 자리는 "읽으려는 그것" 과 "그 밖의 것" 사이다.
1238
+ *
1239
+ * 그래서 `tools/bus.mjs` 는 쪽지함이 없을 때 이 명령을 부른다. 만드는 코드를
1240
+ * 저쪽에 복사하지 않는 이유는 사본이 곧 두 벌이고, 두 벌은 반드시 어긋나기
1241
+ * 때문이다 — 이 저장소가 팀 사본을 걷어낸 것과 같은 이유다.
1242
+ */
1243
+ function cmdBusRepair() {
1244
+ const root = repoRoot()
1245
+ // .axmap 을 손으로 지웠어도 git 쪽에 worktree 등록이 남아 add 가 실패한다.
1246
+ git(['worktree', 'prune'], { cwd: root })
1247
+ const r = ensureBus(root)
1248
+ if (r === 'failed') process.exit(1)
1249
+ console.log(
1250
+ r === 'ready'
1251
+ ? `쪽지함이 이미 있습니다: ${BUS_REL}`
1252
+ : `쪽지함을 만들었습니다: ${BUS_REL} (${BUS_BRANCH})`,
1253
+ )
1254
+ }
1255
+
1256
+ /** 사람별 상태만 준비한다. `bus-repair` 와 같은 이유로 따로 있다. */
1257
+ function cmdUsersRepair() {
1258
+ const root = repoRoot()
1259
+ git(['worktree', 'prune'], { cwd: root })
1260
+ const r = ensureUsers(root)
1261
+ if (r === 'failed') process.exit(1)
1262
+ console.log(
1263
+ r === 'ready'
1264
+ ? `사람별 상태가 이미 있습니다: ${USERS_REL}`
1265
+ : `사람별 상태를 만들었습니다: ${USERS_REL} (${USERS_BRANCH})`,
1266
+ )
1173
1267
  }
1174
1268
 
1175
1269
  function cmdInit(flags = {}) {
@@ -1186,6 +1280,7 @@ function cmdInit(flags = {}) {
1186
1280
  // 돌린 사람은 장부만 있고 쪽지함이 없다. 그 사람들이 다시 init 을 불렀을 때
1187
1281
  // 받아 가는 자리가 여기다.
1188
1282
  if (ensureBus(root) === 'created') console.log(`쪽지함을 만들었습니다: ${BUS_REL} (${BUS_BRANCH})`)
1283
+ if (ensureUsers(root) === 'created') console.log(`사람별 상태를 만들었습니다: ${USERS_REL} (${USERS_BRANCH})`)
1189
1284
  return
1190
1285
  }
1191
1286
  if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
@@ -1262,8 +1357,9 @@ function cmdInit(flags = {}) {
1262
1357
  }
1263
1358
 
1264
1359
  ensureBus(root)
1360
+ ensureUsers(root)
1265
1361
 
1266
- console.log(`준비 완료. 장부 ${LEDGER_BRANCH} · 쪽지함 ${BUS_BRANCH}`)
1362
+ console.log(`준비 완료. 장부 ${LEDGER_BRANCH} · 쪽지함 ${BUS_BRANCH} · 사람별 상태 ${USERS_BRANCH}`)
1267
1363
  }
1268
1364
 
1269
1365
  // ---------------------------------------------------------------------------
@@ -1664,7 +1760,16 @@ function collectCodeCommits(root, range) {
1664
1760
  for (const line of log.out ? log.out.split(NL) : []) {
1665
1761
  if (!line) continue
1666
1762
  const [sha, author, ct, subject] = line.split(NUL)
1667
- const files = git(['show', '--no-renames', '--name-only', '--format=', sha], { cwd: root }).out
1763
+ // 🔴 `-c core.quotepath=false` 없으면 **한글 경로가 claim 맞는다.**
1764
+ // git 은 기본으로 ASCII 밖 글자를 역슬래시 + 8진수 세 자리로 감싸 내보낸다.
1765
+ // 그 문자열은 장부에 적힌 경로와 절대 같아질 수 없고, 검사는 "안 겹친다" 로
1766
+ // **조용히 통과**한다 - 락에서 최악인 fail-open 이다.
1767
+ // 팀 CI 가 잡 안에서 `git config core.quotepath false` 로 막고 있었다.
1768
+ // 우회는 그 잡에만 걸린다. 부르는 자리에서 못을 박는다 (verify 와 같은 방식).
1769
+ const files = git(
1770
+ ['-c', 'core.quotepath=false', 'show', '--no-renames', '--name-only', '--format=', sha],
1771
+ { cwd: root },
1772
+ ).out
1668
1773
  out.push({
1669
1774
  sha,
1670
1775
  author,
@@ -2186,6 +2291,9 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
2186
2291
  axmap setup 이 PC 와 이 저장소에 axMap 을 붙인다 (첫 한 번)
2187
2292
  [--remote <이름|git 주소>] [--name] [--dry-run]
2188
2293
  axmap init 장부(고아 브랜치 + worktree)를 준비한다
2294
+ axmap bus-repair 쪽지함만 준비한다 (장부는 안 건드린다)
2295
+ 쪽지를 읽다가 "쪽지함을 못 열었습니다" 를 봤을 때
2296
+ axmap users-repair 사람별 상태(어디까지 읽었나)만 준비한다
2189
2297
  axmap claim <경로...> 경로를 선점한다 [--task --intent --ttl 30m]
2190
2298
  axmap release [경로...] 반납한다 (경로 생략 시 전부)
2191
2299
  axmap renew TTL 을 연장한다 [--ttl 30m]
@@ -2197,6 +2305,7 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
2197
2305
  axmap doctor 이 PC 에서 선점이 실제로 도는지 점검 [--json]
2198
2306
  axmap hook install verify 를 pre-commit 훅으로 설치
2199
2307
  axmap update 새 버전이 있는지 묻는다 (바꾸지는 않는다)
2308
+ axmap --version 지금 도는 판 번호를 찍는다 (아래 version 명령과 다르다)
2200
2309
 
2201
2310
  딸린 프로그램 — 예전에는 파일 경로로 불렀다. 이제 이름으로 부른다.
2202
2311
  인자는 그대로 전달되고, 각각의 사용법은 그 프로그램이 답한다.
@@ -2219,6 +2328,27 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
2219
2328
  const { positional, flags } = parseArgs(process.argv.slice(2))
2220
2329
  const [cmd, ...rest] = positional
2221
2330
 
2331
+ /**
2332
+ * 🔴 `--version` 은 명령이 아니라 **깃발**이라 아래 switch 에 안 걸린다.
2333
+ * 그대로 default 로 떨어져 **도움말을 찍고 종료 코드 0** 을 냈다. 팀 CI 가
2334
+ * "어느 판이 돌았는지" 를 남기려고 넣은 줄이 조용히 쓸모없어졌고, 0 이라
2335
+ * 아무도 못 알아챘다 (실측 2026-09-01, 팀 저장소 .gitlab-ci.yml eb3b56d).
2336
+ *
2337
+ * 🔴 `version` **명령과는 다른 것을 답한다.** 저쪽(tools/version.mjs)은
2338
+ * `process.cwd()` 의 git 태그를 읽는 도구라, 남의 저장소에서 부르면 그
2339
+ * 저장소의 버전이 나온다. 여기가 답해야 하는 것은 "지금 도는 axMap 이 몇
2340
+ * 판인가" 이고, 그 답은 설치본 자기 package.json 에만 있다.
2341
+ *
2342
+ * 못 읽으면 죽는다. 모르는 것을 빈 줄로 내면 CI 로그에는 "판을 확인했다" 는
2343
+ * 흔적만 남고 실제로는 아무것도 확인되지 않는다.
2344
+ */
2345
+ if (boolFlag(flags.version, 'version') && !cmd) {
2346
+ const v = selfVersion()
2347
+ if (!v) die('버전을 알 수 없습니다 - package.json 이 없는 사본입니다.')
2348
+ console.log(v)
2349
+ process.exit(0)
2350
+ }
2351
+
2222
2352
  switch (cmd) {
2223
2353
  // 이름을 경로로 바꿔 주는 것들 (RUNNERS). 인자는 손대지 않고 그대로 간다.
2224
2354
  case 'setup':
@@ -2238,6 +2368,12 @@ switch (cmd) {
2238
2368
  case 'init':
2239
2369
  cmdInit(flags)
2240
2370
  break
2371
+ case 'bus-repair':
2372
+ cmdBusRepair()
2373
+ break
2374
+ case 'users-repair':
2375
+ cmdUsersRepair()
2376
+ break
2241
2377
  case 'claim':
2242
2378
  cmdClaim(rest, flags)
2243
2379
  break
package/mcp/server.mjs CHANGED
@@ -460,7 +460,7 @@ const TOOLS = [
460
460
  inputSchema: {
461
461
  type: 'object',
462
462
  properties: {
463
- to: { type: 'string', description: '받는 에이전트 이름. 생략하면 모두에게.' },
463
+ to: { type: 'string', description: '받는 사람. 이름도 이메일도 됩니다. 여럿이면 쉼표로 (예: "bob,carol"). 생략하면 모두에게.' },
464
464
  subject: { type: 'string', description: '한 줄 제목' },
465
465
  body: { type: 'string', description: '본문 (마크다운)' },
466
466
  },
@@ -878,7 +878,11 @@ function dispatch(name, args = {}) {
878
878
  case 'ax_send': {
879
879
  if (!args.subject || !args.body) return { ok: false, text: 'subject 와 body 가 필요합니다.' }
880
880
  const a = ['post', '--subject', String(args.subject)]
881
- if (args.to) a.push('--to', String(args.to))
881
+ // 여럿에게 한 통을 보낼 수 있다. 문자열로 `"a,b"` 를 줘도, 배열로 줘도 같다 —
882
+ // 같은 내용을 두 번 보내면 답장이 두 갈래로 갈려 대화가 쪼개진다.
883
+ for (const t of (Array.isArray(args.to) ? args.to : (args.to ? [args.to] : []))) {
884
+ if (String(t).trim()) a.push('--to', String(t).trim())
885
+ }
882
886
  const r = bus(a, String(args.body))
883
887
  return {
884
888
  ok: r.code === 0,
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.0.0",
4
+ "version": "1.0.3",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
package/tools/bus.mjs CHANGED
@@ -30,7 +30,7 @@
30
30
  * 에 한 줄로 적는 편이 빠르다 — 그건 `axmap status` 로 바로 보인다.
31
31
  */
32
32
 
33
- import { execFileSync } from 'node:child_process'
33
+ 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'
@@ -114,6 +114,79 @@ function gitBus(argv, opts = {}) {
114
114
 
115
115
  const busReady = () => BUS_WT !== null && fs.existsSync(path.join(BUS_WT, '.git'))
116
116
 
117
+ /**
118
+ * 쪽지함을 못 연 이유. `null` 이면 정상이다.
119
+ *
120
+ * 🔴 이 변수가 있는 이유는 하나다 — **"쪽지 없음" 과 "못 읽었음" 을 다른 문장으로
121
+ * 내기 위해서다.**
122
+ *
123
+ * 2026-09-03 실측: 원격 `axmap/bus` 에 쪽지 129건이 있고 그중 오늘 온 것이
124
+ * 있는데, 이 체크아웃에 worktree 가 없어서 `pull()` 이 **fetch 를 한 번도 안
125
+ * 하고 조용히 반환**했다. 읽는 자리는 옛 함(`docs/bus`)으로 떨어졌고 거기 있던
126
+ * 8월 것은 이미 읽음이라 화면에 나온 답은 `쪽지 없음.` 이었다. 경고도 없었다.
127
+ *
128
+ * **보내기는 같은 상황에서 제대로 거부했다** (아래 `post`). 쓰기는 막고 읽기만
129
+ * 통과시킨 것이다. 락 시스템에서 최악의 실패는 거부해야 할 것을 조용히
130
+ * 통과시키는 것이고(CLAUDE.md 「애매하면 거부한다」), 쪽지함에서는 **못 읽은
131
+ * 것을 "없다" 고 답하는 것**이 바로 그 자리다. 잘못 막으면 사람이 메시지를
132
+ * 읽고 고치면 되지만, 조용히 없다고 하면 아무도 잘못됐다는 것을 모른다.
133
+ */
134
+ let busProblem = null
135
+
136
+ /** 옛 함(`docs/bus`)에서만 읽었는가. 새 함을 못 연 채로 성공한 것처럼 보이지 않게 한다. */
137
+ let readFromLegacyOnly = false
138
+
139
+ /**
140
+ * 쪽지함을 연다. **없으면 만든다. 만들었으면 만들었다고 말한다.**
141
+ *
142
+ * 🔴 만드는 코드를 여기에 복사하지 않는다. `bin/axmap.mjs` 의 `ensureBus` 하나뿐이고
143
+ * 이 함수는 그것을 `bus-repair` 로 부른다. 사본을 두면 두 벌이 되고, 두 벌은
144
+ * 반드시 어긋난다 — 이 저장소가 팀 사본을 걷어낸 것과 같은 이유다.
145
+ *
146
+ * 🔴 만들고 나서 **조용히 넘어가지 않는다.** 이 버그의 본질이 "말없이 넘어간 것"
147
+ * 이라, 자동으로 만들어 놓고 또 말없이 넘어가면 증상만 다른 같은 병이 된다.
148
+ * stdout 이 아니라 stderr 로 낸다 — 목록을 파이프로 넘겨도 사람 눈에는 남는다.
149
+ */
150
+ function openBus() {
151
+ if (process.env.AXMAP_BUS_DIR) return true // 대상이 명시됐으면 물을 것이 없다
152
+ if (busReady()) return true
153
+ if (BUS_WT === null) {
154
+ busProblem = '이 저장소를 찾지 못했습니다.'
155
+ return false
156
+ }
157
+
158
+ const r = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'axmap.mjs'), 'bus-repair'], {
159
+ cwd: REPO, encoding: 'utf8', windowsHide: true,
160
+ })
161
+ if (busReady()) {
162
+ console.error(`쪽지함이 없어서 만들었습니다: ${path.join('.axmap', 'bus')} (${BUS_BRANCH})`)
163
+ return true
164
+ }
165
+ busProblem = (r.stderr ?? '').trim() || (r.stdout ?? '').trim() || '알 수 없는 이유로 실패했습니다.'
166
+ return false
167
+ }
168
+
169
+ /**
170
+ * 쪽지함을 못 열었다는 것을 사람이 볼 수 있게 낸다.
171
+ *
172
+ * 🔴 **"없음" 이라고 말하지 않는다.** 받은 쪽지가 없는 것이 아니라 확인을 못 한
173
+ * 것이고, 둘은 사람이 해야 할 일이 정반대다.
174
+ */
175
+ function reportBusProblem() {
176
+ // 🔴 순서가 뜻이다. **무슨 일인지 먼저, 이유는 그다음.** 이유부터 내면 그것이
177
+ // 여러 줄일 때 안내가 아래로 밀려서, 급한 사람이 첫 줄만 보고 넘어간다.
178
+ const why = busProblem
179
+ .replace(/^경고: /, '')
180
+ .split('\n')
181
+ .map((s, i) => (i === 0 ? s : ` ${s.trim()}`))
182
+ .join('\n')
183
+ console.error(
184
+ '쪽지함을 못 열었습니다 — 받은 쪽지가 없는 것이 아니라 확인을 못 한 것입니다.\n' +
185
+ `\n 이유: ${why}\n` +
186
+ '\n 고치기: axmap bus-repair (MCP 에서는 ax_init)',
187
+ )
188
+ }
189
+
117
190
  /**
118
191
  * 원격의 쪽지를 받아온다. **실패해도 죽지 않는다** — 못 받은 것은 위험이 아니라
119
192
  * 지연이다. 장부(`syncLedger`)가 같은 자리에서 죽는 것과 정반대이고, 그 차이의
@@ -129,7 +202,10 @@ const busReady = () => BUS_WT !== null && fs.existsSync(path.join(BUS_WT, '.git'
129
202
  * 쪽지 worktree(`.axmap/bus`) **바깥**이라 고아 브랜치에 섞이지 않는다.
130
203
  */
131
204
  function pull() {
132
- if (!busReady()) return
205
+ // 🔴 예전에는 여기가 `if (!busReady()) return` 이었다. 쪽지함이 없으면 **fetch 를
206
+ // 한 번도 안 하고 조용히 반환**했고, 그것이 "쪽지 없음" 으로 보였다.
207
+ // 이제는 열어 보고, 못 열면 그 사실을 `busProblem` 에 남긴다.
208
+ if (!openBus()) return
133
209
  // 🔴 주기는 **설정에서 온다.** `--throttle` 이 1순위(부르는 쪽이 그 자리에서
134
210
  // 정한다), 없으면 `AXMAP_BUS_POLL`(초), 그것도 없으면 0 = 매번 받아온다.
135
211
  //
@@ -159,6 +235,30 @@ const cmd = args[0]
159
235
  const flag = (n, d = null) => { const i = args.indexOf(n); return i < 0 ? d : args[i + 1] }
160
236
  const has = (n) => args.includes(n)
161
237
 
238
+ /**
239
+ * 같은 이름의 옵션이 여러 번 와도 전부 모은다. `flag` 는 첫 번째만 낸다.
240
+ *
241
+ * 🔴 쪽지 216통을 세어 보니 **같은 제목이 초 단위로 두 번씩 나간 것**이 여러
242
+ * 건이었다. 같은 내용을 두 사람에게 보내려면 두 번 보내는 수밖에 없어서다.
243
+ * 그러면 답장이 두 갈래로 갈려 대화가 쪼개지고, 나중에 읽는 사람은 어느
244
+ * 쪽이 이어진 이야기인지 모른다. 그래서 사람들이 관계없는 사람까지 읽는
245
+ * 전체공지로 도망쳤다 — 48통이 그렇게 나갔다.
246
+ */
247
+ const flagAll = (n) => {
248
+ const out = []
249
+ for (let i = 0; i < args.length; i++) if (args[i] === n && args[i + 1] != null) out.push(args[i + 1])
250
+ return out
251
+ }
252
+
253
+ /** 받는 사람 목록. `--to a --to b` 도, `--to "a,b"` 도 같은 것으로 본다. */
254
+ const recipientArgs = () => flagAll('--to')
255
+ .flatMap((s) => String(s).split(','))
256
+ .map((s) => s.trim())
257
+ .filter(Boolean)
258
+
259
+ /** 쪽지 하나의 받는 사람들. 예전 쪽지는 값이 하나라 그대로 한 개짜리 목록이 된다. */
260
+ const recipientsOf = (m) => String(m.to ?? 'all').split(',').map(norm).filter(Boolean)
261
+
162
262
  /**
163
263
  * 나는 누구인가. 선점 프로토콜과 **같은 값**을 쓴다 — 두 이름을 두면 갈린다.
164
264
  *
@@ -191,6 +291,186 @@ function me() {
191
291
  process.exit(1)
192
292
  }
193
293
 
294
+ /**
295
+ * 사람의 주소는 **이메일**이다. 이름은 그 사람을 부르는 여러 별칭 중 하나일 뿐이다.
296
+ *
297
+ * 🔴 왜 이름이 아니라 이메일인가. 실측(2026-09-05, 팀 저장소 최근 300커밋):
298
+ *
299
+ * 사람 6명 → git 이름 11개 → git 이메일 6개
300
+ *
301
+ * 한 사람이 `yeaseung lee` · `yeaseung-lee` · `이예승` 셋으로 커밋한다.
302
+ * **이름은 갈리는데 이메일은 안 갈린다** — 표기는 바꿔도 이메일은 안 바꾼다.
303
+ *
304
+ * 그래서 이름을 주소로 쓰면, 보낸 쪽은 "보냈습니다" 를 보고 받는 쪽은
305
+ * "쪽지 없음" 을 본다. **양쪽 다 오류가 없어서 아무도 실패를 못 본다.**
306
+ * 실제로 216통 중 존재하지 않는 이름으로 간 것이 1통 있었고, 보낸 사람이
307
+ * 13분 뒤에 눈치채고 다시 보냈다.
308
+ *
309
+ * 그리고 이 저장소는 **이미 한쪽에서 옳게 하고 있었다** — 투표권자 명단
310
+ * (`governance/policy.json`)은 처음부터 이메일로 사람을 가른다. 쪽지함과
311
+ * 선점만 이름을 썼다. 한 저장소가 두 개의 주소 체계를 갖고 있던 것이다.
312
+ */
313
+ const norm = (s) => String(s ?? '').trim().toLowerCase()
314
+
315
+ /** 이 저장소를 쓰는 사람들. `{ email, names:Set }` 의 목록. 한 프로세스에서 한 번만 센다. */
316
+ let rosterCache = null
317
+
318
+ function roster() {
319
+ if (rosterCache) return rosterCache
320
+ const byEmail = new Map()
321
+
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()
337
+ const add = (email, name) => {
338
+ const e = norm(email)
339
+ if (!e || !e.includes('@')) return
340
+ if (!byEmail.has(e)) byEmail.set(e, { email: e, names: new Set() })
341
+ const n = norm(name)
342
+ if (!n) return
343
+ const owner = nameOwner.get(n)
344
+ if (owner && owner !== e) return // 이미 남의 이름이다. 합치지 않는다
345
+ nameOwner.set(n, e)
346
+ byEmail.get(e).names.add(n)
347
+ }
348
+
349
+ // 1) git 이력 — 이 저장소에서 실제로 일한 사람이 곧 명단이다. 별도 파일도,
350
+ // 별도 브랜치도 필요 없다. **아무 저장소에서나 성립한다**는 것이 중요하다.
351
+ try {
352
+ const out = execFileSync('git', ['log', '--all', '--format=%ae\t%an', '-n', '5000'], {
353
+ cwd: REPO ?? process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
354
+ })
355
+ for (const line of out.split('\n')) {
356
+ const [e, n] = line.split('\t')
357
+ add(e, n)
358
+ }
359
+ } catch { /* 저장소가 아니거나 이력이 없다. 아래 2)로 채운다 */ }
360
+
361
+ // 2) 쪽지를 보낸 적이 있는 사람. 커밋은 없지만 쪽지는 쓴 사람이 실제로 있다
362
+ // (기획·디자인). 여기서 안 주우면 그 사람에게는 답장을 못 보낸다.
363
+ try {
364
+ for (const m of readAll()) if (m.fromEmail) add(m.fromEmail, m.from)
365
+ } catch { /* 쪽지함을 못 열었을 뿐이다. 1)만으로도 명단은 선다 */ }
366
+
367
+ // 3) 적혀 있는 별칭. **git 이 모르는 이름이 여기 들어온다.**
368
+ //
369
+ // 🔴 여기서 받아오는 것이 중요하다. 예전에는 안 읽음 표시를 볼 때만 받아왔고,
370
+ // 명단은 그 자리를 안 거쳤다. 그래서 **별칭 앞으로 온 쪽지가 그 사람에게
371
+ // 안 갔다** — 보낸 쪽 PC 에는 별칭이 있고 받는 쪽 PC 에는 없었기 때문이다.
372
+ // 이름이 어긋나서 쪽지가 묻히는 것, 정확히 고치려던 그 증상이 자리만
373
+ // 바꿔서 되살아난 것이었다.
374
+ pullUsers()
375
+ //
376
+ // 🔴 실측에서 커밋 로그에 없는 이름이 넷 있었다 — codex-jinmiri · jaehyeon-2 ·
377
+ // codex · rleaderjoon-desktop. 사람이 아니라 그 사람이 띄운 AI 도구이거나
378
+ // 다른 PC 다. git 은 이것을 알 방법이 없으므로 적어 두는 자리가 필요하다.
379
+ try {
380
+ const dir = USERS_WT ? path.join(USERS_WT, 'users') : null
381
+ for (const f of (dir ? fs.readdirSync(dir) : [])) {
382
+ if (!f.endsWith('.json')) continue
383
+ const st = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
384
+ const email = f.replace(/\.json$/, '')
385
+ add(email, st.name)
386
+ for (const a of (st.aliases ?? [])) add(email, a)
387
+ }
388
+ } catch { /* 사람별 상태가 아직 없다. 1)·2)만으로도 명단은 선다 */ }
389
+
390
+ rosterCache = [...byEmail.values()]
391
+ return rosterCache
392
+ }
393
+
394
+ /**
395
+ * 이 사람을 가리키는 **모든 주소**. 이름으로 물어도 이메일로 물어도 같은 답이 온다.
396
+ *
397
+ * 명단에 없으면 물어본 것 그대로 한 개짜리 집합을 낸다 — 모르는 사람을 아는 척
398
+ * 하지 않는다. 그 판단은 부르는 쪽(`post`)이 한다.
399
+ */
400
+ function addressesOf(who) {
401
+ 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])
404
+ }
405
+
406
+ /**
407
+ * 아는 주소 중 이것과 **거의 같은 것**들. 오타를 잡기 위한 것이지 검색이 아니다.
408
+ *
409
+ * 두 가지만 본다.
410
+ * 1. 한쪽이 다른 쪽의 앞부분이다 — `ahwlstjd` / `ahwlstjd57` (실제로 난 오타)
411
+ * 2. 글자 **하나** 차이다 — 손가락이 미끄러진 것
412
+ *
413
+ * 🔴 넓히지 않는다. 넓히면 남남인 이름끼리 "혹시 이것입니까" 가 뜨고, 그러면
414
+ * 사람은 그 물음을 안 읽게 된다. 안 읽히는 확인은 없는 확인이다.
415
+ *
416
+ * 두 글자까지 봤다가 `reader` 와 `sender` 가 걸렸다 — 여섯 글자에서 두 글자면
417
+ * 3분의 1이 다른 것이고, 그건 오타가 아니라 다른 낱말이다. 실제로 났던 오타는
418
+ * 거리가 아니라 **앞부분 일치**로 잡히므로 좁혀도 잃는 것이 없다.
419
+ */
420
+ function nearMatches(who) {
421
+ const w = norm(who)
422
+ const all = roster().flatMap((p) => [p.email, ...p.names])
423
+ return all.filter((a) => {
424
+ if (a === w) return false
425
+ const [s, l] = a.length < w.length ? [a, w] : [w, a]
426
+ if (l.startsWith(s) && l.length - s.length <= 4) return true
427
+ return distance(w, a) <= 1
428
+ }).slice(0, 5)
429
+ }
430
+
431
+ /** 두 글자열을 같게 만드는 데 필요한 최소 편집 횟수 (Levenshtein). */
432
+ function distance(a, b) {
433
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
434
+ for (let i = 1; i <= a.length; i++) {
435
+ let diag = prev[0]
436
+ prev[0] = i
437
+ for (let j = 1; j <= b.length; j++) {
438
+ const t = prev[j]
439
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1))
440
+ diag = t
441
+ }
442
+ }
443
+ return prev[b.length]
444
+ }
445
+
446
+ /** 내 이메일. 없으면 null — 없다고 죽지는 않는다. 이름만으로도 지금까지처럼 돈다. */
447
+ function myEmail() {
448
+ const v = process.env.AXMAP_AGENT_EMAIL
449
+ if (v && v.includes('@')) return norm(v)
450
+ try {
451
+ const e = execFileSync('git', ['config', 'user.email'], {
452
+ cwd: REPO ?? process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
453
+ }).trim()
454
+ return e.includes('@') ? norm(e) : null
455
+ } catch { return null }
456
+ }
457
+
458
+ /**
459
+ * 나에게 온 쪽지인지 판정할 때 쓰는 내 주소 전부.
460
+ *
461
+ * 🔴 `AXMAP_AGENT` 로 붙인 이름도 넣는다. 실측에서 `codex-jinmiri`·`jaehyeon-2`
462
+ * 처럼 **커밋 로그에 없는 이름**이 넷 있었다. 사람이 아니라 그 사람이 띄운
463
+ * AI 도구라 git 이 모른다. 그것도 나다.
464
+ */
465
+ function myAddresses() {
466
+ const s = new Set()
467
+ const e = myEmail()
468
+ 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))
471
+ return s
472
+ }
473
+
194
474
  /**
195
475
  * 읽음 표시 — 규격은 `docs/SPEC.md` §2「읽음 표시」다. 여기와 `bin/axmap.mjs`
196
476
  * 의 `unreadNotes` 가 **같은 파일을 같은 규칙으로** 본다. 규칙이 한 줄이라
@@ -199,11 +479,94 @@ function me() {
199
479
  * 🔴 장부에 넣지 않는다. 읽었는지는 나만의 상태라 남과 합의할 필요가 없고,
200
480
  * 장부에 쓰면 쪽지를 볼 때마다 push 경합이 생긴다.
201
481
  */
482
+ /**
483
+ * 옛 자리 — **이 PC 안의 파일 하나.** 읽기만 한다. 새 자리로 옮기는 재료다.
484
+ *
485
+ * 🔴 여기 있었기 때문에 **PC 를 바꾸면 전부 다시 안 읽음**이 됐다. 쪽지 본문은
486
+ * 고아 브랜치로 모두가 공유하는데, 읽었다는 사실만 그 PC 에 갇혀 있었다.
487
+ * 다시 clone 하면 사라지고, 같은 사람이 도구를 둘 띄우면 각자 다른 상태를 봤다.
488
+ */
202
489
  const SEEN_FILE = REPO ? path.join(REPO, '.axmap-bus-seen.json') : null
203
490
 
491
+ /** 새 자리 — 고아 브랜치 `axmap/users` 의 worktree. 사람마다 파일 하나. */
492
+ const USERS_WT = REPO ? path.join(REPO, '.axmap', 'users') : null
493
+ const USERS_BRANCH = 'axmap/users'
494
+ const usersReady = () => USERS_WT !== null && fs.existsSync(path.join(USERS_WT, '.git'))
495
+
496
+ function gitUsers(argv, opts = {}) {
497
+ try {
498
+ return {
499
+ code: 0,
500
+ out: execFileSync('git', argv, {
501
+ cwd: USERS_WT, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], ...opts,
502
+ }).trim(),
503
+ }
504
+ } catch (e) { return { code: e.status ?? 1, out: '', err: String(e.stderr ?? e.message) } }
505
+ }
506
+
507
+ /**
508
+ * 사람별 상태를 연다. **못 열어도 죽지 않고, 말하지도 않는다.**
509
+ *
510
+ * 🔴 실패 방침이 쪽지함과 **정반대**다. 쪽지함은 못 읽으면 큰 소리로 말해야 한다 —
511
+ * 거기서 조용하면 **온 쪽지를 없다고 답하게** 되기 때문이다. 여기서 조용하면
512
+ * **이미 읽은 쪽지가 한 번 더 뜰** 뿐이다.
513
+ *
514
+ * 즉 두 실패의 방향이 다르다. 하나는 **덜 보여주는 쪽**으로 틀리고 하나는
515
+ * **더 보여주는 쪽**으로 틀린다. 더 보여주는 실패는 사람이 알아서 넘긴다.
516
+ * 그래서 여기에 경고를 달면 쓸모없는 줄이 매번 뜨고, 그러면 진짜 경고까지
517
+ * 같이 안 읽히게 된다.
518
+ */
519
+ function openUsers() {
520
+ if (usersReady()) return true
521
+ if (USERS_WT === null || process.env.AXMAP_BUS_DIR) return false
522
+ spawnSync(process.execPath, [path.join(ROOT, 'bin', 'axmap.mjs'), 'users-repair'], {
523
+ cwd: REPO, encoding: 'utf8', windowsHide: true,
524
+ })
525
+ return usersReady()
526
+ }
527
+
528
+ /** 이 사람의 파일 자리. **이메일이 정본**이고, 없는 사람만 이름으로 떨어진다. */
529
+ function userKey(who) {
530
+ const w = norm(who)
531
+ const p = roster().find((x) => x.email === w || x.names.has(w))
532
+ return p ? p.email : w
533
+ }
534
+
535
+ const userFile = (who) => path.join(USERS_WT, 'users', `${userKey(who)}.json`)
536
+
537
+ function readUserState(who) {
538
+ try { return JSON.parse(fs.readFileSync(userFile(who), 'utf8')) } catch { return {} }
539
+ }
540
+
541
+ /** 원격의 사람별 상태를 받아온다. 자주 물을 이유가 없어 느슨하게 맞춘다. */
542
+ function pullUsers() {
543
+ if (!openUsers()) return
544
+ const stampFile = path.join(REPO, '.axmap', '.users-lastfetch')
545
+ // 🔴 60초. 쪽지함(기본 0초)보다 훨씬 느슨한 것은 **늦어도 손해가 없기 때문**이다.
546
+ // 늦으면 다른 PC 에서 읽은 것이 여기서 한 번 더 뜬다. 그뿐이다.
547
+ // 반대로 매번 물으면 도구 호출마다 통신이 한 번씩 더 붙는다 (실측 0.8초).
548
+ 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 { /* 스탬프 실패는 치명적이지 않다 */ }
555
+ }
556
+
204
557
  function readSeen(who) {
205
- if (!SEEN_FILE) return ''
206
- try { return JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8'))[who] ?? '' } catch { return '' }
558
+ pullUsers()
559
+ const fromBranch = usersReady() ? (readUserState(who).seen ?? '') : ''
560
+ // 🔴 옛 자리도 함께 본다. **둘 중 더 나아간 쪽**을 쓴다.
561
+ //
562
+ // 안 그러면 이 판으로 올린 날 모두의 쪽지가 전부 "안 읽음" 으로 되살아난다.
563
+ // 수십 건이 한꺼번에 뜨면 사람은 그것을 배경으로 여기고 안 읽게 되는데,
564
+ // 그건 이 도구가 고치려던 바로 그 상태다.
565
+ let legacy = ''
566
+ if (SEEN_FILE) {
567
+ try { legacy = JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8'))[who] ?? '' } catch { /* 없다 */ }
568
+ }
569
+ return fromBranch > legacy ? fromBranch : legacy
207
570
  }
208
571
 
209
572
  /**
@@ -215,14 +578,56 @@ function readSeen(who) {
215
578
  * 한쪽이 질 수 있는데, 지는 쪽의 손해는 "한 번 더 뜬다" 뿐이라 잠그지 않는다.
216
579
  */
217
580
  function markSeen(who, id) {
218
- if (!SEEN_FILE || !who || !id) return
581
+ if (!who || !id) return
582
+ // 옛 자리에도 계속 쓴다. 아직 옛 판을 쓰는 PC 가 같은 저장소에 있을 수 있고,
583
+ // 그쪽에서는 이 자리만 읽는다. 새 판이 다 퍼지면 이 줄은 지운다.
584
+ if (SEEN_FILE) {
585
+ try {
586
+ let all = {}
587
+ try { all = JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8')) } catch { /* 처음이다 */ }
588
+ if ((all[who] ?? '') < id) {
589
+ all[who] = id
590
+ fs.writeFileSync(SEEN_FILE, JSON.stringify(all, null, 2) + '\n')
591
+ }
592
+ } catch { /* 조용히 */ }
593
+ }
594
+
595
+ if (!openUsers()) return
219
596
  try {
220
- let all = {}
221
- try { all = JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8')) } catch { /* 처음이다 */ }
222
- if ((all[who] ?? '') >= id) return // 뒤로 가지 않는다
223
- all[who] = id
224
- fs.writeFileSync(SEEN_FILE, JSON.stringify(all, null, 2) + '\n')
225
- } catch { /* 조용히 */ }
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 { /* 조용히 — 못 찍으면 다음에 한 번 더 뜰 뿐이다 */ }
226
631
  }
227
632
 
228
633
  const stamp = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
@@ -230,11 +635,17 @@ const slug = (s) => s.toLowerCase().replace(/[^\w가-힣]+/g, '-').replace(/^-|-
230
635
 
231
636
  function readAll() {
232
637
  const found = []
638
+ let newBoxOpened = false
233
639
  for (const box of READ_BOXES) {
234
640
  let ns = []
235
641
  try { ns = fs.readdirSync(box).filter((f) => f.endsWith('.md')) } catch { continue }
642
+ if (box === BOX) newBoxOpened = true
236
643
  for (const f of ns) found.push({ box, f })
237
644
  }
645
+ // 🔴 옛 함(`docs/bus`)은 **읽기 전용 유산**이다. 새 함을 못 열었는데 옛 함이
646
+ // 읽히면 지금까지는 그것이 **성공한 것처럼** 보였다 — 8월 20일자 8건이
647
+ // 나오고 오늘 온 쪽지는 없는 것이 된다. 어느 함에서 읽었는지를 남긴다.
648
+ readFromLegacyOnly = !newBoxOpened && found.length > 0
238
649
  return found.map(({ box, f }) => {
239
650
  const text = fs.readFileSync(path.join(box, f), 'utf8')
240
651
  const head = {}
@@ -269,6 +680,24 @@ function stdin() {
269
680
 
270
681
  function post({ to, subject, body, replyTo = null }) {
271
682
  const from = me()
683
+ // 부르는 쪽이 하나를 주든 여럿을 주든 여기서는 목록 하나로 본다.
684
+ let tos = (Array.isArray(to) ? to : (to == null ? [] : [to])).map((s) => String(s).trim()).filter(Boolean)
685
+
686
+ // 🔴 대상을 안 주면 지금까지처럼 전체에게 간다. **바꾸지 않는다** — 바꾸면
687
+ // 어제 되던 것이 오늘 안 되는 변경이고, 그건 맨 앞자리 번호를 올리고 먼저
688
+ // 알린 뒤에 할 일이다 (MCP 의 ax_send 도 `to` 를 선택으로 두고 있다).
689
+ //
690
+ // 대신 **조용히 넘어가지는 않는다.** 옵션 하나를 빠뜨린 것과 전체에게
691
+ // 보내려는 것은 명령줄에서 생김새가 똑같다. 실제로 216통 중 48통이
692
+ // 전체공지였는데, 그중 몇이 의도한 것이었는지는 아무도 모른다.
693
+ if (!tos.length) {
694
+ console.error('※ 받는 사람을 주지 않았습니다 — 전체(all)에게 보냅니다. 한 사람에게 보내려면 --to <상대>')
695
+ tos = ['all']
696
+ }
697
+ if (tos.length > 1 && tos.some((t) => norm(t) === 'all')) {
698
+ console.error('※ all 이 섞여 있어 전체에게 보냅니다. 나머지 이름은 뜻이 없습니다.')
699
+ tos = ['all']
700
+ }
272
701
  if (!subject) { console.error('--subject 가 필요합니다.'); process.exit(1) }
273
702
  if (!body.trim()) { console.error('본문이 비었습니다 (stdin 으로 주세요).'); process.exit(1) }
274
703
  // 🔴 쪽지함이 없으면 **쓰지 않고 거부한다.**
@@ -286,12 +715,55 @@ function post({ to, subject, body, replyTo = null }) {
286
715
  )
287
716
  process.exit(1)
288
717
  }
718
+ // 🔴 **모르는 주소로는 안 보낸다.**
719
+ //
720
+ // 지금까지는 받는 사람 이름을 아무도 검사하지 않았다. 오타 하나면 그 쪽지는
721
+ // 아무에게도 안 가는데 보낸 쪽은 성공을 본다 — 실측에서 실제로 1통이
722
+ // `ahwlstjd` (뒤의 `57` 이 빠진 이름)로 갔고 13분 뒤에야 발견됐다.
723
+ //
724
+ // 거부하는 쪽을 고른 이유는 이 저장소가 정한 것 그대로다 — 잘못 막으면
725
+ // 사람이 메시지를 읽고 고치면 되지만, 조용히 보내면 아무도 모른다.
726
+ // 다만 **처음 오는 사람**(커밋도 쪽지도 아직 없는 사람)은 오타와 생김새가
727
+ // 같으므로 빠져나갈 문을 둔다 — `--force`. 문이 없으면 사람은 시스템 밖으로
728
+ // 나가고, 그때는 흔적도 안 남는다.
729
+ for (const one of (has('--force') ? [] : tos)) {
730
+ if (norm(one) === 'all') continue
731
+ const known = roster().some((p) => p.email === norm(one) || p.names.has(norm(one)))
732
+ if (!known) {
733
+ const near = nearMatches(one)
734
+ if (near.length) {
735
+ // 🔴 **거부하는 것은 오타뿐이다.** 아는 이름과 한 글자 차이거나 그 이름의
736
+ // 앞부분이면 새 사람이 아니라 손이 미끄러진 것이다. 실측에서 실제로
737
+ // `ahwlstjd` 로 갔다 — 뒤의 `57` 이 빠졌고, 13분 뒤에야 발견됐다.
738
+ console.error(
739
+ `받는 사람을 찾지 못했습니다: ${one}\n` +
740
+ ` 혹시 이것입니까? ${near.join(' · ')}\n\n` +
741
+ ' 이 이름 그대로 보내려면: --force',
742
+ )
743
+ process.exit(1)
744
+ }
745
+ // 🔴 **모르는 사람이라고 막지는 않는다.** 아직 커밋이 없는 사람이 실제로
746
+ // 있다 — 팀에 새로 온 사람에게 보내는 첫 쪽지가 정확히 그 모양이다.
747
+ // 여기서 막으면 정상적인 첫 연락마다 `--force` 를 치게 되고, 그러면
748
+ // 사람은 그것을 반사적으로 붙이게 되어 **오타 검사가 아무것도 못 잡는다.**
749
+ // 대신 보내는 순간 눈에 띄게 말한다 — 조용히 보내는 것과는 다르다.
750
+ console.error(`※ 이 저장소에서 처음 보는 이름입니다: ${one} (그래도 보냅니다)`)
751
+ }
752
+ }
753
+
289
754
  fs.mkdirSync(BOX, { recursive: true })
290
755
  const now = new Date()
291
756
  const id = `${stamp(now)}-${from}-${slug(subject)}`
757
+ // 🔴 `from` 은 **이름 그대로** 둔다. 파일 이름에 들어가는 값이라 바꾸면 예전
758
+ // 쪽지와 형식이 갈린다. 대신 이메일을 한 줄 더 적는다 — 이름이 나중에
759
+ // 바뀌어도 **누구였는지는 남는다.** 이것이 명단을 세우는 재료가 된다.
760
+ const fromEmail = myEmail()
292
761
  const head = [
293
762
  `from: ${from}`,
294
- `to: ${to ?? 'all'}`,
763
+ fromEmail ? `fromEmail: ${fromEmail}` : null,
764
+ // 여럿이면 쉼표로 잇는다. 하나면 예전 쪽지와 글자 그대로 같은 모양이 된다 —
765
+ // 형식을 새로 만들지 않으므로 옛 쪽지도 새 코드가 그대로 읽는다.
766
+ `to: ${tos.join(', ')}`,
295
767
  `at: ${now.toISOString()}`,
296
768
  `subject: ${subject}`,
297
769
  replyTo ? `replyTo: ${replyTo}` : null,
@@ -393,8 +865,18 @@ function list() {
393
865
  const all = readAll()
394
866
  const to = has('--mine') ? me() : flag('--to')
395
867
  const from = flag('--from')
396
- let rows = all.filter((m) => (has('--all') || !to || m.to === to || m.to === 'all')
397
- && (!from || m.from === from))
868
+
869
+ // 🔴 **한 사람에게는 주소가 여럿이다.** `--to yeaseung-lee` 로 물어도
870
+ // `이예승` 앞으로 온 쪽지가 나와야 한다 — 같은 사람이기 때문이다.
871
+ // 이름 하나로만 맞춰 보던 것이 쪽지를 묻은 원인이었다.
872
+ //
873
+ // `--mine` 일 때는 내 이메일·git 이름·AXMAP_AGENT 를 전부 나로 친다.
874
+ const toSet = to ? (has('--mine') ? myAddresses() : addressesOf(to)) : null
875
+ const fromSet = from ? addressesOf(from) : null
876
+
877
+ let rows = all.filter((m) => (has('--all') || !toSet
878
+ || recipientsOf(m).some((r) => r === 'all' || toSet.has(r)))
879
+ && (!fromSet || fromSet.has(norm(m.from)) || (m.fromEmail && fromSet.has(norm(m.fromEmail)))))
398
880
 
399
881
  // 🔴 `--unread` 는 자기 앞으로 온 것을 가릴 때만 뜻이 있다. 받는 사람이
400
882
  // 정해지지 않았는데 "안 읽음" 을 말하면 누구의 읽음인지가 없다.
@@ -416,10 +898,33 @@ function list() {
416
898
  //
417
899
  // 숫자가 둘이면 사람은 어느 쪽도 안 믿는다. 그 순간 알림은 배경이 되고,
418
900
  // 배경이 된 알림은 진짜 쪽지가 왔을 때도 안 읽힌다.
419
- rows = rows.filter((m) => m.id > seen && m.from !== who)
901
+ // 🔴 `m.from !== who` 였다. 이름이 하나일 때만 맞는 비교다 — 내가 `이예승`
902
+ // 으로 커밋하고 `yeaseung-lee` 로 쪽지를 보냈으면, 내가 보낸 전체공지가
903
+ // **내 안 읽은 쪽지로 다시 잡힌다.** 주소 전부와 견준다.
904
+ const mine = has('--mine') ? myAddresses() : addressesOf(who)
905
+ rows = rows.filter((m) => m.id > seen
906
+ && !mine.has(norm(m.from)) && !(m.fromEmail && mine.has(norm(m.fromEmail))))
420
907
  }
421
908
 
422
- if (!rows.length) return has('--quiet-if-empty') ? undefined : console.log('쪽지 없음.')
909
+ // 🔴 **못 읽었으면 "없음" 이라고 답하지 않는다.** 이 세 줄이 이 파일에서 제일
910
+ // 중요하다. 나머지는 편의고 이것만이 사고를 막는다.
911
+ //
912
+ // 종료 코드를 0 이 아닌 것으로 낸다 — MCP 서버(`mcp/server.mjs`)는 이미
913
+ // `ok: r.code === 0` 으로 갈라 "쪽지함을 읽지 못했습니다" 를 따로 내도록
914
+ // 되어 있었다. 읽기가 그 신호를 **한 번도 낸 적이 없었을** 뿐이다.
915
+ //
916
+ // `--quiet-if-empty`(훅·배너)일 때만 0 으로 끝낸다. 훅에서 0 이 아니면
917
+ // 커밋이 막히는데, 쪽지를 못 읽은 것으로 커밋을 막는 것은 과하다.
918
+ // **다만 말은 한다** — stderr 는 그 경우에도 그대로 나간다.
919
+ if (busProblem) {
920
+ reportBusProblem()
921
+ if (!has('--quiet-if-empty')) process.exit(1)
922
+ return
923
+ }
924
+ if (!rows.length) {
925
+ if (readFromLegacyOnly) console.error('※ 옛 쪽지함(docs/bus)만 읽었습니다 — 새 쪽지함은 비어 있습니다.')
926
+ return has('--quiet-if-empty') ? undefined : console.log('쪽지 없음.')
927
+ }
423
928
  for (const m of rows) {
424
929
  console.log(`${m.at?.slice(0, 16).replace('T', ' ')} ${(m.from ?? '?').padEnd(18)} → ${(m.to ?? 'all').padEnd(18)} ${m.subject ?? ''}`)
425
930
  console.log(` ${m.id}`)
@@ -444,6 +949,9 @@ function list() {
444
949
  function read(id) {
445
950
  pull()
446
951
  const m = readAll().find((x) => x.id === id || x.id.includes(id))
952
+ // 🔴 못 열었으면 "없는 쪽지" 라고 말하지 않는다. 목록과 같은 이유다 — 사람은
953
+ // 아이디를 잘못 적었다고 믿고 다시 치게 되는데, 몇 번을 쳐도 같은 답이 온다.
954
+ if (!m && busProblem) { reportBusProblem(); process.exit(1) }
447
955
  if (!m) { console.error(`없는 쪽지: ${id}`); process.exit(1) }
448
956
  console.log(`── ${m.subject}\n ${m.from} → ${m.to} ${m.at}\n`)
449
957
  console.log(m.body)
@@ -488,10 +996,14 @@ function seen() {
488
996
  }
489
997
 
490
998
  switch (cmd) {
491
- case 'post': post({ to: flag('--to'), subject: flag('--subject'), body: stdin() }); break
999
+ case 'post': post({ to: recipientArgs(), subject: flag('--subject'), body: stdin() }); break
492
1000
  case 'reply': {
493
1001
  const target = args[1]
1002
+ // 🔴 답장도 먼저 받아온다. 예전에는 받아오지 않고 찾아서, 원격에만 있는 쪽지에
1003
+ // 답장하면 **"없는 쪽지"** 가 나왔다. 아이디를 잘못 적은 것과 구별이 안 된다.
1004
+ pull()
494
1005
  const src = readAll().find((x) => x.id === target || x.id.includes(target))
1006
+ if (!src && busProblem) { reportBusProblem(); process.exit(1) }
495
1007
  if (!src) { console.error(`없는 쪽지: ${target}`); process.exit(1) }
496
1008
  post({ to: src.from, subject: flag('--subject') ?? `Re: ${src.subject}`, body: stdin(), replyTo: src.id })
497
1009
  break
@@ -504,6 +1016,8 @@ switch (cmd) {
504
1016
  console.log(`에이전트 쪽지함
505
1017
 
506
1018
  ${selfCmd()} post --to <상대> --subject "<제목>" < 본문.md
1019
+ --to 를 여러 번 주거나 "a,b" 로 여럿에게 한 통을 보냅니다
1020
+ --to 를 아예 안 주면 전체(all)에게 갑니다
507
1021
  ${selfCmd()} list [--to <나>|--mine] [--from <상대>] [--all]
508
1022
  [--unread] [--no-mark] [--throttle <초>] [--quiet-if-empty]
509
1023
  ${selfCmd()} read <아이디>
@@ -1,32 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(sed -i 's|/\\\\*\\\\* 궤도 모드에서 허용하는 yaw 한계 \\(약 35°\\). cos 이 0.82 아래로 내려가지 않는다. \\\\*/\\\\nconst YAW_LIMIT = 0.62|X|' app/web/graph.js)",
5
- "Bash(sed -i 's|const YAW_LIMIT = 0.62|const TILT_LIMIT = 0.62|; s|허용하는 yaw 한계|허용하는 기울기 한계 \\(yaw · pitch 공통\\)|' app/web/graph.js)",
6
- "Bash(sed -i 's|인코딩은 인코딩이 아니다. cos\\(TILT_LIMIT\\)=0.82 이면 고리의 화면 반지름이\\\\n \\\\* \\\\[0.82R, R\\\\] 안에 머물러 이웃 단계와 절대 겹치지 않는다.|X|' app/web/graph.js)",
7
- "Bash(sed -n '/인코딩은 인코딩이 아니다/,+2p' app/web/graph.js)",
8
- "Bash(sed -i 's|import { applyEdits, draft, editRecord } from|import { applyEdits, draft } from|' app/server.mjs)",
9
- "Bash(sed -i 's|\"test\": \"node --test test/protocol.test.mjs test/model.test.mjs test/analyze.test.mjs\"|\"test\": \"node --test test/protocol.test.mjs test/model.test.mjs test/analyze.test.mjs test/features.test.mjs\"|' package.json)",
10
- "Bash(npm test *)",
11
- "Bash(sed -i 's| .filter\\(\\(\\\\[t\\\\]\\) => t !== name \\\\&\\\\& !taken.has\\(t\\)\\)| .filter\\(\\([t]\\) => t !== name \\\\&\\\\& !taken.has\\(t\\) \\\\&\\\\& !tooCommon\\(t\\)\\)|' app/lib/features.mjs)",
12
- "Bash(node -e ' *)",
13
- "Bash(npm run *)",
14
- "Bash(export AXMAP_AGENT=claude-fix GIT_AUTHOR_NAME=claude-fix GIT_AUTHOR_EMAIL=claude-fix@local GIT_COMMITTER_NAME=claude-fix GIT_COMMITTER_EMAIL=claude-fix@local)",
15
- "Bash(node bin/axmap.mjs claim app/web/app.js app/web/graph.js app/web/style.css --task fix-click-color --intent \"이름 클릭=강조/더블클릭=이름변경, 단계 없을 때 색 회귀 수정\" --actor agent)",
16
- "Bash(sed 's/\\\\$$//')",
17
- "Bash(python -)",
18
- "Bash(sed -n '/import 를 뽑을 수 있는 언어 이름/,/^\\\\]\\)/p' app/lib/analyze.mjs)",
19
- "Bash(export AXMAP_AGENT=janghyojoon)",
20
- "Bash(node bin/axmap.mjs release)",
21
- "Bash(echo \"// 테스트\")",
22
- "Bash(git add *)",
23
- "Bash(git commit *)",
24
- "Bash(git reset *)",
25
- "Bash(git checkout *)"
26
- ]
27
- },
28
- "enabledMcpjsonServers": [
29
- "axmap"
30
- ],
31
- "enableAllProjectMcpServers": true
32
- }