triflux 9.2.2 → 9.2.3
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/bin/triflux.mjs +1 -1
- package/hooks/agent-route-guard.mjs +107 -6
- package/hooks/cross-review-tracker.mjs +122 -0
- package/hooks/error-context.mjs +148 -0
- package/hooks/pipeline-stop.mjs +81 -57
- package/hooks/safety-guard.mjs +106 -0
- package/hooks/subagent-verifier.mjs +80 -0
- package/package.json +1 -1
package/bin/triflux.mjs
CHANGED
|
@@ -20,7 +20,7 @@ const CODEX_CONFIG_PATH = join(CODEX_DIR, "config.toml");
|
|
|
20
20
|
const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
|
|
21
21
|
|
|
22
22
|
// 이 배열에 포함된 버전에서만 star prompt를 표시한다 (빈 배열 = 모든 버전에서 표시)
|
|
23
|
-
const STAR_PROMPT_VERSIONS = [];
|
|
23
|
+
const STAR_PROMPT_VERSIONS = ["9.2.2"];
|
|
24
24
|
|
|
25
25
|
const REQUIRED_CODEX_PROFILES = [
|
|
26
26
|
{
|
|
@@ -1,8 +1,109 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// PreToolUse:Agent
|
|
3
|
-
//
|
|
2
|
+
// hooks/agent-route-guard.mjs — PreToolUse:Agent 훅
|
|
3
|
+
// 서브에이전트 스폰 시 triflux 컨텍스트를 구조화 JSON으로 주입한다.
|
|
4
|
+
// - subagent_type별 최적 라우팅 가이드
|
|
5
|
+
// - tfx-multi 활성 상태 시 headless dispatch 강제
|
|
6
|
+
// - 프로젝트 컨텍스트 자동 첨부
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
|
|
12
|
+
const TFX_MULTI_STATE = join(tmpdir(), "tfx-multi-state.json");
|
|
13
|
+
const EXPIRE_MS = 30 * 60 * 1000; // 30분
|
|
14
|
+
|
|
15
|
+
// 서브에이전트 타입별 라우팅 힌트
|
|
16
|
+
const AGENT_HINTS = {
|
|
17
|
+
"general-purpose": "범용 에이전트. tfx 스킬이 활성이면 스킬 MD의 라우팅을 우선한다.",
|
|
18
|
+
Explore: "탐색 전용. 파일 수정 불가. Glob/Grep/Read만 사용.",
|
|
19
|
+
Plan: "설계 전용. 파일 수정 불가. 구현 계획 반환.",
|
|
20
|
+
"oh-my-claudecode:executor": "OMC executor. triflux 프로젝트에서는 tfx-auto 라우팅을 우선.",
|
|
21
|
+
"oh-my-claudecode:code-reviewer": "OMC 리뷰어. 교차 리뷰 시 CLAUDE.md 교차 검증 규칙 준수.",
|
|
22
|
+
"oh-my-claudecode:architect": "OMC 아키텍트. READ-ONLY.",
|
|
23
|
+
"oh-my-claudecode:debugger": "OMC 디버거. 근본 원인 분석 집중.",
|
|
24
|
+
"oh-my-claudecode:test-engineer": "OMC 테스트. npm test 실행 후 결과 반환.",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function readStdin() {
|
|
28
|
+
try {
|
|
29
|
+
return readFileSync(0, "utf8");
|
|
30
|
+
} catch {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getTfxMultiState() {
|
|
36
|
+
if (!existsSync(TFX_MULTI_STATE)) return null;
|
|
37
|
+
try {
|
|
38
|
+
const state = JSON.parse(readFileSync(TFX_MULTI_STATE, "utf8"));
|
|
39
|
+
if (Date.now() - state.activatedAt > EXPIRE_MS) return null;
|
|
40
|
+
return state.active ? state : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildContext(agentType, prompt) {
|
|
47
|
+
const parts = [];
|
|
48
|
+
|
|
49
|
+
// 1. tfx-multi 활성 상태 확인
|
|
50
|
+
const multiState = getTfxMultiState();
|
|
51
|
+
if (multiState) {
|
|
52
|
+
parts.push(
|
|
53
|
+
"[tfx-multi ACTIVE] headless dispatch 모드. " +
|
|
54
|
+
"CLI 작업은 Bash(tfx-route.sh)를 통해 실행하세요."
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. 에이전트 타입별 힌트
|
|
59
|
+
const hint = AGENT_HINTS[agentType];
|
|
60
|
+
if (hint) {
|
|
61
|
+
parts.push(`[Agent:${agentType}] ${hint}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 3. 프로젝트 컨텍스트
|
|
65
|
+
parts.push(
|
|
66
|
+
"triflux 프로젝트: subagent_type 미지정 시 'general-purpose' 기본. " +
|
|
67
|
+
"tfx-* 스킬 활성 시 스킬 MD 라우팅 우선."
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return parts.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function main() {
|
|
74
|
+
const raw = readStdin();
|
|
75
|
+
if (!raw.trim()) process.exit(0);
|
|
76
|
+
|
|
77
|
+
let input;
|
|
78
|
+
try {
|
|
79
|
+
input = JSON.parse(raw);
|
|
80
|
+
} catch {
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (input.tool_name !== "Agent") process.exit(0);
|
|
85
|
+
|
|
86
|
+
const toolInput = input.tool_input || {};
|
|
87
|
+
const agentType = toolInput.subagent_type || toolInput.agent || "general-purpose";
|
|
88
|
+
const prompt = toolInput.prompt || "";
|
|
89
|
+
|
|
90
|
+
const context = buildContext(agentType, prompt);
|
|
91
|
+
|
|
92
|
+
// 구조화된 hookSpecificOutput 반환
|
|
93
|
+
const output = {
|
|
94
|
+
hookSpecificOutput: {
|
|
95
|
+
hookEventName: "PreToolUse",
|
|
96
|
+
permissionDecision: "allow",
|
|
97
|
+
additionalContext: context,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
process.stdout.write(JSON.stringify(output));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
main();
|
|
106
|
+
} catch {
|
|
107
|
+
// 훅 실패 시 블로킹하지 않음
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/cross-review-tracker.mjs — PostToolUse:Edit|Write 훅
|
|
3
|
+
//
|
|
4
|
+
// 파일 수정을 추적하여 교차 리뷰 미검증 파일을 감지한다.
|
|
5
|
+
// CLAUDE.md 규칙: "Claude 작성 코드 → Codex 리뷰, Codex 작성 → Claude 리뷰"
|
|
6
|
+
//
|
|
7
|
+
// 동작:
|
|
8
|
+
// 1. Edit/Write 성공 시 수정된 파일 경로를 상태 파일에 누적
|
|
9
|
+
// 2. 일정 수(REVIEW_THRESHOLD) 이상 미검증 파일이 쌓이면 nudge 메시지 주입
|
|
10
|
+
// 3. git commit 전 미검증 파일 경고
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
13
|
+
import { join, relative } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
|
|
16
|
+
const STATE_DIR = join(tmpdir(), "tfx-cross-review");
|
|
17
|
+
const STATE_FILE = join(STATE_DIR, "pending-review.json");
|
|
18
|
+
const REVIEW_THRESHOLD = 5; // 이 수 이상 미검증 파일 → nudge
|
|
19
|
+
const EXPIRE_MS = 60 * 60 * 1000; // 1시간 후 자동 만료
|
|
20
|
+
|
|
21
|
+
// 코드 파일만 추적 (설정/문서/빌드 산출물 제외)
|
|
22
|
+
const CODE_EXTENSIONS = new Set([
|
|
23
|
+
".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
|
|
24
|
+
".py", ".rs", ".go", ".java", ".c", ".cpp", ".h",
|
|
25
|
+
".vue", ".svelte", ".sh", ".bash", ".ps1",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function isCodeFile(filePath) {
|
|
29
|
+
if (!filePath) return false;
|
|
30
|
+
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
|
|
31
|
+
return CODE_EXTENSIONS.has(ext);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadState() {
|
|
35
|
+
if (!existsSync(STATE_FILE)) {
|
|
36
|
+
return { files: {}, startedAt: Date.now() };
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const state = JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
|
40
|
+
// 만료 체크
|
|
41
|
+
if (Date.now() - state.startedAt > EXPIRE_MS) {
|
|
42
|
+
return { files: {}, startedAt: Date.now() };
|
|
43
|
+
}
|
|
44
|
+
return state;
|
|
45
|
+
} catch {
|
|
46
|
+
return { files: {}, startedAt: Date.now() };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveState(state) {
|
|
51
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
52
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readStdin() {
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(0, "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function main() {
|
|
64
|
+
const raw = readStdin();
|
|
65
|
+
if (!raw.trim()) process.exit(0);
|
|
66
|
+
|
|
67
|
+
let input;
|
|
68
|
+
try {
|
|
69
|
+
input = JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const toolName = input.tool_name || "";
|
|
75
|
+
if (toolName !== "Edit" && toolName !== "Write") process.exit(0);
|
|
76
|
+
|
|
77
|
+
const toolInput = input.tool_input || {};
|
|
78
|
+
const filePath = toolInput.file_path || "";
|
|
79
|
+
|
|
80
|
+
if (!filePath || !isCodeFile(filePath)) process.exit(0);
|
|
81
|
+
|
|
82
|
+
// 프로젝트 루트 기준 상대 경로
|
|
83
|
+
const cwd = input.cwd || process.cwd();
|
|
84
|
+
const relPath = relative(cwd, filePath) || filePath;
|
|
85
|
+
|
|
86
|
+
// 상태 갱신: 파일 추가
|
|
87
|
+
const state = loadState();
|
|
88
|
+
state.files[relPath] = {
|
|
89
|
+
tool: toolName,
|
|
90
|
+
modifiedAt: Date.now(),
|
|
91
|
+
reviewed: false,
|
|
92
|
+
};
|
|
93
|
+
saveState(state);
|
|
94
|
+
|
|
95
|
+
// 미검증 파일 수 체크
|
|
96
|
+
const unreviewed = Object.entries(state.files).filter(
|
|
97
|
+
([, v]) => !v.reviewed
|
|
98
|
+
);
|
|
99
|
+
const count = unreviewed.length;
|
|
100
|
+
|
|
101
|
+
if (count >= REVIEW_THRESHOLD) {
|
|
102
|
+
// nudge 메시지 주입
|
|
103
|
+
const fileList = unreviewed
|
|
104
|
+
.slice(0, 8)
|
|
105
|
+
.map(([f]) => ` - ${f}`)
|
|
106
|
+
.join("\n");
|
|
107
|
+
|
|
108
|
+
const output = {
|
|
109
|
+
systemMessage:
|
|
110
|
+
`[교차 리뷰 nudge] 미검증 코드 파일 ${count}개:\n${fileList}\n` +
|
|
111
|
+
(count > 8 ? ` ... 외 ${count - 8}개\n` : "") +
|
|
112
|
+
`커밋 전 교차 리뷰를 권장합니다. (Claude→Codex 또는 Codex→Claude)`,
|
|
113
|
+
};
|
|
114
|
+
process.stdout.write(JSON.stringify(output));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
main();
|
|
120
|
+
} catch {
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/error-context.mjs — PostToolUseFailure 훅
|
|
3
|
+
//
|
|
4
|
+
// 도구 실패 시 에러 패턴을 분석하여 해결 힌트를 additionalContext로 주입한다.
|
|
5
|
+
// Claude가 동일 에러를 반복하지 않도록 구체적 가이드를 제공.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
// ── 에러 패턴 → 해결 힌트 매핑 ─────────────────────────────
|
|
10
|
+
const ERROR_HINTS = [
|
|
11
|
+
// Node.js / npm
|
|
12
|
+
{
|
|
13
|
+
pattern: /ENOENT.*no such file or directory/i,
|
|
14
|
+
hint: "파일/디렉토리가 존재하지 않습니다. 경로를 확인하거나 mkdir -p로 디렉토리를 먼저 생성하세요.",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
pattern: /EACCES.*permission denied/i,
|
|
18
|
+
hint: "권한 부족. Windows에서는 관리자 권한, Unix에서는 chmod/sudo를 확인하세요.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
pattern: /EADDRINUSE/i,
|
|
22
|
+
hint: "포트가 이미 사용 중입니다. lsof -i :{port} 또는 netstat -ano | findstr :{port}로 확인 후 프로세스를 종료하세요.",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
pattern: /ERR_MODULE_NOT_FOUND|Cannot find module/i,
|
|
26
|
+
hint: "모듈을 찾을 수 없습니다. npm install을 실행하거나, import 경로에 .mjs/.js 확장자를 명시하세요.",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
pattern: /ETARGET|ERR_INVALID_PACKAGE_TARGET/i,
|
|
30
|
+
hint: "패키지 버전 해석 실패. package.json의 exports 필드 또는 의존성 버전을 확인하세요.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
pattern: /npm ERR! code E40[134]/i,
|
|
34
|
+
hint: "npm 인증 오류. npm login 또는 .npmrc 토큰을 확인하세요.",
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Git
|
|
38
|
+
{
|
|
39
|
+
pattern: /fatal: not a git repository/i,
|
|
40
|
+
hint: "git 저장소가 아닙니다. git init 또는 올바른 디렉토리로 이동하세요.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
pattern: /merge conflict|CONFLICT.*Merge/i,
|
|
44
|
+
hint: "병합 충돌 발생. 충돌 파일을 수동 해결 후 git add + git commit 하세요.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
pattern: /rejected.*non-fast-forward/i,
|
|
48
|
+
hint: "원격에 새 커밋이 있습니다. git pull --rebase 후 다시 push하세요.",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
pattern: /fatal: refusing to merge unrelated histories/i,
|
|
52
|
+
hint: "--allow-unrelated-histories 플래그가 필요할 수 있습니다.",
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Python
|
|
56
|
+
{
|
|
57
|
+
pattern: /ModuleNotFoundError/i,
|
|
58
|
+
hint: "Python 모듈 미설치. pip install 또는 가상환경 활성화를 확인하세요.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
pattern: /SyntaxError.*invalid syntax/i,
|
|
62
|
+
hint: "Python 문법 오류. Python 버전(2 vs 3) 호환성도 확인하세요.",
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// Windows 특이
|
|
66
|
+
{
|
|
67
|
+
pattern: /is not recognized as an internal or external command/i,
|
|
68
|
+
hint: "명령어를 찾을 수 없습니다. PATH 환경변수를 확인하거나 절대 경로를 사용하세요.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
pattern: /execution policy/i,
|
|
72
|
+
hint: "PowerShell 실행 정책 제한. -ExecutionPolicy Bypass 플래그를 추가하세요.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
pattern: /The process cannot access the file because it is being used/i,
|
|
76
|
+
hint: "파일이 다른 프로세스에 의해 잠겨 있습니다. 해당 프로세스를 종료하거나 잠시 후 재시도하세요.",
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// 일반
|
|
80
|
+
{
|
|
81
|
+
pattern: /timeout|timed out|ETIMEDOUT/i,
|
|
82
|
+
hint: "타임아웃 발생. 네트워크 상태를 확인하거나 timeout 값을 늘리세요.",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
pattern: /out of memory|heap|ENOMEM/i,
|
|
86
|
+
hint: "메모리 부족. --max-old-space-size를 늘리거나 데이터 크기를 줄이세요.",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
pattern: /ECONNREFUSED/i,
|
|
90
|
+
hint: "연결 거부. 대상 서버/서비스가 실행 중인지 확인하세요.",
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function readStdin() {
|
|
95
|
+
try {
|
|
96
|
+
return readFileSync(0, "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findHints(errorText) {
|
|
103
|
+
const hints = [];
|
|
104
|
+
for (const rule of ERROR_HINTS) {
|
|
105
|
+
if (rule.pattern.test(errorText)) {
|
|
106
|
+
hints.push(rule.hint);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return hints;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function main() {
|
|
113
|
+
const raw = readStdin();
|
|
114
|
+
if (!raw.trim()) process.exit(0);
|
|
115
|
+
|
|
116
|
+
let input;
|
|
117
|
+
try {
|
|
118
|
+
input = JSON.parse(raw);
|
|
119
|
+
} catch {
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// tool_output 또는 error 필드에서 에러 텍스트 추출
|
|
124
|
+
const errorText = [
|
|
125
|
+
input.tool_output || "",
|
|
126
|
+
input.error || "",
|
|
127
|
+
input.tool_input?.command || "",
|
|
128
|
+
JSON.stringify(input.tool_result || ""),
|
|
129
|
+
].join("\n");
|
|
130
|
+
|
|
131
|
+
const hints = findHints(errorText);
|
|
132
|
+
if (hints.length === 0) process.exit(0);
|
|
133
|
+
|
|
134
|
+
const toolName = input.tool_name || "Unknown";
|
|
135
|
+
const output = {
|
|
136
|
+
systemMessage:
|
|
137
|
+
`[error-context] ${toolName} 실패 — 해결 힌트:\n` +
|
|
138
|
+
hints.map((h) => ` → ${h}`).join("\n"),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
process.stdout.write(JSON.stringify(output));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
main();
|
|
146
|
+
} catch {
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
package/hooks/pipeline-stop.mjs
CHANGED
|
@@ -1,57 +1,81 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// hooks/pipeline-stop.mjs —
|
|
3
|
-
//
|
|
4
|
-
// Claude Code
|
|
5
|
-
// 비터미널 단계의 파이프라인이 있으면 "
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/pipeline-stop.mjs — Stop 훅: 활성 파이프라인 감지 시 구조화 decision 반환
|
|
3
|
+
//
|
|
4
|
+
// Claude Code Stop 이벤트에서 실행.
|
|
5
|
+
// 비터미널 단계의 파이프라인이 있으면 decision:"block" + reason으로 중단을 방지한다.
|
|
6
|
+
// 파이프라인이 없으면 정상 종료를 허용한다.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
let getPipelineStateDbPath;
|
|
13
|
+
try {
|
|
14
|
+
const stateModule = await import("../hub/pipeline/state.mjs");
|
|
15
|
+
getPipelineStateDbPath = stateModule.getPipelineStateDbPath;
|
|
16
|
+
} catch {
|
|
17
|
+
// hub/pipeline 모듈 없으면 훅 무동작
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
22
|
+
const HUB_DB_PATH = getPipelineStateDbPath(PROJECT_ROOT);
|
|
23
|
+
const TERMINAL = new Set(["complete", "failed"]);
|
|
24
|
+
|
|
25
|
+
async function checkActivePipelines() {
|
|
26
|
+
if (!existsSync(HUB_DB_PATH)) return [];
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const { default: Database } = await import("better-sqlite3");
|
|
30
|
+
const { ensurePipelineTable, listPipelineStates } = await import(
|
|
31
|
+
join(
|
|
32
|
+
process.env.CLAUDE_PLUGIN_ROOT || PROJECT_ROOT,
|
|
33
|
+
"hub",
|
|
34
|
+
"pipeline",
|
|
35
|
+
"state.mjs"
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const db = new Database(HUB_DB_PATH, { readonly: true });
|
|
40
|
+
ensurePipelineTable(db);
|
|
41
|
+
const states = listPipelineStates(db);
|
|
42
|
+
db.close();
|
|
43
|
+
|
|
44
|
+
return states.filter((s) => !TERMINAL.has(s.phase));
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const active = await checkActivePipelines();
|
|
52
|
+
|
|
53
|
+
if (active.length === 0) {
|
|
54
|
+
// 활성 파이프라인 없음 → 정상 종료 허용
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 활성 파이프라인 발견 → 구조화 decision으로 block
|
|
59
|
+
const lines = active.map(
|
|
60
|
+
(s) =>
|
|
61
|
+
` - 팀 ${s.team_name}: ${s.phase} 단계 (fix: ${s.fix_attempt}/${s.fix_max}, ralph: ${s.ralph_iteration}/${s.ralph_max})`
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const reason =
|
|
65
|
+
`[tfx-multi 파이프라인 진행 중]\n` +
|
|
66
|
+
`활성 파이프라인 ${active.length}개가 아직 완료되지 않았습니다:\n` +
|
|
67
|
+
`${lines.join("\n")}\n\n` +
|
|
68
|
+
`파이프라인을 이어서 진행하려면 /tfx-multi status 로 상태를 확인하세요.\n` +
|
|
69
|
+
`강제 종료하려면 /tfx-multi cancel 을 먼저 실행하세요.`;
|
|
70
|
+
|
|
71
|
+
// 구조화된 Stop hook 출력: decision + reason
|
|
72
|
+
const output = {
|
|
73
|
+
decision: "block",
|
|
74
|
+
reason,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
process.stdout.write(JSON.stringify(output));
|
|
78
|
+
} catch {
|
|
79
|
+
// 훅 실패 시 종료 허용
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/safety-guard.mjs — PreToolUse:Bash 훅
|
|
3
|
+
//
|
|
4
|
+
// 위험한 Bash 명령을 사전 차단(exit 2)하거나 경고(additionalContext)한다.
|
|
5
|
+
// hooks.json에서 `if: "Bash(*)"` 필터와 함께 사용.
|
|
6
|
+
//
|
|
7
|
+
// 차단 레벨:
|
|
8
|
+
// BLOCK (exit 2) — 복구 불가능한 파괴적 명령
|
|
9
|
+
// WARN (allow + context) — 주의가 필요한 명령
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
// ── 차단 규칙 ──────────────────────────────────────────────
|
|
14
|
+
const BLOCK_RULES = [
|
|
15
|
+
{ pattern: /\brm\s+(-[^\s]*)?-rf?\s+[/~](?!\S*node_modules)/i, reason: "루트/홈 디렉토리 rm -rf 차단" },
|
|
16
|
+
{ pattern: /\brm\s+(-[^\s]*)?-rf?\s+\.\s*$/i, reason: "현재 디렉토리 rm -rf . 차단" },
|
|
17
|
+
{ pattern: /\bgit\s+push\s+.*--force\s+.*\b(main|master)\b/i, reason: "main/master force push 차단" },
|
|
18
|
+
{ pattern: /\bgit\s+push\s+--force\s*$/i, reason: "대상 미지정 force push 차단" },
|
|
19
|
+
{ pattern: /\bgit\s+reset\s+--hard\s+origin\//i, reason: "remote reset --hard 차단 — 로컬 작업 소실 위험" },
|
|
20
|
+
{ pattern: /\bdrop\s+(table|database|schema)\b/i, reason: "SQL DROP 차단" },
|
|
21
|
+
{ pattern: /\btruncate\s+table\b/i, reason: "SQL TRUNCATE 차단" },
|
|
22
|
+
{ pattern: /\bformat\s+[a-z]:/i, reason: "디스크 포맷 차단" },
|
|
23
|
+
{ pattern: /\b(del|rmdir)\s+\/[sq]\b/i, reason: "Windows 재귀 삭제 차단" },
|
|
24
|
+
{ pattern: /\bgit\s+clean\s+.*-fd/i, reason: "git clean -fd 차단 — 추적되지 않은 파일 소실 위험" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// ── 경고 규칙 ──────────────────────────────────────────────
|
|
28
|
+
const WARN_RULES = [
|
|
29
|
+
{ pattern: /\bgit\s+push\b(?!.*--force)/i, warn: "git push 감지. 원격 저장소에 반영됩니다." },
|
|
30
|
+
{ pattern: /\bgit\s+rebase\b/i, warn: "git rebase 감지. 커밋 히스토리가 변경됩니다." },
|
|
31
|
+
{ pattern: /\bgit\s+branch\s+-[dD]\b/i, warn: "브랜치 삭제 감지." },
|
|
32
|
+
{ pattern: /\bnpm\s+publish\b/i, warn: "npm publish 감지. 공개 레지스트리에 배포됩니다." },
|
|
33
|
+
{ pattern: /\brm\s+(-[^\s]*)?-rf?\s/i, warn: "재귀 삭제 감지. 대상을 확인하세요." },
|
|
34
|
+
{ pattern: /--no-verify\b/i, warn: "--no-verify 감지. 훅 건너뛰기는 권장하지 않습니다." },
|
|
35
|
+
{ pattern: /\bchmod\s+777\b/i, warn: "chmod 777 감지. 보안 위험." },
|
|
36
|
+
{ pattern: /\bcurl\s.*\|\s*(bash|sh)\b/i, warn: "curl | sh 감지. 원격 스크립트 실행 주의." },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function readStdin() {
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(0, "utf8");
|
|
42
|
+
} catch {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function main() {
|
|
48
|
+
const raw = readStdin();
|
|
49
|
+
if (!raw.trim()) process.exit(0);
|
|
50
|
+
|
|
51
|
+
let input;
|
|
52
|
+
try {
|
|
53
|
+
input = JSON.parse(raw);
|
|
54
|
+
} catch {
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (input.tool_name !== "Bash") process.exit(0);
|
|
59
|
+
|
|
60
|
+
const command = (input.tool_input?.command || "").trim();
|
|
61
|
+
if (!command) process.exit(0);
|
|
62
|
+
|
|
63
|
+
// 1. BLOCK 체크 — exit 2로 차단
|
|
64
|
+
for (const rule of BLOCK_RULES) {
|
|
65
|
+
if (rule.pattern.test(command)) {
|
|
66
|
+
process.stderr.write(
|
|
67
|
+
`[triflux safety-guard] BLOCKED: ${rule.reason}\n` +
|
|
68
|
+
`명령어: ${command.slice(0, 120)}${command.length > 120 ? "..." : ""}\n` +
|
|
69
|
+
`이 명령은 실행할 수 없습니다. 안전한 대안을 사용하세요.`
|
|
70
|
+
);
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. WARN 체크 — allow + additionalContext
|
|
76
|
+
const warnings = [];
|
|
77
|
+
for (const rule of WARN_RULES) {
|
|
78
|
+
if (rule.pattern.test(command)) {
|
|
79
|
+
warnings.push(rule.warn);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (warnings.length > 0) {
|
|
84
|
+
const output = {
|
|
85
|
+
hookSpecificOutput: {
|
|
86
|
+
hookEventName: "PreToolUse",
|
|
87
|
+
permissionDecision: "allow",
|
|
88
|
+
additionalContext:
|
|
89
|
+
`[safety-guard] ⚠ ${warnings.join(" | ")}\n` +
|
|
90
|
+
`명령어: ${command.slice(0, 200)}`,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
process.stdout.write(JSON.stringify(output));
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. 안전한 명령 → 통과
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
main();
|
|
103
|
+
} catch {
|
|
104
|
+
// 훅 실패 시 블로킹하지 않음
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/subagent-verifier.mjs — SubagentStop 훅
|
|
3
|
+
//
|
|
4
|
+
// 서브에이전트 완료 시 결과 품질을 체크한다:
|
|
5
|
+
// - 빈 결과 감지 → 재시도 제안
|
|
6
|
+
// - 에러 종료 감지 → 원인 분석 컨텍스트 주입
|
|
7
|
+
// - 과도한 토큰 사용 감지 → 효율성 알림
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
function readStdin() {
|
|
12
|
+
try {
|
|
13
|
+
return readFileSync(0, "utf8");
|
|
14
|
+
} catch {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function main() {
|
|
20
|
+
const raw = readStdin();
|
|
21
|
+
if (!raw.trim()) process.exit(0);
|
|
22
|
+
|
|
23
|
+
let input;
|
|
24
|
+
try {
|
|
25
|
+
input = JSON.parse(raw);
|
|
26
|
+
} catch {
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const agentType = input.agent_type || input.subagent_type || "unknown";
|
|
31
|
+
const result = input.tool_output || input.result || "";
|
|
32
|
+
const resultStr = typeof result === "string" ? result : JSON.stringify(result);
|
|
33
|
+
|
|
34
|
+
const issues = [];
|
|
35
|
+
|
|
36
|
+
// 1. 빈 결과 체크
|
|
37
|
+
if (!resultStr.trim() || resultStr.trim().length < 20) {
|
|
38
|
+
issues.push(
|
|
39
|
+
`서브에이전트(${agentType})가 거의 빈 결과를 반환했습니다. ` +
|
|
40
|
+
"프롬프트를 더 구체적으로 작성하거나, 다른 subagent_type을 시도하세요."
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. 에러 키워드 감지
|
|
45
|
+
const errorPatterns = [
|
|
46
|
+
/error:|exception:|traceback|failed to|fatal:/i,
|
|
47
|
+
/❌|FAILED|ERROR/,
|
|
48
|
+
];
|
|
49
|
+
const hasError = errorPatterns.some((p) => p.test(resultStr));
|
|
50
|
+
if (hasError && resultStr.length > 50) {
|
|
51
|
+
issues.push(
|
|
52
|
+
`서브에이전트(${agentType}) 결과에 에러 신호가 감지되었습니다. ` +
|
|
53
|
+
"결과를 검토하고, 필요 시 다른 접근 방식을 사용하세요."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 3. 결과가 너무 길면 요약 필요 알림
|
|
58
|
+
if (resultStr.length > 15000) {
|
|
59
|
+
issues.push(
|
|
60
|
+
`서브에이전트(${agentType}) 결과가 ${Math.round(resultStr.length / 1000)}K 자입니다. ` +
|
|
61
|
+
"핵심만 추출하여 컨텍스트 윈도우를 절약하세요."
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (issues.length === 0) process.exit(0);
|
|
66
|
+
|
|
67
|
+
const output = {
|
|
68
|
+
systemMessage:
|
|
69
|
+
`[subagent-verifier] ${agentType} 완료 — 주의사항:\n` +
|
|
70
|
+
issues.map((i) => ` → ${i}`).join("\n"),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
process.stdout.write(JSON.stringify(output));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
main();
|
|
78
|
+
} catch {
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|