triflux 10.20.0 → 10.20.2
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/README.ko.md +34 -42
- package/README.md +30 -36
- package/bin/triflux.mjs +13 -4
- package/hooks/hook-orchestrator.mjs +1 -1
- package/hooks/hook-registry.json +76 -2
- package/hooks/hooks.json +49 -1
- package/hooks/permission-safe-allow.mjs +233 -0
- package/hooks/pipeline-stop.mjs +355 -16
- package/hooks/post-tool-tips.mjs +306 -0
- package/hooks/pre-compact-snapshot.mjs +181 -0
- package/hooks/session-end-cleanup.mjs +201 -0
- package/hooks/subagent-tracker.mjs +267 -0
- package/hooks/subagent-verifier.mjs +4 -0
- package/hub/account-broker.mjs +297 -8
- package/hub/codex-adapter.mjs +62 -2
- package/hub/server.mjs +51 -4
- package/hub/team/cli/services/native-control.mjs +1 -0
- package/hub/team/conductor.mjs +94 -29
- package/hub/team/native-supervisor.mjs +20 -7
- package/hub/team/swarm-cli.mjs +98 -4
- package/hub/team/swarm-hypervisor.mjs +112 -10
- package/hub/team/swarm-locks.mjs +7 -2
- package/hub/team/swarm-preflight.mjs +317 -0
- package/hub/team/synapse-cli.mjs +243 -6
- package/hub/team/worker-sandbox.mjs +93 -0
- package/hub/team/worktree-lifecycle.mjs +49 -8
- package/hub/workers/delegator-mcp.mjs +14 -1
- package/package.json +1 -1
- package/scripts/__tests__/gen-skill-docs.test.mjs +4 -0
- package/scripts/__tests__/skill-surface.test.mjs +61 -0
- package/scripts/gen-skill-manifest.mjs +3 -0
- package/scripts/lib/skill-template.mjs +2 -0
- package/scripts/session-stale-cleanup.mjs +111 -2
- package/skills/star-prompt/skill.json +1 -1
- package/skills/tfx-analysis/skill.json +11 -3
- package/skills/tfx-auto/skill.json +2 -2
- package/skills/tfx-autopilot/skill.json +9 -4
- package/skills/tfx-consensus/skill.json +4 -3
- package/skills/tfx-debate/skill.json +11 -4
- package/skills/tfx-doctor/skill.json +3 -1
- package/skills/tfx-fullcycle/skill.json +10 -4
- package/skills/tfx-hooks/skill.json +3 -1
- package/skills/tfx-hub/skill.json +5 -3
- package/skills/tfx-index/skill.json +6 -1
- package/skills/tfx-interview/skill.json +6 -1
- package/skills/tfx-multi/skill.json +7 -3
- package/skills/tfx-panel/skill.json +11 -4
- package/skills/tfx-persist/skill.json +11 -4
- package/skills/tfx-plan/skill.json +13 -3
- package/skills/tfx-profile/skill.json +3 -1
- package/skills/tfx-prune/skill.json +7 -1
- package/skills/tfx-psmux-rules/SKILL.md +2 -0
- package/skills/tfx-psmux-rules/SKILL.md.tmpl +22 -307
- package/skills/tfx-psmux-rules/skill.json +7 -2
- package/skills/tfx-qa/skill.json +12 -3
- package/skills/tfx-ralph/skill.json +4 -1
- package/skills/tfx-remote/skill.json +8 -0
- package/skills/tfx-remote-setup/SKILL.md +2 -0
- package/skills/tfx-remote-setup/SKILL.md.tmpl +23 -570
- package/skills/tfx-remote-setup/skill.json +7 -3
- package/skills/tfx-remote-spawn/SKILL.md +2 -0
- package/skills/tfx-remote-spawn/SKILL.md.tmpl +31 -242
- package/skills/tfx-remote-spawn/skill.json +7 -3
- package/skills/tfx-research/skill.json +11 -4
- package/skills/tfx-review/skill.json +12 -3
- package/skills/tfx-setup/skill.json +3 -1
- package/skills/tfx-ship/skill.json +13 -0
- package/skills/tfx-swarm/skill.json +13 -0
- package/skills/tfx-wt/skill.json +12 -0
package/hooks/hooks.json
CHANGED
|
@@ -25,6 +25,18 @@
|
|
|
25
25
|
]
|
|
26
26
|
}
|
|
27
27
|
],
|
|
28
|
+
"PermissionRequest": [
|
|
29
|
+
{
|
|
30
|
+
"matcher": "Bash",
|
|
31
|
+
"hooks": [
|
|
32
|
+
{
|
|
33
|
+
"type": "command",
|
|
34
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
35
|
+
"timeout": 3
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
],
|
|
28
40
|
"PreToolUse": [
|
|
29
41
|
{
|
|
30
42
|
"matcher": "Bash|Agent|Skill",
|
|
@@ -39,7 +51,7 @@
|
|
|
39
51
|
],
|
|
40
52
|
"PostToolUse": [
|
|
41
53
|
{
|
|
42
|
-
"matcher": "Edit|Write",
|
|
54
|
+
"matcher": "Bash|Edit|Write",
|
|
43
55
|
"hooks": [
|
|
44
56
|
{
|
|
45
57
|
"type": "command",
|
|
@@ -73,6 +85,30 @@
|
|
|
73
85
|
]
|
|
74
86
|
}
|
|
75
87
|
],
|
|
88
|
+
"SessionEnd": [
|
|
89
|
+
{
|
|
90
|
+
"matcher": "*",
|
|
91
|
+
"hooks": [
|
|
92
|
+
{
|
|
93
|
+
"type": "command",
|
|
94
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
95
|
+
"timeout": 5
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
"SubagentStart": [
|
|
101
|
+
{
|
|
102
|
+
"matcher": "*",
|
|
103
|
+
"hooks": [
|
|
104
|
+
{
|
|
105
|
+
"type": "command",
|
|
106
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
107
|
+
"timeout": 5
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
],
|
|
76
112
|
"SubagentStop": [
|
|
77
113
|
{
|
|
78
114
|
"matcher": "*",
|
|
@@ -84,6 +120,18 @@
|
|
|
84
120
|
}
|
|
85
121
|
]
|
|
86
122
|
}
|
|
123
|
+
],
|
|
124
|
+
"PreCompact": [
|
|
125
|
+
{
|
|
126
|
+
"matcher": "*",
|
|
127
|
+
"hooks": [
|
|
128
|
+
{
|
|
129
|
+
"type": "command",
|
|
130
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
131
|
+
"timeout": 5
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
}
|
|
87
135
|
]
|
|
88
136
|
}
|
|
89
137
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { stdin, stdout } from "node:process";
|
|
4
|
+
|
|
5
|
+
const META_PATTERNS = [/&&/, /\|\|/, /[;|<>`]/, /\$\(/];
|
|
6
|
+
const RISKY_PATTERNS = [
|
|
7
|
+
/(^|\s)rm(\s|$)/,
|
|
8
|
+
/(^|\s)mv(\s|$)/,
|
|
9
|
+
/(^|\s)cp(\s|$)/,
|
|
10
|
+
/(^|\s)curl(\s|$)/,
|
|
11
|
+
/(^|\s)wget(\s|$)/,
|
|
12
|
+
/(^|\s)sudo(\s|$)/,
|
|
13
|
+
/(^|\s)chmod(\s|$)/,
|
|
14
|
+
/(^|\s)chown(\s|$)/,
|
|
15
|
+
/(^|\s)git\s+push(\s|$)/,
|
|
16
|
+
/(^|\s)git\s+reset(\s|$)/,
|
|
17
|
+
/(^|\s)git\s+clean(\s|$)/,
|
|
18
|
+
/(^|\s)npm\s+publish(\s|$)/,
|
|
19
|
+
/(^|\s)pnpm\s+publish(\s|$)/,
|
|
20
|
+
/(^|\s)yarn\s+publish(\s|$)/,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function silentlyExit() {
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readStdin() {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
let input = "";
|
|
30
|
+
stdin.setEncoding("utf8");
|
|
31
|
+
stdin.on("data", (chunk) => {
|
|
32
|
+
input += chunk;
|
|
33
|
+
});
|
|
34
|
+
stdin.on("end", () => resolve(input));
|
|
35
|
+
stdin.resume();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function shellWords(command) {
|
|
40
|
+
const words = [];
|
|
41
|
+
const pattern = /"([^"]*)"|'([^']*)'|([^\s]+)/g;
|
|
42
|
+
let match;
|
|
43
|
+
while ((match = pattern.exec(command)) !== null) {
|
|
44
|
+
words.push(match[1] ?? match[2] ?? match[3]);
|
|
45
|
+
}
|
|
46
|
+
return words;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isRepoRelativePath(value) {
|
|
50
|
+
if (!value || value.startsWith("/") || value.startsWith("~")) return false;
|
|
51
|
+
if (value === ".") return true;
|
|
52
|
+
if (value === ".." || value.startsWith("../") || value.includes("/../"))
|
|
53
|
+
return false;
|
|
54
|
+
return /^[A-Za-z0-9._/*?@{}+,:=-][A-Za-z0-9._/*?'@{}+,:=-]*$/.test(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function hasUnsafeSurface(command) {
|
|
58
|
+
return (
|
|
59
|
+
META_PATTERNS.some((pattern) => pattern.test(command)) ||
|
|
60
|
+
RISKY_PATTERNS.some((pattern) => pattern.test(command))
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function allowGit(words, command) {
|
|
65
|
+
if (command === "git status" || command === "git status --short")
|
|
66
|
+
return "git status";
|
|
67
|
+
if (command === "git diff --stat" || command === "git diff --cached --stat")
|
|
68
|
+
return "git diff stat";
|
|
69
|
+
if (command === "git branch --show-current") return "git branch";
|
|
70
|
+
if (words[0] === "git" && words[1] === "log" && words[2] === "--oneline") {
|
|
71
|
+
const rest = words.slice(3);
|
|
72
|
+
if (
|
|
73
|
+
rest.every(
|
|
74
|
+
(word) =>
|
|
75
|
+
/^[A-Za-z0-9._/@{}~^:+,=-]+$/.test(word) && !word.startsWith("-"),
|
|
76
|
+
)
|
|
77
|
+
) {
|
|
78
|
+
return "git log";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function allowListing(words) {
|
|
85
|
+
if (words[0] === "pwd" && words.length === 1) return "pwd";
|
|
86
|
+
|
|
87
|
+
if (words[0] === "ls") {
|
|
88
|
+
const args = words.slice(1);
|
|
89
|
+
if (
|
|
90
|
+
args.every(
|
|
91
|
+
(word) => /^-[A-Za-z0-9]+$/.test(word) || isRepoRelativePath(word),
|
|
92
|
+
)
|
|
93
|
+
) {
|
|
94
|
+
return "ls";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (words[0] === "find") {
|
|
99
|
+
const args = words.slice(1);
|
|
100
|
+
if (args.some((word) => word === "-delete" || word.startsWith("-exec")))
|
|
101
|
+
return null;
|
|
102
|
+
const firstOption = args.findIndex((word) => word.startsWith("-"));
|
|
103
|
+
const pathArgs = (
|
|
104
|
+
firstOption === -1 ? args : args.slice(0, firstOption)
|
|
105
|
+
).filter(Boolean);
|
|
106
|
+
if (pathArgs.length === 0 || pathArgs.every(isRepoRelativePath)) {
|
|
107
|
+
return "find";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function allowSearch(words) {
|
|
115
|
+
if (words[0] !== "rg" && words[0] !== "grep") return null;
|
|
116
|
+
const args = words.slice(1);
|
|
117
|
+
if (args.length === 0) return null;
|
|
118
|
+
if (
|
|
119
|
+
args.some(
|
|
120
|
+
(word) =>
|
|
121
|
+
word.startsWith("/") ||
|
|
122
|
+
word === ".." ||
|
|
123
|
+
word.startsWith("../") ||
|
|
124
|
+
word.includes("/../"),
|
|
125
|
+
)
|
|
126
|
+
) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return `${words[0]} search`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function allowTargetedTest(words) {
|
|
133
|
+
if (words[0] !== "node" || words[1] !== "scripts/test-lock.mjs") return null;
|
|
134
|
+
if (!words.includes("--test")) return null;
|
|
135
|
+
const args = words.slice(2);
|
|
136
|
+
const allowedFlags = new Set(["--test", "--test-force-exit"]);
|
|
137
|
+
const pathArgs = [];
|
|
138
|
+
for (const word of args) {
|
|
139
|
+
if (allowedFlags.has(word)) continue;
|
|
140
|
+
if (/^--test-concurrency=\d+$/.test(word)) continue;
|
|
141
|
+
if (word.startsWith("-")) return null;
|
|
142
|
+
pathArgs.push(word);
|
|
143
|
+
}
|
|
144
|
+
if (pathArgs.length === 0) return null;
|
|
145
|
+
if (
|
|
146
|
+
pathArgs.every(
|
|
147
|
+
(word) =>
|
|
148
|
+
word.startsWith("tests/") || word.startsWith("scripts/__tests__/"),
|
|
149
|
+
) &&
|
|
150
|
+
pathArgs.every(isRepoRelativePath)
|
|
151
|
+
) {
|
|
152
|
+
return "targeted test";
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function allowNpm(command) {
|
|
158
|
+
// npm scripts are intentionally not auto-allowed: package.json can redefine
|
|
159
|
+
// them to perform arbitrary side effects. Prefer explicit node test commands.
|
|
160
|
+
void command;
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function allowanceReason(command) {
|
|
165
|
+
const trimmed = command.trim().replace(/\s+/g, " ");
|
|
166
|
+
if (!trimmed || hasUnsafeSurface(trimmed)) return null;
|
|
167
|
+
|
|
168
|
+
const words = shellWords(trimmed);
|
|
169
|
+
if (words.length === 0) return null;
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
allowGit(words, trimmed) ??
|
|
173
|
+
allowListing(words) ??
|
|
174
|
+
allowSearch(words) ??
|
|
175
|
+
allowTargetedTest(words) ??
|
|
176
|
+
allowNpm(trimmed)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function hasAskSuggestion(value) {
|
|
181
|
+
if (!value || typeof value !== "object") return false;
|
|
182
|
+
if (Array.isArray(value)) return value.some(hasAskSuggestion);
|
|
183
|
+
if (value.behavior === "ask") return true;
|
|
184
|
+
if (value.decision?.behavior === "ask") return true;
|
|
185
|
+
return Object.values(value).some(hasAskSuggestion);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function emitAllow(reason) {
|
|
189
|
+
stdout.write(
|
|
190
|
+
JSON.stringify({
|
|
191
|
+
hookSpecificOutput: {
|
|
192
|
+
hookEventName: "PermissionRequest",
|
|
193
|
+
decision: { behavior: "allow" },
|
|
194
|
+
additionalContext: `[permission-safe-allow] allowed: ${reason}`,
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const input = await readStdin();
|
|
201
|
+
let payload;
|
|
202
|
+
try {
|
|
203
|
+
payload = JSON.parse(input || "{}");
|
|
204
|
+
} catch {
|
|
205
|
+
silentlyExit();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (
|
|
209
|
+
payload?.hook_event_name !== "PermissionRequest" ||
|
|
210
|
+
payload?.tool_name !== "Bash"
|
|
211
|
+
) {
|
|
212
|
+
silentlyExit();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (
|
|
216
|
+
hasAskSuggestion(
|
|
217
|
+
payload.permission_suggestions ?? payload.permissionSuggestions,
|
|
218
|
+
)
|
|
219
|
+
) {
|
|
220
|
+
silentlyExit();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const command = payload?.tool_input?.command ?? payload?.command;
|
|
224
|
+
if (typeof command !== "string") {
|
|
225
|
+
silentlyExit();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const reason = allowanceReason(command);
|
|
229
|
+
if (!reason) {
|
|
230
|
+
silentlyExit();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
emitAllow(reason);
|
package/hooks/pipeline-stop.mjs
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hooks/pipeline-stop.mjs — Stop 훅: 활성 파이프라인 감지
|
|
2
|
+
// hooks/pipeline-stop.mjs — Stop 훅: 컨텍스트 가드와 활성 파이프라인 감지
|
|
3
3
|
//
|
|
4
4
|
// Claude Code Stop 이벤트에서 실행.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// 컨텍스트 한계/사용자 중단 신호는 조용히 통과시키고, 높은 컨텍스트 사용량은
|
|
6
|
+
// 세션별 제한 횟수만 block 한다. 이후 비터미널 단계의 파이프라인이 있으면
|
|
7
|
+
// decision:"block" + reason으로 중단을 방지한다.
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import {
|
|
11
|
+
closeSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
openSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
readSync,
|
|
17
|
+
statSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
9
22
|
|
|
10
23
|
let getPipelineStateDbPath;
|
|
11
24
|
let ensurePipelineTable;
|
|
@@ -22,6 +35,319 @@ const PROJECT_ROOT = process.env.CLAUDE_CWD || process.cwd();
|
|
|
22
35
|
const HUB_DB_PATH = getPipelineStateDbPath(PROJECT_ROOT);
|
|
23
36
|
const TERMINAL = new Set(["complete", "failed"]);
|
|
24
37
|
|
|
38
|
+
const DEFAULT_CONTEXT_THRESHOLD = 75;
|
|
39
|
+
const DEFAULT_CRITICAL_THRESHOLD = 95;
|
|
40
|
+
const DEFAULT_MAX_BLOCKS = 2;
|
|
41
|
+
const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/;
|
|
42
|
+
|
|
43
|
+
const CONTEXT_LIMIT_PATTERNS = [
|
|
44
|
+
"context_limit",
|
|
45
|
+
"context_window",
|
|
46
|
+
"context_exceeded",
|
|
47
|
+
"context_full",
|
|
48
|
+
"max_context",
|
|
49
|
+
"token_limit",
|
|
50
|
+
"max_tokens",
|
|
51
|
+
"conversation_too_long",
|
|
52
|
+
"input_too_long",
|
|
53
|
+
];
|
|
54
|
+
const USER_ABORT_EXACT = new Set([
|
|
55
|
+
"aborted",
|
|
56
|
+
"abort",
|
|
57
|
+
"cancel",
|
|
58
|
+
"cancelled",
|
|
59
|
+
"interrupt",
|
|
60
|
+
]);
|
|
61
|
+
const USER_ABORT_SUBSTRINGS = [
|
|
62
|
+
"user_cancel",
|
|
63
|
+
"user_interrupt",
|
|
64
|
+
"ctrl_c",
|
|
65
|
+
"manual_stop",
|
|
66
|
+
"keyboard_interrupt",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function readStopPayload() {
|
|
70
|
+
try {
|
|
71
|
+
const raw = readFileSync(0, "utf8");
|
|
72
|
+
if (!raw.trim()) return {};
|
|
73
|
+
const parsed = JSON.parse(raw);
|
|
74
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
75
|
+
} catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function envNumber(name, fallback) {
|
|
81
|
+
const value = Number(process.env[name]);
|
|
82
|
+
return Number.isFinite(value) ? value : fallback;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stopReason(payload) {
|
|
86
|
+
return String(
|
|
87
|
+
payload.stop_reason ??
|
|
88
|
+
payload.stopReason ??
|
|
89
|
+
payload.reason ??
|
|
90
|
+
payload.error ??
|
|
91
|
+
"",
|
|
92
|
+
).toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isContextLimitStop(payload) {
|
|
96
|
+
const reason = stopReason(payload);
|
|
97
|
+
return CONTEXT_LIMIT_PATTERNS.some((pattern) => reason.includes(pattern));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isUserAbortStop(payload) {
|
|
101
|
+
if (payload.user_requested === true || payload.userRequested === true) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const reason = stopReason(payload).trim();
|
|
106
|
+
return (
|
|
107
|
+
USER_ABORT_EXACT.has(reason) ||
|
|
108
|
+
USER_ABORT_SUBSTRINGS.some((pattern) => reason.includes(pattern))
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function numericPercent(value) {
|
|
113
|
+
const number = Number(value);
|
|
114
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function tokenPercent(used, max) {
|
|
118
|
+
const usedNumber = Number(used);
|
|
119
|
+
const maxNumber = Number(max);
|
|
120
|
+
if (
|
|
121
|
+
!Number.isFinite(usedNumber) ||
|
|
122
|
+
!Number.isFinite(maxNumber) ||
|
|
123
|
+
maxNumber <= 0
|
|
124
|
+
) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return (usedNumber / maxNumber) * 100;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function contextPercentsFromObject(value) {
|
|
131
|
+
if (!value || typeof value !== "object") return [];
|
|
132
|
+
|
|
133
|
+
const candidates = [
|
|
134
|
+
value.used_percentage,
|
|
135
|
+
value.usedPercentage,
|
|
136
|
+
value.used_percent,
|
|
137
|
+
value.usedPercent,
|
|
138
|
+
value.percentage,
|
|
139
|
+
value.percent,
|
|
140
|
+
value.context_percent,
|
|
141
|
+
value.contextPercent,
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const currentUsage = value.current_usage ?? value.currentUsage ?? {};
|
|
145
|
+
const maxTokens =
|
|
146
|
+
value.context_window_size ??
|
|
147
|
+
value.contextWindowSize ??
|
|
148
|
+
value.max_context_tokens ??
|
|
149
|
+
value.maxContextTokens ??
|
|
150
|
+
value.max_tokens ??
|
|
151
|
+
value.maxTokens ??
|
|
152
|
+
value.total_tokens ??
|
|
153
|
+
value.totalTokens;
|
|
154
|
+
candidates.push(
|
|
155
|
+
tokenPercent(value.used_tokens ?? value.usedTokens, maxTokens),
|
|
156
|
+
tokenPercent(
|
|
157
|
+
currentUsage.input_tokens ?? currentUsage.inputTokens,
|
|
158
|
+
maxTokens,
|
|
159
|
+
),
|
|
160
|
+
tokenPercent(
|
|
161
|
+
currentUsage.total_tokens ?? currentUsage.totalTokens,
|
|
162
|
+
maxTokens,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return candidates.map(numericPercent).filter((percent) => percent !== null);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function contextPercentFromPayload(payload) {
|
|
170
|
+
const percents = [
|
|
171
|
+
...contextPercentsFromObject(payload.context_window),
|
|
172
|
+
...contextPercentsFromObject(payload.contextWindow),
|
|
173
|
+
];
|
|
174
|
+
return percents.length > 0 ? Math.max(...percents) : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseJsonCandidate(text) {
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(text);
|
|
180
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readTranscriptTail(transcriptPath) {
|
|
187
|
+
let fd;
|
|
188
|
+
try {
|
|
189
|
+
const stat = statSync(transcriptPath);
|
|
190
|
+
if (!stat.isFile()) return null;
|
|
191
|
+
|
|
192
|
+
const bytesToRead = Math.min(stat.size, 64 * 1024);
|
|
193
|
+
const offset = Math.max(0, stat.size - bytesToRead);
|
|
194
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
195
|
+
|
|
196
|
+
fd = openSync(transcriptPath, "r");
|
|
197
|
+
const bytesRead = readSync(fd, buffer, 0, bytesToRead, offset);
|
|
198
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
} finally {
|
|
202
|
+
if (fd !== undefined) {
|
|
203
|
+
try {
|
|
204
|
+
closeSync(fd);
|
|
205
|
+
} catch {
|
|
206
|
+
// Ignore close failures: transcript reads are best-effort only.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function contextPercentFromTranscript(payload) {
|
|
213
|
+
const transcriptPath = payload.transcript_path ?? payload.transcriptPath;
|
|
214
|
+
if (!transcriptPath || typeof transcriptPath !== "string") return null;
|
|
215
|
+
|
|
216
|
+
const tail = readTranscriptTail(transcriptPath);
|
|
217
|
+
if (tail === null) return 0;
|
|
218
|
+
|
|
219
|
+
const percents = [];
|
|
220
|
+
for (const line of tail.split(/\r?\n/)) {
|
|
221
|
+
const trimmed = line.trim();
|
|
222
|
+
if (!trimmed) continue;
|
|
223
|
+
const parsed = parseJsonCandidate(trimmed);
|
|
224
|
+
if (!parsed) continue;
|
|
225
|
+
const percent = contextPercentFromPayload(parsed);
|
|
226
|
+
if (percent !== null) percents.push(percent);
|
|
227
|
+
}
|
|
228
|
+
return percents.length > 0 ? Math.max(...percents) : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function contextPercentFromCache() {
|
|
232
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
233
|
+
if (!home) return null;
|
|
234
|
+
const cachePath = join(
|
|
235
|
+
home,
|
|
236
|
+
".claude",
|
|
237
|
+
"cache",
|
|
238
|
+
"tfx-hub",
|
|
239
|
+
"context-monitor.json",
|
|
240
|
+
);
|
|
241
|
+
if (!existsSync(cachePath)) return null;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const parsed = parseJsonCandidate(readFileSync(cachePath, "utf8"));
|
|
245
|
+
if (!parsed) return null;
|
|
246
|
+
const direct = contextPercentFromPayload(parsed);
|
|
247
|
+
if (direct !== null) return direct;
|
|
248
|
+
const percents = contextPercentsFromObject(parsed);
|
|
249
|
+
return percents.length > 0 ? Math.max(...percents) : null;
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function contextPercent(payload) {
|
|
256
|
+
return (
|
|
257
|
+
contextPercentFromPayload(payload) ??
|
|
258
|
+
contextPercentFromTranscript(payload) ??
|
|
259
|
+
contextPercentFromCache()
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function guardKey(payload) {
|
|
264
|
+
const sessionId = payload.session_id ?? payload.sessionId;
|
|
265
|
+
if (typeof sessionId === "string" && SESSION_ID_PATTERN.test(sessionId)) {
|
|
266
|
+
return sessionId;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const fallback = JSON.stringify({
|
|
270
|
+
cwd: PROJECT_ROOT,
|
|
271
|
+
transcript: payload.transcript_path ?? payload.transcriptPath ?? "",
|
|
272
|
+
});
|
|
273
|
+
return createHash("sha256").update(fallback).digest("hex");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function stateDir() {
|
|
277
|
+
return (
|
|
278
|
+
process.env.TRIFLUX_CONTEXT_GUARD_STATE_DIR ||
|
|
279
|
+
join(tmpdir(), "tfx-context-guard-stop")
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function statePathForKey(key) {
|
|
284
|
+
const safeKey = SESSION_ID_PATTERN.test(key)
|
|
285
|
+
? key
|
|
286
|
+
: createHash("sha256").update(key).digest("hex");
|
|
287
|
+
return join(stateDir(), `${safeKey}.json`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function readGuardState(path) {
|
|
291
|
+
try {
|
|
292
|
+
const parsed = parseJsonCandidate(readFileSync(path, "utf8"));
|
|
293
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
294
|
+
} catch {
|
|
295
|
+
return {};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function maybeContextGuardBlock(payload) {
|
|
300
|
+
const percent = contextPercent(payload);
|
|
301
|
+
if (percent === null) return null;
|
|
302
|
+
|
|
303
|
+
const threshold = envNumber(
|
|
304
|
+
"TFX_CONTEXT_GUARD_THRESHOLD",
|
|
305
|
+
DEFAULT_CONTEXT_THRESHOLD,
|
|
306
|
+
);
|
|
307
|
+
const criticalThreshold = envNumber(
|
|
308
|
+
"TFX_CONTEXT_GUARD_CRITICAL_THRESHOLD",
|
|
309
|
+
DEFAULT_CRITICAL_THRESHOLD,
|
|
310
|
+
);
|
|
311
|
+
const maxBlocks = Math.max(
|
|
312
|
+
0,
|
|
313
|
+
Math.floor(envNumber("TFX_CONTEXT_GUARD_MAX_BLOCKS", DEFAULT_MAX_BLOCKS)),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
if (percent >= criticalThreshold) return null;
|
|
317
|
+
if (percent < threshold || maxBlocks === 0) return null;
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const path = statePathForKey(guardKey(payload));
|
|
321
|
+
const state = readGuardState(path);
|
|
322
|
+
const blockCount = Math.max(0, Math.floor(Number(state.blockCount) || 0));
|
|
323
|
+
if (blockCount >= maxBlocks) return null;
|
|
324
|
+
|
|
325
|
+
const nextBlockCount = blockCount + 1;
|
|
326
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
327
|
+
writeFileSync(
|
|
328
|
+
path,
|
|
329
|
+
JSON.stringify({
|
|
330
|
+
blockCount: nextBlockCount,
|
|
331
|
+
percent,
|
|
332
|
+
updatedAt: new Date().toISOString(),
|
|
333
|
+
}),
|
|
334
|
+
"utf8",
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
decision: "block",
|
|
339
|
+
reason:
|
|
340
|
+
`[tfx context guard] context usage ${percent.toFixed(1)}% is above ` +
|
|
341
|
+
`${threshold}% threshold. Compact or reduce context before stopping. ` +
|
|
342
|
+
`(Block ${nextBlockCount}/${maxBlocks})`,
|
|
343
|
+
};
|
|
344
|
+
} catch {
|
|
345
|
+
// Context guard state is best-effort. If retry-state I/O fails, fail open
|
|
346
|
+
// for the guard only so active pipeline Stop blocks can still run.
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
25
351
|
async function checkActivePipelines() {
|
|
26
352
|
if (!existsSync(HUB_DB_PATH)) return [];
|
|
27
353
|
|
|
@@ -39,15 +365,7 @@ async function checkActivePipelines() {
|
|
|
39
365
|
}
|
|
40
366
|
}
|
|
41
367
|
|
|
42
|
-
|
|
43
|
-
const active = await checkActivePipelines();
|
|
44
|
-
|
|
45
|
-
if (active.length === 0) {
|
|
46
|
-
// 활성 파이프라인 없음 → 정상 종료 허용
|
|
47
|
-
process.exit(0);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// 활성 파이프라인 발견 → 구조화 decision으로 block
|
|
368
|
+
function activePipelineBlock(active) {
|
|
51
369
|
const lines = active.map(
|
|
52
370
|
(s) =>
|
|
53
371
|
` - 팀 ${s.team_name}: ${s.phase} 단계 (fix: ${s.fix_attempt}/${s.fix_max}, ralph: ${s.ralph_iteration}/${s.ralph_max})`,
|
|
@@ -60,13 +378,34 @@ try {
|
|
|
60
378
|
`파이프라인을 이어서 진행하려면 /tfx-multi status 로 상태를 확인하세요.\n` +
|
|
61
379
|
`강제 종료하려면 /tfx-multi cancel 을 먼저 실행하세요.`;
|
|
62
380
|
|
|
63
|
-
|
|
64
|
-
const output = {
|
|
381
|
+
return {
|
|
65
382
|
decision: "block",
|
|
66
383
|
reason,
|
|
67
384
|
};
|
|
385
|
+
}
|
|
68
386
|
|
|
69
|
-
|
|
387
|
+
try {
|
|
388
|
+
const payload = readStopPayload();
|
|
389
|
+
|
|
390
|
+
if (isContextLimitStop(payload) || isUserAbortStop(payload)) {
|
|
391
|
+
process.exit(0);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const contextBlock = maybeContextGuardBlock(payload);
|
|
395
|
+
if (contextBlock) {
|
|
396
|
+
process.stdout.write(JSON.stringify(contextBlock));
|
|
397
|
+
process.exit(0);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const active = await checkActivePipelines();
|
|
401
|
+
|
|
402
|
+
if (active.length === 0) {
|
|
403
|
+
// 활성 파이프라인 없음 → 정상 종료 허용
|
|
404
|
+
process.exit(0);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// 활성 파이프라인 발견 → 구조화 decision으로 block
|
|
408
|
+
process.stdout.write(JSON.stringify(activePipelineBlock(active)));
|
|
70
409
|
} catch {
|
|
71
410
|
// 훅 실패 시 종료 허용
|
|
72
411
|
process.exit(0);
|