triflux 10.20.0 → 10.20.2

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 (71) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +34 -42
  4. package/README.md +30 -36
  5. package/bin/triflux.mjs +13 -4
  6. package/hooks/hook-orchestrator.mjs +1 -1
  7. package/hooks/hook-registry.json +76 -2
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/permission-safe-allow.mjs +233 -0
  10. package/hooks/pipeline-stop.mjs +355 -16
  11. package/hooks/post-tool-tips.mjs +306 -0
  12. package/hooks/pre-compact-snapshot.mjs +181 -0
  13. package/hooks/session-end-cleanup.mjs +201 -0
  14. package/hooks/subagent-tracker.mjs +267 -0
  15. package/hooks/subagent-verifier.mjs +4 -0
  16. package/hub/account-broker.mjs +297 -8
  17. package/hub/codex-adapter.mjs +62 -2
  18. package/hub/server.mjs +51 -4
  19. package/hub/team/cli/services/native-control.mjs +1 -0
  20. package/hub/team/conductor.mjs +94 -29
  21. package/hub/team/native-supervisor.mjs +20 -7
  22. package/hub/team/swarm-cli.mjs +98 -4
  23. package/hub/team/swarm-hypervisor.mjs +112 -10
  24. package/hub/team/swarm-locks.mjs +7 -2
  25. package/hub/team/swarm-preflight.mjs +317 -0
  26. package/hub/team/synapse-cli.mjs +243 -6
  27. package/hub/team/worker-sandbox.mjs +93 -0
  28. package/hub/team/worktree-lifecycle.mjs +49 -8
  29. package/hub/workers/delegator-mcp.mjs +14 -1
  30. package/package.json +1 -1
  31. package/scripts/__tests__/gen-skill-docs.test.mjs +4 -0
  32. package/scripts/__tests__/skill-surface.test.mjs +61 -0
  33. package/scripts/gen-skill-manifest.mjs +3 -0
  34. package/scripts/lib/skill-template.mjs +2 -0
  35. package/scripts/session-stale-cleanup.mjs +111 -2
  36. package/skills/star-prompt/skill.json +1 -1
  37. package/skills/tfx-analysis/skill.json +11 -3
  38. package/skills/tfx-auto/skill.json +2 -2
  39. package/skills/tfx-autopilot/skill.json +9 -4
  40. package/skills/tfx-consensus/skill.json +4 -3
  41. package/skills/tfx-debate/skill.json +11 -4
  42. package/skills/tfx-doctor/skill.json +3 -1
  43. package/skills/tfx-fullcycle/skill.json +10 -4
  44. package/skills/tfx-hooks/skill.json +3 -1
  45. package/skills/tfx-hub/skill.json +5 -3
  46. package/skills/tfx-index/skill.json +6 -1
  47. package/skills/tfx-interview/skill.json +6 -1
  48. package/skills/tfx-multi/skill.json +7 -3
  49. package/skills/tfx-panel/skill.json +11 -4
  50. package/skills/tfx-persist/skill.json +11 -4
  51. package/skills/tfx-plan/skill.json +13 -3
  52. package/skills/tfx-profile/skill.json +3 -1
  53. package/skills/tfx-prune/skill.json +7 -1
  54. package/skills/tfx-psmux-rules/SKILL.md +2 -0
  55. package/skills/tfx-psmux-rules/SKILL.md.tmpl +22 -307
  56. package/skills/tfx-psmux-rules/skill.json +7 -2
  57. package/skills/tfx-qa/skill.json +12 -3
  58. package/skills/tfx-ralph/skill.json +4 -1
  59. package/skills/tfx-remote/skill.json +8 -0
  60. package/skills/tfx-remote-setup/SKILL.md +2 -0
  61. package/skills/tfx-remote-setup/SKILL.md.tmpl +23 -570
  62. package/skills/tfx-remote-setup/skill.json +7 -3
  63. package/skills/tfx-remote-spawn/SKILL.md +2 -0
  64. package/skills/tfx-remote-spawn/SKILL.md.tmpl +31 -242
  65. package/skills/tfx-remote-spawn/skill.json +7 -3
  66. package/skills/tfx-research/skill.json +11 -4
  67. package/skills/tfx-review/skill.json +12 -3
  68. package/skills/tfx-setup/skill.json +3 -1
  69. package/skills/tfx-ship/skill.json +13 -0
  70. package/skills/tfx-swarm/skill.json +13 -0
  71. package/skills/tfx-wt/skill.json +12 -0
@@ -241,6 +241,7 @@ export function createSwarmHypervisor(opts) {
241
241
  integrationFailures: [],
242
242
  skipped: [],
243
243
  integrationBranch: null,
244
+ report: [],
244
245
  processCleanup: {
245
246
  totalKilled: 0,
246
247
  byCategory: {},
@@ -319,6 +320,9 @@ export function createSwarmHypervisor(opts) {
319
320
  ]),
320
321
  skipped: Object.freeze([...(payload.skipped || [])]),
321
322
  integrationBranch: payload.integrationBranch || null,
323
+ report: Object.freeze(
324
+ (payload.report || []).map((row) => Object.freeze({ ...row })),
325
+ ),
322
326
  results: Object.freeze([...(payload.results || [])]),
323
327
  processCleanup: Object.freeze({
324
328
  totalKilled: processCleanup.totalKilled || 0,
@@ -340,6 +344,7 @@ export function createSwarmHypervisor(opts) {
340
344
  integrationFailures: [...integrationResult.integrationFailures],
341
345
  skipped: [...integrationResult.skipped],
342
346
  integrationBranch: integrationResult.integrationBranch,
347
+ report: integrationResult.report.map((row) => ({ ...row })),
343
348
  processCleanup: integrationResult.processCleanup,
344
349
  error: integrationResult.error,
345
350
  };
@@ -373,6 +378,20 @@ export function createSwarmHypervisor(opts) {
373
378
  return getProcessCleanupSummary();
374
379
  }
375
380
 
381
+ function normalizeIntegrationReportRow(row) {
382
+ return {
383
+ shard: row.shard,
384
+ strategy: row.strategy || "failed",
385
+ commits:
386
+ Number.isFinite(Number(row.commits)) && row.commits !== null
387
+ ? Number(row.commits)
388
+ : null,
389
+ finalSha: row.finalSha || null,
390
+ notes: row.notes || "",
391
+ recoveryPatch: row.recoveryPatch || null,
392
+ };
393
+ }
394
+
376
395
  function git(args, cwd = workdir) {
377
396
  return new Promise((resolve, reject) => {
378
397
  execFile(
@@ -964,9 +983,23 @@ export function createSwarmHypervisor(opts) {
964
983
 
965
984
  const integrated = [];
966
985
  const integrationFailures = [];
986
+ const integrationReport = [];
967
987
  const preIntegrationFailures = [...failures.keys()];
968
988
  let integrationBranch = null;
969
989
 
990
+ const appendIntegrationReport = (row) => {
991
+ const normalized = normalizeIntegrationReportRow(row);
992
+ integrationReport.push(normalized);
993
+ eventLog.append("integration_shard_report", normalized);
994
+ return normalized;
995
+ };
996
+
997
+ const failureNotes = (shardName, fallback = "failed") => {
998
+ const info = failures.get(shardName);
999
+ if (!info) return fallback;
1000
+ return info.reason || info.mode || fallback;
1001
+ };
1002
+
970
1003
  try {
971
1004
  ({ integrationBranch } = await prepareIntegrationBranchImpl({
972
1005
  runId,
@@ -977,6 +1010,11 @@ export function createSwarmHypervisor(opts) {
977
1010
  for (const shardName of plan.mergeOrder) {
978
1011
  if (failures.has(shardName)) {
979
1012
  eventLog.append("skip_failed_shard", { shard: shardName });
1013
+ appendIntegrationReport({
1014
+ shard: shardName,
1015
+ strategy: "failed",
1016
+ notes: `skipped: ${failureNotes(shardName)}`,
1017
+ });
980
1018
  continue;
981
1019
  }
982
1020
 
@@ -1002,11 +1040,22 @@ export function createSwarmHypervisor(opts) {
1002
1040
  shard: shardName,
1003
1041
  error: fetchResult.error,
1004
1042
  });
1005
- await maybeCleanupWorktree(shardName, worker, shard, {
1006
- force: true,
1007
- failureReason: "remote_fetch_failed",
1008
- });
1043
+ const cleanup = await maybeCleanupWorktree(
1044
+ shardName,
1045
+ worker,
1046
+ shard,
1047
+ {
1048
+ force: true,
1049
+ failureReason: "remote_fetch_failed",
1050
+ },
1051
+ );
1009
1052
  integrationFailures.push(shardName);
1053
+ appendIntegrationReport({
1054
+ shard: shardName,
1055
+ strategy: "failed",
1056
+ notes: `remote_fetch_failed: ${fetchResult.error || "unknown"}`,
1057
+ recoveryPatch: cleanup?.recoveryPatch?.patchPath || null,
1058
+ });
1010
1059
  continue;
1011
1060
  }
1012
1061
  eventLog.append("remote_fetch_ok", {
@@ -1042,12 +1091,20 @@ export function createSwarmHypervisor(opts) {
1042
1091
  shard: shardName,
1043
1092
  ...commitEvidence,
1044
1093
  });
1045
- await maybeCleanupWorktree(shardName, worker, shard, {
1094
+ const cleanup = await maybeCleanupWorktree(shardName, worker, shard, {
1046
1095
  force: true,
1047
1096
  failureReason:
1048
1097
  failures.get(shardName)?.mode || "no_commit_evidence",
1049
1098
  });
1050
1099
  integrationFailures.push(shardName);
1100
+ appendIntegrationReport({
1101
+ shard: shardName,
1102
+ strategy: "failed",
1103
+ commits: commitEvidence.commitsAhead,
1104
+ finalSha: commitEvidence.headCommit,
1105
+ notes: failureNotes(shardName, "no_commit_evidence"),
1106
+ recoveryPatch: cleanup?.recoveryPatch?.patchPath || null,
1107
+ });
1051
1108
  continue;
1052
1109
  }
1053
1110
 
@@ -1065,12 +1122,18 @@ export function createSwarmHypervisor(opts) {
1065
1122
  shard: shardName,
1066
1123
  violations: validation.violations,
1067
1124
  });
1068
- await maybeCleanupWorktree(shardName, worker, shard, {
1125
+ const cleanup = await maybeCleanupWorktree(shardName, worker, shard, {
1069
1126
  force: true,
1070
1127
  failureReason:
1071
1128
  failures.get(shardName)?.mode || "lease_violation_revert",
1072
1129
  });
1073
1130
  integrationFailures.push(shardName);
1131
+ appendIntegrationReport({
1132
+ shard: shardName,
1133
+ strategy: "failed",
1134
+ notes: failureNotes(shardName, "lease_violation_revert"),
1135
+ recoveryPatch: cleanup?.recoveryPatch?.patchPath || null,
1136
+ });
1074
1137
  continue;
1075
1138
  }
1076
1139
 
@@ -1087,12 +1150,19 @@ export function createSwarmHypervisor(opts) {
1087
1150
  integrationBranch,
1088
1151
  error: rebaseResult.error,
1089
1152
  });
1090
- await maybeCleanupWorktree(shardName, worker, shard, {
1153
+ const cleanup = await maybeCleanupWorktree(shardName, worker, shard, {
1091
1154
  force: true,
1092
1155
  failureReason:
1093
1156
  rebaseResult.error || FAILURE_MODES.F5_MERGE_CONFLICT,
1094
1157
  });
1095
1158
  integrationFailures.push(shardName);
1159
+ appendIntegrationReport({
1160
+ shard: shardName,
1161
+ strategy: "failed",
1162
+ commits: rebaseResult.commits ?? commitEvidence.commitsAhead,
1163
+ notes: rebaseResult.error || FAILURE_MODES.F5_MERGE_CONFLICT,
1164
+ recoveryPatch: cleanup?.recoveryPatch?.patchPath || null,
1165
+ });
1096
1166
  continue;
1097
1167
  }
1098
1168
 
@@ -1103,9 +1173,19 @@ export function createSwarmHypervisor(opts) {
1103
1173
  worktreePath: worker.worktreePath || null,
1104
1174
  integrationBranch,
1105
1175
  headCommit: rebaseResult.headCommit,
1176
+ integrationStrategy: rebaseResult.strategy || "cherry-pick",
1177
+ commits: rebaseResult.commits ?? commitEvidence.commitsAhead,
1178
+ notes: rebaseResult.notes || "",
1106
1179
  completedAt: Date.now(),
1107
1180
  });
1108
1181
  integrated.push(shardName);
1182
+ appendIntegrationReport({
1183
+ shard: shardName,
1184
+ strategy: rebaseResult.strategy || "cherry-pick",
1185
+ commits: rebaseResult.commits ?? commitEvidence.commitsAhead,
1186
+ finalSha: rebaseResult.headCommit,
1187
+ notes: rebaseResult.notes || "",
1188
+ });
1109
1189
 
1110
1190
  await maybeCleanupWorktree(shardName, worker, shard, {
1111
1191
  branchName: shardBranch,
@@ -1128,6 +1208,15 @@ export function createSwarmHypervisor(opts) {
1128
1208
  const skipped = preIntegrationFailures.filter(
1129
1209
  (name) => !integrationFailures.includes(name),
1130
1210
  );
1211
+ const reported = new Set(integrationReport.map((row) => row.shard));
1212
+ for (const shardName of failed) {
1213
+ if (reported.has(shardName)) continue;
1214
+ appendIntegrationReport({
1215
+ shard: shardName,
1216
+ strategy: "failed",
1217
+ notes: `integration_error: ${err.message}`,
1218
+ });
1219
+ }
1131
1220
 
1132
1221
  eventLog.append("integration_complete", {
1133
1222
  integrated,
@@ -1135,6 +1224,7 @@ export function createSwarmHypervisor(opts) {
1135
1224
  integrationFailures,
1136
1225
  integrationBranch,
1137
1226
  skipped,
1227
+ report: integrationReport,
1138
1228
  error: err.message,
1139
1229
  });
1140
1230
 
@@ -1145,6 +1235,7 @@ export function createSwarmHypervisor(opts) {
1145
1235
  integrationFailures,
1146
1236
  integrationBranch,
1147
1237
  skipped,
1238
+ report: integrationReport,
1148
1239
  results: [...results.values()],
1149
1240
  partial: true,
1150
1241
  error: err.message,
@@ -1164,6 +1255,7 @@ export function createSwarmHypervisor(opts) {
1164
1255
  integrationFailures,
1165
1256
  integrationBranch,
1166
1257
  skipped,
1258
+ report: integrationReport,
1167
1259
  });
1168
1260
 
1169
1261
  if (failed.length > 0 && integrated.length === 0) {
@@ -1181,6 +1273,7 @@ export function createSwarmHypervisor(opts) {
1181
1273
  integrationFailures,
1182
1274
  integrationBranch,
1183
1275
  skipped,
1276
+ report: integrationReport,
1184
1277
  results: [...results.values()],
1185
1278
  partial: failed.length > 0,
1186
1279
  });
@@ -1198,9 +1291,10 @@ export function createSwarmHypervisor(opts) {
1198
1291
  failureReason = null,
1199
1292
  } = {},
1200
1293
  ) {
1201
- if (!worker?.worktreePath || shard?.host) return;
1202
- if (failureReason && keepFailedWorktrees) return;
1203
- if (cleanedWorktreePaths.has(worker.worktreePath)) return;
1294
+ const outcome = { recoveryPatch: null, processCleanup: null };
1295
+ if (!worker?.worktreePath || shard?.host) return outcome;
1296
+ if (failureReason && keepFailedWorktrees) return outcome;
1297
+ if (cleanedWorktreePaths.has(worker.worktreePath)) return outcome;
1204
1298
 
1205
1299
  // Best-effort: preserve any uncommitted worker changes before removing
1206
1300
  // the worktree. Silent on failure — must not block cleanup or mask the
@@ -1213,6 +1307,10 @@ export function createSwarmHypervisor(opts) {
1213
1307
  recoveryDir: join(workdir, ".codex-swarm", "recovery"),
1214
1308
  });
1215
1309
  if (preservation?.ok && !preservation.skipped) {
1310
+ outcome.recoveryPatch = {
1311
+ patchPath: preservation.patchPath,
1312
+ manifestPath: preservation.manifestPath || null,
1313
+ };
1216
1314
  eventLog.append("recovery_patch_saved", {
1217
1315
  shard: shardName,
1218
1316
  worktreePath: worker.worktreePath,
@@ -1236,6 +1334,7 @@ export function createSwarmHypervisor(opts) {
1236
1334
  runId: plan?.runId || runId,
1237
1335
  shardName,
1238
1336
  });
1337
+ outcome.processCleanup = processCleanup;
1239
1338
  worker.processCleanup = processCleanup;
1240
1339
  recordProcessCleanup(processCleanup);
1241
1340
  eventLog.append("process_cleanup", {
@@ -1275,6 +1374,8 @@ export function createSwarmHypervisor(opts) {
1275
1374
  error: err.message,
1276
1375
  });
1277
1376
  }
1377
+
1378
+ return outcome;
1278
1379
  }
1279
1380
 
1280
1381
  /**
@@ -1387,6 +1488,7 @@ export function createSwarmHypervisor(opts) {
1387
1488
  integrationFailures: [...integrationPromiseState.integrationFailures],
1388
1489
  skipped: [...integrationPromiseState.skipped],
1389
1490
  integrationBranch: integrationPromiseState.integrationBranch,
1491
+ report: integrationPromiseState.report.map((row) => ({ ...row })),
1390
1492
  processCleanup: Object.freeze({
1391
1493
  totalKilled: integrationPromiseState.processCleanup.totalKilled,
1392
1494
  byCategory: Object.freeze({
@@ -15,12 +15,12 @@ const LOCK_TTL_MS = 10 * 60_000; // 10 minutes default TTL
15
15
  // override via validateChanges options.sensitiveDeny.
16
16
  //
17
17
  // Paths use POSIX separators (matches normalizePath output).
18
- const SENSITIVE_PATH_PREFIXES = [
18
+ export const SENSITIVE_PATH_PREFIXES = [
19
19
  ".claude-plugin/",
20
20
  "bin/",
21
21
  ".github/workflows/",
22
22
  ];
23
- const SENSITIVE_PATH_FILES = [
23
+ export const SENSITIVE_PATH_FILES = [
24
24
  ".gitignore",
25
25
  ".npmignore",
26
26
  "package.json",
@@ -28,6 +28,11 @@ const SENSITIVE_PATH_FILES = [
28
28
  "biome.json",
29
29
  ];
30
30
 
31
+ export const DEFAULT_SENSITIVE_DENY = Object.freeze({
32
+ prefixes: Object.freeze([...SENSITIVE_PATH_PREFIXES]),
33
+ files: Object.freeze([...SENSITIVE_PATH_FILES]),
34
+ });
35
+
31
36
  /**
32
37
  * Swarm lock manager factory.
33
38
  * @param {object} [opts]
@@ -0,0 +1,317 @@
1
+ // hub/team/swarm-preflight.mjs — PRD swarm preflight report/gate (#188)
2
+ // Predicts launch-time blockers before any worker session or worktree spawn.
3
+
4
+ import { resolve } from "node:path";
5
+ import { readHosts, resolveHost } from "../lib/hosts-compat.mjs";
6
+ import { whichCommand } from "../platform.mjs";
7
+ import { MCP_CATALOG } from "./mcp-selector.mjs";
8
+ import { DEFAULT_SENSITIVE_DENY } from "./swarm-locks.mjs";
9
+ import { planSwarm } from "./swarm-planner.mjs";
10
+
11
+ const AGENT_COMMANDS = Object.freeze({
12
+ codex: "codex",
13
+ gemini: "gemini",
14
+ claude: "claude",
15
+ });
16
+
17
+ function unique(values) {
18
+ return [...new Set(values.filter(Boolean))];
19
+ }
20
+
21
+ function normalizePath(filePath) {
22
+ return String(filePath || "")
23
+ .replace(/\\/g, "/")
24
+ .replace(/^\.\//u, "");
25
+ }
26
+
27
+ function isSensitiveFile(filePath, sensitiveDeny = DEFAULT_SENSITIVE_DENY) {
28
+ const path = normalizePath(filePath);
29
+ const files = new Set(sensitiveDeny.files || []);
30
+ const prefixes = sensitiveDeny.prefixes || [];
31
+ return files.has(path) || prefixes.some((prefix) => path.startsWith(prefix));
32
+ }
33
+
34
+ function planToJson(plan) {
35
+ return {
36
+ totalShards: plan.shards.length,
37
+ shards: plan.shards.map((s) => ({
38
+ name: s.name,
39
+ agent: s.agent,
40
+ host: s.host || null,
41
+ files: [...s.files],
42
+ mcp: [...s.mcp],
43
+ depends: [...s.depends],
44
+ critical: s.critical,
45
+ })),
46
+ leaseMap: Object.fromEntries(plan.leaseMap),
47
+ mcpManifest: Object.fromEntries(plan.mcpManifest),
48
+ mergeOrder: [...plan.mergeOrder],
49
+ criticalShards: [...plan.criticalShards],
50
+ conflicts: [...plan.conflicts],
51
+ remoteSuggestion: plan.remoteSuggestion,
52
+ };
53
+ }
54
+
55
+ function makeCheck(name, ok, details = {}) {
56
+ return { name, ok: Boolean(ok), ...details };
57
+ }
58
+
59
+ function buildLeaseTable(plan, repoRoot) {
60
+ return plan.shards.map((shard) => ({
61
+ shard: shard.name,
62
+ host: shard.host || null,
63
+ worktreePath: resolve(repoRoot, ".codex-swarm", `wt-${shard.name}`),
64
+ files: [...shard.files],
65
+ }));
66
+ }
67
+
68
+ function checkSensitive(plan, sensitiveDeny) {
69
+ const matches = [];
70
+ for (const shard of plan.shards) {
71
+ for (const file of shard.files) {
72
+ if (!isSensitiveFile(file, sensitiveDeny)) continue;
73
+ matches.push({
74
+ shard: shard.name,
75
+ file,
76
+ kind: "sensitive-planned-lease",
77
+ severity: "warning",
78
+ });
79
+ }
80
+ }
81
+ return makeCheck("sensitiveDeny", true, { matches });
82
+ }
83
+
84
+ function checkHosts(plan, repoRoot) {
85
+ const registry = readHosts(repoRoot);
86
+ const missing = [];
87
+ const capabilityWarnings = [];
88
+
89
+ for (const shard of plan.shards) {
90
+ if (!shard.host) continue;
91
+ const resolvedHost = resolveHost(shard.host, repoRoot);
92
+ if (!resolvedHost) {
93
+ missing.push({ shard: shard.name, host: shard.host });
94
+ continue;
95
+ }
96
+ const capabilities = resolvedHost.host.capabilities || [];
97
+ if (shard.agent && !capabilities.includes(shard.agent)) {
98
+ capabilityWarnings.push({
99
+ shard: shard.name,
100
+ host: shard.host,
101
+ agent: shard.agent,
102
+ capabilities,
103
+ });
104
+ }
105
+ }
106
+
107
+ return makeCheck("hosts", missing.length === 0, {
108
+ registryPath: registry.path,
109
+ missing,
110
+ warnings: capabilityWarnings,
111
+ });
112
+ }
113
+
114
+ function checkLocalWorkerClis(plan, deps = {}) {
115
+ const which = deps.whichCommand || whichCommand;
116
+ const missing = [];
117
+ const present = [];
118
+ const checked = new Map();
119
+
120
+ for (const shard of plan.shards) {
121
+ if (shard.host) continue;
122
+ const command = AGENT_COMMANDS[shard.agent] || shard.agent;
123
+ if (!command) {
124
+ missing.push({ shard: shard.name, agent: shard.agent, command: null });
125
+ continue;
126
+ }
127
+ if (!checked.has(command)) {
128
+ checked.set(command, which(command, deps.whichOptions || {}));
129
+ }
130
+ const path = checked.get(command);
131
+ if (path)
132
+ present.push({ shard: shard.name, agent: shard.agent, command, path });
133
+ else missing.push({ shard: shard.name, agent: shard.agent, command });
134
+ }
135
+
136
+ return makeCheck("workerCli", missing.length === 0, { present, missing });
137
+ }
138
+
139
+ function checkMcp(plan) {
140
+ const catalog = new Map(MCP_CATALOG.map((entry) => [entry.name, entry]));
141
+ const unknown = [];
142
+ const incompatible = [];
143
+
144
+ for (const shard of plan.shards) {
145
+ for (const server of shard.mcp) {
146
+ const entry = catalog.get(server);
147
+ if (!entry) {
148
+ unknown.push({ shard: shard.name, server });
149
+ continue;
150
+ }
151
+ if (
152
+ ["codex", "gemini"].includes(shard.agent) &&
153
+ !entry.cli.includes(shard.agent)
154
+ ) {
155
+ incompatible.push({
156
+ shard: shard.name,
157
+ agent: shard.agent,
158
+ server,
159
+ supportedCli: [...entry.cli],
160
+ });
161
+ }
162
+ }
163
+ }
164
+
165
+ return makeCheck("mcp", incompatible.length === 0, { unknown, incompatible });
166
+ }
167
+
168
+ function collectMessages(checks) {
169
+ const errors = [];
170
+ const warnings = [];
171
+
172
+ const leases = checks.leases;
173
+ for (const conflict of leases.conflicts || []) {
174
+ errors.push(
175
+ `lease conflict: ${conflict.file} assigned to ${conflict.shards.join(", ")}`,
176
+ );
177
+ }
178
+
179
+ const hosts = checks.hosts;
180
+ for (const missing of hosts.missing || []) {
181
+ errors.push(
182
+ `missing host: shard ${missing.shard} references ${missing.host}`,
183
+ );
184
+ }
185
+ for (const warn of hosts.warnings || []) {
186
+ warnings.push(
187
+ `host capability warning: ${warn.host} does not advertise ${warn.agent} for shard ${warn.shard}`,
188
+ );
189
+ }
190
+
191
+ const workerCli = checks.workerCli;
192
+ for (const missing of workerCli.missing || []) {
193
+ errors.push(
194
+ `missing local worker CLI: shard ${missing.shard} requires ${missing.command || missing.agent}`,
195
+ );
196
+ }
197
+
198
+ const mcp = checks.mcp;
199
+ for (const item of mcp.incompatible || []) {
200
+ errors.push(
201
+ `MCP incompatibility: ${item.server} is not supported by ${item.agent} for shard ${item.shard}`,
202
+ );
203
+ }
204
+ for (const item of mcp.unknown || []) {
205
+ warnings.push(
206
+ `unknown MCP server: ${item.server} requested by shard ${item.shard}`,
207
+ );
208
+ }
209
+
210
+ const sensitive = checks.sensitiveDeny;
211
+ for (const item of sensitive.matches || []) {
212
+ warnings.push(
213
+ `sensitive path planned lease: ${item.file} owned by shard ${item.shard}`,
214
+ );
215
+ }
216
+
217
+ return { errors: unique(errors), warnings: unique(warnings) };
218
+ }
219
+
220
+ /**
221
+ * Build a no-side-effect preflight report for a swarm PRD.
222
+ *
223
+ * @param {string} prdPath
224
+ * @param {object} [opts]
225
+ * @param {string} [opts.repoRoot]
226
+ * @param {object} [opts.deps]
227
+ * @returns {object}
228
+ */
229
+ export function runSwarmPreflight(prdPath, opts = {}) {
230
+ const repoRoot = opts.repoRoot || process.cwd();
231
+ const absPrd = resolve(prdPath);
232
+ const sensitiveDeny = opts.sensitiveDeny || DEFAULT_SENSITIVE_DENY;
233
+ let plan;
234
+
235
+ try {
236
+ plan = (opts.deps?.planSwarm || planSwarm)(absPrd, { repoRoot });
237
+ } catch (error) {
238
+ const message = error instanceof Error ? error.message : String(error);
239
+ return {
240
+ ok: false,
241
+ verdict: "no-go",
242
+ prdPath: absPrd,
243
+ repoRoot,
244
+ checks: {
245
+ plan: makeCheck("plan", false, { error: message }),
246
+ },
247
+ errors: [message],
248
+ warnings: [],
249
+ plan: null,
250
+ leaseTable: [],
251
+ };
252
+ }
253
+
254
+ const checks = {
255
+ plan: makeCheck("plan", true),
256
+ leases: makeCheck("leases", plan.conflicts.length === 0, {
257
+ conflicts: [...plan.conflicts],
258
+ }),
259
+ sensitiveDeny: checkSensitive(plan, sensitiveDeny),
260
+ hosts: checkHosts(plan, repoRoot),
261
+ workerCli: checkLocalWorkerClis(plan, opts.deps),
262
+ mcp: checkMcp(plan),
263
+ };
264
+ const { errors, warnings } = collectMessages(checks);
265
+ const ok = Object.values(checks).every((check) => check.ok);
266
+
267
+ return {
268
+ ok,
269
+ verdict: ok ? "go" : "no-go",
270
+ prdPath: absPrd,
271
+ repoRoot,
272
+ checks,
273
+ errors,
274
+ warnings,
275
+ plan: planToJson(plan),
276
+ leaseTable: buildLeaseTable(plan, repoRoot),
277
+ };
278
+ }
279
+
280
+ export function formatPreflightReport(report) {
281
+ const lines = [`SWARM PREFLIGHT: ${report.verdict.toUpperCase()}`];
282
+
283
+ if (report.plan) {
284
+ lines.push(`PRD: ${report.prdPath}`);
285
+ lines.push(`Shards: ${report.plan.totalShards}`);
286
+ lines.push("");
287
+ lines.push("Lease table:");
288
+ for (const lease of report.leaseTable) {
289
+ const host = lease.host ? ` @${lease.host}` : "";
290
+ lines.push(` - ${lease.shard}${host}`);
291
+ lines.push(` worktree: ${lease.worktreePath}`);
292
+ lines.push(
293
+ ` files: ${lease.files.length ? lease.files.join(", ") : "(none)"}`,
294
+ );
295
+ }
296
+ }
297
+
298
+ lines.push("");
299
+ lines.push("Checks:");
300
+ for (const [name, check] of Object.entries(report.checks || {})) {
301
+ lines.push(` ${check.ok ? "✓" : "✗"} ${name}`);
302
+ }
303
+
304
+ if (report.errors?.length) {
305
+ lines.push("");
306
+ lines.push("Blocking errors:");
307
+ for (const error of report.errors) lines.push(` - ${error}`);
308
+ }
309
+
310
+ if (report.warnings?.length) {
311
+ lines.push("");
312
+ lines.push("Warnings:");
313
+ for (const warning of report.warnings) lines.push(` - ${warning}`);
314
+ }
315
+
316
+ return `${lines.join("\n")}\n`;
317
+ }