axmap-cli 1.0.0 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/axmap.mjs +162 -26
- package/mcp/server.mjs +6 -2
- package/package.json +1 -1
- package/tools/bus.mjs +531 -17
- package/.claude/settings.local.json +0 -32
package/bin/axmap.mjs
CHANGED
|
@@ -108,6 +108,24 @@ const LEDGER_REL = path.join('.axmap', 'ledger')
|
|
|
108
108
|
*/
|
|
109
109
|
const BUS_BRANCH = 'axmap/bus'
|
|
110
110
|
const BUS_REL = path.join('.axmap', 'bus')
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 사람별 상태가 사는 곳 — 어디까지 읽었는지, 이 사람이 쓰는 이름이 무엇무엇인지.
|
|
114
|
+
*
|
|
115
|
+
* 🔴 왜 쪽지함과 다른 브랜치인가. 쪽지 **본문**은 모두가 공유하는 한 벌이고,
|
|
116
|
+
* **어디까지 읽었는지**는 사람마다 다르다. 한 브랜치에 섞으면 쪽지를 읽기만
|
|
117
|
+
* 해도 공유 브랜치에 커밋이 쌓인다 — 읽는 행위가 남에게 보이는 흔적을 남기는
|
|
118
|
+
* 것은 쪽지함이 할 일이 아니다.
|
|
119
|
+
*
|
|
120
|
+
* 🔴 그리고 왜 각자 PC 의 파일이 아닌가. 지금까지 읽음 표시는 작업 폴더 안의
|
|
121
|
+
* 파일 하나였다. 그래서 **PC 를 바꾸면 전부 다시 안 읽음**이 됐다. 쪽지는
|
|
122
|
+
* 공유되는데 읽었다는 사실만 그 PC 에 갇혀 있었다.
|
|
123
|
+
*
|
|
124
|
+
* 파일은 사람마다 하나(`users/<이메일>.json`)다. 서로 다른 파일이라 여럿이
|
|
125
|
+
* 동시에 써도 부딪히지 않는다 — 쪽지가 파일 하나씩인 것과 같은 이유다.
|
|
126
|
+
*/
|
|
127
|
+
const USERS_BRANCH = 'axmap/users'
|
|
128
|
+
const USERS_REL = path.join('.axmap', 'users')
|
|
111
129
|
/**
|
|
112
130
|
* `release` 가 아무것도 반납하지 못했을 때. SPEC 8절.
|
|
113
131
|
* 0 이 아니어야 하는 이유는 그 주변 주석에 적혀 있다.
|
|
@@ -591,6 +609,15 @@ function busMessagesDir(root) {
|
|
|
591
609
|
return path.join(busDir(root), 'messages')
|
|
592
610
|
}
|
|
593
611
|
|
|
612
|
+
function usersDir(root) {
|
|
613
|
+
return path.join(root, USERS_REL)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** 사람마다의 파일이 쌓이는 곳. `.gitattributes` 와 섞이지 않게 한 폴더 아래 모은다. */
|
|
617
|
+
function usersFilesDir(root) {
|
|
618
|
+
return path.join(usersDir(root), 'users')
|
|
619
|
+
}
|
|
620
|
+
|
|
594
621
|
/**
|
|
595
622
|
* 옛 쪽지함. **읽기만 한다.**
|
|
596
623
|
*
|
|
@@ -1108,11 +1135,25 @@ function restoreClaimFile(file, prev) {
|
|
|
1108
1135
|
*
|
|
1109
1136
|
* @returns {'ready'|'created'|'failed'}
|
|
1110
1137
|
*/
|
|
1111
|
-
|
|
1112
|
-
|
|
1138
|
+
/**
|
|
1139
|
+
* 고아 브랜치(**코드 이력과 아무 조상도 공유하지 않는 별도 계보**) 하나를 만들고
|
|
1140
|
+
* 그것을 옆 폴더에 펼친다. 쪽지함과 사람별 상태가 **같은 코드**로 만들어진다.
|
|
1141
|
+
*
|
|
1142
|
+
* 🔴 이 함수가 있는 이유는 사본을 안 만들기 위해서다. 쪽지함 것을 복사해서
|
|
1143
|
+
* 사람 것을 만들면 두 벌이 되고, 두 벌은 반드시 어긋난다 — 이 저장소가 팀
|
|
1144
|
+
* 사본을 걷어낸 것과 같은 이유이고, 실제로 그 어긋남으로 쪽지가 묻혔다.
|
|
1145
|
+
*
|
|
1146
|
+
* @param what 실패 메시지에 쓸 사람 말 ("쪽지함" · "사람별 상태")
|
|
1147
|
+
* @param seed worktree 를 만든 직후 그 안을 채우는 함수. 브랜치마다 다른 유일한 곳
|
|
1148
|
+
*/
|
|
1149
|
+
function ensureOrphan(root, { branch, rel, dir, what, seed }) {
|
|
1150
|
+
const fail = (r) => {
|
|
1151
|
+
console.error(`경고: ${what}을(를) 준비하지 못했습니다 - ${(r.err || r.out || '').split('\n')[0]}`)
|
|
1152
|
+
return 'failed'
|
|
1153
|
+
}
|
|
1113
1154
|
if (fs.existsSync(path.join(dir, '.git'))) return 'ready'
|
|
1114
1155
|
if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
|
|
1115
|
-
console.error(`경고: ${
|
|
1156
|
+
console.error(`경고: ${rel} 가 있지만 ${what} worktree 가 아닙니다.\n 지운 뒤 다시 실행하세요: rm -rf ${rel}`)
|
|
1116
1157
|
return 'failed'
|
|
1117
1158
|
}
|
|
1118
1159
|
|
|
@@ -1122,54 +1163,107 @@ function ensureBus(root) {
|
|
|
1122
1163
|
const remote = r.source === 'ambiguous' ? null : r.name
|
|
1123
1164
|
|
|
1124
1165
|
let base = null
|
|
1125
|
-
if (remote && git(['fetch', '--quiet', remote,
|
|
1166
|
+
if (remote && git(['fetch', '--quiet', remote, branch], { cwd: root }).code === 0) {
|
|
1126
1167
|
base = git(['rev-parse', 'FETCH_HEAD'], { cwd: root }).out
|
|
1127
1168
|
} else {
|
|
1128
1169
|
// 빈 트리 -> 부모 없는 커밋 -> 브랜치. 작업 트리를 건드리지 않는 고아 브랜치.
|
|
1129
1170
|
const tree = git(['mktree'], { cwd: root, input: '' })
|
|
1130
|
-
if (tree.code !== 0) return
|
|
1131
|
-
const c = git(['commit-tree', tree.out, '-m',
|
|
1132
|
-
if (c.code !== 0) return
|
|
1171
|
+
if (tree.code !== 0) return fail(tree)
|
|
1172
|
+
const c = git(['commit-tree', tree.out, '-m', `axmap: ${branch} init`], { cwd: root })
|
|
1173
|
+
if (c.code !== 0) return fail(c)
|
|
1133
1174
|
base = c.out
|
|
1134
1175
|
}
|
|
1135
1176
|
|
|
1136
|
-
if (git(['rev-parse', '--verify', '--quiet',
|
|
1137
|
-
const b = git(['branch',
|
|
1138
|
-
if (b.code !== 0) return
|
|
1177
|
+
if (git(['rev-parse', '--verify', '--quiet', branch], { cwd: root }).code !== 0) {
|
|
1178
|
+
const b = git(['branch', branch, base], { cwd: root })
|
|
1179
|
+
if (b.code !== 0) return fail(b)
|
|
1139
1180
|
}
|
|
1140
|
-
const w = git(['worktree', 'add', '--quiet', dir,
|
|
1141
|
-
if (w.code !== 0) return
|
|
1181
|
+
const w = git(['worktree', 'add', '--quiet', dir, branch], { cwd: root })
|
|
1182
|
+
if (w.code !== 0) return fail(w)
|
|
1142
1183
|
|
|
1143
|
-
|
|
1144
|
-
fs.mkdirSync(busMessagesDir(root), { recursive: true })
|
|
1145
|
-
fs.writeFileSync(path.join(busMessagesDir(root), '.gitkeep'), '')
|
|
1184
|
+
seed(dir)
|
|
1146
1185
|
|
|
1147
1186
|
// 🔴 고아 브랜치는 **자기 트리의 `.gitattributes` 만** 본다. 저장소 루트에
|
|
1148
1187
|
// 있는 것은 여기 안 닿으므로, 심어 두지 않으면 Windows(`core.autocrlf=true`)
|
|
1149
|
-
// 에서
|
|
1188
|
+
// 에서 파일이 CRLF 로 체크아웃된다. 2026-08-26 에 실제로 그랬고, 머리말
|
|
1150
1189
|
// 파서가 `\r` 에 걸려 **쪽지가 목록에서 조용히 사라졌다.**
|
|
1151
1190
|
// 파서도 함께 고쳤지만(`tools/bus.mjs`) 바이트가 플랫폼마다 달라지는 것
|
|
1152
1191
|
// 자체를 막는 편이 낫다 — 해시도, diff 도, 파서도 전부 같은 것을 본다.
|
|
1153
1192
|
fs.writeFileSync(
|
|
1154
1193
|
path.join(dir, '.gitattributes'),
|
|
1155
|
-
'#
|
|
1194
|
+
'# 이 브랜치의 파일은 어느 OS 에서 만들어도 같은 바이트여야 한다.\n' +
|
|
1156
1195
|
'# 저장소 루트의 .gitattributes 는 고아 브랜치에 닿지 않으므로 여기 따로 둔다.\n' +
|
|
1157
1196
|
'* text=auto eol=lf\n',
|
|
1158
1197
|
)
|
|
1159
1198
|
|
|
1160
1199
|
if (remote) {
|
|
1161
1200
|
git(['add', '-A'], { cwd: dir })
|
|
1162
|
-
// --no-verify: 연결된 worktree 는 훅을 공유한다.
|
|
1163
|
-
git(['commit', '--quiet', '--no-verify', '-m',
|
|
1164
|
-
const p = git(['push', '--quiet', remote, `HEAD:${
|
|
1165
|
-
if (p.code !== 0) console.error(`경고:
|
|
1201
|
+
// --no-verify: 연결된 worktree 는 훅을 공유한다. 이 브랜치는 사용자 코드가 아니다.
|
|
1202
|
+
git(['commit', '--quiet', '--no-verify', '-m', `axmap: ${branch} init`], { cwd: dir })
|
|
1203
|
+
const p = git(['push', '--quiet', remote, `HEAD:${branch}`], { cwd: dir })
|
|
1204
|
+
if (p.code !== 0) console.error(`경고: ${what} push 실패 - ${(p.err ?? '').split('\n')[0]}`)
|
|
1166
1205
|
}
|
|
1167
1206
|
return 'created'
|
|
1168
1207
|
}
|
|
1169
1208
|
|
|
1170
|
-
function
|
|
1171
|
-
|
|
1172
|
-
|
|
1209
|
+
function ensureBus(root) {
|
|
1210
|
+
return ensureOrphan(root, {
|
|
1211
|
+
branch: BUS_BRANCH, rel: BUS_REL, dir: busDir(root), what: '쪽지함',
|
|
1212
|
+
seed: () => {
|
|
1213
|
+
// git 은 빈 디렉터리를 추적하지 않는다. 쪽지가 0개인 동안에도 폴더가 살아 있게 한다.
|
|
1214
|
+
fs.mkdirSync(busMessagesDir(root), { recursive: true })
|
|
1215
|
+
fs.writeFileSync(path.join(busMessagesDir(root), '.gitkeep'), '')
|
|
1216
|
+
},
|
|
1217
|
+
})
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function ensureUsers(root) {
|
|
1221
|
+
return ensureOrphan(root, {
|
|
1222
|
+
branch: USERS_BRANCH, rel: USERS_REL, dir: usersDir(root), what: '사람별 상태',
|
|
1223
|
+
seed: () => {
|
|
1224
|
+
fs.mkdirSync(usersFilesDir(root), { recursive: true })
|
|
1225
|
+
fs.writeFileSync(path.join(usersFilesDir(root), '.gitkeep'), '')
|
|
1226
|
+
},
|
|
1227
|
+
})
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* 쪽지함만 준비한다. **장부는 건드리지 않는다.**
|
|
1232
|
+
*
|
|
1233
|
+
* 🔴 왜 `init` 을 부르지 않고 이 명령이 따로 있는가. 쪽지를 **읽는** 길에서
|
|
1234
|
+
* 부를 자리가 필요해서다. `init` 은 장부(`axmap/claims`)까지 만들고 그것을
|
|
1235
|
+
* 원격에 push 한다 — "내 쪽지 좀 보자" 가 남의 저장소에 **선점 장부**를
|
|
1236
|
+
* 만드는 일이 되면 안 된다. 쪽지 브랜치는 쪽지함이 원래 사는 곳이므로
|
|
1237
|
+
* 거기까지는 간다. 선을 긋는 자리는 "읽으려는 그것" 과 "그 밖의 것" 사이다.
|
|
1238
|
+
*
|
|
1239
|
+
* 그래서 `tools/bus.mjs` 는 쪽지함이 없을 때 이 명령을 부른다. 만드는 코드를
|
|
1240
|
+
* 저쪽에 복사하지 않는 이유는 사본이 곧 두 벌이고, 두 벌은 반드시 어긋나기
|
|
1241
|
+
* 때문이다 — 이 저장소가 팀 사본을 걷어낸 것과 같은 이유다.
|
|
1242
|
+
*/
|
|
1243
|
+
function cmdBusRepair() {
|
|
1244
|
+
const root = repoRoot()
|
|
1245
|
+
// .axmap 을 손으로 지웠어도 git 쪽에 worktree 등록이 남아 add 가 실패한다.
|
|
1246
|
+
git(['worktree', 'prune'], { cwd: root })
|
|
1247
|
+
const r = ensureBus(root)
|
|
1248
|
+
if (r === 'failed') process.exit(1)
|
|
1249
|
+
console.log(
|
|
1250
|
+
r === 'ready'
|
|
1251
|
+
? `쪽지함이 이미 있습니다: ${BUS_REL}`
|
|
1252
|
+
: `쪽지함을 만들었습니다: ${BUS_REL} (${BUS_BRANCH})`,
|
|
1253
|
+
)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** 사람별 상태만 준비한다. `bus-repair` 와 같은 이유로 따로 있다. */
|
|
1257
|
+
function cmdUsersRepair() {
|
|
1258
|
+
const root = repoRoot()
|
|
1259
|
+
git(['worktree', 'prune'], { cwd: root })
|
|
1260
|
+
const r = ensureUsers(root)
|
|
1261
|
+
if (r === 'failed') process.exit(1)
|
|
1262
|
+
console.log(
|
|
1263
|
+
r === 'ready'
|
|
1264
|
+
? `사람별 상태가 이미 있습니다: ${USERS_REL}`
|
|
1265
|
+
: `사람별 상태를 만들었습니다: ${USERS_REL} (${USERS_BRANCH})`,
|
|
1266
|
+
)
|
|
1173
1267
|
}
|
|
1174
1268
|
|
|
1175
1269
|
function cmdInit(flags = {}) {
|
|
@@ -1186,6 +1280,7 @@ function cmdInit(flags = {}) {
|
|
|
1186
1280
|
// 돌린 사람은 장부만 있고 쪽지함이 없다. 그 사람들이 다시 init 을 불렀을 때
|
|
1187
1281
|
// 받아 가는 자리가 여기다.
|
|
1188
1282
|
if (ensureBus(root) === 'created') console.log(`쪽지함을 만들었습니다: ${BUS_REL} (${BUS_BRANCH})`)
|
|
1283
|
+
if (ensureUsers(root) === 'created') console.log(`사람별 상태를 만들었습니다: ${USERS_REL} (${USERS_BRANCH})`)
|
|
1189
1284
|
return
|
|
1190
1285
|
}
|
|
1191
1286
|
if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
|
|
@@ -1262,8 +1357,9 @@ function cmdInit(flags = {}) {
|
|
|
1262
1357
|
}
|
|
1263
1358
|
|
|
1264
1359
|
ensureBus(root)
|
|
1360
|
+
ensureUsers(root)
|
|
1265
1361
|
|
|
1266
|
-
console.log(`준비 완료. 장부 ${LEDGER_BRANCH} · 쪽지함 ${BUS_BRANCH}`)
|
|
1362
|
+
console.log(`준비 완료. 장부 ${LEDGER_BRANCH} · 쪽지함 ${BUS_BRANCH} · 사람별 상태 ${USERS_BRANCH}`)
|
|
1267
1363
|
}
|
|
1268
1364
|
|
|
1269
1365
|
// ---------------------------------------------------------------------------
|
|
@@ -1664,7 +1760,16 @@ function collectCodeCommits(root, range) {
|
|
|
1664
1760
|
for (const line of log.out ? log.out.split(NL) : []) {
|
|
1665
1761
|
if (!line) continue
|
|
1666
1762
|
const [sha, author, ct, subject] = line.split(NUL)
|
|
1667
|
-
|
|
1763
|
+
// 🔴 `-c core.quotepath=false` 가 없으면 **한글 경로가 claim 과 안 맞는다.**
|
|
1764
|
+
// git 은 기본으로 ASCII 밖 글자를 역슬래시 + 8진수 세 자리로 감싸 내보낸다.
|
|
1765
|
+
// 그 문자열은 장부에 적힌 경로와 절대 같아질 수 없고, 검사는 "안 겹친다" 로
|
|
1766
|
+
// **조용히 통과**한다 - 락에서 최악인 fail-open 이다.
|
|
1767
|
+
// 팀 CI 가 잡 안에서 `git config core.quotepath false` 로 막고 있었다.
|
|
1768
|
+
// 우회는 그 잡에만 걸린다. 부르는 자리에서 못을 박는다 (verify 와 같은 방식).
|
|
1769
|
+
const files = git(
|
|
1770
|
+
['-c', 'core.quotepath=false', 'show', '--no-renames', '--name-only', '--format=', sha],
|
|
1771
|
+
{ cwd: root },
|
|
1772
|
+
).out
|
|
1668
1773
|
out.push({
|
|
1669
1774
|
sha,
|
|
1670
1775
|
author,
|
|
@@ -2186,6 +2291,9 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
|
|
|
2186
2291
|
axmap setup 이 PC 와 이 저장소에 axMap 을 붙인다 (첫 한 번)
|
|
2187
2292
|
[--remote <이름|git 주소>] [--name] [--dry-run]
|
|
2188
2293
|
axmap init 장부(고아 브랜치 + worktree)를 준비한다
|
|
2294
|
+
axmap bus-repair 쪽지함만 준비한다 (장부는 안 건드린다)
|
|
2295
|
+
쪽지를 읽다가 "쪽지함을 못 열었습니다" 를 봤을 때
|
|
2296
|
+
axmap users-repair 사람별 상태(어디까지 읽었나)만 준비한다
|
|
2189
2297
|
axmap claim <경로...> 경로를 선점한다 [--task --intent --ttl 30m]
|
|
2190
2298
|
axmap release [경로...] 반납한다 (경로 생략 시 전부)
|
|
2191
2299
|
axmap renew TTL 을 연장한다 [--ttl 30m]
|
|
@@ -2197,6 +2305,7 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
|
|
|
2197
2305
|
axmap doctor 이 PC 에서 선점이 실제로 도는지 점검 [--json]
|
|
2198
2306
|
axmap hook install verify 를 pre-commit 훅으로 설치
|
|
2199
2307
|
axmap update 새 버전이 있는지 묻는다 (바꾸지는 않는다)
|
|
2308
|
+
axmap --version 지금 도는 판 번호를 찍는다 (아래 version 명령과 다르다)
|
|
2200
2309
|
|
|
2201
2310
|
딸린 프로그램 — 예전에는 파일 경로로 불렀다. 이제 이름으로 부른다.
|
|
2202
2311
|
인자는 그대로 전달되고, 각각의 사용법은 그 프로그램이 답한다.
|
|
@@ -2219,6 +2328,27 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
|
|
|
2219
2328
|
const { positional, flags } = parseArgs(process.argv.slice(2))
|
|
2220
2329
|
const [cmd, ...rest] = positional
|
|
2221
2330
|
|
|
2331
|
+
/**
|
|
2332
|
+
* 🔴 `--version` 은 명령이 아니라 **깃발**이라 아래 switch 에 안 걸린다.
|
|
2333
|
+
* 그대로 default 로 떨어져 **도움말을 찍고 종료 코드 0** 을 냈다. 팀 CI 가
|
|
2334
|
+
* "어느 판이 돌았는지" 를 남기려고 넣은 줄이 조용히 쓸모없어졌고, 0 이라
|
|
2335
|
+
* 아무도 못 알아챘다 (실측 2026-09-01, 팀 저장소 .gitlab-ci.yml eb3b56d).
|
|
2336
|
+
*
|
|
2337
|
+
* 🔴 `version` **명령과는 다른 것을 답한다.** 저쪽(tools/version.mjs)은
|
|
2338
|
+
* `process.cwd()` 의 git 태그를 읽는 도구라, 남의 저장소에서 부르면 그
|
|
2339
|
+
* 저장소의 버전이 나온다. 여기가 답해야 하는 것은 "지금 도는 axMap 이 몇
|
|
2340
|
+
* 판인가" 이고, 그 답은 설치본 자기 package.json 에만 있다.
|
|
2341
|
+
*
|
|
2342
|
+
* 못 읽으면 죽는다. 모르는 것을 빈 줄로 내면 CI 로그에는 "판을 확인했다" 는
|
|
2343
|
+
* 흔적만 남고 실제로는 아무것도 확인되지 않는다.
|
|
2344
|
+
*/
|
|
2345
|
+
if (boolFlag(flags.version, 'version') && !cmd) {
|
|
2346
|
+
const v = selfVersion()
|
|
2347
|
+
if (!v) die('버전을 알 수 없습니다 - package.json 이 없는 사본입니다.')
|
|
2348
|
+
console.log(v)
|
|
2349
|
+
process.exit(0)
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2222
2352
|
switch (cmd) {
|
|
2223
2353
|
// 이름을 경로로 바꿔 주는 것들 (RUNNERS). 인자는 손대지 않고 그대로 간다.
|
|
2224
2354
|
case 'setup':
|
|
@@ -2238,6 +2368,12 @@ switch (cmd) {
|
|
|
2238
2368
|
case 'init':
|
|
2239
2369
|
cmdInit(flags)
|
|
2240
2370
|
break
|
|
2371
|
+
case 'bus-repair':
|
|
2372
|
+
cmdBusRepair()
|
|
2373
|
+
break
|
|
2374
|
+
case 'users-repair':
|
|
2375
|
+
cmdUsersRepair()
|
|
2376
|
+
break
|
|
2241
2377
|
case 'claim':
|
|
2242
2378
|
cmdClaim(rest, flags)
|
|
2243
2379
|
break
|
package/mcp/server.mjs
CHANGED
|
@@ -460,7 +460,7 @@ const TOOLS = [
|
|
|
460
460
|
inputSchema: {
|
|
461
461
|
type: 'object',
|
|
462
462
|
properties: {
|
|
463
|
-
to: { type: 'string', description: '받는
|
|
463
|
+
to: { type: 'string', description: '받는 사람. 이름도 이메일도 됩니다. 여럿이면 쉼표로 (예: "bob,carol"). 생략하면 모두에게.' },
|
|
464
464
|
subject: { type: 'string', description: '한 줄 제목' },
|
|
465
465
|
body: { type: 'string', description: '본문 (마크다운)' },
|
|
466
466
|
},
|
|
@@ -878,7 +878,11 @@ function dispatch(name, args = {}) {
|
|
|
878
878
|
case 'ax_send': {
|
|
879
879
|
if (!args.subject || !args.body) return { ok: false, text: 'subject 와 body 가 필요합니다.' }
|
|
880
880
|
const a = ['post', '--subject', String(args.subject)]
|
|
881
|
-
|
|
881
|
+
// 여럿에게 한 통을 보낼 수 있다. 문자열로 `"a,b"` 를 줘도, 배열로 줘도 같다 —
|
|
882
|
+
// 같은 내용을 두 번 보내면 답장이 두 갈래로 갈려 대화가 쪼개진다.
|
|
883
|
+
for (const t of (Array.isArray(args.to) ? args.to : (args.to ? [args.to] : []))) {
|
|
884
|
+
if (String(t).trim()) a.push('--to', String(t).trim())
|
|
885
|
+
}
|
|
882
886
|
const r = bus(a, String(args.body))
|
|
883
887
|
return {
|
|
884
888
|
ok: r.code === 0,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//name": "🔴 npm 의 'axmap' 은 2022년부터 남이 쓰고 있는 다른 꾸러미다(leozin_ 의 Map 라이브러리, 1.1.2). 그래서 `npx axmap` 은 우리 것이 아니고, 누구에게도 그렇게 안내하면 안 된다. 2026-08-28 에 axmap-cli 로 정했다 — 스코프(@사용자/이름)를 쓰지 않는 이유는 스코프가 npm 계정 이름에 묶여서 나중에 팀이나 조직으로 옮길 때 이름이 통째로 바뀌기 때문이다. 사람이 치는 **명령** 이름은 그대로 axmap 이다 (아래 bin) — 꾸러미 이름과 명령 이름은 달라도 된다.",
|
|
3
3
|
"name": "axmap-cli",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.3",
|
|
5
5
|
"//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
|
|
6
6
|
"//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
|
|
7
7
|
"publishConfig": {
|
package/tools/bus.mjs
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* 에 한 줄로 적는 편이 빠르다 — 그건 `axmap status` 로 바로 보인다.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import { execFileSync } from 'node:child_process'
|
|
33
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
34
34
|
import fs from 'node:fs'
|
|
35
35
|
import path from 'node:path'
|
|
36
36
|
import { fileURLToPath } from 'node:url'
|
|
@@ -114,6 +114,79 @@ function gitBus(argv, opts = {}) {
|
|
|
114
114
|
|
|
115
115
|
const busReady = () => BUS_WT !== null && fs.existsSync(path.join(BUS_WT, '.git'))
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* 쪽지함을 못 연 이유. `null` 이면 정상이다.
|
|
119
|
+
*
|
|
120
|
+
* 🔴 이 변수가 있는 이유는 하나다 — **"쪽지 없음" 과 "못 읽었음" 을 다른 문장으로
|
|
121
|
+
* 내기 위해서다.**
|
|
122
|
+
*
|
|
123
|
+
* 2026-09-03 실측: 원격 `axmap/bus` 에 쪽지 129건이 있고 그중 오늘 온 것이
|
|
124
|
+
* 있는데, 이 체크아웃에 worktree 가 없어서 `pull()` 이 **fetch 를 한 번도 안
|
|
125
|
+
* 하고 조용히 반환**했다. 읽는 자리는 옛 함(`docs/bus`)으로 떨어졌고 거기 있던
|
|
126
|
+
* 8월 것은 이미 읽음이라 화면에 나온 답은 `쪽지 없음.` 이었다. 경고도 없었다.
|
|
127
|
+
*
|
|
128
|
+
* **보내기는 같은 상황에서 제대로 거부했다** (아래 `post`). 쓰기는 막고 읽기만
|
|
129
|
+
* 통과시킨 것이다. 락 시스템에서 최악의 실패는 거부해야 할 것을 조용히
|
|
130
|
+
* 통과시키는 것이고(CLAUDE.md 「애매하면 거부한다」), 쪽지함에서는 **못 읽은
|
|
131
|
+
* 것을 "없다" 고 답하는 것**이 바로 그 자리다. 잘못 막으면 사람이 메시지를
|
|
132
|
+
* 읽고 고치면 되지만, 조용히 없다고 하면 아무도 잘못됐다는 것을 모른다.
|
|
133
|
+
*/
|
|
134
|
+
let busProblem = null
|
|
135
|
+
|
|
136
|
+
/** 옛 함(`docs/bus`)에서만 읽었는가. 새 함을 못 연 채로 성공한 것처럼 보이지 않게 한다. */
|
|
137
|
+
let readFromLegacyOnly = false
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 쪽지함을 연다. **없으면 만든다. 만들었으면 만들었다고 말한다.**
|
|
141
|
+
*
|
|
142
|
+
* 🔴 만드는 코드를 여기에 복사하지 않는다. `bin/axmap.mjs` 의 `ensureBus` 하나뿐이고
|
|
143
|
+
* 이 함수는 그것을 `bus-repair` 로 부른다. 사본을 두면 두 벌이 되고, 두 벌은
|
|
144
|
+
* 반드시 어긋난다 — 이 저장소가 팀 사본을 걷어낸 것과 같은 이유다.
|
|
145
|
+
*
|
|
146
|
+
* 🔴 만들고 나서 **조용히 넘어가지 않는다.** 이 버그의 본질이 "말없이 넘어간 것"
|
|
147
|
+
* 이라, 자동으로 만들어 놓고 또 말없이 넘어가면 증상만 다른 같은 병이 된다.
|
|
148
|
+
* stdout 이 아니라 stderr 로 낸다 — 목록을 파이프로 넘겨도 사람 눈에는 남는다.
|
|
149
|
+
*/
|
|
150
|
+
function openBus() {
|
|
151
|
+
if (process.env.AXMAP_BUS_DIR) return true // 대상이 명시됐으면 물을 것이 없다
|
|
152
|
+
if (busReady()) return true
|
|
153
|
+
if (BUS_WT === null) {
|
|
154
|
+
busProblem = '이 저장소를 찾지 못했습니다.'
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const r = spawnSync(process.execPath, [path.join(ROOT, 'bin', 'axmap.mjs'), 'bus-repair'], {
|
|
159
|
+
cwd: REPO, encoding: 'utf8', windowsHide: true,
|
|
160
|
+
})
|
|
161
|
+
if (busReady()) {
|
|
162
|
+
console.error(`쪽지함이 없어서 만들었습니다: ${path.join('.axmap', 'bus')} (${BUS_BRANCH})`)
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
busProblem = (r.stderr ?? '').trim() || (r.stdout ?? '').trim() || '알 수 없는 이유로 실패했습니다.'
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 쪽지함을 못 열었다는 것을 사람이 볼 수 있게 낸다.
|
|
171
|
+
*
|
|
172
|
+
* 🔴 **"없음" 이라고 말하지 않는다.** 받은 쪽지가 없는 것이 아니라 확인을 못 한
|
|
173
|
+
* 것이고, 둘은 사람이 해야 할 일이 정반대다.
|
|
174
|
+
*/
|
|
175
|
+
function reportBusProblem() {
|
|
176
|
+
// 🔴 순서가 뜻이다. **무슨 일인지 먼저, 이유는 그다음.** 이유부터 내면 그것이
|
|
177
|
+
// 여러 줄일 때 안내가 아래로 밀려서, 급한 사람이 첫 줄만 보고 넘어간다.
|
|
178
|
+
const why = busProblem
|
|
179
|
+
.replace(/^경고: /, '')
|
|
180
|
+
.split('\n')
|
|
181
|
+
.map((s, i) => (i === 0 ? s : ` ${s.trim()}`))
|
|
182
|
+
.join('\n')
|
|
183
|
+
console.error(
|
|
184
|
+
'쪽지함을 못 열었습니다 — 받은 쪽지가 없는 것이 아니라 확인을 못 한 것입니다.\n' +
|
|
185
|
+
`\n 이유: ${why}\n` +
|
|
186
|
+
'\n 고치기: axmap bus-repair (MCP 에서는 ax_init)',
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
117
190
|
/**
|
|
118
191
|
* 원격의 쪽지를 받아온다. **실패해도 죽지 않는다** — 못 받은 것은 위험이 아니라
|
|
119
192
|
* 지연이다. 장부(`syncLedger`)가 같은 자리에서 죽는 것과 정반대이고, 그 차이의
|
|
@@ -129,7 +202,10 @@ const busReady = () => BUS_WT !== null && fs.existsSync(path.join(BUS_WT, '.git'
|
|
|
129
202
|
* 쪽지 worktree(`.axmap/bus`) **바깥**이라 고아 브랜치에 섞이지 않는다.
|
|
130
203
|
*/
|
|
131
204
|
function pull() {
|
|
132
|
-
if (!busReady()) return
|
|
205
|
+
// 🔴 예전에는 여기가 `if (!busReady()) return` 이었다. 쪽지함이 없으면 **fetch 를
|
|
206
|
+
// 한 번도 안 하고 조용히 반환**했고, 그것이 "쪽지 없음" 으로 보였다.
|
|
207
|
+
// 이제는 열어 보고, 못 열면 그 사실을 `busProblem` 에 남긴다.
|
|
208
|
+
if (!openBus()) return
|
|
133
209
|
// 🔴 주기는 **설정에서 온다.** `--throttle` 이 1순위(부르는 쪽이 그 자리에서
|
|
134
210
|
// 정한다), 없으면 `AXMAP_BUS_POLL`(초), 그것도 없으면 0 = 매번 받아온다.
|
|
135
211
|
//
|
|
@@ -159,6 +235,30 @@ const cmd = args[0]
|
|
|
159
235
|
const flag = (n, d = null) => { const i = args.indexOf(n); return i < 0 ? d : args[i + 1] }
|
|
160
236
|
const has = (n) => args.includes(n)
|
|
161
237
|
|
|
238
|
+
/**
|
|
239
|
+
* 같은 이름의 옵션이 여러 번 와도 전부 모은다. `flag` 는 첫 번째만 낸다.
|
|
240
|
+
*
|
|
241
|
+
* 🔴 쪽지 216통을 세어 보니 **같은 제목이 초 단위로 두 번씩 나간 것**이 여러
|
|
242
|
+
* 건이었다. 같은 내용을 두 사람에게 보내려면 두 번 보내는 수밖에 없어서다.
|
|
243
|
+
* 그러면 답장이 두 갈래로 갈려 대화가 쪼개지고, 나중에 읽는 사람은 어느
|
|
244
|
+
* 쪽이 이어진 이야기인지 모른다. 그래서 사람들이 관계없는 사람까지 읽는
|
|
245
|
+
* 전체공지로 도망쳤다 — 48통이 그렇게 나갔다.
|
|
246
|
+
*/
|
|
247
|
+
const flagAll = (n) => {
|
|
248
|
+
const out = []
|
|
249
|
+
for (let i = 0; i < args.length; i++) if (args[i] === n && args[i + 1] != null) out.push(args[i + 1])
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 받는 사람 목록. `--to a --to b` 도, `--to "a,b"` 도 같은 것으로 본다. */
|
|
254
|
+
const recipientArgs = () => flagAll('--to')
|
|
255
|
+
.flatMap((s) => String(s).split(','))
|
|
256
|
+
.map((s) => s.trim())
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
|
|
259
|
+
/** 쪽지 하나의 받는 사람들. 예전 쪽지는 값이 하나라 그대로 한 개짜리 목록이 된다. */
|
|
260
|
+
const recipientsOf = (m) => String(m.to ?? 'all').split(',').map(norm).filter(Boolean)
|
|
261
|
+
|
|
162
262
|
/**
|
|
163
263
|
* 나는 누구인가. 선점 프로토콜과 **같은 값**을 쓴다 — 두 이름을 두면 갈린다.
|
|
164
264
|
*
|
|
@@ -191,6 +291,186 @@ function me() {
|
|
|
191
291
|
process.exit(1)
|
|
192
292
|
}
|
|
193
293
|
|
|
294
|
+
/**
|
|
295
|
+
* 사람의 주소는 **이메일**이다. 이름은 그 사람을 부르는 여러 별칭 중 하나일 뿐이다.
|
|
296
|
+
*
|
|
297
|
+
* 🔴 왜 이름이 아니라 이메일인가. 실측(2026-09-05, 팀 저장소 최근 300커밋):
|
|
298
|
+
*
|
|
299
|
+
* 사람 6명 → git 이름 11개 → git 이메일 6개
|
|
300
|
+
*
|
|
301
|
+
* 한 사람이 `yeaseung lee` · `yeaseung-lee` · `이예승` 셋으로 커밋한다.
|
|
302
|
+
* **이름은 갈리는데 이메일은 안 갈린다** — 표기는 바꿔도 이메일은 안 바꾼다.
|
|
303
|
+
*
|
|
304
|
+
* 그래서 이름을 주소로 쓰면, 보낸 쪽은 "보냈습니다" 를 보고 받는 쪽은
|
|
305
|
+
* "쪽지 없음" 을 본다. **양쪽 다 오류가 없어서 아무도 실패를 못 본다.**
|
|
306
|
+
* 실제로 216통 중 존재하지 않는 이름으로 간 것이 1통 있었고, 보낸 사람이
|
|
307
|
+
* 13분 뒤에 눈치채고 다시 보냈다.
|
|
308
|
+
*
|
|
309
|
+
* 그리고 이 저장소는 **이미 한쪽에서 옳게 하고 있었다** — 투표권자 명단
|
|
310
|
+
* (`governance/policy.json`)은 처음부터 이메일로 사람을 가른다. 쪽지함과
|
|
311
|
+
* 선점만 이름을 썼다. 한 저장소가 두 개의 주소 체계를 갖고 있던 것이다.
|
|
312
|
+
*/
|
|
313
|
+
const norm = (s) => String(s ?? '').trim().toLowerCase()
|
|
314
|
+
|
|
315
|
+
/** 이 저장소를 쓰는 사람들. `{ email, names:Set }` 의 목록. 한 프로세스에서 한 번만 센다. */
|
|
316
|
+
let rosterCache = null
|
|
317
|
+
|
|
318
|
+
function roster() {
|
|
319
|
+
if (rosterCache) return rosterCache
|
|
320
|
+
const byEmail = new Map()
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 🔴 **이름 하나는 사람 하나에게만 붙는다. 먼저 붙은 쪽이 이긴다.**
|
|
324
|
+
*
|
|
325
|
+
* 이 줄이 없으면 어긋난 쪽지 한 통이 두 사람을 영구히 합친다. 실제로 그랬다 —
|
|
326
|
+
* 도구 이름은 `alice` 인데 git 이메일이 `bob@x.com` 인 쪽지가 하나 있었고,
|
|
327
|
+
* 그 뒤로 `alice` 가 bob 의 이름이 됐다. 그러면 bob 은 alice 가 보낸 쪽지를
|
|
328
|
+
* **자기가 보낸 것으로 보고 안 읽음에서 지운다.**
|
|
329
|
+
*
|
|
330
|
+
* 이름이 어긋나 쪽지가 묻히는 것이 이 판에서 고치려던 바로 그 증상인데,
|
|
331
|
+
* 신원을 합치는 방식으로 고치면 **자리만 바꿔서 되살아난다.**
|
|
332
|
+
*
|
|
333
|
+
* 순서가 곧 우선순위다: git 이력(1) → 쪽지 머리말(2) → 적어 둔 별칭(3).
|
|
334
|
+
* git 이력이 가장 믿을 만하다 — 사람이 자기 PC 에 직접 설정한 값이다.
|
|
335
|
+
*/
|
|
336
|
+
const nameOwner = new Map()
|
|
337
|
+
const add = (email, name) => {
|
|
338
|
+
const e = norm(email)
|
|
339
|
+
if (!e || !e.includes('@')) return
|
|
340
|
+
if (!byEmail.has(e)) byEmail.set(e, { email: e, names: new Set() })
|
|
341
|
+
const n = norm(name)
|
|
342
|
+
if (!n) return
|
|
343
|
+
const owner = nameOwner.get(n)
|
|
344
|
+
if (owner && owner !== e) return // 이미 남의 이름이다. 합치지 않는다
|
|
345
|
+
nameOwner.set(n, e)
|
|
346
|
+
byEmail.get(e).names.add(n)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 1) git 이력 — 이 저장소에서 실제로 일한 사람이 곧 명단이다. 별도 파일도,
|
|
350
|
+
// 별도 브랜치도 필요 없다. **아무 저장소에서나 성립한다**는 것이 중요하다.
|
|
351
|
+
try {
|
|
352
|
+
const out = execFileSync('git', ['log', '--all', '--format=%ae\t%an', '-n', '5000'], {
|
|
353
|
+
cwd: REPO ?? process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
354
|
+
})
|
|
355
|
+
for (const line of out.split('\n')) {
|
|
356
|
+
const [e, n] = line.split('\t')
|
|
357
|
+
add(e, n)
|
|
358
|
+
}
|
|
359
|
+
} catch { /* 저장소가 아니거나 이력이 없다. 아래 2)로 채운다 */ }
|
|
360
|
+
|
|
361
|
+
// 2) 쪽지를 보낸 적이 있는 사람. 커밋은 없지만 쪽지는 쓴 사람이 실제로 있다
|
|
362
|
+
// (기획·디자인). 여기서 안 주우면 그 사람에게는 답장을 못 보낸다.
|
|
363
|
+
try {
|
|
364
|
+
for (const m of readAll()) if (m.fromEmail) add(m.fromEmail, m.from)
|
|
365
|
+
} catch { /* 쪽지함을 못 열었을 뿐이다. 1)만으로도 명단은 선다 */ }
|
|
366
|
+
|
|
367
|
+
// 3) 적혀 있는 별칭. **git 이 모르는 이름이 여기 들어온다.**
|
|
368
|
+
//
|
|
369
|
+
// 🔴 여기서 받아오는 것이 중요하다. 예전에는 안 읽음 표시를 볼 때만 받아왔고,
|
|
370
|
+
// 명단은 그 자리를 안 거쳤다. 그래서 **별칭 앞으로 온 쪽지가 그 사람에게
|
|
371
|
+
// 안 갔다** — 보낸 쪽 PC 에는 별칭이 있고 받는 쪽 PC 에는 없었기 때문이다.
|
|
372
|
+
// 이름이 어긋나서 쪽지가 묻히는 것, 정확히 고치려던 그 증상이 자리만
|
|
373
|
+
// 바꿔서 되살아난 것이었다.
|
|
374
|
+
pullUsers()
|
|
375
|
+
//
|
|
376
|
+
// 🔴 실측에서 커밋 로그에 없는 이름이 넷 있었다 — codex-jinmiri · jaehyeon-2 ·
|
|
377
|
+
// codex · rleaderjoon-desktop. 사람이 아니라 그 사람이 띄운 AI 도구이거나
|
|
378
|
+
// 다른 PC 다. git 은 이것을 알 방법이 없으므로 적어 두는 자리가 필요하다.
|
|
379
|
+
try {
|
|
380
|
+
const dir = USERS_WT ? path.join(USERS_WT, 'users') : null
|
|
381
|
+
for (const f of (dir ? fs.readdirSync(dir) : [])) {
|
|
382
|
+
if (!f.endsWith('.json')) continue
|
|
383
|
+
const st = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
|
|
384
|
+
const email = f.replace(/\.json$/, '')
|
|
385
|
+
add(email, st.name)
|
|
386
|
+
for (const a of (st.aliases ?? [])) add(email, a)
|
|
387
|
+
}
|
|
388
|
+
} catch { /* 사람별 상태가 아직 없다. 1)·2)만으로도 명단은 선다 */ }
|
|
389
|
+
|
|
390
|
+
rosterCache = [...byEmail.values()]
|
|
391
|
+
return rosterCache
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 이 사람을 가리키는 **모든 주소**. 이름으로 물어도 이메일로 물어도 같은 답이 온다.
|
|
396
|
+
*
|
|
397
|
+
* 명단에 없으면 물어본 것 그대로 한 개짜리 집합을 낸다 — 모르는 사람을 아는 척
|
|
398
|
+
* 하지 않는다. 그 판단은 부르는 쪽(`post`)이 한다.
|
|
399
|
+
*/
|
|
400
|
+
function addressesOf(who) {
|
|
401
|
+
const w = norm(who)
|
|
402
|
+
const p = roster().find((x) => x.email === w || x.names.has(w))
|
|
403
|
+
return p ? new Set([p.email, ...p.names]) : new Set([w])
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 아는 주소 중 이것과 **거의 같은 것**들. 오타를 잡기 위한 것이지 검색이 아니다.
|
|
408
|
+
*
|
|
409
|
+
* 두 가지만 본다.
|
|
410
|
+
* 1. 한쪽이 다른 쪽의 앞부분이다 — `ahwlstjd` / `ahwlstjd57` (실제로 난 오타)
|
|
411
|
+
* 2. 글자 **하나** 차이다 — 손가락이 미끄러진 것
|
|
412
|
+
*
|
|
413
|
+
* 🔴 넓히지 않는다. 넓히면 남남인 이름끼리 "혹시 이것입니까" 가 뜨고, 그러면
|
|
414
|
+
* 사람은 그 물음을 안 읽게 된다. 안 읽히는 확인은 없는 확인이다.
|
|
415
|
+
*
|
|
416
|
+
* 두 글자까지 봤다가 `reader` 와 `sender` 가 걸렸다 — 여섯 글자에서 두 글자면
|
|
417
|
+
* 3분의 1이 다른 것이고, 그건 오타가 아니라 다른 낱말이다. 실제로 났던 오타는
|
|
418
|
+
* 거리가 아니라 **앞부분 일치**로 잡히므로 좁혀도 잃는 것이 없다.
|
|
419
|
+
*/
|
|
420
|
+
function nearMatches(who) {
|
|
421
|
+
const w = norm(who)
|
|
422
|
+
const all = roster().flatMap((p) => [p.email, ...p.names])
|
|
423
|
+
return all.filter((a) => {
|
|
424
|
+
if (a === w) return false
|
|
425
|
+
const [s, l] = a.length < w.length ? [a, w] : [w, a]
|
|
426
|
+
if (l.startsWith(s) && l.length - s.length <= 4) return true
|
|
427
|
+
return distance(w, a) <= 1
|
|
428
|
+
}).slice(0, 5)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 두 글자열을 같게 만드는 데 필요한 최소 편집 횟수 (Levenshtein). */
|
|
432
|
+
function distance(a, b) {
|
|
433
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
434
|
+
for (let i = 1; i <= a.length; i++) {
|
|
435
|
+
let diag = prev[0]
|
|
436
|
+
prev[0] = i
|
|
437
|
+
for (let j = 1; j <= b.length; j++) {
|
|
438
|
+
const t = prev[j]
|
|
439
|
+
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
440
|
+
diag = t
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return prev[b.length]
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 내 이메일. 없으면 null — 없다고 죽지는 않는다. 이름만으로도 지금까지처럼 돈다. */
|
|
447
|
+
function myEmail() {
|
|
448
|
+
const v = process.env.AXMAP_AGENT_EMAIL
|
|
449
|
+
if (v && v.includes('@')) return norm(v)
|
|
450
|
+
try {
|
|
451
|
+
const e = execFileSync('git', ['config', 'user.email'], {
|
|
452
|
+
cwd: REPO ?? process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
453
|
+
}).trim()
|
|
454
|
+
return e.includes('@') ? norm(e) : null
|
|
455
|
+
} catch { return null }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 나에게 온 쪽지인지 판정할 때 쓰는 내 주소 전부.
|
|
460
|
+
*
|
|
461
|
+
* 🔴 `AXMAP_AGENT` 로 붙인 이름도 넣는다. 실측에서 `codex-jinmiri`·`jaehyeon-2`
|
|
462
|
+
* 처럼 **커밋 로그에 없는 이름**이 넷 있었다. 사람이 아니라 그 사람이 띄운
|
|
463
|
+
* AI 도구라 git 이 모른다. 그것도 나다.
|
|
464
|
+
*/
|
|
465
|
+
function myAddresses() {
|
|
466
|
+
const s = new Set()
|
|
467
|
+
const e = myEmail()
|
|
468
|
+
if (e) for (const a of addressesOf(e)) s.add(a)
|
|
469
|
+
s.add(norm(me()))
|
|
470
|
+
if (process.env.AXMAP_AGENT) s.add(norm(process.env.AXMAP_AGENT))
|
|
471
|
+
return s
|
|
472
|
+
}
|
|
473
|
+
|
|
194
474
|
/**
|
|
195
475
|
* 읽음 표시 — 규격은 `docs/SPEC.md` §2「읽음 표시」다. 여기와 `bin/axmap.mjs`
|
|
196
476
|
* 의 `unreadNotes` 가 **같은 파일을 같은 규칙으로** 본다. 규칙이 한 줄이라
|
|
@@ -199,11 +479,94 @@ function me() {
|
|
|
199
479
|
* 🔴 장부에 넣지 않는다. 읽었는지는 나만의 상태라 남과 합의할 필요가 없고,
|
|
200
480
|
* 장부에 쓰면 쪽지를 볼 때마다 push 경합이 생긴다.
|
|
201
481
|
*/
|
|
482
|
+
/**
|
|
483
|
+
* 옛 자리 — **이 PC 안의 파일 하나.** 읽기만 한다. 새 자리로 옮기는 재료다.
|
|
484
|
+
*
|
|
485
|
+
* 🔴 여기 있었기 때문에 **PC 를 바꾸면 전부 다시 안 읽음**이 됐다. 쪽지 본문은
|
|
486
|
+
* 고아 브랜치로 모두가 공유하는데, 읽었다는 사실만 그 PC 에 갇혀 있었다.
|
|
487
|
+
* 다시 clone 하면 사라지고, 같은 사람이 도구를 둘 띄우면 각자 다른 상태를 봤다.
|
|
488
|
+
*/
|
|
202
489
|
const SEEN_FILE = REPO ? path.join(REPO, '.axmap-bus-seen.json') : null
|
|
203
490
|
|
|
491
|
+
/** 새 자리 — 고아 브랜치 `axmap/users` 의 worktree. 사람마다 파일 하나. */
|
|
492
|
+
const USERS_WT = REPO ? path.join(REPO, '.axmap', 'users') : null
|
|
493
|
+
const USERS_BRANCH = 'axmap/users'
|
|
494
|
+
const usersReady = () => USERS_WT !== null && fs.existsSync(path.join(USERS_WT, '.git'))
|
|
495
|
+
|
|
496
|
+
function gitUsers(argv, opts = {}) {
|
|
497
|
+
try {
|
|
498
|
+
return {
|
|
499
|
+
code: 0,
|
|
500
|
+
out: execFileSync('git', argv, {
|
|
501
|
+
cwd: USERS_WT, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], ...opts,
|
|
502
|
+
}).trim(),
|
|
503
|
+
}
|
|
504
|
+
} catch (e) { return { code: e.status ?? 1, out: '', err: String(e.stderr ?? e.message) } }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* 사람별 상태를 연다. **못 열어도 죽지 않고, 말하지도 않는다.**
|
|
509
|
+
*
|
|
510
|
+
* 🔴 실패 방침이 쪽지함과 **정반대**다. 쪽지함은 못 읽으면 큰 소리로 말해야 한다 —
|
|
511
|
+
* 거기서 조용하면 **온 쪽지를 없다고 답하게** 되기 때문이다. 여기서 조용하면
|
|
512
|
+
* **이미 읽은 쪽지가 한 번 더 뜰** 뿐이다.
|
|
513
|
+
*
|
|
514
|
+
* 즉 두 실패의 방향이 다르다. 하나는 **덜 보여주는 쪽**으로 틀리고 하나는
|
|
515
|
+
* **더 보여주는 쪽**으로 틀린다. 더 보여주는 실패는 사람이 알아서 넘긴다.
|
|
516
|
+
* 그래서 여기에 경고를 달면 쓸모없는 줄이 매번 뜨고, 그러면 진짜 경고까지
|
|
517
|
+
* 같이 안 읽히게 된다.
|
|
518
|
+
*/
|
|
519
|
+
function openUsers() {
|
|
520
|
+
if (usersReady()) return true
|
|
521
|
+
if (USERS_WT === null || process.env.AXMAP_BUS_DIR) return false
|
|
522
|
+
spawnSync(process.execPath, [path.join(ROOT, 'bin', 'axmap.mjs'), 'users-repair'], {
|
|
523
|
+
cwd: REPO, encoding: 'utf8', windowsHide: true,
|
|
524
|
+
})
|
|
525
|
+
return usersReady()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** 이 사람의 파일 자리. **이메일이 정본**이고, 없는 사람만 이름으로 떨어진다. */
|
|
529
|
+
function userKey(who) {
|
|
530
|
+
const w = norm(who)
|
|
531
|
+
const p = roster().find((x) => x.email === w || x.names.has(w))
|
|
532
|
+
return p ? p.email : w
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const userFile = (who) => path.join(USERS_WT, 'users', `${userKey(who)}.json`)
|
|
536
|
+
|
|
537
|
+
function readUserState(who) {
|
|
538
|
+
try { return JSON.parse(fs.readFileSync(userFile(who), 'utf8')) } catch { return {} }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** 원격의 사람별 상태를 받아온다. 자주 물을 이유가 없어 느슨하게 맞춘다. */
|
|
542
|
+
function pullUsers() {
|
|
543
|
+
if (!openUsers()) return
|
|
544
|
+
const stampFile = path.join(REPO, '.axmap', '.users-lastfetch')
|
|
545
|
+
// 🔴 60초. 쪽지함(기본 0초)보다 훨씬 느슨한 것은 **늦어도 손해가 없기 때문**이다.
|
|
546
|
+
// 늦으면 다른 PC 에서 읽은 것이 여기서 한 번 더 뜬다. 그뿐이다.
|
|
547
|
+
// 반대로 매번 물으면 도구 호출마다 통신이 한 번씩 더 붙는다 (실측 0.8초).
|
|
548
|
+
try {
|
|
549
|
+
if (Date.now() - fs.statSync(stampFile).mtimeMs < 60_000) return
|
|
550
|
+
} catch { /* 처음이다. 받아온다 */ }
|
|
551
|
+
const remote = gitUsers(['config', '--get', 'axmap.remote'], { cwd: REPO }).out || 'origin'
|
|
552
|
+
if (gitUsers(['fetch', '--quiet', remote, USERS_BRANCH]).code !== 0) return
|
|
553
|
+
gitUsers(['reset', '--hard', '--quiet', 'FETCH_HEAD'])
|
|
554
|
+
try { fs.writeFileSync(stampFile, '') } catch { /* 스탬프 실패는 치명적이지 않다 */ }
|
|
555
|
+
}
|
|
556
|
+
|
|
204
557
|
function readSeen(who) {
|
|
205
|
-
|
|
206
|
-
|
|
558
|
+
pullUsers()
|
|
559
|
+
const fromBranch = usersReady() ? (readUserState(who).seen ?? '') : ''
|
|
560
|
+
// 🔴 옛 자리도 함께 본다. **둘 중 더 나아간 쪽**을 쓴다.
|
|
561
|
+
//
|
|
562
|
+
// 안 그러면 이 판으로 올린 날 모두의 쪽지가 전부 "안 읽음" 으로 되살아난다.
|
|
563
|
+
// 수십 건이 한꺼번에 뜨면 사람은 그것을 배경으로 여기고 안 읽게 되는데,
|
|
564
|
+
// 그건 이 도구가 고치려던 바로 그 상태다.
|
|
565
|
+
let legacy = ''
|
|
566
|
+
if (SEEN_FILE) {
|
|
567
|
+
try { legacy = JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8'))[who] ?? '' } catch { /* 없다 */ }
|
|
568
|
+
}
|
|
569
|
+
return fromBranch > legacy ? fromBranch : legacy
|
|
207
570
|
}
|
|
208
571
|
|
|
209
572
|
/**
|
|
@@ -215,14 +578,56 @@ function readSeen(who) {
|
|
|
215
578
|
* 한쪽이 질 수 있는데, 지는 쪽의 손해는 "한 번 더 뜬다" 뿐이라 잠그지 않는다.
|
|
216
579
|
*/
|
|
217
580
|
function markSeen(who, id) {
|
|
218
|
-
if (!
|
|
581
|
+
if (!who || !id) return
|
|
582
|
+
// 옛 자리에도 계속 쓴다. 아직 옛 판을 쓰는 PC 가 같은 저장소에 있을 수 있고,
|
|
583
|
+
// 그쪽에서는 이 자리만 읽는다. 새 판이 다 퍼지면 이 줄은 지운다.
|
|
584
|
+
if (SEEN_FILE) {
|
|
585
|
+
try {
|
|
586
|
+
let all = {}
|
|
587
|
+
try { all = JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8')) } catch { /* 처음이다 */ }
|
|
588
|
+
if ((all[who] ?? '') < id) {
|
|
589
|
+
all[who] = id
|
|
590
|
+
fs.writeFileSync(SEEN_FILE, JSON.stringify(all, null, 2) + '\n')
|
|
591
|
+
}
|
|
592
|
+
} catch { /* 조용히 */ }
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!openUsers()) return
|
|
219
596
|
try {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if ((
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
597
|
+
const f = userFile(who)
|
|
598
|
+
const cur = readUserState(who)
|
|
599
|
+
if ((cur.seen ?? '') >= id) return // 뒤로 가지 않는다
|
|
600
|
+
fs.mkdirSync(path.dirname(f), { recursive: true })
|
|
601
|
+
|
|
602
|
+
// 🔴 **별칭은 저절로 적힌다.** 손으로 관리하게 하면 아무도 안 한다.
|
|
603
|
+
//
|
|
604
|
+
// 지금 쓰고 있는 이름이 이메일도 아니고 git 이 아는 이름도 아니면, 그것은
|
|
605
|
+
// 이 사람이 띄운 도구의 이름이다 (codex-jinmiri · jaehyeon-2 처럼). 적어
|
|
606
|
+
// 두면 다음부터 그 이름으로 온 쪽지도 이 사람 것이 된다.
|
|
607
|
+
const known = addressesOf(who)
|
|
608
|
+
const now = norm(me())
|
|
609
|
+
const aliases = [...new Set([...(cur.aliases ?? []), ...(known.has(now) ? [] : [now])])]
|
|
610
|
+
|
|
611
|
+
// 이름도 같이 남긴다 — 파일 이름이 이메일이라 사람이 열었을 때 누구인지 보이게.
|
|
612
|
+
fs.writeFileSync(f, JSON.stringify({ ...cur, name: me(), aliases, seen: id }, null, 2) + '\n')
|
|
613
|
+
|
|
614
|
+
gitUsers(['add', '--', path.relative(USERS_WT, f).replace(/\\/g, '/')])
|
|
615
|
+
// --no-verify: 연결된 worktree 는 훅을 공유한다. 이 브랜치는 사용자 코드가 아니다.
|
|
616
|
+
gitUsers(['commit', '--quiet', '--no-verify', '-m', `seen: ${userKey(who)}`])
|
|
617
|
+
|
|
618
|
+
// 🔴 push 는 **60초에 한 번**만 시도한다. 커밋은 이 PC 안이라 싸지만 push 는
|
|
619
|
+
// 통신이고, 쪽지를 볼 때마다 붙으면 모든 도구 호출이 그만큼 느려진다.
|
|
620
|
+
// 늦게 가도 손해가 없다 — 다른 PC 에서 그 쪽지가 한 번 더 뜰 뿐이다.
|
|
621
|
+
const stamp = path.join(REPO, '.axmap', '.users-lastpush')
|
|
622
|
+
let due = true
|
|
623
|
+
try { due = Date.now() - fs.statSync(stamp).mtimeMs >= 60_000 } catch { /* 처음이다 */ }
|
|
624
|
+
if (due) {
|
|
625
|
+
const remote = gitUsers(['config', '--get', 'axmap.remote'], { cwd: REPO }).out || 'origin'
|
|
626
|
+
if (gitUsers(['push', '--quiet', remote, `HEAD:${USERS_BRANCH}`]).code === 0) {
|
|
627
|
+
try { fs.writeFileSync(stamp, '') } catch { /* 스탬프 실패는 치명적이지 않다 */ }
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
} catch { /* 조용히 — 못 찍으면 다음에 한 번 더 뜰 뿐이다 */ }
|
|
226
631
|
}
|
|
227
632
|
|
|
228
633
|
const stamp = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
|
|
@@ -230,11 +635,17 @@ const slug = (s) => s.toLowerCase().replace(/[^\w가-힣]+/g, '-').replace(/^-|-
|
|
|
230
635
|
|
|
231
636
|
function readAll() {
|
|
232
637
|
const found = []
|
|
638
|
+
let newBoxOpened = false
|
|
233
639
|
for (const box of READ_BOXES) {
|
|
234
640
|
let ns = []
|
|
235
641
|
try { ns = fs.readdirSync(box).filter((f) => f.endsWith('.md')) } catch { continue }
|
|
642
|
+
if (box === BOX) newBoxOpened = true
|
|
236
643
|
for (const f of ns) found.push({ box, f })
|
|
237
644
|
}
|
|
645
|
+
// 🔴 옛 함(`docs/bus`)은 **읽기 전용 유산**이다. 새 함을 못 열었는데 옛 함이
|
|
646
|
+
// 읽히면 지금까지는 그것이 **성공한 것처럼** 보였다 — 8월 20일자 8건이
|
|
647
|
+
// 나오고 오늘 온 쪽지는 없는 것이 된다. 어느 함에서 읽었는지를 남긴다.
|
|
648
|
+
readFromLegacyOnly = !newBoxOpened && found.length > 0
|
|
238
649
|
return found.map(({ box, f }) => {
|
|
239
650
|
const text = fs.readFileSync(path.join(box, f), 'utf8')
|
|
240
651
|
const head = {}
|
|
@@ -269,6 +680,24 @@ function stdin() {
|
|
|
269
680
|
|
|
270
681
|
function post({ to, subject, body, replyTo = null }) {
|
|
271
682
|
const from = me()
|
|
683
|
+
// 부르는 쪽이 하나를 주든 여럿을 주든 여기서는 목록 하나로 본다.
|
|
684
|
+
let tos = (Array.isArray(to) ? to : (to == null ? [] : [to])).map((s) => String(s).trim()).filter(Boolean)
|
|
685
|
+
|
|
686
|
+
// 🔴 대상을 안 주면 지금까지처럼 전체에게 간다. **바꾸지 않는다** — 바꾸면
|
|
687
|
+
// 어제 되던 것이 오늘 안 되는 변경이고, 그건 맨 앞자리 번호를 올리고 먼저
|
|
688
|
+
// 알린 뒤에 할 일이다 (MCP 의 ax_send 도 `to` 를 선택으로 두고 있다).
|
|
689
|
+
//
|
|
690
|
+
// 대신 **조용히 넘어가지는 않는다.** 옵션 하나를 빠뜨린 것과 전체에게
|
|
691
|
+
// 보내려는 것은 명령줄에서 생김새가 똑같다. 실제로 216통 중 48통이
|
|
692
|
+
// 전체공지였는데, 그중 몇이 의도한 것이었는지는 아무도 모른다.
|
|
693
|
+
if (!tos.length) {
|
|
694
|
+
console.error('※ 받는 사람을 주지 않았습니다 — 전체(all)에게 보냅니다. 한 사람에게 보내려면 --to <상대>')
|
|
695
|
+
tos = ['all']
|
|
696
|
+
}
|
|
697
|
+
if (tos.length > 1 && tos.some((t) => norm(t) === 'all')) {
|
|
698
|
+
console.error('※ all 이 섞여 있어 전체에게 보냅니다. 나머지 이름은 뜻이 없습니다.')
|
|
699
|
+
tos = ['all']
|
|
700
|
+
}
|
|
272
701
|
if (!subject) { console.error('--subject 가 필요합니다.'); process.exit(1) }
|
|
273
702
|
if (!body.trim()) { console.error('본문이 비었습니다 (stdin 으로 주세요).'); process.exit(1) }
|
|
274
703
|
// 🔴 쪽지함이 없으면 **쓰지 않고 거부한다.**
|
|
@@ -286,12 +715,55 @@ function post({ to, subject, body, replyTo = null }) {
|
|
|
286
715
|
)
|
|
287
716
|
process.exit(1)
|
|
288
717
|
}
|
|
718
|
+
// 🔴 **모르는 주소로는 안 보낸다.**
|
|
719
|
+
//
|
|
720
|
+
// 지금까지는 받는 사람 이름을 아무도 검사하지 않았다. 오타 하나면 그 쪽지는
|
|
721
|
+
// 아무에게도 안 가는데 보낸 쪽은 성공을 본다 — 실측에서 실제로 1통이
|
|
722
|
+
// `ahwlstjd` (뒤의 `57` 이 빠진 이름)로 갔고 13분 뒤에야 발견됐다.
|
|
723
|
+
//
|
|
724
|
+
// 거부하는 쪽을 고른 이유는 이 저장소가 정한 것 그대로다 — 잘못 막으면
|
|
725
|
+
// 사람이 메시지를 읽고 고치면 되지만, 조용히 보내면 아무도 모른다.
|
|
726
|
+
// 다만 **처음 오는 사람**(커밋도 쪽지도 아직 없는 사람)은 오타와 생김새가
|
|
727
|
+
// 같으므로 빠져나갈 문을 둔다 — `--force`. 문이 없으면 사람은 시스템 밖으로
|
|
728
|
+
// 나가고, 그때는 흔적도 안 남는다.
|
|
729
|
+
for (const one of (has('--force') ? [] : tos)) {
|
|
730
|
+
if (norm(one) === 'all') continue
|
|
731
|
+
const known = roster().some((p) => p.email === norm(one) || p.names.has(norm(one)))
|
|
732
|
+
if (!known) {
|
|
733
|
+
const near = nearMatches(one)
|
|
734
|
+
if (near.length) {
|
|
735
|
+
// 🔴 **거부하는 것은 오타뿐이다.** 아는 이름과 한 글자 차이거나 그 이름의
|
|
736
|
+
// 앞부분이면 새 사람이 아니라 손이 미끄러진 것이다. 실측에서 실제로
|
|
737
|
+
// `ahwlstjd` 로 갔다 — 뒤의 `57` 이 빠졌고, 13분 뒤에야 발견됐다.
|
|
738
|
+
console.error(
|
|
739
|
+
`받는 사람을 찾지 못했습니다: ${one}\n` +
|
|
740
|
+
` 혹시 이것입니까? ${near.join(' · ')}\n\n` +
|
|
741
|
+
' 이 이름 그대로 보내려면: --force',
|
|
742
|
+
)
|
|
743
|
+
process.exit(1)
|
|
744
|
+
}
|
|
745
|
+
// 🔴 **모르는 사람이라고 막지는 않는다.** 아직 커밋이 없는 사람이 실제로
|
|
746
|
+
// 있다 — 팀에 새로 온 사람에게 보내는 첫 쪽지가 정확히 그 모양이다.
|
|
747
|
+
// 여기서 막으면 정상적인 첫 연락마다 `--force` 를 치게 되고, 그러면
|
|
748
|
+
// 사람은 그것을 반사적으로 붙이게 되어 **오타 검사가 아무것도 못 잡는다.**
|
|
749
|
+
// 대신 보내는 순간 눈에 띄게 말한다 — 조용히 보내는 것과는 다르다.
|
|
750
|
+
console.error(`※ 이 저장소에서 처음 보는 이름입니다: ${one} (그래도 보냅니다)`)
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
289
754
|
fs.mkdirSync(BOX, { recursive: true })
|
|
290
755
|
const now = new Date()
|
|
291
756
|
const id = `${stamp(now)}-${from}-${slug(subject)}`
|
|
757
|
+
// 🔴 `from` 은 **이름 그대로** 둔다. 파일 이름에 들어가는 값이라 바꾸면 예전
|
|
758
|
+
// 쪽지와 형식이 갈린다. 대신 이메일을 한 줄 더 적는다 — 이름이 나중에
|
|
759
|
+
// 바뀌어도 **누구였는지는 남는다.** 이것이 명단을 세우는 재료가 된다.
|
|
760
|
+
const fromEmail = myEmail()
|
|
292
761
|
const head = [
|
|
293
762
|
`from: ${from}`,
|
|
294
|
-
`
|
|
763
|
+
fromEmail ? `fromEmail: ${fromEmail}` : null,
|
|
764
|
+
// 여럿이면 쉼표로 잇는다. 하나면 예전 쪽지와 글자 그대로 같은 모양이 된다 —
|
|
765
|
+
// 형식을 새로 만들지 않으므로 옛 쪽지도 새 코드가 그대로 읽는다.
|
|
766
|
+
`to: ${tos.join(', ')}`,
|
|
295
767
|
`at: ${now.toISOString()}`,
|
|
296
768
|
`subject: ${subject}`,
|
|
297
769
|
replyTo ? `replyTo: ${replyTo}` : null,
|
|
@@ -393,8 +865,18 @@ function list() {
|
|
|
393
865
|
const all = readAll()
|
|
394
866
|
const to = has('--mine') ? me() : flag('--to')
|
|
395
867
|
const from = flag('--from')
|
|
396
|
-
|
|
397
|
-
|
|
868
|
+
|
|
869
|
+
// 🔴 **한 사람에게는 주소가 여럿이다.** `--to yeaseung-lee` 로 물어도
|
|
870
|
+
// `이예승` 앞으로 온 쪽지가 나와야 한다 — 같은 사람이기 때문이다.
|
|
871
|
+
// 이름 하나로만 맞춰 보던 것이 쪽지를 묻은 원인이었다.
|
|
872
|
+
//
|
|
873
|
+
// `--mine` 일 때는 내 이메일·git 이름·AXMAP_AGENT 를 전부 나로 친다.
|
|
874
|
+
const toSet = to ? (has('--mine') ? myAddresses() : addressesOf(to)) : null
|
|
875
|
+
const fromSet = from ? addressesOf(from) : null
|
|
876
|
+
|
|
877
|
+
let rows = all.filter((m) => (has('--all') || !toSet
|
|
878
|
+
|| recipientsOf(m).some((r) => r === 'all' || toSet.has(r)))
|
|
879
|
+
&& (!fromSet || fromSet.has(norm(m.from)) || (m.fromEmail && fromSet.has(norm(m.fromEmail)))))
|
|
398
880
|
|
|
399
881
|
// 🔴 `--unread` 는 자기 앞으로 온 것을 가릴 때만 뜻이 있다. 받는 사람이
|
|
400
882
|
// 정해지지 않았는데 "안 읽음" 을 말하면 누구의 읽음인지가 없다.
|
|
@@ -416,10 +898,33 @@ function list() {
|
|
|
416
898
|
//
|
|
417
899
|
// 숫자가 둘이면 사람은 어느 쪽도 안 믿는다. 그 순간 알림은 배경이 되고,
|
|
418
900
|
// 배경이 된 알림은 진짜 쪽지가 왔을 때도 안 읽힌다.
|
|
419
|
-
|
|
901
|
+
// 🔴 `m.from !== who` 였다. 이름이 하나일 때만 맞는 비교다 — 내가 `이예승`
|
|
902
|
+
// 으로 커밋하고 `yeaseung-lee` 로 쪽지를 보냈으면, 내가 보낸 전체공지가
|
|
903
|
+
// **내 안 읽은 쪽지로 다시 잡힌다.** 주소 전부와 견준다.
|
|
904
|
+
const mine = has('--mine') ? myAddresses() : addressesOf(who)
|
|
905
|
+
rows = rows.filter((m) => m.id > seen
|
|
906
|
+
&& !mine.has(norm(m.from)) && !(m.fromEmail && mine.has(norm(m.fromEmail))))
|
|
420
907
|
}
|
|
421
908
|
|
|
422
|
-
|
|
909
|
+
// 🔴 **못 읽었으면 "없음" 이라고 답하지 않는다.** 이 세 줄이 이 파일에서 제일
|
|
910
|
+
// 중요하다. 나머지는 편의고 이것만이 사고를 막는다.
|
|
911
|
+
//
|
|
912
|
+
// 종료 코드를 0 이 아닌 것으로 낸다 — MCP 서버(`mcp/server.mjs`)는 이미
|
|
913
|
+
// `ok: r.code === 0` 으로 갈라 "쪽지함을 읽지 못했습니다" 를 따로 내도록
|
|
914
|
+
// 되어 있었다. 읽기가 그 신호를 **한 번도 낸 적이 없었을** 뿐이다.
|
|
915
|
+
//
|
|
916
|
+
// `--quiet-if-empty`(훅·배너)일 때만 0 으로 끝낸다. 훅에서 0 이 아니면
|
|
917
|
+
// 커밋이 막히는데, 쪽지를 못 읽은 것으로 커밋을 막는 것은 과하다.
|
|
918
|
+
// **다만 말은 한다** — stderr 는 그 경우에도 그대로 나간다.
|
|
919
|
+
if (busProblem) {
|
|
920
|
+
reportBusProblem()
|
|
921
|
+
if (!has('--quiet-if-empty')) process.exit(1)
|
|
922
|
+
return
|
|
923
|
+
}
|
|
924
|
+
if (!rows.length) {
|
|
925
|
+
if (readFromLegacyOnly) console.error('※ 옛 쪽지함(docs/bus)만 읽었습니다 — 새 쪽지함은 비어 있습니다.')
|
|
926
|
+
return has('--quiet-if-empty') ? undefined : console.log('쪽지 없음.')
|
|
927
|
+
}
|
|
423
928
|
for (const m of rows) {
|
|
424
929
|
console.log(`${m.at?.slice(0, 16).replace('T', ' ')} ${(m.from ?? '?').padEnd(18)} → ${(m.to ?? 'all').padEnd(18)} ${m.subject ?? ''}`)
|
|
425
930
|
console.log(` ${m.id}`)
|
|
@@ -444,6 +949,9 @@ function list() {
|
|
|
444
949
|
function read(id) {
|
|
445
950
|
pull()
|
|
446
951
|
const m = readAll().find((x) => x.id === id || x.id.includes(id))
|
|
952
|
+
// 🔴 못 열었으면 "없는 쪽지" 라고 말하지 않는다. 목록과 같은 이유다 — 사람은
|
|
953
|
+
// 아이디를 잘못 적었다고 믿고 다시 치게 되는데, 몇 번을 쳐도 같은 답이 온다.
|
|
954
|
+
if (!m && busProblem) { reportBusProblem(); process.exit(1) }
|
|
447
955
|
if (!m) { console.error(`없는 쪽지: ${id}`); process.exit(1) }
|
|
448
956
|
console.log(`── ${m.subject}\n ${m.from} → ${m.to} ${m.at}\n`)
|
|
449
957
|
console.log(m.body)
|
|
@@ -488,10 +996,14 @@ function seen() {
|
|
|
488
996
|
}
|
|
489
997
|
|
|
490
998
|
switch (cmd) {
|
|
491
|
-
case 'post': post({ to:
|
|
999
|
+
case 'post': post({ to: recipientArgs(), subject: flag('--subject'), body: stdin() }); break
|
|
492
1000
|
case 'reply': {
|
|
493
1001
|
const target = args[1]
|
|
1002
|
+
// 🔴 답장도 먼저 받아온다. 예전에는 받아오지 않고 찾아서, 원격에만 있는 쪽지에
|
|
1003
|
+
// 답장하면 **"없는 쪽지"** 가 나왔다. 아이디를 잘못 적은 것과 구별이 안 된다.
|
|
1004
|
+
pull()
|
|
494
1005
|
const src = readAll().find((x) => x.id === target || x.id.includes(target))
|
|
1006
|
+
if (!src && busProblem) { reportBusProblem(); process.exit(1) }
|
|
495
1007
|
if (!src) { console.error(`없는 쪽지: ${target}`); process.exit(1) }
|
|
496
1008
|
post({ to: src.from, subject: flag('--subject') ?? `Re: ${src.subject}`, body: stdin(), replyTo: src.id })
|
|
497
1009
|
break
|
|
@@ -504,6 +1016,8 @@ switch (cmd) {
|
|
|
504
1016
|
console.log(`에이전트 쪽지함
|
|
505
1017
|
|
|
506
1018
|
${selfCmd()} post --to <상대> --subject "<제목>" < 본문.md
|
|
1019
|
+
--to 를 여러 번 주거나 "a,b" 로 여럿에게 한 통을 보냅니다
|
|
1020
|
+
--to 를 아예 안 주면 전체(all)에게 갑니다
|
|
507
1021
|
${selfCmd()} list [--to <나>|--mine] [--from <상대>] [--all]
|
|
508
1022
|
[--unread] [--no-mark] [--throttle <초>] [--quiet-if-empty]
|
|
509
1023
|
${selfCmd()} read <아이디>
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(sed -i 's|/\\\\*\\\\* 궤도 모드에서 허용하는 yaw 한계 \\(약 35°\\). cos 이 0.82 아래로 내려가지 않는다. \\\\*/\\\\nconst YAW_LIMIT = 0.62|X|' app/web/graph.js)",
|
|
5
|
-
"Bash(sed -i 's|const YAW_LIMIT = 0.62|const TILT_LIMIT = 0.62|; s|허용하는 yaw 한계|허용하는 기울기 한계 \\(yaw · pitch 공통\\)|' app/web/graph.js)",
|
|
6
|
-
"Bash(sed -i 's|인코딩은 인코딩이 아니다. cos\\(TILT_LIMIT\\)=0.82 이면 고리의 화면 반지름이\\\\n \\\\* \\\\[0.82R, R\\\\] 안에 머물러 이웃 단계와 절대 겹치지 않는다.|X|' app/web/graph.js)",
|
|
7
|
-
"Bash(sed -n '/인코딩은 인코딩이 아니다/,+2p' app/web/graph.js)",
|
|
8
|
-
"Bash(sed -i 's|import { applyEdits, draft, editRecord } from|import { applyEdits, draft } from|' app/server.mjs)",
|
|
9
|
-
"Bash(sed -i 's|\"test\": \"node --test test/protocol.test.mjs test/model.test.mjs test/analyze.test.mjs\"|\"test\": \"node --test test/protocol.test.mjs test/model.test.mjs test/analyze.test.mjs test/features.test.mjs\"|' package.json)",
|
|
10
|
-
"Bash(npm test *)",
|
|
11
|
-
"Bash(sed -i 's| .filter\\(\\(\\\\[t\\\\]\\) => t !== name \\\\&\\\\& !taken.has\\(t\\)\\)| .filter\\(\\([t]\\) => t !== name \\\\&\\\\& !taken.has\\(t\\) \\\\&\\\\& !tooCommon\\(t\\)\\)|' app/lib/features.mjs)",
|
|
12
|
-
"Bash(node -e ' *)",
|
|
13
|
-
"Bash(npm run *)",
|
|
14
|
-
"Bash(export AXMAP_AGENT=claude-fix GIT_AUTHOR_NAME=claude-fix GIT_AUTHOR_EMAIL=claude-fix@local GIT_COMMITTER_NAME=claude-fix GIT_COMMITTER_EMAIL=claude-fix@local)",
|
|
15
|
-
"Bash(node bin/axmap.mjs claim app/web/app.js app/web/graph.js app/web/style.css --task fix-click-color --intent \"이름 클릭=강조/더블클릭=이름변경, 단계 없을 때 색 회귀 수정\" --actor agent)",
|
|
16
|
-
"Bash(sed 's/\\\\$$//')",
|
|
17
|
-
"Bash(python -)",
|
|
18
|
-
"Bash(sed -n '/import 를 뽑을 수 있는 언어 이름/,/^\\\\]\\)/p' app/lib/analyze.mjs)",
|
|
19
|
-
"Bash(export AXMAP_AGENT=janghyojoon)",
|
|
20
|
-
"Bash(node bin/axmap.mjs release)",
|
|
21
|
-
"Bash(echo \"// 테스트\")",
|
|
22
|
-
"Bash(git add *)",
|
|
23
|
-
"Bash(git commit *)",
|
|
24
|
-
"Bash(git reset *)",
|
|
25
|
-
"Bash(git checkout *)"
|
|
26
|
-
]
|
|
27
|
-
},
|
|
28
|
-
"enabledMcpjsonServers": [
|
|
29
|
-
"axmap"
|
|
30
|
-
],
|
|
31
|
-
"enableAllProjectMcpServers": true
|
|
32
|
-
}
|