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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/bin/triflux.mjs +2 -0
- package/hooks/hook-registry.json +13 -0
- package/hooks/hooks.json +12 -0
- package/hooks/pre-compact-snapshot.mjs +181 -0
- package/hub/team/synapse-cli.mjs +243 -6
- package/package.json +1 -1
- package/scripts/session-stale-cleanup.mjs +111 -2
- package/skills/tfx-harness/SKILL.md +85 -0
- package/skills/tfx-harness/SKILL.md.tmpl +75 -0
- package/skills/tfx-harness/skill.json +16 -0
|
@@ -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.
|
|
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.
|
|
33
|
+
"version": "10.20.1"
|
|
34
34
|
}
|
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()) {
|
package/hooks/hook-registry.json
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|
package/hub/team/synapse-cli.mjs
CHANGED
|
@@ -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
|
|
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
|
-
{
|
|
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
|
|
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
|
@@ -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
|
+
}
|