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
@@ -4,7 +4,7 @@
4
4
  // so the CLI works even when Hub is offline.
5
5
 
6
6
  import { execFile } from "node:child_process";
7
- import { existsSync, readFileSync } from "node:fs";
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join, resolve } from "node:path";
10
10
 
@@ -15,6 +15,15 @@ const DEFAULT_REGISTRY_CANDIDATES = [
15
15
  ".triflux/synapse/registry.json",
16
16
  join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
17
17
  ];
18
+ const DEFAULT_SWARM_LOGS_DIR = ".triflux/swarm-logs";
19
+ const STALE_AFTER_MS = 30 * 60 * 1000;
20
+ const STATUS_PRIORITY = {
21
+ active: 3,
22
+ running: 3,
23
+ completed: 2,
24
+ failed: 2,
25
+ stale: 1,
26
+ };
18
27
 
19
28
  function gitExec(args, cwd) {
20
29
  return new Promise((res, rej) => {
@@ -55,10 +64,228 @@ function loadRegistrySnapshot(path) {
55
64
  }
56
65
  }
57
66
 
67
+ function parseJsonLines(path) {
68
+ try {
69
+ return readFileSync(path, "utf8")
70
+ .split(/\r?\n/)
71
+ .map((line) => line.trim())
72
+ .filter(Boolean)
73
+ .flatMap((line) => {
74
+ try {
75
+ return [JSON.parse(line)];
76
+ } catch {
77
+ return [];
78
+ }
79
+ });
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ function normalizeRunId(name) {
86
+ return String(name || "").replace(/^run-/, "");
87
+ }
88
+
89
+ function eventTimeMs(event) {
90
+ const ms = Date.parse(String(event?.ts || ""));
91
+ return Number.isFinite(ms) ? ms : 0;
92
+ }
93
+
94
+ function deriveLogStatus(events, lastHeartbeatMs, nowMs) {
95
+ const stateEvents = events.filter((e) => e.event === "swarm_state");
96
+ const lastState = stateEvents[stateEvents.length - 1]?.to;
97
+ if (lastState === "completed" || lastState === "failed") return lastState;
98
+ if (events.some((e) => e.event === "integration_complete")) {
99
+ const lastIntegration = [...events]
100
+ .reverse()
101
+ .find((e) => e.event === "integration_complete");
102
+ if (
103
+ Array.isArray(lastIntegration?.failed) &&
104
+ lastIntegration.failed.length
105
+ ) {
106
+ return "failed";
107
+ }
108
+ return "completed";
109
+ }
110
+ if (nowMs - lastHeartbeatMs > STALE_AFTER_MS) return "stale";
111
+ return "active";
112
+ }
113
+
114
+ function buildLogSession(runDir, nowMs = Date.now()) {
115
+ const eventPath = join(runDir, "swarm-events.jsonl");
116
+ if (!existsSync(eventPath)) return null;
117
+ const events = parseJsonLines(eventPath);
118
+ if (events.length === 0) return null;
119
+
120
+ let statMs = 0;
121
+ try {
122
+ statMs = statSync(eventPath).mtimeMs;
123
+ } catch {}
124
+
125
+ const eventMs = events.map(eventTimeMs).filter((ms) => ms > 0);
126
+ const lastEventMs = eventMs.length ? Math.max(...eventMs) : statMs;
127
+ const launched = new Set();
128
+ const completed = new Set();
129
+ const failed = new Set();
130
+ for (const event of events) {
131
+ if (event.event === "shard_launched" && event.shard) {
132
+ launched.add(event.shard);
133
+ } else if (event.event === "shard_completed" && event.shard) {
134
+ completed.add(event.shard);
135
+ } else if (
136
+ (event.event === "shard_failed" ||
137
+ event.event === "shard_launch_failed") &&
138
+ event.shard
139
+ ) {
140
+ failed.add(event.shard);
141
+ }
142
+ }
143
+
144
+ const runId = normalizeRunId(runDir.split(/[\\/]/).pop());
145
+ const totalShards = launched.size || completed.size + failed.size;
146
+ const aliveWorkers = [...launched].filter(
147
+ (name) => !completed.has(name) && !failed.has(name),
148
+ ).length;
149
+
150
+ return {
151
+ sessionId: runId,
152
+ runId,
153
+ host: "-",
154
+ branch: "-",
155
+ dirtyFiles: [],
156
+ status: deriveLogStatus(events, lastEventMs || nowMs, nowMs),
157
+ taskSummary: "swarm log run",
158
+ source: "logs",
159
+ shards: totalShards ? `${completed.size}/${totalShards}` : "-",
160
+ workers: totalShards ? `${aliveWorkers} alive` : "-",
161
+ lastHeartbeat: lastEventMs || null,
162
+ logDir: runDir,
163
+ };
164
+ }
165
+
166
+ export function loadSwarmLogSessions(logsDir, opts = {}) {
167
+ const nowMs = opts.nowMs ?? Date.now();
168
+ const root = logsDir || DEFAULT_SWARM_LOGS_DIR;
169
+ if (!existsSync(root)) return [];
170
+ try {
171
+ return readdirSync(root, { withFileTypes: true })
172
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
173
+ .map((entry) => buildLogSession(join(root, entry.name), nowMs))
174
+ .filter(Boolean);
175
+ } catch {
176
+ return [];
177
+ }
178
+ }
179
+
180
+ function statusPriority(status) {
181
+ return STATUS_PRIORITY[status] || 0;
182
+ }
183
+
184
+ function mergeSession(existing, next) {
185
+ const status =
186
+ statusPriority(next.status) > statusPriority(existing.status)
187
+ ? next.status
188
+ : existing.status;
189
+ const source =
190
+ existing.source && next.source && existing.source !== next.source
191
+ ? "synapse + logs"
192
+ : existing.source || next.source || "synapse";
193
+ return {
194
+ ...next,
195
+ ...existing,
196
+ status,
197
+ source,
198
+ shards: next.shards || existing.shards,
199
+ workers: next.workers || existing.workers,
200
+ lastHeartbeat: Math.max(
201
+ Number(existing.lastHeartbeat || 0),
202
+ Number(next.lastHeartbeat || 0),
203
+ ),
204
+ logDir: existing.logDir || next.logDir,
205
+ };
206
+ }
207
+
208
+ export function mergeRegistryAndLogSessions(registrySessions, logSessions) {
209
+ const merged = new Map();
210
+ for (const session of registrySessions) {
211
+ const key = String(session.runId || session.sessionId || "");
212
+ if (!key) continue;
213
+ merged.set(key, {
214
+ ...session,
215
+ source: session.source || "synapse",
216
+ lastHeartbeat: session.lastHeartbeat || null,
217
+ });
218
+ }
219
+ for (const session of logSessions) {
220
+ const key = String(session.runId || session.sessionId || "");
221
+ if (!key) continue;
222
+ const existing = merged.get(key);
223
+ merged.set(key, existing ? mergeSession(existing, session) : session);
224
+ }
225
+ return [...merged.values()].sort((left, right) => {
226
+ const rightTs = Number(right.lastHeartbeat || 0);
227
+ const leftTs = Number(left.lastHeartbeat || 0);
228
+ return rightTs - leftTs;
229
+ });
230
+ }
231
+
232
+ function formatAge(ts, nowMs = Date.now()) {
233
+ if (!Number.isFinite(Number(ts)) || Number(ts) <= 0) return "-";
234
+ const seconds = Math.max(0, Math.round((nowMs - Number(ts)) / 1000));
235
+ if (seconds < 60) return `${seconds}s ago`;
236
+ const minutes = Math.round(seconds / 60);
237
+ if (minutes < 60) return `${minutes}m ago`;
238
+ const hours = Math.round(minutes / 60);
239
+ return `${hours}h ago`;
240
+ }
241
+
242
+ function hasSwarmLogFields(sessions) {
243
+ return sessions.some(
244
+ (s) =>
245
+ s.source === "logs" ||
246
+ s.source === "synapse + logs" ||
247
+ s.runId ||
248
+ s.shards ||
249
+ s.workers,
250
+ );
251
+ }
252
+
253
+ function formatSwarmStatus(sessions) {
254
+ const nowMs = Date.now();
255
+ const rows = [];
256
+ rows.push(
257
+ "RUN_ID STATUS SHARDS WORKERS LAST_HEARTBEAT SOURCE",
258
+ );
259
+ rows.push(
260
+ "───────────────────── ───────── ─────── ─────── ────────────── ──────────────",
261
+ );
262
+ for (const s of sessions) {
263
+ const id = String(s.runId || s.sessionId || "?")
264
+ .padEnd(21)
265
+ .slice(0, 21);
266
+ const status = String(s.status || "active")
267
+ .padEnd(9)
268
+ .slice(0, 9);
269
+ const shards = String(s.shards || "-")
270
+ .padEnd(7)
271
+ .slice(0, 7);
272
+ const workers = String(s.workers || "-")
273
+ .padEnd(7)
274
+ .slice(0, 7);
275
+ const heartbeat = formatAge(s.lastHeartbeat, nowMs).padEnd(14).slice(0, 14);
276
+ const source = String(s.source || "synapse").slice(0, 20);
277
+ rows.push(
278
+ `${id} ${status} ${shards} ${workers} ${heartbeat} ${source}`,
279
+ );
280
+ }
281
+ return rows.join("\n");
282
+ }
283
+
58
284
  function formatStatus(sessions) {
59
285
  if (!sessions.length) {
60
286
  return "no active sessions (synapse-registry empty)";
61
287
  }
288
+ if (hasSwarmLogFields(sessions)) return formatSwarmStatus(sessions);
62
289
  const rows = [];
63
290
  rows.push("SESSION HOST BRANCH DIRTY STATE TASK");
64
291
  rows.push(
@@ -84,13 +311,22 @@ function formatStatus(sessions) {
84
311
  export async function cmdSynapseStatus(args = [], opts = {}) {
85
312
  const jsonOut = args.includes("--json") || opts.json;
86
313
  const explicit = extractFlag(args, "--registry");
314
+ const explicitLogsDir = extractFlag(args, "--logs-dir");
87
315
  const path = locateRegistryPath(explicit);
88
- const sessions = loadRegistrySnapshot(path);
316
+ const registrySessions = loadRegistrySnapshot(path);
317
+ const logsDir = explicitLogsDir || DEFAULT_SWARM_LOGS_DIR;
318
+ const logSessions = loadSwarmLogSessions(logsDir);
319
+ const sessions = mergeRegistryAndLogSessions(registrySessions, logSessions);
89
320
 
90
321
  if (jsonOut) {
91
322
  process.stdout.write(
92
323
  JSON.stringify(
93
- { registry: path, count: sessions.length, sessions },
324
+ {
325
+ registry: path,
326
+ logsDir: existsSync(logsDir) ? logsDir : null,
327
+ count: sessions.length,
328
+ sessions,
329
+ },
94
330
  null,
95
331
  2,
96
332
  ) + "\n",
@@ -98,13 +334,14 @@ export async function cmdSynapseStatus(args = [], opts = {}) {
98
334
  return;
99
335
  }
100
336
 
101
- if (!path) {
337
+ if (!path && logSessions.length === 0) {
102
338
  process.stdout.write(
103
- "no registry file found (looked for .triflux/synapse-registry.json)\n",
339
+ "no registry or swarm logs found (looked for .triflux/synapse-registry.json and .triflux/swarm-logs)\n",
104
340
  );
105
341
  return;
106
342
  }
107
- process.stdout.write(`registry: ${path}\n`);
343
+ if (path) process.stdout.write(`registry: ${path}\n`);
344
+ if (logSessions.length > 0) process.stdout.write(`swarm logs: ${logsDir}\n`);
108
345
  process.stdout.write(`${formatStatus(sessions)}\n`);
109
346
  }
110
347
 
@@ -0,0 +1,93 @@
1
+ // hub/team/worker-sandbox.mjs — local worker HOME/APPDATA isolation helpers
2
+ //
3
+ // Team/swarm workers run untrusted task-specific CLIs. Keep their mutable user
4
+ // state under the worker worktree instead of the operator's real home/appdata,
5
+ // while preserving explicitly brokered auth homes such as CODEX_HOME.
6
+
7
+ import { mkdirSync } from "node:fs";
8
+ import { join, resolve, win32 } from "node:path";
9
+
10
+ const SAFE_SEGMENT_RE = /[^a-zA-Z0-9._-]/g;
11
+
12
+ function safeSegment(value, fallback = "worker") {
13
+ const raw = String(value ?? "").trim();
14
+ const cleaned = raw.replace(SAFE_SEGMENT_RE, "_").replace(/^_+|_+$/g, "");
15
+ return cleaned || fallback;
16
+ }
17
+
18
+ function isDisabled(env = {}) {
19
+ return /^(0|false|no|off)$/i.test(String(env.TFX_WORKER_SANDBOX ?? ""));
20
+ }
21
+
22
+ function windowsHomeParts(home) {
23
+ const normalized = String(home || "").replace(/\//g, "\\");
24
+ const match = normalized.match(/^([a-zA-Z]:)(\\.*)$/);
25
+ if (!match) return null;
26
+ return { drive: match[1], path: match[2] || "\\" };
27
+ }
28
+
29
+ function mkdirAll(paths) {
30
+ for (const path of paths) mkdirSync(path, { recursive: true });
31
+ }
32
+
33
+ /**
34
+ * Build a deterministic sandbox home for a local team/swarm worker.
35
+ *
36
+ * @param {object} opts
37
+ * @param {string} [opts.cwd] Worktree/current working directory for the worker.
38
+ * @param {string} [opts.sessionId] Stable session/member id.
39
+ * @param {object} [opts.env] Existing env used for opt-out and explicit root.
40
+ * @param {boolean} [opts.create=true] Create directories eagerly.
41
+ * @returns {{env: object, root: string|null, home: string|null, disabled: boolean}}
42
+ */
43
+ export function buildWorkerSandboxEnv(opts = {}) {
44
+ const env = opts.env && typeof opts.env === "object" ? opts.env : {};
45
+ if (isDisabled(env)) {
46
+ return { env: {}, root: null, home: null, disabled: true };
47
+ }
48
+
49
+ const cwd = resolve(opts.cwd || process.cwd());
50
+ const sessionId = safeSegment(opts.sessionId, "worker");
51
+ const home = resolve(
52
+ env.TFX_WORKER_HOME || join(cwd, ".triflux", "worker-home", sessionId),
53
+ );
54
+ const appData = join(home, "AppData", "Roaming");
55
+ const localAppData = join(home, "AppData", "Local");
56
+ const xdgConfig = join(home, ".config");
57
+ const xdgCache = join(home, ".cache");
58
+ const xdgState = join(home, ".local", "state");
59
+
60
+ if (opts.create !== false) {
61
+ mkdirAll([home, appData, localAppData, xdgConfig, xdgCache, xdgState]);
62
+ }
63
+
64
+ const overlay = {
65
+ TFX_WORKER_HOME: home,
66
+ HOME: home,
67
+ USERPROFILE: home,
68
+ APPDATA: appData,
69
+ LOCALAPPDATA: localAppData,
70
+ XDG_CONFIG_HOME: xdgConfig,
71
+ XDG_CACHE_HOME: xdgCache,
72
+ XDG_STATE_HOME: xdgState,
73
+ };
74
+
75
+ const winParts =
76
+ windowsHomeParts(home) ||
77
+ (process.platform === "win32"
78
+ ? {
79
+ drive: win32.parse(home).root.replace(/[\\/]$/, "") || "C:",
80
+ path:
81
+ win32
82
+ .relative(win32.parse(home).root, home)
83
+ .replace(/\//g, "\\")
84
+ .replace(/^/, "\\") || "\\",
85
+ }
86
+ : null);
87
+ if (winParts) {
88
+ overlay.HOMEDRIVE = winParts.drive;
89
+ overlay.HOMEPATH = winParts.path;
90
+ }
91
+
92
+ return { env: overlay, root: home, home, disabled: false };
93
+ }
@@ -314,7 +314,7 @@ export async function prepareIntegrationBranch({
314
314
  * @param {string} [opts.rootDir=process.cwd()]
315
315
  * @param {{ checkRebase: Function } | null} [opts.preflight=null] — git-preflight instance
316
316
  * @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
317
- * @returns {Promise<{ ok: boolean, headCommit?: string, error?: string, preflight?: object }>}
317
+ * @returns {Promise<{ ok: boolean, headCommit?: string, strategy?: string, commits?: number, notes?: string, fallbackReason?: string, error?: string, preflight?: object }>}
318
318
  */
319
319
  export async function rebaseShardOntoIntegration({
320
320
  shardBranch,
@@ -355,10 +355,9 @@ export async function rebaseShardOntoIntegration({
355
355
  // Backup integration HEAD for rollback
356
356
  const backupCommit = await git(["rev-parse", integrationBranch], rootDir);
357
357
 
358
- // #127: cherry-pick instead of rebase. Rebase fails when shardBranch is
359
- // already checked out by a swarm worktree ("branch already used by worktree").
360
- // Cherry-pick reads the shard's commits without touching its branch ref,
361
- // so worktree contention disappears. Memory: feedback_swarm_cherry_pick.
358
+ let shaList = [];
359
+ let ffError = null;
360
+
362
361
  try {
363
362
  const log = await git(
364
363
  [
@@ -369,19 +368,55 @@ export async function rebaseShardOntoIntegration({
369
368
  ],
370
369
  rootDir,
371
370
  );
372
- const shaList = log
371
+ shaList = log
373
372
  .split("\n")
374
373
  .map((s) => s.trim())
375
374
  .filter(Boolean);
376
375
 
377
376
  await git(["checkout", integrationBranch], rootDir);
378
377
 
378
+ try {
379
+ await git(["merge", "--ff-only", shardBranch], rootDir);
380
+ const headCommit = await git(["rev-parse", "HEAD"], rootDir);
381
+ return {
382
+ ok: true,
383
+ headCommit,
384
+ strategy: "auto-ff",
385
+ commits: shaList.length,
386
+ notes: "linear (merge --ff-only)",
387
+ };
388
+ } catch (err) {
389
+ ffError = err;
390
+ try {
391
+ await git(["merge", "--abort"], rootDir);
392
+ } catch {
393
+ /* ff-only usually leaves no merge state */
394
+ }
395
+ try {
396
+ await git(["reset", "--hard", backupCommit], rootDir);
397
+ } catch {
398
+ /* fallback below will report if cherry-pick also fails */
399
+ }
400
+ }
401
+
402
+ // #127: cherry-pick fallback instead of branch merge. Rebase/ff paths can
403
+ // fail when histories diverge or a shard branch is checked out by a swarm
404
+ // worktree. Cherry-pick reads commits without moving the shard branch ref.
379
405
  for (const sha of shaList) {
380
406
  await git(["cherry-pick", sha], rootDir);
381
407
  }
382
408
 
383
409
  const headCommit = await git(["rev-parse", "HEAD"], rootDir);
384
- return { ok: true, headCommit };
410
+ return {
411
+ ok: true,
412
+ headCommit,
413
+ strategy: "cherry-pick",
414
+ commits: shaList.length,
415
+ fallbackReason: ffError?.message || null,
416
+ notes: ffError
417
+ ? `auto-ff failed: ${ffError.message}`
418
+ : "cherry-pick fallback",
419
+ };
385
420
  } catch (err) {
386
421
  try {
387
422
  await git(["cherry-pick", "--abort"], rootDir);
@@ -422,7 +457,13 @@ export async function rebaseShardOntoIntegration({
422
457
  }
423
458
  }
424
459
 
425
- return { ok: false, error: err.message };
460
+ return {
461
+ ok: false,
462
+ error: err.message,
463
+ strategy: "failed",
464
+ commits: shaList.length,
465
+ fallbackReason: ffError?.message || null,
466
+ };
426
467
  } finally {
427
468
  // Always restore caller's branch. Skip if originalBranch is null
428
469
  // (detached HEAD case) or already on target.
@@ -14,6 +14,7 @@ import * as z from "zod";
14
14
  import { resolveBashExecutable } from "../lib/bash-path.mjs";
15
15
  import { whichCommand } from "../platform.mjs";
16
16
  import { runHeadlessWithCleanup } from "../team/headless.mjs";
17
+ import { buildWorkerSandboxEnv } from "../team/worker-sandbox.mjs";
17
18
  import { CodexMcpWorker } from "./codex-mcp.mjs";
18
19
  import { GeminiWorker } from "./gemini-worker.mjs";
19
20
 
@@ -1182,8 +1183,20 @@ export class DelegatorMcpWorker {
1182
1183
  }
1183
1184
 
1184
1185
  _buildRouteEnv(args) {
1185
- const env = cloneEnv(this.env);
1186
+ const baseEnv = cloneEnv(this.env);
1187
+ const sandbox = buildWorkerSandboxEnv({
1188
+ cwd: args.cwd || this.cwd,
1189
+ sessionId:
1190
+ args.teamTaskId ||
1191
+ args.teamAgentName ||
1192
+ args.workerIndex ||
1193
+ args.agentType ||
1194
+ "route",
1195
+ env: baseEnv,
1196
+ });
1197
+ const env = { ...baseEnv, ...sandbox.env };
1186
1198
  env.TFX_CLI_MODE = pickRouteMode(args.provider);
1199
+ if (sandbox.root) env.TFX_WORKER_SANDBOX_SCOPE = "delegator-route";
1187
1200
 
1188
1201
  if (args.codexTransport) {
1189
1202
  env.TFX_CODEX_TRANSPORT = args.codexTransport;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.20.0",
3
+ "version": "10.20.2",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -163,6 +163,8 @@ describe("gen-skill-manifest", () => {
163
163
  " - manifest",
164
164
  "argument-hint: <arg>",
165
165
  "internal: true",
166
+ "deprecated: true",
167
+ "superseded-by: tfx-auto",
166
168
  "---",
167
169
  "body",
168
170
  ].join("\n"),
@@ -180,6 +182,8 @@ describe("gen-skill-manifest", () => {
180
182
  assert.deepEqual(manifest.triggers, ["test", "manifest"]);
181
183
  assert.equal(manifest.argument_hint, "<arg>");
182
184
  assert.equal(manifest.internal, true);
185
+ assert.equal(manifest.deprecated, true);
186
+ assert.equal(manifest.superseded_by, "tfx-auto");
183
187
  } finally {
184
188
  rmSync(root, { recursive: true, force: true });
185
189
  }
@@ -0,0 +1,61 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { describe, it } from "node:test";
5
+
6
+ import { parseFrontmatter } from "../lib/skill-template.mjs";
7
+
8
+ const repoRoot = join(import.meta.dirname, "..", "..");
9
+ const skillsDir = join(repoRoot, "skills");
10
+
11
+ function readSkillFrontmatter(name) {
12
+ const skillPath = join(skillsDir, name, "SKILL.md");
13
+ assert.equal(existsSync(skillPath), true, `${name} SKILL.md missing`);
14
+ return parseFrontmatter(readFileSync(skillPath, "utf8")).data;
15
+ }
16
+
17
+ function listSkillFrontmatter() {
18
+ return readdirSync(skillsDir, { withFileTypes: true })
19
+ .filter((entry) => entry.isDirectory())
20
+ .filter((entry) => existsSync(join(skillsDir, entry.name, "SKILL.md")))
21
+ .map((entry) => readSkillFrontmatter(entry.name))
22
+ .filter((data) => data.name);
23
+ }
24
+
25
+ describe("skill surface consolidation (#112)", () => {
26
+ it("keeps the public core skill surface at or below the target size", () => {
27
+ const coreSkills = listSkillFrontmatter().filter(
28
+ (data) => data.internal !== true && data.deprecated !== true,
29
+ );
30
+
31
+ assert.ok(
32
+ coreSkills.length <= 15,
33
+ `expected <=15 public core skills, got ${coreSkills.length}: ${coreSkills
34
+ .map((skill) => skill.name)
35
+ .sort()
36
+ .join(", ")}`,
37
+ );
38
+ });
39
+
40
+ it("marks legacy compatibility aliases as deprecated and superseded", () => {
41
+ const expectedAliases = [
42
+ "tfx-autopilot",
43
+ "tfx-consensus",
44
+ "tfx-debate",
45
+ "tfx-fullcycle",
46
+ "tfx-multi",
47
+ "tfx-panel",
48
+ "tfx-persist",
49
+ "tfx-psmux-rules",
50
+ "tfx-remote-setup",
51
+ "tfx-remote-spawn",
52
+ "tfx-swarm",
53
+ ];
54
+
55
+ for (const name of expectedAliases) {
56
+ const data = readSkillFrontmatter(name);
57
+ assert.equal(data.deprecated, true, `${name} must be deprecated`);
58
+ assert.ok(data["superseded-by"], `${name} must declare superseded-by`);
59
+ }
60
+ });
61
+ });
@@ -25,6 +25,9 @@ function extractManifest(skillMdContent) {
25
25
  if (data["argument-hint"]) manifest.argument_hint = data["argument-hint"];
26
26
  if (data.internal === true || data.internal === "true")
27
27
  manifest.internal = true;
28
+ if (data.deprecated === true || data.deprecated === "true")
29
+ manifest.deprecated = true;
30
+ if (data["superseded-by"]) manifest.superseded_by = data["superseded-by"];
28
31
 
29
32
  return manifest;
30
33
  }
@@ -360,6 +360,8 @@ export function parseFrontmatterWithManifest(source, skillDir) {
360
360
  if (manifest.triggers) merged.triggers = manifest.triggers;
361
361
  if (manifest.argument_hint) merged["argument-hint"] = manifest.argument_hint;
362
362
  if (manifest.internal != null) merged.internal = manifest.internal;
363
+ if (manifest.deprecated != null) merged.deprecated = manifest.deprecated;
364
+ if (manifest.superseded_by) merged["superseded-by"] = manifest.superseded_by;
363
365
 
364
366
  return { data: merged, body };
365
367
  }