axmap-cli 1.0.1 → 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 +99 -25
- package/mcp/server.mjs +6 -2
- package/package.json +1 -1
- package/tools/bus.mjs +421 -14
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,68 @@ 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
|
+
})
|
|
1173
1228
|
}
|
|
1174
1229
|
|
|
1175
1230
|
/**
|
|
@@ -1198,6 +1253,19 @@ function cmdBusRepair() {
|
|
|
1198
1253
|
)
|
|
1199
1254
|
}
|
|
1200
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
|
+
)
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1201
1269
|
function cmdInit(flags = {}) {
|
|
1202
1270
|
const root = repoRoot()
|
|
1203
1271
|
const dir = ledgerDir(root)
|
|
@@ -1212,6 +1280,7 @@ function cmdInit(flags = {}) {
|
|
|
1212
1280
|
// 돌린 사람은 장부만 있고 쪽지함이 없다. 그 사람들이 다시 init 을 불렀을 때
|
|
1213
1281
|
// 받아 가는 자리가 여기다.
|
|
1214
1282
|
if (ensureBus(root) === 'created') console.log(`쪽지함을 만들었습니다: ${BUS_REL} (${BUS_BRANCH})`)
|
|
1283
|
+
if (ensureUsers(root) === 'created') console.log(`사람별 상태를 만들었습니다: ${USERS_REL} (${USERS_BRANCH})`)
|
|
1215
1284
|
return
|
|
1216
1285
|
}
|
|
1217
1286
|
if (fs.existsSync(dir) && fs.readdirSync(dir).length) {
|
|
@@ -1288,8 +1357,9 @@ function cmdInit(flags = {}) {
|
|
|
1288
1357
|
}
|
|
1289
1358
|
|
|
1290
1359
|
ensureBus(root)
|
|
1360
|
+
ensureUsers(root)
|
|
1291
1361
|
|
|
1292
|
-
console.log(`준비 완료. 장부 ${LEDGER_BRANCH} · 쪽지함 ${BUS_BRANCH}`)
|
|
1362
|
+
console.log(`준비 완료. 장부 ${LEDGER_BRANCH} · 쪽지함 ${BUS_BRANCH} · 사람별 상태 ${USERS_BRANCH}`)
|
|
1293
1363
|
}
|
|
1294
1364
|
|
|
1295
1365
|
// ---------------------------------------------------------------------------
|
|
@@ -2223,6 +2293,7 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
|
|
|
2223
2293
|
axmap init 장부(고아 브랜치 + worktree)를 준비한다
|
|
2224
2294
|
axmap bus-repair 쪽지함만 준비한다 (장부는 안 건드린다)
|
|
2225
2295
|
쪽지를 읽다가 "쪽지함을 못 열었습니다" 를 봤을 때
|
|
2296
|
+
axmap users-repair 사람별 상태(어디까지 읽었나)만 준비한다
|
|
2226
2297
|
axmap claim <경로...> 경로를 선점한다 [--task --intent --ttl 30m]
|
|
2227
2298
|
axmap release [경로...] 반납한다 (경로 생략 시 전부)
|
|
2228
2299
|
axmap renew TTL 을 연장한다 [--ttl 30m]
|
|
@@ -2300,6 +2371,9 @@ switch (cmd) {
|
|
|
2300
2371
|
case 'bus-repair':
|
|
2301
2372
|
cmdBusRepair()
|
|
2302
2373
|
break
|
|
2374
|
+
case 'users-repair':
|
|
2375
|
+
cmdUsersRepair()
|
|
2376
|
+
break
|
|
2303
2377
|
case 'claim':
|
|
2304
2378
|
cmdClaim(rest, flags)
|
|
2305
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
|
@@ -235,6 +235,30 @@ const cmd = args[0]
|
|
|
235
235
|
const flag = (n, d = null) => { const i = args.indexOf(n); return i < 0 ? d : args[i + 1] }
|
|
236
236
|
const has = (n) => args.includes(n)
|
|
237
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
|
+
|
|
238
262
|
/**
|
|
239
263
|
* 나는 누구인가. 선점 프로토콜과 **같은 값**을 쓴다 — 두 이름을 두면 갈린다.
|
|
240
264
|
*
|
|
@@ -267,6 +291,186 @@ function me() {
|
|
|
267
291
|
process.exit(1)
|
|
268
292
|
}
|
|
269
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
|
+
|
|
270
474
|
/**
|
|
271
475
|
* 읽음 표시 — 규격은 `docs/SPEC.md` §2「읽음 표시」다. 여기와 `bin/axmap.mjs`
|
|
272
476
|
* 의 `unreadNotes` 가 **같은 파일을 같은 규칙으로** 본다. 규칙이 한 줄이라
|
|
@@ -275,11 +479,94 @@ function me() {
|
|
|
275
479
|
* 🔴 장부에 넣지 않는다. 읽었는지는 나만의 상태라 남과 합의할 필요가 없고,
|
|
276
480
|
* 장부에 쓰면 쪽지를 볼 때마다 push 경합이 생긴다.
|
|
277
481
|
*/
|
|
482
|
+
/**
|
|
483
|
+
* 옛 자리 — **이 PC 안의 파일 하나.** 읽기만 한다. 새 자리로 옮기는 재료다.
|
|
484
|
+
*
|
|
485
|
+
* 🔴 여기 있었기 때문에 **PC 를 바꾸면 전부 다시 안 읽음**이 됐다. 쪽지 본문은
|
|
486
|
+
* 고아 브랜치로 모두가 공유하는데, 읽었다는 사실만 그 PC 에 갇혀 있었다.
|
|
487
|
+
* 다시 clone 하면 사라지고, 같은 사람이 도구를 둘 띄우면 각자 다른 상태를 봤다.
|
|
488
|
+
*/
|
|
278
489
|
const SEEN_FILE = REPO ? path.join(REPO, '.axmap-bus-seen.json') : null
|
|
279
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
|
+
|
|
280
557
|
function readSeen(who) {
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
283
570
|
}
|
|
284
571
|
|
|
285
572
|
/**
|
|
@@ -291,14 +578,56 @@ function readSeen(who) {
|
|
|
291
578
|
* 한쪽이 질 수 있는데, 지는 쪽의 손해는 "한 번 더 뜬다" 뿐이라 잠그지 않는다.
|
|
292
579
|
*/
|
|
293
580
|
function markSeen(who, id) {
|
|
294
|
-
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
|
|
295
596
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if ((
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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 { /* 조용히 — 못 찍으면 다음에 한 번 더 뜰 뿐이다 */ }
|
|
302
631
|
}
|
|
303
632
|
|
|
304
633
|
const stamp = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
|
|
@@ -351,6 +680,24 @@ function stdin() {
|
|
|
351
680
|
|
|
352
681
|
function post({ to, subject, body, replyTo = null }) {
|
|
353
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
|
+
}
|
|
354
701
|
if (!subject) { console.error('--subject 가 필요합니다.'); process.exit(1) }
|
|
355
702
|
if (!body.trim()) { console.error('본문이 비었습니다 (stdin 으로 주세요).'); process.exit(1) }
|
|
356
703
|
// 🔴 쪽지함이 없으면 **쓰지 않고 거부한다.**
|
|
@@ -368,12 +715,55 @@ function post({ to, subject, body, replyTo = null }) {
|
|
|
368
715
|
)
|
|
369
716
|
process.exit(1)
|
|
370
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
|
+
|
|
371
754
|
fs.mkdirSync(BOX, { recursive: true })
|
|
372
755
|
const now = new Date()
|
|
373
756
|
const id = `${stamp(now)}-${from}-${slug(subject)}`
|
|
757
|
+
// 🔴 `from` 은 **이름 그대로** 둔다. 파일 이름에 들어가는 값이라 바꾸면 예전
|
|
758
|
+
// 쪽지와 형식이 갈린다. 대신 이메일을 한 줄 더 적는다 — 이름이 나중에
|
|
759
|
+
// 바뀌어도 **누구였는지는 남는다.** 이것이 명단을 세우는 재료가 된다.
|
|
760
|
+
const fromEmail = myEmail()
|
|
374
761
|
const head = [
|
|
375
762
|
`from: ${from}`,
|
|
376
|
-
`
|
|
763
|
+
fromEmail ? `fromEmail: ${fromEmail}` : null,
|
|
764
|
+
// 여럿이면 쉼표로 잇는다. 하나면 예전 쪽지와 글자 그대로 같은 모양이 된다 —
|
|
765
|
+
// 형식을 새로 만들지 않으므로 옛 쪽지도 새 코드가 그대로 읽는다.
|
|
766
|
+
`to: ${tos.join(', ')}`,
|
|
377
767
|
`at: ${now.toISOString()}`,
|
|
378
768
|
`subject: ${subject}`,
|
|
379
769
|
replyTo ? `replyTo: ${replyTo}` : null,
|
|
@@ -475,8 +865,18 @@ function list() {
|
|
|
475
865
|
const all = readAll()
|
|
476
866
|
const to = has('--mine') ? me() : flag('--to')
|
|
477
867
|
const from = flag('--from')
|
|
478
|
-
|
|
479
|
-
|
|
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)))))
|
|
480
880
|
|
|
481
881
|
// 🔴 `--unread` 는 자기 앞으로 온 것을 가릴 때만 뜻이 있다. 받는 사람이
|
|
482
882
|
// 정해지지 않았는데 "안 읽음" 을 말하면 누구의 읽음인지가 없다.
|
|
@@ -498,7 +898,12 @@ function list() {
|
|
|
498
898
|
//
|
|
499
899
|
// 숫자가 둘이면 사람은 어느 쪽도 안 믿는다. 그 순간 알림은 배경이 되고,
|
|
500
900
|
// 배경이 된 알림은 진짜 쪽지가 왔을 때도 안 읽힌다.
|
|
501
|
-
|
|
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))))
|
|
502
907
|
}
|
|
503
908
|
|
|
504
909
|
// 🔴 **못 읽었으면 "없음" 이라고 답하지 않는다.** 이 세 줄이 이 파일에서 제일
|
|
@@ -591,7 +996,7 @@ function seen() {
|
|
|
591
996
|
}
|
|
592
997
|
|
|
593
998
|
switch (cmd) {
|
|
594
|
-
case 'post': post({ to:
|
|
999
|
+
case 'post': post({ to: recipientArgs(), subject: flag('--subject'), body: stdin() }); break
|
|
595
1000
|
case 'reply': {
|
|
596
1001
|
const target = args[1]
|
|
597
1002
|
// 🔴 답장도 먼저 받아온다. 예전에는 받아오지 않고 찾아서, 원격에만 있는 쪽지에
|
|
@@ -611,6 +1016,8 @@ switch (cmd) {
|
|
|
611
1016
|
console.log(`에이전트 쪽지함
|
|
612
1017
|
|
|
613
1018
|
${selfCmd()} post --to <상대> --subject "<제목>" < 본문.md
|
|
1019
|
+
--to 를 여러 번 주거나 "a,b" 로 여럿에게 한 통을 보냅니다
|
|
1020
|
+
--to 를 아예 안 주면 전체(all)에게 갑니다
|
|
614
1021
|
${selfCmd()} list [--to <나>|--mine] [--from <상대>] [--all]
|
|
615
1022
|
[--unread] [--no-mark] [--throttle <초>] [--quiet-if-empty]
|
|
616
1023
|
${selfCmd()} read <아이디>
|