axmap-cli 1.0.3 → 1.1.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/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' } } },
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.3",
4
+ "version": "1.1.1",
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) {
package/src/protocol.mjs CHANGED
@@ -47,66 +47,63 @@ export function activeClaims(claims, now) {
47
47
  }
48
48
 
49
49
  /**
50
- * 아직 효력이 있는 claim 을 찾는다. 만료되었으면 null 이다.
50
+ * 레코드를 **내가(이 세션이) 적었는가.**
51
+ *
52
+ * 🔴 레코드의 임자는 이름 하나가 아니라 **(이름, 세션) 짝**이다.
53
+ *
54
+ * 2026-08-28 사고의 뿌리가 여기였다. 한 PC 에서 AI 도구 창을 둘 띄우면 둘 다
55
+ * `git config user.name` 이 같으므로 장부는 둘을 **한 사람**으로 봤고, 레코드가
56
+ * 이름당 하나뿐이라 **뒤에 온 기록이 앞 세션의 것을 통째로 갈아끼웠다.**
57
+ * 그래서 B 창의 `release` 하나가 A 창이 잡고 있던 경로까지 함께 풀었다.
58
+ *
59
+ * 이름은 **누구인가**이고 세션은 **어느 작업 주체인가**다. 한 사람이 동시에 두
60
+ * 주체일 수 있다. 그래서 **적는 자리를 세션마다 따로 둔다** — 덮을 자리가
61
+ * 아예 없으면 덮어쓰기 사고도 없다.
62
+ *
63
+ * 세션을 모르는 실행들(세션 개념이 없는 셸, 옛 레코드)은 `null` 이라는 한 자리를
64
+ * 함께 쓴다. 실행마다 다른 값을 지어내면 자기가 잡은 것을 자기가 못 반납한다.
65
+ */
66
+ export function isSameSubject(claim, me, session = null) {
67
+ return claim.agent === me && (claim.session ?? null) === (session ?? null)
68
+ }
69
+
70
+ /**
71
+ * 아직 효력이 있는 **이 세션의** claim 을 찾는다. 만료되었으면 null 이다.
51
72
  *
52
73
  * 만료를 "레코드가 사라짐"이 아니라 "레코드는 남고 효력만 잃음"으로 정의했으므로,
53
74
  * 레코드의 존재를 효력으로 착각하면 안 된다.
54
75
  * 만료된 내 claim 의 paths 를 다음 claim 에 합치면, 그 사이 남이 정당하게 가져간
55
76
  * 경로가 검사 없이 부활한다. 락이 조용히 두 명에게 발급되는 최악의 실패다.
56
77
  */
57
- export function myActiveClaim(claims, me, now) {
58
- return activeClaims(claims, now).find((c) => c.agent === me) ?? null
78
+ export function myActiveClaim(claims, me, now, session = null) {
79
+ return activeClaims(claims, now).find((c) => isSameSubject(c, me, session)) ?? null
59
80
  }
60
81
 
61
82
  /**
62
- * 레코드를 만든 **세션**과 지금 실행하는 세션이 같은가.
63
- *
64
- * 🔴 왜 이름만으로는 부족한가 — 2026-08-28 에 두 번 재현된 사고.
65
- *
66
- * 한 PC 에서 Claude Code 세션 두 개가 같은 저장소를 만졌다. 둘 다
67
- * `git config user.name` 이 `janghyojoon` 이라 장부에서 **한 사람**이었다.
68
- * `checkOverlap` 은 자기 claim 을 겹침으로 보지 않으므로 세션 B 의 claim 이
69
- * 그냥 통과했고, 레코드는 이름 하나에 파일 하나라 A 의 것이 B 의 것으로
70
- * 덮였다. 두 세션이 같은 파일을 동시에 밀었고 장부는 아무 말도 안 했다.
71
- *
72
- * 이름은 **누구인가**이고 세션은 **어느 작업 주체인가**다. 한 사람이 동시에
73
- * 두 주체일 수 있다. 그 둘을 같은 것으로 두면 락이 조용히 두 번 발급된다.
74
- *
75
- * ── 왜 "모르면 막는다" 가 아닌가 ──────────────────────────────────────────
83
+ * 같은 **이름**의, 세션이 아닌 다른 세션이 잡고 있는 것들.
76
84
  *
77
- * 세션 id 환경이 알려줄 때만 있다. 없을 막아버리면 세션 개념이 없던
78
- * 시절에 잡아둔 레코드와 셸에서 쓰는 사람이 **자기 claim 못 늘리고 못 반납**
79
- * 한다 고치려던 것보다 고장이다. 그래서 셋으로 나눈다:
80
- *
81
- * same 양쪽 다 알고 같다 → 지금까지와 똑같이 돈다
82
- * different 양쪽 다 알고 다르다 → **거부한다.** 이것이 위 사고다
83
- * unverifiable 한쪽이라도 모른다 → 막지 않되 **조용히 넘어가지 않는다**
84
- *
85
- * 이 판정은 인증이 아니다. 환경변수를 지우면 `unverifiable` 로 내려온다.
86
- * 막으려는 것은 악의가 아니라 **아무도 눈치 못 챈 사고**다.
87
- *
88
- * @returns {'none'|'same'|'different'|'unverifiable'}
85
+ * 막기 위한 것이 아니라 **말하기 위한** 것이다. 사고의 피해는 "덮어썼다"
86
+ * 아니라 "덮어썼는데 아무도 몰랐다" 였다. 이제 덮지는 않지만, 같은 이름이
87
+ * 장부에 줄로 있다는 사실은 자리에서 알려준다.
89
88
  */
90
- export function sessionVerdict(prev, session) {
91
- if (!prev) return 'none'
92
- const held = prev.session ?? null
93
- const mine = session ?? null
94
- if (!held || !mine) return 'unverifiable'
95
- return held === mine ? 'same' : 'different'
89
+ export function otherSessionClaims(claims, me, now, session = null) {
90
+ return activeClaims(claims, now).filter((c) => c.agent === me && !isSameSubject(c, me, session))
96
91
  }
97
92
 
98
- /** 거부 메시지에 실을 만큼만 담은 충돌 정보. 레코드 원본을 그대로 흘리지 않는다. */
99
- function sessionConflictOf(prev, me, session) {
100
- return {
101
- agent: me,
102
- heldSession: prev.session ?? null,
103
- mySession: session ?? null,
104
- task: prev.task ?? null,
105
- intent: prev.intent ?? null,
106
- paths: [...(prev.paths ?? [])],
107
- since: prev.since,
108
- expiresAt: claimExpiresAt(prev),
93
+ /**
94
+ * **사람 단위**로 지금 잡고 있는 경로 전부 (세션을 가리지 않는다).
95
+ *
96
+ * pre-commit 검사가 쓰는 자리다. 커밋을 하는 것은 세션이 아니라 사람이고,
97
+ * A 창에서 잡아 B 창에서 커밋하는 것은 이 저장소가 원래 허용하던 흐름이다.
98
+ * 여기까지 세션으로 좁히면 고치려던 것보다 큰 고장이 된다.
99
+ */
100
+ export function myActivePaths(claims, me, now) {
101
+ const out = new Set()
102
+ for (const c of activeClaims(claims, now)) {
103
+ if (c.agent !== me) continue
104
+ for (const p of c.paths ?? []) out.add(normalizePath(p))
109
105
  }
106
+ return [...out]
110
107
  }
111
108
 
112
109
  /**
@@ -117,12 +114,22 @@ function sessionConflictOf(prev, me, session) {
117
114
  *
118
115
  * @returns {{ok: boolean, blocks: Array}} blocks 는 막은 이유의 목록
119
116
  */
120
- export function checkOverlap({ requested, claims, me, now }) {
117
+ export function checkOverlap({ requested, claims, me, now, session = null }) {
121
118
  const want = requested.map(normalizePath)
122
119
  const blocks = []
123
120
 
124
121
  for (const c of activeClaims(claims, now)) {
125
- if (c.agent === me) continue // claim 과는 겹쳐도 된다 (추가 claim)
122
+ // 🔴 **"것" 이름이 아니라 (이름, 세션) 짝이다.**
123
+ //
124
+ // 예전에는 이름만 봤다. 그래서 한 PC 에서 창을 둘 띄우면 **같은 파일을
125
+ // 둘 다 잡을 수 있었다** — 겹침을 막으려고 만든 검사가 정작 가장 흔한
126
+ // 겹침을 통과시킨 것이다. 사람이 자기 창 둘을 헷갈리는 일은 남의 작업과
127
+ // 부딪히는 일보다 잦다.
128
+ //
129
+ // 이름만 봐도 됐던 것은 한 사람에게 줄이 하나뿐이던 시절의 이야기다.
130
+ // 반납 단위를 세션으로 가르면서 줄이 여럿이 됐으므로 여기도 같이 간다 —
131
+ // 한쪽만 바꾸면 "따로 적히는데 서로 안 막는" 어중간한 상태가 된다.
132
+ if (isSameSubject(c, me, session)) continue // 내 줄과는 겹쳐도 된다 (추가 claim)
126
133
  for (const w of want) {
127
134
  for (const held of (c.paths ?? []).map(normalizePath)) {
128
135
  if (pathsOverlap(w, held)) {
@@ -151,26 +158,44 @@ export function checkOverlap({ requested, claims, me, now }) {
151
158
  // 검증기가 같은 로직을 다시 구현하면, 검증하는 대상이 제품이 아니라 검증기가 된다.
152
159
  // ---------------------------------------------------------------------------
153
160
 
161
+ /**
162
+ * 레코드 하나를 장부에 앉힌다.
163
+ *
164
+ * 🔴 **밀어내는 기준이 이름이 아니라 (이름, 세션) 짝이다.** 이름으로 밀어내면
165
+ * 같은 사람의 다른 창이 적어둔 줄이 여기서 사라진다 — 그것이 이 사고였다.
166
+ */
154
167
  function upsert(claims, record) {
155
- return [...claims.filter((c) => c.agent !== record.agent), record].sort((a, b) =>
156
- a.agent < b.agent ? -1 : a.agent > b.agent ? 1 : 0,
157
- )
168
+ const rest = claims.filter((c) => !isSameSubject(c, record.agent, record.session ?? null))
169
+ return sortClaims([...rest, record])
170
+ }
171
+
172
+ /** 장부의 줄 순서. 이름이 같으면 세션으로 가른다 — 순서가 흔들리면 diff 가 시끄럽다. */
173
+ function sortClaims(claims) {
174
+ return [...claims].sort((a, b) => {
175
+ if (a.agent !== b.agent) return a.agent < b.agent ? -1 : 1
176
+ const x = a.session ?? ''
177
+ const y = b.session ?? ''
178
+ return x < y ? -1 : x > y ? 1 : 0
179
+ })
158
180
  }
159
181
 
160
182
  /**
161
183
  * claim 취득. 관문 2 의 판정이 여기서 일어난다.
162
184
  *
163
- * 관문 2 이제 질문이 둘이다.
164
- * 2-a 남이 잡은 경로와 겹치는가 → checkOverlap
165
- * 2-b 같은 이름인데 **다른 세션**이 잡았는가 → sessionVerdict
185
+ * 관문 2 묻는 것은 하나다 — **남이 잡은 경로와 겹치는가** (`checkOverlap`).
186
+ * 막히면 **아무것도 쓰지 않고 돌아간다.** 판정이 끝나기 전에는 레코드를 만들지 않는다.
187
+ *
188
+ * 🔴 같은 이름의 다른 세션은 **막지 않는다. 대신 자리를 따로 준다.**
189
+ * 예전에는 여기서 거부했다 — 레코드가 이름당 하나뿐이라 그대로 두면 앞 세션의
190
+ * 작업·의도·시작 시각이 덮이기 때문이었다. 그런데 거부는 증상만 막았다.
191
+ * 같은 사람이 창 두 개로 **겹치지 않는 다른 일**을 하는 것까지 통째로 막혀서,
192
+ * 사람은 도구 밖으로 나가거나 이름을 바꿔 달았다.
166
193
  *
167
- * 🔴 어느 쪽으로 막든 **아무것도 쓰지 않고 돌아간다.** 결함의 핵심 피해가
168
- * "A 레코드가 사라졌다" 였으므로, 판정이 끝나기 전에는 레코드를 만들지 않는다.
169
- * `--takeover` 넘겨받을 때조차 `paths` 합집합이라 경로는 하나도 안 없어진다.
194
+ * 이제 세션마다 레코드가 따로 적히므로 덮을 자리 자체가 없다. 세션의 줄은
195
+ * 그대로 있고, 세션은 자기 줄을 새로 만든다. 다만 **조용히 넘어가지는
196
+ * 않는다** 같은 이름의 다른 줄이 있으면 `otherSessions` 돌려준다.
170
197
  *
171
- * @returns {{ok:true, record, claims, hadExpired, session, tookOver}
172
- * |{ok:false, blocks}
173
- * |{ok:false, blocks:[], sessionConflict}}
198
+ * @returns {{ok:true, record, claims, hadExpired, otherSessions}|{ok:false, blocks}}
174
199
  */
175
200
  export function applyClaim({
176
201
  claims,
@@ -182,29 +207,16 @@ export function applyClaim({
182
207
  intent = null,
183
208
  actor = null,
184
209
  session = null,
185
- takeover = false,
186
210
  }) {
187
211
  const want = [...new Set(requested.map(normalizePath))]
188
212
 
189
- const verdict = checkOverlap({ requested: want, claims, me, now })
213
+ const verdict = checkOverlap({ requested: want, claims, me, now, session })
190
214
  if (!verdict.ok) return { ok: false, blocks: verdict.blocks }
191
215
 
192
- // 반드시 "효력이 남은" claim 하고만 합친다. 만료된 것과 합치면
216
+ // 반드시 "효력이 남은 **이 세션의**" claim 하고만 합친다. 만료된 것과 합치면
193
217
  // 그 사이 남이 가져간 경로가 관문 2 를 거치지 않고 부활한다.
194
- const prev = myActiveClaim(claims, me, now)
195
- const hadExpired = !prev && claims.some((c) => c.agent === me)
196
-
197
- /**
198
- * 경로가 안 겹쳐도 막는다.
199
- *
200
- * 겹칠 때만 막으면 안 되는 이유: 이름당 레코드는 하나뿐이라 경로가 안 겹쳐도
201
- * 저쪽의 `task`·`intent`·`since`·TTL 이 이쪽 값으로 덮인다. 장부를 읽는 사람은
202
- * 저 세션이 무엇을 왜 하고 있었는지를 잃는다 — 그게 "기록이 사라졌다" 의 실체다.
203
- */
204
- const sv = sessionVerdict(prev, session)
205
- if (sv === 'different' && !takeover) {
206
- return { ok: false, blocks: [], sessionConflict: sessionConflictOf(prev, me, session) }
207
- }
218
+ const prev = myActiveClaim(claims, me, now, session)
219
+ const hadExpired = !prev && claims.some((c) => isSameSubject(c, me, session))
208
220
 
209
221
  const record = {
210
222
  agent: me,
@@ -214,51 +226,75 @@ export function applyClaim({
214
226
  // 프로토콜 판정에는 쓰이지 않는다 — 표시용 정보다.
215
227
  actor: actor ?? prev?.actor ?? null,
216
228
  /**
217
- * 이 레코드를 만든 세션. **판정에 쓰인다** (actor 와 다른 점이다).
229
+ * 이 레코드를 적은 세션. **레코드를 가르는 키의 절반이다** (actor 와 다른 점이다).
218
230
  *
219
- * 내가 세션을 모르면 레코드의 값을 그대로 둔다. 모른다고 지워버리면
220
- * 세션 없는 셸에서 부르는 것만으로 표식이 날아가고, 그다음부터
221
- * 다른 세션이 아무 말 없이 덮을 수 있게 된다 — 막으려던 그 자리로 돌아간다.
231
+ * `prev` 이미 같은 세션의 것만 찾아온 것이라 여기서 갈릴 일이 없다.
232
+ * 세션을 모르면 `null` 그것도 하나의 자리다 (isSameSubject 참고).
222
233
  */
223
- session: session ?? prev?.session ?? null,
234
+ session: session ?? null,
224
235
  since: new Date(now).toISOString(),
225
236
  ttlMs,
226
237
  paths: [...new Set([...(prev?.paths ?? []).map(normalizePath), ...want])].sort(),
227
238
  }
228
- return { ok: true, record, claims: upsert(claims, record), hadExpired, session: sv, tookOver: sv === 'different' }
239
+ return {
240
+ ok: true,
241
+ record,
242
+ claims: upsert(claims, record),
243
+ hadExpired,
244
+ otherSessions: otherSessionClaims(claims, me, now, session),
245
+ }
229
246
  }
230
247
 
231
248
  /**
232
249
  * 반납. drop 이 비어 있으면 전부 반납한다.
233
250
  * 만료된 레코드도 반납할 수 있다 (단순 정리이므로 관문 2 가 필요 없다).
234
251
  *
235
- * 🔴 **세션 검사가 claim 뿐 아니라 여기에도 있어야 한다.**
236
- * `release` 는 경로를 다 빼면 레코드 파일을 지운다. 같은 이름의 다른 세션이
237
- * 이것을 부르면 claim 덮는 것보다 확실하게 기록이 사라진다.
252
+ * 🔴 **반납의 단위는 세션이다.**
253
+ *
254
+ * `release` 경로를 빼면 레코드를 지운다. 이름만 보고 지우면 같은 PC 의
255
+ * 다른 창이 잡고 있던 것까지 함께 풀린다 — 저쪽은 자기가 아직 쥐고 있다고
256
+ * 믿는데 장부는 비어 있고, 그 자리에 다른 사람이 들어온다. 2026-08-28.
238
257
  *
239
- * 다만 **만료된 레코드는 막지 않는다.** 만료된 것은 아무도 막는 껍데기라
240
- * 지우는 것이 정리다. 여기서까지 막으면 죽은 세션이 두고 레코드를
241
- * 아무도 못 치운다. (`now` 주면 만료 여부를 모르므로 막는 쪽으로 둔다.)
258
+ * 그래서 여기서 푸는 것은 **이 세션이 적은 줄뿐**이다. 다른 세션의 것까지
259
+ * 풀어야 때가 있으므로(창이 죽어 두고 등) 문을 하나 둔다 —
260
+ * `allSessions`. 문이 없으면 사람은 시스템 밖으로 나가고, 그때 하는 일은
261
+ * 장부 파일을 손으로 지우는 것이라 아무 기록도 남지 않는다.
262
+ *
263
+ * @returns {{claims, records, removed, unheld, hadNothing, otherSessions}}
264
+ * records 경로가 남아 다시 적을 레코드들
265
+ * removed 경로가 하나도 안 남아 지울 레코드들
242
266
  */
243
- export function applyRelease({ claims, me, drop, session = null, takeover = false, now = null }) {
244
- const mine = claims.find((c) => c.agent === me)
245
- if (!mine) return { claims, record: null, unheld: [], hadNothing: true }
246
-
247
- const stillActive = now === null || !isExpired(mine, now)
248
- if (stillActive && !takeover && sessionVerdict(mine, session) === 'different') {
249
- return { claims, record: null, unheld: [], hadNothing: false, sessionConflict: sessionConflictOf(mine, me, session) }
267
+ export function applyRelease({ claims, me, drop, session = null, allSessions = false }) {
268
+ const mine = claims.filter((c) => (allSessions ? c.agent === me : isSameSubject(c, me, session)))
269
+ if (!mine.length) {
270
+ return {
271
+ claims,
272
+ records: [],
273
+ removed: [],
274
+ unheld: [],
275
+ hadNothing: true,
276
+ // 이름은 맞는데 세션이 달라서 못 찾은 것인지를 부르는 쪽이 말할 수 있어야 한다.
277
+ // 안 그러면 "반납할 게 없다" 를 보고 이름을 고치는 엉뚱한 처방으로 간다.
278
+ otherSessions: claims.filter((c) => c.agent === me),
279
+ }
250
280
  }
251
281
 
252
- const held = mine.paths.map(normalizePath)
253
282
  const want = [...new Set(drop.map(normalizePath))]
254
- const unheld = want.filter((p) => !held.includes(p))
255
- const remaining = want.length ? held.filter((p) => !want.includes(p)) : []
256
-
257
- if (!remaining.length) {
258
- return { claims: claims.filter((c) => c.agent !== me), record: null, unheld }
283
+ const heldAll = new Set(mine.flatMap((c) => (c.paths ?? []).map(normalizePath)))
284
+ const unheld = want.filter((p) => !heldAll.has(p))
285
+
286
+ const records = []
287
+ const removed = []
288
+ for (const c of mine) {
289
+ const held = (c.paths ?? []).map(normalizePath)
290
+ const remaining = want.length ? held.filter((p) => !want.includes(p)) : []
291
+ if (remaining.length) records.push({ ...c, paths: remaining })
292
+ else removed.push(c)
259
293
  }
260
- const record = { ...mine, paths: remaining }
261
- return { claims: upsert(claims, record), record, unheld }
294
+
295
+ let next = claims.filter((c) => !mine.includes(c))
296
+ for (const r of records) next = upsert(next, r)
297
+ return { claims: sortClaims(next), records, removed, unheld, hadNothing: false, otherSessions: [] }
262
298
  }
263
299
 
264
300
  /**
@@ -266,20 +302,24 @@ export function applyRelease({ claims, me, drop, session = null, takeover = fals
266
302
  * 만료된 claim 은 연장할 수 없다. renew 는 관문 2 를 거치지 않으므로,
267
303
  * 만료 이후를 허용하면 그 사이 남이 가져간 경로를 검사 없이 되찾게 된다.
268
304
  *
269
- * 🔴 **세션이 달라도 막지 않는다.** renew 아무것도 지우지 않고 아무것도 덮지
270
- * 않는다 시간만 늘린다. 여기서 막으면 "한 사람이 자기 claim 갱신한다"
271
- * 정상 흐름이 세션 표식 하나 때문에 끊긴다. 대신 어느 세션의 것을 늘리고
272
- * 있는지는 `session` 으로 돌려주고, 부르는 쪽이 그것을 말한다.
305
+ * 🔴 **늘리는 것도 세션의 것뿐이다.** 남의 세션 줄의 수명을 모르고 늘리면
306
+ * 경로는 아무도 쓰는데 계속 막혀 있게 된다. 다른 세션의 것을 늘리려면
307
+ * `--session <그 세션 id>` 세션이라고 말하고 늘린다.
273
308
  *
274
- * 세션 표식이 없던 레코드에는 이번 세션 값을 **채워 넣는다**(이관이 아니라
275
- * 이주다). 이미 표식이 있으면 건드리지 않는다 바꾸면 그것이 조용한 이관이다.
309
+ * 찾았을 같은 이름의 다른 줄이 있으면 `otherSessions` 로 알려준다 —
310
+ * "claim 없다" "세션이 달라 찾았다" 처방이 다르다.
276
311
  */
277
312
  export function applyRenew({ claims, me, now, ttlMs, session = null }) {
278
- const mine = myActiveClaim(claims, me, now)
279
- if (!mine) return { ok: false, reason: 'expired-or-missing' }
280
- const sv = sessionVerdict(mine, session)
281
- const record = { ...mine, session: mine.session ?? session ?? null, since: new Date(now).toISOString(), ttlMs }
282
- return { ok: true, record, claims: upsert(claims, record), session: sv }
313
+ const mine = myActiveClaim(claims, me, now, session)
314
+ if (!mine) {
315
+ return {
316
+ ok: false,
317
+ reason: 'expired-or-missing',
318
+ otherSessions: otherSessionClaims(claims, me, now, session),
319
+ }
320
+ }
321
+ const record = { ...mine, since: new Date(now).toISOString(), ttlMs }
322
+ return { ok: true, record, claims: upsert(claims, record) }
283
323
  }
284
324
 
285
325
  // ---------------------------------------------------------------------------
@@ -380,37 +420,38 @@ export function shortSession(id) {
380
420
  }
381
421
 
382
422
  /**
383
- * "같은 이름, 다른 세션" 사람과 AI 가 함께 읽는 형태로.
423
+ * "같은 이름의 다른 세션도 뭔가를 잡고 있다" 사람과 AI 가 함께 읽는 형태로.
424
+ *
425
+ * 🔴 **막는 말이 아니라 알리는 말이다.** 세션마다 레코드가 따로 적히므로 이제
426
+ * 서로 덮지 않는다. 그래도 말은 한다 — 이 사고의 피해는 "덮어썼다" 가 아니라
427
+ * **"덮어썼는데 아무도 몰랐다"** 였다. 막을 이유가 없을 때 할 수 있는 최소한은
428
+ * 무엇이 일어나고 있는지를 그 자리에서 보여주는 것이다.
384
429
  *
385
- * `formatBlocks` 같은 원칙이다 막혔다는 말만 하면 에이전트가 다음 행동을
386
- * 고를 없다. 여기서 특히 중요한 것은 **왜 이름이 같은데 남이라고 하는지**를
387
- * 설명하는 것이다. 설명이 없으면 에이전트는 이것을 버그로 보고 우회를 찾는다.
430
+ * `overlap` 있으면 먼저 말한다. 같은 이름이면 겹침 검사가 서로를 막지 않으므로
431
+ * (`checkOverlap` 자기 claim 분기) **같은 파일을 창이 동시에 고칠 수 있다.**
432
+ * 그것만은 사람이 알고 해야 한다.
388
433
  */
389
- export function formatSessionConflict(c, now, command = 'claim') {
390
- const lines = [
391
- `${command} 거부 - 이름은 "${c.agent}" 로 같지만 그 claim 을 만든 세션이 다릅니다.`,
392
- '',
393
- ` 잡고 있는 세션 : ${shortSession(c.heldSession)}`,
394
- ` 지금 세션 : ${shortSession(c.mySession)}`,
395
- ]
396
- if (c.task) lines.push(` 작업 : ${c.task}`)
397
- if (c.intent) lines.push(` 의도 : ${c.intent}`)
398
- lines.push(` 시작 : ${c.since}`)
399
- lines.push(` TTL : ${humanDuration(c.expiresAt - now)} 남음`)
400
- lines.push(' 잡힌 경로 :')
401
- for (const p of c.paths) lines.push(` ${p}`)
402
- lines.push('')
403
- lines.push('같은 사람이라도 세션이 다르면 서로 다른 작업 주체입니다. 이름당 레코드는')
404
- lines.push('하나뿐이라, 그대로 진행하면 저 세션의 작업·의도·시작 시각이 이 세션의')
405
- lines.push('것으로 덮여 장부에서 사라집니다. 그래서 아무것도 쓰지 않고 멈췄습니다.')
406
- lines.push('')
407
- lines.push('다음 하나를 하세요:')
408
- lines.push(' -세션에 다른 이름을 준다 (권장)')
409
- lines.push(` AXMAP_AGENT=${c.agent}-2 — 장부에서 둘로 갈라지고 겹침 검사가 실제로 돕니다`)
410
- lines.push(' - 저 세션이 끝난 것이 확실하면 TTL 만료를 기다린다')
411
- lines.push(` - 저 세션이 확실히 나 자신이면 --takeover 를 붙인다`)
412
- lines.push(' 레코드를 이 세션으로 넘겨받습니다. 잡혀 있던 경로는 하나도 안 없어집니다.')
413
- return lines.join('\n')
434
+ export function formatOtherSessions(others, requested, now) {
435
+ const want = (requested ?? []).map(normalizePath)
436
+ const lines = []
437
+ for (const c of others) {
438
+ const held = (c.paths ?? []).map(normalizePath)
439
+ const overlap = want.filter((w) => held.some((h) => pathsOverlap(w, h)))
440
+ lines.push(
441
+ ` 세션 ${shortSession(c.session)}${c.task ? ` [${c.task}]` : ''}` +
442
+ `${c.intent ? ` "${c.intent}"` : ''} 경로 ${held.length}개 · ${humanDuration(claimExpiresAt(c) - now)} 남음`,
443
+ )
444
+ if (overlap.length) {
445
+ lines.push(` 🔴 겹칩니다: ${overlap.join(' ')} — 같은 이름이라 겹침 검사가 서로를 막지 않습니다`)
446
+ }
447
+ }
448
+ if (!lines.length) return ''
449
+ return [
450
+ '알림: 같은 이름의 다른 세션도 잡고 있는 것이 있습니다.',
451
+ ...lines,
452
+ ' 장부에는 세션마다 따로 적히므로 서로 덮지 않습니다.',
453
+ ' release 세션이 잡은 것만 풉니다 (전부 풀려면 --all-sessions).',
454
+ ].join('\n')
414
455
  }
415
456
 
416
457
  // ---------------------------------------------------------------------------