triflux 8.12.5 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/cross-review-gate.mjs +180 -0
- package/scripts/cross-review-tracker.mjs +279 -0
- package/scripts/session-spawn-helper.mjs +184 -0
- package/skills/merge-worktree/SKILL.md +144 -0
- package/skills/tfx-analysis/SKILL.md +1 -0
- package/skills/tfx-auto-codex/SKILL.md +1 -0
- package/skills/tfx-autopilot/SKILL.md +1 -2
- package/skills/tfx-codex/SKILL.md +1 -0
- package/skills/tfx-codex-swarm/SKILL.md +60 -23
- package/skills/tfx-codex-swarm/mcp-daemon/start-daemons.ps1 +54 -0
- package/skills/tfx-codex-swarm/mcp-daemon/stop-daemons.ps1 +15 -0
- package/skills/tfx-consensus/SKILL.md +1 -0
- package/skills/tfx-deep-analysis/SKILL.md +1 -0
- package/skills/tfx-deep-plan/SKILL.md +1 -0
- package/skills/tfx-deep-qa/SKILL.md +1 -0
- package/skills/tfx-deep-research/SKILL.md +1 -0
- package/skills/tfx-deep-review/SKILL.md +1 -0
- package/skills/tfx-gemini/SKILL.md +1 -0
- package/skills/tfx-hub/SKILL.md +1 -0
- package/skills/tfx-multi/SKILL.md +1 -0
- package/skills/tfx-plan/SKILL.md +1 -0
- package/skills/tfx-qa/SKILL.md +1 -0
- package/skills/tfx-ralph/SKILL.md +2 -5
- package/skills/tfx-research/SKILL.md +1 -0
- package/skills/tfx-review/SKILL.md +1 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
{
|
|
10
10
|
"name": "triflux",
|
|
11
11
|
"description": "CLI-first multi-model orchestrator for Claude Code. Routes tasks to Codex, Gemini, and Claude CLIs with automatic triage (Sonnet classification + Opus decomposition), DAG-based parallel execution, and cost-optimized routing. Includes 16 skills, HUD status bar, and shell-based CLI routing wrapper.",
|
|
12
|
-
"version": "
|
|
12
|
+
"version": "9.0.0",
|
|
13
13
|
"author": {
|
|
14
14
|
"name": "tellang"
|
|
15
15
|
},
|
|
@@ -27,5 +27,5 @@
|
|
|
27
27
|
]
|
|
28
28
|
}
|
|
29
29
|
],
|
|
30
|
-
"version": "
|
|
30
|
+
"version": "9.0.0"
|
|
31
31
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const SESSION_TTL_SEC = 30 * 60;
|
|
7
|
+
const STATE_REL_PATH = join(".omc", "state", "cross-review.json");
|
|
8
|
+
|
|
9
|
+
function readStdin() {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let raw = "";
|
|
12
|
+
process.stdin.setEncoding("utf8");
|
|
13
|
+
process.stdin.on("data", (chunk) => {
|
|
14
|
+
raw += chunk;
|
|
15
|
+
});
|
|
16
|
+
process.stdin.on("end", () => resolve(raw));
|
|
17
|
+
process.stdin.on("error", () => resolve(""));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseJson(raw) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function nowSec() {
|
|
30
|
+
return Math.floor(Date.now() / 1000);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveBaseDir(payload) {
|
|
34
|
+
if (typeof payload?.cwd === "string" && payload.cwd.trim()) return payload.cwd;
|
|
35
|
+
if (typeof payload?.directory === "string" && payload.directory.trim()) return payload.directory;
|
|
36
|
+
return process.cwd();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function expectedReviewer(author) {
|
|
40
|
+
if (author === "claude") return "codex";
|
|
41
|
+
if (author === "codex") return "claude";
|
|
42
|
+
if (author === "gemini") return "claude";
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function shouldTrackPath(filePath) {
|
|
47
|
+
if (typeof filePath !== "string" || !filePath.trim()) return false;
|
|
48
|
+
|
|
49
|
+
const lower = filePath.toLowerCase();
|
|
50
|
+
if (lower.startsWith(".omc/") || lower.startsWith(".claude/")) return false;
|
|
51
|
+
if (lower === "package-lock.json" || lower.endsWith("/package-lock.json")) return false;
|
|
52
|
+
if (/\.(md|lock|yml|yaml)$/i.test(lower)) return false;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadState(statePath) {
|
|
57
|
+
if (!existsSync(statePath)) return null;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
61
|
+
const startedAt = Number(state?.session_start || 0);
|
|
62
|
+
const expired = !startedAt || nowSec() - startedAt > SESSION_TTL_SEC;
|
|
63
|
+
if (expired) {
|
|
64
|
+
try {
|
|
65
|
+
unlinkSync(statePath);
|
|
66
|
+
} catch {}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return state;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isGitCommitCommand(command) {
|
|
76
|
+
if (typeof command !== "string") return false;
|
|
77
|
+
return /\bgit\s+commit\b/i.test(command);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function nudge(message) {
|
|
81
|
+
process.stdout.write(JSON.stringify({
|
|
82
|
+
hookSpecificOutput: {
|
|
83
|
+
hookEventName: "PreToolUse",
|
|
84
|
+
additionalContext: message,
|
|
85
|
+
},
|
|
86
|
+
}));
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function deny(message) {
|
|
91
|
+
process.stderr.write(message);
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function summarizePending(entries) {
|
|
96
|
+
return entries
|
|
97
|
+
.map((item) => {
|
|
98
|
+
const reviewer = item.expectedReviewer || "cross-reviewer";
|
|
99
|
+
return ` * ${item.path} (author=${item.author}, reviewer=${reviewer})`;
|
|
100
|
+
})
|
|
101
|
+
.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function main() {
|
|
105
|
+
if (process.env.TFX_SKIP_CROSS_REVIEW === "1") {
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const raw = await readStdin();
|
|
110
|
+
if (!raw.trim()) process.exit(0);
|
|
111
|
+
|
|
112
|
+
const payload = parseJson(raw);
|
|
113
|
+
if (!payload) process.exit(0);
|
|
114
|
+
|
|
115
|
+
const toolName = payload.tool_name || "";
|
|
116
|
+
const toolInput = payload.tool_input || {};
|
|
117
|
+
|
|
118
|
+
if (toolName !== "Bash") process.exit(0);
|
|
119
|
+
if (!isGitCommitCommand(toolInput.command || "")) process.exit(0);
|
|
120
|
+
|
|
121
|
+
// cwd 전파: tracker와 동일한 resolveBaseDir 사용
|
|
122
|
+
const baseDir = resolveBaseDir(payload);
|
|
123
|
+
const statePath = join(baseDir, STATE_REL_PATH);
|
|
124
|
+
|
|
125
|
+
const state = loadState(statePath);
|
|
126
|
+
if (!state || !state.files || typeof state.files !== "object") process.exit(0);
|
|
127
|
+
|
|
128
|
+
const pending = [];
|
|
129
|
+
const selfApproved = [];
|
|
130
|
+
|
|
131
|
+
for (const [path, info] of Object.entries(state.files)) {
|
|
132
|
+
if (!shouldTrackPath(path)) continue;
|
|
133
|
+
const meta = info && typeof info === "object" ? info : {};
|
|
134
|
+
const author = String(meta.author || "").toLowerCase();
|
|
135
|
+
const reviewer = String(meta.reviewer || "").toLowerCase();
|
|
136
|
+
const reviewed = meta.reviewed === true;
|
|
137
|
+
const requiredReviewer = expectedReviewer(author);
|
|
138
|
+
|
|
139
|
+
// tracker가 설정한 self_approved 플래그 명시적 체크
|
|
140
|
+
if (meta.self_approved === true) {
|
|
141
|
+
selfApproved.push({ path, author, reviewer: meta.reviewer || author, expectedReviewer: requiredReviewer });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (reviewed && reviewer && reviewer === author) {
|
|
146
|
+
selfApproved.push({ path, author, reviewer, expectedReviewer: requiredReviewer });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (reviewed && requiredReviewer && reviewer && reviewer !== requiredReviewer) {
|
|
151
|
+
selfApproved.push({ path, author, reviewer, expectedReviewer: requiredReviewer });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!reviewed) {
|
|
156
|
+
pending.push({ path, author, expectedReviewer: requiredReviewer });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (selfApproved.length > 0) {
|
|
161
|
+
const lines = selfApproved
|
|
162
|
+
.map((item) => ` * ${item.path} (author=${item.author}, reviewer=${item.reviewer}, required=${item.expectedReviewer || "n/a"})`)
|
|
163
|
+
.join("\n");
|
|
164
|
+
deny(
|
|
165
|
+
`[cross-review] self-approve 차단: 동일/비허용 reviewer가 감지되었습니다.\n${lines}\n` +
|
|
166
|
+
"규칙: author=claude -> reviewer=codex, author=codex -> reviewer=claude",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (pending.length > 0) {
|
|
171
|
+
nudge(
|
|
172
|
+
`[cross-review] git commit 전에 교차 검증이 필요합니다.\n${summarizePending(pending)}\n` +
|
|
173
|
+
"규칙: author=claude -> reviewer=codex, author=codex -> reviewer=claude",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
5
|
+
|
|
6
|
+
const SESSION_TTL_SEC = 30 * 60;
|
|
7
|
+
const STATE_REL_PATH = join(".omc", "state", "cross-review.json");
|
|
8
|
+
const EXCLUDED_FILE_PATTERN = /\.(md|lock|yml|yaml)$/i;
|
|
9
|
+
|
|
10
|
+
function nowSec() {
|
|
11
|
+
return Math.floor(Date.now() / 1000);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readStdin() {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let raw = "";
|
|
17
|
+
process.stdin.setEncoding("utf8");
|
|
18
|
+
process.stdin.on("data", (chunk) => {
|
|
19
|
+
raw += chunk;
|
|
20
|
+
});
|
|
21
|
+
process.stdin.on("end", () => resolve(raw));
|
|
22
|
+
process.stdin.on("error", () => resolve(""));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseJson(raw) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveBaseDir(payload) {
|
|
35
|
+
if (typeof payload?.cwd === "string" && payload.cwd.trim()) return payload.cwd;
|
|
36
|
+
if (typeof payload?.directory === "string" && payload.directory.trim()) return payload.directory;
|
|
37
|
+
return process.cwd();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveStatePath(baseDir) {
|
|
41
|
+
return join(baseDir, STATE_REL_PATH);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createEmptyState() {
|
|
45
|
+
return {
|
|
46
|
+
files: {},
|
|
47
|
+
session_start: nowSec(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadState(statePath) {
|
|
52
|
+
if (!existsSync(statePath)) return createEmptyState();
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(statePath, "utf8"));
|
|
56
|
+
const sessionStart = Number(parsed?.session_start || 0);
|
|
57
|
+
const expired = !sessionStart || nowSec() - sessionStart > SESSION_TTL_SEC;
|
|
58
|
+
if (expired) {
|
|
59
|
+
try {
|
|
60
|
+
unlinkSync(statePath);
|
|
61
|
+
} catch {}
|
|
62
|
+
return createEmptyState();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
files: parsed?.files && typeof parsed.files === "object" ? parsed.files : {},
|
|
67
|
+
session_start: sessionStart,
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
return createEmptyState();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function saveState(statePath, state) {
|
|
75
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
76
|
+
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizePath(filePath, baseDir) {
|
|
80
|
+
if (typeof filePath !== "string" || !filePath.trim()) return "";
|
|
81
|
+
|
|
82
|
+
const raw = filePath.trim();
|
|
83
|
+
let normalized = raw;
|
|
84
|
+
|
|
85
|
+
if (isAbsolute(raw)) {
|
|
86
|
+
const relPath = relative(baseDir, raw);
|
|
87
|
+
if (relPath.startsWith("..")) return "";
|
|
88
|
+
normalized = relPath;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return normalized.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shouldTrackPath(filePath) {
|
|
95
|
+
if (!filePath) return false;
|
|
96
|
+
const lower = filePath.toLowerCase();
|
|
97
|
+
|
|
98
|
+
if (lower.startsWith(".omc/") || lower.startsWith(".claude/")) return false;
|
|
99
|
+
if (lower === "package-lock.json" || lower.endsWith("/package-lock.json")) return false;
|
|
100
|
+
if (EXCLUDED_FILE_PATTERN.test(lower)) return false;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractFilePath(toolInput) {
|
|
105
|
+
if (!toolInput || typeof toolInput !== "object") return "";
|
|
106
|
+
const candidate = toolInput.file_path ?? toolInput.path ?? toolInput.filePath ?? "";
|
|
107
|
+
return typeof candidate === "string" ? candidate : "";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractCandidatePaths(payload, baseDir) {
|
|
111
|
+
const candidates = new Set();
|
|
112
|
+
|
|
113
|
+
const looksLikePath = (value) => {
|
|
114
|
+
if (typeof value !== "string") return false;
|
|
115
|
+
const trimmed = value.trim();
|
|
116
|
+
if (!trimmed || /\s/.test(trimmed)) return false;
|
|
117
|
+
if (trimmed.length > 260) return false;
|
|
118
|
+
if (!trimmed.includes(".") && !trimmed.includes("/") && !trimmed.includes("\\")) return false;
|
|
119
|
+
return /^[./\\A-Za-z0-9_-]/.test(trimmed);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const addPath = (value) => {
|
|
123
|
+
if (!looksLikePath(value)) return;
|
|
124
|
+
const normalized = normalizePath(value, baseDir);
|
|
125
|
+
if (shouldTrackPath(normalized)) candidates.add(normalized);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const scanValue = (value, depth = 0) => {
|
|
129
|
+
if (depth > 3 || value == null) return;
|
|
130
|
+
if (typeof value === "string") {
|
|
131
|
+
addPath(value);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(value)) {
|
|
135
|
+
for (const item of value) scanValue(item, depth + 1);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (typeof value !== "object") return;
|
|
139
|
+
|
|
140
|
+
for (const [key, child] of Object.entries(value)) {
|
|
141
|
+
const keyLower = key.toLowerCase();
|
|
142
|
+
if (keyLower.includes("file") || keyLower.includes("path")) {
|
|
143
|
+
scanValue(child, depth + 1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
addPath(extractFilePath(payload?.tool_input));
|
|
149
|
+
|
|
150
|
+
scanValue(payload?.tool_response);
|
|
151
|
+
scanValue(payload?.tool_output);
|
|
152
|
+
scanValue(payload?.result);
|
|
153
|
+
scanValue(payload?.output);
|
|
154
|
+
|
|
155
|
+
return [...candidates];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function collectStrings(value, out = [], depth = 0) {
|
|
159
|
+
if (depth > 4) return out;
|
|
160
|
+
if (typeof value === "string") {
|
|
161
|
+
out.push(value);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
if (!value || typeof value !== "object") return out;
|
|
165
|
+
if (Array.isArray(value)) {
|
|
166
|
+
for (const item of value) collectStrings(item, out, depth + 1);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const key of Object.keys(value)) {
|
|
171
|
+
collectStrings(value[key], out, depth + 1);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function detectCliActor(payload) {
|
|
177
|
+
const lines = collectStrings(payload).join("\n");
|
|
178
|
+
const match = lines.match(/\bcli\s*[:=]\s*(claude|codex|gemini)\b/i);
|
|
179
|
+
return match ? match[1].toLowerCase() : "";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function detectAuthor(payload) {
|
|
183
|
+
const actor = detectCliActor(payload);
|
|
184
|
+
if (actor) return actor;
|
|
185
|
+
return "claude";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function expectedReviewer(author) {
|
|
189
|
+
if (author === "claude") return "codex";
|
|
190
|
+
if (author === "codex") return "claude";
|
|
191
|
+
if (author === "gemini") return "claude";
|
|
192
|
+
return "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function applyReviewer(state, reviewer, ts) {
|
|
196
|
+
for (const [filePath, meta] of Object.entries(state.files)) {
|
|
197
|
+
if (!meta || typeof meta !== "object") continue;
|
|
198
|
+
if (!shouldTrackPath(filePath)) continue;
|
|
199
|
+
|
|
200
|
+
const author = String(meta.author || "").toLowerCase();
|
|
201
|
+
const expected = expectedReviewer(author);
|
|
202
|
+
|
|
203
|
+
if (expected && reviewer === expected) {
|
|
204
|
+
meta.reviewed = true;
|
|
205
|
+
meta.reviewer = reviewer;
|
|
206
|
+
meta.reviewed_ts = ts;
|
|
207
|
+
delete meta.self_approved;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (reviewer === author) {
|
|
212
|
+
meta.reviewed = false;
|
|
213
|
+
meta.reviewer = reviewer;
|
|
214
|
+
meta.reviewed_ts = ts;
|
|
215
|
+
meta.self_approved = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function main() {
|
|
221
|
+
if (process.env.TFX_SKIP_CROSS_REVIEW === "1") {
|
|
222
|
+
process.exit(0);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const raw = await readStdin();
|
|
226
|
+
if (!raw.trim()) {
|
|
227
|
+
process.exit(0);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const payload = parseJson(raw);
|
|
231
|
+
if (!payload) {
|
|
232
|
+
process.exit(0);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const baseDir = resolveBaseDir(payload);
|
|
236
|
+
const statePath = resolveStatePath(baseDir);
|
|
237
|
+
const state = loadState(statePath);
|
|
238
|
+
const toolName = payload.tool_name || "";
|
|
239
|
+
const ts = nowSec();
|
|
240
|
+
let changed = false;
|
|
241
|
+
|
|
242
|
+
if (toolName === "Edit" || toolName === "Write") {
|
|
243
|
+
const toolInput = payload.tool_input || {};
|
|
244
|
+
const normalizedPath = normalizePath(extractFilePath(toolInput), baseDir);
|
|
245
|
+
if (shouldTrackPath(normalizedPath)) {
|
|
246
|
+
state.files[normalizedPath] = {
|
|
247
|
+
author: detectAuthor(payload),
|
|
248
|
+
ts,
|
|
249
|
+
reviewed: false,
|
|
250
|
+
};
|
|
251
|
+
changed = true;
|
|
252
|
+
}
|
|
253
|
+
} else if (toolName === "Bash") {
|
|
254
|
+
const actor = detectCliActor(payload);
|
|
255
|
+
if (actor) {
|
|
256
|
+
const paths = extractCandidatePaths(payload, baseDir);
|
|
257
|
+
if (paths.length > 0) {
|
|
258
|
+
for (const path of paths) {
|
|
259
|
+
state.files[path] = {
|
|
260
|
+
author: actor,
|
|
261
|
+
ts,
|
|
262
|
+
reviewed: false,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
applyReviewer(state, actor, ts);
|
|
267
|
+
}
|
|
268
|
+
changed = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (changed) {
|
|
273
|
+
saveState(statePath, state);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const SESSION_PREFIX = "tfx-isolated";
|
|
7
|
+
const DEFAULT_ATTACH_PROFILE = "triflux";
|
|
8
|
+
const SESSION_EXPIRE_MS = 30 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
const STOP_WORDS = new Set([
|
|
11
|
+
"a", "an", "and", "as", "at", "be", "by", "for", "from", "in",
|
|
12
|
+
"is", "it", "of", "on", "or", "that", "the", "to", "with",
|
|
13
|
+
"작업", "요청", "합니다", "그리고", "에서", "으로",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// ── psmux helpers ──
|
|
17
|
+
|
|
18
|
+
function hasPsmux() {
|
|
19
|
+
try {
|
|
20
|
+
execFileSync("psmux", ["-V"], { timeout: 2000, stdio: "ignore" });
|
|
21
|
+
return true;
|
|
22
|
+
} catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function psmux(...args) {
|
|
26
|
+
execFileSync("psmux", args, { timeout: 5000, stdio: "ignore" });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function psmuxCapture(sessionName) {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync("psmux", ["capture-pane", "-t", sessionName, "-p"], {
|
|
32
|
+
timeout: 5000, encoding: "utf8",
|
|
33
|
+
}).trim();
|
|
34
|
+
} catch { return ""; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function psmuxHasSession(sessionName) {
|
|
38
|
+
try {
|
|
39
|
+
execFileSync("psmux", ["has-session", "-t", sessionName], { timeout: 2000, stdio: "ignore" });
|
|
40
|
+
return true;
|
|
41
|
+
} catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── core functions ──
|
|
45
|
+
|
|
46
|
+
export function createIsolatedSessionName(timestamp = Date.now()) {
|
|
47
|
+
return `${SESSION_PREFIX}-${Math.trunc(timestamp)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createIsolatedSession(options = {}) {
|
|
51
|
+
const ts = options.timestamp ?? Date.now();
|
|
52
|
+
const sessionName = options.name || createIsolatedSessionName(ts);
|
|
53
|
+
|
|
54
|
+
psmux("new-session", "-s", sessionName, "-d");
|
|
55
|
+
|
|
56
|
+
// cd to project root
|
|
57
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
58
|
+
psmux("send-keys", "-t", sessionName, `cd '${projectRoot}'`, "Enter");
|
|
59
|
+
|
|
60
|
+
// send prompt as claude command
|
|
61
|
+
if (options.prompt) {
|
|
62
|
+
const safePrompt = options.prompt.replace(/'/g, "'\\''");
|
|
63
|
+
psmux("send-keys", "-t", sessionName, `claude --prompt '${safePrompt}'`, "Enter");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { sessionName };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function attachWithWindowsTerminal(sessionName, options = {}) {
|
|
70
|
+
const profile = options.profile || DEFAULT_ATTACH_PROFILE;
|
|
71
|
+
const title = options.title || sessionName;
|
|
72
|
+
|
|
73
|
+
// sp (split-pane), not new-tab
|
|
74
|
+
const wtArgs = ["sp", "-p", profile, "--title", title, "--", "psmux", "attach-session", "-t", sessionName];
|
|
75
|
+
const child = spawn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false });
|
|
76
|
+
child.unref();
|
|
77
|
+
return wtArgs;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function waitForCompletion(sessionName, opts = {}) {
|
|
81
|
+
const pollMs = opts.pollMs || 3000;
|
|
82
|
+
const maxMs = opts.maxMs || SESSION_EXPIRE_MS;
|
|
83
|
+
const start = Date.now();
|
|
84
|
+
|
|
85
|
+
return new Promise((res) => {
|
|
86
|
+
const check = () => {
|
|
87
|
+
if (!psmuxHasSession(sessionName) || Date.now() - start > maxMs) {
|
|
88
|
+
const output = psmuxCapture(sessionName);
|
|
89
|
+
// cleanup expired session
|
|
90
|
+
try { psmux("kill-session", "-t", sessionName); } catch {}
|
|
91
|
+
res({ sessionName, output, expired: Date.now() - start > maxMs });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setTimeout(check, pollMs);
|
|
95
|
+
};
|
|
96
|
+
check();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── context drift (kept from codex) ──
|
|
101
|
+
|
|
102
|
+
function tokenize(text) {
|
|
103
|
+
return String(text || "").toLowerCase()
|
|
104
|
+
.split(/[^\p{L}\p{N}_-]+/u)
|
|
105
|
+
.filter((t) => t.length >= 2 && !STOP_WORDS.has(t));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function evaluateContextDrift(input = {}) {
|
|
109
|
+
const taskTokens = Array.from(new Set(tokenize(input.taskPrompt)));
|
|
110
|
+
if (!taskTokens.length) return { drift: false, overlapRatio: 1, reason: "task-token-empty" };
|
|
111
|
+
|
|
112
|
+
const outputTokens = new Set(tokenize(input.latestOutput));
|
|
113
|
+
const matched = taskTokens.filter((t) => outputTokens.has(t));
|
|
114
|
+
const ratio = matched.length / taskTokens.length;
|
|
115
|
+
const threshold = input.minOverlapRatio ?? 0.2;
|
|
116
|
+
|
|
117
|
+
return { drift: ratio < threshold, overlapRatio: ratio, reason: ratio < threshold ? "token-overlap-low" : "token-overlap-ok" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── CLI ──
|
|
121
|
+
|
|
122
|
+
function parseArgs(argv) {
|
|
123
|
+
const a = { spawn: false, prompt: "", attach: false, background: false, name: "" };
|
|
124
|
+
for (let i = 2; i < argv.length; i++) {
|
|
125
|
+
const arg = argv[i];
|
|
126
|
+
if (arg === "--spawn") { a.spawn = true; continue; }
|
|
127
|
+
if (arg === "--attach") { a.attach = true; continue; }
|
|
128
|
+
if (arg === "--background") { a.background = true; continue; }
|
|
129
|
+
if ((arg === "--prompt" || arg === "-p") && argv[i + 1]) { a.prompt = argv[++i]; continue; }
|
|
130
|
+
if ((arg === "--name" || arg === "-n") && argv[i + 1]) { a.name = argv[++i]; continue; }
|
|
131
|
+
}
|
|
132
|
+
return a;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function main() {
|
|
136
|
+
const args = parseArgs(process.argv);
|
|
137
|
+
|
|
138
|
+
if (!args.spawn) {
|
|
139
|
+
process.stdout.write([
|
|
140
|
+
"session-spawn-helper: psmux 격리 세션 생성 도구",
|
|
141
|
+
"",
|
|
142
|
+
"사용법:",
|
|
143
|
+
" node scripts/session-spawn-helper.mjs --spawn --prompt '작업 내용' [--attach] [--background] [--name 세션명]",
|
|
144
|
+
"",
|
|
145
|
+
"옵션:",
|
|
146
|
+
" --spawn 세션 생성 (필수)",
|
|
147
|
+
" --prompt TEXT Claude에 전달할 프롬프트",
|
|
148
|
+
" --attach WT split-pane으로 attach",
|
|
149
|
+
" --background attach 없이 실행, 완료 시 결과 출력",
|
|
150
|
+
" --name NAME 세션 이름 (기본: tfx-isolated-{ts})",
|
|
151
|
+
"",
|
|
152
|
+
].join("\n"));
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!hasPsmux()) {
|
|
157
|
+
process.stderr.write("ERROR: psmux가 설치되어 있지 않습니다. npm install -g psmux\n");
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { sessionName } = createIsolatedSession({
|
|
162
|
+
name: args.name || undefined,
|
|
163
|
+
prompt: args.prompt || undefined,
|
|
164
|
+
projectRoot: process.cwd(),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
process.stdout.write(`[session-spawn] 세션 생성: ${sessionName}\n`);
|
|
168
|
+
|
|
169
|
+
if (args.attach) {
|
|
170
|
+
attachWithWindowsTerminal(sessionName);
|
|
171
|
+
process.stdout.write(`[session-spawn] WT split-pane attach 완료\n`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (args.background) {
|
|
175
|
+
process.stdout.write(`[session-spawn] 백그라운드 대기 중...\n`);
|
|
176
|
+
const result = await waitForCompletion(sessionName);
|
|
177
|
+
const preview = (result.output || "(no output)").slice(0, 200);
|
|
178
|
+
process.stdout.write(`[session-spawn] 완료: ${sessionName} | expired=${result.expired} | preview=${preview}\n`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (process.argv[1]?.endsWith("session-spawn-helper.mjs")) {
|
|
183
|
+
main().catch((e) => { process.stderr.write(`${e.message}\n`); process.exit(1); });
|
|
184
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: merge-worktree
|
|
3
|
+
description: "워크트리 브랜치를 main으로 squash-merge + conventional commit 자동 생성. codex-swarm 워크트리 자동 인식. '머지해', 'merge worktree', '워크트리 머지', '결과 수집', 'squash merge' 요청에 사용."
|
|
4
|
+
argument-hint: "[target-branch]"
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Merge Worktree
|
|
9
|
+
|
|
10
|
+
워크트리 브랜치를 대상 브랜치로 squash-merge하고 conventional commit 메시지를 자동 작성한다.
|
|
11
|
+
|
|
12
|
+
## Current context
|
|
13
|
+
|
|
14
|
+
* Git dir: `!git rev-parse --git-dir`
|
|
15
|
+
* Current branch: `!git branch --show-current`
|
|
16
|
+
* Recent commits: `!git log --oneline -20`
|
|
17
|
+
* Working tree status: `!git status --short`
|
|
18
|
+
|
|
19
|
+
## Instructions
|
|
20
|
+
|
|
21
|
+
### Phase 1: Validation
|
|
22
|
+
|
|
23
|
+
1. **Worktree 확인**: `git rev-parse --git-dir` 출력에 `/worktrees/`가 포함되어야 한다. 아니면 중지.
|
|
24
|
+
|
|
25
|
+
2. **현재 브랜치 확인**: `git branch --show-current`
|
|
26
|
+
|
|
27
|
+
3. **대상 브랜치 결정**:
|
|
28
|
+
* `$ARGUMENTS`가 있으면 해당 브랜치 사용
|
|
29
|
+
* 없으면 `main` 존재 확인, 없으면 `master`
|
|
30
|
+
|
|
31
|
+
4. **원본 레포 경로 확인**: `git rev-parse --git-common-dir`의 부모 디렉토리
|
|
32
|
+
|
|
33
|
+
5. **클린 상태 확인**: `git status --porcelain`이 비어있어야 한다. 미커밋 변경이 있으면 먼저 커밋/스태시 안내.
|
|
34
|
+
|
|
35
|
+
### Phase 2: Research
|
|
36
|
+
|
|
37
|
+
1. **커밋 이력**: `git log --oneline <target>..HEAD`
|
|
38
|
+
|
|
39
|
+
2. **변경 파일 요약**: `git diff <target>...HEAD --stat`
|
|
40
|
+
|
|
41
|
+
3. **전체 diff**: `git diff <target>...HEAD` — 꼼꼼히 읽는다.
|
|
42
|
+
|
|
43
|
+
4. **핵심 파일 읽기**: 가장 큰 변경, 신규 파일, 삭제 파일을 Read로 확인.
|
|
44
|
+
|
|
45
|
+
5. **변경 분류**:
|
|
46
|
+
* Features (신규 기능)
|
|
47
|
+
* Fixes (버그 수정)
|
|
48
|
+
* Refactors (구조 변경)
|
|
49
|
+
* Tests (테스트)
|
|
50
|
+
* Docs (문서)
|
|
51
|
+
* Config/Chore (빌드, CI, 의존성)
|
|
52
|
+
|
|
53
|
+
6. **dominant type 결정**: `feat`, `fix`, `refactor`, `docs`, `chore`, `test` 중 하나
|
|
54
|
+
|
|
55
|
+
### Phase 3: 대상 브랜치 준비
|
|
56
|
+
|
|
57
|
+
1. **대상 브랜치 최근 커밋 확인**: `git -C <원본레포> log --oneline -10 <target>`
|
|
58
|
+
|
|
59
|
+
2. **WIP 커밋 감지**: `wip:`, `auto-commit`, `WIP` 시작 커밋이 있으면 사용자에게 경고.
|
|
60
|
+
|
|
61
|
+
3. **최신 fetch**: `git -C <원본레포> fetch origin <target> 2>/dev/null`
|
|
62
|
+
|
|
63
|
+
### Phase 4: Squash Merge
|
|
64
|
+
|
|
65
|
+
1. **대상 브랜치 checkout**:
|
|
66
|
+
```
|
|
67
|
+
git -C <원본레포> checkout <target>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
2. **squash merge 실행**:
|
|
71
|
+
```
|
|
72
|
+
git -C <원본레포> merge --squash <워크트리브랜치>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
3. **충돌 처리**: 충돌 발생 시 충돌 파일 목록 + 마커를 보여주고 **중지**. 자동 해결 시도 금지.
|
|
76
|
+
|
|
77
|
+
### Phase 5: 커밋 메시지 작성 + 커밋
|
|
78
|
+
|
|
79
|
+
Phase 2 분석 기반으로 아래 구조의 커밋 메시지를 작성한다:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
<type>: <명령형 요약, 72자 이내, 마침표 없음>
|
|
83
|
+
|
|
84
|
+
<무엇을 왜 했는지 2-4문장. 동기와 접근 방식 중심.>
|
|
85
|
+
|
|
86
|
+
Changes:
|
|
87
|
+
* <그룹별 변경 사항>
|
|
88
|
+
* <하위 항목은 서브 불릿>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**규칙:**
|
|
92
|
+
* `<type>`은 `feat`, `fix`, `refactor`, `docs`, `chore`, `test` 중 하나
|
|
93
|
+
* 여러 유형이 섞이면 dominant 사용
|
|
94
|
+
* 요약: 명령형 ("add", "fix", "refactor"), 마침표 없음, 72자 제한
|
|
95
|
+
* 본문: *왜*와 *맥락*, *무엇*만이 아님
|
|
96
|
+
* Changes: 관련 항목 그룹핑, 중요한 것 먼저
|
|
97
|
+
* Co-Authored-By 푸터 **절대 추가 금지** (글로벌 설정 `includeCoAuthoredBy: false`)
|
|
98
|
+
|
|
99
|
+
**커밋 실행**:
|
|
100
|
+
```bash
|
|
101
|
+
git -C <원본레포> commit -m "$(cat <<'EOF'
|
|
102
|
+
<커밋 메시지>
|
|
103
|
+
EOF
|
|
104
|
+
)"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Phase 6: 정리 + 검증
|
|
108
|
+
|
|
109
|
+
1. **커밋 확인**: `git -C <원본레포> log --oneline -3`
|
|
110
|
+
|
|
111
|
+
2. **워크트리 자동 정리**:
|
|
112
|
+
```bash
|
|
113
|
+
git -C <원본레포> worktree remove <워크트리경로>
|
|
114
|
+
git -C <원본레포> branch -d <워크트리브랜치>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
3. **codex-swarm 정리 감지**: 워크트리 경로가 `.codex-swarm/wt-*` 패턴이면:
|
|
118
|
+
* 같은 `.codex-swarm/` 디렉토리에 다른 워크트리가 남아있는지 확인
|
|
119
|
+
* 모든 워크트리가 머지 완료되었으면 `.codex-swarm/` 전체 정리 제안
|
|
120
|
+
* `git worktree prune` 실행
|
|
121
|
+
|
|
122
|
+
4. **결과 보고**:
|
|
123
|
+
* 커밋 해시 + 요약
|
|
124
|
+
* 머지 대상 브랜치
|
|
125
|
+
* 워크트리 정리 완료 여부
|
|
126
|
+
* push 안내 (`git push`)
|
|
127
|
+
|
|
128
|
+
## codex-swarm 연동
|
|
129
|
+
|
|
130
|
+
이 스킬은 `tfx-codex-swarm`의 Step 10 "결과 수집"에서 자동으로 호출된다.
|
|
131
|
+
codex-swarm이 완료한 각 워크트리에 대해 순차적으로 실행:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
각 워크트리에 대해:
|
|
135
|
+
1. 워크트리로 cd
|
|
136
|
+
2. /merge-worktree main
|
|
137
|
+
3. 다음 워크트리로 이동
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## 주의사항
|
|
141
|
+
|
|
142
|
+
* force-push, destructive 연산은 사용자 확인 없이 절대 실행 금지
|
|
143
|
+
* pre-commit hook 건너뛰기(`--no-verify`) 금지
|
|
144
|
+
* 예상치 못한 상황이면 추측하지 말고 **중지 후 설명**
|
|
@@ -8,6 +8,7 @@ argument-hint: "\"작업 설명\" | N:agent_type \"작업 설명\""
|
|
|
8
8
|
|
|
9
9
|
# tfx-auto-codex — Codex 리드형 tfx-auto
|
|
10
10
|
|
|
11
|
+
> **래퍼**: tfx-auto의 Codex 전용 바로가기. TFX_NO_CLAUDE_NATIVE=1.
|
|
11
12
|
> 목적: 기존 `tfx-auto`의 오케스트레이션 패턴을 유지하면서
|
|
12
13
|
> Claude 네이티브 역할(`explore`, `verifier`, `test-engineer`, `qa-tester`)을
|
|
13
14
|
> Codex로 치환해 Codex/Gemini만으로 실행한다.
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tfx-autopilot
|
|
3
|
-
description: "간단한 작업을 자율적으로 구현해야 할 때 사용한다. 'autopilot', '자동으로', '알아서 해', '그냥 해줘'
|
|
3
|
+
description: "간단한 작업을 자율적으로 구현해야 할 때 사용한다. 'autopilot', '자동으로', '알아서 해', '그냥 해줘' 같은 요청에 반드시 사용. 명확한 단일 작업을 빠르게 자동 구현+검증할 때 적극 활용."
|
|
4
4
|
triggers:
|
|
5
5
|
- autopilot
|
|
6
6
|
- 자동
|
|
7
7
|
- 알아서 해
|
|
8
|
-
- auto
|
|
9
8
|
argument-hint: "<구현할 작업 설명>"
|
|
10
9
|
---
|
|
11
10
|
|
|
@@ -8,6 +8,7 @@ argument-hint: "\"작업 설명\" | N:codex \"작업 설명\""
|
|
|
8
8
|
|
|
9
9
|
# tfx-codex — Codex-Only 오케스트레이터
|
|
10
10
|
|
|
11
|
+
> **래퍼**: tfx-auto의 Codex 전용 바로가기. TFX_CLI_MODE=codex.
|
|
11
12
|
> **HARD RULE**: Claude는 이 스킬에서 Edit/Write를 사용하면 안 된다. 모든 코드 수정은 Codex CLI를 통해 수행한다.
|
|
12
13
|
> Codex CLI만 사용하여 모든 외부 CLI 작업을 라우팅합니다.
|
|
13
14
|
> Gemini CLI가 없는 환경에서 사용합니다.
|
|
@@ -13,8 +13,18 @@ description: OMX 스킬을 활용하는 Codex 다중 세션 스폰 오케스트
|
|
|
13
13
|
- `codex` CLI 설치됨
|
|
14
14
|
- `psmux` 설치됨 (세션 관리)
|
|
15
15
|
- `git` (worktree 생성)
|
|
16
|
-
- Windows Terminal (탭 기반 attach)
|
|
17
16
|
- Windows Terminal (`wt.exe` 탭 기반 attach)
|
|
17
|
+
- MCP 싱글톤 데몬 (`supergateway` + `mcp-remote`) — 선택적이나 스웜 시 강력 권장
|
|
18
|
+
|
|
19
|
+
## 설정
|
|
20
|
+
|
|
21
|
+
| 설정 | 기본값 | 설명 |
|
|
22
|
+
|------|--------|------|
|
|
23
|
+
| MAX_CONCURRENCY | 4 | 동시 실행 세션 수. 초과분은 큐 대기 후 순차 시작 |
|
|
24
|
+
| WT_ATTACH_MODE | attach | `attach`: split-pane 직접 attach (기본). `dashboard`: 모니터링 탭만 |
|
|
25
|
+
| MCP_DAEMON_REQUIRED | true | MCP 싱글톤 데몬 사전 확인 필수. false면 세션별 MCP 직접 스폰 (비권장) |
|
|
26
|
+
|
|
27
|
+
사용자가 명시하지 않으면 기본값 사용. AskUserQuestion으로 오버라이드 가능.
|
|
18
28
|
|
|
19
29
|
## 워크플로우
|
|
20
30
|
|
|
@@ -267,7 +277,13 @@ git worktree add .codex-swarm/wt-issue-{N} codex/issue-{N}
|
|
|
267
277
|
cp {PRD_PATH} .codex-swarm/wt-issue-{N}/{PRD_PATH}
|
|
268
278
|
```
|
|
269
279
|
|
|
270
|
-
### Step 7: psmux 세션 생성 + Codex 실행
|
|
280
|
+
### Step 7: psmux 세션 생성 + Codex 실행 (웨이브 방식)
|
|
281
|
+
|
|
282
|
+
**MAX_CONCURRENCY(기본 4)에 따라 웨이브 단위로 실행한다.**
|
|
283
|
+
- Wave 1: 태스크 1~MAX_CONCURRENCY 동시 시작
|
|
284
|
+
- Wave 2+: 이전 웨이브에서 완료된 슬롯만큼 다음 태스크 시작
|
|
285
|
+
- 완료 감지: `psmux capture-pane`으로 codex 종료 여부 확인 (30초 폴링)
|
|
286
|
+
- 전체 태스크 > MAX_CONCURRENCY일 때만 큐잉 적용
|
|
271
287
|
|
|
272
288
|
각 태스크에 대해 psmux 세션을 생성하고 Codex를 실행한다:
|
|
273
289
|
|
|
@@ -293,33 +309,39 @@ psmux send-keys -t "codex-swarm-{id}" \
|
|
|
293
309
|
# --skip-git-repo-check: codex exec 전용이므로 대화식 모드에서 사용 불가
|
|
294
310
|
```
|
|
295
311
|
|
|
296
|
-
### Step 8: WT
|
|
312
|
+
### Step 8: WT attach
|
|
297
313
|
|
|
298
314
|
> WT `triflux` 프로파일 사용 필수 (`commandline: "psmux"`, One Half Dark, acrylic).
|
|
299
|
-
>
|
|
300
|
-
> **레이아웃 규칙:**
|
|
301
|
-
> - 세션 1-3개: split-pane only (탭 불필요)
|
|
302
|
-
> - 세션 4개+: **테마/웨이브별 탭 그룹핑** + 탭 내부 split-pane
|
|
303
|
-
> - 같은 카테고리/웨이브는 하나의 탭에 묶는다
|
|
304
|
-
> - 탭 내부 2-4개 split (가로 `-H` / 세로 `-V` 적절 배분)
|
|
305
|
-
> - 사용자가 탭/split 레이아웃을 명시하면 해당 지시를 우선한다
|
|
315
|
+
> 기본은 **split-pane 직접 attach**. 4개 이하 2x2 그리드, 5개 이상은 사용자 확인.
|
|
306
316
|
|
|
307
317
|
```bash
|
|
308
|
-
#
|
|
309
|
-
wt.exe -w
|
|
310
|
-
-p triflux --title "{
|
|
311
|
-
|
|
312
|
-
split-pane -V -p triflux --title "{title3}" psmux attach-session -t codex-swarm-{id3}
|
|
318
|
+
# 2개: 상하 분할
|
|
319
|
+
wt.exe -w 0 \
|
|
320
|
+
sp -H -p triflux --title "{t1}" psmux attach-session -t {id1} \; \
|
|
321
|
+
sp -V -p triflux --title "{t2}" psmux attach-session -t {id2}
|
|
313
322
|
|
|
314
|
-
#
|
|
323
|
+
# 4개: 2x2 그리드
|
|
315
324
|
wt.exe -w 0 \
|
|
316
|
-
-p triflux --title "
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
325
|
+
sp -H -p triflux --title "{t1}" psmux attach-session -t {id1} \; \
|
|
326
|
+
sp -V -p triflux --title "{t2}" psmux attach-session -t {id2} \; \
|
|
327
|
+
move-focus up \; \
|
|
328
|
+
sp -V -p triflux --title "{t3}" psmux attach-session -t {id3} \; \
|
|
329
|
+
move-focus down \; \
|
|
330
|
+
sp -V -p triflux --title "{t4}" psmux attach-session -t {id4}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
5개 이상이면 AskUserQuestion으로 확인 후 dashboard 모드 제안:
|
|
334
|
+
```bash
|
|
335
|
+
# dashboard 모드 (모니터링 전용)
|
|
336
|
+
wt.exe -w 0 -p triflux --title "swarm-dashboard" bash -c '
|
|
337
|
+
while true; do
|
|
338
|
+
clear; echo "=== Codex Swarm Dashboard ==="
|
|
339
|
+
for s in $(psmux list-sessions -F "#{session_name}" 2>/dev/null | grep codex-swarm); do
|
|
340
|
+
echo " [$s] $(psmux capture-pane -t "$s" -p | tail -1)"
|
|
341
|
+
done
|
|
342
|
+
sleep 10
|
|
343
|
+
done
|
|
344
|
+
'
|
|
323
345
|
```
|
|
324
346
|
|
|
325
347
|
### Step 9: 상태 보고
|
|
@@ -439,6 +461,21 @@ for bin in codex psmux git; do
|
|
|
439
461
|
exit 1
|
|
440
462
|
}
|
|
441
463
|
done
|
|
464
|
+
|
|
465
|
+
# MCP 싱글톤 데몬 검사 (MCP_DAEMON_REQUIRED=true일 때)
|
|
466
|
+
if [ "$MCP_DAEMON_REQUIRED" != "false" ]; then
|
|
467
|
+
DAEMON_OK=true
|
|
468
|
+
for port in 9001 9002 9003 9004 9005; do
|
|
469
|
+
curl -s --max-time 1 "http://localhost:$port/sse" >/dev/null 2>&1 || {
|
|
470
|
+
DAEMON_OK=false
|
|
471
|
+
break
|
|
472
|
+
}
|
|
473
|
+
done
|
|
474
|
+
if [ "$DAEMON_OK" = "false" ]; then
|
|
475
|
+
echo "WARN: MCP daemons not running. Starting..."
|
|
476
|
+
powershell -ExecutionPolicy Bypass -File "$HOME/.codex/mcp-daemon/start-daemons.ps1"
|
|
477
|
+
fi
|
|
478
|
+
fi
|
|
442
479
|
```
|
|
443
480
|
|
|
444
481
|
## 정리
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# MCP Singleton Daemons - supergateway wrapper
|
|
2
|
+
# Each OMX MCP server runs once, codex sessions connect via mcp-remote
|
|
3
|
+
# Usage: powershell -ExecutionPolicy Bypass -File start-daemons.ps1
|
|
4
|
+
|
|
5
|
+
$OMX_BASE = "$env:APPDATA/npm/node_modules/oh-my-codex/dist/mcp"
|
|
6
|
+
$SG_CMD = "$env:APPDATA\npm\supergateway.cmd"
|
|
7
|
+
$DAEMON_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
8
|
+
|
|
9
|
+
$servers = @(
|
|
10
|
+
@{ Name = "omx_state"; Script = "state-server.js"; Port = 9001 }
|
|
11
|
+
@{ Name = "omx_memory"; Script = "memory-server.js"; Port = 9002 }
|
|
12
|
+
@{ Name = "omx_code_intel"; Script = "code-intel-server.js"; Port = 9003 }
|
|
13
|
+
@{ Name = "omx_trace"; Script = "trace-server.js"; Port = 9004 }
|
|
14
|
+
@{ Name = "omx_team_run"; Script = "team-server.js"; Port = 9005 }
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
foreach ($srv in $servers) {
|
|
18
|
+
$port = $srv.Port
|
|
19
|
+
$name = $srv.Name
|
|
20
|
+
$script = "$OMX_BASE/$($srv.Script)"
|
|
21
|
+
|
|
22
|
+
# Check if already running on this port
|
|
23
|
+
$existing = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue
|
|
24
|
+
if ($existing) {
|
|
25
|
+
Write-Host "[SKIP] $name already running on port $port"
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Create individual launcher .cmd
|
|
30
|
+
$launcher = Join-Path $DAEMON_DIR "run-$name.cmd"
|
|
31
|
+
$content = "@echo off`r`ncall `"$SG_CMD`" --stdio `"node $script`" --port $port"
|
|
32
|
+
Set-Content -Path $launcher -Value $content -Encoding ASCII
|
|
33
|
+
|
|
34
|
+
Write-Host "[START] $name on port $port"
|
|
35
|
+
Start-Process -WindowStyle Hidden -FilePath $launcher
|
|
36
|
+
|
|
37
|
+
Start-Sleep -Milliseconds 800
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Start-Sleep -Milliseconds 1000
|
|
41
|
+
|
|
42
|
+
# Verify
|
|
43
|
+
$ok = 0
|
|
44
|
+
foreach ($srv in $servers) {
|
|
45
|
+
$c = Get-NetTCPConnection -LocalPort $srv.Port -ErrorAction SilentlyContinue
|
|
46
|
+
if ($c) {
|
|
47
|
+
Write-Host "[OK] $($srv.Name) listening on port $($srv.Port)"
|
|
48
|
+
$ok++
|
|
49
|
+
} else {
|
|
50
|
+
Write-Host "[FAIL] $($srv.Name) NOT listening on port $($srv.Port)"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
Write-Host ""
|
|
54
|
+
Write-Host "$ok / $($servers.Count) daemons running"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Stop all MCP singleton daemons
|
|
2
|
+
# Usage: powershell -ExecutionPolicy Bypass -File stop-daemons.ps1
|
|
3
|
+
|
|
4
|
+
9001..9005 | ForEach-Object {
|
|
5
|
+
$port = $_
|
|
6
|
+
$conn = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue
|
|
7
|
+
if ($conn) {
|
|
8
|
+
$pid = $conn[0].OwningProcess
|
|
9
|
+
Write-Host "[STOP] Killing PID $pid on port $port"
|
|
10
|
+
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
|
|
11
|
+
} else {
|
|
12
|
+
Write-Host "[SKIP] Nothing on port $port"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
Write-Host "All MCP daemons stopped."
|
|
@@ -14,6 +14,7 @@ argument-hint: "[--depth quick|standard|deep] <리서치 주제>"
|
|
|
14
14
|
|
|
15
15
|
# tfx-deep-research — Multi-Source Deep Research with Tri-CLI Consensus
|
|
16
16
|
|
|
17
|
+
> **Light 버전**: tfx-research. 기본값. 깊이 수정자 없으면 Light 선택.
|
|
17
18
|
> 쿼리 분해 → 3-CLI 독립 병렬 검색 → 교차검증 → 합의 기반 종합 보고서.
|
|
18
19
|
> STORM(Stanford) perspective-guided + GPT-Researcher recursive tree + Tavily deep research pipeline 영감.
|
|
19
20
|
|
package/skills/tfx-hub/SKILL.md
CHANGED
|
@@ -11,6 +11,7 @@ argument-hint: "<start|stop|status|자유형 작업 설명>"
|
|
|
11
11
|
|
|
12
12
|
# tfx-hub — MCP 메시지 버스 관리 + 개방형 작업
|
|
13
13
|
|
|
14
|
+
> **인프라**: 다른 스킬이 내부적으로 사용. 직접 호출할 필요 없음.
|
|
14
15
|
> CLI 에이전트(Codex/Gemini/Claude) 간 실시간 메시지 허브를 관리합니다.
|
|
15
16
|
> **커맨드 매칭 + fallthrough**: start/stop/status에 매칭되면 즉시 실행,
|
|
16
17
|
> 매칭 안 되면 **hub 도메인 컨텍스트를 활용한 범용 작업**으로 처리합니다.
|
|
@@ -8,6 +8,7 @@ argument-hint: '"작업 설명" | --agents codex,gemini "작업" | --tmux "작
|
|
|
8
8
|
|
|
9
9
|
# tfx-multi v3 — 파이프라인 기반 멀티-CLI 팀 오케스트레이터
|
|
10
10
|
|
|
11
|
+
> **인프라**: 다른 스킬이 내부적으로 사용. 직접 호출할 필요 없음.
|
|
11
12
|
> Claude Code Native Teams의 Shift+Down 네비게이션을 복원한다.
|
|
12
13
|
> Codex/Gemini 워커마다 최소 프롬프트(~100 토큰)의 슬림 Agent 래퍼를 spawn하여 네비게이션에 등록하고,
|
|
13
14
|
> 실제 작업은 `tfx-route.sh`가 수행한다. task 상태는 `team_task_list`를 truth source로 검증한다.
|
package/skills/tfx-plan/SKILL.md
CHANGED
package/skills/tfx-qa/SKILL.md
CHANGED
|
@@ -2,16 +2,13 @@
|
|
|
2
2
|
name: tfx-ralph
|
|
3
3
|
description: "작업이 완전히 끝날 때까지 멈추지 않고 반복 실행해야 할 때 사용한다. 'ralph', '끝까지 해', '멈추지 마', 'don't stop', '완료될 때까지', '다 될 때까지 계속' 같은 요청에 반드시 사용. 여러 기준을 모두 충족해야 하는 복잡한 구현 작업에 적극 활용."
|
|
4
4
|
triggers:
|
|
5
|
-
- ralph
|
|
6
|
-
- don't stop
|
|
7
|
-
- 끝까지
|
|
8
|
-
- until done
|
|
9
|
-
- 멈추지 마
|
|
5
|
+
- tfx-ralph
|
|
10
6
|
argument-hint: "<완료할 작업 설명>"
|
|
11
7
|
---
|
|
12
8
|
|
|
13
9
|
# tfx-ralph — Alias for tfx-persist
|
|
14
10
|
|
|
11
|
+
> **래퍼**: tfx-persist의 alias. 동일 기능.
|
|
15
12
|
> `tfx-ralph`는 `tfx-persist`의 alias이다. 동일한 Tri-Verified Persistence Loop을 실행한다.
|
|
16
13
|
> The boulder never stops — but it stops being wrong.
|
|
17
14
|
|
|
@@ -11,6 +11,7 @@ argument-hint: "[파일 경로 또는 변경 설명]"
|
|
|
11
11
|
|
|
12
12
|
# tfx-review — Light Code Review
|
|
13
13
|
|
|
14
|
+
> **Deep 버전**: tfx-deep-review. "제대로/꼼꼼히" 수정자로 자동 에스컬레이션.
|
|
14
15
|
> **HARD RULE**: 리뷰 결과를 생성할 때 Claude가 직접 git log/diff를 실행하지 마라. Codex code-reviewer에게 위임하라.
|
|
15
16
|
> Codex 단일 리뷰로 빠른 피드백. 토큰 최소화.
|
|
16
17
|
|