triflux 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.
Files changed (73) hide show
  1. package/CLAUDE.md +233 -0
  2. package/bin/tfx-live.mjs +3 -1
  3. package/bin/triflux.mjs +48 -1
  4. package/config/routing-policy.json +1 -1
  5. package/cto/hygiene-actions.mjs +355 -0
  6. package/cto/hygiene.mjs +79 -9
  7. package/cto/index.mjs +14 -2
  8. package/cto/steward.mjs +362 -0
  9. package/hooks/keyword-rules.json +39 -5
  10. package/hooks/pipeline-stop.mjs +1 -1
  11. package/hub/bridge.mjs +80 -1
  12. package/hub/cli-adapter-base.mjs +100 -19
  13. package/hub/codex-adapter.mjs +5 -1
  14. package/hub/dynamic-routing-engine.mjs +1 -1
  15. package/hub/gemini-adapter.mjs +3 -1
  16. package/hub/intent.mjs +13 -9
  17. package/hub/lib/cto-env.mjs +94 -0
  18. package/hub/lib/memory-store.mjs +34 -13
  19. package/hub/lib/timeout-defaults.mjs +45 -0
  20. package/hub/lib/worker-lifecycle.mjs +101 -0
  21. package/hub/log-retention.mjs +726 -0
  22. package/hub/middleware/request-logger.mjs +4 -1
  23. package/hub/pipe.mjs +11 -1
  24. package/hub/public/tray.html +50 -53
  25. package/hub/role-contract.mjs +314 -0
  26. package/hub/role-control-events.mjs +113 -0
  27. package/hub/router.mjs +135 -30
  28. package/hub/schema.sql +1 -0
  29. package/hub/server.mjs +89 -44
  30. package/hub/store.mjs +50 -19
  31. package/hub/team/claude-daemon-control.mjs +28 -15
  32. package/hub/team/cli/commands/start/parse-args.mjs +2 -2
  33. package/hub/team/headless.mjs +295 -24
  34. package/hub/team/intervention.mjs +530 -0
  35. package/hub/team/retry-state-machine.mjs +6 -2
  36. package/hub/team/swarm-hypervisor.mjs +20 -2
  37. package/hub/team/swarm-locks.mjs +17 -1
  38. package/hub/team/uds-orchestrator.mjs +62 -9
  39. package/hub/tools.mjs +9 -7
  40. package/hub/workers/codex-app-server-worker.mjs +54 -5
  41. package/hub/workers/codex-mcp.mjs +1 -1
  42. package/hub/workers/delegator-mcp.mjs +18 -18
  43. package/hud/constants.mjs +21 -0
  44. package/hud/context-monitor.mjs +18 -21
  45. package/hud/hud-qos-status.mjs +1 -1
  46. package/hud/providers/codex-probe.mjs +216 -0
  47. package/hud/providers/codex.mjs +206 -29
  48. package/hud/renderers.mjs +6 -9
  49. package/package.json +1 -1
  50. package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +1 -1
  51. package/scripts/__tests__/tfx-route-node-entry.test.mjs +6 -6
  52. package/scripts/headless-guard.mjs +70 -23
  53. package/scripts/lib/cli-agy.mjs +1 -0
  54. package/scripts/lib/cli-claude.mjs +1 -0
  55. package/scripts/lib/cli-codex.mjs +22 -21
  56. package/scripts/lib/codex-profile-config.mjs +2 -2
  57. package/scripts/pack.mjs +1 -0
  58. package/scripts/setup.mjs +15 -11
  59. package/scripts/tfx-gate-activate.mjs +1 -1
  60. package/scripts/tfx-route-worker.mjs +5 -0
  61. package/scripts/tfx-route.sh +136 -84
  62. package/skills/tfx-analysis/SKILL.md +3 -1
  63. package/skills/tfx-harness/SKILL.md +89 -0
  64. package/skills/tfx-hub/SKILL.md +1 -1
  65. package/skills/tfx-plan/SKILL.md +3 -1
  66. package/skills/tfx-profile/SKILL.md +9 -9
  67. package/skills/tfx-prune/SKILL.md +4 -2
  68. package/skills/tfx-qa/SKILL.md +6 -2
  69. package/skills/tfx-research/SKILL.md +3 -1
  70. package/skills/tfx-review/SKILL.md +5 -3
  71. package/skills/tfx-setup/SKILL.md +1 -1
  72. package/tui/codex-profile.mjs +3 -1
  73. package/tui/setup.mjs +4 -4
package/cto/hygiene.mjs CHANGED
@@ -11,8 +11,9 @@ import {
11
11
  } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { basename, dirname, join } from "node:path";
14
-
14
+ import { getCtoHygieneApplyMode } from "../hub/lib/cto-env.mjs";
15
15
  import { appendCtoEvent } from "./events.mjs";
16
+ import { applyHygieneArchiveActions } from "./hygiene-actions.mjs";
16
17
  import { resolveLakeRootDir } from "./lake-root.mjs";
17
18
 
18
19
  const COUNT_KEYS = [
@@ -178,6 +179,18 @@ function rowFrom(kind, id, status, entry, ref, action) {
178
179
  if (entry?.summary) row.summary = String(entry.summary);
179
180
  if (action) row.action = action;
180
181
  if (action && !hasKnownOwner(ref)) row.owner = "unknown";
182
+ if (typeof ref?.artifact_path === "string" && ref.artifact_path.trim()) {
183
+ row.artifact_path = ref.artifact_path.trim();
184
+ }
185
+ if (
186
+ typeof ref?.project_root_hash === "string" &&
187
+ ref.project_root_hash.trim()
188
+ ) {
189
+ row.project_root_hash = ref.project_root_hash.trim();
190
+ }
191
+ if (typeof ref?.session_id === "string" && ref.session_id.trim()) {
192
+ row.session_id = ref.session_id.trim();
193
+ }
181
194
  return row;
182
195
  }
183
196
 
@@ -517,17 +530,52 @@ function isApplicableRow(row) {
517
530
  return Boolean(row?.action) || APPLICABLE_STATUSES.has(row?.status);
518
531
  }
519
532
 
533
+ const ARCHIVE_ACK_STATUSES = new Set(["archived", "already_archived"]);
534
+
535
+ function hygieneApplyActor(opts = {}) {
536
+ const actor = { cli: "tfx cto hygiene --apply" };
537
+ if (typeof opts.actorSessionId === "string" && opts.actorSessionId.trim()) {
538
+ actor.session_id = opts.actorSessionId.trim();
539
+ }
540
+ return actor;
541
+ }
542
+
520
543
  async function applyHygieneRows({
521
544
  rootDir,
522
545
  lakeRoot,
523
546
  projection,
524
547
  steward,
548
+ active,
549
+ approvals,
525
550
  opts,
526
551
  }) {
527
552
  const rows = projection.rows.filter(isApplicableRow);
528
553
  const appended = [];
529
554
  const now = opts.now || new Date().toISOString();
555
+ const actions = await applyHygieneArchiveActions({
556
+ rootDir,
557
+ lakeRoot,
558
+ projection: { ...projection, rows },
559
+ dryRun: false,
560
+ now,
561
+ applyMode: opts.applyMode || getCtoHygieneApplyMode(),
562
+ active,
563
+ humanAck: opts.humanAck,
564
+ humanGate: opts.humanGate,
565
+ actorSessionId: opts.actorSessionId,
566
+ approvals,
567
+ fsOps: opts.fsOps,
568
+ });
569
+ const operationByKey = new Map(
570
+ (actions.operations || []).map((operation) => [
571
+ operation.hygiene_key,
572
+ operation,
573
+ ]),
574
+ );
530
575
  for (const row of rows) {
576
+ const hygieneKey = hygieneKeyForRow(row);
577
+ const operation = operationByKey.get(hygieneKey);
578
+ if (operation && !ARCHIVE_ACK_STATUSES.has(operation.status)) continue;
531
579
  const result = await appendCtoEvent(
532
580
  lakeRoot,
533
581
  {
@@ -536,12 +584,12 @@ async function applyHygieneRows({
536
584
  source: "tfx_cto_hygiene_apply",
537
585
  project_root: rootDir,
538
586
  status: row.status,
539
- hygiene_key: hygieneKeyForRow(row),
587
+ hygiene_key: hygieneKey,
540
588
  hygiene_kind: row.kind,
541
589
  hygiene_id: row.id,
542
590
  hygiene_action: row.action || `acknowledge_${row.status}`,
543
591
  summary: `hygiene apply ${row.kind}:${row.id} ${row.status}`,
544
- actor: { cli: "tfx cto hygiene --apply" },
592
+ actor: hygieneApplyActor(opts),
545
593
  },
546
594
  {
547
595
  stderr: opts.stderr,
@@ -559,6 +607,7 @@ async function applyHygieneRows({
559
607
  },
560
608
  applicable_count: rows.length,
561
609
  applied_count: appended.length,
610
+ actions,
562
611
  events: appended,
563
612
  };
564
613
  }
@@ -582,15 +631,21 @@ export async function runHygiene(args = [], opts = {}) {
582
631
  const current = existsSync(currentPath) ? readJson(currentPath) : {};
583
632
  const ledger = readJsonLines(join(lakeRoot, "ledger.jsonl"));
584
633
  const overlay = await readLiveOverlay({ ...opts, rootDir, lakeRoot });
585
- return projectCtoHygiene({
586
- current,
587
- ledger: ledger.length > 0 ? ledger : null,
634
+ return {
635
+ ledger,
588
636
  overlay,
589
- });
637
+ projection: projectCtoHygiene({
638
+ current,
639
+ ledger: ledger.length > 0 ? ledger : null,
640
+ overlay,
641
+ }),
642
+ };
590
643
  };
591
644
 
592
645
  let steward = null;
593
646
  let projection;
647
+ let overlay = { live_sessions: [] };
648
+ let ledger = [];
594
649
  let applyResult = null;
595
650
  if (apply) {
596
651
  steward = await acquireCtoHygieneStewardLock(rootDir, lakeRoot, {
@@ -600,12 +655,14 @@ export async function runHygiene(args = [], opts = {}) {
600
655
  lockPath: opts.stewardLockPath,
601
656
  });
602
657
  try {
603
- projection = await buildProjection();
658
+ ({ projection, overlay, ledger } = await buildProjection());
604
659
  applyResult = await applyHygieneRows({
605
660
  rootDir,
606
661
  lakeRoot,
607
662
  projection,
608
663
  steward,
664
+ active: overlay,
665
+ approvals: ledger.filter((entry) => entry?.event === "hygiene_applied"),
609
666
  opts,
610
667
  });
611
668
  projection = { ...projection, dry_run: false, apply: applyResult };
@@ -613,7 +670,20 @@ export async function runHygiene(args = [], opts = {}) {
613
670
  steward.release();
614
671
  }
615
672
  } else {
616
- projection = await buildProjection();
673
+ ({ projection, overlay } = await buildProjection());
674
+ projection = {
675
+ ...projection,
676
+ actions: await applyHygieneArchiveActions({
677
+ rootDir,
678
+ lakeRoot,
679
+ projection,
680
+ dryRun: true,
681
+ now: opts.now || new Date().toISOString(),
682
+ applyMode: opts.applyMode || getCtoHygieneApplyMode(),
683
+ active: overlay,
684
+ fsOps: opts.fsOps,
685
+ }),
686
+ };
617
687
  }
618
688
 
619
689
  if (jsonOut) writeJson(stdout, projection);
package/cto/index.mjs CHANGED
@@ -1,4 +1,11 @@
1
- const SUBCOMMANDS = ["collect", "status", "dashboard", "hygiene", "event"];
1
+ const SUBCOMMANDS = [
2
+ "collect",
3
+ "status",
4
+ "dashboard",
5
+ "hygiene",
6
+ "steward",
7
+ "event",
8
+ ];
2
9
 
3
10
  function printUsage(subcommand) {
4
11
  if (subcommand) {
@@ -6,13 +13,14 @@ function printUsage(subcommand) {
6
13
  }
7
14
  console.log(`
8
15
  Usage
9
- tfx cto <collect|status|dashboard|hygiene|event> [options]
16
+ tfx cto <collect|status|dashboard|hygiene|steward|event> [options]
10
17
 
11
18
  Subcommands
12
19
  collect Refresh .triflux/lake/current.json from repo-local authority sources
13
20
  status Print the current authority summary
14
21
  dashboard Render the CTO console dashboard, optionally with --watch
15
22
  hygiene Project CTO hygiene counts and actionable dry-run rows
23
+ steward Periodically collect and run one-shot CTO hygiene (TFX_CTO=0 disables)
16
24
  event Append normalized wrapper lifecycle events to the CTO ledger
17
25
  `);
18
26
  }
@@ -37,6 +45,10 @@ export async function cmdCto(cmdArgs, opts = {}) {
37
45
  const { runHygiene } = await import("./hygiene.mjs");
38
46
  return runHygiene(rest, opts);
39
47
  }
48
+ case "steward": {
49
+ const { runSteward } = await import("./steward.mjs");
50
+ return runSteward(rest, opts);
51
+ }
40
52
  case "event": {
41
53
  const { runEvent } = await import("./events.mjs");
42
54
  return runEvent(rest, opts);
@@ -0,0 +1,362 @@
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 "../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
+ }
@@ -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);