@triflux/remote 10.42.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.
@@ -4,168 +4,21 @@
4
4
  // 산출한다. 실제 spawn 은 Phase 1 에서 이관된다 (현재는 tfx-route.sh 가 수행).
5
5
  //
6
6
  // 진입점: tfx-route.mjs → selectAdapter("codex") → plan({...}).
7
- // 출처: scripts/tfx-route.sh route_agent() L1001-L1079 case 문 미러.
7
+ // 출처: agent-route-policy.mjs 의 canonical Codex role policy.
8
+ // timeoutSec은 lane 예상 소요 시간(advisory)이다. hard ceiling 집행은 Bash route가 소유한다.
8
9
 
9
10
  export const id = "codex";
10
11
  export const cliType = "codex";
11
12
  export const command = "codex";
12
13
 
13
- const AGENT_PROFILES = {
14
- executor: {
15
- profile: "gpt55_high",
16
- timeoutSec: 1080,
17
- runMode: "fg",
18
- opusOversight: false,
19
- mcpHint: "implement",
20
- },
21
- codex: {
22
- profile: "gpt55_high",
23
- timeoutSec: 1080,
24
- runMode: "fg",
25
- opusOversight: false,
26
- mcpHint: "implement",
27
- },
28
- "build-fixer": {
29
- profile: "gpt55_low",
30
- timeoutSec: 540,
31
- runMode: "fg",
32
- opusOversight: false,
33
- mcpHint: "implement",
34
- },
35
- cleanup: {
36
- profile: "gpt55_med",
37
- timeoutSec: 540,
38
- runMode: "fg",
39
- opusOversight: false,
40
- mcpHint: "implement",
41
- },
42
- deslop: {
43
- profile: "gpt55_med",
44
- timeoutSec: 540,
45
- runMode: "fg",
46
- opusOversight: false,
47
- mcpHint: "implement",
48
- },
49
- debugger: {
50
- profile: "gpt55_xhigh",
51
- timeoutSec: 900,
52
- runMode: "bg",
53
- opusOversight: false,
54
- mcpHint: "implement",
55
- },
56
- "deep-executor": {
57
- profile: "gpt55_xhigh",
58
- timeoutSec: 3600,
59
- runMode: "bg",
60
- opusOversight: true,
61
- mcpHint: "implement",
62
- },
63
- architect: {
64
- profile: "gpt55_xhigh",
65
- timeoutSec: 3600,
66
- runMode: "bg",
67
- opusOversight: true,
68
- mcpHint: "analyze",
69
- },
70
- critic: {
71
- profile: "gpt55_xhigh",
72
- timeoutSec: 3600,
73
- runMode: "bg",
74
- opusOversight: true,
75
- mcpHint: "analyze",
76
- },
77
- planner: {
78
- profile: "gpt55_xhigh",
79
- timeoutSec: 3600,
80
- runMode: "fg",
81
- opusOversight: true,
82
- mcpHint: "analyze",
83
- },
84
- analyst: {
85
- profile: "gpt55_xhigh",
86
- timeoutSec: 3600,
87
- runMode: "fg",
88
- opusOversight: true,
89
- mcpHint: "analyze",
90
- },
91
- "code-reviewer": {
92
- profile: "gpt55_high",
93
- timeoutSec: 1800,
94
- runMode: "bg",
95
- opusOversight: false,
96
- mcpHint: "review",
97
- subcommand: "review",
98
- },
99
- "quality-reviewer": {
100
- profile: "gpt55_high",
101
- timeoutSec: 1800,
102
- runMode: "bg",
103
- opusOversight: false,
104
- mcpHint: "review",
105
- subcommand: "review",
106
- },
107
- "security-reviewer": {
108
- profile: "gpt55_xhigh",
109
- timeoutSec: 1800,
110
- runMode: "bg",
111
- opusOversight: true,
112
- mcpHint: "review",
113
- subcommand: "review",
114
- },
115
- scientist: {
116
- profile: "gpt55_high",
117
- timeoutSec: 1440,
118
- runMode: "bg",
119
- opusOversight: false,
120
- mcpHint: "analyze",
121
- },
122
- "document-specialist": {
123
- profile: "gpt55_high",
124
- timeoutSec: 1440,
125
- runMode: "bg",
126
- opusOversight: false,
127
- mcpHint: "analyze",
128
- },
129
- "scientist-deep": {
130
- profile: "gpt55_xhigh",
131
- timeoutSec: 3600,
132
- runMode: "bg",
133
- opusOversight: false,
134
- mcpHint: "analyze",
135
- },
136
- verifier: {
137
- profile: "gpt55_high",
138
- timeoutSec: 1200,
139
- runMode: "fg",
140
- opusOversight: false,
141
- mcpHint: "review",
142
- subcommand: "review",
143
- },
144
- "test-engineer": {
145
- profile: "gpt55_high",
146
- timeoutSec: 1200,
147
- runMode: "bg",
148
- opusOversight: false,
149
- mcpHint: "implement",
150
- },
151
- "qa-tester": {
152
- profile: "gpt55_high",
153
- timeoutSec: 1200,
154
- runMode: "bg",
155
- opusOversight: false,
156
- mcpHint: "review",
157
- subcommand: "review",
158
- },
159
- spark: {
160
- profile: "gpt55_low",
161
- timeoutSec: 180,
162
- runMode: "fg",
163
- opusOversight: false,
164
- mcpHint: "implement",
165
- },
166
- };
14
+ import {
15
+ CODEX_AGENT_POLICY,
16
+ resolveCodexAgentPolicy,
17
+ resolveCodexAgentProfile,
18
+ resolveNestedCodexAgentProfile,
19
+ } from "./agent-route-policy.mjs";
167
20
 
168
- const DEFAULT_PROFILE = AGENT_PROFILES.executor;
21
+ export { resolveCodexAgentProfile, resolveNestedCodexAgentProfile };
169
22
 
170
23
  export function plan({
171
24
  agent,
@@ -173,18 +26,26 @@ export function plan({
173
26
  mcpProfile = "auto",
174
27
  timeoutSec,
175
28
  contextFile,
29
+ profileOverride = "auto",
30
+ retryProfile = null,
31
+ nested = false,
176
32
  } = {}) {
177
33
  if (!agent) {
178
34
  throw new Error("[cli-codex] agent required");
179
35
  }
180
- const cfg = AGENT_PROFILES[agent] ?? DEFAULT_PROFILE;
36
+ const cfg = resolveCodexAgentPolicy(agent);
37
+ const profile = resolveCodexAgentProfile(agent, {
38
+ profileOverride,
39
+ retryProfile,
40
+ nested,
41
+ });
181
42
  const effectiveTimeoutSec =
182
43
  Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : cfg.timeoutSec;
183
44
  return {
184
45
  command,
185
46
  subcommand: cfg.subcommand ?? "exec",
186
- profile: cfg.profile,
187
- effort: cfg.profile,
47
+ profile,
48
+ effort: profile,
188
49
  timeoutMs: effectiveTimeoutSec * 1000,
189
50
  runMode: cfg.runMode,
190
51
  opusOversight: cfg.opusOversight,
@@ -195,5 +56,5 @@ export function plan({
195
56
  }
196
57
 
197
58
  export function describe() {
198
- return { id, cliType, command, agents: Object.keys(AGENT_PROFILES) };
59
+ return { id, cliType, command, agents: Object.keys(CODEX_AGENT_POLICY) };
199
60
  }
@@ -9,6 +9,8 @@ import { homedir } from "node:os";
9
9
  import { dirname, join } from "node:path";
10
10
 
11
11
  const EFFORT_BY_SUFFIX = {
12
+ ultra: "ultra",
13
+ max: "max",
12
14
  xhigh: "xhigh",
13
15
  high: "high",
14
16
  med: "medium",
@@ -16,6 +18,15 @@ const EFFORT_BY_SUFFIX = {
16
18
  low: "low",
17
19
  };
18
20
 
21
+ const CANONICAL_PROFILE_VALUES = Object.freeze({
22
+ gpt56_luna_low: { model: "gpt-5.6-luna", effort: "low" },
23
+ gpt56_terra_med: { model: "gpt-5.6-terra", effort: "medium" },
24
+ gpt56_terra_high: { model: "gpt-5.6-terra", effort: "high" },
25
+ gpt56_sol_xhigh: { model: "gpt-5.6-sol", effort: "xhigh" },
26
+ gpt56_sol_max: { model: "gpt-5.6-sol", effort: "max" },
27
+ gpt56_sol_ultra: { model: "gpt-5.6-sol", effort: "ultra" },
28
+ });
29
+
19
30
  function readProfileScalar(raw, key) {
20
31
  const match = String(raw).match(
21
32
  new RegExp(
@@ -29,7 +40,7 @@ function readProfileScalar(raw, key) {
29
40
  }
30
41
 
31
42
  /**
32
- * Resolve a Codex effort profile name (e.g. "gpt55_high") to the `-c key=value`
43
+ * Resolve a Codex effort profile name (e.g. "gpt56_terra_high") to the `-c key=value`
33
44
  * config overrides that `codex exec` accepts, so the headless CLI lanes can
34
45
  * select a profile WITHOUT `codex exec --profile <name>`. codex 0.134+ rejects
35
46
  * `--profile X` whenever config.toml still contains an inline `[profiles.X]`
@@ -47,10 +58,12 @@ function readProfileScalar(raw, key) {
47
58
  *
48
59
  * @param {string} profileName
49
60
  * @param {{ codexHome?: string }} [opts]
50
- * @returns {string[]} e.g. ['model="gpt-5.5"', 'model_reasoning_effort="high"']
61
+ * @returns {string[]} e.g. ['model="gpt-5.6-terra"', 'model_reasoning_effort="high"']
51
62
  */
52
- export function codexProfileConfigOverrides(profileName, opts = {}) {
53
- if (typeof profileName !== "string" || !profileName) return [];
63
+ export function resolveCodexProfileConfigValues(profileName, opts = {}) {
64
+ if (typeof profileName !== "string" || !profileName) {
65
+ return { model: null, effort: null };
66
+ }
54
67
  const codexHome =
55
68
  opts.codexHome || process.env.CODEX_HOME || join(homedir(), ".codex");
56
69
  let model = null;
@@ -63,9 +76,22 @@ export function codexProfileConfigOverrides(profileName, opts = {}) {
63
76
  model = readProfileScalar(raw, "model");
64
77
  effort = readProfileScalar(raw, "model_reasoning_effort");
65
78
  } catch {
66
- const suffix = /_(xhigh|high|med|medium|low)$/.exec(profileName);
79
+ const suffix = /_(ultra|max|xhigh|high|med|medium|low)$/.exec(profileName);
67
80
  if (suffix) effort = EFFORT_BY_SUFFIX[suffix[1]] || null;
68
81
  }
82
+ return { model, effort };
83
+ }
84
+
85
+ export function codexProfileConfigOverrides(profileName, opts = {}) {
86
+ let { model, effort } = resolveCodexProfileConfigValues(profileName, opts);
87
+ if (opts.enforceCanonicalProfile === true) {
88
+ const canonical = CANONICAL_PROFILE_VALUES[profileName];
89
+ if (canonical) ({ model, effort } = canonical);
90
+ }
91
+ if (opts.disallowUltra === true && effort?.toLowerCase() === "ultra") {
92
+ model = "gpt-5.6-sol";
93
+ effort = "max";
94
+ }
69
95
  const overrides = [];
70
96
  if (model) overrides.push(`model="${model}"`);
71
97
  if (effort) overrides.push(`model_reasoning_effort="${effort}"`);
@@ -26,6 +26,7 @@ function normalizeState(state) {
26
26
 
27
27
  function normalizeRule(rule) {
28
28
  if (!rule || typeof rule !== "object") return null;
29
+ if (rule.disabled === true) return null;
29
30
  if (typeof rule.id !== "string" || !rule.id.trim()) return null;
30
31
  if (!Array.isArray(rule.patterns) || rule.patterns.length === 0) return null;
31
32
  if (typeof rule.priority !== "number" || !Number.isFinite(rule.priority))
@@ -61,6 +62,13 @@ function normalizeRule(rule) {
61
62
  const state = normalizeState(rule.state);
62
63
  if (rule.state != null && state == null) return null;
63
64
 
65
+ // repo_scope: path segment 화이트리스트 (빈 배열 = 전역). explicit: 종결 라우팅 마커.
66
+ const repoScope = Array.isArray(rule.repo_scope)
67
+ ? rule.repo_scope
68
+ .filter((s) => typeof s === "string" && s.trim())
69
+ .map((s) => s.trim())
70
+ : [];
71
+
64
72
  return {
65
73
  id: rule.id.trim(),
66
74
  patterns,
@@ -72,6 +80,8 @@ function normalizeRule(rule) {
72
80
  exclusive: rule.exclusive === true,
73
81
  state,
74
82
  mcp_route: mcpRoute,
83
+ repo_scope: repoScope,
84
+ explicit: rule.explicit === true,
75
85
  };
76
86
  }
77
87
 
@@ -149,6 +159,8 @@ export function matchRules(compiledRules, cleanText) {
149
159
  exclusive: rule.exclusive === true,
150
160
  state: rule.state || null,
151
161
  mcp_route: rule.mcp_route || null,
162
+ repo_scope: rule.repo_scope || [],
163
+ explicit: rule.explicit === true,
152
164
  });
153
165
  }
154
166
 
package/cto/steward.mjs DELETED
@@ -1,362 +0,0 @@
1
- // cto/steward.mjs — collect + hygiene 주기 루프 (resident-cto-manager PRD T2 선행 슬라이스)
2
- // 가드레일: TFX_CTO kill-switch 매 cycle 확인 + watch 루프는 lake당 단일 인스턴스 락.
3
- import { utimesSync } from "node:fs";
4
- import { join } from "node:path";
5
-
6
- import { isCtoDisabled } from "@triflux/core/hub/lib/cto-env.mjs";
7
- import { runCollect } from "./collect.mjs";
8
- import { acquireCtoHygieneStewardLock, runHygiene } from "./hygiene.mjs";
9
- import { resolveLakeRootDir } from "./lake-root.mjs";
10
-
11
- const SCHEMA_VERSION = "cto-steward.v1";
12
- const DEFAULT_INTERVAL_MS = 60_000;
13
- const LOOP_LOCK_STALE_MS = 10 * 60_000;
14
- const KNOWN_FLAGS_WITH_VALUES = new Set(["--max-runs", "--interval-ms"]);
15
- const KNOWN_BOOLEAN_FLAGS = new Set([
16
- "--watch",
17
- "--apply",
18
- "--dry-run",
19
- "--json",
20
- "--no-collect",
21
- ]);
22
-
23
- function writeJson(stdout, payload) {
24
- stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
25
- }
26
-
27
- function parsePositiveInteger(name, value) {
28
- if (value === undefined || String(value).startsWith("--")) {
29
- throw new Error(`tfx cto steward ${name} requires a value`);
30
- }
31
- if (!/^\d+$/u.test(String(value))) {
32
- throw new Error(`tfx cto steward ${name} must be a positive integer`);
33
- }
34
- const parsed = Number(value);
35
- if (!Number.isSafeInteger(parsed) || parsed <= 0) {
36
- throw new Error(`tfx cto steward ${name} must be a positive integer`);
37
- }
38
- return parsed;
39
- }
40
-
41
- function parseNonNegativeInteger(name, value) {
42
- if (value === undefined || String(value).startsWith("--")) {
43
- throw new Error(`tfx cto steward ${name} requires a value`);
44
- }
45
- if (!/^\d+$/u.test(String(value))) {
46
- throw new Error(`tfx cto steward ${name} must be a non-negative integer`);
47
- }
48
- const parsed = Number(value);
49
- if (!Number.isSafeInteger(parsed) || parsed < 0) {
50
- throw new Error(`tfx cto steward ${name} must be a non-negative integer`);
51
- }
52
- return parsed;
53
- }
54
-
55
- export function parseStewardArgs(args = []) {
56
- const parsed = {
57
- watch: false,
58
- maxRuns: null,
59
- intervalMs: DEFAULT_INTERVAL_MS,
60
- apply: false,
61
- dryRun: false,
62
- json: false,
63
- collectEnabled: true,
64
- };
65
-
66
- for (let index = 0; index < args.length; index += 1) {
67
- const arg = args[index];
68
- if (KNOWN_FLAGS_WITH_VALUES.has(arg)) {
69
- const value = args[index + 1];
70
- if (arg === "--max-runs")
71
- parsed.maxRuns = parsePositiveInteger(arg, value);
72
- else parsed.intervalMs = parseNonNegativeInteger(arg, value);
73
- index += 1;
74
- continue;
75
- }
76
- if (KNOWN_BOOLEAN_FLAGS.has(arg)) {
77
- if (arg === "--watch") parsed.watch = true;
78
- else if (arg === "--apply") parsed.apply = true;
79
- else if (arg === "--dry-run") parsed.dryRun = true;
80
- else if (arg === "--json") parsed.json = true;
81
- else if (arg === "--no-collect") parsed.collectEnabled = false;
82
- continue;
83
- }
84
- throw new Error(`unknown tfx cto steward flag: ${arg}`);
85
- }
86
-
87
- if (parsed.apply && parsed.dryRun) {
88
- throw new Error("tfx cto steward accepts only one of --apply or --dry-run");
89
- }
90
- return parsed;
91
- }
92
-
93
- function createSilentStdout() {
94
- return {
95
- write() {
96
- return true;
97
- },
98
- };
99
- }
100
-
101
- function compactRun(index, collectResult, hygieneResult) {
102
- return {
103
- index,
104
- collected: collectResult !== null,
105
- collect: collectResult,
106
- hygiene: hygieneResult,
107
- };
108
- }
109
-
110
- function isAbortError(error) {
111
- return error?.name === "AbortError" || error?.code === "ABORT_ERR";
112
- }
113
-
114
- export function stewardLoopLockPath(lakeRoot) {
115
- return join(lakeRoot, "stewards", "steward-loop.lock");
116
- }
117
-
118
- export async function runSteward(args = [], opts = {}) {
119
- const parsed = parseStewardArgs(args);
120
- const jsonRequested = parsed.json || opts.json === true;
121
- if (parsed.watch && parsed.maxRuns === null && jsonRequested) {
122
- throw new Error(
123
- "tfx cto steward --watch --json requires --max-runs for a finite JSON summary",
124
- );
125
- }
126
- if (!parsed.watch && parsed.maxRuns !== null) {
127
- (opts.stderr || process.stderr).write(
128
- "tfx cto steward: --max-runs is ignored without --watch\n",
129
- );
130
- }
131
-
132
- // steward 전용 opts 는 여기서 걸러내고, 나머지(overlay, synapseReader 등)만
133
- // collect/hygiene 로 전달한다.
134
- const {
135
- collectFn: injectedCollectFn,
136
- hygieneFn: injectedHygieneFn,
137
- sleepFn: injectedSleepFn,
138
- onRun,
139
- json: _jsonOpt,
140
- stdout: injectedStdout,
141
- stderr: _stderrOpt,
142
- signal: externalSignal,
143
- isCtoDisabledFn: injectedGate,
144
- acquireLoopLockFn: injectedAcquireLoopLock,
145
- installSignalHandler,
146
- rootDir: _rootDirOpt,
147
- lakeRoot: _lakeRootOpt,
148
- ...innerOpts
149
- } = opts;
150
-
151
- const rootDir = opts.rootDir || resolveLakeRootDir(process.cwd());
152
- const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
153
- const stdout = injectedStdout || process.stdout;
154
- const collectFn = injectedCollectFn || runCollect;
155
- const hygieneFn = injectedHygieneFn || runHygiene;
156
- const ctoDisabled = injectedGate || isCtoDisabled;
157
-
158
- const controller = new AbortController();
159
- const signal = controller.signal;
160
- if (externalSignal) {
161
- if (externalSignal.aborted) controller.abort();
162
- else
163
- externalSignal.addEventListener("abort", () => controller.abort(), {
164
- once: true,
165
- });
166
- }
167
-
168
- const sleepFn =
169
- injectedSleepFn ||
170
- ((ms) =>
171
- new Promise((resolve) => {
172
- if (signal.aborted) {
173
- resolve();
174
- return;
175
- }
176
- const timer = setTimeout(resolve, ms);
177
- signal.addEventListener(
178
- "abort",
179
- () => {
180
- clearTimeout(timer);
181
- resolve();
182
- },
183
- { once: true },
184
- );
185
- }));
186
-
187
- const mode = parsed.apply ? "apply" : "dry-run";
188
- const silentStdout = createSilentStdout();
189
- const runLimit = parsed.watch ? parsed.maxRuns : 1;
190
- const retainRuns = runLimit !== null;
191
- const runs = [];
192
- let latestRun = null;
193
- let lastCollect = null;
194
- let lastHygiene = null;
195
- let runCount = 0;
196
- let stoppedReason = null;
197
-
198
- // 가드레일 1 (PRD kill-switch): 루프 진입 전에도, 매 cycle에도 확인한다.
199
- if (ctoDisabled()) stoppedReason = "disabled";
200
-
201
- // 가드레일 2 (PRD single-instance): watch 루프는 lake당 하나만 돈다.
202
- const loopLockPath = stewardLoopLockPath(lakeRoot);
203
- const loopLockStaleMs = Math.max(LOOP_LOCK_STALE_MS, parsed.intervalMs * 3);
204
- let loopLock = null;
205
- let loopLockHeartbeat = null;
206
- if (!stoppedReason && parsed.watch) {
207
- const acquireLock =
208
- injectedAcquireLoopLock ||
209
- ((lockPath) =>
210
- acquireCtoHygieneStewardLock(rootDir, lakeRoot, {
211
- lockPath,
212
- timeoutMs: 0,
213
- staleMs: loopLockStaleMs,
214
- }));
215
- loopLock = await acquireLock(loopLockPath);
216
- // 한 cycle이 staleMs보다 길어져도 다른 인스턴스가 락을 stale로 오판해
217
- // 훔치지 못하도록, 작업 진행 중에도 주기적으로 mtime을 갱신한다.
218
- if (loopLock?.path) {
219
- const heartbeatMs = Math.max(30_000, Math.floor(loopLockStaleMs / 3));
220
- loopLockHeartbeat = setInterval(() => {
221
- try {
222
- const touch = new Date();
223
- utimesSync(loopLock.path, touch, touch);
224
- } catch {}
225
- }, heartbeatMs);
226
- loopLockHeartbeat.unref?.();
227
- }
228
- }
229
-
230
- let sigintHandler = null;
231
- if (!stoppedReason && parsed.watch && installSignalHandler !== false) {
232
- sigintHandler = () => controller.abort();
233
- process.once("SIGINT", sigintHandler);
234
- }
235
-
236
- try {
237
- while (!stoppedReason && (runLimit === null || runCount < runLimit)) {
238
- if (ctoDisabled()) {
239
- stoppedReason = "disabled";
240
- break;
241
- }
242
- if (signal.aborted) {
243
- stoppedReason = "signal";
244
- break;
245
- }
246
-
247
- const runIndex = runCount + 1;
248
- let collectResult = null;
249
-
250
- if (parsed.collectEnabled) {
251
- collectResult = await collectFn([], {
252
- ...innerOpts,
253
- rootDir,
254
- lakeRoot,
255
- stdout: silentStdout,
256
- });
257
- lastCollect = collectResult;
258
- }
259
-
260
- // collect 도중 중단/kill-switch가 들어왔으면 이번 cycle의 hygiene(특히
261
- // apply)을 시작하지 않는다 — 중단 요청 이후의 fs-mutation 금지.
262
- if (ctoDisabled()) {
263
- stoppedReason = "disabled";
264
- break;
265
- }
266
- if (signal.aborted) {
267
- stoppedReason = "signal";
268
- break;
269
- }
270
-
271
- const hygieneArgs = parsed.apply
272
- ? ["--apply", "--json"]
273
- : ["--dry-run", "--json"];
274
- const hygieneResult = await hygieneFn(hygieneArgs, {
275
- ...innerOpts,
276
- rootDir,
277
- lakeRoot,
278
- stdout: silentStdout,
279
- apply: parsed.apply,
280
- dryRun: !parsed.apply,
281
- json: true,
282
- });
283
- lastHygiene = hygieneResult;
284
-
285
- const run = compactRun(runIndex, collectResult, hygieneResult);
286
- latestRun = run;
287
- if (retainRuns) runs.push(run);
288
- runCount += 1;
289
-
290
- if (parsed.watch && !jsonRequested) {
291
- stdout.write(
292
- `cto steward run ${runIndex} (${mode}): collect=${
293
- collectResult !== null ? "ok" : "skipped"
294
- } hygiene=ok\n`,
295
- );
296
- }
297
- if (typeof onRun === "function") {
298
- await onRun(run, {
299
- run_count: runCount,
300
- rootDir,
301
- lakeRoot,
302
- mode,
303
- });
304
- }
305
-
306
- if (!parsed.watch) break;
307
- if (runLimit !== null && runCount >= runLimit) break;
308
- try {
309
- await sleepFn(parsed.intervalMs);
310
- } catch (error) {
311
- if (isAbortError(error)) {
312
- stoppedReason = "signal";
313
- break;
314
- }
315
- throw error;
316
- }
317
- if (signal.aborted) {
318
- stoppedReason = "signal";
319
- break;
320
- }
321
- }
322
- } finally {
323
- if (sigintHandler) process.off("SIGINT", sigintHandler);
324
- if (loopLockHeartbeat) clearInterval(loopLockHeartbeat);
325
- loopLock?.release?.();
326
- }
327
-
328
- if (!stoppedReason) stoppedReason = parsed.watch ? "max-runs" : "single-run";
329
-
330
- const summary = {
331
- schema_version: SCHEMA_VERSION,
332
- mode,
333
- watch: parsed.watch,
334
- interval_ms: parsed.intervalMs,
335
- max_runs: parsed.maxRuns,
336
- collect_enabled: parsed.collectEnabled,
337
- disabled: stoppedReason === "disabled",
338
- stopped_reason: stoppedReason,
339
- loop_lock_path: parsed.watch ? loopLockPath : null,
340
- run_count: runCount,
341
- retained_run_count: runs.length,
342
- runs,
343
- latest_run: latestRun,
344
- last_collect: lastCollect,
345
- last_hygiene: lastHygiene,
346
- };
347
-
348
- if (jsonRequested) writeJson(stdout, summary);
349
- else if (stoppedReason === "disabled") {
350
- stdout.write(
351
- `cto steward disabled by TFX_CTO kill-switch after ${runCount} run(s)\n`,
352
- );
353
- } else {
354
- stdout.write(
355
- `cto steward ${mode}: ${runCount} run(s), collect ${
356
- parsed.collectEnabled ? "enabled" : "disabled"
357
- }, stopped=${stoppedReason}\n`,
358
- );
359
- }
360
-
361
- return summary;
362
- }