triflux 10.0.4 → 10.0.6
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/hooks/hook-orchestrator.mjs +48 -1
- package/hooks/keyword-rules.json +12 -26
- package/hub/cli-adapter-base.mjs +7 -6
- package/hub/team/swarm-hypervisor.mjs +24 -0
- package/hub/team/worktree-lifecycle.mjs +37 -1
- package/package.json +1 -1
- package/scripts/__tests__/gen-skill-docs.test.mjs +89 -1
- package/scripts/__tests__/keyword-detector.test.mjs +5 -5
- package/scripts/__tests__/skill-template.test.mjs +90 -0
- package/scripts/gen-skill-docs.mjs +4 -1
- package/scripts/gen-skill-manifest.mjs +83 -0
- package/scripts/lib/skill-template.mjs +24 -0
- package/scripts/pack.mjs +1 -1
- package/skills/merge-worktree/skill.json +5 -0
- package/skills/star-prompt/skill.json +4 -0
- package/skills/tfx-analysis/SKILL.md +1 -0
- package/skills/tfx-analysis/skill.json +12 -0
- package/skills/tfx-auto/SKILL.md +45 -0
- package/skills/tfx-auto/SKILL.md.tmpl +45 -0
- package/skills/tfx-auto/skill.json +26 -0
- package/skills/tfx-auto-codex/SKILL.md +1 -0
- package/skills/tfx-auto-codex/skill.json +9 -0
- package/skills/tfx-autopilot/SKILL.md +1 -0
- package/skills/tfx-autopilot/skill.json +11 -0
- package/skills/tfx-autoresearch/SKILL.md +1 -0
- package/skills/tfx-autoresearch/skill.json +15 -0
- package/skills/tfx-autoroute/SKILL.md +1 -0
- package/skills/tfx-autoroute/skill.json +13 -0
- package/skills/tfx-codex/SKILL.md +1 -0
- package/skills/tfx-codex/SKILL.md.tmpl +1 -0
- package/skills/tfx-codex/skill.json +9 -0
- package/skills/tfx-codex-swarm/skill.json +8 -0
- package/skills/tfx-consensus/SKILL.md +1 -0
- package/skills/tfx-consensus/skill.json +7 -0
- package/skills/tfx-debate/SKILL.md +1 -0
- package/skills/tfx-debate/skill.json +13 -0
- package/skills/tfx-deep-analysis/SKILL.md +1 -0
- package/skills/tfx-deep-analysis/skill.json +11 -0
- package/skills/tfx-deep-interview/skill.json +12 -0
- package/skills/tfx-deep-plan/SKILL.md +1 -0
- package/skills/tfx-deep-plan/skill.json +14 -0
- package/skills/tfx-deep-qa/SKILL.md +1 -0
- package/skills/tfx-deep-qa/skill.json +12 -0
- package/skills/tfx-deep-research/SKILL.md +1 -0
- package/skills/tfx-deep-research/skill.json +15 -0
- package/skills/tfx-deep-review/SKILL.md +1 -0
- package/skills/tfx-deep-review/skill.json +13 -0
- package/skills/tfx-doctor/skill.json +8 -0
- package/skills/tfx-find/SKILL.md +1 -0
- package/skills/tfx-find/SKILL.md.tmpl +1 -0
- package/skills/tfx-find/skill.json +13 -0
- package/skills/tfx-forge/skill.json +12 -0
- package/skills/tfx-fullcycle/SKILL.md +1 -0
- package/skills/tfx-fullcycle/skill.json +12 -0
- package/skills/tfx-gemini/SKILL.md +1 -0
- package/skills/tfx-gemini/skill.json +9 -0
- package/skills/tfx-hooks/skill.json +8 -0
- package/skills/tfx-hub/skill.json +8 -0
- package/skills/tfx-index/skill.json +11 -0
- package/skills/tfx-interview/SKILL.md +1 -0
- package/skills/tfx-interview/skill.json +13 -0
- package/skills/tfx-multi/skill.json +8 -0
- package/skills/tfx-panel/SKILL.md +1 -0
- package/skills/tfx-panel/skill.json +13 -0
- package/skills/tfx-persist/SKILL.md +1 -0
- package/skills/tfx-persist/skill.json +13 -0
- package/skills/tfx-plan/SKILL.md +1 -0
- package/skills/tfx-plan/SKILL.md.tmpl +1 -0
- package/skills/tfx-plan/skill.json +12 -0
- package/skills/tfx-profile/skill.json +8 -0
- package/skills/tfx-prune/SKILL.md +1 -0
- package/skills/tfx-prune/skill.json +13 -0
- package/skills/tfx-psmux-rules/skill.json +8 -0
- package/skills/tfx-qa/SKILL.md +1 -0
- package/skills/tfx-qa/SKILL.md.tmpl +1 -0
- package/skills/tfx-qa/skill.json +12 -0
- package/skills/tfx-ralph/SKILL.md +1 -0
- package/skills/tfx-ralph/skill.json +9 -0
- package/skills/tfx-remote-setup/skill.json +8 -0
- package/skills/tfx-remote-spawn/skill.json +9 -0
- package/skills/tfx-research/SKILL.md +1 -0
- package/skills/tfx-research/SKILL.md.tmpl +1 -0
- package/skills/tfx-research/skill.json +14 -0
- package/skills/tfx-review/SKILL.md +1 -0
- package/skills/tfx-review/SKILL.md.tmpl +1 -0
- package/skills/tfx-review/skill.json +12 -0
- package/skills/tfx-setup/skill.json +8 -0
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// CLAUDE_PLUGIN_ROOT — ${PLUGIN_ROOT} 치환용
|
|
20
20
|
// HOME / USERPROFILE — ${HOME} 치환용
|
|
21
21
|
|
|
22
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
22
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
23
23
|
import { join, dirname } from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
import { execFileSync, execFile } from "node:child_process";
|
|
@@ -210,8 +210,55 @@ function mergeOutputs(accumulated, newOutput) {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
// ── 라우팅 가중치 기록 ────────────────────────────────────────
|
|
214
|
+
function recordRouteOutcome(slug, mode, outcome) {
|
|
215
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
216
|
+
const projDir = join(home, ".gstack", "projects", slug);
|
|
217
|
+
const weightsPath = join(projDir, "routing-weights.json");
|
|
218
|
+
|
|
219
|
+
mkdirSync(projDir, { recursive: true });
|
|
220
|
+
|
|
221
|
+
const weights = existsSync(weightsPath)
|
|
222
|
+
? JSON.parse(readFileSync(weightsPath, "utf8"))
|
|
223
|
+
: { updated_at: null, total_routes: 0, overrides: 0, weights: { mode_bias: {}, profile_bias: {}, depth_bias: {} } };
|
|
224
|
+
|
|
225
|
+
weights.total_routes++;
|
|
226
|
+
weights.updated_at = new Date().toISOString();
|
|
227
|
+
|
|
228
|
+
const bias = weights.weights.mode_bias;
|
|
229
|
+
const current = bias[mode] || 0;
|
|
230
|
+
|
|
231
|
+
if (outcome === "override") {
|
|
232
|
+
bias[mode] = Math.max(0, current - 0.1);
|
|
233
|
+
weights.overrides++;
|
|
234
|
+
} else if (outcome === "completion") {
|
|
235
|
+
bias[mode] = Math.min(1, current + 0.05);
|
|
236
|
+
} else if (outcome === "abort") {
|
|
237
|
+
bias[mode] = Math.max(0, current - 0.1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const total = Object.values(bias).reduce((s, v) => s + v, 0);
|
|
241
|
+
if (total > 0) {
|
|
242
|
+
for (const key of Object.keys(bias)) {
|
|
243
|
+
bias[key] = +(bias[key] / total).toFixed(3);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
writeFileSync(weightsPath, JSON.stringify(weights, null, 2), "utf8");
|
|
248
|
+
}
|
|
249
|
+
|
|
213
250
|
// ── 메인 ────────────────────────────────────────────────────
|
|
214
251
|
async function main() {
|
|
252
|
+
// CLI: --record-route <slug> <mode> <outcome>
|
|
253
|
+
const rrIdx = process.argv.indexOf("--record-route");
|
|
254
|
+
if (rrIdx !== -1) {
|
|
255
|
+
const slug = process.argv[rrIdx + 1];
|
|
256
|
+
const mode = process.argv[rrIdx + 2];
|
|
257
|
+
const outcome = process.argv[rrIdx + 3];
|
|
258
|
+
if (slug && mode && outcome) recordRouteOutcome(slug, mode, outcome);
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
261
|
+
|
|
215
262
|
const stdinRaw = readStdin();
|
|
216
263
|
const registry = loadRegistry();
|
|
217
264
|
|
package/hooks/keyword-rules.json
CHANGED
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
"priority": 0,
|
|
14
14
|
"supersedes": [
|
|
15
15
|
"tfx-multi",
|
|
16
|
-
"tfx-
|
|
17
|
-
"tfx-auto-codex",
|
|
16
|
+
"tfx-unified",
|
|
18
17
|
"tfx-codex",
|
|
19
18
|
"tfx-gemini"
|
|
20
19
|
],
|
|
@@ -71,34 +70,21 @@
|
|
|
71
70
|
"mcp_route": null
|
|
72
71
|
},
|
|
73
72
|
{
|
|
74
|
-
"id": "tfx-
|
|
73
|
+
"id": "tfx-unified",
|
|
75
74
|
"patterns": [
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"
|
|
85
|
-
"tfx-codex"
|
|
86
|
-
],
|
|
87
|
-
"exclusive": false,
|
|
88
|
-
"state": null,
|
|
89
|
-
"mcp_route": null
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
"id": "tfx-auto",
|
|
93
|
-
"patterns": [
|
|
94
|
-
{
|
|
95
|
-
"source": "\\btfx[\\s-]?auto\\b",
|
|
96
|
-
"flags": "i"
|
|
97
|
-
}
|
|
75
|
+
{ "source": "\\btfx[\\s-]?auto\\b", "flags": "i" },
|
|
76
|
+
{ "source": "\\btfx[\\s-]?auto[\\s-]?codex\\b", "flags": "i" },
|
|
77
|
+
{ "source": "(?:만들어|고쳐|구현해|짜줘|수정해|바꿔)", "flags": "i" },
|
|
78
|
+
{ "source": "(?:리뷰해|검토해|봐줘|괜찮아)", "flags": "i" },
|
|
79
|
+
{ "source": "(?:테스트|검증|돌려봐|QA)", "flags": "i" },
|
|
80
|
+
{ "source": "(?:분석해|계획|설계해)", "flags": "i" },
|
|
81
|
+
{ "source": "(?:찾아봐|조사해|검색해)", "flags": "i" },
|
|
82
|
+
{ "source": "(?:정리해|슬롭|클린업)", "flags": "i" },
|
|
83
|
+
{ "source": "\\b(?:implement|build|fix|review|test|plan|analyze)\\b", "flags": "i" }
|
|
98
84
|
],
|
|
99
85
|
"skill": "tfx-auto",
|
|
100
86
|
"priority": 2,
|
|
101
|
-
"supersedes": [],
|
|
87
|
+
"supersedes": ["tfx-auto-codex"],
|
|
102
88
|
"exclusive": false,
|
|
103
89
|
"state": null,
|
|
104
90
|
"mcp_route": null
|
package/hub/cli-adapter-base.mjs
CHANGED
|
@@ -38,15 +38,16 @@ export function gte(minMinor) {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Codex CLI 기능별 분기 객체.
|
|
41
|
-
*
|
|
41
|
+
* 실측 기반 임계값: 0.114.0에서 exec/skip-git-repo-check/color 확인됨.
|
|
42
|
+
* --output-last-message는 0.114.0에 없음 (0.117+ 추정).
|
|
42
43
|
*/
|
|
43
44
|
export const FEATURES = {
|
|
44
|
-
/** exec 서브커맨드 사용 가능 여부 */
|
|
45
|
-
get execSubcommand() { return gte(
|
|
46
|
-
/** --output-last-message 플래그 지원 여부 */
|
|
45
|
+
/** exec 서브커맨드 사용 가능 여부 (0.110+ 이전부터 존재) */
|
|
46
|
+
get execSubcommand() { return gte(110); },
|
|
47
|
+
/** --output-last-message 플래그 지원 여부 (0.117+) */
|
|
47
48
|
get outputLastMessage() { return gte(117); },
|
|
48
|
-
/** --color
|
|
49
|
-
get colorNever() { return gte(
|
|
49
|
+
/** --color <COLOR> 플래그 지원 여부 (exec와 동시 도입) */
|
|
50
|
+
get colorNever() { return gte(110); },
|
|
50
51
|
/** 플러그인 시스템 지원 여부 (향후 확장용) */
|
|
51
52
|
get pluginSystem() { return gte(120); },
|
|
52
53
|
};
|
|
@@ -19,6 +19,8 @@ import { createConductor, STATES } from './conductor.mjs';
|
|
|
19
19
|
import { createSwarmLocks } from './swarm-locks.mjs';
|
|
20
20
|
import { createEventLog } from './event-log.mjs';
|
|
21
21
|
import { probeRemoteEnv, resolveRemoteDir } from './remote-session.mjs';
|
|
22
|
+
import { fetchRemoteShard } from './worktree-lifecycle.mjs';
|
|
23
|
+
import { getHostConfig } from '../lib/ssh-command.mjs';
|
|
22
24
|
|
|
23
25
|
// ── Swarm states ──────────────────────────────────────────────
|
|
24
26
|
|
|
@@ -337,6 +339,28 @@ export function createSwarmHypervisor(opts) {
|
|
|
337
339
|
const worker = workers.get(shardName);
|
|
338
340
|
if (!worker) continue;
|
|
339
341
|
|
|
342
|
+
// Fetch remote shard branch to local (push-blocked hosts like Ultra4)
|
|
343
|
+
const shard = plan.shards.find((s) => s.name === shardName);
|
|
344
|
+
if (shard?.host && shard._remoteEnv) {
|
|
345
|
+
const hostConfig = getHostConfig(shard.host, config.rootDir);
|
|
346
|
+
const sshUser = hostConfig?.ssh_user || shard.host;
|
|
347
|
+
const remoteRepoPath = resolveRemoteDir(config.rootDir || process.cwd(), shard._remoteEnv);
|
|
348
|
+
const fetchResult = await fetchRemoteShard({
|
|
349
|
+
host: shard.host,
|
|
350
|
+
sshUser,
|
|
351
|
+
remoteRepoPath,
|
|
352
|
+
branchName: worker.branchName || `swarm/${config.runId}/${shardName}`,
|
|
353
|
+
rootDir: config.rootDir || process.cwd(),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
if (!fetchResult.ok) {
|
|
357
|
+
eventLog.append('remote_fetch_failed', { shard: shardName, error: fetchResult.error });
|
|
358
|
+
integrationFailures.push(shardName);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
eventLog.append('remote_fetch_ok', { shard: shardName, headCommit: fetchResult.headCommit });
|
|
362
|
+
}
|
|
363
|
+
|
|
340
364
|
// Read shard output log for changed files
|
|
341
365
|
const changedFiles = detectChangedFiles(shardName, worker);
|
|
342
366
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { execFile } from 'node:child_process';
|
|
7
7
|
import { resolve, normalize } from 'node:path';
|
|
8
8
|
import { mkdir, rm, access } from 'node:fs/promises';
|
|
9
|
-
import { remoteGit } from './remote-session.mjs';
|
|
9
|
+
import { remoteGit, validateHost } from './remote-session.mjs';
|
|
10
10
|
|
|
11
11
|
const SWARM_ROOT = '.codex-swarm';
|
|
12
12
|
const SLEEP_MS = 2000; // WT race-guard (MEMORY.md: wt-attach-spacing)
|
|
@@ -191,3 +191,39 @@ export async function pruneWorktree({ worktreePath, branchName, rootDir = proces
|
|
|
191
191
|
try { await git(['branch', '-D', branchName], rootDir); } catch { /* may not exist */ }
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Fetch a remote shard's branch to the local repo via SSH.
|
|
197
|
+
* Workaround for hosts that cannot push to GitHub (e.g. Ultra4).
|
|
198
|
+
*
|
|
199
|
+
* Flow: add temp remote → fetch branch → remove remote.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} opts
|
|
202
|
+
* @param {string} opts.host — SSH host (e.g. "ultra4")
|
|
203
|
+
* @param {string} opts.sshUser — SSH user (e.g. "SSAFY")
|
|
204
|
+
* @param {string} opts.remoteRepoPath — absolute path on remote (e.g. "/c/Users/SSAFY/Desktop/Projects/cli/triflux")
|
|
205
|
+
* @param {string} opts.branchName — branch to fetch (e.g. "swarm/run123/auth")
|
|
206
|
+
* @param {string} [opts.rootDir=process.cwd()] — local repo root
|
|
207
|
+
* @returns {Promise<{ ok: boolean, localRef?: string, error?: string }>}
|
|
208
|
+
*/
|
|
209
|
+
export async function fetchRemoteShard({ host, sshUser, remoteRepoPath, branchName, rootDir = process.cwd() }) {
|
|
210
|
+
validateHost(host);
|
|
211
|
+
|
|
212
|
+
const remoteName = `_swarm-${host}-${Date.now()}`;
|
|
213
|
+
const sshUrl = `ssh://${sshUser}@${host}${remoteRepoPath}`;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await git(['remote', 'add', remoteName, sshUrl], rootDir);
|
|
217
|
+
|
|
218
|
+
await git(['fetch', remoteName, branchName, '--no-tags'], rootDir);
|
|
219
|
+
|
|
220
|
+
const localRef = `${remoteName}/${branchName}`;
|
|
221
|
+
const headCommit = await git(['rev-parse', `FETCH_HEAD`], rootDir);
|
|
222
|
+
|
|
223
|
+
return { ok: true, localRef, headCommit };
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return { ok: false, error: err.message };
|
|
226
|
+
} finally {
|
|
227
|
+
try { await git(['remote', 'remove', remoteName], rootDir); } catch { /* cleanup */ }
|
|
228
|
+
}
|
|
229
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { describe, it } from "node:test";
|
|
6
6
|
|
|
7
7
|
import { generateSkillDocs } from "../gen-skill-docs.mjs";
|
|
8
|
+
import { generateSkillManifests } from "../gen-skill-manifest.mjs";
|
|
8
9
|
|
|
9
10
|
function makeTempDir() {
|
|
10
11
|
return mkdtempSync(join(tmpdir(), "tfx-gen-skill-docs-"));
|
|
@@ -84,4 +85,91 @@ describe("gen-skill-docs", () => {
|
|
|
84
85
|
rmSync(root, { recursive: true, force: true });
|
|
85
86
|
}
|
|
86
87
|
});
|
|
88
|
+
|
|
89
|
+
it("partial 내부의 {{#include}}를 해석한다", () => {
|
|
90
|
+
const root = makeTempDir();
|
|
91
|
+
try {
|
|
92
|
+
const skillsDir = join(root, "skills");
|
|
93
|
+
const templatesDir = join(skillsDir, "_templates");
|
|
94
|
+
const sharedDir = join(skillsDir, "shared");
|
|
95
|
+
const skillDir = join(skillsDir, "tfx-inc");
|
|
96
|
+
|
|
97
|
+
mkdirSync(templatesDir, { recursive: true });
|
|
98
|
+
mkdirSync(sharedDir, { recursive: true });
|
|
99
|
+
mkdirSync(skillDir, { recursive: true });
|
|
100
|
+
|
|
101
|
+
writeFileSync(join(sharedDir, "args.md"), "ARGS={{SKILL_NAME}}", "utf8");
|
|
102
|
+
writeFileSync(join(templatesDir, "base.md"), "{{#include shared/args.md}}", "utf8");
|
|
103
|
+
writeFileSync(
|
|
104
|
+
join(skillDir, "SKILL.md.tmpl"),
|
|
105
|
+
"---\nname: tfx-inc\ndescription: inc test\n---\n{{> base}}\nend",
|
|
106
|
+
"utf8",
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const result = generateSkillDocs({ skillsDir, templatesDir, write: true });
|
|
110
|
+
assert.equal(result.count, 1);
|
|
111
|
+
|
|
112
|
+
const output = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
|
113
|
+
assert.match(output, /ARGS=tfx-inc/);
|
|
114
|
+
} finally {
|
|
115
|
+
rmSync(root, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("gen-skill-manifest", () => {
|
|
121
|
+
it("SKILL.md frontmatter에서 skill.json을 생성한다", () => {
|
|
122
|
+
const root = makeTempDir();
|
|
123
|
+
try {
|
|
124
|
+
const skillsDir = join(root, "skills");
|
|
125
|
+
const skillDir = join(skillsDir, "tfx-manifest-test");
|
|
126
|
+
|
|
127
|
+
mkdirSync(skillDir, { recursive: true });
|
|
128
|
+
writeFileSync(
|
|
129
|
+
join(skillDir, "SKILL.md"),
|
|
130
|
+
[
|
|
131
|
+
"---",
|
|
132
|
+
"name: tfx-manifest-test",
|
|
133
|
+
"description: test manifest",
|
|
134
|
+
"triggers:",
|
|
135
|
+
" - test",
|
|
136
|
+
" - manifest",
|
|
137
|
+
"argument-hint: <arg>",
|
|
138
|
+
"internal: true",
|
|
139
|
+
"---",
|
|
140
|
+
"body",
|
|
141
|
+
].join("\n"),
|
|
142
|
+
"utf8",
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const result = generateSkillManifests({ skillsDir, write: true });
|
|
146
|
+
assert.equal(result.count, 1);
|
|
147
|
+
|
|
148
|
+
const manifest = JSON.parse(readFileSync(join(skillDir, "skill.json"), "utf8"));
|
|
149
|
+
assert.equal(manifest.name, "tfx-manifest-test");
|
|
150
|
+
assert.equal(manifest.description, "test manifest");
|
|
151
|
+
assert.deepEqual(manifest.triggers, ["test", "manifest"]);
|
|
152
|
+
assert.equal(manifest.argument_hint, "<arg>");
|
|
153
|
+
assert.equal(manifest.internal, true);
|
|
154
|
+
} finally {
|
|
155
|
+
rmSync(root, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("frontmatter가 없는 SKILL.md는 건너뛴다", () => {
|
|
160
|
+
const root = makeTempDir();
|
|
161
|
+
try {
|
|
162
|
+
const skillsDir = join(root, "skills");
|
|
163
|
+
const skillDir = join(skillsDir, "tfx-no-fm");
|
|
164
|
+
|
|
165
|
+
mkdirSync(skillDir, { recursive: true });
|
|
166
|
+
writeFileSync(join(skillDir, "SKILL.md"), "no frontmatter", "utf8");
|
|
167
|
+
|
|
168
|
+
const result = generateSkillManifests({ skillsDir, write: true });
|
|
169
|
+
assert.equal(result.count, 0);
|
|
170
|
+
assert.equal(existsSync(join(skillDir, "skill.json")), false);
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(root, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
87
175
|
});
|
|
@@ -29,7 +29,7 @@ const { extractPrompt, sanitizeForKeywordDetection } = detectorModule;
|
|
|
29
29
|
|
|
30
30
|
function loadCompiledRules() {
|
|
31
31
|
const rules = loadRules(rulesPath);
|
|
32
|
-
assert.equal(rules.length,
|
|
32
|
+
assert.equal(rules.length, 31);
|
|
33
33
|
return compileRules(rules);
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -100,8 +100,8 @@ test("sanitizeForKeywordDetection: 코드블록/URL/파일경로/XML 태그 제
|
|
|
100
100
|
|
|
101
101
|
test("loadRules: 유효한 JSON 로드", () => {
|
|
102
102
|
const rules = loadRules(rulesPath);
|
|
103
|
-
assert.equal(rules.length,
|
|
104
|
-
assert.equal(rules.filter((rule) => rule.skill).length,
|
|
103
|
+
assert.equal(rules.length, 31);
|
|
104
|
+
assert.equal(rules.filter((rule) => rule.skill).length, 18);
|
|
105
105
|
assert.equal(rules.filter((rule) => rule.mcp_route).length, 10);
|
|
106
106
|
});
|
|
107
107
|
|
|
@@ -122,7 +122,7 @@ test("loadRules: 잘못된 파일 처리", () => {
|
|
|
122
122
|
test("compileRules: 정규식 컴파일 성공", () => {
|
|
123
123
|
const rules = loadRules(rulesPath);
|
|
124
124
|
const compiled = compileRules(rules);
|
|
125
|
-
assert.equal(compiled.length,
|
|
125
|
+
assert.equal(compiled.length, 31);
|
|
126
126
|
for (const rule of compiled) {
|
|
127
127
|
assert.ok(Array.isArray(rule.compiledPatterns));
|
|
128
128
|
assert.ok(rule.compiledPatterns.length > 0);
|
|
@@ -153,7 +153,7 @@ test("matchRules: tfx 키워드 매칭", () => {
|
|
|
153
153
|
const compiledRules = loadCompiledRules();
|
|
154
154
|
const cases = [
|
|
155
155
|
{ text: "tfx multi 세션 시작", expectedId: "tfx-multi" },
|
|
156
|
-
{ text: "tfx auto 돌려줘", expectedId: "tfx-
|
|
156
|
+
{ text: "tfx auto 돌려줘", expectedId: "tfx-unified" },
|
|
157
157
|
{ text: "tfx codex 로 실행", expectedId: "tfx-codex" },
|
|
158
158
|
{ text: "tfx gemini 로 실행", expectedId: "tfx-gemini" },
|
|
159
159
|
{ text: "canceltfx", expectedId: "tfx-cancel" }
|
|
@@ -6,11 +6,14 @@ import { describe, it } from "node:test";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
buildSkillTemplateContext,
|
|
9
|
+
loadSkillManifest,
|
|
9
10
|
loadTemplatePartials,
|
|
10
11
|
parseFrontmatter,
|
|
12
|
+
parseFrontmatterWithManifest,
|
|
11
13
|
renderSkillTemplate,
|
|
12
14
|
} from "../lib/skill-template.mjs";
|
|
13
15
|
import { generateSkillDocs } from "../gen-skill-docs.mjs";
|
|
16
|
+
import { generateSkillManifests } from "../gen-skill-manifest.mjs";
|
|
14
17
|
|
|
15
18
|
function makeTempDir() {
|
|
16
19
|
return mkdtempSync(join(tmpdir(), "tfx-skill-template-"));
|
|
@@ -159,6 +162,93 @@ describe("skill-template engine", () => {
|
|
|
159
162
|
assert.equal(renderedLines.at(-1), "name=big-template");
|
|
160
163
|
});
|
|
161
164
|
|
|
165
|
+
it("{{#include shared/*.md}}로 파일을 인라인 확장한다", () => {
|
|
166
|
+
const root = makeTempDir();
|
|
167
|
+
try {
|
|
168
|
+
const sharedDir = join(root, "shared");
|
|
169
|
+
mkdirSync(sharedDir, { recursive: true });
|
|
170
|
+
writeFileSync(join(sharedDir, "telemetry.md"), "TEL={{SKILL_NAME}}", "utf8");
|
|
171
|
+
|
|
172
|
+
const template = "before\n{{#include shared/telemetry.md}}\nafter";
|
|
173
|
+
const output = renderSkillTemplate(template, { SKILL_NAME: "test-skill" }, {
|
|
174
|
+
partials: {},
|
|
175
|
+
includeBaseDir: root,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
assert.match(output, /before/);
|
|
179
|
+
assert.match(output, /TEL=test-skill/);
|
|
180
|
+
assert.match(output, /after/);
|
|
181
|
+
} finally {
|
|
182
|
+
rmSync(root, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("loadSkillManifest는 skill.json이 있으면 파싱한다", () => {
|
|
187
|
+
const root = makeTempDir();
|
|
188
|
+
try {
|
|
189
|
+
writeFileSync(join(root, "skill.json"), JSON.stringify({
|
|
190
|
+
name: "tfx-test",
|
|
191
|
+
description: "test skill",
|
|
192
|
+
triggers: ["test"],
|
|
193
|
+
}), "utf8");
|
|
194
|
+
|
|
195
|
+
const manifest = loadSkillManifest(root);
|
|
196
|
+
assert.equal(manifest.name, "tfx-test");
|
|
197
|
+
assert.deepEqual(manifest.triggers, ["test"]);
|
|
198
|
+
} finally {
|
|
199
|
+
rmSync(root, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("loadSkillManifest는 skill.json이 없으면 null을 반환한다", () => {
|
|
204
|
+
const root = makeTempDir();
|
|
205
|
+
try {
|
|
206
|
+
assert.equal(loadSkillManifest(root), null);
|
|
207
|
+
} finally {
|
|
208
|
+
rmSync(root, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("parseFrontmatterWithManifest는 skill.json 우선으로 병합한다", () => {
|
|
213
|
+
const root = makeTempDir();
|
|
214
|
+
try {
|
|
215
|
+
writeFileSync(join(root, "skill.json"), JSON.stringify({
|
|
216
|
+
name: "manifest-name",
|
|
217
|
+
description: "manifest-desc",
|
|
218
|
+
internal: true,
|
|
219
|
+
}), "utf8");
|
|
220
|
+
|
|
221
|
+
const source = [
|
|
222
|
+
"---",
|
|
223
|
+
"name: yaml-name",
|
|
224
|
+
"description: yaml-desc",
|
|
225
|
+
"deep: true",
|
|
226
|
+
"---",
|
|
227
|
+
"body",
|
|
228
|
+
].join("\n");
|
|
229
|
+
|
|
230
|
+
const result = parseFrontmatterWithManifest(source, root);
|
|
231
|
+
assert.equal(result.data.name, "manifest-name");
|
|
232
|
+
assert.equal(result.data.description, "manifest-desc");
|
|
233
|
+
assert.equal(result.data.internal, true);
|
|
234
|
+
assert.equal(result.data.deep, true);
|
|
235
|
+
assert.equal(result.body, "body");
|
|
236
|
+
} finally {
|
|
237
|
+
rmSync(root, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("parseFrontmatterWithManifest는 skill.json 없으면 YAML fallback한다", () => {
|
|
242
|
+
const root = makeTempDir();
|
|
243
|
+
try {
|
|
244
|
+
const source = "---\nname: yaml-only\n---\nbody";
|
|
245
|
+
const result = parseFrontmatterWithManifest(source, root);
|
|
246
|
+
assert.equal(result.data.name, "yaml-only");
|
|
247
|
+
} finally {
|
|
248
|
+
rmSync(root, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
162
252
|
it("gen-skill-docs에서 누락 partial 참조 시 에러를 전파한다", () => {
|
|
163
253
|
const root = makeTempDir();
|
|
164
254
|
try {
|
|
@@ -65,7 +65,10 @@ export function generateSkillDocs({
|
|
|
65
65
|
for (const templatePath of templateFiles) {
|
|
66
66
|
const templateContent = readFileSync(templatePath, "utf8");
|
|
67
67
|
const context = createRenderContext(templateContent, templatePath);
|
|
68
|
-
const rendered = renderSkillTemplate(templateContent, context, {
|
|
68
|
+
const rendered = renderSkillTemplate(templateContent, context, {
|
|
69
|
+
partials,
|
|
70
|
+
includeBaseDir: skillsDir,
|
|
71
|
+
});
|
|
69
72
|
const outputPath = resolveOutputPath(templatePath);
|
|
70
73
|
|
|
71
74
|
if (write) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { parseFrontmatter } from "./lib/skill-template.mjs";
|
|
6
|
+
|
|
7
|
+
function collectSkillDirs(skillsDir) {
|
|
8
|
+
return readdirSync(skillsDir, { withFileTypes: true })
|
|
9
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("_"))
|
|
10
|
+
.map((entry) => ({
|
|
11
|
+
name: entry.name,
|
|
12
|
+
dir: join(skillsDir, entry.name),
|
|
13
|
+
}))
|
|
14
|
+
.filter(({ dir }) => existsSync(join(dir, "SKILL.md")))
|
|
15
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractManifest(skillMdContent) {
|
|
19
|
+
const { data } = parseFrontmatter(skillMdContent);
|
|
20
|
+
if (!data.name) return null;
|
|
21
|
+
|
|
22
|
+
const manifest = { name: data.name };
|
|
23
|
+
if (data.description) manifest.description = data.description;
|
|
24
|
+
if (data.triggers) manifest.triggers = data.triggers;
|
|
25
|
+
if (data["argument-hint"]) manifest.argument_hint = data["argument-hint"];
|
|
26
|
+
if (data.internal === true || data.internal === "true") manifest.internal = true;
|
|
27
|
+
|
|
28
|
+
return manifest;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function generateSkillManifests({ skillsDir, write = true } = {}) {
|
|
32
|
+
if (!skillsDir) throw new Error("skillsDir is required");
|
|
33
|
+
|
|
34
|
+
const skillDirs = collectSkillDirs(skillsDir);
|
|
35
|
+
const generated = [];
|
|
36
|
+
|
|
37
|
+
for (const { name, dir } of skillDirs) {
|
|
38
|
+
const skillMdPath = join(dir, "SKILL.md");
|
|
39
|
+
const content = readFileSync(skillMdPath, "utf8");
|
|
40
|
+
const manifest = extractManifest(content);
|
|
41
|
+
|
|
42
|
+
if (!manifest) continue;
|
|
43
|
+
|
|
44
|
+
const manifestPath = join(dir, "skill.json");
|
|
45
|
+
const json = JSON.stringify(manifest, null, 2) + "\n";
|
|
46
|
+
|
|
47
|
+
if (write) {
|
|
48
|
+
const existing = existsSync(manifestPath)
|
|
49
|
+
? readFileSync(manifestPath, "utf8")
|
|
50
|
+
: null;
|
|
51
|
+
if (existing !== json) {
|
|
52
|
+
writeFileSync(manifestPath, json, "utf8");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
generated.push({ name, manifestPath, manifest });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { generated, count: generated.length };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function runCli() {
|
|
63
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
64
|
+
const repoRoot = resolve(scriptDir, "..");
|
|
65
|
+
const skillsDir = join(repoRoot, "skills");
|
|
66
|
+
|
|
67
|
+
const result = generateSkillManifests({ skillsDir });
|
|
68
|
+
console.log(`Generated ${result.count} skill.json file(s).`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isDirectRun = process.argv[1]
|
|
72
|
+
? resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
73
|
+
: false;
|
|
74
|
+
|
|
75
|
+
if (isDirectRun) {
|
|
76
|
+
try {
|
|
77
|
+
runCli();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
console.error(`[gen-skill-manifest] ${message}`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -317,6 +317,30 @@ export function loadTemplatePartials(partialsDir) {
|
|
|
317
317
|
return partials;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
export function loadSkillManifest(skillDir) {
|
|
321
|
+
const manifestPath = join(skillDir, "skill.json");
|
|
322
|
+
if (!existsSync(manifestPath)) return null;
|
|
323
|
+
|
|
324
|
+
const raw = readFileSync(manifestPath, "utf8");
|
|
325
|
+
return JSON.parse(raw);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function parseFrontmatterWithManifest(source, skillDir) {
|
|
329
|
+
const manifest = skillDir ? loadSkillManifest(skillDir) : null;
|
|
330
|
+
const { data: yamlData, body } = parseFrontmatter(source);
|
|
331
|
+
|
|
332
|
+
if (!manifest) return { data: yamlData, body };
|
|
333
|
+
|
|
334
|
+
const merged = { ...yamlData };
|
|
335
|
+
if (manifest.name) merged.name = manifest.name;
|
|
336
|
+
if (manifest.description) merged.description = manifest.description;
|
|
337
|
+
if (manifest.triggers) merged.triggers = manifest.triggers;
|
|
338
|
+
if (manifest.argument_hint) merged["argument-hint"] = manifest.argument_hint;
|
|
339
|
+
if (manifest.internal != null) merged.internal = manifest.internal;
|
|
340
|
+
|
|
341
|
+
return { data: merged, body };
|
|
342
|
+
}
|
|
343
|
+
|
|
320
344
|
export function renderSkillTemplate(template, context = {}, options = {}) {
|
|
321
345
|
const { partials = {}, includes = {}, includeBaseDir = "" } = options;
|
|
322
346
|
return renderWithContext(template, context, partials, {
|
package/scripts/pack.mjs
CHANGED
|
@@ -227,7 +227,7 @@ export { createSwarmLocks } from './team/swarm-locks.mjs';
|
|
|
227
227
|
export { parseShards, buildFileLeaseMap, buildMcpManifest, computeMergeOrder, planSwarm } from './team/swarm-planner.mjs';
|
|
228
228
|
export { createSwarmHypervisor, SWARM_STATES } from './team/swarm-hypervisor.mjs';
|
|
229
229
|
export { reconcile, buildRedundantIds, shouldRunRedundant } from './team/swarm-reconciler.mjs';
|
|
230
|
-
export { ensureWorktree, prepareIntegrationBranch, rebaseShardOntoIntegration, pruneWorktree } from './team/worktree-lifecycle.mjs';
|
|
230
|
+
export { ensureWorktree, prepareIntegrationBranch, rebaseShardOntoIntegration, pruneWorktree, fetchRemoteShard } from './team/worktree-lifecycle.mjs';
|
|
231
231
|
`;
|
|
232
232
|
|
|
233
233
|
function writeIndex(dest, content) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "star-prompt",
|
|
3
|
+
"description": "CLI 프로젝트의 setup/postinstall 흐름에 GitHub 스타 요청 프롬프트를 추가한다. gh CLI 인증 확인 → 이미 스타 여부 감지 → 인터랙티브 confirm → gh API로 자동 스타. Apple 스타일 UX 카피 포함. 'star prompt', '스타 요청', '리포 스타', 'star request', '깃헙 스타 넣어줘', 'star 눌러달라고', '응원 요청' 같은 요청에 사용한다."
|
|
4
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tfx-analysis",
|
|
3
|
+
"description": "코드나 아키텍처를 분석해야 할 때 사용한다. '코드 분석', 'code analysis', '아키텍처 분석', '이 코드 어떻게 돌아가?', '구조 파악' 같은 요청에 반드시 사용. 코드 품질, 보안, 성능, 복잡도 분석이 필요한 모든 상황에 적극 활용.",
|
|
4
|
+
"triggers": [
|
|
5
|
+
"코드 분석",
|
|
6
|
+
"code analysis",
|
|
7
|
+
"아키텍처 분석",
|
|
8
|
+
"analysis"
|
|
9
|
+
],
|
|
10
|
+
"argument_hint": "<분석 대상 — 파일, 디렉토리, 또는 주제>",
|
|
11
|
+
"internal": true
|
|
12
|
+
}
|