axmap-cli 1.2.4 → 1.3.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/mcp/server.mjs CHANGED
@@ -46,8 +46,6 @@ import path from 'node:path'
46
46
  import readline from 'node:readline'
47
47
  import { fileURLToPath } from 'node:url'
48
48
 
49
- import { readTarget } from '../src/repotarget.mjs'
50
-
51
49
  const HERE = path.dirname(fileURLToPath(import.meta.url))
52
50
 
53
51
  /**
@@ -59,6 +57,10 @@ const HERE = path.dirname(fileURLToPath(import.meta.url))
59
57
  */
60
58
  const SELF = path.resolve(HERE, '..')
61
59
  const CLI = path.join(SELF, 'bin', 'axmap.mjs')
60
+ // Vendored copies may omit package.json; the protocol handshake must still work.
61
+ const VERSION = (() => {
62
+ try { return JSON.parse(fs.readFileSync(path.join(SELF, 'package.json'), 'utf8')).version } catch { return 'unknown' }
63
+ })()
62
64
 
63
65
  /**
64
66
  * 대상 저장소 — **지금 이 서버가 겨누고 있는 폴더.**
@@ -67,32 +69,52 @@ const CLI = path.join(SELF, 'bin', 'axmap.mjs')
67
69
  * 끝이어서, 사람이 앱에서 다른 저장소를 열어도 MCP 는 처음 폴더를 계속 붙잡고 있었다.
68
70
  * 지금은 **도구를 부를 때마다** `syncRepo()` 가 다시 정한다.
69
71
  */
72
+ let clientRoots = null
73
+ let rootsError = null
74
+ let rootsSupported = false
75
+ let rootsTask = null
76
+ let rootsGeneration = 0
77
+ let rootRequestId = 0
78
+ const pendingRoots = new Map()
70
79
  let REPO = resolveRepo()
71
80
 
72
- /**
73
- * 대상 저장소를 정하는 규칙.
74
- *
75
- * 🔴 **사람에게는 아무것도 묻지 않는다.** 파일 한 개를 읽고 git 에게 한 번 물어보는
76
- * 것이 전부다. 사람의 입력은 앱에서 폴더를 고르는 그 한 번뿐이다.
77
- *
78
- * 순서에 근거가 있다.
79
- *
80
- * 1. `AXMAP_REPO` — 사람이 "여기로 고정" 이라고 **명시한** 것. 명시는 항상 이긴다.
81
- * 뒤로 미루면 고정해 둔 사람이 "왜 안 지켜지지" 를 겪고, 그러면 설정을 못 믿는다.
82
- * (`tools/mcp-register.mjs` 는 이 값을 **일부러 안 적는다** — 적어 두면 등록은
83
- * 전역인데 대상은 한 곳으로 굳어, 다른 저장소를 열어도 엉뚱한 장부에 쌓인다.)
84
- * 2. `~/.axmap/current.json` — 앱에서 [폴더 열기] 로 **고른** 것. (`src/repotarget.mjs`)
85
- * 3. git 루트 — CLI 를 `FE/` 같은 하위 폴더에서 켰어도 저장소 하나를 가리키게 한다.
86
- * `bin/axmap.mjs` 의 `repoRoot()` 가 처음부터 이렇게 했다. 여기만 안 하고 있었다.
87
- * 4. cwd — git 저장소가 아닐 때의 마지막 답.
88
- */
89
- function resolveRepo() {
90
- if (process.env.AXMAP_REPO) return path.resolve(process.env.AXMAP_REPO)
91
- const chosen = readTarget()
92
- if (chosen) return chosen
93
- const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', windowsHide: true })
81
+ function gitRoot(cwd) {
82
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', windowsHide: true })
94
83
  const top = r.status === 0 ? (r.stdout ?? '').trim() : ''
95
- return path.resolve(top || process.cwd())
84
+ return top ? fs.realpathSync(top) : null
85
+ }
86
+
87
+ /** Explicit pin > per-call cwd > client roots > launch cwd.
88
+ * A global desktop selection must never redirect another CLI session. */
89
+ function resolveRepo(args = {}) {
90
+ if (process.env.AXMAP_REPO) {
91
+ const pinned = path.resolve(process.env.AXMAP_REPO)
92
+ return gitRoot(pinned) || pinned
93
+ }
94
+ if (args.cwd !== undefined) {
95
+ if (typeof args.cwd !== 'string' || !path.isAbsolute(args.cwd)) {
96
+ throw new Error('cwd 는 현재 작업 폴더의 절대 경로여야 합니다.')
97
+ }
98
+ const top = gitRoot(args.cwd)
99
+ if (!top) throw new Error(`작업 폴더에서 Git 저장소를 찾지 못했습니다: ${args.cwd}`)
100
+ return top
101
+ }
102
+ if (rootsError) throw new Error(`작업 폴더를 확인하지 못했습니다: ${rootsError}`)
103
+ if (clientRoots !== null) {
104
+ const dirs = [...new Set(clientRoots.map((root) => {
105
+ if (typeof root?.uri !== 'string' || !root.uri.startsWith('file:')) {
106
+ throw new Error('클라이언트가 유효한 file:// 작업 폴더를 보내지 않았습니다.')
107
+ }
108
+ const top = gitRoot(fileURLToPath(root.uri))
109
+ if (!top) throw new Error(`작업 폴더에서 Git 저장소를 찾지 못했습니다: ${root.uri}`)
110
+ return top
111
+ }))]
112
+ if (dirs.length === 1) return dirs[0]
113
+ const launched = gitRoot(process.cwd())
114
+ if (dirs.includes(launched)) return launched
115
+ throw new Error('작업 저장소를 하나로 정할 수 없습니다. 도구의 cwd 에 작업 폴더 절대 경로를 지정하세요.')
116
+ }
117
+ return gitRoot(process.cwd()) || process.cwd()
96
118
  }
97
119
 
98
120
  /** axMap 저장소 자신을 겨누고 있는가. 브리핑이 무엇을 읽을지가 여기서 갈린다. */
@@ -363,28 +385,13 @@ const TOOLS = [
363
385
  inputSchema: { type: 'object', properties: {} },
364
386
  },
365
387
 
366
- /**
367
- * 🔴 왜 `init` 이 MCP 도구여야 하는가.
368
- *
369
- * 장부 없는 저장소에서 첫 `ax_claim` 은 이렇게 떨어진다:
370
- *
371
- * 장부가 없습니다. 먼저 실행하세요:
372
- * axmap init
373
- *
374
- * 메시지는 정확한데 **그 명령을 부를 방법이 도구 목록에 없었다.** 에이전트는
375
- * 지시를 읽고도 실행할 수 없고, 사람이 셸로 내려가야만 풀린다. 붙이자마자
376
- * 막히는 벽이 정확히 여기였다 — 그리고 이 저장소 안에서만 테스트하면
377
- * 장부가 이미 있으므로 한 번도 안 밟는다.
378
- *
379
- * 이 도구는 판정을 새로 하지 않는다. `bin/axmap.mjs init` 을 그대로 부른다.
380
- */
381
388
  {
382
389
  name: 'ax_init',
383
390
  description:
384
- '이 저장소에 장부가 없을 때 한 번 부른다. 장부 worktree(.axmap/ledger)와 브랜치(axmap/claims)를 만든다. '
391
+ '명시적으로 장부와 부가 저장소를 준비한다. 일반적인 선점·조회는 장부를 자동으로 준비하므로 먼저 부를 필요가 없다. '
385
392
  + '이미 있으면 아무것도 바꾸지 않으므로 여러 번 불러도 안전하다. '
386
- + 'ax_claim 이 "장부가 없습니다" 로 실패하면 이것을 부른 뒤 다시 claim 하십시오.',
387
- inputSchema: { type: 'object', properties: {} },
393
+ + '원격을 직접 고르려면 remote 에 Git 원격 이름을 지정한다.',
394
+ inputSchema: { type: 'object', properties: { remote: { type: 'string', description: 'Git 원격 이름' } } },
388
395
  },
389
396
  {
390
397
  name: 'ax_claim',
@@ -473,6 +480,13 @@ const TOOLS = [
473
480
 
474
481
  // ── 브리핑 ──────────────────────────────────────────────────────────────────
475
482
 
483
+ for (const tool of TOOLS) {
484
+ tool.inputSchema.properties.cwd = {
485
+ type: 'string',
486
+ description: '현재 작업 폴더의 절대 경로. roots 미지원 또는 여러 repo가 열려 있을 때 지정. 생략하면 세션의 작업 repo를 사용한다.',
487
+ }
488
+ }
489
+
476
490
  // `SELF` · `REPO` · `isForeign()` 은 파일 맨 위에 있다. 기준은 거기 적어 두었다.
477
491
 
478
492
  const readFrom = (base, rel) => {
@@ -691,14 +705,8 @@ function brief() {
691
705
  }
692
706
  }
693
707
 
694
- // 🔴 막다른 길을 미리 없앤다. 장부가 없으면 claim 이 전부 실패하는데,
695
- // 브리핑은 보통 claim 보다 먼저 불린다. 여기서 말해 주면 한 번에 이어진다.
696
- if (!fs.existsSync(path.join(REPO, '.axmap', 'ledger', '.git'))) {
697
- out.push('## 🔴 이 저장소에는 아직 장부가 없다')
698
- out.push('')
699
- out.push('`ax_init` 을 먼저 부른다. 그 전에는 claim 이 전부 실패한다.')
700
- out.push('')
701
- }
708
+ out.push('장부는 선점·조회 시 자동으로 준비한다. 별도 `ax_init` 호출은 필요 없다.')
709
+ out.push('')
702
710
 
703
711
  out.push('## 이 도구를 쓰는 순서')
704
712
  out.push('')
@@ -725,7 +733,7 @@ function brief() {
725
733
  * 지금 `REPO` 가 아니라 **떠나는 저장소**를 겨눠야 한다.
726
734
  */
727
735
  function releaseAllIn(dir) {
728
- const r = spawnSync(process.execPath, [CLI, 'release'], {
736
+ const r = spawnSync(process.execPath, [CLI, 'release', '--if-present'], {
729
737
  cwd: dir,
730
738
  encoding: 'utf8',
731
739
  windowsHide: true,
@@ -738,7 +746,7 @@ function releaseAllIn(dir) {
738
746
  }
739
747
 
740
748
  /**
741
- * 사람이 앱에서 다른 저장소를 열었으면 따라간다.
749
+ * 이 세션의 클라이언트 작업 폴더가 바뀌면 따라간다.
742
750
  *
743
751
  * 🔴 **떠나기 전에 잡아둔 것을 전부 반납한다.**
744
752
  *
@@ -754,8 +762,8 @@ function releaseAllIn(dir) {
754
762
  * 반납 실패를 **숨기지 않는다.** 조용히 넘어가면 위의 상황이 그대로 일어나는데
755
763
  * 아무도 그것을 모른다.
756
764
  */
757
- function syncRepo() {
758
- const next = resolveRepo()
765
+ function syncRepo(args) {
766
+ const next = resolveRepo(args)
759
767
  if (next === REPO) return null
760
768
  const from = REPO
761
769
  const r = releaseAllIn(from)
@@ -785,37 +793,10 @@ ${s.out}`
785
793
  * `ax_claim` 이 쓰는 장부가 서로 다른 저장소일 수 있다. 그러면 에이전트는
786
794
  * 자기가 본 것을 근거로 확신 있게 틀린 답을 만든다 — 사람이 못 잡는 종류다.
787
795
  */
788
- /**
789
- * 🔴 **막다른 길을 남기지 않는다 — 한 곳에서.**
790
- *
791
- * 장부가 없는 저장소에서 CLI 는 어느 명령이든 이렇게 답한다:
792
- *
793
- * 장부가 없습니다. 먼저 실행하세요:
794
- * axmap init
795
- *
796
- * 메시지는 정확한데 **에이전트에게는 셸이 없다.** 부를 수 있는 이름으로 바꿔줘야
797
- * 한다. 예전에는 그 변환이 `ax_claim` 안에만 있었고, `ax_check` · `ax_status` ·
798
- * `ax_release` · `ax_renew` 는 원문을 그대로 냈다. 하필 `ax_check` 는 설명이
799
- * "작업 계획을 세울 때 쓴다" 라 **claim 보다 먼저 불리는 도구**다 — 처음 온
800
- * 사람은 고쳐둔 안내를 못 보고 원래의 막다른 길을 먼저 밟았다.
801
- *
802
- * 그래서 도구마다 복사하지 않고 **길목에서 한 번** 본다. 도구가 늘어나도
803
- * 새 도구가 이 안내를 빠뜨릴 수 없고, 다섯 군데가 따로 낡지도 않는다.
804
- * 판정하지 않고 **CLI 가 낸 말을 볼 뿐**이라는 점이 중요하다 — 도구 이름
805
- * 목록으로 거르면 그 목록이 코드보다 먼저 낡는다.
806
- */
807
- const INIT_HINT = '\n\n→ MCP 에서는 ax_init 을 부르십시오. 그 뒤 이 도구를 다시 시도하면 됩니다.'
808
-
809
- function withInitHint(r) {
810
- if (r.ok) return r
811
- const t = r.text ?? ''
812
- if (!t.includes('장부가 없습니다') || t.includes('ax_init')) return r
813
- return { ...r, text: t + INIT_HINT }
814
- }
815
-
816
796
  function callTool(name, args = {}) {
817
- const switched = syncRepo()
818
- const r = withInitHint(dispatch(name, args))
797
+ let switched
798
+ try { switched = syncRepo(args) } catch (e) { return { ok: false, text: e.message } }
799
+ const r = dispatch(name, args)
819
800
  return switched ? { ...r, text: `${switchNotice(switched)}
820
801
 
821
802
  ${r.text}` } : r
@@ -828,7 +809,7 @@ function dispatch(name, args = {}) {
828
809
  case 'ax_brief':
829
810
  return { ok: true, text: brief() }
830
811
  case 'ax_init': {
831
- const r = cli(['init'])
812
+ const r = cli(['init', ...(args.remote ? ['--remote', String(args.remote)] : [])])
832
813
  return {
833
814
  ok: r.code === 0,
834
815
  text: r.code === 0
@@ -984,26 +965,57 @@ function send(msg) {
984
965
  process.stdout.write(JSON.stringify(msg) + '\n')
985
966
  }
986
967
 
987
- function handle(req) {
968
+ function refreshRoots() {
969
+ const generation = ++rootsGeneration
970
+ const id = `axmap-roots-${++rootRequestId}`
971
+ rootsTask = new Promise((resolve) => {
972
+ const timer = setTimeout(() => {
973
+ pendingRoots.delete(id)
974
+ if (generation === rootsGeneration) rootsError = 'roots/list 응답 시간 초과'
975
+ resolve()
976
+ }, 5000)
977
+ pendingRoots.set(id, (msg) => {
978
+ clearTimeout(timer)
979
+ if (generation === rootsGeneration) {
980
+ const roots = msg.result?.roots
981
+ rootsError = msg.error?.message || (!Array.isArray(roots) ? '잘못된 roots/list 응답' : null)
982
+ clientRoots = Array.isArray(roots) ? roots : null
983
+ }
984
+ resolve()
985
+ })
986
+ send({ jsonrpc: '2.0', id, method: 'roots/list' })
987
+ })
988
+ }
989
+
990
+ async function handle(req) {
988
991
  const { id, method, params } = req
989
992
 
990
993
  if (method === 'initialize') {
994
+ rootsSupported = !!params?.capabilities?.roots
991
995
  return {
992
996
  protocolVersion: '2024-11-05',
993
997
  capabilities: { tools: {} },
994
- serverInfo: { name: 'axmap', version: '0.1.0' },
998
+ serverInfo: { name: 'axmap', version: VERSION },
995
999
  }
996
1000
  }
997
1001
  if (method === 'tools/list') return { tools: TOOLS }
998
1002
  if (method === 'tools/call') {
1003
+ if (rootsSupported && !process.env.AXMAP_REPO) {
1004
+ if (!rootsTask || rootsError) refreshRoots()
1005
+ // A change notification may replace the task while the previous reply arrives.
1006
+ let task
1007
+ do { task = rootsTask; await task } while (task !== rootsTask)
1008
+ }
999
1009
  const r = callTool(params?.name, params?.arguments ?? {})
1000
- return { content: [{ type: 'text', text: r.text + unreadBanner(params?.name, r.text) }], isError: !r.ok }
1010
+ return { content: [{ type: 'text', text: r.text + (r.ok ? unreadBanner(params?.name, r.text) : '') }], isError: !r.ok }
1001
1011
  }
1002
1012
  if (method === 'ping') return {}
1003
1013
  return null // 알림이거나 지원하지 않는 메서드
1004
1014
  }
1005
1015
 
1006
1016
  const rl = readline.createInterface({ input: process.stdin })
1017
+ // Execute tools in order, but keep reading client replies while a tool awaits roots.
1018
+ let requests = Promise.resolve()
1007
1019
  rl.on('line', (line) => {
1008
1020
  const s = line.trim()
1009
1021
  if (!s) return
@@ -1013,18 +1025,29 @@ rl.on('line', (line) => {
1013
1025
  } catch {
1014
1026
  return
1015
1027
  }
1016
- // 알림(id 없음)에는 답하지 않는다
1017
- if (req.id === undefined) return
1018
- try {
1019
- const result = handle(req)
1020
- if (result === null) {
1021
- send({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: `지원하지 않는 메서드: ${req.method}` } })
1022
- } else {
1023
- send({ jsonrpc: '2.0', id: req.id, result })
1024
- }
1025
- } catch (e) {
1026
- send({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: e.message } })
1028
+ if (!req || typeof req !== 'object') return
1029
+ if (!req.method) {
1030
+ const done = pendingRoots.get(req.id)
1031
+ if (done) { pendingRoots.delete(req.id); done(req) }
1032
+ return
1033
+ }
1034
+ if (req.id === undefined) {
1035
+ if (rootsSupported && !process.env.AXMAP_REPO &&
1036
+ ['notifications/initialized', 'notifications/roots/list_changed'].includes(req.method)) refreshRoots()
1037
+ return
1027
1038
  }
1039
+ requests = requests.then(async () => {
1040
+ try {
1041
+ const result = await handle(req)
1042
+ if (result === null) {
1043
+ send({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: `지원하지 않는 메서드: ${req.method}` } })
1044
+ } else {
1045
+ send({ jsonrpc: '2.0', id: req.id, result })
1046
+ }
1047
+ } catch (e) {
1048
+ send({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: e.message } })
1049
+ }
1050
+ })
1028
1051
  })
1029
1052
 
1030
1053
  process.stderr.write(`axMap MCP · 저장소 ${REPO} · 에이전트 ${AGENT} (${ACTOR})\n`)
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.2.4",
4
+ "version": "1.3.0",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -12,7 +12,7 @@
12
12
  "license": "MIT",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "git+https://lab.ssafy.com/rleaderjoon/axmap.git"
15
+ "url": "git+https://github.com/rl2aderjoon/axmap.git"
16
16
  },
17
17
  "keywords": [
18
18
  "ai-agent",
@@ -52,5 +52,9 @@
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=20"
55
+ },
56
+ "homepage": "https://github.com/rl2aderjoon/axmap#readme",
57
+ "bugs": {
58
+ "url": "https://github.com/rl2aderjoon/axmap/issues"
55
59
  }
56
60
  }
package/src/overlap.mjs CHANGED
@@ -27,7 +27,7 @@
27
27
  *
28
28
  * 실측으로 확인했다. 두 검사는 **정확히 엇갈린다** —
29
29
  * 뒤처짐 검사는 20:15 건을 못 잡고, 안 합쳐진 브랜치 검사는 22:31 건을 못 잡는다.
30
- * 그래서 둘 다 있어야 한다. (근거: axMap `docs/26.09.11/04-무엇으로-막나.md`)
30
+ * 그래서 둘 다 있어야 한다. (근거: axMap `docs/26.09/26.09.11/04-무엇으로-막나.md`)
31
31
  *
32
32
  * ── 🔴 왜 숫자를 경로로 자르는가 ────────────────────────────────────────────
33
33
  *
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs'
2
+ import crypto from 'node:crypto'
3
+
4
+ /** Serialize bootstrap across processes and linked worktrees. Never steal a lock:
5
+ * a slow remote must not let a second initializer change the same refs/index. */
6
+ export function withRepoLock(file, action, timeoutMs = 30000) {
7
+ const owner = JSON.stringify({ pid: process.pid, token: crypto.randomUUID() })
8
+ const deadline = Date.now() + timeoutMs
9
+ for (;;) {
10
+ try {
11
+ fs.writeFileSync(file, owner, { flag: 'wx' })
12
+ break
13
+ } catch (e) {
14
+ if (e.code !== 'EEXIST') throw e
15
+ if (Date.now() >= deadline) {
16
+ throw new Error(`장부 준비 잠금을 기다리다 중단했습니다: ${file}\n` +
17
+ '다른 초기화가 끝난 뒤 다시 시도하세요. 비정상 종료로 남은 잠금은 소유 프로세스가 종료됐는지 확인한 뒤 제거하세요.')
18
+ }
19
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
20
+ }
21
+ }
22
+ const release = () => {
23
+ try { if (fs.readFileSync(file, 'utf8') === owner) fs.unlinkSync(file) } catch { /* already released */ }
24
+ }
25
+ // CLI errors use process.exit; finally alone would leave a permanent lock.
26
+ process.once('exit', release)
27
+ try { return action() } finally {
28
+ release()
29
+ process.removeListener('exit', release)
30
+ }
31
+ }
@@ -1,4 +1,7 @@
1
1
  /**
2
+ * 1.3.0: 이 파일은 데스크톱 선택 상태다. MCP는 세션의 roots/cwd를 사용하며
3
+ * 전역 선택을 읽지 않는다. 아래는 데스크톱과 MCP가 공유하던 이전 설계 기록이다.
4
+ *
2
5
  * 지금 사람이 보고 있는 저장소가 어디인지를 **프로세스 사이에 전달하는 한 칸.**
3
6
  *
4
7
  * ── 왜 파일 한 개인가 ────────────────────────────────────────────────────────
package/src/update.mjs CHANGED
@@ -131,15 +131,33 @@ export const isStale = (rec, nowMs, interval = CHECK_INTERVAL_MS) =>
131
131
  !rec || typeof rec.checkedAt !== 'number' || nowMs - rec.checkedAt >= interval
132
132
 
133
133
  /**
134
- * 사람에게 보여줄 한 줄. 알릴 것이 없으면 `null`.
134
+ * 사람에게 보여줄 안내. 알릴 것이 없으면 `null`.
135
135
  *
136
136
  * 🔴 부르는 쪽은 이것을 **stderr 로** 낸다. stdout 은 `--json` 이 쓰는 자리라,
137
137
  * 여기에 사람이 읽는 글을 섞으면 그 출력을 파싱하는 쪽이 조용히 깨진다.
138
+ *
139
+ * 🔴 **두 줄을 다 낸다 (2026-09-12).** 이 PC 에 판이 두 군데 있기 때문이다 —
140
+ * `axmap` 명령(전역 설치)과 git 훅이 부르는 사본(`~/.axmap/app`). 서로 안
141
+ * 따라간다.
142
+ *
143
+ * 전에는 `npx … setup` 한 줄만 안내했다. 그런데 **`setup` 은 전역 설치를 전혀
144
+ * 건드리지 않는다** (`tools/setup.mjs` 에 `npm i` 가 0건이다 — 거기 있는
145
+ * `--commands global` 의 "global" 은 슬래시 명령을 어디 둘지이지 전역 설치가
146
+ * 아니다). 그래서 그 안내를 그대로 따르면 **사본만 새 판이 되고 `axmap` 명령은
147
+ * 옛 판으로 남는다.**
148
+ *
149
+ * 그건 이 명령이 1.2.4 에서 고친 문제(사본이 낡았는데 "최신입니다" 라고 한 것)를
150
+ * **안내가 스스로 만들어 내는 것**이다. 반쪽짜리 안내는 틀린 안내보다 낫지
151
+ * 않다 — 시킨 대로 했는데 안 고쳐지면 사람은 도구를 안 믿게 된다.
138
152
  */
139
153
  export function noticeLine(current, latest) {
140
154
  if (!current || !latest) return null
141
155
  if (compareVersions(latest, current) <= 0) return null
142
- return `새 버전이 있습니다: ${current} → ${latest}\n 받으려면: npx -y ${PACKAGE_NAME}@latest setup`
156
+ return `새 버전이 있습니다: ${current} → ${latest}\n`
157
+ + ` 받으려면: npm i -g ${PACKAGE_NAME}@latest\n`
158
+ + ` npx -y ${PACKAGE_NAME}@latest setup\n`
159
+ + ' 🔴 두 줄 다 필요합니다 — 앞은 axmap 명령을, 뒤는 git 훅이 부르는 사본을\n'
160
+ + ' 올립니다. 서로 안 따라갑니다.'
143
161
  }
144
162
 
145
163
  /**
package/tools/smoke.mjs CHANGED
@@ -26,6 +26,7 @@ import { spawn, spawnSync, execFileSync } from 'node:child_process'
26
26
  import fs from 'node:fs'
27
27
  import path from 'node:path'
28
28
  import { fileURLToPath } from 'node:url'
29
+ import { detect } from '../app/lib/agentcli.mjs'
29
30
 
30
31
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
31
32
  const PORT = Number(process.env.AXMAP_SMOKE_PORT ?? 7912)
@@ -92,9 +93,16 @@ const OPENED = [
92
93
  { what: '온보딩이 닫혔다', re: /id="onboard" hidden/ },
93
94
  ]
94
95
 
96
+ // 검사 PC의 설치된 CLI를 선택한다. 특정 팀의 Claude 설치에 의존하지 않는다.
97
+ const agent = detect().find((a) => a.installed)
98
+ if (!agent) {
99
+ console.error('온보딩 이후 화면 검증에 필요한 AI CLI가 없습니다. Claude Code, agy 또는 Codex CLI를 설치하세요.')
100
+ process.exit(1)
101
+ }
102
+
95
103
  const PAGES = [
96
104
  { path: '/', what: '처음 오는 사람', must: FIRST },
97
- { path: '/?agent=claude', what: '고르기를 끝낸 사람', must: OPENED },
105
+ { path: `/?agent=${encodeURIComponent(agent.id)}`, what: '고르기를 끝낸 사람', must: OPENED },
98
106
  ]
99
107
 
100
108
  async function waitUp(ms = 120_000) {