triflux 10.30.1 → 10.32.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.
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { argv, exit, stdin, stdout } from "node:process";
4
+ import { pathToFileURL } from "node:url";
5
+ import { drainPendingSynapse as defaultDrainPendingSynapse } from "../hub/team/synapse-http.mjs";
6
+ import {
7
+ heartbeatInteractiveSession as defaultHeartbeatInteractiveSession,
8
+ registerInteractiveSession as defaultRegisterInteractiveSession,
9
+ } from "./session-start-fast.mjs";
10
+
11
+ // hub-ensure is loaded lazily so the byte-identical packages/core mirror of this
12
+ // file loads cleanly. packages/core mirrors scripts/lib only (not scripts/*), so a
13
+ // static `../scripts/hub-ensure.mjs` import would make the core copy throw
14
+ // ERR_MODULE_NOT_FOUND at load time. The core copy is published-but-dormant — the
15
+ // live codex hook always runs from the installed triflux package where the path
16
+ // resolves. This mirrors session-start-fast.mjs's dynamic script-import pattern.
17
+ async function defaultHubEnsureRun(stdinData) {
18
+ const { run } = await import(
19
+ new URL("../scripts/hub-ensure.mjs", import.meta.url).href
20
+ );
21
+ return run(stdinData);
22
+ }
23
+
24
+ function parsePayload(stdinData) {
25
+ try {
26
+ const raw = typeof stdinData === "string" ? stdinData : "";
27
+ return raw.trim()
28
+ ? { ok: true, payload: JSON.parse(raw) }
29
+ : { ok: false, payload: {} };
30
+ } catch {
31
+ return { ok: false, payload: {} };
32
+ }
33
+ }
34
+
35
+ function normalizeMode(mode, payload) {
36
+ const direct = String(mode || "")
37
+ .trim()
38
+ .toLowerCase();
39
+ if (direct === "register" || direct === "heartbeat") return direct;
40
+
41
+ const eventName = String(payload?.hook_event_name || "")
42
+ .trim()
43
+ .toLowerCase()
44
+ .replace(/-/g, "_");
45
+ if (eventName === "session_start" || eventName === "sessionstart") {
46
+ return "register";
47
+ }
48
+ if (eventName === "user_prompt_submit" || eventName === "userpromptsubmit") {
49
+ return "heartbeat";
50
+ }
51
+ return "";
52
+ }
53
+
54
+ export async function runCodexSessionHook(stdinData, opts = {}) {
55
+ const output = "{}\n";
56
+ const parsed = parsePayload(stdinData);
57
+ const mode = parsed.ok
58
+ ? normalizeMode(opts.argvMode ?? argv[2], parsed.payload)
59
+ : "";
60
+ const hubEnsureRun = opts.hubEnsureRun || defaultHubEnsureRun;
61
+ const registerInteractiveSession =
62
+ opts.registerInteractiveSession || defaultRegisterInteractiveSession;
63
+ const heartbeatInteractiveSession =
64
+ opts.heartbeatInteractiveSession || defaultHeartbeatInteractiveSession;
65
+ const drainPendingSynapse =
66
+ opts.drainPendingSynapse || defaultDrainPendingSynapse;
67
+
68
+ try {
69
+ if (mode === "register") {
70
+ try {
71
+ await hubEnsureRun(stdinData);
72
+ } catch {}
73
+ try {
74
+ registerInteractiveSession(stdinData);
75
+ } catch {}
76
+ try {
77
+ await drainPendingSynapse(1000);
78
+ } catch {}
79
+ } else if (mode === "heartbeat") {
80
+ try {
81
+ heartbeatInteractiveSession(stdinData);
82
+ } catch {}
83
+ try {
84
+ await drainPendingSynapse(500);
85
+ } catch {}
86
+ }
87
+ } catch {
88
+ // Codex session hooks are observational and must never block the session.
89
+ }
90
+
91
+ if (opts.writeStdout !== false) {
92
+ stdout.write(output);
93
+ }
94
+ return output;
95
+ }
96
+
97
+ function readStdin() {
98
+ return new Promise((resolve) => {
99
+ let data = "";
100
+ stdin.setEncoding("utf8");
101
+ stdin.on("data", (chunk) => {
102
+ data += chunk;
103
+ });
104
+ stdin.on("end", () => resolve(data));
105
+ stdin.on("error", () => resolve(data));
106
+ });
107
+ }
108
+
109
+ if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) {
110
+ const stdinData = await readStdin();
111
+ await runCodexSessionHook(stdinData);
112
+ exit(0);
113
+ }
package/hub/server.mjs CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  CallToolRequestSchema,
23
23
  ListToolsRequestSchema,
24
24
  } from "@modelcontextprotocol/sdk/types.js";
25
+ import { runCollect } from "../cto/collect.mjs";
25
26
  import { createModuleLogger } from "../scripts/lib/logger.mjs";
26
27
  import { broker as brokerInstance, reloadBroker } from "./account-broker.mjs";
27
28
  import { createAdaptiveEngine } from "./adaptive.mjs";
@@ -53,6 +54,7 @@ import {
53
54
  writeState,
54
55
  } from "./state.mjs";
55
56
  import { createStoreAdapter } from "./store-adapter.mjs";
57
+ import { createCtoAutoCollector } from "./team/cto-auto-collect.mjs";
56
58
  import { createGitPreflight } from "./team/git-preflight.mjs";
57
59
  import { nativeProxy } from "./team/nativeProxy.mjs";
58
60
  import { createSwarmLocks } from "./team/swarm-locks.mjs";
@@ -1009,10 +1011,16 @@ export async function startHub({
1009
1011
  registry: synapseRegistry,
1010
1012
  locks: swarmLocks,
1011
1013
  });
1014
+ const ctoAutoCollector = createCtoAutoCollector({
1015
+ registry: synapseRegistry,
1016
+ runCollect,
1017
+ logger: hubLog,
1018
+ });
1012
1019
 
1013
1020
  // Synapse Layer 5: emitter subscribers — bridge events to hub logging
1014
- synapseEmitter.on("synapse.session.started", ({ sessionId }) => {
1021
+ synapseEmitter.on("synapse.session.started", ({ sessionId, session }) => {
1015
1022
  hubLog.info({ sessionId }, "synapse.session.started");
1023
+ ctoAutoCollector.handleSessionStarted({ sessionId, session });
1016
1024
  });
1017
1025
  synapseEmitter.on("synapse.session.heartbeat", ({ sessionId }) => {
1018
1026
  hubLog.debug({ sessionId }, "synapse.session.heartbeat");
@@ -2054,6 +2062,16 @@ export async function startHub({
2054
2062
  }, 60000);
2055
2063
  sessionTimer.unref();
2056
2064
 
2065
+ const synapsePruneTimer = setInterval(() => {
2066
+ try {
2067
+ const { count } = synapseRegistry.pruneExpired();
2068
+ if (count > 0) hubLog.info({ count }, "synapse.prune");
2069
+ } catch (err) {
2070
+ hubLog.warn({ err }, "synapse.prune_failed");
2071
+ }
2072
+ }, 60000);
2073
+ synapsePruneTimer.unref();
2074
+
2057
2075
  // 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
2058
2076
  // TFX_DISABLE_ORPHAN_CLEANUP=1 로 비활성 (active SSH/swarm 세션 보호 회피용 hotfix gate)
2059
2077
  const orphanCleanupTimer = setInterval(
@@ -2288,6 +2306,7 @@ export async function startHub({
2288
2306
  router.stopSweeper();
2289
2307
  clearInterval(hitlTimer);
2290
2308
  clearInterval(sessionTimer);
2309
+ clearInterval(synapsePruneTimer);
2291
2310
  clearInterval(rateLimitTimer);
2292
2311
  clearInterval(orphanCleanupTimer);
2293
2312
  if (idleTimer) {
@@ -0,0 +1,109 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const DEFAULT_DEBOUNCE_MS = 120_000;
5
+ const OFF_VALUES = new Set(["0", "false", "off", "no"]);
6
+
7
+ export function isCtoAutoCollectDisabled(env = process.env) {
8
+ return OFF_VALUES.has(
9
+ String(env?.TFX_CTO_AUTO_COLLECT ?? "")
10
+ .trim()
11
+ .toLowerCase(),
12
+ );
13
+ }
14
+
15
+ function freshCurrentMd(projectRoot, now, debounceMs) {
16
+ try {
17
+ const currentMd = join(projectRoot, ".triflux", "lake", "current.md");
18
+ if (!existsSync(currentMd)) return false;
19
+ return now - statSync(currentMd).mtimeMs < debounceMs;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ export function createCtoAutoCollector(opts = {}) {
26
+ const registry = opts.registry;
27
+ const runCollect = opts.runCollect;
28
+ const logger = opts.logger || null;
29
+ const debounceMs = Number(opts.debounceMs || DEFAULT_DEBOUNCE_MS);
30
+ const now = opts.now || (() => Date.now());
31
+ const env = opts.env || process.env;
32
+ const lastCollectMs = opts.lastCollectMs || new Map();
33
+ const inFlight = new Set();
34
+
35
+ function handleSessionStarted({ sessionId, session } = {}) {
36
+ if (isCtoAutoCollectDisabled(env))
37
+ return { triggered: false, reason: "disabled" };
38
+ if (!registry || typeof registry.querySessions !== "function") {
39
+ return { triggered: false, reason: "missing-registry" };
40
+ }
41
+ if (typeof runCollect !== "function") {
42
+ return { triggered: false, reason: "missing-runCollect" };
43
+ }
44
+
45
+ const cwd = typeof session?.cwd === "string" ? session.cwd : "";
46
+ const worktree =
47
+ typeof session?.worktreePath === "string" ? session.worktreePath : "";
48
+ const projectRoot = worktree || cwd;
49
+ if (!projectRoot)
50
+ return { triggered: false, reason: "missing-project-root" };
51
+
52
+ let peers = [];
53
+ try {
54
+ peers = registry.querySessions({
55
+ cwd,
56
+ worktree,
57
+ excludeSessionId: sessionId,
58
+ });
59
+ } catch (error) {
60
+ logger?.warn?.(
61
+ { sessionId, err: String(error?.message || error) },
62
+ "cto.auto_collect.query_failed",
63
+ );
64
+ return { triggered: false, reason: "query-failed" };
65
+ }
66
+ if (!Array.isArray(peers) || peers.length < 1) {
67
+ return { triggered: false, reason: "no-peers" };
68
+ }
69
+
70
+ const currentTime = now();
71
+ const last = lastCollectMs.get(projectRoot) || 0;
72
+ if (currentTime - last < debounceMs) {
73
+ return { triggered: false, reason: "debounced" };
74
+ }
75
+ if (freshCurrentMd(projectRoot, currentTime, debounceMs)) {
76
+ lastCollectMs.set(projectRoot, currentTime);
77
+ return { triggered: false, reason: "fresh-lake" };
78
+ }
79
+
80
+ lastCollectMs.set(projectRoot, currentTime);
81
+ const promise = Promise.resolve()
82
+ .then(() => runCollect([], { rootDir: projectRoot }))
83
+ .catch((error) => {
84
+ logger?.warn?.(
85
+ { projectRoot, err: String(error?.message || error) },
86
+ "cto.auto_collect.failed",
87
+ );
88
+ })
89
+ .finally(() => {
90
+ inFlight.delete(promise);
91
+ });
92
+ inFlight.add(promise);
93
+ return {
94
+ triggered: true,
95
+ reason: "triggered",
96
+ projectRoot,
97
+ peerCount: peers.length,
98
+ };
99
+ }
100
+
101
+ async function drain() {
102
+ await Promise.allSettled([...inFlight]);
103
+ }
104
+
105
+ return Object.freeze({
106
+ handleSessionStarted,
107
+ drain,
108
+ });
109
+ }
@@ -5,6 +5,7 @@ const DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS = 5_000;
5
5
  const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
6
6
  const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
7
7
  const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
8
+ const DEFAULT_EXPIRE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
8
9
  // Interactive (Claude/Codex) sessions idle for long stretches; a 30s local
9
10
  // timeout produces stale false-positives. 5-minute TTL + an `idle` state
10
11
  // distinguishes "alive but inactive" from "presumed dead".
@@ -133,6 +134,7 @@ export function createSynapseRegistry(opts = {}) {
133
134
  remoteTimeoutMs = DEFAULT_REMOTE_TIMEOUT_MS,
134
135
  interactiveHeartbeatIntervalMs = DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS,
135
136
  interactiveTimeoutMs = DEFAULT_INTERACTIVE_TIMEOUT_MS,
137
+ expireTimeoutMs = DEFAULT_EXPIRE_TIMEOUT_MS,
136
138
  } = opts;
137
139
 
138
140
  const sessions = new Map();
@@ -355,10 +357,89 @@ export function createSynapseRegistry(opts = {}) {
355
357
  return true;
356
358
  }
357
359
 
360
+ // stale/expired 세션이 expireTimeoutMs 넘게 누적되면 Map에서 제거.
361
+ // live(active/idle)는 lastHeartbeat가 오래돼도 보존 — git-preflight dirty-file 가드가 의존.
362
+ function pruneExpired(opts2 = {}) {
363
+ if (destroyed) return { removed: [], count: 0 };
364
+
365
+ const cutoff =
366
+ typeof opts2.olderThanMs === "number"
367
+ ? opts2.olderThanMs
368
+ : expireTimeoutMs;
369
+ const removed = [];
370
+ const currentTime = now();
371
+
372
+ for (const [sessionId, session] of sessions) {
373
+ if (
374
+ isLiveStatus(session.status) ||
375
+ currentTime - session.lastHeartbeat <= cutoff
376
+ ) {
377
+ continue;
378
+ }
379
+
380
+ stopMonitor(sessionId);
381
+ sessions.delete(sessionId);
382
+ removed.push(sessionId);
383
+ notifyRemoved(session);
384
+ }
385
+
386
+ if (removed.length > 0) persist();
387
+ return { removed, count: removed.length };
388
+ }
389
+
358
390
  function heartbeat(sessionId, partialMeta = null) {
359
391
  const normalized = normalizeSessionId(sessionId);
360
392
  const session = sessions.get(normalized);
361
- if (!session) return false;
393
+ if (!session) {
394
+ // self-heal은 위치 정보(worktreePath 또는 cwd)가 있는 heartbeat에서만
395
+ // 발동한다. UserPromptSubmit heartbeat는 항상 worktreePath를 보내므로
396
+ // SessionStart register POST drop 복구 경로는 커버되고, taskSummary만 담은
397
+ // 빈약한 heartbeat로 위치 없는 가비지 세션이 register 되는 것은 막는다
398
+ // (그 경우는 기존대로 false → 400/heartbeat failed).
399
+ const hasLocator =
400
+ partialMeta &&
401
+ typeof partialMeta === "object" &&
402
+ !Array.isArray(partialMeta) &&
403
+ ((typeof partialMeta.worktreePath === "string" &&
404
+ partialMeta.worktreePath.length > 0) ||
405
+ (typeof partialMeta.cwd === "string" && partialMeta.cwd.length > 0));
406
+ const canSelfHeal = Boolean(normalized) && hasLocator;
407
+ if (!canSelfHeal) return false;
408
+
409
+ // 미등록 세션 heartbeat = SessionStart register POST가 drop된 경우.
410
+ // self-heal로 register 복구(영구 미등록 방지).
411
+ //
412
+ // sessionKind는 명시값이 없으면 "interactive"로 본다. 실제 복구 경로인
413
+ // heartbeatInteractiveSession은 worktreePath/host/taskSummary만 보내고
414
+ // sessionKind는 생략하는데, sanitizeSession 기본값("headless")으로 떨어지면
415
+ // 복구된 Claude 세션이 30초 local timeout을 받아 idle/5분 TTL을 잃고 곧장
416
+ // stale로 전이돼 getActive()/peer discovery에서 사라진다(이 PRD가 고치려던
417
+ // 원래 증상의 재현). heartbeat 기반 liveness로 살아나는 세션은 interactive다.
418
+ const sanitizedKind =
419
+ typeof partialMeta.sessionKind === "string"
420
+ ? partialMeta.sessionKind
421
+ : "interactive";
422
+ const healed = sanitizeSession(
423
+ {
424
+ ...partialMeta,
425
+ sessionId: normalized,
426
+ sessionKind: sanitizedKind,
427
+ status: "active",
428
+ lastHeartbeat: now(),
429
+ },
430
+ normalized,
431
+ );
432
+ if (!healed) return false;
433
+
434
+ sessions.set(normalized, healed);
435
+ startMonitor(normalized);
436
+ persist();
437
+ emitter?.emit("synapse.session.started", {
438
+ sessionId: normalized,
439
+ session: cloneSession(healed),
440
+ });
441
+ return true;
442
+ }
362
443
 
363
444
  const wasRemote = session.isRemote;
364
445
  const updated = { ...session, lastHeartbeat: now(), status: "active" };
@@ -492,6 +573,7 @@ export function createSynapseRegistry(opts = {}) {
492
573
  return Object.freeze({
493
574
  register,
494
575
  unregister,
576
+ pruneExpired,
495
577
  heartbeat,
496
578
  getActive,
497
579
  getAll,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.30.1",
3
+ "version": "10.32.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from "node:crypto";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const PROJECT_ROOT = dirname(__dirname);
17
+ const SESSION_START_MATCHER = "startup|resume|clear";
18
+ const TRIFLUX_HOOK_BASENAME = "codex-session-hook.mjs";
19
+
20
+ function atomicWriteFile(path, content) {
21
+ const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
22
+ writeFileSync(tmpPath, content, "utf8");
23
+ renameSync(tmpPath, path);
24
+ }
25
+
26
+ function quoteCommandPath(value) {
27
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
28
+ }
29
+
30
+ function buildCommand(nodeBin, hookScriptPath, mode) {
31
+ return `${nodeBin} ${quoteCommandPath(hookScriptPath)} ${mode}`;
32
+ }
33
+
34
+ export function canonicalJson(value) {
35
+ if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
36
+ if (value && typeof value === "object") {
37
+ return Object.fromEntries(
38
+ Object.keys(value)
39
+ .sort()
40
+ .map((key) => [key, canonicalJson(value[key])]),
41
+ );
42
+ }
43
+ return value;
44
+ }
45
+
46
+ export function computeTrustedHookHash({
47
+ eventName,
48
+ matcher,
49
+ command,
50
+ timeout,
51
+ statusMessage,
52
+ }) {
53
+ const hook = {
54
+ type: "command",
55
+ command,
56
+ timeout: Math.max(1, Number(timeout ?? 600)),
57
+ async: false,
58
+ };
59
+ if (statusMessage) hook.statusMessage = statusMessage;
60
+ const identity = {
61
+ event_name: eventName,
62
+ ...(matcher ? { matcher } : {}),
63
+ hooks: [hook],
64
+ };
65
+ return `sha256:${createHash("sha256")
66
+ .update(JSON.stringify(canonicalJson(identity)))
67
+ .digest("hex")}`;
68
+ }
69
+
70
+ function normalizeHooksJson(raw) {
71
+ try {
72
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
73
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
74
+ return { hooks: {} };
75
+ }
76
+ if (!parsed.hooks || typeof parsed.hooks !== "object") parsed.hooks = {};
77
+ return parsed;
78
+ } catch {
79
+ return { hooks: {} };
80
+ }
81
+ }
82
+
83
+ function isTrifluxHookGroup(group) {
84
+ return Array.isArray(group?.hooks)
85
+ ? group.hooks.some(
86
+ (hook) =>
87
+ typeof hook?.command === "string" &&
88
+ hook.command.includes(TRIFLUX_HOOK_BASENAME),
89
+ )
90
+ : false;
91
+ }
92
+
93
+ function upsertHookGroup(groups, desired) {
94
+ const list = Array.isArray(groups) ? [...groups] : [];
95
+ const existingIndex = list.findIndex((group) => isTrifluxHookGroup(group));
96
+ if (existingIndex >= 0) {
97
+ list[existingIndex] = desired;
98
+ return { groups: list, index: existingIndex };
99
+ }
100
+ list.push(desired);
101
+ return { groups: list, index: list.length - 1 };
102
+ }
103
+
104
+ function buildDesiredGroups({ nodeBin, hookScriptPath }) {
105
+ return {
106
+ SessionStart: {
107
+ matcher: SESSION_START_MATCHER,
108
+ hooks: [
109
+ {
110
+ type: "command",
111
+ command: buildCommand(nodeBin, hookScriptPath, "register"),
112
+ timeout: 15,
113
+ },
114
+ ],
115
+ },
116
+ UserPromptSubmit: {
117
+ hooks: [
118
+ {
119
+ type: "command",
120
+ command: buildCommand(nodeBin, hookScriptPath, "heartbeat"),
121
+ timeout: 10,
122
+ },
123
+ ],
124
+ },
125
+ };
126
+ }
127
+
128
+ function formatHooksJson(value) {
129
+ return `${JSON.stringify(value, null, 2)}\n`;
130
+ }
131
+
132
+ function escapeTomlKey(key) {
133
+ return `"${String(key).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
134
+ }
135
+
136
+ function stateLine(key, hash) {
137
+ return `${escapeTomlKey(key)} = { trusted_hash = "${hash}" }`;
138
+ }
139
+
140
+ function escapeRegExp(value) {
141
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
142
+ }
143
+
144
+ function stripManagedStateLines(content, managedKeys, hooksPath) {
145
+ if (!content) return "";
146
+ const escapedKeys = [...managedKeys].map((key) =>
147
+ escapeRegExp(escapeTomlKey(key)),
148
+ );
149
+ for (const label of ["session_start", "user_prompt_submit"]) {
150
+ escapedKeys.push(
151
+ escapeRegExp(escapeTomlKey(`${hooksPath}:${label}:`)).replace(
152
+ /"$/,
153
+ '\\d+:\\d+"',
154
+ ),
155
+ );
156
+ }
157
+ const re = new RegExp(
158
+ `^\\s*(?:${escapedKeys.join("|")})\\s*=\\s*\\{\\s*trusted_hash\\s*=.*\\}\\s*$`,
159
+ "gm",
160
+ );
161
+ return content.replace(re, "").replace(/\n{3,}/g, "\n\n");
162
+ }
163
+
164
+ function mergeHooksState(content, entries, hooksPath) {
165
+ const managedKeys = new Set(entries.map((entry) => entry.key));
166
+ let updated = stripManagedStateLines(content, managedKeys, hooksPath);
167
+ const lines = entries.map((entry) => stateLine(entry.key, entry.hash));
168
+ const sectionRe = /^\[hooks\.state\]\s*$/m;
169
+ const match = sectionRe.exec(updated);
170
+
171
+ if (!match) {
172
+ if (updated.length > 0 && !updated.endsWith("\n")) updated += "\n";
173
+ if (updated.length > 0 && !updated.endsWith("\n\n")) updated += "\n";
174
+ return `${updated}[hooks.state]\n${lines.join("\n")}\n`;
175
+ }
176
+
177
+ const sectionStart = match.index;
178
+ const afterHeader = match.index + match[0].length;
179
+ const nextSectionMatch = /^\[[^\]]+\]\s*$/m.exec(updated.slice(afterHeader));
180
+ const sectionEnd = nextSectionMatch
181
+ ? afterHeader + nextSectionMatch.index
182
+ : updated.length;
183
+ let sectionBody = updated.slice(sectionStart, sectionEnd).trimEnd();
184
+ sectionBody += `\n${lines.join("\n")}\n`;
185
+ return (
186
+ updated.slice(0, sectionStart) + sectionBody + updated.slice(sectionEnd)
187
+ );
188
+ }
189
+
190
+ function timestamp() {
191
+ return new Date()
192
+ .toISOString()
193
+ .replace(/[-:.TZ]/g, "")
194
+ .slice(0, 14);
195
+ }
196
+
197
+ export function ensureCodexHooks(opts = {}) {
198
+ const codexHome = opts.codexHome || join(homedir(), ".codex");
199
+ if (!existsSync(codexHome)) {
200
+ return { skipped: true, changedHooks: false, changedConfig: false };
201
+ }
202
+
203
+ const hooksPath = resolve(codexHome, "hooks.json");
204
+ const configPath = resolve(codexHome, "config.toml");
205
+ const hookScriptPath = resolve(
206
+ opts.hookScriptPath || join(PROJECT_ROOT, "hooks", TRIFLUX_HOOK_BASENAME),
207
+ );
208
+ const nodeBin = opts.nodeBin || process.execPath;
209
+ const desired = buildDesiredGroups({ nodeBin, hookScriptPath });
210
+
211
+ const hooksJson = normalizeHooksJson(
212
+ existsSync(hooksPath) ? readFileSync(hooksPath, "utf8") : "",
213
+ );
214
+ const sessionStart = upsertHookGroup(
215
+ hooksJson.hooks.SessionStart,
216
+ desired.SessionStart,
217
+ );
218
+ const promptSubmit = upsertHookGroup(
219
+ hooksJson.hooks.UserPromptSubmit,
220
+ desired.UserPromptSubmit,
221
+ );
222
+ hooksJson.hooks.SessionStart = sessionStart.groups;
223
+ hooksJson.hooks.UserPromptSubmit = promptSubmit.groups;
224
+
225
+ const originalHooks = existsSync(hooksPath)
226
+ ? readFileSync(hooksPath, "utf8")
227
+ : "";
228
+ const nextHooks = formatHooksJson(hooksJson);
229
+ const changedHooks = originalHooks !== nextHooks;
230
+ if (changedHooks) {
231
+ mkdirSync(dirname(hooksPath), { recursive: true });
232
+ atomicWriteFile(hooksPath, nextHooks);
233
+ }
234
+
235
+ const stateEntries = [
236
+ {
237
+ key: `${hooksPath}:session_start:${sessionStart.index}:0`,
238
+ hash: computeTrustedHookHash({
239
+ eventName: "session_start",
240
+ matcher: desired.SessionStart.matcher,
241
+ command: desired.SessionStart.hooks[0].command,
242
+ timeout: desired.SessionStart.hooks[0].timeout,
243
+ }),
244
+ },
245
+ {
246
+ key: `${hooksPath}:user_prompt_submit:${promptSubmit.index}:0`,
247
+ hash: computeTrustedHookHash({
248
+ eventName: "user_prompt_submit",
249
+ command: desired.UserPromptSubmit.hooks[0].command,
250
+ timeout: desired.UserPromptSubmit.hooks[0].timeout,
251
+ }),
252
+ },
253
+ ];
254
+
255
+ const originalConfig = existsSync(configPath)
256
+ ? readFileSync(configPath, "utf8")
257
+ : "";
258
+ const nextConfig = mergeHooksState(originalConfig, stateEntries, hooksPath);
259
+ const changedConfig = originalConfig !== nextConfig;
260
+ if (changedConfig) {
261
+ if (existsSync(configPath)) {
262
+ const backupPath = `${configPath}.bak-tfx-codex-hooks-${opts.backupTimestamp || timestamp()}`;
263
+ if (!existsSync(backupPath)) {
264
+ writeFileSync(backupPath, originalConfig, "utf8");
265
+ }
266
+ }
267
+ atomicWriteFile(configPath, nextConfig);
268
+ }
269
+
270
+ return {
271
+ skipped: false,
272
+ changedHooks,
273
+ changedConfig,
274
+ hooksPath,
275
+ configPath,
276
+ };
277
+ }
278
+
279
+ if (import.meta.url === `file://${process.argv[1]}`) {
280
+ const result = ensureCodexHooks();
281
+ process.stdout.write(`${JSON.stringify(result)}\n`);
282
+ }
package/scripts/setup.mjs CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  ensureTfxSection,
27
27
  getLatestRoutingTable,
28
28
  } from "./claudemd-sync.mjs";
29
+ import { ensureCodexHooks } from "./ensure-codex-hooks.mjs";
29
30
  import { addPluginRootFallbackToCommand } from "./lib/doctor-env-checks.mjs";
30
31
  import { cleanupTmpFiles } from "./tmp-cleanup.mjs";
31
32
 
@@ -1373,6 +1374,9 @@ function ensureCriticalSetup() {
1373
1374
  try {
1374
1375
  ensureCodexProfiles();
1375
1376
  } catch {}
1377
+ try {
1378
+ ensureCodexHooks();
1379
+ } catch {}
1376
1380
  }
1377
1381
 
1378
1382
  export {
@@ -1383,6 +1387,7 @@ export {
1383
1387
  cleanupStaleSkills,
1384
1388
  DEPRECATED_SKILLS,
1385
1389
  detectDevMode,
1390
+ ensureCodexHooks,
1386
1391
  ensureCodexHubServerConfig,
1387
1392
  ensureCodexProfiles,
1388
1393
  ensureHooksInSettings,
@@ -2147,6 +2152,21 @@ export async function runDeferred(stdinData) {
2147
2152
  synced++;
2148
2153
  }
2149
2154
 
2155
+ try {
2156
+ const codexHooksResult = ensureCodexHooks();
2157
+ if (
2158
+ !codexHooksResult?.skipped &&
2159
+ (codexHooksResult?.changedHooks || codexHooksResult?.changedConfig)
2160
+ ) {
2161
+ io.log(" \x1b[32m✓\x1b[0m Codex hooks: registered session sync hooks");
2162
+ synced++;
2163
+ }
2164
+ } catch (error) {
2165
+ io.log(
2166
+ ` \x1b[33m⚠\x1b[0m Codex hooks 등록 실패: ${error.message || error}`,
2167
+ );
2168
+ }
2169
+
2150
2170
  // ── Windows Codex 단독 실행 보호: 로그인 시 hub-ensure 등록 ──
2151
2171
  // Claude SessionStart 훅이 없는 순수 Codex 시작 경로에서도 tfx-hub가 살아있게 한다.
2152
2172
  if (enableHubAutostart) {
@@ -464,6 +464,85 @@ prepend_codex_north_star() {
464
464
  rm -f "$tmp_file" 2>/dev/null || true
465
465
  }
466
466
 
467
+ # prepend_skill: opt-in 으로 등록된 스킬의 SKILL.md 본문을 프롬프트 앞에 주입한다.
468
+ # 활성 조건은 TFX_INJECT_SKILL env (tfx-auto 가 --skill <name> 으로 set). 미설정이면
469
+ # 현행 동작 그대로 (no-op). CLI-agnostic — codex/agy 레인에서 동일하게 재사용한다.
470
+ # 전달은 prepend_codex_north_star 와 동일하게 printf/cat → temp file 만 사용하므로
471
+ # $ / 백슬래시(₩) 같은 특수문자를 셸 재확장 없이 리터럴로 보존한다 (parse-safe).
472
+ # 스킬 경로: ${TFX_SKILLS_DIR:-<repo>/skills}/<name>/SKILL.md.
473
+ prepend_skill() {
474
+ local prompt="${1:-}"
475
+ local skill_name="${TFX_INJECT_SKILL:-}"
476
+ if [[ -z "$skill_name" ]]; then
477
+ printf '%s' "$prompt"
478
+ return 0
479
+ fi
480
+ # 경로 탈출 방지: skill_name 은 skills/ 하위 단일 디렉토리 이름만 허용한다.
481
+ if [[ "$skill_name" == *"/"* || "$skill_name" == *".."* ]]; then
482
+ echo "[tfx-route] WARNING: TFX_INJECT_SKILL='${skill_name}' 에 경로 구분자/.. 포함 — 주입 거부" >&2
483
+ printf '%s' "$prompt"
484
+ return 0
485
+ fi
486
+
487
+ local skills_dir="${TFX_SKILLS_DIR:-}"
488
+ if [[ -z "$skills_dir" ]]; then
489
+ skills_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." 2>/dev/null && pwd)/skills"
490
+ fi
491
+ local skill_file="${skills_dir}/${skill_name}/SKILL.md"
492
+ if [[ ! -r "$skill_file" ]]; then
493
+ echo "[tfx-route] WARNING: TFX_INJECT_SKILL='${skill_name}' 스킬을 찾지 못함 ($skill_file) — 주입 생략" >&2
494
+ printf '%s' "$prompt"
495
+ return 0
496
+ fi
497
+
498
+ local tmp_file
499
+ tmp_file="$(mktemp "${TFX_TMP:-${TMPDIR:-/tmp}}/tfx-skill-prompt.XXXXXX" 2>/dev/null)" || {
500
+ printf '%s' "$prompt"
501
+ return 0
502
+ }
503
+
504
+ if ! {
505
+ printf '%s\n' "--- SKILL: ${skill_name} (apply this methodology to the task below) ---"
506
+ cat "$skill_file"
507
+ if [[ -s "$skill_file" ]]; then
508
+ local last_byte
509
+ last_byte="$(tail -c 1 "$skill_file" 2>/dev/null || true)"
510
+ [[ -n "$last_byte" ]] && printf '\n'
511
+ fi
512
+ printf '%s\n' "--- END SKILL ---"
513
+ printf '%s' "$prompt"
514
+ } > "$tmp_file"; then
515
+ rm -f "$tmp_file" 2>/dev/null || true
516
+ printf '%s' "$prompt"
517
+ return 0
518
+ fi
519
+
520
+ cat "$tmp_file" 2>/dev/null || printf '%s' "$prompt"
521
+ rm -f "$tmp_file" 2>/dev/null || true
522
+ }
523
+
524
+ # append_agy_anti_overclaim: agy(Gemini 3.x) 레인 전용 완료/grounding 규율 블록을
525
+ # 프롬프트 *뒤*에 붙인다. Gemini 3.x 는 과신(overconfidence) outlier 라 거짓 완료
526
+ # 주장·환각이 잦다 (AA-Omniscience 실증). 부정 제약은 Gemini 3 공식 가이드 권고대로
527
+ # 프롬프트 END 에 배치하고, blanket "do not guess" 대신 "주어진 context 로 추론 +
528
+ # 정확한 abstention 우선" 형태로 적는다 (open-ended negative 는 역효과). TFX_AGY_ANTI_OVERCLAIM=0 으로 opt-out.
529
+ append_agy_anti_overclaim() {
530
+ local prompt="${1:-}"
531
+ case "${TFX_AGY_ANTI_OVERCLAIM:-1}" in
532
+ 0|false|FALSE|off|OFF|no|NO)
533
+ printf '%s' "$prompt"
534
+ return 0
535
+ ;;
536
+ esac
537
+
538
+ printf '%s' "$prompt"
539
+ printf '\n\n%s\n' "--- COMPLETION & GROUNDING DISCIPLINE (apply strictly) ---"
540
+ printf '%s\n' "- Claim done/fixed/passing ONLY after running the check in this turn and seeing its output. With no fresh evidence, report what is still unverified instead of asserting success."
541
+ printf '%s\n' "- Ground every answer in the provided context and the actual repository. If something is not verifiable from what you can see, say 'No Info / 확인 불가' and stop rather than inventing plausible-but-unconfirmed details."
542
+ printf '%s\n' "- Use the provided context for deductions and prefer accurate abstention over confident guessing."
543
+ printf '%s' "--- END DISCIPLINE ---"
544
+ }
545
+
467
546
  read_probe_state() {
468
547
  local pid="$1"
469
548
  local state_file="${TFX_PROBE_STATE_FILE:-${TFX_PROBE_DIR}/${pid}.json}"
@@ -2678,6 +2757,14 @@ FALLBACK_EOF
2678
2757
  _codex_full_prompt_with_sentinel="$(prepend_codex_north_star "$FULL_PROMPT"; printf '%s' "$_codex_prompt_sentinel")"
2679
2758
  FULL_PROMPT="${_codex_full_prompt_with_sentinel%"$_codex_prompt_sentinel"}"
2680
2759
  fi
2760
+ # Opt-in 스킬 주입 (TFX_INJECT_SKILL). north-star 와 동일한 sentinel 패턴으로
2761
+ # trailing newline 을 보존한다. 미설정이면 prepend_skill 이 no-op.
2762
+ if [[ -n "${TFX_INJECT_SKILL:-}" ]]; then
2763
+ local _codex_skill_sentinel="__TFX_CODEX_SKILL_END_${$}_${RANDOM}__"
2764
+ local _codex_skill_prompt
2765
+ _codex_skill_prompt="$(prepend_skill "$FULL_PROMPT"; printf '%s' "$_codex_skill_sentinel")"
2766
+ FULL_PROMPT="${_codex_skill_prompt%"$_codex_skill_sentinel"}"
2767
+ fi
2681
2768
  codex_transport_effective="exec"
2682
2769
  if [[ "$TFX_CODEX_TRANSPORT" != "exec" ]]; then
2683
2770
  run_codex_mcp "$FULL_PROMPT" "$use_tee" || exit_code=$?
@@ -2782,6 +2869,18 @@ EOF
2782
2869
  _agy_full_prompt_with_sentinel="$(prepend_codex_north_star "$FULL_PROMPT"; printf '%s' "$_agy_prompt_sentinel")"
2783
2870
  FULL_PROMPT="${_agy_full_prompt_with_sentinel%"$_agy_prompt_sentinel"}"
2784
2871
  fi
2872
+ # Opt-in 스킬 주입 (TFX_INJECT_SKILL) — codex 레인과 동일.
2873
+ if [[ -n "${TFX_INJECT_SKILL:-}" ]]; then
2874
+ local _agy_skill_sentinel="__TFX_AGY_SKILL_END_${$}_${RANDOM}__"
2875
+ local _agy_skill_prompt
2876
+ _agy_skill_prompt="$(prepend_skill "$FULL_PROMPT"; printf '%s' "$_agy_skill_sentinel")"
2877
+ FULL_PROMPT="${_agy_skill_prompt%"$_agy_skill_sentinel"}"
2878
+ fi
2879
+ # agy(Gemini 3.x) anti-overclaim 규율 블록을 프롬프트 END 에 append. opt-out: TFX_AGY_ANTI_OVERCLAIM=0.
2880
+ local _agy_aoc_sentinel="__TFX_AGY_AOC_END_${$}_${RANDOM}__"
2881
+ local _agy_aoc_prompt
2882
+ _agy_aoc_prompt="$(append_agy_anti_overclaim "$FULL_PROMPT"; printf '%s' "$_agy_aoc_sentinel")"
2883
+ FULL_PROMPT="${_agy_aoc_prompt%"$_agy_aoc_sentinel"}"
2785
2884
  run_antigravity_exec "$FULL_PROMPT" "$use_tee" || exit_code=$?
2786
2885
  if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
2787
2886
  local agy_stderr_bytes=0
@@ -28,7 +28,7 @@ triggers:
28
28
  - spec-panel
29
29
  - business-panel
30
30
  - index-repo
31
- argument-hint: "<command|task> [args...] [--cli auto|codex|antigravity|claude] [--mode quick|deep|consensus] [--risk-tier auto|low|medium|high] [--shape consensus|debate|panel] [--cli-set triad|no-antigravity|custom] [--parallel 1|N|swarm] [--retry 0|1|ralph] [--isolation none|worktree] [--remote <host>|none]"
31
+ argument-hint: "<command|task> [args...] [--cli auto|codex|antigravity|claude] [--mode quick|deep|consensus] [--risk-tier auto|low|medium|high] [--shape consensus|debate|panel] [--cli-set triad|no-antigravity|custom] [--parallel 1|N|swarm] [--retry 0|1|ralph] [--isolation none|worktree] [--remote <host>|none] [--skill <name>]"
32
32
  ---
33
33
 
34
34
  # tfx-auto — 통합 CLI 오케스트레이터
@@ -169,6 +169,7 @@ ARGUMENTS 에 아래 플래그가 있으면 Step 0 스마트 라우팅의 내부
169
169
  | `--no-claude-native` | false (기본) | Claude native sub-agent 경로 유지 | — |
170
170
  | `--no-claude-native` | true | Claude native 경로 disable, CLI 기반 worker 강제 | tfx-route.sh |
171
171
  | `--max-iterations` | `0` (기본, unlimited) | `--retry ralph`/`auto-escalate` 상한 | retry-state-machine.mjs |
172
+ | `--skill` | `<name>` | `skills/<name>/SKILL.md` 본문을 codex/agy 프롬프트 앞에 주입 (`TFX_INJECT_SKILL`). 미지정 시 no-op | tfx-route.sh |
172
173
 
173
174
  ### `--risk-tier` 계약
174
175
 
@@ -204,6 +205,37 @@ ARGUMENTS 에 아래 플래그가 있으면 Step 0 스마트 라우팅의 내부
204
205
  - `--retry ralph` → true ralph state machine (Phase 3, retry-state-machine.mjs)
205
206
  - `--retry auto-escalate` → CLI 승격 체인 (Phase 3)
206
207
  - `--max-iterations N` (N>0) → ralph/auto-escalate 에 상한 부여
208
+ - `--skill <name>` → `TFX_INJECT_SKILL=<name>` 로 tfx-route.sh 에 전달. 스킬 파일 부재 시 warning 후 주입 생략 (fail-open, 작업은 계속)
209
+
210
+ ### 스킬 주입 (`--skill <name>`)
211
+
212
+ codex/agy 워커 프롬프트 앞에 등록된 스킬의 방법론을 주입하는 **opt-in** 프레임워크. 설계 근거는 `decision-skill-passing` (omc 레퍼런스 + codex/agy 네이티브 스킬 조사):
213
+
214
+ - **메커니즘**: `--skill <name>` → tfx-route.sh `TFX_INJECT_SKILL=<name>`. `skills/<name>/SKILL.md` 본문을 `--- SKILL: <name> (apply this methodology...) ---` delimited block 으로 프롬프트 앞에 prepend. codex·agy 레인 공통 (CLI-agnostic, `prepend_skill`).
215
+ - **기본 off**: 미지정이면 no-op → 현행 동작 그대로. 회귀 위험 없음.
216
+ - **파싱 안전**: prepend 는 `printf`/`cat` → temp file 만 사용. codex 는 argv `--` 뒤, agy 는 stdin `printf %s` 로 전달되므로 `$`(codex)·`₩`/백슬래시(agy) 같은 특수문자를 셸 재확장 없이 **리터럴 보존**. 스킬 본문을 그대로 넘겨도 깨지지 않음.
217
+ - **네이티브 스킬을 안 쓰는 이유**: codex `$skill-name`/`/name` 슬래시와 agy `/name` 은 둘 다 **인터랙티브 TUI 전용** → headless one-shot 에서 named-skill 강제 호출 불가 (미문서). 그래서 prose 주입을 채택 (레퍼런스 omc 도 role .md 를 raw prepend, 네이티브 회피).
218
+
219
+ #### 커맨드→스킬 매핑 (tfx-auto convention, opt-in)
220
+
221
+ tfx-auto 가 커맨드/agent 별로 주입할 스킬을 고를 때 쓰는 **권고 테이블**. 기본은 매핑 없음 (명시 `--skill` 만 작동) — 부적절한 스킬이 모든 프롬프트에 새는 것을 막는다. 프로젝트가 명시적으로 활성화할 때만 아래를 적용해 `--skill` 을 자동 부여한다.
222
+
223
+ | 커맨드/agent | 권고 스킬 | 비고 |
224
+ |--------------|-----------|------|
225
+ | (기본) | 없음 | explicit `--skill` 우선 |
226
+ | 프로젝트 정의 | 프로젝트가 지정 | 활성화 시 tfx-auto 가 해당 `--skill` set |
227
+
228
+ > 매핑된 스킬 파일이 없으면 `prepend_skill` 이 warning 후 주입을 생략한다 (fail-open). 존재하지 않는 매핑을 "주입됨"으로 가정하지 않는다.
229
+
230
+ #### agy anti-overclaim (자동, agy 레인 전용)
231
+
232
+ agy(Gemini 3.x) 레인은 `TFX_AGY_ANTI_OVERCLAIM`(기본 on) 으로 완료/grounding 규율 블록을 프롬프트 **END** 에 자동 append (`append_agy_anti_overclaim`). Gemini 3.x 과신(AA-Omniscience 실측: 정확도 53-56% 대비 환각률 88-91%) 대응:
233
+
234
+ - fresh 증거 없이 done/fixed/passing 주장 금지.
235
+ - 검증 불가 시 `No Info / 확인 불가` 후 중단 (날조 금지).
236
+ - 추론은 주어진 context 로, confident guessing 보다 accurate abstention 우선.
237
+
238
+ 부정 제약은 Gemini 3 공식 가이드대로 **END 배치**하고 blanket "do not guess" 는 역효과라 피한다. opt-out: `TFX_AGY_ANTI_OVERCLAIM=0`.
207
239
 
208
240
  ### Retry state machine 계약 (legacy autoroute / persist 이관)
209
241