selftune 0.2.21 → 0.2.23
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 +15 -8
- package/apps/local-dashboard/dist/assets/index-CwOtTrUS.css +1 -0
- package/apps/local-dashboard/dist/assets/index-f1HQpbeH.js +59 -0
- package/apps/local-dashboard/dist/assets/vendor-ui-jVSaIZey.js +12 -0
- package/apps/local-dashboard/dist/index.html +3 -3
- 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/adapters/pi/hook.ts +273 -0
- package/cli/selftune/adapters/pi/install.ts +207 -0
- package/cli/selftune/constants.ts +10 -1
- package/cli/selftune/dashboard-contract.ts +14 -0
- package/cli/selftune/evolution/engines/judge-engine.ts +96 -0
- package/cli/selftune/evolution/engines/replay-engine.ts +158 -0
- package/cli/selftune/evolution/evidence.ts +2 -6
- package/cli/selftune/evolution/evolve-body.ts +73 -20
- package/cli/selftune/evolution/validate-body.ts +78 -42
- package/cli/selftune/evolution/validate-routing.ts +45 -104
- package/cli/selftune/hooks/auto-activate.ts +43 -37
- package/cli/selftune/hooks/skill-eval.ts +2 -1
- 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 +91 -0
- package/cli/selftune/index.ts +76 -6
- package/cli/selftune/ingestors/pi-ingest.ts +726 -0
- package/cli/selftune/init.ts +11 -1
- package/cli/selftune/localdb/direct-write.ts +85 -0
- package/cli/selftune/localdb/materialize.ts +6 -7
- package/cli/selftune/localdb/queries.ts +126 -0
- package/cli/selftune/localdb/schema.ts +38 -0
- package/cli/selftune/observability.ts +8 -1
- package/cli/selftune/orchestrate.ts +43 -0
- package/cli/selftune/registry/client.ts +74 -0
- package/cli/selftune/registry/history.ts +54 -0
- package/cli/selftune/registry/index.ts +90 -0
- package/cli/selftune/registry/install.ts +141 -0
- package/cli/selftune/registry/list.ts +44 -0
- package/cli/selftune/registry/push.ts +171 -0
- package/cli/selftune/registry/rollback.ts +49 -0
- package/cli/selftune/registry/status.ts +62 -0
- package/cli/selftune/registry/sync.ts +125 -0
- package/cli/selftune/repair/skill-usage.ts +4 -1
- package/cli/selftune/status.ts +31 -0
- package/cli/selftune/sync.ts +127 -23
- package/cli/selftune/types.ts +2 -1
- package/cli/selftune/utils/jsonl.ts +1 -30
- package/cli/selftune/utils/llm-call.ts +99 -34
- package/cli/selftune/utils/skill-discovery.ts +22 -0
- package/node_modules/@selftune/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
- package/node_modules/@selftune/telemetry-contract/fixtures/golden.test.ts +0 -1
- package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
- package/node_modules/@selftune/telemetry-contract/package.json +1 -1
- package/node_modules/@selftune/telemetry-contract/src/index.ts +1 -0
- package/node_modules/@selftune/telemetry-contract/src/schemas.ts +22 -4
- package/node_modules/@selftune/telemetry-contract/src/types.ts +1 -12
- package/node_modules/@selftune/telemetry-contract/tests/compatibility.test.ts +0 -1
- package/package.json +1 -1
- package/packages/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
- package/packages/telemetry-contract/fixtures/golden.test.ts +0 -1
- package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
- package/packages/telemetry-contract/package.json +1 -1
- package/packages/telemetry-contract/src/index.ts +1 -0
- package/packages/telemetry-contract/src/schemas.ts +22 -4
- package/packages/telemetry-contract/src/types.ts +1 -12
- package/packages/telemetry-contract/tests/compatibility.test.ts +0 -1
- package/packages/ui/AGENTS.md +16 -0
- package/packages/ui/README.md +1 -1
- package/packages/ui/package.json +1 -1
- package/packages/ui/src/components/ActivityTimeline.tsx +152 -168
- package/packages/ui/src/components/AnalyticsCharts.tsx +344 -0
- package/packages/ui/src/components/EvidenceViewer.tsx +153 -443
- package/packages/ui/src/components/EvolutionTimeline.tsx +34 -87
- package/packages/ui/src/components/InfoTip.tsx +1 -2
- package/packages/ui/src/components/InvocationsPanel.tsx +413 -0
- package/packages/ui/src/components/JobHistoryTimeline.tsx +156 -0
- package/packages/ui/src/components/OrchestrateRunsPanel.tsx +18 -36
- package/packages/ui/src/components/OverviewPanels.tsx +652 -0
- package/packages/ui/src/components/PipelineStatusBar.tsx +65 -0
- package/packages/ui/src/components/SkillReportGuide.tsx +215 -0
- package/packages/ui/src/components/SkillReportPanels.tsx +919 -0
- package/packages/ui/src/components/SkillsLibrary.tsx +437 -0
- package/packages/ui/src/components/index.ts +56 -1
- package/packages/ui/src/components/section-cards.tsx +18 -35
- package/packages/ui/src/components/skill-health-grid.tsx +47 -37
- package/packages/ui/src/lib/constants.tsx +0 -1
- package/packages/ui/src/primitives/card.tsx +1 -1
- package/packages/ui/src/primitives/checkbox.tsx +1 -1
- package/packages/ui/src/primitives/dropdown-menu.tsx +2 -2
- package/packages/ui/src/primitives/select.tsx +2 -2
- package/packages/ui/src/types.ts +172 -4
- package/skill/SKILL.md +26 -2
- package/skill/Workflows/Ingest.md +60 -2
- package/skill/Workflows/Initialize.md +54 -9
- package/skill/Workflows/PlatformHooks.md +109 -0
- package/skill/Workflows/Registry.md +99 -0
- package/skill/Workflows/Sync.md +3 -1
- package/apps/local-dashboard/dist/assets/index-D8O-RG1I.js +0 -60
- package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +0 -1
- package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +0 -12
- package/cli/selftune/utils/html.ts +0 -27
- package/packages/ui/src/components/RecentActivityFeed.tsx +0 -117
|
@@ -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
|
+
}
|