selftune 0.2.21 → 0.2.22
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/README.md +12 -7
- package/cli/selftune/adapters/cline/hook.ts +167 -0
- package/cli/selftune/adapters/cline/install.ts +197 -0
- package/cli/selftune/adapters/codex/hook.ts +296 -0
- package/cli/selftune/adapters/codex/install.ts +289 -0
- package/cli/selftune/adapters/opencode/hook.ts +222 -0
- package/cli/selftune/adapters/opencode/install.ts +543 -0
- package/cli/selftune/hooks/auto-activate.ts +43 -37
- package/cli/selftune/hooks-shared/git-metadata.ts +149 -0
- package/cli/selftune/hooks-shared/hook-output.ts +105 -0
- package/cli/selftune/hooks-shared/normalize.ts +196 -0
- package/cli/selftune/hooks-shared/session-state.ts +76 -0
- package/cli/selftune/hooks-shared/skill-paths.ts +50 -0
- package/cli/selftune/hooks-shared/stdin-dispatch.ts +59 -0
- package/cli/selftune/hooks-shared/types.ts +90 -0
- package/cli/selftune/index.ts +56 -4
- package/cli/selftune/utils/llm-call.ts +99 -34
- package/package.json +1 -1
- package/skill/SKILL.md +10 -0
- package/skill/Workflows/Initialize.md +48 -6
- package/skill/Workflows/PlatformHooks.md +93 -0
package/README.md
CHANGED
|
@@ -126,6 +126,9 @@ Your agent runs these — you just say what you want ("improve my skills", "show
|
|
|
126
126
|
| | `selftune eval composability --skill <name>` | Detect conflicts between co-occurring skills |
|
|
127
127
|
| | `selftune eval family-overlap --prefix sc-` | Detect sibling overlap and suggest when a skill family should be consolidated |
|
|
128
128
|
| | `selftune eval import` | Import external eval corpus from [SkillsBench](https://github.com/benchflow-ai/skillsbench) |
|
|
129
|
+
| **hooks** | `selftune codex install` | Install selftune hooks into Codex (`--dry-run`, `--uninstall`) |
|
|
130
|
+
| | `selftune opencode install` | Install selftune hooks into OpenCode |
|
|
131
|
+
| | `selftune cline install` | Install selftune hooks into Cline |
|
|
129
132
|
| **auto** | `selftune cron setup` | Install OS-level scheduling (cron/launchd/systemd) |
|
|
130
133
|
| | `selftune watch --skill <name>` | Monitor after deploy. Auto-rollback on regression. |
|
|
131
134
|
| **other** | `selftune workflows` | Discover and manage multi-skill workflows |
|
|
@@ -165,13 +168,15 @@ selftune is complementary to these tools, not competitive. They trace what happe
|
|
|
165
168
|
|
|
166
169
|
## Platforms
|
|
167
170
|
|
|
168
|
-
|
|
171
|
+
| Platform | Support | Real-time Hooks | Eval/Optimizer Agents | Batch Ingest | Config Location |
|
|
172
|
+
| --- | --- | --- | --- | --- | --- |
|
|
173
|
+
| **Claude Code** | Full | Automatic via `selftune init` | `claude --agent` (native) | `selftune ingest claude` | `~/.claude/settings.json` |
|
|
174
|
+
| **Codex** | Experimental | `selftune codex install` | `codex exec` (inlined) | `selftune ingest codex` | `~/.codex/hooks.json` |
|
|
175
|
+
| **OpenCode** | Experimental | `selftune opencode install` | `opencode run --agent` (native) | `selftune ingest opencode` | `./opencode.json` or `~/.config/opencode/opencode.json` |
|
|
176
|
+
| **Cline** | Experimental | `selftune cline install` | — | — | `~/Documents/Cline/Hooks/` |
|
|
177
|
+
| **OpenClaw** | Experimental | — | — | `selftune ingest openclaw` | — |
|
|
169
178
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
**OpenCode** (experimental) — `selftune ingest opencode`. Adapter exists but is not actively tested.
|
|
173
|
-
|
|
174
|
-
**OpenClaw** (experimental) — `selftune ingest openclaw` + `selftune cron setup` for autonomous evolution. Adapter exists but is not actively tested.
|
|
179
|
+
OpenCode and Codex now support eval/optimizer agent workflows (evolution-reviewer, diagnosis-analyst, pattern-analyst, integration-guide). OpenCode agents are registered in the config during `selftune opencode install`; Codex inlines agent instructions into the prompt since it lacks a native `--agent` flag. OpenCode lacks a prompt-submission hook event, so prompt logging and auto-activate are unavailable. Cline only exposes PostToolUse and task lifecycle events, limiting coverage to commit tracking and session telemetry. All platforms write to the same shared log schema.
|
|
175
180
|
|
|
176
181
|
Requires [Bun](https://bun.sh) or Node.js 18+. No extra API keys.
|
|
177
182
|
|
|
@@ -181,6 +186,6 @@ Requires [Bun](https://bun.sh) or Node.js 18+. No extra API keys.
|
|
|
181
186
|
|
|
182
187
|
[Architecture](ARCHITECTURE.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [Integration Guide](docs/integration-guide.md) · [Sponsor](https://github.com/sponsors/WellDunDun)
|
|
183
188
|
|
|
184
|
-
MIT licensed. Free forever.
|
|
189
|
+
MIT licensed. Free forever. Hooks for Claude Code, Codex, OpenCode, and Cline; batch ingest for OpenClaw.
|
|
185
190
|
|
|
186
191
|
</div>
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Cline hook adapter for selftune.
|
|
4
|
+
*
|
|
5
|
+
* Translates Cline hook events (PostToolUse, TaskComplete, TaskCancel)
|
|
6
|
+
* into selftune hook calls for commit tracking and session telemetry.
|
|
7
|
+
*
|
|
8
|
+
* Protocol: reads JSON from stdin, routes to the appropriate handler,
|
|
9
|
+
* and writes `{"cancel": false}` to stdout.
|
|
10
|
+
*
|
|
11
|
+
* Fail-open: never crashes, never blocks Cline. All errors are silent.
|
|
12
|
+
*
|
|
13
|
+
* Usage: echo '$HOOK_PAYLOAD' | selftune cline hook
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { StopPayload } from "../../types.js";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Cline hook input shape
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
interface ClineHookInput {
|
|
23
|
+
hookName: string;
|
|
24
|
+
taskId: string;
|
|
25
|
+
workspaceRoots?: string[];
|
|
26
|
+
postToolUse?: {
|
|
27
|
+
toolName: string;
|
|
28
|
+
parameters: Record<string, unknown>;
|
|
29
|
+
result?: string;
|
|
30
|
+
success?: boolean;
|
|
31
|
+
};
|
|
32
|
+
taskComplete?: {
|
|
33
|
+
taskMetadata: { taskId: string; ulid: string };
|
|
34
|
+
};
|
|
35
|
+
taskCancel?: {
|
|
36
|
+
taskMetadata: { taskId: string; ulid: string };
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function outputResponse(): void {
|
|
45
|
+
process.stdout.write(JSON.stringify({ cancel: false }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readStdin(): Promise<{ full: string }> {
|
|
49
|
+
const raw = await Bun.stdin.text();
|
|
50
|
+
return { full: raw };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// PostToolUse handler — commit tracking (inline, fast path)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
async function handlePostToolUse(input: ClineHookInput): Promise<void> {
|
|
58
|
+
const { postToolUse, taskId } = input;
|
|
59
|
+
if (!postToolUse) return;
|
|
60
|
+
|
|
61
|
+
const { toolName, parameters, result } = postToolUse;
|
|
62
|
+
|
|
63
|
+
// Only care about execute_command that might be git commits
|
|
64
|
+
if (toolName !== "execute_command") return;
|
|
65
|
+
|
|
66
|
+
const command = typeof parameters.command === "string" ? parameters.command : "";
|
|
67
|
+
if (!command) return;
|
|
68
|
+
|
|
69
|
+
// Use selftune's commit-track logic
|
|
70
|
+
const { containsGitCommitCommand, parseCommitSha, parseCommitTitle, parseBranchFromOutput } =
|
|
71
|
+
await import("../../hooks/commit-track.js");
|
|
72
|
+
|
|
73
|
+
if (!containsGitCommitCommand(command)) return;
|
|
74
|
+
if (!result) return;
|
|
75
|
+
|
|
76
|
+
const commitSha = parseCommitSha(result);
|
|
77
|
+
if (!commitSha) return;
|
|
78
|
+
|
|
79
|
+
const commitTitle = parseCommitTitle(result);
|
|
80
|
+
const branch = parseBranchFromOutput(result);
|
|
81
|
+
|
|
82
|
+
// Write to SQLite
|
|
83
|
+
try {
|
|
84
|
+
const { writeCommitTracking } = await import("../../localdb/direct-write.js");
|
|
85
|
+
writeCommitTracking({
|
|
86
|
+
session_id: taskId,
|
|
87
|
+
commit_sha: commitSha,
|
|
88
|
+
commit_title: commitTitle,
|
|
89
|
+
branch,
|
|
90
|
+
timestamp: new Date().toISOString(),
|
|
91
|
+
});
|
|
92
|
+
} catch {
|
|
93
|
+
/* fail-open */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// TaskComplete / TaskCancel handler — session telemetry (background)
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
async function handleTaskEnd(input: ClineHookInput): Promise<void> {
|
|
102
|
+
const { taskId, workspaceRoots } = input;
|
|
103
|
+
const cwd = workspaceRoots?.[0] ?? process.cwd();
|
|
104
|
+
|
|
105
|
+
// Build a StopPayload compatible with selftune's session-stop processor
|
|
106
|
+
const payload: StopPayload = {
|
|
107
|
+
session_id: taskId,
|
|
108
|
+
cwd,
|
|
109
|
+
// Cline doesn't provide a transcript path in the same way Claude Code does.
|
|
110
|
+
// session-stop will still record session-level telemetry from what's available.
|
|
111
|
+
transcript_path: "",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const { processSessionStop } = await import("../../hooks/session-stop.js");
|
|
116
|
+
await processSessionStop(payload);
|
|
117
|
+
} catch {
|
|
118
|
+
/* fail-open */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Main entry point
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export async function cliMain(): Promise<void> {
|
|
127
|
+
try {
|
|
128
|
+
const { full } = await readStdin();
|
|
129
|
+
|
|
130
|
+
if (!full.trim()) {
|
|
131
|
+
outputResponse();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let input: ClineHookInput;
|
|
136
|
+
try {
|
|
137
|
+
input = JSON.parse(full) as ClineHookInput;
|
|
138
|
+
} catch {
|
|
139
|
+
outputResponse();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const { hookName } = input;
|
|
144
|
+
if (!hookName) {
|
|
145
|
+
outputResponse();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (hookName === "PostToolUse") {
|
|
150
|
+
await handlePostToolUse(input);
|
|
151
|
+
} else if (hookName === "TaskComplete" || hookName === "TaskCancel") {
|
|
152
|
+
await handleTaskEnd(input);
|
|
153
|
+
}
|
|
154
|
+
// Unknown events are silently ignored (fail-open)
|
|
155
|
+
|
|
156
|
+
outputResponse();
|
|
157
|
+
} catch {
|
|
158
|
+
// Fail-open: always output a valid response
|
|
159
|
+
outputResponse();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- stdin main (only when executed directly, not when imported) ---
|
|
164
|
+
if (import.meta.main) {
|
|
165
|
+
await cliMain();
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Install selftune hooks into Cline environment.
|
|
4
|
+
*
|
|
5
|
+
* Creates hook scripts in ~/Documents/Cline/Hooks/ for:
|
|
6
|
+
* - PostToolUse (inline — commit tracking, fast path)
|
|
7
|
+
* - TaskComplete (background — session telemetry)
|
|
8
|
+
* - TaskCancel (background — session cleanup)
|
|
9
|
+
*
|
|
10
|
+
* Each hook is a bash shim that pipes stdin to `npx selftune cline hook`.
|
|
11
|
+
*
|
|
12
|
+
* Usage: selftune cline install [--dry-run] [--uninstall]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const CLINE_HOOKS_DIR = join(homedir(), "Documents", "Cline", "Hooks");
|
|
20
|
+
const MARKER = "# selftune-managed";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Hook script generators
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Build a hook command that prefers SELFTUNE_CLI_PATH, then npx. */
|
|
27
|
+
const HOOK_CMD =
|
|
28
|
+
'if [ -n "$SELFTUNE_CLI_PATH" ]; then "$SELFTUNE_CLI_PATH" cline hook; else npx selftune cline hook; fi';
|
|
29
|
+
|
|
30
|
+
function hookScript(hookName: string): string {
|
|
31
|
+
if (hookName === "PostToolUse") {
|
|
32
|
+
// Inline — commit tracking is fast; finish before Cline moves on.
|
|
33
|
+
// hook.ts writes {"cancel": false} to stdout, so we suppress only stderr.
|
|
34
|
+
return `#!/usr/bin/env bash
|
|
35
|
+
${MARKER}
|
|
36
|
+
input=$(cat)
|
|
37
|
+
echo "$input" | (${HOOK_CMD}) 2>/dev/null || echo '{"cancel": false}'
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Background — session telemetry upload can be slow; don't block Cline
|
|
42
|
+
return `#!/usr/bin/env bash
|
|
43
|
+
${MARKER}
|
|
44
|
+
input=$(cat)
|
|
45
|
+
echo "$input" | (${HOOK_CMD}) &>/dev/null &
|
|
46
|
+
echo '{"cancel": false}'
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Hook definitions
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const HOOKS: Array<{ name: string; description: string }> = [
|
|
55
|
+
{ name: "PostToolUse", description: "Track git commits via selftune" },
|
|
56
|
+
{ name: "TaskComplete", description: "Record session telemetry when a Cline task completes" },
|
|
57
|
+
{ name: "TaskCancel", description: "Record session telemetry when a Cline task is cancelled" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Install
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function installHooks(dryRun: boolean): void {
|
|
65
|
+
console.log("Setting up selftune hooks for Cline...");
|
|
66
|
+
console.log(`Hooks directory: ${CLINE_HOOKS_DIR}`);
|
|
67
|
+
console.log("");
|
|
68
|
+
|
|
69
|
+
if (!dryRun) {
|
|
70
|
+
mkdirSync(CLINE_HOOKS_DIR, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let installed = 0;
|
|
74
|
+
let skipped = 0;
|
|
75
|
+
|
|
76
|
+
for (const hook of HOOKS) {
|
|
77
|
+
const hookPath = join(CLINE_HOOKS_DIR, hook.name);
|
|
78
|
+
|
|
79
|
+
if (existsSync(hookPath)) {
|
|
80
|
+
const existing = readFileSync(hookPath, "utf-8");
|
|
81
|
+
if (existing.includes(MARKER)) {
|
|
82
|
+
if (dryRun) {
|
|
83
|
+
console.log(` Would update: ${hook.name}`);
|
|
84
|
+
} else {
|
|
85
|
+
writeFileSync(hookPath, hookScript(hook.name), { mode: 0o755 });
|
|
86
|
+
chmodSync(hookPath, 0o755);
|
|
87
|
+
console.log(` Updated: ${hook.name}`);
|
|
88
|
+
}
|
|
89
|
+
installed++;
|
|
90
|
+
} else {
|
|
91
|
+
console.log(` Skipped: ${hook.name} (existing hook not managed by selftune)`);
|
|
92
|
+
skipped++;
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
if (dryRun) {
|
|
96
|
+
console.log(` Would create: ${hook.name}`);
|
|
97
|
+
} else {
|
|
98
|
+
writeFileSync(hookPath, hookScript(hook.name), { mode: 0o755 });
|
|
99
|
+
console.log(` Created: ${hook.name}`);
|
|
100
|
+
}
|
|
101
|
+
installed++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log("");
|
|
106
|
+
if (dryRun) {
|
|
107
|
+
console.log(`Dry run: ${installed} hook(s) would be installed.`);
|
|
108
|
+
} else if (installed > 0) {
|
|
109
|
+
console.log(`Installed ${installed} hook(s).`);
|
|
110
|
+
}
|
|
111
|
+
if (skipped > 0) {
|
|
112
|
+
console.log(`Skipped ${skipped} hook(s) with existing non-selftune content.`);
|
|
113
|
+
}
|
|
114
|
+
if (!dryRun && installed > 0) {
|
|
115
|
+
console.log("");
|
|
116
|
+
if (skipped === 0) {
|
|
117
|
+
console.log("Cline will now track commits and record session telemetry.");
|
|
118
|
+
} else {
|
|
119
|
+
console.log("Partial install: some hooks were skipped. Telemetry may be incomplete.");
|
|
120
|
+
}
|
|
121
|
+
console.log("Run `selftune status` to verify setup.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Uninstall
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function uninstallHooks(dryRun: boolean): void {
|
|
130
|
+
console.log("Removing selftune hooks from Cline...");
|
|
131
|
+
console.log("");
|
|
132
|
+
|
|
133
|
+
let removed = 0;
|
|
134
|
+
let skipped = 0;
|
|
135
|
+
|
|
136
|
+
for (const hook of HOOKS) {
|
|
137
|
+
const hookPath = join(CLINE_HOOKS_DIR, hook.name);
|
|
138
|
+
|
|
139
|
+
if (!existsSync(hookPath)) {
|
|
140
|
+
console.log(` Not found: ${hook.name}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const existing = readFileSync(hookPath, "utf-8");
|
|
145
|
+
if (!existing.includes(MARKER)) {
|
|
146
|
+
console.log(` Skipped: ${hook.name} (not managed by selftune)`);
|
|
147
|
+
skipped++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (dryRun) {
|
|
152
|
+
console.log(` Would remove: ${hook.name}`);
|
|
153
|
+
} else {
|
|
154
|
+
rmSync(hookPath);
|
|
155
|
+
console.log(` Removed: ${hook.name}`);
|
|
156
|
+
}
|
|
157
|
+
removed++;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log("");
|
|
161
|
+
if (dryRun) {
|
|
162
|
+
console.log(`Dry run: ${removed} hook(s) would be removed.`);
|
|
163
|
+
} else if (removed > 0) {
|
|
164
|
+
console.log(`Removed ${removed} hook(s).`);
|
|
165
|
+
}
|
|
166
|
+
if (skipped > 0) {
|
|
167
|
+
console.log(`Skipped ${skipped} hook(s) not managed by selftune.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Main entry point
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export async function cliMain(): Promise<void> {
|
|
176
|
+
const args = process.argv.slice(2);
|
|
177
|
+
const dryRun = args.includes("--dry-run");
|
|
178
|
+
const uninstall = args.includes("--uninstall");
|
|
179
|
+
|
|
180
|
+
if (uninstall) {
|
|
181
|
+
uninstallHooks(dryRun);
|
|
182
|
+
} else {
|
|
183
|
+
installHooks(dryRun);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- stdin main (only when executed directly, not when imported) ---
|
|
188
|
+
if (import.meta.main) {
|
|
189
|
+
try {
|
|
190
|
+
await cliMain();
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error(
|
|
193
|
+
`[selftune] Cline install failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
194
|
+
);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Codex hook adapter for selftune.
|
|
4
|
+
*
|
|
5
|
+
* Reads Codex hook payloads from stdin and delegates to shared selftune hook logic.
|
|
6
|
+
* Codex uses the same hook protocol as Claude Code (JSON on stdin, JSON on stdout),
|
|
7
|
+
* so the payloads are structurally identical.
|
|
8
|
+
*
|
|
9
|
+
* Usage: echo '$HOOK_PAYLOAD' | selftune codex hook
|
|
10
|
+
*
|
|
11
|
+
* Event routing:
|
|
12
|
+
* SessionStart -> prompt-log (processPrompt) + auto-activate (processAutoActivate)
|
|
13
|
+
* PreToolUse -> skill-change-guard + evolution-guard
|
|
14
|
+
* PostToolUse -> skill-eval (processToolUse) + commit-track (processCommitTrack)
|
|
15
|
+
* Stop -> session-stop (processSessionStop)
|
|
16
|
+
*
|
|
17
|
+
* Exit codes:
|
|
18
|
+
* 0 = success / allow
|
|
19
|
+
* 2 = block (PreToolUse guard rejection, Claude Code convention)
|
|
20
|
+
*
|
|
21
|
+
* Fail-open: any unhandled error -> exit 0, never crash the host agent.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
PostToolUsePayload,
|
|
26
|
+
PreToolUsePayload,
|
|
27
|
+
PromptSubmitPayload,
|
|
28
|
+
StopPayload,
|
|
29
|
+
} from "../../types.js";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** Codex hook payload — superset of all event fields. */
|
|
36
|
+
export interface CodexHookPayload {
|
|
37
|
+
hook_event_name?: string;
|
|
38
|
+
session_id?: string;
|
|
39
|
+
transcript_path?: string;
|
|
40
|
+
cwd?: string;
|
|
41
|
+
tool_name?: string;
|
|
42
|
+
tool_input?: Record<string, unknown>;
|
|
43
|
+
tool_use_id?: string;
|
|
44
|
+
tool_response?: Record<string, unknown>;
|
|
45
|
+
prompt?: string;
|
|
46
|
+
user_prompt?: string;
|
|
47
|
+
permission_mode?: string;
|
|
48
|
+
stop_hook_active?: boolean;
|
|
49
|
+
last_assistant_message?: string;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Response written to stdout. Empty object = no-op. */
|
|
54
|
+
type HookResponse = Record<string, unknown>;
|
|
55
|
+
|
|
56
|
+
const EMPTY_RESPONSE: HookResponse = {};
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Event handlers (dynamic imports for fast startup)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
async function handleSessionStart(payload: CodexHookPayload): Promise<HookResponse> {
|
|
63
|
+
// 1. Prompt logging
|
|
64
|
+
try {
|
|
65
|
+
const { processPrompt } = await import("../../hooks/prompt-log.js");
|
|
66
|
+
const promptPayload: PromptSubmitPayload = {
|
|
67
|
+
session_id: payload.session_id,
|
|
68
|
+
transcript_path: payload.transcript_path,
|
|
69
|
+
cwd: payload.cwd,
|
|
70
|
+
prompt: payload.prompt,
|
|
71
|
+
user_prompt: payload.user_prompt,
|
|
72
|
+
hook_event_name: "UserPromptSubmit",
|
|
73
|
+
};
|
|
74
|
+
await processPrompt(promptPayload);
|
|
75
|
+
} catch {
|
|
76
|
+
// fail-open
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. Auto-activate suggestions
|
|
80
|
+
let response: HookResponse = EMPTY_RESPONSE;
|
|
81
|
+
try {
|
|
82
|
+
const { processAutoActivate } = await import("../../hooks/auto-activate.js");
|
|
83
|
+
const sessionId = payload.session_id ?? "unknown";
|
|
84
|
+
const suggestions = await processAutoActivate(sessionId);
|
|
85
|
+
if (suggestions.length > 0) {
|
|
86
|
+
const context = suggestions.map((s) => `[selftune] Suggestion: ${s}`).join("\n");
|
|
87
|
+
// Codex supports hookSpecificOutput.additionalContext like Claude Code
|
|
88
|
+
response = {
|
|
89
|
+
hookSpecificOutput: {
|
|
90
|
+
hookEventName: "SessionStart",
|
|
91
|
+
additionalContext: context,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// fail-open
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return response;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function handlePreToolUse(
|
|
103
|
+
payload: CodexHookPayload,
|
|
104
|
+
): Promise<{ response: HookResponse; exitCode: number }> {
|
|
105
|
+
const prePayload: PreToolUsePayload = {
|
|
106
|
+
tool_name: payload.tool_name ?? "",
|
|
107
|
+
tool_input: payload.tool_input ?? {},
|
|
108
|
+
tool_use_id: payload.tool_use_id,
|
|
109
|
+
session_id: payload.session_id,
|
|
110
|
+
transcript_path: payload.transcript_path,
|
|
111
|
+
cwd: payload.cwd,
|
|
112
|
+
permission_mode: payload.permission_mode,
|
|
113
|
+
hook_event_name: "PreToolUse",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Import constants once for both guards
|
|
117
|
+
let constants:
|
|
118
|
+
| { EVOLUTION_AUDIT_LOG: string; SELFTUNE_CONFIG_DIR: string; SESSION_STATE_DIR: string }
|
|
119
|
+
| undefined;
|
|
120
|
+
try {
|
|
121
|
+
constants = await import("../../constants.js");
|
|
122
|
+
} catch {
|
|
123
|
+
// fail-open
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 1. Evolution guard (can block with exit 2)
|
|
127
|
+
try {
|
|
128
|
+
if (constants) {
|
|
129
|
+
const { processEvolutionGuard } = await import("../../hooks/evolution-guard.js");
|
|
130
|
+
const guardResult = await processEvolutionGuard(prePayload, {
|
|
131
|
+
auditLogPath: constants.EVOLUTION_AUDIT_LOG,
|
|
132
|
+
selftuneDir: constants.SELFTUNE_CONFIG_DIR,
|
|
133
|
+
});
|
|
134
|
+
if (guardResult) {
|
|
135
|
+
process.stderr.write(`${guardResult.message}\n`);
|
|
136
|
+
return { response: EMPTY_RESPONSE, exitCode: guardResult.exitCode };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// fail-open
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. Skill change guard (advisory only, never blocks)
|
|
144
|
+
try {
|
|
145
|
+
if (constants) {
|
|
146
|
+
const { processPreToolUse } = await import("../../hooks/skill-change-guard.js");
|
|
147
|
+
const sessionId = payload.session_id ?? "unknown";
|
|
148
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
149
|
+
const statePath = `${constants.SESSION_STATE_DIR}/guard-state-${safe}.json`;
|
|
150
|
+
const suggestion = processPreToolUse(prePayload, statePath);
|
|
151
|
+
if (suggestion) {
|
|
152
|
+
process.stderr.write(`[selftune] Suggestion: ${suggestion}\n`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// fail-open
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { response: EMPTY_RESPONSE, exitCode: 0 };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function handlePostToolUse(payload: CodexHookPayload): Promise<HookResponse> {
|
|
163
|
+
const postPayload: PostToolUsePayload = {
|
|
164
|
+
tool_name: payload.tool_name ?? "",
|
|
165
|
+
tool_input: payload.tool_input ?? {},
|
|
166
|
+
tool_use_id: payload.tool_use_id,
|
|
167
|
+
tool_response: payload.tool_response,
|
|
168
|
+
session_id: payload.session_id,
|
|
169
|
+
transcript_path: payload.transcript_path,
|
|
170
|
+
cwd: payload.cwd,
|
|
171
|
+
permission_mode: payload.permission_mode,
|
|
172
|
+
hook_event_name: "PostToolUse",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// 1. Skill eval (Read/Skill tool usage tracking)
|
|
176
|
+
try {
|
|
177
|
+
const { processToolUse } = await import("../../hooks/skill-eval.js");
|
|
178
|
+
await processToolUse(postPayload);
|
|
179
|
+
} catch {
|
|
180
|
+
// fail-open
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 2. Commit tracking (git commit detection in Bash output)
|
|
184
|
+
try {
|
|
185
|
+
const { processCommitTrack } = await import("../../hooks/commit-track.js");
|
|
186
|
+
await processCommitTrack(postPayload);
|
|
187
|
+
} catch {
|
|
188
|
+
// fail-open
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return EMPTY_RESPONSE;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function handleStop(payload: CodexHookPayload): Promise<HookResponse> {
|
|
195
|
+
try {
|
|
196
|
+
const { processSessionStop } = await import("../../hooks/session-stop.js");
|
|
197
|
+
const stopPayload: StopPayload = {
|
|
198
|
+
session_id: payload.session_id,
|
|
199
|
+
transcript_path: payload.transcript_path,
|
|
200
|
+
cwd: payload.cwd,
|
|
201
|
+
permission_mode: payload.permission_mode,
|
|
202
|
+
stop_hook_active: payload.stop_hook_active,
|
|
203
|
+
last_assistant_message:
|
|
204
|
+
typeof payload.last_assistant_message === "string"
|
|
205
|
+
? payload.last_assistant_message
|
|
206
|
+
: undefined,
|
|
207
|
+
hook_event_name: "Stop",
|
|
208
|
+
};
|
|
209
|
+
await processSessionStop(stopPayload);
|
|
210
|
+
} catch {
|
|
211
|
+
// fail-open
|
|
212
|
+
}
|
|
213
|
+
return EMPTY_RESPONSE;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Main entry point
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
function writeResponseAndExit(response: HookResponse, code: number): void {
|
|
221
|
+
const data = JSON.stringify(response);
|
|
222
|
+
process.stdout.write(data, () => {
|
|
223
|
+
process.exit(code);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* CLI entry point. Reads stdin, routes to the correct handler, writes response.
|
|
229
|
+
*/
|
|
230
|
+
export async function cliMain(): Promise<void> {
|
|
231
|
+
let exitCode = 0;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const { readStdinWithPreview } = await import("../../hooks/stdin-preview.js");
|
|
235
|
+
const { full } = await readStdinWithPreview();
|
|
236
|
+
|
|
237
|
+
// Fast-path: empty stdin -> no-op
|
|
238
|
+
if (!full.trim()) {
|
|
239
|
+
writeResponseAndExit(EMPTY_RESPONSE, 0);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let payload: CodexHookPayload;
|
|
244
|
+
try {
|
|
245
|
+
payload = JSON.parse(full) as CodexHookPayload;
|
|
246
|
+
} catch {
|
|
247
|
+
writeResponseAndExit(EMPTY_RESPONSE, 0);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const eventName = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
|
|
252
|
+
|
|
253
|
+
// Fast-path: use preview to skip irrelevant events without full routing
|
|
254
|
+
if (!eventName) {
|
|
255
|
+
writeResponseAndExit(EMPTY_RESPONSE, 0);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let response: HookResponse = EMPTY_RESPONSE;
|
|
260
|
+
|
|
261
|
+
switch (eventName) {
|
|
262
|
+
case "SessionStart": {
|
|
263
|
+
response = await handleSessionStart(payload);
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
case "PreToolUse": {
|
|
267
|
+
const result = await handlePreToolUse(payload);
|
|
268
|
+
response = result.response;
|
|
269
|
+
exitCode = result.exitCode;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case "PostToolUse": {
|
|
273
|
+
response = await handlePostToolUse(payload);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case "Stop": {
|
|
277
|
+
response = await handleStop(payload);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
default: {
|
|
281
|
+
// Unknown event — no-op
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
writeResponseAndExit(response, exitCode);
|
|
287
|
+
} catch {
|
|
288
|
+
// Fail-open: never crash
|
|
289
|
+
writeResponseAndExit(EMPTY_RESPONSE, 0);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- stdin main (only when executed directly, not when imported) ---
|
|
294
|
+
if (import.meta.main) {
|
|
295
|
+
await cliMain();
|
|
296
|
+
}
|