@triflux/core 10.49.0 → 10.50.1

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 (46) hide show
  1. package/hooks/agy-session-hook.mjs +1 -1
  2. package/hooks/claude-cwd-projection-refresh.mjs +7 -79
  3. package/hooks/codex-session-hook.mjs +3 -8
  4. package/hooks/hook-orchestrator.mjs +8 -648
  5. package/hooks/lib/resolve-root.mjs +1 -1
  6. package/hub/bridge.mjs +290 -51
  7. package/hub/team/claude-daemon-control.mjs +54 -6
  8. package/hub/team/claude-transcript.mjs +240 -0
  9. package/hub/team/session-context.mjs +203 -0
  10. package/hud/constants.mjs +10 -0
  11. package/hud/hud-qos-status.mjs +23 -4
  12. package/hud/providers/gemini.mjs +4 -1
  13. package/hud/renderers.mjs +10 -6
  14. package/package.json +1 -1
  15. package/scripts/lib/doctor-env-checks.mjs +0 -85
  16. package/scripts/lib/legacy-hook-cleanup.mjs +230 -0
  17. package/scripts/lib/legacy-mcp-cleanup.mjs +772 -0
  18. package/scripts/lib/mcp-guard-engine.mjs +44 -271
  19. package/scripts/lib/mcp-manifest.mjs +2 -2
  20. package/scripts/lib/session-presence.mjs +249 -0
  21. package/hooks/agent-route-guard.mjs +0 -113
  22. package/hooks/cross-review-tracker.mjs +0 -135
  23. package/hooks/cto-north-star-brief.mjs +0 -133
  24. package/hooks/error-context.mjs +0 -185
  25. package/hooks/hook-adaptive-collector.mjs +0 -88
  26. package/hooks/hook-manager.mjs +0 -426
  27. package/hooks/hook-registry.json +0 -367
  28. package/hooks/hooks.json +0 -149
  29. package/hooks/keyword-rules.json +0 -676
  30. package/hooks/mcp-config-watcher.mjs +0 -84
  31. package/hooks/permission-safe-allow.mjs +0 -267
  32. package/hooks/pipeline-stop.mjs +0 -435
  33. package/hooks/post-tool-tips.mjs +0 -306
  34. package/hooks/pre-compact-snapshot.mjs +0 -181
  35. package/hooks/safety-guard.mjs +0 -466
  36. package/hooks/session-end-cleanup.mjs +0 -360
  37. package/hooks/session-start-fast.mjs +0 -579
  38. package/hooks/session-start-lake.mjs +0 -81
  39. package/hooks/subagent-tracker.mjs +0 -281
  40. package/hooks/subagent-verifier.mjs +0 -85
  41. package/scripts/lib/cross-review-utils.mjs +0 -59
  42. package/scripts/lib/hook-utils.mjs +0 -16
  43. package/scripts/lib/keyword-rules.mjs +0 -296
  44. package/scripts/lib/mcp-gateway-health-check.mjs +0 -224
  45. package/scripts/lib/mcp-gateway-servers.mjs +0 -70
  46. package/scripts/lib/mcp-gateway-wrapper-check.mjs +0 -35
@@ -1,435 +0,0 @@
1
- #!/usr/bin/env node
2
- // hooks/pipeline-stop.mjs — Stop 훅: 컨텍스트 가드와 활성 파이프라인 감지
3
- //
4
- // Claude Code Stop 이벤트에서 실행.
5
- // 컨텍스트 한계/사용자 중단 신호는 조용히 통과시키고, 높은 컨텍스트 사용량은
6
- // 세션별 제한 횟수만 block 한다. 이후 비터미널 단계의 파이프라인이 있으면
7
- // decision:"block" + reason으로 중단을 방지한다.
8
-
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";
22
-
23
- let getPipelineStateDbPath;
24
- let ensurePipelineTable;
25
- let listPipelineStates;
26
- try {
27
- ({ getPipelineStateDbPath, ensurePipelineTable, listPipelineStates } =
28
- await import("../hub/pipeline/state.mjs"));
29
- } catch {
30
- // hub/pipeline 모듈 없으면 훅 무동작
31
- process.exit(0);
32
- }
33
-
34
- const PROJECT_ROOT = process.env.CLAUDE_CWD || process.cwd();
35
- const HUB_DB_PATH = getPipelineStateDbPath(PROJECT_ROOT);
36
- const TERMINAL = new Set(["complete", "failed"]);
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 explicitMaxTokens =
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
- // Fall back to a 1M context window when the payload omits one — Opus 4.x
155
- // [1M] and Codex GPT-5.6 family run on a 1M window, and assuming 200K there
156
- // false-positives at ~15% real usage. Override via
157
- // TFX_CONTEXT_DEFAULT_MAX_TOKENS for narrower setups.
158
- const envFallback = Number(process.env.TFX_CONTEXT_DEFAULT_MAX_TOKENS);
159
- const fallbackMaxTokens =
160
- Number.isFinite(envFallback) && envFallback > 0 ? envFallback : 1_000_000;
161
- const explicitNumeric = Number(explicitMaxTokens);
162
- const maxTokens =
163
- Number.isFinite(explicitNumeric) && explicitNumeric > 0
164
- ? explicitNumeric
165
- : fallbackMaxTokens;
166
- candidates.push(
167
- tokenPercent(value.used_tokens ?? value.usedTokens, maxTokens),
168
- tokenPercent(
169
- currentUsage.input_tokens ?? currentUsage.inputTokens,
170
- maxTokens,
171
- ),
172
- tokenPercent(
173
- currentUsage.total_tokens ?? currentUsage.totalTokens,
174
- maxTokens,
175
- ),
176
- );
177
-
178
- return candidates.map(numericPercent).filter((percent) => percent !== null);
179
- }
180
-
181
- function contextPercentFromPayload(payload) {
182
- const percents = [
183
- ...contextPercentsFromObject(payload.context_window),
184
- ...contextPercentsFromObject(payload.contextWindow),
185
- ];
186
- return percents.length > 0 ? Math.max(...percents) : null;
187
- }
188
-
189
- function parseJsonCandidate(text) {
190
- try {
191
- const parsed = JSON.parse(text);
192
- return parsed && typeof parsed === "object" ? parsed : null;
193
- } catch {
194
- return null;
195
- }
196
- }
197
-
198
- function readTranscriptTail(transcriptPath) {
199
- let fd;
200
- try {
201
- const stat = statSync(transcriptPath);
202
- if (!stat.isFile()) return null;
203
-
204
- const bytesToRead = Math.min(stat.size, 64 * 1024);
205
- const offset = Math.max(0, stat.size - bytesToRead);
206
- const buffer = Buffer.alloc(bytesToRead);
207
-
208
- fd = openSync(transcriptPath, "r");
209
- const bytesRead = readSync(fd, buffer, 0, bytesToRead, offset);
210
- return buffer.toString("utf8", 0, bytesRead);
211
- } catch {
212
- return null;
213
- } finally {
214
- if (fd !== undefined) {
215
- try {
216
- closeSync(fd);
217
- } catch {
218
- // Ignore close failures: transcript reads are best-effort only.
219
- }
220
- }
221
- }
222
- }
223
-
224
- function contextPercentFromTranscript(payload) {
225
- const transcriptPath = payload.transcript_path ?? payload.transcriptPath;
226
- if (!transcriptPath || typeof transcriptPath !== "string") return null;
227
-
228
- const tail = readTranscriptTail(transcriptPath);
229
- if (tail === null) return 0;
230
-
231
- const percents = [];
232
- for (const line of tail.split(/\r?\n/)) {
233
- const trimmed = line.trim();
234
- if (!trimmed) continue;
235
- const parsed = parseJsonCandidate(trimmed);
236
- if (!parsed) continue;
237
- const percent = contextPercentFromPayload(parsed);
238
- if (percent !== null) percents.push(percent);
239
- }
240
- return percents.length > 0 ? Math.max(...percents) : null;
241
- }
242
-
243
- function isHubTokenMonitorSnapshot(snapshot) {
244
- if (!snapshot || typeof snapshot !== "object") return false;
245
- return (
246
- Object.hasOwn(snapshot, "requestTokens") ||
247
- Object.hasOwn(snapshot, "responseTokens") ||
248
- Object.hasOwn(snapshot, "totalUpdates") ||
249
- (snapshot.byTool && typeof snapshot.byTool === "object")
250
- );
251
- }
252
-
253
- function contextPercentFromCache() {
254
- const home = process.env.HOME || process.env.USERPROFILE;
255
- if (!home) return null;
256
- const cachePath = join(
257
- home,
258
- ".claude",
259
- "cache",
260
- "tfx-hub",
261
- "context-monitor.json",
262
- );
263
- if (!existsSync(cachePath)) return null;
264
-
265
- try {
266
- const parsed = parseJsonCandidate(readFileSync(cachePath, "utf8"));
267
- if (!parsed) return null;
268
- if (isHubTokenMonitorSnapshot(parsed)) return null;
269
- const direct = contextPercentFromPayload(parsed);
270
- if (direct !== null) return direct;
271
- const percents = contextPercentsFromObject(parsed);
272
- return percents.length > 0 ? Math.max(...percents) : null;
273
- } catch {
274
- return null;
275
- }
276
- }
277
-
278
- function contextPercent(payload) {
279
- return (
280
- contextPercentFromPayload(payload) ??
281
- contextPercentFromTranscript(payload) ??
282
- contextPercentFromCache()
283
- );
284
- }
285
-
286
- function guardKey(payload) {
287
- const sessionId = payload.session_id ?? payload.sessionId;
288
- if (typeof sessionId === "string" && SESSION_ID_PATTERN.test(sessionId)) {
289
- return sessionId;
290
- }
291
-
292
- const fallback = JSON.stringify({
293
- cwd: PROJECT_ROOT,
294
- transcript: payload.transcript_path ?? payload.transcriptPath ?? "",
295
- });
296
- return createHash("sha256").update(fallback).digest("hex");
297
- }
298
-
299
- function stateDir() {
300
- return (
301
- process.env.TRIFLUX_CONTEXT_GUARD_STATE_DIR ||
302
- join(tmpdir(), "tfx-context-guard-stop")
303
- );
304
- }
305
-
306
- function statePathForKey(key) {
307
- const safeKey = SESSION_ID_PATTERN.test(key)
308
- ? key
309
- : createHash("sha256").update(key).digest("hex");
310
- return join(stateDir(), `${safeKey}.json`);
311
- }
312
-
313
- function readGuardState(path) {
314
- try {
315
- const parsed = parseJsonCandidate(readFileSync(path, "utf8"));
316
- return parsed && typeof parsed === "object" ? parsed : {};
317
- } catch {
318
- return {};
319
- }
320
- }
321
-
322
- function maybeContextGuardBlock(payload) {
323
- const percent = contextPercent(payload);
324
- if (percent === null) return null;
325
-
326
- const threshold = envNumber(
327
- "TFX_CONTEXT_GUARD_THRESHOLD",
328
- DEFAULT_CONTEXT_THRESHOLD,
329
- );
330
- const criticalThreshold = envNumber(
331
- "TFX_CONTEXT_GUARD_CRITICAL_THRESHOLD",
332
- DEFAULT_CRITICAL_THRESHOLD,
333
- );
334
- const maxBlocks = Math.max(
335
- 0,
336
- Math.floor(envNumber("TFX_CONTEXT_GUARD_MAX_BLOCKS", DEFAULT_MAX_BLOCKS)),
337
- );
338
-
339
- if (percent >= criticalThreshold) return null;
340
- if (percent < threshold || maxBlocks === 0) return null;
341
-
342
- try {
343
- const path = statePathForKey(guardKey(payload));
344
- const state = readGuardState(path);
345
- const blockCount = Math.max(0, Math.floor(Number(state.blockCount) || 0));
346
- if (blockCount >= maxBlocks) return null;
347
-
348
- const nextBlockCount = blockCount + 1;
349
- mkdirSync(stateDir(), { recursive: true });
350
- writeFileSync(
351
- path,
352
- JSON.stringify({
353
- blockCount: nextBlockCount,
354
- percent,
355
- updatedAt: new Date().toISOString(),
356
- }),
357
- "utf8",
358
- );
359
-
360
- return {
361
- decision: "block",
362
- reason:
363
- `[tfx context guard] context usage ${percent.toFixed(1)}% is above ` +
364
- `${threshold}% threshold. Compact or reduce context before stopping. ` +
365
- `(Block ${nextBlockCount}/${maxBlocks})`,
366
- };
367
- } catch {
368
- // Context guard state is best-effort. If retry-state I/O fails, fail open
369
- // for the guard only so active pipeline Stop blocks can still run.
370
- return null;
371
- }
372
- }
373
-
374
- async function checkActivePipelines() {
375
- if (!existsSync(HUB_DB_PATH)) return [];
376
-
377
- try {
378
- const { default: Database } = await import("better-sqlite3");
379
-
380
- const db = new Database(HUB_DB_PATH, { readonly: true });
381
- ensurePipelineTable(db);
382
- const states = listPipelineStates(db);
383
- db.close();
384
-
385
- return states.filter((s) => !TERMINAL.has(s.phase));
386
- } catch {
387
- return [];
388
- }
389
- }
390
-
391
- function activePipelineBlock(active) {
392
- const lines = active.map(
393
- (s) =>
394
- ` - 팀 ${s.team_name}: ${s.phase} 단계 (fix: ${s.fix_attempt}/${s.fix_max}, ralph: ${s.ralph_iteration}/${s.ralph_max})`,
395
- );
396
-
397
- const reason =
398
- `[tfx-multi 파이프라인 진행 중]\n` +
399
- `활성 파이프라인 ${active.length}개가 아직 완료되지 않았습니다:\n` +
400
- `${lines.join("\n")}\n\n` +
401
- `파이프라인을 이어서 진행하려면 /tfx-multi status 로 상태를 확인하세요.\n` +
402
- `강제 종료하려면 /tfx-multi cancel 을 먼저 실행하세요.`;
403
-
404
- return {
405
- decision: "block",
406
- reason,
407
- };
408
- }
409
-
410
- try {
411
- const payload = readStopPayload();
412
-
413
- if (isContextLimitStop(payload) || isUserAbortStop(payload)) {
414
- process.exit(0);
415
- }
416
-
417
- const contextBlock = maybeContextGuardBlock(payload);
418
- if (contextBlock) {
419
- process.stdout.write(JSON.stringify(contextBlock));
420
- process.exit(0);
421
- }
422
-
423
- const active = await checkActivePipelines();
424
-
425
- if (active.length === 0) {
426
- // 활성 파이프라인 없음 → 정상 종료 허용
427
- process.exit(0);
428
- }
429
-
430
- // 활성 파이프라인 발견 → 구조화 decision으로 block
431
- process.stdout.write(JSON.stringify(activePipelineBlock(active)));
432
- } catch {
433
- // 훅 실패 시 종료 허용
434
- process.exit(0);
435
- }