axmap-cli 0.3.0 → 1.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.
package/README.md CHANGED
@@ -50,7 +50,9 @@ node app/server.mjs https://github.com/pallets/flask 7777
50
50
  ⑤ 이제 당신 차례
51
51
  ```
52
52
 
53
- **팀으로 쓰려면** → [docs/ONBOARD-TEAM.md](docs/ONBOARD-TEAM.md) (3줄이면 붙는다)
53
+ **남의 저장소에서 쓰려면** → [docs/INSTALL.md](docs/INSTALL.md) (npm 두 줄이면 끝난다)
54
+
55
+ **axMap 자체를 고치려면** → [docs/ONBOARD-TEAM.md](docs/ONBOARD-TEAM.md) (3줄이면 붙는다)
54
56
 
55
57
  ---
56
58
 
@@ -179,7 +181,8 @@ npm run smoke # 🔴 화면이 실제로 뜨는지 헤드리스 크롬
179
181
  | | |
180
182
  |---|---|
181
183
  | [CLAUDE.md](CLAUDE.md) | 작업 규칙 (사람에게도 AI 에게도 같다) |
182
- | [docs/ONBOARD-TEAM.md](docs/ONBOARD-TEAM.md) | 팀원용 5분 안내 |
184
+ | [docs/INSTALL.md](docs/INSTALL.md) | **팀원이 읽을 하나** — npm 으로 깔고 쓰는 법 |
185
+ | [docs/ONBOARD-TEAM.md](docs/ONBOARD-TEAM.md) | axMap 자체를 고칠 사람용 5분 안내 |
183
186
  | [docs/DECISIONS.md](docs/DECISIONS.md) | 제품 결정 D1~D15 와 **아직 못 정한 것들** |
184
187
  | [docs/WHY-CORPUS.md](docs/WHY-CORPUS.md) | 코퍼스가 왜 필요한가 |
185
188
  | [docs/PERSONA-LOOP.md](docs/PERSONA-LOOP.md) | 시각화가 실제로 일하는지 재는 법과 결과 |
package/bin/axmap.mjs CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  noticeLine,
42
42
  registryUrl,
43
43
  } from '../src/update.mjs'
44
+ import { homeRegistrations } from '../src/mcpstate.mjs'
44
45
 
45
46
  /** 이 CLI 가 들어 있는 axMap 폴더. 갱신 확인과 setup 이 자기 위치를 알아야 한다. */
46
47
  const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
@@ -1171,6 +1172,32 @@ function busSetupFailed(r) {
1171
1172
  return 'failed'
1172
1173
  }
1173
1174
 
1175
+ /**
1176
+ * 쪽지함만 준비한다. **장부는 건드리지 않는다.**
1177
+ *
1178
+ * 🔴 왜 `init` 을 부르지 않고 이 명령이 따로 있는가. 쪽지를 **읽는** 길에서
1179
+ * 부를 자리가 필요해서다. `init` 은 장부(`axmap/claims`)까지 만들고 그것을
1180
+ * 원격에 push 한다 — "내 쪽지 좀 보자" 가 남의 저장소에 **선점 장부**를
1181
+ * 만드는 일이 되면 안 된다. 쪽지 브랜치는 쪽지함이 원래 사는 곳이므로
1182
+ * 거기까지는 간다. 선을 긋는 자리는 "읽으려는 그것" 과 "그 밖의 것" 사이다.
1183
+ *
1184
+ * 그래서 `tools/bus.mjs` 는 쪽지함이 없을 때 이 명령을 부른다. 만드는 코드를
1185
+ * 저쪽에 복사하지 않는 이유는 사본이 곧 두 벌이고, 두 벌은 반드시 어긋나기
1186
+ * 때문이다 — 이 저장소가 팀 사본을 걷어낸 것과 같은 이유다.
1187
+ */
1188
+ function cmdBusRepair() {
1189
+ const root = repoRoot()
1190
+ // .axmap 을 손으로 지웠어도 git 쪽에 worktree 등록이 남아 add 가 실패한다.
1191
+ git(['worktree', 'prune'], { cwd: root })
1192
+ const r = ensureBus(root)
1193
+ if (r === 'failed') process.exit(1)
1194
+ console.log(
1195
+ r === 'ready'
1196
+ ? `쪽지함이 이미 있습니다: ${BUS_REL}`
1197
+ : `쪽지함을 만들었습니다: ${BUS_REL} (${BUS_BRANCH})`,
1198
+ )
1199
+ }
1200
+
1174
1201
  function cmdInit(flags = {}) {
1175
1202
  const root = repoRoot()
1176
1203
  const dir = ledgerDir(root)
@@ -1631,6 +1658,60 @@ function cmdVerify(flags) {
1631
1658
  // 명령: audit (사후 증명)
1632
1659
  // ---------------------------------------------------------------------------
1633
1660
 
1661
+ /**
1662
+ * `--code <범위>` 가 가리키는 **코드 커밋**을 모은다 (I5 · 강제 검사의 입력).
1663
+ *
1664
+ * 🔴 **머지 커밋은 뺀다** (`--no-merges`). 서버가 만든 것이라 사람이 파일을 고른
1665
+ * 적이 없고, 그것을 위반으로 세면 MR 을 쓸수록 빨개진다 — 문을 세운 결과가
1666
+ * 문을 우회하는 것이 된다.
1667
+ *
1668
+ * 🔴 **범위를 인자로 받는다. 이력 전체를 검사하지 않는다.** 장부가 생기기 전에
1669
+ * 쌓인 커밋은 아무도 잡지 않은 채 고쳐진 것이 당연하고, 그것까지 세면 이 잡은
1670
+ * 켠 날부터 영원히 빨갛다. CI 는 이번 push 로 들어온 구간만 넘긴다.
1671
+ */
1672
+ function collectCodeCommits(root, range) {
1673
+ const ZERO = '0000000000000000000000000000000000000000'
1674
+ if (range.includes(ZERO)) {
1675
+ // 새 브랜치의 첫 push 면 GitLab 이 "이전 sha" 자리에 0 을 넣는다. 비교할 앞이 없다.
1676
+ console.log(`구간에 빈 sha 가 있습니다 (${range}) - 첫 push 로 보고 코드 대조를 건너뜁니다.`)
1677
+ return null
1678
+ }
1679
+ const NUL = String.fromCharCode(0)
1680
+ const NL = String.fromCharCode(10)
1681
+ const log = git(['log', '--no-merges', '--format=%H%x00%an%x00%ct%x00%s', range], { cwd: root })
1682
+ if (log.code !== 0) {
1683
+ // 🔴 못 읽은 것을 통과로 내지 않는다. 조용히 빈 배열을 주면 "위반 0" 이 된다.
1684
+ die([
1685
+ `코드 구간을 읽지 못했습니다 (${range}).`,
1686
+ ' 구간을 확인하세요 - 검사하지 못한 것을 통과로 내지 않습니다.',
1687
+ ].join(NL))
1688
+ }
1689
+ const out = []
1690
+ for (const line of log.out ? log.out.split(NL) : []) {
1691
+ if (!line) continue
1692
+ const [sha, author, ct, subject] = line.split(NUL)
1693
+ // 🔴 `-c core.quotepath=false` 가 없으면 **한글 경로가 claim 과 안 맞는다.**
1694
+ // git 은 기본으로 ASCII 밖 글자를 역슬래시 + 8진수 세 자리로 감싸 내보낸다.
1695
+ // 그 문자열은 장부에 적힌 경로와 절대 같아질 수 없고, 검사는 "안 겹친다" 로
1696
+ // **조용히 통과**한다 - 락에서 최악인 fail-open 이다.
1697
+ // 팀 CI 가 잡 안에서 `git config core.quotepath false` 로 막고 있었다.
1698
+ // 우회는 그 잡에만 걸린다. 부르는 자리에서 못을 박는다 (verify 와 같은 방식).
1699
+ const files = git(
1700
+ ['-c', 'core.quotepath=false', 'show', '--no-renames', '--name-only', '--format=', sha],
1701
+ { cwd: root },
1702
+ ).out
1703
+ out.push({
1704
+ sha,
1705
+ author,
1706
+ time: Number(ct) * 1000,
1707
+ subject,
1708
+ files: (files ? files.split(NL) : []).filter(Boolean),
1709
+ })
1710
+ }
1711
+ return out
1712
+ }
1713
+
1714
+
1634
1715
  function cmdAudit(flags) {
1635
1716
  const root = repoRoot()
1636
1717
  const dir = ledgerDir(root)
@@ -1702,7 +1783,11 @@ function cmdAudit(flags) {
1702
1783
  snapshots.push({ commit: sha, time: Number(ct) * 1000, subject, claims })
1703
1784
  }
1704
1785
 
1705
- const report = auditLedger(snapshots)
1786
+ const codeCommits = typeof flags.code === 'string' && flags.code
1787
+ ? collectCodeCommits(root, flags.code)
1788
+ : null
1789
+
1790
+ const report = auditLedger(snapshots, codeCommits)
1706
1791
  if (flags.json) {
1707
1792
  console.log(JSON.stringify(report, null, 2))
1708
1793
  } else {
@@ -1803,6 +1888,17 @@ function cmdDoctor(flags) {
1803
1888
  }
1804
1889
 
1805
1890
  // 7. MCP 설정 — AI 도구가 자동으로 붙는 경로
1891
+ //
1892
+ // 🔴 붙는 길은 **둘**이다. 저장소의 `.mcp.json` 과 각 도구의 홈 설정.
1893
+ // 예전에는 앞의 것만 봤다. 그래서 npm 판을 깔고 `axmap setup` 까지 끝낸 사람에게도
1894
+ // ".mcp.json 이 없습니다 — AI 도구가 자동으로 붙지 않습니다" 라고 말했다.
1895
+ // 붙어 있는데 안 붙었다고 하는 것이라, 새로 온 팀은 첫날 이 줄을 보고 설치가
1896
+ // 잘못된 줄 안다. 홈 등록이 기본이 된 지금은 `.mcp.json` 이 **없는 것이 정상**이다.
1897
+ const homeMcp = homeRegistrations()
1898
+ const homeWord = homeMcp.found.length
1899
+ ? `홈에 등록돼 있습니다 (${homeMcp.found.join(' · ')})` +
1900
+ (homeMcp.guessed.length ? ` ※ ${homeMcp.guessed.join(' · ')} 는 설정 파일 글자만 보고 짐작한 것입니다` : '')
1901
+ : null
1806
1902
  const mcpJson = path.join(root, '.mcp.json')
1807
1903
  if (fs.existsSync(mcpJson)) {
1808
1904
  let server = null
@@ -1810,8 +1906,17 @@ function cmdDoctor(flags) {
1810
1906
  if (!server) hm('MCP 설정', '.mcp.json 은 있는데 axmap 서버를 못 찾았습니다')
1811
1907
  else if (!fs.existsSync(path.resolve(root, server))) no('MCP 설정', `서버 파일이 없습니다: ${server}`)
1812
1908
  else ok('MCP 설정', `${server} (AI 도구가 승인만 하면 붙습니다)`)
1909
+ // 둘 다 있으면 **저장소 쪽이 이긴다.** 같은 서버면 문제가 없지만, npm 판을 깔아 둔
1910
+ // 사람이 "내가 깐 것이 쓰이겠거니" 하고 여기서 엇갈린다. 그 자리에서 말해 준다.
1911
+ if (homeMcp.found.length) {
1912
+ hm('MCP 설정', `홈에도 등록돼 있습니다 (${homeMcp.found.join(' · ')}) — 이 저장소에서는 위의 .mcp.json 이 먼저 쓰입니다`)
1913
+ }
1914
+ } else if (homeWord) {
1915
+ ok('MCP 설정', `${homeWord} — 저장소에 파일이 없어도 됩니다`)
1916
+ } else if (homeMcp.unreadable.length) {
1917
+ hm('MCP 설정', `홈 설정을 읽지 못했습니다 (${homeMcp.unreadable.join(' · ')}) — 붙었는지 판단하지 않습니다`)
1813
1918
  } else {
1814
- hm('MCP 설정', '.mcp.json 없습니다 — AI 도구가 자동으로 붙지 않습니다')
1919
+ hm('MCP 설정', '아직 등록된 곳이 없습니다 — AI 도구가 자동으로 붙지 않습니다\n axmap setup')
1815
1920
  }
1816
1921
 
1817
1922
  // 8. 실제로 판정이 도는가 — 장부를 바꾸지 않고 읽기만 한다
@@ -2116,6 +2221,8 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
2116
2221
  axmap setup 이 PC 와 이 저장소에 axMap 을 붙인다 (첫 한 번)
2117
2222
  [--remote <이름|git 주소>] [--name] [--dry-run]
2118
2223
  axmap init 장부(고아 브랜치 + worktree)를 준비한다
2224
+ axmap bus-repair 쪽지함만 준비한다 (장부는 안 건드린다)
2225
+ 쪽지를 읽다가 "쪽지함을 못 열었습니다" 를 봤을 때
2119
2226
  axmap claim <경로...> 경로를 선점한다 [--task --intent --ttl 30m]
2120
2227
  axmap release [경로...] 반납한다 (경로 생략 시 전부)
2121
2228
  axmap renew TTL 을 연장한다 [--ttl 30m]
@@ -2127,6 +2234,7 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
2127
2234
  axmap doctor 이 PC 에서 선점이 실제로 도는지 점검 [--json]
2128
2235
  axmap hook install verify 를 pre-commit 훅으로 설치
2129
2236
  axmap update 새 버전이 있는지 묻는다 (바꾸지는 않는다)
2237
+ axmap --version 지금 도는 판 번호를 찍는다 (아래 version 명령과 다르다)
2130
2238
 
2131
2239
  딸린 프로그램 — 예전에는 파일 경로로 불렀다. 이제 이름으로 부른다.
2132
2240
  인자는 그대로 전달되고, 각각의 사용법은 그 프로그램이 답한다.
@@ -2149,6 +2257,27 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
2149
2257
  const { positional, flags } = parseArgs(process.argv.slice(2))
2150
2258
  const [cmd, ...rest] = positional
2151
2259
 
2260
+ /**
2261
+ * 🔴 `--version` 은 명령이 아니라 **깃발**이라 아래 switch 에 안 걸린다.
2262
+ * 그대로 default 로 떨어져 **도움말을 찍고 종료 코드 0** 을 냈다. 팀 CI 가
2263
+ * "어느 판이 돌았는지" 를 남기려고 넣은 줄이 조용히 쓸모없어졌고, 0 이라
2264
+ * 아무도 못 알아챘다 (실측 2026-09-01, 팀 저장소 .gitlab-ci.yml eb3b56d).
2265
+ *
2266
+ * 🔴 `version` **명령과는 다른 것을 답한다.** 저쪽(tools/version.mjs)은
2267
+ * `process.cwd()` 의 git 태그를 읽는 도구라, 남의 저장소에서 부르면 그
2268
+ * 저장소의 버전이 나온다. 여기가 답해야 하는 것은 "지금 도는 axMap 이 몇
2269
+ * 판인가" 이고, 그 답은 설치본 자기 package.json 에만 있다.
2270
+ *
2271
+ * 못 읽으면 죽는다. 모르는 것을 빈 줄로 내면 CI 로그에는 "판을 확인했다" 는
2272
+ * 흔적만 남고 실제로는 아무것도 확인되지 않는다.
2273
+ */
2274
+ if (boolFlag(flags.version, 'version') && !cmd) {
2275
+ const v = selfVersion()
2276
+ if (!v) die('버전을 알 수 없습니다 - package.json 이 없는 사본입니다.')
2277
+ console.log(v)
2278
+ process.exit(0)
2279
+ }
2280
+
2152
2281
  switch (cmd) {
2153
2282
  // 이름을 경로로 바꿔 주는 것들 (RUNNERS). 인자는 손대지 않고 그대로 간다.
2154
2283
  case 'setup':
@@ -2168,6 +2297,9 @@ switch (cmd) {
2168
2297
  case 'init':
2169
2298
  cmdInit(flags)
2170
2299
  break
2300
+ case 'bus-repair':
2301
+ cmdBusRepair()
2302
+ break
2171
2303
  case 'claim':
2172
2304
  cmdClaim(rest, flags)
2173
2305
  break
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": "0.3.0",
4
+ "version": "1.0.1",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -192,12 +192,64 @@ export function snapshotTime(snapshot) {
192
192
  return Math.max(snapshot.time, ...(sinces.length ? sinces : [snapshot.time]))
193
193
  }
194
194
 
195
+ // ---------------------------------------------------------------------------
196
+ // I5 · 강제 (전이 불변식)
197
+ // 커밋이 파일을 고쳤다면, 그 시각에 **누군가는** 그 파일을 잡고 있어야 한다.
198
+ //
199
+ // 🔴 이 검사가 없으면 claim 은 권고 사항이다. 지금까지 강제하는 것은 각자 PC 의
200
+ // 커밋 훅뿐이었고, 훅은 "설치했는가" 에 달려 있으며 `--no-verify` 로 넘어가고
201
+ // 웹 IDE 에는 아예 없다. 서버에서 도는 검사만이 설치 여부와 무관하다.
202
+ //
203
+ // 🔴 **"고친 사람이 곧 잡은 사람인가" 는 묻지 않는다.** 장부의 `agent` 는 세션
204
+ // 이름(`claude-code-mcpjson` 같은)이고 커밋의 author 는 git 이름(`janghyojoon`)
205
+ // 이라 서로 다른 이름 공간이다. 둘을 이름으로 맞추면 **정상 작업이 전부 위반으로
206
+ // 나온다.** 그래서 여기서는 "아무도 안 잡은 파일이 고쳐졌나" 하나만 본다 —
207
+ // 이것이 이 검사가 실제로 증명할 수 있는 문장이다. 소유자까지 맞추려면 claim
208
+ // 레코드에 git 신원을 함께 적어야 하고, 그건 별개의 변경이다.
209
+ //
210
+ // 🔴 장부보다 앞선 커밋은 **건너뛰되 세어서 보고한다.** 조용히 빼면 "위반 0" 이
211
+ // "검사했고 깨끗함" 으로 읽힌다. 검사하지 않은 것을 통과로 내지 않는다.
212
+ // ---------------------------------------------------------------------------
213
+
214
+ export function enforcementViolations(snapshots, commits) {
215
+ const snaps = [...(snapshots ?? [])].sort((a, b) => snapshotTime(a) - snapshotTime(b))
216
+ const violations = []
217
+ const skipped = []
218
+
219
+ for (const c of commits ?? []) {
220
+ // 그 커밋 시각에 유효했던 마지막 장부 스냅샷을 찾는다.
221
+ let snap = null
222
+ for (const s of snaps) {
223
+ if (snapshotTime(s) <= c.time) snap = s
224
+ else break
225
+ }
226
+ if (!snap) {
227
+ skipped.push({ commit: c.sha, subject: c.subject, reason: '장부보다 앞선 커밋' })
228
+ continue
229
+ }
230
+ const held = activeClaims(snap.claims, c.time).flatMap((cl) => cl.paths.map(normalizePath))
231
+ for (const f of (c.files ?? []).map(normalizePath)) {
232
+ if (held.some((p) => pathsOverlap(p, f))) continue
233
+ violations.push({
234
+ invariant: 'I5',
235
+ message: `${f} 를 아무도 잡지 않은 채 고쳤다`,
236
+ agents: c.author ? [c.author] : [],
237
+ paths: [f],
238
+ commit: c.sha,
239
+ subject: c.subject,
240
+ at: new Date(c.time).toISOString(),
241
+ })
242
+ }
243
+ }
244
+ return { violations, skipped }
245
+ }
246
+
195
247
  /**
196
248
  * 시간이 흐르는 것만으로는 겹침이 생기지 않는다 (만료는 claim 을 없앨 뿐이다).
197
249
  * 겹침은 오직 claim 이 추가될 때 생기고, claim 은 커밋으로만 추가된다.
198
250
  * 따라서 각 커밋 시점만 검사하면 전체 구간을 덮는다.
199
251
  */
200
- export function auditLedger(snapshots) {
252
+ export function auditLedger(snapshots, commits = null) {
201
253
  const violations = []
202
254
  for (const snap of snapshots) {
203
255
  const t = snapshotTime(snap)
@@ -205,17 +257,37 @@ export function auditLedger(snapshots) {
205
257
  violations.push({ ...v, commit: snap.commit, subject: snap.subject, at: new Date(t).toISOString() })
206
258
  }
207
259
  }
208
- return { ok: violations.length === 0, checked: snapshots.length, violations }
260
+ const code = commits ? enforcementViolations(snapshots, commits) : null
261
+ if (code) violations.push(...code.violations)
262
+ return {
263
+ ok: violations.length === 0,
264
+ checked: snapshots.length,
265
+ codeChecked: commits ? commits.length : null,
266
+ codeSkipped: code ? code.skipped : null,
267
+ violations,
268
+ }
209
269
  }
210
270
 
211
271
  export function formatAudit(report) {
272
+ // 코드 검사를 돌렸는지, 그중 몇 개를 못 봤는지는 통과·실패 어느 쪽에서도 적는다.
273
+ const codeLine =
274
+ report.codeChecked === null || report.codeChecked === undefined
275
+ ? '코드 커밋 대조: 안 함 (--code <범위> 를 주면 봅니다)'
276
+ : `코드 커밋 대조: ${report.codeChecked}개` +
277
+ (report.codeSkipped?.length ? ` (장부보다 앞서 건너뛴 것 ${report.codeSkipped.length}개)` : '')
278
+
212
279
  if (report.ok) {
213
- return (
214
- `감사 통과 - 스냅샷 ${report.checked}개\n` +
215
- '장부 이력 전체에서 상호배제(I1)가 깨진 시점이 없습니다.'
216
- )
280
+ return [
281
+ `감사 통과 - 스냅샷 ${report.checked}개`,
282
+ '장부 이력 전체에서 상호배제(I1)가 깨진 시점이 없습니다.',
283
+ codeLine,
284
+ ].join('\n')
217
285
  }
218
- const lines = [`감사 실패 - 스냅샷 ${report.checked}개 중 위반 ${report.violations.length}건\n`]
286
+ const lines = [
287
+ `감사 실패 - 스냅샷 ${report.checked}개 중 위반 ${report.violations.length}건`,
288
+ codeLine,
289
+ '',
290
+ ]
219
291
  for (const v of report.violations) {
220
292
  lines.push(` x [${v.invariant}] ${v.at} commit ${v.commit.slice(0, 7)} ${v.subject}`)
221
293
  lines.push(` ${v.message}`)
@@ -0,0 +1,90 @@
1
+ /**
2
+ * axmap MCP 가 **어디에 등록돼 있는가** 를 읽기만 하는 자리.
3
+ *
4
+ * 왜 따로 있나 — `tools/mcp-register.mjs` 는 맨 아래에서 `process.exit(main())` 를
5
+ * 부른다. 그래서 그 파일을 import 하면 **등록이 실제로 실행되고 프로세스가 죽는다.**
6
+ * 판정만 필요한 쪽(`doctor`)이 그것을 부를 수 없다. 그렇다고 doctor 안에 같은 판정을
7
+ * 한 벌 더 쓰면 두 곳이 서로 다르게 굴게 된다 — 등록기는 넣었다는데 doctor 는
8
+ * 없다고 하는 상태가 정확히 이 파일이 생긴 이유다.
9
+ *
10
+ * 여기에는 **쓰는 코드를 두지 않는다.** 무엇을 어디에 쓸지는 등록기가 정한다.
11
+ */
12
+
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+
17
+ /**
18
+ * `~/.claude.json` 의 user 범위 axmap 항목.
19
+ *
20
+ * 없으면 `null`, **못 읽으면 `undefined`** 다. 이 둘을 가르는 것이 중요하다 —
21
+ * 파일이 아예 없는 것은 "등록 안 됨" 이고, 있는데 못 읽는 것은 "사고" 다.
22
+ * 사고를 "등록 안 됨" 으로 뭉개면 남의 설정을 덮어쓰자는 판단이 나온다.
23
+ */
24
+ export function claudeUserEntry() {
25
+ try {
26
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'))
27
+ return (cfg && cfg.mcpServers && cfg.mcpServers.axmap) || null
28
+ } catch (e) {
29
+ return e && e.code === 'ENOENT' ? null : undefined
30
+ }
31
+ }
32
+
33
+ /** `~/.gemini/config/mcp_config.json` 의 axmap 항목. 규칙은 위와 같다. */
34
+ export function agyUserEntry() {
35
+ try {
36
+ const f = path.join(os.homedir(), '.gemini', 'config', 'mcp_config.json')
37
+ const raw = fs.readFileSync(f, 'utf8')
38
+ // agy 는 설치할 때 0바이트 파일을 만들어 둔다. 빈 파일은 사고가 아니라 "아직 없음" 이다.
39
+ if (!raw.trim()) return null
40
+ const cfg = JSON.parse(raw)
41
+ return (cfg && cfg.mcpServers && cfg.mcpServers.axmap) || null
42
+ } catch (e) {
43
+ return e && e.code === 'ENOENT' ? null : undefined
44
+ }
45
+ }
46
+
47
+ /**
48
+ * `~/.codex/config.toml` 안에 axmap 서버가 있는가.
49
+ *
50
+ * 🔴 **이것은 글자 수준의 짐작이다.** 이 저장소에는 codex 로 실제 등록한 결과물이
51
+ * 없어서(`tools/mcp-register.mjs` 의 codex 절 주석 참고) TOML 을 제대로 파싱해서
52
+ * 판정할 근거가 없다. 그래서 `[mcp_servers.axmap]` 머리표만 찾는다.
53
+ *
54
+ * 확실하지 않은 것을 확실한 척 말하지 않으려고 **반환값에 그 사실을 실어 보낸다** —
55
+ * 부르는 쪽이 "짐작" 이라고 사람에게 말할 수 있게. 실물로 확인되면 이 함수만 고친다.
56
+ */
57
+ export function codexUserEntry() {
58
+ try {
59
+ const raw = fs.readFileSync(path.join(os.homedir(), '.codex', 'config.toml'), 'utf8')
60
+ return /^\s*\[mcp_servers\.axmap\]/m.test(raw) ? { guessed: true } : null
61
+ } catch (e) {
62
+ return e && e.code === 'ENOENT' ? null : undefined
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 홈에 등록된 것을 한 번에 모은다.
68
+ *
69
+ * `found` 는 등록이 확인된 도구 이름, `unreadable` 은 설정이 있는데 못 읽은 도구다.
70
+ * 못 읽은 것을 "없음" 쪽에 넣지 않는다 — 그러면 이미 붙어 있는 사람에게
71
+ * "안 붙었습니다" 라고 말하게 되고, 그게 지금 고치는 바로 그 버그다.
72
+ */
73
+ export function homeRegistrations() {
74
+ const probes = [
75
+ ['claude', claudeUserEntry],
76
+ ['agy', agyUserEntry],
77
+ ['codex', codexUserEntry],
78
+ ]
79
+ const found = []
80
+ const unreadable = []
81
+ const guessed = []
82
+ for (const [id, probe] of probes) {
83
+ const e = probe()
84
+ if (e === undefined) { unreadable.push(id); continue }
85
+ if (!e) continue
86
+ found.push(id)
87
+ if (e.guessed) guessed.push(id)
88
+ }
89
+ return { found, unreadable, guessed }
90
+ }
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
  //
@@ -230,11 +306,17 @@ const slug = (s) => s.toLowerCase().replace(/[^\w가-힣]+/g, '-').replace(/^-|-
230
306
 
231
307
  function readAll() {
232
308
  const found = []
309
+ let newBoxOpened = false
233
310
  for (const box of READ_BOXES) {
234
311
  let ns = []
235
312
  try { ns = fs.readdirSync(box).filter((f) => f.endsWith('.md')) } catch { continue }
313
+ if (box === BOX) newBoxOpened = true
236
314
  for (const f of ns) found.push({ box, f })
237
315
  }
316
+ // 🔴 옛 함(`docs/bus`)은 **읽기 전용 유산**이다. 새 함을 못 열었는데 옛 함이
317
+ // 읽히면 지금까지는 그것이 **성공한 것처럼** 보였다 — 8월 20일자 8건이
318
+ // 나오고 오늘 온 쪽지는 없는 것이 된다. 어느 함에서 읽었는지를 남긴다.
319
+ readFromLegacyOnly = !newBoxOpened && found.length > 0
238
320
  return found.map(({ box, f }) => {
239
321
  const text = fs.readFileSync(path.join(box, f), 'utf8')
240
322
  const head = {}
@@ -419,7 +501,25 @@ function list() {
419
501
  rows = rows.filter((m) => m.id > seen && m.from !== who)
420
502
  }
421
503
 
422
- if (!rows.length) return has('--quiet-if-empty') ? undefined : console.log('쪽지 없음.')
504
+ // 🔴 **못 읽었으면 "없음" 이라고 답하지 않는다.** 이 세 줄이 이 파일에서 제일
505
+ // 중요하다. 나머지는 편의고 이것만이 사고를 막는다.
506
+ //
507
+ // 종료 코드를 0 이 아닌 것으로 낸다 — MCP 서버(`mcp/server.mjs`)는 이미
508
+ // `ok: r.code === 0` 으로 갈라 "쪽지함을 읽지 못했습니다" 를 따로 내도록
509
+ // 되어 있었다. 읽기가 그 신호를 **한 번도 낸 적이 없었을** 뿐이다.
510
+ //
511
+ // `--quiet-if-empty`(훅·배너)일 때만 0 으로 끝낸다. 훅에서 0 이 아니면
512
+ // 커밋이 막히는데, 쪽지를 못 읽은 것으로 커밋을 막는 것은 과하다.
513
+ // **다만 말은 한다** — stderr 는 그 경우에도 그대로 나간다.
514
+ if (busProblem) {
515
+ reportBusProblem()
516
+ if (!has('--quiet-if-empty')) process.exit(1)
517
+ return
518
+ }
519
+ if (!rows.length) {
520
+ if (readFromLegacyOnly) console.error('※ 옛 쪽지함(docs/bus)만 읽었습니다 — 새 쪽지함은 비어 있습니다.')
521
+ return has('--quiet-if-empty') ? undefined : console.log('쪽지 없음.')
522
+ }
423
523
  for (const m of rows) {
424
524
  console.log(`${m.at?.slice(0, 16).replace('T', ' ')} ${(m.from ?? '?').padEnd(18)} → ${(m.to ?? 'all').padEnd(18)} ${m.subject ?? ''}`)
425
525
  console.log(` ${m.id}`)
@@ -444,6 +544,9 @@ function list() {
444
544
  function read(id) {
445
545
  pull()
446
546
  const m = readAll().find((x) => x.id === id || x.id.includes(id))
547
+ // 🔴 못 열었으면 "없는 쪽지" 라고 말하지 않는다. 목록과 같은 이유다 — 사람은
548
+ // 아이디를 잘못 적었다고 믿고 다시 치게 되는데, 몇 번을 쳐도 같은 답이 온다.
549
+ if (!m && busProblem) { reportBusProblem(); process.exit(1) }
447
550
  if (!m) { console.error(`없는 쪽지: ${id}`); process.exit(1) }
448
551
  console.log(`── ${m.subject}\n ${m.from} → ${m.to} ${m.at}\n`)
449
552
  console.log(m.body)
@@ -491,7 +594,11 @@ switch (cmd) {
491
594
  case 'post': post({ to: flag('--to'), subject: flag('--subject'), body: stdin() }); break
492
595
  case 'reply': {
493
596
  const target = args[1]
597
+ // 🔴 답장도 먼저 받아온다. 예전에는 받아오지 않고 찾아서, 원격에만 있는 쪽지에
598
+ // 답장하면 **"없는 쪽지"** 가 나왔다. 아이디를 잘못 적은 것과 구별이 안 된다.
599
+ pull()
494
600
  const src = readAll().find((x) => x.id === target || x.id.includes(target))
601
+ if (!src && busProblem) { reportBusProblem(); process.exit(1) }
495
602
  if (!src) { console.error(`없는 쪽지: ${target}`); process.exit(1) }
496
603
  post({ to: src.from, subject: flag('--subject') ?? `Re: ${src.subject}`, body: stdin(), replyTo: src.id })
497
604
  break
@@ -41,6 +41,7 @@ import path from 'node:path'
41
41
  import { fileURLToPath } from 'node:url'
42
42
 
43
43
  import { AGENTS, resolveBin } from '../app/lib/agentcli.mjs'
44
+ import { claudeUserEntry } from '../src/mcpstate.mjs'
44
45
 
45
46
  const HERE = path.dirname(fileURLToPath(import.meta.url))
46
47
  const AXMAP_DIR = path.resolve(HERE, '..') // .../axmap
@@ -92,16 +93,9 @@ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
92
93
  // 있는지 **읽는 것만** 우리가 하고, 쓰는 일은 claude 자신에게 맡긴다 —
93
94
  // doCodex 와 같은 태도다.
94
95
 
95
- /** `~/.claude.json` user 범위 axmap 항목. 없으면 null, **못 읽으면 undefined**. */
96
- function claudeUserEntry() {
97
- try {
98
- const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'))
99
- return (cfg && cfg.mcpServers && cfg.mcpServers.axmap) || null
100
- } catch (e) {
101
- // 파일이 아예 없는 것은 "등록 안 됨"(null)이고, 있는데 못 읽는 것은 사고(undefined)다.
102
- return e && e.code === 'ENOENT' ? null : undefined
103
- }
104
- }
96
+ // `claudeUserEntry` `src/mcpstate.mjs` 있다 (위 import).
97
+ // 여기 두었다가 doctor 가 같은 판정을 한 벌 더 쓰게 됐고, 둘이 어긋나면
98
+ // "등록기는 넣었다는데 doctor 는 없다고 한다" 가 된다. 판정은 한 곳에만 둔다.
105
99
 
106
100
  /**
107
101
  * 이미 쓸 만한 항목인가.