selftune 0.2.15 → 0.2.18
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 +24 -19
- package/bin/run-hook.cjs +36 -0
- package/cli/selftune/alpha-upload/build-payloads.ts +14 -1
- package/cli/selftune/alpha-upload/client.ts +51 -1
- package/cli/selftune/alpha-upload/flush.ts +46 -5
- package/cli/selftune/alpha-upload/stage-canonical.ts +25 -4
- package/cli/selftune/alpha-upload-contract.ts +9 -0
- package/cli/selftune/constants.ts +82 -5
- package/cli/selftune/contribute/sanitize.ts +52 -5
- package/cli/selftune/dashboard-contract.ts +100 -0
- package/cli/selftune/dashboard-server.ts +2 -2
- package/cli/selftune/evolution/description-quality.ts +12 -11
- package/cli/selftune/evolution/evolve.ts +238 -53
- package/cli/selftune/evolution/unblock-suggestions.ts +159 -0
- package/cli/selftune/evolution/validate-proposal.ts +9 -6
- package/cli/selftune/grading/grade-session.ts +20 -0
- package/cli/selftune/hooks/commit-track.ts +188 -0
- package/cli/selftune/hooks/prompt-log.ts +10 -1
- package/cli/selftune/hooks/session-stop.ts +2 -2
- package/cli/selftune/hooks/skill-eval.ts +15 -1
- package/cli/selftune/hooks/stdin-preview.ts +32 -0
- package/cli/selftune/init.ts +198 -27
- package/cli/selftune/localdb/direct-write.ts +69 -6
- package/cli/selftune/localdb/queries.ts +552 -7
- package/cli/selftune/localdb/schema.ts +46 -0
- package/cli/selftune/orchestrate.ts +32 -4
- package/cli/selftune/routes/overview.ts +41 -3
- package/cli/selftune/routes/skill-report.ts +88 -17
- package/cli/selftune/types.ts +32 -0
- package/cli/selftune/utils/hooks.ts +12 -2
- package/cli/selftune/utils/transcript.ts +210 -1
- package/node_modules/@selftune/telemetry-contract/src/types.ts +11 -0
- package/package.json +1 -1
- package/packages/telemetry-contract/src/types.ts +11 -0
- package/skill/SKILL.md +29 -1
- package/skill/Workflows/AutoActivation.md +1 -1
- package/skill/Workflows/Evolve.md +31 -13
- package/skill/Workflows/ExportCanonical.md +121 -0
- package/skill/Workflows/Hook.md +131 -0
- package/skill/Workflows/Initialize.md +9 -8
- package/skill/Workflows/Orchestrate.md +27 -5
- package/skill/Workflows/Quickstart.md +94 -0
- package/skill/Workflows/RepairSkillUsage.md +87 -0
- package/skill/Workflows/Uninstall.md +82 -0
- package/skill/settings_snippet.json +19 -8
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { EvalEntry, EvolutionProposal, InvocationTypeScores } from "../types.js";
|
|
10
|
-
import { callLlm } from "../utils/llm-call.js";
|
|
10
|
+
import { callLlm, type EffortLevel } from "../utils/llm-call.js";
|
|
11
11
|
import {
|
|
12
12
|
buildBatchTriggerCheckPrompt,
|
|
13
13
|
buildTriggerCheckPrompt,
|
|
@@ -52,6 +52,7 @@ export async function validateProposalSequential(
|
|
|
52
52
|
evalSet: EvalEntry[],
|
|
53
53
|
agent: string,
|
|
54
54
|
modelFlag?: string,
|
|
55
|
+
effort?: EffortLevel,
|
|
55
56
|
): Promise<ValidationResult> {
|
|
56
57
|
if (evalSet.length === 0) {
|
|
57
58
|
return {
|
|
@@ -76,14 +77,14 @@ export async function validateProposalSequential(
|
|
|
76
77
|
for (const entry of evalSet) {
|
|
77
78
|
// Check with original description
|
|
78
79
|
const beforePrompt = buildTriggerCheckPrompt(proposal.original_description, entry.query);
|
|
79
|
-
const beforeRaw = await callLlm(systemPrompt, beforePrompt, agent, modelFlag);
|
|
80
|
+
const beforeRaw = await callLlm(systemPrompt, beforePrompt, agent, modelFlag, effort);
|
|
80
81
|
const beforeTriggered = parseTriggerResponse(beforeRaw);
|
|
81
82
|
const beforePass =
|
|
82
83
|
(entry.should_trigger && beforeTriggered) || (!entry.should_trigger && !beforeTriggered);
|
|
83
84
|
|
|
84
85
|
// Check with proposed description
|
|
85
86
|
const afterPrompt = buildTriggerCheckPrompt(proposal.proposed_description, entry.query);
|
|
86
|
-
const afterRaw = await callLlm(systemPrompt, afterPrompt, agent, modelFlag);
|
|
87
|
+
const afterRaw = await callLlm(systemPrompt, afterPrompt, agent, modelFlag, effort);
|
|
87
88
|
const afterTriggered = parseTriggerResponse(afterRaw);
|
|
88
89
|
const afterPass =
|
|
89
90
|
(entry.should_trigger && afterTriggered) || (!entry.should_trigger && !afterTriggered);
|
|
@@ -208,6 +209,7 @@ export async function validateProposalBatched(
|
|
|
208
209
|
evalSet: EvalEntry[],
|
|
209
210
|
agent: string,
|
|
210
211
|
modelFlag?: string,
|
|
212
|
+
effort?: EffortLevel,
|
|
211
213
|
): Promise<ValidationResult> {
|
|
212
214
|
if (evalSet.length === 0) {
|
|
213
215
|
return {
|
|
@@ -242,8 +244,8 @@ export async function validateProposalBatched(
|
|
|
242
244
|
// Run VALIDATION_RUNS times in parallel and majority-vote to reduce LLM variance
|
|
243
245
|
const allCalls: Promise<string>[] = [];
|
|
244
246
|
for (let r = 0; r < VALIDATION_RUNS; r++) {
|
|
245
|
-
allCalls.push(callLlm(systemPrompt, beforePrompt, agent, modelFlag));
|
|
246
|
-
allCalls.push(callLlm(systemPrompt, afterPrompt, agent, modelFlag));
|
|
247
|
+
allCalls.push(callLlm(systemPrompt, beforePrompt, agent, modelFlag, effort));
|
|
248
|
+
allCalls.push(callLlm(systemPrompt, afterPrompt, agent, modelFlag, effort));
|
|
247
249
|
}
|
|
248
250
|
const allRaw = await Promise.all(allCalls);
|
|
249
251
|
|
|
@@ -353,6 +355,7 @@ export async function validateProposal(
|
|
|
353
355
|
evalSet: EvalEntry[],
|
|
354
356
|
agent: string,
|
|
355
357
|
modelFlag?: string,
|
|
358
|
+
effort?: EffortLevel,
|
|
356
359
|
): Promise<ValidationResult> {
|
|
357
|
-
return validateProposalBatched(proposal, evalSet, agent, modelFlag);
|
|
360
|
+
return validateProposalBatched(proposal, evalSet, agent, modelFlag, effort);
|
|
358
361
|
}
|
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
GradingExpectation,
|
|
27
27
|
GradingResult,
|
|
28
28
|
SessionTelemetryRecord,
|
|
29
|
+
SessionType,
|
|
29
30
|
SkillUsageRecord,
|
|
30
31
|
} from "../types.js";
|
|
31
32
|
import { CLIError, handleCLIError } from "../utils/cli-error.js";
|
|
@@ -420,6 +421,8 @@ export function buildExecutionMetrics(telemetry: SessionTelemetryRecord): Execut
|
|
|
420
421
|
errors_encountered: telemetry.errors_encountered ?? 0,
|
|
421
422
|
skills_triggered: telemetry.skills_triggered ?? [],
|
|
422
423
|
transcript_chars: telemetry.transcript_chars ?? 0,
|
|
424
|
+
artifact_count: telemetry.artifact_count,
|
|
425
|
+
session_type: telemetry.session_type,
|
|
423
426
|
};
|
|
424
427
|
}
|
|
425
428
|
|
|
@@ -481,13 +484,30 @@ export function buildGradingPrompt(
|
|
|
481
484
|
? transcriptExcerpt.slice(0, MAX_TRANSCRIPT_LENGTH)
|
|
482
485
|
: transcriptExcerpt;
|
|
483
486
|
|
|
487
|
+
const sessionType: SessionType = (telemetry.session_type as SessionType) ?? "mixed";
|
|
488
|
+
const SESSION_TYPE_CONTEXT: Record<SessionType, string> = {
|
|
489
|
+
dev: "This is a development session — code output and commits are expected productivity signals.",
|
|
490
|
+
research:
|
|
491
|
+
"This is a research session — information gathering and synthesis are the primary outputs, not code changes.",
|
|
492
|
+
content:
|
|
493
|
+
"This is a content/writing session — document creation is the primary output, not code commits.",
|
|
494
|
+
mixed:
|
|
495
|
+
"This is a mixed session — evaluate based on what was actually accomplished, not code-specific metrics.",
|
|
496
|
+
};
|
|
497
|
+
const sessionTypeContext = SESSION_TYPE_CONTEXT[sessionType] ?? SESSION_TYPE_CONTEXT.mixed;
|
|
498
|
+
|
|
484
499
|
return `Skill: ${skillName}
|
|
485
500
|
|
|
501
|
+
=== SESSION CONTEXT ===
|
|
502
|
+
Session type: ${sessionType}
|
|
503
|
+
${sessionTypeContext}
|
|
504
|
+
|
|
486
505
|
=== PROCESS TELEMETRY ===
|
|
487
506
|
Skills triggered: ${JSON.stringify(telemetry.skills_triggered ?? [])}
|
|
488
507
|
Assistant turns: ${telemetry.assistant_turns ?? "?"}
|
|
489
508
|
Errors: ${telemetry.errors_encountered ?? "?"}
|
|
490
509
|
Total tool calls: ${telemetry.total_tool_calls ?? "?"}
|
|
510
|
+
Artifacts produced: ${telemetry.artifact_count ?? "?"}
|
|
491
511
|
|
|
492
512
|
Tool breakdown:
|
|
493
513
|
${toolSummary}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* PostToolUse hook: commit-track.ts
|
|
4
|
+
*
|
|
5
|
+
* Detects git commits in Bash tool output and records commit SHA, title,
|
|
6
|
+
* branch, and session ID for session-to-commit traceability.
|
|
7
|
+
*
|
|
8
|
+
* Fail-open: exits 0 on all errors. Never blocks the host agent.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
import type { PostToolUsePayload } from "../types.js";
|
|
14
|
+
|
|
15
|
+
// -- Regex patterns (pre-compiled at module load) ----------------------------
|
|
16
|
+
|
|
17
|
+
/** Matches git commands that produce commits. */
|
|
18
|
+
const GIT_COMMIT_CMD_RE = /\bgit\s+(commit|merge|cherry-pick|revert)\b/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Matches the standard git commit output format: [branch SHA] title
|
|
22
|
+
* Supports optional parenthetical like (root-commit).
|
|
23
|
+
* Branch names can contain word chars, slashes, dots, hyphens, plus signs.
|
|
24
|
+
*/
|
|
25
|
+
const COMMIT_OUTPUT_RE = /\[([\w/.+-]+)(?:\s+\([^)]+\))?\s+([a-f0-9]{7,40})\]\s+(.+)/;
|
|
26
|
+
|
|
27
|
+
// -- Pure extraction functions (exported for testability) ---------------------
|
|
28
|
+
|
|
29
|
+
/** Check if a command string contains a git commit/merge/cherry-pick/revert. */
|
|
30
|
+
export function containsGitCommitCommand(command: string): boolean {
|
|
31
|
+
return GIT_COMMIT_CMD_RE.test(command);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Extract commit SHA from git output. */
|
|
35
|
+
export function parseCommitSha(output: string): string | undefined {
|
|
36
|
+
const match = output.match(COMMIT_OUTPUT_RE);
|
|
37
|
+
return match ? match[2] : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Extract commit title from git output. */
|
|
41
|
+
export function parseCommitTitle(output: string): string | undefined {
|
|
42
|
+
const match = output.match(COMMIT_OUTPUT_RE);
|
|
43
|
+
return match ? match[3].trim() : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extract branch name from git output. */
|
|
47
|
+
export function parseBranchFromOutput(output: string): string | undefined {
|
|
48
|
+
const match = output.match(COMMIT_OUTPUT_RE);
|
|
49
|
+
return match ? match[1] : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Scrub credentials from a remote URL. Returns undefined for empty input. */
|
|
53
|
+
export function scrubRemoteUrl(rawUrl: string): string | undefined {
|
|
54
|
+
if (!rawUrl) return undefined;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(rawUrl);
|
|
57
|
+
parsed.username = "";
|
|
58
|
+
parsed.password = "";
|
|
59
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
60
|
+
} catch {
|
|
61
|
+
// SSH or non-URL format — safe as-is
|
|
62
|
+
return rawUrl;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// -- Commit tracking record shape --------------------------------------------
|
|
67
|
+
|
|
68
|
+
export interface CommitTrackRecord {
|
|
69
|
+
session_id: string;
|
|
70
|
+
commit_sha: string;
|
|
71
|
+
commit_title?: string;
|
|
72
|
+
branch?: string;
|
|
73
|
+
repo_remote?: string;
|
|
74
|
+
timestamp: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// -- Core processing logic ---------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Process a PostToolUse payload for git commit tracking.
|
|
81
|
+
* Returns the record that was written, or null if skipped.
|
|
82
|
+
* Exported for testability.
|
|
83
|
+
*/
|
|
84
|
+
export async function processCommitTrack(
|
|
85
|
+
payload: PostToolUsePayload,
|
|
86
|
+
): Promise<CommitTrackRecord | null> {
|
|
87
|
+
// Fast-path: only care about Bash tool
|
|
88
|
+
if (payload.tool_name !== "Bash") return null;
|
|
89
|
+
|
|
90
|
+
// Fast-path: if cwd is known and not inside a git repo, skip
|
|
91
|
+
const cwd = payload.cwd ?? "";
|
|
92
|
+
if (cwd) {
|
|
93
|
+
try {
|
|
94
|
+
execSync("git rev-parse --is-inside-work-tree", {
|
|
95
|
+
cwd,
|
|
96
|
+
timeout: 1000,
|
|
97
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
return null; // Not inside a git repo
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Fast-path: check if the command is a git commit-producing operation
|
|
105
|
+
const command = typeof payload.tool_input?.command === "string" ? payload.tool_input.command : "";
|
|
106
|
+
if (!containsGitCommitCommand(command)) return null;
|
|
107
|
+
|
|
108
|
+
// Extract stdout from tool_response
|
|
109
|
+
const response = payload.tool_response ?? {};
|
|
110
|
+
const stdout = typeof response.stdout === "string" ? response.stdout : "";
|
|
111
|
+
if (!stdout) return null;
|
|
112
|
+
|
|
113
|
+
// Parse commit SHA — if we can't find one, nothing to track
|
|
114
|
+
const commitSha = parseCommitSha(stdout);
|
|
115
|
+
if (!commitSha) return null;
|
|
116
|
+
|
|
117
|
+
const commitTitle = parseCommitTitle(stdout);
|
|
118
|
+
const outputBranch = parseBranchFromOutput(stdout);
|
|
119
|
+
const sessionId = payload.session_id;
|
|
120
|
+
if (!sessionId) return null; // No session ID — skip insert (fail-open)
|
|
121
|
+
|
|
122
|
+
// Try to get branch from git if not parsed from output
|
|
123
|
+
let branch = outputBranch;
|
|
124
|
+
if (!branch && cwd) {
|
|
125
|
+
try {
|
|
126
|
+
branch =
|
|
127
|
+
execSync("git rev-parse --abbrev-ref HEAD", {
|
|
128
|
+
cwd,
|
|
129
|
+
timeout: 3000,
|
|
130
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
131
|
+
})
|
|
132
|
+
.toString()
|
|
133
|
+
.trim() || undefined;
|
|
134
|
+
} catch {
|
|
135
|
+
/* not a git repo or git not available */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Try to get remote URL (scrub credentials)
|
|
140
|
+
let repoRemote: string | undefined;
|
|
141
|
+
if (cwd) {
|
|
142
|
+
try {
|
|
143
|
+
const rawRemote =
|
|
144
|
+
execSync("git remote get-url origin", {
|
|
145
|
+
cwd,
|
|
146
|
+
timeout: 3000,
|
|
147
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
148
|
+
})
|
|
149
|
+
.toString()
|
|
150
|
+
.trim() || undefined;
|
|
151
|
+
if (rawRemote) {
|
|
152
|
+
repoRemote = scrubRemoteUrl(rawRemote);
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
/* no remote configured */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const record: CommitTrackRecord = {
|
|
160
|
+
session_id: sessionId,
|
|
161
|
+
commit_sha: commitSha,
|
|
162
|
+
commit_title: commitTitle,
|
|
163
|
+
branch,
|
|
164
|
+
repo_remote: repoRemote,
|
|
165
|
+
timestamp: new Date().toISOString(),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Write to SQLite (dynamic import to reduce hook startup cost)
|
|
169
|
+
try {
|
|
170
|
+
const { writeCommitTracking } = await import("../localdb/direct-write.js");
|
|
171
|
+
writeCommitTracking(record);
|
|
172
|
+
} catch {
|
|
173
|
+
/* hooks must never block */
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return record;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- stdin main (only when executed directly, not when imported) ---
|
|
180
|
+
if (import.meta.main) {
|
|
181
|
+
try {
|
|
182
|
+
const payload: PostToolUsePayload = JSON.parse(await Bun.stdin.text());
|
|
183
|
+
await processCommitTrack(payload);
|
|
184
|
+
} catch {
|
|
185
|
+
// silent — hooks must never block Claude
|
|
186
|
+
}
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
@@ -226,7 +226,16 @@ export async function processPrompt(
|
|
|
226
226
|
// --- stdin main (only when executed directly, not when imported) ---
|
|
227
227
|
if (import.meta.main) {
|
|
228
228
|
try {
|
|
229
|
-
const
|
|
229
|
+
const { readStdinWithPreview } = await import("./stdin-preview.js");
|
|
230
|
+
const { preview, full } = await readStdinWithPreview();
|
|
231
|
+
|
|
232
|
+
// Fast-path: prompt-log only handles UserPromptSubmit events.
|
|
233
|
+
// If the keyword is absent from the first 4 KiB we can skip JSON.parse entirely.
|
|
234
|
+
if (!preview.includes('"UserPromptSubmit"')) {
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const payload: PromptSubmitPayload = JSON.parse(full);
|
|
230
239
|
await processPrompt(payload);
|
|
231
240
|
} catch {
|
|
232
241
|
// silent — hooks must never block Claude
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { execSync } from "node:child_process";
|
|
12
12
|
import { readFileSync } from "node:fs";
|
|
13
13
|
|
|
14
|
-
import { CANONICAL_LOG,
|
|
14
|
+
import { CANONICAL_LOG, getOrchestrateLockPath, TELEMETRY_LOG } from "../constants.js";
|
|
15
15
|
import {
|
|
16
16
|
appendCanonicalRecords,
|
|
17
17
|
buildCanonicalExecutionFact,
|
|
@@ -47,7 +47,7 @@ function hasFreshOrchestrateLock(lockPath: string): boolean {
|
|
|
47
47
|
* Returns true if a process was spawned, false otherwise.
|
|
48
48
|
*/
|
|
49
49
|
export async function maybeSpawnReactiveOrchestrate(
|
|
50
|
-
lockPath: string =
|
|
50
|
+
lockPath: string = getOrchestrateLockPath(),
|
|
51
51
|
deps: ReactiveSpawnDeps = {},
|
|
52
52
|
): Promise<boolean> {
|
|
53
53
|
try {
|
|
@@ -359,7 +359,21 @@ async function processSkillToolUse(
|
|
|
359
359
|
// --- stdin main (only when executed directly, not when imported) ---
|
|
360
360
|
if (import.meta.main) {
|
|
361
361
|
try {
|
|
362
|
-
const
|
|
362
|
+
const { readStdinWithPreview } = await import("./stdin-preview.js");
|
|
363
|
+
const { preview, full } = await readStdinWithPreview();
|
|
364
|
+
|
|
365
|
+
// Fast-path: skill-eval only handles PostToolUse events.
|
|
366
|
+
if (!preview.includes('"PostToolUse"')) {
|
|
367
|
+
process.exit(0);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Secondary fast-path: only Read and Skill tools are relevant.
|
|
371
|
+
// Most PostToolUse events are for Bash/Write/Edit — skip those entirely.
|
|
372
|
+
if (!preview.includes('"Read"') && !preview.includes('"Skill"')) {
|
|
373
|
+
process.exit(0);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const payload: PostToolUsePayload = JSON.parse(full);
|
|
363
377
|
await processToolUse(payload);
|
|
364
378
|
} catch {
|
|
365
379
|
// silent — hooks must never block Claude
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared stdin preview utility for hook fast-path optimization.
|
|
3
|
+
*
|
|
4
|
+
* Reads all of stdin once, then exposes a small preview slice so callers
|
|
5
|
+
* can do cheap `.includes()` keyword checks before paying for JSON.parse().
|
|
6
|
+
* When the keyword is absent the hook can exit in <1ms.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const STDIN_PREVIEW_BYTES = 4096;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read stdin and return both a preview slice and the full text.
|
|
13
|
+
*
|
|
14
|
+
* Bun's `stdin.text()` consumes the entire stream in one call, so this is
|
|
15
|
+
* not a true streaming preview. The win comes from avoiding `JSON.parse()`
|
|
16
|
+
* entirely when the preview slice already proves the payload is irrelevant.
|
|
17
|
+
*
|
|
18
|
+
* @param previewBytes Number of leading characters to expose in `preview`.
|
|
19
|
+
* Defaults to 4096, which comfortably covers the
|
|
20
|
+
* envelope fields (`hook_event_name`, `tool_name`, etc.)
|
|
21
|
+
* of any Claude Code hook payload.
|
|
22
|
+
*/
|
|
23
|
+
export async function readStdinWithPreview(previewBytes: number = STDIN_PREVIEW_BYTES): Promise<{
|
|
24
|
+
preview: string;
|
|
25
|
+
full: string;
|
|
26
|
+
}> {
|
|
27
|
+
const raw = await Bun.stdin.text();
|
|
28
|
+
return {
|
|
29
|
+
preview: raw.slice(0, previewBytes),
|
|
30
|
+
full: raw,
|
|
31
|
+
};
|
|
32
|
+
}
|
package/cli/selftune/init.ts
CHANGED
|
@@ -45,7 +45,7 @@ import { installAgentFiles } from "./claude-agents.js";
|
|
|
45
45
|
import { CLAUDE_CODE_HOOK_KEYS, SELFTUNE_CONFIG_DIR, SELFTUNE_CONFIG_PATH } from "./constants.js";
|
|
46
46
|
import type { AgentCommandGuidance, AlphaIdentity, SelftuneConfig } from "./types.js";
|
|
47
47
|
import { CLIError, handleCLIError } from "./utils/cli-error.js";
|
|
48
|
-
import { hookKeyHasSelftuneEntry } from "./utils/hooks.js";
|
|
48
|
+
import { hookKeyHasSelftuneEntry, isSelftuneCommand } from "./utils/hooks.js";
|
|
49
49
|
import { detectAgent } from "./utils/llm-call.js";
|
|
50
50
|
|
|
51
51
|
export { installAgentFiles } from "./claude-agents.js";
|
|
@@ -229,10 +229,13 @@ const SETTINGS_SNIPPET_PATH = resolve(
|
|
|
229
229
|
*
|
|
230
230
|
* - Creates settings.json if it does not exist
|
|
231
231
|
* - Creates the hooks section if it does not exist
|
|
232
|
-
* -
|
|
233
|
-
* -
|
|
232
|
+
* - Adds hook entries for keys that don't already have a selftune entry
|
|
233
|
+
* - Updates existing selftune entries with new attributes from the snippet
|
|
234
|
+
* (e.g. `if`, `statusMessage`, `async`, `timeout`) while preserving
|
|
235
|
+
* the resolved `command` path from the existing entry
|
|
236
|
+
* - Never overwrites existing non-selftune hooks
|
|
234
237
|
*
|
|
235
|
-
* Returns the list of hook keys that were added.
|
|
238
|
+
* Returns the list of hook keys that were added or updated.
|
|
236
239
|
*/
|
|
237
240
|
export function installClaudeCodeHooks(options?: {
|
|
238
241
|
settingsPath?: string;
|
|
@@ -279,43 +282,211 @@ export function installClaudeCodeHooks(options?: {
|
|
|
279
282
|
}
|
|
280
283
|
const existingHooks = settings.hooks as Record<string, unknown[]>;
|
|
281
284
|
|
|
282
|
-
// Resolve the
|
|
285
|
+
// Resolve the package root for path substitution
|
|
286
|
+
// cliPath points to cli/selftune/index.ts → package root is two levels up
|
|
283
287
|
const cliPath = options?.cliPath;
|
|
284
|
-
const
|
|
288
|
+
const packageRoot = cliPath ? resolve(dirname(cliPath), "..", "..").replace(/\\/g, "/") : null;
|
|
285
289
|
|
|
286
|
-
const
|
|
290
|
+
const changedKeys: string[] = [];
|
|
287
291
|
|
|
288
292
|
for (const key of Object.keys(snippetHooks)) {
|
|
289
|
-
//
|
|
290
|
-
if (hookKeyHasSelftuneEntry(existingHooks, key)) {
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Get the snippet entries for this key, replacing /PATH/TO/ with actual path
|
|
293
|
+
// Get the snippet entries for this key, replacing /PATH/TO/ with actual package root
|
|
295
294
|
let entries = snippetHooks[key];
|
|
296
|
-
if (
|
|
297
|
-
// Deep clone and substitute
|
|
298
|
-
const raw = JSON.stringify(entries).replace(/\/PATH\/TO
|
|
295
|
+
if (packageRoot) {
|
|
296
|
+
// Deep clone and substitute all /PATH/TO/ references with the resolved package root
|
|
297
|
+
const raw = JSON.stringify(entries).replace(/\/PATH\/TO\//g, `${packageRoot}/`);
|
|
299
298
|
entries = JSON.parse(raw);
|
|
300
299
|
}
|
|
301
300
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
301
|
+
if (hookKeyHasSelftuneEntry(existingHooks, key)) {
|
|
302
|
+
// Key already has selftune hooks — update them in-place with new attributes
|
|
303
|
+
// while preserving non-selftune entries and the resolved command paths
|
|
304
|
+
if (updateExistingSelftuneHooks(existingHooks, key, entries)) {
|
|
305
|
+
changedKeys.push(key);
|
|
306
|
+
}
|
|
305
307
|
} else {
|
|
306
|
-
|
|
308
|
+
// No selftune entry yet — add the snippet entries
|
|
309
|
+
if (Array.isArray(existingHooks[key])) {
|
|
310
|
+
existingHooks[key] = [...existingHooks[key], ...entries];
|
|
311
|
+
} else {
|
|
312
|
+
existingHooks[key] = entries;
|
|
313
|
+
}
|
|
314
|
+
changedKeys.push(key);
|
|
307
315
|
}
|
|
308
|
-
|
|
309
|
-
addedKeys.push(key);
|
|
310
316
|
}
|
|
311
317
|
|
|
312
|
-
if (
|
|
318
|
+
if (changedKeys.length > 0) {
|
|
313
319
|
// Ensure ~/.claude/ directory exists
|
|
314
320
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
315
321
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
316
322
|
}
|
|
317
323
|
|
|
318
|
-
return
|
|
324
|
+
return changedKeys;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Update existing selftune hook entries in-place with new attributes from the snippet.
|
|
329
|
+
*
|
|
330
|
+
* For each matcher group that contains selftune hooks, replaces ALL selftune
|
|
331
|
+
* hook entries with the full set of snippet entries while:
|
|
332
|
+
* - Resolving snippet commands using the package root from existing entries
|
|
333
|
+
* - Preserving non-selftune hooks in the same matcher group
|
|
334
|
+
* - Handling N→M changes (e.g. 2 hooks expanding to 4 with Write/Edit splits)
|
|
335
|
+
*
|
|
336
|
+
* Returns true if any entries were actually modified.
|
|
337
|
+
*/
|
|
338
|
+
export function updateExistingSelftuneHooks(
|
|
339
|
+
existingHooks: Record<string, unknown[]>,
|
|
340
|
+
key: string,
|
|
341
|
+
snippetEntries: unknown[],
|
|
342
|
+
): boolean {
|
|
343
|
+
const existingArray = existingHooks[key];
|
|
344
|
+
if (!Array.isArray(existingArray)) return false;
|
|
345
|
+
|
|
346
|
+
// Collect all snippet hooks (flattened from matcher groups)
|
|
347
|
+
const allSnippetHooks: Array<Record<string, unknown>> = [];
|
|
348
|
+
for (const group of snippetEntries) {
|
|
349
|
+
if (typeof group !== "object" || group === null) continue;
|
|
350
|
+
const g = group as Record<string, unknown>;
|
|
351
|
+
const hooks = g.hooks as Array<Record<string, unknown>> | undefined;
|
|
352
|
+
if (!Array.isArray(hooks)) continue;
|
|
353
|
+
allSnippetHooks.push(...hooks);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (allSnippetHooks.length === 0) return false;
|
|
357
|
+
|
|
358
|
+
let modified = false;
|
|
359
|
+
|
|
360
|
+
for (let i = 0; i < existingArray.length; i++) {
|
|
361
|
+
const group = existingArray[i];
|
|
362
|
+
if (typeof group !== "object" || group === null) continue;
|
|
363
|
+
const g = group as Record<string, unknown>;
|
|
364
|
+
const hooks = g.hooks as Array<Record<string, unknown>> | undefined;
|
|
365
|
+
|
|
366
|
+
// Handle flat entries (direct { command: "..." } without nested hooks array).
|
|
367
|
+
// These are a legacy format from older selftune versions or manual installs.
|
|
368
|
+
if (!Array.isArray(hooks)) {
|
|
369
|
+
if (!isHookSelftune(g)) continue;
|
|
370
|
+
const pkgRoot = derivePackageRootFromCommand(typeof g.command === "string" ? g.command : "");
|
|
371
|
+
|
|
372
|
+
// Replace the flat entry with the full snippet group structure.
|
|
373
|
+
// If we can derive a package root, resolve /PATH/TO/ in the snippet.
|
|
374
|
+
// If not (e.g. "npx selftune hook ..."), use snippet entries as-is
|
|
375
|
+
// (they were already resolved by the caller if a cliPath was provided).
|
|
376
|
+
const resolvedEntries = snippetEntries.map((se) => {
|
|
377
|
+
if (!pkgRoot) return se;
|
|
378
|
+
const raw = JSON.stringify(se).replace(/\/PATH\/TO\//g, `${pkgRoot}/`);
|
|
379
|
+
return JSON.parse(raw);
|
|
380
|
+
});
|
|
381
|
+
existingArray.splice(i, 1, ...resolvedEntries);
|
|
382
|
+
modified = true;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Derive package root from the first selftune hook in this group
|
|
387
|
+
let packageRoot: string | null = null;
|
|
388
|
+
for (const hook of hooks) {
|
|
389
|
+
if (isHookSelftune(hook)) {
|
|
390
|
+
packageRoot = derivePackageRootFromCommand(
|
|
391
|
+
typeof hook.command === "string" ? hook.command : "",
|
|
392
|
+
);
|
|
393
|
+
if (packageRoot) break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Check if this group has any selftune hooks at all
|
|
398
|
+
const hasSelftuneHooks = hooks.some(isHookSelftune);
|
|
399
|
+
if (!hasSelftuneHooks) continue;
|
|
400
|
+
|
|
401
|
+
// Build resolved snippet hooks using the derived package root.
|
|
402
|
+
// If no package root was derivable (e.g. "npx selftune hook ..."),
|
|
403
|
+
// use snippet hooks as-is (already resolved by caller if cliPath was provided).
|
|
404
|
+
const resolvedSnippetHooks = allSnippetHooks.map((snippetHook) => {
|
|
405
|
+
const cmd = typeof snippetHook.command === "string" ? snippetHook.command : "";
|
|
406
|
+
const resolvedCmd = packageRoot ? cmd.replace(/\/PATH\/TO\//g, `${packageRoot}/`) : cmd;
|
|
407
|
+
return { ...snippetHook, command: resolvedCmd };
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Check if anything actually changed (compare sorted JSON for order independence)
|
|
411
|
+
const oldSelftune = hooks.filter(isHookSelftune);
|
|
412
|
+
const oldSorted = JSON.stringify(sortKeys(oldSelftune));
|
|
413
|
+
const newSorted = JSON.stringify(sortKeys(resolvedSnippetHooks));
|
|
414
|
+
if (oldSorted !== newSorted) {
|
|
415
|
+
modified = true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Rebuild hooks preserving original ordering of non-selftune entries:
|
|
419
|
+
// replace the first selftune hook with all resolved snippet hooks,
|
|
420
|
+
// remove remaining old selftune hooks, keep non-selftune hooks in place
|
|
421
|
+
const updatedHooks: Array<Record<string, unknown>> = [];
|
|
422
|
+
let selftuneInserted = false;
|
|
423
|
+
for (const hook of hooks) {
|
|
424
|
+
if (isHookSelftune(hook)) {
|
|
425
|
+
if (!selftuneInserted) {
|
|
426
|
+
// Insert all resolved snippet hooks at the position of the first selftune hook
|
|
427
|
+
updatedHooks.push(...resolvedSnippetHooks);
|
|
428
|
+
selftuneInserted = true;
|
|
429
|
+
}
|
|
430
|
+
// Skip remaining old selftune hooks (replaced by snippet set above)
|
|
431
|
+
} else {
|
|
432
|
+
updatedHooks.push(hook);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
g.hooks = updatedHooks;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return modified;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Check if a hook entry is a selftune-managed hook. Delegates to shared isSelftuneCommand. */
|
|
442
|
+
function isHookSelftune(hook: Record<string, unknown>): boolean {
|
|
443
|
+
return typeof hook.command === "string" && isSelftuneCommand(hook.command);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Sort object keys recursively for order-independent JSON comparison. */
|
|
447
|
+
function sortKeys(obj: unknown): unknown {
|
|
448
|
+
if (Array.isArray(obj)) return obj.map(sortKeys);
|
|
449
|
+
if (obj !== null && typeof obj === "object") {
|
|
450
|
+
const sorted: Record<string, unknown> = {};
|
|
451
|
+
for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
|
|
452
|
+
sorted[key] = sortKeys((obj as Record<string, unknown>)[key]);
|
|
453
|
+
}
|
|
454
|
+
return sorted;
|
|
455
|
+
}
|
|
456
|
+
return obj;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Derive the selftune package root from an existing hook command.
|
|
461
|
+
* Supports both old format ("bun run .../cli/selftune/hooks/X.ts")
|
|
462
|
+
* and new format ("node .../bin/run-hook.cjs .../cli/selftune/hooks/X.ts").
|
|
463
|
+
*
|
|
464
|
+
* Handles paths with spaces (e.g. "/Users/Alice Smith/...") and
|
|
465
|
+
* optional surrounding quotes in the command string.
|
|
466
|
+
*/
|
|
467
|
+
export function derivePackageRootFromCommand(command: string): string | null {
|
|
468
|
+
// Normalize: strip quotes, collapse backslashes (for Windows-style paths)
|
|
469
|
+
const normalized = command.replace(/["']/g, "").replace(/\\/g, "/");
|
|
470
|
+
// Split on the known directory marker and take the prefix.
|
|
471
|
+
// The command may contain the package root multiple times (e.g.
|
|
472
|
+
// "node /root/bin/run-hook.cjs /root/cli/selftune/hooks/script.ts")
|
|
473
|
+
// so we split on the LAST occurrence of the marker.
|
|
474
|
+
for (const marker of ["/cli/selftune/hooks/", "/bin/run-hook.cjs"]) {
|
|
475
|
+
const idx = normalized.lastIndexOf(marker);
|
|
476
|
+
if (idx === -1) continue;
|
|
477
|
+
// Everything before the marker is "<prefix> <package-root>" or just "<package-root>"
|
|
478
|
+
const beforeMarker = normalized.slice(0, idx);
|
|
479
|
+
// Find the start of the path: scan backwards from end for the path start.
|
|
480
|
+
// Paths start with / (Unix) or a drive letter like C:/ (Windows).
|
|
481
|
+
// The command prefix (e.g. "node " or "bun run ") precedes the path.
|
|
482
|
+
const pathMatch = beforeMarker.match(/.*\s(\/.*|[A-Za-z]:\/.*)/);
|
|
483
|
+
if (pathMatch) return pathMatch[1];
|
|
484
|
+
// No space prefix — the entire string is the path (e.g. no "node " prefix)
|
|
485
|
+
if (beforeMarker.startsWith("/") || /^[A-Za-z]:\//.test(beforeMarker)) {
|
|
486
|
+
return beforeMarker;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
319
490
|
}
|
|
320
491
|
|
|
321
492
|
// ---------------------------------------------------------------------------
|
|
@@ -615,16 +786,16 @@ export async function runInit(opts: InitOptions): Promise<SelftuneConfig> {
|
|
|
615
786
|
);
|
|
616
787
|
}
|
|
617
788
|
|
|
618
|
-
const
|
|
789
|
+
const changedHookKeys = installClaudeCodeHooks({
|
|
619
790
|
settingsPath,
|
|
620
791
|
cliPath,
|
|
621
792
|
});
|
|
622
|
-
if (
|
|
793
|
+
if (changedHookKeys.length > 0) {
|
|
623
794
|
config.hooks_installed = true;
|
|
624
795
|
// Re-write config with updated hooks_installed flag
|
|
625
796
|
writeSelftuneConfig(configPath, config);
|
|
626
797
|
console.error(
|
|
627
|
-
`[INFO] Installed ${
|
|
798
|
+
`[INFO] Installed/updated ${changedHookKeys.length} selftune hook(s) in ${settingsPath}: ${changedHookKeys.join(", ")}`,
|
|
628
799
|
);
|
|
629
800
|
} else if (!config.hooks_installed) {
|
|
630
801
|
// Re-check in case hooks were already present
|