@triflux/core 10.61.1 → 10.62.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.
@@ -58,6 +58,28 @@ function beginTurn(state) {
58
58
  function acceptEntry(state, entry, marker) {
59
59
  if (entry?.isSidechain) return;
60
60
  const message = entry?.message;
61
+ if (
62
+ entry?.type === "attachment" &&
63
+ entry.attachment?.type === "goal_status"
64
+ ) {
65
+ const goal = entry.attachment;
66
+ const cleared = goal.met === true && goal.sentinel === true;
67
+ state.goal = {
68
+ state: cleared ? "none" : goal.met ? "complete" : "active",
69
+ objective: cleared ? null : goal.condition,
70
+ judgedBy: "evaluator",
71
+ ...(goal.reason !== undefined ? { reason: goal.reason } : {}),
72
+ ...(goal.tokens !== undefined ? { tokensUsed: goal.tokens } : {}),
73
+ updatedAt: entry.timestamp ?? null,
74
+ };
75
+ if (goal.sentinel === true) {
76
+ state.goalSentinel = {
77
+ ...goal,
78
+ updatedAt: entry.timestamp,
79
+ count: (state.goalSentinel?.count ?? 0) + 1,
80
+ };
81
+ }
82
+ }
61
83
  if (entry?.type === "system" && entry.subtype === "compact_boundary") {
62
84
  state.compactCount += 1;
63
85
  state.compact = {
@@ -211,6 +233,8 @@ export async function readClaudeTranscript(transcriptPath, { requestId } = {}) {
211
233
  measuredAt: null,
212
234
  compactCount: 0,
213
235
  compact: null,
236
+ goal: { state: "none", objective: null, judgedBy: "evaluator" },
237
+ goalSentinel: null,
214
238
  };
215
239
  }
216
240
  try {
@@ -255,5 +279,7 @@ export async function readClaudeTranscript(transcriptPath, { requestId } = {}) {
255
279
  sectionClosed: state.sectionClosed,
256
280
  compactCount: state.compactCount,
257
281
  compact: state.compact,
282
+ goal: state.goal,
283
+ goalSentinel: state.goalSentinel,
258
284
  };
259
285
  }
@@ -23,10 +23,8 @@ import {
23
23
  } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
25
 
26
- import {
27
- canonicalCodexProfileModel,
28
- normalizeCodexProfileName,
29
- } from "../../scripts/lib/codex-profile-config.mjs";
26
+ import { normalizeCodexProfileName } from "../../scripts/lib/codex-profile-config.mjs";
27
+ import { CODEX_FAMILY_PROFILES } from "../../scripts/lib/model-family-resolver.mjs";
30
28
 
31
29
  export const STATES = Object.freeze({
32
30
  PLANNING: "PLANNING",
@@ -47,13 +45,12 @@ const ESCALATION_CHAIN_CONFIG_PATH = ".triflux/config/escalation-chain.json";
47
45
  const DEFAULT_CLAUDE_MODEL = "fable";
48
46
 
49
47
  // Escalation chain (2026-09-22 정책):
50
- // 1. codex gpt-6-astra max: 최난도 단일 작업용 Codex escalation 단계
48
+ // 1. codex astra_max: 최난도 단일 작업용 Codex escalation 단계
51
49
  // 2. claude fable alias: Claude CLI가 최신 Fable tier로 해석하는 최종 수단
52
50
  const DEFAULT_ESCALATION_CHAIN = Object.freeze([
53
51
  Object.freeze({
54
52
  cli: "codex",
55
- model: "gpt-6-astra",
56
- profile: "gpt6_astra_max",
53
+ profile: "astra_max",
57
54
  }),
58
55
  Object.freeze({ cli: "claude", model: DEFAULT_CLAUDE_MODEL }),
59
56
  ]);
@@ -62,8 +59,8 @@ const STUCK_THRESHOLD = 3;
62
59
 
63
60
  function normalizeEscalationProfile(profile) {
64
61
  const normalized = normalizeCodexProfileName(profile);
65
- return normalized === "ultra" || normalized === "gpt6_astra_ultra"
66
- ? "gpt6_astra_max"
62
+ return normalized === "ultra" || normalized === "astra_ultra"
63
+ ? "astra_max"
67
64
  : normalized;
68
65
  }
69
66
 
@@ -307,26 +304,21 @@ function normalizeEscalationChainEntry(entry, index, source) {
307
304
  `${source}: chain[${index}].cli must be a non-empty string`,
308
305
  );
309
306
  }
307
+ const profile = typeof entry.profile === "string" ? entry.profile.trim() : "";
308
+ const normalizedProfile = normalizeEscalationProfile(profile);
309
+ if (
310
+ entry.cli === "codex" &&
311
+ Object.hasOwn(CODEX_FAMILY_PROFILES, normalizedProfile)
312
+ ) {
313
+ return { cli: entry.cli, profile: normalizedProfile };
314
+ }
310
315
  if (typeof entry.model !== "string" || entry.model.trim() === "") {
311
316
  throw new Error(
312
317
  `${source}: chain[${index}].model must be a non-empty string`,
313
318
  );
314
319
  }
315
-
316
- const normalized = {
317
- cli: entry.cli,
318
- model: entry.model,
319
- };
320
- if (typeof entry.profile === "string" && entry.profile.trim() !== "") {
321
- const profile = entry.profile.trim();
322
- normalized.profile =
323
- entry.cli === "codex" ? normalizeEscalationProfile(profile) : profile;
324
- // A remapped legacy profile must not keep reporting its old model.
325
- if (normalized.profile !== profile) {
326
- normalized.model =
327
- canonicalCodexProfileModel(normalized.profile) ?? normalized.model;
328
- }
329
- }
320
+ const normalized = { cli: entry.cli, model: entry.model };
321
+ if (profile) normalized.profile = profile;
330
322
  return normalized;
331
323
  }
332
324
 
@@ -4,12 +4,13 @@ import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { StringDecoder } from "node:string_decoder";
6
6
 
7
- // https://platform.claude.com/docs/en/build-with-claude/context-windows 2026-10-08
7
+ // https://platform.claude.com/docs/en/build-with-claude/context-windows 2026-10-12
8
8
  // https://developers.openai.com/api/docs/models 2026-10-08
9
9
  const MODEL_CONTEXT = [
10
10
  {
11
11
  cli: "claude",
12
- model: /^(?:claude-)?(?:fable-5[.-]1|opus-5[.-]5|sonnet-5[.-]5)(?:$|[-[])/i,
12
+ model:
13
+ /^(?:claude-)?(?:fable|opus|sonnet|haiku)-(?:[5-9]|[1-9]\d+)(?:[.-]\d+)*(?:$|[-[])/i,
13
14
  tokens: 1_000_000,
14
15
  source:
15
16
  "https://platform.claude.com/docs/en/build-with-claude/context-windows",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.61.1",
3
+ "version": "10.62.0",
4
4
  "description": "triflux core — CLI routing, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -10,127 +10,127 @@ import { resolveGeminiProfileForPurpose } from "./gemini-profiles.mjs";
10
10
 
11
11
  const policy = {
12
12
  executor: {
13
- profile: "gpt61_sol_high",
13
+ profile: "sol_high",
14
14
  timeoutSec: 1080,
15
15
  mcpHint: "implement",
16
16
  subcommand: "exec",
17
17
  },
18
18
  codex: {
19
- profile: "gpt61_sol_high",
19
+ profile: "sol_high",
20
20
  timeoutSec: 1080,
21
21
  mcpHint: "implement",
22
22
  subcommand: "exec",
23
23
  },
24
24
  "build-fixer": {
25
- profile: "gpt6_luna_high",
25
+ profile: "luna_high",
26
26
  timeoutSec: 540,
27
27
  mcpHint: "implement",
28
28
  subcommand: "exec",
29
29
  },
30
30
  cleanup: {
31
- profile: "gpt61_sol_med",
31
+ profile: "sol_med",
32
32
  timeoutSec: 540,
33
33
  mcpHint: "implement",
34
34
  subcommand: "exec",
35
35
  },
36
36
  deslop: {
37
- profile: "gpt61_sol_med",
37
+ profile: "sol_med",
38
38
  timeoutSec: 540,
39
39
  mcpHint: "implement",
40
40
  subcommand: "exec",
41
41
  },
42
42
  debugger: {
43
- profile: "gpt6_astra_xhigh",
43
+ profile: "astra_xhigh",
44
44
  timeoutSec: 900,
45
45
  mcpHint: "implement",
46
46
  subcommand: "exec",
47
47
  },
48
48
  "deep-executor": {
49
- profile: "gpt6_astra_xhigh",
49
+ profile: "astra_xhigh",
50
50
  timeoutSec: 3600,
51
51
  mcpHint: "implement",
52
52
  subcommand: "exec",
53
53
  },
54
54
  architect: {
55
- profile: "gpt6_astra_xhigh",
55
+ profile: "astra_xhigh",
56
56
  timeoutSec: 3600,
57
57
  mcpHint: "analyze",
58
58
  subcommand: "exec",
59
59
  },
60
60
  critic: {
61
- profile: "gpt6_astra_xhigh",
61
+ profile: "astra_xhigh",
62
62
  timeoutSec: 3600,
63
63
  mcpHint: "analyze",
64
64
  subcommand: "exec",
65
65
  },
66
66
  planner: {
67
- profile: "gpt6_astra_xhigh",
67
+ profile: "astra_xhigh",
68
68
  timeoutSec: 3600,
69
69
  mcpHint: "analyze",
70
70
  subcommand: "exec",
71
71
  },
72
72
  analyst: {
73
- profile: "gpt6_astra_xhigh",
73
+ profile: "astra_xhigh",
74
74
  timeoutSec: 3600,
75
75
  mcpHint: "analyze",
76
76
  subcommand: "exec",
77
77
  },
78
78
  "code-reviewer": {
79
- profile: "gpt61_sol_high",
79
+ profile: "sol_high",
80
80
  timeoutSec: 1800,
81
81
  mcpHint: "review",
82
82
  subcommand: "review",
83
83
  },
84
84
  "quality-reviewer": {
85
- profile: "gpt61_sol_high",
85
+ profile: "sol_high",
86
86
  timeoutSec: 1800,
87
87
  mcpHint: "review",
88
88
  subcommand: "review",
89
89
  },
90
90
  "security-reviewer": {
91
- profile: "gpt6_astra_xhigh",
91
+ profile: "astra_xhigh",
92
92
  timeoutSec: 1800,
93
93
  mcpHint: "review",
94
94
  subcommand: "review",
95
95
  },
96
96
  scientist: {
97
- profile: "gpt61_sol_high",
97
+ profile: "sol_high",
98
98
  timeoutSec: 1440,
99
99
  mcpHint: "analyze",
100
100
  subcommand: "exec",
101
101
  },
102
102
  "document-specialist": {
103
- profile: "gpt61_sol_high",
103
+ profile: "sol_high",
104
104
  timeoutSec: 1440,
105
105
  mcpHint: "analyze",
106
106
  subcommand: "exec",
107
107
  },
108
108
  "scientist-deep": {
109
- profile: "gpt6_astra_xhigh",
109
+ profile: "astra_xhigh",
110
110
  timeoutSec: 3600,
111
111
  mcpHint: "analyze",
112
112
  subcommand: "exec",
113
113
  },
114
114
  verifier: {
115
- profile: "gpt61_sol_high",
115
+ profile: "sol_high",
116
116
  timeoutSec: 1200,
117
117
  mcpHint: "review",
118
118
  subcommand: "review",
119
119
  },
120
120
  "test-engineer": {
121
- profile: "gpt61_sol_high",
121
+ profile: "sol_high",
122
122
  timeoutSec: 1200,
123
123
  mcpHint: "implement",
124
124
  subcommand: "exec",
125
125
  },
126
126
  "qa-tester": {
127
- profile: "gpt61_sol_high",
127
+ profile: "sol_high",
128
128
  timeoutSec: 1200,
129
129
  mcpHint: "review",
130
130
  subcommand: "review",
131
131
  },
132
132
  spark: {
133
- profile: "gpt6_luna_low",
133
+ profile: "luna_low",
134
134
  timeoutSec: 180,
135
135
  mcpHint: "implement",
136
136
  subcommand: "exec",
@@ -138,13 +138,13 @@ const policy = {
138
138
  // These entries are only for explicit direct-Codex overrides. agent-map.json
139
139
  // remains the provider-selection authority for their normal execution.
140
140
  designer: {
141
- profile: "gpt6_astra_xhigh",
141
+ profile: "astra_xhigh",
142
142
  timeoutSec: 900,
143
143
  mcpHint: "implement",
144
144
  subcommand: "exec",
145
145
  },
146
146
  writer: {
147
- profile: "gpt6_luna_high",
147
+ profile: "luna_high",
148
148
  timeoutSec: 900,
149
149
  mcpHint: "implement",
150
150
  subcommand: "exec",
@@ -167,13 +167,13 @@ const DEFAULT_CODEX_AGENT = "executor";
167
167
 
168
168
  const CANONICAL_CODEX_PROFILE_OVERRIDES = Object.freeze(
169
169
  new Set([
170
- "gpt6_luna_low",
171
- "gpt6_luna_high",
172
- "gpt61_sol_med",
173
- "gpt61_sol_high",
174
- "gpt6_astra_xhigh",
175
- "gpt6_astra_max",
176
- "gpt6_astra_ultra",
170
+ "luna_low",
171
+ "luna_high",
172
+ "sol_med",
173
+ "sol_high",
174
+ "astra_xhigh",
175
+ "astra_max",
176
+ "astra_ultra",
177
177
  ]),
178
178
  );
179
179
  const ULTRA_ELIGIBLE_CODEX_AGENTS = Object.freeze(
@@ -183,8 +183,8 @@ const ULTRA_ELIGIBLE_CODEX_AGENTS = Object.freeze(
183
183
  const NESTED_GLOBAL_PROFILE_OVERRIDES = new Set([
184
184
  "max",
185
185
  "ultra",
186
- "gpt6_astra_max",
187
- "gpt6_astra_ultra",
186
+ "astra_max",
187
+ "astra_ultra",
188
188
  ]);
189
189
 
190
190
  export function resolveCodexAgentPolicy(agent) {
@@ -219,9 +219,9 @@ export function resolveCodexAgentProfile(
219
219
 
220
220
  const canonical =
221
221
  requested === "max"
222
- ? "gpt6_astra_max"
222
+ ? "astra_max"
223
223
  : requested === "ultra"
224
- ? "gpt6_astra_ultra"
224
+ ? "astra_ultra"
225
225
  : requested;
226
226
  if (!CANONICAL_CODEX_PROFILE_OVERRIDES.has(canonical)) {
227
227
  if (allowCustom) return requested;
@@ -229,9 +229,9 @@ export function resolveCodexAgentProfile(
229
229
  `[agent-route-policy] unsupported profile override: ${requested}`,
230
230
  );
231
231
  }
232
- if (canonical !== "gpt6_astra_ultra") return canonical;
232
+ if (canonical !== "astra_ultra") return canonical;
233
233
  return nested || !ULTRA_ELIGIBLE_CODEX_AGENTS.has(agent)
234
- ? "gpt6_astra_max"
234
+ ? "astra_max"
235
235
  : canonical;
236
236
  }
237
237
 
@@ -270,7 +270,7 @@ export function resolveNestedCodexAgentProfile(
270
270
  });
271
271
  const { effort } = resolveCodexProfileConfigValues(candidate, { codexHome });
272
272
  if (!effort) return fallback;
273
- return effort.toLowerCase() === "ultra" ? "gpt6_astra_max" : candidate;
273
+ return effort.toLowerCase() === "ultra" ? "astra_max" : candidate;
274
274
  }
275
275
 
276
276
  const EXPECTED_DURATION_BY_AGENT = Object.freeze({
@@ -408,13 +408,13 @@ async function runCli() {
408
408
  const module = await import(pathToFileURL(geminiProfilesModule).href);
409
409
  resolver = (agent) => {
410
410
  try {
411
- return module.resolveGeminiProfileForPurpose(agent) || "flash38";
411
+ return module.resolveGeminiProfileForPurpose(agent) || "flash";
412
412
  } catch {
413
- return "flash38";
413
+ return "flash";
414
414
  }
415
415
  };
416
416
  } catch {
417
- resolver = () => "flash38";
417
+ resolver = () => "flash";
418
418
  }
419
419
  }
420
420
  process.stdout.write(
@@ -9,6 +9,12 @@ import {
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
  import { writeRotatedBackup } from "./backup-rotation.mjs";
12
+ import {
13
+ CODEX_FAMILY_PROFILES,
14
+ modelFamilyPaths,
15
+ resolveModelFamilies,
16
+ writeModelFile,
17
+ } from "./model-family-resolver.mjs";
12
18
 
13
19
  const EFFORT_BY_SUFFIX = {
14
20
  ultra: "ultra",
@@ -20,41 +26,38 @@ const EFFORT_BY_SUFFIX = {
20
26
  low: "low",
21
27
  };
22
28
 
23
- const CANONICAL_PROFILE_VALUES = Object.freeze({
24
- gpt6_luna_low: { model: "gpt-6-luna", effort: "low" },
25
- gpt6_luna_high: { model: "gpt-6-luna", effort: "high" },
26
- gpt61_sol_med: { model: "gpt-6.1-sol", effort: "medium" },
27
- gpt61_sol_high: { model: "gpt-6.1-sol", effort: "high" },
28
- gpt6_astra_xhigh: { model: "gpt-6-astra", effort: "xhigh" },
29
- gpt6_astra_max: { model: "gpt-6-astra", effort: "max" },
30
- gpt6_astra_ultra: { model: "gpt-6-astra", effort: "ultra" },
31
- });
32
-
33
- // GPT-5.6 lane names map to the GPT-6 lane with the same effort. GPT-6 has no
34
- // Terra tier, so Terra lanes move to Sol. GPT-6 Sol lane names map to GPT-6.1
35
- // Sol with the same effort.
36
29
  const LEGACY_CANONICAL_PROFILE_ALIASES = Object.freeze({
37
- gpt56_sol_xhigh: "gpt6_astra_xhigh",
38
- gpt56_sol_max: "gpt6_astra_max",
39
- gpt56_sol_ultra: "gpt6_astra_ultra",
40
- gpt56_terra_high: "gpt61_sol_high",
41
- gpt56_terra_med: "gpt61_sol_med",
42
- gpt56_luna_low: "gpt6_luna_low",
43
- gpt6_sol_high: "gpt61_sol_high",
44
- gpt6_sol_med: "gpt61_sol_med",
30
+ gpt56_sol_xhigh: "astra_xhigh",
31
+ gpt56_sol_max: "astra_max",
32
+ gpt56_sol_ultra: "astra_ultra",
33
+ gpt56_terra_high: "sol_high",
34
+ gpt56_terra_med: "sol_med",
45
35
  });
46
36
 
47
37
  export function normalizeCodexProfileName(profileName) {
48
38
  const normalized = String(profileName ?? "").trim();
49
- return LEGACY_CANONICAL_PROFILE_ALIASES[normalized] || normalized;
39
+ const legacy = LEGACY_CANONICAL_PROFILE_ALIASES[normalized];
40
+ if (legacy) return legacy;
41
+ const familyName = normalized.replace(/^gpt\d+_(sol|astra|luna)_/, "$1_");
42
+ return Object.hasOwn(CODEX_FAMILY_PROFILES, familyName)
43
+ ? familyName
44
+ : normalized;
50
45
  }
51
46
 
52
- /** Model ID of a canonical profile (after legacy normalization), or null. */
53
- export function canonicalCodexProfileModel(profileName) {
54
- return (
55
- CANONICAL_PROFILE_VALUES[normalizeCodexProfileName(profileName)]?.model ??
56
- null
57
- );
47
+ export function ensureManagedCodexProfiles(opts = {}) {
48
+ const { codexHome } = modelFamilyPaths(opts);
49
+ const { profiles } = resolveModelFamilies("codex", opts);
50
+ let changed = 0;
51
+ for (const [name, { model, effort }] of Object.entries(profiles)) {
52
+ if (
53
+ writeModelFile(
54
+ join(codexHome, `${name}.config.toml`),
55
+ `model = "${model}"\nmodel_reasoning_effort = "${effort}"\n`,
56
+ )
57
+ )
58
+ changed++;
59
+ }
60
+ return { changed, profiles };
58
61
  }
59
62
 
60
63
  function readProfileScalar(raw, key) {
@@ -70,7 +73,7 @@ function readProfileScalar(raw, key) {
70
73
  }
71
74
 
72
75
  /**
73
- * Resolve a Codex effort profile name (e.g. "gpt61_sol_high") to the `-c key=value`
76
+ * Resolve a Codex effort profile name (e.g. "sol_high") to the `-c key=value`
74
77
  * config overrides that `codex exec` accepts, so the headless CLI lanes can
75
78
  * select a profile WITHOUT `codex exec --profile <name>`. codex 0.134+ rejects
76
79
  * `--profile X` whenever config.toml still contains an inline `[profiles.X]`
@@ -118,12 +121,20 @@ export function codexProfileConfigOverrides(profileName, opts = {}) {
118
121
  opts,
119
122
  );
120
123
  if (opts.enforceCanonicalProfile === true) {
121
- const canonical = CANONICAL_PROFILE_VALUES[normalizedProfileName];
122
- if (canonical) ({ model, effort } = canonical);
124
+ if (Object.hasOwn(CODEX_FAMILY_PROFILES, normalizedProfileName)) {
125
+ ({ model, effort } = resolveModelFamilies("codex", {
126
+ ...opts,
127
+ allowCommand: false,
128
+ persist: false,
129
+ }).profiles[normalizedProfileName]);
130
+ }
123
131
  }
124
132
  if (opts.disallowUltra === true && effort?.toLowerCase() === "ultra") {
125
- model = "gpt-6-astra";
126
- effort = "max";
133
+ ({ model, effort } = resolveModelFamilies("codex", {
134
+ ...opts,
135
+ allowCommand: false,
136
+ persist: false,
137
+ }).profiles.astra_max);
127
138
  }
128
139
  const overrides = [];
129
140
  if (model) overrides.push(`model="${model}"`);
@@ -1,72 +1,64 @@
1
+ import { copyFileSync, existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
1
3
  import {
2
- copyFileSync,
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- writeFileSync,
7
- } from "node:fs";
8
- import { homedir } from "node:os";
9
- import { join } from "node:path";
4
+ FLASH_FAMILY_PROFILES,
5
+ modelFamilyPaths,
6
+ resolveModelFamilies,
7
+ writeModelFile,
8
+ } from "./model-family-resolver.mjs";
10
9
 
11
- // 모델명 SSOT. 값은 `agy models` 가 출력하는 display name 형식이다
12
- // (`--model` 인자가 이 문자열을 받는다). 옛 `gemini-*-preview` ID 형식이 아님.
13
- // 카탈로그 확인: agy 1.1.27 `agy models` (2026-09-07). 운영 정책은 3.8 Flash 한 세대만
14
- // 쓰고 effort(Low/Medium/High)로 차등한다. Pro 와 이전 세대 Flash 는 기본 프로필에서 뺐다.
15
10
  const DEFAULT_GEMINI_PROFILES = {
16
- model: "Gemini 3.8 Flash (Medium)",
17
- profiles: {
18
- flash38_low: {
19
- model: "Gemini 3.8 Flash (Low)",
20
- hint: "3.8 Flash (Low) — 경량·저비용",
21
- },
22
- flash38: {
23
- model: "Gemini 3.8 Flash (Medium)",
24
- hint: "3.8 Flash (Medium) — 기본, 비용·속도 균형",
25
- },
26
- flash38_high: {
27
- model: "Gemini 3.8 Flash (High)",
28
- hint: "3.8 Flash (High) — 코드/추론 강화",
29
- },
30
- },
11
+ model: FLASH_FAMILY_PROFILES.flash.model,
12
+ profiles: Object.fromEntries(
13
+ Object.entries(FLASH_FAMILY_PROFILES).map(([name, { model }]) => [
14
+ name,
15
+ { model },
16
+ ]),
17
+ ),
31
18
  };
32
19
 
20
+ export function normalizeGeminiProfileName(name) {
21
+ const raw = String(name ?? "").trim();
22
+ return raw.replace(/^flash\d*(?=_(?:low|high)$|$)/, "flash");
23
+ }
24
+
33
25
  // ── 용도별 effort (SSOT) ──
34
- // 한 세대(3.8 Flash)만 쓰고 역할에 따라 effort 를 나눈다. 키는 tfx 에이전트/역할 이름
26
+ // Flash 가족 안에서 역할에 따라 effort 를 나눈다. 키는 tfx 에이전트/역할 이름
35
27
  // (agent-map.json 의 에이전트, tfx multi --assign 의 role). bash(tfx-route.sh)와
36
28
  // 실행 모드(execution-mode)가 이 표를 읽는다.
37
29
  // High : 판단이 결과를 좌우하는 역할 (검토, 설계, 디버깅, 분석)
38
30
  // Medium : 생성/작성/실행 (기본값)
39
31
  // Low : 분류, 요약, 추출, 번역처럼 짧고 기계적인 작업
40
32
  const GEMINI_PROFILE_BY_PURPOSE = Object.freeze({
41
- designer: "flash38_high",
42
- reviewer: "flash38_high",
43
- "code-reviewer": "flash38_high",
44
- "security-reviewer": "flash38_high",
45
- "quality-reviewer": "flash38_high",
46
- critic: "flash38_high",
47
- architect: "flash38_high",
48
- verifier: "flash38_high",
49
- analyst: "flash38_high",
50
- debugger: "flash38_high",
51
- planner: "flash38_high",
52
- scientist: "flash38_high",
53
- "test-engineer": "flash38_high",
54
- writer: "flash38",
55
- executor: "flash38",
56
- worker: "flash38",
57
- "document-specialist": "flash38",
58
- researcher: "flash38",
59
- antigravity: "flash38",
60
- agy: "flash38",
61
- gemini: "flash38",
62
- summarizer: "flash38_low",
63
- classifier: "flash38_low",
64
- triage: "flash38_low",
65
- translator: "flash38_low",
66
- formatter: "flash38_low",
67
- explore: "flash38_low",
33
+ designer: "flash_high",
34
+ reviewer: "flash_high",
35
+ "code-reviewer": "flash_high",
36
+ "security-reviewer": "flash_high",
37
+ "quality-reviewer": "flash_high",
38
+ critic: "flash_high",
39
+ architect: "flash_high",
40
+ verifier: "flash_high",
41
+ analyst: "flash_high",
42
+ debugger: "flash_high",
43
+ planner: "flash_high",
44
+ scientist: "flash_high",
45
+ "test-engineer": "flash_high",
46
+ writer: "flash",
47
+ executor: "flash",
48
+ worker: "flash",
49
+ "document-specialist": "flash",
50
+ researcher: "flash",
51
+ antigravity: "flash",
52
+ agy: "flash",
53
+ gemini: "flash",
54
+ summarizer: "flash_low",
55
+ classifier: "flash_low",
56
+ triage: "flash_low",
57
+ translator: "flash_low",
58
+ formatter: "flash_low",
59
+ explore: "flash_low",
68
60
  });
69
- const GEMINI_DEFAULT_PURPOSE_PROFILE = "flash38";
61
+ const GEMINI_DEFAULT_PURPOSE_PROFILE = "flash";
70
62
 
71
63
  function resolveGeminiProfileForPurpose(name) {
72
64
  const key = String(name ?? "")
@@ -84,70 +76,66 @@ function resolveGeminiModel(profileOrModel, opts = {}) {
84
76
  const profilesPath =
85
77
  opts.profilesPath ||
86
78
  process.env.GEMINI_PROFILES_PATH ||
87
- join(homedir(), ".gemini", "triflux-profiles.json");
79
+ join(modelFamilyPaths(opts).home, ".gemini", "triflux-profiles.json");
88
80
  let cfg = null;
89
81
  try {
90
82
  cfg = JSON.parse(readFileSync(profilesPath, "utf8"));
91
83
  } catch {
92
84
  cfg = null;
93
85
  }
94
- const entry = cfg?.profiles?.[raw];
95
- const fromFile = typeof entry === "string" ? entry : entry?.model;
96
- if (typeof fromFile === "string" && fromFile.trim()) return fromFile.trim();
97
- const fromDefault = DEFAULT_GEMINI_PROFILES.profiles[raw];
98
- if (fromDefault) return fromDefault.model;
99
- if (typeof cfg?.model === "string" && cfg.model.trim())
100
- return cfg.model.trim();
101
- return DEFAULT_GEMINI_PROFILES.model;
86
+ const name = normalizeGeminiProfileName(raw);
87
+ const modelValue = (entry) =>
88
+ typeof entry === "string"
89
+ ? entry
90
+ : typeof entry?.model === "string"
91
+ ? entry.model
92
+ : entry?.model?.name || entry?.name;
93
+ for (const bucket of [
94
+ cfg?.profiles,
95
+ cfg?.model?.profiles,
96
+ cfg?.modelProfiles,
97
+ cfg?.models,
98
+ ]) {
99
+ const entry = bucket?.[name] || bucket?.[raw];
100
+ const value = modelValue(entry);
101
+ if (typeof value === "string" && value.trim()) return value.trim();
102
+ }
103
+ if (name !== "default" && DEFAULT_GEMINI_PROFILES.profiles[name])
104
+ return DEFAULT_GEMINI_PROFILES.profiles[name].model;
105
+ const fallback =
106
+ cfg?.defaultModel ||
107
+ cfg?.default_profile ||
108
+ cfg?.defaultProfile ||
109
+ (typeof cfg?.model === "string" ? cfg.model : cfg?.model?.default);
110
+ if (typeof fallback === "string" && fallback.trim()) return fallback.trim();
111
+ const defaultProfile = normalizeGeminiProfileName(
112
+ (opts.env || process.env).TFX_GEMINI_DEFAULT_PROFILE,
113
+ );
114
+ return (
115
+ modelValue(cfg?.profiles?.[defaultProfile]) ||
116
+ DEFAULT_GEMINI_PROFILES.profiles[defaultProfile]?.model ||
117
+ DEFAULT_GEMINI_PROFILES.model
118
+ );
102
119
  }
103
120
 
104
- const DEFAULT_PROFILE_COUNT = Object.keys(
105
- DEFAULT_GEMINI_PROFILES.profiles,
106
- ).length;
107
-
108
- function ensureGeminiProfiles({
109
- geminiDir = join(homedir(), ".gemini"),
110
- profilesPath = join(geminiDir, "triflux-profiles.json"),
111
- } = {}) {
121
+ function ensureGeminiProfiles(opts = {}) {
122
+ const geminiDir =
123
+ opts.geminiDir || join(modelFamilyPaths(opts).home, ".gemini");
124
+ const profilesPath =
125
+ opts.profilesPath ||
126
+ process.env.GEMINI_PROFILES_PATH ||
127
+ join(geminiDir, "triflux-profiles.json");
112
128
  try {
113
- if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
114
-
115
- if (!existsSync(profilesPath)) {
116
- writeFileSync(
117
- profilesPath,
118
- JSON.stringify(DEFAULT_GEMINI_PROFILES, null, 2) + "\n",
119
- { encoding: "utf8", mode: 0o600 },
120
- );
121
- return {
122
- ok: true,
123
- created: true,
124
- added: DEFAULT_PROFILE_COUNT,
125
- count: DEFAULT_PROFILE_COUNT,
126
- message: null,
127
- };
128
- }
129
-
130
129
  let cfg;
131
- try {
132
- cfg = JSON.parse(readFileSync(profilesPath, "utf8"));
133
- } catch {
130
+ let created = !existsSync(profilesPath);
131
+ if (!created) {
134
132
  try {
135
- copyFileSync(profilesPath, profilesPath + `.bak.${Date.now()}`);
136
- } catch {}
137
- writeFileSync(
138
- profilesPath,
139
- JSON.stringify(DEFAULT_GEMINI_PROFILES, null, 2) + "\n",
140
- { encoding: "utf8", mode: 0o600 },
141
- );
142
- return {
143
- ok: true,
144
- created: true,
145
- added: DEFAULT_PROFILE_COUNT,
146
- count: DEFAULT_PROFILE_COUNT,
147
- message: null,
148
- };
133
+ cfg = JSON.parse(readFileSync(profilesPath, "utf8"));
134
+ } catch {
135
+ copyFileSync(profilesPath, `${profilesPath}.bak.${Date.now()}`);
136
+ created = true;
137
+ }
149
138
  }
150
-
151
139
  if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) cfg = {};
152
140
  if (
153
141
  !cfg.profiles ||
@@ -155,28 +143,25 @@ function ensureGeminiProfiles({
155
143
  Array.isArray(cfg.profiles)
156
144
  )
157
145
  cfg.profiles = {};
158
-
146
+ const { profiles } = resolveModelFamilies("agy", {
147
+ ...opts,
148
+ home: opts.home || dirname(geminiDir),
149
+ });
159
150
  let added = 0;
160
- for (const [name, value] of Object.entries(
161
- DEFAULT_GEMINI_PROFILES.profiles,
162
- )) {
163
- if (!cfg.profiles[name]) {
164
- cfg.profiles[name] = value;
165
- added++;
166
- }
151
+ for (const [name, { model }] of Object.entries(profiles)) {
152
+ if (!cfg.profiles[name]) added++;
153
+ cfg.profiles[name] = { model };
167
154
  }
168
- if (!cfg.model) cfg.model = DEFAULT_GEMINI_PROFILES.model;
169
-
170
- if (added > 0) {
171
- writeFileSync(profilesPath, JSON.stringify(cfg, null, 2) + "\n", {
172
- encoding: "utf8",
173
- mode: 0o600,
174
- });
175
- }
176
-
155
+ if (!cfg.model || /^Gemini \d+(?:\.\d+)* Flash \(/.test(cfg.model))
156
+ cfg.model = profiles.flash.model;
157
+ const changed = writeModelFile(
158
+ profilesPath,
159
+ `${JSON.stringify(cfg, null, 2)}\n`,
160
+ );
177
161
  return {
178
162
  ok: true,
179
- created: false,
163
+ created,
164
+ changed,
180
165
  added,
181
166
  count: Object.keys(cfg.profiles).length,
182
167
  message: null,
@@ -185,6 +170,7 @@ function ensureGeminiProfiles({
185
170
  return {
186
171
  ok: false,
187
172
  created: false,
173
+ changed: false,
188
174
  added: 0,
189
175
  count: 0,
190
176
  message: error.message,
@@ -0,0 +1,335 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import {
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { resolveTrifluxHome } from "./machine-profile.mjs";
12
+
13
+ export const CODEX_FAMILY_PROFILES = Object.freeze({
14
+ sol_high: { family: "sol", effort: "high", model: "gpt-6.1-sol" },
15
+ sol_med: { family: "sol", effort: "medium", model: "gpt-6.1-sol" },
16
+ astra_xhigh: { family: "astra", effort: "xhigh", model: "gpt-6-astra" },
17
+ astra_max: { family: "astra", effort: "max", model: "gpt-6-astra" },
18
+ astra_ultra: { family: "astra", effort: "ultra", model: "gpt-6-astra" },
19
+ luna_high: { family: "luna", effort: "high", model: "gpt-6-luna" },
20
+ luna_low: { family: "luna", effort: "low", model: "gpt-6-luna" },
21
+ });
22
+ export const FLASH_FAMILY_PROFILES = Object.freeze({
23
+ flash_low: {
24
+ family: "flash",
25
+ effort: "low",
26
+ model: "Gemini 3.8 Flash (Low)",
27
+ },
28
+ flash: {
29
+ family: "flash",
30
+ effort: "medium",
31
+ model: "Gemini 3.8 Flash (Medium)",
32
+ },
33
+ flash_high: {
34
+ family: "flash",
35
+ effort: "high",
36
+ model: "Gemini 3.8 Flash (High)",
37
+ },
38
+ });
39
+ const DAY_MS = 86_400_000;
40
+ const CODEX_SLUG = /^gpt-(\d+(?:\.\d+)*)-(sol|astra|luna)$/;
41
+ const FLASH_SLUG = /^gemini-(\d+(?:\.\d+)*)-flash-(low|medium|high)$/;
42
+
43
+ function compareVersions(a, b) {
44
+ const left = a.split(".").map(Number);
45
+ const right = b.split(".").map(Number);
46
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
47
+ const difference = (left[i] || 0) - (right[i] || 0);
48
+ if (difference) return difference;
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ export function selectCodexModel(models, family, effort) {
54
+ return (
55
+ (models || [])
56
+ .filter((item) => {
57
+ const match = CODEX_SLUG.exec(item?.slug);
58
+ return (
59
+ match?.[2] === family &&
60
+ item.visibility === "list" &&
61
+ Array.isArray(item.supported_reasoning_levels) &&
62
+ item.supported_reasoning_levels.some(
63
+ (level) => (level?.effort ?? level) === effort,
64
+ )
65
+ );
66
+ })
67
+ .sort((a, b) =>
68
+ compareVersions(CODEX_SLUG.exec(b.slug)[1], CODEX_SLUG.exec(a.slug)[1]),
69
+ )[0] || null
70
+ );
71
+ }
72
+
73
+ export function parseAgyModels(output) {
74
+ return String(output)
75
+ .split(/\r?\n/)
76
+ .flatMap((line) => {
77
+ const [slug, model] = line.trim().split("\t");
78
+ return FLASH_SLUG.test(slug) && model?.trim()
79
+ ? [{ slug, model: model.trim() }]
80
+ : [];
81
+ });
82
+ }
83
+
84
+ export function selectFlashModel(models, effort) {
85
+ return (
86
+ (models || [])
87
+ .filter(
88
+ (item) =>
89
+ FLASH_SLUG.exec(item?.slug)?.[2] === effort &&
90
+ typeof item.model === "string",
91
+ )
92
+ .sort((a, b) =>
93
+ compareVersions(FLASH_SLUG.exec(b.slug)[1], FLASH_SLUG.exec(a.slug)[1]),
94
+ )[0] || null
95
+ );
96
+ }
97
+
98
+ export function modelFamilyPaths(opts = {}) {
99
+ const env = opts.env || process.env;
100
+ const platform = opts.platform || process.platform;
101
+ const home = opts.home || resolveTrifluxHome({ env, platform });
102
+ const stateDir =
103
+ opts.stateDir ||
104
+ join(
105
+ platform === "win32"
106
+ ? env.APPDATA || join(home, "AppData", "Roaming")
107
+ : env.XDG_CONFIG_HOME || join(home, ".config"),
108
+ "triflux",
109
+ );
110
+ return {
111
+ home,
112
+ stateDir,
113
+ codexHome:
114
+ opts.codexHome ||
115
+ env.CODEX_HOME ||
116
+ env.TFX_CODEX_HOME ||
117
+ join(home, ".codex"),
118
+ pinsPath: join(stateDir, "models.json"),
119
+ };
120
+ }
121
+
122
+ function readJson(path) {
123
+ try {
124
+ const value = JSON.parse(readFileSync(path, "utf8"));
125
+ return value && typeof value === "object" && !Array.isArray(value)
126
+ ? value
127
+ : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function signature(path) {
134
+ try {
135
+ const stat = statSync(path);
136
+ return `${path}:${stat.ino}:${stat.mtimeMs}:${stat.size}`;
137
+ } catch {
138
+ return `${path}:missing`;
139
+ }
140
+ }
141
+
142
+ export function writeModelFile(path, content) {
143
+ try {
144
+ if (readFileSync(path, "utf8") === content) return false;
145
+ } catch {}
146
+ mkdirSync(dirname(path), { recursive: true });
147
+ const temp = `${path}.${process.pid}.tmp`;
148
+ writeFileSync(temp, content, { encoding: "utf8", mode: 0o600 });
149
+ renameSync(temp, path);
150
+ return true;
151
+ }
152
+
153
+ function saveJson(path, value) {
154
+ try {
155
+ writeModelFile(path, `${JSON.stringify(value)}\n`);
156
+ } catch {
157
+ // 읽기 전용 상태 디렉터리여도 이번 실행의 해석 결과는 쓸 수 있다.
158
+ }
159
+ }
160
+
161
+ const codexCatalogs = new Map();
162
+ export function readCodexModelCatalog(opts = {}) {
163
+ const path = join(modelFamilyPaths(opts).codexHome, "models_cache.json");
164
+ const key = signature(path);
165
+ let cached = codexCatalogs.get(path);
166
+ if (cached?.key !== key) {
167
+ const value = readJson(path);
168
+ cached = { key, catalog: Array.isArray(value?.models) ? value : null };
169
+ codexCatalogs.set(path, cached);
170
+ }
171
+ return cached.catalog;
172
+ }
173
+
174
+ export function resolveModelFamilies(provider, opts = {}) {
175
+ const paths = modelFamilyPaths(opts);
176
+ const definitions =
177
+ provider === "codex" ? CODEX_FAMILY_PROFILES : FLASH_FAMILY_PROFILES;
178
+ const recordPath = join(paths.stateDir, `${provider}-model-resolution.json`);
179
+ const previous = readJson(recordPath) || {};
180
+ const now = (opts.now || Date.now)();
181
+ const fetchedAt = new Date(now).toISOString();
182
+ const run =
183
+ opts.runCommand ||
184
+ ((command, args) =>
185
+ execFileSync(command, args, {
186
+ encoding: "utf8",
187
+ timeout: 10_000,
188
+ stdio: ["ignore", "pipe", "ignore"],
189
+ env: {
190
+ ...(opts.env || process.env),
191
+ HOME: paths.home,
192
+ CODEX_HOME: paths.codexHome,
193
+ },
194
+ maxBuffer: 4 * 1024 * 1024,
195
+ }));
196
+ let catalog = null;
197
+ let sourceSignature;
198
+ let checkedAt = previous.checked_at;
199
+ if (provider === "codex") {
200
+ const cachePath = join(paths.codexHome, "models_cache.json");
201
+ sourceSignature = signature(cachePath);
202
+ if (sourceSignature !== previous.sourceSignature)
203
+ catalog = readCodexModelCatalog(opts);
204
+ if (
205
+ sourceSignature.endsWith(":missing") &&
206
+ opts.allowCommand !== false &&
207
+ (!checkedAt || now - Date.parse(checkedAt) >= DAY_MS)
208
+ ) {
209
+ checkedAt = fetchedAt;
210
+ try {
211
+ const parsed = JSON.parse(
212
+ run(opts.command || "codex", ["debug", "models"]),
213
+ );
214
+ if (Array.isArray(parsed?.models))
215
+ catalog = { ...parsed, fetched_at: parsed.fetched_at || fetchedAt };
216
+ } catch {}
217
+ }
218
+ } else {
219
+ const cachePath = join(paths.stateDir, "agy-models.json");
220
+ let cached = readJson(cachePath) || {};
221
+ if (
222
+ opts.allowCommand !== false &&
223
+ (!cached.checked_at || now - Date.parse(cached.checked_at) >= DAY_MS)
224
+ ) {
225
+ const lock = `${cachePath}.lock`;
226
+ let acquired = false;
227
+ try {
228
+ if (opts.persist !== false) {
229
+ mkdirSync(paths.stateDir, { recursive: true });
230
+ // 프로세스가 끊긴 잠금만 회수한다. CLI 조회 제한은 10초다.
231
+ try {
232
+ if (now - statSync(lock).mtimeMs > 60_000)
233
+ rmSync(lock, { recursive: true, force: true });
234
+ } catch {}
235
+ mkdirSync(lock);
236
+ acquired = true;
237
+ cached = readJson(cachePath) || cached;
238
+ }
239
+ if (
240
+ !cached.checked_at ||
241
+ now - Date.parse(cached.checked_at) >= DAY_MS
242
+ ) {
243
+ cached.checked_at = fetchedAt;
244
+ try {
245
+ const models = parseAgyModels(
246
+ run(opts.command || "agy", ["models"]),
247
+ );
248
+ if (models.length)
249
+ cached = { models, fetched_at: fetchedAt, checked_at: fetchedAt };
250
+ } catch {}
251
+ if (opts.persist !== false) saveJson(cachePath, cached);
252
+ }
253
+ } catch {
254
+ // 다른 디스패치가 갱신 중이면 기존 결과로 실행한다.
255
+ } finally {
256
+ if (acquired) rmSync(lock, { recursive: true, force: true });
257
+ }
258
+ }
259
+ sourceSignature = signature(cachePath);
260
+ checkedAt = cached.checked_at;
261
+ if (Array.isArray(cached.models)) catalog = cached;
262
+ }
263
+ const profiles = {};
264
+ const selectedProfiles = {};
265
+ const pins = readJson(paths.pinsPath) || {};
266
+ for (const [name, definition] of Object.entries(definitions)) {
267
+ const saved = previous.profiles?.[name];
268
+ const last =
269
+ typeof saved?.model === "string" && saved.effort === definition.effort
270
+ ? saved
271
+ : null;
272
+ const candidate =
273
+ provider === "codex"
274
+ ? selectCodexModel(
275
+ catalog?.models,
276
+ definition.family,
277
+ definition.effort,
278
+ )
279
+ : selectFlashModel(catalog?.models, definition.effort);
280
+ profiles[name] =
281
+ candidate &&
282
+ (!last?.fetched_at ||
283
+ !catalog.fetched_at ||
284
+ Date.parse(catalog.fetched_at) >= Date.parse(last.fetched_at))
285
+ ? {
286
+ model: provider === "codex" ? candidate.slug : candidate.model,
287
+ effort: definition.effort,
288
+ fetched_at: catalog.fetched_at || fetchedAt,
289
+ }
290
+ : last || {
291
+ model: definition.model,
292
+ effort: definition.effort,
293
+ fetched_at: null,
294
+ };
295
+ selectedProfiles[name] = { ...profiles[name] };
296
+ const pin = pins[definition.family];
297
+ if (
298
+ typeof pin === "string" &&
299
+ provider === "codex" &&
300
+ CODEX_SLUG.exec(pin)?.[2] === definition.family
301
+ ) {
302
+ selectedProfiles[name] = {
303
+ model: pin,
304
+ effort: definition.effort,
305
+ source: paths.pinsPath,
306
+ fetched_at: null,
307
+ };
308
+ } else if (
309
+ typeof pin === "string" &&
310
+ provider === "agy" &&
311
+ /^gemini-\d+(?:\.\d+)*-flash$/.test(pin)
312
+ ) {
313
+ const slug = `${pin}-${definition.effort}`;
314
+ const version = pin.slice(7, -6);
315
+ const effort =
316
+ definition.effort[0].toUpperCase() + definition.effort.slice(1);
317
+ selectedProfiles[name] = {
318
+ model:
319
+ catalog?.models?.find((item) => item.slug === slug)?.model ||
320
+ `Gemini ${version} Flash (${effort})`,
321
+ effort: definition.effort,
322
+ source: paths.pinsPath,
323
+ fetched_at: null,
324
+ };
325
+ }
326
+ }
327
+ const record = {
328
+ sourceSignature,
329
+ checked_at: checkedAt || null,
330
+ profiles,
331
+ selectedProfiles,
332
+ };
333
+ if (opts.persist !== false) saveJson(recordPath, record);
334
+ return { profiles: selectedProfiles };
335
+ }