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/CLAUDE.md +12 -8
- package/README.md +3 -2
- package/app/lib/teamview.mjs +15 -2
- package/bin/axmap.mjs +500 -121
- package/mcp/README.md +3 -2
- package/mcp/server.mjs +12 -6
- package/package.json +1 -1
- package/src/invariants.mjs +103 -2
- package/src/protocol.mjs +185 -144
- package/tools/bus.mjs +421 -14
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
|
-
|
|
24
|
+
formatOtherSessions,
|
|
24
25
|
shortSession,
|
|
25
26
|
humanDuration,
|
|
26
27
|
normalizePath,
|
|
27
28
|
activeClaims,
|
|
28
29
|
claimExpiresAt,
|
|
29
|
-
|
|
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,
|
|
@@ -108,6 +110,45 @@ const LEDGER_REL = path.join('.axmap', 'ledger')
|
|
|
108
110
|
*/
|
|
109
111
|
const BUS_BRANCH = 'axmap/bus'
|
|
110
112
|
const BUS_REL = path.join('.axmap', 'bus')
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 사람별 상태가 사는 곳 — 어디까지 읽었는지, 이 사람이 쓰는 이름이 무엇무엇인지.
|
|
116
|
+
*
|
|
117
|
+
* 🔴 왜 쪽지함과 다른 브랜치인가. 쪽지 **본문**은 모두가 공유하는 한 벌이고,
|
|
118
|
+
* **어디까지 읽었는지**는 사람마다 다르다. 한 브랜치에 섞으면 쪽지를 읽기만
|
|
119
|
+
* 해도 공유 브랜치에 커밋이 쌓인다 — 읽는 행위가 남에게 보이는 흔적을 남기는
|
|
120
|
+
* 것은 쪽지함이 할 일이 아니다.
|
|
121
|
+
*
|
|
122
|
+
* 🔴 그리고 왜 각자 PC 의 파일이 아닌가. 지금까지 읽음 표시는 작업 폴더 안의
|
|
123
|
+
* 파일 하나였다. 그래서 **PC 를 바꾸면 전부 다시 안 읽음**이 됐다. 쪽지는
|
|
124
|
+
* 공유되는데 읽었다는 사실만 그 PC 에 갇혀 있었다.
|
|
125
|
+
*
|
|
126
|
+
* 파일은 사람마다 하나(`users/<이메일>.json`)다. 서로 다른 파일이라 여럿이
|
|
127
|
+
* 동시에 써도 부딪히지 않는다 — 쪽지가 파일 하나씩인 것과 같은 이유다.
|
|
128
|
+
*/
|
|
129
|
+
const USERS_BRANCH = 'axmap/users'
|
|
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
|
+
|
|
111
152
|
/**
|
|
112
153
|
* `release` 가 아무것도 반납하지 못했을 때. SPEC 8절.
|
|
113
154
|
* 0 이 아니어야 하는 이유는 그 주변 주석에 적혀 있다.
|
|
@@ -135,6 +176,14 @@ const GIT_ENV_KEYS = [
|
|
|
135
176
|
'GIT_PREFIX',
|
|
136
177
|
]
|
|
137
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
|
+
|
|
138
187
|
function cleanEnv(keep = []) {
|
|
139
188
|
const e = { ...process.env }
|
|
140
189
|
for (const k of GIT_ENV_KEYS) if (!keep.includes(k)) delete e[k]
|
|
@@ -591,6 +640,15 @@ function busMessagesDir(root) {
|
|
|
591
640
|
return path.join(busDir(root), 'messages')
|
|
592
641
|
}
|
|
593
642
|
|
|
643
|
+
function usersDir(root) {
|
|
644
|
+
return path.join(root, USERS_REL)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** 사람마다의 파일이 쌓이는 곳. `.gitattributes` 와 섞이지 않게 한 폴더 아래 모은다. */
|
|
648
|
+
function usersFilesDir(root) {
|
|
649
|
+
return path.join(usersDir(root), 'users')
|
|
650
|
+
}
|
|
651
|
+
|
|
594
652
|
/**
|
|
595
653
|
* 옛 쪽지함. **읽기만 한다.**
|
|
596
654
|
*
|
|
@@ -805,19 +863,29 @@ function resolveSessionId(flags) {
|
|
|
805
863
|
}
|
|
806
864
|
|
|
807
865
|
/**
|
|
808
|
-
*
|
|
866
|
+
* 물러난 깃발 `--takeover` 를 만났을 때.
|
|
867
|
+
*
|
|
868
|
+
* 🔴 **그냥 무시하면 안 된다.** `parseArgs` 는 모르는 깃발을 조용히 흘려보내는데,
|
|
869
|
+
* `--takeover ci` 처럼 쓰면 뒤 낱말을 값으로 삼켜 **경로 하나가 통째로 사라진다.**
|
|
870
|
+
* 그래서 여기서 `boolFlag` 로 한 번 걸러 그 사고부터 막는다.
|
|
871
|
+
*
|
|
872
|
+
* 이 깃발은 "같은 이름의 다른 세션에게서 레코드를 넘겨받는다" 는 뜻이었다.
|
|
873
|
+
* 이제 세션마다 레코드가 따로라 넘겨받을 것이 없다. release 에서만 뜻이 남아
|
|
874
|
+
* `--all-sessions` 로 이름이 바뀌었다.
|
|
809
875
|
*
|
|
810
|
-
*
|
|
811
|
-
* "덮어썼는데 아무도 몰랐다" 였다. 막을 근거가 없을 때 할 수 있는 최소한은
|
|
812
|
-
* 무엇이 일어나는지를 말하는 것이다.
|
|
876
|
+
* @returns {boolean} release 에서 `--all-sessions` 로 이어줄지
|
|
813
877
|
*/
|
|
814
|
-
function
|
|
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
|
+
}
|
|
815
884
|
console.error(
|
|
816
|
-
|
|
817
|
-
'
|
|
818
|
-
` (${sessionFrom ? `내 세션은 ${sessionFrom} 에서 왔지만 레코드에 세션 표식이 없습니다` : 'AXMAP_SESSION 도 CLAUDE_CODE_SESSION_ID 도 없습니다'}).\n` +
|
|
819
|
-
' 한 PC 에서 세션을 여럿 굴린다면 세션마다 AXMAP_SESSION 을 다르게 주세요.',
|
|
885
|
+
'알림: --takeover 는 이제 필요 없습니다 — 세션마다 레코드가 따로 적히므로 넘겨받을 것이 없습니다.\n' +
|
|
886
|
+
' claim 은 그대로 진행합니다.',
|
|
820
887
|
)
|
|
888
|
+
return false
|
|
821
889
|
}
|
|
822
890
|
|
|
823
891
|
/**
|
|
@@ -1012,10 +1080,49 @@ function readClaims(root) {
|
|
|
1012
1080
|
})
|
|
1013
1081
|
}
|
|
1014
1082
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
+
}
|
|
1019
1126
|
}
|
|
1020
1127
|
|
|
1021
1128
|
/**
|
|
@@ -1070,11 +1177,10 @@ function ensureLedgerIgnore(root) {
|
|
|
1070
1177
|
function writeClaim(root, claim) {
|
|
1071
1178
|
fs.mkdirSync(claimsDir(root), { recursive: true })
|
|
1072
1179
|
ensureLedgerIgnore(root)
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
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')
|
|
1078
1184
|
}
|
|
1079
1185
|
|
|
1080
1186
|
/**
|
|
@@ -1108,11 +1214,25 @@ function restoreClaimFile(file, prev) {
|
|
|
1108
1214
|
*
|
|
1109
1215
|
* @returns {'ready'|'created'|'failed'}
|
|
1110
1216
|
*/
|
|
1111
|
-
|
|
1112
|
-
|
|
1217
|
+
/**
|
|
1218
|
+
* 고아 브랜치(**코드 이력과 아무 조상도 공유하지 않는 별도 계보**) 하나를 만들고
|
|
1219
|
+
* 그것을 옆 폴더에 펼친다. 쪽지함과 사람별 상태가 **같은 코드**로 만들어진다.
|
|
1220
|
+
*
|
|
1221
|
+
* 🔴 이 함수가 있는 이유는 사본을 안 만들기 위해서다. 쪽지함 것을 복사해서
|
|
1222
|
+
* 사람 것을 만들면 두 벌이 되고, 두 벌은 반드시 어긋난다 — 이 저장소가 팀
|
|
1223
|
+
* 사본을 걷어낸 것과 같은 이유이고, 실제로 그 어긋남으로 쪽지가 묻혔다.
|
|
1224
|
+
*
|
|
1225
|
+
* @param what 실패 메시지에 쓸 사람 말 ("쪽지함" · "사람별 상태")
|
|
1226
|
+
* @param seed worktree 를 만든 직후 그 안을 채우는 함수. 브랜치마다 다른 유일한 곳
|
|
1227
|
+
*/
|
|
1228
|
+
function ensureOrphan(root, { branch, rel, dir, what, seed }) {
|
|
1229
|
+
const fail = (r) => {
|
|
1230
|
+
console.error(`경고: ${what}을(를) 준비하지 못했습니다 - ${(r.err || r.out || '').split('\n')[0]}`)
|
|
1231
|
+
return 'failed'
|
|
1232
|
+
}
|
|
1113
1233
|
if (fs.existsSync(path.join(dir, '.git'))) return 'ready'
|
|
1114
1234
|
if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
|
|
1115
|
-
console.error(`경고: ${
|
|
1235
|
+
console.error(`경고: ${rel} 가 있지만 ${what} worktree 가 아닙니다.\n 지운 뒤 다시 실행하세요: rm -rf ${rel}`)
|
|
1116
1236
|
return 'failed'
|
|
1117
1237
|
}
|
|
1118
1238
|
|
|
@@ -1122,54 +1242,68 @@ function ensureBus(root) {
|
|
|
1122
1242
|
const remote = r.source === 'ambiguous' ? null : r.name
|
|
1123
1243
|
|
|
1124
1244
|
let base = null
|
|
1125
|
-
if (remote && git(['fetch', '--quiet', remote,
|
|
1245
|
+
if (remote && git(['fetch', '--quiet', remote, branch], { cwd: root }).code === 0) {
|
|
1126
1246
|
base = git(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
|
|
1127
1247
|
} else {
|
|
1128
1248
|
// 빈 트리 -> 부모 없는 커밋 -> 브랜치. 작업 트리를 건드리지 않는 고아 브랜치.
|
|
1129
1249
|
const tree = git(['mktree'], { cwd: root, input: '' })
|
|
1130
|
-
if (tree.code !== 0) return
|
|
1131
|
-
const c = git(['commit-tree', tree.out, '-m',
|
|
1132
|
-
if (c.code !== 0) return
|
|
1250
|
+
if (tree.code !== 0) return fail(tree)
|
|
1251
|
+
const c = git(['commit-tree', tree.out, '-m', `axmap: ${branch} init`], { cwd: root })
|
|
1252
|
+
if (c.code !== 0) return fail(c)
|
|
1133
1253
|
base = c.out
|
|
1134
1254
|
}
|
|
1135
1255
|
|
|
1136
|
-
if (git(['rev-parse', '--verify', '--quiet',
|
|
1137
|
-
const b = git(['branch',
|
|
1138
|
-
if (b.code !== 0) return
|
|
1256
|
+
if (git(['rev-parse', '--verify', '--quiet', branch], { cwd: root }).code !== 0) {
|
|
1257
|
+
const b = git(['branch', branch, base], { cwd: root })
|
|
1258
|
+
if (b.code !== 0) return fail(b)
|
|
1139
1259
|
}
|
|
1140
|
-
const w = git(['worktree', 'add', '--quiet', dir,
|
|
1141
|
-
if (w.code !== 0) return
|
|
1260
|
+
const w = git(['worktree', 'add', '--quiet', dir, branch], { cwd: root })
|
|
1261
|
+
if (w.code !== 0) return fail(w)
|
|
1142
1262
|
|
|
1143
|
-
|
|
1144
|
-
fs.mkdirSync(busMessagesDir(root), { recursive: true })
|
|
1145
|
-
fs.writeFileSync(path.join(busMessagesDir(root), '.gitkeep'), '')
|
|
1263
|
+
seed(dir)
|
|
1146
1264
|
|
|
1147
1265
|
// 🔴 고아 브랜치는 **자기 트리의 `.gitattributes` 만** 본다. 저장소 루트에
|
|
1148
1266
|
// 있는 것은 여기 안 닿으므로, 심어 두지 않으면 Windows(`core.autocrlf=true`)
|
|
1149
|
-
// 에서
|
|
1267
|
+
// 에서 파일이 CRLF 로 체크아웃된다. 2026-08-26 에 실제로 그랬고, 머리말
|
|
1150
1268
|
// 파서가 `\r` 에 걸려 **쪽지가 목록에서 조용히 사라졌다.**
|
|
1151
1269
|
// 파서도 함께 고쳤지만(`tools/bus.mjs`) 바이트가 플랫폼마다 달라지는 것
|
|
1152
1270
|
// 자체를 막는 편이 낫다 — 해시도, diff 도, 파서도 전부 같은 것을 본다.
|
|
1153
1271
|
fs.writeFileSync(
|
|
1154
1272
|
path.join(dir, '.gitattributes'),
|
|
1155
|
-
'#
|
|
1273
|
+
'# 이 브랜치의 파일은 어느 OS 에서 만들어도 같은 바이트여야 한다.\n' +
|
|
1156
1274
|
'# 저장소 루트의 .gitattributes 는 고아 브랜치에 닿지 않으므로 여기 따로 둔다.\n' +
|
|
1157
1275
|
'* text=auto eol=lf\n',
|
|
1158
1276
|
)
|
|
1159
1277
|
|
|
1160
1278
|
if (remote) {
|
|
1161
1279
|
git(['add', '-A'], { cwd: dir })
|
|
1162
|
-
// --no-verify: 연결된 worktree 는 훅을 공유한다.
|
|
1163
|
-
git(['commit', '--quiet', '--no-verify', '-m',
|
|
1164
|
-
const p = git(['push', '--quiet', remote, `HEAD:${
|
|
1165
|
-
if (p.code !== 0) console.error(`경고:
|
|
1280
|
+
// --no-verify: 연결된 worktree 는 훅을 공유한다. 이 브랜치는 사용자 코드가 아니다.
|
|
1281
|
+
git(['commit', '--quiet', '--no-verify', '-m', `axmap: ${branch} init`], { cwd: dir })
|
|
1282
|
+
const p = git(['push', '--quiet', remote, `HEAD:${branch}`], { cwd: dir })
|
|
1283
|
+
if (p.code !== 0) console.error(`경고: ${what} push 실패 - ${(p.err ?? '').split('\n')[0]}`)
|
|
1166
1284
|
}
|
|
1167
1285
|
return 'created'
|
|
1168
1286
|
}
|
|
1169
1287
|
|
|
1170
|
-
function
|
|
1171
|
-
|
|
1172
|
-
|
|
1288
|
+
function ensureBus(root) {
|
|
1289
|
+
return ensureOrphan(root, {
|
|
1290
|
+
branch: BUS_BRANCH, rel: BUS_REL, dir: busDir(root), what: '쪽지함',
|
|
1291
|
+
seed: () => {
|
|
1292
|
+
// git 은 빈 디렉터리를 추적하지 않는다. 쪽지가 0개인 동안에도 폴더가 살아 있게 한다.
|
|
1293
|
+
fs.mkdirSync(busMessagesDir(root), { recursive: true })
|
|
1294
|
+
fs.writeFileSync(path.join(busMessagesDir(root), '.gitkeep'), '')
|
|
1295
|
+
},
|
|
1296
|
+
})
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function ensureUsers(root) {
|
|
1300
|
+
return ensureOrphan(root, {
|
|
1301
|
+
branch: USERS_BRANCH, rel: USERS_REL, dir: usersDir(root), what: '사람별 상태',
|
|
1302
|
+
seed: () => {
|
|
1303
|
+
fs.mkdirSync(usersFilesDir(root), { recursive: true })
|
|
1304
|
+
fs.writeFileSync(path.join(usersFilesDir(root), '.gitkeep'), '')
|
|
1305
|
+
},
|
|
1306
|
+
})
|
|
1173
1307
|
}
|
|
1174
1308
|
|
|
1175
1309
|
/**
|
|
@@ -1198,6 +1332,19 @@ function cmdBusRepair() {
|
|
|
1198
1332
|
)
|
|
1199
1333
|
}
|
|
1200
1334
|
|
|
1335
|
+
/** 사람별 상태만 준비한다. `bus-repair` 와 같은 이유로 따로 있다. */
|
|
1336
|
+
function cmdUsersRepair() {
|
|
1337
|
+
const root = repoRoot()
|
|
1338
|
+
git(['worktree', 'prune'], { cwd: root })
|
|
1339
|
+
const r = ensureUsers(root)
|
|
1340
|
+
if (r === 'failed') process.exit(1)
|
|
1341
|
+
console.log(
|
|
1342
|
+
r === 'ready'
|
|
1343
|
+
? `사람별 상태가 이미 있습니다: ${USERS_REL}`
|
|
1344
|
+
: `사람별 상태를 만들었습니다: ${USERS_REL} (${USERS_BRANCH})`,
|
|
1345
|
+
)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1201
1348
|
function cmdInit(flags = {}) {
|
|
1202
1349
|
const root = repoRoot()
|
|
1203
1350
|
const dir = ledgerDir(root)
|
|
@@ -1212,6 +1359,7 @@ function cmdInit(flags = {}) {
|
|
|
1212
1359
|
// 돌린 사람은 장부만 있고 쪽지함이 없다. 그 사람들이 다시 init 을 불렀을 때
|
|
1213
1360
|
// 받아 가는 자리가 여기다.
|
|
1214
1361
|
if (ensureBus(root) === 'created') console.log(`쪽지함을 만들었습니다: ${BUS_REL} (${BUS_BRANCH})`)
|
|
1362
|
+
if (ensureUsers(root) === 'created') console.log(`사람별 상태를 만들었습니다: ${USERS_REL} (${USERS_BRANCH})`)
|
|
1215
1363
|
return
|
|
1216
1364
|
}
|
|
1217
1365
|
if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
|
|
@@ -1288,8 +1436,9 @@ function cmdInit(flags = {}) {
|
|
|
1288
1436
|
}
|
|
1289
1437
|
|
|
1290
1438
|
ensureBus(root)
|
|
1439
|
+
ensureUsers(root)
|
|
1291
1440
|
|
|
1292
|
-
console.log(`준비 완료. 장부 ${LEDGER_BRANCH} · 쪽지함 ${BUS_BRANCH}`)
|
|
1441
|
+
console.log(`준비 완료. 장부 ${LEDGER_BRANCH} · 쪽지함 ${BUS_BRANCH} · 사람별 상태 ${USERS_BRANCH}`)
|
|
1293
1442
|
}
|
|
1294
1443
|
|
|
1295
1444
|
// ---------------------------------------------------------------------------
|
|
@@ -1301,7 +1450,7 @@ function cmdClaim(positional, flags) {
|
|
|
1301
1450
|
requireLedger(root)
|
|
1302
1451
|
const me = agentName(flags)
|
|
1303
1452
|
const session = resolveSessionId(flags)
|
|
1304
|
-
|
|
1453
|
+
warnRetiredTakeover(flags, 'claim')
|
|
1305
1454
|
if (!positional.length) die('claim 할 경로를 하나 이상 지정하세요.\n axmap claim src/auth --task task-12')
|
|
1306
1455
|
for (const p of positional) {
|
|
1307
1456
|
const err = claimPathError(p)
|
|
@@ -1339,36 +1488,23 @@ function cmdClaim(positional, flags) {
|
|
|
1339
1488
|
// 정하는 필드라 피해가 작지만, 원칙에 예외를 두면 그 예외가 기준이 된다.
|
|
1340
1489
|
actor: resolveActor(flags.actor),
|
|
1341
1490
|
session,
|
|
1342
|
-
takeover,
|
|
1343
1491
|
})
|
|
1344
1492
|
if (!res.ok) {
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
* 부르는 쪽이 해야 할 일이 같기 때문이다 — 재시도하지 말고 방향을 바꾼다.
|
|
1348
|
-
*/
|
|
1349
|
-
if (res.sessionConflict) {
|
|
1350
|
-
console.error(formatSessionConflict(res.sessionConflict, t, 'claim'))
|
|
1351
|
-
console.error(`\n (이 세션의 id 는 ${sessionFrom ?? '어디에서도 오지 않았습니다'})`)
|
|
1352
|
-
} else {
|
|
1353
|
-
console.error(formatBlocks(res.blocks, t))
|
|
1354
|
-
}
|
|
1493
|
+
// 거부는 종료 코드 2 다 (SPEC 8절). 재시도하지 말고 방향을 바꾸라는 뜻이다.
|
|
1494
|
+
console.error(formatBlocks(res.blocks, t))
|
|
1355
1495
|
process.exit(2)
|
|
1356
1496
|
}
|
|
1357
1497
|
if (res.hadExpired) {
|
|
1358
1498
|
console.error(`알림: ${me} 의 이전 claim 이 만료되어 새 claim 으로 시작합니다.`)
|
|
1359
1499
|
}
|
|
1360
|
-
if (res.
|
|
1361
|
-
console.error(
|
|
1362
|
-
|
|
1363
|
-
' 저 세션이 잡고 있던 경로는 하나도 빠지지 않고 그대로 합쳐집니다.',
|
|
1364
|
-
)
|
|
1365
|
-
} else if (res.session === 'unverifiable') {
|
|
1366
|
-
warnUnverifiableSession(me, res.record.paths.length)
|
|
1500
|
+
if (res.otherSessions.length) {
|
|
1501
|
+
console.error(formatOtherSessions(res.otherSessions, want, t))
|
|
1502
|
+
console.error(` (이 세션의 id 는 ${sessionFrom ?? '어디에서도 오지 않았습니다'})`)
|
|
1367
1503
|
}
|
|
1368
1504
|
// push 실패 시 되돌아갈 지점. 커밋을 만들기 **전에** 잡아둔다.
|
|
1369
1505
|
const before = ledgerHead(root)
|
|
1370
1506
|
// 커밋 전에 실패할 수도 있다. 그때는 HEAD 가 아니라 이 파일을 되돌려야 한다.
|
|
1371
|
-
const myFile = claimPath(root, me)
|
|
1507
|
+
const myFile = claimPath(root, me, session)
|
|
1372
1508
|
const myFileBefore = fs.existsSync(myFile) ? fs.readFileSync(myFile) : null
|
|
1373
1509
|
writeClaim(root, res.record)
|
|
1374
1510
|
|
|
@@ -1425,23 +1561,17 @@ function cmdRelease(positional, flags) {
|
|
|
1425
1561
|
requireLedger(root)
|
|
1426
1562
|
const me = agentName(flags)
|
|
1427
1563
|
const session = resolveSessionId(flags)
|
|
1428
|
-
|
|
1564
|
+
// 다른 세션이 잡은 것까지 함께 푸는 문. 기본은 **이 세션이 잡은 것만**이다.
|
|
1565
|
+
// 둘을 각각 먼저 읽는다. `||` 로 이으면 앞이 참일 때 뒤가 안 불리고,
|
|
1566
|
+
// 그러면 `--takeover` 가 뒤 낱말을 삼키는 것을 못 잡는다 (warnRetiredTakeover 참고).
|
|
1567
|
+
const retired = warnRetiredTakeover(flags, 'release')
|
|
1568
|
+
const allSessions = boolFlag(flags['all-sessions'], 'all-sessions') || retired
|
|
1429
1569
|
const drop = positional.map(normalizePath)
|
|
1430
1570
|
|
|
1431
1571
|
for (let attempt = 1; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
1432
1572
|
syncLedger(root)
|
|
1433
|
-
|
|
1434
|
-
const res = applyRelease({ claims: readClaims(root), me, drop, session,
|
|
1435
|
-
/**
|
|
1436
|
-
* 🔴 `hadNothing` 보다 **먼저** 본다. 여기서 막힌 것은 "반납할 게 없다"가
|
|
1437
|
-
* 아니라 "반납하면 남의 세션 기록이 사라진다" 이고, 둘을 같은 코드로 내면
|
|
1438
|
-
* 부르는 쪽이 이름을 고치는 엉뚱한 처방으로 간다.
|
|
1439
|
-
*/
|
|
1440
|
-
if (res.sessionConflict) {
|
|
1441
|
-
console.error(formatSessionConflict(res.sessionConflict, t, 'release'))
|
|
1442
|
-
console.error(`\n (이 세션의 id 는 ${sessionFrom ?? '어디에서도 오지 않았습니다'})`)
|
|
1443
|
-
process.exit(2)
|
|
1444
|
-
}
|
|
1573
|
+
// 🔴 release 는 시계를 안 본다. 만료된 껍데기도 반납 대상이다 — 지우는 것이 곧 정리다.
|
|
1574
|
+
const res = applyRelease({ claims: readClaims(root), me, drop, session, allSessions })
|
|
1445
1575
|
if (res.hadNothing) {
|
|
1446
1576
|
/**
|
|
1447
1577
|
* 🔴 반납을 시켰는데 아무것도 반납 안 된 것은 **성공이 아니다.**
|
|
@@ -1455,8 +1585,24 @@ function cmdRelease(positional, flags) {
|
|
|
1455
1585
|
* 조용히 통과시키는 것이 가장 나쁜 실패다.
|
|
1456
1586
|
*/
|
|
1457
1587
|
const others = readClaims(root).filter((c) => c.agent !== me)
|
|
1458
|
-
console.error(`반납할 것이 없습니다 — "${me}" 이(가) 잡고 있는 경로가 하나도 없습니다.`)
|
|
1588
|
+
console.error(`반납할 것이 없습니다 — "${me}" 이(가) 이 세션에서 잡고 있는 경로가 하나도 없습니다.`)
|
|
1459
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
|
+
}
|
|
1460
1606
|
if (others.length) {
|
|
1461
1607
|
console.error('')
|
|
1462
1608
|
console.error(' 장부에는 다른 이름으로 잡힌 것이 있습니다:')
|
|
@@ -1470,12 +1616,13 @@ function cmdRelease(positional, flags) {
|
|
|
1470
1616
|
if (res.unheld.length) {
|
|
1471
1617
|
console.error(`알림: 잡고 있지 않은 경로는 무시합니다: ${res.unheld.join(', ')}`)
|
|
1472
1618
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
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)
|
|
1475
1622
|
|
|
1476
1623
|
const pushed = commitAndPush(root, `release(${me}): ${drop.length ? drop.join(' ') : 'all'}`)
|
|
1477
1624
|
if (pushed === 'ok' || pushed === 'nothing') {
|
|
1478
|
-
const left = res.
|
|
1625
|
+
const left = res.records.reduce((n, r) => n + r.paths.length, 0)
|
|
1479
1626
|
console.log(`release 완료 - ${me}${left ? ` (남은 ${left}개)` : ' (전부 반납)'}`)
|
|
1480
1627
|
return
|
|
1481
1628
|
}
|
|
@@ -1497,23 +1644,22 @@ function cmdRenew(flags) {
|
|
|
1497
1644
|
syncLedger(root)
|
|
1498
1645
|
const t = now()
|
|
1499
1646
|
const res = applyRenew({ claims: readClaims(root), me, now: t, ttlMs, session })
|
|
1500
|
-
/**
|
|
1501
|
-
* renew 는 **막지 않는다** (applyRenew 의 주석 참고). 다만 남의 세션 락의
|
|
1502
|
-
* 수명을 늘리고 있다는 사실은 말한다 — 모르고 늘리면 그 경로는 아무도
|
|
1503
|
-
* 안 쓰는데 계속 막혀 있게 된다.
|
|
1504
|
-
*/
|
|
1505
|
-
if (res.ok && res.session === 'different') {
|
|
1506
|
-
console.error(
|
|
1507
|
-
`경고: "${me}" 의 이 claim 은 다른 세션(${shortSession(res.record.session)})이 만든 것입니다.\n` +
|
|
1508
|
-
' TTL 만 늘리고 소유자는 그대로 둡니다. 저 세션이 끝난 것이면 늘리지 마세요.',
|
|
1509
|
-
)
|
|
1510
|
-
}
|
|
1511
1647
|
if (!res.ok) {
|
|
1512
|
-
|
|
1513
|
-
`${me} 의 유효한 claim 이 없습니다.\n` +
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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)
|
|
1517
1663
|
}
|
|
1518
1664
|
writeClaim(root, res.record)
|
|
1519
1665
|
const pushed = commitAndPush(root, `renew(${me})`)
|
|
@@ -1570,8 +1716,9 @@ function cmdStatus(flags) {
|
|
|
1570
1716
|
}
|
|
1571
1717
|
|
|
1572
1718
|
console.log(`장부 상태 (${new Date(t).toISOString()})\n`)
|
|
1573
|
-
// 이름이 같은 줄이
|
|
1574
|
-
//
|
|
1719
|
+
// 🔴 이름이 같은 줄이 **여럿일 수 있다.** 레코드가 (이름, 세션) 짝마다 하나라,
|
|
1720
|
+
// 창을 둘 띄우면 같은 이름이 두 줄로 선다. 그래서 세션을 반드시 함께 찍는다 —
|
|
1721
|
+
// 안 찍으면 같은 이름 두 줄이 장부의 버그처럼 보인다.
|
|
1575
1722
|
const mySession = resolveSessionId(flags)
|
|
1576
1723
|
for (const c of live) {
|
|
1577
1724
|
console.log(` ${c.agent}${c.task ? ` [${c.task}]` : ''} - ${humanDuration(claimExpiresAt(c) - t)} 남음`)
|
|
@@ -1639,8 +1786,13 @@ function cmdVerify(flags) {
|
|
|
1639
1786
|
|
|
1640
1787
|
if (!staged.length) return
|
|
1641
1788
|
|
|
1642
|
-
|
|
1643
|
-
|
|
1789
|
+
/**
|
|
1790
|
+
* 🔴 여기만 **사람 단위**로 본다 (세션이 아니라 이름 전체).
|
|
1791
|
+
*
|
|
1792
|
+
* 커밋하는 것은 세션이 아니라 사람이다. A 창에서 잡고 B 창에서 커밋하는 것은
|
|
1793
|
+
* 원래 되던 일이고, 여기까지 세션으로 좁히면 고치려던 것보다 큰 고장이 된다.
|
|
1794
|
+
*/
|
|
1795
|
+
const paths = myActivePaths(readClaims(root), me, now())
|
|
1644
1796
|
const uncovered = staged.filter((f) => !coversPath(paths, f))
|
|
1645
1797
|
|
|
1646
1798
|
if (uncovered.length) {
|
|
@@ -1676,8 +1828,6 @@ function collectCodeCommits(root, range) {
|
|
|
1676
1828
|
console.log(`구간에 빈 sha 가 있습니다 (${range}) - 첫 push 로 보고 코드 대조를 건너뜁니다.`)
|
|
1677
1829
|
return null
|
|
1678
1830
|
}
|
|
1679
|
-
const NUL = String.fromCharCode(0)
|
|
1680
|
-
const NL = String.fromCharCode(10)
|
|
1681
1831
|
const log = git(['log', '--no-merges', '--format=%H%x00%an%x00%ct%x00%s', range], { cwd: root })
|
|
1682
1832
|
if (log.code !== 0) {
|
|
1683
1833
|
// 🔴 못 읽은 것을 통과로 내지 않는다. 조용히 빈 배열을 주면 "위반 0" 이 된다.
|
|
@@ -1712,10 +1862,152 @@ function collectCodeCommits(root, range) {
|
|
|
1712
1862
|
}
|
|
1713
1863
|
|
|
1714
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
|
+
|
|
1715
2006
|
function cmdAudit(flags) {
|
|
1716
2007
|
const root = repoRoot()
|
|
1717
2008
|
const dir = ledgerDir(root)
|
|
1718
2009
|
const hasWorktree = fs.existsSync(path.join(dir, '.git'))
|
|
2010
|
+
const wantCheckpoint = boolFlag(flags.checkpoint, 'checkpoint')
|
|
1719
2011
|
|
|
1720
2012
|
/**
|
|
1721
2013
|
* 🔴 **`--fetch` 는 장부 worktree 를 요구하지 않는다.**
|
|
@@ -1733,9 +2025,10 @@ function cmdAudit(flags) {
|
|
|
1733
2025
|
* (`log` · `ls-tree` · `show`). 쓰지 않으므로 worktree 가 필요 없고 ref 하나면 된다.
|
|
1734
2026
|
*/
|
|
1735
2027
|
let cwd = dir
|
|
1736
|
-
let tip =
|
|
2028
|
+
let tip = null
|
|
1737
2029
|
if (hasWorktree) {
|
|
1738
2030
|
if (flags.fetch) syncLedger(root)
|
|
2031
|
+
tip = gitOrDie(['rev-parse', 'HEAD'], { cwd: dir }).out
|
|
1739
2032
|
} else if (flags.fetch) {
|
|
1740
2033
|
const r = resolveRemote(root)
|
|
1741
2034
|
const remote = r.source === 'ambiguous' ? null : r.name
|
|
@@ -1757,21 +2050,60 @@ function cmdAudit(flags) {
|
|
|
1757
2050
|
)
|
|
1758
2051
|
}
|
|
1759
2052
|
cwd = root
|
|
1760
|
-
|
|
2053
|
+
// 🔴 FETCH_HEAD 를 그대로 들고 다니지 않는다. 바로 뒤에서 체크포인트를 또
|
|
2054
|
+
// 받으면 덮어쓴다. 받은 자리에서 sha 로 굳힌다.
|
|
2055
|
+
tip = gitOrDie(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
|
|
1761
2056
|
} else {
|
|
1762
2057
|
requireLedger(root)
|
|
2058
|
+
tip = gitOrDie(['rev-parse', 'HEAD'], { cwd: dir }).out
|
|
1763
2059
|
}
|
|
1764
2060
|
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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
|
+
}
|
|
1768
2098
|
|
|
1769
2099
|
const snapshots = []
|
|
1770
|
-
for (const
|
|
2100
|
+
for (const line of lines) {
|
|
2101
|
+
if (!line) continue
|
|
2102
|
+
const [sha, ct, subject] = line.split(NUL)
|
|
1771
2103
|
const files = git(['ls-tree', '-r', '--name-only', sha, 'claims/'], { cwd }).out
|
|
1772
2104
|
const claims = []
|
|
1773
2105
|
// readClaims 와 같은 규칙으로 .json 만 본다. claims/ 에는 .gitkeep 도 있다.
|
|
1774
|
-
for (const f of (files ? files.split(
|
|
2106
|
+
for (const f of (files ? files.split(NL) : []).filter((f) => f.endsWith('.json'))) {
|
|
1775
2107
|
const blob = git(['show', `${sha}:${f}`], { cwd })
|
|
1776
2108
|
if (blob.code !== 0) continue
|
|
1777
2109
|
try {
|
|
@@ -1783,17 +2115,57 @@ function cmdAudit(flags) {
|
|
|
1783
2115
|
snapshots.push({ commit: sha, time: Number(ct) * 1000, subject, claims })
|
|
1784
2116
|
}
|
|
1785
2117
|
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
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
|
+
}
|
|
1789
2153
|
|
|
1790
|
-
const report = auditLedger(snapshots, codeCommits)
|
|
1791
2154
|
if (flags.json) {
|
|
1792
2155
|
console.log(JSON.stringify(report, null, 2))
|
|
1793
2156
|
} else {
|
|
1794
2157
|
console.log(formatAudit(report))
|
|
1795
2158
|
}
|
|
1796
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
|
+
}
|
|
1797
2169
|
}
|
|
1798
2170
|
|
|
1799
2171
|
/**
|
|
@@ -2223,14 +2595,17 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
|
|
|
2223
2595
|
axmap init 장부(고아 브랜치 + worktree)를 준비한다
|
|
2224
2596
|
axmap bus-repair 쪽지함만 준비한다 (장부는 안 건드린다)
|
|
2225
2597
|
쪽지를 읽다가 "쪽지함을 못 열었습니다" 를 봤을 때
|
|
2598
|
+
axmap users-repair 사람별 상태(어디까지 읽었나)만 준비한다
|
|
2226
2599
|
axmap claim <경로...> 경로를 선점한다 [--task --intent --ttl 30m]
|
|
2227
|
-
axmap release [경로...] 반납한다 (경로 생략 시 전부)
|
|
2600
|
+
axmap release [경로...] 반납한다 (경로 생략 시 이 세션이 잡은 것 전부)
|
|
2601
|
+
[--all-sessions 같은 이름의 다른 창이 잡은 것까지]
|
|
2228
2602
|
axmap renew TTL 을 연장한다 [--ttl 30m]
|
|
2229
|
-
claim·release 는 [--session <id>]
|
|
2603
|
+
claim·release·renew 는 [--session <id>]
|
|
2230
2604
|
axmap status 누가 무엇을 잡고 있는지 [--json]
|
|
2231
2605
|
axmap verify staged 파일이 내 claim 안에 있는지 검사
|
|
2232
2606
|
axmap audit 장부 이력 전체를 재생해 상호배제 위반을 사후 증명
|
|
2233
|
-
[--json] [--fetch]
|
|
2607
|
+
[--json] [--fetch] [--code <구간>]
|
|
2608
|
+
[--checkpoint 통과하면 "여기까지 봤다" 를 남긴다]
|
|
2234
2609
|
axmap doctor 이 PC 에서 선점이 실제로 도는지 점검 [--json]
|
|
2235
2610
|
axmap hook install verify 를 pre-commit 훅으로 설치
|
|
2236
2611
|
axmap update 새 버전이 있는지 묻는다 (바꾸지는 않는다)
|
|
@@ -2249,8 +2624,9 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
|
|
|
2249
2624
|
|
|
2250
2625
|
에이전트 이름: --agent 또는 AXMAP_AGENT, 없으면 git config user.name
|
|
2251
2626
|
세션 id : --session 또는 AXMAP_SESSION, 없으면 CLAUDE_CODE_SESSION_ID
|
|
2252
|
-
|
|
2253
|
-
|
|
2627
|
+
레코드는 이름당 하나가 아니라 (이름, 세션) 짝마다 하나다. 그래서
|
|
2628
|
+
한 PC 의 창 둘이 서로를 덮지 않고, release 도 이 세션이 잡은 것만 푼다.
|
|
2629
|
+
다른 창이 두고 간 것까지 풀려면 release --all-sessions.
|
|
2254
2630
|
장부 원격: --remote 또는 AXMAP_REMOTE, 없으면 git config axmap.remote
|
|
2255
2631
|
`
|
|
2256
2632
|
|
|
@@ -2300,6 +2676,9 @@ switch (cmd) {
|
|
|
2300
2676
|
case 'bus-repair':
|
|
2301
2677
|
cmdBusRepair()
|
|
2302
2678
|
break
|
|
2679
|
+
case 'users-repair':
|
|
2680
|
+
cmdUsersRepair()
|
|
2681
|
+
break
|
|
2303
2682
|
case 'claim':
|
|
2304
2683
|
cmdClaim(rest, flags)
|
|
2305
2684
|
break
|