@triflux/remote 10.50.2 → 10.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cto/brief.mjs +0 -12
- package/cto/collect.mjs +0 -73
- package/cto/current.schema.json +0 -4
- package/cto/status.mjs +2 -50
- package/hub/index.mjs +0 -8
- package/hub/team/agent-map.json +0 -1
- package/hub/team/ansi.mjs +1 -35
- package/hub/team/backend.mjs +9 -145
- package/hub/team/cli/commands/attach.mjs +1 -17
- package/hub/team/cli/commands/control.mjs +12 -38
- package/hub/team/cli/commands/debug.mjs +1 -37
- package/hub/team/cli/commands/focus.mjs +5 -31
- package/hub/team/cli/commands/interrupt.mjs +6 -25
- package/hub/team/cli/commands/kill.mjs +1 -30
- package/hub/team/cli/commands/list.mjs +0 -16
- package/hub/team/cli/commands/send.mjs +6 -26
- package/hub/team/cli/commands/start/index.mjs +41 -133
- package/hub/team/cli/commands/start/parse-args.mjs +15 -40
- package/hub/team/cli/commands/start/start-headless.mjs +138 -105
- package/hub/team/cli/commands/start/start-mux.mjs +0 -3
- package/hub/team/cli/commands/status.mjs +2 -57
- package/hub/team/cli/commands/stop.mjs +14 -19
- package/hub/team/cli/help.mjs +4 -5
- package/hub/team/cli/manifest.mjs +5 -1
- package/hub/team/cli/render.mjs +0 -12
- package/hub/team/cli/services/hub-client.mjs +2 -88
- package/hub/team/cli/services/runtime-mode.mjs +27 -98
- package/hub/team/handoff.mjs +3 -34
- package/hub/team/headless.mjs +13 -754
- package/hub/team/orchestrator.mjs +4 -30
- package/hub/team/pane.mjs +6 -28
- package/hub/team/psmux.mjs +0 -1
- package/hub/team/remote-session.mjs +1 -2
- package/hub/team/session.mjs +7 -91
- package/hub/team/swarm-intent.mjs +0 -198
- package/hub/team/tui-core.mjs +1 -1
- package/hub/team/tui-viewer.mjs +2 -2
- package/hub/team/tui.mjs +8 -269
- package/hub/team/wt-manager.mjs +2 -2
- package/hub/workers/factory.mjs +4 -37
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +0 -2
- package/package.json +1 -1
- package/scripts/lib/agent-route-policy.mjs +162 -14
- package/scripts/lib/claudemd-scanner.mjs +2 -199
- package/scripts/lib/cli-claude.mjs +0 -2
- package/scripts/lib/context.mjs +3 -19
- package/scripts/lib/env-probe.mjs +5 -64
- package/scripts/lib/gemini-profiles.mjs +2 -69
- package/scripts/lib/machine-profile.mjs +3 -17
- package/scripts/lib/mcp-filter.mjs +109 -53
- package/scripts/lib/mcp-health.mjs +2 -2
- package/scripts/lib/mcp-server-catalog.mjs +2 -2
- package/scripts/lib/psmux-info.mjs +11 -59
- package/scripts/lib/skill-template.mjs +0 -245
- package/hub/team/build-worker-prompt.mjs +0 -92
- package/hub/team/check-mcp-hub.mjs +0 -49
- package/hub/team/claude-native-bridge.mjs +0 -987
- package/hub/team/cli/commands/start/start-in-process.mjs +0 -50
- package/hub/team/cli/commands/start/start-wt.mjs +0 -86
- package/hub/team/cli/services/native-control.mjs +0 -145
- package/hub/team/codex-review.mjs +0 -520
- package/hub/team/conductor-mesh-bridge.mjs +0 -122
- package/hub/team/conductor.mjs +0 -1529
- package/hub/team/consensus-meta.mjs +0 -176
- package/hub/team/cto-pull.mjs +0 -31
- package/hub/team/daemon-pty-tmux-bridge.mjs +0 -146
- package/hub/team/dashboard.mjs +0 -322
- package/hub/team/event-log.mjs +0 -84
- package/hub/team/execution-mode.mjs +0 -324
- package/hub/team/extract-completion-payload.mjs +0 -110
- package/hub/team/headless-bridge-session.mjs +0 -17
- package/hub/team/health-probe.mjs +0 -435
- package/hub/team/interactive-native-launcher.mjs +0 -119
- package/hub/team/interactive-native-worker.mjs +0 -80
- package/hub/team/interactive-tui-transport.mjs +0 -479
- package/hub/team/launcher-template.mjs +0 -144
- package/hub/team/mcp-selector.mjs +0 -145
- package/hub/team/native-supervisor.mjs +0 -460
- package/hub/team/native.mjs +0 -727
- package/hub/team/recovery-store.mjs +0 -149
- package/hub/team/remote-probe.mjs +0 -292
- package/hub/team/remote-watcher.mjs +0 -506
- package/hub/team/runtime-strategy.mjs +0 -200
- package/hub/team/schemas/worker-completion.json +0 -39
- package/hub/team/sentinel-capture.mjs +0 -127
- package/hub/team/swarm-cli.mjs +0 -390
- package/hub/team/swarm-hypervisor.mjs +0 -2206
- package/hub/team/swarm-locks.mjs +0 -335
- package/hub/team/swarm-planner.mjs +0 -371
- package/hub/team/swarm-preflight.mjs +0 -366
- package/hub/team/swarm-reconciler.mjs +0 -242
- package/hub/team/tui-remote-adapter.mjs +0 -377
- package/hub/team/worker-completion-validator.mjs +0 -79
- package/hub/team/worktree-lifecycle.mjs +0 -639
- package/hub/team/wt-templates.json +0 -43
- package/hub/workers/antigravity-route-worker.mjs +0 -179
- package/scripts/lib/dynamic-route-cli.mjs +0 -107
package/hub/team/swarm-locks.mjs
DELETED
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
// hub/team/swarm-locks.mjs — File-level lease lock manager for swarm execution
|
|
2
|
-
// Prevents multiple workers from writing to the same file simultaneously.
|
|
3
|
-
// Lock state is kept in-memory (single-process hypervisor) with optional
|
|
4
|
-
// JSON persistence to .triflux/swarm-locks.json for crash recovery.
|
|
5
|
-
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { dirname, relative, resolve } from "node:path";
|
|
8
|
-
import { DEFAULT_SWARM_LOCK_TTL_MS } from "@triflux/core/hub/lib/timeout-defaults.mjs";
|
|
9
|
-
|
|
10
|
-
const LOCK_TTL_MS = DEFAULT_SWARM_LOCK_TTL_MS;
|
|
11
|
-
|
|
12
|
-
// Distribution-critical paths. A swarm worker that modifies these without
|
|
13
|
-
// having them explicitly listed in its shard lease is almost certainly a
|
|
14
|
-
// recovery-patch fallout (worker exited before commit, hypervisor saved
|
|
15
|
-
// patch with destructive `+++ /dev/null` deletions). Block by default;
|
|
16
|
-
// override via validateChanges options.sensitiveDeny.
|
|
17
|
-
//
|
|
18
|
-
// Paths use POSIX separators (matches normalizePath output).
|
|
19
|
-
export const SENSITIVE_PATH_PREFIXES = [
|
|
20
|
-
".claude-plugin/",
|
|
21
|
-
"bin/",
|
|
22
|
-
".github/workflows/",
|
|
23
|
-
];
|
|
24
|
-
export const SENSITIVE_PATH_FILES = [
|
|
25
|
-
".gitignore",
|
|
26
|
-
".npmignore",
|
|
27
|
-
"package.json",
|
|
28
|
-
"package-lock.json",
|
|
29
|
-
"biome.json",
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
export const DEFAULT_SENSITIVE_DENY = Object.freeze({
|
|
33
|
-
prefixes: Object.freeze([...SENSITIVE_PATH_PREFIXES]),
|
|
34
|
-
files: Object.freeze([...SENSITIVE_PATH_FILES]),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Swarm lock manager factory.
|
|
39
|
-
* @param {object} [opts]
|
|
40
|
-
* @param {string} [opts.repoRoot] — repository root for relative path normalization
|
|
41
|
-
* @param {string} [opts.persistPath] — JSON file path for crash recovery persistence
|
|
42
|
-
* @param {number} [opts.ttlMs=600000] — lock TTL in ms (auto-expire stale locks)
|
|
43
|
-
* @returns {SwarmLockManager}
|
|
44
|
-
*/
|
|
45
|
-
export function createSwarmLocks(opts = {}) {
|
|
46
|
-
const { repoRoot = process.cwd(), persistPath, ttlMs = LOCK_TTL_MS } = opts;
|
|
47
|
-
|
|
48
|
-
/** @type {Map<string, LockEntry>} normalized relative path → lock */
|
|
49
|
-
const locks = new Map();
|
|
50
|
-
|
|
51
|
-
// ── helpers ──────────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
function normalizePath(filePath) {
|
|
54
|
-
const abs = resolve(repoRoot, filePath);
|
|
55
|
-
return relative(repoRoot, abs).replace(/\\/g, "/");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function now() {
|
|
59
|
-
return Date.now();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function normalizeLeaseType(leaseType) {
|
|
63
|
-
return leaseType === "shared-read" ? "shared-read" : "exclusive";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function normalizeSessionMeta(sessionMeta) {
|
|
67
|
-
return sessionMeta ?? null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function normalizeEntry(entry) {
|
|
71
|
-
return {
|
|
72
|
-
workerId: entry.workerId,
|
|
73
|
-
acquiredAt: entry.acquiredAt,
|
|
74
|
-
leaseType: normalizeLeaseType(entry.leaseType),
|
|
75
|
-
sessionMeta: normalizeSessionMeta(entry.sessionMeta),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function hasConflict(existing, requesterId, requestedLeaseType) {
|
|
80
|
-
if (!existing || existing.workerId === requesterId) return false;
|
|
81
|
-
if (
|
|
82
|
-
existing.leaseType === "shared-read" &&
|
|
83
|
-
requestedLeaseType === "shared-read"
|
|
84
|
-
) {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
return true;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function isExpired(entry) {
|
|
91
|
-
return now() - entry.acquiredAt > ttlMs;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function pruneExpired() {
|
|
95
|
-
for (const [path, entry] of locks) {
|
|
96
|
-
if (isExpired(entry)) locks.delete(path);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ── persistence ─────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
function persist() {
|
|
103
|
-
if (!persistPath) return;
|
|
104
|
-
try {
|
|
105
|
-
mkdirSync(dirname(persistPath), { recursive: true });
|
|
106
|
-
const data = Object.fromEntries(
|
|
107
|
-
[...locks].map(([k, v]) => [k, { ...v }]),
|
|
108
|
-
);
|
|
109
|
-
writeFileSync(persistPath, JSON.stringify(data, null, 2), "utf8");
|
|
110
|
-
} catch {
|
|
111
|
-
/* best-effort */
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function restore() {
|
|
116
|
-
if (!persistPath || !existsSync(persistPath)) return;
|
|
117
|
-
try {
|
|
118
|
-
const data = JSON.parse(readFileSync(persistPath, "utf8"));
|
|
119
|
-
const ts = now();
|
|
120
|
-
for (const [path, entry] of Object.entries(data)) {
|
|
121
|
-
if (ts - entry.acquiredAt <= ttlMs) {
|
|
122
|
-
locks.set(path, normalizeEntry(entry));
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
} catch {
|
|
126
|
-
/* corrupted file — start fresh */
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// restore on creation
|
|
131
|
-
restore();
|
|
132
|
-
|
|
133
|
-
// ── public API ──────────────────────────────────────────────
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Acquire file leases for a worker.
|
|
137
|
-
* @param {string} workerId — worker/shard identifier
|
|
138
|
-
* @param {string[]} files — file paths to lock
|
|
139
|
-
* @param {{ leaseType?: "exclusive" | "shared-read", sessionMeta?: { sessionId: string, host: string, taskSummary: string } }} [opts]
|
|
140
|
-
* @returns {{ ok: boolean, acquired: string[], conflicts: Array<{ file: string, holder: string }> }}
|
|
141
|
-
*/
|
|
142
|
-
function acquire(workerId, files, opts = {}) {
|
|
143
|
-
pruneExpired();
|
|
144
|
-
|
|
145
|
-
const leaseType = normalizeLeaseType(opts.leaseType);
|
|
146
|
-
const sessionMeta = normalizeSessionMeta(opts.sessionMeta);
|
|
147
|
-
const normalized = files.map((f) => normalizePath(f));
|
|
148
|
-
const conflicts = [];
|
|
149
|
-
const toAcquire = [];
|
|
150
|
-
const allowedSharedRead = [];
|
|
151
|
-
|
|
152
|
-
for (let i = 0; i < normalized.length; i++) {
|
|
153
|
-
const path = normalized[i];
|
|
154
|
-
const existing = locks.get(path);
|
|
155
|
-
|
|
156
|
-
if (!existing || isExpired(existing)) {
|
|
157
|
-
toAcquire.push(path);
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (hasConflict(existing, workerId, leaseType)) {
|
|
162
|
-
conflicts.push({ file: files[i], holder: existing.workerId });
|
|
163
|
-
} else {
|
|
164
|
-
if (
|
|
165
|
-
!(
|
|
166
|
-
existing.workerId !== workerId &&
|
|
167
|
-
existing.leaseType === "shared-read" &&
|
|
168
|
-
leaseType === "shared-read"
|
|
169
|
-
)
|
|
170
|
-
) {
|
|
171
|
-
toAcquire.push(path);
|
|
172
|
-
} else {
|
|
173
|
-
allowedSharedRead.push(path);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (conflicts.length > 0) {
|
|
179
|
-
return { ok: false, acquired: [], conflicts };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const ts = now();
|
|
183
|
-
for (const path of toAcquire) {
|
|
184
|
-
locks.set(path, { workerId, acquiredAt: ts, leaseType, sessionMeta });
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
persist();
|
|
188
|
-
return {
|
|
189
|
-
ok: true,
|
|
190
|
-
acquired: [...toAcquire, ...allowedSharedRead],
|
|
191
|
-
conflicts: [],
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Release all locks held by a worker.
|
|
197
|
-
* @param {string} workerId
|
|
198
|
-
* @returns {number} number of locks released
|
|
199
|
-
*/
|
|
200
|
-
function release(workerId) {
|
|
201
|
-
let count = 0;
|
|
202
|
-
for (const [path, entry] of locks) {
|
|
203
|
-
if (entry.workerId === workerId) {
|
|
204
|
-
locks.delete(path);
|
|
205
|
-
count += 1;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
if (count > 0) persist();
|
|
209
|
-
return count;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function renew(workerId) {
|
|
213
|
-
pruneExpired();
|
|
214
|
-
const ts = now();
|
|
215
|
-
let count = 0;
|
|
216
|
-
for (const entry of locks.values()) {
|
|
217
|
-
if (entry.workerId === workerId) {
|
|
218
|
-
entry.acquiredAt = ts;
|
|
219
|
-
count += 1;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
if (count > 0) persist();
|
|
223
|
-
return count;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Check if a file write would violate any lease.
|
|
228
|
-
* @param {string} workerId — the worker attempting the write
|
|
229
|
-
* @param {string} filePath — the file being written
|
|
230
|
-
* @param {"exclusive" | "shared-read"} [leaseType="exclusive"] — requested lease type
|
|
231
|
-
* @returns {{ allowed: boolean, holder?: string }}
|
|
232
|
-
*/
|
|
233
|
-
function check(workerId, filePath, leaseType = "exclusive") {
|
|
234
|
-
pruneExpired();
|
|
235
|
-
const path = normalizePath(filePath);
|
|
236
|
-
const entry = locks.get(path);
|
|
237
|
-
const normalizedLeaseType = normalizeLeaseType(leaseType);
|
|
238
|
-
|
|
239
|
-
if (!entry || isExpired(entry)) return { allowed: true };
|
|
240
|
-
if (!hasConflict(entry, workerId, normalizedLeaseType))
|
|
241
|
-
return { allowed: true };
|
|
242
|
-
return { allowed: false, holder: entry.workerId };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Validate a set of changed files against the lease map.
|
|
247
|
-
* Returns all violations found.
|
|
248
|
-
*
|
|
249
|
-
* Beyond the basic cross-lease check, when `options.ownLease` is supplied
|
|
250
|
-
* (the caller's known lease set), this also flags out-of-lease writes to
|
|
251
|
-
* distribution-critical paths — e.g. recovery-patch fallouts where a
|
|
252
|
-
* worker's recorded diff carries `+++ /dev/null` deletions of files like
|
|
253
|
-
* `.claude-plugin/marketplace.json` (regression of #115 / #34).
|
|
254
|
-
*
|
|
255
|
-
* `options.ownLease` is opt-in to preserve existing callers; when omitted,
|
|
256
|
-
* legacy behavior (cross-lease check only) is kept.
|
|
257
|
-
*
|
|
258
|
-
* @param {string} workerId
|
|
259
|
-
* @param {string[]} changedFiles
|
|
260
|
-
* @param {{ ownLease?: string[], sensitiveDeny?: { prefixes?: string[], files?: string[] } }} [options]
|
|
261
|
-
* @returns {Array<{ file: string, holder?: string, kind: "other-lease" | "sensitive-out-of-lease" }>}
|
|
262
|
-
*/
|
|
263
|
-
function validateChanges(workerId, changedFiles, options = {}) {
|
|
264
|
-
pruneExpired();
|
|
265
|
-
const violations = [];
|
|
266
|
-
const ownLeaseSet = Array.isArray(options.ownLease)
|
|
267
|
-
? new Set(options.ownLease.map((path) => normalizePath(path)))
|
|
268
|
-
: null;
|
|
269
|
-
const sensitivePrefixes =
|
|
270
|
-
options.sensitiveDeny?.prefixes ?? SENSITIVE_PATH_PREFIXES;
|
|
271
|
-
const sensitiveFiles = new Set(
|
|
272
|
-
options.sensitiveDeny?.files ?? SENSITIVE_PATH_FILES,
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
for (const file of changedFiles) {
|
|
276
|
-
const path = normalizePath(file);
|
|
277
|
-
const entry = locks.get(path);
|
|
278
|
-
|
|
279
|
-
if (entry && entry.workerId !== workerId && !isExpired(entry)) {
|
|
280
|
-
violations.push({ file, holder: entry.workerId, kind: "other-lease" });
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Sensitive-path guard only runs when caller supplied ownLease.
|
|
285
|
-
// We only flag when the worker did NOT explicitly lease the file —
|
|
286
|
-
// an explicit lease means the shard intentionally owns it.
|
|
287
|
-
if (ownLeaseSet && !ownLeaseSet.has(path)) {
|
|
288
|
-
const isSensitive =
|
|
289
|
-
sensitiveFiles.has(path) ||
|
|
290
|
-
sensitivePrefixes.some((prefix) => path.startsWith(prefix));
|
|
291
|
-
if (isSensitive) {
|
|
292
|
-
violations.push({ file, kind: "sensitive-out-of-lease" });
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return violations;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Get snapshot of all active locks.
|
|
302
|
-
* @returns {Array<{ file: string, workerId: string, acquiredAt: number, leaseType: "exclusive" | "shared-read", sessionMeta: { sessionId: string, host: string, taskSummary: string } | null }>}
|
|
303
|
-
*/
|
|
304
|
-
function snapshot() {
|
|
305
|
-
pruneExpired();
|
|
306
|
-
return [...locks].map(([file, entry]) => ({
|
|
307
|
-
file,
|
|
308
|
-
workerId: entry.workerId,
|
|
309
|
-
acquiredAt: entry.acquiredAt,
|
|
310
|
-
leaseType: normalizeLeaseType(entry.leaseType),
|
|
311
|
-
sessionMeta: normalizeSessionMeta(entry.sessionMeta),
|
|
312
|
-
}));
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Release all locks.
|
|
317
|
-
*/
|
|
318
|
-
function releaseAll() {
|
|
319
|
-
locks.clear();
|
|
320
|
-
persist();
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return Object.freeze({
|
|
324
|
-
acquire,
|
|
325
|
-
release,
|
|
326
|
-
renew,
|
|
327
|
-
check,
|
|
328
|
-
validateChanges,
|
|
329
|
-
snapshot,
|
|
330
|
-
releaseAll,
|
|
331
|
-
get size() {
|
|
332
|
-
return locks.size;
|
|
333
|
-
},
|
|
334
|
-
});
|
|
335
|
-
}
|
|
@@ -1,371 +0,0 @@
|
|
|
1
|
-
// hub/team/swarm-planner.mjs — PRD → Swarm execution plan generator
|
|
2
|
-
// Parses a PRD markdown document into shards (units of work), each with:
|
|
3
|
-
// - file-lease-map: files the shard is allowed to modify
|
|
4
|
-
// - MCP manifest: MCP servers the shard needs
|
|
5
|
-
// - mergeOrder: topological order for integrating results
|
|
6
|
-
//
|
|
7
|
-
// PRD format expected:
|
|
8
|
-
// ## Shard: <name>
|
|
9
|
-
// - agent: codex|gemini|claude
|
|
10
|
-
// - files: path/a.mjs, path/b.mjs
|
|
11
|
-
// - mcp: server1, server2
|
|
12
|
-
// - depends: shard-name-1, shard-name-2
|
|
13
|
-
// - critical: true|false
|
|
14
|
-
// - redundancy: false|fallback|true|same-cli
|
|
15
|
-
// - prompt: |
|
|
16
|
-
// multi-line prompt text
|
|
17
|
-
|
|
18
|
-
import { readFileSync } from "node:fs";
|
|
19
|
-
import { selectHostForCapability } from "@triflux/core/hub/lib/ssh-command.mjs";
|
|
20
|
-
|
|
21
|
-
/** Shard schema defaults */
|
|
22
|
-
const SHARD_DEFAULTS = Object.freeze({
|
|
23
|
-
agent: "codex",
|
|
24
|
-
files: [],
|
|
25
|
-
mcp: [],
|
|
26
|
-
depends: [],
|
|
27
|
-
critical: false,
|
|
28
|
-
redundancy: false,
|
|
29
|
-
prompt: "",
|
|
30
|
-
host: "",
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Parse a PRD markdown into shard definitions.
|
|
35
|
-
* @param {string} content — PRD markdown content
|
|
36
|
-
* @returns {Shard[]}
|
|
37
|
-
*/
|
|
38
|
-
export function parseShards(content) {
|
|
39
|
-
const lines = content.split(/\r?\n/);
|
|
40
|
-
const shards = [];
|
|
41
|
-
let current = null;
|
|
42
|
-
let inPrompt = false;
|
|
43
|
-
let promptLines = [];
|
|
44
|
-
|
|
45
|
-
function flushPrompt() {
|
|
46
|
-
if (current && promptLines.length > 0) {
|
|
47
|
-
current.prompt = promptLines.join("\n").trim();
|
|
48
|
-
promptLines = [];
|
|
49
|
-
}
|
|
50
|
-
inPrompt = false;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function flushShard() {
|
|
54
|
-
flushPrompt();
|
|
55
|
-
if (current) {
|
|
56
|
-
shards.push({ ...SHARD_DEFAULTS, ...current });
|
|
57
|
-
current = null;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
for (const line of lines) {
|
|
62
|
-
// New shard header: ## Shard: <name>
|
|
63
|
-
const shardMatch = line.match(/^##\s+Shard:\s*(.+)$/i);
|
|
64
|
-
if (shardMatch) {
|
|
65
|
-
flushShard();
|
|
66
|
-
current = { name: shardMatch[1].trim() };
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Non-shard heading ends current shard (e.g. ## Notes)
|
|
71
|
-
if (/^##\s+/.test(line) && !line.match(/^##\s+Shard:/i)) {
|
|
72
|
-
flushShard();
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (!current) continue;
|
|
77
|
-
|
|
78
|
-
// Prompt block continuation
|
|
79
|
-
if (inPrompt) {
|
|
80
|
-
if (/^- \w+:/i.test(line)) {
|
|
81
|
-
flushPrompt();
|
|
82
|
-
// fall through to field parsing
|
|
83
|
-
} else {
|
|
84
|
-
promptLines.push(line);
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Field parsing: - key: value
|
|
90
|
-
const fieldMatch = line.match(/^-\s+(\w+):\s*(.*)$/i);
|
|
91
|
-
if (!fieldMatch) continue;
|
|
92
|
-
|
|
93
|
-
const [, key, rawValue] = fieldMatch;
|
|
94
|
-
const value = rawValue.trim();
|
|
95
|
-
|
|
96
|
-
switch (key.toLowerCase()) {
|
|
97
|
-
case "agent":
|
|
98
|
-
current.agent = value.toLowerCase();
|
|
99
|
-
break;
|
|
100
|
-
case "files":
|
|
101
|
-
current.files = value
|
|
102
|
-
.split(/,\s*/)
|
|
103
|
-
.map((f) => f.trim())
|
|
104
|
-
.filter(Boolean);
|
|
105
|
-
break;
|
|
106
|
-
case "mcp":
|
|
107
|
-
current.mcp = value
|
|
108
|
-
.split(/,\s*/)
|
|
109
|
-
.map((s) => s.trim())
|
|
110
|
-
.filter(Boolean);
|
|
111
|
-
break;
|
|
112
|
-
case "depends":
|
|
113
|
-
current.depends = value
|
|
114
|
-
.split(/,\s*/)
|
|
115
|
-
.map((d) => d.trim())
|
|
116
|
-
.filter(Boolean)
|
|
117
|
-
.filter(
|
|
118
|
-
(d) => !["none", "-", "—", "n/a", "없음"].includes(d.toLowerCase()),
|
|
119
|
-
);
|
|
120
|
-
break;
|
|
121
|
-
case "critical":
|
|
122
|
-
current.critical = /^(true|yes|1)$/i.test(value);
|
|
123
|
-
break;
|
|
124
|
-
case "redundancy":
|
|
125
|
-
if (/^(true|yes|1|fallback)$/i.test(value)) {
|
|
126
|
-
current.redundancy = "fallback";
|
|
127
|
-
} else if (/^(same-cli|same_cli|same|primary)$/i.test(value)) {
|
|
128
|
-
current.redundancy = "same-cli";
|
|
129
|
-
} else {
|
|
130
|
-
current.redundancy = false;
|
|
131
|
-
}
|
|
132
|
-
break;
|
|
133
|
-
case "host":
|
|
134
|
-
current.host = value;
|
|
135
|
-
break;
|
|
136
|
-
case "prompt":
|
|
137
|
-
if (value && !value.startsWith("|")) {
|
|
138
|
-
current.prompt = value;
|
|
139
|
-
} else {
|
|
140
|
-
inPrompt = true;
|
|
141
|
-
promptLines = [];
|
|
142
|
-
}
|
|
143
|
-
break;
|
|
144
|
-
default:
|
|
145
|
-
// store unknown fields as-is
|
|
146
|
-
current[key] = value;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
flushShard();
|
|
151
|
-
return shards;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Build file-lease-map from shards.
|
|
156
|
-
* Maps each shard name to its allowed files.
|
|
157
|
-
* Detects conflicting file assignments across shards.
|
|
158
|
-
* @param {Shard[]} shards
|
|
159
|
-
* @returns {{ leaseMap: Map<string, string[]>, conflicts: Array<{ file: string, shards: string[] }> }}
|
|
160
|
-
*/
|
|
161
|
-
export function buildFileLeaseMap(shards) {
|
|
162
|
-
const leaseMap = new Map();
|
|
163
|
-
const fileOwners = new Map(); // file → [shard names]
|
|
164
|
-
|
|
165
|
-
for (const shard of shards) {
|
|
166
|
-
leaseMap.set(shard.name, [...shard.files]);
|
|
167
|
-
for (const file of shard.files) {
|
|
168
|
-
const owners = fileOwners.get(file) || [];
|
|
169
|
-
owners.push(shard.name);
|
|
170
|
-
fileOwners.set(file, owners);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const conflicts = [];
|
|
175
|
-
for (const [file, owners] of fileOwners) {
|
|
176
|
-
if (owners.length > 1) {
|
|
177
|
-
conflicts.push({ file, shards: owners });
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return { leaseMap, conflicts };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Build MCP manifest from shards.
|
|
186
|
-
* Maps each shard name to its required MCP servers.
|
|
187
|
-
* @param {Shard[]} shards
|
|
188
|
-
* @returns {Map<string, string[]>}
|
|
189
|
-
*/
|
|
190
|
-
export function buildMcpManifest(shards) {
|
|
191
|
-
const manifest = new Map();
|
|
192
|
-
for (const shard of shards) {
|
|
193
|
-
manifest.set(shard.name, [...shard.mcp]);
|
|
194
|
-
}
|
|
195
|
-
return manifest;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Compute topological merge order based on shard dependencies.
|
|
200
|
-
* @param {Shard[]} shards
|
|
201
|
-
* @returns {{ order: string[], cycles: string[][] }}
|
|
202
|
-
*/
|
|
203
|
-
export function computeMergeOrder(shards) {
|
|
204
|
-
const nameSet = new Set(shards.map((s) => s.name));
|
|
205
|
-
const adj = new Map(); // name → [dependents]
|
|
206
|
-
const inDeg = new Map(); // name → number
|
|
207
|
-
|
|
208
|
-
for (const shard of shards) {
|
|
209
|
-
adj.set(shard.name, []);
|
|
210
|
-
inDeg.set(shard.name, 0);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
for (const shard of shards) {
|
|
214
|
-
for (const dep of shard.depends) {
|
|
215
|
-
if (!nameSet.has(dep)) continue; // ignore unknown deps
|
|
216
|
-
adj.get(dep).push(shard.name);
|
|
217
|
-
inDeg.set(shard.name, inDeg.get(shard.name) + 1);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Kahn's algorithm
|
|
222
|
-
const queue = [];
|
|
223
|
-
for (const [name, deg] of inDeg) {
|
|
224
|
-
if (deg === 0) queue.push(name);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const order = [];
|
|
228
|
-
while (queue.length > 0) {
|
|
229
|
-
// stable sort: alphabetical among same-level nodes
|
|
230
|
-
queue.sort();
|
|
231
|
-
const node = queue.shift();
|
|
232
|
-
order.push(node);
|
|
233
|
-
|
|
234
|
-
for (const next of adj.get(node)) {
|
|
235
|
-
const newDeg = inDeg.get(next) - 1;
|
|
236
|
-
inDeg.set(next, newDeg);
|
|
237
|
-
if (newDeg === 0) queue.push(next);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Detect cycles (nodes not in order)
|
|
242
|
-
const cycles = [];
|
|
243
|
-
if (order.length < shards.length) {
|
|
244
|
-
const remaining = shards
|
|
245
|
-
.filter((s) => !order.includes(s.name))
|
|
246
|
-
.map((s) => s.name);
|
|
247
|
-
cycles.push(remaining);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
return { order, cycles };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Full planning pipeline: parse PRD → build plan.
|
|
255
|
-
* @param {string} prdPath — path to PRD markdown file
|
|
256
|
-
* @param {object} [opts]
|
|
257
|
-
* @param {string} [opts.content] — PRD content (if provided, prdPath is ignored)
|
|
258
|
-
* @returns {SwarmPlan}
|
|
259
|
-
*/
|
|
260
|
-
export function planSwarm(prdPath, opts = {}) {
|
|
261
|
-
const content = opts.content || readFileSync(prdPath, "utf8");
|
|
262
|
-
const shards = parseShards(content);
|
|
263
|
-
|
|
264
|
-
if (shards.length === 0) {
|
|
265
|
-
throw new Error(
|
|
266
|
-
'No shards found in PRD. Expected "## Shard: <name>" sections.',
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const emptyPromptShards = shards.filter(
|
|
271
|
-
(s) => !s.prompt || s.prompt.trim() === "",
|
|
272
|
-
);
|
|
273
|
-
if (emptyPromptShards.length > 0) {
|
|
274
|
-
const names = emptyPromptShards.map((s) => s.name).join(", ");
|
|
275
|
-
throw new Error(
|
|
276
|
-
`Shard(s) with empty prompt: ${names}. ` +
|
|
277
|
-
`Each shard must include a non-empty "- prompt: |" block. ` +
|
|
278
|
-
`See docs/prd/_template.md for PRD format.`,
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const { leaseMap, conflicts } = buildFileLeaseMap(shards);
|
|
283
|
-
const mcpManifest = buildMcpManifest(shards);
|
|
284
|
-
const { order: mergeOrder, cycles } = computeMergeOrder(shards);
|
|
285
|
-
|
|
286
|
-
if (cycles.length > 0) {
|
|
287
|
-
throw new Error(`Dependency cycle detected: ${cycles[0].join(" → ")}`);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Auto-remote suggestion: PRD에 host 미지정 shard가 있고,
|
|
291
|
-
// hosts.json에 가용 원격 호스트가 있으면 제안 데이터를 생성한다.
|
|
292
|
-
const remoteSuggestion = buildRemoteSuggestion(shards, opts.repoRoot);
|
|
293
|
-
|
|
294
|
-
return Object.freeze({
|
|
295
|
-
shards: Object.freeze(shards.map((s) => Object.freeze({ ...s }))),
|
|
296
|
-
leaseMap,
|
|
297
|
-
mcpManifest,
|
|
298
|
-
mergeOrder,
|
|
299
|
-
conflicts,
|
|
300
|
-
criticalShards: shards.filter((s) => s.critical).map((s) => s.name),
|
|
301
|
-
remoteSuggestion,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* PRD shard에 host가 없고 원격 호스트가 가용하면 분배 제안을 생성한다.
|
|
307
|
-
* 실제 AskUserQuestion 호출은 스킬(tfx-swarm)에서 수행. 여기는 데이터만.
|
|
308
|
-
* @param {Shard[]} shards
|
|
309
|
-
* @param {string} [repoRoot]
|
|
310
|
-
* @returns {object|null} 제안 데이터 또는 null (제안 없음)
|
|
311
|
-
*/
|
|
312
|
-
function buildRemoteSuggestion(shards, repoRoot) {
|
|
313
|
-
const localShards = shards.filter((s) => !s.host);
|
|
314
|
-
if (localShards.length === 0) return null; // 모든 shard에 host 지정됨
|
|
315
|
-
|
|
316
|
-
// 각 shard의 agent에 대해 가용 원격 호스트 조회
|
|
317
|
-
const agentTypes = [...new Set(localShards.map((s) => s.agent))];
|
|
318
|
-
const availableHosts = [];
|
|
319
|
-
|
|
320
|
-
for (const agent of agentTypes) {
|
|
321
|
-
try {
|
|
322
|
-
const hosts = selectHostForCapability(agent, repoRoot);
|
|
323
|
-
for (const h of hosts) {
|
|
324
|
-
if (!availableHosts.find((ah) => ah.name === h.name)) {
|
|
325
|
-
availableHosts.push(h);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
} catch {
|
|
329
|
-
// hosts.json 없거나 파싱 실패 → 무시
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (availableHosts.length === 0) return null;
|
|
334
|
-
|
|
335
|
-
// 분배 제안: 로컬 shard 중 절반(내림)을 원격에 배치
|
|
336
|
-
const remoteCount = Math.min(
|
|
337
|
-
Math.floor(localShards.length / 2),
|
|
338
|
-
availableHosts.length,
|
|
339
|
-
);
|
|
340
|
-
if (remoteCount === 0) return null;
|
|
341
|
-
|
|
342
|
-
// 의존성 없는 shard를 우선 원격 후보로 선택
|
|
343
|
-
const candidates = localShards
|
|
344
|
-
.filter((s) => s.depends.length === 0)
|
|
345
|
-
.concat(localShards.filter((s) => s.depends.length > 0));
|
|
346
|
-
|
|
347
|
-
const suggested = [];
|
|
348
|
-
for (let i = 0; i < remoteCount && i < candidates.length; i++) {
|
|
349
|
-
const shard = candidates[i];
|
|
350
|
-
const host = availableHosts[i % availableHosts.length];
|
|
351
|
-
suggested.push({
|
|
352
|
-
shardName: shard.name,
|
|
353
|
-
host: host.name,
|
|
354
|
-
hostDescription: host.config.description,
|
|
355
|
-
specs: host.specs,
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return Object.freeze({
|
|
360
|
-
localCount: localShards.length - remoteCount,
|
|
361
|
-
remoteCount,
|
|
362
|
-
totalShards: shards.length,
|
|
363
|
-
availableHosts: availableHosts.map((h) => ({
|
|
364
|
-
name: h.name,
|
|
365
|
-
description: h.config.description,
|
|
366
|
-
specs: h.specs,
|
|
367
|
-
capabilities: h.config.capabilities,
|
|
368
|
-
})),
|
|
369
|
-
suggested: Object.freeze(suggested),
|
|
370
|
-
});
|
|
371
|
-
}
|