axmap-cli 0.0.1 → 0.2.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/commands/ax.md +23 -7
- package/CLAUDE.md +23 -1
- package/README.md +8 -0
- package/bin/axmap.mjs +301 -18
- package/governance/GOVERNANCE.md +172 -2
- package/governance/gate.mjs +28 -0
- package/governance/vote.mjs +303 -12
- package/mcp/README.md +16 -0
- package/mcp/server.mjs +40 -5
- package/package.json +10 -2
- package/src/governance.mjs +378 -26
- package/src/promote.mjs +87 -0
- package/src/protocol.mjs +163 -7
- package/tools/mcp-register.mjs +1 -1
- package/tools/promote.mjs +19 -4
- package/tools/version.mjs +15 -1
package/.claude/commands/ax.md
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: axMap — 지금 누가 어디를 잡고 있는지
|
|
2
|
+
description: axMap — 지금 누가 어디를 잡고 있는지 본다. 경로를 주면 겹치는지도 확인한다
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
인자: $ARGUMENTS (경로들. 비워도 된다)
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
겹치는 것이 있는지 확인하고, 결과를 한 문단으로 정리해줘.
|
|
7
|
+
## 할 일
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
1. `ax_status` 를 부른다. 장부에 무엇이 들어 있는지 그대로 보여준다.
|
|
10
|
+
2. **인자로 경로를 받았을 때만** 그 경로들로 `ax_check` 를 부른다.
|
|
11
|
+
3. 결과를 짧게 정리한다. 겹치면 **재시도하지 말고** 누가 왜 잡고 있는지와 함께
|
|
12
|
+
비어 있는 다른 곳을 제안한다. 같은 요청은 몇 번을 보내도 같은 답이 온다.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
## 🔴 저장소를 뒤지지 않는다
|
|
15
|
+
|
|
16
|
+
**인자가 없으면 `ax_status` 만 부르고 끝낸다.** 무엇을 건드릴지 추측하지 않는다.
|
|
17
|
+
|
|
18
|
+
예전에는 이 명령이 *"이번 대화에서 건드릴 것으로 보이는 경로를 확인해라"* 라고
|
|
19
|
+
시켰다. 그러면 에이전트는 추측하려고 저장소를 뒤진다 — 파일 목록, git 상태,
|
|
20
|
+
문서 몇 개. **한 줄 확인하자고 대화 하나치 컨텍스트를 쓴다.** 그리고 그렇게
|
|
21
|
+
추측한 경로는 대체로 틀린다. 정작 건드릴 파일은 일을 시작해 봐야 정해지기 때문이다.
|
|
22
|
+
|
|
23
|
+
잡을 곳이 정해졌을 때 부르는 것이 맞다:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
/ax frontend/src/pages/Trip.tsx
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
정해지지 않았으면 장부만 보면 된다. 그게 이 명령의 기본값이다.
|
package/CLAUDE.md
CHANGED
|
@@ -79,6 +79,28 @@ axmap claim <경로...> --task <작업ID> --intent "<한 줄 설명>"
|
|
|
79
79
|
> 훅이 당신을 **다른 사람으로 보고 커밋을 막는다.** 커밋할 때도 같은 값을 준다:
|
|
80
80
|
> `AXMAP_AGENT=<내-이름> git commit`
|
|
81
81
|
|
|
82
|
+
> 🔴 **"세션마다 고유하게" 는 말이 아니라 검사다 (2026-08-28).**
|
|
83
|
+
>
|
|
84
|
+
> 같은 PC 에서 AI 도구 세션 두 개가 같은 저장소를 만졌다. 둘 다 이름을 안 주어
|
|
85
|
+
> `git config user.name` 으로 떨어졌고, 장부에서 **한 사람**이 되었다.
|
|
86
|
+
> 뒤에 온 claim 이 그냥 통과했고 — 레코드는 이름당 파일 하나다 — 앞 세션의
|
|
87
|
+
> 작업·의도가 덮여 `status` 에 1건만 남았다. 두 세션이 같은 파일을 동시에 밀었다.
|
|
88
|
+
>
|
|
89
|
+
> 이제 claim 레코드에 **세션 id** 가 함께 적힌다(`--session` · `AXMAP_SESSION` ·
|
|
90
|
+
> `CLAUDE_CODE_SESSION_ID` 순). 같은 이름인데 세션이 다르면 `claim` 과 `release` 를
|
|
91
|
+
> **거부한다**(종료 코드 2). 레코드는 손대지 않으므로 앞 세션의 것이 사라지지 않는다.
|
|
92
|
+
> 양쪽 중 하나라도 세션을 모르면 막지 않고 **알리기만 한다** — 모르는 것과 다른 것은
|
|
93
|
+
> 다르고, 모른다고 막으면 세션 개념이 없는 셸에서 아무것도 못 하게 된다.
|
|
94
|
+
>
|
|
95
|
+
> 제대로 된 해법은 여전히 위 한 줄이다. **세션마다 `AXMAP_AGENT` 를 다르게 준다.**
|
|
96
|
+
> 그래야 장부에서 둘로 갈라지고 겹침 검사가 실제로 돈다. `--takeover` 는 "저 세션이
|
|
97
|
+
> 확실히 나 자신" 일 때만 쓰는 문이고, 붙여도 잡혀 있던 경로는 안 없어진다.
|
|
98
|
+
|
|
99
|
+
> 🔴 **링크된 worktree**(`git worktree add` 로 옆에 펼친 폴더)**에서도 그대로 돈다.**
|
|
100
|
+
> 장부는 메인 체크아웃에 **하나뿐**이고 링크된 worktree 는 그것을 본다
|
|
101
|
+
> (`git rev-parse --git-common-dir` 로 찾는다). 그 안에 `.axmap/ledger` 를 또 만들지
|
|
102
|
+
> 않는다 — 같은 저장소에 장부가 둘이면 락이 아니다.
|
|
103
|
+
|
|
82
104
|
**어떤 파일이든 수정하기 전에 claim 이 먼저다.** 읽기만 할 때는 필요 없다.
|
|
83
105
|
|
|
84
106
|
작업 범위가 확실치 않으면 **넓게 잡지 말고 좁게 시작해서 추가로 claim** 한다.
|
|
@@ -88,7 +110,7 @@ axmap claim <경로...> --task <작업ID> --intent "<한 줄 설명>"
|
|
|
88
110
|
|
|
89
111
|
| 종료 코드 | 의미 | 할 일 |
|
|
90
112
|
|---|---|---|
|
|
91
|
-
| 2 | 경로가 겹침 | **다른 작업으로 전환.** 재시도해도 결과는 같다 |
|
|
113
|
+
| 2 | 경로가 겹침 **또는 같은 이름·다른 세션** | **다른 작업으로 전환.** 재시도해도 결과는 같다 |
|
|
92
114
|
| 3 | claim 없이 커밋 시도 | 해당 경로를 claim 하거나 스테이징에서 뺀다 |
|
|
93
115
|
| 1 | 환경 오류 / 경합 과다 | 메시지를 읽고 고친다 |
|
|
94
116
|
| 5 | `release` 가 **아무것도 반납 못 함** | 잡을 때와 이름이 다르다. `AXMAP_AGENT` 를 확인한다 |
|
package/README.md
CHANGED
|
@@ -81,6 +81,14 @@ node bin/axmap.mjs status # 누가 무엇을
|
|
|
81
81
|
node bin/axmap.mjs release # 끝나면 즉시
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
이름은 **세션마다 다르게** 준다. 같은 PC 에서 AI 도구 세션을 둘 띄우고 둘 다
|
|
85
|
+
이름을 안 주면 `git config user.name` 으로 떨어져 장부에서 한 사람이 된다.
|
|
86
|
+
그러면 서로를 하나도 못 막는다. 이름이 같은데 세션이 다르면 `claim`·`release` 는
|
|
87
|
+
거부되고(종료 코드 2) 앞 세션의 레코드는 그대로 남는다 — 자세한 것은 [docs/SPEC.md](docs/SPEC.md) 3절.
|
|
88
|
+
|
|
89
|
+
`git worktree add` 로 만든 폴더 안에서도 그대로 쓴다. 장부는 메인 체크아웃에
|
|
90
|
+
하나뿐이고, 링크된 worktree 는 그것을 본다. 거기에 장부를 또 만들지 않는다.
|
|
91
|
+
|
|
84
92
|
git 은 **같은 줄**을 고쳐야 충돌을 안다. 그런데 더 위험한 것은
|
|
85
93
|
같은 기능을 앞에서부터·뒤에서부터 만드는 두 사람이다 — 경로가 안 겹쳐도 어긋난다.
|
|
86
94
|
장부는 그것을 코드를 쓰기 **전에** 터뜨린다.
|
package/bin/axmap.mjs
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
applyRenew,
|
|
21
21
|
coversPath,
|
|
22
22
|
formatBlocks,
|
|
23
|
+
formatSessionConflict,
|
|
24
|
+
shortSession,
|
|
23
25
|
humanDuration,
|
|
24
26
|
normalizePath,
|
|
25
27
|
activeClaims,
|
|
@@ -321,6 +323,26 @@ function parseArgs(argv) {
|
|
|
321
323
|
return { positional, flags }
|
|
322
324
|
}
|
|
323
325
|
|
|
326
|
+
/**
|
|
327
|
+
* 값을 받지 않는 깃발을 안전하게 읽는다.
|
|
328
|
+
*
|
|
329
|
+
* 🔴 `parseArgs` 는 `--x 뒤낱말` 을 값으로 삼킨다. 그래서
|
|
330
|
+
* `axmap claim --takeover src/foo` 는 `takeover='src/foo'` 가 되고
|
|
331
|
+
* **경로 하나가 positional 에서 통째로 사라진다.** 잡은 줄 알았는데 안 잡힌
|
|
332
|
+
* 상태로 커밋하러 가는 것이라, 조용히 참으로 읽지 않고 거부한다.
|
|
333
|
+
*/
|
|
334
|
+
function boolFlag(v, name) {
|
|
335
|
+
if (v === undefined) return false
|
|
336
|
+
if (v === true) return true
|
|
337
|
+
const s = String(v).toLowerCase()
|
|
338
|
+
if (s === 'true' || s === '1' || s === 'yes') return true
|
|
339
|
+
die(
|
|
340
|
+
`--${name} 는 값을 받지 않는데 뒤에 온 "${v}" 를 값으로 삼켰습니다.\n` +
|
|
341
|
+
` 그대로 두면 "${v}" 가 인자 목록에서 사라집니다.\n` +
|
|
342
|
+
` --${name} 를 맨 뒤에 두거나 --${name}=true 로 쓰세요.`,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
324
346
|
// ---------------------------------------------------------------------------
|
|
325
347
|
// 레포 / 장부 위치
|
|
326
348
|
// ---------------------------------------------------------------------------
|
|
@@ -472,14 +494,93 @@ function repoRoot() {
|
|
|
472
494
|
return r.out
|
|
473
495
|
}
|
|
474
496
|
|
|
497
|
+
/**
|
|
498
|
+
* `.axmap/` 가 실제로 있는 곳 — **메인 체크아웃**.
|
|
499
|
+
*
|
|
500
|
+
* 🔴 링크된 worktree 에서 커밋이 통째로 막히던 자리다 (2026-08-28 실측).
|
|
501
|
+
*
|
|
502
|
+
* `git worktree add` 로 만든 worktree(= 같은 저장소의 다른 브랜치를 옆 폴더로
|
|
503
|
+
* 펼쳐 둔 것)에서 `git commit` 을 하면 `pre-commit` 훅이 돈다. 훅 디렉터리는
|
|
504
|
+
* 저장소에 하나뿐이라(공통 디렉터리 아래) worktree 마다 따로 설치할 것도 없이
|
|
505
|
+
* 그대로 실행된다. 그런데 `repoRoot()` 는 **그 worktree** 를 가리키고
|
|
506
|
+
* `.axmap/ledger` 는 **메인 체크아웃에만** 있으므로 `requireLedger` 가
|
|
507
|
+
* "장부가 없습니다" 로 종료 코드 1 을 냈다 — 선점과 아무 상관 없는 커밋까지.
|
|
508
|
+
*
|
|
509
|
+
* 사람이 쓴 우회는 그 worktree 안에 장부 worktree 를 하나 더 만드는 것이었다.
|
|
510
|
+
* 돌아가긴 하지만 장부 worktree 가 worktree 수만큼 늘고, 그중 하나가 뒤처지면
|
|
511
|
+
* **같은 저장소에 장부가 여럿**이 된다. 락 시스템에서 진실이 둘이면 락이 아니다.
|
|
512
|
+
*
|
|
513
|
+
* ── 왜 `--git-common-dir` 인가 ────────────────────────────────────────────
|
|
514
|
+
*
|
|
515
|
+
* git 은 worktree 마다 다른 것(HEAD·인덱스·FETCH_HEAD)과 저장소에 하나뿐인 것
|
|
516
|
+
* (오브젝트·refs·훅)을 나눠 둔다. 후자가 있는 곳이 **공통 디렉터리**다.
|
|
517
|
+
*
|
|
518
|
+
* 메인 체크아웃에서 git rev-parse --git-common-dir → .git (상대)
|
|
519
|
+
* 링크된 worktree 에서 git rev-parse --git-common-dir → <메인>/.git (절대)
|
|
520
|
+
*
|
|
521
|
+
* 그 부모가 메인 체크아웃이다. 이 파일은 이미 훅 설치(`cmdHookInstall`)에서
|
|
522
|
+
* 같은 명령을 쓰고 있었다 — 훅은 공통이라는 사실을 알면서 장부는 몰랐던 것이다.
|
|
523
|
+
*
|
|
524
|
+
* 🔴 **"못 찾음" 과 "없음" 을 뭉개지 않는다.** 여기서 하는 일은 *어디를 볼지*를
|
|
525
|
+
* 바로잡는 것뿐이다. 바로잡은 자리에 장부가 없으면 `requireLedger` 는 지금처럼
|
|
526
|
+
* 거부한다. 뭉개면 `init` 을 안 돌린 저장소에서 장부 없이 커밋이 통과한다.
|
|
527
|
+
*
|
|
528
|
+
* 판정이 안 서면 `root` 를 그대로 돌려준다(= 지금까지의 동작). 공통 디렉터리가
|
|
529
|
+
* `.git` 이라는 이름이 아닌 배치 — bare 저장소의 worktree 나 `--git-dir` 을 손으로
|
|
530
|
+
* 준 경우 — 에서 부모를 짚으면 저장소 **바깥**을 가리키게 되기 때문이다.
|
|
531
|
+
*/
|
|
532
|
+
const axmapHomeCache = new Map()
|
|
533
|
+
|
|
534
|
+
function axmapHome(root) {
|
|
535
|
+
if (axmapHomeCache.has(root)) return axmapHomeCache.get(root)
|
|
536
|
+
const home = computeAxmapHome(root)
|
|
537
|
+
axmapHomeCache.set(root, home)
|
|
538
|
+
return home
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function computeAxmapHome(root) {
|
|
542
|
+
/**
|
|
543
|
+
* 🔴 빠른 길이 있어야 한다. 이 함수는 거의 모든 명령이 부르는데, git 을 한 번 더
|
|
544
|
+
* 띄우는 것은 윈도우에서 수십 ms 다. `test/ledgerlock.test.mjs` 처럼 400ms
|
|
545
|
+
* 창 안에서 재시도가 도는지를 보는 검사가 있어, 여기서 늘린 시간이 그대로
|
|
546
|
+
* **엉뚱한 곳의 실패**로 나타난다.
|
|
547
|
+
*
|
|
548
|
+
* 판별은 파일 하나로 끝난다. 메인 체크아웃의 `.git` 은 **디렉터리**이고
|
|
549
|
+
* 링크된 worktree 의 `.git` 은 `gitdir: …` 한 줄이 든 **파일**이다.
|
|
550
|
+
* 디렉터리면 여기가 곧 메인 체크아웃이므로 git 에게 물을 것이 없다 —
|
|
551
|
+
* 지금까지의 동작과 한 글자도 다르지 않다.
|
|
552
|
+
*/
|
|
553
|
+
try {
|
|
554
|
+
if (fs.statSync(path.join(root, '.git')).isDirectory()) return root
|
|
555
|
+
} catch {
|
|
556
|
+
/* .git 이 없거나 못 읽으면 아래에서 git 에게 물어본다 */
|
|
557
|
+
}
|
|
558
|
+
const r = git(['rev-parse', '--git-common-dir'], { cwd: root })
|
|
559
|
+
if (r.code !== 0 || !r.out) return root
|
|
560
|
+
const common = path.resolve(root, r.out)
|
|
561
|
+
const parent = path.dirname(common)
|
|
562
|
+
if (path.basename(common) !== '.git' || !fs.existsSync(parent)) return root
|
|
563
|
+
return parent
|
|
564
|
+
}
|
|
565
|
+
|
|
475
566
|
function ledgerDir(root) {
|
|
476
|
-
return path.join(root, LEDGER_REL)
|
|
567
|
+
return path.join(axmapHome(root), LEDGER_REL)
|
|
477
568
|
}
|
|
478
569
|
|
|
479
570
|
function claimsDir(root) {
|
|
480
571
|
return path.join(ledgerDir(root), 'claims')
|
|
481
572
|
}
|
|
482
573
|
|
|
574
|
+
/**
|
|
575
|
+
* 쪽지함은 **일부러 `root` 그대로 둔다.**
|
|
576
|
+
*
|
|
577
|
+
* 장부와 같은 `.axmap/` 아래라 같은 문제가 있지만(링크된 worktree 에는 없다),
|
|
578
|
+
* 고치려면 `tools/bus.mjs` 의 `SEEN_FILE`·쪽지함 경로까지 같이 옮겨야 한다.
|
|
579
|
+
* 한쪽만 옮기면 **읽는 곳과 쓰는 곳이 갈려** 읽음 표시가 어긋난다 —
|
|
580
|
+
* 쪽지가 안 보이는 것보다 나쁘다. 장부와 달리 여기서는 못 찾아도 죽지 않고
|
|
581
|
+
* "새 쪽지 없음" 으로 조용히 지나가므로, 링크된 worktree 에서 쪽지가 안 보이는
|
|
582
|
+
* 것은 남는 구멍으로 적어 둔다.
|
|
583
|
+
*/
|
|
483
584
|
function busDir(root) {
|
|
484
585
|
return path.join(root, BUS_REL)
|
|
485
586
|
}
|
|
@@ -662,6 +763,62 @@ function agentName(flags) {
|
|
|
662
763
|
return r.name
|
|
663
764
|
}
|
|
664
765
|
|
|
766
|
+
/**
|
|
767
|
+
* 세션 id 가 **어디서 왔는지**. 이름의 `agentFrom` 과 같은 이유로 남긴다 —
|
|
768
|
+
* 막혔을 때 "왜 남이라고 하는가" 의 답이 대부분 여기 있다.
|
|
769
|
+
*/
|
|
770
|
+
let sessionFrom = null
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* 이 실행은 **어느 세션의 것인가**. 이름과 달리 **없어도 죽지 않는다.**
|
|
774
|
+
*
|
|
775
|
+
* 🔴 이름은 없으면 die 인데 세션은 왜 아닌가.
|
|
776
|
+
*
|
|
777
|
+
* 이름이 없으면 장부에 아무것도 못 적는다 — 레코드의 키가 이름이다.
|
|
778
|
+
* 세션은 키가 아니라 **키 안의 구별**이다. 없으면 지금까지처럼 돌고,
|
|
779
|
+
* 다만 같은 이름의 두 주체를 구별하지 못할 뿐이다. 없다고 멈춰 세우면
|
|
780
|
+
* 세션 개념이 없던 모든 셸에서 axMap 이 통째로 안 돈다.
|
|
781
|
+
*
|
|
782
|
+
* 순서. 이름(`resolveAgentName`)과 같은 모양으로 맞춘다 — 모양이 다르면
|
|
783
|
+
* 한쪽만 기억하게 되고, 그때부터 둘 중 하나는 반드시 틀린다.
|
|
784
|
+
*
|
|
785
|
+
* 1. `--session <값>` 그 명령에서만
|
|
786
|
+
* 2. `AXMAP_SESSION` 도구가 심어준다 (MCP 서버가 여기에 적는다)
|
|
787
|
+
* 3. `CLAUDE_CODE_SESSION_ID` Claude Code 가 세션마다 다르게 준다
|
|
788
|
+
* 4. 없으면 null → "확인할 수 없음". 막지 않되 조용히 넘어가지도 않는다
|
|
789
|
+
*
|
|
790
|
+
* 🔴 **없다고 프로세스마다 다른 값을 지어내지 않는다.** pid 나 난수를 채우면
|
|
791
|
+
* `axmap` 을 부를 때마다 다른 세션이 되고, 자기가 잡은 것을 자기가 못 늘리고
|
|
792
|
+
* 못 반납한다. 모를 때는 모른다고 말하는 것이 유일하게 안전한 값이다.
|
|
793
|
+
*/
|
|
794
|
+
function resolveSessionId(flags) {
|
|
795
|
+
const pick = (v, src) => {
|
|
796
|
+
sessionFrom = src
|
|
797
|
+
return String(v)
|
|
798
|
+
}
|
|
799
|
+
if (typeof flags?.session === 'string' && flags.session) return pick(flags.session, '--session')
|
|
800
|
+
if (process.env.AXMAP_SESSION) return pick(process.env.AXMAP_SESSION, 'AXMAP_SESSION')
|
|
801
|
+
if (process.env.CLAUDE_CODE_SESSION_ID) return pick(process.env.CLAUDE_CODE_SESSION_ID, 'CLAUDE_CODE_SESSION_ID')
|
|
802
|
+
sessionFrom = null
|
|
803
|
+
return null
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* 세션을 확인할 수 없을 때 한 번만 알린다.
|
|
808
|
+
*
|
|
809
|
+
* 🔴 **조용히 넘어가지 않는다.** 이 결함의 피해는 "덮어썼다" 가 아니라
|
|
810
|
+
* "덮어썼는데 아무도 몰랐다" 였다. 막을 근거가 없을 때 할 수 있는 최소한은
|
|
811
|
+
* 무엇이 일어나는지를 말하는 것이다.
|
|
812
|
+
*/
|
|
813
|
+
function warnUnverifiableSession(me, prevPaths) {
|
|
814
|
+
console.error(
|
|
815
|
+
`알림: "${me}" 의 유효한 claim 이 이미 있습니다 (경로 ${prevPaths}개). 덮지 않고 여기에 더합니다.\n` +
|
|
816
|
+
' 이 실행이 그 claim 을 만든 세션과 같은 세션인지는 확인하지 못했습니다\n' +
|
|
817
|
+
` (${sessionFrom ? `내 세션은 ${sessionFrom} 에서 왔지만 레코드에 세션 표식이 없습니다` : 'AXMAP_SESSION 도 CLAUDE_CODE_SESSION_ID 도 없습니다'}).\n` +
|
|
818
|
+
' 한 PC 에서 세션을 여럿 굴린다면 세션마다 AXMAP_SESSION 을 다르게 주세요.',
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
|
|
665
822
|
/**
|
|
666
823
|
* 장부 worktree 의 존재만 확인한다.
|
|
667
824
|
*
|
|
@@ -670,9 +827,17 @@ function agentName(flags) {
|
|
|
670
827
|
* 그러면 "정상적으로 다 반납한 상태"가 "장부가 없음"으로 오진된다.
|
|
671
828
|
*/
|
|
672
829
|
function requireLedger(root) {
|
|
673
|
-
if (
|
|
674
|
-
|
|
830
|
+
if (fs.existsSync(path.join(ledgerDir(root), '.git'))) return
|
|
831
|
+
const home = axmapHome(root)
|
|
832
|
+
let msg = `장부가 없습니다. 먼저 실행하세요:\n axmap init`
|
|
833
|
+
// 링크된 worktree 에서 실행 중이면 어디를 보고 없다고 하는지 밝힌다.
|
|
834
|
+
// 안 밝히면 "여기 .axmap 이 없는데?" 로 읽혀 장부를 또 만들게 된다 (그러면 진실이 둘이다).
|
|
835
|
+
if (path.resolve(home) !== path.resolve(root)) {
|
|
836
|
+
msg += `\n\n찾아본 곳: ${path.join(home, LEDGER_REL)}`
|
|
837
|
+
msg += `\n(여기는 링크된 worktree 라 장부는 메인 체크아웃에 하나만 둡니다.`
|
|
838
|
+
msg += `\n 이 폴더 안에 장부를 또 만들지 마세요 — 같은 저장소에 장부가 둘이 되면 락이 아닙니다.)`
|
|
675
839
|
}
|
|
840
|
+
die(msg)
|
|
676
841
|
}
|
|
677
842
|
|
|
678
843
|
/** 지금 실행 위치가 장부 worktree 자신인가. */
|
|
@@ -1108,6 +1273,8 @@ function cmdClaim(positional, flags) {
|
|
|
1108
1273
|
const root = repoRoot()
|
|
1109
1274
|
requireLedger(root)
|
|
1110
1275
|
const me = agentName(flags)
|
|
1276
|
+
const session = resolveSessionId(flags)
|
|
1277
|
+
const takeover = boolFlag(flags.takeover, 'takeover')
|
|
1111
1278
|
if (!positional.length) die('claim 할 경로를 하나 이상 지정하세요.\n axmap claim src/auth --task task-12')
|
|
1112
1279
|
for (const p of positional) {
|
|
1113
1280
|
const err = claimPathError(p)
|
|
@@ -1144,14 +1311,33 @@ function cmdClaim(positional, flags) {
|
|
|
1144
1311
|
// 치환은 서로 다른 두 입력을 같은 것으로 만든다 — 여기서는 색깔만
|
|
1145
1312
|
// 정하는 필드라 피해가 작지만, 원칙에 예외를 두면 그 예외가 기준이 된다.
|
|
1146
1313
|
actor: resolveActor(flags.actor),
|
|
1314
|
+
session,
|
|
1315
|
+
takeover,
|
|
1147
1316
|
})
|
|
1148
1317
|
if (!res.ok) {
|
|
1149
|
-
|
|
1318
|
+
/**
|
|
1319
|
+
* 두 가지 거부가 있고 **둘 다 종료 코드 2** 다 (SPEC 8절).
|
|
1320
|
+
* 부르는 쪽이 해야 할 일이 같기 때문이다 — 재시도하지 말고 방향을 바꾼다.
|
|
1321
|
+
*/
|
|
1322
|
+
if (res.sessionConflict) {
|
|
1323
|
+
console.error(formatSessionConflict(res.sessionConflict, t, 'claim'))
|
|
1324
|
+
console.error(`\n (이 세션의 id 는 ${sessionFrom ?? '어디에서도 오지 않았습니다'})`)
|
|
1325
|
+
} else {
|
|
1326
|
+
console.error(formatBlocks(res.blocks, t))
|
|
1327
|
+
}
|
|
1150
1328
|
process.exit(2)
|
|
1151
1329
|
}
|
|
1152
1330
|
if (res.hadExpired) {
|
|
1153
1331
|
console.error(`알림: ${me} 의 이전 claim 이 만료되어 새 claim 으로 시작합니다.`)
|
|
1154
1332
|
}
|
|
1333
|
+
if (res.tookOver) {
|
|
1334
|
+
console.error(
|
|
1335
|
+
`알림: --takeover — "${me}" 의 레코드를 다른 세션에게서 이 세션이 넘겨받습니다.\n` +
|
|
1336
|
+
' 저 세션이 잡고 있던 경로는 하나도 빠지지 않고 그대로 합쳐집니다.',
|
|
1337
|
+
)
|
|
1338
|
+
} else if (res.session === 'unverifiable') {
|
|
1339
|
+
warnUnverifiableSession(me, res.record.paths.length)
|
|
1340
|
+
}
|
|
1155
1341
|
// push 실패 시 되돌아갈 지점. 커밋을 만들기 **전에** 잡아둔다.
|
|
1156
1342
|
const before = ledgerHead(root)
|
|
1157
1343
|
// 커밋 전에 실패할 수도 있다. 그때는 HEAD 가 아니라 이 파일을 되돌려야 한다.
|
|
@@ -1211,11 +1397,24 @@ function cmdRelease(positional, flags) {
|
|
|
1211
1397
|
const root = repoRoot()
|
|
1212
1398
|
requireLedger(root)
|
|
1213
1399
|
const me = agentName(flags)
|
|
1400
|
+
const session = resolveSessionId(flags)
|
|
1401
|
+
const takeover = boolFlag(flags.takeover, 'takeover')
|
|
1214
1402
|
const drop = positional.map(normalizePath)
|
|
1215
1403
|
|
|
1216
1404
|
for (let attempt = 1; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
1217
1405
|
syncLedger(root)
|
|
1218
|
-
const
|
|
1406
|
+
const t = now()
|
|
1407
|
+
const res = applyRelease({ claims: readClaims(root), me, drop, session, takeover, now: t })
|
|
1408
|
+
/**
|
|
1409
|
+
* 🔴 `hadNothing` 보다 **먼저** 본다. 여기서 막힌 것은 "반납할 게 없다"가
|
|
1410
|
+
* 아니라 "반납하면 남의 세션 기록이 사라진다" 이고, 둘을 같은 코드로 내면
|
|
1411
|
+
* 부르는 쪽이 이름을 고치는 엉뚱한 처방으로 간다.
|
|
1412
|
+
*/
|
|
1413
|
+
if (res.sessionConflict) {
|
|
1414
|
+
console.error(formatSessionConflict(res.sessionConflict, t, 'release'))
|
|
1415
|
+
console.error(`\n (이 세션의 id 는 ${sessionFrom ?? '어디에서도 오지 않았습니다'})`)
|
|
1416
|
+
process.exit(2)
|
|
1417
|
+
}
|
|
1219
1418
|
if (res.hadNothing) {
|
|
1220
1419
|
/**
|
|
1221
1420
|
* 🔴 반납을 시켰는데 아무것도 반납 안 된 것은 **성공이 아니다.**
|
|
@@ -1264,12 +1463,24 @@ function cmdRenew(flags) {
|
|
|
1264
1463
|
const root = repoRoot()
|
|
1265
1464
|
requireLedger(root)
|
|
1266
1465
|
const me = agentName(flags)
|
|
1466
|
+
const session = resolveSessionId(flags)
|
|
1267
1467
|
const ttlMs = parseTtl(flags.ttl)
|
|
1268
1468
|
|
|
1269
1469
|
for (let attempt = 1; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
1270
1470
|
syncLedger(root)
|
|
1271
1471
|
const t = now()
|
|
1272
|
-
const res = applyRenew({ claims: readClaims(root), me, now: t, ttlMs })
|
|
1472
|
+
const res = applyRenew({ claims: readClaims(root), me, now: t, ttlMs, session })
|
|
1473
|
+
/**
|
|
1474
|
+
* renew 는 **막지 않는다** (applyRenew 의 주석 참고). 다만 남의 세션 락의
|
|
1475
|
+
* 수명을 늘리고 있다는 사실은 말한다 — 모르고 늘리면 그 경로는 아무도
|
|
1476
|
+
* 안 쓰는데 계속 막혀 있게 된다.
|
|
1477
|
+
*/
|
|
1478
|
+
if (res.ok && res.session === 'different') {
|
|
1479
|
+
console.error(
|
|
1480
|
+
`경고: "${me}" 의 이 claim 은 다른 세션(${shortSession(res.record.session)})이 만든 것입니다.\n` +
|
|
1481
|
+
' TTL 만 늘리고 소유자는 그대로 둡니다. 저 세션이 끝난 것이면 늘리지 마세요.',
|
|
1482
|
+
)
|
|
1483
|
+
}
|
|
1273
1484
|
if (!res.ok) {
|
|
1274
1485
|
die(
|
|
1275
1486
|
`${me} 의 유효한 claim 이 없습니다.\n` +
|
|
@@ -1332,8 +1543,15 @@ function cmdStatus(flags) {
|
|
|
1332
1543
|
}
|
|
1333
1544
|
|
|
1334
1545
|
console.log(`장부 상태 (${new Date(t).toISOString()})\n`)
|
|
1546
|
+
// 이름이 같은 줄이 여럿일 수는 없다(레코드는 이름당 하나). 그래서 세션은
|
|
1547
|
+
// 여기서 줄을 나누는 것이 아니라, **내 것이 아닌데 내 이름인** 줄을 알아보게 한다.
|
|
1548
|
+
const mySession = resolveSessionId(flags)
|
|
1335
1549
|
for (const c of live) {
|
|
1336
1550
|
console.log(` ${c.agent}${c.task ? ` [${c.task}]` : ''} - ${humanDuration(claimExpiresAt(c) - t)} 남음`)
|
|
1551
|
+
if (c.session) {
|
|
1552
|
+
const same = mySession && c.session === mySession
|
|
1553
|
+
console.log(` 세션 ${shortSession(c.session)}${same ? ' (이 세션)' : mySession ? ' (이 세션이 아님)' : ''}`)
|
|
1554
|
+
}
|
|
1337
1555
|
if (c.intent) console.log(` "${c.intent}"`)
|
|
1338
1556
|
for (const p of c.paths) console.log(` ${p}`)
|
|
1339
1557
|
console.log('')
|
|
@@ -1531,10 +1749,24 @@ function cmdDoctor(flags) {
|
|
|
1531
1749
|
if (me) ok('내 이름', `${me} (${agentFrom})`)
|
|
1532
1750
|
else no('내 이름', 'AXMAP_AGENT 도 git config user.name 도 없습니다')
|
|
1533
1751
|
|
|
1752
|
+
// 3-b. 세션 — 이름이 같은 두 주체를 가르는 것. 없으면 못 가른다(= 예전 사고 그대로)
|
|
1753
|
+
const sid = resolveSessionId(flags)
|
|
1754
|
+
if (sid) ok('내 세션', `${shortSession(sid)} (${sessionFrom})`)
|
|
1755
|
+
else {
|
|
1756
|
+
hm(
|
|
1757
|
+
'내 세션',
|
|
1758
|
+
'알 수 없습니다 — 같은 이름의 다른 세션을 구별하지 못합니다\n' +
|
|
1759
|
+
' 세션마다 AXMAP_SESSION 을 다르게 주세요 (Claude Code 는 자동입니다)',
|
|
1760
|
+
)
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1534
1763
|
// 4. 장부
|
|
1764
|
+
const home = axmapHome(root)
|
|
1535
1765
|
const hasLedger = fs.existsSync(path.join(ledgerDir(root), '.git'))
|
|
1536
|
-
|
|
1537
|
-
|
|
1766
|
+
// 링크된 worktree 면 장부가 어디 있는지 그대로 보여준다. 같은 저장소에 장부는 하나다.
|
|
1767
|
+
const where = path.resolve(home) === path.resolve(root) ? LEDGER_REL : path.join(home, LEDGER_REL)
|
|
1768
|
+
if (hasLedger) ok('장부', where)
|
|
1769
|
+
else no('장부', `없습니다 (${where}) — 'axmap init' 또는 setup 스크립트를 실행하세요`)
|
|
1538
1770
|
|
|
1539
1771
|
// 5. 원격 — 없어도 돌지만, 그때 claim 은 "겹치지 않는다" 가 아니라 "남을 못 본다" 다
|
|
1540
1772
|
const r = resolveRemote(root, flags)
|
|
@@ -1750,21 +1982,49 @@ exec node "$AX" verify
|
|
|
1750
1982
|
* 무엇을 대신 하면 되는지를 말한다 — 이 저장소가 `tools/bus.mjs` 로 한 번 겪은
|
|
1751
1983
|
* 실패다(사본에 빠져 있는데 오류만 났다).
|
|
1752
1984
|
*/
|
|
1753
|
-
|
|
1754
|
-
|
|
1985
|
+
/**
|
|
1986
|
+
* 딸린 프로그램들. **이름을 경로로 바꿔 주는 것이 전부다.**
|
|
1987
|
+
*
|
|
1988
|
+
* 🔴 원래는 파일 경로로 불렀다 — `node axmap/governance/gate.mjs`. 저장소 안에
|
|
1989
|
+
* 사본을 두고 쓸 때는 그게 됐다. 사본의 자리를 팀이 알고 있었기 때문이다.
|
|
1990
|
+
* **npm 으로 설치하면 그 경로가 기계마다 다르다.** 그래서 팀의 CI 설정과
|
|
1991
|
+
* 슬래시 명령이 경로를 박아 둘 수밖에 없었고, 경로가 박혀 있는 한 사본을
|
|
1992
|
+
* 지울 수 없었다. 여기 이름이 하나 생길 때마다 박힌 경로가 하나 사라진다.
|
|
1993
|
+
*
|
|
1994
|
+
* 값은 [파일, 한 줄 설명] 이다. 설명은 HELP 와 오류 메시지가 같이 쓴다 —
|
|
1995
|
+
* 두 군데 적으면 한 쪽만 고쳐지는 날이 온다.
|
|
1996
|
+
*/
|
|
1997
|
+
const RUNNERS = {
|
|
1998
|
+
gate: ['governance/gate.mjs', '이 MR 이 정족수를 채웠는가'],
|
|
1999
|
+
vote: ['governance/vote.mjs', '표를 던진다'],
|
|
2000
|
+
bus: ['tools/bus.mjs', '에이전트 사이 쪽지함'],
|
|
2001
|
+
'mr-target': ['tools/mr-target.mjs', '이 브랜치가 저 브랜치로 갈 수 있는가'],
|
|
2002
|
+
version: ['tools/version.mjs', '브랜치 단계로 다음 버전을 정한다'],
|
|
2003
|
+
promote: ['tools/promote.mjs', '한 단계 위로 올리는 MR 을 만든다'],
|
|
2004
|
+
mcp: ['mcp/server.mjs', 'AI 도구가 붙는 서버를 띄운다'],
|
|
2005
|
+
setup: ['tools/setup.mjs', '이 PC 와 이 저장소에 axMap 을 붙인다'],
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
function cmdRun(name) {
|
|
2009
|
+
const [rel, what] = RUNNERS[name]
|
|
2010
|
+
const script = path.join(SELF_ROOT, ...rel.split('/'))
|
|
1755
2011
|
if (!fs.existsSync(script)) {
|
|
1756
2012
|
die(
|
|
1757
|
-
`이 axMap 에는
|
|
2013
|
+
`이 axMap 에는 ${name}(${what})이 들어 있지 않습니다: ${script}\n\n` +
|
|
1758
2014
|
(isVendored(SELF_ROOT)
|
|
1759
2015
|
? ` 여기는 팀 저장소 안의 **벤더링된 사본**입니다(옆에 SOURCE.json 이 있습니다).\n` +
|
|
1760
|
-
` 사본에는 CI 가 판정에 쓰는 파일만 들어
|
|
1761
|
-
`
|
|
1762
|
-
|
|
1763
|
-
: ` 꾸러미가 온전하지 않습니다. 다시 받으세요:\n npx -y ${PACKAGE_NAME}@latest setup`),
|
|
2016
|
+
` 사본에는 CI 가 판정에 쓰는 파일만 들어 있습니다.\n\n` +
|
|
2017
|
+
` 배포판으로 부르세요:\n npx -y ${PACKAGE_NAME}@latest ${name}`
|
|
2018
|
+
: ` 꾸러미가 온전하지 않습니다. 다시 받으세요:\n npx -y ${PACKAGE_NAME}@latest ${name}`),
|
|
1764
2019
|
)
|
|
1765
2020
|
}
|
|
1766
|
-
//
|
|
1767
|
-
|
|
2021
|
+
// 인자는 그대로 넘긴다. 여기서 다시 해석하면 두 곳의 플래그 목록이 어긋난다.
|
|
2022
|
+
//
|
|
2023
|
+
// 🔴 명령 이름은 **처음 나온 것 하나만** 뗀다. 전부 지우면 같은 낱말이 값으로
|
|
2024
|
+
// 올 때 그 값까지 사라진다 — `axmap bus post --subject vote` 의 "vote".
|
|
2025
|
+
const argv = process.argv.slice(2)
|
|
2026
|
+
const at = argv.indexOf(name)
|
|
2027
|
+
if (at >= 0) argv.splice(at, 1)
|
|
1768
2028
|
const r = spawnSync(process.execPath, [script, ...argv], { stdio: 'inherit', windowsHide: true })
|
|
1769
2029
|
process.exit(r.status ?? 1)
|
|
1770
2030
|
}
|
|
@@ -1859,6 +2119,7 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
|
|
|
1859
2119
|
axmap claim <경로...> 경로를 선점한다 [--task --intent --ttl 30m]
|
|
1860
2120
|
axmap release [경로...] 반납한다 (경로 생략 시 전부)
|
|
1861
2121
|
axmap renew TTL 을 연장한다 [--ttl 30m]
|
|
2122
|
+
claim·release 는 [--session <id>] [--takeover]
|
|
1862
2123
|
axmap status 누가 무엇을 잡고 있는지 [--json]
|
|
1863
2124
|
axmap verify staged 파일이 내 claim 안에 있는지 검사
|
|
1864
2125
|
axmap audit 장부 이력 전체를 재생해 상호배제 위반을 사후 증명
|
|
@@ -1867,7 +2128,21 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
|
|
|
1867
2128
|
axmap hook install verify 를 pre-commit 훅으로 설치
|
|
1868
2129
|
axmap update 새 버전이 있는지 묻는다 (바꾸지는 않는다)
|
|
1869
2130
|
|
|
2131
|
+
딸린 프로그램 — 예전에는 파일 경로로 불렀다. 이제 이름으로 부른다.
|
|
2132
|
+
인자는 그대로 전달되고, 각각의 사용법은 그 프로그램이 답한다.
|
|
2133
|
+
|
|
2134
|
+
axmap gate 이 MR 이 정족수를 채웠는가 (CI 가 부른다)
|
|
2135
|
+
axmap vote 표를 던진다
|
|
2136
|
+
axmap bus 에이전트 사이 쪽지함 (post · list · read · reply)
|
|
2137
|
+
axmap mr-target 이 브랜치가 저 브랜치로 갈 수 있는가
|
|
2138
|
+
axmap version 브랜치 단계로 다음 버전을 정한다
|
|
2139
|
+
axmap promote 한 단계 위로 올리는 MR 을 만든다
|
|
2140
|
+
axmap mcp AI 도구가 붙는 서버를 띄운다 (직접 부를 일은 없다)
|
|
2141
|
+
|
|
1870
2142
|
에이전트 이름: --agent 또는 AXMAP_AGENT, 없으면 git config user.name
|
|
2143
|
+
세션 id : --session 또는 AXMAP_SESSION, 없으면 CLAUDE_CODE_SESSION_ID
|
|
2144
|
+
같은 이름이라도 세션이 다르면 남의 claim 을 덮지 않고 거부한다(종료 코드 2).
|
|
2145
|
+
정말 같은 세션이라면 --takeover 로 넘겨받는다 — 경로는 안 없어진다.
|
|
1871
2146
|
장부 원격: --remote 또는 AXMAP_REMOTE, 없으면 git config axmap.remote
|
|
1872
2147
|
`
|
|
1873
2148
|
|
|
@@ -1875,8 +2150,16 @@ const { positional, flags } = parseArgs(process.argv.slice(2))
|
|
|
1875
2150
|
const [cmd, ...rest] = positional
|
|
1876
2151
|
|
|
1877
2152
|
switch (cmd) {
|
|
2153
|
+
// 이름을 경로로 바꿔 주는 것들 (RUNNERS). 인자는 손대지 않고 그대로 간다.
|
|
1878
2154
|
case 'setup':
|
|
1879
|
-
|
|
2155
|
+
case 'gate':
|
|
2156
|
+
case 'vote':
|
|
2157
|
+
case 'bus':
|
|
2158
|
+
case 'mr-target':
|
|
2159
|
+
case 'version':
|
|
2160
|
+
case 'promote':
|
|
2161
|
+
case 'mcp':
|
|
2162
|
+
cmdRun(cmd)
|
|
1880
2163
|
break
|
|
1881
2164
|
case 'update':
|
|
1882
2165
|
// 이 파일은 ESM 이라 최상위 await 이 된다. update 만 비동기다 (레지스트리에 묻는다).
|