axmap-cli 1.0.1 → 1.1.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/README.md CHANGED
@@ -152,8 +152,9 @@ Claude Code 에서는 `/ax`, `/ax-start`, `/ax-done`, `/ax-tell` 로도 부른
152
152
  ### `AXMAP_SESSION` 은 서버가 알아서 정한다 — 이름이 겹쳤을 때의 마지막 그물
153
153
 
154
154
  이름은 "누구인가" 이고 세션은 "어느 작업 주체인가" 다. 한 사람이 세션을 둘 띄우면
155
- 이름이 같아도 서로 다른 주체이고, 장부는 이름당 레코드 하나라 **뒤에 온 쪽이 앞의
156
- 것을 덮는다.** 2026-08-28 에 두 번 재현됐다.
155
+ 이름이 같아도 서로 다른 주체다. 장부가 이름당 레코드 하나였을 때는 **뒤에 온 쪽이
156
+ 앞의 것을 덮었다** 2026-08-28 에 두 번 재현됐다. 지금은 레코드가 (이름, 세션)
157
+ 짝마다 하나라 덮지 않고, `ax_release` 도 **그 세션이 잡은 것만** 푼다.
157
158
 
158
159
  서버는 `AXMAP_SESSION` → `CLAUDE_CODE_SESSION_ID` → `mcp-<pid>` 순으로 정해
159
160
  CLI 를 부를 때마다 넘긴다. 앞 두 칸은 CLI 와 순서가 같아서, MCP 로 잡은 것을 셸에서
package/mcp/server.mjs CHANGED
@@ -168,7 +168,8 @@ const TTL = process.env.AXMAP_TTL ?? '45m'
168
168
  *
169
169
  * 🔴 2026-08-28 에 두 번 재현된 사고가 정확히 여기서 났다. 한 PC 에서 AI 도구
170
170
  * 세션 두 개가 붙었고, 둘 다 `git config user.name` 이 같아 `AGENT` 가 같았다.
171
- * 장부는 이름당 레코드 하나라 뒤에 온 쪽이 앞의 것을 덮었고, 아무 경고도 없었다.
171
+ * 장부가 이름당 레코드 하나였던 탓에 뒤에 온 쪽이 앞의 것을 덮었고, 경고도 없었다.
172
+ * 지금은 (이름, 세션) 짝마다 레코드가 하나라 덮이지 않는다.
172
173
  * 이름은 "누구인가", 세션은 "어느 작업 주체인가" 다. 둘을 갈라야 서로를 막는다.
173
174
  *
174
175
  * 순서를 CLI(`resolveSessionId`)와 **앞 두 칸까지 똑같이** 맞춘다. 안 맞추면
@@ -182,8 +183,9 @@ const TTL = process.env.AXMAP_TTL ?? '45m'
182
183
  * 🔴 3번에 난수를 쓰지 않는다. pid 는 서버가 사는 동안 안 변하므로 같은 세션의
183
184
  * claim·release 가 짝이 맞고, 서버를 두 개 띄우면 반드시 다르다. 난수였다면
184
185
  * 그것도 되지만 로그에서 어느 프로세스였는지 되짚을 수 없다.
185
- * 서버가 재시작하면 pid 가 바뀌어 "다른 세션" 이 된다 — 그때는 CLI
186
- * 거부하면서 `--takeover` 알려준다. 조용히 덮는 것보다 낫다.
186
+ * 서버가 재시작하면 pid 가 바뀌어 "다른 세션" 이 된다 — 그때 서버가 두고 간
187
+ * 줄은 **그대로 남는다** (덮지도, 대신 풀지도 않는다). TTL 이 지나 저절로
188
+ * 걷히거나, 사람이 `release --all-sessions` 로 치운다.
187
189
  */
188
190
  const SESSION = process.env.AXMAP_SESSION || process.env.CLAUDE_CODE_SESSION_ID || `mcp-${process.pid}`
189
191
 
@@ -415,7 +417,7 @@ const TOOLS = [
415
417
  },
416
418
  {
417
419
  name: 'ax_release',
418
- description: '작업이 끝나면 호출한다. 경로를 생략하면 전부 반납한다.',
420
+ description: '작업이 끝나면 호출한다. 경로를 생략하면 이 세션이 잡은 것을 전부 반납한다 (다른 창의 것은 안 건드린다).',
419
421
  inputSchema: {
420
422
  type: 'object',
421
423
  properties: { paths: { type: 'array', items: { type: 'string' } } },
@@ -460,7 +462,7 @@ const TOOLS = [
460
462
  inputSchema: {
461
463
  type: 'object',
462
464
  properties: {
463
- to: { type: 'string', description: '받는 에이전트 이름. 생략하면 모두에게.' },
465
+ to: { type: 'string', description: '받는 사람. 이름도 이메일도 됩니다. 여럿이면 쉼표로 (예: "bob,carol"). 생략하면 모두에게.' },
464
466
  subject: { type: 'string', description: '한 줄 제목' },
465
467
  body: { type: 'string', description: '본문 (마크다운)' },
466
468
  },
@@ -878,7 +880,11 @@ function dispatch(name, args = {}) {
878
880
  case 'ax_send': {
879
881
  if (!args.subject || !args.body) return { ok: false, text: 'subject 와 body 가 필요합니다.' }
880
882
  const a = ['post', '--subject', String(args.subject)]
881
- if (args.to) a.push('--to', String(args.to))
883
+ // 여럿에게 한 통을 보낼 수 있다. 문자열로 `"a,b"` 를 줘도, 배열로 줘도 같다 —
884
+ // 같은 내용을 두 번 보내면 답장이 두 갈래로 갈려 대화가 쪼개진다.
885
+ for (const t of (Array.isArray(args.to) ? args.to : (args.to ? [args.to] : []))) {
886
+ if (String(t).trim()) a.push('--to', String(t).trim())
887
+ }
882
888
  const r = bus(a, String(args.body))
883
889
  return {
884
890
  ok: r.code === 0,
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.0.1",
4
+ "version": "1.1.0",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -244,12 +244,90 @@ export function enforcementViolations(snapshots, commits) {
244
244
  return { violations, skipped }
245
245
  }
246
246
 
247
+ // ---------------------------------------------------------------------------
248
+ // 체크포인트 — "여기까지는 이미 재생해서 깨끗함을 봤다"
249
+ //
250
+ // 🔴 왜 필요한가. 감사는 장부 이력을 **처음부터 전부** 다시 재생했다. 장부는
251
+ // 하루 100건씩 늘기만 하므로 이 검사는 쓸수록 느려진다 — 팀 저장소에서
252
+ // 파이프라인 평균이 42초에서 332초로 갔고, 같은 파이프라인의 다른 잡은
253
+ // 5~10초 그대로였다 (실측 2026-09-04, 표본 각 15건).
254
+ //
255
+ // 🔴 그런데 **이력을 버리면 안 된다.** 감사가 보장하는 문장은 "어느 시점에도 두
256
+ // 사람이 같은 파일을 동시에 잡은 적이 없다" 이고, 최근 N개만 보면 사흘 전
257
+ // 겹침이 영원히 안 보인다. TTL 로 오래된 것을 지우는 것도 같은 이유로 안 된다.
258
+ //
259
+ // 그래서 버리는 대신 **이미 본 것을 다시 안 본다.** 체크포인트 하나는 이런
260
+ // 문장이다 — *"장부를 뿌리부터 이 커밋까지 재생했고, 그 구간의 모든 스냅샷에서
261
+ // 상호배제(I1)가 성립했다."* 장부는 append-only(**뒤에 붙기만 하고 지난 것이
262
+ // 바뀌지 않는**) git 이력이므로 한 번 증명한 앞구간은 계속 참이다.
263
+ // 체크포인트부터 재생한 결과를 거기에 이어 붙이면 **전체를 본 것과 같다.**
264
+ //
265
+ // 🔴 체크포인트는 장부 상태의 **사본이 아니라 주소**다 (커밋 sha 하나).
266
+ // 상태를 복사해 두면 장부와 두 벌이 되고, 두 벌은 반드시 어긋난다.
267
+ // 주소는 어긋날 수가 없다 — 그 커밋의 트리가 곧 그 시점의 장부다.
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * `atMs` 시점에 이미 성립해 있던 체크포인트 중 **가장 늦은 것**.
272
+ * 하나도 없으면 null (= 장부 뿌리부터 재생해야 한다).
273
+ */
274
+ export function pickCheckpoint(checkpoints, atMs) {
275
+ let best = null
276
+ for (const cp of checkpoints ?? []) {
277
+ if (!Number.isFinite(cp?.time) || cp.time > atMs) continue
278
+ if (!best || cp.time > best.time) best = cp
279
+ }
280
+ return best
281
+ }
282
+
283
+ /**
284
+ * 이번 감사를 **어디서부터** 재생할지, 그리고 어떤 코드 커밋을 판정할지 정한다.
285
+ *
286
+ * 🔴 시작 지점은 "가장 최근 체크포인트" 가 **아니다.** "이번 코드 구간에서 가장
287
+ * 오래된 커밋보다 앞선 체크포인트" 다. 이것이 이 설계의 전부다.
288
+ *
289
+ * I5(강제)는 커밋 하나하나에 대해 *"그 시각에 누가 그 파일을 잡고 있었나"* 를
290
+ * 묻는다. 최근 체크포인트부터 재생하면 그보다 오래된 커밋은 참고할 장부
291
+ * 스냅샷이 아예 없어서 **조용히 통과**한다 — 락에서 최악인 fail-open 이다.
292
+ *
293
+ * - 기능 브랜치는 오늘 만든 커밋 몇 개뿐이라 오늘치만 읽고 끝난다
294
+ * - 승격 MR(`common/dev -> main` 같은 것)은 9일치 91커밋을 한 번에 나른다.
295
+ * 그때는 9일 전 체크포인트부터 재생해야 판정이 성립한다
296
+ *
297
+ * **드물게 무거운 것이 자주 무거운 것보다 낫다.** 고치기 전은 반대였다.
298
+ *
299
+ * 🔴 첫 체크포인트보다 앞선 코드 커밋은 **면제**한다 (amnesty). 이미 만들어진
300
+ * 커밋이라 지금 와서 다시 만들 수 없다. 장부가 생기기 전 커밋을 봐주는 자리가
301
+ * 이미 있고(enforcementViolations 의 skipped), 그 자리를 한 번 더 쓰는 것이다.
302
+ * 면제는 **조용하면 안 된다** — 몇 건을 왜 면제했는지 부르는 쪽이 찍는다.
303
+ *
304
+ * @returns {{from: object|null, judged: Array, amnestied: Array}}
305
+ */
306
+ export function planAudit({ checkpoints = [], codeCommits = null }) {
307
+ const cps = [...checkpoints].filter((c) => Number.isFinite(c?.time)).sort((a, b) => a.time - b.time)
308
+ const first = cps[0] ?? null
309
+
310
+ // 코드 대조를 안 하면 I1 만 본다. 그때는 마지막 체크포인트부터면 충분하다.
311
+ if (!codeCommits) return { from: cps[cps.length - 1] ?? null, judged: null, amnestied: [] }
312
+
313
+ const amnestied = first ? codeCommits.filter((c) => c.time < first.time) : []
314
+ const judged = first ? codeCommits.filter((c) => c.time >= first.time) : [...codeCommits]
315
+
316
+ if (!judged.length) return { from: first, judged, amnestied }
317
+ const oldest = Math.min(...judged.map((c) => c.time))
318
+ return { from: pickCheckpoint(cps, oldest), judged, amnestied }
319
+ }
320
+
247
321
  /**
248
322
  * 시간이 흐르는 것만으로는 겹침이 생기지 않는다 (만료는 claim 을 없앨 뿐이다).
249
323
  * 겹침은 오직 claim 이 추가될 때 생기고, claim 은 커밋으로만 추가된다.
250
324
  * 따라서 각 커밋 시점만 검사하면 전체 구간을 덮는다.
325
+ *
326
+ * @param replay 체크포인트로 앞구간을 건너뛰었다면 그 사실 — 어느 체크포인트가
327
+ * 앞선 몇 개를 보증하는가. null 이면 장부 뿌리부터 전부 재생했다는 뜻이다.
328
+ * @param amnesty 첫 체크포인트보다 앞서서 **판정에서 면제한** 코드 커밋들.
251
329
  */
252
- export function auditLedger(snapshots, commits = null) {
330
+ export function auditLedger(snapshots, commits = null, { replay = null, amnesty = [] } = {}) {
253
331
  const violations = []
254
332
  for (const snap of snapshots) {
255
333
  const t = snapshotTime(snap)
@@ -264,6 +342,8 @@ export function auditLedger(snapshots, commits = null) {
264
342
  checked: snapshots.length,
265
343
  codeChecked: commits ? commits.length : null,
266
344
  codeSkipped: code ? code.skipped : null,
345
+ replay,
346
+ amnesty,
267
347
  violations,
268
348
  }
269
349
  }
@@ -276,16 +356,37 @@ export function formatAudit(report) {
276
356
  : `코드 커밋 대조: ${report.codeChecked}개` +
277
357
  (report.codeSkipped?.length ? ` (장부보다 앞서 건너뛴 것 ${report.codeSkipped.length}개)` : '')
278
358
 
359
+ // 어디서부터 재생했는지. 체크포인트가 없으면 뿌리부터 본 것이라 적을 것이 없다.
360
+ const replayLine = report.replay
361
+ ? `재생 구간: 체크포인트 ${report.replay.from.slice(0, 7)} (${report.replay.at}) 이후` +
362
+ ` - 그 앞 스냅샷 ${report.replay.covered}개는 이 체크포인트가 보증합니다`
363
+ : null
364
+
365
+ // 🔴 면제는 조용하면 안 된다. 몇 건을 왜 봐줬는지 통과·실패 어느 쪽에서도 적는다.
366
+ const amnestyLines = []
367
+ if (report.amnesty?.length) {
368
+ amnestyLines.push(
369
+ `면제 ${report.amnesty.length}개 - 첫 체크포인트보다 앞선 코드 커밋이라 판정하지 않았습니다.`,
370
+ )
371
+ for (const c of report.amnesty) {
372
+ amnestyLines.push(` - ${c.sha.slice(0, 7)} ${c.at} ${c.subject}`)
373
+ }
374
+ }
375
+
279
376
  if (report.ok) {
280
377
  return [
281
- `감사 통과 - 스냅샷 ${report.checked}개`,
378
+ `감사 통과 - 스냅샷 ${report.checked}개 재생`,
282
379
  '장부 이력 전체에서 상호배제(I1)가 깨진 시점이 없습니다.',
380
+ ...(replayLine ? [replayLine] : []),
283
381
  codeLine,
382
+ ...amnestyLines,
284
383
  ].join('\n')
285
384
  }
286
385
  const lines = [
287
386
  `감사 실패 - 스냅샷 ${report.checked}개 중 위반 ${report.violations.length}건`,
387
+ ...(replayLine ? [replayLine] : []),
288
388
  codeLine,
389
+ ...amnestyLines,
289
390
  '',
290
391
  ]
291
392
  for (const v of report.violations) {