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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared git metadata extraction for hooks.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from duplicated logic in session-stop.ts (branch/remote extraction)
|
|
5
|
+
* and commit-track.ts (commit detection, remote scrubbing, branch fallback).
|
|
6
|
+
*
|
|
7
|
+
* All functions are fail-open: git errors return undefined/null, never throw.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Types
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Git metadata extracted from a working directory. */
|
|
17
|
+
export interface GitMetadata {
|
|
18
|
+
branch?: string;
|
|
19
|
+
repoRemote?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parsed commit information from git output. */
|
|
23
|
+
export interface ParsedCommit {
|
|
24
|
+
sha?: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
branch?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Pre-compiled regex patterns
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Matches git commands that produce commits: commit, merge, cherry-pick, revert. */
|
|
34
|
+
const GIT_COMMIT_CMD_RE = /\bgit\s+(commit|merge|cherry-pick|revert)\b/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Matches standard git commit output: [branch SHA] title
|
|
38
|
+
* Supports optional parenthetical like (root-commit).
|
|
39
|
+
* Branch names can contain word chars, slashes, dots, hyphens, plus signs.
|
|
40
|
+
*/
|
|
41
|
+
const COMMIT_OUTPUT_RE = /\[([\w/.+-]+)(?:\s+\([^)]+\))?\s+([a-f0-9]{7,40})\]\s+(.+)/;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Git metadata extraction
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract git branch and remote URL from a working directory.
|
|
49
|
+
*
|
|
50
|
+
* Uses short-timeout execSync calls. Returns partial results if one
|
|
51
|
+
* command fails (e.g., branch succeeds but remote is not configured).
|
|
52
|
+
* Returns empty object if cwd is not a git repo.
|
|
53
|
+
*
|
|
54
|
+
* @param cwd Working directory to inspect
|
|
55
|
+
*/
|
|
56
|
+
export function extractGitMetadata(cwd: string): GitMetadata {
|
|
57
|
+
if (!cwd) return {};
|
|
58
|
+
|
|
59
|
+
const result: GitMetadata = {};
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
result.branch =
|
|
63
|
+
execSync("git rev-parse --abbrev-ref HEAD", {
|
|
64
|
+
cwd,
|
|
65
|
+
timeout: 3000,
|
|
66
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
67
|
+
})
|
|
68
|
+
.toString()
|
|
69
|
+
.trim() || undefined;
|
|
70
|
+
} catch {
|
|
71
|
+
/* not a git repo or git not available */
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const rawRemote =
|
|
76
|
+
execSync("git remote get-url origin", {
|
|
77
|
+
cwd,
|
|
78
|
+
timeout: 3000,
|
|
79
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
80
|
+
})
|
|
81
|
+
.toString()
|
|
82
|
+
.trim() || undefined;
|
|
83
|
+
if (rawRemote) {
|
|
84
|
+
result.repoRemote = scrubRemoteUrl(rawRemote);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
/* no remote configured */
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// URL scrubbing
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Scrub credentials from a git remote URL.
|
|
99
|
+
*
|
|
100
|
+
* HTTP(S) URLs have username/password stripped. SSH URLs and other formats
|
|
101
|
+
* are returned as-is (they don't embed credentials in the URL structure).
|
|
102
|
+
*
|
|
103
|
+
* @param rawUrl Raw remote URL from `git remote get-url`
|
|
104
|
+
* @returns Scrubbed URL, or undefined for empty input
|
|
105
|
+
*/
|
|
106
|
+
export function scrubRemoteUrl(rawUrl: string): string | undefined {
|
|
107
|
+
if (!rawUrl) return undefined;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = new URL(rawUrl);
|
|
110
|
+
parsed.username = "";
|
|
111
|
+
parsed.password = "";
|
|
112
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
113
|
+
} catch {
|
|
114
|
+
// SSH or non-URL format -- safe as-is
|
|
115
|
+
return rawUrl;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Commit detection
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if a command string contains a git commit-producing operation.
|
|
125
|
+
* Detects: git commit, git merge, git cherry-pick, git revert.
|
|
126
|
+
*/
|
|
127
|
+
export function containsGitCommitCommand(command: string): boolean {
|
|
128
|
+
return GIT_COMMIT_CMD_RE.test(command);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parse commit metadata from git's standard output format.
|
|
133
|
+
*
|
|
134
|
+
* Expects output like: `[main abc1234] Fix the bug`
|
|
135
|
+
* or with root-commit: `[main (root-commit) abc1234] Initial commit`
|
|
136
|
+
*
|
|
137
|
+
* @param stdout The stdout from a git commit/merge/cherry-pick/revert command
|
|
138
|
+
* @returns Parsed commit info, or null if output doesn't match
|
|
139
|
+
*/
|
|
140
|
+
export function parseCommitFromOutput(stdout: string): ParsedCommit | null {
|
|
141
|
+
const match = stdout.match(COMMIT_OUTPUT_RE);
|
|
142
|
+
if (!match) return null;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
branch: match[1],
|
|
146
|
+
sha: match[2],
|
|
147
|
+
title: match[3].trim(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared output helpers for platform-agnostic hook responses.
|
|
3
|
+
*
|
|
4
|
+
* Hooks communicate with their host agent through stdout (context injection),
|
|
5
|
+
* stderr (advisory messages), and exit codes (allow/block decisions). The
|
|
6
|
+
* exact format varies by platform — this module abstracts those differences.
|
|
7
|
+
*
|
|
8
|
+
* Claude Code conventions (the primary platform):
|
|
9
|
+
* - stdout JSON with hookSpecificOutput.additionalContext for context injection
|
|
10
|
+
* - stderr for advisory suggestions
|
|
11
|
+
* - exit 0 = allow, exit 2 = block with message
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { HookPlatform, HookResponse } from "./types.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format a HookResponse for a specific platform's expected output format.
|
|
18
|
+
*
|
|
19
|
+
* For Claude Code: produces JSON with hookSpecificOutput wrapper.
|
|
20
|
+
* For other platforms: produces a simplified JSON response.
|
|
21
|
+
*
|
|
22
|
+
* @param response The platform-agnostic hook response
|
|
23
|
+
* @param platform The target platform
|
|
24
|
+
* @returns Formatted string ready to write to stdout
|
|
25
|
+
*/
|
|
26
|
+
export function formatResponseForPlatform(response: HookResponse, platform: HookPlatform): string {
|
|
27
|
+
if (platform === "claude-code" || platform === "codex") {
|
|
28
|
+
// Claude Code / Codex use hookSpecificOutput wrapper
|
|
29
|
+
const output: Record<string, unknown> = {};
|
|
30
|
+
|
|
31
|
+
if (response.context) {
|
|
32
|
+
output.hookSpecificOutput = {
|
|
33
|
+
additionalContext: response.context,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (response.updated_input) {
|
|
38
|
+
output.updatedInput = response.updated_input;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (response.decision) {
|
|
42
|
+
output.decision = response.decision;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return JSON.stringify(output);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Generic JSON format for other platforms
|
|
49
|
+
return JSON.stringify({
|
|
50
|
+
modified: response.modified,
|
|
51
|
+
decision: response.decision,
|
|
52
|
+
message: response.message,
|
|
53
|
+
context: response.context,
|
|
54
|
+
updated_input: response.updated_input,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Write an advisory suggestion to stderr.
|
|
60
|
+
*
|
|
61
|
+
* Stderr messages appear as system messages to the host agent in Claude Code.
|
|
62
|
+
* Other platforms may handle stderr differently, but writing to stderr is
|
|
63
|
+
* universally safe (it never affects the hook's exit code or stdout response).
|
|
64
|
+
*
|
|
65
|
+
* @param message The suggestion text to display
|
|
66
|
+
*/
|
|
67
|
+
export function writeSuggestion(message: string): void {
|
|
68
|
+
process.stderr.write(`${message}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write context injection to stdout.
|
|
73
|
+
*
|
|
74
|
+
* For Claude Code, this injects content into Claude's context via the
|
|
75
|
+
* hookSpecificOutput.additionalContext mechanism. The output is JSON-formatted
|
|
76
|
+
* to match Claude Code's expected hook output schema.
|
|
77
|
+
*
|
|
78
|
+
* @param context The context string to inject
|
|
79
|
+
*/
|
|
80
|
+
export function writeContext(context: string): void {
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
JSON.stringify({
|
|
83
|
+
hookSpecificOutput: {
|
|
84
|
+
additionalContext: context,
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Exit with a platform-appropriate exit code for allow/block decisions.
|
|
92
|
+
*
|
|
93
|
+
* Claude Code convention:
|
|
94
|
+
* - exit 0 = allow the tool call
|
|
95
|
+
* - exit 2 = block the tool call (with stderr message)
|
|
96
|
+
*
|
|
97
|
+
* Other platforms use the same convention unless they specify otherwise.
|
|
98
|
+
*
|
|
99
|
+
* @param decision "allow" or "block"
|
|
100
|
+
* @param _platform The target platform (reserved for future per-platform codes)
|
|
101
|
+
*/
|
|
102
|
+
export function exitWithDecision(decision: "allow" | "block", _platform: HookPlatform): never {
|
|
103
|
+
const code = decision === "block" ? 2 : 0;
|
|
104
|
+
process.exit(code);
|
|
105
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizers that convert platform-specific hook payloads to UnifiedHookEvent.
|
|
3
|
+
*
|
|
4
|
+
* Each platform adapter maps its native payload shape and event names
|
|
5
|
+
* to the shared UnifiedHookEvent interface. The normalizers are intentionally
|
|
6
|
+
* lenient — unknown fields are ignored, missing fields become undefined.
|
|
7
|
+
*
|
|
8
|
+
* Fail-open: any parsing error returns a minimal event with what we have.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { HookEventType, HookPlatform, UnifiedHookEvent } from "./types.js";
|
|
12
|
+
import { PLATFORM_EVENT_MAP } from "./types.js";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Reverse lookup: native event name -> HookEventType
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Build a reverse map from native event string to HookEventType for a platform. */
|
|
19
|
+
function buildReverseLookup(platform: HookPlatform): Map<string, HookEventType> {
|
|
20
|
+
const forward = PLATFORM_EVENT_MAP[platform];
|
|
21
|
+
const reverse = new Map<string, HookEventType>();
|
|
22
|
+
for (const [hookType, nativeName] of Object.entries(forward)) {
|
|
23
|
+
if (nativeName) {
|
|
24
|
+
reverse.set(nativeName, hookType as HookEventType);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return reverse;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const reverseLookups = new Map<HookPlatform, Map<string, HookEventType>>();
|
|
31
|
+
|
|
32
|
+
/** Resolve a native event type string to the normalized HookEventType. */
|
|
33
|
+
function resolveEventType(
|
|
34
|
+
platform: HookPlatform,
|
|
35
|
+
nativeEventType: string,
|
|
36
|
+
): HookEventType | undefined {
|
|
37
|
+
let lookup = reverseLookups.get(platform);
|
|
38
|
+
if (!lookup) {
|
|
39
|
+
lookup = buildReverseLookup(platform);
|
|
40
|
+
reverseLookups.set(platform, lookup);
|
|
41
|
+
}
|
|
42
|
+
return lookup.get(nativeEventType);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Shared field extraction helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
function str(v: unknown): string | undefined {
|
|
50
|
+
return typeof v === "string" ? v : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function obj(v: unknown): Record<string, unknown> | undefined {
|
|
54
|
+
return typeof v === "object" && v !== null ? (v as Record<string, unknown>) : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Per-platform field extraction config
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Describes how to extract prompt and last_message fields from a platform's
|
|
63
|
+
* payload. Most platforms use the same field names; Claude Code has a
|
|
64
|
+
* user_prompt fallback, and some use last_assistant_message vs last_message.
|
|
65
|
+
*/
|
|
66
|
+
interface PlatformFieldConfig {
|
|
67
|
+
/** Default event_type when resolution fails */
|
|
68
|
+
fallbackEvent: HookEventType;
|
|
69
|
+
/** Fields to try for prompt (in order, first non-undefined wins) */
|
|
70
|
+
promptFields: string[];
|
|
71
|
+
/** Field name for session-end last message */
|
|
72
|
+
lastMessageField: string;
|
|
73
|
+
/** Field name for post_tool_use output (e.g., "tool_response" or "tool_output") */
|
|
74
|
+
toolOutputField: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const FIELD_CONFIG: Record<HookPlatform, PlatformFieldConfig> = {
|
|
78
|
+
"claude-code": {
|
|
79
|
+
fallbackEvent: "prompt_submit",
|
|
80
|
+
promptFields: ["prompt", "user_prompt"],
|
|
81
|
+
lastMessageField: "last_assistant_message",
|
|
82
|
+
toolOutputField: "tool_response",
|
|
83
|
+
},
|
|
84
|
+
codex: {
|
|
85
|
+
fallbackEvent: "prompt_submit",
|
|
86
|
+
promptFields: ["prompt"],
|
|
87
|
+
lastMessageField: "last_assistant_message",
|
|
88
|
+
toolOutputField: "tool_response",
|
|
89
|
+
},
|
|
90
|
+
opencode: {
|
|
91
|
+
fallbackEvent: "session_end",
|
|
92
|
+
promptFields: ["prompt"],
|
|
93
|
+
lastMessageField: "last_message",
|
|
94
|
+
toolOutputField: "tool_output",
|
|
95
|
+
},
|
|
96
|
+
cline: {
|
|
97
|
+
fallbackEvent: "session_end",
|
|
98
|
+
promptFields: ["prompt"],
|
|
99
|
+
lastMessageField: "last_message",
|
|
100
|
+
toolOutputField: "tool_output",
|
|
101
|
+
},
|
|
102
|
+
pi: {
|
|
103
|
+
fallbackEvent: "session_end",
|
|
104
|
+
promptFields: ["prompt"],
|
|
105
|
+
lastMessageField: "last_message",
|
|
106
|
+
toolOutputField: "tool_output",
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Unified normalizer
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Normalize a platform-specific hook payload to UnifiedHookEvent.
|
|
116
|
+
*
|
|
117
|
+
* This single function replaces per-platform normalizers by using
|
|
118
|
+
* FIELD_CONFIG to handle platform-specific field names.
|
|
119
|
+
*/
|
|
120
|
+
function normalizeForPlatform(
|
|
121
|
+
platform: HookPlatform,
|
|
122
|
+
payload: unknown,
|
|
123
|
+
eventType: string,
|
|
124
|
+
): UnifiedHookEvent {
|
|
125
|
+
const raw = obj(payload) ?? {};
|
|
126
|
+
const config = FIELD_CONFIG[platform];
|
|
127
|
+
const resolved = resolveEventType(platform, eventType);
|
|
128
|
+
|
|
129
|
+
const base: UnifiedHookEvent = {
|
|
130
|
+
platform,
|
|
131
|
+
event_type: resolved ?? config.fallbackEvent,
|
|
132
|
+
session_id: str(raw.session_id) ?? "unknown",
|
|
133
|
+
cwd: str(raw.cwd),
|
|
134
|
+
transcript_path: str(raw.transcript_path),
|
|
135
|
+
raw_payload: payload,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (resolved === "pre_tool_use" || resolved === "post_tool_use") {
|
|
139
|
+
base.tool_name = str(raw.tool_name);
|
|
140
|
+
base.tool_input = obj(raw.tool_input);
|
|
141
|
+
if (resolved === "post_tool_use") {
|
|
142
|
+
base.tool_output = obj(raw[config.toolOutputField]);
|
|
143
|
+
}
|
|
144
|
+
} else if (resolved === "prompt_submit") {
|
|
145
|
+
for (const field of config.promptFields) {
|
|
146
|
+
const v = str(raw[field]);
|
|
147
|
+
if (v) {
|
|
148
|
+
base.prompt = v;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} else if (resolved === "session_end") {
|
|
153
|
+
base.last_message = str(raw[config.lastMessageField]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return base;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Public API — named exports for backward compatibility + unified entry point
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export function normalizeClaudeCode(payload: unknown, eventType: string): UnifiedHookEvent {
|
|
164
|
+
return normalizeForPlatform("claude-code", payload, eventType);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function normalizeCodex(payload: unknown, eventType: string): UnifiedHookEvent {
|
|
168
|
+
return normalizeForPlatform("codex", payload, eventType);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function normalizeOpenCode(payload: unknown, eventType: string): UnifiedHookEvent {
|
|
172
|
+
return normalizeForPlatform("opencode", payload, eventType);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function normalizeCline(payload: unknown, eventType: string): UnifiedHookEvent {
|
|
176
|
+
return normalizeForPlatform("cline", payload, eventType);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function normalizePi(payload: unknown, eventType: string): UnifiedHookEvent {
|
|
180
|
+
return normalizeForPlatform("pi", payload, eventType);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Auto-detect platform and normalize a hook payload to UnifiedHookEvent.
|
|
185
|
+
*
|
|
186
|
+
* @param payload Raw payload (typically parsed from stdin JSON)
|
|
187
|
+
* @param platform The host platform
|
|
188
|
+
* @param nativeEventType The platform-native event type string
|
|
189
|
+
*/
|
|
190
|
+
export function normalizeHookEvent(
|
|
191
|
+
payload: unknown,
|
|
192
|
+
platform: HookPlatform,
|
|
193
|
+
nativeEventType: string,
|
|
194
|
+
): UnifiedHookEvent {
|
|
195
|
+
return normalizeForPlatform(platform, payload, nativeEventType);
|
|
196
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic session state persistence for hooks.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the duplicate patterns in auto-activate.ts (loadSessionState/saveSessionState)
|
|
5
|
+
* and skill-change-guard.ts (loadGuardState/saveGuardState). Both follow the same pattern:
|
|
6
|
+
*
|
|
7
|
+
* 1. Read a JSON file keyed by session_id
|
|
8
|
+
* 2. If session_id matches, return persisted state; otherwise return defaults
|
|
9
|
+
* 3. Write state back after updates
|
|
10
|
+
*
|
|
11
|
+
* This module generalizes that pattern with a type-safe generic interface.
|
|
12
|
+
* Fail-open: corrupt or missing files return fresh defaults.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import type { SessionState } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load session state from a JSON file.
|
|
22
|
+
*
|
|
23
|
+
* The file is located at `{dir}/{prefix}-{sessionId}.json`. If the file does not
|
|
24
|
+
* exist, is corrupt, or belongs to a different session, fresh defaults are returned.
|
|
25
|
+
*
|
|
26
|
+
* @param dir Directory to store state files (e.g., SELFTUNE_CONFIG_DIR)
|
|
27
|
+
* @param prefix Filename prefix (e.g., "session-state", "guard-state")
|
|
28
|
+
* @param sessionId Current session ID — state is invalidated when it changes
|
|
29
|
+
* @param defaults Factory function returning fresh default state data
|
|
30
|
+
*/
|
|
31
|
+
export function loadSessionState<T extends Record<string, unknown>>(
|
|
32
|
+
dir: string,
|
|
33
|
+
prefix: string,
|
|
34
|
+
sessionId: string,
|
|
35
|
+
defaults: () => T,
|
|
36
|
+
): SessionState<T> {
|
|
37
|
+
const safeName = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
38
|
+
const filePath = join(dir, `${prefix}-${safeName}.json`);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const raw = JSON.parse(readFileSync(filePath, "utf-8")) as SessionState<T>;
|
|
42
|
+
if (raw.session_id === sessionId && typeof raw.data === "object" && raw.data !== null) {
|
|
43
|
+
return raw;
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// ENOENT (missing) or corrupt JSON -- return fresh defaults
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
session_id: sessionId,
|
|
51
|
+
created_at: new Date().toISOString(),
|
|
52
|
+
data: defaults(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Save session state to a JSON file.
|
|
58
|
+
*
|
|
59
|
+
* The file is written to `{dir}/{prefix}-{state.session_id}.json`.
|
|
60
|
+
* The directory is created if it does not exist.
|
|
61
|
+
*
|
|
62
|
+
* @param dir Directory to store state files
|
|
63
|
+
* @param prefix Filename prefix (must match what was used in loadSessionState)
|
|
64
|
+
* @param state The session state to persist
|
|
65
|
+
*/
|
|
66
|
+
export function saveSessionState<T extends Record<string, unknown>>(
|
|
67
|
+
dir: string,
|
|
68
|
+
prefix: string,
|
|
69
|
+
state: SessionState<T>,
|
|
70
|
+
): void {
|
|
71
|
+
const safeName = state.session_id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
72
|
+
const filePath = join(dir, `${prefix}-${safeName}.json`);
|
|
73
|
+
|
|
74
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
75
|
+
writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8");
|
|
76
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared skill name and path extraction utilities for hooks.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from duplicated logic in:
|
|
5
|
+
* - skill-eval.ts: extractSkillName (checks SKILL.MD basename)
|
|
6
|
+
* - skill-change-guard.ts: isSkillMdWrite, extractSkillNameFromPath
|
|
7
|
+
* - evolution-guard.ts: isSkillMdWrite, extractSkillName (identical copies)
|
|
8
|
+
*
|
|
9
|
+
* All three files independently implement the same SKILL.md detection pattern.
|
|
10
|
+
* This module provides a single source of truth.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { basename, dirname } from "node:path";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract the skill folder name from a file path that ends in SKILL.md.
|
|
17
|
+
*
|
|
18
|
+
* The convention is that skill definitions live at `<skill-name>/SKILL.md`,
|
|
19
|
+
* so the parent directory name is the skill name.
|
|
20
|
+
*
|
|
21
|
+
* @param filePath Absolute or relative path to check
|
|
22
|
+
* @returns Skill folder name, or null if the path does not end in SKILL.md
|
|
23
|
+
*/
|
|
24
|
+
export function extractSkillName(filePath: string): string | null {
|
|
25
|
+
if (!isSkillMdFile(filePath)) return null;
|
|
26
|
+
return basename(dirname(filePath)) || "unknown";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a file path points to a SKILL.md file (case-insensitive).
|
|
31
|
+
*
|
|
32
|
+
* @param filePath Path to check
|
|
33
|
+
*/
|
|
34
|
+
export function isSkillMdFile(filePath: string): boolean {
|
|
35
|
+
return basename(filePath).toUpperCase() === "SKILL.MD";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if a tool call is a Write or Edit operation targeting a SKILL.md file.
|
|
40
|
+
*
|
|
41
|
+
* Used by guard hooks (skill-change-guard, evolution-guard) to detect
|
|
42
|
+
* when an agent is about to modify a skill definition.
|
|
43
|
+
*
|
|
44
|
+
* @param toolName The tool being called (e.g., "Write", "Edit", "Read")
|
|
45
|
+
* @param filePath The file_path from tool_input
|
|
46
|
+
*/
|
|
47
|
+
export function isSkillMdWrite(toolName: string, filePath: string): boolean {
|
|
48
|
+
if (toolName !== "Write" && toolName !== "Edit") return false;
|
|
49
|
+
return isSkillMdFile(filePath);
|
|
50
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generalized stdin preview and keyword filtering for hooks.
|
|
3
|
+
*
|
|
4
|
+
* Hooks receive payloads via stdin. Most hooks only care about specific
|
|
5
|
+
* event types (e.g., skill-eval only handles PostToolUse). The existing
|
|
6
|
+
* stdin-preview.ts provides a fast-path optimization: read stdin once,
|
|
7
|
+
* check a preview slice for keywords, and skip JSON.parse entirely when
|
|
8
|
+
* the keyword is absent.
|
|
9
|
+
*
|
|
10
|
+
* This module wraps that pattern into a single readAndFilter call that
|
|
11
|
+
* combines the preview check with JSON parsing, returning null when
|
|
12
|
+
* the payload is irrelevant (caller should exit 0 immediately).
|
|
13
|
+
*
|
|
14
|
+
* Re-exports readStdinWithPreview for backward compatibility.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { readStdinWithPreview } from "../hooks/stdin-preview.js";
|
|
18
|
+
|
|
19
|
+
/** Default preview size in characters (covers envelope fields). */
|
|
20
|
+
const DEFAULT_PREVIEW_BYTES = 4096;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read stdin with fast-path keyword filtering.
|
|
24
|
+
*
|
|
25
|
+
* Reads all of stdin, checks the leading preview slice for the presence of
|
|
26
|
+
* ALL required keywords. If any keyword is missing, returns null (the caller
|
|
27
|
+
* should exit early). Otherwise, parses the full payload as JSON and returns it.
|
|
28
|
+
*
|
|
29
|
+
* This is the recommended way to read hook payloads when you know which
|
|
30
|
+
* keywords must appear in the envelope (e.g., event name, tool name).
|
|
31
|
+
*
|
|
32
|
+
* @param requiredKeywords Strings that must ALL appear in the preview slice.
|
|
33
|
+
* Typically quoted JSON values like '"PostToolUse"'.
|
|
34
|
+
* @param previewBytes Number of leading characters to check (default 4096).
|
|
35
|
+
* @returns Parsed payload and raw string, or null if keywords don't match.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const result = await readAndFilter<PostToolUsePayload>(['"PostToolUse"', '"Read"']);
|
|
40
|
+
* if (!result) process.exit(0);
|
|
41
|
+
* const { payload } = result;
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export async function readAndFilter<T = unknown>(
|
|
45
|
+
requiredKeywords: string[],
|
|
46
|
+
previewBytes: number = DEFAULT_PREVIEW_BYTES,
|
|
47
|
+
): Promise<{ payload: T; raw: string } | null> {
|
|
48
|
+
const raw = await Bun.stdin.text();
|
|
49
|
+
const preview = raw.slice(0, previewBytes);
|
|
50
|
+
|
|
51
|
+
for (const keyword of requiredKeywords) {
|
|
52
|
+
if (!preview.includes(keyword)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const payload = JSON.parse(raw) as T;
|
|
58
|
+
return { payload, raw };
|
|
59
|
+
}
|