triflux 10.43.0 → 10.44.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 (47) hide show
  1. package/README.md +2 -0
  2. package/adapters/codex/skills/tfx-harness/SKILL.md +30 -0
  3. package/adapters/codex/skills/tfx-harness/skill.json +5 -0
  4. package/bin/triflux.mjs +14 -0
  5. package/hooks/keyword-rules.json +139 -19
  6. package/hub/cli-adapter-base.mjs +11 -2
  7. package/hub/codex-adapter.mjs +3 -0
  8. package/hub/intent.mjs +24 -32
  9. package/hub/router.mjs +272 -0
  10. package/hub/schema.sql +61 -2
  11. package/hub/server.mjs +42 -0
  12. package/hub/store.mjs +577 -5
  13. package/hub/team/conductor.mjs +23 -2
  14. package/hub/team/execution-mode.mjs +12 -2
  15. package/hub/team/headless.mjs +16 -0
  16. package/hub/team/retry-state-machine.mjs +8 -3
  17. package/hub/team/swarm-hypervisor.mjs +1 -0
  18. package/hub/team/swarm-preflight.mjs +47 -0
  19. package/hub/team/synapse-http.mjs +149 -4
  20. package/hub/team/worktree-lifecycle.mjs +56 -59
  21. package/hub/tools.mjs +11 -0
  22. package/hub/workers/codex-mcp.mjs +20 -4
  23. package/hub/workers/delegator-mcp.mjs +3 -49
  24. package/package.json +2 -1
  25. package/scripts/__tests__/lint-skills.test.mjs +68 -0
  26. package/scripts/__tests__/tfx-route-node-entry.test.mjs +36 -0
  27. package/scripts/keyword-detector.mjs +55 -8
  28. package/scripts/lib/agent-route-policy.mjs +360 -0
  29. package/scripts/lib/cli-codex.mjs +22 -160
  30. package/scripts/lib/codex-profile-config.mjs +29 -3
  31. package/scripts/lib/keyword-rules.mjs +12 -0
  32. package/scripts/lint-skills.mjs +65 -0
  33. package/scripts/pack.mjs +13 -0
  34. package/scripts/release/check-packages-mirror.mjs +5 -0
  35. package/scripts/setup.mjs +94 -2
  36. package/scripts/tfx-route.mjs +14 -1
  37. package/scripts/tfx-route.sh +233 -70
  38. package/skills/tfx-harness/SKILL.md +19 -76
  39. package/skills/tfx-hub/SKILL.md +1 -1
  40. package/skills/tfx-interview/SKILL.md +3 -1
  41. package/skills/tfx-interview/skill.json +1 -13
  42. package/skills/tfx-profile/SKILL.md +25 -15
  43. package/skills/tfx-prune/SKILL.md +3 -1
  44. package/skills/tfx-prune/skill.json +1 -8
  45. package/skills/tfx-setup/SKILL.md +1 -1
  46. package/tui/codex-profile.mjs +11 -2
  47. package/tui/setup.mjs +2 -0
@@ -19,6 +19,8 @@ import {
19
19
  import { homedir } from "node:os";
20
20
  import { dirname, join } from "node:path";
21
21
  import { createRegistry } from "../../mesh/mesh-registry.mjs";
22
+ import { resolveNestedCodexAgentProfile } from "../../scripts/lib/cli-codex.mjs";
23
+ import { codexProfileConfigOverrides } from "../../scripts/lib/codex-profile-config.mjs";
22
24
  import { broker } from "../account-broker.mjs";
23
25
  import { runPreflight as runCodexPreflight } from "../codex-preflight.mjs";
24
26
  import {
@@ -608,7 +610,9 @@ export function createConductor(opts = {}) {
608
610
  cli: session.config.agent,
609
611
  prompt: session.config.prompt,
610
612
  profile: session.config.profile,
613
+ role: session.config.role,
611
614
  model: session.config.model,
615
+ env: session.config.env,
612
616
  mcpServers: session.config.mcpServers,
613
617
  excludeMcpServers,
614
618
  resolveCommand: opts.deps?.resolveCliExecutable,
@@ -902,8 +906,25 @@ export function createConductor(opts = {}) {
902
906
  } else if (agent === "gemini") {
903
907
  remoteBin = "gemini -y";
904
908
  } else {
905
- remoteBin =
906
- "codex exec -s danger-full-access --dangerously-bypass-approvals-and-sandbox";
909
+ const codexHome = session.config.env?.CODEX_HOME;
910
+ const profile = resolveNestedCodexAgentProfile(
911
+ session.config.role || "executor",
912
+ {
913
+ profileOverride: session.config.profile ?? "auto",
914
+ globalProfile:
915
+ session.config.env?.TFX_CODEX_PROFILE ??
916
+ process.env.TFX_CODEX_PROFILE,
917
+ codexHome,
918
+ },
919
+ );
920
+ const configArgs = codexProfileConfigOverrides(profile, {
921
+ codexHome,
922
+ disallowUltra: true,
923
+ enforceCanonicalProfile: true,
924
+ })
925
+ .map((override) => `-c '${override.replace(/'/g, `'\\''`)}'`)
926
+ .join(" ");
927
+ remoteBin = `codex exec${configArgs ? ` ${configArgs}` : ""} -s danger-full-access --dangerously-bypass-approvals-and-sandbox`;
907
928
  }
908
929
 
909
930
  // prompt는 stdin으로 전달 — 셸 이스케이프 문제 완전 회피
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { dirname, resolve as pathResolve } from "node:path";
5
-
5
+ import { resolveNestedCodexAgentProfile } from "../../scripts/lib/cli-codex.mjs";
6
6
  import { codexProfileConfigOverrides } from "../../scripts/lib/codex-profile-config.mjs";
7
7
  import { whichCommand } from "../platform.mjs";
8
8
 
@@ -160,7 +160,17 @@ export function buildSpawnSpecForMode(mode, opts = {}) {
160
160
  // still contains an inline [profiles.X] table (which codex re-injects on
161
161
  // config rewrite). These go straight to spawn argv (shell: false), so push
162
162
  // the raw TOML-scalar overrides without shell-quoting.
163
- for (const override of codexProfileConfigOverrides(opts.profile)) {
163
+ const codexHome = opts.env?.CODEX_HOME;
164
+ const codexProfile = resolveNestedCodexAgentProfile(opts.role || "executor", {
165
+ profileOverride: opts.profile ?? "auto",
166
+ globalProfile: opts.env?.TFX_CODEX_PROFILE ?? process.env.TFX_CODEX_PROFILE,
167
+ codexHome,
168
+ });
169
+ for (const override of codexProfileConfigOverrides(codexProfile, {
170
+ codexHome,
171
+ disallowUltra: true,
172
+ enforceCanonicalProfile: true,
173
+ })) {
164
174
  args.push("-c", override);
165
175
  }
166
176
  if (Array.isArray(opts.mcpServers)) {
@@ -20,6 +20,7 @@ import net from "node:net";
20
20
  import { tmpdir } from "node:os";
21
21
  import { dirname, join, resolve } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
+ import { resolveNestedCodexAgentProfile } from "../../scripts/lib/cli-codex.mjs";
23
24
  import { requestJson } from "../bridge.mjs";
24
25
  import { escapePwshSingleQuoted } from "../cli-adapter-base.mjs";
25
26
  import { getMaxSpawnPerSec } from "../lib/spawn-trace.mjs";
@@ -366,6 +367,14 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
366
367
  writeFileSync(promptFile, fullPrompt, "utf8");
367
368
  void IS_WINDOWS; // referenced for diagnostic guard chain below
368
369
 
370
+ const codexProfile =
371
+ resolvedCli === "codex"
372
+ ? resolveNestedCodexAgentProfile(opts.role || cli, {
373
+ profileOverride: opts.profile ?? "auto",
374
+ globalProfile: process.env.TFX_CODEX_PROFILE,
375
+ codexHome: process.env.CODEX_HOME,
376
+ })
377
+ : undefined;
369
378
  const backendCommand =
370
379
  resolvedCli === "antigravity"
371
380
  ? buildRouteBackedHeadlessCommand(resolvedCli, promptFile, resultFile, {
@@ -375,6 +384,10 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
375
384
  : getBackend(resolvedCli).buildArgs(fullPrompt, resultFile, {
376
385
  ...opts,
377
386
  model,
387
+ profile: codexProfile,
388
+ codexHome: process.env.CODEX_HOME,
389
+ disallowUltra: true,
390
+ enforceCanonicalProfile: true,
378
391
  promptFile,
379
392
  });
380
393
  const safeCwd =
@@ -994,6 +1007,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
994
1007
  mcp: assignment.mcp,
995
1008
  role: assignment.role,
996
1009
  model: assignment.model,
1010
+ profile: assignment.profile,
997
1011
  cwd: assignment.cwd || assignment.workdir,
998
1012
  },
999
1013
  );
@@ -1076,6 +1090,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
1076
1090
  mcp: assignment.mcp,
1077
1091
  role: assignment.role,
1078
1092
  model: assignment.model,
1093
+ profile: assignment.profile,
1079
1094
  cwd: assignment.cwd || assignment.workdir,
1080
1095
  },
1081
1096
  );
@@ -1139,6 +1154,7 @@ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
1139
1154
  mcp: assignment.mcp,
1140
1155
  role: assignment.role,
1141
1156
  model: assignment.model,
1157
+ profile: assignment.profile,
1142
1158
  cwd: assignment.cwd || assignment.workdir,
1143
1159
  },
1144
1160
  );
@@ -41,13 +41,13 @@ export const MODES = Object.freeze({
41
41
  const ESCALATION_CHAIN_CONFIG_PATH = ".triflux/config/escalation-chain.json";
42
42
 
43
43
  // Escalation chain (2026-05-27 정책):
44
- // 1. codex gpt-5.6-sol — 기존 최상위 Codex escalation 단계
44
+ // 1. codex gpt-5.6-sol max 최난도 단일 작업용 Codex escalation 단계
45
45
  // 2. claude opus-4-8 — 최종 수단
46
46
  const DEFAULT_ESCALATION_CHAIN = Object.freeze([
47
47
  Object.freeze({
48
48
  cli: "codex",
49
49
  model: "gpt-5.6-sol",
50
- profile: "gpt56_sol_xhigh",
50
+ profile: "gpt56_sol_max",
51
51
  }),
52
52
  Object.freeze({ cli: "claude", model: "opus-4-8" }),
53
53
  ]);
@@ -295,7 +295,12 @@ function normalizeEscalationChainEntry(entry, index, source) {
295
295
  model: entry.model,
296
296
  };
297
297
  if (typeof entry.profile === "string" && entry.profile.trim() !== "") {
298
- normalized.profile = entry.profile;
298
+ const profile = entry.profile.trim();
299
+ normalized.profile =
300
+ entry.cli === "codex" &&
301
+ (profile === "ultra" || profile === "gpt56_sol_ultra")
302
+ ? "gpt56_sol_max"
303
+ : profile;
299
304
  }
300
305
  return normalized;
301
306
  }
@@ -678,6 +678,7 @@ export function createSwarmHypervisor(opts) {
678
678
  const config = {
679
679
  id: buildSwarmSessionId(shard),
680
680
  agent: shard.agent,
681
+ role: shard.role,
681
682
  // #125: append Completion Protocol appendix so workers emit a
682
683
  // sentinel-framed JSON payload that conductor can reliably capture.
683
684
  prompt: buildWorkerPrompt(shard.prompt, {
@@ -1,6 +1,7 @@
1
1
  // hub/team/swarm-preflight.mjs — PRD swarm preflight report/gate (#188)
2
2
  // Predicts launch-time blockers before any worker session or worktree spawn.
3
3
 
4
+ import { execFileSync } from "node:child_process";
4
5
  import { resolve } from "node:path";
5
6
  import { readHosts, resolveHost } from "../lib/hosts-compat.mjs";
6
7
  import { whichCommand } from "../platform.mjs";
@@ -83,6 +84,44 @@ function checkSensitive(plan, sensitiveDeny) {
83
84
  return makeCheck("sensitiveDeny", true, { matches });
84
85
  }
85
86
 
87
+ function parseTrackedStatus(rawStatus) {
88
+ const files = [];
89
+ const records = String(rawStatus || "").split("\0");
90
+ for (let index = 0; index < records.length; index++) {
91
+ const record = records[index];
92
+ if (record.length < 4) continue;
93
+ const status = record.slice(0, 2);
94
+ files.push(normalizePath(record.slice(3)));
95
+ if (/[RC]/u.test(status) && records[index + 1]) {
96
+ files.push(normalizePath(records[++index]));
97
+ }
98
+ }
99
+ return unique(files);
100
+ }
101
+
102
+ function checkRootDirtyLease(plan, repoRoot, deps = {}) {
103
+ const gitStatus =
104
+ deps.gitStatus ||
105
+ ((cwd) =>
106
+ execFileSync(
107
+ "git",
108
+ ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
109
+ { cwd, encoding: "utf8", windowsHide: true },
110
+ ));
111
+ const dirtyFiles = parseTrackedStatus(gitStatus(repoRoot));
112
+ const leasedFiles = unique(
113
+ [...plan.leaseMap.values()].flat().map(normalizePath),
114
+ );
115
+ const leaseSet = new Set(leasedFiles);
116
+ const overlappingFiles = dirtyFiles.filter((file) => leaseSet.has(file));
117
+
118
+ return makeCheck("rootDirtyLease", overlappingFiles.length === 0, {
119
+ dirtyFiles,
120
+ leasedFiles,
121
+ overlappingFiles,
122
+ });
123
+ }
124
+
86
125
  function checkHosts(plan, repoRoot) {
87
126
  const registry = readHosts(repoRoot);
88
127
  const missing = [];
@@ -216,6 +255,13 @@ function collectMessages(checks) {
216
255
  );
217
256
  }
218
257
 
258
+ const rootDirtyLease = checks.rootDirtyLease;
259
+ if (rootDirtyLease && !rootDirtyLease.ok) {
260
+ errors.push(
261
+ `rootDir tracked changes overlap shard leases: ${rootDirtyLease.overlappingFiles.join(", ")}`,
262
+ );
263
+ }
264
+
219
265
  return { errors: unique(errors), warnings: unique(warnings) };
220
266
  }
221
267
 
@@ -258,6 +304,7 @@ export function runSwarmPreflight(prdPath, opts = {}) {
258
304
  leases: makeCheck("leases", plan.conflicts.length === 0, {
259
305
  conflicts: [...plan.conflicts],
260
306
  }),
307
+ rootDirtyLease: checkRootDirtyLease(plan, repoRoot, opts.deps),
261
308
  sensitiveDeny: checkSensitive(plan, sensitiveDeny),
262
309
  hosts: checkHosts(plan, repoRoot),
263
310
  workerCli: checkLocalWorkerClis(plan, opts.deps),
@@ -1,6 +1,9 @@
1
- import { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir, hostname } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { normalizeRoleKey } from "../role-contract.mjs";
4
7
 
5
8
  const HUB_DEFAULT_PORT = 27888;
6
9
  // Same token file as hub/bridge.mjs (HUB_TOKEN_FILE). Kept inline rather than
@@ -21,6 +24,144 @@ function normalizeToken(raw) {
21
24
  return token || null;
22
25
  }
23
26
 
27
+ function opaqueId(prefix, value) {
28
+ return `${prefix}_${createHash("sha256")
29
+ .update(String(value || ""))
30
+ .digest("base64url")
31
+ .slice(0, 22)}`;
32
+ }
33
+
34
+ function normalizedProjectId(value) {
35
+ try {
36
+ return normalizeRoleKey({ project_id: value, role_kind: "cto" }).project_id;
37
+ } catch {
38
+ return "";
39
+ }
40
+ }
41
+
42
+ function resolveProjectId(cwd, meta, opts = {}) {
43
+ const explicit = meta?.project_id || process.env.TFX_PROJECT_ID;
44
+ if (explicit) return normalizedProjectId(explicit);
45
+ if (typeof opts.resolveProjectId === "function") {
46
+ return normalizedProjectId(opts.resolveProjectId(cwd));
47
+ }
48
+ let current = cwd;
49
+ while (current) {
50
+ const manifest = join(current, ".triflux", "project.json");
51
+ if (existsSync(manifest)) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
54
+ if (parsed?.schema_version !== "tfx.project.v1") return "";
55
+ return normalizedProjectId(parsed.project_id);
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+ const parent = dirname(current);
61
+ if (parent === current) break;
62
+ current = parent;
63
+ }
64
+ return "";
65
+ }
66
+
67
+ function inferSessionCli(meta, opts = {}) {
68
+ const explicit = String(meta?.cli || meta?.actor_cli || "")
69
+ .trim()
70
+ .toLowerCase();
71
+ if (explicit === "codex" || explicit === "claude") return explicit;
72
+ const entrypoint = String(opts.entrypoint ?? process.argv[1] ?? "");
73
+ if (entrypoint.includes("codex-session-hook")) return "codex";
74
+ if (entrypoint.includes("agy-session-hook")) return "other";
75
+ return "claude";
76
+ }
77
+
78
+ function resolveTmuxSession(meta, opts = {}) {
79
+ const explicit =
80
+ meta?.tmux_session || meta?.session_name || process.env.TFX_TMUX_SESSION;
81
+ if (explicit) return String(explicit).trim();
82
+ if (!process.env.TMUX) return "";
83
+ try {
84
+ const run =
85
+ opts.tmuxSessionName ||
86
+ (() =>
87
+ execFileSync("tmux", ["display-message", "-p", "#{session_name}"], {
88
+ encoding: "utf8",
89
+ timeout: 200,
90
+ windowsHide: true,
91
+ }));
92
+ return String(run() || "").trim();
93
+ } catch {
94
+ return "";
95
+ }
96
+ }
97
+
98
+ export function buildSynapseRegistrationMeta(meta, opts = {}) {
99
+ if (!meta || meta.sessionKind !== "interactive") return meta;
100
+ const sessionId = String(meta.sessionId || "").trim();
101
+ if (!sessionId) return meta;
102
+ const cwd = typeof meta.cwd === "string" ? meta.cwd : process.cwd();
103
+ const cli = inferSessionCli(meta, opts);
104
+ const hostId = String(
105
+ meta.host_id ||
106
+ process.env.TFX_HOST_ID ||
107
+ opaqueId("hst", opts.hostname?.() || hostname()),
108
+ ).trim();
109
+ const expiresAtMs = Number(opts.nowMs?.() ?? Date.now()) + 299000;
110
+ const transportLocators = [];
111
+ if (cli === "claude") {
112
+ transportLocators.push({
113
+ schema_version: "tfx.transport-locator.v1",
114
+ transport_id: opaqueId("trn", `claude-uds:${hostId}:${sessionId}`),
115
+ kind: "claude-uds",
116
+ host_id: hostId,
117
+ capabilities: ["probe", "wake", "activate", "interrupt"],
118
+ priority: 100,
119
+ expires_at_ms: expiresAtMs,
120
+ locator: {
121
+ session_id: sessionId,
122
+ bridge_id: String(
123
+ meta.bridge_id || process.env.TFX_BRIDGE_ID || "local-hub",
124
+ ),
125
+ },
126
+ });
127
+ }
128
+ const tmuxSession = resolveTmuxSession(meta, opts);
129
+ if (tmuxSession) {
130
+ const muxServerSource =
131
+ meta.mux_server_id ||
132
+ process.env.TFX_MUX_SERVER_ID ||
133
+ String(process.env.TMUX || "default").split(",", 1)[0];
134
+ transportLocators.push({
135
+ schema_version: "tfx.transport-locator.v1",
136
+ transport_id: opaqueId("trn", `tmux:${hostId}:${tmuxSession}`),
137
+ kind: "tmux",
138
+ host_id: hostId,
139
+ capabilities: ["probe", "wake", "activate", "interrupt"],
140
+ priority: cli === "codex" ? 100 : 50,
141
+ expires_at_ms: expiresAtMs,
142
+ locator: {
143
+ session_name: tmuxSession,
144
+ mux_server_id: opaqueId("mux", muxServerSource),
145
+ },
146
+ });
147
+ }
148
+ const directAgentId = `session:${sessionId}`;
149
+ return {
150
+ ...meta,
151
+ agent_id:
152
+ meta.agent_id ||
153
+ (directAgentId.length <= 64 && /^[a-zA-Z0-9._:-]+$/u.test(directAgentId)
154
+ ? directAgentId
155
+ : opaqueId("session", sessionId)),
156
+ cli,
157
+ project_id: resolveProjectId(cwd, meta, opts) || undefined,
158
+ session_id: meta.session_id || sessionId,
159
+ host_id: hostId,
160
+ transport_locators_json:
161
+ meta.transport_locators_json ?? JSON.stringify(transportLocators),
162
+ };
163
+ }
164
+
24
165
  // Mirrors hub/bridge.mjs:readHubToken — env override first, then the token file.
25
166
  // Missing token → null (backward-compatible with token-less hubs).
26
167
  function readHubToken() {
@@ -139,7 +280,11 @@ export function drainPendingSynapse(timeoutMs = 1000) {
139
280
  }
140
281
 
141
282
  export function registerSynapseSession(meta, opts = {}) {
142
- return fireAndForgetSynapse("/synapse/register", meta, opts);
283
+ return fireAndForgetSynapse(
284
+ "/synapse/register",
285
+ buildSynapseRegistrationMeta(meta, opts),
286
+ opts,
287
+ );
143
288
  }
144
289
 
145
290
  export function heartbeatSynapseSession(
@@ -4,8 +4,16 @@
4
4
  // Remote support: host option → SSH-based git operations via remote-session.mjs.
5
5
 
6
6
  import { execFile } from "node:child_process";
7
- import { access, mkdir, readdir, realpath, rm } from "node:fs/promises";
8
- import { normalize, relative, resolve } from "node:path";
7
+ import {
8
+ access,
9
+ mkdir,
10
+ mkdtemp,
11
+ readdir,
12
+ realpath,
13
+ rm,
14
+ } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join, normalize, relative, resolve } from "node:path";
9
17
  import { remoteGit, validateHost } from "./remote-session.mjs";
10
18
 
11
19
  const SWARM_ROOT = ".codex-swarm";
@@ -327,24 +335,23 @@ export async function rebaseShardOntoIntegration({
327
335
  }
328
336
  }
329
337
 
330
- // #127 BUG-E: capture caller's branch BEFORE any checkout. Without
331
- // restoring it in finally, this function leaks main repo HEAD onto
332
- // integrationBranch and every subsequent Edit/commit lands on the swarm
333
- // temp branch silently (observed 2026-04-20: probe + edits + commits all
334
- // ended up on swarm/.../merge while user thought they were on main).
335
- let originalBranch = null;
336
- try {
337
- const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], rootDir);
338
- if (head && head !== "HEAD") originalBranch = head;
339
- } catch {
340
- /* detached HEAD or unknown — skip restore */
341
- }
342
-
343
- // Backup integration HEAD for rollback
338
+ // #127 BUG-E accident history: this function used to checkout the
339
+ // integration branch in rootDir, leak rootDir HEAD, and redirect later user
340
+ // edits/commits into swarm/.../merge. Integration now happens exclusively in
341
+ // a disposable worktree, so rootDir HEAD never moves and needs no restore.
342
+ //
343
+ // #129 BUG-J accident history: checkout failure was followed by a blind
344
+ // reset --hard (or branch -f rollback) against rootDir, which rewound the
345
+ // caller branch. Rollback now resets only the disposable worktree; there is
346
+ // no rootDir reset path and therefore no stash-create last-resort path.
344
347
  const backupCommit = await git(["rev-parse", integrationBranch], rootDir);
345
348
 
346
349
  let shaList = [];
347
350
  let ffError = null;
351
+ let integrationWorktreeAdded = false;
352
+ const integrationWorktree = await mkdtemp(
353
+ join(tmpdir(), "tfx-swarm-integration-"),
354
+ );
348
355
 
349
356
  try {
350
357
  const log = await git(
@@ -361,11 +368,15 @@ export async function rebaseShardOntoIntegration({
361
368
  .map((s) => s.trim())
362
369
  .filter(Boolean);
363
370
 
364
- await git(["checkout", integrationBranch], rootDir);
371
+ await git(
372
+ ["worktree", "add", "--quiet", integrationWorktree, integrationBranch],
373
+ rootDir,
374
+ );
375
+ integrationWorktreeAdded = true;
365
376
 
366
377
  try {
367
- await git(["merge", "--ff-only", shardBranch], rootDir);
368
- const headCommit = await git(["rev-parse", "HEAD"], rootDir);
378
+ await git(["merge", "--ff-only", shardBranch], integrationWorktree);
379
+ const headCommit = await git(["rev-parse", "HEAD"], integrationWorktree);
369
380
  return {
370
381
  ok: true,
371
382
  headCommit,
@@ -376,12 +387,15 @@ export async function rebaseShardOntoIntegration({
376
387
  } catch (err) {
377
388
  ffError = err;
378
389
  try {
379
- await git(["merge", "--abort"], rootDir);
390
+ await git(["merge", "--abort"], integrationWorktree);
380
391
  } catch {
381
392
  /* ff-only usually leaves no merge state */
382
393
  }
383
394
  try {
384
- await git(["reset", "--hard", backupCommit], rootDir);
395
+ // Safe destructive operation: this cwd is the disposable integration
396
+ // worktree, never rootDir. It restores the integration ref before the
397
+ // cherry-pick fallback without touching user files.
398
+ await git(["reset", "--hard", backupCommit], integrationWorktree);
385
399
  } catch {
386
400
  /* fallback below will report if cherry-pick also fails */
387
401
  }
@@ -391,10 +405,10 @@ export async function rebaseShardOntoIntegration({
391
405
  // fail when histories diverge or a shard branch is checked out by a swarm
392
406
  // worktree. Cherry-pick reads commits without moving the shard branch ref.
393
407
  for (const sha of shaList) {
394
- await git(["cherry-pick", sha], rootDir);
408
+ await git(["cherry-pick", sha], integrationWorktree);
395
409
  }
396
410
 
397
- const headCommit = await git(["rev-parse", "HEAD"], rootDir);
411
+ const headCommit = await git(["rev-parse", "HEAD"], integrationWorktree);
398
412
  return {
399
413
  ok: true,
400
414
  headCommit,
@@ -406,42 +420,17 @@ export async function rebaseShardOntoIntegration({
406
420
  : "cherry-pick fallback",
407
421
  };
408
422
  } catch (err) {
409
- try {
410
- await git(["cherry-pick", "--abort"], rootDir);
411
- } catch {
412
- /* already clean */
413
- }
414
-
415
- // #129 BUG-J: Roll back integrationBranch without mutating whatever
416
- // branch HEAD currently points at. The previous implementation did a
417
- // blind `reset --hard backupCommit` on current HEAD — if the earlier
418
- // `git checkout integrationBranch` inside the try block failed (e.g.
419
- // integrationBranch was already checked out by a swarm worktree) the
420
- // caller's branch silently rewound to integrationBranch's backup commit.
421
- // Observed 2026-04-20: fix branch tree lost to main HEAD during swarm
422
- // probe, requiring `git merge --ff-only origin/<branch>` recovery.
423
- let currentBranch = null;
424
- try {
425
- const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], rootDir);
426
- if (head && head !== "HEAD") currentBranch = head;
427
- } catch {
428
- /* detached — fall through to ref-only update */
429
- }
430
-
431
- if (currentBranch === integrationBranch) {
432
- // HEAD is on integrationBranch — reset advances this branch only.
423
+ if (integrationWorktreeAdded) {
433
424
  try {
434
- await git(["reset", "--hard", backupCommit], rootDir);
425
+ await git(["cherry-pick", "--abort"], integrationWorktree);
435
426
  } catch {
436
- /* best-effort */
427
+ /* already clean */
437
428
  }
438
- } else {
439
- // HEAD is elsewhere (originalBranch, detached, or another branch).
440
- // Update the integrationBranch ref directly; never touch current HEAD.
441
429
  try {
442
- await git(["branch", "-f", integrationBranch, backupCommit], rootDir);
430
+ // #129 rollback is now local to the disposable integration worktree.
431
+ await git(["reset", "--hard", backupCommit], integrationWorktree);
443
432
  } catch {
444
- /* best-effort: branch may be checked out in another worktree */
433
+ /* best-effort */
445
434
  }
446
435
  }
447
436
 
@@ -453,14 +442,22 @@ export async function rebaseShardOntoIntegration({
453
442
  fallbackReason: ffError?.message || null,
454
443
  };
455
444
  } finally {
456
- // Always restore caller's branch. Skip if originalBranch is null
457
- // (detached HEAD case) or already on target.
458
- if (originalBranch) {
445
+ if (integrationWorktreeAdded) {
459
446
  try {
460
- await git(["checkout", originalBranch], rootDir);
447
+ await git(
448
+ ["worktree", "remove", "--force", integrationWorktree],
449
+ rootDir,
450
+ );
461
451
  } catch {
462
- /* best-effort caller will see HEAD on integrationBranch */
452
+ await rm(integrationWorktree, { recursive: true, force: true });
453
+ try {
454
+ await git(["worktree", "prune"], rootDir);
455
+ } catch {
456
+ /* best-effort metadata cleanup */
457
+ }
463
458
  }
459
+ } else {
460
+ await rm(integrationWorktree, { recursive: true, force: true });
464
461
  }
465
462
  }
466
463
  }
package/hub/tools.mjs CHANGED
@@ -153,6 +153,17 @@ export function createTools(store, router, hitl, pipe = null) {
153
153
  },
154
154
  topics: { type: "array", items: { type: "string" }, maxItems: 64 },
155
155
  metadata: { type: "object" },
156
+ project_id: { type: "string", pattern: "^prj_[A-Za-z0-9_-]{22}$" },
157
+ session_id: { type: "string", minLength: 1, maxLength: 256 },
158
+ host_id: { type: "string", pattern: "^hst_[A-Za-z0-9_-]+$" },
159
+ transport_locators_json: {
160
+ description:
161
+ "tfx.transport-locator.v1 객체 배열 또는 그 JSON 문자열",
162
+ oneOf: [
163
+ { type: "array", items: { type: "object" } },
164
+ { type: "string" },
165
+ ],
166
+ },
156
167
  heartbeat_ttl_ms: {
157
168
  type: "integer",
158
169
  minimum: 10000,
@@ -146,9 +146,11 @@ export function resolveCodexProfileConfig(profileName) {
146
146
  } catch {
147
147
  // Profile file absent (non-file alias). Derive effort from the naming
148
148
  // convention; leave model unset so codex uses its config.toml default.
149
- const suffix = /_(xhigh|high|med|medium|low)$/.exec(profileName);
149
+ const suffix = /_(ultra|max|xhigh|high|med|medium|low)$/.exec(profileName);
150
150
  if (suffix) {
151
151
  const map = {
152
+ ultra: "ultra",
153
+ max: "max",
152
154
  xhigh: "xhigh",
153
155
  high: "high",
154
156
  med: "medium",
@@ -193,16 +195,27 @@ export function buildCodexArguments(prompt, opts = {}) {
193
195
  // profile effort merges on top of any opts.config.
194
196
  if (typeof opts.profile === "string" && opts.profile) {
195
197
  const resolved = resolveCodexProfileConfig(opts.profile);
196
- if (resolved.model && typeof args.model !== "string") {
197
- args.model = resolved.model;
198
+ const profileDerivedUltra =
199
+ resolved.reasoningEffort?.toLowerCase() === "ultra";
200
+ if (typeof args.model !== "string") {
201
+ if (profileDerivedUltra) args.model = "gpt-5.6-sol";
202
+ else if (resolved.model) args.model = resolved.model;
198
203
  }
199
204
  if (resolved.reasoningEffort) {
200
205
  args.config = {
201
206
  ...(args.config || {}),
202
- model_reasoning_effort: resolved.reasoningEffort,
207
+ model_reasoning_effort: profileDerivedUltra
208
+ ? "max"
209
+ : resolved.reasoningEffort,
203
210
  };
204
211
  }
205
212
  }
213
+ if (typeof opts.reasoningEffort === "string" && opts.reasoningEffort) {
214
+ args.config = {
215
+ ...(args.config || {}),
216
+ model_reasoning_effort: opts.reasoningEffort,
217
+ };
218
+ }
206
219
 
207
220
  return args;
208
221
  }
@@ -694,6 +707,9 @@ function parseCliArgs(argv) {
694
707
  case "--model":
695
708
  options.model = next();
696
709
  break;
710
+ case "--reasoning-effort":
711
+ options.reasoningEffort = next();
712
+ break;
697
713
  case "--approval-policy":
698
714
  options.approvalPolicy = next();
699
715
  break;