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