triflux 10.20.0 → 10.20.1

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "triflux",
11
11
  "description": "Tri-CLI orchestrator for Claude Code. Routes tasks across Claude + Codex + Gemini with consensus intelligence, natural language routing, 42 skills, and cross-model review.",
12
- "version": "10.20.0",
12
+ "version": "10.20.1",
13
13
  "author": {
14
14
  "name": "tellang"
15
15
  },
@@ -30,5 +30,5 @@
30
30
  ]
31
31
  }
32
32
  ],
33
- "version": "10.20.0"
33
+ "version": "10.20.1"
34
34
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.20.0",
3
+ "version": "10.20.1",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "author": {
6
6
  "name": "tellang"
package/bin/triflux.mjs CHANGED
@@ -77,6 +77,7 @@ import {
77
77
  getVersion,
78
78
  getWindowsHubAutostartStatus,
79
79
  hasProfileSection,
80
+ isSetupUserStateFile,
80
81
  LEGACY_CODEX_MODELS,
81
82
  REQUIRED_CODEX_PROFILES,
82
83
  replaceProfileSection,
@@ -1206,6 +1207,7 @@ function cmdSetup(options = {}) {
1206
1207
  if (existsSync(refSrc)) {
1207
1208
  mkdirSync(refDst, { recursive: true });
1208
1209
  for (const refFile of readdirSync(refSrc)) {
1210
+ if (isSetupUserStateFile(refFile)) continue;
1209
1211
  const rSrc = join(refSrc, refFile);
1210
1212
  const rDst = join(refDst, refFile);
1211
1213
  if (statSync(rSrc).isFile()) {
@@ -264,6 +264,19 @@
264
264
  "blocking": false,
265
265
  "description": "서브에이전트 결과 품질 체크"
266
266
  }
267
+ ],
268
+ "PreCompact": [
269
+ {
270
+ "id": "tfx-pre-compact-snapshot",
271
+ "source": "triflux",
272
+ "matcher": "*",
273
+ "command": "node \"${PLUGIN_ROOT}/hooks/pre-compact-snapshot.mjs\"",
274
+ "priority": 0,
275
+ "enabled": true,
276
+ "timeout": 3,
277
+ "blocking": false,
278
+ "description": "compact 직전 repo-local 진행 상태 스냅샷 주입 (#245)"
279
+ }
267
280
  ]
268
281
  }
269
282
  }
package/hooks/hooks.json CHANGED
@@ -84,6 +84,18 @@
84
84
  }
85
85
  ]
86
86
  }
87
+ ],
88
+ "PreCompact": [
89
+ {
90
+ "matcher": "*",
91
+ "hooks": [
92
+ {
93
+ "type": "command",
94
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
95
+ "timeout": 5
96
+ }
97
+ ]
98
+ }
87
99
  ]
88
100
  }
89
101
  }
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ // hooks/pre-compact-snapshot.mjs — PreCompact hook
3
+ //
4
+ // Compaction 직전에 짧은 상태 스냅샷을 주입한다. 민감 데이터는 읽지 않고,
5
+ // repo-local 상태와 git 요약만 3KB 이하로 제한한다.
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const MAX_CONTEXT_BYTES = 3000;
13
+ const STALE_RUN_MS = 30 * 60 * 1000;
14
+ const PROJECT_ROOT = process.env.CLAUDE_CWD || process.cwd();
15
+
16
+ function readStdinJson() {
17
+ try {
18
+ const raw = readFileSync(0, "utf8");
19
+ return raw.trim() ? JSON.parse(raw) : {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ function git(args) {
26
+ try {
27
+ return execFileSync("git", args, {
28
+ cwd: PROJECT_ROOT,
29
+ encoding: "utf8",
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ timeout: 3000,
32
+ }).trim();
33
+ } catch {
34
+ return "";
35
+ }
36
+ }
37
+
38
+ function summarizeGit() {
39
+ const branch =
40
+ git(["branch", "--show-current"]) || git(["rev-parse", "--short", "HEAD"]);
41
+ const status = git(["status", "--short"]);
42
+ const rows = status ? status.split(/\r?\n/).filter(Boolean) : [];
43
+ return {
44
+ branch: branch || "unknown",
45
+ dirty: rows.length,
46
+ sample: rows.slice(0, 8),
47
+ };
48
+ }
49
+
50
+ function readJsonIfSmall(path, maxBytes = 24_000) {
51
+ try {
52
+ if (!existsSync(path)) return null;
53
+ if (statSync(path).size > maxBytes) return null;
54
+ return JSON.parse(readFileSync(path, "utf8"));
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function summarizeModeState(root) {
61
+ const stateDir = join(root, ".omx", "state");
62
+ if (!existsSync(stateDir)) return [];
63
+ try {
64
+ return readdirSync(stateDir)
65
+ .filter((name) => name.endsWith("-state.json"))
66
+ .flatMap((name) => {
67
+ const state = readJsonIfSmall(join(stateDir, name));
68
+ if (!state || state.active === false) return [];
69
+ return [
70
+ {
71
+ mode: state.mode || name.replace(/-state\.json$/, ""),
72
+ phase: state.current_phase || state.phase || null,
73
+ active: state.active !== false,
74
+ },
75
+ ];
76
+ })
77
+ .slice(0, 8);
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function summarizeRetryState(input) {
84
+ const sessionId = input.session_id || input.sessionId || "";
85
+ if (!sessionId) return null;
86
+ const candidates = [
87
+ join(PROJECT_ROOT, ".omc", "state", `retry-${sessionId}.json`),
88
+ join(homedir(), ".omc", "state", `retry-${sessionId}.json`),
89
+ ];
90
+ for (const path of candidates) {
91
+ const state = readJsonIfSmall(path);
92
+ if (!state) continue;
93
+ return {
94
+ phase: state.phase || state.current_phase || null,
95
+ iteration: state.iteration ?? state.retry_count ?? null,
96
+ max: state.max_iterations ?? state.max ?? null,
97
+ };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function countStaleSwarmRuns(nowMs = Date.now()) {
103
+ const logsRoot = join(PROJECT_ROOT, ".triflux", "swarm-logs");
104
+ if (!existsSync(logsRoot)) return 0;
105
+ try {
106
+ return readdirSync(logsRoot, { withFileTypes: true }).filter((entry) => {
107
+ if (!entry.isDirectory() || !entry.name.startsWith("run-")) return false;
108
+ const eventPath = join(logsRoot, entry.name, "swarm-events.jsonl");
109
+ if (!existsSync(eventPath)) return false;
110
+ try {
111
+ return nowMs - statSync(eventPath).mtimeMs > STALE_RUN_MS;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }).length;
116
+ } catch {
117
+ return 0;
118
+ }
119
+ }
120
+
121
+ function truncateUtf8(text, maxBytes) {
122
+ let output = text;
123
+ while (Buffer.byteLength(output, "utf8") > maxBytes) {
124
+ output = output.slice(0, Math.max(0, output.length - 200));
125
+ }
126
+ return output;
127
+ }
128
+
129
+ export function buildSnapshot(input = readStdinJson()) {
130
+ const gitState = summarizeGit();
131
+ const activeModes = summarizeModeState(PROJECT_ROOT);
132
+ const retry = summarizeRetryState(input);
133
+ const staleSwarmRuns = countStaleSwarmRuns();
134
+
135
+ const lines = [
136
+ "[triflux pre-compact snapshot]",
137
+ `cwd: ${PROJECT_ROOT}`,
138
+ `git: ${gitState.branch}; dirty=${gitState.dirty}`,
139
+ ];
140
+ if (gitState.sample.length) {
141
+ lines.push(`dirty_sample: ${gitState.sample.join(" | ")}`);
142
+ }
143
+ if (activeModes.length) {
144
+ lines.push(
145
+ `active_modes: ${activeModes
146
+ .map((m) => `${m.mode}${m.phase ? `:${m.phase}` : ""}`)
147
+ .join(", ")}`,
148
+ );
149
+ }
150
+ if (retry) {
151
+ lines.push(
152
+ `retry: phase=${retry.phase || "-"} iteration=${retry.iteration ?? "-"}/${retry.max ?? "-"}`,
153
+ );
154
+ }
155
+ if (staleSwarmRuns > 0) {
156
+ lines.push(`stale_swarm_runs: ${staleSwarmRuns}`);
157
+ }
158
+
159
+ return truncateUtf8(lines.join("\n"), MAX_CONTEXT_BYTES);
160
+ }
161
+
162
+ function main() {
163
+ const snapshot = buildSnapshot();
164
+ if (!snapshot.trim()) return;
165
+ process.stdout.write(
166
+ JSON.stringify({
167
+ hookSpecificOutput: {
168
+ hookEventName: "PreCompact",
169
+ additionalContext: snapshot,
170
+ },
171
+ }),
172
+ );
173
+ }
174
+
175
+ try {
176
+ if (import.meta.url.endsWith(process.argv[1]?.split(/[\\/]/).pop() || "")) {
177
+ main();
178
+ }
179
+ } catch {
180
+ process.exit(0);
181
+ }
@@ -4,7 +4,7 @@
4
4
  // so the CLI works even when Hub is offline.
5
5
 
6
6
  import { execFile } from "node:child_process";
7
- import { existsSync, readFileSync } from "node:fs";
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join, resolve } from "node:path";
10
10
 
@@ -15,6 +15,15 @@ const DEFAULT_REGISTRY_CANDIDATES = [
15
15
  ".triflux/synapse/registry.json",
16
16
  join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
17
17
  ];
18
+ const DEFAULT_SWARM_LOGS_DIR = ".triflux/swarm-logs";
19
+ const STALE_AFTER_MS = 30 * 60 * 1000;
20
+ const STATUS_PRIORITY = {
21
+ active: 3,
22
+ running: 3,
23
+ completed: 2,
24
+ failed: 2,
25
+ stale: 1,
26
+ };
18
27
 
19
28
  function gitExec(args, cwd) {
20
29
  return new Promise((res, rej) => {
@@ -55,10 +64,228 @@ function loadRegistrySnapshot(path) {
55
64
  }
56
65
  }
57
66
 
67
+ function parseJsonLines(path) {
68
+ try {
69
+ return readFileSync(path, "utf8")
70
+ .split(/\r?\n/)
71
+ .map((line) => line.trim())
72
+ .filter(Boolean)
73
+ .flatMap((line) => {
74
+ try {
75
+ return [JSON.parse(line)];
76
+ } catch {
77
+ return [];
78
+ }
79
+ });
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ function normalizeRunId(name) {
86
+ return String(name || "").replace(/^run-/, "");
87
+ }
88
+
89
+ function eventTimeMs(event) {
90
+ const ms = Date.parse(String(event?.ts || ""));
91
+ return Number.isFinite(ms) ? ms : 0;
92
+ }
93
+
94
+ function deriveLogStatus(events, lastHeartbeatMs, nowMs) {
95
+ const stateEvents = events.filter((e) => e.event === "swarm_state");
96
+ const lastState = stateEvents[stateEvents.length - 1]?.to;
97
+ if (lastState === "completed" || lastState === "failed") return lastState;
98
+ if (events.some((e) => e.event === "integration_complete")) {
99
+ const lastIntegration = [...events]
100
+ .reverse()
101
+ .find((e) => e.event === "integration_complete");
102
+ if (
103
+ Array.isArray(lastIntegration?.failed) &&
104
+ lastIntegration.failed.length
105
+ ) {
106
+ return "failed";
107
+ }
108
+ return "completed";
109
+ }
110
+ if (nowMs - lastHeartbeatMs > STALE_AFTER_MS) return "stale";
111
+ return "active";
112
+ }
113
+
114
+ function buildLogSession(runDir, nowMs = Date.now()) {
115
+ const eventPath = join(runDir, "swarm-events.jsonl");
116
+ if (!existsSync(eventPath)) return null;
117
+ const events = parseJsonLines(eventPath);
118
+ if (events.length === 0) return null;
119
+
120
+ let statMs = 0;
121
+ try {
122
+ statMs = statSync(eventPath).mtimeMs;
123
+ } catch {}
124
+
125
+ const eventMs = events.map(eventTimeMs).filter((ms) => ms > 0);
126
+ const lastEventMs = eventMs.length ? Math.max(...eventMs) : statMs;
127
+ const launched = new Set();
128
+ const completed = new Set();
129
+ const failed = new Set();
130
+ for (const event of events) {
131
+ if (event.event === "shard_launched" && event.shard) {
132
+ launched.add(event.shard);
133
+ } else if (event.event === "shard_completed" && event.shard) {
134
+ completed.add(event.shard);
135
+ } else if (
136
+ (event.event === "shard_failed" ||
137
+ event.event === "shard_launch_failed") &&
138
+ event.shard
139
+ ) {
140
+ failed.add(event.shard);
141
+ }
142
+ }
143
+
144
+ const runId = normalizeRunId(runDir.split(/[\\/]/).pop());
145
+ const totalShards = launched.size || completed.size + failed.size;
146
+ const aliveWorkers = [...launched].filter(
147
+ (name) => !completed.has(name) && !failed.has(name),
148
+ ).length;
149
+
150
+ return {
151
+ sessionId: runId,
152
+ runId,
153
+ host: "-",
154
+ branch: "-",
155
+ dirtyFiles: [],
156
+ status: deriveLogStatus(events, lastEventMs || nowMs, nowMs),
157
+ taskSummary: "swarm log run",
158
+ source: "logs",
159
+ shards: totalShards ? `${completed.size}/${totalShards}` : "-",
160
+ workers: totalShards ? `${aliveWorkers} alive` : "-",
161
+ lastHeartbeat: lastEventMs || null,
162
+ logDir: runDir,
163
+ };
164
+ }
165
+
166
+ export function loadSwarmLogSessions(logsDir, opts = {}) {
167
+ const nowMs = opts.nowMs ?? Date.now();
168
+ const root = logsDir || DEFAULT_SWARM_LOGS_DIR;
169
+ if (!existsSync(root)) return [];
170
+ try {
171
+ return readdirSync(root, { withFileTypes: true })
172
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
173
+ .map((entry) => buildLogSession(join(root, entry.name), nowMs))
174
+ .filter(Boolean);
175
+ } catch {
176
+ return [];
177
+ }
178
+ }
179
+
180
+ function statusPriority(status) {
181
+ return STATUS_PRIORITY[status] || 0;
182
+ }
183
+
184
+ function mergeSession(existing, next) {
185
+ const status =
186
+ statusPriority(next.status) > statusPriority(existing.status)
187
+ ? next.status
188
+ : existing.status;
189
+ const source =
190
+ existing.source && next.source && existing.source !== next.source
191
+ ? "synapse + logs"
192
+ : existing.source || next.source || "synapse";
193
+ return {
194
+ ...next,
195
+ ...existing,
196
+ status,
197
+ source,
198
+ shards: next.shards || existing.shards,
199
+ workers: next.workers || existing.workers,
200
+ lastHeartbeat: Math.max(
201
+ Number(existing.lastHeartbeat || 0),
202
+ Number(next.lastHeartbeat || 0),
203
+ ),
204
+ logDir: existing.logDir || next.logDir,
205
+ };
206
+ }
207
+
208
+ export function mergeRegistryAndLogSessions(registrySessions, logSessions) {
209
+ const merged = new Map();
210
+ for (const session of registrySessions) {
211
+ const key = String(session.runId || session.sessionId || "");
212
+ if (!key) continue;
213
+ merged.set(key, {
214
+ ...session,
215
+ source: session.source || "synapse",
216
+ lastHeartbeat: session.lastHeartbeat || null,
217
+ });
218
+ }
219
+ for (const session of logSessions) {
220
+ const key = String(session.runId || session.sessionId || "");
221
+ if (!key) continue;
222
+ const existing = merged.get(key);
223
+ merged.set(key, existing ? mergeSession(existing, session) : session);
224
+ }
225
+ return [...merged.values()].sort((left, right) => {
226
+ const rightTs = Number(right.lastHeartbeat || 0);
227
+ const leftTs = Number(left.lastHeartbeat || 0);
228
+ return rightTs - leftTs;
229
+ });
230
+ }
231
+
232
+ function formatAge(ts, nowMs = Date.now()) {
233
+ if (!Number.isFinite(Number(ts)) || Number(ts) <= 0) return "-";
234
+ const seconds = Math.max(0, Math.round((nowMs - Number(ts)) / 1000));
235
+ if (seconds < 60) return `${seconds}s ago`;
236
+ const minutes = Math.round(seconds / 60);
237
+ if (minutes < 60) return `${minutes}m ago`;
238
+ const hours = Math.round(minutes / 60);
239
+ return `${hours}h ago`;
240
+ }
241
+
242
+ function hasSwarmLogFields(sessions) {
243
+ return sessions.some(
244
+ (s) =>
245
+ s.source === "logs" ||
246
+ s.source === "synapse + logs" ||
247
+ s.runId ||
248
+ s.shards ||
249
+ s.workers,
250
+ );
251
+ }
252
+
253
+ function formatSwarmStatus(sessions) {
254
+ const nowMs = Date.now();
255
+ const rows = [];
256
+ rows.push(
257
+ "RUN_ID STATUS SHARDS WORKERS LAST_HEARTBEAT SOURCE",
258
+ );
259
+ rows.push(
260
+ "───────────────────── ───────── ─────── ─────── ────────────── ──────────────",
261
+ );
262
+ for (const s of sessions) {
263
+ const id = String(s.runId || s.sessionId || "?")
264
+ .padEnd(21)
265
+ .slice(0, 21);
266
+ const status = String(s.status || "active")
267
+ .padEnd(9)
268
+ .slice(0, 9);
269
+ const shards = String(s.shards || "-")
270
+ .padEnd(7)
271
+ .slice(0, 7);
272
+ const workers = String(s.workers || "-")
273
+ .padEnd(7)
274
+ .slice(0, 7);
275
+ const heartbeat = formatAge(s.lastHeartbeat, nowMs).padEnd(14).slice(0, 14);
276
+ const source = String(s.source || "synapse").slice(0, 20);
277
+ rows.push(
278
+ `${id} ${status} ${shards} ${workers} ${heartbeat} ${source}`,
279
+ );
280
+ }
281
+ return rows.join("\n");
282
+ }
283
+
58
284
  function formatStatus(sessions) {
59
285
  if (!sessions.length) {
60
286
  return "no active sessions (synapse-registry empty)";
61
287
  }
288
+ if (hasSwarmLogFields(sessions)) return formatSwarmStatus(sessions);
62
289
  const rows = [];
63
290
  rows.push("SESSION HOST BRANCH DIRTY STATE TASK");
64
291
  rows.push(
@@ -84,13 +311,22 @@ function formatStatus(sessions) {
84
311
  export async function cmdSynapseStatus(args = [], opts = {}) {
85
312
  const jsonOut = args.includes("--json") || opts.json;
86
313
  const explicit = extractFlag(args, "--registry");
314
+ const explicitLogsDir = extractFlag(args, "--logs-dir");
87
315
  const path = locateRegistryPath(explicit);
88
- const sessions = loadRegistrySnapshot(path);
316
+ const registrySessions = loadRegistrySnapshot(path);
317
+ const logsDir = explicitLogsDir || DEFAULT_SWARM_LOGS_DIR;
318
+ const logSessions = loadSwarmLogSessions(logsDir);
319
+ const sessions = mergeRegistryAndLogSessions(registrySessions, logSessions);
89
320
 
90
321
  if (jsonOut) {
91
322
  process.stdout.write(
92
323
  JSON.stringify(
93
- { registry: path, count: sessions.length, sessions },
324
+ {
325
+ registry: path,
326
+ logsDir: existsSync(logsDir) ? logsDir : null,
327
+ count: sessions.length,
328
+ sessions,
329
+ },
94
330
  null,
95
331
  2,
96
332
  ) + "\n",
@@ -98,13 +334,14 @@ export async function cmdSynapseStatus(args = [], opts = {}) {
98
334
  return;
99
335
  }
100
336
 
101
- if (!path) {
337
+ if (!path && logSessions.length === 0) {
102
338
  process.stdout.write(
103
- "no registry file found (looked for .triflux/synapse-registry.json)\n",
339
+ "no registry or swarm logs found (looked for .triflux/synapse-registry.json and .triflux/swarm-logs)\n",
104
340
  );
105
341
  return;
106
342
  }
107
- process.stdout.write(`registry: ${path}\n`);
343
+ if (path) process.stdout.write(`registry: ${path}\n`);
344
+ if (logSessions.length > 0) process.stdout.write(`swarm logs: ${logsDir}\n`);
108
345
  process.stdout.write(`${formatStatus(sessions)}\n`);
109
346
  }
110
347
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.20.0",
3
+ "version": "10.20.1",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,6 +5,7 @@
5
5
  * 새 세션 시작 시 이전 세션의 stale 상태를 정리한다:
6
6
  * 1. tfx-multi-state.json — 세션 간 상태 누수 방지 (#62)
7
7
  * 2. tfx-route-*-pids — 고아 워커 프로세스 정리 (#62 후속)
8
+ * 3. git fsmonitor--daemon 누적 감시 — threshold 초과 시 증거 기록 (#214)
8
9
  *
9
10
  * @see scripts/headless-guard.mjs — 상태 소비자
10
11
  * @see scripts/tfx-gate-activate.mjs — 상태 생산자 (ownerPid 기록)
@@ -13,14 +14,17 @@
13
14
 
14
15
  import { execSync } from "node:child_process";
15
16
  import {
17
+ appendFileSync,
16
18
  existsSync,
19
+ mkdirSync,
17
20
  readdirSync,
18
21
  readFileSync,
19
22
  statSync,
20
23
  unlinkSync,
21
24
  } from "node:fs";
22
- import { platform, tmpdir } from "node:os";
23
- import { join } from "node:path";
25
+ import { homedir, platform, tmpdir } from "node:os";
26
+ import { dirname, join } from "node:path";
27
+ import { findFsmonitorDaemons } from "../hub/lib/process-utils.mjs";
24
28
  import { isProcessAlive } from "./lib/process-utils.mjs";
25
29
 
26
30
  const MULTI_STATE_FILE = join(tmpdir(), "tfx-multi-state.json");
@@ -32,6 +36,8 @@ const PROTECTED_ANCESTOR_NAMES = new Set([
32
36
  "gemini.exe",
33
37
  ]);
34
38
  const PID_REUSE_GRACE_MS = 1000;
39
+ const DEFAULT_FSMONITOR_ALERT_THRESHOLD = 50;
40
+ const FSMONITOR_STALE_MS = 24 * 60 * 60 * 1000;
35
41
 
36
42
  function normalizeName(name) {
37
43
  return String(name || "")
@@ -44,6 +50,33 @@ function parseCreationMs(value) {
44
50
  return Number.isFinite(ms) ? ms : null;
45
51
  }
46
52
 
53
+ function parsePositiveInteger(value, fallback) {
54
+ const n = Number.parseInt(String(value ?? ""), 10);
55
+ return Number.isInteger(n) && n > 0 ? n : fallback;
56
+ }
57
+
58
+ function resolveHomeDir() {
59
+ return (
60
+ process.env.TRIFLUX_TEST_HOME ||
61
+ process.env.USERPROFILE ||
62
+ process.env.HOME ||
63
+ homedir()
64
+ );
65
+ }
66
+
67
+ function defaultFsmonitorAlertLogPath() {
68
+ return join(resolveHomeDir(), ".omc", "state", "fsmonitor-alert.log");
69
+ }
70
+
71
+ function summarizeFsmonitorDaemon(proc) {
72
+ return {
73
+ pid: proc.pid,
74
+ parentPid: proc.parentPid,
75
+ ageMs: Number.isFinite(proc.ageMs) ? proc.ageMs : null,
76
+ commandLine: String(proc.commandLine || "").slice(0, 200),
77
+ };
78
+ }
79
+
47
80
  function collectWindowsProcessTable() {
48
81
  if (platform() !== "win32") return new Map();
49
82
  try {
@@ -243,9 +276,85 @@ function cleanupOrphanPidFiles() {
243
276
  }
244
277
  }
245
278
 
279
+ export function monitorFsmonitorDaemons({
280
+ threshold = parsePositiveInteger(
281
+ process.env.TFX_FSMONITOR_ALERT_THRESHOLD,
282
+ DEFAULT_FSMONITOR_ALERT_THRESHOLD,
283
+ ),
284
+ logPath = process.env.TFX_FSMONITOR_ALERT_LOG ||
285
+ defaultFsmonitorAlertLogPath(),
286
+ findFn = findFsmonitorDaemons,
287
+ now = new Date(),
288
+ isWindows = platform() === "win32",
289
+ } = {}) {
290
+ if (process.env.TFX_FSMONITOR_MONITOR === "0") {
291
+ return { checked: false, reason: "disabled" };
292
+ }
293
+ if (!isWindows) return { checked: false, reason: "non-windows" };
294
+
295
+ const effectiveThreshold = parsePositiveInteger(
296
+ threshold,
297
+ DEFAULT_FSMONITOR_ALERT_THRESHOLD,
298
+ );
299
+
300
+ try {
301
+ const daemons = findFn({
302
+ minAgeMs: 0,
303
+ nowMs: now.getTime(),
304
+ isWindows,
305
+ });
306
+ const total = daemons.length;
307
+ if (total < effectiveThreshold) {
308
+ return {
309
+ checked: true,
310
+ alert: false,
311
+ total,
312
+ threshold: effectiveThreshold,
313
+ };
314
+ }
315
+
316
+ const record = {
317
+ ts: now.toISOString(),
318
+ source: "session-start",
319
+ total,
320
+ threshold: effectiveThreshold,
321
+ stale24h: daemons.filter(
322
+ (proc) =>
323
+ Number.isFinite(proc.ageMs) && proc.ageMs >= FSMONITOR_STALE_MS,
324
+ ).length,
325
+ pids: daemons.slice(0, 20).map(summarizeFsmonitorDaemon),
326
+ };
327
+
328
+ mkdirSync(dirname(logPath), { recursive: true });
329
+ appendFileSync(logPath, `${JSON.stringify(record)}\n`, "utf8");
330
+ console.error(
331
+ `[session-stale-cleanup] git fsmonitor daemon threshold exceeded: total=${total} threshold=${effectiveThreshold} log=${logPath}`,
332
+ );
333
+
334
+ return {
335
+ checked: true,
336
+ alert: true,
337
+ total,
338
+ threshold: effectiveThreshold,
339
+ stale24h: record.stale24h,
340
+ logPath,
341
+ };
342
+ } catch (error) {
343
+ console.error(
344
+ `[session-stale-cleanup] fsmonitor monitor failed: ${error?.message || error}`,
345
+ );
346
+ return {
347
+ checked: false,
348
+ reason: "error",
349
+ error: error?.message || String(error),
350
+ };
351
+ }
352
+ }
353
+
246
354
  export function main() {
247
355
  cleanupMultiState();
248
356
  cleanupOrphanPidFiles();
357
+ monitorFsmonitorDaemons();
249
358
  }
250
359
 
251
360
  const isDirectRun =
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: tfx-harness
3
+ description: >
4
+ TFX workflow harness. Use as the single front door when the user asks which TFX/gstack/superpowers
5
+ skill should handle a task, wants issue-resolution routing, mentions 신규/계속/디버그/리서치/검증/배포/저장/복원,
6
+ or asks for a TFX harness that dispatches to office-hours, autoplan, writing-plans, tfx-auto, tfx-find,
7
+ tfx-research, review/qa, tfx-ship, ship, context-save, or context-restore.
8
+ triggers:
9
+ - tfx-harness
10
+ - tfx
11
+ - harness
12
+ - 하네스
13
+ - 스킬 추천
14
+ - 어떤 스킬
15
+ - 작업 라우팅
16
+ - 이슈 해결
17
+ - workflow routing
18
+ argument-hint: "<task or issue description>"
19
+ ---
20
+
21
+ # tfx-harness — TFX Workflow Harness
22
+
23
+ > **ARGUMENTS 처리**: 이 스킬이 `ARGUMENTS: <값>`과 함께 호출되면, 해당 값을 사용자 입력으로 취급하여
24
+ > 워크플로우의 첫 단계 입력으로 사용한다. ARGUMENTS가 비어있거나 없으면 기존 절차대로 사용자에게 입력을 요청한다.
25
+
26
+ > **Telemetry**
27
+ >
28
+ > - Skill: `tfx-harness`
29
+ > - Description: `TFX workflow harness. Use as the single front door when the user asks which TFX/gstack/superpowers skill should handle a task, wants issue-resolution routing, mentions 신규/계속/디버그/리서치/검증/배포/저장/복원, or asks for a TFX harness that dispatches to office-hours, autoplan, writing-plans, tfx-auto, tfx-find, tfx-research, review/qa, tfx-ship, ship, context-save, or context-restore.`
30
+ > - Session: 요청별 식별자를 유지해 단계별 실행 로그를 추적한다.
31
+ > - Errors: 실패 시 원인/복구/재시도 여부를 구조화해 기록한다.
32
+
33
+
34
+
35
+ ## Contract
36
+
37
+ This skill is the routing harness, not a replacement execution engine.
38
+ Classify the user's task, dispatch to the smallest correct workflow chain, then stop this harness unless no downstream skill can be invoked.
39
+
40
+ If `.claude/rules/tfx-routing.md` exists in the repo, treat it as the source of truth and reconcile this table against it. If the host cannot invoke another skill directly, state the exact next workflow and continue only with the nearest safe local equivalent.
41
+
42
+ ## Routing Table
43
+
44
+ | Situation | Dispatch chain | Use when |
45
+ | --- | --- | --- |
46
+ | New product or idea | `/office-hours` -> `/autoplan` -> `/writing-plans` -> `/tfx-auto` | The problem, audience, or scope is not yet shaped. |
47
+ | Continue prior work | `/gstack-context-restore` -> `/tfx-auto` | A checkpoint/session restore is the first necessary step. |
48
+ | Debug or issue fix | `/tfx-find` -> `/superpowers:systematic-debugging` -> `/tfx-auto` | There is a bug, failing behavior, or unknown root cause. |
49
+ | Research | `/tfx-research` or `/multilingual-parallel-research` | Use TFX for current/official/tool behavior; use multilingual for cross-language source gathering. |
50
+ | Verification | `/superpowers:review` + `/gstack /qa` | Code judgment and browser/workflow gates are both needed. |
51
+ | Triflux release | `/tfx-ship` | Shipping this repo or TFX-specific artifacts. |
52
+ | General PR/deploy | `/ship` | Non-triflux PR/deploy workflow. Do not add AI attribution footer/trailer. |
53
+ | Save or restore | `/gstack-context-save` or `/gstack-context-restore` | Preserve or reload session state. |
54
+
55
+ ## Decision Procedure
56
+
57
+ 1. Identify the dominant intent: new planning, continuation, debugging, research, verification, release, save/restore, or direct implementation.
58
+ 2. If the user asks only "which skill?", answer with the selected chain and one sentence of rationale.
59
+ 3. If the user asks to proceed, invoke the first skill in the selected chain and hand off the original task as arguments.
60
+ 4. For mixed requests, run prerequisites first: restore before execution, find/debug before fix, review/qa before ship, save after a meaningful stopping point.
61
+ 5. For direct implementation that does not fit a more specific lane, dispatch to `/tfx-auto`.
62
+
63
+ ## Issue-Resolution Recommendation
64
+
65
+ For "이슈 해결" style prompts, default to:
66
+
67
+ 1. `/tfx-find` to map files, symbols, logs, and likely ownership.
68
+ 2. `/superpowers:systematic-debugging` to prove root cause before editing.
69
+ 3. `/tfx-auto` to implement the minimal fix.
70
+ 4. `/superpowers:review` and the repo's targeted tests or `/gstack /qa` to verify.
71
+
72
+ Escalate to `/tfx-ship` or `/ship` only after fresh verification evidence exists.
73
+
74
+ ## Output Shape
75
+
76
+ Keep the user-facing response short:
77
+
78
+ ```text
79
+ [tfx-harness] lane: <lane>
80
+ next: <workflow chain>
81
+ reason: <one sentence>
82
+ status: dispatching | recommendation-only | blocked
83
+ ```
84
+
85
+ Do not claim natural-language auto-triggering is guaranteed by this skill alone. Explicit `/tfx-harness` invocation is deterministic; automatic routing from arbitrary phrasing depends on the host keyword/hook layer.
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: tfx-harness
3
+ description: >
4
+ TFX workflow harness. Use as the single front door when the user asks which TFX/gstack/superpowers
5
+ skill should handle a task, wants issue-resolution routing, mentions 신규/계속/디버그/리서치/검증/배포/저장/복원,
6
+ or asks for a TFX harness that dispatches to office-hours, autoplan, writing-plans, tfx-auto, tfx-find,
7
+ tfx-research, review/qa, tfx-ship, ship, context-save, or context-restore.
8
+ triggers:
9
+ - tfx-harness
10
+ - tfx
11
+ - harness
12
+ - 하네스
13
+ - 스킬 추천
14
+ - 어떤 스킬
15
+ - 작업 라우팅
16
+ - 이슈 해결
17
+ - workflow routing
18
+ argument-hint: "<task or issue description>"
19
+ ---
20
+
21
+ # {{SKILL_NAME}} — TFX Workflow Harness
22
+
23
+ {{> base}}
24
+
25
+ ## Contract
26
+
27
+ This skill is the routing harness, not a replacement execution engine.
28
+ Classify the user's task, dispatch to the smallest correct workflow chain, then stop this harness unless no downstream skill can be invoked.
29
+
30
+ If `.claude/rules/tfx-routing.md` exists in the repo, treat it as the source of truth and reconcile this table against it. If the host cannot invoke another skill directly, state the exact next workflow and continue only with the nearest safe local equivalent.
31
+
32
+ ## Routing Table
33
+
34
+ | Situation | Dispatch chain | Use when |
35
+ | --- | --- | --- |
36
+ | New product or idea | `/office-hours` -> `/autoplan` -> `/writing-plans` -> `/tfx-auto` | The problem, audience, or scope is not yet shaped. |
37
+ | Continue prior work | `/gstack-context-restore` -> `/tfx-auto` | A checkpoint/session restore is the first necessary step. |
38
+ | Debug or issue fix | `/tfx-find` -> `/superpowers:systematic-debugging` -> `/tfx-auto` | There is a bug, failing behavior, or unknown root cause. |
39
+ | Research | `/tfx-research` or `/multilingual-parallel-research` | Use TFX for current/official/tool behavior; use multilingual for cross-language source gathering. |
40
+ | Verification | `/superpowers:review` + `/gstack /qa` | Code judgment and browser/workflow gates are both needed. |
41
+ | Triflux release | `/tfx-ship` | Shipping this repo or TFX-specific artifacts. |
42
+ | General PR/deploy | `/ship` | Non-triflux PR/deploy workflow. Do not add AI attribution footer/trailer. |
43
+ | Save or restore | `/gstack-context-save` or `/gstack-context-restore` | Preserve or reload session state. |
44
+
45
+ ## Decision Procedure
46
+
47
+ 1. Identify the dominant intent: new planning, continuation, debugging, research, verification, release, save/restore, or direct implementation.
48
+ 2. If the user asks only "which skill?", answer with the selected chain and one sentence of rationale.
49
+ 3. If the user asks to proceed, invoke the first skill in the selected chain and hand off the original task as arguments.
50
+ 4. For mixed requests, run prerequisites first: restore before execution, find/debug before fix, review/qa before ship, save after a meaningful stopping point.
51
+ 5. For direct implementation that does not fit a more specific lane, dispatch to `/tfx-auto`.
52
+
53
+ ## Issue-Resolution Recommendation
54
+
55
+ For "이슈 해결" style prompts, default to:
56
+
57
+ 1. `/tfx-find` to map files, symbols, logs, and likely ownership.
58
+ 2. `/superpowers:systematic-debugging` to prove root cause before editing.
59
+ 3. `/tfx-auto` to implement the minimal fix.
60
+ 4. `/superpowers:review` and the repo's targeted tests or `/gstack /qa` to verify.
61
+
62
+ Escalate to `/tfx-ship` or `/ship` only after fresh verification evidence exists.
63
+
64
+ ## Output Shape
65
+
66
+ Keep the user-facing response short:
67
+
68
+ ```text
69
+ [tfx-harness] lane: <lane>
70
+ next: <workflow chain>
71
+ reason: <one sentence>
72
+ status: dispatching | recommendation-only | blocked
73
+ ```
74
+
75
+ Do not claim natural-language auto-triggering is guaranteed by this skill alone. Explicit `/tfx-harness` invocation is deterministic; automatic routing from arbitrary phrasing depends on the host keyword/hook layer.
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "tfx-harness",
3
+ "description": "TFX workflow harness. Use as the single front door when the user asks which TFX/gstack/superpowers skill should handle a task, wants issue-resolution routing, mentions 신규/계속/디버그/리서치/검증/배포/저장/복원, or asks for a TFX harness that dispatches to office-hours, autoplan, writing-plans, tfx-auto, tfx-find, tfx-research, review/qa, tfx-ship, ship, context-save, or context-restore.",
4
+ "triggers": [
5
+ "tfx-harness",
6
+ "tfx",
7
+ "harness",
8
+ "하네스",
9
+ "스킬 추천",
10
+ "어떤 스킬",
11
+ "작업 라우팅",
12
+ "이슈 해결",
13
+ "workflow routing"
14
+ ],
15
+ "argument_hint": "<task or issue description>"
16
+ }