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/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
  // ---------------------------------------------------------------------------