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
@@ -79,6 +79,7 @@ export async function startNativeSupervisor({
79
79
  {
80
80
  sessionName: sessionId,
81
81
  hubUrl,
82
+ workdir: process.cwd(),
82
83
  startupDelayMs: 3000,
83
84
  logsDir,
84
85
  runtimeFile: runtimePath,
@@ -45,6 +45,7 @@ import {
45
45
  registerSynapseSession,
46
46
  unregisterSynapseSession,
47
47
  } from "./synapse-http.mjs";
48
+ import { buildWorkerSandboxEnv } from "./worker-sandbox.mjs";
48
49
 
49
50
  /** 세션 상태 */
50
51
  export const STATES = Object.freeze({
@@ -87,16 +88,24 @@ const DEFAULT_GRACE_MS = 10_000;
87
88
  * @param {'codex'|'gemini'} agent
88
89
  * @param {string} sessionId — 로깅용
89
90
  * @param {object} eventLog — createEventLog 인스턴스
91
+ * @param {object} brokerInstance — AccountBroker 인스턴스
90
92
  */
91
- function swapAuthFile(lease, agent, sessionId, eventLog) {
93
+ function swapAuthFile(
94
+ lease,
95
+ agent,
96
+ sessionId,
97
+ eventLog,
98
+ brokerInstance,
99
+ targetEnv = {},
100
+ ) {
92
101
  if (lease?.mode !== "auth" || !lease.authFile) return true;
93
102
  if (
94
103
  agent === "codex" &&
95
104
  lease.id &&
96
- broker &&
97
- typeof broker.syncAuthToSource === "function"
105
+ brokerInstance &&
106
+ typeof brokerInstance.syncAuthToSource === "function"
98
107
  ) {
99
- const result = broker.syncAuthToSource(lease.id);
108
+ const result = brokerInstance.syncAuthToSource(lease.id);
100
109
  if (result?.copied || result?.reason === "up_to_date") {
101
110
  eventLog.append("auth_copy", {
102
111
  session: sessionId,
@@ -113,12 +122,14 @@ function swapAuthFile(lease, agent, sessionId, eventLog) {
113
122
  });
114
123
  return false;
115
124
  }
125
+ const targetHome = targetEnv.HOME || homedir();
126
+ const codexHome = targetEnv.CODEX_HOME || join(targetHome, ".codex");
116
127
  const dests =
117
128
  agent === "codex"
118
- ? [join(homedir(), ".codex", "auth.json")]
129
+ ? [join(codexHome, "auth.json")]
119
130
  : [
120
- join(homedir(), ".gemini", "oauth_creds.json"),
121
- join(homedir(), ".gemini", "gemini-credentials.json"),
131
+ join(targetHome, ".gemini", "oauth_creds.json"),
132
+ join(targetHome, ".gemini", "gemini-credentials.json"),
122
133
  ];
123
134
  let allOk = true;
124
135
  for (const dest of dests) {
@@ -182,6 +193,7 @@ export function createConductor(opts = {}) {
182
193
  baseUrl: opts.synapseBaseUrl,
183
194
  fetchImpl: opts.synapseFetch,
184
195
  };
196
+ const getActiveBroker = () => optsBroker ?? broker;
185
197
 
186
198
  function buildSynapseMeta(session, state = session.state, reason = "") {
187
199
  return {
@@ -486,13 +498,14 @@ export function createConductor(opts = {}) {
486
498
  emitter.emit("dead", { sessionId: session.id, reason });
487
499
 
488
500
  // broker release on final death
489
- if (broker && session.config.accountId) {
490
- broker.release(session.config.accountId, {
501
+ const activeBroker = getActiveBroker();
502
+ if (activeBroker && session.config.accountId) {
503
+ activeBroker.release(session.config.accountId, {
491
504
  ok: false,
492
505
  failureMode: session.lastFailureMode,
493
506
  });
494
507
  if (session.lastFailureMode === "rate_limited") {
495
- broker.markRateLimited(session.config.accountId, 5 * 60 * 1000);
508
+ activeBroker.markRateLimited(session.config.accountId, 5 * 60 * 1000);
496
509
  }
497
510
  }
498
511
  }
@@ -568,19 +581,26 @@ export function createConductor(opts = {}) {
568
581
  }
569
582
  }
570
583
 
584
+ const spawnEnv = {
585
+ ...process.env,
586
+ ...launcher.env,
587
+ ...(session.config.env || {}),
588
+ ...(session.config.branchGuard ? { TRIFLUX_GIT_BRANCH_GUARD: "1" } : {}),
589
+ };
590
+ if (session.config.workerSandbox?.root) {
591
+ eventLog.append("worker_sandbox_env", {
592
+ session: session.id,
593
+ root: session.config.workerSandbox.root,
594
+ codeHomePreserved: Boolean(spawnEnv.CODEX_HOME),
595
+ });
596
+ }
597
+
571
598
  let child;
572
599
  try {
573
600
  child = spawnFn(spawnSpec.command, spawnSpec.args, {
574
601
  shell: false,
575
602
  cwd: spawnCwd,
576
- env: {
577
- ...process.env,
578
- ...launcher.env,
579
- ...(session.config.env || {}),
580
- ...(session.config.branchGuard
581
- ? { TRIFLUX_GIT_BRANCH_GUARD: "1" }
582
- : {}),
583
- },
603
+ env: spawnEnv,
584
604
  reason: `conductor:respawnSession:${session.id}`,
585
605
  stdio: ["pipe", "pipe", "pipe"],
586
606
  windowsHide: true,
@@ -699,8 +719,9 @@ export function createConductor(opts = {}) {
699
719
  completionPayload,
700
720
  });
701
721
  }
702
- if (broker && session.config.accountId) {
703
- broker.release(session.config.accountId, { ok: true });
722
+ const activeBroker = getActiveBroker();
723
+ if (activeBroker && session.config.accountId) {
724
+ activeBroker.release(session.config.accountId, { ok: true });
704
725
  }
705
726
  } else {
706
727
  // detect rate_limited from recent output before handleFailure
@@ -1001,10 +1022,11 @@ export function createConductor(opts = {}) {
1001
1022
 
1002
1023
  // broker lease (graceful — broker null if accounts.json absent)
1003
1024
  let lease = null;
1004
- if (broker && config.agent && !config.remote) {
1005
- lease = broker.lease({ provider: config.agent });
1025
+ const activeBroker = getActiveBroker();
1026
+ if (activeBroker && config.agent && !config.remote) {
1027
+ lease = activeBroker.lease({ provider: config.agent });
1006
1028
  if (lease === null) {
1007
- const eta = broker.nextAvailableEta(config.agent);
1029
+ const eta = activeBroker.nextAvailableEta(config.agent);
1008
1030
  eventLog.append("broker_no_lease", {
1009
1031
  session: config.id,
1010
1032
  agent: config.agent,
@@ -1016,17 +1038,47 @@ export function createConductor(opts = {}) {
1016
1038
  }
1017
1039
 
1018
1040
  // apply lease profile/env/auth to config (immutable — new object)
1019
- const resolvedConfig = lease
1041
+ const leaseEnv =
1042
+ lease?.authFile && config.agent === "codex"
1043
+ ? { ...(lease.env || {}), CODEX_HOME: dirname(lease.authFile) }
1044
+ : lease?.env || {};
1045
+
1046
+ let resolvedConfig = lease
1020
1047
  ? {
1021
1048
  ...config,
1022
1049
  profile: lease.profile ?? config.profile,
1023
- env: { ...(config.env || {}), ...(lease.env || {}) },
1050
+ env: { ...(config.env || {}), ...leaseEnv },
1024
1051
  accountId: lease.id,
1025
1052
  }
1026
- : config;
1053
+ : {
1054
+ ...config,
1055
+ env: { ...(config.env || {}) },
1056
+ };
1057
+
1058
+ if (!resolvedConfig.remote) {
1059
+ const sandbox = buildWorkerSandboxEnv({
1060
+ cwd: resolvedConfig.workdir || process.cwd(),
1061
+ sessionId: resolvedConfig.id,
1062
+ env: { ...process.env, ...(resolvedConfig.env || {}) },
1063
+ });
1064
+ resolvedConfig = {
1065
+ ...resolvedConfig,
1066
+ env: { ...(resolvedConfig.env || {}), ...sandbox.env },
1067
+ workerSandbox: sandbox.disabled
1068
+ ? { disabled: true }
1069
+ : { root: sandbox.root, home: sandbox.home },
1070
+ };
1071
+ }
1027
1072
 
1028
1073
  // auth file copy — broker resolved absolute path, conductor does the actual copy
1029
- swapAuthFile(lease, config.agent, config.id, eventLog);
1074
+ swapAuthFile(
1075
+ lease,
1076
+ config.agent,
1077
+ config.id,
1078
+ eventLog,
1079
+ activeBroker,
1080
+ resolvedConfig.env,
1081
+ );
1030
1082
 
1031
1083
  // 원격 세션은 launcher 불필요 (이미 원격에서 실행 중)
1032
1084
  const launcher = resolvedConfig.remote
@@ -1188,8 +1240,21 @@ export function createConductor(opts = {}) {
1188
1240
 
1189
1241
  const newLease = brokerInstance.lease({ provider });
1190
1242
  if (!newLease) continue;
1191
-
1192
- swapAuthFile(newLease, provider, session.id, eventLog);
1243
+ if (newLease.authFile && provider === "codex") {
1244
+ session.config.env = {
1245
+ ...(session.config.env || {}),
1246
+ CODEX_HOME: dirname(newLease.authFile),
1247
+ };
1248
+ }
1249
+
1250
+ swapAuthFile(
1251
+ newLease,
1252
+ provider,
1253
+ session.id,
1254
+ eventLog,
1255
+ brokerInstance,
1256
+ session.config.env,
1257
+ );
1193
1258
  eventLog.append("tier_fallback_swap", {
1194
1259
  session: session.id,
1195
1260
  agent: provider,
@@ -15,6 +15,7 @@ import {
15
15
  } from "../lib/bash-path.mjs";
16
16
  import { verifySlimWrapperRouteExecution } from "./native.mjs";
17
17
  import { forceCleanupTeam } from "./nativeProxy.mjs";
18
+ import { buildWorkerSandboxEnv } from "./worker-sandbox.mjs";
18
19
 
19
20
  const ROUTE_LOG_TAIL_BYTES = 65536;
20
21
 
@@ -116,6 +117,7 @@ function memberStateSnapshot() {
116
117
  routeVerification: state?.routeVerification || null,
117
118
  logFile: state?.logFile || null,
118
119
  errFile: state?.errFile || null,
120
+ workerSandboxRoot: state?.workerSandboxRoot || null,
119
121
  });
120
122
  }
121
123
  return states;
@@ -164,15 +166,25 @@ function spawnMember(member) {
164
166
  const outWs = createWriteStream(outPath, { flags: "a" });
165
167
  const errWs = createWriteStream(errPath, { flags: "a" });
166
168
 
169
+ const baseEnv = {
170
+ ...process.env,
171
+ TERM:
172
+ process.env.TERM && process.env.TERM !== "dumb"
173
+ ? process.env.TERM
174
+ : "xterm-256color",
175
+ };
176
+ const sandbox =
177
+ member.role === "worker"
178
+ ? buildWorkerSandboxEnv({
179
+ cwd: config.workdir || process.cwd(),
180
+ sessionId: `${sessionName}-${member.name}`,
181
+ env: baseEnv,
182
+ })
183
+ : { env: {}, root: null };
184
+
167
185
  const child = spawn(command, {
168
186
  shell: true,
169
- env: {
170
- ...process.env,
171
- TERM:
172
- process.env.TERM && process.env.TERM !== "dumb"
173
- ? process.env.TERM
174
- : "xterm-256color",
175
- },
187
+ env: { ...baseEnv, ...sandbox.env },
176
188
  stdio: ["pipe", "pipe", "pipe"],
177
189
  windowsHide: true,
178
190
  });
@@ -187,6 +199,7 @@ function spawnMember(member) {
187
199
  status: "running",
188
200
  exitCode: null,
189
201
  lastPreview: "",
202
+ workerSandboxRoot: sandbox.root || null,
190
203
  };
191
204
 
192
205
  child.stdout.on("data", (buf) => {
@@ -5,6 +5,10 @@ import { existsSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
6
  import { createSwarmHypervisor } from "./swarm-hypervisor.mjs";
7
7
  import { planSwarm } from "./swarm-planner.mjs";
8
+ import {
9
+ formatPreflightReport,
10
+ runSwarmPreflight,
11
+ } from "./swarm-preflight.mjs";
8
12
 
9
13
  const RESET = "\u001b[0m";
10
14
  const BOLD = "\u001b[1m";
@@ -150,10 +154,44 @@ function printPlan(plan) {
150
154
  }
151
155
  }
152
156
 
157
+ function formatCell(value, width) {
158
+ const text = value == null || value === "" ? "-" : String(value);
159
+ return text.padEnd(width).slice(0, width);
160
+ }
161
+
162
+ export function formatIntegrationReport(report = []) {
163
+ const rows = Array.isArray(report) ? report : [];
164
+ const lines = [
165
+ "=== INTEGRATION REPORT ===",
166
+ "SHARD STRATEGY COMMITS FINAL_SHA NOTES",
167
+ ];
168
+
169
+ let integrated = 0;
170
+ let recoveryPatches = 0;
171
+ for (const row of rows) {
172
+ if (row.strategy !== "failed") integrated += 1;
173
+ if (row.recoveryPatch) recoveryPatches += 1;
174
+ const finalSha = row.finalSha ? String(row.finalSha).slice(0, 7) : "-";
175
+ const notes = row.recoveryPatch
176
+ ? `${row.notes || "failed"}; recovery: ${row.recoveryPatch}`
177
+ : row.notes || "-";
178
+ lines.push(
179
+ `${formatCell(row.shard, 9)} ${formatCell(row.strategy, 14)} ${formatCell(row.commits, 8)} ${formatCell(finalSha, 10)} ${notes}`,
180
+ );
181
+ }
182
+
183
+ const total = rows.length;
184
+ lines.push("");
185
+ lines.push(
186
+ `TOTAL: ${integrated}/${total} integrated, ${recoveryPatches} recovery patch${recoveryPatches === 1 ? "" : "es"} saved.`,
187
+ );
188
+ return lines.join("\n");
189
+ }
190
+
153
191
  /**
154
192
  * tfx swarm <prd-path> — PRD 실행
155
193
  */
156
- export async function cmdSwarmRun(args, { json = false } = {}) {
194
+ export async function cmdSwarmRun(args, { json = false, deps = {} } = {}) {
157
195
  const { flags, positional } = parseFlags(args);
158
196
  flags.json = flags.json || json;
159
197
 
@@ -168,9 +206,10 @@ export async function cmdSwarmRun(args, { json = false } = {}) {
168
206
  throw new Error(`PRD file not found: ${absPrd}`);
169
207
  }
170
208
 
171
- const plan = planSwarm(absPrd, { repoRoot: process.cwd() });
172
-
173
209
  if (flags.dryRun) {
210
+ const plan = (deps.planSwarm || planSwarm)(absPrd, {
211
+ repoRoot: process.cwd(),
212
+ });
174
213
  if (flags.json) {
175
214
  process.stdout.write(JSON.stringify(planToJson(plan), null, 2) + "\n");
176
215
  } else {
@@ -179,6 +218,24 @@ export async function cmdSwarmRun(args, { json = false } = {}) {
179
218
  return;
180
219
  }
181
220
 
221
+ const preflight = (deps.runSwarmPreflight || runSwarmPreflight)(absPrd, {
222
+ repoRoot: process.cwd(),
223
+ deps,
224
+ });
225
+ if (!preflight.ok) {
226
+ if (flags.json) {
227
+ process.stdout.write(JSON.stringify(preflight, null, 2) + "\n");
228
+ } else {
229
+ process.stderr.write(formatPreflightReport(preflight));
230
+ }
231
+ process.exitCode = 1;
232
+ return;
233
+ }
234
+
235
+ const plan = (deps.planSwarm || planSwarm)(absPrd, {
236
+ repoRoot: process.cwd(),
237
+ });
238
+
182
239
  const ttyGate = assertTtyForSwarm();
183
240
  for (const w of ttyGate.warnings) {
184
241
  console.error(` ${YELLOW}⚠${RESET} ${w}`);
@@ -190,7 +247,7 @@ export async function cmdSwarmRun(args, { json = false } = {}) {
190
247
  const logsDir =
191
248
  flags.logsDir ||
192
249
  join(process.cwd(), ".triflux", "swarm-logs", `run-${Date.now()}`);
193
- const hyper = createSwarmHypervisor({
250
+ const hyper = (deps.createSwarmHypervisor || createSwarmHypervisor)({
194
251
  workdir: process.cwd(),
195
252
  logsDir,
196
253
  maxRestarts: flags.maxRestarts,
@@ -257,6 +314,10 @@ export async function cmdSwarmRun(args, { json = false } = {}) {
257
314
  );
258
315
  }
259
316
 
317
+ if (Array.isArray(ip.report) && ip.report.length > 0) {
318
+ console.log(`\n${formatIntegrationReport(ip.report)}`);
319
+ }
320
+
260
321
  if (flags.json) {
261
322
  process.stdout.write(JSON.stringify(status, null, 2) + "\n");
262
323
  }
@@ -272,6 +333,39 @@ export async function cmdSwarmPlan(args, { json = false } = {}) {
272
333
  return cmdSwarmRun(["--dry-run", ...args], { json });
273
334
  }
274
335
 
336
+ /**
337
+ * tfx swarm preflight <prd-path> — PRD 실행 전 no-go 리포트 출력
338
+ */
339
+ export async function cmdSwarmPreflight(
340
+ args,
341
+ { json = false, deps = {} } = {},
342
+ ) {
343
+ const { flags, positional } = parseFlags(args);
344
+ flags.json = flags.json || json;
345
+
346
+ const prdPath = positional[0];
347
+ if (!prdPath) {
348
+ throw new Error(
349
+ "PRD path required. Usage: tfx swarm preflight <prd-path> [--json]",
350
+ );
351
+ }
352
+ const absPrd = resolve(prdPath);
353
+ if (!existsSync(absPrd)) {
354
+ throw new Error(`PRD file not found: ${absPrd}`);
355
+ }
356
+
357
+ const report = (deps.runSwarmPreflight || runSwarmPreflight)(absPrd, {
358
+ repoRoot: process.cwd(),
359
+ deps,
360
+ });
361
+ if (flags.json) {
362
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
363
+ } else {
364
+ process.stdout.write(formatPreflightReport(report));
365
+ }
366
+ if (!report.ok) process.exitCode = 1;
367
+ }
368
+
275
369
  /**
276
370
  * tfx swarm list — synapse status로 위임 (swarm 세션은 synapse registry에 등록)
277
371
  */