@triflux/core 10.1.0 → 10.26.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.
Files changed (145) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +20 -8
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +267 -35
  7. package/hooks/hook-registry.json +343 -256
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +191 -31
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +262 -34
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +1051 -290
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -4
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +22 -21
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/schema/delegator-tools.schema.json +1 -1
  35. package/hub/delegator/service.mjs +93 -64
  36. package/hub/delegator/tool-definitions.mjs +35 -35
  37. package/hub/dynamic-routing-engine.mjs +511 -0
  38. package/hub/fullcycle.mjs +34 -18
  39. package/hub/gemini-adapter.mjs +82 -108
  40. package/hub/hitl.mjs +202 -143
  41. package/hub/intent.mjs +167 -38
  42. package/hub/lib/bash-path.mjs +73 -0
  43. package/hub/lib/cache-guard.mjs +43 -17
  44. package/hub/lib/env-detect.mjs +145 -0
  45. package/hub/lib/hosts-compat.mjs +319 -0
  46. package/hub/lib/mcp-response-cache.mjs +239 -0
  47. package/hub/lib/memory-store.mjs +285 -111
  48. package/hub/lib/path-utils.mjs +165 -0
  49. package/hub/lib/phase-manager.mjs +493 -0
  50. package/hub/lib/process-utils.mjs +797 -104
  51. package/hub/lib/prompt-tmp.mjs +33 -0
  52. package/hub/lib/risk-tier.mjs +53 -0
  53. package/hub/lib/spawn-trace.mjs +561 -0
  54. package/hub/lib/ssh-command.mjs +44 -24
  55. package/hub/lib/ssh-retry.mjs +5 -1
  56. package/hub/lib/staged-file-detect.mjs +43 -0
  57. package/hub/lib/state-snapshot.mjs +293 -0
  58. package/hub/lib/tfx-route-args.mjs +309 -0
  59. package/hub/lib/timeline-adapter.mjs +190 -0
  60. package/hub/lib/trace-recorder.mjs +153 -0
  61. package/hub/lib/uuidv7.mjs +4 -3
  62. package/hub/memory-doctor.mjs +266 -106
  63. package/hub/middleware/quota-middleware.mjs +70 -0
  64. package/hub/middleware/request-logger.mjs +61 -34
  65. package/hub/paths.mjs +9 -9
  66. package/hub/pipeline/gates/confidence.mjs +34 -15
  67. package/hub/pipeline/gates/consensus.mjs +27 -15
  68. package/hub/pipeline/gates/index.mjs +7 -3
  69. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  70. package/hub/pipeline/index.mjs +77 -42
  71. package/hub/pipeline/state.mjs +10 -10
  72. package/hub/pipeline/transitions.mjs +40 -23
  73. package/hub/platform.mjs +65 -48
  74. package/hub/quality/deslop.mjs +70 -49
  75. package/hub/reflexion.mjs +87 -13
  76. package/hub/research.mjs +33 -25
  77. package/hub/router.mjs +941 -791
  78. package/hub/routing/complexity.mjs +132 -29
  79. package/hub/routing/index.mjs +17 -12
  80. package/hub/routing/q-learning.mjs +78 -30
  81. package/hub/routing-snapshot.mjs +261 -0
  82. package/hub/session-fingerprint.mjs +127 -61
  83. package/hub/state.mjs +86 -44
  84. package/hub/team/claude-daemon-control.mjs +1130 -0
  85. package/hub/team/retry-state-machine.mjs +336 -0
  86. package/hub/token-mode.mjs +72 -71
  87. package/hub/workers/worker-utils.mjs +23 -17
  88. package/hud/colors.mjs +27 -9
  89. package/hud/constants.mjs +190 -27
  90. package/hud/context-monitor.mjs +133 -44
  91. package/hud/hud-qos-status.mjs +157 -50
  92. package/hud/mission-board.mjs +56 -0
  93. package/hud/providers/claude.mjs +398 -114
  94. package/hud/providers/codex.mjs +96 -21
  95. package/hud/providers/gemini.mjs +210 -52
  96. package/hud/renderers.mjs +440 -127
  97. package/hud/terminal.mjs +101 -31
  98. package/hud/utils.mjs +81 -39
  99. package/mesh/index.mjs +69 -0
  100. package/mesh/mesh-budget.mjs +137 -0
  101. package/mesh/mesh-heartbeat.mjs +100 -0
  102. package/mesh/mesh-protocol.mjs +96 -0
  103. package/mesh/mesh-queue.mjs +163 -0
  104. package/mesh/mesh-registry.mjs +78 -0
  105. package/mesh/mesh-router.mjs +77 -0
  106. package/package.json +5 -1
  107. package/scripts/lib/agent-json.mjs +27 -0
  108. package/scripts/lib/async.mjs +174 -0
  109. package/scripts/lib/claudemd-scanner.mjs +6 -1
  110. package/scripts/lib/cli-agy.mjs +62 -0
  111. package/scripts/lib/cli-claude.mjs +78 -0
  112. package/scripts/lib/cli-codex.mjs +199 -0
  113. package/scripts/lib/cli-gemini.mjs +37 -0
  114. package/scripts/lib/codex-recovery.sh +50 -0
  115. package/scripts/lib/context.mjs +3 -3
  116. package/scripts/lib/cross-review-utils.mjs +13 -5
  117. package/scripts/lib/doctor-env-checks.mjs +121 -0
  118. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  119. package/scripts/lib/env-probe.mjs +58 -30
  120. package/scripts/lib/env.mjs +14 -0
  121. package/scripts/lib/gemini-profiles.mjs +44 -10
  122. package/scripts/lib/handoff.mjs +33 -17
  123. package/scripts/lib/hook-utils.mjs +8 -6
  124. package/scripts/lib/hub.mjs +48 -0
  125. package/scripts/lib/keyword-rules.mjs +49 -20
  126. package/scripts/lib/logger.mjs +24 -24
  127. package/scripts/lib/mcp-filter.mjs +391 -247
  128. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  129. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  130. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  131. package/scripts/lib/mcp-guard-engine.mjs +1281 -115
  132. package/scripts/lib/mcp-health.mjs +773 -0
  133. package/scripts/lib/mcp-manifest.mjs +24 -14
  134. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  135. package/scripts/lib/pid.mjs +63 -0
  136. package/scripts/lib/process-utils.mjs +26 -0
  137. package/scripts/lib/psmux-info.mjs +117 -12
  138. package/scripts/lib/quota.mjs +47 -0
  139. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  140. package/scripts/lib/skill-state.mjs +220 -0
  141. package/scripts/lib/skill-template.mjs +32 -7
  142. package/scripts/lib/team.mjs +75 -0
  143. package/scripts/lib/timeout.mjs +66 -0
  144. package/scripts/lib/tmp.mjs +26 -0
  145. package/scripts/lib/toml.mjs +113 -0
@@ -8,34 +8,91 @@ export const PSMUX_REQUIRED_COMMANDS = [
8
8
  "capture-pane",
9
9
  ];
10
10
 
11
- export const PSMUX_OPTIONAL_COMMANDS = [
12
- "detach-client",
13
- ];
11
+ export const PSMUX_OPTIONAL_COMMANDS = ["detach-client"];
14
12
 
13
+ // Windows 패키지 매니저 — winget/scoop/choco 는 Windows 전용
15
14
  export const PSMUX_INSTALL_COMMANDS = [
16
- "winget install marlocarlo.psmux",
15
+ "winget install psmux",
17
16
  "scoop install psmux",
18
17
  "choco install psmux",
19
18
  "cargo install psmux",
20
19
  ];
21
20
 
22
21
  export const PSMUX_UPDATE_COMMANDS = [
23
- "winget upgrade marlocarlo.psmux",
22
+ "winget upgrade psmux",
24
23
  "scoop update psmux",
25
24
  "choco upgrade psmux",
26
25
  "cargo install psmux --force",
27
26
  ];
28
27
 
29
- export function formatPsmuxCommandList(commands = PSMUX_INSTALL_COMMANDS, indent = "") {
28
+ // macOS 전용 — brew 가 표준, cargo 는 cross-platform fallback
29
+ export const PSMUX_INSTALL_COMMANDS_DARWIN = [
30
+ "brew install psmux",
31
+ "cargo install psmux",
32
+ ];
33
+
34
+ export const PSMUX_UPDATE_COMMANDS_DARWIN = [
35
+ "brew upgrade psmux",
36
+ "cargo install psmux --force",
37
+ ];
38
+
39
+ // Linux 전용 — cargo 만 cross-platform 지원, distro 패키지는 미제공
40
+ export const PSMUX_INSTALL_COMMANDS_LINUX = ["cargo install psmux"];
41
+
42
+ export const PSMUX_UPDATE_COMMANDS_LINUX = ["cargo install psmux --force"];
43
+
44
+ // PR #258 OS-primary 분기 정책: macOS/Linux 는 tmux 가 POSIX 표준 primary,
45
+ // psmux 는 선택형이다. 따라서 macOS/Linux 에서 "psmux 미설치"는 fallback 사유가
46
+ // 아니며, tmux 까지 없을 때만 native/in-process fallback 을 고려한다.
47
+ export const PSMUX_FALLBACK_NOTE_DARWIN =
48
+ "macOS 에서는 tmux 가 표준 멀티플렉서이며, " +
49
+ "psmux 미설치는 fallback 사유가 아닙니다. tmux 도 없을 때만 in-process/native fallback 을 사용합니다.";
50
+
51
+ export const PSMUX_FALLBACK_NOTE_LINUX =
52
+ "Linux 에서는 tmux 가 표준 멀티플렉서이며, " +
53
+ "psmux 미설치는 fallback 사유가 아닙니다. tmux 도 없을 때만 in-process/native fallback 을 사용합니다.";
54
+
55
+ export function getPsmuxInstallCommandsFor(platform = process.platform) {
56
+ if (platform === "darwin") return PSMUX_INSTALL_COMMANDS_DARWIN;
57
+ if (platform === "linux") return PSMUX_INSTALL_COMMANDS_LINUX;
58
+ // win32 또는 기타 (BSD 등) → Windows 풀 리스트로 폴백 (변경 전 동작 유지)
59
+ return PSMUX_INSTALL_COMMANDS;
60
+ }
61
+
62
+ export function getPsmuxUpdateCommandsFor(platform = process.platform) {
63
+ if (platform === "darwin") return PSMUX_UPDATE_COMMANDS_DARWIN;
64
+ if (platform === "linux") return PSMUX_UPDATE_COMMANDS_LINUX;
65
+ return PSMUX_UPDATE_COMMANDS;
66
+ }
67
+
68
+ export function getPsmuxFallbackNoteFor(platform = process.platform) {
69
+ if (platform === "darwin") return PSMUX_FALLBACK_NOTE_DARWIN;
70
+ if (platform === "linux") return PSMUX_FALLBACK_NOTE_LINUX;
71
+ return null;
72
+ }
73
+
74
+ export function formatPsmuxCommandList(
75
+ commands = PSMUX_INSTALL_COMMANDS,
76
+ indent = "",
77
+ ) {
30
78
  return commands.map((command) => `${indent}${command}`).join("\n");
31
79
  }
32
80
 
33
- export function formatPsmuxInstallGuidance(indent = "") {
34
- return formatPsmuxCommandList(PSMUX_INSTALL_COMMANDS, indent);
81
+ export function formatPsmuxInstallGuidance(
82
+ indent = "",
83
+ platform = process.platform,
84
+ ) {
85
+ const commands = getPsmuxInstallCommandsFor(platform);
86
+ const list = formatPsmuxCommandList(commands, indent);
87
+ const note = getPsmuxFallbackNoteFor(platform);
88
+ return note ? `${list}\n${indent}(${note})` : list;
35
89
  }
36
90
 
37
- export function formatPsmuxUpdateGuidance(indent = "") {
38
- return formatPsmuxCommandList(PSMUX_UPDATE_COMMANDS, indent);
91
+ export function formatPsmuxUpdateGuidance(
92
+ indent = "",
93
+ platform = process.platform,
94
+ ) {
95
+ return formatPsmuxCommandList(getPsmuxUpdateCommandsFor(platform), indent);
39
96
  }
40
97
 
41
98
  export function parsePsmuxVersion(output = "") {
@@ -44,8 +101,12 @@ export function parsePsmuxVersion(output = "") {
44
101
  }
45
102
 
46
103
  export function compareSemver(a, b) {
47
- const left = String(a || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
48
- const right = String(b || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
104
+ const left = String(a || "")
105
+ .split(".")
106
+ .map((part) => Number.parseInt(part, 10) || 0);
107
+ const right = String(b || "")
108
+ .split(".")
109
+ .map((part) => Number.parseInt(part, 10) || 0);
49
110
  for (let index = 0; index < 3; index += 1) {
50
111
  if (left[index] > right[index]) return 1;
51
112
  if (left[index] < right[index]) return -1;
@@ -117,3 +178,47 @@ export function probePsmuxSupport(options = {}) {
117
178
  };
118
179
  }
119
180
  }
181
+
182
+ export function probePrimaryMultiplexerSupport(options = {}) {
183
+ const platform = options.platform || process.platform;
184
+ const execFileSyncFn = options.execFileSyncFn || execFileSync;
185
+
186
+ if (platform === "win32") {
187
+ return {
188
+ kind: "psmux",
189
+ ...probePsmuxSupport(options),
190
+ };
191
+ }
192
+
193
+ const bin = options.bin || "tmux";
194
+ try {
195
+ const versionOutput = execFileSyncFn(bin, ["-V"], {
196
+ encoding: "utf8",
197
+ timeout: 2000,
198
+ stdio: ["ignore", "pipe", "pipe"],
199
+ windowsHide: true,
200
+ });
201
+ return {
202
+ ok: true,
203
+ installed: true,
204
+ kind: "tmux",
205
+ version: String(versionOutput || "").trim() || null,
206
+ recommendedVersion: null,
207
+ recommended: true,
208
+ missingCommands: [],
209
+ missingOptionalCommands: [],
210
+ };
211
+ } catch (error) {
212
+ return {
213
+ ok: false,
214
+ installed: false,
215
+ kind: "tmux",
216
+ version: null,
217
+ recommendedVersion: null,
218
+ recommended: false,
219
+ missingCommands: ["tmux"],
220
+ missingOptionalCommands: [],
221
+ error: error?.message || String(error),
222
+ };
223
+ }
224
+ }
@@ -0,0 +1,47 @@
1
+ export const QUOTA_PATTERNS = [
2
+ /usage limit exceeded/i,
3
+ /rate limit exceeded/i,
4
+ /rate limit reached/i,
5
+ /try again at/i,
6
+ /purchase more credits/i,
7
+ /quota exceeded/i,
8
+ /RESOURCE_EXHAUSTED/i,
9
+ /rateLimitExceeded/i,
10
+ /Too Many Requests/i,
11
+ /rate_limit_error/i,
12
+ /overloaded_error/i,
13
+ /insufficient_quota/i,
14
+ ];
15
+
16
+ export class QuotaStreamBuffer {
17
+ constructor({ maxBytes = 65536 } = {}) {
18
+ this.maxBytes = maxBytes;
19
+ this.text = "";
20
+ }
21
+
22
+ push(chunk) {
23
+ this.text += Buffer.isBuffer(chunk)
24
+ ? chunk.toString("utf8")
25
+ : String(chunk);
26
+ if (this.text.length > this.maxBytes) {
27
+ this.text = this.text.slice(-this.maxBytes);
28
+ }
29
+ return detectQuotaText(this.text);
30
+ }
31
+ }
32
+
33
+ export function detectQuotaText(text = "") {
34
+ return QUOTA_PATTERNS.some((pattern) => pattern.test(text));
35
+ }
36
+
37
+ export function getRerouteTarget(cliType) {
38
+ switch (cliType) {
39
+ case "codex":
40
+ case "gemini":
41
+ return "antigravity";
42
+ case "antigravity":
43
+ return "codex";
44
+ default:
45
+ return null;
46
+ }
47
+ }
@@ -13,22 +13,37 @@ function normalizeMarkdownTarget(raw) {
13
13
  function sanitizeToken(raw) {
14
14
  let token = String(raw || "").trim();
15
15
  if (!token) return "";
16
- token = token.replace(/^[<(]+/u, "").replace(/[)>.,;:!?]+$/u, "").trim();
16
+ token = token
17
+ .replace(/^[<(]+/u, "")
18
+ .replace(/[)>.,;:!?]+$/u, "")
19
+ .trim();
17
20
  return token;
18
21
  }
19
22
 
20
23
  function isLikelyPathToken(token) {
21
24
  if (!token || URL_LIKE_RE.test(token)) return false;
22
- if (token.startsWith("app://") || token.startsWith("plugin://") || token.startsWith("mention://")) return false;
25
+ if (
26
+ token.startsWith("app://") ||
27
+ token.startsWith("plugin://") ||
28
+ token.startsWith("mention://")
29
+ )
30
+ return false;
23
31
  if (token === "~") return false;
24
32
 
25
33
  const hasPathSeparator = token.includes("/") || token.includes("\\");
26
- const hasDotPrefix = token.startsWith("./") || token.startsWith("../") || token.startsWith(".");
34
+ const hasDotPrefix =
35
+ token.startsWith("./") || token.startsWith("../") || token.startsWith(".");
27
36
  const isHomeRelative = token.startsWith("~/") || token.startsWith("~\\");
28
37
  const isWindowsAbs = WINDOWS_ABS_RE.test(token);
29
38
  const hasFileLikeSuffix = /\.[a-zA-Z0-9]{1,16}$/u.test(token);
30
39
 
31
- return hasPathSeparator || hasDotPrefix || isHomeRelative || isWindowsAbs || hasFileLikeSuffix;
40
+ return (
41
+ hasPathSeparator ||
42
+ hasDotPrefix ||
43
+ isHomeRelative ||
44
+ isWindowsAbs ||
45
+ hasFileLikeSuffix
46
+ );
32
47
  }
33
48
 
34
49
  export function extractExplicitFileTokens(text) {
@@ -67,7 +82,9 @@ function validateTransferFile(filePath, maxBytes) {
67
82
  throw new Error(`referenced path is not a file: ${filePath}`);
68
83
  }
69
84
  if (stats.size > maxBytes) {
70
- throw new Error(`referenced file too large: ${stats.size} bytes (max ${maxBytes}) for ${filePath}`);
85
+ throw new Error(
86
+ `referenced file too large: ${stats.size} bytes (max ${maxBytes}) for ${filePath}`,
87
+ );
71
88
  }
72
89
  return stats.size;
73
90
  }
@@ -132,16 +149,23 @@ export function buildRemoteTransferPlan(options = {}) {
132
149
  validateTransferFile(absoluteHandoffPath, maxBytes);
133
150
  const handoffContent = readFileSync(absoluteHandoffPath, "utf8").trim();
134
151
 
135
- const stagedHandoffPath = toRemotePath(remoteStageRoot, `handoff/${basename(absoluteHandoffPath)}`);
136
- const transfers = [{
137
- localPath: absoluteHandoffPath,
138
- remotePath: stagedHandoffPath,
139
- type: "handoff",
140
- }];
152
+ const stagedHandoffPath = toRemotePath(
153
+ remoteStageRoot,
154
+ `handoff/${basename(absoluteHandoffPath)}`,
155
+ );
156
+ const transfers = [
157
+ {
158
+ localPath: absoluteHandoffPath,
159
+ remotePath: stagedHandoffPath,
160
+ type: "handoff",
161
+ },
162
+ ];
141
163
 
142
164
  const tokens = extractExplicitFileTokens(handoffContent);
143
165
  if (tokens.length > maxReferenceFiles) {
144
- throw new Error(`too many referenced files: ${tokens.length} (max ${maxReferenceFiles})`);
166
+ throw new Error(
167
+ `too many referenced files: ${tokens.length} (max ${maxReferenceFiles})`,
168
+ );
145
169
  }
146
170
 
147
171
  const stagedByLocalPath = new Map();
@@ -166,7 +190,10 @@ export function buildRemoteTransferPlan(options = {}) {
166
190
  });
167
191
  }
168
192
 
169
- replacements.push({ token, stagedPath: stagedByLocalPath.get(resolvedPath) });
193
+ replacements.push({
194
+ token,
195
+ stagedPath: stagedByLocalPath.get(resolvedPath),
196
+ });
170
197
  }
171
198
 
172
199
  const replacementEntries = Array.from(
@@ -189,7 +216,10 @@ export function buildRemoteTransferPlan(options = {}) {
189
216
 
190
217
  return {
191
218
  prompt,
192
- replacements: replacementEntries.map(([token, stagedPath]) => ({ stagedPath, token })),
219
+ replacements: replacementEntries.map(([token, stagedPath]) => ({
220
+ stagedPath,
221
+ token,
222
+ })),
193
223
  stagedHandoffPath,
194
224
  transfers,
195
225
  };
@@ -0,0 +1,220 @@
1
+ import {
2
+ access,
3
+ mkdir,
4
+ open,
5
+ readdir,
6
+ readFile,
7
+ rename,
8
+ rm,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { basename, join } from "node:path";
12
+
13
+ const DEFAULT_STATE_DIR = join(process.cwd(), ".tfx", "state");
14
+ const STOP_HOOKS = new Map();
15
+
16
+ function stateFilePath(stateDir, skillName) {
17
+ return join(stateDir, `${skillName}-active.json`);
18
+ }
19
+
20
+ function assertValidSkillName(skillName) {
21
+ if (basename(skillName) !== skillName) {
22
+ throw new Error(`Invalid skill name: ${skillName}`);
23
+ }
24
+ }
25
+
26
+ function assertValidOnStop(onStop) {
27
+ if (onStop !== undefined && typeof onStop !== "function") {
28
+ throw new TypeError("onStop must be a function");
29
+ }
30
+ }
31
+
32
+ async function readStateFile(filePath) {
33
+ try {
34
+ const raw = await readFile(filePath, "utf8");
35
+ return JSON.parse(raw);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function rememberStopHook(filePath, onStop) {
42
+ if (typeof onStop === "function") {
43
+ STOP_HOOKS.set(filePath, onStop);
44
+ return;
45
+ }
46
+ STOP_HOOKS.delete(filePath);
47
+ }
48
+
49
+ function logStopHookWarning(message, error) {
50
+ if (error) {
51
+ console.warn(message, error);
52
+ return;
53
+ }
54
+ console.warn(message);
55
+ }
56
+
57
+ async function runStopHook(filePath, state, onStop) {
58
+ if (!state?.hasStopHook) {
59
+ STOP_HOOKS.delete(filePath);
60
+ return;
61
+ }
62
+
63
+ const hook = onStop ?? STOP_HOOKS.get(filePath);
64
+ STOP_HOOKS.delete(filePath);
65
+
66
+ if (typeof hook !== "function") {
67
+ return;
68
+ }
69
+
70
+ try {
71
+ await hook({
72
+ skillName: state.skillName,
73
+ filePath,
74
+ state,
75
+ });
76
+ } catch (error) {
77
+ logStopHookWarning(
78
+ `Failed to run stop-hook for skill: ${state.skillName}`,
79
+ error,
80
+ );
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Activate a skill by writing its state file.
86
+ * Throws if the skill is already active.
87
+ *
88
+ * @param {string} skillName
89
+ * @param {{ stateDir?: string, onStop?: (() => Promise<void> | void) }} options
90
+ */
91
+ export async function activateSkill(
92
+ skillName,
93
+ { stateDir = DEFAULT_STATE_DIR, onStop } = {},
94
+ ) {
95
+ assertValidSkillName(skillName);
96
+ assertValidOnStop(onStop);
97
+
98
+ await mkdir(stateDir, { recursive: true });
99
+
100
+ const filePath = stateFilePath(stateDir, skillName);
101
+ const lockPath = `${filePath}.lock`;
102
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
103
+ let lockHandle;
104
+
105
+ try {
106
+ lockHandle = await open(lockPath, "wx");
107
+ try {
108
+ await access(filePath);
109
+ throw new Error(`Skill already active: ${skillName}`);
110
+ } catch (error) {
111
+ if (error?.message === `Skill already active: ${skillName}`) {
112
+ throw error;
113
+ }
114
+ if (error?.code !== "ENOENT") {
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ const state = {
120
+ skillName,
121
+ pid: process.pid,
122
+ activatedAt: Date.now(),
123
+ hasStopHook: typeof onStop === "function",
124
+ };
125
+ await writeFile(tmpPath, JSON.stringify(state), "utf8");
126
+ await rename(tmpPath, filePath);
127
+ rememberStopHook(filePath, onStop);
128
+ } finally {
129
+ await rm(tmpPath, { force: true }).catch(() => {});
130
+ if (lockHandle) {
131
+ await lockHandle.close().catch(() => {});
132
+ }
133
+ await rm(lockPath, { force: true }).catch(() => {});
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Deactivate a skill by removing its state file.
139
+ * Does not throw if the file does not exist.
140
+ *
141
+ * @param {string} skillName
142
+ * @param {{ stateDir?: string, onStop?: (() => Promise<void> | void) }} options
143
+ */
144
+ export async function deactivateSkill(
145
+ skillName,
146
+ { stateDir = DEFAULT_STATE_DIR, onStop } = {},
147
+ ) {
148
+ assertValidOnStop(onStop);
149
+
150
+ const filePath = stateFilePath(stateDir, skillName);
151
+ const state = await readStateFile(filePath);
152
+
153
+ try {
154
+ await runStopHook(filePath, state, onStop);
155
+ } finally {
156
+ STOP_HOOKS.delete(filePath);
157
+ await rm(filePath, { force: true }).catch(() => {});
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Return all currently active skills by scanning *-active.json files.
163
+ *
164
+ * @param {{ stateDir?: string }} options
165
+ * @returns {Promise<Array<{ skillName: string, pid: number, activatedAt: number, hasStopHook?: boolean }>>}
166
+ */
167
+ export async function getActiveSkills({ stateDir = DEFAULT_STATE_DIR } = {}) {
168
+ let entries;
169
+ try {
170
+ entries = await readdir(stateDir);
171
+ } catch {
172
+ return [];
173
+ }
174
+
175
+ const results = [];
176
+ for (const entry of entries) {
177
+ if (!entry.endsWith("-active.json")) continue;
178
+ const state = await readStateFile(join(stateDir, entry));
179
+ if (state) {
180
+ results.push(state);
181
+ }
182
+ }
183
+ return results;
184
+ }
185
+
186
+ /**
187
+ * Remove state files for skills whose processes are no longer alive.
188
+ *
189
+ * @param {{ stateDir?: string }} options
190
+ * @returns {Promise<string[]>} list of pruned skill names
191
+ */
192
+ export async function pruneOrphanSkillStates({
193
+ stateDir = DEFAULT_STATE_DIR,
194
+ } = {}) {
195
+ const active = await getActiveSkills({ stateDir });
196
+ const pruned = [];
197
+
198
+ for (const state of active) {
199
+ let alive = true;
200
+ try {
201
+ process.kill(state.pid, 0);
202
+ } catch {
203
+ alive = false;
204
+ }
205
+
206
+ if (!alive) {
207
+ const filePath = stateFilePath(stateDir, state.skillName);
208
+ STOP_HOOKS.delete(filePath);
209
+ if (state.hasStopHook) {
210
+ logStopHookWarning(
211
+ `Skipping stop-hook for orphaned skill state: ${state.skillName}`,
212
+ );
213
+ }
214
+ await rm(filePath, { force: true }).catch(() => {});
215
+ pruned.push(state.skillName);
216
+ }
217
+ }
218
+
219
+ return pruned;
220
+ }
@@ -1,5 +1,12 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
- import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import {
3
+ basename,
4
+ extname,
5
+ isAbsolute,
6
+ join,
7
+ relative,
8
+ resolve,
9
+ } from "node:path";
3
10
 
4
11
  const IF_TAG_RE = /{{\s*(#if\s+([A-Za-z0-9_.-]+)|\/if)\s*}}/g;
5
12
  const PARTIAL_RE = /{{>\s*([A-Za-z0-9_./-]+)\s*}}/g;
@@ -187,7 +194,8 @@ function resolveIncludeContent(includeName, options) {
187
194
  const fullPath = resolve(baseDir, normalizedName);
188
195
  const relativePath = relative(baseDir, fullPath);
189
196
  const isInsideBaseDir =
190
- relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
197
+ relativePath === "" ||
198
+ (!relativePath.startsWith("..") && !isAbsolute(relativePath));
191
199
 
192
200
  if (!isInsideBaseDir || !existsSync(fullPath)) {
193
201
  throw new Error(`Missing include: ${normalizedName}`);
@@ -223,9 +231,21 @@ function renderVariables(source, context) {
223
231
  });
224
232
  }
225
233
 
226
- function renderWithContext(source, context, partials, options, includeStack = []) {
234
+ function renderWithContext(
235
+ source,
236
+ context,
237
+ partials,
238
+ options,
239
+ includeStack = [],
240
+ ) {
227
241
  const afterIf = evaluateConditionals(source, context);
228
- const afterPartials = renderPartials(afterIf, context, partials, options, includeStack);
242
+ const afterPartials = renderPartials(
243
+ afterIf,
244
+ context,
245
+ partials,
246
+ options,
247
+ includeStack,
248
+ );
229
249
  const afterFileIncludes = renderFileIncludes(
230
250
  afterPartials,
231
251
  context,
@@ -239,8 +259,8 @@ function renderWithContext(source, context, partials, options, includeStack = []
239
259
  function readAllTemplateFiles(rootDir, currentDir = rootDir) {
240
260
  if (!rootDir || !existsSync(rootDir)) return [];
241
261
 
242
- const entries = readdirSync(currentDir, { withFileTypes: true }).sort((left, right) =>
243
- left.name.localeCompare(right.name),
262
+ const entries = readdirSync(currentDir, { withFileTypes: true }).sort(
263
+ (left, right) => left.name.localeCompare(right.name),
244
264
  );
245
265
  const files = [];
246
266
 
@@ -278,7 +298,10 @@ export function parseFrontmatter(source) {
278
298
  return { data, body };
279
299
  }
280
300
 
281
- export function buildSkillTemplateContext({ frontmatter = {}, skillDirName = "" } = {}) {
301
+ export function buildSkillTemplateContext({
302
+ frontmatter = {},
303
+ skillDirName = "",
304
+ } = {}) {
282
305
  const context = { ...frontmatter };
283
306
  const skillName = String(frontmatter.name || skillDirName || "").trim();
284
307
  const skillDescription = String(frontmatter.description || "").trim();
@@ -337,6 +360,8 @@ export function parseFrontmatterWithManifest(source, skillDir) {
337
360
  if (manifest.triggers) merged.triggers = manifest.triggers;
338
361
  if (manifest.argument_hint) merged["argument-hint"] = manifest.argument_hint;
339
362
  if (manifest.internal != null) merged.internal = manifest.internal;
363
+ if (manifest.deprecated != null) merged.deprecated = manifest.deprecated;
364
+ if (manifest.superseded_by) merged["superseded-by"] = manifest.superseded_by;
340
365
 
341
366
  return { data: merged, body };
342
367
  }
@@ -0,0 +1,75 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ function teamContext(env) {
5
+ const team = env.TFX_TEAM_NAME || "";
6
+ const taskId = env.TFX_TEAM_TASK_ID || "";
7
+ if (!team || !taskId) return null;
8
+ return {
9
+ team,
10
+ taskId,
11
+ agent: env.TFX_TEAM_AGENT_NAME || "agent",
12
+ lead: env.TFX_TEAM_LEAD_NAME || "team-lead",
13
+ };
14
+ }
15
+
16
+ export async function claimTask({ env = process.env, bridge } = {}) {
17
+ const ctx = teamContext(env);
18
+ if (!ctx) return { skipped: true };
19
+ const response = await bridge.claim({
20
+ team: ctx.team,
21
+ taskId: ctx.taskId,
22
+ owner: ctx.agent,
23
+ status: "in_progress",
24
+ });
25
+ if (response?.ok) return { claimed: true };
26
+ const code = response?.error?.code;
27
+ const before = response?.error?.details?.task_before ?? {};
28
+ if (
29
+ code === "CLAIM_CONFLICT" &&
30
+ before.owner === ctx.agent &&
31
+ before.status === "in_progress"
32
+ ) {
33
+ return { claimed: true, alreadyClaimed: true };
34
+ }
35
+ if (code === "CLAIM_CONFLICT") {
36
+ return { claimed: false, conflict: true, owner: before.owner ?? null };
37
+ }
38
+ return { claimed: false, error: response?.error ?? null };
39
+ }
40
+
41
+ export async function completeTask({
42
+ env = process.env,
43
+ bridge,
44
+ result = "success",
45
+ summary = "작업 완료",
46
+ now = () => new Date().toISOString(),
47
+ } = {}) {
48
+ const ctx = teamContext(env);
49
+ if (!ctx) return { skipped: true };
50
+ const trimmed = String(summary).slice(0, 4096);
51
+ const response = await bridge.complete({
52
+ team: ctx.team,
53
+ taskId: ctx.taskId,
54
+ agent: ctx.agent,
55
+ result,
56
+ summary: trimmed,
57
+ });
58
+
59
+ const resultDir =
60
+ env.TFX_RESULT_DIR ||
61
+ join(process.env.HOME || ".", ".claude", "tfx-results", ctx.team);
62
+ mkdirSync(resultDir, { recursive: true });
63
+ writeFileSync(
64
+ join(resultDir, `${ctx.taskId}.json`),
65
+ `${JSON.stringify({
66
+ taskId: ctx.taskId,
67
+ agent: ctx.agent,
68
+ team: ctx.team,
69
+ result,
70
+ summary: trimmed,
71
+ timestamp: now(),
72
+ })}\n`,
73
+ );
74
+ return { completed: Boolean(response?.ok), response };
75
+ }