triflux 10.9.19 → 10.9.21

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 (113) hide show
  1. package/CLAUDE.md +212 -0
  2. package/hub/lib/bash-path.mjs +73 -0
  3. package/hub/team/dashboard-open.mjs +1 -68
  4. package/hub/team/native-supervisor.mjs +9 -2
  5. package/hub/team/psmux.mjs +5 -13
  6. package/hub/team/session.mjs +6 -26
  7. package/hub/team/swarm-hypervisor.mjs +205 -27
  8. package/hub/team/synapse-http.mjs +1 -0
  9. package/hub/team/tui-core.mjs +292 -0
  10. package/hub/team/tui-lite.mjs +20 -154
  11. package/hub/team/tui-synapse.mjs +213 -0
  12. package/hub/team/tui-widgets.mjs +262 -0
  13. package/hub/team/tui.mjs +159 -255
  14. package/hub/workers/delegator-mcp.mjs +2 -2
  15. package/package.json +21 -62
  16. package/references/hosts.json +46 -0
  17. package/scripts/__tests__/keyword-detector.test.mjs +4 -4
  18. package/scripts/cross-review-gate.mjs +13 -0
  19. package/scripts/remote-spawn.mjs +11 -46
  20. package/scripts/session-spawn-helper.mjs +8 -21
  21. package/scripts/test-tfx-route-no-claude-native.mjs +4 -2
  22. package/scripts/tfx-route.sh +13 -0
  23. package/skills/tfx-deep-interview/SKILL.md +6 -6
  24. package/skills/tfx-deep-interview/SKILL.md.tmpl +6 -6
  25. package/skills/tfx-index/SKILL.md +1 -1
  26. package/skills/tfx-index/SKILL.md.tmpl +1 -1
  27. package/skills/tfx-interview/SKILL.md +9 -9
  28. package/skills/tfx-interview/SKILL.md.tmpl +9 -9
  29. package/skills/tfx-plan/SKILL.md +1 -1
  30. package/skills/tfx-plan/SKILL.md.tmpl +1 -1
  31. package/skills/tfx-research/SKILL.md +1 -1
  32. package/skills/tfx-research/SKILL.md.tmpl +1 -1
  33. package/skills/tfx-workspace/async-tests/run-tests.sh +203 -0
  34. package/skills/tfx-workspace/evals/evals.json +79 -0
  35. package/skills/tfx-workspace/iteration-1/benchmark.json +524 -0
  36. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/eval_metadata.json +11 -0
  37. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/grading.json +25 -0
  38. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/outputs/analysis.md +154 -0
  39. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/timing.json +5 -0
  40. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/grading.json +25 -0
  41. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/outputs/analysis.md +126 -0
  42. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/timing.json +5 -0
  43. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/eval_metadata.json +11 -0
  44. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/grading.json +25 -0
  45. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/outputs/analysis.md +119 -0
  46. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/timing.json +5 -0
  47. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/grading.json +25 -0
  48. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/outputs/analysis.md +115 -0
  49. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/timing.json +5 -0
  50. package/skills/tfx-workspace/iteration-1/hub-start-sequence/eval_metadata.json +10 -0
  51. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/grading.json +20 -0
  52. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/outputs/analysis.md +86 -0
  53. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/timing.json +5 -0
  54. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/grading.json +20 -0
  55. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/outputs/analysis.md +81 -0
  56. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/timing.json +5 -0
  57. package/skills/tfx-workspace/iteration-1/multi-team-creation/eval_metadata.json +12 -0
  58. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/grading.json +30 -0
  59. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/outputs/analysis.md +316 -0
  60. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/timing.json +5 -0
  61. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/grading.json +30 -0
  62. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/outputs/analysis.md +352 -0
  63. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/timing.json +5 -0
  64. package/skills/tfx-workspace/iteration-1/review.html +1325 -0
  65. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/eval_metadata.json +12 -0
  66. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/grading.json +30 -0
  67. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/outputs/analysis.md +97 -0
  68. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/timing.json +5 -0
  69. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/grading.json +30 -0
  70. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/outputs/analysis.md +94 -0
  71. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/timing.json +5 -0
  72. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/eval_metadata.json +12 -0
  73. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/grading.json +30 -0
  74. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/outputs/analysis.md +209 -0
  75. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/timing.json +5 -0
  76. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/grading.json +30 -0
  77. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/outputs/analysis.md +193 -0
  78. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/timing.json +5 -0
  79. package/skills/tfx-workspace/iteration-2/benchmark.json +144 -0
  80. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/eval_metadata.json +13 -0
  81. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/grading.json +35 -0
  82. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/outputs/analysis.md +382 -0
  83. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/timing.json +5 -0
  84. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/grading.json +35 -0
  85. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/outputs/analysis.md +333 -0
  86. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/timing.json +5 -0
  87. package/skills/tfx-workspace/iteration-2/review.html +1325 -0
  88. package/skills/tfx-workspace/skill-snapshot/tfx-auto/SKILL.md +217 -0
  89. package/skills/tfx-workspace/skill-snapshot/tfx-auto-codex/SKILL.md +77 -0
  90. package/skills/tfx-workspace/skill-snapshot/tfx-codex/SKILL.md +65 -0
  91. package/skills/tfx-workspace/skill-snapshot/tfx-doctor/SKILL.md +94 -0
  92. package/skills/tfx-workspace/skill-snapshot/tfx-gemini/SKILL.md +82 -0
  93. package/skills/tfx-workspace/skill-snapshot/tfx-hub/SKILL.md +133 -0
  94. package/skills/tfx-workspace/skill-snapshot/tfx-multi/SKILL.md +426 -0
  95. package/skills/tfx-workspace/skill-snapshot/tfx-setup/SKILL.md +101 -0
  96. package/.claude-plugin/marketplace.json +0 -34
  97. package/.claude-plugin/plugin.json +0 -22
  98. package/config/mcp-registry.json +0 -29
  99. package/scripts/__tests__/release-governance.test.mjs +0 -148
  100. package/scripts/release/bump-version.mjs +0 -77
  101. package/scripts/release/check-sync.mjs +0 -51
  102. package/scripts/release/lib.mjs +0 -303
  103. package/scripts/release/prepare.mjs +0 -85
  104. package/scripts/release/publish.mjs +0 -87
  105. package/scripts/release/verify.mjs +0 -81
  106. package/scripts/release/version-manifest.json +0 -26
  107. package/tui/codex-profile.mjs +0 -457
  108. package/tui/core.mjs +0 -266
  109. package/tui/doctor.mjs +0 -375
  110. package/tui/gemini-profile.mjs +0 -299
  111. package/tui/monitor-data.mjs +0 -152
  112. package/tui/monitor.mjs +0 -339
  113. package/tui/setup.mjs +0 -598
@@ -10,6 +10,7 @@
10
10
  // F4: File lease violation → revert worker changes, flag shard as failed
11
11
  // F5: Merge conflict → retry integration with conflict resolution
12
12
 
13
+ import { execFile } from "node:child_process";
13
14
  import { EventEmitter } from "node:events";
14
15
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
15
16
  import { join } from "node:path";
@@ -66,6 +67,7 @@ const FAILURE_MODES = Object.freeze({
66
67
  F3_STALL: "F3_stall",
67
68
  F4_LEASE_VIOLATION: "F4_lease_violation",
68
69
  F5_MERGE_CONFLICT: "F5_merge_conflict",
70
+ F6_NO_COMMIT: "F6_no_commit",
69
71
  });
70
72
 
71
73
  const FALLBACK_AGENTS = Object.freeze({
@@ -249,6 +251,138 @@ export function createSwarmHypervisor(opts) {
249
251
  return integrationResult;
250
252
  }
251
253
 
254
+ function git(args, cwd = workdir) {
255
+ return new Promise((resolve, reject) => {
256
+ execFile(
257
+ "git",
258
+ args,
259
+ { cwd, windowsHide: true, timeout: 30_000 },
260
+ (err, stdout, stderr) => {
261
+ if (err) {
262
+ reject(
263
+ new Error(
264
+ `git ${args[0]} failed: ${stderr?.trim() || err.message}`,
265
+ ),
266
+ );
267
+ return;
268
+ }
269
+ resolve(stdout.trim());
270
+ },
271
+ );
272
+ });
273
+ }
274
+
275
+ function computeAuthoritativeStatus(shardName, workerEntry, sessions) {
276
+ const failureInfo = failures.get(shardName) || null;
277
+ if (failureInfo) {
278
+ return {
279
+ status: "failed",
280
+ reason: failureInfo.mode || failureInfo.reason || "failed",
281
+ };
282
+ }
283
+
284
+ if (results.has(shardName)) {
285
+ return { status: "done", reason: "integrated" };
286
+ }
287
+
288
+ if (!sessions.length) {
289
+ if (completedShards.has(shardName)) {
290
+ return { status: "done", reason: "awaiting_integration" };
291
+ }
292
+ return { status: "running", reason: "no_sessions" };
293
+ }
294
+
295
+ const states = sessions.map((session) => session.state);
296
+ if (states.every((stateValue) => stateValue === STATES.COMPLETED)) {
297
+ return { status: "done", reason: "awaiting_integration" };
298
+ }
299
+ if (states.some((stateValue) => stateValue === STATES.INPUT_WAIT)) {
300
+ return { status: "blocked", reason: "user_input" };
301
+ }
302
+ if (states.some((stateValue) => stateValue === STATES.STALLED)) {
303
+ return { status: "stalled", reason: "health_probe_stall" };
304
+ }
305
+ if (
306
+ states.some(
307
+ (stateValue) =>
308
+ stateValue === STATES.FAILED || stateValue === STATES.DEAD,
309
+ )
310
+ ) {
311
+ return { status: "failed", reason: "session_terminal" };
312
+ }
313
+ if (
314
+ states.some(
315
+ (stateValue) =>
316
+ stateValue === STATES.STARTING ||
317
+ stateValue === STATES.RESTARTING ||
318
+ stateValue === STATES.INIT,
319
+ )
320
+ ) {
321
+ return { status: "running", reason: "starting" };
322
+ }
323
+
324
+ return { status: "running", reason: "healthy" };
325
+ }
326
+
327
+ async function collectCommitEvidence(worker, integrationBranch) {
328
+ const branchName = worker?.branchName || null;
329
+ const evidence = {
330
+ branchName,
331
+ integrationBranch,
332
+ commitsAhead: 0,
333
+ dirty: false,
334
+ dirtyFiles: [],
335
+ headCommit: null,
336
+ ok: false,
337
+ error: null,
338
+ };
339
+
340
+ if (!branchName) {
341
+ evidence.error = "missing_branch_name";
342
+ return evidence;
343
+ }
344
+
345
+ try {
346
+ evidence.commitsAhead =
347
+ Number.parseInt(
348
+ await git([
349
+ "rev-list",
350
+ "--count",
351
+ `${integrationBranch}..${branchName}`,
352
+ ]),
353
+ 10,
354
+ ) || 0;
355
+ } catch (err) {
356
+ evidence.error = err.message;
357
+ return evidence;
358
+ }
359
+
360
+ try {
361
+ evidence.headCommit = await git(["rev-parse", branchName]);
362
+ } catch {
363
+ /* best-effort */
364
+ }
365
+
366
+ if (worker?.worktreePath && !worker?.shardConfig?.host) {
367
+ try {
368
+ const rawStatus = await git(["status", "--short"], worker.worktreePath);
369
+ evidence.dirtyFiles = rawStatus
370
+ .split(/\r?\n/)
371
+ .map((line) => line.trim())
372
+ .filter(Boolean)
373
+ .map((line) => line.slice(2).trim())
374
+ .filter(Boolean);
375
+ evidence.dirty = evidence.dirtyFiles.length > 0;
376
+ } catch (err) {
377
+ evidence.error = evidence.error || err.message;
378
+ return evidence;
379
+ }
380
+ }
381
+
382
+ evidence.ok = evidence.commitsAhead > 0 && evidence.dirty === false;
383
+ return evidence;
384
+ }
385
+
252
386
  // ── Worker lifecycle ────────────────────────────────────────
253
387
 
254
388
  function buildSessionConfig(shard) {
@@ -523,6 +657,7 @@ export function createSwarmHypervisor(opts) {
523
657
  if (/stall|l1_stall|timeout/u.test(r)) return FAILURE_MODES.F3_STALL;
524
658
  if (/lease|violation/u.test(r)) return FAILURE_MODES.F4_LEASE_VIOLATION;
525
659
  if (/merge|conflict/u.test(r)) return FAILURE_MODES.F5_MERGE_CONFLICT;
660
+ if (/no.?commit|dirty_worktree/u.test(r)) return FAILURE_MODES.F6_NO_COMMIT;
526
661
  return FAILURE_MODES.F1_CRASH;
527
662
  }
528
663
 
@@ -603,10 +738,7 @@ export function createSwarmHypervisor(opts) {
603
738
  if (shard?.host && shard._remoteEnv) {
604
739
  const hostConfig = getHostConfig(shard.host, workdir);
605
740
  const sshUser = hostConfig?.ssh_user || shard.host;
606
- const remoteRepoPath = resolveRemoteDir(
607
- workdir,
608
- shard._remoteEnv,
609
- );
741
+ const remoteRepoPath = resolveRemoteDir(workdir, shard._remoteEnv);
610
742
  const fetchResult = await fetchRemoteShard({
611
743
  host: shard.host,
612
744
  sshUser,
@@ -630,6 +762,38 @@ export function createSwarmHypervisor(opts) {
630
762
  });
631
763
  }
632
764
 
765
+ // Read shard output log for changed files
766
+ const commitEvidence = await collectCommitEvidence(
767
+ worker,
768
+ integrationBranch,
769
+ );
770
+ worker.commitEvidence = commitEvidence;
771
+ eventLog.append("commit_evidence", {
772
+ shard: shardName,
773
+ ...commitEvidence,
774
+ });
775
+
776
+ const expectsCommitEvidence =
777
+ Array.isArray(shard?.files) && shard.files.length > 0;
778
+ if (expectsCommitEvidence && !commitEvidence.ok) {
779
+ failures.set(shardName, {
780
+ mode: FAILURE_MODES.F6_NO_COMMIT,
781
+ reason: commitEvidence.error
782
+ ? `no_commit_evidence:${commitEvidence.error}`
783
+ : commitEvidence.dirty
784
+ ? "dirty_worktree_without_commit"
785
+ : "no_commit_evidence",
786
+ commitEvidence,
787
+ });
788
+ eventLog.append("no_commit_guard_failed", {
789
+ shard: shardName,
790
+ ...commitEvidence,
791
+ });
792
+ await maybeCleanupWorktree(shardName, worker, shard);
793
+ integrationFailures.push(shardName);
794
+ continue;
795
+ }
796
+
633
797
  // Read shard output log for changed files
634
798
  const changedFiles = detectChangedFiles(shardName, worker);
635
799
 
@@ -856,6 +1020,7 @@ export function createSwarmHypervisor(opts) {
856
1020
 
857
1021
  for (const [name, w] of workers) {
858
1022
  const snap = w.conductor.getSnapshot();
1023
+ const authoritative = computeAuthoritativeStatus(name, w, snap);
859
1024
  workerStatuses.push({
860
1025
  shard: name,
861
1026
  agent: w.shardConfig.agent,
@@ -863,6 +1028,9 @@ export function createSwarmHypervisor(opts) {
863
1028
  failed: failures.has(name),
864
1029
  failureInfo: failures.get(name) || null,
865
1030
  integrated: results.has(name),
1031
+ authoritativeStatus: authoritative.status,
1032
+ authoritativeReason: authoritative.reason,
1033
+ commitEvidence: w.commitEvidence || null,
866
1034
  });
867
1035
  }
868
1036
 
@@ -901,25 +1069,30 @@ export function createSwarmHypervisor(opts) {
901
1069
  let hubKeepaliveTimer = null;
902
1070
  function startHubKeepalive() {
903
1071
  // 5분마다 Hub /status 핑 (idle timeout 기본 10분)
904
- hubKeepaliveTimer = setInterval(async () => {
905
- try {
906
- const resp = await fetch("http://127.0.0.1:27888/status");
907
- if (!resp.ok && ensureHubAliveFn) {
908
- eventLog.append("hub_keepalive_restart", {});
909
- await ensureHubAliveFn();
910
- }
911
- } catch {
912
- // Hub 다운 — 재시작 시도
913
- if (ensureHubAliveFn) {
914
- eventLog.append("hub_keepalive_restart", { reason: "fetch_failed" });
915
- try {
1072
+ hubKeepaliveTimer = setInterval(
1073
+ async () => {
1074
+ try {
1075
+ const resp = await fetch("http://127.0.0.1:27888/status");
1076
+ if (!resp.ok && ensureHubAliveFn) {
1077
+ eventLog.append("hub_keepalive_restart", {});
916
1078
  await ensureHubAliveFn();
917
- } catch {
918
- eventLog.append("hub_restart_failed", {});
1079
+ }
1080
+ } catch {
1081
+ // Hub 다운 — 재시작 시도
1082
+ if (ensureHubAliveFn) {
1083
+ eventLog.append("hub_keepalive_restart", {
1084
+ reason: "fetch_failed",
1085
+ });
1086
+ try {
1087
+ await ensureHubAliveFn();
1088
+ } catch {
1089
+ eventLog.append("hub_restart_failed", {});
1090
+ }
919
1091
  }
920
1092
  }
921
- }
922
- }, 5 * 60 * 1000);
1093
+ },
1094
+ 5 * 60 * 1000,
1095
+ );
923
1096
  }
924
1097
 
925
1098
  function stopHubKeepalive() {
@@ -939,12 +1112,17 @@ export function createSwarmHypervisor(opts) {
939
1112
 
940
1113
  // Hub alive 확인 — 죽어있으면 재시작
941
1114
  if (ensureHubAliveFn) {
942
- ensureHubAliveFn().then((hub) => {
943
- eventLog.append("hub_ensured", { port: hub?.port });
944
- }).catch((err) => {
945
- eventLog.append("hub_ensure_failed", { error: err.message });
946
- emitter.emit("warning", { type: "hub_unavailable", error: err.message });
947
- });
1115
+ ensureHubAliveFn()
1116
+ .then((hub) => {
1117
+ eventLog.append("hub_ensured", { port: hub?.port });
1118
+ })
1119
+ .catch((err) => {
1120
+ eventLog.append("hub_ensure_failed", { error: err.message });
1121
+ emitter.emit("warning", {
1122
+ type: "hub_unavailable",
1123
+ error: err.message,
1124
+ });
1125
+ });
948
1126
  }
949
1127
 
950
1128
  // Warn about file conflicts but don't block
@@ -1007,7 +1185,7 @@ export function createSwarmHypervisor(opts) {
1007
1185
  `${launched.size} launched, ${pending.size} pending deps`,
1008
1186
  );
1009
1187
 
1010
- return getStatus();
1188
+ return { ...getStatus(), done: integrationPromise };
1011
1189
  }
1012
1190
 
1013
1191
  /**
@@ -1,6 +1,7 @@
1
1
  const DEFAULT_SYNAPSE_BASE_URL = "http://127.0.0.1:27888";
2
2
 
3
3
  function resolveSynapseFetch(fetchImpl) {
4
+ if (fetchImpl === null) return null;
4
5
  if (typeof fetchImpl === "function") return fetchImpl;
5
6
  if (typeof globalThis.fetch === "function") {
6
7
  return globalThis.fetch.bind(globalThis);
@@ -0,0 +1,292 @@
1
+ // hub/team/tui-core.mjs — 대시보드 공통 유틸리티 (ISSUE-11)
2
+ // tui.mjs / tui-lite.mjs 간 중복 로직을 단일 모듈로 통합.
3
+
4
+ import { FG, MOCHA, wcswidth } from "./ansi.mjs";
5
+
6
+ // ── 상수 ──────────────────────────────────────────────────────────────────
7
+ export const FALLBACK_COLUMNS = 100;
8
+ export const FALLBACK_ROWS = 30;
9
+ export const VALID_TABS = Object.freeze(["log", "detail", "files"]);
10
+
11
+ // ── 버전 로드 ─────────────────────────────────────────────────────────────
12
+ let _cachedVersion = null;
13
+ export async function loadVersion(fallback = "7.x") {
14
+ if (_cachedVersion) return _cachedVersion;
15
+ try {
16
+ const { createRequire } = await import("node:module");
17
+ const require = createRequire(import.meta.url);
18
+ _cachedVersion = require("../../package.json").version;
19
+ } catch {
20
+ _cachedVersion = fallback;
21
+ }
22
+ return _cachedVersion;
23
+ }
24
+
25
+ // ── 수학 유틸 ─────────────────────────────────────────────────────────────
26
+ export function clamp(value, min, max) {
27
+ return Math.min(max, Math.max(min, value));
28
+ }
29
+
30
+ // ── 텍스트 정규화 ─────────────────────────────────────────────────────────
31
+ export function stripCodeBlocks(text) {
32
+ return (
33
+ String(text || "")
34
+ .replace(/\r/g, "")
35
+ .replace(/```[\s\S]*?(?:```|$)/g, "\n")
36
+ .replace(/^\s*```.*$/gm, "")
37
+ .replace(/^(?: {4}|\t).+$/gm, "")
38
+ .replace(/^(?:PS\s+\S[^\n]*?>|>\s+|\$\s+)[^\n]*/gm, "")
39
+ .trim()
40
+ );
41
+ }
42
+
43
+ export function sanitizeTextBlock(text, rawMode = false) {
44
+ const normalized = rawMode
45
+ ? String(text || "").replace(/\r/g, "")
46
+ : stripCodeBlocks(text);
47
+ return normalized
48
+ .split("\n")
49
+ .map((line) => line.trim())
50
+ .filter(Boolean)
51
+ .filter((line) => line !== "--- HANDOFF ---")
52
+ .join("\n")
53
+ .trim();
54
+ }
55
+
56
+ export function sanitizeOneLine(text, fallback = "") {
57
+ return sanitizeTextBlock(text).replace(/\s+/g, " ").trim() || fallback;
58
+ }
59
+
60
+ export function sanitizeFiles(files) {
61
+ if (!files) return [];
62
+ const raw = Array.isArray(files) ? files : String(files).split(",");
63
+ return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
64
+ }
65
+
66
+ export function sanitizeFindings(findings) {
67
+ if (!findings) return [];
68
+ const raw = Array.isArray(findings)
69
+ ? findings
70
+ : sanitizeTextBlock(findings).split("\n");
71
+ return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
72
+ }
73
+
74
+ export function normalizeTokens(tokens) {
75
+ if (tokens === null || tokens === undefined || tokens === "") return "";
76
+ if (typeof tokens === "number" && Number.isFinite(tokens)) return tokens;
77
+ const raw = sanitizeOneLine(tokens);
78
+ if (!raw) return "";
79
+ const match = raw.match(/(\d+(?:[.,]\d+)?\s*[kKmM]?)/);
80
+ return match ? match[1].replace(/\s+/g, "").toLowerCase() : raw;
81
+ }
82
+
83
+ export function formatTokens(tokens) {
84
+ if (tokens === null || tokens === undefined || tokens === "") return "n/a";
85
+ if (typeof tokens === "number" && Number.isFinite(tokens)) {
86
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
87
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
88
+ return `${tokens}`;
89
+ }
90
+ return String(tokens);
91
+ }
92
+
93
+ // ── 워커 상태 ─────────────────────────────────────────────────────────────
94
+ export function runtimeStatus(worker) {
95
+ return worker?.handoff?.status || worker?.status || "pending";
96
+ }
97
+
98
+ /**
99
+ * 워커 상태 정규화 (tui.mjs / tui-lite.mjs 공통)
100
+ * @param {object} existing - 기존 워커 상태
101
+ * @param {object} state - 새 상태 패치
102
+ * @param {object} [opts]
103
+ * @param {boolean} [opts.trackChanges=false] - _prevStatus/_statusChangedAt 추적 (full TUI용)
104
+ * @param {function} [opts.now=Date.now] - 시간 함수
105
+ */
106
+ export function normalizeWorkerState(existing = {}, state = {}, opts = {}) {
107
+ const { trackChanges = false, now = Date.now } = opts;
108
+
109
+ const nextHandoff =
110
+ state.handoff === undefined
111
+ ? existing.handoff
112
+ : {
113
+ ...(existing.handoff || {}),
114
+ ...(state.handoff || {}),
115
+ verdict:
116
+ state.handoff?.verdict !== undefined
117
+ ? sanitizeOneLine(state.handoff.verdict)
118
+ : existing.handoff?.verdict,
119
+ confidence:
120
+ state.handoff?.confidence !== undefined
121
+ ? sanitizeOneLine(state.handoff.confidence)
122
+ : existing.handoff?.confidence,
123
+ status:
124
+ state.handoff?.status !== undefined
125
+ ? sanitizeOneLine(state.handoff.status)
126
+ : existing.handoff?.status,
127
+ files_changed:
128
+ state.handoff?.files_changed !== undefined
129
+ ? sanitizeFiles(state.handoff.files_changed)
130
+ : existing.handoff?.files_changed,
131
+ };
132
+
133
+ const merged = {
134
+ ...existing,
135
+ ...state,
136
+ cli:
137
+ state.cli !== undefined
138
+ ? sanitizeOneLine(state.cli, existing.cli || "codex")
139
+ : existing.cli || "codex",
140
+ role:
141
+ state.role !== undefined ? sanitizeOneLine(state.role) : existing.role,
142
+ status:
143
+ state.status !== undefined
144
+ ? sanitizeOneLine(state.status, existing.status || "pending")
145
+ : existing.status || "pending",
146
+ snapshot:
147
+ state.snapshot !== undefined
148
+ ? sanitizeTextBlock(state.snapshot)
149
+ : existing.snapshot,
150
+ summary:
151
+ state.summary !== undefined
152
+ ? sanitizeTextBlock(state.summary)
153
+ : existing.summary,
154
+ detail:
155
+ state.detail !== undefined
156
+ ? sanitizeTextBlock(state.detail)
157
+ : existing.detail,
158
+ findings:
159
+ state.findings !== undefined
160
+ ? sanitizeFindings(state.findings)
161
+ : existing.findings,
162
+ files_changed:
163
+ state.files_changed !== undefined
164
+ ? sanitizeFiles(state.files_changed)
165
+ : existing.files_changed,
166
+ confidence:
167
+ state.confidence !== undefined
168
+ ? sanitizeOneLine(state.confidence)
169
+ : existing.confidence,
170
+ tokens:
171
+ state.tokens !== undefined
172
+ ? normalizeTokens(state.tokens)
173
+ : existing.tokens,
174
+ progress:
175
+ state.progress !== undefined
176
+ ? clamp(Number(state.progress) || 0, 0, 1)
177
+ : existing.progress,
178
+ handoff: nextHandoff,
179
+ };
180
+
181
+ if (trackChanges) {
182
+ const statusChanged =
183
+ state.status !== undefined &&
184
+ sanitizeOneLine(state.status) !== existing.status;
185
+ merged._prevStatus = statusChanged
186
+ ? existing.status
187
+ : existing._prevStatus;
188
+ merged._statusChangedAt = statusChanged
189
+ ? now()
190
+ : existing._statusChangedAt || 0;
191
+ }
192
+
193
+ return merged;
194
+ }
195
+
196
+ // ── 색상 헬퍼 ─────────────────────────────────────────────────────────────
197
+ export function cliColor(cli) {
198
+ if (cli === "gemini") return FG.gemini;
199
+ if (cli === "claude") return FG.claude;
200
+ if (cli === "codex") return FG.codex;
201
+ return FG.white;
202
+ }
203
+
204
+ export function statusColor(status) {
205
+ if (status === "ok" || status === "completed") return MOCHA.ok;
206
+ if (status === "partial") return MOCHA.partial;
207
+ if (status === "failed") return MOCHA.fail;
208
+ if (status === "running" || status === "in_progress") return MOCHA.executing;
209
+ return FG.muted;
210
+ }
211
+
212
+ export function countStatuses(names, workers) {
213
+ let ok = 0,
214
+ partial = 0,
215
+ failed = 0,
216
+ running = 0;
217
+ for (const name of names) {
218
+ const st = workers.get(name);
219
+ const s = runtimeStatus(st);
220
+ if (s === "ok" || s === "completed") ok++;
221
+ else if (s === "partial") partial++;
222
+ else if (s === "failed") failed++;
223
+ else if (s === "running" || s === "in_progress") running++;
224
+ }
225
+ return { ok, partial, failed, running };
226
+ }
227
+
228
+ // ── 뷰포트 해상도 ────────────────────────────────────────────────────────
229
+ export function resolveViewportColumns(opts = {}) {
230
+ const { columns, stream } = opts;
231
+ const v = Number.isFinite(columns)
232
+ ? columns
233
+ : Number.isFinite(stream?.columns)
234
+ ? stream.columns
235
+ : Number.isFinite(process.stdout?.columns)
236
+ ? process.stdout.columns
237
+ : FALLBACK_COLUMNS;
238
+ return Math.max(48, v || FALLBACK_COLUMNS);
239
+ }
240
+
241
+ export function resolveViewportRows(opts = {}) {
242
+ const { rows, stream } = opts;
243
+ const v = Number.isFinite(rows)
244
+ ? rows
245
+ : Number.isFinite(stream?.rows)
246
+ ? stream.rows
247
+ : Number.isFinite(process.stdout?.rows)
248
+ ? process.stdout.rows
249
+ : FALLBACK_ROWS;
250
+ return Math.max(10, v || FALLBACK_ROWS);
251
+ }
252
+
253
+ // ── 텍스트 래핑 ───────────────────────────────────────────────────────────
254
+ export function wrapLine(text, width) {
255
+ const limit = Math.max(8, width);
256
+ const source = String(text || "").trim();
257
+ if (!source) return [""];
258
+ const words = source.split(/\s+/);
259
+ const lines = [];
260
+ let current = "";
261
+ for (const word of words) {
262
+ const candidate = current ? `${current} ${word}` : word;
263
+ if (wcswidth(candidate) <= limit) {
264
+ current = candidate;
265
+ continue;
266
+ }
267
+ if (current) {
268
+ lines.push(current);
269
+ current = "";
270
+ }
271
+ if (wcswidth(word) <= limit) {
272
+ current = word;
273
+ continue;
274
+ }
275
+ let offset = 0;
276
+ while (offset < word.length) {
277
+ lines.push(word.slice(offset, offset + limit));
278
+ offset += limit;
279
+ }
280
+ }
281
+ if (current) lines.push(current);
282
+ return lines.length > 0 ? lines : [source.slice(0, limit)];
283
+ }
284
+
285
+ export function wrapText(text, width, rawMode = false) {
286
+ const input = sanitizeTextBlock(text, rawMode);
287
+ if (!input) return [];
288
+ return input
289
+ .split("\n")
290
+ .flatMap((line) => wrapLine(line, width))
291
+ .filter(Boolean);
292
+ }