axmap-cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude/commands/ax-done.md +10 -0
  2. package/.claude/commands/ax-setup.md +31 -0
  3. package/.claude/commands/ax-start.md +15 -0
  4. package/.claude/commands/ax-tell.md +14 -0
  5. package/.claude/commands/ax-update.md +19 -0
  6. package/.claude/commands/ax.md +13 -0
  7. package/CLAUDE.md +309 -0
  8. package/LICENSE +20 -0
  9. package/README.md +207 -0
  10. package/app/README.md +366 -0
  11. package/app/eval/edges.mjs +242 -0
  12. package/app/lib/adjacent.mjs +125 -0
  13. package/app/lib/agentcli.mjs +153 -0
  14. package/app/lib/analyze.mjs +1159 -0
  15. package/app/lib/cochange.mjs +421 -0
  16. package/app/lib/datanodes.mjs +127 -0
  17. package/app/lib/entry.mjs +192 -0
  18. package/app/lib/featuregraph.mjs +389 -0
  19. package/app/lib/features.mjs +645 -0
  20. package/app/lib/fetchrepo-run.mjs +37 -0
  21. package/app/lib/fetchrepo.mjs +164 -0
  22. package/app/lib/flow.mjs +1089 -0
  23. package/app/lib/ladder.mjs +387 -0
  24. package/app/lib/langs.mjs +630 -0
  25. package/app/lib/live.mjs +346 -0
  26. package/app/lib/llm.mjs +594 -0
  27. package/app/lib/newfile.mjs +126 -0
  28. package/app/lib/prdiff.mjs +651 -0
  29. package/app/lib/reveal.mjs +316 -0
  30. package/app/lib/roots.mjs +186 -0
  31. package/app/lib/scope.mjs +342 -0
  32. package/app/lib/session.mjs +389 -0
  33. package/app/lib/slots.mjs +233 -0
  34. package/app/lib/ssot.mjs +277 -0
  35. package/app/lib/teamview.mjs +962 -0
  36. package/app/lib/terms.ko.mjs +169 -0
  37. package/app/server.mjs +1959 -0
  38. package/app/web/shell.css +538 -0
  39. package/app/web/shell.html +197 -0
  40. package/app/web/shell.js +638 -0
  41. package/app/web/stage.js +347 -0
  42. package/app/web/words.js +85 -0
  43. package/bin/axmap.mjs +1918 -0
  44. package/governance/GOVERNANCE.md +433 -0
  45. package/governance/gate.mjs +526 -0
  46. package/governance/vote.mjs +501 -0
  47. package/mcp/README.md +254 -0
  48. package/mcp/SETUP-FOR-AI.md +186 -0
  49. package/mcp/install.ps1 +341 -0
  50. package/mcp/install.sh +339 -0
  51. package/mcp/server.mjs +969 -0
  52. package/package.json +48 -0
  53. package/src/closure.mjs +343 -0
  54. package/src/governance.mjs +839 -0
  55. package/src/invariants.mjs +226 -0
  56. package/src/mrtarget.mjs +284 -0
  57. package/src/promote.mjs +177 -0
  58. package/src/protocol.mjs +423 -0
  59. package/src/repotarget.mjs +81 -0
  60. package/src/update.mjs +177 -0
  61. package/src/version.mjs +186 -0
  62. package/tools/bus.mjs +520 -0
  63. package/tools/cluster-experiment.mjs +256 -0
  64. package/tools/cluster-sweep.mjs +226 -0
  65. package/tools/make-icon.mjs +108 -0
  66. package/tools/mcp-register.mjs +269 -0
  67. package/tools/mr-target.mjs +49 -0
  68. package/tools/persona-bench.mjs +362 -0
  69. package/tools/pick-repo.mjs +229 -0
  70. package/tools/promote.mjs +550 -0
  71. package/tools/reveal-demo.mjs +158 -0
  72. package/tools/run-tests.mjs +42 -0
  73. package/tools/setup.mjs +490 -0
  74. package/tools/shortcut.mjs +121 -0
  75. package/tools/smoke.mjs +166 -0
  76. package/tools/topicgraph.py +154 -0
  77. package/tools/vendor.mjs +382 -0
  78. package/tools/version.mjs +115 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * 불변식 검사기.
3
+ *
4
+ * docs/INVARIANTS.md 의 I1~I8 을 코드로 옮긴 것이다.
5
+ * 모델 검증기(test/model.test.mjs), 카오스 테스트(demo/chaos.sh),
6
+ * 사후 감사(axmap audit)가 **같은 검사기**를 쓴다.
7
+ * 도구마다 다른 판정을 하면 무엇이 맞는지 알 수 없게 된다.
8
+ *
9
+ * 여기도 순수하다. git 도 파일시스템도 없다.
10
+ */
11
+
12
+ import {
13
+ DEFAULT_MERGE_POLICY,
14
+ activeClaims,
15
+ claimExpiresAt,
16
+ normalizePath,
17
+ pathsOverlap,
18
+ } from './protocol.mjs'
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // I1 · 상호배제 (상태 불변식)
22
+ // 어느 시점에도, 서로 겹치는 경로를 유효하게 소유한 에이전트는 최대 1명이다.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export function mutualExclusionViolations(claims, now) {
26
+ const live = activeClaims(claims, now)
27
+ const out = []
28
+ for (let i = 0; i < live.length; i++) {
29
+ for (let j = i + 1; j < live.length; j++) {
30
+ const a = live[i]
31
+ const b = live[j]
32
+ if (a.agent === b.agent) continue
33
+ for (const pa of a.paths.map(normalizePath)) {
34
+ for (const pb of b.paths.map(normalizePath)) {
35
+ if (pathsOverlap(pa, pb)) {
36
+ out.push({
37
+ invariant: 'I1',
38
+ message: `${pa} 와 ${pb} 를 ${a.agent} 와 ${b.agent} 가 동시에 소유`,
39
+ agents: [a.agent, b.agent],
40
+ paths: [pa, pb],
41
+ windows: [
42
+ { agent: a.agent, since: a.since, until: new Date(claimExpiresAt(a)).toISOString() },
43
+ { agent: b.agent, since: b.since, until: new Date(claimExpiresAt(b)).toISOString() },
44
+ ],
45
+ })
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ return out
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // I2 · 무손실 (전이 불변식)
56
+ // claim 이 성공을 반환했으면, 요청한 경로는 반드시 내 레코드에 있다.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export function lossViolations({ requested, record }) {
60
+ const have = new Set(record.paths.map(normalizePath))
61
+ return requested
62
+ .map(normalizePath)
63
+ .filter((p) => !have.has(p))
64
+ .map((p) => ({
65
+ invariant: 'I2',
66
+ message: `claim 이 성공했는데 ${p} 가 레코드에 없다`,
67
+ agents: [record.agent],
68
+ paths: [p],
69
+ }))
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // I6 · 비부활 (전이 불변식)
74
+ // 성공한 claim 의 경로는 (요청한 경로 ∪ 직전 "유효" claim 의 경로) 안에 있어야 한다.
75
+ // 만료된 내 레코드에서 넘어온 경로는 관문 2 를 거치지 않았으므로 부활이다.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export function resurrectionViolations({ before, after, requested, me, now }) {
79
+ const prev = activeClaims(before, now).find((c) => c.agent === me)
80
+ const allowed = new Set([
81
+ ...requested.map(normalizePath),
82
+ ...(prev?.paths ?? []).map(normalizePath),
83
+ ])
84
+ const mine = after.find((c) => c.agent === me)
85
+ if (!mine) return []
86
+ return mine.paths
87
+ .map(normalizePath)
88
+ .filter((p) => !allowed.has(p))
89
+ .map((p) => ({
90
+ invariant: 'I6',
91
+ message: `${p} 가 만료된 레코드에서 검사 없이 부활했다`,
92
+ agents: [me],
93
+ paths: [p],
94
+ }))
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // I7 · 세션 이름 유일성 (상태 불변식)
99
+ // 한 슬롯 레지스트리 안에서 동시에 살아있는 두 세션은 서로 다른 AXMAP_AGENT 를 갖는다.
100
+ //
101
+ // 🔴 "모든 에이전트의 이름이 다르다" 는 여기에 적을 수 없다. 사람이 두 셸에
102
+ // 같은 AXMAP_AGENT 를 넣으면 시스템이 막을 방법이 없고, 검사할 수 없는 문장을
103
+ // 불변식이라고 적어두면 그 목록 전체의 신뢰가 떨어진다.
104
+ // 시스템이 **실제로 보장할 수 있는 것**은 자기가 띄운 세션들의 이름뿐이다.
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export function sessionIdentityViolations(sessions) {
108
+ const out = []
109
+ const seen = new Map()
110
+ for (const s of sessions ?? []) {
111
+ const id = s?.id ?? null
112
+ const name = typeof s?.agent === 'string' ? s.agent.trim() : ''
113
+ /**
114
+ * 이름이 없는 것을 건너뛰지 않는다. 이름이 없는 세션은 자식이 서버의 환경을
115
+ * 그대로 물려받는다는 뜻이고, 그것이 이 불변식을 만든 버그 그 자체다.
116
+ * 여기서 조용히 넘기면 "위반 0" 이 "아무 문제 없음" 으로 읽힌다.
117
+ */
118
+ if (!name) {
119
+ out.push({
120
+ invariant: 'I7',
121
+ message: `세션 ${id} 에 이름이 없다 — 자식이 서버의 AXMAP_AGENT 를 그대로 물려받는다`,
122
+ agents: [],
123
+ sessions: [id],
124
+ })
125
+ continue
126
+ }
127
+ const prev = seen.get(name)
128
+ if (prev !== undefined && prev !== id) {
129
+ out.push({
130
+ invariant: 'I7',
131
+ message: `세션 ${prev} 와 ${id} 가 같은 이름 "${name}" 을 쓴다 — 서로를 겹침으로 보지 못한다`,
132
+ agents: [name],
133
+ sessions: [prev, id],
134
+ })
135
+ continue
136
+ }
137
+ seen.set(name, id)
138
+ }
139
+ return out
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // I8 · 게이트 우선 (전이 불변식)
144
+ // 게이트가 하나라도 빨갛거나 빠진 제안은 어떤 표 조합으로도 병합되지 않는다.
145
+ //
146
+ // I5(강제)와 같은 자리에 있다. I5 가 없으면 claim 이 권고 사항이 되듯,
147
+ // I8 이 없으면 게이트가 권고 사항이 된다. 그리고 "작업 노드가 없어도 도는
148
+ // 시스템" 의 실패 모드는 멈춤이 아니라 조용한 부패다 - 아무도 안 볼 때
149
+ // 자동 워커가 빨간 것을 밀어 넣고, 그것을 아무도 모른다.
150
+ //
151
+ // checkMerge 를 부르지 않고 게이트만 다시 본다. 판정을 만든 함수로 그 판정을
152
+ // 검사하면 검사 대상이 제품이 아니라 검사기가 된다 (모델 검증기의 oracle 원칙).
153
+ // ---------------------------------------------------------------------------
154
+
155
+ export function gatePrecedenceViolations({ merge, policy = DEFAULT_MERGE_POLICY }) {
156
+ const gates = merge.gates ?? {}
157
+ const out = []
158
+ for (const name of policy.requiredGates) {
159
+ const g = gates[name]
160
+ if (g && g.ok) continue
161
+ out.push({
162
+ invariant: 'I8',
163
+ message: g
164
+ ? `게이트 ${name} 가 빨간 채로 ${merge.id} 가 병합됐다`
165
+ : `게이트 ${name} 를 돌리지 않은 채 ${merge.id} 가 병합됐다`,
166
+ agents: merge.agent ? [merge.agent] : [],
167
+ paths: merge.paths ?? [],
168
+ gate: name,
169
+ })
170
+ }
171
+ return out
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // 사후 감사
176
+ //
177
+ // 장부 브랜치는 모든 claim 변화를 커밋으로 기록한 완전한 감사 로그다.
178
+ // 스냅샷을 순서대로 재생하면 상호배제가 깨진 적이 있는지 사후에 증명할 수 있다.
179
+ // 구현을 믿을 필요가 없어진다는 것이 이 검사의 핵심이다.
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * 스냅샷의 논리 시각.
184
+ *
185
+ * 커밋 시각을 그대로 쓰지 않는 이유: 테스트가 AXMAP_NOW 로 시계를 옮기면
186
+ * 커밋 시각(실제 벽시계)과 레코드의 since(논리 시각)가 어긋난다.
187
+ * since 는 논리적으로 미래일 수 없으므로, 둘 중 늦은 쪽이 그 스냅샷의 시각이다.
188
+ * 실제 사용에서는 항상 since <= 커밋 시각이므로 커밋 시각과 같다.
189
+ */
190
+ export function snapshotTime(snapshot) {
191
+ const sinces = snapshot.claims.map((c) => Date.parse(c.since)).filter((n) => !Number.isNaN(n))
192
+ return Math.max(snapshot.time, ...(sinces.length ? sinces : [snapshot.time]))
193
+ }
194
+
195
+ /**
196
+ * 시간이 흐르는 것만으로는 겹침이 생기지 않는다 (만료는 claim 을 없앨 뿐이다).
197
+ * 겹침은 오직 claim 이 추가될 때 생기고, claim 은 커밋으로만 추가된다.
198
+ * 따라서 각 커밋 시점만 검사하면 전체 구간을 덮는다.
199
+ */
200
+ export function auditLedger(snapshots) {
201
+ const violations = []
202
+ for (const snap of snapshots) {
203
+ const t = snapshotTime(snap)
204
+ for (const v of mutualExclusionViolations(snap.claims, t)) {
205
+ violations.push({ ...v, commit: snap.commit, subject: snap.subject, at: new Date(t).toISOString() })
206
+ }
207
+ }
208
+ return { ok: violations.length === 0, checked: snapshots.length, violations }
209
+ }
210
+
211
+ export function formatAudit(report) {
212
+ if (report.ok) {
213
+ return (
214
+ `감사 통과 - 스냅샷 ${report.checked}개\n` +
215
+ '장부 이력 전체에서 상호배제(I1)가 깨진 시점이 없습니다.'
216
+ )
217
+ }
218
+ const lines = [`감사 실패 - 스냅샷 ${report.checked}개 중 위반 ${report.violations.length}건\n`]
219
+ for (const v of report.violations) {
220
+ lines.push(` x [${v.invariant}] ${v.at} commit ${v.commit.slice(0, 7)} ${v.subject}`)
221
+ lines.push(` ${v.message}`)
222
+ for (const w of v.windows ?? []) lines.push(` ${w.agent}: ${w.since} ~ ${w.until}`)
223
+ lines.push('')
224
+ }
225
+ return lines.join('\n')
226
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * MR(**Merge Request** — 내 브랜치의 변경을 다른 브랜치로 합쳐 달라는 요청)이
3
+ * **한 칸씩만 올라가게** 하는 판정.
4
+ *
5
+ * 순수 로직만 둔다. git 호출도 환경변수도 여기 없다 (`tools/mr-target.mjs` 가 담당).
6
+ *
7
+ * ── 왜 설정이 아니라 코드인가 ────────────────────────────────────────────────
8
+ *
9
+ * 이 GitLab 은 Community Edition(무료판)이라 **"이 브랜치로는 MR 을 못 연다"
10
+ * 는 설정이 없다.** 대상 브랜치를 제한하는 기능 자체가 없고, 있는 것은
11
+ * 기본 대상 브랜치를 정해 두는 것뿐이다 — 사람이 드롭다운에서 바꾸면 그만이다.
12
+ *
13
+ * 그래서 잡(job — 파이프라인이 돌리는 작업 하나)으로 만든다. 루트 `CLAUDE.md`
14
+ * 3절이 말하는 그대로다: 권한은 사람의 목록이고 파이프라인은 코드의 조건이다.
15
+ *
16
+ * ── 왜 단계 판정을 새로 안 만드는가 ───────────────────────────────────────────
17
+ *
18
+ * 🔴 `levelOf` 를 `src/version.mjs` 에서 **가져다 쓴다.** 여기서 다시 만들면
19
+ * 한 저장소에 브랜치 단계 판정이 둘이 되고, 그러면 `back/main` 이 최상위인지
20
+ * 파트인지를 **버전 자동화와 MR 검사가 서로 다르게 답할 수 있다.**
21
+ * 거버넌스가 경로 매칭을 새로 안 만들고 `protocol.mjs` 의 `coversPath` 를
22
+ * 그대로 쓴 것과 같은 이유다.
23
+ */
24
+
25
+ import { levelOf, partOf, isNonProductPart } from './version.mjs'
26
+
27
+ // 🔴 `partOf` 는 `version.mjs` 로 옮겼다. 여기서 다시 내보내는 것은 이미 이 모듈에서
28
+ // 가져다 쓰는 곳(`src/promote.mjs`, `test/mrtarget.test.mjs`)을 깨지 않기 위해서다.
29
+ export { partOf }
30
+
31
+ /**
32
+ * 종료 코드. 선점 프로토콜·거버넌스와 같은 관례다.
33
+ *
34
+ * 🔴 **2 와 1 을 가르는 것이 핵심이다.**
35
+ * 2 는 "대상 브랜치를 바꾸면 되는 일", 1 은 "내가 판정을 못 했다".
36
+ * 뭉치면 AI 에이전트가 고장을 **고치면 되는 일**로 읽고 엉뚱한 곳을 고친다.
37
+ */
38
+ export const EXIT = {
39
+ OK: 0,
40
+ /** 규칙 위반 — 사람이 MR 대상을 바꾸면 풀린다 */
41
+ BLOCKED: 2,
42
+ /** 판정 불가 — 브랜치 이름을 못 받았다. 환경을 고쳐야 한다 */
43
+ UNDECIDABLE: 1,
44
+ }
45
+
46
+ /**
47
+ * MR 의 소스도 대상도 될 수 없는 브랜치.
48
+ *
49
+ * 장부(`axmap/claims`)와 표(`axmap/votes`)는 **코드와 이력을 공유하지 않는
50
+ * 고아 브랜치**(orphan branch — 부모 커밋이 없어 코드 브랜치와 아예 갈라져 있는
51
+ * 브랜치)다. 도구가 직접 쓰고 읽는 자리이지 사람이 합치는 자리가 아니다.
52
+ *
53
+ * 이름만 보면 `axmap/claims` 는 "axmap 파트의 claims 브랜치" 처럼 생겼다.
54
+ * 그래서 규칙에 안 걸리고 기능 브랜치로 취급돼 **`<파트>/dev` 로 올릴 수 있게
55
+ * 된다.** 명시적으로 막지 않으면 그 길이 열려 있다.
56
+ */
57
+ const RESERVED = new Set(['axmap/claims', 'axmap/votes', 'axmap/bus'])
58
+
59
+ /**
60
+ * 긴급 수정 브랜치인가 — `hotfix/…` 로 시작하는가.
61
+ *
62
+ * ── 🔴 이 판정은 사다리에 **구멍을 낸다.** 그 대가를 알고 낸 것이다 ──────────
63
+ *
64
+ * 사다리(기능 → `<파트>/dev` → `<파트>/main` → `main`)의 요점은 "아래 관문을
65
+ * 지난 것만 위로 간다" 이다. 여기만 그 규칙을 어긴다. 왜 어기는가.
66
+ *
67
+ * **고장 난 것이 `main` 자신일 때 사다리가 막다른 길이 되기 때문이다.**
68
+ * 2026-08-26 에 실제로 그랬다 — `main` 의 `.gitlab-ci.yml` 이 무효라 모든
69
+ * 파이프라인이 잡 0개로 죽었는데, 그것을 고치는 MR 이 `verify:mr-target` 에
70
+ * 걸려 `main` 으로 갈 수 없었다(S15P21E201-22). `Pipelines must succeed` 가
71
+ * 켜져 있었다면 **팀 전체가 CI 를 영영 못 고쳤을 것이다.**
72
+ *
73
+ * 탈출구가 없으면 사람은 시스템 밖에서 탈출한다 — 스위치를 잠깐 내리거나
74
+ * `main` 에 직접 push 한다. 둘 다 흔적이 안 남는다. **탈출구를 시스템 안에 두는
75
+ * 편이 낫다.** 보이고, 기록되고, 규칙으로 다듬을 수 있다.
76
+ *
77
+ * 남는 브레이크가 둘 있어서 이 구멍이 공짜 통과가 되지는 않는다.
78
+ * · `main` 으로 가는 MR 은 `governance` 가 정족수를 요구한다 (표 없이는 못 간다)
79
+ * · 브랜치 이름이 `hotfix/…` 라 diff 목록과 이력에 그대로 남는다
80
+ *
81
+ * 🔴 이름만 보고 판정한다 — 내용이 정말 긴급한지는 코드가 알 수 없다.
82
+ * 그 판단은 표를 던지는 사람의 몫이고, 여기서 하려 들면 못 하는 판정을
83
+ * 하는 척하게 된다.
84
+ *
85
+ * 규약에는 원래부터 있던 타입이다 — `docs/git-convention.md` 의
86
+ * *"`hotfix`: 운영 중인 `main`의 긴급 수정"*. 게이트만 그것을 몰랐다.
87
+ */
88
+ function isHotfix(branch) {
89
+ return /^hotfix\//.test(branch)
90
+ }
91
+
92
+ /**
93
+ * 이 브랜치가 무엇인지 사람이 읽는 말로.
94
+ *
95
+ * `levelOf` 가 주는 `main`·`func`·`dev`·`null` 을 그대로 노출하지 않는 이유는
96
+ * `null` 이 "기능 브랜치" 라는 뜻인데 그 말이 어디에도 안 적혀 있어서다.
97
+ */
98
+ export function describeBranch(branch) {
99
+ const name = String(branch ?? '').trim()
100
+ if (!name) return '이름 없음'
101
+ if (RESERVED.has(name)) return '도구 전용 브랜치'
102
+ switch (levelOf(name)) {
103
+ case 'main': return '최상위'
104
+ case 'func': return '파트 브랜치'
105
+ case 'dev': return '파트 개발 브랜치'
106
+ default: return '기능 브랜치'
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 이 브랜치를 어디로 올릴 수 있나 — 사람이 읽는 한 줄.
112
+ *
113
+ * 목록이 아니라 문장인 이유: 기능 브랜치가 갈 수 있는 곳은 "존재하는 모든 파트의
114
+ * dev" 라 목록으로 적으려면 파트 목록을 알아야 하고, 그건 순수 판정이 알 수 없다.
115
+ */
116
+ export function allowedTargetsOf(branch) {
117
+ const name = String(branch ?? '').trim()
118
+ if (RESERVED.has(name)) return '없다 — 도구가 직접 쓰는 브랜치라 MR 로 합치지 않는다'
119
+ switch (levelOf(name)) {
120
+ case 'main': return '없다 — 최상위가 종점이다'
121
+ case 'func':
122
+ return isNonProductPart(name)
123
+ ? '없다 — 이 파트에는 파트 브랜치 단계가 없다. `<파트>/dev` 에서 바로 올린다'
124
+ : '최상위 `main`'
125
+ case 'dev': {
126
+ // 제품이 아닌 파트는 파트 단계를 건너뛴다 — 갈 곳이 최상위뿐이다.
127
+ if (isNonProductPart(name)) return '최상위 `main` — 이 파트에는 파트 브랜치 단계가 없다'
128
+ const part = partOf(name)
129
+ return part ? `같은 파트의 \`${part}/main\` (= \`${part}/func\`)` : '같은 파트의 파트 브랜치'
130
+ }
131
+ default:
132
+ return isHotfix(name)
133
+ ? '`<파트>/dev`, 또는 `main` 이 그 자체로 고장 났다면 최상위 `main`'
134
+ : '`<파트>/dev` — 예: `front/dev` · `back/dev`'
135
+ }
136
+ }
137
+
138
+ /**
139
+ * 이 MR 이 한 칸씩 올라가는가.
140
+ *
141
+ * @param {string} source 합쳐 달라고 내미는 쪽 브랜치
142
+ * @param {string} target 합쳐 받는 쪽 브랜치
143
+ * @returns {{ok:boolean, exit:number, code:string, message:string, source?:string, target?:string}}
144
+ */
145
+ export function checkTarget(source, target) {
146
+ const s = String(source ?? '').trim()
147
+ const t = String(target ?? '').trim()
148
+
149
+ // 🔴 이름을 못 받은 것은 통과가 아니다. 못 재면 막는다 — 락에서 최악은 조용한 통과다.
150
+ if (!s || !t) {
151
+ return {
152
+ ok: false,
153
+ exit: EXIT.UNDECIDABLE,
154
+ code: 'branch-unknown',
155
+ message:
156
+ '소스 또는 대상 브랜치 이름을 받지 못했습니다.\n' +
157
+ ' 추측하지 않습니다 — 단계를 잘못 읽으면 막아야 할 MR 을 통과시킵니다.',
158
+ }
159
+ }
160
+
161
+ const bad = (code, message) => ({ ok: false, exit: EXIT.BLOCKED, code, message, source: s, target: t })
162
+ const good = (message) => ({ ok: true, exit: EXIT.OK, code: 'ok', message, source: s, target: t })
163
+
164
+ if (s === t) return bad('same-branch', '소스와 대상이 같은 브랜치입니다.')
165
+ if (RESERVED.has(s)) return bad('reserved-source', `\`${s}\` 는 도구가 직접 쓰는 브랜치라 MR 의 소스가 될 수 없습니다.`)
166
+ if (RESERVED.has(t)) return bad('reserved-target', `\`${t}\` 는 도구가 직접 쓰는 브랜치라 MR 의 대상이 될 수 없습니다.`)
167
+
168
+ const from = levelOf(s)
169
+ const to = levelOf(t)
170
+
171
+ // 기능 브랜치 → 파트 dev. 그리고 hotfix 만 최상위로도 갈 수 있다.
172
+ if (from === null) {
173
+ if (to === 'dev') return good('기능 브랜치 → 파트 개발 브랜치')
174
+ if (to === 'main' && isHotfix(s)) return good('긴급 수정 → 최상위 (사다리를 건너뛴다)')
175
+ return bad(
176
+ 'feature-must-target-dev',
177
+ '기능 브랜치는 파트 개발 브랜치(`<파트>/dev`)로만 올립니다.\n' +
178
+ ' 한 칸씩 올라가야 아래 관문을 지난 것만 위로 갑니다 — 건너뛰면 그 관문이 없는 것과 같습니다.\n' +
179
+ ' `main` 이 그 자체로 고장 났을 때만 `hotfix/…` 로 바로 올립니다.',
180
+ )
181
+ }
182
+
183
+ // 파트 dev → 같은 파트의 파트 브랜치.
184
+ if (from === 'dev') {
185
+ // 🔴 제품이 아닌 파트(`common` 등)에는 파트 브랜치 단계가 **없다.**
186
+ //
187
+ // `<파트>/main` 의 뜻은 "이 파트의 릴리스 후보" 다. 릴리스할 제품이 없는
188
+ // 파트에는 그 자리가 비어 있고, 비어 있는 칸을 지나가라고 요구하면 사다리가
189
+ // **막다른 길**이 된다 — 2026-08-27 에 실제로 그랬다. 팀 문서가 `common/dev`
190
+ // 까지 올라온 뒤, `common/main` 이 없어서 `main` 으로 갈 길이 사라졌다.
191
+ // 그래서 문서는 clone 하면 딸려오는 `main` 에 영영 못 들어갔다.
192
+ //
193
+ // 🔴 **통과가 늘어난다.** 그 대가를 알고 늘린다 — `main` 으로 가는 MR 은
194
+ // `governance` 가 정족수를 요구하므로 표 없이는 여전히 못 간다.
195
+ // 사다리가 지키려던 것("아래 관문을 지난 것만 위로")은 그대로 있고,
196
+ // 없는 칸을 요구하지 않을 뿐이다.
197
+ if (isNonProductPart(s)) {
198
+ if (to === 'main') return good('제품이 아닌 파트의 개발 브랜치 → 최상위 (파트 단계가 없다)')
199
+ return bad(
200
+ 'nonproduct-must-target-top',
201
+ `\`${partOf(s)}\` 는 제품 코드가 없는 파트라 **파트 브랜치 단계가 없습니다.**\n` +
202
+ ' 문서·CI·공용 설정 자리이고, 버전도 갖지 않습니다.\n' +
203
+ ' 최상위 `main` 으로 바로 올립니다.',
204
+ )
205
+ }
206
+ if (to !== 'func') {
207
+ return bad(
208
+ 'dev-must-target-part',
209
+ `\`${s}\` 는 같은 파트의 파트 브랜치로만 올립니다.`,
210
+ )
211
+ }
212
+ const a = partOf(s)
213
+ const b = partOf(t)
214
+ // 🔴 파트를 대조하지 않으면 `front/dev → back/main` 이 통과한다.
215
+ // 프론트에서 검토된 적 없는 코드가 백엔드 파트의 릴리스 후보로 들어간다.
216
+ if (a === null || b === null || a !== b) {
217
+ return bad(
218
+ 'part-mismatch',
219
+ `파트가 다릅니다 — \`${a ?? '?'}\` 에서 \`${b ?? '?'}\` 로 건너뛸 수 없습니다.`,
220
+ )
221
+ }
222
+ return good('파트 개발 브랜치 → 같은 파트의 파트 브랜치')
223
+ }
224
+
225
+ // 파트 브랜치 → 최상위.
226
+ if (from === 'func') {
227
+ // 🔴 제품이 아닌 파트에 파트 브랜치가 있다는 것 자체가 실수다. 막고 알린다 —
228
+ // 조용히 통과시키면 `common/main` 이 다시 생기고, 그 브랜치는 아무도
229
+ // 무엇에 쓰는지 모른 채 사다리 한 칸을 더 만든다.
230
+ if (isNonProductPart(s)) {
231
+ return bad(
232
+ 'nonproduct-has-no-part-branch',
233
+ `\`${s}\` 는 만들지 않습니다 — \`${partOf(s)}\` 에는 파트 브랜치 단계가 없습니다.\n` +
234
+ ' `<파트>/dev` 에서 최상위 `main` 으로 바로 올립니다.',
235
+ )
236
+ }
237
+ if (to === 'main') return good('파트 브랜치 → 최상위')
238
+ return bad('part-must-target-main', `\`${s}\` 는 최상위 \`main\` 으로만 올립니다.`)
239
+ }
240
+
241
+ // 최상위에서 나가는 MR 은 없다.
242
+ return bad('top-is-terminal', '최상위 `main` 은 종점입니다. 여기서 나가는 MR 은 없습니다.')
243
+ }
244
+
245
+ /**
246
+ * 사람과 에이전트가 함께 읽는 출력.
247
+ *
248
+ * 메시지를 순수 로직에 두는 것은 이 저장소의 관례다 (`protocol.mjs` 의
249
+ * `formatBlocks`, `governance.mjs` 의 `formatVerdict`). 덕분에 **"거부당했을 때
250
+ * 어디로 가야 하는지가 출력에 있는가" 를 테스트로 고정할 수 있다.**
251
+ */
252
+ export function formatCheck(result) {
253
+ const L = []
254
+ if (result.exit === EXIT.UNDECIDABLE) {
255
+ L.push('MR 대상 검사 불가 — 브랜치 이름을 읽지 못했습니다.')
256
+ L.push('')
257
+ L.push(` ${result.message.split('\n').join('\n ')}`)
258
+ L.push('')
259
+ L.push('환경 문제입니다. 대상 브랜치를 바꿔도 풀리지 않습니다.')
260
+ return L.join('\n')
261
+ }
262
+
263
+ const { source, target } = result
264
+ if (result.ok) {
265
+ L.push(`MR 대상 통과 — ${result.message}`)
266
+ L.push(` ${source} → ${target}`)
267
+ return L.join('\n')
268
+ }
269
+
270
+ L.push('MR 대상 거부 — 한 칸씩만 올라갑니다.')
271
+ L.push('')
272
+ L.push(` 올린 곳 : ${source} (${describeBranch(source)})`)
273
+ L.push(` 올린 대상 : ${target} (${describeBranch(target)})`)
274
+ L.push('')
275
+ L.push(` ${result.message.split('\n').join('\n ')}`)
276
+ L.push('')
277
+ // 🔴 거부하면서 **갈 수 있는 곳을 함께 말한다.** 선점 프로토콜이 거부할 때
278
+ // 점유자·작업·남은 시간을 함께 주는 것과 같은 규칙이다 — 이유 없는 거부는
279
+ // 같은 요청을 다시 보내게 만든다.
280
+ L.push(` \`${source}\` 가 갈 수 있는 곳: ${allowedTargetsOf(source)}`)
281
+ L.push('')
282
+ L.push('MR 의 대상 브랜치를 바꾸고 다시 여세요. 코드를 고칠 일이 아닙니다.')
283
+ return L.join('\n')
284
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * 승격(**promotion** — 아래 단계 브랜치에 쌓인 것을 위 단계로 올리는 것) **계획**.
3
+ *
4
+ * 순수 판정만 둔다. git 도 네트워크도 시계도 여기 없다 (`tools/promote.mjs` 가 담당).
5
+ * 이 저장소가 `src/version.mjs` 와 `tools/version.mjs` 를 가른 것과 같은 이유다 —
6
+ * 하루를 기다리지 않고 "오늘 봇이 무엇을 올릴 것인가" 를 검증할 수 있어야 한다.
7
+ *
8
+ * 봇이 도는 주기는 둘이다.
9
+ *
10
+ * dev-to-part `<파트>/dev` → `<파트>/main` (= `<파트>/func`) 하루 한 번
11
+ * part-to-main `<파트>/main` → 최상위 `main` 주 한 번
12
+ *
13
+ * ── 왜 짝짓기 규칙을 여기서 새로 만들지 않는가 ────────────────────────────────
14
+ *
15
+ * 🔴 만든 짝은 **전부 `checkTarget`(src/mrtarget.mjs)을 지난다.** 여기서 "dev 는
16
+ * 같은 파트의 main 으로" 를 다시 적으면 한 저장소에 브랜치 규칙이 둘이 되고,
17
+ * 그러면 **사람이 연 MR 은 막히는데 봇이 연 MR 은 통과하는** 조합이 생긴다.
18
+ * 봇이 자기 규칙의 예외가 되는 순간 그 규칙은 규칙이 아니다.
19
+ *
20
+ * 같은 이유로 단계 판정도 `levelOf`(src/version.mjs) 를 가져다 쓴다.
21
+ * `back/main` 이 최상위인지 파트인지를 버전 자동화와 봇이 서로 다르게 답하면
22
+ * 안 된다.
23
+ *
24
+ * 도구 전용 브랜치(`axmap/claims`·`axmap/votes`)를 여기서 따로 걸러내지 않는 것도
25
+ * 같은 맥락이다. `checkTarget` 이 이미 막는다 — 두 군데서 막으면 한쪽을 고칠 때
26
+ * 다른 쪽이 남아 "고쳤는데 안 바뀐다" 가 된다.
27
+ */
28
+
29
+ import { levelOf } from './version.mjs'
30
+ import { checkTarget, partOf } from './mrtarget.mjs'
31
+
32
+ /** 봇이 도는 주기. 문자열을 코드 여기저기에 흩지 않는다. */
33
+ export const STEPS = ['dev-to-part', 'part-to-main']
34
+
35
+ /** 사람이 읽는 주기 이름. 로그의 첫 줄이 된다. */
36
+ export function describeStep(step) {
37
+ switch (step) {
38
+ case 'dev-to-part': return '파트 개발 브랜치 → 파트 브랜치 (하루 한 번)'
39
+ case 'part-to-main': return '파트 브랜치 → 최상위 main (주 한 번)'
40
+ default: return '알 수 없는 주기'
41
+ }
42
+ }
43
+
44
+ /** 최상위 브랜치 이름은 저장소마다 다르다 (`main` · `master`). 목록에서 고른다. */
45
+ function topBranchOf(list) {
46
+ if (list.includes('main')) return 'main'
47
+ if (list.includes('master')) return 'master'
48
+ return null
49
+ }
50
+
51
+ /**
52
+ * 브랜치 이름 목록을 다듬는다. 공백·중복·비문자열을 버린다.
53
+ *
54
+ * 버리는 것을 `skipped` 에 적지 않는 이유: 여기서 걸러지는 것은 "브랜치가 아닌 것"
55
+ * 이지 "승격 후보였는데 떨어진 것" 이 아니다. 둘을 한 목록에 담으면 사람이
56
+ * 건너뛴 이유를 읽으러 왔다가 잡음부터 읽게 된다.
57
+ */
58
+ function cleanBranches(branches) {
59
+ const out = []
60
+ const seen = new Set()
61
+ for (const b of branches ?? []) {
62
+ if (typeof b !== 'string') continue
63
+ const name = b.trim()
64
+ if (!name || seen.has(name)) continue
65
+ seen.add(name)
66
+ out.push(name)
67
+ }
68
+ return out.sort()
69
+ }
70
+
71
+ /**
72
+ * 이번 주기에 올릴 짝을 만든다.
73
+ *
74
+ * @param {string[]} branches 저장소에 **실제로 있는** 브랜치 이름 목록
75
+ * @param {{step: string}} opts 주기
76
+ * @returns {{step:string, pairs:Array<{source:string,target:string,reason:string}>,
77
+ * skipped:Array<{source:string,target:string|null,code:string,reason:string}>}}
78
+ *
79
+ * 🔴 `pairs` 만 보고 실행하면 안 되는 이유가 `skipped` 에 있다. "오늘 back 은 왜
80
+ * 안 올라갔나" 의 답이 거기 들어 있고, 그 답이 없으면 사람이 봇을 의심하는 대신
81
+ * 자기 브랜치를 의심한다.
82
+ */
83
+ export function planPromotions(branches, { step } = {}) {
84
+ if (!STEPS.includes(step)) {
85
+ throw new Error(
86
+ `알 수 없는 승격 주기: ${step}\n`
87
+ + ` 쓸 수 있는 것: ${STEPS.join(' | ')}`,
88
+ )
89
+ }
90
+
91
+ const list = cleanBranches(branches)
92
+ const pairs = []
93
+ const skipped = []
94
+ const exists = new Set(list)
95
+
96
+ const drop = (source, target, code, reason) => skipped.push({ source, target, code, reason })
97
+
98
+ /** 짝을 넣기 전에 반드시 여기를 지난다. 봇도 사람과 같은 문을 쓴다. */
99
+ const accept = (source, target, reason) => {
100
+ const verdict = checkTarget(source, target)
101
+ if (!verdict.ok) {
102
+ drop(source, target, `blocked:${verdict.code}`,
103
+ `브랜치 규칙이 막았습니다 — ${verdict.message.split('\n')[0]}`)
104
+ return
105
+ }
106
+ pairs.push({ source, target, reason })
107
+ }
108
+
109
+ if (step === 'dev-to-part') {
110
+ for (const source of list) {
111
+ if (levelOf(source) !== 'dev') continue
112
+ const part = partOf(source)
113
+ if (!part) {
114
+ drop(source, null, 'no-part',
115
+ '파트를 읽을 수 없습니다 — `<파트>/dev` 처럼 앞에 파트 이름이 있어야 합니다.')
116
+ continue
117
+ }
118
+ // 팀 컨벤션은 `<파트>/main` 이고 `<파트>/func` 도 같게 친다 (levelOf 가 둘 다
119
+ // func 으로 읽는다). 둘 다 있으면 main 쪽으로 올린다 — 컨벤션에 적힌 이름이
120
+ // 그쪽이고, 둘로 갈라 올리면 같은 코드가 두 릴리스 후보에 각각 들어간다.
121
+ const main = `${part}/main`
122
+ const func = `${part}/func`
123
+ const target = exists.has(main) ? main : (exists.has(func) ? func : null)
124
+ if (!target) {
125
+ drop(source, null, 'no-target',
126
+ `올릴 곳이 없습니다 — \`${main}\` 도 \`${func}\` 도 아직 없습니다.`)
127
+ continue
128
+ }
129
+ if (target === main && exists.has(func)) {
130
+ drop(source, func, 'duplicate-target',
131
+ `\`${main}\` 과 \`${func}\` 이 둘 다 있어 \`${main}\` 쪽으로만 올립니다.`)
132
+ }
133
+ accept(source, target, `${part} 파트의 하루치`)
134
+ }
135
+ return { step, pairs, skipped }
136
+ }
137
+
138
+ // part-to-main
139
+ const top = topBranchOf(list)
140
+ for (const source of list) {
141
+ if (levelOf(source) !== 'func') continue
142
+ if (!top) {
143
+ drop(source, null, 'no-top-main',
144
+ '최상위 `main` 이 목록에 없습니다 — 올릴 곳을 추측하지 않습니다.')
145
+ continue
146
+ }
147
+ accept(source, top, `${partOf(source) ?? source} 파트의 한 주치`)
148
+ }
149
+ return { step, pairs, skipped }
150
+ }
151
+
152
+ /**
153
+ * 사람과 에이전트가 함께 읽는 계획표.
154
+ *
155
+ * 메시지 생성을 순수 로직에 두는 것은 이 저장소의 관례다 (`formatCheck`,
156
+ * `formatVerdict`). 덕분에 **"건너뛴 이유가 출력에 남는가" 를 테스트로 고정할 수
157
+ * 있다** — 그 줄을 지우면 테스트가 빨개진다.
158
+ */
159
+ export function formatPlan(plan) {
160
+ const L = []
161
+ L.push(`승격 계획 — ${describeStep(plan.step)}`)
162
+ L.push('')
163
+ if (plan.pairs.length === 0) {
164
+ L.push(' 올릴 짝이 없습니다.')
165
+ } else {
166
+ for (const p of plan.pairs) L.push(` o ${p.source} -> ${p.target} (${p.reason})`)
167
+ }
168
+ if (plan.skipped.length) {
169
+ L.push('')
170
+ L.push(' 건너뛴 것')
171
+ for (const s of plan.skipped) {
172
+ const where = s.target ? `${s.source} -> ${s.target}` : s.source
173
+ L.push(` - ${where} [${s.code}] ${s.reason}`)
174
+ }
175
+ }
176
+ return L.join('\n')
177
+ }