triflux 10.9.11 → 10.9.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/triflux.mjs +55 -0
- package/hooks/session-start-fast.mjs +17 -0
- package/hub/server.mjs +19 -3
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +65 -9
- package/hub/team/swarm-reconciler.mjs +80 -0
- package/hub/team/synapse-cli.mjs +207 -0
- package/hub/team/synapse-registry.mjs +275 -0
- package/hub/team/worktree-lifecycle.mjs +48 -1
- package/hub/workers/gemini-worker.mjs +5 -1
- package/package.json +1 -1
- package/scripts/__tests__/tfx-doctor-diagnose.test.mjs +9 -0
- package/scripts/claude-login-detect.mjs +76 -0
- package/scripts/doctor-diagnose.mjs +65 -1
- package/scripts/headless-guard.mjs +2 -11
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/session-stale-cleanup.mjs +1 -9
- package/scripts/tfx-route-worker.mjs +21 -1
- package/scripts/tfx-route.sh +37 -8
package/bin/triflux.mjs
CHANGED
|
@@ -289,6 +289,41 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
289
289
|
},
|
|
290
290
|
},
|
|
291
291
|
},
|
|
292
|
+
synapse: {
|
|
293
|
+
usage: "tfx synapse status [--json] [--registry <path>]",
|
|
294
|
+
description: "Synapse v1 세션 레지스트리 조회 (활성 스웜 세션 목록)",
|
|
295
|
+
subcommands: {
|
|
296
|
+
status: "활성 세션 테이블 표시 (host/branch/dirty/state/task)",
|
|
297
|
+
},
|
|
298
|
+
options: [
|
|
299
|
+
{
|
|
300
|
+
name: "--json",
|
|
301
|
+
type: "boolean",
|
|
302
|
+
description: "구조화된 JSON 출력",
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: "--registry",
|
|
306
|
+
type: "string",
|
|
307
|
+
description: "registry 파일 경로 오버라이드",
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
why: {
|
|
312
|
+
usage: "tfx why <path> [--json]",
|
|
313
|
+
description: "해당 경로의 마지막 커밋에서 X-Intent 트레일러 추출",
|
|
314
|
+
options: [
|
|
315
|
+
{
|
|
316
|
+
name: "path",
|
|
317
|
+
type: "string",
|
|
318
|
+
description: "intent를 조회할 파일 경로",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: "--json",
|
|
322
|
+
type: "boolean",
|
|
323
|
+
description: "구조화된 JSON 출력",
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
},
|
|
292
327
|
hub: {
|
|
293
328
|
usage: "tfx hub <start|stop|status|ensure> [--port N] [--json]",
|
|
294
329
|
description: "tfx-hub 프로세스 제어",
|
|
@@ -5306,6 +5341,26 @@ async function main() {
|
|
|
5306
5341
|
}
|
|
5307
5342
|
return;
|
|
5308
5343
|
}
|
|
5344
|
+
case "synapse": {
|
|
5345
|
+
const { cmdSynapseStatus } = await import(
|
|
5346
|
+
"../hub/team/synapse-cli.mjs"
|
|
5347
|
+
);
|
|
5348
|
+
const sub = cmdArgs[0] || "status";
|
|
5349
|
+
if (sub !== "status") {
|
|
5350
|
+
throw createCliError(`synapse 서브커맨드 미지원: ${sub}`, {
|
|
5351
|
+
exitCode: EXIT_ARG_ERROR,
|
|
5352
|
+
reason: "argError",
|
|
5353
|
+
fix: "tfx synapse status [--json] [--registry <path>]",
|
|
5354
|
+
});
|
|
5355
|
+
}
|
|
5356
|
+
await cmdSynapseStatus(cmdArgs.slice(1), { json: JSON_OUTPUT });
|
|
5357
|
+
return;
|
|
5358
|
+
}
|
|
5359
|
+
case "why": {
|
|
5360
|
+
const { cmdSynapseWhy } = await import("../hub/team/synapse-cli.mjs");
|
|
5361
|
+
await cmdSynapseWhy(cmdArgs, { json: JSON_OUTPUT });
|
|
5362
|
+
return;
|
|
5363
|
+
}
|
|
5309
5364
|
case "version":
|
|
5310
5365
|
case "--version":
|
|
5311
5366
|
case "-v":
|
|
@@ -83,6 +83,23 @@ async function runBlocking(stdinData) {
|
|
|
83
83
|
*/
|
|
84
84
|
function runDeferred(stdinData) {
|
|
85
85
|
const tasks = [
|
|
86
|
+
{
|
|
87
|
+
name: "session-stale-cleanup",
|
|
88
|
+
fn: async () => {
|
|
89
|
+
const mod = await importMod(join(SCRIPTS, "session-stale-cleanup.mjs"));
|
|
90
|
+
if (typeof mod.main === "function") mod.main();
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "claude-login-detect",
|
|
95
|
+
fn: async () => {
|
|
96
|
+
const mod = await importMod(join(SCRIPTS, "claude-login-detect.mjs"));
|
|
97
|
+
const result = mod.run?.();
|
|
98
|
+
if (result?.changed) {
|
|
99
|
+
return { stdout: `[claude-login] HUD 캐시 ${result.cleared}개 초기화됨\n` };
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
},
|
|
86
103
|
{
|
|
87
104
|
name: "mcp-gateway-ensure",
|
|
88
105
|
fn: async () => {
|
package/hub/server.mjs
CHANGED
|
@@ -1486,6 +1486,17 @@ export async function startHub({
|
|
|
1486
1486
|
"hub.public_dir",
|
|
1487
1487
|
);
|
|
1488
1488
|
|
|
1489
|
+
/**
|
|
1490
|
+
* Hub 서버 정지 함수.
|
|
1491
|
+
*
|
|
1492
|
+
* Trade-off (F01 — 영구 poisoning 허용):
|
|
1493
|
+
* 첫 정지 호출에서 cleanup 파이프라인이 실패하면 stopPromise는 실패 상태로
|
|
1494
|
+
* 고정되고, 이후 모든 호출은 동일한 실패 promise를 반환합니다. stopPromise를
|
|
1495
|
+
* null로 리셋하면 재시도가 가능하지만, router sweeper / transports / pipe 등이
|
|
1496
|
+
* 이미 부분 해제된 상태에서 두 번째 close가 실행되면 use-after-close 및
|
|
1497
|
+
* race condition을 유발합니다. 실패한 stopFn은 프로세스 전체 재시작으로 복구해야
|
|
1498
|
+
* 하며, 이 동작은 의도된 설계입니다.
|
|
1499
|
+
*/
|
|
1489
1500
|
const stopFn = async () => {
|
|
1490
1501
|
if (stopPromise) return stopPromise;
|
|
1491
1502
|
|
|
@@ -1682,9 +1693,9 @@ function cleanupStaleSpawnSessions(log) {
|
|
|
1682
1693
|
const QUOTA_CACHE_PATH = join(CACHE_DIR, "broker-quota-cache.json");
|
|
1683
1694
|
|
|
1684
1695
|
async function checkSingleAccountQuota(acct) {
|
|
1685
|
-
const authPath = join(PID_DIR, acct.authFile);
|
|
1686
|
-
if (!existsSync(authPath)) return { id: acct.id, status: "no_auth" };
|
|
1687
1696
|
try {
|
|
1697
|
+
const authPath = join(PID_DIR, acct.authFile);
|
|
1698
|
+
if (!existsSync(authPath)) return { id: acct.id, status: "no_auth" };
|
|
1688
1699
|
const auth = JSON.parse(readFileSync(authPath, "utf8"));
|
|
1689
1700
|
if (acct.provider === "codex") {
|
|
1690
1701
|
const token = auth.tokens?.access_token || auth.OPENAI_API_KEY || "";
|
|
@@ -1712,7 +1723,12 @@ async function checkSingleAccountQuota(acct) {
|
|
|
1712
1723
|
async function refreshAllAccountQuotas() {
|
|
1713
1724
|
const snap = brokerInstance?.snapshot() || [];
|
|
1714
1725
|
const checks = snap.filter(a => a.authFile).map(a => checkSingleAccountQuota(a));
|
|
1715
|
-
const
|
|
1726
|
+
const settled = await Promise.allSettled(checks);
|
|
1727
|
+
const results = settled.map((s, i) =>
|
|
1728
|
+
s.status === "fulfilled"
|
|
1729
|
+
? s.value
|
|
1730
|
+
: { id: snap.filter(a => a.authFile)[i]?.id ?? "unknown", status: "error", message: String(s.reason?.message || s.reason).substring(0, 60) },
|
|
1731
|
+
);
|
|
1716
1732
|
// 캐시 저장
|
|
1717
1733
|
try {
|
|
1718
1734
|
writeFileSync(QUOTA_CACHE_PATH, JSON.stringify({ ts: Date.now(), results }));
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// hub/team/git-preflight.mjs — Pre-flight safety check for dangerous git ops.
|
|
2
|
+
// Blocks checkout/rebase/cherry-pick/reset/stash-pop/worktree-remove when they
|
|
3
|
+
// would conflict with other active Synapse sessions' dirty files or claimed
|
|
4
|
+
// leases. Fail-open by default: if registry/locks are unavailable, allow the op
|
|
5
|
+
// with a warning (worktree isolation is the baseline safety net).
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Factory for git pre-flight check.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} opts
|
|
11
|
+
* @param {{ getActive: () => Array<object> }} opts.registry — synapse-registry duck type
|
|
12
|
+
* @param {{ snapshot: () => Array<object> }} opts.locks — swarm-locks duck type
|
|
13
|
+
* @param {boolean} [opts.failOpen=true] — allow op when registry/locks throw or are missing
|
|
14
|
+
* @param {(level: string, msg: string, data?: object) => void} [opts.logger] — optional logger
|
|
15
|
+
* @returns {{
|
|
16
|
+
* check: Function,
|
|
17
|
+
* checkRebase: Function,
|
|
18
|
+
* checkCheckout: Function,
|
|
19
|
+
* checkCherryPick: Function,
|
|
20
|
+
* checkReset: Function,
|
|
21
|
+
* checkStashPop: Function,
|
|
22
|
+
* checkWorktreeRemove: Function,
|
|
23
|
+
* }}
|
|
24
|
+
*/
|
|
25
|
+
export function createGitPreflight(opts = {}) {
|
|
26
|
+
const { registry, locks, failOpen = true, logger = null } = opts;
|
|
27
|
+
|
|
28
|
+
function log(level, msg, data) {
|
|
29
|
+
if (logger) {
|
|
30
|
+
try {
|
|
31
|
+
logger(level, msg, data);
|
|
32
|
+
} catch {
|
|
33
|
+
/* logger failure is non-fatal */
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (level === "warn") {
|
|
38
|
+
console.warn(`[git-preflight] ${msg}`, data ?? "");
|
|
39
|
+
} else if (level === "error") {
|
|
40
|
+
console.error(`[git-preflight] ${msg}`, data ?? "");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── helpers ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function safeGetActive() {
|
|
47
|
+
try {
|
|
48
|
+
const value = registry?.getActive?.();
|
|
49
|
+
if (!Array.isArray(value)) return null;
|
|
50
|
+
return value;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
log("warn", "registry.getActive threw", { error: err?.message });
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeSnapshot() {
|
|
58
|
+
try {
|
|
59
|
+
const value = locks?.snapshot?.();
|
|
60
|
+
if (!Array.isArray(value)) return null;
|
|
61
|
+
return value;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
log("warn", "locks.snapshot threw", { error: err?.message });
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function failOpenDecision(reason) {
|
|
69
|
+
log("warn", "hub unavailable, failing open", { reason });
|
|
70
|
+
return { allowed: true, reason: "hub_unavailable_fail_open" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeFiles(list) {
|
|
74
|
+
if (!Array.isArray(list)) return new Set();
|
|
75
|
+
return new Set(
|
|
76
|
+
list
|
|
77
|
+
.filter((f) => typeof f === "string" && f.length > 0)
|
|
78
|
+
.map((f) => f.replace(/\\/g, "/")),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function otherActiveSessions(active, sessionId) {
|
|
83
|
+
return active.filter((s) => s && s.sessionId && s.sessionId !== sessionId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function otherLeases(snapshot, workerId) {
|
|
87
|
+
return snapshot.filter(
|
|
88
|
+
(entry) => entry && entry.workerId && entry.workerId !== workerId,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildConflict(file, session, holder) {
|
|
93
|
+
return {
|
|
94
|
+
file,
|
|
95
|
+
activeSession: session?.sessionId ?? holder ?? "unknown",
|
|
96
|
+
activeTask: session?.taskSummary ?? "",
|
|
97
|
+
...(holder ? { leaseHolder: holder } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function recommend(conflicts) {
|
|
102
|
+
if (!conflicts.length) return "";
|
|
103
|
+
const first = conflicts[0];
|
|
104
|
+
const owner = first.leaseHolder || first.activeSession;
|
|
105
|
+
const task = first.activeTask
|
|
106
|
+
? ` Active task: '${first.activeTask}'`
|
|
107
|
+
: "";
|
|
108
|
+
return `Wait for '${owner}' to finish, or coordinate via HITL.${task}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function blockedDecision(op, conflicts, extraReason) {
|
|
112
|
+
return {
|
|
113
|
+
allowed: false,
|
|
114
|
+
reason: extraReason || "overlap_with_active_session",
|
|
115
|
+
conflicts,
|
|
116
|
+
recommendation: recommend(conflicts),
|
|
117
|
+
op,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function allowedDecision() {
|
|
122
|
+
return { allowed: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── shared scanners ─────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function findDirtyFileConflicts(sessionId, workerId) {
|
|
128
|
+
const active = safeGetActive();
|
|
129
|
+
const snapshot = safeSnapshot();
|
|
130
|
+
|
|
131
|
+
if (active == null || snapshot == null) {
|
|
132
|
+
return failOpen ? { fallOpen: true } : { fallOpen: false, conflicts: [] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const conflicts = [];
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
|
|
138
|
+
for (const session of otherActiveSessions(active, sessionId)) {
|
|
139
|
+
const dirty = normalizeFiles(session.dirtyFiles);
|
|
140
|
+
for (const file of dirty) {
|
|
141
|
+
if (seen.has(file)) continue;
|
|
142
|
+
seen.add(file);
|
|
143
|
+
conflicts.push(buildConflict(file, session));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const lease of otherLeases(snapshot, workerId)) {
|
|
148
|
+
if (lease.leaseType && lease.leaseType !== "exclusive") continue;
|
|
149
|
+
const file = typeof lease.file === "string" ? lease.file : null;
|
|
150
|
+
if (!file || seen.has(file)) continue;
|
|
151
|
+
seen.add(file);
|
|
152
|
+
const sessionMeta = lease.sessionMeta || null;
|
|
153
|
+
conflicts.push(
|
|
154
|
+
buildConflict(
|
|
155
|
+
file,
|
|
156
|
+
sessionMeta
|
|
157
|
+
? {
|
|
158
|
+
sessionId: sessionMeta.sessionId || lease.workerId,
|
|
159
|
+
taskSummary: sessionMeta.taskSummary || "",
|
|
160
|
+
}
|
|
161
|
+
: null,
|
|
162
|
+
lease.workerId,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { conflicts };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function findTargetFileConflicts(targetFiles, sessionId, workerId) {
|
|
171
|
+
const active = safeGetActive();
|
|
172
|
+
const snapshot = safeSnapshot();
|
|
173
|
+
|
|
174
|
+
if (active == null || snapshot == null) {
|
|
175
|
+
return failOpen ? { fallOpen: true } : { fallOpen: false, conflicts: [] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const targets = normalizeFiles(targetFiles);
|
|
179
|
+
if (targets.size === 0) return { conflicts: [] };
|
|
180
|
+
|
|
181
|
+
const conflicts = [];
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
|
|
184
|
+
for (const session of otherActiveSessions(active, sessionId)) {
|
|
185
|
+
const dirty = normalizeFiles(session.dirtyFiles);
|
|
186
|
+
for (const file of dirty) {
|
|
187
|
+
if (!targets.has(file) || seen.has(file)) continue;
|
|
188
|
+
seen.add(file);
|
|
189
|
+
conflicts.push(buildConflict(file, session));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const lease of otherLeases(snapshot, workerId)) {
|
|
194
|
+
if (lease.leaseType && lease.leaseType !== "exclusive") continue;
|
|
195
|
+
const file = typeof lease.file === "string" ? lease.file : null;
|
|
196
|
+
if (!file || !targets.has(file) || seen.has(file)) continue;
|
|
197
|
+
seen.add(file);
|
|
198
|
+
const sessionMeta = lease.sessionMeta || null;
|
|
199
|
+
conflicts.push(
|
|
200
|
+
buildConflict(
|
|
201
|
+
file,
|
|
202
|
+
sessionMeta
|
|
203
|
+
? {
|
|
204
|
+
sessionId: sessionMeta.sessionId || lease.workerId,
|
|
205
|
+
taskSummary: sessionMeta.taskSummary || "",
|
|
206
|
+
}
|
|
207
|
+
: null,
|
|
208
|
+
lease.workerId,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { conflicts };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── public API ──────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Core pre-flight check.
|
|
220
|
+
*
|
|
221
|
+
* @param {"checkout"|"rebase"|"cherry-pick"|"reset"|"stash-pop"|"worktree-remove"} op
|
|
222
|
+
* @param {{ targetFiles?: string[], branch?: string, ref?: string, worktreePath?: string }} args
|
|
223
|
+
* @param {{ sessionId: string, workerId?: string }} sessionContext
|
|
224
|
+
* @returns {{ allowed: boolean, reason?: string, conflicts?: object[], recommendation?: string, op?: string }}
|
|
225
|
+
*/
|
|
226
|
+
function check(op, args = {}, sessionContext = {}) {
|
|
227
|
+
const sessionId = sessionContext.sessionId || "";
|
|
228
|
+
const workerId = sessionContext.workerId || sessionId;
|
|
229
|
+
|
|
230
|
+
switch (op) {
|
|
231
|
+
case "checkout":
|
|
232
|
+
case "rebase":
|
|
233
|
+
case "reset":
|
|
234
|
+
case "stash-pop": {
|
|
235
|
+
const res = findDirtyFileConflicts(sessionId, workerId);
|
|
236
|
+
if (res.fallOpen) return failOpenDecision(op);
|
|
237
|
+
if (res.conflicts.length > 0) return blockedDecision(op, res.conflicts);
|
|
238
|
+
return allowedDecision();
|
|
239
|
+
}
|
|
240
|
+
case "cherry-pick": {
|
|
241
|
+
const targets = Array.isArray(args.targetFiles) ? args.targetFiles : [];
|
|
242
|
+
const res = findTargetFileConflicts(targets, sessionId, workerId);
|
|
243
|
+
if (res.fallOpen) return failOpenDecision(op);
|
|
244
|
+
if (res.conflicts.length > 0) return blockedDecision(op, res.conflicts);
|
|
245
|
+
return allowedDecision();
|
|
246
|
+
}
|
|
247
|
+
case "worktree-remove": {
|
|
248
|
+
const active = safeGetActive();
|
|
249
|
+
if (active == null) return failOpenDecision(op);
|
|
250
|
+
const target = typeof args.worktreePath === "string"
|
|
251
|
+
? args.worktreePath.replace(/\\/g, "/")
|
|
252
|
+
: "";
|
|
253
|
+
if (!target) return allowedDecision();
|
|
254
|
+
const conflicts = [];
|
|
255
|
+
for (const session of otherActiveSessions(active, sessionId)) {
|
|
256
|
+
const sessionPath = typeof session.worktreePath === "string"
|
|
257
|
+
? session.worktreePath.replace(/\\/g, "/")
|
|
258
|
+
: "";
|
|
259
|
+
if (sessionPath && sessionPath === target) {
|
|
260
|
+
conflicts.push({
|
|
261
|
+
file: target,
|
|
262
|
+
activeSession: session.sessionId,
|
|
263
|
+
activeTask: session.taskSummary || "",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (conflicts.length > 0) {
|
|
268
|
+
return blockedDecision(op, conflicts, "active_worktree");
|
|
269
|
+
}
|
|
270
|
+
return allowedDecision();
|
|
271
|
+
}
|
|
272
|
+
default:
|
|
273
|
+
return allowedDecision();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return Object.freeze({
|
|
278
|
+
check,
|
|
279
|
+
checkRebase: (args, ctx) => check("rebase", args, ctx),
|
|
280
|
+
checkCheckout: (args, ctx) => check("checkout", args, ctx),
|
|
281
|
+
checkCherryPick: (args, ctx) => check("cherry-pick", args, ctx),
|
|
282
|
+
checkReset: (args, ctx) => check("reset", args, ctx),
|
|
283
|
+
checkStashPop: (args, ctx) => check("stash-pop", args, ctx),
|
|
284
|
+
checkWorktreeRemove: (args, ctx) => check("worktree-remove", args, ctx),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// hub/team/swarm-intent.mjs — X-Intent helper utilities for semantic merge awareness
|
|
2
|
+
|
|
3
|
+
const INTENT_TRAILER_REGEX = /^X-Intent:\s*(.+)$/m;
|
|
4
|
+
|
|
5
|
+
function normalizeText(value) {
|
|
6
|
+
return typeof value === "string" ? value.trim() : "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeTouches(touches) {
|
|
10
|
+
if (!Array.isArray(touches)) return null;
|
|
11
|
+
return touches
|
|
12
|
+
.filter((item) => typeof item === "string")
|
|
13
|
+
.map((item) => item.trim().replace(/\\/g, "/"))
|
|
14
|
+
.filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeIntent(intentObj) {
|
|
18
|
+
if (!intentObj || typeof intentObj !== "object" || Array.isArray(intentObj)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const scope = normalizeText(intentObj.scope);
|
|
23
|
+
const action = normalizeText(intentObj.action);
|
|
24
|
+
const reason = normalizeText(intentObj.reason);
|
|
25
|
+
const touches = normalizeTouches(intentObj.touches);
|
|
26
|
+
|
|
27
|
+
if (!scope || !action || !reason || !touches) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
scope,
|
|
33
|
+
action,
|
|
34
|
+
reason,
|
|
35
|
+
touches,
|
|
36
|
+
invariant: normalizeText(intentObj.invariant),
|
|
37
|
+
conflictsWith: normalizeText(intentObj.conflictsWith),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fallbackIntent(llmResponse) {
|
|
42
|
+
const responseText = String(llmResponse ?? "");
|
|
43
|
+
return {
|
|
44
|
+
scope: "unknown",
|
|
45
|
+
action: "unknown",
|
|
46
|
+
reason: responseText.slice(0, 100),
|
|
47
|
+
touches: [],
|
|
48
|
+
invariant: "",
|
|
49
|
+
conflictsWith: "",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function includesEitherWay(a, b) {
|
|
54
|
+
if (!a || !b) return false;
|
|
55
|
+
return a.includes(b) || b.includes(a);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasConflict(intentA, intentB) {
|
|
59
|
+
const conflictsWith = normalizeText(intentA?.conflictsWith).toLowerCase();
|
|
60
|
+
const scope = normalizeText(intentB?.scope).toLowerCase();
|
|
61
|
+
const action = normalizeText(intentB?.action).toLowerCase();
|
|
62
|
+
|
|
63
|
+
if (!conflictsWith) return false;
|
|
64
|
+
return (
|
|
65
|
+
includesEitherWay(conflictsWith, scope) ||
|
|
66
|
+
includesEitherWay(conflictsWith, action)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getTouchesOverlap(intentA, intentB) {
|
|
71
|
+
const touchesA = new Set(
|
|
72
|
+
(normalizeTouches(intentA?.touches) ?? []).map((item) =>
|
|
73
|
+
item.toLowerCase(),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
const touchesB = (normalizeTouches(intentB?.touches) ?? []).map((item) =>
|
|
77
|
+
item.toLowerCase(),
|
|
78
|
+
);
|
|
79
|
+
return touchesB.filter((item) => touchesA.has(item));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build prompts for an LLM to generate an X-Intent JSON object.
|
|
84
|
+
* @param {string[]} changedFiles
|
|
85
|
+
* @param {string} commitDiff
|
|
86
|
+
* @param {string} taskContext
|
|
87
|
+
* @returns {{ systemPrompt: string, userPrompt: string }}
|
|
88
|
+
*/
|
|
89
|
+
export function generateIntentPrompt(changedFiles, commitDiff, taskContext) {
|
|
90
|
+
const files = Array.isArray(changedFiles)
|
|
91
|
+
? changedFiles.filter((filePath) => typeof filePath === "string")
|
|
92
|
+
: [];
|
|
93
|
+
const diffSnippet = String(commitDiff ?? "").slice(0, 4000);
|
|
94
|
+
const context = String(taskContext ?? "").trim();
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
systemPrompt:
|
|
98
|
+
"You generate commit intent metadata for semantic merge awareness. Return only valid JSON with keys: scope, action, reason, touches, invariant, conflictsWith.",
|
|
99
|
+
userPrompt: [
|
|
100
|
+
"Create an intent object from the following commit context.",
|
|
101
|
+
"- scope/action/reason must be concise strings.",
|
|
102
|
+
"- touches must be an array of touched file paths.",
|
|
103
|
+
"- invariant/conflictsWith should be strings (empty if unknown).",
|
|
104
|
+
"- Return JSON only (no markdown).",
|
|
105
|
+
"",
|
|
106
|
+
`Task context:\n${context || "(none provided)"}`,
|
|
107
|
+
"",
|
|
108
|
+
`Changed files (${files.length}):\n${files.join("\n") || "(none)"}`,
|
|
109
|
+
"",
|
|
110
|
+
"Commit diff (first 4000 chars):",
|
|
111
|
+
diffSnippet || "(empty)",
|
|
112
|
+
].join("\n"),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Format an intent object into an X-Intent commit trailer.
|
|
118
|
+
* @param {object} intentObj
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function formatIntentTrailer(intentObj) {
|
|
122
|
+
return `X-Intent: ${JSON.stringify(intentObj)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse an X-Intent trailer from a commit message.
|
|
127
|
+
* @param {string} commitMessage
|
|
128
|
+
* @returns {object | null}
|
|
129
|
+
*/
|
|
130
|
+
export function parseIntentTrailer(commitMessage) {
|
|
131
|
+
const message = String(commitMessage ?? "");
|
|
132
|
+
const match = message.match(INTENT_TRAILER_REGEX);
|
|
133
|
+
if (!match) return null;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(match[1]);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Classify the relation between two intents.
|
|
144
|
+
* @param {object} intentA
|
|
145
|
+
* @param {object} intentB
|
|
146
|
+
* @returns {{ relation: "complementary" | "complementary-risky" | "contradictory" | "independent", reason: string }}
|
|
147
|
+
*/
|
|
148
|
+
export function classifyIntentPair(intentA, intentB) {
|
|
149
|
+
if (hasConflict(intentA, intentB) || hasConflict(intentB, intentA)) {
|
|
150
|
+
return {
|
|
151
|
+
relation: "contradictory",
|
|
152
|
+
reason:
|
|
153
|
+
"conflictsWith on one intent matches the other intent scope/action",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const scopeA = normalizeText(intentA?.scope).toLowerCase();
|
|
158
|
+
const scopeB = normalizeText(intentB?.scope).toLowerCase();
|
|
159
|
+
|
|
160
|
+
if (scopeA && scopeB && scopeA === scopeB) {
|
|
161
|
+
const overlap = getTouchesOverlap(intentA, intentB);
|
|
162
|
+
if (overlap.length > 0) {
|
|
163
|
+
return {
|
|
164
|
+
relation: "complementary-risky",
|
|
165
|
+
reason: `same scope with overlapping touched files: ${overlap.join(", ")}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
relation: "complementary",
|
|
171
|
+
reason:
|
|
172
|
+
"same scope with no conflicting intent and no touched-file overlap",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
relation: "independent",
|
|
178
|
+
reason: "different scopes and no conflictsWith match detected",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build a validated intent object from raw LLM response text.
|
|
184
|
+
* @param {string} llmResponse
|
|
185
|
+
* @returns {{ scope: string, action: string, reason: string, touches: string[], invariant: string, conflictsWith: string }}
|
|
186
|
+
*/
|
|
187
|
+
export function buildIntentFromLLMResponse(llmResponse) {
|
|
188
|
+
const responseText = String(llmResponse ?? "").trim();
|
|
189
|
+
const candidates = [];
|
|
190
|
+
|
|
191
|
+
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
|
|
192
|
+
for (const match of responseText.matchAll(codeBlockRegex)) {
|
|
193
|
+
if (match[1]) candidates.push(match[1].trim());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
candidates.push(responseText);
|
|
197
|
+
|
|
198
|
+
const firstBrace = responseText.indexOf("{");
|
|
199
|
+
const lastBrace = responseText.lastIndexOf("}");
|
|
200
|
+
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
201
|
+
candidates.push(responseText.slice(firstBrace, lastBrace + 1));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const candidate of candidates) {
|
|
205
|
+
if (!candidate) continue;
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(candidate);
|
|
208
|
+
const normalized = normalizeIntent(parsed);
|
|
209
|
+
if (normalized) return normalized;
|
|
210
|
+
} catch {
|
|
211
|
+
// ignore parse failures and continue trying other candidates
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return fallbackIntent(responseText);
|
|
216
|
+
}
|