axmap-cli 1.1.0 → 1.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/settings.local.json +32 -0
- package/governance/GOVERNANCE.md +56 -2
- package/package.json +1 -1
- package/src/governance.mjs +86 -6
- package/tools/bus.mjs +65 -2
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
}
|
package/governance/GOVERNANCE.md
CHANGED
|
@@ -212,7 +212,7 @@ node axmap/governance/gate.mjs --source <소스브랜치> --target <타깃브랜
|
|
|
212
212
|
|
|
213
213
|
| | | 안 지키면 |
|
|
214
214
|
|---|---|---|
|
|
215
|
-
| **G1** | **자기 표는 세지 않는다** — MR 소스 브랜치 커밋의 author 인 사람의 표는 무효 | 혼자 쓰고 혼자 승인한다 |
|
|
215
|
+
| **G1** | **자기 표는 세지 않는다** — MR 소스 브랜치 커밋의 author 인 사람의 표는 무효 (예외 하나 — 바로 아래) | 혼자 쓰고 혼자 승인한다 |
|
|
216
216
|
| **G2** | **정책은 언제나 타깃 브랜치**(**합쳐 받는 쪽 브랜치**)**에서 읽는다** | 정족수를 1 로 낮추는 MR 이 **자기 자신의 낮춘 규칙으로** 통과한다 |
|
|
217
217
|
| **G3** | **표는 커밋(sha)에 묶인다** — 헤드가 바뀌면 효력을 잃는다 | 승인받은 뒤 내용을 갈아끼운다 |
|
|
218
218
|
| **G4** | **한 사람은 한 표** — id 유일, email 유일 | 명단에 자기를 두 줄 적으면 두 표다 |
|
|
@@ -225,6 +225,58 @@ node axmap/governance/gate.mjs --source <소스브랜치> --target <타깃브랜
|
|
|
225
225
|
**G2 만 순수 판정 밖에 있다.** 어느 브랜치에서 정책을 읽어오는가는 git 을 만지는
|
|
226
226
|
일이라 게이트의 책임이다. `src/governance.mjs` 는 **읽어온 정책이 성립하는지**만 본다.
|
|
227
227
|
|
|
228
|
+
### 🔴 G1 의 예외 하나 — 던질 수 있는 사람이 정족수보다 적을 때
|
|
229
|
+
|
|
230
|
+
오래 쌓는 `<파트>/dev` 브랜치에서는 시간이 갈수록 커밋한 사람이 늘어
|
|
231
|
+
**던질 수 있는 사람이 0 으로 수렴한다.** 실측 (팀 저장소 S15P21E201, 2026-09-07):
|
|
232
|
+
|
|
233
|
+
| 브랜치 | 투표권자 | 그중 소스 브랜치의 author | 던질 수 있는 사람 | 정족수 |
|
|
234
|
+
|---|---|---|---|---|
|
|
235
|
+
| `front/main` ← `front/dev` | 4명 | **4명 전원** | **0명** | 2 |
|
|
236
|
+
| `back/main` ← `back/dev` | 6명 | **6명 전원** | **0명** | 2 |
|
|
237
|
+
|
|
238
|
+
이건 **"아직 표가 모자라다"(미달)가 아니다.** 표를 더 모아도 안 풀린다 — 던질 수
|
|
239
|
+
있는 사람 자체가 없기 때문이다. 그 상태에 미달(종료 코드 2)을 내면 부르는 쪽에게
|
|
240
|
+
*"사람에게 부탁하면 된다"* 고 거짓말을 하는 것이고, 사람은 시스템 밖에서
|
|
241
|
+
탈출한다 (`hotfix/` 를 코드 안에 둔 것과 같은 이유다).
|
|
242
|
+
|
|
243
|
+
그래서 **그 판정 하나에 한해** G1 을 푼다. 조건 셋을 **모두** 만족할 때만이다.
|
|
244
|
+
|
|
245
|
+
| | |
|
|
246
|
+
|---|---|
|
|
247
|
+
| 1 | 이번 판정의 **정족수를 숫자로 알고 있다** (`judge` 가 `countVotes` 에 넘긴다) |
|
|
248
|
+
| 2 | **G1 이 실제로 명단에서 누군가를 뺐다** — 안 뺐으면 G1 이 만든 교착이 아니다 |
|
|
249
|
+
| 3 | 그러고 **남은 사람이 정족수보다 적다** |
|
|
250
|
+
|
|
251
|
+
2번이 없으면 *"명단 자체가 정족수보다 작다"*(승계로도 못 채운 **막힌 저장소**)까지
|
|
252
|
+
이 문으로 샌다. 그건 G1 을 풀어도 아무도 못 던지므로, 판정문에 거짓 사유만
|
|
253
|
+
한 줄 남는다.
|
|
254
|
+
|
|
255
|
+
**바뀌지 않는 것 — 이게 절반이다.**
|
|
256
|
+
|
|
257
|
+
- **정족수 숫자.** `majorityThreshold` 는 그대로다. 문턱을 낮추면 *"최소 두 사람이
|
|
258
|
+
본다"* 는 팀의 뜻이 조용히 사라진다. 여기서 푸는 것은 **누가 던질 수 있는가** 하나다
|
|
259
|
+
- **G3 · G4 · G5**, 브랜치 대조, committer(**표를 기록한 git 신원**) 대조,
|
|
260
|
+
`vote_note`, 미래 시각 — 전부 그대로 돈다
|
|
261
|
+
- **fail-closed.** 소스 브랜치의 author 목록을 못 받으면 여전히 판정 불가다
|
|
262
|
+
(`authors-missing`). **"저자를 모를 때" 는 해제 조건이 아니다** — 거기로 새면
|
|
263
|
+
G1 은 켜고 끄는 스위치가 되고, 그건 성질이 아니라 설정이다
|
|
264
|
+
|
|
265
|
+
🔴 **해제는 조용히 발동하지 않는다.** 승계와 같은 규칙이다 — 판정문이 찍는다.
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
🔴 G1(자기 표 배제)이 해제된 판정입니다 — 유효 투표권자 0명 < 정족수 2.
|
|
269
|
+
저자 본인의 표를 셌습니다.
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
해제 덕분에 세어진 사람의 이름까지 남는다. 기계가 읽을 표식은 `tally` 에 있다 —
|
|
273
|
+
`g1Waived` · `eligibleCount` · `threshold` · `selfVoted`. `gate --json` 이 그대로 낸다.
|
|
274
|
+
|
|
275
|
+
**`self_vote` 정책(아래)과는 다른 층이다.** `self_vote` 는 *"누구까지를 자기로
|
|
276
|
+
보는가"* 를 **사람이 미리 정하는 것**이고, 이 예외는 *"그렇게 정했더니 아무도 못
|
|
277
|
+
던지게 됐을 때"* **코드가 그 판정에서만 푸는 것**이다. `self_vote: "off"` 면
|
|
278
|
+
배제할 사람이 없으므로 이 예외는 아예 안 걸린다.
|
|
279
|
+
|
|
228
280
|
---
|
|
229
281
|
|
|
230
282
|
## 종료 코드
|
|
@@ -496,7 +548,9 @@ git log --format=%B -1 <커밋> | git interpret-trailers --parse
|
|
|
496
548
|
살아 있는 투표권자가 필요 표보다 적으면, 최근 90일 안에 커밋한 author 상위 3명이
|
|
497
549
|
**임시 투표권**을 갖는다.
|
|
498
550
|
|
|
499
|
-
- 승계로 들어온 사람도 **자기 표는 못 센다** (G1
|
|
551
|
+
- 승계로 들어온 사람도 **자기 표는 못 센다** (G1). 예외는 하나 — 던질 수 있는
|
|
552
|
+
사람이 정족수보다 적어 G1 이 해제된 판정에서는 승계로 들어온 사람의 자기 표도
|
|
553
|
+
센다 (위 "G1 의 예외 하나")
|
|
500
554
|
- 정책에 적힌 사람은 **살아 있지 않아도 투표권을 잃지 않는다.** 생사는 *승계를 켤지*만
|
|
501
555
|
정한다. 두 달 자리를 비운 사람이 돌아와 던진 표가 안 세지면 그건 fail-closed 가
|
|
502
556
|
아니라 그냥 틀린 판정이다
|
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.
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
|
|
6
6
|
"//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
|
|
7
7
|
"publishConfig": {
|
package/src/governance.mjs
CHANGED
|
@@ -929,9 +929,15 @@ const FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000
|
|
|
929
929
|
* @param {string|null} [args.branch] 소스 브랜치 이름. 주면 표의 branch 와 대조한다
|
|
930
930
|
* @param {string|null} [args.tipAuthorEmail] 맨 위 커밋의 author email.
|
|
931
931
|
* `self_vote: "tip"` 일 때만 쓰이고, 그때는 **없으면 판정 불가**다
|
|
932
|
-
* @
|
|
932
|
+
* @param {number|null} [args.threshold] 이번 판정의 정족수. **주면** 자기 표 배제가
|
|
933
|
+
* 판정을 불가능하게 만드는지 볼 수 있다 (아래 waiver). 안 주면 해제는
|
|
934
|
+
* 영영 안 걸린다 — 모르면 안 푸는 쪽이 fail-closed 다. `judge` 가 넣어 준다
|
|
935
|
+
* @returns {{approvals:number, counted:Array, rejections:Array, invalid:Array,
|
|
936
|
+
* g1Waived:boolean, eligibleCount:number, threshold:number|null, selfVoted:Array}}
|
|
933
937
|
*/
|
|
934
|
-
export function countVotes({
|
|
938
|
+
export function countVotes({
|
|
939
|
+
policy, votes, authorEmails, sha, now, voters = null, branch = null, tipAuthorEmail = null, threshold = null,
|
|
940
|
+
}) {
|
|
935
941
|
const roster = voters ?? policy?.voters ?? null
|
|
936
942
|
if (!Array.isArray(roster) || roster.length === 0) {
|
|
937
943
|
throw new GovernanceError('투표권자 명단이 없습니다.', { exit: EXIT.POLICY_BROKEN, code: 'voters-missing' })
|
|
@@ -966,9 +972,41 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
|
|
|
966
972
|
const authors = excludedAuthors(selfVote, authorEmails, tipAuthorEmail)
|
|
967
973
|
const byEmail = new Map(roster.map((v) => [fold(v.email), v]))
|
|
968
974
|
|
|
975
|
+
// ── G1 해제(waiver) — 자기 표 배제가 **판정 자체를 불가능하게 만들 때만** ────
|
|
976
|
+
//
|
|
977
|
+
// 🔴 왜 필요한가 — 실측. 오래 쌓는 `<파트>/dev` 브랜치에서는 시간이 갈수록
|
|
978
|
+
// 커밋한 사람이 늘어 "던질 수 있는 사람" 이 0 으로 수렴한다. 팀 저장소의
|
|
979
|
+
// `front/main` 은 투표권자 4명이 **전원 저자**였고(정족수 2), `back/dev` 는
|
|
980
|
+
// 명단 6명 전원이 저자였다. 이건 "아직 표가 모자라다"(미달, exit 2)가 아니라
|
|
981
|
+
// **표를 더 모아도 영원히 안 풀리는 교착**이다. 재시도가 답이 아닌 상태에
|
|
982
|
+
// 미달 코드를 내는 것은 부르는 쪽에게 거짓말을 하는 것이다.
|
|
983
|
+
//
|
|
984
|
+
// 🔴 해제 조건을 좁게 잡는다. 셋을 **모두** 만족해야 한다.
|
|
985
|
+
// 1. 이번 판정의 정족수를 숫자로 알고 있다 (`threshold` 를 받았다)
|
|
986
|
+
// 2. G1 이 실제로 명단에서 누군가를 뺐다 (`excludedFromRoster > 0`)
|
|
987
|
+
// 3. 그러고 남은 사람이 정족수보다 적다 (`eligibleCount < threshold`)
|
|
988
|
+
// 2번이 없으면 "명단 자체가 정족수보다 작다"(승계로도 못 채운 막힌 저장소)
|
|
989
|
+
// 까지 이 문으로 새어 나온다. 그건 G1 이 만든 교착이 아니므로 G1 을 풀어도
|
|
990
|
+
// 아무도 못 던진다 — 판정문에 거짓 사유만 한 줄 남는다.
|
|
991
|
+
//
|
|
992
|
+
// 🔴 **정족수 숫자는 건드리지 않는다.** 문턱을 낮추면 "두 사람이 본다" 는 팀의
|
|
993
|
+
// 뜻이 조용히 사라진다. 여기서 바꾸는 것은 **누가 던질 수 있는가** 하나다.
|
|
994
|
+
//
|
|
995
|
+
// 🔴 fail-closed 는 그대로다. 저자 목록을 못 받으면 위에서 이미 던졌다
|
|
996
|
+
// (`authors-missing`). "저자를 모를 때" 는 해제 조건이 **아니다** —
|
|
997
|
+
// 모르는 것과 셀 수 없는 것을 통과로 바꾸면 이 층이 없는 것과 같다.
|
|
998
|
+
const eligible = roster.filter((v) => !authors.has(fold(v?.email)))
|
|
999
|
+
const eligibleCount = eligible.length
|
|
1000
|
+
const excludedFromRoster = roster.length - eligibleCount
|
|
1001
|
+
const need = isInt(threshold) && threshold >= 1 ? threshold : null
|
|
1002
|
+
const g1Waived = need !== null && excludedFromRoster > 0 && eligibleCount < need
|
|
1003
|
+
|
|
969
1004
|
const counted = []
|
|
970
1005
|
const rejections = []
|
|
971
1006
|
const invalid = []
|
|
1007
|
+
// 해제 덕분에 세어진 표. **판정문에 이름까지 남긴다** — 해제가 조용히 발동하면
|
|
1008
|
+
// 그건 예외가 아니라 우회로다 (승계를 출력에 찍는 것과 같은 이유).
|
|
1009
|
+
const selfVoted = []
|
|
972
1010
|
const seen = new Map() // fold(email) -> 이미 센 표
|
|
973
1011
|
|
|
974
1012
|
for (const raw of votes) {
|
|
@@ -993,7 +1031,8 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
|
|
|
993
1031
|
if (!isStr(v.committerEmail)) { bad('committer-unknown'); continue }
|
|
994
1032
|
if (fold(v.committerEmail) !== email) { bad('committer-mismatch', v.committerEmail); continue }
|
|
995
1033
|
|
|
996
|
-
|
|
1034
|
+
// G1 · 자기 표는 안 센다. 단 해제된 판정에서는 센다 (위 waiver 주석).
|
|
1035
|
+
if (authors.has(email) && !g1Waived) { bad('self-vote'); continue }
|
|
997
1036
|
if (toMs(v.at, 'votes[].at') > nowMs + FUTURE_TOLERANCE_MS) { bad('future-dated', v.at); continue }
|
|
998
1037
|
// 🔴 이유 없는 표는 **찬성이든 반대든** 안 센다. 반대야말로 이유가 필요하다 —
|
|
999
1038
|
// 이유 없는 반대는 판정문에 "누군가 막고 있다" 만 남기고 무엇을 고쳐야
|
|
@@ -1004,13 +1043,28 @@ export function countVotes({ policy, votes, authorEmails, sha, now, voters = nul
|
|
|
1004
1043
|
if (seen.has(email)) { bad('duplicate', `이미 센 표: ${seen.get(email).voter}`); continue } // G4
|
|
1005
1044
|
|
|
1006
1045
|
seen.set(email, v)
|
|
1046
|
+
// 해제가 없었으면 여기 못 왔을 표. 판정문이 이름을 찍는다.
|
|
1047
|
+
if (g1Waived && authors.has(email)) selfVoted.push({ voter: v.voter, email: v.email, vote: v.vote })
|
|
1007
1048
|
if (v.vote === 'approve') counted.push(v)
|
|
1008
1049
|
// 반대는 정족수 계산에 들어가지 않는다. 이 층이 답하는 질문은 "찬성이 몇인가"
|
|
1009
1050
|
// 하나뿐이고, 거부권은 다른 제도다 — 만들려면 SPEC 부터 고쳐야 한다.
|
|
1010
1051
|
else rejections.push(v)
|
|
1011
1052
|
}
|
|
1012
1053
|
|
|
1013
|
-
return {
|
|
1054
|
+
return {
|
|
1055
|
+
approvals: counted.length,
|
|
1056
|
+
counted,
|
|
1057
|
+
rejections,
|
|
1058
|
+
invalid,
|
|
1059
|
+
// ── 기계가 읽는 해제 표식 ────────────────────────────────────────────
|
|
1060
|
+
// `gate.mjs --json` 이 이 셋을 그대로 내보낸다. 판정문의 한 줄이 지워져도
|
|
1061
|
+
// 이 표식은 남는다 — 나중에 "이 판정이 예외였나" 를 사람이 아니라 코드가
|
|
1062
|
+
// 물어볼 수 있어야 한다.
|
|
1063
|
+
g1Waived,
|
|
1064
|
+
eligibleCount,
|
|
1065
|
+
threshold: need,
|
|
1066
|
+
selfVoted,
|
|
1067
|
+
}
|
|
1014
1068
|
}
|
|
1015
1069
|
|
|
1016
1070
|
/**
|
|
@@ -1084,7 +1138,14 @@ export function judge({
|
|
|
1084
1138
|
: null
|
|
1085
1139
|
const need = rulesFor(changed, policy, { majority })
|
|
1086
1140
|
const roster = deriveVoters({ policy, contributors, now, threshold: need.threshold })
|
|
1087
|
-
|
|
1141
|
+
// 🔴 정족수를 **분자 쪽에도** 넘긴다. `countVotes` 는 이 숫자가 있어야
|
|
1142
|
+
// "자기 표를 빼고 나면 던질 수 있는 사람이 정족수보다 적은가" 를 볼 수
|
|
1143
|
+
// 있다. 문턱은 여기서 정해지므로(`rulesFor`) 그 값을 그대로 준다 —
|
|
1144
|
+
// 분자가 다른 문턱을 보고 해제를 판단하면 판정만 조용히 틀린다.
|
|
1145
|
+
const tally = countVotes({
|
|
1146
|
+
policy, votes, authorEmails, sha, now, voters: roster.voters, branch, tipAuthorEmail,
|
|
1147
|
+
threshold: need.threshold,
|
|
1148
|
+
})
|
|
1088
1149
|
const ok = tally.approvals >= need.threshold
|
|
1089
1150
|
return {
|
|
1090
1151
|
exit: ok ? EXIT.OK : EXIT.SHORT,
|
|
@@ -1141,6 +1202,22 @@ export function formatVerdict(verdict) {
|
|
|
1141
1202
|
}
|
|
1142
1203
|
L.push('')
|
|
1143
1204
|
|
|
1205
|
+
// 🔴 자기 표 배제 해제는 **조용히 발동하지 않는다.** 이 줄을 지우면 해제가
|
|
1206
|
+
// 예외가 아니라 우회로가 된다 — 승계 줄을 지울 수 없는 것과 같은 이유다.
|
|
1207
|
+
// 나중에 이력을 보는 사람이 "이 판정은 예외였다" 를 여기서 알아야 한다.
|
|
1208
|
+
if (tally?.g1Waived) {
|
|
1209
|
+
L.push(` 🔴 G1(자기 표 배제)이 해제된 판정입니다 — 유효 투표권자 ${tally.eligibleCount}명 < 정족수 ${threshold}. 저자 본인의 표를 셌습니다.`)
|
|
1210
|
+
L.push(' 오래 쌓인 브랜치는 투표권자가 전부 저자가 되어 던질 수 있는 사람이 0 이 됩니다.')
|
|
1211
|
+
L.push(' 그건 표가 모자란 것이 아니라 표를 더 모아도 안 풀리는 교착이라, 이 판정에 한해서만 풉니다.')
|
|
1212
|
+
L.push(' 🔴 정족수 숫자는 그대로입니다. G3(표는 커밋에 묶인다)·G4(같은 사람의 두 번째 표)·')
|
|
1213
|
+
L.push(' 브랜치 대조·committer(표를 기록한 git 신원) 대조도 전부 그대로 돕니다.')
|
|
1214
|
+
if (tally.selfVoted?.length) {
|
|
1215
|
+
L.push(' 해제 덕분에 센 표')
|
|
1216
|
+
for (const s of tally.selfVoted) L.push(` ! ${s.voter} <${s.email}> ${s.vote}`)
|
|
1217
|
+
}
|
|
1218
|
+
L.push('')
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1144
1221
|
if (tally.counted.length) {
|
|
1145
1222
|
L.push(' 센 표')
|
|
1146
1223
|
for (const c of tally.counted) L.push(` o ${c.voter} <${c.email}> ${c.at}`)
|
|
@@ -1173,7 +1250,10 @@ export function formatVerdict(verdict) {
|
|
|
1173
1250
|
} else {
|
|
1174
1251
|
L.push(' 그런데 창 안에 기여자가 없습니다.')
|
|
1175
1252
|
}
|
|
1176
|
-
|
|
1253
|
+
// 해제된 판정에서는 이 줄이 거짓이 된다. 틀린 안내는 안 하는 것만 못하다.
|
|
1254
|
+
L.push(tally?.g1Waived
|
|
1255
|
+
? ' 이 판정은 자기 표 배제가 해제되어 있어 승계로 들어온 사람의 자기 표도 셉니다 (위 🔴).'
|
|
1256
|
+
: ' 승계로 들어온 사람도 자기 표는 세지 않습니다 (G1).')
|
|
1177
1257
|
L.push('')
|
|
1178
1258
|
}
|
|
1179
1259
|
if (roster.short > 0) {
|
package/tools/bus.mjs
CHANGED
|
@@ -633,6 +633,61 @@ function markSeen(who, id) {
|
|
|
633
633
|
const stamp = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
|
|
634
634
|
const slug = (s) => s.toLowerCase().replace(/[^\w가-힣]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'msg'
|
|
635
635
|
|
|
636
|
+
/**
|
|
637
|
+
* 시간대 치우침을 `+09:00` 처럼 적는다. 동쪽이 `+`, 서쪽이 `-` 다.
|
|
638
|
+
*
|
|
639
|
+
* 🔴 `getTimezoneOffset()` 은 **부호가 반대**다 — 서울에서 `-540`(분) 을 낸다.
|
|
640
|
+
* "이 시각을 UTC 로 만들려면 몇 분을 더해야 하나" 를 답하기 때문이다.
|
|
641
|
+
* 그래서 뒤집는다. 여기서 부호를 놓치면 화면에 `-09:00` 이 찍혀, 고치려던
|
|
642
|
+
* 것과 똑같은 병이 된다 — **시각은 맞는데 시간대가 거짓말**.
|
|
643
|
+
*
|
|
644
|
+
* 30분·45분 단위 시간대(인도 `+05:30`, 네팔 `+05:45`)도 그대로 나온다.
|
|
645
|
+
*/
|
|
646
|
+
function zoneOffset(d) {
|
|
647
|
+
const mins = -d.getTimezoneOffset()
|
|
648
|
+
const sign = mins < 0 ? '-' : '+'
|
|
649
|
+
const abs = Math.abs(mins)
|
|
650
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
651
|
+
return `${sign}${p(Math.floor(abs / 60))}:${p(abs % 60)}`
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* 쪽지 시각을 **이 PC 의 시간대**로 그린다.
|
|
656
|
+
*
|
|
657
|
+
* 🔴 **여기 있던 버그다.** `m.at.slice(0, 16).replace('T', ' ')` 였다. 저장된
|
|
658
|
+
* UTC 문자열을 앞에서 잘라 놓고 그것을 지역 시각인 척 보여준 것이라, 서울에서
|
|
659
|
+
* **9시간 이른 시각**이 떴다.
|
|
660
|
+
*
|
|
661
|
+
* 실측 (2026-09-07): 파일 이름이 `20260903T071235Z` 인 쪽지가 목록에
|
|
662
|
+
* `2026-09-03 07:12` 로 떴다. 한국 시간으로는 `16:12` 다. 오후에 온 쪽지가
|
|
663
|
+
* 새벽에 온 것으로 보이니, 받은 사람은 그것을 **이미 지나간 일**로 여기고
|
|
664
|
+
* 넘긴다. 틀린 시각은 없는 시각보다 나쁘다 — 없으면 물어보지만 있으면 믿는다.
|
|
665
|
+
*
|
|
666
|
+
* 🔴 **저장은 그대로 UTC 다.** 파일 이름의 `…Z` 도 머리말의 `at:` 도 안 건드린다.
|
|
667
|
+
* 바뀌는 것은 **보여줄 때** 하나뿐이다. 저장을 지역 시간으로 옮기면 (1) 파일
|
|
668
|
+
* 이름이 곧 정렬 키이자 읽음 표시의 기준(`id <= seen`)인데 시간대가 다른 PC
|
|
669
|
+
* 끼리 순서가 어긋나고, (2) 이미 쌓인 쪽지가 전부 다른 시각을 뜻하게 된다.
|
|
670
|
+
*
|
|
671
|
+
* 🔴 `Asia/Seoul` 을 코드에 박지 않는다. 팀원이 다른 시간대에 있으면 그 사람
|
|
672
|
+
* 시계로 보여야 맞다. PC 의 시간대를 따른다.
|
|
673
|
+
*
|
|
674
|
+
* 🔴 `Intl` 을 안 쓴다. Node 를 small-icu 로 빌드하면 시간대 이름이 `GMT+9` 로만
|
|
675
|
+
* 나오거나 아예 없다. `Date` 의 지역 getter 와 `getTimezoneOffset()` 은 어느
|
|
676
|
+
* 빌드에도 있고 로케일에 따라 흔들리지도 않는다.
|
|
677
|
+
*
|
|
678
|
+
* 못 읽는 값이면 **원문을 그대로 돌려준다.** 지어내지 않는다 — 시각을 그럴듯하게
|
|
679
|
+
* 틀리게 보여주는 것이 이 함수가 고치러 온 버그다.
|
|
680
|
+
*/
|
|
681
|
+
function localTime(iso, { seconds = false } = {}) {
|
|
682
|
+
if (!iso) return '(시각 없음)'
|
|
683
|
+
const d = new Date(iso)
|
|
684
|
+
if (Number.isNaN(d.getTime())) return String(iso)
|
|
685
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
686
|
+
const date = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
687
|
+
const time = `${p(d.getHours())}:${p(d.getMinutes())}${seconds ? `:${p(d.getSeconds())}` : ''}`
|
|
688
|
+
return `${date} ${time} ${zoneOffset(d)}`
|
|
689
|
+
}
|
|
690
|
+
|
|
636
691
|
function readAll() {
|
|
637
692
|
const found = []
|
|
638
693
|
let newBoxOpened = false
|
|
@@ -926,7 +981,12 @@ function list() {
|
|
|
926
981
|
return has('--quiet-if-empty') ? undefined : console.log('쪽지 없음.')
|
|
927
982
|
}
|
|
928
983
|
for (const m of rows) {
|
|
929
|
-
|
|
984
|
+
// 🔴 시간대 표시(`+09:00`)를 **줄마다** 붙인다. 목록 끝에 한 번만 적으면 안
|
|
985
|
+
// 되는 이유가 있다 — 이 줄들은 그대로 **잘려서 다른 곳에 다시 실린다.**
|
|
986
|
+
// MCP 배너(`mcp/server.mjs` 의 `busRows`)와 프롬프트 훅은 화살표가 있는
|
|
987
|
+
// 제목 줄만 가져가고 아래 요약 줄은 안 가져간다. 거기서 시간대가 사라지면
|
|
988
|
+
// 사람이 실제로 보는 자리에서 다시 "이게 무슨 시간인가" 가 된다.
|
|
989
|
+
console.log(`${localTime(m.at)} ${(m.from ?? '?').padEnd(18)} → ${(m.to ?? 'all').padEnd(18)} ${m.subject ?? ''}`)
|
|
930
990
|
console.log(` ${m.id}`)
|
|
931
991
|
}
|
|
932
992
|
console.log(`\n총 ${rows.length}개. 본문: ${selfCmd()} read <아이디>`)
|
|
@@ -953,7 +1013,10 @@ function read(id) {
|
|
|
953
1013
|
// 아이디를 잘못 적었다고 믿고 다시 치게 되는데, 몇 번을 쳐도 같은 답이 온다.
|
|
954
1014
|
if (!m && busProblem) { reportBusProblem(); process.exit(1) }
|
|
955
1015
|
if (!m) { console.error(`없는 쪽지: ${id}`); process.exit(1) }
|
|
956
|
-
|
|
1016
|
+
// 목록과 **같은 시간대**로 찍는다. 한쪽만 고치면 목록과 본문이 서로 다른 시각을
|
|
1017
|
+
// 말하게 되고, 숫자가 둘이면 사람은 어느 쪽도 안 믿는다. 본문은 한 통뿐이라
|
|
1018
|
+
// 초까지 보여준다 — 쪽지가 오간 순서를 따질 때 분 단위로는 모자란다.
|
|
1019
|
+
console.log(`── ${m.subject}\n ${m.from} → ${m.to} ${localTime(m.at, { seconds: true })}\n`)
|
|
957
1020
|
console.log(m.body)
|
|
958
1021
|
}
|
|
959
1022
|
|