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/bin/axmap.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { spawnSync } from 'node:child_process'
14
+ import crypto from 'node:crypto'
14
15
  import fs from 'node:fs'
15
16
  import path from 'node:path'
16
17
  import { fileURLToPath } from 'node:url'
@@ -20,17 +21,18 @@ import {
20
21
  applyRenew,
21
22
  coversPath,
22
23
  formatBlocks,
23
- formatSessionConflict,
24
+ formatOtherSessions,
24
25
  shortSession,
25
26
  humanDuration,
26
27
  normalizePath,
27
28
  activeClaims,
28
29
  claimExpiresAt,
29
- myActiveClaim,
30
+ isSameSubject,
31
+ myActivePaths,
30
32
  agentNameError,
31
33
  claimPathError,
32
34
  } from '../src/protocol.mjs'
33
- import { auditLedger, formatAudit } from '../src/invariants.mjs'
35
+ import { auditLedger, formatAudit, planAudit, snapshotTime } from '../src/invariants.mjs'
34
36
  import {
35
37
  PACKAGE_NAME,
36
38
  fetchLatest,
@@ -126,6 +128,27 @@ const BUS_REL = path.join('.axmap', 'bus')
126
128
  */
127
129
  const USERS_BRANCH = 'axmap/users'
128
130
  const USERS_REL = path.join('.axmap', 'users')
131
+
132
+ /**
133
+ * 감사 체크포인트가 사는 곳 — *"장부를 뿌리부터 여기까지 재생했고 깨끗했다"* 는 기록.
134
+ *
135
+ * 🔴 **장부 브랜치에 넣지 않는다.** 장부(`axmap/claims`)는 선점의 진실이고,
136
+ * 거기에 감사 결과가 섞이면 claim 한 번에 커밋이 둘 생기거나(경합이 두 배)
137
+ * 감사가 자기가 검사하는 이력을 자기가 늘리게 된다. 검사 대상과 검사 결과는
138
+ * 같은 자리에 두지 않는다.
139
+ *
140
+ * 🔴 **worktree 를 만들지 않는다.** 쪽지함·사람별 상태와 다른 점이다.
141
+ * 이것을 쓰는 자리는 팀 CI 의 감사 잡이고, CI 는 신선한 clone 이라 `.axmap/`
142
+ * 아래에 아무것도 없다. 읽는 것도 쓰는 것도 git 배관(hash-object · mktree ·
143
+ * commit-tree)으로 하면 폴더가 필요 없고, 그러면 **각자 PC 와 CI 가 같은
144
+ * 한 길**을 쓴다. 길이 둘이면 반드시 한쪽만 고쳐진다.
145
+ *
146
+ * 파일은 하나(`checkpoints.json`)다. 쪽지처럼 사람마다 갈릴 것이 없고, 쓰는 일이
147
+ * 드물어(감사가 통과할 때만) 경합이 거의 없다 — 부딪히면 다시 받아서 얹는다.
148
+ */
149
+ const CHECKPOINT_BRANCH = 'axmap/checkpoints'
150
+ const CHECKPOINT_FILE = 'checkpoints.json'
151
+
129
152
  /**
130
153
  * `release` 가 아무것도 반납하지 못했을 때. SPEC 8절.
131
154
  * 0 이 아니어야 하는 이유는 그 주변 주석에 적혀 있다.
@@ -153,6 +176,14 @@ const GIT_ENV_KEYS = [
153
176
  'GIT_PREFIX',
154
177
  ]
155
178
 
179
+ /**
180
+ * 눈에 안 보이는 두 글자와 탭. git 의 `%x00` 구분자와 줄바꿈을 소스에 그대로
181
+ * 적지 않는 이유는 편집기·패치마다 사라지거나 바뀌기 때문이다.
182
+ */
183
+ const NUL = String.fromCharCode(0)
184
+ const NL = String.fromCharCode(10)
185
+ const TAB = String.fromCharCode(9)
186
+
156
187
  function cleanEnv(keep = []) {
157
188
  const e = { ...process.env }
158
189
  for (const k of GIT_ENV_KEYS) if (!keep.includes(k)) delete e[k]
@@ -832,19 +863,29 @@ function resolveSessionId(flags) {
832
863
  }
833
864
 
834
865
  /**
835
- * 세션을 확인할 없을 한 번만 알린다.
866
+ * 물러난 깃발 `--takeover` 만났을 때.
867
+ *
868
+ * 🔴 **그냥 무시하면 안 된다.** `parseArgs` 는 모르는 깃발을 조용히 흘려보내는데,
869
+ * `--takeover ci` 처럼 쓰면 뒤 낱말을 값으로 삼켜 **경로 하나가 통째로 사라진다.**
870
+ * 그래서 여기서 `boolFlag` 로 한 번 걸러 그 사고부터 막는다.
871
+ *
872
+ * 이 깃발은 "같은 이름의 다른 세션에게서 레코드를 넘겨받는다" 는 뜻이었다.
873
+ * 이제 세션마다 레코드가 따로라 넘겨받을 것이 없다. release 에서만 뜻이 남아
874
+ * `--all-sessions` 로 이름이 바뀌었다.
836
875
  *
837
- * 🔴 **조용히 넘어가지 않는다.** 결함의 피해는 "덮어썼다" 가 아니라
838
- * "덮어썼는데 아무도 몰랐다" 였다. 막을 근거가 없을 때 할 수 있는 최소한은
839
- * 무엇이 일어나는지를 말하는 것이다.
876
+ * @returns {boolean} release 에서 `--all-sessions` 이어줄지
840
877
  */
841
- function warnUnverifiableSession(me, prevPaths) {
878
+ function warnRetiredTakeover(flags, command) {
879
+ if (!boolFlag(flags.takeover, 'takeover')) return false
880
+ if (command === 'release') {
881
+ console.error('알림: --takeover 는 --all-sessions 로 이름이 바뀌었습니다. 이번에는 그것으로 처리합니다.')
882
+ return true
883
+ }
842
884
  console.error(
843
- `알림: "${me}" 유효한 claim 이미 있습니다 (경로 ${prevPaths}개). 덮지 않고 여기에 더합니다.\n` +
844
- ' 이 실행이 그 claim 만든 세션과 같은 세션인지는 확인하지 못했습니다\n' +
845
- ` (${sessionFrom ? `내 세션은 ${sessionFrom} 에서 왔지만 레코드에 세션 표식이 없습니다` : 'AXMAP_SESSION 도 CLAUDE_CODE_SESSION_ID 도 없습니다'}).\n` +
846
- ' 한 PC 에서 세션을 여럿 굴린다면 세션마다 AXMAP_SESSION 을 다르게 주세요.',
885
+ '알림: --takeover 이제 필요 없습니다 세션마다 레코드가 따로 적히므로 넘겨받을 것이 없습니다.\n' +
886
+ ' claim 그대로 진행합니다.',
847
887
  )
888
+ return false
848
889
  }
849
890
 
850
891
  /**
@@ -1039,10 +1080,49 @@ function readClaims(root) {
1039
1080
  })
1040
1081
  }
1041
1082
 
1042
- // 에이전트 이름은 agentNameError() 로 이미 검증되었으므로 그대로 파일명이 된다.
1043
- // 여기서 문자를 치환하면 서로 다른 이름이 같은 파일을 가리킬 수 있다.
1044
- function claimPath(root, agent) {
1045
- return path.join(claimsDir(root), `${agent}.json`)
1083
+ /**
1084
+ * 레코드 하나가 앉을 파일.
1085
+ *
1086
+ * 에이전트 이름은 agentNameError() 로 이미 검증되었으므로 그대로 파일명이 된다.
1087
+ * 여기서 문자를 치환하면 서로 다른 이름이 같은 파일을 가리킬 수 있다.
1088
+ *
1089
+ * 🔴 **세션마다 다른 파일이다.** 한 파일에 이름 하나면 두 번째 창이 적는 순간
1090
+ * 첫 창의 줄이 사라진다 — 그것이 이 판의 결함이었다 (protocol.mjs `isSameSubject`).
1091
+ *
1092
+ * 세션 id 는 **그대로 파일명에 못 쓴다.** 값이 환경변수에서 오므로 슬래시든
1093
+ * 무엇이든 들어올 수 있고, 이름 검증을 거치지 않는다. 그래서 짧게 해싱한다 —
1094
+ * 파일명은 자리를 가르기만 하면 되고, 진짜 값은 파일 **안**에 그대로 들어 있다.
1095
+ * 세션을 모르는 레코드는 예전과 같은 `<이름>.json` 이라 옛 장부와 그대로 맞물린다.
1096
+ */
1097
+ function claimPath(root, agent, session = null) {
1098
+ if (!session) return path.join(claimsDir(root), `${agent}.json`)
1099
+ const h = crypto.createHash('sha256').update(String(session)).digest('hex').slice(0, 8)
1100
+ return path.join(claimsDir(root), `${agent}@${h}.json`)
1101
+ }
1102
+
1103
+ /**
1104
+ * 같은 (이름, 세션) 짝의 레코드 파일을 **전부** 지운다. `keep` 만 남긴다.
1105
+ *
1106
+ * 🔴 파일명을 계산해서 지우는 것만으로는 부족하다. 세션 표식이 있는 레코드가
1107
+ * 옛 이름(`<이름>.json`)에 들어 있는 장부가 이미 돌아다니기 때문이다.
1108
+ * 그것을 안 지우면 같은 임자의 줄이 둘로 보이고, 지운 줄 알았던 것이 남는다.
1109
+ * 그래서 **파일 안을 열어보고** 임자가 같으면 지운다.
1110
+ */
1111
+ function removeClaimFiles(root, agent, session, keep = null) {
1112
+ const dir = claimsDir(root)
1113
+ if (!fs.existsSync(dir)) return
1114
+ for (const f of fs.readdirSync(dir)) {
1115
+ if (!f.endsWith('.json')) continue
1116
+ const full = path.join(dir, f)
1117
+ if (keep && path.resolve(full) === path.resolve(keep)) continue
1118
+ let rec
1119
+ try {
1120
+ rec = JSON.parse(fs.readFileSync(full, 'utf8'))
1121
+ } catch {
1122
+ continue // 깨진 파일은 readClaims 가 fail-closed 로 잡는다. 여기서 지우면 증거가 사라진다.
1123
+ }
1124
+ if (isSameSubject(rec, agent, session ?? null)) fs.rmSync(full, { force: true })
1125
+ }
1046
1126
  }
1047
1127
 
1048
1128
  /**
@@ -1097,11 +1177,10 @@ function ensureLedgerIgnore(root) {
1097
1177
  function writeClaim(root, claim) {
1098
1178
  fs.mkdirSync(claimsDir(root), { recursive: true })
1099
1179
  ensureLedgerIgnore(root)
1100
- writeFileAtomic(claimPath(root, claim.agent), JSON.stringify(claim, null, 2) + '\n')
1101
- }
1102
-
1103
- function myClaim(root, agent) {
1104
- return readClaims(root).find((c) => c.agent === agent) ?? null
1180
+ const target = claimPath(root, claim.agent, claim.session)
1181
+ // 같은 임자의 줄이 옛 이름으로 남아 있으면 지운다. 안 그러면 장부에 같은 줄이 둘이다.
1182
+ removeClaimFiles(root, claim.agent, claim.session ?? null, target)
1183
+ writeFileAtomic(target, JSON.stringify(claim, null, 2) + '\n')
1105
1184
  }
1106
1185
 
1107
1186
  /**
@@ -1371,7 +1450,7 @@ function cmdClaim(positional, flags) {
1371
1450
  requireLedger(root)
1372
1451
  const me = agentName(flags)
1373
1452
  const session = resolveSessionId(flags)
1374
- const takeover = boolFlag(flags.takeover, 'takeover')
1453
+ warnRetiredTakeover(flags, 'claim')
1375
1454
  if (!positional.length) die('claim 할 경로를 하나 이상 지정하세요.\n axmap claim src/auth --task task-12')
1376
1455
  for (const p of positional) {
1377
1456
  const err = claimPathError(p)
@@ -1409,36 +1488,23 @@ function cmdClaim(positional, flags) {
1409
1488
  // 정하는 필드라 피해가 작지만, 원칙에 예외를 두면 그 예외가 기준이 된다.
1410
1489
  actor: resolveActor(flags.actor),
1411
1490
  session,
1412
- takeover,
1413
1491
  })
1414
1492
  if (!res.ok) {
1415
- /**
1416
- * 두 가지 거부가 있고 **둘 다 종료 코드 2** 다 (SPEC 8절).
1417
- * 부르는 쪽이 해야 할 일이 같기 때문이다 — 재시도하지 말고 방향을 바꾼다.
1418
- */
1419
- if (res.sessionConflict) {
1420
- console.error(formatSessionConflict(res.sessionConflict, t, 'claim'))
1421
- console.error(`\n (이 세션의 id 는 ${sessionFrom ?? '어디에서도 오지 않았습니다'})`)
1422
- } else {
1423
- console.error(formatBlocks(res.blocks, t))
1424
- }
1493
+ // 거부는 종료 코드 2 다 (SPEC 8절). 재시도하지 말고 방향을 바꾸라는 뜻이다.
1494
+ console.error(formatBlocks(res.blocks, t))
1425
1495
  process.exit(2)
1426
1496
  }
1427
1497
  if (res.hadExpired) {
1428
1498
  console.error(`알림: ${me} 의 이전 claim 이 만료되어 새 claim 으로 시작합니다.`)
1429
1499
  }
1430
- if (res.tookOver) {
1431
- console.error(
1432
- `알림: --takeover "${me}" 레코드를 다른 세션에게서 이 세션이 넘겨받습니다.\n` +
1433
- ' 저 세션이 잡고 있던 경로는 하나도 빠지지 않고 그대로 합쳐집니다.',
1434
- )
1435
- } else if (res.session === 'unverifiable') {
1436
- warnUnverifiableSession(me, res.record.paths.length)
1500
+ if (res.otherSessions.length) {
1501
+ console.error(formatOtherSessions(res.otherSessions, want, t))
1502
+ console.error(` (이 세션의 id ${sessionFrom ?? '어디에서도 오지 않았습니다'})`)
1437
1503
  }
1438
1504
  // push 실패 시 되돌아갈 지점. 커밋을 만들기 **전에** 잡아둔다.
1439
1505
  const before = ledgerHead(root)
1440
1506
  // 커밋 전에 실패할 수도 있다. 그때는 HEAD 가 아니라 이 파일을 되돌려야 한다.
1441
- const myFile = claimPath(root, me)
1507
+ const myFile = claimPath(root, me, session)
1442
1508
  const myFileBefore = fs.existsSync(myFile) ? fs.readFileSync(myFile) : null
1443
1509
  writeClaim(root, res.record)
1444
1510
 
@@ -1495,23 +1561,17 @@ function cmdRelease(positional, flags) {
1495
1561
  requireLedger(root)
1496
1562
  const me = agentName(flags)
1497
1563
  const session = resolveSessionId(flags)
1498
- const takeover = boolFlag(flags.takeover, 'takeover')
1564
+ // 다른 세션이 잡은 것까지 함께 푸는 문. 기본은 **이 세션이 잡은 것만**이다.
1565
+ // 둘을 각각 먼저 읽는다. `||` 로 이으면 앞이 참일 때 뒤가 안 불리고,
1566
+ // 그러면 `--takeover` 가 뒤 낱말을 삼키는 것을 못 잡는다 (warnRetiredTakeover 참고).
1567
+ const retired = warnRetiredTakeover(flags, 'release')
1568
+ const allSessions = boolFlag(flags['all-sessions'], 'all-sessions') || retired
1499
1569
  const drop = positional.map(normalizePath)
1500
1570
 
1501
1571
  for (let attempt = 1; attempt <= MAX_CAS_RETRIES; attempt++) {
1502
1572
  syncLedger(root)
1503
- const t = now()
1504
- const res = applyRelease({ claims: readClaims(root), me, drop, session, takeover, now: t })
1505
- /**
1506
- * 🔴 `hadNothing` 보다 **먼저** 본다. 여기서 막힌 것은 "반납할 게 없다"가
1507
- * 아니라 "반납하면 남의 세션 기록이 사라진다" 이고, 둘을 같은 코드로 내면
1508
- * 부르는 쪽이 이름을 고치는 엉뚱한 처방으로 간다.
1509
- */
1510
- if (res.sessionConflict) {
1511
- console.error(formatSessionConflict(res.sessionConflict, t, 'release'))
1512
- console.error(`\n (이 세션의 id 는 ${sessionFrom ?? '어디에서도 오지 않았습니다'})`)
1513
- process.exit(2)
1514
- }
1573
+ // 🔴 release 는 시계를 안 본다. 만료된 껍데기도 반납 대상이다 — 지우는 것이 곧 정리다.
1574
+ const res = applyRelease({ claims: readClaims(root), me, drop, session, allSessions })
1515
1575
  if (res.hadNothing) {
1516
1576
  /**
1517
1577
  * 🔴 반납을 시켰는데 아무것도 반납 안 된 것은 **성공이 아니다.**
@@ -1525,8 +1585,24 @@ function cmdRelease(positional, flags) {
1525
1585
  * 조용히 통과시키는 것이 가장 나쁜 실패다.
1526
1586
  */
1527
1587
  const others = readClaims(root).filter((c) => c.agent !== me)
1528
- console.error(`반납할 것이 없습니다 — "${me}" 이(가) 잡고 있는 경로가 하나도 없습니다.`)
1588
+ console.error(`반납할 것이 없습니다 — "${me}" 이(가) 이 세션에서 잡고 있는 경로가 하나도 없습니다.`)
1529
1589
  console.error(` 이 이름은 ${agentFrom} 에서 왔습니다.`)
1590
+ /**
1591
+ * 🔴 **이름은 맞는데 세션이 달라서 못 찾은 경우를 따로 말한다.**
1592
+ * 둘을 같은 말로 내면 이름을 고치는 엉뚱한 처방으로 간다. 여기서는
1593
+ * 이름이 아니라 **어느 창이 잡았는가**가 원인이다.
1594
+ */
1595
+ if (res.otherSessions.length) {
1596
+ console.error('')
1597
+ console.error(` 같은 이름 "${me}" 을(를) 잡고 있는 것은 있습니다 — 다른 세션이 적은 줄입니다:`)
1598
+ for (const c of res.otherSessions) {
1599
+ console.error(` 세션 ${shortSession(c.session)}${c.task ? ` [${c.task}]` : ''} 경로 ${c.paths.length}개`)
1600
+ }
1601
+ console.error(` (이 세션의 id 는 ${sessionFrom ?? '어디에서도 오지 않았습니다'})`)
1602
+ console.error('')
1603
+ console.error(' 그것까지 풀려면 --all-sessions 를 붙이세요.')
1604
+ console.error(' 저 창이 아직 살아 있다면 풀지 마세요 — 저쪽은 자기가 쥐고 있다고 믿습니다.')
1605
+ }
1530
1606
  if (others.length) {
1531
1607
  console.error('')
1532
1608
  console.error(' 장부에는 다른 이름으로 잡힌 것이 있습니다:')
@@ -1540,12 +1616,13 @@ function cmdRelease(positional, flags) {
1540
1616
  if (res.unheld.length) {
1541
1617
  console.error(`알림: 잡고 있지 않은 경로는 무시합니다: ${res.unheld.join(', ')}`)
1542
1618
  }
1543
- if (res.record) writeClaim(root, res.record)
1544
- else fs.rmSync(claimPath(root, me), { force: true })
1619
+ for (const r of res.records) writeClaim(root, r)
1620
+ // 지우는 것도 파일명 계산이 아니라 **내용으로** 찾아 지운다 (removeClaimFiles 참고).
1621
+ for (const r of res.removed) removeClaimFiles(root, r.agent, r.session ?? null)
1545
1622
 
1546
1623
  const pushed = commitAndPush(root, `release(${me}): ${drop.length ? drop.join(' ') : 'all'}`)
1547
1624
  if (pushed === 'ok' || pushed === 'nothing') {
1548
- const left = res.record?.paths.length ?? 0
1625
+ const left = res.records.reduce((n, r) => n + r.paths.length, 0)
1549
1626
  console.log(`release 완료 - ${me}${left ? ` (남은 ${left}개)` : ' (전부 반납)'}`)
1550
1627
  return
1551
1628
  }
@@ -1567,23 +1644,22 @@ function cmdRenew(flags) {
1567
1644
  syncLedger(root)
1568
1645
  const t = now()
1569
1646
  const res = applyRenew({ claims: readClaims(root), me, now: t, ttlMs, session })
1570
- /**
1571
- * renew 는 **막지 않는다** (applyRenew 의 주석 참고). 다만 남의 세션 락의
1572
- * 수명을 늘리고 있다는 사실은 말한다 — 모르고 늘리면 그 경로는 아무도
1573
- * 안 쓰는데 계속 막혀 있게 된다.
1574
- */
1575
- if (res.ok && res.session === 'different') {
1576
- console.error(
1577
- `경고: "${me}" 의 이 claim 은 다른 세션(${shortSession(res.record.session)})이 만든 것입니다.\n` +
1578
- ' TTL 만 늘리고 소유자는 그대로 둡니다. 저 세션이 끝난 것이면 늘리지 마세요.',
1579
- )
1580
- }
1581
1647
  if (!res.ok) {
1582
- die(
1583
- `${me} 의 유효한 claim 이 없습니다.\n` +
1584
- 'TTL 이 이미 만료되었다면 그 사이 다른 에이전트가 가져갔을 수 있습니다.\n' +
1585
- 'axmap claim 으로 다시 선점하세요 (겹침 검사를 다시 거칩니다).',
1586
- )
1648
+ let msg =
1649
+ `${me} 의 유효한 claim 이 이 세션에는 없습니다.\n` +
1650
+ 'TTL 이 이미 만료되었다면 그 사이 다른 에이전트가 가져갔을 수 있습니다.\n' +
1651
+ 'axmap claim 으로 다시 선점하세요 (겹침 검사를 다시 거칩니다).'
1652
+ // 같은 이름의 다른 창이 잡고 있는 것이라면 처방이 다르다 — 다시 잡는 게 아니라
1653
+ // 그 세션이라고 말하고 늘리는 것이다. 안 알려주면 두 번 잡게 된다.
1654
+ if (res.otherSessions.length) {
1655
+ msg += '\n\n같은 이름의 다른 세션이 잡고 있는 것은 있습니다:'
1656
+ for (const c of res.otherSessions) {
1657
+ msg += `\n 세션 ${shortSession(c.session)}${c.task ? ` [${c.task}]` : ''} 경로 ${c.paths.length}개`
1658
+ }
1659
+ msg += '\n그 세션의 것을 늘리려면 --session <그 세션 id> 로 그 세션이라고 말하세요.'
1660
+ msg += '\n저 창이 이미 끝난 것이면 늘리지 마세요 — 늘린 만큼 아무도 안 쓰는 경로가 막힙니다.'
1661
+ }
1662
+ die(msg)
1587
1663
  }
1588
1664
  writeClaim(root, res.record)
1589
1665
  const pushed = commitAndPush(root, `renew(${me})`)
@@ -1640,8 +1716,9 @@ function cmdStatus(flags) {
1640
1716
  }
1641
1717
 
1642
1718
  console.log(`장부 상태 (${new Date(t).toISOString()})\n`)
1643
- // 이름이 같은 줄이 여럿일 수는 없다(레코드는 이름당 하나). 그래서 세션은
1644
- // 여기서 줄을 나누는 것이 아니라, **내 것이 아닌데 이름인** 줄을 알아보게 한다.
1719
+ // 🔴 이름이 같은 줄이 **여럿일 있다.** 레코드가 (이름, 세션) 짝마다 하나라,
1720
+ // 창을 띄우면 같은 이름이 줄로 선다. 그래서 세션을 반드시 함께 찍는다
1721
+ // 안 찍으면 같은 이름 두 줄이 장부의 버그처럼 보인다.
1645
1722
  const mySession = resolveSessionId(flags)
1646
1723
  for (const c of live) {
1647
1724
  console.log(` ${c.agent}${c.task ? ` [${c.task}]` : ''} - ${humanDuration(claimExpiresAt(c) - t)} 남음`)
@@ -1709,8 +1786,13 @@ function cmdVerify(flags) {
1709
1786
 
1710
1787
  if (!staged.length) return
1711
1788
 
1712
- const mine = myActiveClaim(readClaims(root), me, now())
1713
- const paths = mine?.paths ?? []
1789
+ /**
1790
+ * 🔴 여기만 **사람 단위**로 본다 (세션이 아니라 이름 전체).
1791
+ *
1792
+ * 커밋하는 것은 세션이 아니라 사람이다. A 창에서 잡고 B 창에서 커밋하는 것은
1793
+ * 원래 되던 일이고, 여기까지 세션으로 좁히면 고치려던 것보다 큰 고장이 된다.
1794
+ */
1795
+ const paths = myActivePaths(readClaims(root), me, now())
1714
1796
  const uncovered = staged.filter((f) => !coversPath(paths, f))
1715
1797
 
1716
1798
  if (uncovered.length) {
@@ -1746,8 +1828,6 @@ function collectCodeCommits(root, range) {
1746
1828
  console.log(`구간에 빈 sha 가 있습니다 (${range}) - 첫 push 로 보고 코드 대조를 건너뜁니다.`)
1747
1829
  return null
1748
1830
  }
1749
- const NUL = String.fromCharCode(0)
1750
- const NL = String.fromCharCode(10)
1751
1831
  const log = git(['log', '--no-merges', '--format=%H%x00%an%x00%ct%x00%s', range], { cwd: root })
1752
1832
  if (log.code !== 0) {
1753
1833
  // 🔴 못 읽은 것을 통과로 내지 않는다. 조용히 빈 배열을 주면 "위반 0" 이 된다.
@@ -1782,10 +1862,152 @@ function collectCodeCommits(root, range) {
1782
1862
  }
1783
1863
 
1784
1864
 
1865
+ // ---------------------------------------------------------------------------
1866
+ // 감사 체크포인트 — "여기까지는 이미 재생해서 깨끗함을 봤다"
1867
+ //
1868
+ // 왜 필요한지와 왜 이력을 버리지 않는지는 src/invariants.mjs 의 planAudit 위에 있다.
1869
+ // 여기 있는 것은 그 기록을 git 에 넣고 빼는 배관뿐이다.
1870
+ // ---------------------------------------------------------------------------
1871
+
1872
+ /**
1873
+ * 체크포인트 브랜치의 끝을 **sha 로 굳혀서** 돌려준다. 없으면 null.
1874
+ *
1875
+ * FETCH_HEAD 는 worktree 마다 따로 보관되고 다음 fetch 가 덮어쓴다. 그래서 받자마자
1876
+ * sha 로 바꾼다 — 이 값은 그 뒤 어느 폴더에서 써도 같다 (한 저장소의 worktree 들은
1877
+ * 객체 저장소를 공유한다).
1878
+ */
1879
+ function checkpointTip(root, { fetch }) {
1880
+ if (fetch) {
1881
+ const r = resolveRemote(root)
1882
+ const remote = r.source === 'ambiguous' ? null : r.name
1883
+ if (remote && git(['fetch', '--quiet', remote, CHECKPOINT_BRANCH], { cwd: root }).code === 0) {
1884
+ const h = git(['rev-parse', 'FETCH_HEAD'], { cwd: root })
1885
+ if (h.code === 0 && h.out) return h.out
1886
+ }
1887
+ /**
1888
+ * 못 받았으면 **아직 아무도 체크포인트를 안 남겼다**로 읽는다.
1889
+ * 원격이 죽은 경우는 장부를 받는 자리에서 이미 멈춰 있고(거기가 fail-closed),
1890
+ * 여기서 잘못 판단해도 대가는 "처음부터 다 재생한다" 뿐이다 — 느려질 뿐
1891
+ * 통과가 헐거워지지 않는다.
1892
+ */
1893
+ }
1894
+ const local = git(['rev-parse', '--verify', '--quiet', CHECKPOINT_BRANCH], { cwd: root })
1895
+ return local.code === 0 && local.out ? local.out : null
1896
+ }
1897
+
1898
+ /**
1899
+ * 체크포인트 목록을 읽는다. **깨져 있으면 멈춘다** (fail-closed).
1900
+ *
1901
+ * 조용히 빈 목록으로 넘기면 그냥 느려질 뿐이라 무해해 보이지만 아니다 —
1902
+ * 이 목록이 **면제(amnesty) 경계**를 정한다. 없는 것으로 만들면 봐주는 범위가
1903
+ * 통째로 달라진다. 판정 범위를 정하는 입력은 못 읽었을 때 추측하지 않는다.
1904
+ */
1905
+ function readCheckpoints(cwd, tipSha) {
1906
+ if (!tipSha) return []
1907
+ const r = git(['show', `${tipSha}:${CHECKPOINT_FILE}`], { cwd })
1908
+ if (r.code !== 0) return [] // 브랜치는 있는데 파일이 없다 = 아직 하나도 안 적혔다
1909
+ let doc
1910
+ try {
1911
+ doc = JSON.parse(r.out)
1912
+ } catch (e) {
1913
+ die(
1914
+ `체크포인트를 읽을 수 없습니다 (${CHECKPOINT_BRANCH}:${CHECKPOINT_FILE})` +
1915
+ NL + e.message + NL + NL +
1916
+ '어디서부터 재생할지 정할 수 없으므로 중단합니다 — 검사하지 못한 것을 통과로 내지 않습니다.',
1917
+ )
1918
+ }
1919
+ const list = Array.isArray(doc?.checkpoints) ? doc.checkpoints : null
1920
+ if (!list) {
1921
+ die(`체크포인트 파일에 checkpoints 배열이 없습니다 (${CHECKPOINT_BRANCH}:${CHECKPOINT_FILE}).`)
1922
+ }
1923
+ for (const c of list) {
1924
+ if (!/^[0-9a-f]{40}$/.test(c?.ledger ?? '') || !Number.isFinite(c?.time)) {
1925
+ die(
1926
+ `체크포인트 항목이 망가졌습니다: ${JSON.stringify(c)}` + NL +
1927
+ '필요: ledger(장부 커밋 sha 40자) · time(그 스냅샷의 논리 시각, ms)',
1928
+ )
1929
+ }
1930
+ }
1931
+ return list
1932
+ }
1933
+
1934
+ function warnCheckpoint(r) {
1935
+ console.error(`경고: 체크포인트를 남기지 못했습니다 — ${(r.err || r.out || '').split(NL)[0]}`)
1936
+ console.error(' 판정은 그대로입니다. 다음 감사가 그만큼 더 오래 걸립니다.')
1937
+ }
1938
+
1939
+ /**
1940
+ * 이번에 재생해서 깨끗했던 끝점을 체크포인트로 남긴다.
1941
+ *
1942
+ * 🔴 **감사가 통과했을 때만 부른다.** 체크포인트의 뜻이 "여기까지 봤고 깨끗했다"
1943
+ * 이므로, 위반이 있는데 적으면 그 위반이 영원히 검사 밖으로 나간다.
1944
+ *
1945
+ * 🔴 적는 것은 장부 상태의 **사본이 아니라 커밋 sha 하나**다. 사본을 두면 장부와
1946
+ * 두 벌이 되고, 두 벌은 반드시 어긋난다. 주소는 어긋날 수가 없다.
1947
+ *
1948
+ * 실패해도 **죽지 않는다.** 못 적은 체크포인트의 대가는 다음 감사가 느린 것이지
1949
+ * 판정이 헐거워지는 것이 아니다 — 장부 push 실패와 정반대다.
1950
+ */
1951
+ function saveCheckpoint(root, cwd, ledgerTip, time, snapshotCount, agent) {
1952
+ const r0 = resolveRemote(root)
1953
+ const remote = r0.source === 'ambiguous' ? null : r0.name
1954
+ const entry = {
1955
+ ledger: ledgerTip,
1956
+ time,
1957
+ at: new Date(time).toISOString(),
1958
+ snapshots: snapshotCount,
1959
+ by: agent,
1960
+ }
1961
+ // CI 컨테이너에는 git 신원이 없을 수 있다. commit-tree 는 그것 없이는 못 만든다.
1962
+ const ident = git(['config', 'user.email'], { cwd: root }).out
1963
+ const idArgs = ident ? [] : ['-c', 'user.name=axmap', '-c', 'user.email=axmap@invalid']
1964
+
1965
+ // 회차는 1부터 센다 — casBackoff 가 그 관례로 대기 시간을 고른다.
1966
+ for (let attempt = 1; attempt <= MAX_CAS_RETRIES; attempt++) {
1967
+ const parent = checkpointTip(root, { fetch: !!remote })
1968
+ const list = readCheckpoints(cwd, parent)
1969
+ if (list.some((c) => c.ledger === ledgerTip)) {
1970
+ console.log(`체크포인트가 이미 있습니다: ${ledgerTip.slice(0, 7)}`)
1971
+ return
1972
+ }
1973
+ const next = [...list, entry].sort((a, b) => a.time - b.time)
1974
+ const body = JSON.stringify({ version: 1, checkpoints: next }, null, 2) + NL
1975
+ const blob = git(['hash-object', '-w', '--stdin'], { cwd: root, input: body })
1976
+ if (blob.code !== 0) return warnCheckpoint(blob)
1977
+ const tree = git(['mktree'], { cwd: root, input: `100644 blob ${blob.out}${TAB}${CHECKPOINT_FILE}${NL}` })
1978
+ if (tree.code !== 0) return warnCheckpoint(tree)
1979
+ const msg = `axmap: checkpoint ${ledgerTip.slice(0, 7)} (스냅샷 ${snapshotCount}개까지 재생 확인)`
1980
+ const commit = git(
1981
+ [...idArgs, 'commit-tree', tree.out, ...(parent ? ['-p', parent] : []), '-m', msg],
1982
+ { cwd: root },
1983
+ )
1984
+ if (commit.code !== 0) return warnCheckpoint(commit)
1985
+
1986
+ if (!remote) {
1987
+ git(['branch', '-f', CHECKPOINT_BRANCH, commit.out], { cwd: root })
1988
+ warnSolo(r0)
1989
+ console.log(`체크포인트를 로컬에만 남겼습니다: ${ledgerTip.slice(0, 7)}`)
1990
+ return
1991
+ }
1992
+ // push 를 먼저 하고 성공했을 때만 로컬 브랜치를 옮긴다. 순서를 뒤집으면
1993
+ // 원격에 없는 체크포인트를 로컬이 진짜로 믿게 된다.
1994
+ const p = git(['push', '--quiet', remote, `${commit.out}:refs/heads/${CHECKPOINT_BRANCH}`], { cwd: root })
1995
+ if (p.code === 0) {
1996
+ git(['branch', '-f', CHECKPOINT_BRANCH, commit.out], { cwd: root })
1997
+ console.log(`체크포인트를 남겼습니다: ${ledgerTip.slice(0, 7)} (${CHECKPOINT_BRANCH})`)
1998
+ return
1999
+ }
2000
+ // 거부 = 남이 먼저 올렸다. 다시 받아서 그 위에 얹는다 (장부의 CAS 와 같은 모양).
2001
+ if (attempt === MAX_CAS_RETRIES) return warnCheckpoint(p)
2002
+ casBackoff(attempt) // 이 함수가 직접 잔다
2003
+ }
2004
+ }
2005
+
1785
2006
  function cmdAudit(flags) {
1786
2007
  const root = repoRoot()
1787
2008
  const dir = ledgerDir(root)
1788
2009
  const hasWorktree = fs.existsSync(path.join(dir, '.git'))
2010
+ const wantCheckpoint = boolFlag(flags.checkpoint, 'checkpoint')
1789
2011
 
1790
2012
  /**
1791
2013
  * 🔴 **`--fetch` 는 장부 worktree 를 요구하지 않는다.**
@@ -1803,9 +2025,10 @@ function cmdAudit(flags) {
1803
2025
  * (`log` · `ls-tree` · `show`). 쓰지 않으므로 worktree 가 필요 없고 ref 하나면 된다.
1804
2026
  */
1805
2027
  let cwd = dir
1806
- let tip = 'HEAD'
2028
+ let tip = null
1807
2029
  if (hasWorktree) {
1808
2030
  if (flags.fetch) syncLedger(root)
2031
+ tip = gitOrDie(['rev-parse', 'HEAD'], { cwd: dir }).out
1809
2032
  } else if (flags.fetch) {
1810
2033
  const r = resolveRemote(root)
1811
2034
  const remote = r.source === 'ambiguous' ? null : r.name
@@ -1827,21 +2050,60 @@ function cmdAudit(flags) {
1827
2050
  )
1828
2051
  }
1829
2052
  cwd = root
1830
- tip = 'FETCH_HEAD'
2053
+ // 🔴 FETCH_HEAD 를 그대로 들고 다니지 않는다. 바로 뒤에서 체크포인트를 또
2054
+ // 받으면 덮어쓴다. 받은 자리에서 sha 로 굳힌다.
2055
+ tip = gitOrDie(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
1831
2056
  } else {
1832
2057
  requireLedger(root)
2058
+ tip = gitOrDie(['rev-parse', 'HEAD'], { cwd: dir }).out
1833
2059
  }
1834
2060
 
1835
- // 오래된 것부터 재생한다.
1836
- const log = gitOrDie(['log', '--reverse', '--format=%H%x00%ct%x00%s', tip], { cwd }).out
1837
- const commits = log ? log.split('\n').map((l) => l.split('\0')) : []
2061
+ const codeCommits = typeof flags.code === 'string' && flags.code
2062
+ ? collectCodeCommits(root, flags.code)
2063
+ : null
2064
+
2065
+ // 어디서부터 재생할지는 **이번 코드 구간에서 가장 오래된 커밋**이 정한다.
2066
+ // 왜 "가장 최근 체크포인트" 가 아닌지는 planAudit 위에 적혀 있다.
2067
+ const checkpoints = readCheckpoints(cwd, checkpointTip(root, { fetch: !!flags.fetch }))
2068
+ const plan = planAudit({ checkpoints, codeCommits })
2069
+
2070
+ let replay = null
2071
+ let lines = []
2072
+ const FMT = '%H%x00%ct%x00%s'
2073
+ if (plan.from) {
2074
+ /**
2075
+ * 🔴 체크포인트가 가리키는 커밋이 지금 장부의 조상이 아니면 **판정 불가**다.
2076
+ * 이력이 다시 쓰였다는 뜻이고, 그러면 "여기까지 봤다" 는 문장이 가리키는
2077
+ * 이력이 더 이상 없다. 성능을 위해 보장을 무르게 만들지 않는다.
2078
+ */
2079
+ if (git(['merge-base', '--is-ancestor', plan.from.ledger, tip], { cwd }).code !== 0) {
2080
+ die(
2081
+ `판정 불가 — 체크포인트 ${plan.from.ledger.slice(0, 7)} 가 지금 장부(${tip.slice(0, 7)})의 조상이 아닙니다.` + NL +
2082
+ ' 장부 이력이 다시 쓰였을 때만 이렇게 됩니다. 그 체크포인트가 보증하던 구간이 사라졌으므로' + NL +
2083
+ ' 이어서 재생할 수 없습니다. 체크포인트 브랜치를 지우고 (' + CHECKPOINT_BRANCH + ')' + NL +
2084
+ ' `axmap audit --checkpoint` 로 처음부터 다시 세우세요.',
2085
+ )
2086
+ }
2087
+ const before = Number(gitOrDie(['rev-list', '--count', plan.from.ledger], { cwd }).out)
2088
+ replay = { from: plan.from.ledger, at: plan.from.at ?? new Date(plan.from.time).toISOString(), covered: before - 1 }
2089
+ // 체크포인트 자신도 재생 목록에 넣는다. I5 가 그 시점의 장부 상태를 밑바탕으로 쓴다.
2090
+ const head = gitOrDie(['log', '-1', `--format=${FMT}`, plan.from.ledger], { cwd }).out
2091
+ const rest = gitOrDie(['log', '--reverse', `--format=${FMT}`, `${plan.from.ledger}..${tip}`], { cwd }).out
2092
+ lines = [head, ...(rest ? rest.split(NL) : [])]
2093
+ } else {
2094
+ // 체크포인트가 하나도 없다. 예전과 똑같이 뿌리부터 전부 재생한다.
2095
+ const log = gitOrDie(['log', '--reverse', `--format=${FMT}`, tip], { cwd }).out
2096
+ lines = log ? log.split(NL) : []
2097
+ }
1838
2098
 
1839
2099
  const snapshots = []
1840
- for (const [sha, ct, subject] of commits) {
2100
+ for (const line of lines) {
2101
+ if (!line) continue
2102
+ const [sha, ct, subject] = line.split(NUL)
1841
2103
  const files = git(['ls-tree', '-r', '--name-only', sha, 'claims/'], { cwd }).out
1842
2104
  const claims = []
1843
2105
  // readClaims 와 같은 규칙으로 .json 만 본다. claims/ 에는 .gitkeep 도 있다.
1844
- for (const f of (files ? files.split('\n') : []).filter((f) => f.endsWith('.json'))) {
2106
+ for (const f of (files ? files.split(NL) : []).filter((f) => f.endsWith('.json'))) {
1845
2107
  const blob = git(['show', `${sha}:${f}`], { cwd })
1846
2108
  if (blob.code !== 0) continue
1847
2109
  try {
@@ -1853,17 +2115,57 @@ function cmdAudit(flags) {
1853
2115
  snapshots.push({ commit: sha, time: Number(ct) * 1000, subject, claims })
1854
2116
  }
1855
2117
 
1856
- const codeCommits = typeof flags.code === 'string' && flags.code
1857
- ? collectCodeCommits(root, flags.code)
1858
- : null
2118
+ /**
2119
+ * 🔴 체크포인트에 적힌 시각과 그 커밋을 실제로 펼친 시각이 다르면 **판정 불가**다.
2120
+ * 시작 지점을 고르는 계산이 전부 그 시각 위에 서 있으므로, 어긋난 채로 가면
2121
+ * "가장 오래된 코드 커밋보다 앞선다" 는 판단 자체가 거짓이 된다.
2122
+ */
2123
+ if (plan.from && snapshots.length) {
2124
+ const actual = snapshotTime(snapshots[0])
2125
+ if (actual !== plan.from.time) {
2126
+ die(
2127
+ `판정 불가 — 체크포인트 ${plan.from.ledger.slice(0, 7)} 에 적힌 시각과 실제 장부 스냅샷의 시각이 다릅니다.` + NL +
2128
+ ` 적힌 것: ${new Date(plan.from.time).toISOString()}` + NL +
2129
+ ` 실제: ${new Date(actual).toISOString()}`,
2130
+ )
2131
+ }
2132
+ }
2133
+
2134
+ const amnesty = plan.amnestied.map((c) => ({
2135
+ sha: c.sha,
2136
+ subject: c.subject,
2137
+ at: new Date(c.time).toISOString(),
2138
+ }))
2139
+ const report = auditLedger(snapshots, plan.judged, { replay, amnesty })
2140
+
2141
+ /**
2142
+ * 🔴 그물. 판정하기로 한 커밋이 재생 구간보다 앞서 있으면 enforcementViolations
2143
+ * 가 그것을 "장부보다 앞선 커밋" 으로 조용히 건너뛴다 — 거부해야 할 것을
2144
+ * 통과시키는 쪽의 실패다. 시작 지점 고르기가 어긋났을 때만 생기는 일이라
2145
+ * 평소에는 절대 안 걸리지만, 걸리면 통과가 아니라 중단이어야 한다.
2146
+ */
2147
+ if (plan.from && report.codeSkipped?.length) {
2148
+ die(
2149
+ `판정 불가 — 재생 구간(체크포인트 ${plan.from.ledger.slice(0, 7)} 이후)보다 앞선 코드 커밋 ${report.codeSkipped.length}개가 남았습니다.` + NL +
2150
+ report.codeSkipped.map((s) => ` - ${String(s.commit).slice(0, 7)} ${s.subject}`).join(NL),
2151
+ )
2152
+ }
1859
2153
 
1860
- const report = auditLedger(snapshots, codeCommits)
1861
2154
  if (flags.json) {
1862
2155
  console.log(JSON.stringify(report, null, 2))
1863
2156
  } else {
1864
2157
  console.log(formatAudit(report))
1865
2158
  }
1866
2159
  if (!report.ok) process.exit(4)
2160
+
2161
+ if (wantCheckpoint) {
2162
+ if (!snapshots.length) {
2163
+ console.log('장부에 스냅샷이 없어 체크포인트를 남기지 않았습니다.')
2164
+ return
2165
+ }
2166
+ const total = Number(gitOrDie(['rev-list', '--count', tip], { cwd }).out)
2167
+ saveCheckpoint(root, cwd, tip, snapshotTime(snapshots[snapshots.length - 1]), total, resolveAgentName(flags).name)
2168
+ }
1867
2169
  }
1868
2170
 
1869
2171
  /**
@@ -2295,13 +2597,15 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
2295
2597
  쪽지를 읽다가 "쪽지함을 못 열었습니다" 를 봤을 때
2296
2598
  axmap users-repair 사람별 상태(어디까지 읽었나)만 준비한다
2297
2599
  axmap claim <경로...> 경로를 선점한다 [--task --intent --ttl 30m]
2298
- axmap release [경로...] 반납한다 (경로 생략 시 전부)
2600
+ axmap release [경로...] 반납한다 (경로 생략 시 이 세션이 잡은 것 전부)
2601
+ [--all-sessions 같은 이름의 다른 창이 잡은 것까지]
2299
2602
  axmap renew TTL 을 연장한다 [--ttl 30m]
2300
- claim·release 는 [--session <id>] [--takeover]
2603
+ claim·release·renew 는 [--session <id>]
2301
2604
  axmap status 누가 무엇을 잡고 있는지 [--json]
2302
2605
  axmap verify staged 파일이 내 claim 안에 있는지 검사
2303
2606
  axmap audit 장부 이력 전체를 재생해 상호배제 위반을 사후 증명
2304
- [--json] [--fetch]
2607
+ [--json] [--fetch] [--code <구간>]
2608
+ [--checkpoint 통과하면 "여기까지 봤다" 를 남긴다]
2305
2609
  axmap doctor 이 PC 에서 선점이 실제로 도는지 점검 [--json]
2306
2610
  axmap hook install verify 를 pre-commit 훅으로 설치
2307
2611
  axmap update 새 버전이 있는지 묻는다 (바꾸지는 않는다)
@@ -2320,8 +2624,9 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
2320
2624
 
2321
2625
  에이전트 이름: --agent 또는 AXMAP_AGENT, 없으면 git config user.name
2322
2626
  세션 id : --session 또는 AXMAP_SESSION, 없으면 CLAUDE_CODE_SESSION_ID
2323
- 같은 이름이라도 세션이 다르면 남의 claim 덮지 않고 거부한다(종료 코드 2).
2324
- 정말 같은 세션이라면 --takeover 넘겨받는다 경로는 없어진다.
2627
+ 레코드는 이름당 하나가 아니라 (이름, 세션) 짝마다 하나다. 그래서
2628
+ PC 둘이 서로를 덮지 않고, release 도 이 세션이 잡은 것만 푼다.
2629
+ 다른 창이 두고 간 것까지 풀려면 release --all-sessions.
2325
2630
  장부 원격: --remote 또는 AXMAP_REMOTE, 없으면 git config axmap.remote
2326
2631
  `
2327
2632