@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.
Files changed (97) hide show
  1. package/cto/brief.mjs +0 -12
  2. package/cto/collect.mjs +0 -73
  3. package/cto/current.schema.json +0 -4
  4. package/cto/status.mjs +2 -50
  5. package/hub/index.mjs +0 -8
  6. package/hub/team/agent-map.json +0 -1
  7. package/hub/team/ansi.mjs +1 -35
  8. package/hub/team/backend.mjs +9 -145
  9. package/hub/team/cli/commands/attach.mjs +1 -17
  10. package/hub/team/cli/commands/control.mjs +12 -38
  11. package/hub/team/cli/commands/debug.mjs +1 -37
  12. package/hub/team/cli/commands/focus.mjs +5 -31
  13. package/hub/team/cli/commands/interrupt.mjs +6 -25
  14. package/hub/team/cli/commands/kill.mjs +1 -30
  15. package/hub/team/cli/commands/list.mjs +0 -16
  16. package/hub/team/cli/commands/send.mjs +6 -26
  17. package/hub/team/cli/commands/start/index.mjs +41 -133
  18. package/hub/team/cli/commands/start/parse-args.mjs +15 -40
  19. package/hub/team/cli/commands/start/start-headless.mjs +138 -105
  20. package/hub/team/cli/commands/start/start-mux.mjs +0 -3
  21. package/hub/team/cli/commands/status.mjs +2 -57
  22. package/hub/team/cli/commands/stop.mjs +14 -19
  23. package/hub/team/cli/help.mjs +4 -5
  24. package/hub/team/cli/manifest.mjs +5 -1
  25. package/hub/team/cli/render.mjs +0 -12
  26. package/hub/team/cli/services/hub-client.mjs +2 -88
  27. package/hub/team/cli/services/runtime-mode.mjs +27 -98
  28. package/hub/team/handoff.mjs +3 -34
  29. package/hub/team/headless.mjs +13 -754
  30. package/hub/team/orchestrator.mjs +4 -30
  31. package/hub/team/pane.mjs +6 -28
  32. package/hub/team/psmux.mjs +0 -1
  33. package/hub/team/remote-session.mjs +1 -2
  34. package/hub/team/session.mjs +7 -91
  35. package/hub/team/swarm-intent.mjs +0 -198
  36. package/hub/team/tui-core.mjs +1 -1
  37. package/hub/team/tui-viewer.mjs +2 -2
  38. package/hub/team/tui.mjs +8 -269
  39. package/hub/team/wt-manager.mjs +2 -2
  40. package/hub/workers/factory.mjs +4 -37
  41. package/hub/workers/lib/jsonrpc-ws-uds.mjs +0 -2
  42. package/package.json +1 -1
  43. package/scripts/lib/agent-route-policy.mjs +162 -14
  44. package/scripts/lib/claudemd-scanner.mjs +2 -199
  45. package/scripts/lib/cli-claude.mjs +0 -2
  46. package/scripts/lib/context.mjs +3 -19
  47. package/scripts/lib/env-probe.mjs +5 -64
  48. package/scripts/lib/gemini-profiles.mjs +2 -69
  49. package/scripts/lib/machine-profile.mjs +3 -17
  50. package/scripts/lib/mcp-filter.mjs +109 -53
  51. package/scripts/lib/mcp-health.mjs +2 -2
  52. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  53. package/scripts/lib/psmux-info.mjs +11 -59
  54. package/scripts/lib/skill-template.mjs +0 -245
  55. package/hub/team/build-worker-prompt.mjs +0 -92
  56. package/hub/team/check-mcp-hub.mjs +0 -49
  57. package/hub/team/claude-native-bridge.mjs +0 -987
  58. package/hub/team/cli/commands/start/start-in-process.mjs +0 -50
  59. package/hub/team/cli/commands/start/start-wt.mjs +0 -86
  60. package/hub/team/cli/services/native-control.mjs +0 -145
  61. package/hub/team/codex-review.mjs +0 -520
  62. package/hub/team/conductor-mesh-bridge.mjs +0 -122
  63. package/hub/team/conductor.mjs +0 -1529
  64. package/hub/team/consensus-meta.mjs +0 -176
  65. package/hub/team/cto-pull.mjs +0 -31
  66. package/hub/team/daemon-pty-tmux-bridge.mjs +0 -146
  67. package/hub/team/dashboard.mjs +0 -322
  68. package/hub/team/event-log.mjs +0 -84
  69. package/hub/team/execution-mode.mjs +0 -324
  70. package/hub/team/extract-completion-payload.mjs +0 -110
  71. package/hub/team/headless-bridge-session.mjs +0 -17
  72. package/hub/team/health-probe.mjs +0 -435
  73. package/hub/team/interactive-native-launcher.mjs +0 -119
  74. package/hub/team/interactive-native-worker.mjs +0 -80
  75. package/hub/team/interactive-tui-transport.mjs +0 -479
  76. package/hub/team/launcher-template.mjs +0 -144
  77. package/hub/team/mcp-selector.mjs +0 -145
  78. package/hub/team/native-supervisor.mjs +0 -460
  79. package/hub/team/native.mjs +0 -727
  80. package/hub/team/recovery-store.mjs +0 -149
  81. package/hub/team/remote-probe.mjs +0 -292
  82. package/hub/team/remote-watcher.mjs +0 -506
  83. package/hub/team/runtime-strategy.mjs +0 -200
  84. package/hub/team/schemas/worker-completion.json +0 -39
  85. package/hub/team/sentinel-capture.mjs +0 -127
  86. package/hub/team/swarm-cli.mjs +0 -390
  87. package/hub/team/swarm-hypervisor.mjs +0 -2206
  88. package/hub/team/swarm-locks.mjs +0 -335
  89. package/hub/team/swarm-planner.mjs +0 -371
  90. package/hub/team/swarm-preflight.mjs +0 -366
  91. package/hub/team/swarm-reconciler.mjs +0 -242
  92. package/hub/team/tui-remote-adapter.mjs +0 -377
  93. package/hub/team/worker-completion-validator.mjs +0 -79
  94. package/hub/team/worktree-lifecycle.mjs +0 -639
  95. package/hub/team/wt-templates.json +0 -43
  96. package/hub/workers/antigravity-route-worker.mjs +0 -179
  97. package/scripts/lib/dynamic-route-cli.mjs +0 -107
@@ -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
- }