axmap-cli 0.0.1

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.
Files changed (78) hide show
  1. package/.claude/commands/ax-done.md +10 -0
  2. package/.claude/commands/ax-setup.md +31 -0
  3. package/.claude/commands/ax-start.md +15 -0
  4. package/.claude/commands/ax-tell.md +14 -0
  5. package/.claude/commands/ax-update.md +19 -0
  6. package/.claude/commands/ax.md +13 -0
  7. package/CLAUDE.md +309 -0
  8. package/LICENSE +20 -0
  9. package/README.md +207 -0
  10. package/app/README.md +366 -0
  11. package/app/eval/edges.mjs +242 -0
  12. package/app/lib/adjacent.mjs +125 -0
  13. package/app/lib/agentcli.mjs +153 -0
  14. package/app/lib/analyze.mjs +1159 -0
  15. package/app/lib/cochange.mjs +421 -0
  16. package/app/lib/datanodes.mjs +127 -0
  17. package/app/lib/entry.mjs +192 -0
  18. package/app/lib/featuregraph.mjs +389 -0
  19. package/app/lib/features.mjs +645 -0
  20. package/app/lib/fetchrepo-run.mjs +37 -0
  21. package/app/lib/fetchrepo.mjs +164 -0
  22. package/app/lib/flow.mjs +1089 -0
  23. package/app/lib/ladder.mjs +387 -0
  24. package/app/lib/langs.mjs +630 -0
  25. package/app/lib/live.mjs +346 -0
  26. package/app/lib/llm.mjs +594 -0
  27. package/app/lib/newfile.mjs +126 -0
  28. package/app/lib/prdiff.mjs +651 -0
  29. package/app/lib/reveal.mjs +316 -0
  30. package/app/lib/roots.mjs +186 -0
  31. package/app/lib/scope.mjs +342 -0
  32. package/app/lib/session.mjs +389 -0
  33. package/app/lib/slots.mjs +233 -0
  34. package/app/lib/ssot.mjs +277 -0
  35. package/app/lib/teamview.mjs +962 -0
  36. package/app/lib/terms.ko.mjs +169 -0
  37. package/app/server.mjs +1959 -0
  38. package/app/web/shell.css +538 -0
  39. package/app/web/shell.html +197 -0
  40. package/app/web/shell.js +638 -0
  41. package/app/web/stage.js +347 -0
  42. package/app/web/words.js +85 -0
  43. package/bin/axmap.mjs +1918 -0
  44. package/governance/GOVERNANCE.md +433 -0
  45. package/governance/gate.mjs +526 -0
  46. package/governance/vote.mjs +501 -0
  47. package/mcp/README.md +254 -0
  48. package/mcp/SETUP-FOR-AI.md +186 -0
  49. package/mcp/install.ps1 +341 -0
  50. package/mcp/install.sh +339 -0
  51. package/mcp/server.mjs +969 -0
  52. package/package.json +48 -0
  53. package/src/closure.mjs +343 -0
  54. package/src/governance.mjs +839 -0
  55. package/src/invariants.mjs +226 -0
  56. package/src/mrtarget.mjs +284 -0
  57. package/src/promote.mjs +177 -0
  58. package/src/protocol.mjs +423 -0
  59. package/src/repotarget.mjs +81 -0
  60. package/src/update.mjs +177 -0
  61. package/src/version.mjs +186 -0
  62. package/tools/bus.mjs +520 -0
  63. package/tools/cluster-experiment.mjs +256 -0
  64. package/tools/cluster-sweep.mjs +226 -0
  65. package/tools/make-icon.mjs +108 -0
  66. package/tools/mcp-register.mjs +269 -0
  67. package/tools/mr-target.mjs +49 -0
  68. package/tools/persona-bench.mjs +362 -0
  69. package/tools/pick-repo.mjs +229 -0
  70. package/tools/promote.mjs +550 -0
  71. package/tools/reveal-demo.mjs +158 -0
  72. package/tools/run-tests.mjs +42 -0
  73. package/tools/setup.mjs +490 -0
  74. package/tools/shortcut.mjs +121 -0
  75. package/tools/smoke.mjs +166 -0
  76. package/tools/topicgraph.py +154 -0
  77. package/tools/vendor.mjs +382 -0
  78. package/tools/version.mjs +115 -0
package/tools/bus.mjs ADDED
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 에이전트 사이의 쪽지함 — 사람을 거치지 않고 서로에게 말한다.
4
+ *
5
+ * node tools/bus.mjs post --to <상대> --subject "<제목>" (본문은 stdin)
6
+ * node tools/bus.mjs list [--to <나>|--mine] [--from <상대>] [--all]
7
+ * [--throttle <초>] [--quiet-if-empty] ← 훅용
8
+ * node tools/bus.mjs read <아이디>
9
+ * node tools/bus.mjs reply <아이디> --subject "<제목>" (본문은 stdin)
10
+ *
11
+ * ── 왜 파일 하나가 아니라 **디렉터리**인가 ────────────────────────────────
12
+ *
13
+ * 🔴 쪽지 하나 = 파일 하나. 그래야 두 에이전트가 동시에 써도 안 부딪힌다.
14
+ *
15
+ * 처음엔 append-only 로그 파일 하나를 생각했다. 그런데 그건 우리가 이미
16
+ * 아는 실패다 — 두 사람이 같은 파일 끝에 줄을 붙이면 git 이 텍스트 충돌을
17
+ * 낸다. 이 저장소가 장부를 파서로 만든 이유가 정확히 그것이다
18
+ * (docs/EXPERIMENT.md). 쪽지함에서 같은 실수를 반복하지 않는다.
19
+ *
20
+ * 파일 이름에 시각과 보낸 사람이 들어가므로 두 사람이 같은 이름을 쓸 일이 없다.
21
+ * 충돌이 구조적으로 불가능하면 조율도 필요 없다.
22
+ *
23
+ * ── 왜 git 인가 ──────────────────────────────────────────────────────────
24
+ *
25
+ * 이미 있는 채널이다. 두 에이전트가 다른 컴퓨터에 있어도 pull/push 로 오간다.
26
+ * 새 서버도, 새 의존성도, 새 인증도 필요 없다. 그리고 **기록이 남는다** —
27
+ * 나중에 "왜 이렇게 정했나" 를 되짚을 수 있다.
28
+ *
29
+ * ⚠️ 실시간이 아니다. 상대가 pull 해야 읽는다. 급한 것은 claim 의 `--intent`
30
+ * 에 한 줄로 적는 편이 빠르다 — 그건 `axmap status` 로 바로 보인다.
31
+ */
32
+
33
+ import { execFileSync } from 'node:child_process'
34
+ import fs from 'node:fs'
35
+ import path from 'node:path'
36
+ import { fileURLToPath } from 'node:url'
37
+
38
+ /** **이 프로그램**의 뿌리. 데이터가 아니라 코드를 찾을 때만 쓴다 (`who` 의 axmap.mjs). */
39
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
40
+
41
+ /** 지금 서 있는 폴더가 속한 **대상 저장소**의 루트. git 이 없거나 밖이면 null. */
42
+ function targetRepo() {
43
+ try {
44
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
45
+ cwd: process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
46
+ }).trim()
47
+ } catch { return null }
48
+ }
49
+
50
+ /**
51
+ * 쪽지가 쌓이는 곳. **기본값은 대상 저장소이고 `AXMAP_BUS_DIR` 로 옮길 수 있다.**
52
+ *
53
+ * 🔴 프로그램은 여기(axMap)에 있고 데이터는 대상 저장소에 있어야 한다.
54
+ * MCP 서버가 남의 저장소에 붙었을 때 이 값을 `<대상>/docs/bus` 로 넘긴다.
55
+ * 안 그러면 그 팀의 쪽지가 axMap 저장소에 쌓이고, 정작 팀의 저장소에는
56
+ * 아무것도 안 남는다 — 쪽지는 커밋해야 상대에게 가는데 커밋할 저장소가
57
+ * 엉뚱한 곳이 되는 것이다.
58
+ *
59
+ * 🔴 예전 기본값은 `ROOT/docs/bus` 였다. **사본에서 조용히 틀린다** — 팀 저장소의
60
+ * `ci/axmap/tools/bus.mjs` 에서 `ROOT` 는 `ci/axmap` 이므로 없는 폴더를 가리키고,
61
+ * `readdirSync` 가 던지면 `readAll` 이 빈 배열을 낸다. 즉 **쪽지가 있어도
62
+ * "쪽지 없음" 이라고 답한다.** 2026-08-26 에 팀 저장소에서 실제로 그랬다.
63
+ * 프로그램 위치로 데이터를 찾은 것이 원인이므로 이제 대상 저장소에게 묻는다.
64
+ *
65
+ * 못 찾으면 `ROOT` 로 되돌리지 않는다. 그건 서로 다른 두 상황(대상 저장소 안 /
66
+ * 엉뚱한 곳)을 한 값으로 만드는 치환이고, 위 사고가 정확히 그 치환이었다.
67
+ *
68
+ * 🔴 그리고 이제 그 자리는 `docs/bus` 가 **아니다.** 고아 브랜치 `axmap/bus` 의
69
+ * worktree(`.axmap/bus/messages`)다. 작업 트리에 두면 상대에게 가는 데
70
+ * 커밋 -> MR -> 머지 -> pull 이 필요했고, 보내려면 `docs/bus` 를 claim 해야 해서
71
+ * 한 번에 한 명만 쪽지를 보낼 수 있었다. 장부가 그 문제를 이미 안 겪으므로
72
+ * 같은 방식으로 옮긴다. 근거는 `bin/axmap.mjs` 의 `BUS_BRANCH` 주석.
73
+ */
74
+ const REPO = (() => {
75
+ if (process.env.AXMAP_BUS_DIR) return null // 대상이 명시됐으면 저장소를 물을 필요가 없다
76
+ const repo = targetRepo()
77
+ if (!repo) {
78
+ console.error(
79
+ '쪽지함을 찾지 못했습니다 — 여기는 git 저장소 안이 아닙니다.\n' +
80
+ '대상 저장소 안에서 실행하거나 AXMAP_BUS_DIR 로 쪽지함을 직접 지정하세요.',
81
+ )
82
+ process.exit(1)
83
+ }
84
+ return repo
85
+ })()
86
+
87
+ /** 쪽지 worktree 의 루트. 커밋·push 는 여기서 돈다. */
88
+ const BUS_WT = REPO ? path.join(REPO, '.axmap', 'bus') : null
89
+ const BUS_BRANCH = 'axmap/bus'
90
+
91
+ /** **쓰는 곳은 하나뿐이다.** */
92
+ const BOX = process.env.AXMAP_BUS_DIR
93
+ ? path.resolve(process.env.AXMAP_BUS_DIR)
94
+ : path.join(BUS_WT, 'messages')
95
+
96
+ /**
97
+ * **읽는 곳은 둘이다.** 옛 쪽지함(`docs/bus`)은 읽기만 한다.
98
+ *
99
+ * 옮기지 않는 이유: 옮기면 같은 내용이 두 브랜치에 남고 어느 쪽이 진짜인지
100
+ * 아무도 모른다. 새것은 전부 고아 브랜치로 가므로 옛것은 자연히 마른다.
101
+ */
102
+ const READ_BOXES = [BOX, ...(REPO ? [path.join(REPO, 'docs', 'bus')] : [])]
103
+
104
+ function gitBus(argv, opts = {}) {
105
+ try {
106
+ return {
107
+ code: 0,
108
+ out: execFileSync('git', argv, {
109
+ cwd: BUS_WT, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], ...opts,
110
+ }).trim(),
111
+ }
112
+ } catch (e) { return { code: e.status ?? 1, out: '', err: String(e.stderr ?? e.message) } }
113
+ }
114
+
115
+ const busReady = () => BUS_WT !== null && fs.existsSync(path.join(BUS_WT, '.git'))
116
+
117
+ /**
118
+ * 원격의 쪽지를 받아온다. **실패해도 죽지 않는다** — 못 받은 것은 위험이 아니라
119
+ * 지연이다. 장부(`syncLedger`)가 같은 자리에서 죽는 것과 정반대이고, 그 차이의
120
+ * 근거는 `bin/axmap.mjs` 의 `BUS_BRANCH` 주석에 있다.
121
+ *
122
+ * 🔴 `--throttle <초>` 는 **훅 때문에 생겼다.** MCP 서버는 세션 내내 살아 있어서
123
+ * `unreadBanner` 가 프로세스 안의 변수(`busCheckedAt`)로 15초를 잴 수 있었다.
124
+ * 훅은 부를 때마다 **새 프로세스**라 그 변수가 매번 0에서 시작한다 — 즉 캐시가
125
+ * 절대 안 맞고 훅이 뜰 때마다 `git fetch` 가 돈다. 그래서 시계를 프로세스
126
+ * 밖(스탬프 파일)에 둔다. 캐시를 그대로 옮겨 붙이면 조용히 원격을 두들긴다.
127
+ *
128
+ * 스탬프는 `.axmap/.bus-lastfetch` 다. `.axmap/` 는 통째로 무시되고(.gitignore),
129
+ * 쪽지 worktree(`.axmap/bus`) **바깥**이라 고아 브랜치에 섞이지 않는다.
130
+ */
131
+ function pull() {
132
+ if (!busReady()) return
133
+ // 🔴 주기는 **설정에서 온다.** `--throttle` 이 1순위(부르는 쪽이 그 자리에서
134
+ // 정한다), 없으면 `AXMAP_BUS_POLL`(초), 그것도 없으면 0 = 매번 받아온다.
135
+ //
136
+ // 기본이 0 인 것은 이 파일을 **사람이 직접 부르는 쪽**이 기준이기 때문이다.
137
+ // 손으로 `list` 를 친 사람은 지금 이 순간의 쪽지를 보려는 것이므로 캐시를
138
+ // 쥐여주면 안 된다. 자동으로 자주 부르는 쪽(MCP 배너·훅)이 자기 창을
139
+ // 명시한다 — 기본값을 늘리면 손으로 부른 사람까지 조용히 낡은 것을 본다.
140
+ const sec = Number(flag('--throttle', process.env.AXMAP_BUS_POLL ?? '0'))
141
+ const stampFile = REPO ? path.join(REPO, '.axmap', '.bus-lastfetch') : null
142
+ if (sec > 0 && stampFile) {
143
+ // 스탬프가 아직 신선하면 원격을 묻지 않고 **로컬 worktree 만** 읽는다.
144
+ // 이미 받아둔 쪽지는 그대로 보인다 — 늦는 것은 새로 온 쪽지뿐이다.
145
+ try {
146
+ if (Date.now() - fs.statSync(stampFile).mtimeMs < sec * 1000) return
147
+ } catch { /* 스탬프가 없으면 이번이 처음이다. 받아온다 */ }
148
+ }
149
+ const remote = gitBus(['config', '--get', 'axmap.remote'], { cwd: REPO }).out || 'origin'
150
+ if (gitBus(['fetch', '--quiet', remote, BUS_BRANCH]).code !== 0) return
151
+ gitBus(['reset', '--hard', '--quiet', 'FETCH_HEAD'])
152
+ // 🔴 성공했을 때만 찍는다. 실패에도 찍으면 원격이 잠깐 막힌 사이에 스탬프가
153
+ // 갱신되어 **다음 창까지 조용히 안 받는다.** 못 받은 것은 지연이지 성공이 아니다.
154
+ if (stampFile) { try { fs.writeFileSync(stampFile, '') } catch { /* 스탬프 실패는 치명적이지 않다 */ } }
155
+ }
156
+
157
+ const args = process.argv.slice(2)
158
+ const cmd = args[0]
159
+ const flag = (n, d = null) => { const i = args.indexOf(n); return i < 0 ? d : args[i + 1] }
160
+ const has = (n) => args.includes(n)
161
+
162
+ /**
163
+ * 나는 누구인가. 선점 프로토콜과 **같은 값**을 쓴다 — 두 이름을 두면 갈린다.
164
+ *
165
+ * 🔴 MCP 서버는 `AXMAP_AGENT` 를 떨어뜨려 주지만 **훅은 그렇지 않다.** 훅은
166
+ * 하네스가 직접 띄우는 새 프로세스라 서버의 환경을 물려받지 않는다. 그래서
167
+ * 서버의 `resolveAgent()` 와 **같은 순서**로 되짚는다 — 환경변수가 없으면
168
+ * `git config user.name`. 두 곳이 다른 순서를 쓰면 같은 사람이 두 이름을
169
+ * 갖게 되고, 그 순간 자기 앞으로 온 쪽지가 자기 함에 안 들어온다.
170
+ *
171
+ * 🔴 못 찾으면 기본 이름으로 채우지 않고 죽는다. 채우는 순간 clone 한 모두가
172
+ * 한 사람이 되고 쪽지함이 하나로 합쳐진다 (`mcp/server.mjs` 의 같은 판단).
173
+ */
174
+ function me() {
175
+ const v = process.env.AXMAP_AGENT
176
+ if (v && /^[\w.-]{1,64}$/.test(v)) return v
177
+ try {
178
+ const n = execFileSync('git', ['config', 'user.name'], {
179
+ cwd: REPO ?? process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
180
+ }).trim()
181
+ // 서버와 달리 여기서는 형식을 본다. 통과 못 하면 아래에서 죽는다 —
182
+ // 못 읽는 이름을 그냥 쓰면 파일 이름과 필터가 조용히 어긋난다.
183
+ if (n && /^[\w.-]{1,64}$/.test(n)) return n
184
+ } catch { /* 아래에서 죽는다 */ }
185
+ console.error(
186
+ 'AXMAP_AGENT 를 먼저 정하세요 (선점과 같은 값).\n' +
187
+ ' git config user.name 으로도 정하지 못했습니다.\n' +
188
+ ' 기본 이름으로 대신 채우지 않습니다 — 여러 사람이 같은 이름이 되면\n' +
189
+ ' 서로의 쪽지함이 하나로 합쳐집니다.',
190
+ )
191
+ process.exit(1)
192
+ }
193
+
194
+ /**
195
+ * 읽음 표시 — 규격은 `docs/SPEC.md` §2「읽음 표시」다. 여기와 `bin/axmap.mjs`
196
+ * 의 `unreadNotes` 가 **같은 파일을 같은 규칙으로** 본다. 규칙이 한 줄이라
197
+ * (`id > seen`) 공유 모듈로 빼지 않았지만, 한쪽을 고치면 반드시 다른 쪽도 본다.
198
+ *
199
+ * 🔴 장부에 넣지 않는다. 읽었는지는 나만의 상태라 남과 합의할 필요가 없고,
200
+ * 장부에 쓰면 쪽지를 볼 때마다 push 경합이 생긴다.
201
+ */
202
+ const SEEN_FILE = REPO ? path.join(REPO, '.axmap-bus-seen.json') : null
203
+
204
+ function readSeen(who) {
205
+ if (!SEEN_FILE) return ''
206
+ try { return JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8'))[who] ?? '' } catch { return '' }
207
+ }
208
+
209
+ /**
210
+ * 🔴 **조용히 실패한다.** 못 찍으면 다음에 같은 쪽지가 한 번 더 뜰 뿐이다.
211
+ * 여기서 죽으면 쪽지를 보려다 명령이 통째로 죽는다 — 그쪽이 훨씬 나쁘다.
212
+ *
213
+ * 🔴 읽고-고쳐-쓴다. 이 파일에는 여러 에이전트의 표시가 함께 들어 있어서
214
+ * 통째로 덮으면 남의 표시가 지워진다. 같은 사람의 두 세션이 동시에 쓰면
215
+ * 한쪽이 질 수 있는데, 지는 쪽의 손해는 "한 번 더 뜬다" 뿐이라 잠그지 않는다.
216
+ */
217
+ function markSeen(who, id) {
218
+ if (!SEEN_FILE || !who || !id) return
219
+ 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 { /* 조용히 */ }
226
+ }
227
+
228
+ const stamp = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
229
+ const slug = (s) => s.toLowerCase().replace(/[^\w가-힣]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'msg'
230
+
231
+ function readAll() {
232
+ const found = []
233
+ for (const box of READ_BOXES) {
234
+ let ns = []
235
+ try { ns = fs.readdirSync(box).filter((f) => f.endsWith('.md')) } catch { continue }
236
+ for (const f of ns) found.push({ box, f })
237
+ }
238
+ return found.map(({ box, f }) => {
239
+ const text = fs.readFileSync(path.join(box, f), 'utf8')
240
+ const head = {}
241
+ // 앞머리 `키: 값` 줄들. 빈 줄이 나오면 본문이 시작된다.
242
+ //
243
+ // 🔴 `split('\n')` 이 아니라 `\r?\n` 이다. 2026-08-26 에 여기서 물렸다.
244
+ //
245
+ // 쪽지가 고아 브랜치로 옮겨가면서 그 worktree 는 `.gitattributes` 가 닿지
246
+ // 않는 트리가 됐고, Windows(`core.autocrlf=true`)에서 CRLF 로 체크아웃됐다.
247
+ // 그러면 줄이 `from: alice\r` 이 되는데, **JS 정규식에서 `.` 은 `\r` 을
248
+ // 안 먹는다** (`\r` 도 줄바꿈 문자다). `m` 플래그도 없어 `$` 는 문자열
249
+ // 끝에서만 맞으므로 첫 줄부터 매치가 실패하고, 머리말이 통째로 빈 객체가
250
+ // 된다. 그 결과가 "받는 사람이 없는 쪽지" 라 **목록에서 조용히 사라졌다.**
251
+ //
252
+ // 브랜치에 `.gitattributes` 도 함께 심었지만(`ensureBus`), 그것에만
253
+ // 기대지 않는다. 데이터 포맷이 체크아웃 설정에 의존하면 그 설정이 닿지
254
+ // 않는 자리가 생길 때마다 같은 사고가 난다 — 오늘 이미 세 번 났다.
255
+ const lines = text.split(/\r?\n/)
256
+ let i = 0
257
+ for (; i < lines.length; i++) {
258
+ const m = lines[i].match(/^(\w+):\s*(.*)$/)
259
+ if (!m) break
260
+ head[m[1]] = m[2].trim()
261
+ }
262
+ return { id: f.replace(/\.md$/, ''), file: f, ...head, body: lines.slice(i).join('\n').trim() }
263
+ }).sort((a, b) => (a.id < b.id ? -1 : 1))
264
+ }
265
+
266
+ function stdin() {
267
+ try { return fs.readFileSync(0, 'utf8') } catch { return '' }
268
+ }
269
+
270
+ function post({ to, subject, body, replyTo = null }) {
271
+ const from = me()
272
+ if (!subject) { console.error('--subject 가 필요합니다.'); process.exit(1) }
273
+ if (!body.trim()) { console.error('본문이 비었습니다 (stdin 으로 주세요).'); process.exit(1) }
274
+ // 🔴 쪽지함이 없으면 **쓰지 않고 거부한다.**
275
+ //
276
+ // 그냥 쓰면 `.axmap/bus/messages` 가 worktree 아닌 맨 폴더로 생기고, 그러면
277
+ // 나중에 `ensureBus` 가 "정상적인 쪽지함이 아닙니다 — 지운 뒤 다시 하세요"
278
+ // 라고 안내한다. **안 간 쪽지를 지우라고 시키는 것**이다. 여기서 멈추면
279
+ // 사람은 init 한 번 하고 다시 보내면 된다 — 잃는 것이 없다.
280
+ if (!process.env.AXMAP_BUS_DIR && !busReady()) {
281
+ console.error(
282
+ '쪽지함이 아직 없습니다. 한 번만 준비하면 됩니다:\n' +
283
+ ' axmap init (MCP 에서는 ax_init)\n\n' +
284
+ '준비 전에 쪽지를 쓰지 않습니다 — 여기 남으면 아무에게도 안 가고,\n' +
285
+ '나중에 쪽지함을 만들 때 지워야 할 것으로 보입니다.',
286
+ )
287
+ process.exit(1)
288
+ }
289
+ fs.mkdirSync(BOX, { recursive: true })
290
+ const now = new Date()
291
+ const id = `${stamp(now)}-${from}-${slug(subject)}`
292
+ const head = [
293
+ `from: ${from}`,
294
+ `to: ${to ?? 'all'}`,
295
+ `at: ${now.toISOString()}`,
296
+ `subject: ${subject}`,
297
+ replyTo ? `replyTo: ${replyTo}` : null,
298
+ ].filter(Boolean).join('\n')
299
+ // 임시 파일에 다 쓴 뒤 rename 한다. 같은 디렉터리 안의 rename 은 원자적이라
300
+ // 읽는 쪽이 반쯤 쓰인 쪽지를 보는 일이 없다 — bin/axmap.mjs 의 writeFileAtomic 과
301
+ // 같은 이유다. 장부만큼 치명적이진 않지만(쪽지는 판정에 쓰이지 않는다) 같은 값이면
302
+ // 안전한 쪽으로 쓴다. 임시 이름이 `.md` 로 끝나지 않아 list 의 필터에도 안 걸린다.
303
+ const dst = path.join(BOX, `${id}.md`)
304
+ const tmp = `${dst}.tmp-${process.pid}`
305
+ try {
306
+ fs.writeFileSync(tmp, `${head}\n\n${body.trim()}\n`)
307
+ fs.renameSync(tmp, dst)
308
+ } catch (e) {
309
+ try {
310
+ fs.rmSync(tmp, { force: true })
311
+ } catch {
312
+ /* 무시 */
313
+ }
314
+ throw e
315
+ }
316
+ console.log(`보냄 ${id}`)
317
+ console.log(` 쪽지함: ${BOX}`)
318
+ console.log(` ${publish(id)}`)
319
+ }
320
+
321
+ /**
322
+ * 쪽지를 고아 브랜치에 실어 보낸다.
323
+ *
324
+ * 🔴 예전에는 여기서 아무것도 안 했다. 쪽지가 작업 트리(`docs/bus`)에 있어서
325
+ * "부르는 쪽이 자기 작업과 함께 올리게" 두는 것이 맞았다 — 여기서 커밋하면
326
+ * 남이 작업 중인 트리를 건드리기 때문이다. 이제 쪽지는 **자기 worktree** 에
327
+ * 있으므로 그 걱정이 사라졌고, 미루면 상대가 MR 한 사이클을 기다린다.
328
+ *
329
+ * 🔴 push 가 거부되면 **한 번만** 다시 시도한다. 쪽지 하나 = 파일 하나라 남과
330
+ * 부딪힐 일이 없고, 거부는 곧 "그 사이 남이 쪽지를 넣었다" 는 뜻이다.
331
+ * 받아서 다시 얹으면 끝난다. 그래도 안 되면 **죽이지 않는다** — 쪽지는
332
+ * 로컬에 남아 있고 다음 호출에 함께 올라간다. 여기서 죽이면 원격이 잠깐
333
+ * 흔들릴 때마다 사람의 작업이 멈춘다.
334
+ */
335
+ function publish(id) {
336
+ if (!busReady()) {
337
+ return '아직 안 갔습니다 — 쪽지함이 준비되지 않았습니다. `axmap init` 을 한 번 실행하세요.'
338
+ }
339
+ const remote = gitBus(['config', '--get', 'axmap.remote'], { cwd: REPO }).out || 'origin'
340
+ for (let attempt = 1; attempt <= 2; attempt++) {
341
+ gitBus(['add', '-A'])
342
+ // --no-verify: 연결된 worktree 는 훅을 공유한다. 쪽지함은 사용자 코드가 아니다.
343
+ gitBus(['commit', '--quiet', '--no-verify', '-m', `bus: ${id}`])
344
+ if (gitBus(['push', '--quiet', remote, `HEAD:${BUS_BRANCH}`]).code === 0) {
345
+ return '보냈습니다. 상대는 아무 axMap 도구나 부르면 바로 봅니다.'
346
+ }
347
+ if (attempt === 1) {
348
+ // 남이 먼저 넣었다. 받아서 내 쪽지를 그 위에 다시 얹는다.
349
+ const mine = path.join(BOX, `${id}.md`)
350
+ const keep = fs.existsSync(mine) ? fs.readFileSync(mine) : null
351
+ pull()
352
+ if (keep !== null) { fs.mkdirSync(BOX, { recursive: true }); fs.writeFileSync(mine, keep) }
353
+ }
354
+ }
355
+ return '아직 안 갔습니다 — 원격에 못 올렸습니다. 다음 쪽지를 보낼 때 함께 올라갑니다.'
356
+ }
357
+
358
+ /**
359
+ * 이 파일을 지금 폴더에서 부르는 명령. **문자열로 적지 않고 계산한다.**
360
+ * 벤더링된 사본에서는 `tools/bus.mjs` 가 아니라 `ci/axmap/tools/bus.mjs` 다.
361
+ */
362
+ function selfCmd() {
363
+ const self = fileURLToPath(import.meta.url)
364
+ const rel = path.relative(process.cwd(), self).replace(/\\/g, '/')
365
+ // 🔴 상대경로가 늘 짧은 것은 아니다. 도구가 대상 저장소 **밖**에 있으면
366
+ // (전역 설치, 다른 드라이브) `../../..` 가 줄줄이 붙어 사람이 칠 수 없는
367
+ // 문자열이 된다. 실측 (2026-08-27):
368
+ //
369
+ // node ../../../../../../../../../Desktop/git/axmap/tools/bus.mjs read <아이디>
370
+ //
371
+ // "쪽지가 있다" 고 말한 **바로 그 줄**이 읽는 방법을 못 쓰게 알려준다.
372
+ // 받은 사람은 도구가 고장 났다고 결론짓는다. 둘 중 짧은 쪽을 쓴다 —
373
+ // 안내는 맞기만 해서는 부족하고 **칠 수 있어야** 한다.
374
+ // `bin/axmap.mjs` 의 `busReadHint` 가 같은 판단을 이미 하고 있다.
375
+ const abs = self.replace(/\\/g, '/')
376
+ const use = rel.startsWith('../..') || rel.length >= abs.length
377
+ ? abs
378
+ : (rel.startsWith('.') ? rel : './' + rel)
379
+ return `node ${use}`
380
+ }
381
+
382
+ /**
383
+ * 🔴 `--mine` 과 `--quiet-if-empty` 도 훅 때문에 생겼다.
384
+ *
385
+ * `--mine` — 훅은 자기 이름을 모른다. `--to <이름>` 을 설정 파일에 박으면
386
+ * clone 한 모두가 한 사람의 함을 보게 된다. `me()` 가 풀게 한다.
387
+ * `--quiet` — 훅은 매 턴 돈다. 쪽지가 없을 때 "쪽지 없음." 을 찍으면 그 줄이
388
+ * 대화의 절반을 채우고, 그러면 정작 쪽지가 왔을 때 안 보인다.
389
+ * **아무것도 없을 때 아무 말도 안 하는 것이 알림의 조건이다.**
390
+ */
391
+ function list() {
392
+ pull()
393
+ const all = readAll()
394
+ const to = has('--mine') ? me() : flag('--to')
395
+ const from = flag('--from')
396
+ let rows = all.filter((m) => (has('--all') || !to || m.to === to || m.to === 'all')
397
+ && (!from || m.from === from))
398
+
399
+ // 🔴 `--unread` 는 자기 앞으로 온 것을 가릴 때만 뜻이 있다. 받는 사람이
400
+ // 정해지지 않았는데 "안 읽음" 을 말하면 누구의 읽음인지가 없다.
401
+ const who = has('--unread') ? (to || me()) : null
402
+ if (who) {
403
+ const seen = readSeen(who)
404
+ // 🔴 `m.from !== who` — **내가 보낸 것은 내 안 읽은 쪽지가 아니다.**
405
+ //
406
+ // `bin/axmap.mjs` 의 `unreadNotes` 는 처음부터 이 줄을 갖고 있었고
407
+ // ("내가 보낸 것은 뺀다 — 자기 쪽지에 자기가 놀라면 안 된다") 여기만
408
+ // 없었다. 같은 것을 두 곳이 다른 규칙으로 세면 **한 사람이 같은 순간에**
409
+ // **두 숫자를 본다.**
410
+ //
411
+ // 실측 (2026-08-27, 팀 저장소): `ax_status` 는 "안 읽은 23건" 인데 같은
412
+ // 시점 `ax_send` 결과의 배너는 "34건" 이었다. 차이 11 은 전부 그 사람이
413
+ // 직접 보낸 전체공지였다 — `to: all` 이라 `m.to === 'all'` 에 자기 것이
414
+ // 걸린다. 개인 쪽지에서는 안 드러난다. `to` 가 남의 이름이라 애초에
415
+ // 안 잡히기 때문이고, 그래서 이 버그는 전체공지를 쓰기 시작한 날 나왔다.
416
+ //
417
+ // 숫자가 둘이면 사람은 어느 쪽도 안 믿는다. 그 순간 알림은 배경이 되고,
418
+ // 배경이 된 알림은 진짜 쪽지가 왔을 때도 안 읽힌다.
419
+ rows = rows.filter((m) => m.id > seen && m.from !== who)
420
+ }
421
+
422
+ if (!rows.length) return has('--quiet-if-empty') ? undefined : console.log('쪽지 없음.')
423
+ for (const m of rows) {
424
+ console.log(`${m.at?.slice(0, 16).replace('T', ' ')} ${(m.from ?? '?').padEnd(18)} → ${(m.to ?? 'all').padEnd(18)} ${m.subject ?? ''}`)
425
+ console.log(` ${m.id}`)
426
+ }
427
+ console.log(`\n총 ${rows.length}개. 본문: ${selfCmd()} read <아이디>`)
428
+
429
+ // 🔴 **찍는 것은 보여준 뒤다.** 위에서 죽으면 안 찍혀야 다음에 다시 뜬다.
430
+ // 규격은 SPEC §2「읽음 표시」— 목록에 뜬 순간이 읽은 순간이다.
431
+ //
432
+ // 🔴 `--no-mark` 는 **목록을 잘라서 보여주는 쪽**을 위한 것이다. MCP 배너는
433
+ // 받은 줄 중 3건만 그리는데, 여기서 전부를 찍으면 4번째부터는 화면에 뜬
434
+ // 적도 없이 읽음이 되어 **영영 안 보인다.** 목록을 그대로 다 내보내는 쪽
435
+ // (사람이 부른 `list`)은 이 플래그가 필요 없다.
436
+ //
437
+ // 규칙 한 줄로 적으면 **그린 쪽이, 그린 것만 찍는다.** 자르는 쪽은
438
+ // `--no-mark` 로 읽기만 하고 자기가 그린 id 를 `seen` 에 넘긴다.
439
+ if (who && !has('--no-mark')) {
440
+ markSeen(who, rows.reduce((hi, m) => (m.id > hi ? m.id : hi), ''))
441
+ }
442
+ }
443
+
444
+ function read(id) {
445
+ pull()
446
+ const m = readAll().find((x) => x.id === id || x.id.includes(id))
447
+ if (!m) { console.error(`없는 쪽지: ${id}`); process.exit(1) }
448
+ console.log(`── ${m.subject}\n ${m.from} → ${m.to} ${m.at}\n`)
449
+ console.log(m.body)
450
+ }
451
+
452
+ /** 지금 누가 무엇을 잡고 있나 — 쪽지를 보내기 전에 상대가 뭘 하는지 본다. */
453
+ function who() {
454
+ try {
455
+ console.log(execFileSync('node', [path.join(ROOT, 'bin', 'axmap.mjs'), 'status'], {
456
+ encoding: 'utf8', env: { ...process.env, AXMAP_AGENT: process.env.AXMAP_AGENT ?? 'bus' },
457
+ }))
458
+ } catch (e) { console.error(e.message) }
459
+ }
460
+
461
+ /**
462
+ * 읽음 표시를 **주어진 쪽지에만** 찍는다. 규격은 SPEC §2「읽음 표시」.
463
+ *
464
+ * 🔴 이것이 따로 있는 이유는 하나다 — **목록을 잘라서 보여주는 쪽이 있기 때문이다.**
465
+ * `list` 는 자기가 낸 줄을 전부 알지만, MCP 배너처럼 그중 앞의 몇 줄만 그리는
466
+ * 쪽은 `list` 에게 "내가 실제로 그린 것" 을 말해줄 방법이 없었다. 그래서
467
+ * 찍는 일을 목록에서 떼어내 여기로 옮겼다.
468
+ *
469
+ * 🔴 `pull()` 을 하지 않는다. 로컬 파일 하나를 쓸 뿐이라 원격을 물을 이유가 없고,
470
+ * 알림을 그릴 때마다 fetch 가 돌면 배너가 도구를 느리게 만든다.
471
+ *
472
+ * 고수위 하나만 들고 있으므로(SPEC §2) 여러 개를 받아도 가장 큰 것만 남는다.
473
+ * 뒤로 가지 않는지는 `markSeen` 이 본다.
474
+ */
475
+ function seen() {
476
+ const ids = []
477
+ for (let i = 1; i < args.length; i++) {
478
+ if (args[i] === '--to') { i++; continue } // 그 다음 것은 id 가 아니다
479
+ if (args[i].startsWith('--')) continue
480
+ ids.push(args[i])
481
+ }
482
+ if (!ids.length) {
483
+ console.error(`찍을 쪽지 id 를 주세요. 예: ${selfCmd()} seen <아이디> [<아이디>…]`)
484
+ process.exit(1)
485
+ }
486
+ markSeen(has('--mine') ? me() : (flag('--to') || me()),
487
+ ids.reduce((hi, id) => (id > hi ? id : hi), ''))
488
+ }
489
+
490
+ switch (cmd) {
491
+ case 'post': post({ to: flag('--to'), subject: flag('--subject'), body: stdin() }); break
492
+ case 'reply': {
493
+ const target = args[1]
494
+ const src = readAll().find((x) => x.id === target || x.id.includes(target))
495
+ if (!src) { console.error(`없는 쪽지: ${target}`); process.exit(1) }
496
+ post({ to: src.from, subject: flag('--subject') ?? `Re: ${src.subject}`, body: stdin(), replyTo: src.id })
497
+ break
498
+ }
499
+ case 'list': list(); break
500
+ case 'read': read(args[1]); break
501
+ case 'seen': seen(); break
502
+ case 'who': who(); break
503
+ default:
504
+ console.log(`에이전트 쪽지함
505
+
506
+ ${selfCmd()} post --to <상대> --subject "<제목>" < 본문.md
507
+ ${selfCmd()} list [--to <나>|--mine] [--from <상대>] [--all]
508
+ [--unread] [--no-mark] [--throttle <초>] [--quiet-if-empty]
509
+ ${selfCmd()} read <아이디>
510
+ ${selfCmd()} seen <아이디> [<아이디>…] 보여준 쪽지만 읽음으로 찍는다
511
+ ${selfCmd()} reply <아이디> < 본문.md
512
+ ${selfCmd()} who 지금 누가 무엇을 잡고 있나
513
+
514
+ 목록을 잘라서 보여주는 쪽은 --no-mark 로 읽기만 하고, 자기가 그린 아이디만
515
+ seen 에 넘기세요. 안 그러면 화면에 뜬 적 없는 쪽지가 읽음이 되어 안 보입니다.
516
+
517
+ AXMAP_AGENT 선점과 같은 값으로 두세요.
518
+ AXMAP_BUS_POLL 원격을 다시 묻기까지의 초. --throttle 이 없을 때만 씁니다.
519
+ 쪽지는 고아 브랜치 ${BUS_BRANCH} 로 바로 갑니다 — 커밋도 MR 도 필요 없습니다.`)
520
+ }