@triflux/core 10.41.1 → 10.43.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.
@@ -62,6 +62,40 @@
62
62
  "state": null,
63
63
  "mcp_route": null
64
64
  },
65
+ {
66
+ "id": "tfx-harness",
67
+ "patterns": [
68
+ {
69
+ "source": "\\btfx[\\s-]?harness\\b",
70
+ "flags": "i"
71
+ }
72
+ ],
73
+ "skill": "tfx-harness",
74
+ "priority": 1,
75
+ "supersedes": ["tfx-unified"],
76
+ "exclusive": false,
77
+ "state": null,
78
+ "mcp_route": null
79
+ },
80
+ {
81
+ "id": "tfx-harness-meta",
82
+ "patterns": [
83
+ {
84
+ "source": "(?:어떤\\s*스킬|스킬\\s*추천|스킬\\s*라우팅|작업\\s*라우팅|메타\\s*라우팅)",
85
+ "flags": "i"
86
+ },
87
+ {
88
+ "source": "\\bwhich\\s+skill\\b",
89
+ "flags": "i"
90
+ }
91
+ ],
92
+ "skill": "tfx-harness",
93
+ "priority": 2,
94
+ "supersedes": ["tfx-unified"],
95
+ "exclusive": false,
96
+ "state": null,
97
+ "mcp_route": null
98
+ },
65
99
  {
66
100
  "id": "tfx-unified",
67
101
  "patterns": [
@@ -160,7 +194,7 @@
160
194
  }
161
195
  ],
162
196
  "skill": "tfx-hub",
163
- "priority": 2,
197
+ "priority": 1,
164
198
  "supersedes": [],
165
199
  "exclusive": false,
166
200
  "state": null,
@@ -175,7 +209,7 @@
175
209
  }
176
210
  ],
177
211
  "skill": "tfx-doctor",
178
- "priority": 2,
212
+ "priority": 1,
179
213
  "supersedes": [],
180
214
  "exclusive": false,
181
215
  "state": null,
@@ -190,7 +224,7 @@
190
224
  }
191
225
  ],
192
226
  "skill": "tfx-setup",
193
- "priority": 2,
227
+ "priority": 1,
194
228
  "supersedes": [],
195
229
  "exclusive": false,
196
230
  "state": null,
@@ -205,7 +239,7 @@
205
239
  }
206
240
  ],
207
241
  "skill": "tfx-research",
208
- "priority": 2,
242
+ "priority": 1,
209
243
  "supersedes": [],
210
244
  "exclusive": false,
211
245
  "state": null,
@@ -220,7 +254,7 @@
220
254
  }
221
255
  ],
222
256
  "skill": "tfx-interview",
223
- "priority": 2,
257
+ "priority": 1,
224
258
  "supersedes": [],
225
259
  "exclusive": false,
226
260
  "state": null,
@@ -152,7 +152,7 @@ function contextPercentsFromObject(value) {
152
152
  value.total_tokens ??
153
153
  value.totalTokens;
154
154
  // Fall back to a 1M context window when the payload omits one — Opus 4.x
155
- // [1M] and Codex gpt-5.5 both run on a 1M window, and assuming 200K there
155
+ // [1M] and Codex GPT-5.6 family run on a 1M window, and assuming 200K there
156
156
  // false-positives at ~15% real usage. Override via
157
157
  // TFX_CONTEXT_DEFAULT_MAX_TOKENS for narrower setups.
158
158
  const envFallback = Number(process.env.TFX_CONTEXT_DEFAULT_MAX_TOKENS);
package/hub/bridge.mjs CHANGED
@@ -99,6 +99,11 @@ const HUB_OPERATIONS = Object.freeze({
99
99
  action: "register",
100
100
  httpPath: "/bridge/register",
101
101
  },
102
+ heartbeat: {
103
+ transport: "command",
104
+ action: "heartbeat",
105
+ httpPath: "/bridge/heartbeat",
106
+ },
102
107
  result: {
103
108
  transport: "command",
104
109
  action: "result",
@@ -637,6 +642,14 @@ async function cmdRegister(args) {
637
642
  return emitJson(result || unavailableResult());
638
643
  }
639
644
 
645
+ async function cmdHeartbeat(args) {
646
+ const outcome = await requestHub(HUB_OPERATIONS.heartbeat, {
647
+ agent_id: args.agent,
648
+ ttl_ms: args["ttl-ms"] != null ? Number(args["ttl-ms"]) : undefined,
649
+ });
650
+ return emitJson(outcome?.result || unavailableResult());
651
+ }
652
+
640
653
  async function cmdResult(args) {
641
654
  let output = "";
642
655
  if (args.file && existsSync(args.file)) {
@@ -1559,6 +1572,68 @@ async function cmdRetryStatus(args) {
1559
1572
  return true;
1560
1573
  }
1561
1574
 
1575
+ // intervene-run은 hub transport를 거치지 않는다. heartbeat가 hub 장애 중에도
1576
+ // process/daemon/pane 사다리를 실행할 수 있도록 순수 로컬 모듈만 호출한다.
1577
+ async function cmdInterveneRun(args) {
1578
+ try {
1579
+ const payload = readBridgePayload(args);
1580
+ const intervention = await import("./team/intervention.mjs");
1581
+ let rolloutFile = payload.rolloutFile || null;
1582
+ if (!rolloutFile && payload.cli === "codex" && payload.pid) {
1583
+ rolloutFile = await intervention.resolveCodexRolloutFile({
1584
+ pid: numericOption(payload.pid, undefined),
1585
+ codexHome: payload.codexHome,
1586
+ });
1587
+ }
1588
+ const seconds = (value) => {
1589
+ const parsed = numericOption(value, undefined);
1590
+ return parsed > 0 ? parsed * 1_000 : undefined;
1591
+ };
1592
+ const readActivitySignature = intervention.createFileActivitySource({
1593
+ files: [payload.stdoutLog, payload.stderrLog, payload.resultFile].filter(
1594
+ Boolean,
1595
+ ),
1596
+ rolloutFile,
1597
+ });
1598
+ const ladder = intervention.createInterventionLadder({
1599
+ target: {
1600
+ channel: payload.channel,
1601
+ pid: numericOption(payload.pid, undefined),
1602
+ paneId: payload.paneId,
1603
+ interactive: payload.interactive === true,
1604
+ cli: payload.cli,
1605
+ sessionId: payload.sessionId,
1606
+ codexHome: payload.codexHome,
1607
+ rolloutFile,
1608
+ daemon: payload.daemon,
1609
+ },
1610
+ reinstructPrompt: payload.reinstructPrompt || undefined,
1611
+ readActivitySignature,
1612
+ config: {
1613
+ ...(seconds(payload.reinstructWaitSec)
1614
+ ? { reinstructWaitMs: seconds(payload.reinstructWaitSec) }
1615
+ : {}),
1616
+ ...(seconds(payload.resumeWaitSec)
1617
+ ? { resumeWaitMs: seconds(payload.resumeWaitSec) }
1618
+ : {}),
1619
+ ...(seconds(payload.sigtermGraceSec)
1620
+ ? { sigtermGraceMs: seconds(payload.sigtermGraceSec) }
1621
+ : {}),
1622
+ },
1623
+ log: (event) =>
1624
+ process.stderr.write(`[tfx-intervene] ${JSON.stringify(event)}\n`),
1625
+ });
1626
+ return emitJson(await ladder.intervene());
1627
+ } catch (error) {
1628
+ return emitJson({
1629
+ ok: false,
1630
+ outcome: "failed",
1631
+ step: null,
1632
+ error: error?.message || String(error),
1633
+ });
1634
+ }
1635
+ }
1636
+
1562
1637
  export async function main(argv = process.argv.slice(2)) {
1563
1638
  const cmd = argv[0];
1564
1639
  const args = parseArgs(argv.slice(1));
@@ -1566,6 +1641,8 @@ export async function main(argv = process.argv.slice(2)) {
1566
1641
  switch (cmd) {
1567
1642
  case "register":
1568
1643
  return await cmdRegister(args);
1644
+ case "heartbeat":
1645
+ return await cmdHeartbeat(args);
1569
1646
  case "result":
1570
1647
  return await cmdResult(args);
1571
1648
  case "control":
@@ -1630,9 +1707,11 @@ export async function main(argv = process.argv.slice(2)) {
1630
1707
  return await cmdRetryRun(args);
1631
1708
  case "retry-status":
1632
1709
  return await cmdRetryStatus(args);
1710
+ case "intervene-run":
1711
+ return await cmdInterveneRun(args);
1633
1712
  default:
1634
1713
  console.error(
1635
- "사용법: bridge.mjs <register|result|control|handoff|publish|takeover-role|send-input|context|deregister|assign-async|assign-result|assign-status|assign-retry|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|pipeline-init|pipeline-list|ping|delegator-delegate|delegator-reply|delegator-status|hitl-request|hitl-submit|hitl-pending|daemon-probe|daemon-attach|daemon-interrupt|retry-run|retry-status> [--옵션]",
1714
+ "사용법: bridge.mjs <register|heartbeat|result|control|handoff|publish|takeover-role|send-input|context|deregister|assign-async|assign-result|assign-status|assign-retry|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|pipeline-init|pipeline-list|ping|delegator-delegate|delegator-reply|delegator-status|hitl-request|hitl-submit|hitl-pending|daemon-probe|daemon-attach|daemon-interrupt|retry-run|retry-status|intervene-run> [--옵션]",
1636
1715
  );
1637
1716
  process.exit(1);
1638
1717
  }
@@ -6,7 +6,14 @@ import { existsSync, readFileSync, statSync } from "node:fs";
6
6
 
7
7
  import { codexProfileConfigOverrides } from "../scripts/lib/codex-profile-config.mjs";
8
8
  import { writePromptToTmpFile } from "./lib/prompt-tmp.mjs";
9
+ import {
10
+ createActivityLifecycle,
11
+ isActivityLifecycleEnabled,
12
+ resolveHardCeilingMs,
13
+ resolveStallInterventionMs,
14
+ } from "./lib/worker-lifecycle.mjs";
9
15
  import { IS_WINDOWS, killProcess } from "./platform.mjs";
16
+ import { createInterventionLadder } from "./team/intervention.mjs";
10
17
 
11
18
  // ── Quota retry-after 파싱 ──────────────────────────────────────
12
19
 
@@ -397,19 +404,36 @@ export async function terminateChild(pid, opts = {}) {
397
404
  *
398
405
  * @param {string} command — shell command to run
399
406
  * @param {string} workdir — cwd for the child process
400
- * @param {number} timeout — max duration in ms
407
+ * @param {number} timeout — legacy wall-clock duration (TFX_ACTIVITY_LIFECYCLE=0 only)
401
408
  * @param {object} [opts]
402
409
  * @param {string} [opts.resultFile] — file to read output from (if CLI writes there)
403
410
  * @param {function} [opts.inferStallMode] — (stdout, stderr) => string. Default: () => 'timeout'
404
411
  * @param {number} [opts.stallCheckIntervalMs] — stall check interval (default 10_000)
405
- * @param {number} [opts.stallThresholdMs] — stall threshold (default 30_000)
412
+ * @param {number} [opts.stallThresholdMs] — inactivity threshold
413
+ * @param {number} [opts.hardCeilingMs] — activity-independent maximum duration
414
+ * @param {(context: object) => Promise<boolean|string>|boolean|string} [opts.onStallIntervene]
415
+ * T5 intervention seam; true or "resolved" keeps the process alive
416
+ * @param {object} [opts.deps] — clock/process/timer overrides for deterministic tests
406
417
  * @returns {Promise<object>} createResult-shaped object
407
418
  */
408
419
  export async function runProcess(command, workdir, timeout, opts = {}) {
409
- const startedAt = Date.now();
420
+ const deps = opts.deps || {};
421
+ const now = deps.now || Date.now;
422
+ const spawnProcess = deps.spawn || spawn;
423
+ const scheduleTimeout = deps.setTimeout || setTimeout;
424
+ const cancelTimeout = deps.clearTimeout || clearTimeout;
425
+ const scheduleInterval = deps.setInterval || setInterval;
426
+ const cancelInterval = deps.clearInterval || clearInterval;
427
+ const terminateProcess = deps.terminateChild || terminateChild;
428
+ const startedAt = now();
410
429
  const inferStallMode = opts.inferStallMode || (() => "timeout");
411
430
  const stallCheckIntervalMs = opts.stallCheckIntervalMs ?? 10_000;
412
- const stallThresholdMs = opts.stallThresholdMs ?? 30_000;
431
+ const legacyLifecycle = !isActivityLifecycleEnabled();
432
+ const stallThresholdMs =
433
+ opts.stallThresholdMs ??
434
+ (legacyLifecycle ? 30_000 : resolveStallInterventionMs());
435
+ const hardCeilingMs = opts.hardCeilingMs ?? resolveHardCeilingMs();
436
+ const earlyClassifyMs = opts.earlyClassifyMs ?? 30_000;
413
437
  const resultFile = opts.resultFile || null;
414
438
 
415
439
  let stdout = "";
@@ -421,7 +445,7 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
421
445
  try {
422
446
  // PRD A1: opts.spawnEnv 가 있으면 그 env 로 spawn (lease 의 authFile/env 적용).
423
447
  // undefined 면 spawn 의 default 동작 (부모 process env inherit) 유지.
424
- child = spawn(command, {
448
+ child = spawnProcess(command, {
425
449
  cwd: workdir,
426
450
  shell: true,
427
451
  windowsHide: true,
@@ -430,7 +454,7 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
430
454
  } catch (error) {
431
455
  return createResult(false, {
432
456
  stderr: String(error?.message || error),
433
- duration: Date.now() - startedAt,
457
+ duration: now() - startedAt,
434
458
  });
435
459
  }
436
460
 
@@ -446,9 +470,40 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
446
470
 
447
471
  let lastBytes = 0;
448
472
  let lastResultFileSignature = resultFileSignature();
449
- let lastChange = Date.now();
473
+ let lastChange = now();
474
+ const activitySignature = () =>
475
+ `${Buffer.byteLength(stdout) + Buffer.byteLength(stderr)}:${resultFileSignature()}`;
476
+ let ladder = null;
477
+ const lifecycle = createActivityLifecycle({
478
+ enabled: !legacyLifecycle,
479
+ interventionMs: stallThresholdMs,
480
+ hardCeilingMs,
481
+ now,
482
+ onIntervene: async (context) => {
483
+ if (typeof opts.onStallIntervene === "function") {
484
+ const result = await opts.onStallIntervene(context);
485
+ return result === true || result === "resolved";
486
+ }
487
+ ladder ??= createInterventionLadder({
488
+ target: {
489
+ pid: child.pid,
490
+ cli: opts.cli,
491
+ codexHome: opts.codexHome,
492
+ rolloutFile: opts.rolloutFile,
493
+ },
494
+ readActivitySignature: activitySignature,
495
+ deps:
496
+ typeof opts.resumeHandler === "function"
497
+ ? { resumeHandler: opts.resumeHandler }
498
+ : {},
499
+ });
500
+ const result = await ladder.intervene();
501
+ return result?.outcome === "reactivated" || result?.outcome === "resumed";
502
+ },
503
+ });
450
504
  const touch = () => {
451
- lastChange = Date.now();
505
+ lastChange = now();
506
+ lifecycle.observe(activitySignature());
452
507
  };
453
508
  child.stdout?.on("data", (chunk) => {
454
509
  stdout += String(chunk);
@@ -466,13 +521,15 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
466
521
  const stopFor = async (mode) => {
467
522
  if (failureMode) return;
468
523
  failureMode = mode;
469
- await terminateChild(child.pid);
524
+ await terminateProcess(child.pid);
470
525
  };
471
526
 
472
- const timeoutTimer = setTimeout(() => {
473
- void stopFor("timeout");
474
- }, timeout);
475
- const stallTimer = setInterval(() => {
527
+ const timeoutTimer = legacyLifecycle
528
+ ? scheduleTimeout(() => {
529
+ void stopFor("timeout");
530
+ }, timeout)
531
+ : null;
532
+ const stallTimer = scheduleInterval(() => {
476
533
  const size = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
477
534
  const currentResultFileSignature = resultFileSignature();
478
535
  if (
@@ -482,13 +539,37 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
482
539
  lastBytes = size;
483
540
  lastResultFileSignature = currentResultFileSignature;
484
541
  touch();
542
+ }
543
+ if (legacyLifecycle) {
544
+ if (now() - lastChange >= stallThresholdMs)
545
+ void stopFor(inferStallMode(stdout, stderr));
485
546
  return;
486
547
  }
487
- if (Date.now() - lastChange >= stallThresholdMs)
548
+ const quietMs = now() - lastChange;
549
+ const mode = inferStallMode(stdout, stderr);
550
+ if (
551
+ quietMs >= earlyClassifyMs &&
552
+ (mode === "rate_limited" || mode === "auth_stall")
553
+ ) {
488
554
  void stopFor(inferStallMode(stdout, stderr));
555
+ return;
556
+ }
557
+ void lifecycle
558
+ .check({
559
+ pid: child.pid,
560
+ command,
561
+ workdir,
562
+ mode,
563
+ stdoutTail: stdout.slice(-2000),
564
+ stderrTail: stderr.slice(-2000),
565
+ })
566
+ .then((reason) => {
567
+ if (!reason) return;
568
+ void stopFor(reason === "hard_ceiling" ? "timeout" : mode);
569
+ });
489
570
  }, stallCheckIntervalMs);
490
- timeoutTimer.unref?.();
491
- stallTimer.unref?.();
571
+ timeoutTimer?.unref?.();
572
+ stallTimer?.unref?.();
492
573
 
493
574
  await new Promise((resolve) =>
494
575
  child.on("close", (code) => {
@@ -496,8 +577,8 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
496
577
  resolve();
497
578
  }),
498
579
  );
499
- clearTimeout(timeoutTimer);
500
- clearInterval(stallTimer);
580
+ if (timeoutTimer) cancelTimeout(timeoutTimer);
581
+ cancelInterval(stallTimer);
501
582
 
502
583
  const fileOutput =
503
584
  resultFile && existsSync(resultFile)
@@ -509,7 +590,7 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
509
590
  output,
510
591
  stderr,
511
592
  exitCode,
512
- duration: Date.now() - startedAt,
593
+ duration: now() - startedAt,
513
594
  failureMode: ok ? null : failureMode || "crash",
514
595
  });
515
596
  }
@@ -11,6 +11,7 @@ import {
11
11
  shellQuote,
12
12
  } from "./cli-adapter-base.mjs";
13
13
  import { runPreflight } from "./codex-preflight.mjs";
14
+ import { isActivityLifecycleEnabled } from "./lib/worker-lifecycle.mjs";
14
15
 
15
16
  // ── Codex-specific stall inference ──────────────────────────────
16
17
 
@@ -64,9 +65,10 @@ function buildAttempts(opts, preflight) {
64
65
  forceBypass: preflight.needsBypass,
65
66
  };
66
67
  if (opts.retryOnFail === false) return [base];
68
+ const retryTimeout = isActivityLifecycleEnabled() ? timeout : timeout * 2;
67
69
  return [
68
70
  base,
69
- { ...base, timeout: timeout * 2, excluded: requested, forceBypass: true },
71
+ { ...base, timeout: retryTimeout, excluded: requested, forceBypass: true },
70
72
  ];
71
73
  }
72
74
 
@@ -183,6 +185,8 @@ async function runCodex(prompt, workdir, preflight, attempt, lease) {
183
185
  resultFile,
184
186
  inferStallMode,
185
187
  spawnEnv,
188
+ cli: "codex",
189
+ codexHome: spawnEnv?.CODEX_HOME,
186
190
  });
187
191
  }
188
192
 
@@ -45,7 +45,7 @@ function makeFallback(policy, trigger, inputsHash, now) {
45
45
  {
46
46
  cli: fb.default_cli ?? "codex",
47
47
  accountId: null,
48
- model: fb.default_model ?? "gpt-5.5",
48
+ model: fb.default_model ?? "gpt-5.6-terra",
49
49
  riskTier: "safe",
50
50
  reason: trigger,
51
51
  },
@@ -9,6 +9,7 @@ import {
9
9
  normalizePathForShell,
10
10
  runProcess,
11
11
  } from "./cli-adapter-base.mjs";
12
+ import { isActivityLifecycleEnabled } from "./lib/worker-lifecycle.mjs";
12
13
  import { whichCommandAsync } from "./platform.mjs";
13
14
 
14
15
  function shellSingleQuote(value) {
@@ -88,7 +89,8 @@ function buildAttempts(opts, preflight) {
88
89
  excludeMcpServers: [...(preflight.excludeMcpServers || [])],
89
90
  };
90
91
  if (opts.retryOnFail === false) return [base];
91
- return [base, { ...base, timeout: timeout * 2, allowedMcpServers: [] }];
92
+ const retryTimeout = isActivityLifecycleEnabled() ? timeout : timeout * 2;
93
+ return [base, { ...base, timeout: retryTimeout, allowedMcpServers: [] }];
92
94
  }
93
95
 
94
96
  // ── Public: buildExecArgs ───────────────────────────────────────
package/hub/intent.mjs CHANGED
@@ -62,21 +62,25 @@ function _tryCodexClassify(prompt) {
62
62
  */
63
63
  export const INTENT_CATEGORIES = {
64
64
  // 모델 정책 (2026-04-25):
65
- // - gpt-5.5 = 메인 (코드 포함 모든 메인 직무, fast tier 가능)
65
+ // - GPT-5.6 family = 기존 semantic tier를 Sol/Terra/Luna lane으로 매핑
66
66
  // - gpt-5.4-mini = 자잘/부가/가성비 (fast tier 가능)
67
67
  // - gpt-5.3-codex = escalation 가성비 중간 (fast 미지원)
68
- // - gpt-5.4 폐기, gpt-5.5 로 격상
69
- implement: { agent: "executor", mcp: "implement", effort: "gpt55_high" },
70
- debug: { agent: "debugger", mcp: "implement", effort: "gpt55_xhigh" },
71
- analyze: { agent: "analyst", mcp: "analyze", effort: "gpt55_xhigh" },
72
- design: { agent: "architect", mcp: "analyze", effort: "gpt55_xhigh" },
73
- review: { agent: "code-reviewer", mcp: "review", effort: "gpt55_high" },
68
+ // - 기존 effort는 유지하고 모델만 GPT-5.6 family로 교체
69
+ implement: {
70
+ agent: "executor",
71
+ mcp: "implement",
72
+ effort: "gpt56_terra_high",
73
+ },
74
+ debug: { agent: "debugger", mcp: "implement", effort: "gpt56_sol_xhigh" },
75
+ analyze: { agent: "analyst", mcp: "analyze", effort: "gpt56_sol_xhigh" },
76
+ design: { agent: "architect", mcp: "analyze", effort: "gpt56_sol_xhigh" },
77
+ review: { agent: "code-reviewer", mcp: "review", effort: "gpt56_terra_high" },
74
78
  document: { agent: "writer", mcp: "docs", effort: "pro" },
75
- research: { agent: "scientist", mcp: "analyze", effort: "gpt55_high" },
79
+ research: { agent: "scientist", mcp: "analyze", effort: "gpt56_terra_high" },
76
80
  "quick-fix": {
77
81
  agent: "build-fixer",
78
82
  mcp: "implement",
79
- effort: "gpt55_low",
83
+ effort: "gpt56_luna_low",
80
84
  },
81
85
  explain: { agent: "writer", mcp: "docs", effort: "flash" },
82
86
  test: { agent: "test-engineer", mcp: null, effort: null },
@@ -0,0 +1,94 @@
1
+ const OFF_VALUES = new Set(["0", "false", "off", "no"]);
2
+ const ON_VALUES = new Set(["1", "true", "on", "yes"]);
3
+
4
+ function normalizeEnvValue(name) {
5
+ return String(process.env[name] ?? "")
6
+ .trim()
7
+ .toLowerCase();
8
+ }
9
+
10
+ function isExplicitlyOff(name) {
11
+ return OFF_VALUES.has(normalizeEnvValue(name));
12
+ }
13
+
14
+ function isExplicitlyOn(name) {
15
+ return ON_VALUES.has(normalizeEnvValue(name));
16
+ }
17
+
18
+ function normalizeEnv(env, name) {
19
+ return String(env?.[name] ?? "")
20
+ .trim()
21
+ .toLowerCase();
22
+ }
23
+
24
+ function isEnvOff(env, name) {
25
+ return OFF_VALUES.has(normalizeEnv(env, name));
26
+ }
27
+
28
+ function isEnvOn(env, name) {
29
+ return ON_VALUES.has(normalizeEnv(env, name));
30
+ }
31
+
32
+ /**
33
+ * Resolve the C6a control plane without mutating environment or runtime
34
+ * state. Callers own storing and incrementing the returned generation.
35
+ */
36
+ export function resolveRoleControlSnapshot(env = process.env, options = {}) {
37
+ const masterEnabled = !isEnvOff(env, "TFX_CTO");
38
+ const ctoManagerEnabled = masterEnabled && isEnvOn(env, "TFX_CTO_MANAGER");
39
+ const leadManagerEnabled =
40
+ ctoManagerEnabled && isEnvOn(env, "TFX_LEAD_MANAGER");
41
+ const northStarEnabled =
42
+ masterEnabled && !isEnvOff(env, "TFX_CTO_NORTH_STAR");
43
+ const autoCollectEnabled =
44
+ masterEnabled && !isEnvOff(env, "TFX_CTO_AUTO_COLLECT");
45
+ const values = {
46
+ master_enabled: masterEnabled,
47
+ cto_manager_enabled: ctoManagerEnabled,
48
+ lead_manager_enabled: leadManagerEnabled,
49
+ north_star_enabled: northStarEnabled,
50
+ auto_collect_enabled: autoCollectEnabled,
51
+ };
52
+ const previous = options.previous;
53
+ const changed = Object.keys(values).some(
54
+ (field) => previous?.[field] !== values[field],
55
+ );
56
+ const generation = previous
57
+ ? Number(previous.generation || 0) + (changed ? 1 : 0)
58
+ : Number.isSafeInteger(options.generation) && options.generation >= 0
59
+ ? options.generation
60
+ : 0;
61
+ return { generation, ...values };
62
+ }
63
+
64
+ export function isCtoDisabled() {
65
+ return isExplicitlyOff("TFX_CTO");
66
+ }
67
+
68
+ export function isCtoManagerEnabled() {
69
+ if (isCtoDisabled()) return false;
70
+ return isExplicitlyOn("TFX_CTO_MANAGER");
71
+ }
72
+
73
+ export function getCtoHygieneApplyMode() {
74
+ if (isExplicitlyOff("TFX_CTO")) return "off";
75
+ return normalizeEnvValue("TFX_CTO_HYGIENE_APPLY") === "archive"
76
+ ? "archive"
77
+ : "off";
78
+ }
79
+
80
+ export function isCtoRetentionEnabled() {
81
+ if (isExplicitlyOff("TFX_CTO")) return false;
82
+ return isExplicitlyOn("TFX_CTO_RETENTION");
83
+ }
84
+
85
+ export function getCtoMode() {
86
+ return normalizeEnvValue("TFX_CTO_MODE") === "bridge" ? "bridge" : "bounded";
87
+ }
88
+
89
+ export function getCtoMaxTokens() {
90
+ const raw = normalizeEnvValue("TFX_CTO_MAX_TOKENS");
91
+ if (!raw) return 0;
92
+ const value = Number(raw);
93
+ return Number.isSafeInteger(value) && value > 0 ? value : 0;
94
+ }