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
@@ -0,0 +1,490 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx axmap setup` 의 본체 — **저장소를 clone 하지 않고** 이 PC 와 이 저장소에
4
+ * axMap 을 붙인다.
5
+ *
6
+ * npx -y axmap-cli@latest setup
7
+ * npx -y axmap-cli@latest setup --remote https://lab.ssafy.com/우리팀/우리저장소.git
8
+ *
9
+ * ── 이 파일이 메우는 구멍 ───────────────────────────────────────────────────
10
+ *
11
+ * `mcp/install.sh` · `mcp/install.ps1` 은 이미 "홈에 복사하고 이 PC 의 AI CLI 에
12
+ * 등록한다" 를 한다. 다만 **axMap 파일이 이미 손에 있어야** 시작할 수 있다 —
13
+ * 스크립트만 따로 내려받으면 대놓고 거절한다. 그래서 지금까지 설치의 첫 단계는
14
+ * 언제나 "저장소를 통째로 clone" 이었다.
15
+ *
16
+ * npm 꾸러미가 그 첫 단계를 대신한다. `npx` 가 꾸러미를 받아 풀어 주므로,
17
+ * 이 파일이 도는 시점에는 axMap 원본이 이미 옆에 있다. 여기서는 그 뒤를 잇는다.
18
+ *
19
+ * 1. 이 PC 에 프로그램을 둔다 → mcp/install.{sh,ps1} 에 맡긴다 (다시 안 짠다)
20
+ * 2. 이 PC 의 AI CLI 에 등록한다 → 위와 같음
21
+ * 3. 대상 저장소에 장부를 만든다 → axmap init
22
+ * 4. 커밋 훅을 심는다 → axmap hook install
23
+ * 5. 슬래시 명령을 놓는다 → ~/.claude/commands/ (또는 저장소 안)
24
+ *
25
+ * ── 🔴 git 을 떼어내지 않는다 ───────────────────────────────────────────────
26
+ *
27
+ * 없앤 것은 **"axMap 코드를 받는 수단으로서의 git"**(clone·벤더링·git pull)이지
28
+ * **"협업 백엔드로서의 git"** 이 아니다. 장부와 쪽지는 그대로 고아 브랜치
29
+ * (`axmap/claims` · `axmap/bus`)로 오간다. git push 의 ref CAS(Compare-And-Swap —
30
+ * "내가 읽은 뒤로 바뀐 게 없을 때만 쓴다") 가 직렬화를 공짜로 주기 때문이고,
31
+ * 그것을 버리면 락 서버를 따로 세워야 한다. 그건 다른 제품이다.
32
+ *
33
+ * 그래서 대상이 git 저장소가 아니면 **여기서 멈춘다.** 반쯤 되는 상태로 두면
34
+ * claim 이 아무에게도 안 가면서 조용히 성공한다 — 이 도구에서 제일 나쁜 실패다.
35
+ *
36
+ * ── 종료 코드 ───────────────────────────────────────────────────────────────
37
+ *
38
+ * 0 다 됐다
39
+ * 1 시작할 수 없다 (node 가 낮다 · git 저장소가 아니다 · 이름을 못 정했다)
40
+ * 2 일부가 안 됐다. 무엇이 안 됐는지 !! 줄에 있다
41
+ */
42
+
43
+ import { spawnSync } from 'node:child_process'
44
+ import fs from 'node:fs'
45
+ import os from 'node:os'
46
+ import path from 'node:path'
47
+ import { fileURLToPath } from 'node:url'
48
+
49
+ import { agentNameError } from '../src/protocol.mjs'
50
+
51
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
52
+ const SOURCE_ROOT = path.resolve(HERE, '..')
53
+
54
+ const EXIT = { OK: 0, CANNOT_START: 1, PARTIAL: 2 }
55
+
56
+ // ── 화면 ────────────────────────────────────────────────────────────────────
57
+
58
+ const tty = process.stdout.isTTY
59
+ const C = tty
60
+ ? { g: '\u001b[32m', y: '\u001b[33m', r: '\u001b[31m', d: '\u001b[2m', x: '\u001b[0m' }
61
+ : { g: '', y: '', r: '', d: '', x: '' }
62
+
63
+ let bad = 0
64
+ const say = (s = '') => console.log(s)
65
+ const ok = (what, detail) => say(`${C.g} OK ${C.x}${String(what).padEnd(10)} ${detail}`)
66
+ const hm = (what, detail) => say(`${C.y} ~~ ${C.x}${String(what).padEnd(10)} ${detail}`)
67
+ const no = (what, detail) => { bad++; say(`${C.r} !! ${C.x}${String(what).padEnd(10)} ${detail}`) }
68
+
69
+ function die(msg) {
70
+ console.error(`\n${C.r}중단: ${msg}${C.x}\n`)
71
+ process.exit(EXIT.CANNOT_START)
72
+ }
73
+
74
+ // ── 인자 ────────────────────────────────────────────────────────────────────
75
+
76
+ const argv = process.argv.slice(2)
77
+ const flag = (n) => {
78
+ const i = argv.indexOf(n)
79
+ if (i < 0) return null
80
+ const v = argv[i + 1]
81
+ return v && !v.startsWith('--') ? v : null
82
+ }
83
+ const has = (n) => argv.includes(n)
84
+
85
+ if (has('-h') || has('--help')) {
86
+ say(`
87
+ axmap setup — 이 PC 와 이 저장소에 axMap 을 붙인다 (clone 하지 않는다)
88
+
89
+ npx -y axmap-cli@latest setup
90
+ npx -y axmap-cli@latest setup --remote <이름 또는 git 주소>
91
+
92
+ --repo <경로> 선점을 걸 저장소 (기본: 지금 폴더의 git 루트)
93
+ --remote <이름|URL> 장부를 둘 원격. git 주소를 주면, 그 주소를 가리키는 원격이
94
+ 없을 때 'axmap' 이라는 이름으로 하나 더한다
95
+ --name <이름> 장부에 찍힐 내 이름 (기본: git config user.name)
96
+ --dest <경로> 프로그램을 둘 자리 (기본: ~/.axmap/app)
97
+ --commands <곳> 슬래시 명령을 둘 자리: global(기본) · project · none
98
+ --hook 프롬프트마다 쪽지를 받는 하네스 훅을 settings.json 에 더한다
99
+ (기본은 안 더한다 — 매 턴 도는 명령이라 사람이 고를 일이다)
100
+ --no-register 이 PC 의 AI CLI 등록을 건너뛴다
101
+ --no-ledger 장부·훅을 건드리지 않는다 (프로그램 설치만)
102
+ --dry-run 아무것도 쓰지 않고 무엇을 할지만 찍는다
103
+
104
+ 종료 코드: 0 성공 · 1 시작 불가 · 2 일부 실패
105
+ `)
106
+ process.exit(EXIT.OK)
107
+ }
108
+
109
+ const DRY = has('--dry-run')
110
+ const commandsWhere = flag('--commands') ?? 'global'
111
+ if (!['global', 'project', 'none'].includes(commandsWhere)) {
112
+ die(`--commands 는 global · project · none 중 하나입니다: ${commandsWhere}`)
113
+ }
114
+
115
+ // ── git ─────────────────────────────────────────────────────────────────────
116
+
117
+ function git(args, cwd) {
118
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true })
119
+ return { code: r.status ?? 1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }
120
+ }
121
+
122
+ // ── 1. node ─────────────────────────────────────────────────────────────────
123
+
124
+ const major = Number(process.versions.node.split('.')[0])
125
+
126
+ say('')
127
+ say(`axMap 설치${DRY ? `${C.d} (dry-run — 아무것도 쓰지 않습니다)${C.x}` : ''}`)
128
+ say('')
129
+
130
+ if (major < 20) die(`Node v${process.versions.node} 입니다. 20 이상이 필요합니다.`)
131
+ ok('node', `v${process.versions.node}`)
132
+
133
+ // ── 2. 대상 저장소 ──────────────────────────────────────────────────────────
134
+ //
135
+ // 🔴 여기서 멈추는 것이 맞다. 위의 "git 을 떼어내지 않는다" 를 보라.
136
+ const where = path.resolve(flag('--repo') ?? process.cwd())
137
+ if (!fs.existsSync(where)) die(`그런 폴더가 없습니다: ${where}`)
138
+
139
+ const top = git(['rev-parse', '--show-toplevel'], where)
140
+ if (top.code !== 0) {
141
+ die(
142
+ `git 저장소가 아닙니다: ${where}\n\n` +
143
+ ` axMap 의 장부와 쪽지는 git 의 고아 브랜치로 오갑니다. git push 의 CAS\n` +
144
+ ` (내가 읽은 뒤로 바뀐 게 없을 때만 쓴다)가 여러 사람의 동시 선점을 줄 세워\n` +
145
+ ` 주기 때문입니다. 저장소가 없으면 그 줄이 없어, claim 이 아무에게도 가지\n` +
146
+ ` 않으면서 조용히 성공합니다.\n\n` +
147
+ ` 먼저 작업할 저장소 안으로 들어간 뒤 다시 실행하세요:\n` +
148
+ ` cd <저장소> && npx -y axmap-cli@latest setup`,
149
+ )
150
+ }
151
+ const REPO = path.resolve(top.out)
152
+ ok('저장소', REPO)
153
+
154
+ // ── 3. 이름 ─────────────────────────────────────────────────────────────────
155
+ //
156
+ // [!] 모두에게 같은 기본값을 주면 안 된다. 장부에 한 명만 존재하게 되고, 겹침
157
+ // 판정은 자기 claim 을 겹침으로 보지 않으므로 팀 전원이 서로의 영역을 아무
158
+ // 경고 없이 덮어쓴다. 그래서 이 PC 에서 이미 사람마다 다른 값만 후보로 쓴다.
159
+ let name = flag('--name')
160
+ let nameFrom = '--name'
161
+ if (!name) {
162
+ const g = git(['config', 'user.name'], REPO)
163
+ if (g.code === 0 && g.out) { name = g.out; nameFrom = 'git config user.name' }
164
+ }
165
+ if (!name) {
166
+ try { name = os.userInfo().username; nameFrom = 'OS 사용자 이름' } catch { /* 아래 */ }
167
+ }
168
+ if (!name) { name = os.hostname(); nameFrom = '호스트 이름' }
169
+ if (!name) die('이 PC 에서 쓸 이름을 정하지 못했습니다. --name "홍길동" 으로 직접 주세요.')
170
+
171
+ // 검사기를 다시 짜지 않는다. 여기서 통과시킨 이름을 서버가 거부하면 설치가 거짓말이 된다.
172
+ const nameErr = agentNameError(String(name))
173
+ if (nameErr) die(`이 이름은 장부에서 쓸 수 없습니다: ${name}\n\n${nameErr}`)
174
+ ok('내 이름', `${name} (${nameFrom})`)
175
+
176
+ // ── 4. 장부를 둘 원격 ───────────────────────────────────────────────────────
177
+ //
178
+ // 사용자가 "자기 git 주소" 를 넣는 자리가 여기다. 코드가 아니라 **설정**으로
179
+ // 남는다 — `git config axmap.remote`. 이 저장소가 이미 쓰던 방식이고
180
+ // (`bin/axmap.mjs` 의 `resolveRemote`), 새 방식을 만들지 않았다.
181
+ //
182
+ // 순서는 그쪽이 정한 그대로다: `--remote` → `AXMAP_REMOTE` → `git config
183
+ // axmap.remote` → 유일한 원격 → origin. 여기서는 맨 앞의 한 칸만 채운다.
184
+ const looksLikeUrl = (s) => /^[a-z][a-z0-9+.-]*:\/\//i.test(s) || /^[^/]+@[^/]+:/.test(s) || /\.git$/i.test(s)
185
+
186
+ function configureRemote(spec) {
187
+ if (!spec) {
188
+ const cur = git(['config', '--get', 'axmap.remote'], REPO)
189
+ if (cur.code === 0 && cur.out) ok('장부 원격', `${cur.out} (이미 git config axmap.remote 에 있습니다)`)
190
+ else hm('장부 원격', '지정하지 않았습니다 — axmap init 이 정합니다 (원격이 하나면 그것, origin 이 있으면 origin)')
191
+ return
192
+ }
193
+
194
+ const names = git(['remote'], REPO).out.split('\n').map((s) => s.trim()).filter(Boolean)
195
+ let picked = null
196
+
197
+ if (looksLikeUrl(spec)) {
198
+ // 같은 주소를 가리키는 원격이 이미 있으면 그것을 쓴다. 원격을 새로 만드는 것은
199
+ // 마지막 수단이다 — 같은 곳을 가리키는 원격이 둘이면 사람이 헷갈린다.
200
+ for (const n of names) {
201
+ const url = git(['remote', 'get-url', n], REPO).out
202
+ if (url && url.replace(/\.git$/, '') === spec.replace(/\.git$/, '')) { picked = n; break }
203
+ }
204
+ if (!picked) {
205
+ if (DRY) { hm('장부 원격', `(dry-run) 'axmap' 이라는 원격을 더했을 것입니다 → ${spec}`); picked = 'axmap' }
206
+ else if (names.includes('axmap')) {
207
+ // 이름은 이미 있는데 주소가 다르다. 조용히 갈아끼우지 않는다 — 남의 원격이다.
208
+ no('장부 원격', `'axmap' 이라는 원격이 이미 다른 곳을 가리킵니다: ${git(['remote', 'get-url', 'axmap'], REPO).out}\n 바꾸려면 직접: git remote set-url axmap ${spec}`)
209
+ return
210
+ } else {
211
+ const r = git(['remote', 'add', 'axmap', spec], REPO)
212
+ if (r.code !== 0) { no('장부 원격', `원격을 더하지 못했습니다: ${r.err}`); return }
213
+ picked = 'axmap'
214
+ ok('장부 원격', `'axmap' 이라는 원격을 더했습니다 → ${spec}`)
215
+ }
216
+ } else {
217
+ ok('장부 원격', `이미 있는 원격을 씁니다: ${picked} → ${spec}`)
218
+ }
219
+ } else {
220
+ if (!names.includes(spec)) {
221
+ no('장부 원격', `그런 원격이 없습니다: ${spec}\n 이 저장소의 원격: ${names.join(', ') || '(없음)'}`)
222
+ return
223
+ }
224
+ picked = spec
225
+ }
226
+
227
+ if (DRY) { hm('장부 원격', `(dry-run) git config axmap.remote ${picked}`); return }
228
+ const c = git(['config', 'axmap.remote', picked], REPO)
229
+ if (c.code !== 0) no('장부 원격', `git config 에 적지 못했습니다: ${c.err}`)
230
+ else ok('장부 원격', `git config axmap.remote = ${picked}`)
231
+ }
232
+
233
+ configureRemote(flag('--remote'))
234
+
235
+ // ── 5. 프로그램을 이 PC 에 둔다 + AI CLI 에 등록한다 ────────────────────────
236
+ //
237
+ // 🔴 여기를 다시 짜지 않는다. `mcp/install.sh` · `mcp/install.ps1` 이 이미 하고,
238
+ // 그 둘은 남의 설정 파일을 **읽어서 병합**하며 못 읽으면 손대지 않는다.
239
+ // 설치기를 하나 더 만들면 반드시 한쪽만 고쳐지고, 그때 두 OS 의 사용자가 서로
240
+ // 다른 설정을 갖게 된다.
241
+ const DEST = path.resolve(flag('--dest') ?? path.join(os.homedir(), '.axmap', 'app'))
242
+
243
+ function installAndRegister() {
244
+ if (has('--no-register')) { hm('설치·등록', '--no-register — 건너뜁니다'); return }
245
+ if (DRY) { hm('설치·등록', `(dry-run) ${process.platform === 'win32' ? 'mcp/install.ps1' : 'mcp/install.sh'} 를 돌렸을 것입니다 → ${DEST}`); return }
246
+
247
+ const win = process.platform === 'win32'
248
+ const script = path.join(SOURCE_ROOT, 'mcp', win ? 'install.ps1' : 'install.sh')
249
+ if (!fs.existsSync(script)) { no('설치·등록', `설치기가 없습니다: ${script}`); return }
250
+
251
+ say('')
252
+ const r = win
253
+ ? spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Source', SOURCE_ROOT, '-Dest', DEST, '-Name', name], { stdio: 'inherit', windowsHide: true })
254
+ : spawnSync('bash', [script, '--source', SOURCE_ROOT, '--dest', DEST, '--name', name], { stdio: 'inherit' })
255
+ say('')
256
+
257
+ if (r.status === 0) ok('설치·등록', DEST)
258
+ else no('설치·등록', `설치기가 종료 코드 ${r.status ?? '?'} 를 냈습니다 — 위 줄을 보세요`)
259
+ }
260
+
261
+ installAndRegister()
262
+
263
+ // ── 6. 장부와 훅 ────────────────────────────────────────────────────────────
264
+ //
265
+ // 🔴 **설치된 사본**(`~/.axmap/app`)의 CLI 로 부른다. npx 가 푼 임시 폴더의 것으로
266
+ // 부르면 `hook install` 이 그 임시 경로를 훅에 박고, npx 캐시가 비워지는 순간
267
+ // 훅이 없는 파일을 가리킨다 — 커밋할 때마다 오류가 나는데 원인은 안 보인다.
268
+ function ledgerAndHook() {
269
+ if (has('--no-ledger')) { hm('장부', '--no-ledger — 건너뜁니다'); return }
270
+
271
+ const installed = path.join(DEST, 'bin', 'axmap.mjs')
272
+ const cli = fs.existsSync(installed) ? installed : path.join(SOURCE_ROOT, 'bin', 'axmap.mjs')
273
+ if (cli !== installed) hm('장부', `설치된 사본이 없어 원본으로 돕니다 — 훅에 임시 경로가 박힐 수 있습니다: ${cli}`)
274
+
275
+ if (DRY) { hm('장부', `(dry-run) node ${cli} init · hook install (cwd=${REPO})`); return }
276
+
277
+ const run = (args) => spawnSync(process.execPath, [cli, ...args], {
278
+ cwd: REPO,
279
+ stdio: 'inherit',
280
+ // 이름을 넘겨서 판정을 한 번으로 못 박는다. 여기서 통과한 이름과 나중에 CLI 가
281
+ // 스스로 고르는 이름이 다르면, 자기가 잡은 것을 자기가 반납하지 못한다.
282
+ env: { ...process.env, AXMAP_AGENT: name },
283
+ windowsHide: true,
284
+ })
285
+
286
+ say('')
287
+ const i = run(['init'])
288
+ if (i.status !== 0) { no('장부', `axmap init 이 종료 코드 ${i.status ?? '?'} 를 냈습니다`); return }
289
+ ok('장부', '.axmap/ledger (axmap/claims · axmap/bus)')
290
+
291
+ const h = run(['hook', 'install'])
292
+ if (h.status !== 0) no('커밋 훅', `axmap hook install 이 종료 코드 ${h.status ?? '?'} 를 냈습니다`)
293
+ else ok('커밋 훅', 'pre-commit — claim 하지 않은 파일은 커밋되지 않습니다')
294
+ }
295
+
296
+ ledgerAndHook()
297
+
298
+ // ── 7. 슬래시 명령 ──────────────────────────────────────────────────────────
299
+ //
300
+ // 기본은 **홈**(`~/.claude/commands/`)이다. 전역 설치의 원칙이 "작업할 저장소 안에
301
+ // axMap 파일을 하나도 남기지 않는다" 이기 때문이다 — 남기면 남의 저장소에 우리
302
+ // 파일이 커밋되거나, 남의 .gitignore 를 고치게 만든다. 홈에 두면 어느 저장소를
303
+ // 열어도 `/ax` 가 뜬다.
304
+ //
305
+ // `--commands project` 는 팀 전체가 같은 명령을 공유하고 싶을 때다. 그때는 저장소에
306
+ // 커밋되는 것이 맞다.
307
+ //
308
+ // 🔴 이미 있는 파일을 **덮어쓰지 않는다.** 내용이 같으면 조용히 넘어가고, 다르면
309
+ // 건드리지 않고 알린다. 남이 자기 용도로 고쳐 쓰고 있을 수 있고, 그걸 조용히
310
+ // 지우는 것이 이 작업에서 가장 나쁜 실패다.
311
+ function slashCommands() {
312
+ if (commandsWhere === 'none') { hm('슬래시 명령', '--commands none — 건너뜁니다'); return }
313
+
314
+ const from = path.join(SOURCE_ROOT, '.claude', 'commands')
315
+ if (!fs.existsSync(from)) { no('슬래시 명령', `원본이 없습니다: ${from}`); return }
316
+
317
+ const to = commandsWhere === 'project'
318
+ ? path.join(REPO, '.claude', 'commands')
319
+ : path.join(os.homedir(), '.claude', 'commands')
320
+
321
+ const files = fs.readdirSync(from).filter((f) => f.endsWith('.md')).sort()
322
+ if (!files.length) { no('슬래시 명령', `원본에 .md 가 없습니다: ${from}`); return }
323
+
324
+ if (DRY) { hm('슬래시 명령', `(dry-run) ${files.length}개 → ${to}`); return }
325
+
326
+ try { fs.mkdirSync(to, { recursive: true }) } catch (e) { no('슬래시 명령', `폴더를 만들지 못했습니다 (${e.message}): ${to}`); return }
327
+
328
+ const wrote = []
329
+ const same = []
330
+ const kept = []
331
+ for (const f of files) {
332
+ const src = fs.readFileSync(path.join(from, f), 'utf8')
333
+ const dst = path.join(to, f)
334
+ if (fs.existsSync(dst)) {
335
+ const cur = fs.readFileSync(dst, 'utf8')
336
+ if (cur === src) { same.push(f); continue }
337
+ kept.push(f)
338
+ continue
339
+ }
340
+ try { fs.writeFileSync(dst, src); wrote.push(f) } catch { kept.push(f) }
341
+ }
342
+
343
+ const parts = []
344
+ if (wrote.length) parts.push(`새로 ${wrote.length}개`)
345
+ if (same.length) parts.push(`그대로 ${same.length}개`)
346
+ ok('슬래시 명령', `${parts.join(' · ') || '없음'} → ${to}`)
347
+ if (kept.length) {
348
+ hm('슬래시 명령', `내용이 다른 파일은 **건드리지 않았습니다**: ${kept.join(', ')}\n 바꾸려면 직접 지운 뒤 다시 실행하세요: ${to}`)
349
+ }
350
+ }
351
+
352
+ slashCommands()
353
+
354
+ // ── 8. 쪽지 자동 수신 훅 (하네스 훅) ────────────────────────────────────────
355
+ //
356
+ // 🔴 여기서 말하는 훅은 **git 의 pre-commit 훅이 아니다.** 이름이 같고 하는 일이
357
+ // 전혀 다른 것이 둘 있다. 위 6절의 `hook install` 이 심는 것은 git 훅이고
358
+ // (claim 안 한 파일의 커밋을 막는다), 이것은 **하네스 훅**(harness hook —
359
+ // 에이전트를 돌리는 프로그램이 정해진 시점마다 실행해서 그 출력을 모델 앞에
360
+ // 놓는 명령)이다.
361
+ //
362
+ // 왜 필요한가: 쪽지 알림은 지금 axMap **도구 결과에 얹혀서만** 뜬다. 그래서
363
+ // axMap 도구를 한 번도 안 부르는 세션에는 영영 안 뜬다(docs/BUS-NOTIFY.md 3.2).
364
+ // MCP 규격에는 서버가 모델 컨텍스트에 먼저 글을 넣는 수단이 없으므로(같은 문서
365
+ // 3.1 에서 규격을 확인했다), 그 구멍을 막을 수 있는 것은 하네스뿐이다.
366
+ //
367
+ // 🔴 **기본으로 심지 않는다. `--hook` 을 줘야 심는다.**
368
+ //
369
+ // `settings.json` 은 그 사람의 Claude Code 전체를 지배하는 파일이다(권한·환경
370
+ // 변수·다른 훅). 거기에 **모든 프롬프트마다 도는 명령**을 묻지도 않고 넣는 것은
371
+ // 슬래시 명령 파일 하나를 놓는 것과 무게가 다르다. 매 턴 조금 느려지는 것도
372
+ // 그 사람이 고를 일이다. 그래서 기본은 "안 심고, 심는 법을 알려준다" 다.
373
+ function busHook() {
374
+ const want = has('--hook')
375
+
376
+ const installed = path.join(DEST, 'tools', 'bus.mjs')
377
+ const busPath = fs.existsSync(installed) ? installed : path.join(SOURCE_ROOT, 'tools', 'bus.mjs')
378
+ // 🔴 역슬래시를 쓰지 않는다. 이 문자열은 JSON 에 들어갔다가 셸이 다시 읽는다.
379
+ // 윈도우 경로의 `\U` 같은 것이 이스케이프로 먹히면 훅이 조용히 안 돈다.
380
+ const cmd = `node "${busPath.replace(/\\/g, '/')}" list --mine --unread --throttle 60 --quiet-if-empty`
381
+
382
+ if (!want) {
383
+ hm('쪽지 훅', '안 심었습니다 (기본값). 프롬프트마다 쪽지를 받으려면 --hook 을 붙여 다시 실행하세요')
384
+ return
385
+ }
386
+ if (!fs.existsSync(busPath)) { no('쪽지 훅', `쪽지 도구가 없습니다: ${busPath}`); return }
387
+
388
+ const file = commandsWhere === 'project'
389
+ ? path.join(REPO, '.claude', 'settings.json')
390
+ : path.join(os.homedir(), '.claude', 'settings.json')
391
+
392
+ if (DRY) { hm('쪽지 훅', `(dry-run) UserPromptSubmit 를 더했을 파일: ${file}`); return }
393
+
394
+ const snippet = () => {
395
+ say('')
396
+ say(' 아래를 그 파일의 "hooks" 안에 직접 넣으세요:')
397
+ say('')
398
+ const one = { UserPromptSubmit: [{ hooks: [{ type: 'command', command: cmd, timeout: 20 }] }] }
399
+ for (const l of JSON.stringify(one, null, 2).split('\n')) say(` ${l}`)
400
+ say('')
401
+ }
402
+
403
+ // 🔴 남의 설정을 덮어쓰지 않는다. 못 읽는 것을 지우는 것은 고치는 게 아니라
404
+ // 부수는 것이다 — tools/mcp-register.mjs 가 MCP 설정에 대해 하는 판단과 같다.
405
+ // 빈 파일은 "아직 아무것도 안 적혔다" 이지 "내가 못 읽는 내용" 이 아니다.
406
+ let cfg = null
407
+ if (fs.existsSync(file)) {
408
+ const raw = fs.readFileSync(file, 'utf8')
409
+ if (raw.trim() === '') cfg = {}
410
+ else {
411
+ try { cfg = JSON.parse(raw) } catch { cfg = null }
412
+ if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
413
+ no('쪽지 훅', `설정을 읽지 못했습니다 — 건드리지 않았습니다: ${file}`)
414
+ snippet()
415
+ return
416
+ }
417
+ }
418
+ } else cfg = {}
419
+
420
+ if (cfg.hooks != null && (typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks))) {
421
+ no('쪽지 훅', `hooks 가 객체가 아닙니다 — 건드리지 않았습니다: ${file}`)
422
+ snippet()
423
+ return
424
+ }
425
+ const hooks = cfg.hooks ?? {}
426
+ if (hooks.UserPromptSubmit != null && !Array.isArray(hooks.UserPromptSubmit)) {
427
+ no('쪽지 훅', `UserPromptSubmit 이 배열이 아닙니다 — 건드리지 않았습니다: ${file}`)
428
+ snippet()
429
+ return
430
+ }
431
+ const list = hooks.UserPromptSubmit ?? []
432
+
433
+ // 우리 것만 찾아서 고친다. 남이 넣은 훅은 세지도 건드리지도 않는다.
434
+ const isOurs = (h) => typeof h?.command === 'string' && /bus\.mjs/.test(h.command)
435
+ let found = null
436
+ for (const group of list) {
437
+ if (!Array.isArray(group?.hooks)) continue
438
+ const hit = group.hooks.find(isOurs)
439
+ if (hit) { found = hit; break }
440
+ }
441
+
442
+ const write = (what) => {
443
+ try {
444
+ fs.mkdirSync(path.dirname(file), { recursive: true })
445
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n')
446
+ ok('쪽지 훅', what)
447
+ return true
448
+ } catch (e) {
449
+ no('쪽지 훅', `쓰지 못했습니다 (${e.message}): ${file}`)
450
+ snippet()
451
+ return false
452
+ }
453
+ }
454
+
455
+ if (found) {
456
+ if (found.command === cmd) { ok('쪽지 훅', `이미 있습니다: ${file}`); return }
457
+ const before = found.command
458
+ found.command = cmd
459
+ found.timeout = found.timeout ?? 20
460
+ write(`가리키는 곳을 고쳤습니다: ${file}\n 이전: ${before}`)
461
+ return
462
+ }
463
+
464
+ list.push({ hooks: [{ type: 'command', command: cmd, timeout: 20 }] })
465
+ hooks.UserPromptSubmit = list
466
+ cfg.hooks = hooks
467
+ write(`UserPromptSubmit 에 더했습니다: ${file}`)
468
+ }
469
+
470
+ busHook()
471
+
472
+ // ── 9. 끝 ───────────────────────────────────────────────────────────────────
473
+
474
+ say('')
475
+ if (bad) {
476
+ say(`${C.r}!! 위의 ${bad}줄이 안 됐습니다. 그 부분은 동작하지 않습니다.${C.x}`)
477
+ say('')
478
+ process.exit(EXIT.PARTIAL)
479
+ }
480
+
481
+ say('설치가 끝났습니다.')
482
+ say('')
483
+ say('다음:')
484
+ say(' 1. AI CLI 를 완전히 껐다가 다시 켭니다')
485
+ say(` 2. ${REPO} 에서 "/ax-start 로 시작해줘" 또는 "ax_status 로 지금 누가 뭘 잡고 있는지 보여줘"`)
486
+ say('')
487
+ say(`${C.d} 파일을 고치기 전에 claim → 끝나면 release. 그게 전부입니다.${C.x}`)
488
+ say(`${C.d} 점검: npx -y axmap-cli@latest doctor${C.x}`)
489
+ say('')
490
+ process.exit(EXIT.OK)
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 바탕화면에 실행 바로가기를 만든다. 빌드할 때마다 **같은 자리를 덮어쓴다.**
4
+ *
5
+ * $ node tools/shortcut.mjs 빌드 결과(dist)를 가리킨다
6
+ * $ node tools/shortcut.mjs --dev 소스에서 바로 띄우는 바로가기
7
+ *
8
+ * 🔴 왜 이름을 버전으로 안 나누는가.
9
+ *
10
+ * `axMap 0.0.2.lnk` 처럼 만들면 바탕화면에 낡은 아이콘이 쌓이고, 사람은 그중
11
+ * 아무거나 누른다. **어제 고친 버그가 살아 있는 화면**을 보면서 도구를 탓하게
12
+ * 된다는 뜻이다. 이름을 고정하고 덮어써서 "바탕화면의 그 아이콘 = 마지막 빌드"
13
+ * 가 항상 참이게 한다.
14
+ *
15
+ * 🔴 바탕화면 경로를 `%USERPROFILE%\Desktop` 으로 짐작하지 않는다.
16
+ *
17
+ * OneDrive 를 쓰면 바탕화면이 `...\OneDrive\바탕 화면` 으로 옮겨 가 있고, 한국어
18
+ * 윈도우는 폴더 이름도 다르다. 짐작하면 아무도 안 보는 자리에 파일을 만들어 놓고
19
+ * 성공했다고 말하게 된다. 윈도우에게 직접 묻는다.
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process'
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
28
+ const DEV = process.argv.includes('--dev')
29
+ const NAME = 'axMap'
30
+
31
+ if (process.platform !== 'win32') {
32
+ console.log('바로가기 만들기는 윈도우에서만 합니다 — 건너뜁니다.')
33
+ process.exit(0)
34
+ }
35
+
36
+ /** 윈도우가 아는 진짜 바탕화면 경로. 짐작하지 않는다. */
37
+ function desktopDir() {
38
+ const r = spawnSync('powershell', ['-NoProfile', '-Command', "[Environment]::GetFolderPath('Desktop')"],
39
+ { encoding: 'utf8', windowsHide: true })
40
+ const p = r.stdout?.trim()
41
+ return p && fs.existsSync(p) ? p : null
42
+ }
43
+
44
+ /**
45
+ * 무엇을 가리킬 것인가.
46
+ *
47
+ * 기본은 빌드 결과다. `--dev` 면 소스에서 띄운다 — 빌드하지 않고도 최신 코드를
48
+ * 바로 볼 수 있어야 팀원이 매번 몇 분씩 기다리지 않는다.
49
+ */
50
+ function target() {
51
+ if (DEV) {
52
+ const electron = path.join(ROOT, 'desktop', 'node_modules', 'electron', 'dist', 'electron.exe')
53
+ if (!fs.existsSync(electron)) {
54
+ console.error('electron 이 없습니다. desktop 에서 `npm install` 을 먼저 하세요.')
55
+ process.exit(1)
56
+ }
57
+ return { exe: electron, args: `"${path.join(ROOT, 'desktop')}"`, cwd: path.join(ROOT, 'desktop') }
58
+ }
59
+
60
+ const dist = path.join(ROOT, 'desktop', 'dist', 'win-unpacked', `${NAME}.exe`)
61
+ if (!fs.existsSync(dist)) {
62
+ console.error(`빌드 결과가 없습니다: ${dist}`)
63
+ console.error('`npm run dist` 를 먼저 돌리거나, 소스로 띄우려면 --dev 를 주세요.')
64
+ process.exit(1)
65
+ }
66
+ return { exe: dist, args: '', cwd: path.dirname(dist) }
67
+ }
68
+
69
+ /**
70
+ * 바탕화면에서 쓸 이름 — 언제나 `axMap 실행` 이다.
71
+ *
72
+ * 🔴 저장소가 바탕화면에 있으면 `axmap` 폴더와 `axmap` 바로가기가 나란히
73
+ * 뜬다. 윈도우는 `.lnk` 확장자를 감추므로 **둘 다 "axmap" 으로 보인다.**
74
+ * 실제로 그렇게 됐고, 그러면 사람은 아무거나 누른다 — 폴더를 눌러 놓고
75
+ * "앱이 안 켜진다" 고 말하게 된다.
76
+ *
77
+ * 🔴 그런데 **겹칠 때만 비키게** 하면 이름이 기계마다 달라진다.
78
+ * 저장소를 바탕화면에 둔 사람은 `axMap 실행`, 다른 데 둔 사람은 `axMap` 이
79
+ * 생기고, 저장소를 옮기는 순간 같은 기계에서도 이름이 바뀐다. 그러면
80
+ * README 도, 셋업 안내도, 옆자리에 하는 말("바탕화면의 axMap 실행 을 누르세요")도
81
+ * 누군가에게는 틀린 말이 된다. 이름은 어디에 무엇이 있든 하나여야 한다.
82
+ *
83
+ * "실행" 을 붙여 두면 동사로 읽혀서 옆의 폴더와도 저절로 구별된다.
84
+ */
85
+ function linkPath(dir) {
86
+ return path.join(dir, `${NAME} 실행.lnk`)
87
+ }
88
+
89
+ const dir = desktopDir()
90
+ if (!dir) { console.error('바탕화면 경로를 찾지 못했습니다.'); process.exit(1) }
91
+
92
+ const { exe, args, cwd } = target()
93
+ const link = linkPath(dir)
94
+ const icon = path.join(ROOT, 'desktop', 'build', 'icon.ico')
95
+
96
+ /**
97
+ * `.lnk` 는 COM(WScript.Shell)으로만 만들 수 있다. 순수 Node 로는 못 만든다.
98
+ * `$s.Save()` 는 같은 경로가 있으면 **묻지 않고 덮어쓴다** — 우리가 원하는 동작이다.
99
+ */
100
+ const ps = [
101
+ '$ErrorActionPreference = "Stop"',
102
+ '$w = New-Object -ComObject WScript.Shell',
103
+ `$s = $w.CreateShortcut("${link}")`,
104
+ `$s.TargetPath = "${exe}"`,
105
+ args ? `$s.Arguments = '${args}'` : '$s.Arguments = ""',
106
+ `$s.WorkingDirectory = "${cwd}"`,
107
+ fs.existsSync(icon) ? `$s.IconLocation = "${icon}"` : `$s.IconLocation = "${exe},0"`,
108
+ '$s.Description = "axMap — 코드의 변화를 사람이 이해하고 통제하는 도구"',
109
+ '$s.Save()',
110
+ ].join('; ')
111
+
112
+ const r = spawnSync('powershell', ['-NoProfile', '-Command', ps], { encoding: 'utf8', windowsHide: true })
113
+ if (r.status !== 0 || !fs.existsSync(link)) {
114
+ console.error('바로가기를 만들지 못했습니다.')
115
+ console.error(r.stderr || r.stdout || '(출력 없음)')
116
+ process.exit(1)
117
+ }
118
+
119
+ console.log(`바로가기 ${link}`)
120
+ console.log(` → ${exe}${args ? ` ${args}` : ''}`)
121
+ console.log(` ${DEV ? '소스에서 실행 (--dev)' : '빌드 결과'} · 다음 빌드가 이 파일을 덮어씁니다`)