axmap-cli 1.0.1 → 1.1.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/tools/bus.mjs CHANGED
@@ -235,6 +235,30 @@ const cmd = args[0]
235
235
  const flag = (n, d = null) => { const i = args.indexOf(n); return i < 0 ? d : args[i + 1] }
236
236
  const has = (n) => args.includes(n)
237
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
+
238
262
  /**
239
263
  * 나는 누구인가. 선점 프로토콜과 **같은 값**을 쓴다 — 두 이름을 두면 갈린다.
240
264
  *
@@ -267,6 +291,186 @@ function me() {
267
291
  process.exit(1)
268
292
  }
269
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
+
270
474
  /**
271
475
  * 읽음 표시 — 규격은 `docs/SPEC.md` §2「읽음 표시」다. 여기와 `bin/axmap.mjs`
272
476
  * 의 `unreadNotes` 가 **같은 파일을 같은 규칙으로** 본다. 규칙이 한 줄이라
@@ -275,11 +479,94 @@ function me() {
275
479
  * 🔴 장부에 넣지 않는다. 읽었는지는 나만의 상태라 남과 합의할 필요가 없고,
276
480
  * 장부에 쓰면 쪽지를 볼 때마다 push 경합이 생긴다.
277
481
  */
482
+ /**
483
+ * 옛 자리 — **이 PC 안의 파일 하나.** 읽기만 한다. 새 자리로 옮기는 재료다.
484
+ *
485
+ * 🔴 여기 있었기 때문에 **PC 를 바꾸면 전부 다시 안 읽음**이 됐다. 쪽지 본문은
486
+ * 고아 브랜치로 모두가 공유하는데, 읽었다는 사실만 그 PC 에 갇혀 있었다.
487
+ * 다시 clone 하면 사라지고, 같은 사람이 도구를 둘 띄우면 각자 다른 상태를 봤다.
488
+ */
278
489
  const SEEN_FILE = REPO ? path.join(REPO, '.axmap-bus-seen.json') : null
279
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
+
280
557
  function readSeen(who) {
281
- if (!SEEN_FILE) return ''
282
- 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
283
570
  }
284
571
 
285
572
  /**
@@ -291,14 +578,56 @@ function readSeen(who) {
291
578
  * 한쪽이 질 수 있는데, 지는 쪽의 손해는 "한 번 더 뜬다" 뿐이라 잠그지 않는다.
292
579
  */
293
580
  function markSeen(who, id) {
294
- 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
295
596
  try {
296
- let all = {}
297
- try { all = JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8')) } catch { /* 처음이다 */ }
298
- if ((all[who] ?? '') >= id) return // 뒤로 가지 않는다
299
- all[who] = id
300
- fs.writeFileSync(SEEN_FILE, JSON.stringify(all, null, 2) + '\n')
301
- } 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 { /* 조용히 — 못 찍으면 다음에 한 번 더 뜰 뿐이다 */ }
302
631
  }
303
632
 
304
633
  const stamp = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
@@ -351,6 +680,24 @@ function stdin() {
351
680
 
352
681
  function post({ to, subject, body, replyTo = null }) {
353
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
+ }
354
701
  if (!subject) { console.error('--subject 가 필요합니다.'); process.exit(1) }
355
702
  if (!body.trim()) { console.error('본문이 비었습니다 (stdin 으로 주세요).'); process.exit(1) }
356
703
  // 🔴 쪽지함이 없으면 **쓰지 않고 거부한다.**
@@ -368,12 +715,55 @@ function post({ to, subject, body, replyTo = null }) {
368
715
  )
369
716
  process.exit(1)
370
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
+
371
754
  fs.mkdirSync(BOX, { recursive: true })
372
755
  const now = new Date()
373
756
  const id = `${stamp(now)}-${from}-${slug(subject)}`
757
+ // 🔴 `from` 은 **이름 그대로** 둔다. 파일 이름에 들어가는 값이라 바꾸면 예전
758
+ // 쪽지와 형식이 갈린다. 대신 이메일을 한 줄 더 적는다 — 이름이 나중에
759
+ // 바뀌어도 **누구였는지는 남는다.** 이것이 명단을 세우는 재료가 된다.
760
+ const fromEmail = myEmail()
374
761
  const head = [
375
762
  `from: ${from}`,
376
- `to: ${to ?? 'all'}`,
763
+ fromEmail ? `fromEmail: ${fromEmail}` : null,
764
+ // 여럿이면 쉼표로 잇는다. 하나면 예전 쪽지와 글자 그대로 같은 모양이 된다 —
765
+ // 형식을 새로 만들지 않으므로 옛 쪽지도 새 코드가 그대로 읽는다.
766
+ `to: ${tos.join(', ')}`,
377
767
  `at: ${now.toISOString()}`,
378
768
  `subject: ${subject}`,
379
769
  replyTo ? `replyTo: ${replyTo}` : null,
@@ -475,8 +865,18 @@ function list() {
475
865
  const all = readAll()
476
866
  const to = has('--mine') ? me() : flag('--to')
477
867
  const from = flag('--from')
478
- let rows = all.filter((m) => (has('--all') || !to || m.to === to || m.to === 'all')
479
- && (!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)))))
480
880
 
481
881
  // 🔴 `--unread` 는 자기 앞으로 온 것을 가릴 때만 뜻이 있다. 받는 사람이
482
882
  // 정해지지 않았는데 "안 읽음" 을 말하면 누구의 읽음인지가 없다.
@@ -498,7 +898,12 @@ function list() {
498
898
  //
499
899
  // 숫자가 둘이면 사람은 어느 쪽도 안 믿는다. 그 순간 알림은 배경이 되고,
500
900
  // 배경이 된 알림은 진짜 쪽지가 왔을 때도 안 읽힌다.
501
- 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))))
502
907
  }
503
908
 
504
909
  // 🔴 **못 읽었으면 "없음" 이라고 답하지 않는다.** 이 세 줄이 이 파일에서 제일
@@ -591,7 +996,7 @@ function seen() {
591
996
  }
592
997
 
593
998
  switch (cmd) {
594
- case 'post': post({ to: flag('--to'), subject: flag('--subject'), body: stdin() }); break
999
+ case 'post': post({ to: recipientArgs(), subject: flag('--subject'), body: stdin() }); break
595
1000
  case 'reply': {
596
1001
  const target = args[1]
597
1002
  // 🔴 답장도 먼저 받아온다. 예전에는 받아오지 않고 찾아서, 원격에만 있는 쪽지에
@@ -611,6 +1016,8 @@ switch (cmd) {
611
1016
  console.log(`에이전트 쪽지함
612
1017
 
613
1018
  ${selfCmd()} post --to <상대> --subject "<제목>" < 본문.md
1019
+ --to 를 여러 번 주거나 "a,b" 로 여럿에게 한 통을 보냅니다
1020
+ --to 를 아예 안 주면 전체(all)에게 갑니다
614
1021
  ${selfCmd()} list [--to <나>|--mine] [--from <상대>] [--all]
615
1022
  [--unread] [--no-mark] [--throttle <초>] [--quiet-if-empty]
616
1023
  ${selfCmd()} read <아이디>