selftune 0.2.15 → 0.2.16
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/bin/run-hook.cjs +36 -0
- package/cli/selftune/evolution/evolve.ts +24 -2
- package/cli/selftune/evolution/unblock-suggestions.ts +159 -0
- package/cli/selftune/init.ts +198 -27
- package/cli/selftune/types.ts +1 -0
- package/cli/selftune/utils/hooks.ts +12 -2
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/Workflows/AutoActivation.md +1 -1
- package/skill/settings_snippet.json +8 -8
package/bin/run-hook.cjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Hook runner — executes a TypeScript hook script via Bun.
|
|
4
|
+
*
|
|
5
|
+
* Usage: node run-hook.cjs <path-to-hook.ts>
|
|
6
|
+
*
|
|
7
|
+
* Stdin is piped through to the hook script (Claude Code sends JSON on stdin).
|
|
8
|
+
* Exit code is propagated from the hook. If bun is not found, exits 0
|
|
9
|
+
* (fail-open: hooks must never block Claude).
|
|
10
|
+
*
|
|
11
|
+
* Note: selftune hooks depend on Bun-specific APIs (Bun.stdin.text(),
|
|
12
|
+
* Bun.spawn()) and cannot run under tsx/node. The runner exists so that
|
|
13
|
+
* hook commands use `node run-hook.cjs` (universally available) as the
|
|
14
|
+
* entry point, avoiding a hard dependency on bun being in PATH for the
|
|
15
|
+
* shell that Claude Code invokes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { execFileSync } = require("child_process");
|
|
19
|
+
const hookScript = process.argv[2];
|
|
20
|
+
|
|
21
|
+
if (!hookScript) {
|
|
22
|
+
// No script specified — fail-open
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
execFileSync("bun", ["run", hookScript], { stdio: "inherit" });
|
|
28
|
+
process.exit(0);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
// Hook exited non-zero → propagate (e.g. exit 2 = block in PreToolUse)
|
|
31
|
+
if (e.status != null) {
|
|
32
|
+
process.exit(e.status);
|
|
33
|
+
}
|
|
34
|
+
// bun not found (ENOENT) — fail-open
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
selectFromFrontier,
|
|
52
52
|
} from "./pareto.js";
|
|
53
53
|
import { generateMultipleProposals, generateProposal } from "./propose-description.js";
|
|
54
|
+
import { buildUnblockSuggestions } from "./unblock-suggestions.js";
|
|
54
55
|
import type { ValidationResult } from "./validate-proposal.js";
|
|
55
56
|
import {
|
|
56
57
|
TRIGGER_CHECK_BATCH_SIZE,
|
|
@@ -1272,11 +1273,16 @@ Options:
|
|
|
1272
1273
|
...(result.descriptionQualityAfter != null
|
|
1273
1274
|
? { description_quality_after: result.descriptionQualityAfter }
|
|
1274
1275
|
: {}),
|
|
1276
|
+
...(!result.deployed
|
|
1277
|
+
? {
|
|
1278
|
+
suggestions: buildUnblockSuggestions(result, values.skill),
|
|
1279
|
+
}
|
|
1280
|
+
: {}),
|
|
1275
1281
|
};
|
|
1276
1282
|
console.log(JSON.stringify(summary, null, 2));
|
|
1277
1283
|
}
|
|
1278
1284
|
|
|
1279
|
-
// Print human-readable status to stderr so
|
|
1285
|
+
// Print human-readable status to stderr so agents always see outcome + next steps
|
|
1280
1286
|
if (!result.deployed) {
|
|
1281
1287
|
console.error(`\n[NOT DEPLOYED] ${result.reason}`);
|
|
1282
1288
|
if (result.validation && !result.validation.improved) {
|
|
@@ -1295,9 +1301,25 @@ Options:
|
|
|
1295
1301
|
` Confidence ${result.proposal.confidence.toFixed(2)} below threshold ${values.confidence ?? "0.6"}`,
|
|
1296
1302
|
);
|
|
1297
1303
|
}
|
|
1298
|
-
|
|
1304
|
+
// Targeted suggestions based on specific failure reason
|
|
1305
|
+
const suggestions = buildUnblockSuggestions(result, values.skill);
|
|
1306
|
+
if (suggestions.length > 0) {
|
|
1307
|
+
console.error("\n Next steps:");
|
|
1308
|
+
for (const s of suggestions) {
|
|
1309
|
+
console.error(` → ${s}`);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1299
1312
|
} else {
|
|
1300
1313
|
console.error(`\n[DEPLOYED] ${result.reason}`);
|
|
1314
|
+
// Show quality improvement if available
|
|
1315
|
+
if (result.descriptionQualityBefore != null && result.descriptionQualityAfter != null) {
|
|
1316
|
+
const delta = result.descriptionQualityAfter - result.descriptionQualityBefore;
|
|
1317
|
+
if (delta !== 0) {
|
|
1318
|
+
console.error(
|
|
1319
|
+
` Description quality: ${Math.round(result.descriptionQualityBefore * 100)}% → ${Math.round(result.descriptionQualityAfter * 100)}% (${delta >= 0 ? "+" : ""}${Math.round(delta * 100)}%)`,
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1301
1323
|
}
|
|
1302
1324
|
|
|
1303
1325
|
process.exit(result.deployed ? 0 : 1);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unblock-suggestions.ts
|
|
3
|
+
*
|
|
4
|
+
* Generates targeted, per-failure-reason suggestions when evolve doesn't deploy.
|
|
5
|
+
* Each suggestion is a concrete next CLI command or manual action that helps the
|
|
6
|
+
* agent (or user) unblock the evolution pipeline.
|
|
7
|
+
*
|
|
8
|
+
* Pure function — no I/O, no LLM calls. Depends only on EvolveResult fields and
|
|
9
|
+
* the scoreDescription heuristic.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { scoreDescription } from "./description-quality.js";
|
|
13
|
+
import type { EvolveResult } from "./evolve.js";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Quality hint helper
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Append description quality improvement hints if the score reveals weak criteria.
|
|
21
|
+
* Only fires when composite < 0.7 to avoid noise on already-good descriptions.
|
|
22
|
+
* Skips when descriptionText is empty (no proposal was generated).
|
|
23
|
+
*/
|
|
24
|
+
function appendQualityHints(
|
|
25
|
+
suggestions: string[],
|
|
26
|
+
descriptionText: string,
|
|
27
|
+
skillName: string,
|
|
28
|
+
): void {
|
|
29
|
+
if (!descriptionText) return;
|
|
30
|
+
const score = scoreDescription(descriptionText, skillName);
|
|
31
|
+
if (score.composite >= 0.7) return;
|
|
32
|
+
|
|
33
|
+
const weak: string[] = [];
|
|
34
|
+
if (score.criteria.trigger_context < 0.5) weak.push("add when/if/after trigger context");
|
|
35
|
+
if (score.criteria.vagueness < 0.7) weak.push("remove vague words (various, general, etc)");
|
|
36
|
+
if (score.criteria.specificity < 0.5) weak.push("add concrete action verbs");
|
|
37
|
+
if (score.criteria.length < 0.7) weak.push("adjust length (ideal: 80-300 chars)");
|
|
38
|
+
if (score.criteria.not_just_name < 0.5) weak.push("differentiate from skill name");
|
|
39
|
+
|
|
40
|
+
if (weak.length > 0) {
|
|
41
|
+
suggestions.push(
|
|
42
|
+
`Description quality: ${Math.round(score.composite * 100)}% — improve by: ${weak.join(", ")}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Main suggestion builder
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate targeted suggestions based on the specific failure reason.
|
|
53
|
+
* Each suggestion is a concrete next CLI command or manual action.
|
|
54
|
+
*/
|
|
55
|
+
export function buildUnblockSuggestions(result: EvolveResult, skillName: string): string[] {
|
|
56
|
+
const reason = result.reason;
|
|
57
|
+
const suggestions: string[] = [];
|
|
58
|
+
const descText = result.proposal?.original_description ?? "";
|
|
59
|
+
|
|
60
|
+
// --- Path/config failures ---
|
|
61
|
+
if (reason.includes("SKILL.md not found")) {
|
|
62
|
+
suggestions.push("Verify the --skill-path flag points to a valid SKILL.md file");
|
|
63
|
+
suggestions.push("Run: selftune init (to re-bootstrap config if paths changed)");
|
|
64
|
+
return suggestions;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (reason.includes("Failed to load eval set") || reason.includes("not a JSON array")) {
|
|
68
|
+
suggestions.push("Run: selftune sync (to rebuild source-truth telemetry)");
|
|
69
|
+
suggestions.push(`Then: selftune evolve --skill ${skillName} (to retry with fresh evals)`);
|
|
70
|
+
return suggestions;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- No signal failures ---
|
|
74
|
+
if (reason.includes("No failure patterns found")) {
|
|
75
|
+
suggestions.push("This skill may already be routing well — check: selftune status");
|
|
76
|
+
suggestions.push("If undertriggering, add more sessions so evolve has signal to work with");
|
|
77
|
+
if (result.descriptionQualityBefore != null && result.descriptionQualityBefore < 0.5) {
|
|
78
|
+
suggestions.push(
|
|
79
|
+
`Description quality is ${Math.round(result.descriptionQualityBefore * 100)}% — manually improving the description may help generate patterns`,
|
|
80
|
+
);
|
|
81
|
+
appendQualityHints(suggestions, descText, skillName);
|
|
82
|
+
}
|
|
83
|
+
return suggestions;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Confidence failures (specific before general) ---
|
|
87
|
+
if (reason.includes("No candidates met confidence")) {
|
|
88
|
+
suggestions.push(`Lower the threshold: selftune evolve --skill ${skillName} --confidence 0.4`);
|
|
89
|
+
suggestions.push(
|
|
90
|
+
`Or increase candidates: selftune evolve --skill ${skillName} --pareto --candidates 5`,
|
|
91
|
+
);
|
|
92
|
+
appendQualityHints(suggestions, descText, skillName);
|
|
93
|
+
return suggestions;
|
|
94
|
+
}
|
|
95
|
+
if (reason.toLowerCase().includes("confidence") && reason.includes("threshold")) {
|
|
96
|
+
suggestions.push(`Lower the threshold: selftune evolve --skill ${skillName} --confidence 0.4`);
|
|
97
|
+
suggestions.push("Or add more eval entries so the LLM has more context for proposals");
|
|
98
|
+
appendQualityHints(suggestions, descText, skillName);
|
|
99
|
+
return suggestions;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Validation failures (proposals regressed) ---
|
|
103
|
+
if (reason.includes("Validation failed after")) {
|
|
104
|
+
suggestions.push(
|
|
105
|
+
`The eval set may be contradictory — review with: selftune evolve --skill ${skillName} --verbose`,
|
|
106
|
+
);
|
|
107
|
+
suggestions.push(
|
|
108
|
+
`Try: selftune evolve --skill ${skillName} --pareto --candidates 5 (more diverse proposals)`,
|
|
109
|
+
);
|
|
110
|
+
if (result.validation && result.validation.regressions.length > 0) {
|
|
111
|
+
suggestions.push(
|
|
112
|
+
`${result.validation.regressions.length} regressions detected — check if negative eval entries are too broad`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
appendQualityHints(suggestions, descText, skillName);
|
|
116
|
+
return suggestions;
|
|
117
|
+
}
|
|
118
|
+
if (reason.includes("No Pareto candidates improved")) {
|
|
119
|
+
suggestions.push("All candidates regressed — the eval set may need rebalancing");
|
|
120
|
+
suggestions.push(`Try: selftune sync --force && selftune evolve --skill ${skillName}`);
|
|
121
|
+
return suggestions;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- Gate failures ---
|
|
125
|
+
if (reason.includes("Baseline gate failed")) {
|
|
126
|
+
suggestions.push("Improvement was too marginal to justify deployment");
|
|
127
|
+
suggestions.push("Collect more session data, then retry — small gains compound over time");
|
|
128
|
+
return suggestions;
|
|
129
|
+
}
|
|
130
|
+
if (reason.includes("Gate validation failed")) {
|
|
131
|
+
suggestions.push("The gate model rejected the proposal — it may be too aggressive");
|
|
132
|
+
suggestions.push(
|
|
133
|
+
`Try: selftune evolve --skill ${skillName} --full-model (disables cheap-loop gate)`,
|
|
134
|
+
);
|
|
135
|
+
return suggestions;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Constitutional rejection ---
|
|
139
|
+
if (reason.includes("Constitutional")) {
|
|
140
|
+
suggestions.push("The proposed description violated safety constraints");
|
|
141
|
+
suggestions.push("Review constitutional rules and manually adjust the description if needed");
|
|
142
|
+
return suggestions;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Dry run (not really a failure) ---
|
|
146
|
+
if (reason.includes("Dry run")) {
|
|
147
|
+
suggestions.push(`Deploy: selftune evolve --skill ${skillName} (remove --dry-run to deploy)`);
|
|
148
|
+
return suggestions;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Catch-all for unexpected errors ---
|
|
152
|
+
if (reason.includes("Error during evolution")) {
|
|
153
|
+
suggestions.push("Re-run with --verbose for full stack trace");
|
|
154
|
+
suggestions.push("Run: selftune doctor (to check system health)");
|
|
155
|
+
return suggestions;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return suggestions;
|
|
159
|
+
}
|
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
|
package/cli/selftune/types.ts
CHANGED
|
@@ -13,14 +13,24 @@ function isHookEntry(value: unknown): value is ClaudeCodeHookEntry {
|
|
|
13
13
|
return typeof value === "object" && value !== null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** Check if a command string references a selftune-managed hook. */
|
|
17
|
+
export function isSelftuneCommand(command: string): boolean {
|
|
18
|
+
const normalized = command.replace(/\\/g, "/");
|
|
19
|
+
return (
|
|
20
|
+
normalized.includes("/cli/selftune/hooks/") ||
|
|
21
|
+
normalized.includes("/bin/run-hook.cjs") ||
|
|
22
|
+
normalized.startsWith("npx selftune hook ")
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
export function entryReferencesSelftune(entry: ClaudeCodeHookEntry): boolean {
|
|
17
|
-
if (typeof entry.command === "string" && entry.command
|
|
27
|
+
if (typeof entry.command === "string" && isSelftuneCommand(entry.command)) {
|
|
18
28
|
return true;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
if (Array.isArray(entry.hooks)) {
|
|
22
32
|
return entry.hooks.some(
|
|
23
|
-
(hook) => typeof hook.command === "string" && hook.command
|
|
33
|
+
(hook) => typeof hook.command === "string" && isSelftuneCommand(hook.command),
|
|
24
34
|
);
|
|
25
35
|
}
|
|
26
36
|
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -110,7 +110,7 @@ The hook is registered under `UserPromptSubmit`:
|
|
|
110
110
|
"hooks": {
|
|
111
111
|
"UserPromptSubmit": [
|
|
112
112
|
{
|
|
113
|
-
"command": "
|
|
113
|
+
"command": "node /path/to/bin/run-hook.cjs /path/to/cli/selftune/hooks/auto-activate.ts"
|
|
114
114
|
}
|
|
115
115
|
]
|
|
116
116
|
}
|
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
"hooks": [
|
|
10
10
|
{
|
|
11
11
|
"type": "command",
|
|
12
|
-
"command": "
|
|
12
|
+
"command": "node /PATH/TO/bin/run-hook.cjs /PATH/TO/cli/selftune/hooks/prompt-log.ts",
|
|
13
13
|
"timeout": 5,
|
|
14
14
|
"statusMessage": "selftune: logging prompt"
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
"type": "command",
|
|
18
|
-
"command": "
|
|
18
|
+
"command": "node /PATH/TO/bin/run-hook.cjs /PATH/TO/cli/selftune/hooks/auto-activate.ts",
|
|
19
19
|
"timeout": 5,
|
|
20
20
|
"statusMessage": "selftune: checking activation rules"
|
|
21
21
|
}
|
|
@@ -29,28 +29,28 @@
|
|
|
29
29
|
{
|
|
30
30
|
"type": "command",
|
|
31
31
|
"if": "Write(*SKILL.md)",
|
|
32
|
-
"command": "
|
|
32
|
+
"command": "node /PATH/TO/bin/run-hook.cjs /PATH/TO/cli/selftune/hooks/skill-change-guard.ts",
|
|
33
33
|
"timeout": 5,
|
|
34
34
|
"statusMessage": "selftune: checking skill change guard"
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"type": "command",
|
|
38
38
|
"if": "Edit(*SKILL.md)",
|
|
39
|
-
"command": "
|
|
39
|
+
"command": "node /PATH/TO/bin/run-hook.cjs /PATH/TO/cli/selftune/hooks/skill-change-guard.ts",
|
|
40
40
|
"timeout": 5,
|
|
41
41
|
"statusMessage": "selftune: checking skill change guard"
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
"type": "command",
|
|
45
45
|
"if": "Write(*SKILL.md)",
|
|
46
|
-
"command": "
|
|
46
|
+
"command": "node /PATH/TO/bin/run-hook.cjs /PATH/TO/cli/selftune/hooks/evolution-guard.ts",
|
|
47
47
|
"timeout": 5,
|
|
48
48
|
"statusMessage": "selftune: checking evolution guard"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"type": "command",
|
|
52
52
|
"if": "Edit(*SKILL.md)",
|
|
53
|
-
"command": "
|
|
53
|
+
"command": "node /PATH/TO/bin/run-hook.cjs /PATH/TO/cli/selftune/hooks/evolution-guard.ts",
|
|
54
54
|
"timeout": 5,
|
|
55
55
|
"statusMessage": "selftune: checking evolution guard"
|
|
56
56
|
}
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"hooks": [
|
|
64
64
|
{
|
|
65
65
|
"type": "command",
|
|
66
|
-
"command": "
|
|
66
|
+
"command": "node /PATH/TO/bin/run-hook.cjs /PATH/TO/cli/selftune/hooks/skill-eval.ts",
|
|
67
67
|
"timeout": 5,
|
|
68
68
|
"statusMessage": "selftune: evaluating skill usage"
|
|
69
69
|
}
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"hooks": [
|
|
76
76
|
{
|
|
77
77
|
"type": "command",
|
|
78
|
-
"command": "
|
|
78
|
+
"command": "node /PATH/TO/bin/run-hook.cjs /PATH/TO/cli/selftune/hooks/session-stop.ts",
|
|
79
79
|
"timeout": 60,
|
|
80
80
|
"async": true,
|
|
81
81
|
"statusMessage": "selftune: capturing session telemetry"
|