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.
- package/.claude/commands/ax-done.md +10 -0
- package/.claude/commands/ax-setup.md +31 -0
- package/.claude/commands/ax-start.md +15 -0
- package/.claude/commands/ax-tell.md +14 -0
- package/.claude/commands/ax-update.md +19 -0
- package/.claude/commands/ax.md +13 -0
- package/CLAUDE.md +309 -0
- package/LICENSE +20 -0
- package/README.md +207 -0
- package/app/README.md +366 -0
- package/app/eval/edges.mjs +242 -0
- package/app/lib/adjacent.mjs +125 -0
- package/app/lib/agentcli.mjs +153 -0
- package/app/lib/analyze.mjs +1159 -0
- package/app/lib/cochange.mjs +421 -0
- package/app/lib/datanodes.mjs +127 -0
- package/app/lib/entry.mjs +192 -0
- package/app/lib/featuregraph.mjs +389 -0
- package/app/lib/features.mjs +645 -0
- package/app/lib/fetchrepo-run.mjs +37 -0
- package/app/lib/fetchrepo.mjs +164 -0
- package/app/lib/flow.mjs +1089 -0
- package/app/lib/ladder.mjs +387 -0
- package/app/lib/langs.mjs +630 -0
- package/app/lib/live.mjs +346 -0
- package/app/lib/llm.mjs +594 -0
- package/app/lib/newfile.mjs +126 -0
- package/app/lib/prdiff.mjs +651 -0
- package/app/lib/reveal.mjs +316 -0
- package/app/lib/roots.mjs +186 -0
- package/app/lib/scope.mjs +342 -0
- package/app/lib/session.mjs +389 -0
- package/app/lib/slots.mjs +233 -0
- package/app/lib/ssot.mjs +277 -0
- package/app/lib/teamview.mjs +962 -0
- package/app/lib/terms.ko.mjs +169 -0
- package/app/server.mjs +1959 -0
- package/app/web/shell.css +538 -0
- package/app/web/shell.html +197 -0
- package/app/web/shell.js +638 -0
- package/app/web/stage.js +347 -0
- package/app/web/words.js +85 -0
- package/bin/axmap.mjs +1918 -0
- package/governance/GOVERNANCE.md +433 -0
- package/governance/gate.mjs +526 -0
- package/governance/vote.mjs +501 -0
- package/mcp/README.md +254 -0
- package/mcp/SETUP-FOR-AI.md +186 -0
- package/mcp/install.ps1 +341 -0
- package/mcp/install.sh +339 -0
- package/mcp/server.mjs +969 -0
- package/package.json +48 -0
- package/src/closure.mjs +343 -0
- package/src/governance.mjs +839 -0
- package/src/invariants.mjs +226 -0
- package/src/mrtarget.mjs +284 -0
- package/src/promote.mjs +177 -0
- package/src/protocol.mjs +423 -0
- package/src/repotarget.mjs +81 -0
- package/src/update.mjs +177 -0
- package/src/version.mjs +186 -0
- package/tools/bus.mjs +520 -0
- package/tools/cluster-experiment.mjs +256 -0
- package/tools/cluster-sweep.mjs +226 -0
- package/tools/make-icon.mjs +108 -0
- package/tools/mcp-register.mjs +269 -0
- package/tools/mr-target.mjs +49 -0
- package/tools/persona-bench.mjs +362 -0
- package/tools/pick-repo.mjs +229 -0
- package/tools/promote.mjs +550 -0
- package/tools/reveal-demo.mjs +158 -0
- package/tools/run-tests.mjs +42 -0
- package/tools/setup.mjs +490 -0
- package/tools/shortcut.mjs +121 -0
- package/tools/smoke.mjs +166 -0
- package/tools/topicgraph.py +154 -0
- package/tools/vendor.mjs +382 -0
- package/tools/version.mjs +115 -0
package/mcp/server.mjs
ADDED
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* axMap MCP 서버.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code 같은 AI 도구가 "나는 이 파일들을 건드리겠다"를 선언하게 만든다.
|
|
6
|
+
* 그래야 뷰어가 지금 무슨 일이 일어나는지 보여줄 수 있다.
|
|
7
|
+
*
|
|
8
|
+
* 설정 (.mcp.json 또는 도구별 MCP 설정):
|
|
9
|
+
* {
|
|
10
|
+
* "mcpServers": {
|
|
11
|
+
* "axmap": {
|
|
12
|
+
* "command": "node",
|
|
13
|
+
* "args": ["<axmap>/mcp/server.mjs"],
|
|
14
|
+
* "env": {
|
|
15
|
+
* "AXMAP_REPO": "<대상 저장소>",
|
|
16
|
+
* "AXMAP_ACTOR": "agent"
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* 🔴 `env` 에 에이전트 이름을 **적어 두지 않는다.** 그 자리가 저장소에 커밋되는
|
|
23
|
+
* 순간 모두가 같은 한 사람이 된다.
|
|
24
|
+
*
|
|
25
|
+
* 예전에는 `"AXMAP_AGENT": "${AXMAP_AGENT:-claude}"` 였다. clone 한 팀원 다섯이
|
|
26
|
+
* 전부 `claude` 라는 한 명이 되고, `checkOverlap` 은 **자기 claim 을 겹침으로
|
|
27
|
+
* 보지 않으므로** 서로의 영역을 아무 경고 없이 덮어쓴다. 락이 조용히 다섯 명에게
|
|
28
|
+
* 발급된 것이고, 이 저장소가 fail-closed 로 막겠다고 선언한 바로 그 실패다.
|
|
29
|
+
*
|
|
30
|
+
* 그래서 이름은 아래 순서로만 정한다. **안전한 기본값으로 치환하지 않는다** —
|
|
31
|
+
* 치환은 서로 다른 두 사람을 같은 사람으로 만들고, 그것이 곧 소유권 충돌이다.
|
|
32
|
+
*
|
|
33
|
+
* 1. AXMAP_AGENT
|
|
34
|
+
* 2. git config user.name
|
|
35
|
+
* 3. 없으면 die
|
|
36
|
+
*
|
|
37
|
+
* 판정은 전부 bin/axmap.mjs 에 맡긴다. 여기서 로직을 다시 구현하면
|
|
38
|
+
* 두 경로의 동작이 갈라지고, 그러면 장부를 믿을 수 없게 된다.
|
|
39
|
+
*
|
|
40
|
+
* stdio 전송 — 줄바꿈으로 구분된 JSON-RPC 2.0.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { spawnSync } from 'node:child_process'
|
|
44
|
+
import fs from 'node:fs'
|
|
45
|
+
import path from 'node:path'
|
|
46
|
+
import readline from 'node:readline'
|
|
47
|
+
import { fileURLToPath } from 'node:url'
|
|
48
|
+
|
|
49
|
+
import { readTarget } from '../src/repotarget.mjs'
|
|
50
|
+
|
|
51
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* axMap 저장소 자신. `REPO`(작업 대상)와 다를 수 있다 —
|
|
55
|
+
* 다른 저장소에 붙어 있는 동안에도 이 도구의 **코드**는 여기 있다.
|
|
56
|
+
*
|
|
57
|
+
* 이 구분이 이 파일에서 가장 자주 틀리는 곳이다. 기준은 하나다:
|
|
58
|
+
* 프로그램(실행할 것) → `SELF`, 데이터(대상 저장소의 것) → `REPO`.
|
|
59
|
+
*/
|
|
60
|
+
const SELF = path.resolve(HERE, '..')
|
|
61
|
+
const CLI = path.join(SELF, 'bin', 'axmap.mjs')
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 대상 저장소 — **지금 이 서버가 겨누고 있는 폴더.**
|
|
65
|
+
*
|
|
66
|
+
* `const` 가 아니라 `let` 인 것이 요점이다. 예전에는 서버가 켜질 때 한 번 정해지면
|
|
67
|
+
* 끝이어서, 사람이 앱에서 다른 저장소를 열어도 MCP 는 처음 폴더를 계속 붙잡고 있었다.
|
|
68
|
+
* 지금은 **도구를 부를 때마다** `syncRepo()` 가 다시 정한다.
|
|
69
|
+
*/
|
|
70
|
+
let REPO = resolveRepo()
|
|
71
|
+
|
|
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 })
|
|
94
|
+
const top = r.status === 0 ? (r.stdout ?? '').trim() : ''
|
|
95
|
+
return path.resolve(top || process.cwd())
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** axMap 저장소 자신을 겨누고 있는가. 브리핑이 무엇을 읽을지가 여기서 갈린다. */
|
|
99
|
+
const isForeign = () => REPO !== SELF
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 쪽지함 CLI. **`SELF` 기준이다 — `REPO` 가 아니다.**
|
|
103
|
+
*
|
|
104
|
+
* 🔴 예전에는 `path.join(REPO, 'tools', 'bus.mjs')` 였다. axMap 안에서 자기 자신을
|
|
105
|
+
* 겨눠 쓰는 동안에는 두 경로가 같아서 아무 문제가 없었고, 남의 저장소에 붙이는
|
|
106
|
+
* 순간 `MODULE_NOT_FOUND` 로 쪽지함이 통째로 죽었다. `bus.mjs` 는 **이 도구의
|
|
107
|
+
* 일부**지 대상 저장소가 가진 파일이 아니다. 바로 위 `CLI` 가 처음부터 `SELF`
|
|
108
|
+
* 기준이었던 것과 정확히 같은 이유다.
|
|
109
|
+
*
|
|
110
|
+
* 쪽지의 **내용**은 반대다. 그건 대상 저장소의 데이터이므로 `AXMAP_BUS_DIR` 로
|
|
111
|
+
* `REPO/docs/bus` 를 넘긴다. 안 그러면 팀의 쪽지가 axMap 저장소에 쌓여
|
|
112
|
+
* 정작 그 팀의 저장소에는 아무것도 안 남는다.
|
|
113
|
+
*
|
|
114
|
+
* 이 버그가 오래 살아 있던 이유는 하나다 — REPO 와 SELF 가 다른 조합을
|
|
115
|
+
* 아무도 돌려보지 않았다. `test/mcp.test.mjs` 가 그 조합을 고정한다.
|
|
116
|
+
*/
|
|
117
|
+
const BUS = path.join(SELF, 'tools', 'bus.mjs')
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 🔴 `AXMAP_BUS_DIR` 에 **폴더를 넘기지 않는다.** 빈 문자열로 넘긴다 —
|
|
121
|
+
* `bus.mjs` 는 빈 값을 "지정 안 됨" 으로 보므로 결과는 안 넘긴 것과 같고,
|
|
122
|
+
* 동시에 **부모 환경에 남아 있던 값을 지운다.** 그냥 생략하면 물려받은
|
|
123
|
+
* 엉뚱한 폴더로 쪽지가 떨어질 수 있다. (실제로 넘기는 자리는 `bus()` 안이다.)
|
|
124
|
+
*
|
|
125
|
+
* 예전에는 여기서 `REPO/docs/bus` 를 넘겼다. 쪽지가 작업 트리에 있었으니 폴더
|
|
126
|
+
* 하나만 알려주면 됐다. 지금은 쪽지가 고아 브랜치(`axmap/bus`)의 worktree 에
|
|
127
|
+
* 있고, `bus.mjs` 는 거기에 **커밋하고 push** 해야 한다. 폴더만 넘기면 그 파일은
|
|
128
|
+
* 자기가 어느 저장소의 worktree 안에 있는지 알 수 없어 push 할 곳을 잃는다 —
|
|
129
|
+
* 쪽지는 로컬에만 쌓이고 아무에게도 안 간다. 옛 버그의 정확한 재판이다.
|
|
130
|
+
*
|
|
131
|
+
* 대신 `cwd` 를 대상 저장소로 준다. `bus.mjs` 가 `git rev-parse` 로 스스로 찾는다.
|
|
132
|
+
* 프로그램(`SELF`)과 데이터(`REPO`)를 가르는 규칙은 그대로다 — 알려주는 방식만
|
|
133
|
+
* "폴더를 지정" 에서 "서 있을 자리를 지정" 으로 바뀌었다.
|
|
134
|
+
*/
|
|
135
|
+
/**
|
|
136
|
+
* 에이전트 이름. 기본값이 **없다** — 이유는 이 파일 머리말에 적어 두었다.
|
|
137
|
+
* 요약하면: 모두에게 같은 기본 이름을 주면 여러 사람이 장부에서 한 명이 되고,
|
|
138
|
+
* 그 순간 서로의 claim 이 겹침으로 보이지 않는다.
|
|
139
|
+
*/
|
|
140
|
+
function resolveAgent() {
|
|
141
|
+
if (process.env.AXMAP_AGENT) return process.env.AXMAP_AGENT
|
|
142
|
+
const r = spawnSync('git', ['config', 'user.name'], { cwd: REPO, encoding: 'utf8', windowsHide: true })
|
|
143
|
+
const name = (r.stdout ?? '').trim()
|
|
144
|
+
if (name) return name
|
|
145
|
+
// die. 기본 이름으로 대신 채우면 두 사람이 한 사람이 되고, 그것이 곧 소유권 충돌이다.
|
|
146
|
+
process.stderr.write(
|
|
147
|
+
'axMap MCP: 에이전트 이름을 알 수 없습니다.\n' +
|
|
148
|
+
' AXMAP_AGENT 를 설정하거나 git config user.name 을 지정하세요.\n' +
|
|
149
|
+
' 기본 이름으로 대신 채우지 않습니다 — 여러 사람이 같은 이름이 되면\n' +
|
|
150
|
+
' 서로의 claim 을 겹침으로 보지 못해 같은 파일을 조용히 함께 고칩니다.\n',
|
|
151
|
+
)
|
|
152
|
+
process.exit(1)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 🔴 이름은 대상이 바뀌어도 **따라 바뀌지 않는다.** 시작할 때 한 번 정하고 끝이다.
|
|
157
|
+
*
|
|
158
|
+
* 저장소마다 `git config user.name` 이 다를 수 있는데, 대상을 따라 이름까지 바뀌면
|
|
159
|
+
* 한 사람이 장부에서 둘이 된다. 그러면 자기가 잡아둔 것을 자기가 반납하지 못하고,
|
|
160
|
+
* 겹침 판정도 자기 claim 을 남의 것으로 본다. **대상은 옮겨 다녀도 잡는 사람은 한 명**이다.
|
|
161
|
+
*/
|
|
162
|
+
const AGENT = resolveAgent()
|
|
163
|
+
const ACTOR = process.env.AXMAP_ACTOR ?? 'agent'
|
|
164
|
+
const TTL = process.env.AXMAP_TTL ?? '45m'
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 쪽지함 CLI 를 부른다. `cli` 와 달리 stdin 으로 본문을 넘긴다.
|
|
168
|
+
*
|
|
169
|
+
* 🔴 쪽지 하나 = 파일 하나라 두 에이전트가 동시에 보내도 안 부딪힌다.
|
|
170
|
+
* append-only 로그 파일 하나였다면 git 텍스트 충돌이 난다 —
|
|
171
|
+
* 이 저장소가 장부를 파서로 만든 이유가 정확히 그것이다.
|
|
172
|
+
*/
|
|
173
|
+
function bus(args, input = undefined) {
|
|
174
|
+
const r = spawnSync(process.execPath, [BUS, ...args], {
|
|
175
|
+
cwd: REPO,
|
|
176
|
+
encoding: 'utf8',
|
|
177
|
+
windowsHide: true,
|
|
178
|
+
input,
|
|
179
|
+
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR, AXMAP_BUS_DIR: '' },
|
|
180
|
+
})
|
|
181
|
+
return {
|
|
182
|
+
code: r.status ?? 1,
|
|
183
|
+
out: [(r.stdout ?? '').trim(), (r.stderr ?? '').trim()].filter(Boolean).join('\n'),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 요청하지 않아도 쪽지를 알린다. **모든 도구 결과 끝에 붙는다.**
|
|
189
|
+
*
|
|
190
|
+
* 🔴 왜 MCP 의 알림(notification)이 아닌가. 규격에는 서버가 클라이언트에게 먼저
|
|
191
|
+
* 말을 거는 통로가 있지만, **에이전트가 그것을 본다는 보장이 없다.** 에이전트가
|
|
192
|
+
* 확실히 읽는 것은 자기가 부른 도구의 결과뿐이다. 그래서 그 자리에 붙인다.
|
|
193
|
+
* 무엇을 부르든 — status 든 claim 이든 brief 든 — 보인다.
|
|
194
|
+
*
|
|
195
|
+
* 🔴 매번 원격을 물으면 도구가 느려진다. 그 조절은 이제 `bus.mjs` 의 `--throttle`
|
|
196
|
+
* 이 한다 — 시계가 스탬프 파일이라 훅(부를 때마다 새 프로세스)과 이 서버
|
|
197
|
+
* (세션 내내 살아 있음)가 **같은 창을 나눠 쓴다.**
|
|
198
|
+
*
|
|
199
|
+
* 예전에는 여기서 프로세스 안의 변수로 15초를 쟀다. 그 캐시는 이제 둘 수 없다 —
|
|
200
|
+
* 아래에서 읽음을 찍기 때문에, 캐시가 남아 있으면 **이미 찍은 쪽지를 다시 그린다.**
|
|
201
|
+
* 직전 결과를 재사용하는 것과 상태를 바꾸는 것은 같이 못 간다.
|
|
202
|
+
*
|
|
203
|
+
* 🔴 **그린 것만 찍는다.** 배너는 3건만 그리므로 3건만 읽음이 된다. `list` 에게
|
|
204
|
+
* 찍게 하면(= `--unread` 만 주면) 화면에 뜬 적 없는 4번째부터가 읽음이 되어
|
|
205
|
+
* 영영 안 보인다. 그래서 `--no-mark` 로 읽기만 하고 그린 id 만 `seen` 에 넘긴다.
|
|
206
|
+
* 조용히 사라지는 쪽으로 틀리면 아무도 못 찾는다.
|
|
207
|
+
*/
|
|
208
|
+
/**
|
|
209
|
+
* 🔴 **배너의 두 숫자는 설정에서 온다. 코드에 박지 않는다.**
|
|
210
|
+
*
|
|
211
|
+
* `AXMAP_BUS_ROWS` 배너에 실제로 그리는 줄 수 (기본 3).
|
|
212
|
+
* **0 이면 배너를 끈다** — 아래에서 그 즉시 나간다.
|
|
213
|
+
* `AXMAP_BUS_POLL` 원격을 다시 묻기까지의 초 (기본 15).
|
|
214
|
+
* 0 이면 도구를 부를 때마다 fetch 한다.
|
|
215
|
+
*
|
|
216
|
+
* 이 둘은 서로 다른 것을 조절한다. **줄 수는 시끄러움이고 주기는 비용이다.**
|
|
217
|
+
* 줄 수를 줄이면 화면이 조용해지지만 원격을 묻는 횟수는 그대로이고,
|
|
218
|
+
* 주기를 늘리면 원격 부담이 줄지만 새 쪽지가 그만큼 늦게 뜬다.
|
|
219
|
+
*
|
|
220
|
+
* 왜 기본이 15초인가 — 실측(2026-08-27, lab.ssafy.com): 쪽지 브랜치
|
|
221
|
+
* `git fetch` 한 번이 **0.77~0.84초**다. 이 배너는 도구 결과마다 붙으므로,
|
|
222
|
+
* 창이 없으면 에이전트의 **모든 도구 호출이 0.8초씩 느려진다.** 15초 창을
|
|
223
|
+
* 두면 한창 일하는 중에도 분당 4회를 넘지 않고, 도구 호출 하나가 느려지는
|
|
224
|
+
* 일은 그중 한 번뿐이다. 새 쪽지는 최악 15초 늦게 뜬다 — 쪽지는 사람이
|
|
225
|
+
* 읽고 방향을 바꾸는 것이라 15초는 늦은 축에 못 든다.
|
|
226
|
+
*
|
|
227
|
+
* 바쁜 폴링(busy polling — 짧은 간격으로 계속 물어보는 것)을 하지 않는
|
|
228
|
+
* 이유가 여기 있다. **이 배너에는 자기 시계가 없다.** 에이전트가 도구를
|
|
229
|
+
* 부를 때만 돌고, 세션이 놀고 있으면 fetch 도 0회다. 타이머로 돌리면
|
|
230
|
+
* 아무도 안 보는 새벽에도 원격을 두들기고 배터리를 먹는다.
|
|
231
|
+
*/
|
|
232
|
+
const envNum = (v, dflt) => {
|
|
233
|
+
const n = Number(v)
|
|
234
|
+
// 🔴 못 읽은 값은 기본값으로 되돌린다. 오타 하나로 배너가 조용히 꺼지거나
|
|
235
|
+
// (NaN < 1) 매 호출마다 fetch 가 도는(NaN → 0) 쪽으로 가면 안 된다.
|
|
236
|
+
return Number.isFinite(n) && n >= 0 ? n : dflt
|
|
237
|
+
}
|
|
238
|
+
const BANNER_ROWS = envNum(process.env.AXMAP_BUS_ROWS, 3)
|
|
239
|
+
const BUS_POLL = envNum(process.env.AXMAP_BUS_POLL, 15)
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 목록 출력에서 (제목 줄, id) 쌍을 뽑는다. `bus list` 는 두 줄이 한 쌍이다 —
|
|
243
|
+
* 제목 줄에 화살표가 있고 **그 다음 줄이 id** 다.
|
|
244
|
+
*/
|
|
245
|
+
function busRows(out) {
|
|
246
|
+
const lines = out.split('\n')
|
|
247
|
+
const rows = []
|
|
248
|
+
for (let i = 0; i < lines.length; i++) {
|
|
249
|
+
if (!lines[i].includes('→')) continue
|
|
250
|
+
rows.push({ line: lines[i].trim(), id: (lines[i + 1] ?? '').trim() })
|
|
251
|
+
}
|
|
252
|
+
return rows
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function unreadBanner(tool, text) {
|
|
256
|
+
// 🔴 `AXMAP_BUS_ROWS=0` 은 **끄는 스위치**다. 반드시 여기서 나간다 —
|
|
257
|
+
// 아래로 내려가면 그릴 줄이 0개인 채로 git fetch 만 돌게 된다.
|
|
258
|
+
if (BANNER_ROWS < 1) return ''
|
|
259
|
+
// 지금 쪽지를 읽고 있는 사람에게 "쪽지가 있다" 고 또 말하지 않는다.
|
|
260
|
+
if (tool === 'ax_inbox') return ''
|
|
261
|
+
// 🔴 도구 이름으로 거르지 않고 **결과를 본다.** `claim` 과 `status` 는 CLI 가
|
|
262
|
+
// 이미 같은 알림을 찍는다. 이름 목록으로 거르면 CLI 쪽이 알림을 붙이거나
|
|
263
|
+
// 떼는 순간 여기가 조용히 낡는다 — 목록은 언제나 코드보다 먼저 낡는다.
|
|
264
|
+
if (text.includes('안 읽은 쪽지')) return ''
|
|
265
|
+
|
|
266
|
+
const r = bus(['list', '--to', AGENT, '--unread', '--no-mark',
|
|
267
|
+
'--throttle', String(BUS_POLL), '--quiet-if-empty'])
|
|
268
|
+
if (r.code !== 0) return ''
|
|
269
|
+
const rows = busRows(r.out)
|
|
270
|
+
if (!rows.length) return ''
|
|
271
|
+
|
|
272
|
+
const shown = rows.slice(0, BANNER_ROWS)
|
|
273
|
+
const head = shown.map((s) => ' ' + s.line).join('\n')
|
|
274
|
+
const more = rows.length > shown.length ? `\n … 그 밖에 ${rows.length - shown.length}건` : ''
|
|
275
|
+
// 🔴 **문구가 `bin/axmap.mjs` 의 `printUnread` 와 같아야 한다.**
|
|
276
|
+
//
|
|
277
|
+
// 예전에는 "나에게 온 쪽지 N건" 이었다. 그런데 이 N 은 `--unread` 로
|
|
278
|
+
// 거른 **안 읽은** 수다. 옆에서 CLI 는 같은 것을 "안 읽은 쪽지 N건" 이라
|
|
279
|
+
// 부르니, 한 사람이 한 화면에서 이름이 다른 두 숫자를 본다. 그러면
|
|
280
|
+
// "하나는 전체고 하나는 미읽음인가?" 를 묻게 되는데 **둘 다 미읽음이다.**
|
|
281
|
+
// 읽는 사람이 뜻을 물어야 하는 알림은 알림이 아니다.
|
|
282
|
+
const banner = `\n\n───── 📬 안 읽은 쪽지 ${rows.length}건 — ax_inbox 로 읽으십시오 ─────\n${head}${more}`
|
|
283
|
+
|
|
284
|
+
// 🔴 배너를 다 만든 뒤에 찍는다. 위에서 죽으면 안 찍혀야 다음에 다시 뜬다.
|
|
285
|
+
const ids = shown.map((s) => s.id).filter(Boolean)
|
|
286
|
+
if (ids.length) bus(['seen', '--to', AGENT, ...ids])
|
|
287
|
+
return banner
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 🔴 **사람이 읽을 것과 기계가 읽을 것을 가른다.**
|
|
292
|
+
*
|
|
293
|
+
* 예전에는 `out` 하나뿐이었고 거기에 stdout 과 stderr 를 합쳐서 담았다.
|
|
294
|
+
* 보여주기에는 그게 맞다 — 경고도 같이 보여야 한다. 그런데 `--json` 을
|
|
295
|
+
* **파싱하는 쪽**에는 재앙이었다.
|
|
296
|
+
*
|
|
297
|
+
* `axmap status` 는 원격이 없으면 이렇게 경고한다:
|
|
298
|
+
*
|
|
299
|
+
* 경고: 원격이 없어 단일 작업자 모드로 돕니다. 관문 1(CAS)이 …
|
|
300
|
+
*
|
|
301
|
+
* 이 줄이 stderr 로 나와 JSON 뒤에 붙으면 `JSON.parse` 가 죽는다. 그리고
|
|
302
|
+
* `ax_check` 는 그 실패를 삼키고 **"겹치지 않습니다. claim 해도 됩니다"** 를
|
|
303
|
+
* 냈다 — 장부에 남의 claim 이 몇 개가 있든 상관없이. 원격이 없는 저장소
|
|
304
|
+
* (새로 시작한 팀, 테스트 저장소)에서는 **언제나** 그랬다.
|
|
305
|
+
*
|
|
306
|
+
* 그래서 `out`(합친 것)은 보여주기용으로 남기고, 파싱은 `stdout` 만 본다.
|
|
307
|
+
*/
|
|
308
|
+
function cli(args) {
|
|
309
|
+
const r = spawnSync(process.execPath, [CLI, ...args], {
|
|
310
|
+
cwd: REPO,
|
|
311
|
+
encoding: 'utf8',
|
|
312
|
+
windowsHide: true,
|
|
313
|
+
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR },
|
|
314
|
+
})
|
|
315
|
+
const stdout = (r.stdout ?? '').trim()
|
|
316
|
+
const stderr = (r.stderr ?? '').trim()
|
|
317
|
+
return {
|
|
318
|
+
code: r.status ?? 1,
|
|
319
|
+
out: [stdout, stderr].filter(Boolean).join('\n'), // 사람에게
|
|
320
|
+
stdout, // 기계에게 — 합치지 않는다
|
|
321
|
+
stderr,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// 도구
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
const TOOLS = [
|
|
330
|
+
{
|
|
331
|
+
name: 'ax_brief',
|
|
332
|
+
description:
|
|
333
|
+
'이 저장소에서 처음 일한다면 **가장 먼저** 호출한다. 무엇을 만드는 프로젝트인지, ' +
|
|
334
|
+
'이미 내려진 결정이 무엇인지(다시 논의하지 말 것), 어떤 파일이 무슨 일을 하는지, ' +
|
|
335
|
+
'끝내기 전에 무엇을 통과해야 하는지를 한 번에 준다. ' +
|
|
336
|
+
'내용은 저장소의 문서와 각 파일 머리말에서 그때그때 읽어오므로 낡지 않는다.',
|
|
337
|
+
inputSchema: { type: 'object', properties: {} },
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 🔴 왜 `init` 이 MCP 도구여야 하는가.
|
|
342
|
+
*
|
|
343
|
+
* 장부 없는 저장소에서 첫 `ax_claim` 은 이렇게 떨어진다:
|
|
344
|
+
*
|
|
345
|
+
* 장부가 없습니다. 먼저 실행하세요:
|
|
346
|
+
* axmap init
|
|
347
|
+
*
|
|
348
|
+
* 메시지는 정확한데 **그 명령을 부를 방법이 도구 목록에 없었다.** 에이전트는
|
|
349
|
+
* 지시를 읽고도 실행할 수 없고, 사람이 셸로 내려가야만 풀린다. 붙이자마자
|
|
350
|
+
* 막히는 벽이 정확히 여기였다 — 그리고 이 저장소 안에서만 테스트하면
|
|
351
|
+
* 장부가 이미 있으므로 한 번도 안 밟는다.
|
|
352
|
+
*
|
|
353
|
+
* 이 도구는 판정을 새로 하지 않는다. `bin/axmap.mjs init` 을 그대로 부른다.
|
|
354
|
+
*/
|
|
355
|
+
{
|
|
356
|
+
name: 'ax_init',
|
|
357
|
+
description:
|
|
358
|
+
'이 저장소에 장부가 없을 때 한 번 부른다. 장부 worktree(.axmap/ledger)와 브랜치(axmap/claims)를 만든다. '
|
|
359
|
+
+ '이미 있으면 아무것도 바꾸지 않으므로 여러 번 불러도 안전하다. '
|
|
360
|
+
+ 'ax_claim 이 "장부가 없습니다" 로 실패하면 이것을 부른 뒤 다시 claim 하십시오.',
|
|
361
|
+
inputSchema: { type: 'object', properties: {} },
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
name: 'ax_claim',
|
|
365
|
+
description:
|
|
366
|
+
'파일을 수정하기 전에 반드시 호출한다. 건드릴 경로를 선언해 다른 에이전트와 겹치지 않게 한다. ' +
|
|
367
|
+
'거부되면(exit 2) 재시도하지 말고 겹치지 않는 다른 작업으로 옮겨라.',
|
|
368
|
+
inputSchema: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
properties: {
|
|
371
|
+
paths: { type: 'array', items: { type: 'string' }, description: '저장소 루트 기준 상대 경로. 파일 또는 디렉터리' },
|
|
372
|
+
intent: { type: 'string', description: '무엇을 하려는지 한 문장. 사람이 화면에서 이걸 본다' },
|
|
373
|
+
task: { type: 'string', description: '작업/티켓 식별자 (선택)' },
|
|
374
|
+
},
|
|
375
|
+
required: ['paths', 'intent'],
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: 'ax_check',
|
|
380
|
+
description: '선언하지 않고 겹침만 미리 확인한다. 작업 계획을 세울 때 쓴다.',
|
|
381
|
+
inputSchema: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
properties: { paths: { type: 'array', items: { type: 'string' } } },
|
|
384
|
+
required: ['paths'],
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: 'ax_status',
|
|
389
|
+
description: '지금 누가 어떤 경로를 잡고 있고 무엇을 하려는지 본다.',
|
|
390
|
+
inputSchema: { type: 'object', properties: {} },
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: 'ax_release',
|
|
394
|
+
description: '작업이 끝나면 호출한다. 경로를 생략하면 전부 반납한다.',
|
|
395
|
+
inputSchema: {
|
|
396
|
+
type: 'object',
|
|
397
|
+
properties: { paths: { type: 'array', items: { type: 'string' } } },
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: 'ax_renew',
|
|
402
|
+
description: '작업이 길어질 때 TTL 을 연장한다. 연장하지 않으면 락이 저절로 풀린다.',
|
|
403
|
+
inputSchema: { type: 'object', properties: {} },
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 쪽지함 — 사람을 거치지 않고 다른 에이전트에게 말한다.
|
|
408
|
+
*
|
|
409
|
+
* 🔴 왜 MCP 인가. CLAUDE.md 에 적어두면 **읽은 에이전트만** 쓴다.
|
|
410
|
+
* 도구로 내놓으면 목록에 뜨므로 존재를 알려줄 필요가 없다.
|
|
411
|
+
*
|
|
412
|
+
* ⚠️ 다만 MCP 도 "언제 쓸지" 는 못 정해준다. 그래서 안 읽은 쪽지 알림은
|
|
413
|
+
* `ax_claim` 응답에 함께 실린다 — claim 은 규칙상 반드시 부른다.
|
|
414
|
+
*/
|
|
415
|
+
{
|
|
416
|
+
name: 'ax_inbox',
|
|
417
|
+
description:
|
|
418
|
+
'다른 에이전트가 나에게 보낸 쪽지를 읽는다. 세션을 시작할 때, 그리고 방향을 '
|
|
419
|
+
+ '바꾸기 전에 확인하십시오. id 를 주면 그 쪽지의 본문 전체를 냅니다.',
|
|
420
|
+
inputSchema: {
|
|
421
|
+
type: 'object',
|
|
422
|
+
properties: {
|
|
423
|
+
id: { type: 'string', description: '본문을 볼 쪽지 id. 없으면 목록만.' },
|
|
424
|
+
all: { type: 'boolean', description: '남에게 간 것까지 전부 본다' },
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: 'ax_send',
|
|
430
|
+
description:
|
|
431
|
+
'다른 에이전트에게 쪽지를 보낸다. 레인 배정, 방향 전환, 상대 코드에서 찾은 결함처럼 '
|
|
432
|
+
+ '**상대가 알아야 결정이 달라지는 것**을 보내십시오. 급한 것은 쪽지 대신 '
|
|
433
|
+
+ 'ax_claim 의 intent 에 한 줄로 적는 편이 빠릅니다(그건 status 에 바로 보입니다). '
|
|
434
|
+
+ '쪽지는 고아 브랜치로 바로 갑니다 — 커밋도 MR 도 필요 없습니다.',
|
|
435
|
+
inputSchema: {
|
|
436
|
+
type: 'object',
|
|
437
|
+
properties: {
|
|
438
|
+
to: { type: 'string', description: '받는 에이전트 이름. 생략하면 모두에게.' },
|
|
439
|
+
subject: { type: 'string', description: '한 줄 제목' },
|
|
440
|
+
body: { type: 'string', description: '본문 (마크다운)' },
|
|
441
|
+
},
|
|
442
|
+
required: ['subject', 'body'],
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
// ── 브리핑 ──────────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
// `SELF` · `REPO` · `isForeign()` 은 파일 맨 위에 있다. 기준은 거기 적어 두었다.
|
|
450
|
+
|
|
451
|
+
const readFrom = (base, rel) => {
|
|
452
|
+
try { return fs.readFileSync(path.join(base, rel), 'utf8') } catch { return null }
|
|
453
|
+
}
|
|
454
|
+
/** axMap 자신의 파일. 코드 지도(`MAP`)처럼 **이 도구에 관한 것**만 여기서 읽는다. */
|
|
455
|
+
const readSelf = (rel) => readFrom(SELF, rel)
|
|
456
|
+
/** 대상 저장소의 파일. 브리핑의 내용은 원칙적으로 전부 이쪽이어야 한다. */
|
|
457
|
+
const readRepo = (rel) => readFrom(REPO, rel)
|
|
458
|
+
|
|
459
|
+
/** 제목·인용·빈 줄을 건너뛴 첫 문단. 남의 README 를 요약하지 않고 그대로 보여준다. */
|
|
460
|
+
function firstPara(text) {
|
|
461
|
+
if (!text) return null
|
|
462
|
+
const lines = text.split(/\r?\n/)
|
|
463
|
+
const buf = []
|
|
464
|
+
for (const raw of lines) {
|
|
465
|
+
const l = raw.trim()
|
|
466
|
+
if (!buf.length && (!l || l.startsWith('#') || l.startsWith('>') || l.startsWith('---'))) continue
|
|
467
|
+
if (!l) break
|
|
468
|
+
buf.push(l)
|
|
469
|
+
if (buf.length >= 4) break
|
|
470
|
+
}
|
|
471
|
+
return buf.length ? buf.join(' ') : null
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** `docs/` 안에 무엇이 있는지. 목록일 뿐 지도가 아니다 — 지도는 지어내지 않는다. */
|
|
475
|
+
function docsList(base) {
|
|
476
|
+
try {
|
|
477
|
+
return fs.readdirSync(path.join(base, 'docs'))
|
|
478
|
+
.filter((f) => f.toLowerCase().endsWith('.md'))
|
|
479
|
+
.sort()
|
|
480
|
+
} catch { return [] }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* 파일 맨 위 블록 주석의 **첫 문장**.
|
|
485
|
+
*
|
|
486
|
+
* 🔴 지도를 여기에 적어 두지 않고 **파일에서 긁어오는** 이유.
|
|
487
|
+
*
|
|
488
|
+
* 손으로 적은 지도는 코드보다 먼저 낡는다. 그리고 낡은 지도는 없는 지도보다
|
|
489
|
+
* 나쁘다 — 새로 들어온 에이전트가 그것을 믿고 엉뚱한 파일을 고친다.
|
|
490
|
+
* 각 파일의 머리말을 읽어 오면 **파일을 고친 사람이 지도도 고친 것**이 된다.
|
|
491
|
+
* 그래서 이 저장소의 규칙("주석은 왜를 쓴다")이 곧 인수인계 문서가 된다.
|
|
492
|
+
*/
|
|
493
|
+
function gist(rel) {
|
|
494
|
+
const src = readSelf(rel)
|
|
495
|
+
if (src === null) return '(파일 없음 — 지도가 낡았습니다)'
|
|
496
|
+
const m = src.match(/\/\*\*([\s\S]*?)\*\//)
|
|
497
|
+
if (!m) return '(머리말 주석 없음)'
|
|
498
|
+
for (const raw of m[1].split('\n')) {
|
|
499
|
+
const line = raw.replace(/^\s*\*\s?/, '').trim()
|
|
500
|
+
if (line) return line
|
|
501
|
+
}
|
|
502
|
+
return '(머리말이 비어 있음)'
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 경로만 여기 적고 설명은 파일에서 가져온다.
|
|
507
|
+
* 경로는 거의 안 바뀌고 설명은 자주 바뀌므로, 낡을 수 있는 쪽을 자동화한다.
|
|
508
|
+
*/
|
|
509
|
+
const MAP = [
|
|
510
|
+
['판정 (순수)', 'src/protocol.mjs'],
|
|
511
|
+
['불변식 검사기 (공유)', 'src/invariants.mjs'],
|
|
512
|
+
['CLI · git · 훅', 'bin/axmap.mjs'],
|
|
513
|
+
['MCP 서버 (이 파일)', 'mcp/server.mjs'],
|
|
514
|
+
['뷰어 서버 · API', 'app/server.mjs'],
|
|
515
|
+
['대화 세션 = CLI 프로세스', 'app/lib/session.mjs'],
|
|
516
|
+
['세션마다의 이름 (슬롯)', 'app/lib/slots.mjs'],
|
|
517
|
+
['AI CLI 감지 · 로그인', 'app/lib/agentcli.mjs'],
|
|
518
|
+
['사다리 배치', 'app/lib/ladder.mjs'],
|
|
519
|
+
['화면 부트스트랩', 'app/web/shell.js'],
|
|
520
|
+
['가운데 칸 그리기', 'app/web/stage.js'],
|
|
521
|
+
['화면에 나가는 말 (어휘)', 'app/web/words.js'],
|
|
522
|
+
['데스크톱 셸 (Electron)', 'desktop/main.mjs'],
|
|
523
|
+
]
|
|
524
|
+
|
|
525
|
+
/** axMap 자신을 겨눴을 때. 코드 지도가 있는 유일한 경우다. */
|
|
526
|
+
function briefSelf() {
|
|
527
|
+
const out = []
|
|
528
|
+
out.push('# axMap — 새로 들어온 에이전트를 위한 브리핑')
|
|
529
|
+
out.push('')
|
|
530
|
+
out.push('이 브리핑은 **파일에서 긁어온 것**이라 코드와 함께 갱신된다.')
|
|
531
|
+
out.push('낡았다고 느껴지면 그것은 브리핑이 아니라 그 파일의 머리말이 낡은 것이다.')
|
|
532
|
+
out.push('')
|
|
533
|
+
|
|
534
|
+
out.push('## 먼저 읽을 것')
|
|
535
|
+
out.push('')
|
|
536
|
+
out.push('| | |')
|
|
537
|
+
out.push('|---|---|')
|
|
538
|
+
out.push('| 작업 규칙 (필수) | `CLAUDE.md` — **파일을 고치기 전에 `claim` 이 먼저다** |')
|
|
539
|
+
out.push('| 규격 | `docs/SPEC.md` — 명령과 claim 의미론. 코드보다 이쪽이 먼저다 |')
|
|
540
|
+
out.push('| 제품 방향과 버린 대안 | `docs/DECISIONS.md` |')
|
|
541
|
+
/**
|
|
542
|
+
* 🔴 열려 있는 갈래는 코드에서 읽어낼 수 없다. DECISIONS 는 "이미 정한 것" 이라
|
|
543
|
+
* "왜 아직 안 했나" 와 "다음 한 걸음" 이 어디에도 안 남는다. 그걸 THREADS 가 든다.
|
|
544
|
+
* 이어받는 사람이 제일 먼저 묻는 것이 그것이므로 규격보다 앞이 아니라 바로 뒤에 둔다.
|
|
545
|
+
*/
|
|
546
|
+
out.push('| **열려 있는 갈래** | `docs/THREADS.md` — 상태 · 다음 한 걸음 |')
|
|
547
|
+
// 번호를 여기 적지 않는다. 늘릴 때마다 이 줄이 낡는다 — 실제로 I8 을 넣고 놓쳤다.
|
|
548
|
+
out.push('| 지켜야 할 성질 | `docs/INVARIANTS.md` |')
|
|
549
|
+
out.push('| 왜 파서인가 | `docs/EXPERIMENT.md` |')
|
|
550
|
+
out.push('| 무엇을 재는가 | `docs/BENCH.md` — M3 도달 호출 번호가 주 지표다 |')
|
|
551
|
+
out.push('')
|
|
552
|
+
|
|
553
|
+
out.push('## 코드 지도')
|
|
554
|
+
out.push('')
|
|
555
|
+
for (const [role, rel] of MAP) out.push(`- **${role}** — \`${rel}\`\n ${gist(rel)}`)
|
|
556
|
+
out.push('')
|
|
557
|
+
return out
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* 남의 저장소에 붙었을 때.
|
|
562
|
+
*
|
|
563
|
+
* 🔴 여기서 **axMap 의 문서를 대신 보여주지 않는다.**
|
|
564
|
+
*
|
|
565
|
+
* 예전에는 브리핑이 통째로 `readSelf` 였다. 그래서 남의 프로젝트에 붙여도
|
|
566
|
+
* `src/protocol.mjs` · `app/web/graph.js` 같은 **axMap 의 코드 지도**가 나왔고,
|
|
567
|
+
* 받는 쪽에는 자기 저장소를 설명하는 것처럼 보인다.
|
|
568
|
+
*
|
|
569
|
+
* 이 저장소는 "낡은 지도는 없는 지도보다 나쁘다" 고 적어 두었는데,
|
|
570
|
+
* 남의 지도를 자기 지도인 양 주는 것은 그보다 한 단계 더 나쁘다.
|
|
571
|
+
* 낡은 지도는 언젠가 맞았기라도 하다.
|
|
572
|
+
*
|
|
573
|
+
* 그래서 대상에 문서가 없으면 **없다고 말하고 끝낸다.** 채워 넣지 않는다.
|
|
574
|
+
* 코드 지도는 아예 만들지 않는다 — 지어낸 지도가 정확히 그 실패이기 때문이다.
|
|
575
|
+
*/
|
|
576
|
+
function briefForeign() {
|
|
577
|
+
const out = []
|
|
578
|
+
out.push(`# ${path.basename(REPO)} — axMap 브리핑`)
|
|
579
|
+
out.push('')
|
|
580
|
+
out.push('⚠️ 아래는 전부 **이 대상 저장소에서 읽은 것**이다. axMap 자신의 문서가 아니다.')
|
|
581
|
+
out.push('axMap 은 선점 도구로만 붙어 있고, 이 저장소의 코드에 관해서는 아무것도 모른다.')
|
|
582
|
+
out.push('')
|
|
583
|
+
|
|
584
|
+
out.push('## 이 저장소가 스스로 말하는 것')
|
|
585
|
+
out.push('')
|
|
586
|
+
const claude = readRepo('CLAUDE.md')
|
|
587
|
+
out.push(claude
|
|
588
|
+
? `- \`CLAUDE.md\` **있음 — 가장 먼저 읽는다.** ${firstPara(claude) ?? ''}`.trimEnd()
|
|
589
|
+
: '- `CLAUDE.md` 없음 — 이 저장소에는 에이전트가 지킬 규칙이 아직 적혀 있지 않다')
|
|
590
|
+
const readme = readRepo('README.md')
|
|
591
|
+
if (readme) out.push(`- \`README.md\` — ${firstPara(readme) ?? '(첫 문단이 비어 있다)'}`)
|
|
592
|
+
const docs = docsList(REPO)
|
|
593
|
+
out.push(docs.length
|
|
594
|
+
? `- \`docs/\` — ${docs.map((d) => `\`${d}\``).join(' · ')}`
|
|
595
|
+
: '- `docs/` 없음')
|
|
596
|
+
out.push('')
|
|
597
|
+
return out
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** 제목만 뽑는다. 어느 저장소를 읽든 규칙은 같다. */
|
|
601
|
+
const headings = (text, re) => (text ? [...text.matchAll(re)].map((m) => m[1].trim()) : [])
|
|
602
|
+
|
|
603
|
+
function brief() {
|
|
604
|
+
const read = isForeign() ? readRepo : readSelf
|
|
605
|
+
const out = isForeign() ? briefForeign() : briefSelf()
|
|
606
|
+
|
|
607
|
+
const decs = headings(read('docs/DECISIONS.md'), /^## (D\d+ · .+)$/gm)
|
|
608
|
+
if (decs.length) {
|
|
609
|
+
out.push('## 이미 내려진 결정 (다시 논의하지 말 것 — 뒤집으려면 근거를 새로 대야 한다)')
|
|
610
|
+
out.push('')
|
|
611
|
+
for (const t of decs) out.push(`- ${t}`)
|
|
612
|
+
out.push('')
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const invs = headings(read('docs/INVARIANTS.md'), /^#{2,3} *(I\d+[^\n]*)$/gm)
|
|
616
|
+
if (invs.length) {
|
|
617
|
+
out.push('## 불변식')
|
|
618
|
+
out.push('')
|
|
619
|
+
for (const t of invs) out.push(`- ${t}`)
|
|
620
|
+
out.push('')
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* 🔴 검증 목록을 `package.json` 에서 뽑지 않는다.
|
|
625
|
+
*
|
|
626
|
+
* scripts 에는 검사(`test` · `smoke` · `demo:*`)와 실행(`app` · `desktop`)이
|
|
627
|
+
* 섞여 있다. 전부 나열하면 새 에이전트가 앱을 띄워 놓고 "검증했다" 고 여긴다.
|
|
628
|
+
* **무엇이 통과해야 하는지를 정한 곳은 `CLAUDE.md` 의 `## 검증` 절**이므로
|
|
629
|
+
* 거기서 읽어 온다. 규격이 진실이고 목록은 그 사본이다.
|
|
630
|
+
*/
|
|
631
|
+
const rules = read('CLAUDE.md')
|
|
632
|
+
const fence = rules?.split('## 검증')[1]?.match(/```[a-z]*\n([\s\S]*?)```/)
|
|
633
|
+
const cmds = fence ? fence[1].split('\n').map((l) => l.trim()).filter((l) => l.startsWith('npm ')) : []
|
|
634
|
+
if (cmds.length) {
|
|
635
|
+
out.push('## 끝내기 전에 전부 통과해야 하는 것')
|
|
636
|
+
out.push('')
|
|
637
|
+
for (const c of cmds) out.push(`- \`${c}\``)
|
|
638
|
+
out.push('')
|
|
639
|
+
// axMap 자신에게만 해당하는 주의다. 남의 저장소에 붙여 말하지 않는다.
|
|
640
|
+
if (!isForeign()) {
|
|
641
|
+
out.push('🔴 `app/web/` 를 건드렸으면 `npm run smoke` 는 **선택이 아니다.**')
|
|
642
|
+
out.push('`npm test` 는 브라우저 코드를 실행하지 않으므로 화면이 죽어도 초록이다.')
|
|
643
|
+
out.push('')
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// 🔴 막다른 길을 미리 없앤다. 장부가 없으면 claim 이 전부 실패하는데,
|
|
648
|
+
// 브리핑은 보통 claim 보다 먼저 불린다. 여기서 말해 주면 한 번에 이어진다.
|
|
649
|
+
if (!fs.existsSync(path.join(REPO, '.axmap', 'ledger', '.git'))) {
|
|
650
|
+
out.push('## 🔴 이 저장소에는 아직 장부가 없다')
|
|
651
|
+
out.push('')
|
|
652
|
+
out.push('`ax_init` 을 먼저 부른다. 그 전에는 claim 이 전부 실패한다.')
|
|
653
|
+
out.push('')
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
out.push('## 이 도구를 쓰는 순서')
|
|
657
|
+
out.push('')
|
|
658
|
+
out.push('1. `ax_status` — 지금 누가 무엇을 잡고 있나')
|
|
659
|
+
out.push('2. `ax_check` — 내가 건드릴 곳이 비었나')
|
|
660
|
+
out.push('3. `ax_claim` — 좁게 잡는다. 거부(exit 2)되면 재시도하지 말고 방향을 바꾼다')
|
|
661
|
+
out.push('4. 작업 → `ax_renew` (길어지면) → `ax_release` (끝나면 즉시)')
|
|
662
|
+
out.push('')
|
|
663
|
+
out.push(`대상 저장소: \`${REPO}\``)
|
|
664
|
+
out.push(`이 도구의 저장소: \`${SELF}\``)
|
|
665
|
+
return out.join('\n')
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
// 대상 따라가기
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* 이 저장소에 잡아둔 것을 **전부** 반납한다.
|
|
676
|
+
*
|
|
677
|
+
* `cwd` 를 인자로 받는 것이 요점이다 — 대상이 옮겨가는 길목에서 부르므로
|
|
678
|
+
* 지금 `REPO` 가 아니라 **떠나는 저장소**를 겨눠야 한다.
|
|
679
|
+
*/
|
|
680
|
+
function releaseAllIn(dir) {
|
|
681
|
+
const r = spawnSync(process.execPath, [CLI, 'release'], {
|
|
682
|
+
cwd: dir,
|
|
683
|
+
encoding: 'utf8',
|
|
684
|
+
windowsHide: true,
|
|
685
|
+
env: { ...process.env, AXMAP_AGENT: AGENT, AXMAP_ACTOR: ACTOR },
|
|
686
|
+
})
|
|
687
|
+
return {
|
|
688
|
+
code: r.status ?? 1,
|
|
689
|
+
out: [(r.stdout ?? '').trim(), (r.stderr ?? '').trim()].filter(Boolean).join('\n'),
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 사람이 앱에서 다른 저장소를 열었으면 따라간다.
|
|
695
|
+
*
|
|
696
|
+
* 🔴 **떠나기 전에 잡아둔 것을 전부 반납한다.**
|
|
697
|
+
*
|
|
698
|
+
* 안 하면 그 잠금을 풀 창구가 사라진다. `ax_release` 는 언제나 "지금 대상" 을
|
|
699
|
+
* 겨누므로, 대상이 옮겨간 뒤에는 이전 저장소의 claim 을 아무도 못 푼다.
|
|
700
|
+
* TTL(기본 45분)이 지날 때까지 팀원이 그 파일을 잡지 못하고, 정작 잡고 있는
|
|
701
|
+
* 사람은 이미 그 작업을 떠난 뒤다. **잠금이 조용히 남는 것 — 이 도구가 막으려는
|
|
702
|
+
* 사고가 정확히 그것이다.**
|
|
703
|
+
*
|
|
704
|
+
* 사람이 폴더를 바꿨다는 것은 이전 작업을 떠났다는 뜻이므로 반납이 맞다.
|
|
705
|
+
* 다시 필요하면 claim 한 번이면 된다. 남이 45분을 기다리는 것보다 싸다.
|
|
706
|
+
*
|
|
707
|
+
* 반납 실패를 **숨기지 않는다.** 조용히 넘어가면 위의 상황이 그대로 일어나는데
|
|
708
|
+
* 아무도 그것을 모른다.
|
|
709
|
+
*/
|
|
710
|
+
function syncRepo() {
|
|
711
|
+
const next = resolveRepo()
|
|
712
|
+
if (next === REPO) return null
|
|
713
|
+
const from = REPO
|
|
714
|
+
const r = releaseAllIn(from)
|
|
715
|
+
REPO = next
|
|
716
|
+
// exit 5 = 잡고 있던 것이 없었다. 실패가 아니라 반납할 게 없었다는 뜻이다.
|
|
717
|
+
return { from, to: next, released: r.code === 0, nothing: r.code === 5, out: r.out }
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** 따라간 사실을 에이전트에게 알린다. */
|
|
721
|
+
function switchNotice(s) {
|
|
722
|
+
const head = `⟳ 대상 저장소가 바뀌었습니다: ${s.from} → ${s.to}`
|
|
723
|
+
if (s.nothing) return `${head}
|
|
724
|
+
(이전 저장소에 잡아둔 것은 없었습니다.)`
|
|
725
|
+
if (s.released) return `${head}
|
|
726
|
+
이전 저장소에 잡아둔 것은 **전부 반납했습니다.**`
|
|
727
|
+
return `${head}
|
|
728
|
+
🔴 이전 저장소의 반납에 실패했습니다. 그 claim 은 TTL 이 지날 때까지 남습니다:
|
|
729
|
+
${s.out}`
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* 도구 하나를 부른다. **부를 때마다 대상이 바뀌었는지 먼저 본다.**
|
|
734
|
+
*
|
|
735
|
+
* 🔴 사람 눈에는 안 보이게 하되 **에이전트에게는 보이게** 한다.
|
|
736
|
+
*
|
|
737
|
+
* 완전히 조용히 바꾸면, 에이전트가 방금 `ax_status` 로 본 장부와 지금
|
|
738
|
+
* `ax_claim` 이 쓰는 장부가 서로 다른 저장소일 수 있다. 그러면 에이전트는
|
|
739
|
+
* 자기가 본 것을 근거로 확신 있게 틀린 답을 만든다 — 사람이 못 잡는 종류다.
|
|
740
|
+
*/
|
|
741
|
+
/**
|
|
742
|
+
* 🔴 **막다른 길을 남기지 않는다 — 한 곳에서.**
|
|
743
|
+
*
|
|
744
|
+
* 장부가 없는 저장소에서 CLI 는 어느 명령이든 이렇게 답한다:
|
|
745
|
+
*
|
|
746
|
+
* 장부가 없습니다. 먼저 실행하세요:
|
|
747
|
+
* axmap init
|
|
748
|
+
*
|
|
749
|
+
* 메시지는 정확한데 **에이전트에게는 셸이 없다.** 부를 수 있는 이름으로 바꿔줘야
|
|
750
|
+
* 한다. 예전에는 그 변환이 `ax_claim` 안에만 있었고, `ax_check` · `ax_status` ·
|
|
751
|
+
* `ax_release` · `ax_renew` 는 원문을 그대로 냈다. 하필 `ax_check` 는 설명이
|
|
752
|
+
* "작업 계획을 세울 때 쓴다" 라 **claim 보다 먼저 불리는 도구**다 — 처음 온
|
|
753
|
+
* 사람은 고쳐둔 안내를 못 보고 원래의 막다른 길을 먼저 밟았다.
|
|
754
|
+
*
|
|
755
|
+
* 그래서 도구마다 복사하지 않고 **길목에서 한 번** 본다. 도구가 늘어나도
|
|
756
|
+
* 새 도구가 이 안내를 빠뜨릴 수 없고, 다섯 군데가 따로 낡지도 않는다.
|
|
757
|
+
* 판정하지 않고 **CLI 가 낸 말을 볼 뿐**이라는 점이 중요하다 — 도구 이름
|
|
758
|
+
* 목록으로 거르면 그 목록이 코드보다 먼저 낡는다.
|
|
759
|
+
*/
|
|
760
|
+
const INIT_HINT = '\n\n→ MCP 에서는 ax_init 을 부르십시오. 그 뒤 이 도구를 다시 시도하면 됩니다.'
|
|
761
|
+
|
|
762
|
+
function withInitHint(r) {
|
|
763
|
+
if (r.ok) return r
|
|
764
|
+
const t = r.text ?? ''
|
|
765
|
+
if (!t.includes('장부가 없습니다') || t.includes('ax_init')) return r
|
|
766
|
+
return { ...r, text: t + INIT_HINT }
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function callTool(name, args = {}) {
|
|
770
|
+
const switched = syncRepo()
|
|
771
|
+
const r = withInitHint(dispatch(name, args))
|
|
772
|
+
return switched ? { ...r, text: `${switchNotice(switched)}
|
|
773
|
+
|
|
774
|
+
${r.text}` } : r
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function dispatch(name, args = {}) {
|
|
778
|
+
const paths = Array.isArray(args.paths) ? args.paths.map(String) : []
|
|
779
|
+
|
|
780
|
+
switch (name) {
|
|
781
|
+
case 'ax_brief':
|
|
782
|
+
return { ok: true, text: brief() }
|
|
783
|
+
case 'ax_init': {
|
|
784
|
+
const r = cli(['init'])
|
|
785
|
+
return {
|
|
786
|
+
ok: r.code === 0,
|
|
787
|
+
text: r.code === 0
|
|
788
|
+
? `${r.out}\n이제 ax_claim 을 부를 수 있습니다.`
|
|
789
|
+
: `장부를 만들지 못했습니다 (exit ${r.code})\n${r.out}`,
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
case 'ax_claim': {
|
|
793
|
+
if (!paths.length) return { ok: false, text: 'paths 가 비어 있습니다.' }
|
|
794
|
+
const a = ['claim', ...paths, '--ttl', TTL, '--actor', ACTOR]
|
|
795
|
+
if (args.intent) a.push('--intent', String(args.intent))
|
|
796
|
+
if (args.task) a.push('--task', String(args.task))
|
|
797
|
+
const r = cli(a)
|
|
798
|
+
return {
|
|
799
|
+
ok: r.code === 0,
|
|
800
|
+
text:
|
|
801
|
+
r.code === 0
|
|
802
|
+
? `선언 완료. 이 경로들만 수정하십시오.\n${r.out}`
|
|
803
|
+
: r.code === 2
|
|
804
|
+
? `거부되었습니다. 재시도하지 말고 다른 작업으로 옮기십시오.\n${r.out}`
|
|
805
|
+
// "장부가 없습니다" 를 여기서 따로 다루지 않는다. 그 안내는
|
|
806
|
+
// `withInitHint` 가 길목에서 붙인다 — 다섯 군데가 따로 낡지 않게.
|
|
807
|
+
: `실패 (exit ${r.code})\n${r.out}`,
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
case 'ax_inbox': {
|
|
811
|
+
// 🔴 받는 사람은 `AGENT` 다. `ACTOR` 가 아니다.
|
|
812
|
+
//
|
|
813
|
+
// 둘 다 문자열이라 자리를 바꿔 넣어도 아무도 못 막는다. 그런데 뜻이 다르다 —
|
|
814
|
+
// `AGENT` 는 **장부에 적히는 이름**(git config user.name, 예: `bob`)이고
|
|
815
|
+
// `ACTOR` 는 `.mcp.json` 이 넣는 **역할**(`agent`)이다. 사람 이름이 아니다.
|
|
816
|
+
//
|
|
817
|
+
// 여기에 `ACTOR` 가 들어가 있어서 서버는 `--to agent` 를 물었고, 그런 이름의
|
|
818
|
+
// 수신자는 없으므로 **어떤 쪽지도 찾지 못했다.** 2026-08-26 팀 저장소에서
|
|
819
|
+
// 실제 팀원 쪽지가 이 버그로 묻혀 있었다.
|
|
820
|
+
//
|
|
821
|
+
// 보내기는 멀쩡했던 것이 이 버그를 오래 살렸다 — `ax_send` 는 `AGENT` 를
|
|
822
|
+
// 넘긴다. 보낸 쪽은 성공을 보고 받는 쪽은 "쪽지 없음" 을 본다. 양쪽 다
|
|
823
|
+
// 오류가 없으므로 아무도 실패를 보지 못한다.
|
|
824
|
+
const a = args.id ? ['read', String(args.id)] : ['list', ...(args.all ? ['--all'] : ['--to', AGENT])]
|
|
825
|
+
const r = bus(a)
|
|
826
|
+
|
|
827
|
+
// 🔴 여기도 **낸 것만 찍는다.** 목록을 자르지 않으므로 낸 것 전부다.
|
|
828
|
+
// 이 자리가 비어 있으면 쪽지함을 열어도 배너가 안 줄어든다 — 사람이
|
|
829
|
+
// 읽었는데 도구는 안 읽었다고 말하는 상태가 되고, 그러면 배너는 배경이 된다.
|
|
830
|
+
//
|
|
831
|
+
// `all` 일 때는 안 찍는다. 남에게 간 쪽지까지 섞여 있어서 그중 가장 큰
|
|
832
|
+
// id 로 찍으면 **나에게 온 안 읽은 쪽지가 화면에 뜬 적 없이 묻힌다.**
|
|
833
|
+
if (r.code === 0 && !args.id && !args.all) {
|
|
834
|
+
const ids = busRows(r.out).map((s) => s.id).filter(Boolean)
|
|
835
|
+
if (ids.length) bus(['seen', '--to', AGENT, ...ids])
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
ok: r.code === 0,
|
|
839
|
+
// 🔴 "쪽지 없음" 을 실패로 내지 않는다. 없는 것과 못 읽은 것은 다르다.
|
|
840
|
+
text: r.code === 0 ? (r.out.trim() || '쪽지 없음.') : `쪽지함을 읽지 못했습니다.\n${r.out}`,
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
case 'ax_send': {
|
|
844
|
+
if (!args.subject || !args.body) return { ok: false, text: 'subject 와 body 가 필요합니다.' }
|
|
845
|
+
const a = ['post', '--subject', String(args.subject)]
|
|
846
|
+
if (args.to) a.push('--to', String(args.to))
|
|
847
|
+
const r = bus(a, String(args.body))
|
|
848
|
+
return {
|
|
849
|
+
ok: r.code === 0,
|
|
850
|
+
text: r.code === 0 ? r.out : `보내지 못했습니다.\n${r.out}`,
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
case 'ax_check': {
|
|
854
|
+
// status 를 읽어 겹침을 계산한다. 장부를 바꾸지 않는다.
|
|
855
|
+
const r = cli(['status', '--json'])
|
|
856
|
+
if (r.code !== 0) return { ok: false, text: r.out }
|
|
857
|
+
|
|
858
|
+
// 🔴 **못 읽은 것을 비어 있는 것으로 답하지 않는다.**
|
|
859
|
+
//
|
|
860
|
+
// 예전에는 파싱이 실패하면 `{ active: [] }` 를 그대로 들고 내려갔다.
|
|
861
|
+
// 그러면 겹침이 0건이 되어 **"겹치지 않습니다. claim 해도 됩니다"** 가
|
|
862
|
+
// 나간다. 장부를 한 글자도 못 읽은 상태에서 통과를 내주는 것이다.
|
|
863
|
+
//
|
|
864
|
+
// 이 도구는 판정을 못 했을 때 막아야 한다. 못 읽었는데 무엇을 claim 할지
|
|
865
|
+
// 어떻게 아는가 — 답은 "모른다" 이고, 모르는 것의 이름은 "안 겹침" 이 아니다.
|
|
866
|
+
//
|
|
867
|
+
// 🔴 `out` 이 아니라 `stdout` 을 판다. 합친 것을 파싱하면 경고 한 줄에
|
|
868
|
+
// 판정이 무너진다 — 그 사고가 실제로 이 자리에서 났다. `cli` 주석에
|
|
869
|
+
// 무엇이 붙어서 깨졌는지 적어 두었다.
|
|
870
|
+
let live = null
|
|
871
|
+
try { live = JSON.parse(r.stdout) } catch { /* 바로 아래에서 막는다 */ }
|
|
872
|
+
if (!live || !Array.isArray(live.active)) {
|
|
873
|
+
return {
|
|
874
|
+
ok: false,
|
|
875
|
+
text: '장부를 읽지 못했습니다 — 겹치는지 **판정할 수 없습니다.**\n'
|
|
876
|
+
+ 'claim 하지 마십시오. 못 읽은 것과 비어 있는 것은 다릅니다.\n\n'
|
|
877
|
+
+ `받은 것:\n${r.out}`,
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const norm = (p) => String(p).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
882
|
+
const hit = []
|
|
883
|
+
for (const c of live.active) {
|
|
884
|
+
if (c.agent === AGENT) continue
|
|
885
|
+
for (const want of paths.map(norm)) {
|
|
886
|
+
for (const held of (c.paths ?? []).map(norm)) {
|
|
887
|
+
if (want === held || want.startsWith(held + '/') || held.startsWith(want + '/')) {
|
|
888
|
+
hit.push(`${want} ← ${c.agent} (${c.intent ?? c.task ?? '이유 미기재'}) 가 ${held} 를 점유 중`)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
// 경고는 버리지 않는다. 판정에서 뺐을 뿐 사람은 봐야 한다 —
|
|
894
|
+
// "원격이 없어 남의 claim 을 못 본다" 는 판정 자체의 한계를 말하고 있다.
|
|
895
|
+
const note = r.stderr ? `\n\n${r.stderr}` : ''
|
|
896
|
+
return {
|
|
897
|
+
ok: true,
|
|
898
|
+
text: (hit.length ? `겹칩니다:\n${hit.join('\n')}` : '겹치지 않습니다. claim 해도 됩니다.') + note,
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
case 'ax_status': {
|
|
902
|
+
const r = cli(['status'])
|
|
903
|
+
return { ok: r.code === 0, text: r.out || '장부가 비어 있습니다.' }
|
|
904
|
+
}
|
|
905
|
+
case 'ax_release': {
|
|
906
|
+
const r = cli(['release', ...paths])
|
|
907
|
+
return { ok: r.code === 0, text: r.out }
|
|
908
|
+
}
|
|
909
|
+
case 'ax_renew': {
|
|
910
|
+
const r = cli(['renew', '--ttl', TTL])
|
|
911
|
+
return { ok: r.code === 0, text: r.out }
|
|
912
|
+
}
|
|
913
|
+
default:
|
|
914
|
+
return { ok: false, text: `알 수 없는 도구: ${name}` }
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ---------------------------------------------------------------------------
|
|
919
|
+
// JSON-RPC
|
|
920
|
+
// ---------------------------------------------------------------------------
|
|
921
|
+
|
|
922
|
+
function send(msg) {
|
|
923
|
+
process.stdout.write(JSON.stringify(msg) + '\n')
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function handle(req) {
|
|
927
|
+
const { id, method, params } = req
|
|
928
|
+
|
|
929
|
+
if (method === 'initialize') {
|
|
930
|
+
return {
|
|
931
|
+
protocolVersion: '2024-11-05',
|
|
932
|
+
capabilities: { tools: {} },
|
|
933
|
+
serverInfo: { name: 'axmap', version: '0.1.0' },
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (method === 'tools/list') return { tools: TOOLS }
|
|
937
|
+
if (method === 'tools/call') {
|
|
938
|
+
const r = callTool(params?.name, params?.arguments ?? {})
|
|
939
|
+
return { content: [{ type: 'text', text: r.text + unreadBanner(params?.name, r.text) }], isError: !r.ok }
|
|
940
|
+
}
|
|
941
|
+
if (method === 'ping') return {}
|
|
942
|
+
return null // 알림이거나 지원하지 않는 메서드
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const rl = readline.createInterface({ input: process.stdin })
|
|
946
|
+
rl.on('line', (line) => {
|
|
947
|
+
const s = line.trim()
|
|
948
|
+
if (!s) return
|
|
949
|
+
let req
|
|
950
|
+
try {
|
|
951
|
+
req = JSON.parse(s)
|
|
952
|
+
} catch {
|
|
953
|
+
return
|
|
954
|
+
}
|
|
955
|
+
// 알림(id 없음)에는 답하지 않는다
|
|
956
|
+
if (req.id === undefined) return
|
|
957
|
+
try {
|
|
958
|
+
const result = handle(req)
|
|
959
|
+
if (result === null) {
|
|
960
|
+
send({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: `지원하지 않는 메서드: ${req.method}` } })
|
|
961
|
+
} else {
|
|
962
|
+
send({ jsonrpc: '2.0', id: req.id, result })
|
|
963
|
+
}
|
|
964
|
+
} catch (e) {
|
|
965
|
+
send({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: e.message } })
|
|
966
|
+
}
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
process.stderr.write(`axMap MCP · 저장소 ${REPO} · 에이전트 ${AGENT} (${ACTOR})\n`)
|