axmap-cli 0.0.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/.claude/commands/ax-done.md +10 -0
- package/.claude/commands/ax-setup.md +31 -0
- package/.claude/commands/ax-start.md +15 -0
- package/.claude/commands/ax-tell.md +14 -0
- package/.claude/commands/ax-update.md +19 -0
- package/.claude/commands/ax.md +13 -0
- package/CLAUDE.md +309 -0
- package/LICENSE +20 -0
- package/README.md +207 -0
- package/app/README.md +366 -0
- package/app/eval/edges.mjs +242 -0
- package/app/lib/adjacent.mjs +125 -0
- package/app/lib/agentcli.mjs +153 -0
- package/app/lib/analyze.mjs +1159 -0
- package/app/lib/cochange.mjs +421 -0
- package/app/lib/datanodes.mjs +127 -0
- package/app/lib/entry.mjs +192 -0
- package/app/lib/featuregraph.mjs +389 -0
- package/app/lib/features.mjs +645 -0
- package/app/lib/fetchrepo-run.mjs +37 -0
- package/app/lib/fetchrepo.mjs +164 -0
- package/app/lib/flow.mjs +1089 -0
- package/app/lib/ladder.mjs +387 -0
- package/app/lib/langs.mjs +630 -0
- package/app/lib/live.mjs +346 -0
- package/app/lib/llm.mjs +594 -0
- package/app/lib/newfile.mjs +126 -0
- package/app/lib/prdiff.mjs +651 -0
- package/app/lib/reveal.mjs +316 -0
- package/app/lib/roots.mjs +186 -0
- package/app/lib/scope.mjs +342 -0
- package/app/lib/session.mjs +389 -0
- package/app/lib/slots.mjs +233 -0
- package/app/lib/ssot.mjs +277 -0
- package/app/lib/teamview.mjs +962 -0
- package/app/lib/terms.ko.mjs +169 -0
- package/app/server.mjs +1959 -0
- package/app/web/shell.css +538 -0
- package/app/web/shell.html +197 -0
- package/app/web/shell.js +638 -0
- package/app/web/stage.js +347 -0
- package/app/web/words.js +85 -0
- package/bin/axmap.mjs +1918 -0
- package/governance/GOVERNANCE.md +433 -0
- package/governance/gate.mjs +526 -0
- package/governance/vote.mjs +501 -0
- package/mcp/README.md +254 -0
- package/mcp/SETUP-FOR-AI.md +186 -0
- package/mcp/install.ps1 +341 -0
- package/mcp/install.sh +339 -0
- package/mcp/server.mjs +969 -0
- package/package.json +48 -0
- package/src/closure.mjs +343 -0
- package/src/governance.mjs +839 -0
- package/src/invariants.mjs +226 -0
- package/src/mrtarget.mjs +284 -0
- package/src/promote.mjs +177 -0
- package/src/protocol.mjs +423 -0
- package/src/repotarget.mjs +81 -0
- package/src/update.mjs +177 -0
- package/src/version.mjs +186 -0
- package/tools/bus.mjs +520 -0
- package/tools/cluster-experiment.mjs +256 -0
- package/tools/cluster-sweep.mjs +226 -0
- package/tools/make-icon.mjs +108 -0
- package/tools/mcp-register.mjs +269 -0
- package/tools/mr-target.mjs +49 -0
- package/tools/persona-bench.mjs +362 -0
- package/tools/pick-repo.mjs +229 -0
- package/tools/promote.mjs +550 -0
- package/tools/reveal-demo.mjs +158 -0
- package/tools/run-tests.mjs +42 -0
- package/tools/setup.mjs +490 -0
- package/tools/shortcut.mjs +121 -0
- package/tools/smoke.mjs +166 -0
- package/tools/topicgraph.py +154 -0
- package/tools/vendor.mjs +382 -0
- package/tools/version.mjs +115 -0
package/bin/axmap.mjs
ADDED
|
@@ -0,0 +1,1918 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* axMap CLI - 부수효과(git, fs, 시계)를 담당하는 층.
|
|
4
|
+
* 판정 로직은 전부 src/protocol.mjs 의 순수 함수에 있다.
|
|
5
|
+
*
|
|
6
|
+
* 두 개의 관문이 있다.
|
|
7
|
+
* 관문 1 (직렬화) : git push 의 ref CAS. 공짜로 따라오며 끌 수 없다.
|
|
8
|
+
* 실패는 거절이 아니라 "최신 상태 받아서 다시 하라"는 뜻.
|
|
9
|
+
* 관문 2 (판정) : checkOverlap(). 우리가 직접 묻는다.
|
|
10
|
+
* "요청한 경로가 남이 잡은 경로와 겹치는가?"
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import {
|
|
18
|
+
applyClaim,
|
|
19
|
+
applyRelease,
|
|
20
|
+
applyRenew,
|
|
21
|
+
coversPath,
|
|
22
|
+
formatBlocks,
|
|
23
|
+
humanDuration,
|
|
24
|
+
normalizePath,
|
|
25
|
+
activeClaims,
|
|
26
|
+
claimExpiresAt,
|
|
27
|
+
myActiveClaim,
|
|
28
|
+
agentNameError,
|
|
29
|
+
claimPathError,
|
|
30
|
+
} from '../src/protocol.mjs'
|
|
31
|
+
import { auditLedger, formatAudit } from '../src/invariants.mjs'
|
|
32
|
+
import {
|
|
33
|
+
PACKAGE_NAME,
|
|
34
|
+
fetchLatest,
|
|
35
|
+
isVendored,
|
|
36
|
+
checkDisabled,
|
|
37
|
+
readCache,
|
|
38
|
+
writeCache,
|
|
39
|
+
noticeLine,
|
|
40
|
+
registryUrl,
|
|
41
|
+
} from '../src/update.mjs'
|
|
42
|
+
|
|
43
|
+
/** 이 CLI 가 들어 있는 axMap 폴더. 갱신 확인과 setup 이 자기 위치를 알아야 한다. */
|
|
44
|
+
const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
45
|
+
|
|
46
|
+
/** 지금 도는 axMap 의 버전. 못 읽으면 `null` — 벤더링된 사본에는 package.json 이 없다. */
|
|
47
|
+
function selfVersion() {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(path.join(SELF_ROOT, 'package.json'), 'utf8'))?.version ?? null
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 누가 잡았는지의 종류. 판정에는 쓰이지 않고 화면에서 색을 나누는 데만 쓴다.
|
|
57
|
+
* human 사람이 직접
|
|
58
|
+
* agent 대화형 AI (사람이 보고 있음)
|
|
59
|
+
* background 백그라운드 에이전트 (사람이 안 보고 있음)
|
|
60
|
+
* team 다른 팀원
|
|
61
|
+
*/
|
|
62
|
+
const ACTORS = ['human', 'agent', 'background', 'team']
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `--actor` 값을 확정한다. 없으면 환경변수, 그것도 없으면 null.
|
|
66
|
+
* 값이 있는데 목록에 없으면 **거부**한다 — 오타를 조용히 삼키면
|
|
67
|
+
* 화면에서 색이 안 맞는데 이유를 찾을 수 없게 된다.
|
|
68
|
+
*/
|
|
69
|
+
function resolveActor(flag) {
|
|
70
|
+
const raw = typeof flag === 'string' ? flag : process.env.AXMAP_ACTOR
|
|
71
|
+
if (raw === undefined || raw === null || raw === '') return null
|
|
72
|
+
if (!ACTORS.includes(raw)) {
|
|
73
|
+
console.error(`알 수 없는 actor: ${raw}`)
|
|
74
|
+
console.error(`쓸 수 있는 값: ${ACTORS.join(', ')}`)
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
return raw
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const LEDGER_BRANCH = 'axmap/claims'
|
|
81
|
+
const LEDGER_REL = path.join('.axmap', 'ledger')
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 쪽지함. **장부와 같은 방식이지만 실패했을 때의 뜻이 정반대다.**
|
|
85
|
+
*
|
|
86
|
+
* 🔴 왜 코드 브랜치가 아니라 고아 브랜치인가.
|
|
87
|
+
*
|
|
88
|
+
* 쪽지는 원래 `docs/bus/` 에 있었다. 그건 작업 트리라서 상대에게 가려면
|
|
89
|
+
* 커밋 -> MR -> 머지 -> 상대가 pull 을 거쳤다. `main` 이 보호돼 있으면
|
|
90
|
+
* **쪽지 한 통이 MR 한 사이클**이다. 그리고 보내려면 `docs/bus` 를 claim 해야
|
|
91
|
+
* 해서, 한 사람이 잡고 있는 동안 나머지는 쪽지를 못 보냈다 — 메시징 통로가
|
|
92
|
+
* 한 번에 한 명만 쓸 수 있는 자물쇠였다.
|
|
93
|
+
*
|
|
94
|
+
* 장부(`axmap/claims`)는 이미 그 문제를 안 겪는다. 고아 브랜치라 코드와
|
|
95
|
+
* 이력을 공유하지 않고, push/fetch 로 **즉시** 오간다. 쪽지도 같은 자리로
|
|
96
|
+
* 옮긴다. 표(`axmap/votes`)까지 셋이 나란히 서는 셈이다.
|
|
97
|
+
*
|
|
98
|
+
* 🔴 그러나 **push 실패를 장부처럼 다루면 안 된다.**
|
|
99
|
+
*
|
|
100
|
+
* claim 은 push 가 실패하면 죽여야 한다. 로컬에만 남은 락은 나만 보이고
|
|
101
|
+
* 남은 같은 파일을 잡으므로, 실패를 삼키는 것이 곧 fail-open 이다.
|
|
102
|
+
* 쪽지는 반대다 — 안 간 쪽지는 **아직 안 간 것**일 뿐 아무 위험이 없다.
|
|
103
|
+
* 여기서 죽이면 원격이 잠깐 흔들릴 때마다 사람의 작업이 멈춘다.
|
|
104
|
+
* 그래서 이 아래 함수들은 경고만 하고 계속 간다.
|
|
105
|
+
*/
|
|
106
|
+
const BUS_BRANCH = 'axmap/bus'
|
|
107
|
+
const BUS_REL = path.join('.axmap', 'bus')
|
|
108
|
+
/**
|
|
109
|
+
* `release` 가 아무것도 반납하지 못했을 때. SPEC 8절.
|
|
110
|
+
* 0 이 아니어야 하는 이유는 그 주변 주석에 적혀 있다.
|
|
111
|
+
*/
|
|
112
|
+
const EXIT_NOTHING_RELEASED = 5
|
|
113
|
+
|
|
114
|
+
const MAX_CAS_RETRIES = 5
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// 기본 유틸
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* git 이 훅을 실행할 때 심어놓는 환경변수들.
|
|
122
|
+
* 이것들은 cwd 보다 우선하므로, 훅 안에서 다른 저장소(=장부 worktree)를 다루려면
|
|
123
|
+
* 반드시 걷어내야 한다. 남겨두면 장부 명령이 커밋 중인 저장소를 가리켜 깨진다.
|
|
124
|
+
*/
|
|
125
|
+
const GIT_ENV_KEYS = [
|
|
126
|
+
'GIT_DIR',
|
|
127
|
+
'GIT_WORK_TREE',
|
|
128
|
+
'GIT_COMMON_DIR',
|
|
129
|
+
'GIT_INDEX_FILE',
|
|
130
|
+
'GIT_OBJECT_DIRECTORY',
|
|
131
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
132
|
+
'GIT_PREFIX',
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
function cleanEnv(keep = []) {
|
|
136
|
+
const e = { ...process.env }
|
|
137
|
+
for (const k of GIT_ENV_KEYS) if (!keep.includes(k)) delete e[k]
|
|
138
|
+
return e
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function git(args, opts = {}) {
|
|
142
|
+
const r = spawnSync('git', args, {
|
|
143
|
+
cwd: opts.cwd,
|
|
144
|
+
input: opts.input,
|
|
145
|
+
env: cleanEnv(opts.keepEnv ?? []),
|
|
146
|
+
encoding: 'utf8',
|
|
147
|
+
windowsHide: true,
|
|
148
|
+
})
|
|
149
|
+
return {
|
|
150
|
+
code: r.status ?? 1,
|
|
151
|
+
out: (r.stdout ?? '').trim(),
|
|
152
|
+
err: (r.stderr ?? '').trim(),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function gitOrDie(args, opts = {}) {
|
|
157
|
+
const r = git(args, opts)
|
|
158
|
+
if (r.code !== 0) die(`git ${args.join(' ')} 실패\n${r.err || r.out}`)
|
|
159
|
+
return r
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function die(msg, code = 1) {
|
|
163
|
+
console.error(msg)
|
|
164
|
+
process.exit(code)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// 장부 git 호출의 lock 재시도
|
|
169
|
+
//
|
|
170
|
+
// 🔴 여기가 없던 동안 `commitAndPush` 는 index.lock 하나에 **즉시 죽었다.**
|
|
171
|
+
//
|
|
172
|
+
// 같은 worktree 에서 다른 프로세스가 커밋 중이면 `git add -A` 가
|
|
173
|
+
// "Unable to create '.../index.lock': File exists" 로 실패하고, gitOrDie 가
|
|
174
|
+
// 그대로 exit 1 을 냈다. MAX_CAS_RETRIES 루프는 **push 거부 전용**이라 이것을
|
|
175
|
+
// 전혀 덮지 않는다 — 관문 1 은 원격 ref 의 경합이고 이쪽은 로컬 파일의 경합이다.
|
|
176
|
+
// 앱에서 세션을 여럿 굴리면(= 이 프로젝트가 하려는 바로 그것) 매번 밟는다.
|
|
177
|
+
//
|
|
178
|
+
// lock 실패는 거절이 아니라 "잠깐 뒤에 다시 해봐라" 다. 그래서 재시도한다.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* git 이 "지금 누가 쓰는 중" 이라고 말하는 방식들.
|
|
183
|
+
* 파일 이름(index.lock)과 문장(File exists)을 함께 보는 이유는 git 버전·로케일에
|
|
184
|
+
* 따라 둘 중 하나만 나오는 경우가 있기 때문이다. 넓게 잡아도 안전한 쪽인 것은,
|
|
185
|
+
* 여기서 잘못 걸려봐야 **몇 번 더 기다렸다 같은 실패를 다시 내는 것**뿐이라서다.
|
|
186
|
+
*/
|
|
187
|
+
const LOCK_HINTS = ['index.lock', 'cannot lock ref', 'Unable to create', 'File exists']
|
|
188
|
+
|
|
189
|
+
/** 50 → 1600ms. 마지막까지 가면 총 3.15초 + 지터를 기다린다. */
|
|
190
|
+
const LOCK_BACKOFF_MS = [50, 100, 200, 400, 800, 1600]
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 마지막 lock 실패의 원문. 선언을 여기에 두는 이유는 TDZ 다 — 이 저장소는
|
|
194
|
+
* `let` 이 첫 사용처보다 아래에 있어 부트스트랩이 통째로 죽은 적이 있다(CLAUDE.md).
|
|
195
|
+
*/
|
|
196
|
+
let lastLockError = ''
|
|
197
|
+
|
|
198
|
+
function isLockError(r) {
|
|
199
|
+
const text = `${r.err ?? ''}\n${r.out ?? ''}`
|
|
200
|
+
return LOCK_HINTS.some((h) => text.includes(h))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 의존성 없는 동기 sleep. `Atomics.wait` 은 아무도 깨우지 않는 SharedArrayBuffer
|
|
205
|
+
* 에서 timeout 만큼 정확히 멈춘다. spawnSync 로 짜인 이 파일에 async 를 들여오면
|
|
206
|
+
* 호출부 전체가 물들므로 여기서 막는다.
|
|
207
|
+
*/
|
|
208
|
+
function sleepMs(ms) {
|
|
209
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 몇 ms 를 기다릴까.
|
|
214
|
+
*
|
|
215
|
+
* 🔴 지터를 `Math.random()` 이 아니라 **pid** 로 만든다.
|
|
216
|
+
*
|
|
217
|
+
* 여러 프로세스를 흩어놓는 목적은 pid 로도 똑같이 달성된다 — 동시에 몰린 프로세스는
|
|
218
|
+
* 서로 다른 pid 를 가지므로 서로 다른 만큼 기다린다. 반면 난수를 쓰면 같은 입력에서
|
|
219
|
+
* 같은 타이밍이 재현되지 않아, 이 재시도가 실제로 도는지 테스트가 확인할 수 없다.
|
|
220
|
+
* 이 저장소가 시계를 인자로 받는 것과 같은 이유다.
|
|
221
|
+
*/
|
|
222
|
+
function backoffFor(attempt) {
|
|
223
|
+
const base = LOCK_BACKOFF_MS[Math.min(attempt, LOCK_BACKOFF_MS.length - 1)]
|
|
224
|
+
return base + Math.floor((base * (process.pid % 64)) / 64)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* lock 류 실패면 백오프하며 다시 건다.
|
|
229
|
+
*
|
|
230
|
+
* lock 이 아닌 실패는 **재시도하지 않고 그대로 돌려준다.** 원인이 다른 것을
|
|
231
|
+
* 기다렸다 다시 해봐야 같은 답이 나오고, 그동안 사람은 원인을 모른 채 기다린다.
|
|
232
|
+
*
|
|
233
|
+
* @returns git() 의 결과 + 끝내 lock 이 안 풀렸으면 `lockExhausted: true`
|
|
234
|
+
*/
|
|
235
|
+
function gitLockRetry(args, opts = {}) {
|
|
236
|
+
for (let attempt = 0; ; attempt++) {
|
|
237
|
+
const r = git(args, opts)
|
|
238
|
+
if (r.code === 0 || !isLockError(r)) return r
|
|
239
|
+
if (attempt >= LOCK_BACKOFF_MS.length) return { ...r, lockExhausted: true }
|
|
240
|
+
// 조용히 기다리지 않는다. 몇 초 멈춘 이유를 사람이 알아야 한다.
|
|
241
|
+
console.error(
|
|
242
|
+
`장부가 잠겨 있습니다 (다른 에이전트가 쓰는 중). ` +
|
|
243
|
+
`${backoffFor(attempt)}ms 뒤 재시도 ${attempt + 1}/${LOCK_BACKOFF_MS.length}`,
|
|
244
|
+
)
|
|
245
|
+
sleepMs(backoffFor(attempt))
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* CAS(관문 1) 재시도 사이의 대기.
|
|
251
|
+
*
|
|
252
|
+
* lock 재시도와 **같은 계단·같은 지터**를 쓴다. 둘은 층이 다르지만(로컬 파일 락 vs
|
|
253
|
+
* 원격 ref 경합) 대응은 같다 — 흩어져서 다시 해보는 것. 계단을 둘로 나누면
|
|
254
|
+
* 어느 쪽이 얼마나 기다렸는지 사람이 계산해야 한다.
|
|
255
|
+
*
|
|
256
|
+
* 마지막 회차 뒤에는 기다리지 않는다. 어차피 포기할 것을 기다리게 하면
|
|
257
|
+
* 사람이 이유 없이 1.6초를 더 본다.
|
|
258
|
+
*
|
|
259
|
+
* @param {number} attempt 1부터 세는 회차 (호출부의 for 루프 변수 그대로)
|
|
260
|
+
*/
|
|
261
|
+
function casBackoff(attempt) {
|
|
262
|
+
if (attempt >= MAX_CAS_RETRIES) return
|
|
263
|
+
const ms = backoffFor(attempt - 1)
|
|
264
|
+
console.error(` ${ms}ms 기다렸다 다시 시도합니다.`)
|
|
265
|
+
sleepMs(ms)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** lock 으로 끝내 실패했을 때 사람에게 보일 이유. 원인을 뭉개지 않는다. */
|
|
269
|
+
function lockReason(r) {
|
|
270
|
+
return (r.err || r.out || '').trim().split('\n')[0]
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function lockedMessage(what) {
|
|
274
|
+
return (
|
|
275
|
+
`다른 에이전트가 장부를 쓰는 중이라 ${LOCK_BACKOFF_MS.length + 1}회 재시도 후 포기했습니다 (${what}).\n` +
|
|
276
|
+
` ${lastLockError}\n\n` +
|
|
277
|
+
'같은 worktree 에서 여러 프로세스가 동시에 장부를 커밋하면 git 인덱스 락에서 경합합니다.\n' +
|
|
278
|
+
'위 메시지의 락 파일이 죽은 프로세스의 잔해라면 직접 지운 뒤 다시 시도하세요.\n' +
|
|
279
|
+
'(경로를 여기에 적어두지 않는 이유: worktree 이름이 바뀌면 그 줄이 먼저 낡는다.\n' +
|
|
280
|
+
' 진짜 경로는 git 이 낸 위 한 줄에 들어 있다.)'
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 시계. AXMAP_NOW 로 덮어쓸 수 있다 - 데모에서 TTL 만료를 30분 기다리지 않기 위해. */
|
|
285
|
+
function now() {
|
|
286
|
+
const override = process.env.AXMAP_NOW
|
|
287
|
+
if (override) {
|
|
288
|
+
const t = Date.parse(override)
|
|
289
|
+
if (Number.isNaN(t)) die(`AXMAP_NOW 를 해석할 수 없습니다: ${override}`)
|
|
290
|
+
return t
|
|
291
|
+
}
|
|
292
|
+
return Date.now()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function parseTtl(v) {
|
|
296
|
+
if (v == null) return 30 * 60_000
|
|
297
|
+
const m = String(v).match(/^(\d+)\s*([smh])?$/)
|
|
298
|
+
if (!m) die(`--ttl 형식이 올바르지 않습니다: ${v} (예: 30m, 2h, 90s)`)
|
|
299
|
+
const n = Number(m[1])
|
|
300
|
+
return n * { s: 1000, m: 60_000, h: 3_600_000 }[m[2] ?? 'm']
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function parseArgs(argv) {
|
|
304
|
+
const positional = []
|
|
305
|
+
const flags = {}
|
|
306
|
+
for (let i = 0; i < argv.length; i++) {
|
|
307
|
+
const a = argv[i]
|
|
308
|
+
if (!a.startsWith('--')) {
|
|
309
|
+
positional.push(a)
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
const eq = a.indexOf('=')
|
|
313
|
+
if (eq !== -1) {
|
|
314
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1)
|
|
315
|
+
} else if (argv[i + 1] && !argv[i + 1].startsWith('--')) {
|
|
316
|
+
flags[a.slice(2)] = argv[++i]
|
|
317
|
+
} else {
|
|
318
|
+
flags[a.slice(2)] = true
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { positional, flags }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// 레포 / 장부 위치
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 안 읽은 쪽지를 알린다 (docs/bus).
|
|
330
|
+
*
|
|
331
|
+
* 🔴 쪽지를 **따로 기억해야 하는 것으로 두지 않는다.**
|
|
332
|
+
*
|
|
333
|
+
* 쪽지함을 MCP 도구로 내놓아도 "언제 열어볼지" 는 아무도 안 정해준다.
|
|
334
|
+
* 도구가 있다고 쓰는 게 아니다 — 실제로 상대 세션이 내 쪽지를 읽은 것은
|
|
335
|
+
* 커밋 메시지와 브리핑으로 "이걸 봐라" 라고 했기 때문이었다.
|
|
336
|
+
*
|
|
337
|
+
* 그래서 **반드시 부르는 도구에 얹는다.** claim 은 규칙상 코드를 건드리기 전에
|
|
338
|
+
* 무조건 부른다. 거기서 알려주면 아무도 잊을 수 없다.
|
|
339
|
+
* (화면이 숫자 옆에 문장을 붙여 오독을 막은 것과 같은 원리다.)
|
|
340
|
+
*
|
|
341
|
+
* 읽음 표시는 에이전트별 파일에 남긴다. 장부에 넣지 않는 이유는 —
|
|
342
|
+
* 읽었는지는 **나만의 상태**라 남과 합의할 필요가 없고, 장부에 쓰면
|
|
343
|
+
* 쪽지를 읽을 때마다 push 경합이 생긴다.
|
|
344
|
+
*/
|
|
345
|
+
/**
|
|
346
|
+
* 쪽지함을 원격과 맞춘다. **조용히 실패한다.**
|
|
347
|
+
*
|
|
348
|
+
* 장부의 `syncLedger` 는 같은 자리에서 `die` 한다 — 못 맞춘 장부로 claim 하면
|
|
349
|
+
* 나만 아는 락이 되기 때문이다. 쪽지에는 그 위험이 없다. 원격이 잠깐 안 되면
|
|
350
|
+
* 이번엔 새 쪽지를 못 보는 것뿐이고, 다음 호출에 따라온다.
|
|
351
|
+
*/
|
|
352
|
+
function syncBus(root) {
|
|
353
|
+
const dir = busDir(root)
|
|
354
|
+
if (!fs.existsSync(path.join(dir, '.git'))) return
|
|
355
|
+
const r = resolveRemote(root)
|
|
356
|
+
const remote = r.source === 'ambiguous' ? null : r.name
|
|
357
|
+
if (!remote) return
|
|
358
|
+
// fetch 는 반드시 그 worktree 안에서. FETCH_HEAD 가 worktree 마다 따로다.
|
|
359
|
+
if (git(['fetch', '--quiet', remote, BUS_BRANCH], { cwd: dir }).code !== 0) return
|
|
360
|
+
gitLockRetry(['reset', '--hard', '--quiet', 'FETCH_HEAD'], { cwd: dir })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 쪽지를 찾는 곳. 새것은 고아 브랜치, 옛것은 `docs/bus`(읽기 전용). */
|
|
364
|
+
function busSearchDirs(root) {
|
|
365
|
+
return [busMessagesDir(root), legacyBusDir(root)]
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function unreadNotes(root, me) {
|
|
369
|
+
if (!me) return []
|
|
370
|
+
syncBus(root)
|
|
371
|
+
// 두 곳을 합쳐서 본다. id 가 시각으로 시작하므로 섞여도 순서가 맞고,
|
|
372
|
+
// "읽음" 표시(`id <= seen`)도 그대로 성립한다.
|
|
373
|
+
const found = []
|
|
374
|
+
for (const box of busSearchDirs(root)) {
|
|
375
|
+
let ns = []
|
|
376
|
+
try { ns = fs.readdirSync(box).filter((f) => f.endsWith('.md')) } catch { continue }
|
|
377
|
+
for (const f of ns) found.push({ box, f })
|
|
378
|
+
}
|
|
379
|
+
if (!found.length) return []
|
|
380
|
+
let seen = ''
|
|
381
|
+
try {
|
|
382
|
+
const raw = JSON.parse(fs.readFileSync(path.join(root, '.axmap-bus-seen.json'), 'utf8'))
|
|
383
|
+
seen = raw[me] ?? ''
|
|
384
|
+
} catch { /* 처음이면 아무것도 안 읽은 것 */ }
|
|
385
|
+
|
|
386
|
+
const out = []
|
|
387
|
+
for (const { box, f } of found.sort((a, b) => (a.f < b.f ? -1 : 1))) {
|
|
388
|
+
const id = f.slice(0, -3)
|
|
389
|
+
if (id <= seen) continue
|
|
390
|
+
let head = ''
|
|
391
|
+
try { head = fs.readFileSync(path.join(box, f), 'utf8').slice(0, 400) } catch { continue }
|
|
392
|
+
const g = (k) => {
|
|
393
|
+
const m = head.match(new RegExp('^' + k + ':\\s*(.*)$', 'm'))
|
|
394
|
+
return m ? m[1].trim() : ''
|
|
395
|
+
}
|
|
396
|
+
const from = g('from')
|
|
397
|
+
const to = g('to')
|
|
398
|
+
// 내가 보낸 것은 뺀다 — 자기 쪽지에 자기가 놀라면 안 된다.
|
|
399
|
+
if (from === me) continue
|
|
400
|
+
if (to !== me && to !== 'all') continue
|
|
401
|
+
out.push({ id, from, subject: g('subject') })
|
|
402
|
+
}
|
|
403
|
+
return out
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 읽음 표시를 찍는다. 규격은 `docs/SPEC.md` §2「읽음 표시」.
|
|
408
|
+
*
|
|
409
|
+
* 🔴 **읽기만 하고 쓰지 않던 자리였다.** `unreadNotes` 가 `.axmap-bus-seen.json`
|
|
410
|
+
* 을 보는데 아무도 안 써서 `seen` 이 늘 빈 문자열이었고, 그래서 `id <= seen`
|
|
411
|
+
* 이 아무것도 못 걸렀다 — **모든 쪽지가 영원히 안 읽음**이었다. 알림이 매번
|
|
412
|
+
* 전부를 찍으니 사람은 그것을 배경으로 여기고 안 읽는다. 반쪽짜리 알림은
|
|
413
|
+
* 없는 알림보다 나쁘다. 2026-08-27 에 훅을 붙이려다 드러났다.
|
|
414
|
+
*
|
|
415
|
+
* 🔴 **보여준 것까지만 찍는다.** `printUnread` 는 3건만 출력하므로 3건까지만
|
|
416
|
+
* 읽음이 된다. 세지만 하고 안 보여준 쪽지를 읽음으로 치면 그 쪽지는
|
|
417
|
+
* 영영 안 뜬다.
|
|
418
|
+
*
|
|
419
|
+
* ⚠️ 조용히 실패한다. 못 찍으면 다음에 한 번 더 뜰 뿐이고, 여기서 죽으면
|
|
420
|
+
* claim 이 죽는다. `tools/bus.mjs` 의 `markSeen` 과 같은 규칙이다.
|
|
421
|
+
*/
|
|
422
|
+
function markBusSeen(root, me, id) {
|
|
423
|
+
if (!me || !id) return
|
|
424
|
+
const file = path.join(root, '.axmap-bus-seen.json')
|
|
425
|
+
try {
|
|
426
|
+
let all = {}
|
|
427
|
+
try { all = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { /* 처음이다 */ }
|
|
428
|
+
if ((all[me] ?? '') >= id) return // 뒤로 가지 않는다
|
|
429
|
+
all[me] = id
|
|
430
|
+
fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n')
|
|
431
|
+
} catch { /* 조용히 */ }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 쪽지를 읽는 명령. **경로를 문자열로 적지 않고 계산한다.**
|
|
436
|
+
*
|
|
437
|
+
* 🔴 `node tools/bus.mjs …` 라고 적혀 있었다. 이 저장소에서는 맞고 **사본에서는
|
|
438
|
+
* 틀린다** — 팀 저장소에 벤더링되면 그 파일은 `ci/axmap/tools/bus.mjs` 다.
|
|
439
|
+
* 안내대로 치면 `MODULE_NOT_FOUND` 가 난다. 알림이 "쪽지가 있다" 고 말한 직후에
|
|
440
|
+
* 읽는 방법을 틀리게 알려주는 것이라, 받은 사람은 도구가 고장 났다고 결론짓는다.
|
|
441
|
+
*/
|
|
442
|
+
function busReadHint(me) {
|
|
443
|
+
const abs = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'tools', 'bus.mjs')
|
|
444
|
+
const rel = path.relative(process.cwd(), abs).replace(/\\/g, '/')
|
|
445
|
+
// 🔴 상대경로가 늘 짧은 것은 아니다. 도구가 대상 저장소 **밖**에 있으면
|
|
446
|
+
// (전역 설치, 다른 드라이브) `../../../../../..` 가 줄줄이 붙어 사람이 읽을
|
|
447
|
+
// 수 없는 문자열이 된다. 실제로 그렇게 나왔다. 둘 중 짧은 쪽을 쓴다 —
|
|
448
|
+
// 안내는 맞기만 해서는 부족하고 **칠 수 있어야** 한다.
|
|
449
|
+
const use = rel.startsWith('..' + path.posix.sep + '..') || rel.length >= abs.length
|
|
450
|
+
? abs.replace(/\\/g, '/')
|
|
451
|
+
: (rel.startsWith('.') ? rel : './' + rel)
|
|
452
|
+
return `node ${use} list --to ${me}`
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** claim·status 끝에 붙이는 알림. 없으면 아무것도 안 찍는다. */
|
|
456
|
+
function printUnread(root, me) {
|
|
457
|
+
let notes = []
|
|
458
|
+
try { notes = unreadNotes(root, me) } catch { return } // 알림 때문에 claim 이 죽으면 안 된다
|
|
459
|
+
if (!notes.length) return
|
|
460
|
+
const shown = notes.slice(0, 3)
|
|
461
|
+
console.log('\n 안 읽은 쪽지 ' + notes.length + '건')
|
|
462
|
+
for (const n of shown) console.log(' ' + n.from + ' — ' + n.subject)
|
|
463
|
+
if (notes.length > 3) console.log(' … 그 밖에 ' + (notes.length - 3) + '건')
|
|
464
|
+
console.log(' 읽기: ' + busReadHint(me) + ' (AI 도구를 쓰면 ax_inbox)')
|
|
465
|
+
// 보여준 뒤에 찍는다. 위에서 죽으면 안 찍혀야 다음에 다시 뜬다.
|
|
466
|
+
markBusSeen(root, me, shown.reduce((hi, n) => (n.id > hi ? n.id : hi), ''))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function repoRoot() {
|
|
470
|
+
const r = git(['rev-parse', '--show-toplevel'])
|
|
471
|
+
if (r.code !== 0) die('git 저장소 안에서 실행해야 합니다.')
|
|
472
|
+
return r.out
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function ledgerDir(root) {
|
|
476
|
+
return path.join(root, LEDGER_REL)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function claimsDir(root) {
|
|
480
|
+
return path.join(ledgerDir(root), 'claims')
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function busDir(root) {
|
|
484
|
+
return path.join(root, BUS_REL)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** 쪽지 파일이 실제로 쌓이는 곳. 브랜치 루트에 흩뿌리지 않고 한 폴더 아래 모은다. */
|
|
488
|
+
function busMessagesDir(root) {
|
|
489
|
+
return path.join(busDir(root), 'messages')
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 옛 쪽지함. **읽기만 한다.**
|
|
494
|
+
*
|
|
495
|
+
* 새로 보내는 것은 전부 고아 브랜치로 가지만, 이미 `docs/bus/` 에 쌓여 있는
|
|
496
|
+
* 쪽지를 안 보이게 만들면 그건 데이터를 잃는 것이다. 옮기지도 않는다 —
|
|
497
|
+
* 옮기면 같은 내용이 두 브랜치에 남고, 어느 쪽이 진짜인지 아무도 모른다.
|
|
498
|
+
* 쓰는 곳은 하나, 읽는 곳은 둘. 그러면 옛것은 자연히 마른다.
|
|
499
|
+
*/
|
|
500
|
+
function legacyBusDir(root) {
|
|
501
|
+
return path.join(root, 'docs', 'bus')
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* 장부를 주고받을 원격의 **이름**.
|
|
506
|
+
*
|
|
507
|
+
* 🔴 예전에는 이 자리에 `hasRemote()` 가 있었고 `'origin'` 문자열을 직접 봤다.
|
|
508
|
+
* 나머지 다섯 곳(fetch·push·ls-remote·init)이 그 판정을 믿고 `origin` 을 썼으므로,
|
|
509
|
+
* 원격 이름이 `origin` 이 아니면 여섯 곳이 함께 거짓말했다.
|
|
510
|
+
*
|
|
511
|
+
* 증상이 무서운 쪽이다. `hasRemote` 가 false 가 되면 `syncLedger` 는 즉시
|
|
512
|
+
* return 하고 `pushLedger` 는 push 없이 'ok' 를 낸다. 그러면 **관문 1(CAS)이
|
|
513
|
+
* 통째로 사라지고 관문 2만 남는데, 관문 2는 자기 장부에 대고 판정하므로
|
|
514
|
+
* 영원히 자기 자신하고만 비교한다.** 여섯 명이 같은 파일을 잡아도 전부 성공한다.
|
|
515
|
+
* 종료 코드는 0이고 경고는 init 때 한 줄뿐이었다 — 이 저장소가 금지한 fail-open 이다.
|
|
516
|
+
*
|
|
517
|
+
* 실제로 밟는 경우가 셋이다.
|
|
518
|
+
* - fork 워크플로 (origin=내 포크, upstream=본체)
|
|
519
|
+
* - `git remote rename origin gitlab` 같은 개인 취향
|
|
520
|
+
* - **원격이 둘 이상** — 팀 저장소를 `team` 으로 추가하고 origin 을 개인
|
|
521
|
+
* 저장소로 두면 장부가 개인 저장소로 간다. 이건 경고조차 안 뜬다.
|
|
522
|
+
*
|
|
523
|
+
* 정하는 순서. 위에서부터 먼저 맞는 것을 쓴다.
|
|
524
|
+
*
|
|
525
|
+
* 1. `--remote <이름>` 그 명령에서만
|
|
526
|
+
* 2. `AXMAP_REMOTE` 셸 단위
|
|
527
|
+
* 3. `git config axmap.remote` 저장소에 박힌 값 — `init` 이 여기에 적어 둔다
|
|
528
|
+
* 4. 원격이 **정확히 하나**면 그것. 이름이 무엇이든 상관없다
|
|
529
|
+
* 5. `origin` 이 있으면 `origin` — 옛 동작과의 호환
|
|
530
|
+
* 6. 그 밖 → `ambiguous`. **고르지 않는다**
|
|
531
|
+
*
|
|
532
|
+
* 6에서 하나를 골라 주지 않는 이유는 SPEC §3 의 원칙 그대로다 —
|
|
533
|
+
* 치환은 서로 다른 두 입력을 같은 것으로 만들고, 락에서 그것은 곧 소유권 충돌이다.
|
|
534
|
+
* 잘못 고르면 장부가 엉뚱한 저장소로 가고 **아무도 그것을 모른다.**
|
|
535
|
+
*
|
|
536
|
+
* @returns {{name: string|null, source: string, all: string[]}}
|
|
537
|
+
* `name` 이 null 이면 `source` 가 'none'(원격 없음) 또는 'ambiguous'(못 고름).
|
|
538
|
+
*/
|
|
539
|
+
function resolveRemote(root, flags = {}) {
|
|
540
|
+
const all = git(['remote'], { cwd: root }).out.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
541
|
+
|
|
542
|
+
const pick = (name, source) => {
|
|
543
|
+
// 지정된 이름이 실제로 없으면 조용히 넘어가지 않는다. 오타가 곧 무보호 상태다.
|
|
544
|
+
if (!all.includes(name)) {
|
|
545
|
+
die(
|
|
546
|
+
`${source} 이(가) 가리키는 원격이 없습니다: ${name}\n` +
|
|
547
|
+
(all.length ? ` 이 저장소의 원격: ${all.join(', ')}` : ' 이 저장소에는 원격이 없습니다.'),
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
return { name, source, all }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (typeof flags.remote === 'string') return pick(flags.remote, '--remote')
|
|
554
|
+
if (process.env.AXMAP_REMOTE) return pick(process.env.AXMAP_REMOTE, 'AXMAP_REMOTE')
|
|
555
|
+
|
|
556
|
+
const cfg = git(['config', '--get', 'axmap.remote'], { cwd: root }).out.trim()
|
|
557
|
+
if (cfg) return pick(cfg, 'git config axmap.remote')
|
|
558
|
+
|
|
559
|
+
if (all.length === 0) return { name: null, source: 'none', all }
|
|
560
|
+
if (all.length === 1) return { name: all[0], source: '유일한 원격', all }
|
|
561
|
+
if (all.includes('origin')) return { name: 'origin', source: '기본값(origin)', all }
|
|
562
|
+
return { name: null, source: 'ambiguous', all }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 원격 없이 도는 것을 **매번** 알린다.
|
|
567
|
+
*
|
|
568
|
+
* 🔴 예전에는 `init` 때 한 줄이 전부였다. 그 뒤 수백 번의 claim 이 전부 조용히
|
|
569
|
+
* 성공하므로, 사람은 보호받고 있다고 믿는다. 락 시스템에서 가장 나쁜 실패는
|
|
570
|
+
* 거부해야 할 것을 조용히 통과시키는 것이고, 여기서는 "혼자임"이 그것이다.
|
|
571
|
+
*
|
|
572
|
+
* 거부까지 가지 않고 경고에서 멈추는 이유는 혼자 쓰는 사람이 실제로 있기 때문이다.
|
|
573
|
+
* 대신 **조용하지는 않게** 한다. stdout 이 아니라 stderr 로 낸다 — 파이프로
|
|
574
|
+
* 흘려보내도 사람 눈에는 남는다.
|
|
575
|
+
*/
|
|
576
|
+
/**
|
|
577
|
+
* 한 프로세스에서 한 번만 낸다. claim 은 CAS 재시도로 syncLedger 를 최대 5번 부르는데,
|
|
578
|
+
* 같은 경고를 다섯 줄 쌓으면 사람이 그 다음부터 안 읽는다.
|
|
579
|
+
*
|
|
580
|
+
* 선언을 함수 위에 둔다 — 이 저장소는 `let` 이 첫 사용처보다 아래에 있어 TDZ 로
|
|
581
|
+
* 부트스트랩이 통째로 죽은 적이 있다(CLAUDE.md).
|
|
582
|
+
*/
|
|
583
|
+
let soloWarned = false
|
|
584
|
+
|
|
585
|
+
function warnSolo(r) {
|
|
586
|
+
if (r.name || soloWarned) return
|
|
587
|
+
soloWarned = true
|
|
588
|
+
if (r.source === 'ambiguous') {
|
|
589
|
+
console.error(
|
|
590
|
+
`경고: 원격이 여럿이라 장부를 어디에 둘지 정하지 못했습니다 (${r.all.join(', ')}).\n` +
|
|
591
|
+
' 관문 1(CAS)이 작동하지 않습니다 — 다른 사람의 claim 을 보지 못합니다.\n' +
|
|
592
|
+
' 정하려면: git config axmap.remote <이름>',
|
|
593
|
+
)
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
console.error(
|
|
597
|
+
'경고: 원격이 없어 단일 작업자 모드로 돕니다. 관문 1(CAS)이 작동하지 않습니다\n' +
|
|
598
|
+
' — 다른 사람의 claim 을 보지 못하고, 내 claim 도 아무에게도 가지 않습니다.',
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 이름이 **어디서 왔는지**. 진단에 쓴다.
|
|
604
|
+
*
|
|
605
|
+
* 🔴 조용히 다른 사람이 되는 것이 이 도구에서 가장 위험한 사고다.
|
|
606
|
+
* `AXMAP_AGENT` 는 셸이 바뀌면 사라지고, 그러면 `git config user.name`
|
|
607
|
+
* 으로 떨어져 **그럴듯한 다른 사람**이 된다. 아무 오류도 안 난다.
|
|
608
|
+
*/
|
|
609
|
+
let agentFrom = null
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* 이름을 정하는 순서. **기본값은 없다.**
|
|
613
|
+
*
|
|
614
|
+
* 🔴 안전한 기본 이름을 두지 않는 이유. 예전 `.mcp.json` 은 이름이 없으면
|
|
615
|
+
* 모두에게 `claude` 를 줬다. 그러면 팀원 다섯이 clone 했을 때 장부에는
|
|
616
|
+
* **한 명만** 존재하고, `checkOverlap` 은 자기 claim 을 겹침으로 보지 않으므로
|
|
617
|
+
* 서로의 영역을 아무 경고 없이 덮어쓴다. 이 저장소가 fail-closed 로 막겠다고
|
|
618
|
+
* 선언한 바로 그 실패다 — 치환이 곧 소유권 충돌이다.
|
|
619
|
+
*
|
|
620
|
+
* 옛 이름을 받아주던 한시적 분기는 걷어냈다. 이름을 바꾸기 전부터 돌고 있던 셸을
|
|
621
|
+
* 위한 것이었고, 그런 셸은 이제 없다. 호환을 오래 두면 그것이 규격이 된다.
|
|
622
|
+
*/
|
|
623
|
+
/**
|
|
624
|
+
* 이름을 정한다. **못 정해도 죽지 않는다** — 못 정한 이유를 돌려준다.
|
|
625
|
+
*
|
|
626
|
+
* 🔴 `agentName` 과 순서를 **공유해야** 한다. 두 곳에 적으면 한쪽이 먼저 낡고,
|
|
627
|
+
* 그러면 claim 이 쓰는 이름과 다른 곳이 보는 이름이 갈린다. 갈린 이름은
|
|
628
|
+
* "자기가 잡은 것을 자기가 반납 못 하는" 사고가 되므로, 판정은 여기 하나뿐이다.
|
|
629
|
+
*
|
|
630
|
+
* @returns {{name: string|null, reason: string|null}}
|
|
631
|
+
*/
|
|
632
|
+
function resolveAgentName(flags) {
|
|
633
|
+
let n = null
|
|
634
|
+
if (flags.agent) {
|
|
635
|
+
n = flags.agent
|
|
636
|
+
agentFrom = '--agent'
|
|
637
|
+
} else if (process.env.AXMAP_AGENT) {
|
|
638
|
+
n = process.env.AXMAP_AGENT
|
|
639
|
+
agentFrom = 'AXMAP_AGENT'
|
|
640
|
+
} else {
|
|
641
|
+
n = git(['config', 'user.name']).out
|
|
642
|
+
agentFrom = 'git config user.name'
|
|
643
|
+
}
|
|
644
|
+
if (!n) {
|
|
645
|
+
return {
|
|
646
|
+
name: null,
|
|
647
|
+
reason:
|
|
648
|
+
'에이전트 이름을 알 수 없습니다. --agent 또는 AXMAP_AGENT 를 설정하세요.\n' +
|
|
649
|
+
'기본 이름으로 대신 채우지 않습니다 — 여러 사람이 같은 이름이 되면\n' +
|
|
650
|
+
'서로의 claim 을 겹침으로 보지 못해 같은 파일을 조용히 함께 고칩니다.',
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const err = agentNameError(String(n))
|
|
654
|
+
if (err) return { name: null, reason: err }
|
|
655
|
+
return { name: String(n), reason: null }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 이름이 반드시 필요한 자리. 못 정하면 **여기서 멈춘다.** */
|
|
659
|
+
function agentName(flags) {
|
|
660
|
+
const r = resolveAgentName(flags)
|
|
661
|
+
if (!r.name) die(r.reason)
|
|
662
|
+
return r.name
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* 장부 worktree 의 존재만 확인한다.
|
|
667
|
+
*
|
|
668
|
+
* claims 디렉터리의 존재로 판정하면 안 된다. git 은 빈 디렉터리를 추적하지 않으므로
|
|
669
|
+
* 모두가 반납해 claim 파일이 하나도 없으면 reset --hard 가 그 디렉터리를 지운다.
|
|
670
|
+
* 그러면 "정상적으로 다 반납한 상태"가 "장부가 없음"으로 오진된다.
|
|
671
|
+
*/
|
|
672
|
+
function requireLedger(root) {
|
|
673
|
+
if (!fs.existsSync(path.join(ledgerDir(root), '.git'))) {
|
|
674
|
+
die(`장부가 없습니다. 먼저 실행하세요:\n axmap init`)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** 지금 실행 위치가 장부 worktree 자신인가. */
|
|
679
|
+
function isLedgerWorktree(root) {
|
|
680
|
+
return normalizePath(root).endsWith('/.axmap/ledger')
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
// 관문 1 - 원격과 동기화 / CAS push
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* 원격 장부를 그대로 가져와 로컬 장부를 덮어쓴다.
|
|
689
|
+
* reset --hard 인 이유: 장부는 병합 대상이 아니라 항상 원격이 진실이다.
|
|
690
|
+
* 이전 시도에서 push 가 거부되어 남은 로컬 커밋도 여기서 같이 버려진다.
|
|
691
|
+
*/
|
|
692
|
+
/**
|
|
693
|
+
* 원격에 닿을 수 있나.
|
|
694
|
+
*
|
|
695
|
+
* 🔴 fetch 실패를 "장부 브랜치가 아직 없음" 과 구분해야 한다.
|
|
696
|
+
*
|
|
697
|
+
* 예전에는 fetch 가 실패하면 조용히 통과시켰다. 그러면 **원격이 끊긴 상태에서도
|
|
698
|
+
* claim 이 로컬 장부에만 기록되고**, 같은 시각 원격이 멀쩡한 다른 에이전트가
|
|
699
|
+
* 같은 경로를 잡는다. 두 에이전트가 각자 "내가 쥐고 있다"고 믿는다 —
|
|
700
|
+
* 락 시스템에서 가장 나쁜 실패 방향이고, 이 저장소가 내건 fail-closed 원칙과
|
|
701
|
+
* 정면으로 어긋난다.
|
|
702
|
+
*
|
|
703
|
+
* `ls-remote` 로 원격 자체에 닿는지를 먼저 묻는다. 닿으면 브랜치가 없을 뿐이고,
|
|
704
|
+
* 못 닿으면 그건 장애다.
|
|
705
|
+
*/
|
|
706
|
+
function remoteReachable(root, remote, cwd = ledgerDir(root)) {
|
|
707
|
+
const r = git(['ls-remote', '--exit-code', remote, 'HEAD'], { cwd })
|
|
708
|
+
// --exit-code 는 참조가 없을 때 2 를 준다. 그것도 "닿았다"는 뜻이다.
|
|
709
|
+
return r.code === 0 || r.code === 2
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function syncLedger(root) {
|
|
713
|
+
const r0 = resolveRemote(root)
|
|
714
|
+
const remote = r0.name
|
|
715
|
+
// 🔴 조용히 넘어가지 않는다. 원격이 없으면 이 뒤의 모든 claim 이 성공하는데,
|
|
716
|
+
// 그 성공은 "겹치지 않는다" 가 아니라 "남을 볼 수 없다" 는 뜻이다.
|
|
717
|
+
if (!remote) { warnSolo(r0); return }
|
|
718
|
+
// fetch 를 반드시 장부 worktree 안에서 실행해야 한다.
|
|
719
|
+
// FETCH_HEAD 는 worktree 별로 따로 보관되므로, 메인 worktree 에서 fetch 하면
|
|
720
|
+
// 장부 worktree 에서는 그 FETCH_HEAD 를 볼 수 없다.
|
|
721
|
+
const dir = ledgerDir(root)
|
|
722
|
+
const f = git(['fetch', '--quiet', remote, LEDGER_BRANCH], { cwd: dir })
|
|
723
|
+
if (f.code === 0) {
|
|
724
|
+
// reset 도 인덱스 락을 잡는다. add·commit 과 같은 경합에 같은 방식으로 대응한다.
|
|
725
|
+
const r = gitLockRetry(['reset', '--hard', '--quiet', 'FETCH_HEAD'], { cwd: dir })
|
|
726
|
+
if (r.lockExhausted) {
|
|
727
|
+
lastLockError = lockReason(r)
|
|
728
|
+
die(lockedMessage('장부 동기화'))
|
|
729
|
+
}
|
|
730
|
+
if (r.code !== 0) die(`git reset --hard FETCH_HEAD 실패\n${r.err || r.out}`)
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
// fetch 가 실패했다. 원격에 닿기는 하는가?
|
|
734
|
+
if (remoteReachable(root, remote)) return // 장부 브랜치가 아직 없다 - 첫 push 때 생긴다
|
|
735
|
+
die(
|
|
736
|
+
'원격 장부에 닿을 수 없습니다. 관문 1(직렬화)이 작동하지 않는 상태입니다.\n' +
|
|
737
|
+
` ${(f.err ?? '').trim().split('\n')[0]}\n\n` +
|
|
738
|
+
'이 상태로 claim 하면 나만 아는 락이 됩니다 — 다른 에이전트는 그것을 볼 수 없고,\n' +
|
|
739
|
+
'같은 경로를 동시에 잡게 됩니다. 연결을 고친 뒤 다시 시도하세요.',
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* 장부를 커밋하고 push 한다.
|
|
745
|
+
*
|
|
746
|
+
* @returns {'ok'|'rejected'|'nothing'|'unreachable'|'locked'}
|
|
747
|
+
* rejected 가 곧 CAS 실패 = 누가 먼저 도착함
|
|
748
|
+
* locked 는 로컬 인덱스 락 경합이 끝내 안 풀린 것. **여기서 죽지 않고 돌려준다** —
|
|
749
|
+
* 부르는 쪽이 로컬 장부를 되돌려야 하기 때문이다. 되돌리지 않으면 커밋되지도
|
|
750
|
+
* push 되지도 않은 claim 파일이 worktree 에 남고, 그 저장소만 "내가 쥐고 있다"
|
|
751
|
+
* 고 말한다. 그것이 이 파일이 rollbackLedger 로 막아온 fail-open 이다.
|
|
752
|
+
*/
|
|
753
|
+
function commitAndPush(root, message) {
|
|
754
|
+
const dir = ledgerDir(root)
|
|
755
|
+
const add = gitLockRetry(['add', '-A'], { cwd: dir })
|
|
756
|
+
if (add.lockExhausted) {
|
|
757
|
+
lastLockError = lockReason(add)
|
|
758
|
+
return 'locked'
|
|
759
|
+
}
|
|
760
|
+
if (add.code !== 0) die(`git add -A 실패\n${add.err || add.out}`)
|
|
761
|
+
if (git(['diff', '--cached', '--quiet'], { cwd: dir }).code === 0) return 'nothing'
|
|
762
|
+
// --no-verify 가 반드시 필요하다.
|
|
763
|
+
// 연결된 worktree 는 훅 디렉터리를 공유하므로(.git/hooks 는 git-common-dir 아래에 하나뿐),
|
|
764
|
+
// 사용자가 pre-commit 훅을 설치하면 장부 커밋에도 그 훅이 발동한다.
|
|
765
|
+
// 장부는 사용자 코드가 아니라 내부 기록이므로 사용자 훅의 대상이 아니다.
|
|
766
|
+
const c = gitLockRetry(['commit', '--quiet', '--no-verify', '-m', message], { cwd: dir })
|
|
767
|
+
if (c.lockExhausted) {
|
|
768
|
+
lastLockError = lockReason(c)
|
|
769
|
+
return 'locked'
|
|
770
|
+
}
|
|
771
|
+
if (c.code !== 0) die(`git commit 실패\n${c.err || c.out}`)
|
|
772
|
+
const remote = resolveRemote(root).name
|
|
773
|
+
if (!remote) return 'ok'
|
|
774
|
+
const p = git(['push', '--quiet', remote, `HEAD:${LEDGER_BRANCH}`], { cwd: dir })
|
|
775
|
+
if (p.code === 0) return 'ok'
|
|
776
|
+
// 🔴 push 실패의 원인을 뭉개지 않는다.
|
|
777
|
+
//
|
|
778
|
+
// 예전에는 전부 'rejected'(= 남이 먼저 도착함) 로 만들었다. 그래서 원격 장애·
|
|
779
|
+
// 인증 실패가 "누가 먼저 갱신했습니다. 잠시 후 다시 시도하세요" 로 표시됐고,
|
|
780
|
+
// 그 조언은 절대 통하지 않는다. 원인을 알 수 없게 만드는 것이
|
|
781
|
+
// fail-open 버그를 오래 숨긴 직접 원인이었다.
|
|
782
|
+
if (!remoteReachable(root, remote)) {
|
|
783
|
+
lastPushError = (p.err ?? '').trim()
|
|
784
|
+
return 'unreachable'
|
|
785
|
+
}
|
|
786
|
+
return 'rejected'
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** 마지막 push 실패의 원문. 사람이 원인을 볼 수 있어야 한다. */
|
|
790
|
+
let lastPushError = ''
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* 로컬 장부를 push 이전 상태로 되돌린다.
|
|
794
|
+
*
|
|
795
|
+
* 🔴 이것이 없으면 프로토콜이 fail-open 한다.
|
|
796
|
+
*
|
|
797
|
+
* push 가 끝내 실패했는데 로컬 장부에 claim 이 남아 있으면, 그 저장소의
|
|
798
|
+
* `status` 는 "내가 쥐고 있다" 고 말하고 pre-commit 훅도 통과시킨다.
|
|
799
|
+
* 정작 다른 에이전트는 그 claim 을 볼 수 없으므로 같은 경로를 잡는다.
|
|
800
|
+
* **claim 은 원격 장부에 도달했을 때만 성립한다.**
|
|
801
|
+
*/
|
|
802
|
+
function rollbackLedger(root, before) {
|
|
803
|
+
if (!before) return
|
|
804
|
+
const dir = ledgerDir(root)
|
|
805
|
+
git(['reset', '--hard', '--quiet', before], { cwd: dir })
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** 장부 worktree 의 현재 HEAD. 실패하면 null (아직 커밋이 없는 첫 상태) */
|
|
809
|
+
function ledgerHead(root) {
|
|
810
|
+
const r = git(['rev-parse', 'HEAD'], { cwd: ledgerDir(root) })
|
|
811
|
+
return r.code === 0 ? r.out.trim() : null
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
// 장부 읽기 / 쓰기
|
|
816
|
+
// ---------------------------------------------------------------------------
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* 장부 전체를 읽는다.
|
|
820
|
+
*
|
|
821
|
+
* 읽을 수 없는 레코드가 하나라도 있으면 전체를 중단한다 (fail-closed).
|
|
822
|
+
* 건너뛰면 그 에이전트가 아무것도 잡고 있지 않은 것처럼 보이고,
|
|
823
|
+
* 남의 락 위에 내 claim 이 발급된다. 락 시스템에서 가장 나쁜 실패 방향이다.
|
|
824
|
+
*/
|
|
825
|
+
function readClaims(root) {
|
|
826
|
+
const dir = claimsDir(root)
|
|
827
|
+
if (!fs.existsSync(dir)) return []
|
|
828
|
+
return fs
|
|
829
|
+
.readdirSync(dir)
|
|
830
|
+
.filter((f) => f.endsWith('.json'))
|
|
831
|
+
.map((f) => {
|
|
832
|
+
const full = path.join(dir, f)
|
|
833
|
+
let rec
|
|
834
|
+
try {
|
|
835
|
+
rec = JSON.parse(fs.readFileSync(full, 'utf8'))
|
|
836
|
+
} catch (e) {
|
|
837
|
+
die(
|
|
838
|
+
`장부 레코드를 읽을 수 없습니다: claims/${f}\n${e.message}\n\n` +
|
|
839
|
+
'겹침을 판정할 수 없으므로 중단합니다. 손상된 레코드를 고치거나 지운 뒤 다시 시도하세요.',
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
if (!rec?.agent || !Array.isArray(rec.paths) || !rec.since || !Number.isFinite(rec.ttlMs)) {
|
|
843
|
+
die(`장부 레코드의 필수 필드가 없습니다: claims/${f}\n필요: agent, paths, since, ttlMs`)
|
|
844
|
+
}
|
|
845
|
+
return rec
|
|
846
|
+
})
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// 에이전트 이름은 agentNameError() 로 이미 검증되었으므로 그대로 파일명이 된다.
|
|
850
|
+
// 여기서 문자를 치환하면 서로 다른 이름이 같은 파일을 가리킬 수 있다.
|
|
851
|
+
function claimPath(root, agent) {
|
|
852
|
+
return path.join(claimsDir(root), `${agent}.json`)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* 파일 하나를 원자적으로 쓴다 — 임시 파일에 다 쓴 뒤 같은 디렉터리에서 rename 한다.
|
|
857
|
+
*
|
|
858
|
+
* 🔴 관문 1 이 CAS 인 이상 원자성은 선택이 아니다.
|
|
859
|
+
*
|
|
860
|
+
* `fs.writeFileSync` 는 호출 하나로 보이지만 커널이 여러 번에 나눠 쓸 수 있다.
|
|
861
|
+
* 그 사이에 다른 에이전트의 `git add -A` 가 장부 worktree 를 통째로 스테이징하면
|
|
862
|
+
* **반쯤 쓰인 레코드가 그대로 커밋된다.** readClaims 는 파싱 실패를 건너뛰지 않고
|
|
863
|
+
* 전체를 중단하므로(fail-closed), 그 순간 팀 전원의 장부가 함께 멈춘다.
|
|
864
|
+
* 창은 좁지만 터지면 범위가 전부다.
|
|
865
|
+
*
|
|
866
|
+
* 같은 디렉터리 안의 rename 은 같은 파일시스템이라 원자적이다. 보는 쪽은 옛 파일이나
|
|
867
|
+
* 새 파일만 보고, 반쯤 쓰인 상태는 존재할 수 없다.
|
|
868
|
+
*
|
|
869
|
+
* 임시 이름이 `.json` 으로 끝나지 않는 것도 의도다. rename 직전에 누가 임시 파일을
|
|
870
|
+
* 집어가도 readClaims 의 `.json` 필터가 걸러낸다 — 최악이 "깨진 레코드"가 아니라
|
|
871
|
+
* "무해한 쓰레기 파일"이 된다. 못 막을 때는 실패의 크기라도 낮춘다.
|
|
872
|
+
*/
|
|
873
|
+
function writeFileAtomic(target, data) {
|
|
874
|
+
const tmp = `${target}.tmp-${process.pid}`
|
|
875
|
+
try {
|
|
876
|
+
fs.writeFileSync(tmp, data)
|
|
877
|
+
fs.renameSync(tmp, target)
|
|
878
|
+
} catch (e) {
|
|
879
|
+
// 정리하다 난 오류가 원래 원인을 가리지 않게 한다.
|
|
880
|
+
try {
|
|
881
|
+
fs.rmSync(tmp, { force: true })
|
|
882
|
+
} catch {
|
|
883
|
+
/* 무시 */
|
|
884
|
+
}
|
|
885
|
+
throw e
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* 장부 worktree 가 임시 파일을 절대 커밋하지 않게 한다.
|
|
891
|
+
*
|
|
892
|
+
* writeFileAtomic 의 창은 좁지만 0 은 아니고, 프로세스가 강제 종료되면 임시 파일이
|
|
893
|
+
* 그대로 남는다. `git add -A` 는 그것까지 스테이징하므로 쓰레기가 장부에 실려
|
|
894
|
+
* 모든 클론으로 복제된다. .gitignore 한 줄이면 두 경우가 같이 사라진다.
|
|
895
|
+
*
|
|
896
|
+
* 옛 장부에는 이 파일이 없으므로 claim 할 때마다 존재를 확인한다. 한 번 만들어지면
|
|
897
|
+
* 다음 장부 커밋에 실려 다른 클론에도 따라간다.
|
|
898
|
+
*/
|
|
899
|
+
function ensureLedgerIgnore(root) {
|
|
900
|
+
const gi = path.join(ledgerDir(root), '.gitignore')
|
|
901
|
+
if (!fs.existsSync(gi)) fs.writeFileSync(gi, '*.tmp-*\n')
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function writeClaim(root, claim) {
|
|
905
|
+
fs.mkdirSync(claimsDir(root), { recursive: true })
|
|
906
|
+
ensureLedgerIgnore(root)
|
|
907
|
+
writeFileAtomic(claimPath(root, claim.agent), JSON.stringify(claim, null, 2) + '\n')
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function myClaim(root, agent) {
|
|
911
|
+
return readClaims(root).find((c) => c.agent === agent) ?? null
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* 커밋되기 **전에** 실패했을 때 내 레코드 파일을 원래대로 돌린다.
|
|
916
|
+
*
|
|
917
|
+
* 🔴 `git reset --hard` 로는 이 경우를 못 돌린다. 처음 claim 하는 에이전트의
|
|
918
|
+
* 레코드는 아직 한 번도 add 된 적이 없어 **추적되지 않는 파일**이고, reset 은
|
|
919
|
+
* 추적되지 않는 파일을 건드리지 않는다. 그대로 두면 커밋도 push 도 되지 않은
|
|
920
|
+
* claim 이 worktree 에 남아 그 저장소만 "내가 쥐고 있다" 고 말한다 —
|
|
921
|
+
* rollbackLedger 가 막으려던 fail-open 이 그 옆문으로 그대로 들어온다.
|
|
922
|
+
*
|
|
923
|
+
* `git clean` 을 쓰지 않는 이유: 같은 worktree 에서 동시에 일하는 다른
|
|
924
|
+
* 프로세스가 방금 쓴 레코드까지 지운다. 내 파일 하나만 정확히 되돌린다.
|
|
925
|
+
*/
|
|
926
|
+
function restoreClaimFile(file, prev) {
|
|
927
|
+
if (prev === null) fs.rmSync(file, { force: true })
|
|
928
|
+
else fs.writeFileSync(file, prev)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ---------------------------------------------------------------------------
|
|
932
|
+
// 명령: init
|
|
933
|
+
// ---------------------------------------------------------------------------
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* 쪽지함 worktree 를 준비한다. **이미 있으면 아무 일도 안 한다** (여러 번 불러도 안전).
|
|
937
|
+
*
|
|
938
|
+
* 🔴 여기서는 절대 `die` 하지 않는다. 장부와 정반대다.
|
|
939
|
+
* 장부가 준비 안 되면 claim 이 나만 아는 락이 되므로 멈추는 것이 맞지만,
|
|
940
|
+
* 쪽지함이 없다고 사람의 작업을 막을 이유는 없다. 못 만들었으면 그렇게 말하고
|
|
941
|
+
* 나머지는 그대로 돈다.
|
|
942
|
+
*
|
|
943
|
+
* @returns {'ready'|'created'|'failed'}
|
|
944
|
+
*/
|
|
945
|
+
function ensureBus(root) {
|
|
946
|
+
const dir = busDir(root)
|
|
947
|
+
if (fs.existsSync(path.join(dir, '.git'))) return 'ready'
|
|
948
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
|
|
949
|
+
console.error(`경고: ${BUS_REL} 가 있지만 쪽지함 worktree 가 아닙니다.\n 지운 뒤 다시 실행하세요: rm -rf ${BUS_REL}`)
|
|
950
|
+
return 'failed'
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// 원격을 못 고르겠으면 **고르지 않는다.** 장부가 같은 자리에서 멈추는 것과 같은
|
|
954
|
+
// 이유다 — 엉뚱한 저장소로 간 쪽지는 push 가 성공하고 아무도 못 읽는다.
|
|
955
|
+
const r = resolveRemote(root)
|
|
956
|
+
const remote = r.source === 'ambiguous' ? null : r.name
|
|
957
|
+
|
|
958
|
+
let base = null
|
|
959
|
+
if (remote && git(['fetch', '--quiet', remote, BUS_BRANCH], { cwd: root }).code === 0) {
|
|
960
|
+
base = git(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
|
|
961
|
+
} else {
|
|
962
|
+
// 빈 트리 -> 부모 없는 커밋 -> 브랜치. 작업 트리를 건드리지 않는 고아 브랜치.
|
|
963
|
+
const tree = git(['mktree'], { cwd: root, input: '' })
|
|
964
|
+
if (tree.code !== 0) return busSetupFailed(tree)
|
|
965
|
+
const c = git(['commit-tree', tree.out, '-m', 'axmap: bus init'], { cwd: root })
|
|
966
|
+
if (c.code !== 0) return busSetupFailed(c)
|
|
967
|
+
base = c.out
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (git(['rev-parse', '--verify', '--quiet', BUS_BRANCH], { cwd: root }).code !== 0) {
|
|
971
|
+
const b = git(['branch', BUS_BRANCH, base], { cwd: root })
|
|
972
|
+
if (b.code !== 0) return busSetupFailed(b)
|
|
973
|
+
}
|
|
974
|
+
const w = git(['worktree', 'add', '--quiet', dir, BUS_BRANCH], { cwd: root })
|
|
975
|
+
if (w.code !== 0) return busSetupFailed(w)
|
|
976
|
+
|
|
977
|
+
// git 은 빈 디렉터리를 추적하지 않는다. 쪽지가 0개인 동안에도 폴더가 살아 있게 한다.
|
|
978
|
+
fs.mkdirSync(busMessagesDir(root), { recursive: true })
|
|
979
|
+
fs.writeFileSync(path.join(busMessagesDir(root), '.gitkeep'), '')
|
|
980
|
+
|
|
981
|
+
// 🔴 고아 브랜치는 **자기 트리의 `.gitattributes` 만** 본다. 저장소 루트에
|
|
982
|
+
// 있는 것은 여기 안 닿으므로, 심어 두지 않으면 Windows(`core.autocrlf=true`)
|
|
983
|
+
// 에서 쪽지가 CRLF 로 체크아웃된다. 2026-08-26 에 실제로 그랬고, 머리말
|
|
984
|
+
// 파서가 `\r` 에 걸려 **쪽지가 목록에서 조용히 사라졌다.**
|
|
985
|
+
// 파서도 함께 고쳤지만(`tools/bus.mjs`) 바이트가 플랫폼마다 달라지는 것
|
|
986
|
+
// 자체를 막는 편이 낫다 — 해시도, diff 도, 파서도 전부 같은 것을 본다.
|
|
987
|
+
fs.writeFileSync(
|
|
988
|
+
path.join(dir, '.gitattributes'),
|
|
989
|
+
'# 쪽지는 어느 OS 에서 만들어도 같은 바이트여야 한다.\n' +
|
|
990
|
+
'# 저장소 루트의 .gitattributes 는 고아 브랜치에 닿지 않으므로 여기 따로 둔다.\n' +
|
|
991
|
+
'* text=auto eol=lf\n',
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
if (remote) {
|
|
995
|
+
git(['add', '-A'], { cwd: dir })
|
|
996
|
+
// --no-verify: 연결된 worktree 는 훅을 공유한다. 쪽지함은 사용자 코드가 아니다.
|
|
997
|
+
git(['commit', '--quiet', '--no-verify', '-m', 'axmap: bus init'], { cwd: dir })
|
|
998
|
+
const p = git(['push', '--quiet', remote, `HEAD:${BUS_BRANCH}`], { cwd: dir })
|
|
999
|
+
if (p.code !== 0) console.error(`경고: 쪽지함 push 실패 - ${(p.err ?? '').split('\n')[0]}`)
|
|
1000
|
+
}
|
|
1001
|
+
return 'created'
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function busSetupFailed(r) {
|
|
1005
|
+
console.error(`경고: 쪽지함을 준비하지 못했습니다 - ${(r.err || r.out || '').split('\n')[0]}`)
|
|
1006
|
+
return 'failed'
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function cmdInit(flags = {}) {
|
|
1010
|
+
const root = repoRoot()
|
|
1011
|
+
const dir = ledgerDir(root)
|
|
1012
|
+
|
|
1013
|
+
// .axmap 을 손으로 지웠어도 git 쪽에는 worktree 등록이 남아
|
|
1014
|
+
// worktree add 가 "already registered" 로 실패한다. 먼저 정리한다.
|
|
1015
|
+
git(['worktree', 'prune'], { cwd: root })
|
|
1016
|
+
|
|
1017
|
+
if (fs.existsSync(path.join(dir, '.git'))) {
|
|
1018
|
+
console.log(`장부가 이미 있습니다: ${LEDGER_REL}`)
|
|
1019
|
+
// 🔴 여기서 끝내면 안 된다. 쪽지함은 장부보다 나중에 생겼으므로, 이미 init 을
|
|
1020
|
+
// 돌린 사람은 장부만 있고 쪽지함이 없다. 그 사람들이 다시 init 을 불렀을 때
|
|
1021
|
+
// 받아 가는 자리가 여기다.
|
|
1022
|
+
if (ensureBus(root) === 'created') console.log(`쪽지함을 만들었습니다: ${BUS_REL} (${BUS_BRANCH})`)
|
|
1023
|
+
return
|
|
1024
|
+
}
|
|
1025
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
|
|
1026
|
+
die(
|
|
1027
|
+
`${LEDGER_REL} 가 이미 있지만 정상적인 장부 worktree 가 아닙니다.\n` +
|
|
1028
|
+
`지운 뒤 다시 시도하세요: rm -rf ${LEDGER_REL}`,
|
|
1029
|
+
)
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// 🔴 원격을 고르는 것은 **여기서 한 번**이고, 고른 결과를 저장소에 박아 둔다.
|
|
1033
|
+
//
|
|
1034
|
+
// claim 이 매번 다시 추론하면 상황이 바뀔 때마다(원격 추가·이름 변경) 판정이
|
|
1035
|
+
// 조용히 달라진다. 어제는 team 으로 가던 장부가 오늘은 origin 으로 간다.
|
|
1036
|
+
// init 이 정하고 git config 에 적어 두면 그 뒤로는 아무도 추측하지 않는다.
|
|
1037
|
+
//
|
|
1038
|
+
// 여럿인데 못 고르겠으면 **고르지 말고 멈춘다.** 잘못 고르면 장부가 엉뚱한
|
|
1039
|
+
// 저장소로 가고, 그건 아무 증상이 없다 — push 는 성공하고 팀은 못 본다.
|
|
1040
|
+
const r = resolveRemote(root, flags)
|
|
1041
|
+
if (r.source === 'ambiguous') {
|
|
1042
|
+
die(
|
|
1043
|
+
`원격이 둘 이상입니다. 장부를 어디에 둘지 골라 주세요.\n\n` +
|
|
1044
|
+
r.all.map((n) => ` ${n.padEnd(10)} ${git(['remote', 'get-url', n], { cwd: root }).out}`).join('\n') +
|
|
1045
|
+
`\n\n axmap init --remote <이름>\n` +
|
|
1046
|
+
` (또는 미리: git config axmap.remote <이름>)\n\n` +
|
|
1047
|
+
`아무거나 고르지 않는 이유: 장부가 엉뚱한 저장소로 가면 push 는 성공하고\n` +
|
|
1048
|
+
`팀은 서로의 claim 을 영영 못 봅니다. 증상이 없는 실패입니다.`,
|
|
1049
|
+
)
|
|
1050
|
+
}
|
|
1051
|
+
const remote = r.name
|
|
1052
|
+
if (remote) {
|
|
1053
|
+
// 고른 근거가 추론이었다면 못 박는다. 이미 설정에서 왔으면 다시 쓰지 않는다.
|
|
1054
|
+
if (!r.source.startsWith('git config')) {
|
|
1055
|
+
git(['config', 'axmap.remote', remote], { cwd: root })
|
|
1056
|
+
}
|
|
1057
|
+
console.log(`장부 원격: ${remote} (${r.source})`)
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// 원격에 이미 장부가 있으면 그것을 쓰고, 없으면 빈 트리로 새로 만든다.
|
|
1061
|
+
let base = null
|
|
1062
|
+
if (remote && git(['fetch', '--quiet', remote, LEDGER_BRANCH], { cwd: root }).code === 0) {
|
|
1063
|
+
base = git(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
|
|
1064
|
+
console.log('원격 장부를 발견했습니다. 이어서 사용합니다.')
|
|
1065
|
+
} else {
|
|
1066
|
+
// 작업 트리를 건드리지 않고 고아 브랜치를 만드는 방법:
|
|
1067
|
+
// 빈 트리 -> 그 트리를 가리키는 부모 없는 커밋 -> 브랜치.
|
|
1068
|
+
const tree = gitOrDie(['mktree'], { cwd: root, input: '' }).out
|
|
1069
|
+
base = gitOrDie(['commit-tree', tree, '-m', 'axmap: ledger init'], { cwd: root }).out
|
|
1070
|
+
console.log('새 장부를 만들었습니다.')
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (git(['rev-parse', '--verify', '--quiet', LEDGER_BRANCH], { cwd: root }).code !== 0) {
|
|
1074
|
+
gitOrDie(['branch', LEDGER_BRANCH, base], { cwd: root })
|
|
1075
|
+
}
|
|
1076
|
+
gitOrDie(['worktree', 'add', '--quiet', dir, LEDGER_BRANCH], { cwd: root })
|
|
1077
|
+
fs.mkdirSync(claimsDir(root), { recursive: true })
|
|
1078
|
+
// git 은 빈 디렉터리를 추적하지 않는다. 모두가 반납해 claim 파일이 0개가 되면
|
|
1079
|
+
// claims/ 자체가 사라지므로, 디렉터리를 붙잡아둘 파일을 하나 둔다.
|
|
1080
|
+
fs.writeFileSync(path.join(claimsDir(root), '.gitkeep'), '')
|
|
1081
|
+
// 임시 파일이 장부에 실리지 않게 한다 (writeFileAtomic 참고).
|
|
1082
|
+
ensureLedgerIgnore(root)
|
|
1083
|
+
|
|
1084
|
+
// 장부 worktree 가 본 저장소에서 untracked 로 보이지 않게 한다.
|
|
1085
|
+
const gi = path.join(root, '.gitignore')
|
|
1086
|
+
const cur = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : ''
|
|
1087
|
+
if (!cur.split(/\r?\n/).includes('.axmap/')) {
|
|
1088
|
+
fs.writeFileSync(gi, (cur && !cur.endsWith('\n') ? cur + '\n' : cur) + '.axmap/\n')
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (remote) {
|
|
1092
|
+
const p = git(['push', '--quiet', remote, `HEAD:${LEDGER_BRANCH}`], { cwd: dir })
|
|
1093
|
+
if (p.code !== 0) console.error(`경고: 장부 push 실패 - ${p.err}`)
|
|
1094
|
+
} else {
|
|
1095
|
+
warnSolo(r)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
ensureBus(root)
|
|
1099
|
+
|
|
1100
|
+
console.log(`준비 완료. 장부 ${LEDGER_BRANCH} · 쪽지함 ${BUS_BRANCH}`)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// ---------------------------------------------------------------------------
|
|
1104
|
+
// 명령: claim (핵심)
|
|
1105
|
+
// ---------------------------------------------------------------------------
|
|
1106
|
+
|
|
1107
|
+
function cmdClaim(positional, flags) {
|
|
1108
|
+
const root = repoRoot()
|
|
1109
|
+
requireLedger(root)
|
|
1110
|
+
const me = agentName(flags)
|
|
1111
|
+
if (!positional.length) die('claim 할 경로를 하나 이상 지정하세요.\n axmap claim src/auth --task task-12')
|
|
1112
|
+
for (const p of positional) {
|
|
1113
|
+
const err = claimPathError(p)
|
|
1114
|
+
if (err) die(err)
|
|
1115
|
+
}
|
|
1116
|
+
const want = positional.map(normalizePath)
|
|
1117
|
+
|
|
1118
|
+
// 오타 방어. 아직 만들지 않은 파일을 미리 잡을 수도 있으므로 경고에 그친다.
|
|
1119
|
+
// 그냥 두면 존재하지 않는 경로를 잡은 채 실제 수정은 pre-commit 에 막혀 혼란스럽다.
|
|
1120
|
+
for (const p of want) {
|
|
1121
|
+
if (!fs.existsSync(path.join(root, p))) {
|
|
1122
|
+
console.error(`경고: 저장소에 없는 경로입니다 (오타가 아닌지 확인하세요): ${p}`)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const ttlMs = parseTtl(flags.ttl)
|
|
1127
|
+
|
|
1128
|
+
for (let attempt = 1; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
1129
|
+
syncLedger(root) // 관문 1 준비: 최신 장부를 확보
|
|
1130
|
+
const t = now()
|
|
1131
|
+
|
|
1132
|
+
// 관문 2. 판정과 상태 전이는 전부 순수 함수 안에 있다.
|
|
1133
|
+
const res = applyClaim({
|
|
1134
|
+
claims: readClaims(root),
|
|
1135
|
+
me,
|
|
1136
|
+
requested: want,
|
|
1137
|
+
now: t,
|
|
1138
|
+
ttlMs,
|
|
1139
|
+
task: typeof flags.task === 'string' ? flags.task : null,
|
|
1140
|
+
intent: typeof flags.intent === 'string' ? flags.intent : null,
|
|
1141
|
+
// 🔴 잘못된 값을 조용히 폴백하지 않는다.
|
|
1142
|
+
// 이 저장소는 "이상한 입력을 안전한 값으로 치환하지 말고 거부한다"를
|
|
1143
|
+
// 명시적 원칙으로 걸어뒀다 (CLAUDE.md, SPEC §3 입력 검증).
|
|
1144
|
+
// 치환은 서로 다른 두 입력을 같은 것으로 만든다 — 여기서는 색깔만
|
|
1145
|
+
// 정하는 필드라 피해가 작지만, 원칙에 예외를 두면 그 예외가 기준이 된다.
|
|
1146
|
+
actor: resolveActor(flags.actor),
|
|
1147
|
+
})
|
|
1148
|
+
if (!res.ok) {
|
|
1149
|
+
console.error(formatBlocks(res.blocks, t))
|
|
1150
|
+
process.exit(2)
|
|
1151
|
+
}
|
|
1152
|
+
if (res.hadExpired) {
|
|
1153
|
+
console.error(`알림: ${me} 의 이전 claim 이 만료되어 새 claim 으로 시작합니다.`)
|
|
1154
|
+
}
|
|
1155
|
+
// push 실패 시 되돌아갈 지점. 커밋을 만들기 **전에** 잡아둔다.
|
|
1156
|
+
const before = ledgerHead(root)
|
|
1157
|
+
// 커밋 전에 실패할 수도 있다. 그때는 HEAD 가 아니라 이 파일을 되돌려야 한다.
|
|
1158
|
+
const myFile = claimPath(root, me)
|
|
1159
|
+
const myFileBefore = fs.existsSync(myFile) ? fs.readFileSync(myFile) : null
|
|
1160
|
+
writeClaim(root, res.record)
|
|
1161
|
+
|
|
1162
|
+
// 관문 1: push 가 거부되면 그 사이에 누가 장부를 바꾼 것이다.
|
|
1163
|
+
const pushed = commitAndPush(root, `claim(${me}): ${want.join(' ')}`)
|
|
1164
|
+
if (pushed === 'ok' || pushed === 'nothing') {
|
|
1165
|
+
console.log(`claim 성공 - ${me}`)
|
|
1166
|
+
for (const p of res.record.paths) console.log(` + ${p}`)
|
|
1167
|
+
console.log(` TTL ${humanDuration(ttlMs)} (만료 ${new Date(t + ttlMs).toISOString()})`)
|
|
1168
|
+
printUnread(root, me)
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
if (pushed === 'unreachable') {
|
|
1172
|
+
// 원격 장애다. 재시도해도 결과가 같고, 로컬에 남기면 나만 아는 락이 된다.
|
|
1173
|
+
rollbackLedger(root, before)
|
|
1174
|
+
die(
|
|
1175
|
+
'원격 장부에 push 할 수 없어 claim 을 취소했습니다.\n' +
|
|
1176
|
+
` ${lastPushError.split('\n')[0]}\n\n` +
|
|
1177
|
+
'로컬에만 남겨두면 다른 에이전트는 이 claim 을 볼 수 없고,\n' +
|
|
1178
|
+
'같은 경로를 동시에 잡게 됩니다. 그래서 아무것도 잡지 않은 상태로 되돌렸습니다.',
|
|
1179
|
+
)
|
|
1180
|
+
}
|
|
1181
|
+
if (pushed === 'locked') {
|
|
1182
|
+
// 백오프를 다 쓰고도 인덱스 락이 안 풀렸다. 로컬에 남은 claim 은 이 저장소
|
|
1183
|
+
// 에서만 보이는 락이므로 되돌린다 — unreachable 과 같은 이유다.
|
|
1184
|
+
// 커밋 전에 실패했으므로 HEAD 되돌리기만으로는 부족하다(restoreClaimFile 참고).
|
|
1185
|
+
restoreClaimFile(myFile, myFileBefore)
|
|
1186
|
+
rollbackLedger(root, before)
|
|
1187
|
+
die(lockedMessage('claim'))
|
|
1188
|
+
}
|
|
1189
|
+
console.error(`관문 1: push 거부됨 (누가 먼저 장부를 갱신함). 재시도 ${attempt}/${MAX_CAS_RETRIES}`)
|
|
1190
|
+
// 다음 회차는 syncLedger 가 원격 상태로 reset 하므로 이 커밋은 어차피 사라진다.
|
|
1191
|
+
// 그래도 마지막 회차에서 빠져나갈 때를 위해 여기서 되돌려 둔다.
|
|
1192
|
+
rollbackLedger(root, before)
|
|
1193
|
+
/**
|
|
1194
|
+
* 🔴 즉시 다시 몰아치지 않는다.
|
|
1195
|
+
*
|
|
1196
|
+
* 예전에는 5회를 쉬지 않고 붙였다. 그러면 경합 중인 프로세스들이 매번 같은
|
|
1197
|
+
* 순간에 같이 fetch·push 하므로 **같은 순서로 같이 지는 쪽**이 계속 나온다
|
|
1198
|
+
* (I3 진행성이 걸리는 자리다). 흩어놓아야 누군가는 통과한다.
|
|
1199
|
+
*/
|
|
1200
|
+
casBackoff(attempt)
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
die(`장부 경합이 심해 ${MAX_CAS_RETRIES}회 재시도 후 포기했습니다. 잠시 후 다시 시도하세요.\n(로컬 장부는 되돌렸습니다 — 아무것도 잡지 않은 상태입니다.)`)
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ---------------------------------------------------------------------------
|
|
1207
|
+
// 명령: release / renew
|
|
1208
|
+
// ---------------------------------------------------------------------------
|
|
1209
|
+
|
|
1210
|
+
function cmdRelease(positional, flags) {
|
|
1211
|
+
const root = repoRoot()
|
|
1212
|
+
requireLedger(root)
|
|
1213
|
+
const me = agentName(flags)
|
|
1214
|
+
const drop = positional.map(normalizePath)
|
|
1215
|
+
|
|
1216
|
+
for (let attempt = 1; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
1217
|
+
syncLedger(root)
|
|
1218
|
+
const res = applyRelease({ claims: readClaims(root), me, drop })
|
|
1219
|
+
if (res.hadNothing) {
|
|
1220
|
+
/**
|
|
1221
|
+
* 🔴 반납을 시켰는데 아무것도 반납 안 된 것은 **성공이 아니다.**
|
|
1222
|
+
*
|
|
1223
|
+
* 두 대로 협업하다 실제로 물렸다. `AXMAP_AGENT=X` 로 claim 한 뒤
|
|
1224
|
+
* 그 변수가 없는 셸에서 release 를 불렀고, 이름이 `git config` 로
|
|
1225
|
+
* 떨어져 "잡고 있는 경로가 없습니다" + **종료 코드 0** 이 나왔다.
|
|
1226
|
+
* 반납했다고 믿었지만 락은 그대로였고 다른 에이전트를 30분 더 막았다.
|
|
1227
|
+
*
|
|
1228
|
+
* 0 을 주면 스크립트와 에이전트는 성공으로 읽는다. 락 시스템에서
|
|
1229
|
+
* 조용히 통과시키는 것이 가장 나쁜 실패다.
|
|
1230
|
+
*/
|
|
1231
|
+
const others = readClaims(root).filter((c) => c.agent !== me)
|
|
1232
|
+
console.error(`반납할 것이 없습니다 — "${me}" 이(가) 잡고 있는 경로가 하나도 없습니다.`)
|
|
1233
|
+
console.error(` 이 이름은 ${agentFrom} 에서 왔습니다.`)
|
|
1234
|
+
if (others.length) {
|
|
1235
|
+
console.error('')
|
|
1236
|
+
console.error(' 장부에는 다른 이름으로 잡힌 것이 있습니다:')
|
|
1237
|
+
for (const c of others) console.error(` ${c.agent} [${c.task}] ${c.paths.length}개`)
|
|
1238
|
+
console.error('')
|
|
1239
|
+
console.error(' 잡을 때와 다른 이름으로 반납하려 한 것일 수 있습니다.')
|
|
1240
|
+
console.error(' claim 할 때 쓴 AXMAP_AGENT 를 같은 값으로 주고 다시 시도하세요.')
|
|
1241
|
+
}
|
|
1242
|
+
process.exit(EXIT_NOTHING_RELEASED)
|
|
1243
|
+
}
|
|
1244
|
+
if (res.unheld.length) {
|
|
1245
|
+
console.error(`알림: 잡고 있지 않은 경로는 무시합니다: ${res.unheld.join(', ')}`)
|
|
1246
|
+
}
|
|
1247
|
+
if (res.record) writeClaim(root, res.record)
|
|
1248
|
+
else fs.rmSync(claimPath(root, me), { force: true })
|
|
1249
|
+
|
|
1250
|
+
const pushed = commitAndPush(root, `release(${me}): ${drop.length ? drop.join(' ') : 'all'}`)
|
|
1251
|
+
if (pushed === 'ok' || pushed === 'nothing') {
|
|
1252
|
+
const left = res.record?.paths.length ?? 0
|
|
1253
|
+
console.log(`release 완료 - ${me}${left ? ` (남은 ${left}개)` : ' (전부 반납)'}`)
|
|
1254
|
+
return
|
|
1255
|
+
}
|
|
1256
|
+
if (pushed === 'locked') die(lockedMessage('release'))
|
|
1257
|
+
console.error(`관문 1: push 거부됨. 재시도 ${attempt}/${MAX_CAS_RETRIES}`)
|
|
1258
|
+
casBackoff(attempt)
|
|
1259
|
+
}
|
|
1260
|
+
die('release 실패 - 장부 경합이 계속됩니다.')
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function cmdRenew(flags) {
|
|
1264
|
+
const root = repoRoot()
|
|
1265
|
+
requireLedger(root)
|
|
1266
|
+
const me = agentName(flags)
|
|
1267
|
+
const ttlMs = parseTtl(flags.ttl)
|
|
1268
|
+
|
|
1269
|
+
for (let attempt = 1; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
1270
|
+
syncLedger(root)
|
|
1271
|
+
const t = now()
|
|
1272
|
+
const res = applyRenew({ claims: readClaims(root), me, now: t, ttlMs })
|
|
1273
|
+
if (!res.ok) {
|
|
1274
|
+
die(
|
|
1275
|
+
`${me} 의 유효한 claim 이 없습니다.\n` +
|
|
1276
|
+
'TTL 이 이미 만료되었다면 그 사이 다른 에이전트가 가져갔을 수 있습니다.\n' +
|
|
1277
|
+
'axmap claim 으로 다시 선점하세요 (겹침 검사를 다시 거칩니다).',
|
|
1278
|
+
)
|
|
1279
|
+
}
|
|
1280
|
+
writeClaim(root, res.record)
|
|
1281
|
+
const pushed = commitAndPush(root, `renew(${me})`)
|
|
1282
|
+
if (pushed === 'ok' || pushed === 'nothing') {
|
|
1283
|
+
console.log(`renew 완료 - ${me}, TTL ${humanDuration(ttlMs)} 연장`)
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
if (pushed === 'locked') die(lockedMessage('renew'))
|
|
1287
|
+
console.error(`관문 1: push 거부됨. 재시도 ${attempt}/${MAX_CAS_RETRIES}`)
|
|
1288
|
+
casBackoff(attempt)
|
|
1289
|
+
}
|
|
1290
|
+
die('renew 실패 - 장부 경합이 계속됩니다.')
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// ---------------------------------------------------------------------------
|
|
1294
|
+
// 명령: status
|
|
1295
|
+
// ---------------------------------------------------------------------------
|
|
1296
|
+
|
|
1297
|
+
function cmdStatus(flags) {
|
|
1298
|
+
const root = repoRoot()
|
|
1299
|
+
requireLedger(root)
|
|
1300
|
+
syncLedger(root)
|
|
1301
|
+
const t = now()
|
|
1302
|
+
const all = readClaims(root)
|
|
1303
|
+
|
|
1304
|
+
if (flags.json) {
|
|
1305
|
+
console.log(
|
|
1306
|
+
JSON.stringify(
|
|
1307
|
+
{
|
|
1308
|
+
now: new Date(t).toISOString(),
|
|
1309
|
+
active: activeClaims(all, t),
|
|
1310
|
+
expired: all.filter((c) => claimExpiresAt(c) <= t),
|
|
1311
|
+
},
|
|
1312
|
+
null,
|
|
1313
|
+
2,
|
|
1314
|
+
),
|
|
1315
|
+
)
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const live = activeClaims(all, t)
|
|
1320
|
+
const dead = all.filter((c) => claimExpiresAt(c) <= t)
|
|
1321
|
+
|
|
1322
|
+
// 🔴 `status` 도 쪽지를 알린다. 예전에는 `claim` 만 알렸다.
|
|
1323
|
+
// "지금 무슨 일이 벌어지고 있나" 를 묻는 자리가 여기인데, 정작 나에게 온
|
|
1324
|
+
// 말을 안 보여줬다. 이름을 못 정하면 조용히 건너뛴다 — 알림 때문에
|
|
1325
|
+
// status 가 죽으면 안 된다.
|
|
1326
|
+
const mine = resolveAgentName(flags).name
|
|
1327
|
+
|
|
1328
|
+
if (!live.length && !dead.length) {
|
|
1329
|
+
console.log('장부가 비어 있습니다. 아무도 아무것도 잡고 있지 않습니다.')
|
|
1330
|
+
printUnread(root, mine)
|
|
1331
|
+
return
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
console.log(`장부 상태 (${new Date(t).toISOString()})\n`)
|
|
1335
|
+
for (const c of live) {
|
|
1336
|
+
console.log(` ${c.agent}${c.task ? ` [${c.task}]` : ''} - ${humanDuration(claimExpiresAt(c) - t)} 남음`)
|
|
1337
|
+
if (c.intent) console.log(` "${c.intent}"`)
|
|
1338
|
+
for (const p of c.paths) console.log(` ${p}`)
|
|
1339
|
+
console.log('')
|
|
1340
|
+
}
|
|
1341
|
+
for (const c of dead) {
|
|
1342
|
+
console.log(` ${c.agent} - 만료됨 (자동 회수 대상, 아무것도 막지 않음)`)
|
|
1343
|
+
for (const p of c.paths) console.log(` ${p}`)
|
|
1344
|
+
console.log('')
|
|
1345
|
+
}
|
|
1346
|
+
printUnread(root, mine)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// ---------------------------------------------------------------------------
|
|
1350
|
+
// 명령: verify (pre-commit 훅용)
|
|
1351
|
+
// ---------------------------------------------------------------------------
|
|
1352
|
+
|
|
1353
|
+
function cmdVerify(flags) {
|
|
1354
|
+
const root = repoRoot()
|
|
1355
|
+
// 장부 자신에 대한 커밋은 검사 대상이 아니다.
|
|
1356
|
+
// commitAndPush 가 --no-verify 를 쓰므로 보통은 여기 오지 않지만,
|
|
1357
|
+
// 사람이 장부를 손으로 고칠 때를 대비한 두 번째 그물이다.
|
|
1358
|
+
if (isLedgerWorktree(root)) return
|
|
1359
|
+
requireLedger(root)
|
|
1360
|
+
const me = agentName(flags)
|
|
1361
|
+
syncLedger(root)
|
|
1362
|
+
|
|
1363
|
+
// 여기서만 GIT_INDEX_FILE 을 남긴다.
|
|
1364
|
+
// git commit --only <경로> 는 임시 인덱스를 만들어 이 변수로 알려주므로,
|
|
1365
|
+
// 걷어내면 실제로 커밋될 내용이 아닌 엉뚱한 인덱스를 검사하게 된다.
|
|
1366
|
+
/**
|
|
1367
|
+
* 🔴 `-c core.quotepath=false` 가 없으면 **한글 경로가 통과하지 못한다.**
|
|
1368
|
+
*
|
|
1369
|
+
* git 은 기본으로 ASCII 밖의 글자를 8진 이스케이프로 바꾸고 경로 전체를
|
|
1370
|
+
* 따옴표로 감싼다 —
|
|
1371
|
+
*
|
|
1372
|
+
* docs/bus/방향-전환.md
|
|
1373
|
+
* → "docs/bus/\353\260\251\355\226\245-\354\240\204\355\231\230.md"
|
|
1374
|
+
*
|
|
1375
|
+
* 그러면 앞에 따옴표가 붙어 있어 `docs/bus` 로 시작하지 않게 되고,
|
|
1376
|
+
* **디렉터리를 선점했는데도 그 아래 파일이 거부된다.** 실제로 겪었다 —
|
|
1377
|
+
* `docs/bus` 를 잡은 채로 한글 제목 쪽지를 커밋하려다 막혔다.
|
|
1378
|
+
*
|
|
1379
|
+
* 더 나쁜 방향도 가능하다: 겹침 판정이 경로를 못 알아보면 **막아야 할 것을
|
|
1380
|
+
* 통과시킬** 수도 있다. 락에서 최악은 조용한 통과다.
|
|
1381
|
+
*
|
|
1382
|
+
* 이 저장소는 주석·문서·커밋 메시지가 전부 한국어라 파일 이름도 한국어가
|
|
1383
|
+
* 될 수 있다. 예외가 아니라 기본값으로 다뤄야 한다.
|
|
1384
|
+
*/
|
|
1385
|
+
const staged = gitOrDie(['-c', 'core.quotepath=false', 'diff', '--cached', '--name-only'], {
|
|
1386
|
+
cwd: root,
|
|
1387
|
+
keepEnv: ['GIT_INDEX_FILE'],
|
|
1388
|
+
})
|
|
1389
|
+
.out.split('\n')
|
|
1390
|
+
.map((s) => s.trim())
|
|
1391
|
+
.filter(Boolean)
|
|
1392
|
+
.map(normalizePath)
|
|
1393
|
+
.filter((f) => !f.startsWith('.axmap/'))
|
|
1394
|
+
|
|
1395
|
+
if (!staged.length) return
|
|
1396
|
+
|
|
1397
|
+
const mine = myActiveClaim(readClaims(root), me, now())
|
|
1398
|
+
const paths = mine?.paths ?? []
|
|
1399
|
+
const uncovered = staged.filter((f) => !coversPath(paths, f))
|
|
1400
|
+
|
|
1401
|
+
if (uncovered.length) {
|
|
1402
|
+
console.error('커밋 거부 - claim 하지 않은 파일을 수정했습니다.\n')
|
|
1403
|
+
for (const f of uncovered) console.error(` x ${f}`)
|
|
1404
|
+
console.error(`\n${me} 이(가) 현재 잡고 있는 경로:`)
|
|
1405
|
+
if (paths.length) for (const p of paths) console.error(` - ${p}`)
|
|
1406
|
+
else console.error(' (없음)')
|
|
1407
|
+
console.error('\n해결:\n axmap claim <경로> 먼저 선점한 뒤 커밋하세요')
|
|
1408
|
+
process.exit(3)
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// ---------------------------------------------------------------------------
|
|
1413
|
+
// 명령: audit (사후 증명)
|
|
1414
|
+
// ---------------------------------------------------------------------------
|
|
1415
|
+
|
|
1416
|
+
function cmdAudit(flags) {
|
|
1417
|
+
const root = repoRoot()
|
|
1418
|
+
const dir = ledgerDir(root)
|
|
1419
|
+
const hasWorktree = fs.existsSync(path.join(dir, '.git'))
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* 🔴 **`--fetch` 는 장부 worktree 를 요구하지 않는다.**
|
|
1423
|
+
*
|
|
1424
|
+
* 예전에는 맨 앞에서 `requireLedger` 를 불렀다. 그런데 이 명령을 실제로 부르는
|
|
1425
|
+
* 곳은 팀 CI 의 `claims` 잡이고, **CI 는 신선한 clone 이라 `.axmap/ledger` 가
|
|
1426
|
+
* 없다.** 그래서 CI 에서는 늘 "장부가 없습니다. 먼저 axmap init" 으로 죽었다.
|
|
1427
|
+
* `--fetch` 라는 플래그가 바로 그 CI 를 위해 있는 것인데 순서가 뒤집혀 있었다.
|
|
1428
|
+
*
|
|
1429
|
+
* 이 잡은 **설치 여부와 무관한 유일한 강제 장치**로 설계됐다 — 훅은 각자 PC 에
|
|
1430
|
+
* 있고 이건 서버에 있다. 그것이 한 번도 안 돌았다는 뜻은, 선점 강제가 지금까지
|
|
1431
|
+
* **훅을 설치한 사람에게만** 걸려 있었다는 것이다.
|
|
1432
|
+
*
|
|
1433
|
+
* 고치는 방향은 코드가 이미 알려준다 — `audit` 은 **읽기만** 한다
|
|
1434
|
+
* (`log` · `ls-tree` · `show`). 쓰지 않으므로 worktree 가 필요 없고 ref 하나면 된다.
|
|
1435
|
+
*/
|
|
1436
|
+
let cwd = dir
|
|
1437
|
+
let tip = 'HEAD'
|
|
1438
|
+
if (hasWorktree) {
|
|
1439
|
+
if (flags.fetch) syncLedger(root)
|
|
1440
|
+
} else if (flags.fetch) {
|
|
1441
|
+
const r = resolveRemote(root)
|
|
1442
|
+
const remote = r.source === 'ambiguous' ? null : r.name
|
|
1443
|
+
if (!remote) {
|
|
1444
|
+
die('장부를 받아올 원격을 정할 수 없습니다.\n git config axmap.remote <이름> 으로 정하거나 axmap init 을 실행하세요.')
|
|
1445
|
+
}
|
|
1446
|
+
if (git(['fetch', '--quiet', remote, LEDGER_BRANCH], { cwd: root }).code !== 0) {
|
|
1447
|
+
// 🔴 "아직 없다" 와 "못 닿는다" 를 가른다. init 이 같은 자리에서 하는 구분이다.
|
|
1448
|
+
// 닿는데 브랜치가 없으면 아무도 아직 claim 한 적이 없다는 뜻이고, 검사할
|
|
1449
|
+
// 것이 없는 것이지 실패가 아니다. 여기서 죽이면 장부를 처음 쓰는 팀의
|
|
1450
|
+
// CI 가 영원히 빨갛다.
|
|
1451
|
+
if (remoteReachable(root, remote, root)) {
|
|
1452
|
+
console.log('장부가 아직 없습니다 - 검사할 스냅샷이 없습니다.')
|
|
1453
|
+
return
|
|
1454
|
+
}
|
|
1455
|
+
die(
|
|
1456
|
+
`원격 장부에 닿을 수 없습니다 (${remote}/${LEDGER_BRANCH}).\n` +
|
|
1457
|
+
'검사하지 못한 것을 통과로 내지 않습니다 — 연결을 고친 뒤 다시 시도하세요.',
|
|
1458
|
+
)
|
|
1459
|
+
}
|
|
1460
|
+
cwd = root
|
|
1461
|
+
tip = 'FETCH_HEAD'
|
|
1462
|
+
} else {
|
|
1463
|
+
requireLedger(root)
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// 오래된 것부터 재생한다.
|
|
1467
|
+
const log = gitOrDie(['log', '--reverse', '--format=%H%x00%ct%x00%s', tip], { cwd }).out
|
|
1468
|
+
const commits = log ? log.split('\n').map((l) => l.split('\0')) : []
|
|
1469
|
+
|
|
1470
|
+
const snapshots = []
|
|
1471
|
+
for (const [sha, ct, subject] of commits) {
|
|
1472
|
+
const files = git(['ls-tree', '-r', '--name-only', sha, 'claims/'], { cwd }).out
|
|
1473
|
+
const claims = []
|
|
1474
|
+
// readClaims 와 같은 규칙으로 .json 만 본다. claims/ 에는 .gitkeep 도 있다.
|
|
1475
|
+
for (const f of (files ? files.split('\n') : []).filter((f) => f.endsWith('.json'))) {
|
|
1476
|
+
const blob = git(['show', `${sha}:${f}`], { cwd })
|
|
1477
|
+
if (blob.code !== 0) continue
|
|
1478
|
+
try {
|
|
1479
|
+
claims.push(JSON.parse(blob.out))
|
|
1480
|
+
} catch {
|
|
1481
|
+
die(`감사 중단 - ${sha.slice(0, 7)} 의 ${f} 를 파싱할 수 없습니다.`)
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
snapshots.push({ commit: sha, time: Number(ct) * 1000, subject, claims })
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const report = auditLedger(snapshots)
|
|
1488
|
+
if (flags.json) {
|
|
1489
|
+
console.log(JSON.stringify(report, null, 2))
|
|
1490
|
+
} else {
|
|
1491
|
+
console.log(formatAudit(report))
|
|
1492
|
+
}
|
|
1493
|
+
if (!report.ok) process.exit(4)
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* `doctor` — 이 저장소에서 선점이 **실제로 도는지**를 스스로 확인한다.
|
|
1498
|
+
*
|
|
1499
|
+
* 🔴 왜 필요한가. 이 도구의 실패는 대부분 **조용하다.** 원격이 없거나, 이름이
|
|
1500
|
+
* 남과 같거나, 훅이 안 심겼거나 — 셋 다 claim 은 성공하고 아무 오류도 안 난다.
|
|
1501
|
+
* 그래서 "붙였는데 되는 건가?" 를 사람이 확인할 방법이 필요하다.
|
|
1502
|
+
*
|
|
1503
|
+
* 새 팀원이 clone 하고 나서 물어볼 사람 없이 혼자 답을 얻을 수 있어야 한다는 것이
|
|
1504
|
+
* 이 명령의 전부다. 통과/실패를 눈으로 보여주고 종료 코드로도 낸다.
|
|
1505
|
+
*
|
|
1506
|
+
* 판정 기준은 **경고와 오류를 나눈다.** 혼자 쓰는 것은 오류가 아니지만(경고),
|
|
1507
|
+
* 이름이 없는 것은 오류다 — 그 상태로는 장부가 거짓말을 한다.
|
|
1508
|
+
*/
|
|
1509
|
+
function cmdDoctor(flags) {
|
|
1510
|
+
const lines = []
|
|
1511
|
+
let bad = 0
|
|
1512
|
+
let warn = 0
|
|
1513
|
+
const ok = (what, detail) => lines.push(['ok', what, detail])
|
|
1514
|
+
const no = (what, detail) => { bad++; lines.push(['no', what, detail]) }
|
|
1515
|
+
const hm = (what, detail) => { warn++; lines.push(['hm', what, detail]) }
|
|
1516
|
+
|
|
1517
|
+
// 1. node
|
|
1518
|
+
const major = Number(process.versions.node.split('.')[0])
|
|
1519
|
+
if (major >= 20) ok('node', `v${process.versions.node}`)
|
|
1520
|
+
else no('node', `v${process.versions.node} — 20 이상이 필요합니다`)
|
|
1521
|
+
|
|
1522
|
+
// 2. 저장소
|
|
1523
|
+
let root = null
|
|
1524
|
+
try { root = repoRoot() } catch { /* 아래에서 처리 */ }
|
|
1525
|
+
if (root) ok('저장소', root)
|
|
1526
|
+
else { no('저장소', 'git 저장소 안이 아닙니다'); return finishDoctor(lines, bad, warn, flags) }
|
|
1527
|
+
|
|
1528
|
+
// 3. 이름 — 여기가 제일 중요하다. 여럿이 같은 이름이면 장부가 조용히 거짓말한다.
|
|
1529
|
+
let me = null
|
|
1530
|
+
try { me = agentName(flags) } catch { /* die 가 먼저 난다 */ }
|
|
1531
|
+
if (me) ok('내 이름', `${me} (${agentFrom})`)
|
|
1532
|
+
else no('내 이름', 'AXMAP_AGENT 도 git config user.name 도 없습니다')
|
|
1533
|
+
|
|
1534
|
+
// 4. 장부
|
|
1535
|
+
const hasLedger = fs.existsSync(path.join(ledgerDir(root), '.git'))
|
|
1536
|
+
if (hasLedger) ok('장부', LEDGER_REL)
|
|
1537
|
+
else no('장부', `없습니다 — 'axmap init' 또는 setup 스크립트를 실행하세요`)
|
|
1538
|
+
|
|
1539
|
+
// 5. 원격 — 없어도 돌지만, 그때 claim 은 "겹치지 않는다" 가 아니라 "남을 못 본다" 다
|
|
1540
|
+
const r = resolveRemote(root, flags)
|
|
1541
|
+
if (r.name) {
|
|
1542
|
+
const url = git(['remote', 'get-url', r.name], { cwd: root }).out
|
|
1543
|
+
ok('장부 원격', `${r.name} (${r.source})\n ${url}`)
|
|
1544
|
+
if (hasLedger && !remoteReachable(root, r.name)) {
|
|
1545
|
+
no('원격 연결', `${r.name} 에 닿지 못했습니다 — 관문 1(직렬화)이 작동하지 않습니다`)
|
|
1546
|
+
} else if (hasLedger) {
|
|
1547
|
+
ok('원격 연결', '닿습니다')
|
|
1548
|
+
}
|
|
1549
|
+
} else if (r.source === 'ambiguous') {
|
|
1550
|
+
hm('장부 원격', `원격이 여럿이라 고르지 못했습니다 (${r.all.join(', ')})\n git config axmap.remote <이름>`)
|
|
1551
|
+
} else {
|
|
1552
|
+
hm('장부 원격', '없습니다 — 혼자 쓸 때만 안전합니다. 다른 사람의 claim 을 보지 못합니다')
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// 6. 훅 — 없으면 이 프로토콜은 권고 사항에 불과하다
|
|
1556
|
+
const hookPath = path.join(git(['rev-parse', '--git-common-dir'], { cwd: root }).out || path.join(root, '.git'), 'hooks', 'pre-commit')
|
|
1557
|
+
const hookAbs = path.isAbsolute(hookPath) ? hookPath : path.join(root, hookPath)
|
|
1558
|
+
if (fs.existsSync(hookAbs) && fs.readFileSync(hookAbs, 'utf8').includes('axmap')) {
|
|
1559
|
+
// 훅은 절대 경로를 담는다. 폴더 이름이 바뀌면 사라진 곳을 가리킨다.
|
|
1560
|
+
// 다만 요즘 훅은 그 경로가 없을 때 저장소 안을 한 번 더 본다(cmdHookInstall 참조).
|
|
1561
|
+
// 그래서 박아둔 경로만 보고 빨간불을 켜면 **거짓 경보**가 된다.
|
|
1562
|
+
const target = (fs.readFileSync(hookAbs, 'utf8').match(/"([^"]+axmap\.mjs)"/) ?? [])[1]
|
|
1563
|
+
const fallbacks = ['ci/axmap/bin/axmap.mjs', 'axmap/bin/axmap.mjs', 'bin/axmap.mjs']
|
|
1564
|
+
.map((rel) => path.join(root, rel))
|
|
1565
|
+
.filter((p) => fs.existsSync(p))
|
|
1566
|
+
if (!target || fs.existsSync(target)) ok('커밋 훅', '심겨 있습니다')
|
|
1567
|
+
else if (fallbacks.length) ok('커밋 훅', `심겨 있습니다 (박아둔 경로 대신 ${path.relative(root, fallbacks[0]).replace(/\\/g, '/')} 를 씁니다)`)
|
|
1568
|
+
else no('커밋 훅', `가리키는 파일이 없습니다 (저장소 안에도 없습니다): ${target}\n axmap hook install 로 다시 심으세요`)
|
|
1569
|
+
} else {
|
|
1570
|
+
no('커밋 훅', `없습니다 — claim 하지 않은 파일도 그냥 커밋됩니다\n axmap hook install`)
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// 7. MCP 설정 — AI 도구가 자동으로 붙는 경로
|
|
1574
|
+
const mcpJson = path.join(root, '.mcp.json')
|
|
1575
|
+
if (fs.existsSync(mcpJson)) {
|
|
1576
|
+
let server = null
|
|
1577
|
+
try { server = JSON.parse(fs.readFileSync(mcpJson, 'utf8'))?.mcpServers?.axmap?.args?.[0] } catch { /* 아래 */ }
|
|
1578
|
+
if (!server) hm('MCP 설정', '.mcp.json 은 있는데 axmap 서버를 못 찾았습니다')
|
|
1579
|
+
else if (!fs.existsSync(path.resolve(root, server))) no('MCP 설정', `서버 파일이 없습니다: ${server}`)
|
|
1580
|
+
else ok('MCP 설정', `${server} (AI 도구가 승인만 하면 붙습니다)`)
|
|
1581
|
+
} else {
|
|
1582
|
+
hm('MCP 설정', '.mcp.json 이 없습니다 — AI 도구가 자동으로 붙지 않습니다')
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// 8. 실제로 판정이 도는가 — 장부를 바꾸지 않고 읽기만 한다
|
|
1586
|
+
if (hasLedger) {
|
|
1587
|
+
try {
|
|
1588
|
+
const active = activeClaims(readClaims(root), now())
|
|
1589
|
+
const mine = active.filter((c) => c.agent === me).length
|
|
1590
|
+
ok('선점 판정', `지금 유효한 claim ${active.length}건` + (mine ? ` (그중 내 것 ${mine}건)` : ''))
|
|
1591
|
+
} catch (e) {
|
|
1592
|
+
no('선점 판정', `장부를 읽지 못했습니다: ${e.message}`)
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// 9. 버전 — 🔴 여기서 네트워크를 치지 않는다. 마지막으로 물어본 결과만 읽는다.
|
|
1597
|
+
// doctor 는 "지금 도는가" 를 재는 자리이고, 레지스트리를 기다리게 만들면
|
|
1598
|
+
// 네트워크가 느린 날 점검 자체가 안 끝난다.
|
|
1599
|
+
const ver = selfVersion()
|
|
1600
|
+
if (isVendored(SELF_ROOT)) {
|
|
1601
|
+
ok('버전', `${ver ?? '(모름)'} — 벤더링된 사본입니다. npm 이 아니라 tools/vendor.mjs 가 갱신합니다`)
|
|
1602
|
+
} else {
|
|
1603
|
+
const cache = readCache()
|
|
1604
|
+
const notice = noticeLine(ver, cache?.latest)
|
|
1605
|
+
if (notice) hm('새 버전', notice.split('\n').join('\n '))
|
|
1606
|
+
else if (cache) ok('버전', `${ver ?? '(모름)'} — 최신입니다 (확인: ${new Date(cache.checkedAt).toISOString()})`)
|
|
1607
|
+
else ok('버전', `${ver ?? '(모름)'} — 새 버전이 있는지 물어본 적이 없습니다 (axmap update)`)
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
return finishDoctor(lines, bad, warn, flags)
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function finishDoctor(lines, bad, warn, flags) {
|
|
1614
|
+
if (flags.json) {
|
|
1615
|
+
console.log(JSON.stringify({ ok: bad === 0, errors: bad, warnings: warn, checks: lines.map(([s, w, d]) => ({ status: s, what: w, detail: d })) }, null, 2))
|
|
1616
|
+
} else {
|
|
1617
|
+
const mark = { ok: ' OK ', no: ' !! ', hm: ' ~~ ' }
|
|
1618
|
+
console.log('')
|
|
1619
|
+
for (const [s, what, detail] of lines) console.log(`${mark[s]} ${what.padEnd(10)} ${detail}`)
|
|
1620
|
+
console.log('')
|
|
1621
|
+
if (bad) console.log(`고쳐야 할 것 ${bad}건${warn ? `, 알아둘 것 ${warn}건` : ''}. 위의 !! 줄을 보세요.`)
|
|
1622
|
+
else if (warn) console.log(`쓸 수 있습니다. 다만 알아둘 것이 ${warn}건 있습니다 (~~ 줄).`)
|
|
1623
|
+
else console.log('전부 정상입니다. 파일을 고치기 전에 claim 하는 것만 지키면 됩니다.')
|
|
1624
|
+
console.log('')
|
|
1625
|
+
}
|
|
1626
|
+
process.exit(bad ? 1 : 0)
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function cmdHookInstall() {
|
|
1630
|
+
const root = repoRoot()
|
|
1631
|
+
const dir = gitOrDie(['rev-parse', '--git-common-dir'], { cwd: root }).out
|
|
1632
|
+
const hooks = path.resolve(root, dir, 'hooks')
|
|
1633
|
+
fs.mkdirSync(hooks, { recursive: true })
|
|
1634
|
+
// 검사 대상 저장소가 아니라 이 CLI 자신의 위치를 박는다.
|
|
1635
|
+
// axMap 은 검사 대상 저장소 바깥에 설치되어 있을 수 있다.
|
|
1636
|
+
const self = fileURLToPath(import.meta.url).replace(/\\/g, '/')
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* 🔴 박아둔 경로를 **1순위로 두되 유일한 길로 두지 않는다.**
|
|
1640
|
+
*
|
|
1641
|
+
* 위 주석의 의도(바깥 설치 대응)는 옳다. 그래서 `self` 를 그대로 첫 번째로 둔다.
|
|
1642
|
+
* 그런데 저장소가 갈래마다 axMap 을 다른 자리에 두면 — 원본은 `axmap/`, 벤더링
|
|
1643
|
+
* 사본은 `ci/axmap/` — 브랜치를 옮기는 순간 박아둔 경로가 **사라진다.**
|
|
1644
|
+
*
|
|
1645
|
+
* 문제는 사라졌다는 사실이 아니라 **죽는 방식**이다. 훅은 "검사 실패" 가 아니라
|
|
1646
|
+
* `MODULE_NOT_FOUND` 로 죽고, 0 이 아닌 종료 코드는 곧 커밋 차단이다. 즉
|
|
1647
|
+
* **선점과 무관한 커밋까지 전부 막힌다.** 막히면 사람은 계속 일해야 하므로
|
|
1648
|
+
* `--no-verify` 를 쓰기 시작하고, **그 습관은 훅이 고쳐진 뒤에도 남는다.**
|
|
1649
|
+
* 지금의 고장보다 그것이 비싸다.
|
|
1650
|
+
*
|
|
1651
|
+
* 그래서 넷을 차례로 본다. **배치가 셋이라는 것은 브랜치를 훑어 실측한 것이다**
|
|
1652
|
+
* (2026-08-27 · `git cat-file -e <브랜치>:<경로>`):
|
|
1653
|
+
*
|
|
1654
|
+
* 1. 박아둔 절대 경로 (바깥 설치)
|
|
1655
|
+
* 2. <루트>/ci/axmap/bin main · common/dev · bigData/dev ← 현재 표준
|
|
1656
|
+
* 3. <루트>/axmap/bin chore/axmap-bootstrap · backup/*
|
|
1657
|
+
* 4. <루트>/bin back/dev · front/dev ← 옛 배치
|
|
1658
|
+
*
|
|
1659
|
+
* 🔴 4번을 빠뜨리면 **도구가 거기 있는데 못 찾아서 통과한다.** 그건 "설치 안 됨"
|
|
1660
|
+
* 이 아니라 "찾는 목록이 짧음" 이고, 아래 fail-open 의 근거가 성립하지 않는
|
|
1661
|
+
* 경우다. 하필 `back/dev` · `front/dev` 는 BE·FE 가 앞으로 실제로 쓸 갈래다.
|
|
1662
|
+
*
|
|
1663
|
+
* 🔴 셋 다 없으면 **시끄럽게 경고하고 통과시킨다(exit 0).**
|
|
1664
|
+
*
|
|
1665
|
+
* "도구를 못 찾은 것" 과 "검사가 실패한 것" 은 다르다. 도구가 있는데 선점을
|
|
1666
|
+
* 안 했으면 반드시 막아야 하지만, 도구를 못 찾은 것은 설치 문제다. 설치 문제로
|
|
1667
|
+
* **선점과 무관한 커밋까지 전부 막으면** 사람은 `--no-verify` 를 쓰기 시작하고,
|
|
1668
|
+
* 그 습관은 훅이 고쳐진 뒤에도 남는다. 그때는 진짜로 아무것도 안 막힌다.
|
|
1669
|
+
* `ax_inbox` 의 "없는 것과 못 읽은 것은 다르다" 와 같은 결이다.
|
|
1670
|
+
*
|
|
1671
|
+
* ⚠ 다만 **CI 가 대신 잡아 준다고 말하지 않는다.** 팀 저장소의 `claims` 잡은
|
|
1672
|
+
* `merge_request_event` 에서만 돌고 `changes:` 목록에 든 폴더만 본다.
|
|
1673
|
+
* dev 브랜치 직접 push 와 목록 밖 폴더는 그물 밖이다. 그래서 여기서 할 수
|
|
1674
|
+
* 있는 것은 **조용히 넘어가지 않는 것**뿐이고, 문구는 그 사실만 말한다.
|
|
1675
|
+
* (그물을 넓히는 것은 `.gitlab-ci.yml` 쪽 일이라 여기서 처리하지 않는다.)
|
|
1676
|
+
*/
|
|
1677
|
+
const body = `#!/bin/sh
|
|
1678
|
+
# axMap - claim 하지 않은 파일의 커밋을 막는다.
|
|
1679
|
+
# 경로를 하나만 박지 않는다 — 저장소가 갈래마다 axMap 을 다른 자리에 두기 때문이다
|
|
1680
|
+
# (ci/axmap · axmap · bin 세 배치). 자세한 근거는 bin/axmap.mjs 의 cmdHookInstall.
|
|
1681
|
+
AX="${self}"
|
|
1682
|
+
if [ ! -f "$AX" ]; then
|
|
1683
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || ROOT=""
|
|
1684
|
+
for p in "$ROOT/ci/axmap/bin/axmap.mjs" "$ROOT/axmap/bin/axmap.mjs" "$ROOT/bin/axmap.mjs"; do
|
|
1685
|
+
if [ -f "$p" ]; then AX="$p"; break; fi
|
|
1686
|
+
done
|
|
1687
|
+
fi
|
|
1688
|
+
if [ ! -f "$AX" ]; then
|
|
1689
|
+
echo "" >&2
|
|
1690
|
+
echo " !! axMap: 검사 도구를 찾지 못했습니다." >&2
|
|
1691
|
+
echo " 이 커밋은 선점 검사를 거치지 않았습니다." >&2
|
|
1692
|
+
echo " 고치려면 - node <axmap 위치>/bin/axmap.mjs hook install" >&2
|
|
1693
|
+
echo "" >&2
|
|
1694
|
+
exit 0
|
|
1695
|
+
fi
|
|
1696
|
+
exec node "$AX" verify
|
|
1697
|
+
`
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* 🔴 `pre-commit` 하나로는 부족하다.
|
|
1701
|
+
*
|
|
1702
|
+
* git 은 **자동 머지 커밋에 `pre-commit` 을 부르지 않는다.** `pre-merge-commit`
|
|
1703
|
+
* 을 부른다. 그래서 훅을 설치해도 이 경로가 통째로 열려 있었다 —
|
|
1704
|
+
*
|
|
1705
|
+
* git commit 막힘 (pre-commit)
|
|
1706
|
+
* git commit -m … 후
|
|
1707
|
+
* git merge <브랜치> **안 막힘** → 남의 영역이 main 으로 들어오고 push 까지 된다
|
|
1708
|
+
*
|
|
1709
|
+
* 실제로 재현됐다. claim 이 0건인 에이전트가 `--no-verify` 로 브랜치에 커밋한 뒤
|
|
1710
|
+
* merge 하면 무단 변경이 main 에 올라간다.
|
|
1711
|
+
*
|
|
1712
|
+
* `git merge` 가 충돌 없이 자동 커밋할 때는 `pre-merge-commit` 이,
|
|
1713
|
+
* 충돌을 사람이 풀고 `git commit` 할 때는 `pre-commit` 이 돈다. 둘 다 걸어야 한다.
|
|
1714
|
+
*
|
|
1715
|
+
* cherry-pick·revert·rebase 는 내부적으로 `git commit` 경로를 타므로
|
|
1716
|
+
* `pre-commit` 으로 덮인다. `--no-verify` 는 여전히 우회할 수 있고,
|
|
1717
|
+
* 그건 훅의 본질적 한계라 문서에 남긴다 (SPEC §7).
|
|
1718
|
+
*/
|
|
1719
|
+
const installed = []
|
|
1720
|
+
for (const name of ['pre-commit', 'pre-merge-commit']) {
|
|
1721
|
+
const file = path.join(hooks, name)
|
|
1722
|
+
fs.writeFileSync(file, body)
|
|
1723
|
+
try {
|
|
1724
|
+
fs.chmodSync(file, 0o755)
|
|
1725
|
+
} catch {
|
|
1726
|
+
/* 윈도우에서는 무시 */
|
|
1727
|
+
}
|
|
1728
|
+
installed.push(file)
|
|
1729
|
+
}
|
|
1730
|
+
for (const f of installed) console.log(`훅 설치 완료: ${f}`)
|
|
1731
|
+
console.log(' (merge 로 남의 영역이 들어오는 경로까지 막습니다)')
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// ---------------------------------------------------------------------------
|
|
1735
|
+
// 명령: setup (배포판으로 들어온 사람의 첫 한 줄)
|
|
1736
|
+
// ---------------------------------------------------------------------------
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* 설치 전체를 `tools/setup.mjs` 에 넘긴다.
|
|
1740
|
+
*
|
|
1741
|
+
* 🔴 **import 하지 않고 spawn 한다.** 이유가 둘이다.
|
|
1742
|
+
*
|
|
1743
|
+
* 1. `tools/setup.mjs` 는 설치기를 부르느라 `app/lib/agentcli.mjs` 계열까지
|
|
1744
|
+
* 끌고 온다. 여기서 static import 를 걸면 그 전부가 `src/closure.mjs` 의
|
|
1745
|
+
* import 그래프에 잡혀 **팀 저장소의 `ci/axmap/` 사본이 통째로 불어난다.**
|
|
1746
|
+
* 사본은 CI 가 판정에 쓰는 파일만 담기로 한 자리다.
|
|
1747
|
+
* 2. 설치는 판정이 아니다. 실패해도 CLI 의 나머지가 멈출 이유가 없다.
|
|
1748
|
+
*
|
|
1749
|
+
* 그 대가로 **사본에는 이 파일이 없다.** 없을 때 조용히 실패하지 않고, 왜 없는지와
|
|
1750
|
+
* 무엇을 대신 하면 되는지를 말한다 — 이 저장소가 `tools/bus.mjs` 로 한 번 겪은
|
|
1751
|
+
* 실패다(사본에 빠져 있는데 오류만 났다).
|
|
1752
|
+
*/
|
|
1753
|
+
function cmdSetup(rest, flags) {
|
|
1754
|
+
const script = path.join(SELF_ROOT, 'tools', 'setup.mjs')
|
|
1755
|
+
if (!fs.existsSync(script)) {
|
|
1756
|
+
die(
|
|
1757
|
+
`이 axMap 에는 setup 이 들어 있지 않습니다: ${script}\n\n` +
|
|
1758
|
+
(isVendored(SELF_ROOT)
|
|
1759
|
+
? ` 여기는 팀 저장소 안의 **벤더링된 사본**입니다(옆에 SOURCE.json 이 있습니다).\n` +
|
|
1760
|
+
` 사본에는 CI 가 판정에 쓰는 파일만 들어 있고 설치기는 빠져 있습니다.\n\n` +
|
|
1761
|
+
` 설치는 배포판으로 하세요:\n` +
|
|
1762
|
+
` npx -y ${PACKAGE_NAME}@latest setup`
|
|
1763
|
+
: ` 꾸러미가 온전하지 않습니다. 다시 받으세요:\n npx -y ${PACKAGE_NAME}@latest setup`),
|
|
1764
|
+
)
|
|
1765
|
+
}
|
|
1766
|
+
// 인자를 그대로 넘긴다. 여기서 다시 해석하면 두 곳의 플래그 목록이 어긋난다.
|
|
1767
|
+
const argv = process.argv.slice(2).filter((a) => a !== 'setup')
|
|
1768
|
+
const r = spawnSync(process.execPath, [script, ...argv], { stdio: 'inherit', windowsHide: true })
|
|
1769
|
+
process.exit(r.status ?? 1)
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// ---------------------------------------------------------------------------
|
|
1773
|
+
// 명령: update (새 버전이 있는지 묻는다. 바꾸지는 않는다)
|
|
1774
|
+
// ---------------------------------------------------------------------------
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* 🔴 **확인만 하고 아무것도 갈아치우지 않는다.**
|
|
1778
|
+
*
|
|
1779
|
+
* 사람이 모르는 사이에 도구가 바뀌면 어제 되던 것이 오늘 안 되고, 원인을 찾을
|
|
1780
|
+
* 실마리가 없다 — 바뀐 것이 자기 코드가 아니기 때문이다. 그래서 적용은 사람이
|
|
1781
|
+
* 아래에 찍히는 한 줄을 직접 실행하는 것으로만 일어난다.
|
|
1782
|
+
*
|
|
1783
|
+
* 종료 코드: 0 물어봤다 (최신이든 아니든) · 1 못 물어봤다
|
|
1784
|
+
*/
|
|
1785
|
+
async function cmdUpdate(flags) {
|
|
1786
|
+
const current = selfVersion()
|
|
1787
|
+
|
|
1788
|
+
// 사본은 npm 이 아니라 tools/vendor.mjs 가 갱신한다. 여기서 레지스트리를 치면
|
|
1789
|
+
// 팀 CI 가 매번 바깥 네트워크에 의존하게 되고, 안내를 받은 팀원이 할 수 있는
|
|
1790
|
+
// 일도 없다 (사본은 axMap 저장소에서 vendor.mjs 를 다시 돌려야 바뀐다).
|
|
1791
|
+
if (isVendored(SELF_ROOT)) {
|
|
1792
|
+
console.log('')
|
|
1793
|
+
console.log('여기는 팀 저장소 안의 벤더링된 사본입니다 (옆에 SOURCE.json 이 있습니다).')
|
|
1794
|
+
console.log('사본은 npm 이 아니라 axMap 저장소의 tools/vendor.mjs 가 갱신합니다.')
|
|
1795
|
+
console.log('')
|
|
1796
|
+
console.log(' 사본이 온전한지: node ci/verify-vendor.mjs')
|
|
1797
|
+
console.log(' 어느 커밋의 사본인지: ci/axmap/SOURCE.json')
|
|
1798
|
+
console.log('')
|
|
1799
|
+
process.exit(0)
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
const registry = registryUrl()
|
|
1803
|
+
console.log('')
|
|
1804
|
+
console.log(`지금 버전: ${current ?? '(모름)'}`)
|
|
1805
|
+
console.log(`묻는 곳: ${registry}/${PACKAGE_NAME}`)
|
|
1806
|
+
|
|
1807
|
+
let latest = null
|
|
1808
|
+
try {
|
|
1809
|
+
latest = await fetchLatest({ registry })
|
|
1810
|
+
} catch (e) {
|
|
1811
|
+
console.error('')
|
|
1812
|
+
console.error(`새 버전을 확인하지 못했습니다: ${e.message}`)
|
|
1813
|
+
console.error('네트워크가 안 되거나, 아직 배포된 적이 없는 꾸러미일 수 있습니다.')
|
|
1814
|
+
console.error('')
|
|
1815
|
+
process.exit(1)
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
writeCache({ latest, current, checkedAt: Date.now(), registry })
|
|
1819
|
+
|
|
1820
|
+
const notice = noticeLine(current, latest)
|
|
1821
|
+
console.log(`최신 버전: ${latest}`)
|
|
1822
|
+
console.log('')
|
|
1823
|
+
if (notice) {
|
|
1824
|
+
for (const l of notice.split('\n')) console.log(l)
|
|
1825
|
+
console.log('')
|
|
1826
|
+
console.log(' 🔴 저절로 바뀌지 않습니다. 위 한 줄을 직접 실행할 때만 바뀝니다.')
|
|
1827
|
+
} else {
|
|
1828
|
+
console.log('최신입니다.')
|
|
1829
|
+
}
|
|
1830
|
+
console.log('')
|
|
1831
|
+
process.exit(0)
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/**
|
|
1835
|
+
* 마지막으로 물어본 결과가 "새 버전이 있다" 였으면 **stderr 에** 한 줄 알린다.
|
|
1836
|
+
*
|
|
1837
|
+
* 🔴 여기서 네트워크를 치지 않는다. 도구를 부를 때마다 레지스트리를 기다리면
|
|
1838
|
+
* 선점이 느려지고, 그건 확인 편의보다 훨씬 나쁘다. 캐시는 `axmap update` 와
|
|
1839
|
+
* `axmap setup` 이 채운다.
|
|
1840
|
+
*
|
|
1841
|
+
* 🔴 stdout 이 아니라 stderr 다. stdout 은 `--json` 이 쓰는 자리라, 사람이 읽는
|
|
1842
|
+
* 글을 섞으면 그 출력을 파싱하는 쪽이 조용히 깨진다.
|
|
1843
|
+
*/
|
|
1844
|
+
function warnIfStale() {
|
|
1845
|
+
if (checkDisabled() || isVendored(SELF_ROOT)) return
|
|
1846
|
+
const cache = readCache()
|
|
1847
|
+
if (!cache) return
|
|
1848
|
+
const notice = noticeLine(selfVersion(), cache.latest)
|
|
1849
|
+
if (notice) console.error(`\n${notice}\n`)
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// ---------------------------------------------------------------------------
|
|
1853
|
+
|
|
1854
|
+
const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
|
|
1855
|
+
|
|
1856
|
+
axmap setup 이 PC 와 이 저장소에 axMap 을 붙인다 (첫 한 번)
|
|
1857
|
+
[--remote <이름|git 주소>] [--name] [--dry-run]
|
|
1858
|
+
axmap init 장부(고아 브랜치 + worktree)를 준비한다
|
|
1859
|
+
axmap claim <경로...> 경로를 선점한다 [--task --intent --ttl 30m]
|
|
1860
|
+
axmap release [경로...] 반납한다 (경로 생략 시 전부)
|
|
1861
|
+
axmap renew TTL 을 연장한다 [--ttl 30m]
|
|
1862
|
+
axmap status 누가 무엇을 잡고 있는지 [--json]
|
|
1863
|
+
axmap verify staged 파일이 내 claim 안에 있는지 검사
|
|
1864
|
+
axmap audit 장부 이력 전체를 재생해 상호배제 위반을 사후 증명
|
|
1865
|
+
[--json] [--fetch]
|
|
1866
|
+
axmap doctor 이 PC 에서 선점이 실제로 도는지 점검 [--json]
|
|
1867
|
+
axmap hook install verify 를 pre-commit 훅으로 설치
|
|
1868
|
+
axmap update 새 버전이 있는지 묻는다 (바꾸지는 않는다)
|
|
1869
|
+
|
|
1870
|
+
에이전트 이름: --agent 또는 AXMAP_AGENT, 없으면 git config user.name
|
|
1871
|
+
장부 원격: --remote 또는 AXMAP_REMOTE, 없으면 git config axmap.remote
|
|
1872
|
+
`
|
|
1873
|
+
|
|
1874
|
+
const { positional, flags } = parseArgs(process.argv.slice(2))
|
|
1875
|
+
const [cmd, ...rest] = positional
|
|
1876
|
+
|
|
1877
|
+
switch (cmd) {
|
|
1878
|
+
case 'setup':
|
|
1879
|
+
cmdSetup(rest, flags)
|
|
1880
|
+
break
|
|
1881
|
+
case 'update':
|
|
1882
|
+
// 이 파일은 ESM 이라 최상위 await 이 된다. update 만 비동기다 (레지스트리에 묻는다).
|
|
1883
|
+
await cmdUpdate(flags)
|
|
1884
|
+
break
|
|
1885
|
+
case 'init':
|
|
1886
|
+
cmdInit(flags)
|
|
1887
|
+
break
|
|
1888
|
+
case 'claim':
|
|
1889
|
+
cmdClaim(rest, flags)
|
|
1890
|
+
break
|
|
1891
|
+
case 'release':
|
|
1892
|
+
cmdRelease(rest, flags)
|
|
1893
|
+
break
|
|
1894
|
+
case 'renew':
|
|
1895
|
+
cmdRenew(flags)
|
|
1896
|
+
break
|
|
1897
|
+
case 'status':
|
|
1898
|
+
cmdStatus(flags)
|
|
1899
|
+
// 캐시에 "새 버전이 있다" 가 적혀 있으면 stderr 로 한 줄. 네트워크는 안 친다.
|
|
1900
|
+
warnIfStale()
|
|
1901
|
+
break
|
|
1902
|
+
case 'verify':
|
|
1903
|
+
cmdVerify(flags)
|
|
1904
|
+
break
|
|
1905
|
+
case 'audit':
|
|
1906
|
+
cmdAudit(flags)
|
|
1907
|
+
break
|
|
1908
|
+
case 'doctor':
|
|
1909
|
+
cmdDoctor(flags)
|
|
1910
|
+
break
|
|
1911
|
+
case 'hook':
|
|
1912
|
+
if (rest[0] === 'install') cmdHookInstall()
|
|
1913
|
+
else die(HELP)
|
|
1914
|
+
break
|
|
1915
|
+
default:
|
|
1916
|
+
console.log(HELP)
|
|
1917
|
+
process.exit(cmd ? 1 : 0)
|
|
1918
|
+
}
|