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.
- package/.claude/commands/ax-done.md +10 -0
- package/.claude/commands/ax-setup.md +31 -0
- package/.claude/commands/ax-start.md +15 -0
- package/.claude/commands/ax-tell.md +14 -0
- package/.claude/commands/ax-update.md +19 -0
- package/.claude/commands/ax.md +13 -0
- package/CLAUDE.md +309 -0
- package/LICENSE +20 -0
- package/README.md +207 -0
- package/app/README.md +366 -0
- package/app/eval/edges.mjs +242 -0
- package/app/lib/adjacent.mjs +125 -0
- package/app/lib/agentcli.mjs +153 -0
- package/app/lib/analyze.mjs +1159 -0
- package/app/lib/cochange.mjs +421 -0
- package/app/lib/datanodes.mjs +127 -0
- package/app/lib/entry.mjs +192 -0
- package/app/lib/featuregraph.mjs +389 -0
- package/app/lib/features.mjs +645 -0
- package/app/lib/fetchrepo-run.mjs +37 -0
- package/app/lib/fetchrepo.mjs +164 -0
- package/app/lib/flow.mjs +1089 -0
- package/app/lib/ladder.mjs +387 -0
- package/app/lib/langs.mjs +630 -0
- package/app/lib/live.mjs +346 -0
- package/app/lib/llm.mjs +594 -0
- package/app/lib/newfile.mjs +126 -0
- package/app/lib/prdiff.mjs +651 -0
- package/app/lib/reveal.mjs +316 -0
- package/app/lib/roots.mjs +186 -0
- package/app/lib/scope.mjs +342 -0
- package/app/lib/session.mjs +389 -0
- package/app/lib/slots.mjs +233 -0
- package/app/lib/ssot.mjs +277 -0
- package/app/lib/teamview.mjs +962 -0
- package/app/lib/terms.ko.mjs +169 -0
- package/app/server.mjs +1959 -0
- package/app/web/shell.css +538 -0
- package/app/web/shell.html +197 -0
- package/app/web/shell.js +638 -0
- package/app/web/stage.js +347 -0
- package/app/web/words.js +85 -0
- package/bin/axmap.mjs +1918 -0
- package/governance/GOVERNANCE.md +433 -0
- package/governance/gate.mjs +526 -0
- package/governance/vote.mjs +501 -0
- package/mcp/README.md +254 -0
- package/mcp/SETUP-FOR-AI.md +186 -0
- package/mcp/install.ps1 +341 -0
- package/mcp/install.sh +339 -0
- package/mcp/server.mjs +969 -0
- package/package.json +48 -0
- package/src/closure.mjs +343 -0
- package/src/governance.mjs +839 -0
- package/src/invariants.mjs +226 -0
- package/src/mrtarget.mjs +284 -0
- package/src/promote.mjs +177 -0
- package/src/protocol.mjs +423 -0
- package/src/repotarget.mjs +81 -0
- package/src/update.mjs +177 -0
- package/src/version.mjs +186 -0
- package/tools/bus.mjs +520 -0
- package/tools/cluster-experiment.mjs +256 -0
- package/tools/cluster-sweep.mjs +226 -0
- package/tools/make-icon.mjs +108 -0
- package/tools/mcp-register.mjs +269 -0
- package/tools/mr-target.mjs +49 -0
- package/tools/persona-bench.mjs +362 -0
- package/tools/pick-repo.mjs +229 -0
- package/tools/promote.mjs +550 -0
- package/tools/reveal-demo.mjs +158 -0
- package/tools/run-tests.mjs +42 -0
- package/tools/setup.mjs +490 -0
- package/tools/shortcut.mjs +121 -0
- package/tools/smoke.mjs +166 -0
- package/tools/topicgraph.py +154 -0
- package/tools/vendor.mjs +382 -0
- package/tools/version.mjs +115 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 팀 뷰 — D14 3단계("함께 짠다")의 분석 층.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 이 파일이 필요한가 ────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 3단계에서 도구가 하기로 한 일은 이것 하나다.
|
|
7
|
+
*
|
|
8
|
+
* > 각 팀원도 우리 도구를 쓰면 서로가 뭘 하는지 그래프상에서 알 수 있어서
|
|
9
|
+
* > 중복되는 작업을 막을 수 있다.
|
|
10
|
+
*
|
|
11
|
+
* 지금 있는 것은 둘뿐이다.
|
|
12
|
+
*
|
|
13
|
+
* live.mjs 원시 상태 — 누가 어떤 **경로**를 잡았나
|
|
14
|
+
* adjacent.mjs 기능 인접 경고 — 경로는 안 겹치는데 공변경으로 이어진 claim 쌍
|
|
15
|
+
*
|
|
16
|
+
* 둘 다 맞는 답이지만 **사람이 읽는 답이 아니다.** `app/lib/live.mjs` 를
|
|
17
|
+
* 누가 잡았다는 사실은, 그 파일이 무엇인지 아는 사람에게만 정보다.
|
|
18
|
+
* 3단계의 사용자는 2단계를 막 끝낸 사람이고, 그 사람이 아는 것은
|
|
19
|
+
* 파일 이름이 아니라 **기능 이름**이다 (featuregraph.mjs 가 만든 그것).
|
|
20
|
+
*
|
|
21
|
+
* 이 파일은 그 사이를 잇는다. 경로 단위 장부를 기능 단위 문장으로 올린다.
|
|
22
|
+
*
|
|
23
|
+
* ── 🔴 이것은 전부 경고이지 거부가 아니다 ────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* `checkOverlap` 은 한 줄도 건드리지 않는다. adjacent.mjs 가 같은 판단을
|
|
26
|
+
* 이미 적어 뒀고 이유도 그대로다 —
|
|
27
|
+
*
|
|
28
|
+
* ① "경로가 겹치면 거부" 는 결정론이고 증명 가능하다(I1). "기능이 겹치면
|
|
29
|
+
* 거부" 는 lift 문턱이라는 **조절 가능한 숫자**에 기댄다.
|
|
30
|
+
* ② 히스토리가 없는 새 저장소에서는 그 신호가 아예 없다. 락이 그런 것에
|
|
31
|
+
* 기대면 "어떤 저장소에서는 락이 약해진다" 는 상태가 된다.
|
|
32
|
+
*
|
|
33
|
+
* 그래서 여기서 나오는 어떤 값도 게이트로 흘러가면 안 된다. 모든 쌍에
|
|
34
|
+
* `blocking: false` 를 명시적으로 붙여 둔 이유다.
|
|
35
|
+
*
|
|
36
|
+
* ── 🔴 근거 없는 경고는 경고가 아니다 ────────────────────────────────────────
|
|
37
|
+
*
|
|
38
|
+
* 위험도를 네 등급으로 가르고 **등급마다 왜 그렇게 봤는지를 문자열로** 낸다.
|
|
39
|
+
* 등급이 하나뿐이면 사용자는 곧 전부를 같은 소음으로 취급하고, 그때
|
|
40
|
+
* 진짜 경고("같은 파일을 둘이 잡았다")도 같이 묻힌다.
|
|
41
|
+
*
|
|
42
|
+
* ── 🔴 못 알아낸 것은 말한다 ─────────────────────────────────────────────────
|
|
43
|
+
*
|
|
44
|
+
* 함수마다 `gaps` 를 낸다 (flow.mjs 와 같은 규칙). 이 프로젝트에서 실제로
|
|
45
|
+
* 나온 가장 나쁜 버그는 전부 "조용히 0개를 내놓고 그 침묵을 결과로 제시" 였다.
|
|
46
|
+
* 여기서는 더 위험하다 — 경고가 0개면 사용자는 **안전하다고 읽는다.**
|
|
47
|
+
* 히스토리가 없어서 0개인 것과 정말 안 겹쳐서 0개인 것은 다른 사실이다.
|
|
48
|
+
*
|
|
49
|
+
* 순수 함수만 둔다. git 호출·파일 읽기·`Date.now()` 없다. 시각은 인자다.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
claimExpiresAt, coversPath, humanDuration, normalizePath, pathsOverlap,
|
|
54
|
+
} from '../../src/protocol.mjs'
|
|
55
|
+
import { featureAdjacency } from './adjacent.mjs'
|
|
56
|
+
import { modulesOf } from './featuregraph.mjs'
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// 문턱값 — 전부 근거를 함께 적는다
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* bin/axmap.mjs 의 `parseTtl` 기본값과 같은 30분.
|
|
64
|
+
*
|
|
65
|
+
* 🔴 여기서 다시 적는 이유 — 순수 층은 bin(부수효과 층)을 import 하지 않는다.
|
|
66
|
+
* bin 의 기본값을 바꾸면 이 상수도 함께 바꾼다. 이 값은 **판정이 아니라
|
|
67
|
+
* 표시 기준**이므로(“기본보다 훨씬 길게 잡았다”) 어긋나도 락은 안 흔들린다.
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_TTL_MS = 30 * 60_000
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* "곧 만료" 로 볼 남은 시간. 기본 TTL 의 1/6.
|
|
73
|
+
*
|
|
74
|
+
* 근거: `renew` 한 번이 30분을 다시 붙인다. 5분이면 화면을 보고 갱신을
|
|
75
|
+
* 결정하기에 충분하고, 그보다 길게 잡으면 30분짜리 claim 의 태반이
|
|
76
|
+
* 상시 "곧 만료" 로 켜져 있어 표시가 의미를 잃는다.
|
|
77
|
+
*/
|
|
78
|
+
export const EXPIRING_MS = 5 * 60_000
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* "거의 다 썼다" 로 볼 소진 비율.
|
|
82
|
+
*
|
|
83
|
+
* 근거: 남은 시간(EXPIRING_MS)은 절대값이라 `--ttl 4h` 같은 긴 claim 에는
|
|
84
|
+
* 늦게 켜진다. 비율은 TTL 길이와 무관하게 같은 뜻을 갖는다.
|
|
85
|
+
*/
|
|
86
|
+
export const SPENT_RATIO = 0.8
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 공변경을 근거로 인정할 문턱. adjacent.mjs 의 기본값을 그대로 쓴다.
|
|
90
|
+
*
|
|
91
|
+
* 🔴 여기서 값을 다르게 두면 같은 화면의 두 경고가 서로 다른 답을 한다.
|
|
92
|
+
* adjacent.mjs 가 "경고한다" 고 한 쌍을 여기서 "근거 없음" 으로 깎으면
|
|
93
|
+
* 사용자는 어느 쪽을 믿을지 알 수 없다.
|
|
94
|
+
*/
|
|
95
|
+
export const MIN_LIFT = 3
|
|
96
|
+
export const MIN_SUPPORT = 4
|
|
97
|
+
|
|
98
|
+
/** 위험 등급 — 센 것부터. */
|
|
99
|
+
export const RISK_LEVELS = ['certain', 'high', 'medium', 'low']
|
|
100
|
+
const LEVEL_LABEL = { certain: '확실', high: '높음', medium: '중간', low: '낮음' }
|
|
101
|
+
const LEVEL_RANK = { certain: 0, high: 1, medium: 2, low: 3 }
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 쌍을 Map 키로 만들 때 쓰는 구분자.
|
|
105
|
+
*
|
|
106
|
+
* 🔴 소스에는 **이스케이프 표기**로 적는다. featuregraph.mjs 가 같은 자리에
|
|
107
|
+
* 같은 경고를 남겼고, 이 파일은 실제로 한 번 진짜 NUL 바이트가 들어간 채로
|
|
108
|
+
* 저장됐다 — grep 이 "Binary file matches" 라며 내용을 안 보여줬다.
|
|
109
|
+
* 🔴 공백은 쓸 수 없다. 에이전트 이름은 내부 공백을 허용한다("김 민수").
|
|
110
|
+
* 공백으로 이으면 `"a b"+"c"` 와 `"a"+"b c"` 가 같은 키가 된다 — 서로 다른
|
|
111
|
+
* 두 입력을 같은 것으로 만드는 짓이고, 그것이 곧 소유권 충돌이다(CLAUDE.md).
|
|
112
|
+
* NUL 은 `agentNameError` 가 이름에서 명시적으로 막으므로 안전하다.
|
|
113
|
+
*/
|
|
114
|
+
const SEP = '\u0000'
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// 공통 — 시각과 만료
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 이 레코드가 언제 효력을 잃는가. **판정 불가면 null 을 낸다.**
|
|
122
|
+
*
|
|
123
|
+
* 🔴 모양이 둘이다. 장부 원본은 `since` + `ttlMs` 이고, `readClaims()` 를
|
|
124
|
+
* 거친 것은 `expiresAt`(ISO) 이다. 둘 다 받는다.
|
|
125
|
+
*
|
|
126
|
+
* 🔴 못 읽으면 **0 이나 Infinity 로 치환하지 않는다** (CLAUDE.md).
|
|
127
|
+
* 0 으로 치면 남의 유효한 claim 이 조용히 사라지고, Infinity 로 치면
|
|
128
|
+
* 죽은 claim 이 영원히 남는다. 둘 다 서로 다른 두 입력을 같은 것으로
|
|
129
|
+
* 만드는 짓이다. 모르면 모른다고 내고, 호출부가 `unreadable` 로 드러낸다.
|
|
130
|
+
*/
|
|
131
|
+
export function expiryOf(claim) {
|
|
132
|
+
if (claim?.expiresAt != null) {
|
|
133
|
+
const t = Date.parse(claim.expiresAt)
|
|
134
|
+
return Number.isFinite(t) ? t : null
|
|
135
|
+
}
|
|
136
|
+
if (claim?.since != null && Number.isFinite(Number(claim?.ttlMs))) {
|
|
137
|
+
const t = claimExpiresAt(claim)
|
|
138
|
+
return Number.isFinite(t) ? t : null
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 이 레코드를 언제 잡았나(= 마지막 갱신 시각). 못 읽으면 null. */
|
|
144
|
+
export function heldSince(claim) {
|
|
145
|
+
const t = Date.parse(claim?.since ?? '')
|
|
146
|
+
return Number.isFinite(t) ? t : null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 레코드를 셋으로 가른다 — 효력 있음 / 만료됨 / 못 읽음.
|
|
151
|
+
*
|
|
152
|
+
* 🔴 만료는 "레코드가 사라짐" 이 아니라 "효력만 잃음" 이다. 그래서 버리지
|
|
153
|
+
* 않고 따로 낸다. 화면이 "아까 그 사람 어디 갔지" 에 답할 수 있어야 한다.
|
|
154
|
+
*/
|
|
155
|
+
export function partitionClaims(claims, now) {
|
|
156
|
+
const live = []
|
|
157
|
+
const expired = []
|
|
158
|
+
const unreadable = []
|
|
159
|
+
for (const c of claims ?? []) {
|
|
160
|
+
if (c?.broken) { unreadable.push(c); continue }
|
|
161
|
+
if (!Array.isArray(c?.paths)) { unreadable.push(c); continue }
|
|
162
|
+
const exp = expiryOf(c)
|
|
163
|
+
if (exp === null) { unreadable.push(c); continue }
|
|
164
|
+
if (now != null && exp <= now) { expired.push(c); continue }
|
|
165
|
+
live.push(c)
|
|
166
|
+
}
|
|
167
|
+
return { live, expired, unreadable }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// 공통 — 경로
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/** 이 claim 이 실제로 덮는 파일들. `universe` 안에 있는 것만. */
|
|
175
|
+
function coveredFiles(claim, universe) {
|
|
176
|
+
const held = (claim.paths ?? []).map(normalizePath)
|
|
177
|
+
const out = new Set()
|
|
178
|
+
for (const f of universe) if (coversPath(held, f)) out.add(f)
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 파일이 놓인 폴더. 최상위 파일은 폴더가 없다(빈 문자열). */
|
|
183
|
+
const folderOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `modulesOf` 가 만드는 '나머지' 통. 기능이 아니다.
|
|
187
|
+
*
|
|
188
|
+
* 어디에도 안 들어간 최상위 파일이 여기로 떨어진다. 이것을 기능으로 세면
|
|
189
|
+
* 서로 아무 상관 없는 파일 둘이 "같은 기능" 이 된다.
|
|
190
|
+
*/
|
|
191
|
+
const isBucket = (dir) => dir === '(최상위)'
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 이 기능 노드가 실은 '그릇' 인가 — 기능이 아니라 나머지가 모이는 자리.
|
|
195
|
+
*
|
|
196
|
+
* 🔴 실측으로 찾았다. 이 저장소를 자기 자신에게 돌리면 `modulesOf` 가 뿌리를
|
|
197
|
+
* `app` 으로 잡고, 그러면 뿌리 밑이 아닌 코드가 전부 `app` 모듈로 떨어진다 —
|
|
198
|
+
*
|
|
199
|
+
* app ← app/server.mjs · src/protocol.mjs · bin/axmap.mjs
|
|
200
|
+
* · mcp/server.mjs · corpus/*.mjs
|
|
201
|
+
*
|
|
202
|
+
* 그 결과 `app/server.mjs` 를 잡은 사람과 `corpus/serve.mjs` 를 잡은 사람이
|
|
203
|
+
* "같은 기능" 으로 붙었다. 둘은 아무 상관이 없다.
|
|
204
|
+
*
|
|
205
|
+
* featuregraph.mjs 도 같은 것을 알고 있어서 뿌리를 이름 붙이기에서 뺀다
|
|
206
|
+
* ("뿌리는 기능이 아니라 담는 그릇이다"). 다만 노드 목록에는 남으므로,
|
|
207
|
+
* 노드만 받는 이쪽에서는 직접 알아봐야 한다.
|
|
208
|
+
*
|
|
209
|
+
* 판별은 노드 자신의 내용으로 한다 — **자기 디렉터리 밖의 파일을 담고 있으면
|
|
210
|
+
* 그것은 경계가 아니다.** 뿌리 이름을 따로 넘겨받지 않아도 되고, 다른
|
|
211
|
+
* 저장소에서 뿌리가 무엇이든 같은 규칙이 걸린다.
|
|
212
|
+
*/
|
|
213
|
+
function isContainerNode(node) {
|
|
214
|
+
const dir = normalizePath(node?.dir ?? node?.id ?? '')
|
|
215
|
+
const paths = (node?.paths ?? []).map(normalizePath)
|
|
216
|
+
if (!dir || !paths.length) return true
|
|
217
|
+
return paths.some((p) => !p.startsWith(`${dir}/`))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* `freq` 는 Map 으로 오지만 JSON 을 한 번 건너면 평범한 객체가 된다.
|
|
222
|
+
* 둘 다 받는다 — 여기서 조용히 빈 값이 되면 "히스토리 근거 없음" 이
|
|
223
|
+
* 저장소 전체의 답이 되고, 아무도 그것이 버그인 줄 모른다.
|
|
224
|
+
*/
|
|
225
|
+
const freqKeys = (freq) => {
|
|
226
|
+
if (!freq) return []
|
|
227
|
+
if (typeof freq.keys === 'function') return [...freq.keys()]
|
|
228
|
+
return Object.keys(freq)
|
|
229
|
+
}
|
|
230
|
+
const freqOf = (freq, f) => {
|
|
231
|
+
if (!freq) return 0
|
|
232
|
+
if (typeof freq.get === 'function') return freq.get(f) ?? 0
|
|
233
|
+
return freq[f] ?? 0
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// 한국어 조사 — 문장으로 읽히게
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 받침에 맞는 조사를 고른다.
|
|
242
|
+
*
|
|
243
|
+
* 🔴 왜 이런 걸 넣나 — "민수 / Data Mining / 7 / 3" 을 칸에 끼운 것은
|
|
244
|
+
* 문장이 아니라 표다. 3단계 사용자는 코드를 못 읽어서 여기 온 사람이고,
|
|
245
|
+
* 표를 하나 더 주는 것으로는 아무것도 달라지지 않는다.
|
|
246
|
+
*
|
|
247
|
+
* ⚠️ 한글은 종성으로 정확히 판정하고, 그 밖(영문·숫자)은 **추정**이다.
|
|
248
|
+
* 틀려도 어색할 뿐 뜻이 바뀌지 않는다. 이름을 지어내는 것과 달리
|
|
249
|
+
* 사용자를 오도하지 않으므로 추정을 허용한다.
|
|
250
|
+
*/
|
|
251
|
+
export function josa(word, pair) {
|
|
252
|
+
const [withJong, withoutJong] = pair.split('/')
|
|
253
|
+
const s = String(word ?? '')
|
|
254
|
+
const ch = s.at(-1)
|
|
255
|
+
if (!ch) return withoutJong
|
|
256
|
+
const code = ch.charCodeAt(0)
|
|
257
|
+
if (code >= 0xac00 && code <= 0xd7a3) {
|
|
258
|
+
return (code - 0xac00) % 28 === 0 ? withoutJong : withJong
|
|
259
|
+
}
|
|
260
|
+
// 영문·숫자는 읽는 소리로 추정한다. 모음으로 끝나면 받침이 없다.
|
|
261
|
+
if (/[aeiouAEIOU]/.test(ch)) return withoutJong
|
|
262
|
+
if (/[2459]/.test(ch)) return withoutJong // 이·사·오·구
|
|
263
|
+
return withJong
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const nameList = (names) => names.join('·')
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// ① whoIsWhere — 누가 어느 '기능' 을 잡고 있나
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 경로 단위 claim 을 기능 단위로 올린다.
|
|
274
|
+
*
|
|
275
|
+
* 🔴 부분 점유를 반드시 구분한다.
|
|
276
|
+
*
|
|
277
|
+
* "민수가 Data Mining 을 잡고 있다" 만 보여주면 다른 사람은 그 기능 전체가
|
|
278
|
+
* 막혔다고 읽는다. 실제로는 파일 7개 중 3개다 — 나머지 4개는 지금 당장
|
|
279
|
+
* 들어갈 수 있다. 이 도구의 목적이 **중복을 막는 것**이지 사람을 세워두는
|
|
280
|
+
* 것이 아니므로, 비어 있는 몫을 경로까지 함께 낸다. 그래야 읽은 사람이
|
|
281
|
+
* 바로 `claim` 을 칠 수 있다.
|
|
282
|
+
*
|
|
283
|
+
* @param {object[]} claims claim 레코드 (readClaims() 결과 또는 장부 원본)
|
|
284
|
+
* @param {object[]} modified [{path, code}] — git 이 본 실제 변경
|
|
285
|
+
* @param {object[]} featureNodes featureGraph().nodes — {id, name, nameSource, paths}
|
|
286
|
+
* @param {object} opts
|
|
287
|
+
* now 주면 만료 레코드를 걸러낸다. 안 주면 걸러지지 않았다고 말한다.
|
|
288
|
+
* maxFree 기능마다 보여줄 '비어 있는 경로' 개수
|
|
289
|
+
*/
|
|
290
|
+
export function whoIsWhere(claims, modified = [], featureNodes = [], { now = null, maxFree = 8 } = {}) {
|
|
291
|
+
const gaps = []
|
|
292
|
+
const { live, expired, unreadable } = partitionClaims(claims, now)
|
|
293
|
+
if (now == null) {
|
|
294
|
+
gaps.push('now 를 주지 않아 만료 판정을 하지 못했다 — 넘어온 claim 이 이미 걸러졌다고 가정한다')
|
|
295
|
+
}
|
|
296
|
+
if (unreadable.length) {
|
|
297
|
+
gaps.push(`읽을 수 없는 장부 레코드 ${unreadable.length}건이 있다 — 그 사람이 무엇을 잡았는지 모른다`)
|
|
298
|
+
}
|
|
299
|
+
if (!featureNodes.length) {
|
|
300
|
+
gaps.push('기능 노드가 없다 — 기능 단위로 올릴 수 없어 경로 그대로 보여준다')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const touched = new Map((modified ?? []).map((m) => [normalizePath(m.path), m.code ?? null]))
|
|
304
|
+
const inFeature = new Set()
|
|
305
|
+
const claimedSomewhere = new Set() // 어느 기능에든 들어간 claim 경로
|
|
306
|
+
|
|
307
|
+
const features = []
|
|
308
|
+
for (const node of featureNodes) {
|
|
309
|
+
const paths = (node.paths ?? []).map(normalizePath)
|
|
310
|
+
for (const p of paths) inFeature.add(p)
|
|
311
|
+
if (!paths.length) continue
|
|
312
|
+
|
|
313
|
+
const holders = []
|
|
314
|
+
const heldFiles = new Set()
|
|
315
|
+
for (const c of live) {
|
|
316
|
+
const mine = [...coveredFiles(c, paths)]
|
|
317
|
+
if (!mine.length) continue
|
|
318
|
+
for (const f of mine) heldFiles.add(f)
|
|
319
|
+
for (const p of (c.paths ?? []).map(normalizePath)) {
|
|
320
|
+
if (paths.some((f) => coversPath([p], f))) claimedSomewhere.add(p)
|
|
321
|
+
}
|
|
322
|
+
holders.push({
|
|
323
|
+
agent: c.agent,
|
|
324
|
+
task: c.task ?? null,
|
|
325
|
+
intent: c.intent ?? null,
|
|
326
|
+
actor: c.actor ?? null,
|
|
327
|
+
files: mine.sort(),
|
|
328
|
+
count: mine.length,
|
|
329
|
+
// 이 기능 중 몇 할을 쥐고 있나. 화면이 노드를 얼마나 물들일지의 기준.
|
|
330
|
+
share: +(mine.length / paths.length).toFixed(3),
|
|
331
|
+
// 🔴 '전체를 잡았나' 는 반드시 따로 낸다. 부분 점유와 전체 점유는
|
|
332
|
+
// 다른 사람이 할 수 있는 일이 다르다.
|
|
333
|
+
whole: mine.length === paths.length,
|
|
334
|
+
touched: mine.filter((f) => touched.has(f)).length,
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const modifiedHere = paths.filter((f) => touched.has(f))
|
|
339
|
+
// D3 — 선언 ⊂ 실제. 선언 없이 바뀐 파일은 여기서도 그대로 드러낸다.
|
|
340
|
+
const undeclared = modifiedHere.filter((f) => !heldFiles.has(f))
|
|
341
|
+
if (!holders.length && !modifiedHere.length) continue // 아무 일도 없는 기능은 안 낸다
|
|
342
|
+
|
|
343
|
+
holders.sort((a, b) => b.count - a.count || (a.agent < b.agent ? -1 : 1))
|
|
344
|
+
const state = holders.length === 0 ? 'free'
|
|
345
|
+
: holders.length > 1 ? 'contested'
|
|
346
|
+
: holders[0].whole ? 'whole' : 'partial'
|
|
347
|
+
|
|
348
|
+
const free = paths.filter((f) => !heldFiles.has(f))
|
|
349
|
+
// 🔴 그릇을 기능이라고 부르지 않는다. "app 을 나눠 잡고 있다" 는 문장은
|
|
350
|
+
// 사실이 아니라 `modulesOf` 의 나머지 통을 기능 이름으로 읽은 것이다.
|
|
351
|
+
const container = isContainerNode(node)
|
|
352
|
+
features.push({
|
|
353
|
+
id: node.id,
|
|
354
|
+
name: node.name ?? node.id,
|
|
355
|
+
container,
|
|
356
|
+
// 🔴 이름의 출처를 함께 낸다. featuregraph.mjs 와 같은 이유 —
|
|
357
|
+
// 비전공자에게는 출처가 그 이름이 맞는지 확인할 유일한 수단이다.
|
|
358
|
+
nameSource: node.nameSource ?? null,
|
|
359
|
+
dir: node.dir ?? node.id,
|
|
360
|
+
files: paths.length,
|
|
361
|
+
claimedFiles: heldFiles.size,
|
|
362
|
+
freeFiles: free.length,
|
|
363
|
+
touchedFiles: modifiedHere.length,
|
|
364
|
+
holders,
|
|
365
|
+
undeclared,
|
|
366
|
+
// 다음 사람이 바로 claim 할 수 있는 자리. 잘라서 보여주되 총 수는 위에 있다.
|
|
367
|
+
free: free.slice(0, maxFree),
|
|
368
|
+
freeTruncated: Math.max(0, free.length - maxFree),
|
|
369
|
+
state,
|
|
370
|
+
stateLabel: { free: '비어 있음', partial: '일부 점유', whole: '전체 점유', contested: '나눠 잡음' }[state],
|
|
371
|
+
sentence: featureSentence({
|
|
372
|
+
name: node.name ?? node.id, total: paths.length, holders, state, free: free.length, container,
|
|
373
|
+
}),
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
if (features.some((f) => f.container)) {
|
|
377
|
+
gaps.push('기능이 아니라 "나머지" 통인 노드가 섞여 있다 — 그 안에서 둘이 만난 것은 같은 기능을 건드린다는 뜻이 아니다')
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 기능 지도 밖의 claim — 문서·테스트·설정, 그리고 **아직 커밋 안 된 새 파일**.
|
|
381
|
+
// 숨기면 화면이 절반만 진실이 된다.
|
|
382
|
+
//
|
|
383
|
+
// 🔴 새 파일에는 어느 기능 옆에 있는지를 붙인다.
|
|
384
|
+
//
|
|
385
|
+
// 기능 노드의 `paths` 는 그래프가 아는 파일뿐이라 방금 만든 파일은 어디에도
|
|
386
|
+
// 안 들어간다. 그렇다고 파일 수(`files`)에 더해 버리면 "7개 중 3개" 라는
|
|
387
|
+
// 숫자가 그래프와 어긋난다. 그래서 개수는 건드리지 않고 위치만 알려준다 —
|
|
388
|
+
// 화면이 그 점을 올바른 노드 옆에 그릴 수 있으면 충분하다.
|
|
389
|
+
const featureDirs = featureNodes
|
|
390
|
+
.map((n) => ({ id: n.id, name: n.name ?? n.id, dir: normalizePath(n.dir ?? n.id) }))
|
|
391
|
+
.sort((a, b) => b.dir.length - a.dir.length)
|
|
392
|
+
const outside = []
|
|
393
|
+
for (const c of live) {
|
|
394
|
+
const rest = (c.paths ?? []).map(normalizePath).filter((p) => {
|
|
395
|
+
if (claimedSomewhere.has(p)) return false
|
|
396
|
+
// 디렉터리 claim 이 기능 파일을 하나라도 덮으면 밖이 아니다
|
|
397
|
+
return ![...inFeature].some((f) => coversPath([p], f))
|
|
398
|
+
})
|
|
399
|
+
if (!rest.length) continue
|
|
400
|
+
outside.push({
|
|
401
|
+
agent: c.agent,
|
|
402
|
+
task: c.task ?? null,
|
|
403
|
+
intent: c.intent ?? null,
|
|
404
|
+
paths: rest.sort(),
|
|
405
|
+
near: rest.map((p) => {
|
|
406
|
+
const hit = featureDirs.find((f) => p.startsWith(`${f.dir}/`))
|
|
407
|
+
return hit ? { path: p, feature: hit.id, name: hit.name } : null
|
|
408
|
+
}).filter(Boolean),
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
if (outside.length) {
|
|
412
|
+
gaps.push(`기능 지도에 없는 경로를 잡은 사람이 ${outside.length}명 있다 — 문서·테스트·설정은 기능 노드가 되지 않는다`)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
features.sort((a, b) => b.claimedFiles - a.claimedFiles || b.touchedFiles - a.touchedFiles || (a.id < b.id ? -1 : 1))
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
features,
|
|
419
|
+
outside,
|
|
420
|
+
expired: expired.map((c) => ({ agent: c.agent, task: c.task ?? null, paths: (c.paths ?? []).map(normalizePath) })),
|
|
421
|
+
unreadable: unreadable.map((c) => ({ agent: c?.agent ?? '(알 수 없음)' })),
|
|
422
|
+
stats: {
|
|
423
|
+
agents: live.length,
|
|
424
|
+
totalFeatures: featureNodes.length,
|
|
425
|
+
occupiedFeatures: features.filter((f) => f.holders.length && !f.container).length,
|
|
426
|
+
// 🔴 '나머지' 통에서 둘이 만난 것은 중복 작업이 아니다. 세면 요약이 거짓말을 한다.
|
|
427
|
+
contested: features.filter((f) => f.state === 'contested' && !f.container).length,
|
|
428
|
+
wholeHeld: features.filter((f) => f.state === 'whole' && !f.container).length,
|
|
429
|
+
partialHeld: features.filter((f) => f.state === 'partial' && !f.container).length,
|
|
430
|
+
containers: features.filter((f) => f.container).length,
|
|
431
|
+
},
|
|
432
|
+
gaps,
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** "민수가 Data Mining 을 잡고 있다 (파일 7개 중 3개)" */
|
|
437
|
+
function featureSentence({ name, total, holders, state, free, container = false }) {
|
|
438
|
+
const who = nameList(holders.map((h) => h.agent))
|
|
439
|
+
const held = holders.reduce((a, h) => a + h.count, 0)
|
|
440
|
+
const uniqueHeld = total - free
|
|
441
|
+
// 🔴 그릇이면 문장부터 그렇게 말한다. "app 을 나눠 잡고 있다" 를 그대로 두면
|
|
442
|
+
// 사용자가 없는 기능 하나를 믿게 된다.
|
|
443
|
+
if (container) {
|
|
444
|
+
return `${name}${josa(name, '은/는')} 기능이 아니라 어디에도 안 들어간 파일이 모인 자리다.`
|
|
445
|
+
+ (who
|
|
446
|
+
? ` 지금 ${who}${josa(who, '이/가')} 여기 파일 ${uniqueHeld}개를 잡고 있지만, 같은 기능이라는 뜻은 아니다.`
|
|
447
|
+
: ` 지금 잡고 있는 사람은 없다.`)
|
|
448
|
+
}
|
|
449
|
+
if (state === 'free') return `${name}${josa(name, '은/는')} 지금 아무도 잡고 있지 않다 (파일 ${total}개)`
|
|
450
|
+
if (state === 'whole') {
|
|
451
|
+
return `${who}${josa(who, '이/가')} ${name} 전체를 잡고 있다 (파일 ${total}개 전부)`
|
|
452
|
+
}
|
|
453
|
+
if (state === 'contested') {
|
|
454
|
+
return `${who}${josa(who, '이/가')} ${name}${josa(name, '을/를')} 나눠 잡고 있다`
|
|
455
|
+
+ ` (파일 ${total}개 중 ${uniqueHeld}개, 선언 합계 ${held}개) — 같은 기능을 둘 이상이 건드리는 중이다`
|
|
456
|
+
}
|
|
457
|
+
return `${who}${josa(who, '이/가')} ${name}${josa(name, '을/를')} 잡고 있다`
|
|
458
|
+
+ ` (파일 ${total}개 중 ${uniqueHeld}개 — 나머지 ${free}개는 아직 비어 있다)`
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// ② collisionRisk — 경로는 안 겹치는데 같은 기능을 건드리는 사람들
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 이 파일의 핵심. 사용자가 원문에서 말한 것이 이거다 —
|
|
467
|
+
*
|
|
468
|
+
* > 단순히 file 의 conflict 보다 전체 기능을 앞에서부터 개발하는 사람이 있을 수
|
|
469
|
+
* > 있고 뒤에서부터 개발하는 사람이 있을 수 있으니 이 개발이 해당 기능과
|
|
470
|
+
* > 중첩될 여지가 있는지까지 잡아내야 한다.
|
|
471
|
+
*
|
|
472
|
+
* adjacent.mjs 가 씨앗을 이미 심었다(공변경으로 이어진 claim 쌍). 그것을
|
|
473
|
+
* 다시 구현하지 않고 **호출해서** 중간 등급의 근거로 쓴다. 두 벌로 만들면
|
|
474
|
+
* 같은 화면의 두 경고가 서로 다른 답을 하게 된다.
|
|
475
|
+
*
|
|
476
|
+
* ── 등급 ────────────────────────────────────────────────────────────────────
|
|
477
|
+
*
|
|
478
|
+
* 확실 certain 같은 경로다. 프로토콜(checkOverlap)이 이미 막는다.
|
|
479
|
+
* 그런데도 장부에 보인다면 장부가 갈라진 것이다 — 더 큰 일이다.
|
|
480
|
+
* 높음 high 같은 기능 모듈 안의 다른 파일. 경로는 안 겹친다.
|
|
481
|
+
* ← 앞에서 오는 사람과 뒤에서 오는 사람이 여기서 잡힌다.
|
|
482
|
+
* 중간 medium 모듈은 다른데 히스토리가 "늘 같이 바뀐다" 고 말한다.
|
|
483
|
+
* 근거를 support/lift 로 함께 낸다.
|
|
484
|
+
* 낮음 low 같은 폴더에 있다는 것뿐. 히스토리 근거는 없다.
|
|
485
|
+
*
|
|
486
|
+
* 🔴 등급 순서는 **결과의 심각도가 아니라 근거의 세기**다.
|
|
487
|
+
*
|
|
488
|
+
* 낮음이 안전하다는 뜻이 아니다. 우리가 아는 것이 적다는 뜻이다. 이 축을
|
|
489
|
+
* 섞으면 사용자가 "낮음이니까 괜찮다" 로 읽는데, 근거가 없는 것과 위험이
|
|
490
|
+
* 없는 것은 다른 사실이다. `why` 문자열이 매번 그 차이를 말한다.
|
|
491
|
+
*
|
|
492
|
+
* 🔴 조용한 0 을 안전으로 내지 않는다. 히스토리가 없는 파일은 중간 등급이
|
|
493
|
+
* 구조적으로 뜰 수 없다. 그 사실을 `gaps` 로 낸다.
|
|
494
|
+
*
|
|
495
|
+
* @param {object[]} claims claim 레코드
|
|
496
|
+
* @param {object[]} coEdges 공변경 엣지 [{source, target, support, lift}]
|
|
497
|
+
* @param {Map|object} freq 파일 → 등장 커밋 수 (cochange.mjs 의 freq)
|
|
498
|
+
* @param {number} now 시각. 만료된 claim 은 경고를 만들지 않는다.
|
|
499
|
+
* @param {object} opts
|
|
500
|
+
* featureNodes featureGraph().nodes — 주면 화면과 같은 모듈 경계를 쓴다
|
|
501
|
+
* paths 그래프에 있는 파일 전체 (freq 로 못 채우는 새 파일 보강)
|
|
502
|
+
*/
|
|
503
|
+
export function collisionRisk(claims, coEdges = [], freq = null, now = null, {
|
|
504
|
+
featureNodes = null, paths = [], minLift = MIN_LIFT, minSupport = MIN_SUPPORT, maxEvidence = 6,
|
|
505
|
+
} = {}) {
|
|
506
|
+
const gaps = []
|
|
507
|
+
const { live, expired, unreadable } = partitionClaims(claims, now)
|
|
508
|
+
if (now == null) gaps.push('now 를 주지 않아 만료된 claim 을 걸러내지 못했다')
|
|
509
|
+
if (expired.length) {
|
|
510
|
+
// 만료된 것은 효력이 없다. 경고를 만들지 않는 것이 옳지만, 그 사실을
|
|
511
|
+
// 말하지 않으면 "경고가 없다 = 아무도 없다" 로 읽힌다.
|
|
512
|
+
gaps.push(`만료된 claim ${expired.length}건은 위험 판정에서 뺐다 — 효력이 없다`)
|
|
513
|
+
}
|
|
514
|
+
if (unreadable.length) {
|
|
515
|
+
gaps.push(`읽을 수 없는 레코드 ${unreadable.length}건은 판정할 수 없었다 — 이 사람들과의 겹침은 여기 안 나온다`)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── 파일 세계 ────────────────────────────────────────────────────────────
|
|
519
|
+
// 히스토리(freq) + 공변경 양끝 + 그래프 파일 + claim 경로 자신.
|
|
520
|
+
//
|
|
521
|
+
// 🔴 claim 경로 자신을 반드시 넣는다. 방금 만든 파일은 히스토리도 없고
|
|
522
|
+
// 그래프에도 없다. 그걸 빼면 새 파일만 잡은 사람은 **어떤 경고에도
|
|
523
|
+
// 등장하지 않는다** — 가장 활발히 일하는 사람이 화면에서 사라진다.
|
|
524
|
+
const universe = new Set()
|
|
525
|
+
for (const f of freqKeys(freq)) universe.add(normalizePath(f))
|
|
526
|
+
for (const e of coEdges ?? []) { universe.add(normalizePath(e.source)); universe.add(normalizePath(e.target)) }
|
|
527
|
+
for (const p of paths ?? []) universe.add(normalizePath(p))
|
|
528
|
+
for (const n of featureNodes ?? []) for (const p of n.paths ?? []) universe.add(normalizePath(p))
|
|
529
|
+
// 🔴 claim 경로는 **아무 파일도 안 덮을 때만** 넣는다.
|
|
530
|
+
//
|
|
531
|
+
// 디렉터리 claim(`src/album`)을 그냥 넣으면 그것이 파일인 척 세계에
|
|
532
|
+
// 들어가고, `modulesOf` 가 `src/album` 을 한 단계 얕게 잘라 `src` 라는
|
|
533
|
+
// 있지도 않은 모듈을 만든다. 그러면 서로 다른 기능의 두 사람이 그
|
|
534
|
+
// 유령 모듈 안에서 '높음' 으로 붙는다.
|
|
535
|
+
const known = [...universe]
|
|
536
|
+
for (const c of live) {
|
|
537
|
+
for (const raw of c.paths ?? []) {
|
|
538
|
+
const p = normalizePath(raw)
|
|
539
|
+
if (!known.some((f) => coversPath([p], f))) universe.add(p)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!freqKeys(freq).length && (coEdges ?? []).length) {
|
|
544
|
+
gaps.push('freq 가 비었다 — Map 이 JSON 을 건너면 빈 객체가 된다. 히스토리 판정을 믿지 말 것')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (live.length < 2) {
|
|
548
|
+
return {
|
|
549
|
+
pairs: [], counts: { certain: 0, high: 0, medium: 0, low: 0 }, checked: live.length,
|
|
550
|
+
blocking: false, gaps,
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ── 모듈 경계 ────────────────────────────────────────────────────────────
|
|
555
|
+
// featureNodes 를 주면 화면(/api/featuregraph)과 같은 경계를 쓴다.
|
|
556
|
+
// 안 주면 modulesOf 로 직접 나눈다 — 같은 함수이므로 결과가 어긋나지 않는다.
|
|
557
|
+
const moduleOf = new Map()
|
|
558
|
+
const moduleName = new Map()
|
|
559
|
+
if (featureNodes?.length) {
|
|
560
|
+
for (const n of featureNodes) {
|
|
561
|
+
// 🔴 그릇은 기능이 아니다. 여기서 거르지 않으면 서로 상관없는 두 사람이
|
|
562
|
+
// "같은 기능" 으로 붙는다 (isContainerNode 주석의 실측).
|
|
563
|
+
if (isBucket(n.id) || isContainerNode(n)) continue
|
|
564
|
+
for (const p of n.paths ?? []) moduleOf.set(normalizePath(p), n.id)
|
|
565
|
+
moduleName.set(n.id, {
|
|
566
|
+
name: n.name ?? n.id, nameSource: n.nameSource ?? null, dir: normalizePath(n.dir ?? n.id),
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
} else {
|
|
570
|
+
const { mods, root } = modulesOf([...universe])
|
|
571
|
+
for (const [dir, files] of mods) {
|
|
572
|
+
// 🔴 뿌리와 '(최상위)' 는 기능이 아니라 **담는 그릇**이다.
|
|
573
|
+
//
|
|
574
|
+
// featuregraph.mjs 가 뿌리를 이름 붙이기에서 빼는 것과 같은 이유이고,
|
|
575
|
+
// 여기서는 더 나쁘게 물린다. `(최상위)` 는 어디에도 안 들어간 파일이
|
|
576
|
+
// 떨어지는 자리이므로, 그것을 기능으로 치면 `README.md` 를 잡은 사람과
|
|
577
|
+
// `CLAUDE.md` 를 잡은 사람이 "같은 기능" 으로 붙는다. 실제로 붙었다.
|
|
578
|
+
if (isBucket(dir) || dir === root) continue
|
|
579
|
+
for (const p of files) moduleOf.set(p, dir)
|
|
580
|
+
// 이름을 못 얻었으면 폴더 이름이다. 출처를 'path' 로 정직하게 적는다.
|
|
581
|
+
moduleName.set(dir, { name: dir.split('/').pop(), nameSource: 'path', dir })
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* 🔴 아직 그래프에 없는 파일도 모듈에 넣는다 (실측으로 찾은 구멍).
|
|
587
|
+
*
|
|
588
|
+
* 이 저장소 자신의 장부로 돌려 봤을 때 이렇게 나왔다 —
|
|
589
|
+
*
|
|
590
|
+
* agent-pr app/lib/live.mjs (기존 파일)
|
|
591
|
+
* agent-team app/lib/teamview.mjs (방금 만든 파일)
|
|
592
|
+
*
|
|
593
|
+
* 같은 모듈 `app/lib` 이므로 '높음' 이어야 하는데 **'낮음' 이 나왔다.**
|
|
594
|
+
* `teamview.mjs` 는 아직 커밋된 적이 없어 `git ls-files` 에도 히스토리에도
|
|
595
|
+
* 없고, 그래서 어떤 모듈에도 안 들어갔기 때문이다.
|
|
596
|
+
*
|
|
597
|
+
* 이것이 이 프로젝트에서 반복해 나온 실패의 모양이다 — **가장 활발한 작업이
|
|
598
|
+
* 정확히 그 이유로 화면에서 사라진다.** 새 파일이야말로 지금 누가 뭘 하는지
|
|
599
|
+
* 그 자체다.
|
|
600
|
+
*
|
|
601
|
+
* 그래서 모듈 디렉터리로 한 번 더 찾는다. `modulesOf` 의 모듈 경계는 원래
|
|
602
|
+
* 디렉터리 경계이므로 새 사실을 지어내는 것이 아니다. 다만 그래프가 확인해
|
|
603
|
+
* 준 것은 아니므로 `inferred` 로 표시해서 문장이 그 사실을 말하게 한다.
|
|
604
|
+
*/
|
|
605
|
+
// 긴 디렉터리부터 본다. `app/lib` 이 `app` 보다 먼저 맞아야 한다.
|
|
606
|
+
const moduleDirs = [...moduleName.entries()]
|
|
607
|
+
.map(([id, info]) => ({ id, dir: info.dir ?? id }))
|
|
608
|
+
.sort((a, b) => b.dir.length - a.dir.length)
|
|
609
|
+
const moduleFor = (f) => {
|
|
610
|
+
const known = moduleOf.get(f)
|
|
611
|
+
if (known) return { id: known, inferred: false }
|
|
612
|
+
const hit = moduleDirs.find((m) => f.startsWith(`${m.dir}/`))
|
|
613
|
+
return hit ? { id: hit.id, inferred: true } : null
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const owned = live.map((c) => ({ claim: c, files: coveredFiles(c, universe) }))
|
|
617
|
+
|
|
618
|
+
// ── 중간 등급의 근거는 adjacent.mjs 가 만든다 ────────────────────────────
|
|
619
|
+
const adj = featureAdjacency(live, coEdges ?? [], [...universe], { minLift, minSupport, maxEvidence })
|
|
620
|
+
const coPair = new Map()
|
|
621
|
+
for (const p of adj.pairs) coPair.set(agentKey(p.agents[0], p.agents[1]), p)
|
|
622
|
+
|
|
623
|
+
const pairs = []
|
|
624
|
+
for (let i = 0; i < owned.length; i++) {
|
|
625
|
+
for (let j = i + 1; j < owned.length; j++) {
|
|
626
|
+
const A = owned[i]
|
|
627
|
+
const B = owned[j]
|
|
628
|
+
const reasons = []
|
|
629
|
+
|
|
630
|
+
// ── 확실 — 같은 경로 ─────────────────────────────────────────────────
|
|
631
|
+
const shared = [...A.files].filter((f) => B.files.has(f))
|
|
632
|
+
const pathClash = (A.claim.paths ?? []).some((a) =>
|
|
633
|
+
(B.claim.paths ?? []).some((b) => pathsOverlap(a, b)))
|
|
634
|
+
if (shared.length || pathClash) {
|
|
635
|
+
reasons.push({
|
|
636
|
+
kind: 'path', level: 'certain',
|
|
637
|
+
files: shared.slice(0, maxEvidence),
|
|
638
|
+
why: '두 claim 이 같은 경로를 덮는다. checkOverlap 이 막았어야 하는 상태다'
|
|
639
|
+
+ ' — 장부가 갈라졌거나 두 저장소의 장부를 섞어 본 것이다.',
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ── 높음 — 같은 기능 모듈 안의 다른 파일 ─────────────────────────────
|
|
644
|
+
const modsA = groupByModule(A.files, moduleFor)
|
|
645
|
+
const modsB = groupByModule(B.files, moduleFor)
|
|
646
|
+
const sharedMods = [...modsA.keys()].filter((m) => modsB.has(m))
|
|
647
|
+
for (const m of sharedMods) {
|
|
648
|
+
const info = moduleName.get(m) ?? { name: m, nameSource: null }
|
|
649
|
+
const a = modsA.get(m)
|
|
650
|
+
const b = modsB.get(m)
|
|
651
|
+
// 한쪽이라도 그래프에 없는 새 파일이면 그 사실을 문장에 남긴다.
|
|
652
|
+
const inferred = [...a.newFiles, ...b.newFiles]
|
|
653
|
+
reasons.push({
|
|
654
|
+
kind: 'module', level: 'high', module: m, name: info.name, nameSource: info.nameSource,
|
|
655
|
+
files: [a.files.slice(0, maxEvidence), b.files.slice(0, maxEvidence)],
|
|
656
|
+
inferredFrom: inferred.length ? inferred.slice(0, maxEvidence) : null,
|
|
657
|
+
// 이름의 출처를 문장 안에 그대로 넣는다. 'docs' 면 사람이 그 묶음을
|
|
658
|
+
// 하나의 기능이라고 문서에 적어 둔 것이고, 'path' 면 폴더가 같을 뿐이다.
|
|
659
|
+
why: `같은 기능 "${info.name}"(${m}) 안에서 서로 다른 파일을 잡았다.`
|
|
660
|
+
+ ` 경로는 안 겹치므로 프로토콜은 통과시킨다 — 앞(API)에서 오는 사람과`
|
|
661
|
+
+ ` 뒤(저장소)에서 오는 사람이 여기서 만난다.`
|
|
662
|
+
+ (info.nameSource === 'docs'
|
|
663
|
+
? ' 이 이름은 README 가 그 묶음을 부르는 말이다.'
|
|
664
|
+
: ' ⚠️ 이 이름은 폴더 이름에서 나왔다 — 문서가 확인해 준 기능 경계는 아니다.')
|
|
665
|
+
+ (inferred.length
|
|
666
|
+
? ` (${inferred.join(', ')} 은 아직 그래프에 없는 새 파일이라 디렉터리로 맞췄다)`
|
|
667
|
+
: ''),
|
|
668
|
+
})
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ── 중간 — 히스토리 공변경 ───────────────────────────────────────────
|
|
672
|
+
const co = coPair.get(agentKey(A.claim.agent, B.claim.agent))
|
|
673
|
+
if (co) {
|
|
674
|
+
reasons.push({
|
|
675
|
+
kind: 'cochange', level: 'medium',
|
|
676
|
+
crossings: co.crossings, topLift: co.topLift, evidence: co.evidence,
|
|
677
|
+
why: `히스토리에서 두 영역을 가로지르는 공변경이 ${co.crossings}건 있다`
|
|
678
|
+
+ ` (최고 lift ${co.topLift}, 문턱 lift≥${minLift}·n≥${minSupport}).`
|
|
679
|
+
+ ' 지금까지 늘 같이 바뀐 파일들이라 이번에도 같이 바뀔 공산이 크다.',
|
|
680
|
+
})
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ── 낮음 — 같은 폴더뿐 ───────────────────────────────────────────────
|
|
684
|
+
// 🔴 저장소 뿌리는 폴더로 치지 않는다. 최상위 파일 전부를 형제로 만들면
|
|
685
|
+
// 이 경고가 상수처럼 켜져 있고, 그러면 아무 말도 하지 않는 것과 같다.
|
|
686
|
+
const foldersA = new Set([...A.files].map(folderOf).filter(Boolean))
|
|
687
|
+
const foldersB = new Set([...B.files].map(folderOf).filter(Boolean))
|
|
688
|
+
const sharedFolders = [...foldersA].filter((d) => foldersB.has(d))
|
|
689
|
+
if (sharedFolders.length && !sharedMods.length) {
|
|
690
|
+
reasons.push({
|
|
691
|
+
kind: 'folder', level: 'low', folders: sharedFolders.slice(0, maxEvidence),
|
|
692
|
+
why: `같은 폴더(${sharedFolders[0]})에 나란히 있다. 그것 말고 아는 것이 없다`
|
|
693
|
+
+ ' — 히스토리도 기능 모듈도 이 둘을 잇지 않는다.'
|
|
694
|
+
+ ' 낮음은 "안전하다" 가 아니라 "근거가 적다" 는 뜻이다.',
|
|
695
|
+
})
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (!reasons.length) continue
|
|
699
|
+
|
|
700
|
+
reasons.sort((x, y) => LEVEL_RANK[x.level] - LEVEL_RANK[y.level])
|
|
701
|
+
const level = reasons[0].level
|
|
702
|
+
// 히스토리가 아예 없는 파일이면 중간 등급이 구조적으로 뜰 수 없다.
|
|
703
|
+
const noHistory = [...A.files, ...B.files].filter((f) => freqOf(freq, f) === 0)
|
|
704
|
+
|
|
705
|
+
pairs.push({
|
|
706
|
+
level,
|
|
707
|
+
label: LEVEL_LABEL[level],
|
|
708
|
+
agents: [A.claim.agent, B.claim.agent],
|
|
709
|
+
tasks: [A.claim.task ?? null, B.claim.task ?? null],
|
|
710
|
+
intents: [A.claim.intent ?? null, B.claim.intent ?? null],
|
|
711
|
+
actors: [A.claim.actor ?? null, B.claim.actor ?? null],
|
|
712
|
+
paths: [(A.claim.paths ?? []).map(normalizePath), (B.claim.paths ?? []).map(normalizePath)],
|
|
713
|
+
files: [[...A.files].sort().slice(0, maxEvidence), [...B.files].sort().slice(0, maxEvidence)],
|
|
714
|
+
// 등급 하나로 뭉개지 않는다. 근거는 여러 개일 수 있고 각각 다른 것을 말한다.
|
|
715
|
+
reasons,
|
|
716
|
+
why: reasons.map((r) => r.why).join(' '),
|
|
717
|
+
// 히스토리가 없어서 중간 등급이 못 뜬 경우를 쌍마다 밝힌다.
|
|
718
|
+
historyBlind: noHistory.length ? noHistory.slice(0, maxEvidence) : null,
|
|
719
|
+
// 🔴 절대 게이트가 아니다. 소비자가 이 필드를 보고 착각할 여지를 없앤다.
|
|
720
|
+
blocking: false,
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
pairs.sort((a, b) =>
|
|
726
|
+
LEVEL_RANK[a.level] - LEVEL_RANK[b.level]
|
|
727
|
+
|| (b.reasons.length - a.reasons.length)
|
|
728
|
+
|| (a.agents[0] < b.agents[0] ? -1 : 1))
|
|
729
|
+
|
|
730
|
+
const counts = { certain: 0, high: 0, medium: 0, low: 0 }
|
|
731
|
+
for (const p of pairs) counts[p.level]++
|
|
732
|
+
|
|
733
|
+
const blindPairs = pairs.filter((p) => p.historyBlind).length
|
|
734
|
+
if (blindPairs) {
|
|
735
|
+
gaps.push(`${blindPairs}쌍은 한쪽 파일에 히스토리가 없다 — 공변경(중간 등급)이 구조적으로 뜰 수 없다.`
|
|
736
|
+
+ ' 중간 등급이 없다고 히스토리가 안전을 말한 것은 아니다')
|
|
737
|
+
}
|
|
738
|
+
if (!(coEdges ?? []).length) {
|
|
739
|
+
gaps.push('공변경 엣지가 하나도 없다 — 히스토리가 짧거나 아직 계산되지 않았다. 중간 등급은 이번 판정에 없다')
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return { pairs, counts, checked: live.length, blocking: false, gaps }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* 쌍의 키. 에이전트 이름을 쓴다 — 장부는 에이전트마다 레코드가 하나이므로
|
|
747
|
+
* (protocol.mjs 의 `upsert`) 이름이 곧 식별자다.
|
|
748
|
+
*/
|
|
749
|
+
const agentKey = (a, b) => (String(a) < String(b) ? `${a}${SEP}${b}` : `${b}${SEP}${a}`)
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* 파일들을 모듈별로 묶는다.
|
|
753
|
+
*
|
|
754
|
+
* 어느 모듈에도 안 속한 파일은 빠진다. 그것이 곧 '낮음' 이 존재할 수 있는
|
|
755
|
+
* 이유다 — `modulesOf` 는 소비자 폴더(테스트·예제·문서·스크립트)를 모듈로
|
|
756
|
+
* 치지 않으므로, 그 안의 파일들은 같은 폴더지만 같은 기능이 아니다.
|
|
757
|
+
*/
|
|
758
|
+
function groupByModule(files, moduleFor) {
|
|
759
|
+
const out = new Map()
|
|
760
|
+
for (const f of files) {
|
|
761
|
+
const m = moduleFor(f)
|
|
762
|
+
if (!m) continue
|
|
763
|
+
if (!out.has(m.id)) out.set(m.id, { files: [], newFiles: [] })
|
|
764
|
+
out.get(m.id).files.push(f)
|
|
765
|
+
// 그래프가 확인해 준 것이 아니라 디렉터리로 맞춘 것. 문장이 이 차이를 말한다.
|
|
766
|
+
if (m.inferred) out.get(m.id).newFiles.push(f)
|
|
767
|
+
}
|
|
768
|
+
return out
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** 사람이 읽을 경고문. adjacent.mjs 의 formatAdjacency 와 같은 재료·같은 모양. */
|
|
772
|
+
export function formatRisk(pair) {
|
|
773
|
+
const [a, b] = pair.agents
|
|
774
|
+
const lines = [
|
|
775
|
+
`[${pair.label}] 중복 작업 가능성`,
|
|
776
|
+
``,
|
|
777
|
+
` ${a} (${pair.tasks[0] ?? '작업 미지정'})`,
|
|
778
|
+
` ${pair.paths[0].join(', ')}`,
|
|
779
|
+
pair.intents[0] ? ` 의도: ${pair.intents[0]}` : null,
|
|
780
|
+
``,
|
|
781
|
+
` ${b} (${pair.tasks[1] ?? '작업 미지정'})`,
|
|
782
|
+
` ${pair.paths[1].join(', ')}`,
|
|
783
|
+
pair.intents[1] ? ` 의도: ${pair.intents[1]}` : null,
|
|
784
|
+
``,
|
|
785
|
+
` 왜 — ${pair.why}`,
|
|
786
|
+
pair.historyBlind
|
|
787
|
+
? ` ⚠️ 히스토리 없는 파일: ${pair.historyBlind.join(', ')} (공변경 근거가 나올 수 없다)`
|
|
788
|
+
: null,
|
|
789
|
+
``,
|
|
790
|
+
` 이것은 거부가 아닙니다. 두 사람이 서로의 의도를 아는 것으로 충분할 수 있습니다.`,
|
|
791
|
+
]
|
|
792
|
+
return lines.filter((l) => l !== null).join('\n')
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ---------------------------------------------------------------------------
|
|
796
|
+
// ③ staleClaims — "저 사람 아직 하고 있나?"
|
|
797
|
+
// ---------------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* TTL 이 얼마 안 남았거나 오래 붙잡고 있는 claim.
|
|
801
|
+
*
|
|
802
|
+
* 🔴 만료된 레코드를 효력 있는 것처럼 다루지 않는다 (CLAUDE.md 의 명시적 금지).
|
|
803
|
+
* 만료는 레코드가 사라지는 것이 아니라 효력만 잃는 것이므로, 목록에서
|
|
804
|
+
* 지우지도 않고 `active` 에 섞지도 않는다. 따로 낸다.
|
|
805
|
+
*
|
|
806
|
+
* ⚠️ 한계 — 장부는 "처음 잡은 시각" 을 기억하지 않는다.
|
|
807
|
+
* `applyRenew` 가 `since` 를 지금으로 되돌린다(protocol.mjs). 그래서
|
|
808
|
+
* 여기서 재는 '잡은 지' 는 **마지막 갱신 이후**다. 두 시간을 붙잡고
|
|
809
|
+
* 10분마다 갱신한 사람은 늘 '10분' 으로 보인다. 이것은 계측 한계이므로
|
|
810
|
+
* `gaps` 로 말한다 — 추정해서 메우면 없는 사실을 만들어내는 것이다.
|
|
811
|
+
*/
|
|
812
|
+
export function staleClaims(claims, now, {
|
|
813
|
+
expiringMs = EXPIRING_MS, spentRatio = SPENT_RATIO, longTtlMs = DEFAULT_TTL_MS * 2,
|
|
814
|
+
} = {}) {
|
|
815
|
+
const gaps = []
|
|
816
|
+
if (now == null) {
|
|
817
|
+
return {
|
|
818
|
+
active: [], expiring: [], longHeld: [], expired: [], unreadable: [],
|
|
819
|
+
stats: { active: 0, expiring: 0, longHeld: 0, expired: 0, unreadable: 0 },
|
|
820
|
+
gaps: ['now 가 없어 TTL 을 판정할 수 없다 — 아무것도 판정하지 않았다'],
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
const { live, expired, unreadable } = partitionClaims(claims, now)
|
|
824
|
+
|
|
825
|
+
const rows = live.map((c) => {
|
|
826
|
+
const exp = expiryOf(c)
|
|
827
|
+
const start = heldSince(c)
|
|
828
|
+
const remainingMs = exp - now
|
|
829
|
+
const heldMs = start === null ? null : Math.max(0, now - start)
|
|
830
|
+
// TTL 길이. expiresAt 만 있는 모양에서는 since 로 역산한다.
|
|
831
|
+
const ttlMs = start === null ? null : exp - start
|
|
832
|
+
const flags = []
|
|
833
|
+
if (remainingMs <= expiringMs) flags.push('expiring')
|
|
834
|
+
if (ttlMs && heldMs !== null && heldMs / ttlMs >= spentRatio) flags.push('spent')
|
|
835
|
+
if (ttlMs && ttlMs >= longTtlMs) flags.push('longTtl')
|
|
836
|
+
return {
|
|
837
|
+
agent: c.agent,
|
|
838
|
+
task: c.task ?? null,
|
|
839
|
+
intent: c.intent ?? null,
|
|
840
|
+
actor: c.actor ?? null,
|
|
841
|
+
paths: (c.paths ?? []).map(normalizePath),
|
|
842
|
+
since: c.since ?? null,
|
|
843
|
+
remainingMs,
|
|
844
|
+
remaining: humanDuration(remainingMs),
|
|
845
|
+
heldMs,
|
|
846
|
+
// '잡은 지' 는 마지막 갱신 이후다. 이름을 그대로 두면 오해가 남는다.
|
|
847
|
+
heldSinceRenew: heldMs === null ? null : humanDuration(heldMs),
|
|
848
|
+
ttlMs,
|
|
849
|
+
flags,
|
|
850
|
+
note: flags.includes('expiring')
|
|
851
|
+
? `${humanDuration(remainingMs)} 뒤에 풀린다 — 계속 하는 중이면 renew 가 필요하다`
|
|
852
|
+
: flags.includes('spent')
|
|
853
|
+
? `TTL 의 ${Math.round((heldMs / ttlMs) * 100)}% 를 썼다`
|
|
854
|
+
: flags.includes('longTtl')
|
|
855
|
+
? `기본(30분)보다 긴 ${humanDuration(ttlMs)} 짜리다 — 오래 잡을 작정이다`
|
|
856
|
+
: null,
|
|
857
|
+
}
|
|
858
|
+
}).sort((a, b) => a.remainingMs - b.remainingMs)
|
|
859
|
+
|
|
860
|
+
if (rows.some((r) => r.heldMs === null)) {
|
|
861
|
+
gaps.push('since 를 읽을 수 없는 레코드가 있어 잡은 기간을 재지 못했다')
|
|
862
|
+
}
|
|
863
|
+
gaps.push('renew 는 since 를 지금으로 되돌린다 — 여기 나오는 "잡은 지" 는 마지막 갱신 이후이지 처음 잡은 시각이 아니다')
|
|
864
|
+
if (unreadable.length) {
|
|
865
|
+
gaps.push(`읽을 수 없는 레코드 ${unreadable.length}건은 TTL 판정에서 뺐다 — 효력이 있는지 없는지 모른다`)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const expiredRows = expired.map((c) => {
|
|
869
|
+
const exp = expiryOf(c)
|
|
870
|
+
return {
|
|
871
|
+
agent: c.agent,
|
|
872
|
+
task: c.task ?? null,
|
|
873
|
+
paths: (c.paths ?? []).map(normalizePath),
|
|
874
|
+
expiredAgo: humanDuration(now - exp),
|
|
875
|
+
// 🔴 효력이 없다는 것을 값으로도 못박는다. 화면이 실수로 섞지 못하게.
|
|
876
|
+
inEffect: false,
|
|
877
|
+
}
|
|
878
|
+
}).sort((a, b) => (a.agent < b.agent ? -1 : 1))
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
active: rows,
|
|
882
|
+
expiring: rows.filter((r) => r.flags.includes('expiring')),
|
|
883
|
+
longHeld: rows.filter((r) => r.flags.includes('spent') || r.flags.includes('longTtl')),
|
|
884
|
+
expired: expiredRows,
|
|
885
|
+
unreadable: unreadable.map((c) => ({ agent: c?.agent ?? '(알 수 없음)' })),
|
|
886
|
+
stats: {
|
|
887
|
+
active: rows.length,
|
|
888
|
+
expiring: rows.filter((r) => r.flags.includes('expiring')).length,
|
|
889
|
+
longHeld: rows.filter((r) => r.flags.includes('spent') || r.flags.includes('longTtl')).length,
|
|
890
|
+
expired: expiredRows.length,
|
|
891
|
+
unreadable: unreadable.length,
|
|
892
|
+
},
|
|
893
|
+
gaps,
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ---------------------------------------------------------------------------
|
|
898
|
+
// ④ summary — 위를 한 문단으로
|
|
899
|
+
// ---------------------------------------------------------------------------
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* 화면 맨 위에 놓을 한 문단.
|
|
903
|
+
*
|
|
904
|
+
* 🔴 세 결과를 다 읽는 사용자는 없다. 그래서 한 문단으로 접되,
|
|
905
|
+
* **접으면서 gaps 를 버리지 않는다.** 접는 과정에서 "못 알아낸 것" 이
|
|
906
|
+
* 사라지면 남는 문장은 실제보다 확신에 찬 문장이 된다.
|
|
907
|
+
*/
|
|
908
|
+
export function summary({ who = null, risk = null, stale = null, me = null } = {}) {
|
|
909
|
+
const parts = []
|
|
910
|
+
const agents = stale?.stats?.active ?? who?.stats?.agents ?? 0
|
|
911
|
+
|
|
912
|
+
if (!agents) {
|
|
913
|
+
parts.push('지금 이 저장소를 잡고 있는 사람은 없다.')
|
|
914
|
+
} else {
|
|
915
|
+
const occupied = who?.stats?.occupiedFeatures ?? 0
|
|
916
|
+
parts.push(`지금 ${agents}명이 일하고 있고, 기능 ${occupied}개가 점유되어 있다.`)
|
|
917
|
+
const contested = who?.stats?.contested ?? 0
|
|
918
|
+
if (contested) parts.push(`그중 ${contested}개는 둘 이상이 나눠 잡고 있다.`)
|
|
919
|
+
const partial = who?.stats?.partialHeld ?? 0
|
|
920
|
+
if (partial) parts.push(`${partial}개는 일부만 잡혀 있어 나머지 파일은 지금도 들어갈 수 있다.`)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const c = risk?.counts
|
|
924
|
+
if (c && (c.certain || c.high || c.medium || c.low)) {
|
|
925
|
+
const bits = []
|
|
926
|
+
if (c.certain) bits.push(`확실 ${c.certain}쌍`)
|
|
927
|
+
if (c.high) bits.push(`높음 ${c.high}쌍`)
|
|
928
|
+
if (c.medium) bits.push(`중간 ${c.medium}쌍`)
|
|
929
|
+
if (c.low) bits.push(`낮음 ${c.low}쌍`)
|
|
930
|
+
parts.push(`경로가 안 겹치는데 같은 기능을 건드릴 수 있는 조합이 ${bits.join(' · ')} 있다.`)
|
|
931
|
+
if (c.certain) parts.push('확실 등급은 프로토콜이 이미 막는 상태다 — 장부를 확인해야 한다.')
|
|
932
|
+
} else if (risk) {
|
|
933
|
+
parts.push('서로 겹칠 만한 조합은 이번 판정에서 나오지 않았다.')
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (stale?.stats?.expiring) parts.push(`${stale.stats.expiring}명의 선점이 곧 풀린다.`)
|
|
937
|
+
if (stale?.stats?.expired) parts.push(`만료된 선점 ${stale.stats.expired}건이 장부에 남아 있다 (효력 없음).`)
|
|
938
|
+
if (who?.outside?.length) parts.push(`기능 지도 밖(문서·테스트·설정)을 잡은 사람도 ${who.outside.length}명 있다.`)
|
|
939
|
+
if (me) parts.push(`'나' 는 ${me} 다.`)
|
|
940
|
+
|
|
941
|
+
const gaps = [
|
|
942
|
+
...(who?.gaps ?? []).map((t) => ({ from: 'whoIsWhere', text: t })),
|
|
943
|
+
...(risk?.gaps ?? []).map((t) => ({ from: 'collisionRisk', text: t })),
|
|
944
|
+
...(stale?.gaps ?? []).map((t) => ({ from: 'staleClaims', text: t })),
|
|
945
|
+
]
|
|
946
|
+
|
|
947
|
+
return {
|
|
948
|
+
text: parts.join(' '),
|
|
949
|
+
// 🔴 접어도 버리지 않는다. 화면은 이 줄들을 문단 아래에 흐리게 깐다.
|
|
950
|
+
gaps,
|
|
951
|
+
counts: {
|
|
952
|
+
agents,
|
|
953
|
+
features: who?.stats?.occupiedFeatures ?? 0,
|
|
954
|
+
contested: who?.stats?.contested ?? 0,
|
|
955
|
+
risk: risk?.counts ?? { certain: 0, high: 0, medium: 0, low: 0 },
|
|
956
|
+
expiring: stale?.stats?.expiring ?? 0,
|
|
957
|
+
expired: stale?.stats?.expired ?? 0,
|
|
958
|
+
},
|
|
959
|
+
// 게이트가 아니라는 사실을 페이로드에도 남긴다.
|
|
960
|
+
blocking: false,
|
|
961
|
+
}
|
|
962
|
+
}
|