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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// hooks/post-tool-tips.mjs — opt-in targeted PostToolUse rules reminders (#245)
|
|
4
|
+
//
|
|
5
|
+
// This hook is intentionally quiet by default. Set TFX_POST_TOOL_TIPS=1 to emit
|
|
6
|
+
// bounded reminders after Bash/Edit/Write touches high-impact triflux paths.
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
realpathSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import {
|
|
18
|
+
dirname,
|
|
19
|
+
isAbsolute,
|
|
20
|
+
join,
|
|
21
|
+
normalize,
|
|
22
|
+
relative,
|
|
23
|
+
resolve,
|
|
24
|
+
} from "node:path";
|
|
25
|
+
|
|
26
|
+
const MAX_MESSAGE_CHARS = 1200;
|
|
27
|
+
const MAX_STATE_ENTRIES = 80;
|
|
28
|
+
const DEDUPE_TTL_MS = 60 * 60 * 1000;
|
|
29
|
+
const STATE_DIR =
|
|
30
|
+
process.env.TRIFLUX_POST_TOOL_TIPS_STATE_DIR ||
|
|
31
|
+
join(tmpdir(), "tfx-post-tool-tips");
|
|
32
|
+
const STATE_FILE = join(STATE_DIR, "seen.json");
|
|
33
|
+
|
|
34
|
+
const RULES = [
|
|
35
|
+
{
|
|
36
|
+
id: "hook-lifecycle",
|
|
37
|
+
label: "hook lifecycle",
|
|
38
|
+
patterns: [
|
|
39
|
+
/^hooks\/(hook-registry\.json|hooks\.json|hook-orchestrator\.mjs)$/,
|
|
40
|
+
/^hooks\/.*\.(mjs|json)$/,
|
|
41
|
+
],
|
|
42
|
+
message:
|
|
43
|
+
"hook lifecycle change: keep registry/settings narrow, mirror package hooks, run the focused hook test and npm run release:check-mirror before commit.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "package-hook-mirror",
|
|
47
|
+
label: "package hook mirror",
|
|
48
|
+
patterns: [/^packages\/(triflux|core)\/hooks\//],
|
|
49
|
+
message:
|
|
50
|
+
"package hook mirror change: keep root, packages/triflux, and packages/core lifecycle hook behavior aligned; do not leave core partially mirrored.",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "team-runtime",
|
|
54
|
+
label: "team runtime",
|
|
55
|
+
patterns: [/^hub\/team\//, /^mesh\//, /^hooks\/pipeline-stop\.mjs$/],
|
|
56
|
+
message:
|
|
57
|
+
"team runtime change: verify swarm/retry/pipeline state transitions and avoid blocking user abort or cleanup paths.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "mcp-surface",
|
|
61
|
+
label: "MCP surface",
|
|
62
|
+
patterns: [
|
|
63
|
+
/^scripts\/mcp/,
|
|
64
|
+
/^scripts\/lib\/mcp-/,
|
|
65
|
+
/^hooks\/mcp-config-watcher\.mjs$/,
|
|
66
|
+
/^\.mcp\.json$/,
|
|
67
|
+
],
|
|
68
|
+
message:
|
|
69
|
+
"MCP surface change: preserve stdio MCP remediation, gateway fallback, and transport degradation behavior across macOS/Linux/Windows.",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "instruction-source",
|
|
73
|
+
label: "instruction source",
|
|
74
|
+
patterns: [/^AGENTS\.md$/, /^CLAUDE\.md$/, /^\.claude\/rules\//],
|
|
75
|
+
message:
|
|
76
|
+
"instruction source change: do not mix policy edits with unrelated implementation; preserve exact psmux/WT/Codex routing constraints.",
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
function readStdin() {
|
|
81
|
+
try {
|
|
82
|
+
return readFileSync(0, "utf8");
|
|
83
|
+
} catch {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isEnabled() {
|
|
89
|
+
return process.env.TFX_POST_TOOL_TIPS === "1";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function safeJsonParse(raw) {
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(raw);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeSlash(value) {
|
|
101
|
+
return String(value || "").replace(/\\/g, "/");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cwdFromInput(input) {
|
|
105
|
+
return resolve(input.cwd || process.env.CLAUDE_CWD || process.cwd());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function toRepoRelative(candidate, cwd) {
|
|
109
|
+
if (!candidate || typeof candidate !== "string") return null;
|
|
110
|
+
const stripped = candidate.trim().replace(/^["']|["']$/g, "");
|
|
111
|
+
if (!stripped) return null;
|
|
112
|
+
if (/^[a-z]+:\/\//i.test(stripped)) return null;
|
|
113
|
+
|
|
114
|
+
const absolute = isAbsolute(stripped) ? stripped : resolve(cwd, stripped);
|
|
115
|
+
const normalizedAbs = normalize(absolute);
|
|
116
|
+
const normalizedCwd = normalize(cwd);
|
|
117
|
+
const rel = normalizeSlash(relative(normalizedCwd, normalizedAbs));
|
|
118
|
+
|
|
119
|
+
if (!rel || rel === ".") return null;
|
|
120
|
+
if (rel.startsWith("../") || rel === "..") return null;
|
|
121
|
+
return rel;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractEditWritePath(input, cwd) {
|
|
125
|
+
const filePath = input.tool_input?.file_path || input.tool_input?.path;
|
|
126
|
+
const rel = toRepoRelative(filePath, cwd);
|
|
127
|
+
return rel ? [rel] : [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function tokenizeCommand(command) {
|
|
131
|
+
const tokens = [];
|
|
132
|
+
let current = "";
|
|
133
|
+
let quote = null;
|
|
134
|
+
|
|
135
|
+
for (const ch of String(command || "")) {
|
|
136
|
+
if (quote) {
|
|
137
|
+
if (ch === quote) quote = null;
|
|
138
|
+
else current += ch;
|
|
139
|
+
} else if (ch === "'" || ch === '"') {
|
|
140
|
+
quote = ch;
|
|
141
|
+
} else if (/\s/.test(ch)) {
|
|
142
|
+
if (current) {
|
|
143
|
+
tokens.push(current);
|
|
144
|
+
current = "";
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
current += ch;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (current) tokens.push(current);
|
|
151
|
+
return tokens;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function looksLikePathToken(token) {
|
|
155
|
+
if (!token || token.startsWith("-")) return false;
|
|
156
|
+
if (
|
|
157
|
+
/^(node|npm|pnpm|bun|npx|git|cat|sed|awk|rg|grep|bash|sh|zsh)$/.test(token)
|
|
158
|
+
)
|
|
159
|
+
return false;
|
|
160
|
+
return /^(hooks|packages|hub|mesh|scripts|\.claude|AGENTS\.md|CLAUDE\.md|\.mcp\.json)(\/|$)/.test(
|
|
161
|
+
normalizeSlash(token),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function extractBashPaths(input, cwd) {
|
|
166
|
+
const command = input.tool_input?.command || "";
|
|
167
|
+
const paths = [];
|
|
168
|
+
for (const token of tokenizeCommand(command)) {
|
|
169
|
+
const cleaned = token.replace(/[,:;]+$/g, "");
|
|
170
|
+
if (!cleaned || cleaned.startsWith("-")) continue;
|
|
171
|
+
|
|
172
|
+
const rel = toRepoRelative(cleaned, cwd);
|
|
173
|
+
if (!rel) continue;
|
|
174
|
+
if (!isAbsolute(cleaned) && !looksLikePathToken(cleaned)) continue;
|
|
175
|
+
paths.push(rel);
|
|
176
|
+
}
|
|
177
|
+
return paths;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function extractTouchedPaths(input) {
|
|
181
|
+
const toolName = input.tool_name || "";
|
|
182
|
+
const cwd = cwdFromInput(input);
|
|
183
|
+
|
|
184
|
+
if (toolName === "Edit" || toolName === "Write")
|
|
185
|
+
return extractEditWritePath(input, cwd);
|
|
186
|
+
if (toolName === "Bash") return extractBashPaths(input, cwd);
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function canonicalPathKey(relPath, cwd) {
|
|
191
|
+
const absolute = resolve(cwd, relPath);
|
|
192
|
+
try {
|
|
193
|
+
return normalizeSlash(realpathSync(absolute));
|
|
194
|
+
} catch {
|
|
195
|
+
return normalizeSlash(absolute);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function matchRules(relPaths, cwd) {
|
|
200
|
+
const matched = [];
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
|
|
203
|
+
for (const relPath of relPaths) {
|
|
204
|
+
const normalizedRel = normalizeSlash(relPath);
|
|
205
|
+
for (const rule of RULES) {
|
|
206
|
+
if (!rule.patterns.some((pattern) => pattern.test(normalizedRel)))
|
|
207
|
+
continue;
|
|
208
|
+
const key = `${rule.id}:${canonicalPathKey(normalizedRel, cwd)}`;
|
|
209
|
+
if (seen.has(key)) continue;
|
|
210
|
+
seen.add(key);
|
|
211
|
+
matched.push({ rule, relPath: normalizedRel, key });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return matched;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function loadState() {
|
|
219
|
+
if (!existsSync(STATE_FILE)) return { entries: {} };
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
|
222
|
+
if (
|
|
223
|
+
!parsed ||
|
|
224
|
+
typeof parsed !== "object" ||
|
|
225
|
+
typeof parsed.entries !== "object"
|
|
226
|
+
)
|
|
227
|
+
return { entries: {} };
|
|
228
|
+
return parsed;
|
|
229
|
+
} catch {
|
|
230
|
+
return { entries: {} };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function saveState(state) {
|
|
235
|
+
mkdirSync(dirname(STATE_FILE), { recursive: true });
|
|
236
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), "utf8");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function hashKey(value) {
|
|
240
|
+
return createHash("sha1").update(value).digest("hex").slice(0, 16);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function filterUnseen(matches) {
|
|
244
|
+
if (matches.length === 0) return [];
|
|
245
|
+
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
const state = loadState();
|
|
248
|
+
const entries = Object.fromEntries(
|
|
249
|
+
Object.entries(state.entries || {}).filter(
|
|
250
|
+
([, ts]) => now - Number(ts || 0) <= DEDUPE_TTL_MS,
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const unseen = [];
|
|
255
|
+
for (const match of matches) {
|
|
256
|
+
const key = hashKey(match.key);
|
|
257
|
+
if (entries[key]) continue;
|
|
258
|
+
entries[key] = now;
|
|
259
|
+
unseen.push(match);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const trimmed = Object.entries(entries)
|
|
263
|
+
.sort((a, b) => Number(b[1]) - Number(a[1]))
|
|
264
|
+
.slice(0, MAX_STATE_ENTRIES);
|
|
265
|
+
saveState({ entries: Object.fromEntries(trimmed) });
|
|
266
|
+
|
|
267
|
+
return unseen;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildMessage(matches) {
|
|
271
|
+
if (matches.length === 0) return "";
|
|
272
|
+
|
|
273
|
+
const lines = ["[post-tool-tips] targeted triflux reminder:"];
|
|
274
|
+
for (const { rule, relPath } of matches) {
|
|
275
|
+
lines.push(`- ${rule.label} (${relPath}): ${rule.message}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let message = lines.join("\n");
|
|
279
|
+
if (message.length > MAX_MESSAGE_CHARS) {
|
|
280
|
+
message = `${message.slice(0, MAX_MESSAGE_CHARS - 40).trimEnd()}\n[post-tool-tips] truncated`;
|
|
281
|
+
}
|
|
282
|
+
return message;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function main() {
|
|
286
|
+
if (!isEnabled()) process.exit(0);
|
|
287
|
+
|
|
288
|
+
const input = safeJsonParse(readStdin());
|
|
289
|
+
if (!input || input.hook_event_name !== "PostToolUse") process.exit(0);
|
|
290
|
+
|
|
291
|
+
const cwd = cwdFromInput(input);
|
|
292
|
+
const paths = extractTouchedPaths(input);
|
|
293
|
+
if (paths.length === 0) process.exit(0);
|
|
294
|
+
|
|
295
|
+
const matches = matchRules(paths, cwd);
|
|
296
|
+
const unseen = filterUnseen(matches);
|
|
297
|
+
const message = buildMessage(unseen);
|
|
298
|
+
|
|
299
|
+
if (message) process.stdout.write(JSON.stringify({ systemMessage: message }));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
main();
|
|
304
|
+
} catch {
|
|
305
|
+
process.exit(0);
|
|
306
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/pre-compact-snapshot.mjs — PreCompact hook
|
|
3
|
+
//
|
|
4
|
+
// Compaction 직전에 짧은 상태 스냅샷을 주입한다. 민감 데이터는 읽지 않고,
|
|
5
|
+
// repo-local 상태와 git 요약만 3KB 이하로 제한한다.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const MAX_CONTEXT_BYTES = 3000;
|
|
13
|
+
const STALE_RUN_MS = 30 * 60 * 1000;
|
|
14
|
+
const PROJECT_ROOT = process.env.CLAUDE_CWD || process.cwd();
|
|
15
|
+
|
|
16
|
+
function readStdinJson() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(0, "utf8");
|
|
19
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function git(args) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync("git", args, {
|
|
28
|
+
cwd: PROJECT_ROOT,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
31
|
+
timeout: 3000,
|
|
32
|
+
}).trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function summarizeGit() {
|
|
39
|
+
const branch =
|
|
40
|
+
git(["branch", "--show-current"]) || git(["rev-parse", "--short", "HEAD"]);
|
|
41
|
+
const status = git(["status", "--short"]);
|
|
42
|
+
const rows = status ? status.split(/\r?\n/).filter(Boolean) : [];
|
|
43
|
+
return {
|
|
44
|
+
branch: branch || "unknown",
|
|
45
|
+
dirty: rows.length,
|
|
46
|
+
sample: rows.slice(0, 8),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJsonIfSmall(path, maxBytes = 24_000) {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(path)) return null;
|
|
53
|
+
if (statSync(path).size > maxBytes) return null;
|
|
54
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function summarizeModeState(root) {
|
|
61
|
+
const stateDir = join(root, ".omx", "state");
|
|
62
|
+
if (!existsSync(stateDir)) return [];
|
|
63
|
+
try {
|
|
64
|
+
return readdirSync(stateDir)
|
|
65
|
+
.filter((name) => name.endsWith("-state.json"))
|
|
66
|
+
.flatMap((name) => {
|
|
67
|
+
const state = readJsonIfSmall(join(stateDir, name));
|
|
68
|
+
if (!state || state.active === false) return [];
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
mode: state.mode || name.replace(/-state\.json$/, ""),
|
|
72
|
+
phase: state.current_phase || state.phase || null,
|
|
73
|
+
active: state.active !== false,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
})
|
|
77
|
+
.slice(0, 8);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function summarizeRetryState(input) {
|
|
84
|
+
const sessionId = input.session_id || input.sessionId || "";
|
|
85
|
+
if (!sessionId) return null;
|
|
86
|
+
const candidates = [
|
|
87
|
+
join(PROJECT_ROOT, ".omc", "state", `retry-${sessionId}.json`),
|
|
88
|
+
join(homedir(), ".omc", "state", `retry-${sessionId}.json`),
|
|
89
|
+
];
|
|
90
|
+
for (const path of candidates) {
|
|
91
|
+
const state = readJsonIfSmall(path);
|
|
92
|
+
if (!state) continue;
|
|
93
|
+
return {
|
|
94
|
+
phase: state.phase || state.current_phase || null,
|
|
95
|
+
iteration: state.iteration ?? state.retry_count ?? null,
|
|
96
|
+
max: state.max_iterations ?? state.max ?? null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function countStaleSwarmRuns(nowMs = Date.now()) {
|
|
103
|
+
const logsRoot = join(PROJECT_ROOT, ".triflux", "swarm-logs");
|
|
104
|
+
if (!existsSync(logsRoot)) return 0;
|
|
105
|
+
try {
|
|
106
|
+
return readdirSync(logsRoot, { withFileTypes: true }).filter((entry) => {
|
|
107
|
+
if (!entry.isDirectory() || !entry.name.startsWith("run-")) return false;
|
|
108
|
+
const eventPath = join(logsRoot, entry.name, "swarm-events.jsonl");
|
|
109
|
+
if (!existsSync(eventPath)) return false;
|
|
110
|
+
try {
|
|
111
|
+
return nowMs - statSync(eventPath).mtimeMs > STALE_RUN_MS;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}).length;
|
|
116
|
+
} catch {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function truncateUtf8(text, maxBytes) {
|
|
122
|
+
let output = text;
|
|
123
|
+
while (Buffer.byteLength(output, "utf8") > maxBytes) {
|
|
124
|
+
output = output.slice(0, Math.max(0, output.length - 200));
|
|
125
|
+
}
|
|
126
|
+
return output;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildSnapshot(input = readStdinJson()) {
|
|
130
|
+
const gitState = summarizeGit();
|
|
131
|
+
const activeModes = summarizeModeState(PROJECT_ROOT);
|
|
132
|
+
const retry = summarizeRetryState(input);
|
|
133
|
+
const staleSwarmRuns = countStaleSwarmRuns();
|
|
134
|
+
|
|
135
|
+
const lines = [
|
|
136
|
+
"[triflux pre-compact snapshot]",
|
|
137
|
+
`cwd: ${PROJECT_ROOT}`,
|
|
138
|
+
`git: ${gitState.branch}; dirty=${gitState.dirty}`,
|
|
139
|
+
];
|
|
140
|
+
if (gitState.sample.length) {
|
|
141
|
+
lines.push(`dirty_sample: ${gitState.sample.join(" | ")}`);
|
|
142
|
+
}
|
|
143
|
+
if (activeModes.length) {
|
|
144
|
+
lines.push(
|
|
145
|
+
`active_modes: ${activeModes
|
|
146
|
+
.map((m) => `${m.mode}${m.phase ? `:${m.phase}` : ""}`)
|
|
147
|
+
.join(", ")}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (retry) {
|
|
151
|
+
lines.push(
|
|
152
|
+
`retry: phase=${retry.phase || "-"} iteration=${retry.iteration ?? "-"}/${retry.max ?? "-"}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (staleSwarmRuns > 0) {
|
|
156
|
+
lines.push(`stale_swarm_runs: ${staleSwarmRuns}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return truncateUtf8(lines.join("\n"), MAX_CONTEXT_BYTES);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function main() {
|
|
163
|
+
const snapshot = buildSnapshot();
|
|
164
|
+
if (!snapshot.trim()) return;
|
|
165
|
+
process.stdout.write(
|
|
166
|
+
JSON.stringify({
|
|
167
|
+
hookSpecificOutput: {
|
|
168
|
+
hookEventName: "PreCompact",
|
|
169
|
+
additionalContext: snapshot,
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
if (import.meta.url.endsWith(process.argv[1]?.split(/[\\/]/).pop() || "")) {
|
|
177
|
+
main();
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/session-end-cleanup.mjs — SessionEnd lifecycle state reporter
|
|
3
|
+
//
|
|
4
|
+
// Report-only by default. Opt-in cleanup only removes the repo-local
|
|
5
|
+
// .triflux/subagents/subagents.json file when stale running lifecycle state is
|
|
6
|
+
// present. It never kills processes or touches external HOME/MCP configs.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const MARKER = "[session-end-cleanup]";
|
|
12
|
+
const STALE_MS = 30 * 60 * 1000;
|
|
13
|
+
const MAX_OUTPUT_BYTES = 2 * 1024;
|
|
14
|
+
|
|
15
|
+
function readStdin() {
|
|
16
|
+
try {
|
|
17
|
+
return readFileSync(0, "utf8");
|
|
18
|
+
} catch {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseJson(raw) {
|
|
24
|
+
if (!raw?.trim()) return null;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function timestampMs(agent) {
|
|
33
|
+
const value =
|
|
34
|
+
agent?.startedAt ??
|
|
35
|
+
agent?.started_at ??
|
|
36
|
+
agent?.createdAt ??
|
|
37
|
+
agent?.created_at ??
|
|
38
|
+
agent?.timestamp;
|
|
39
|
+
const parsed = Date.parse(String(value ?? ""));
|
|
40
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isCompleted(agent) {
|
|
44
|
+
return (
|
|
45
|
+
Boolean(
|
|
46
|
+
agent?.stoppedAt ??
|
|
47
|
+
agent?.stopped_at ??
|
|
48
|
+
agent?.completedAt ??
|
|
49
|
+
agent?.completed_at ??
|
|
50
|
+
agent?.endedAt ??
|
|
51
|
+
agent?.ended_at ??
|
|
52
|
+
agent?.durationMs ??
|
|
53
|
+
agent?.duration_ms ??
|
|
54
|
+
agent?.result,
|
|
55
|
+
) ||
|
|
56
|
+
agent?.status === "completed" ||
|
|
57
|
+
agent?.status === "stopped"
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function agentEntries(state) {
|
|
62
|
+
const agents = state?.agents;
|
|
63
|
+
if (Array.isArray(agents)) return agents;
|
|
64
|
+
if (agents && typeof agents === "object") return Object.values(agents);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function summarizeLifecycleState(state, now = Date.now()) {
|
|
69
|
+
const completedEntries = Array.isArray(state?.completed)
|
|
70
|
+
? state.completed
|
|
71
|
+
: [];
|
|
72
|
+
const summary = {
|
|
73
|
+
running: 0,
|
|
74
|
+
completed: completedEntries.length,
|
|
75
|
+
stale: 0,
|
|
76
|
+
total: completedEntries.length,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
for (const agent of agentEntries(state)) {
|
|
80
|
+
if (!agent || typeof agent !== "object") continue;
|
|
81
|
+
summary.total += 1;
|
|
82
|
+
|
|
83
|
+
if (isCompleted(agent)) {
|
|
84
|
+
summary.completed += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const started = timestampMs(agent);
|
|
89
|
+
if (started !== null && now - started >= STALE_MS) {
|
|
90
|
+
summary.stale += 1;
|
|
91
|
+
} else {
|
|
92
|
+
summary.running += 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return summary;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pruneStaleRunningAgents(state, now = Date.now()) {
|
|
100
|
+
const agents = state?.agents;
|
|
101
|
+
if (!agents || typeof agents !== "object" || Array.isArray(agents)) {
|
|
102
|
+
return { nextState: state, removed: 0 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let removed = 0;
|
|
106
|
+
const nextAgents = {};
|
|
107
|
+
for (const [key, agent] of Object.entries(agents)) {
|
|
108
|
+
const started = timestampMs(agent);
|
|
109
|
+
const stale =
|
|
110
|
+
!isCompleted(agent) && started !== null && now - started >= STALE_MS;
|
|
111
|
+
if (stale) {
|
|
112
|
+
removed += 1;
|
|
113
|
+
} else {
|
|
114
|
+
nextAgents[key] = agent;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
nextState: { ...state, agents: nextAgents },
|
|
120
|
+
removed,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildOutput(summary, cleanup) {
|
|
125
|
+
const context =
|
|
126
|
+
`${MARKER} subagent lifecycle: ` +
|
|
127
|
+
`running=${summary.running} completed=${summary.completed} ` +
|
|
128
|
+
`stale=${summary.stale} total=${summary.total} cleanup=${cleanup}`;
|
|
129
|
+
const output = {
|
|
130
|
+
systemMessage: context,
|
|
131
|
+
hookSpecificOutput: {
|
|
132
|
+
hookEventName: "SessionEnd",
|
|
133
|
+
additionalContext: context,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
let json = JSON.stringify(output);
|
|
138
|
+
if (Buffer.byteLength(json, "utf8") >= MAX_OUTPUT_BYTES) {
|
|
139
|
+
const bounded = `${MARKER} subagent lifecycle: running=${summary.running} completed=${summary.completed} stale=${summary.stale} cleanup=${cleanup}`;
|
|
140
|
+
json = JSON.stringify({
|
|
141
|
+
systemMessage: bounded,
|
|
142
|
+
hookSpecificOutput: {
|
|
143
|
+
hookEventName: "SessionEnd",
|
|
144
|
+
additionalContext: bounded,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return json;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function run(input, env = process.env) {
|
|
152
|
+
if (input?.hook_event_name !== "SessionEnd") return "";
|
|
153
|
+
|
|
154
|
+
const root = env.CLAUDE_CWD || process.cwd();
|
|
155
|
+
const statePath = join(root, ".triflux", "subagents", "subagents.json");
|
|
156
|
+
if (!existsSync(statePath)) return "";
|
|
157
|
+
|
|
158
|
+
const state = parseJson(readFileSync(statePath, "utf8"));
|
|
159
|
+
if (!state) return "";
|
|
160
|
+
|
|
161
|
+
const summary = summarizeLifecycleState(state);
|
|
162
|
+
let cleanup = "report-only";
|
|
163
|
+
|
|
164
|
+
if (env.TFX_SESSION_END_CLEANUP === "1" && summary.stale > 0) {
|
|
165
|
+
try {
|
|
166
|
+
const { nextState, removed } = pruneStaleRunningAgents(state);
|
|
167
|
+
const remainingAgents = Object.keys(nextState?.agents || {}).length;
|
|
168
|
+
const remainingCompleted = Array.isArray(nextState?.completed)
|
|
169
|
+
? nextState.completed.length
|
|
170
|
+
: 0;
|
|
171
|
+
if (remainingAgents === 0 && remainingCompleted === 0) {
|
|
172
|
+
unlinkSync(statePath);
|
|
173
|
+
cleanup = "removed";
|
|
174
|
+
} else {
|
|
175
|
+
writeFileSync(
|
|
176
|
+
statePath,
|
|
177
|
+
`${JSON.stringify({ ...nextState, updatedAt: new Date().toISOString() }, null, 2)}\n`,
|
|
178
|
+
"utf8",
|
|
179
|
+
);
|
|
180
|
+
cleanup = `pruned-${removed}`;
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
cleanup = "failed";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return buildOutput(summary, cleanup);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function main() {
|
|
191
|
+
const input = parseJson(readStdin());
|
|
192
|
+
if (!input) return;
|
|
193
|
+
const output = run(input);
|
|
194
|
+
if (output) process.stdout.write(output);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
main();
|
|
199
|
+
} catch {
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|