selftune 0.2.20 → 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/evolution/evolve-body.ts +26 -2
- package/cli/selftune/evolution/validate-host-replay.ts +390 -2
- 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/Evolve.md +22 -6
- package/skill/Workflows/Initialize.md +48 -6
- package/skill/Workflows/PlatformHooks.md +93 -0
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
3
14
|
|
|
4
15
|
import type { EvalEntry, RoutingReplayEntryResult, RoutingReplayFixture } from "../types.js";
|
|
5
16
|
import { parseFrontmatter } from "../utils/frontmatter.js";
|
|
@@ -10,6 +21,7 @@ import {
|
|
|
10
21
|
jaccardSimilarity,
|
|
11
22
|
tokenizeText,
|
|
12
23
|
} from "../utils/text-similarity.js";
|
|
24
|
+
import { replaceSection } from "./deploy-proposal.js";
|
|
13
25
|
|
|
14
26
|
interface ReplaySkillSurface {
|
|
15
27
|
skillName: string;
|
|
@@ -17,12 +29,41 @@ interface ReplaySkillSurface {
|
|
|
17
29
|
whenToUseTokens: Set<string>;
|
|
18
30
|
}
|
|
19
31
|
|
|
32
|
+
interface ReplayWorkspace {
|
|
33
|
+
rootDir: string;
|
|
34
|
+
targetSkillPath: string;
|
|
35
|
+
competingSkillPaths: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ClaudeRuntimeReplayInvokerInput {
|
|
39
|
+
query: string;
|
|
40
|
+
workspaceRoot: string;
|
|
41
|
+
targetSkillName: string;
|
|
42
|
+
targetSkillPath: string;
|
|
43
|
+
competingSkillPaths: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ClaudeRuntimeReplayObservation {
|
|
47
|
+
invokedSkillNames: string[];
|
|
48
|
+
readSkillPaths: string[];
|
|
49
|
+
rawOutput: string;
|
|
50
|
+
sessionId?: string;
|
|
51
|
+
runtimeError?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ClaudeRuntimeReplayInvoker = (
|
|
55
|
+
input: ClaudeRuntimeReplayInvokerInput,
|
|
56
|
+
) => Promise<ClaudeRuntimeReplayObservation>;
|
|
57
|
+
|
|
20
58
|
/**
|
|
21
59
|
* Minimum score needed before replay treats routing text or skill-surface overlap
|
|
22
60
|
* as a real match. Tuned to suppress weak false positives without killing recall
|
|
23
61
|
* for short routing phrases and sparse skill surfaces.
|
|
24
62
|
*/
|
|
25
63
|
const HOST_REPLAY_MATCH_THRESHOLD = 0.18;
|
|
64
|
+
const CLAUDE_RUNTIME_REPLAY_TIMEOUT_MS = 30_000;
|
|
65
|
+
const CLAUDE_RUNTIME_ROUTING_PROMPT =
|
|
66
|
+
"You are being evaluated only on skill routing. Do not solve the user's task. If a local project skill is relevant, invoke exactly one skill immediately. If no local project skill fits, respond with NO_SKILL and do not browse unrelated files.";
|
|
26
67
|
|
|
27
68
|
function resolveReplayPath(path: string): string {
|
|
28
69
|
try {
|
|
@@ -32,6 +73,10 @@ function resolveReplayPath(path: string): string {
|
|
|
32
73
|
}
|
|
33
74
|
}
|
|
34
75
|
|
|
76
|
+
function resolveObservedReplayPath(path: string, workspaceRoot: string): string {
|
|
77
|
+
return resolveReplayPath(isAbsolute(path) ? path : join(workspaceRoot, path));
|
|
78
|
+
}
|
|
79
|
+
|
|
35
80
|
function listCompetingSkillPaths(targetSkillPath: string): string[] {
|
|
36
81
|
const normalizedTargetPath = resolveReplayPath(targetSkillPath);
|
|
37
82
|
const targetSkillDir = dirname(normalizedTargetPath);
|
|
@@ -82,6 +127,304 @@ export function buildRoutingReplayFixture(options: {
|
|
|
82
127
|
};
|
|
83
128
|
}
|
|
84
129
|
|
|
130
|
+
function buildRuntimeReplayTargetContent(skillPath: string, routing: string): string {
|
|
131
|
+
const currentContent = readFileSync(skillPath, "utf8");
|
|
132
|
+
return replaceSection(currentContent, "Workflow Routing", routing.trim());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stageReplaySkill(
|
|
136
|
+
registryDir: string,
|
|
137
|
+
sourceSkillPath: string,
|
|
138
|
+
overrideContent?: string,
|
|
139
|
+
): string {
|
|
140
|
+
const skillDirName = basename(dirname(sourceSkillPath)) || "unknown-skill";
|
|
141
|
+
const destinationDir = join(registryDir, skillDirName);
|
|
142
|
+
mkdirSync(destinationDir, { recursive: true });
|
|
143
|
+
const destinationPath = join(destinationDir, "SKILL.md");
|
|
144
|
+
const content = overrideContent ?? readFileSync(sourceSkillPath, "utf8");
|
|
145
|
+
writeFileSync(destinationPath, content, "utf8");
|
|
146
|
+
return destinationPath;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildRuntimeReplayWorkspace(
|
|
150
|
+
fixture: RoutingReplayFixture,
|
|
151
|
+
routing: string,
|
|
152
|
+
): ReplayWorkspace {
|
|
153
|
+
const rootDir = mkdtempSync(join(tmpdir(), "selftune-runtime-replay-"));
|
|
154
|
+
try {
|
|
155
|
+
const registryDir = join(rootDir, ".claude", "skills");
|
|
156
|
+
mkdirSync(join(rootDir, ".git"), { recursive: true });
|
|
157
|
+
mkdirSync(registryDir, { recursive: true });
|
|
158
|
+
|
|
159
|
+
const targetSkillPath = stageReplaySkill(
|
|
160
|
+
registryDir,
|
|
161
|
+
fixture.target_skill_path,
|
|
162
|
+
buildRuntimeReplayTargetContent(fixture.target_skill_path, routing),
|
|
163
|
+
);
|
|
164
|
+
const competingSkillPaths = fixture.competing_skill_paths.map((skillPath) =>
|
|
165
|
+
stageReplaySkill(registryDir, skillPath),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
rootDir,
|
|
170
|
+
targetSkillPath,
|
|
171
|
+
competingSkillPaths,
|
|
172
|
+
};
|
|
173
|
+
} catch (error) {
|
|
174
|
+
rmSync(rootDir, { recursive: true, force: true });
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function cleanupRuntimeReplayWorkspace(workspace: ReplayWorkspace): void {
|
|
180
|
+
rmSync(workspace.rootDir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseClaudeRuntimeReplayOutput(rawOutput: string): ClaudeRuntimeReplayObservation {
|
|
184
|
+
const invokedSkillNames = new Set<string>();
|
|
185
|
+
const readSkillPaths = new Set<string>();
|
|
186
|
+
let sessionId: string | undefined;
|
|
187
|
+
let runtimeError: string | undefined;
|
|
188
|
+
|
|
189
|
+
for (const line of rawOutput.split("\n")) {
|
|
190
|
+
const trimmed = line.trim();
|
|
191
|
+
if (!trimmed) continue;
|
|
192
|
+
|
|
193
|
+
let parsed: Record<string, unknown>;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(trimmed);
|
|
196
|
+
} catch {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const maybeSessionId = parsed.session_id;
|
|
201
|
+
if (typeof maybeSessionId === "string" && maybeSessionId) {
|
|
202
|
+
sessionId = maybeSessionId;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof parsed.error === "string" && parsed.error) {
|
|
206
|
+
runtimeError = parsed.error;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const assistantMessage =
|
|
210
|
+
parsed.type === "assistant" && typeof parsed.message === "object" && parsed.message !== null
|
|
211
|
+
? (parsed.message as Record<string, unknown>)
|
|
212
|
+
: undefined;
|
|
213
|
+
const content = assistantMessage?.content;
|
|
214
|
+
if (!Array.isArray(content)) continue;
|
|
215
|
+
|
|
216
|
+
for (const block of content) {
|
|
217
|
+
if (typeof block !== "object" || block === null) continue;
|
|
218
|
+
const typedBlock = block as Record<string, unknown>;
|
|
219
|
+
if (typedBlock.type !== "tool_use") continue;
|
|
220
|
+
|
|
221
|
+
const toolName = typedBlock.name;
|
|
222
|
+
const input =
|
|
223
|
+
typeof typedBlock.input === "object" && typedBlock.input !== null
|
|
224
|
+
? (typedBlock.input as Record<string, unknown>)
|
|
225
|
+
: {};
|
|
226
|
+
|
|
227
|
+
if (toolName === "Skill") {
|
|
228
|
+
const skillName = input.skill;
|
|
229
|
+
if (typeof skillName === "string" && skillName.trim()) {
|
|
230
|
+
invokedSkillNames.add(skillName.trim());
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (toolName === "Read") {
|
|
235
|
+
const filePath = input.file_path;
|
|
236
|
+
if (typeof filePath === "string" && filePath.trim()) {
|
|
237
|
+
readSkillPaths.add(resolveReplayPath(filePath.trim()));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
invokedSkillNames: [...invokedSkillNames],
|
|
245
|
+
readSkillPaths: [...readSkillPaths],
|
|
246
|
+
rawOutput,
|
|
247
|
+
...(sessionId ? { sessionId } : {}),
|
|
248
|
+
...(runtimeError ? { runtimeError } : {}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function invokeClaudeRuntimeReplay(
|
|
253
|
+
input: ClaudeRuntimeReplayInvokerInput,
|
|
254
|
+
): Promise<ClaudeRuntimeReplayObservation> {
|
|
255
|
+
const command = [
|
|
256
|
+
"claude",
|
|
257
|
+
"-p",
|
|
258
|
+
"--verbose",
|
|
259
|
+
"--output-format",
|
|
260
|
+
"stream-json",
|
|
261
|
+
"--dangerously-skip-permissions",
|
|
262
|
+
"--no-session-persistence",
|
|
263
|
+
"--setting-sources",
|
|
264
|
+
"project,local",
|
|
265
|
+
"--tools",
|
|
266
|
+
"Skill,Read",
|
|
267
|
+
"--max-turns",
|
|
268
|
+
"1",
|
|
269
|
+
"--append-system-prompt",
|
|
270
|
+
CLAUDE_RUNTIME_ROUTING_PROMPT,
|
|
271
|
+
input.query,
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
const proc = Bun.spawn(command, {
|
|
275
|
+
cwd: input.workspaceRoot,
|
|
276
|
+
stdout: "pipe",
|
|
277
|
+
stderr: "pipe",
|
|
278
|
+
env: { ...process.env, CLAUDECODE: "" },
|
|
279
|
+
});
|
|
280
|
+
const timeout = setTimeout(() => proc.kill(), CLAUDE_RUNTIME_REPLAY_TIMEOUT_MS);
|
|
281
|
+
|
|
282
|
+
const [stdoutText, stderrText, exitCode] = await Promise.all([
|
|
283
|
+
new Response(proc.stdout).text(),
|
|
284
|
+
new Response(proc.stderr).text(),
|
|
285
|
+
proc.exited,
|
|
286
|
+
]);
|
|
287
|
+
clearTimeout(timeout);
|
|
288
|
+
|
|
289
|
+
const observation = parseClaudeRuntimeReplayOutput(stdoutText);
|
|
290
|
+
const combinedError = [observation.runtimeError, stderrText.trim()].filter(Boolean).join(" | ");
|
|
291
|
+
const hasRoutingSignal =
|
|
292
|
+
observation.invokedSkillNames.length > 0 || observation.readSkillPaths.length > 0;
|
|
293
|
+
|
|
294
|
+
if (exitCode !== 0 && !hasRoutingSignal) {
|
|
295
|
+
throw new Error(combinedError || `claude runtime replay exited with code ${exitCode}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
...observation,
|
|
300
|
+
...(combinedError ? { runtimeError: combinedError } : {}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function prefixReplayEvidence(
|
|
305
|
+
results: RoutingReplayEntryResult[],
|
|
306
|
+
prefix: string,
|
|
307
|
+
): RoutingReplayEntryResult[] {
|
|
308
|
+
return results.map((result) => ({
|
|
309
|
+
...result,
|
|
310
|
+
evidence: result.evidence ? `${prefix}; ${result.evidence}` : prefix,
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function evaluateRuntimeReplayObservation(
|
|
315
|
+
entry: EvalEntry,
|
|
316
|
+
fixture: RoutingReplayFixture,
|
|
317
|
+
observation: ClaudeRuntimeReplayObservation,
|
|
318
|
+
workspace: ReplayWorkspace,
|
|
319
|
+
): RoutingReplayEntryResult {
|
|
320
|
+
const normalizedReadPaths = new Set(
|
|
321
|
+
observation.readSkillPaths.map((path) => resolveObservedReplayPath(path, workspace.rootDir)),
|
|
322
|
+
);
|
|
323
|
+
const allowedReadPaths = new Set([
|
|
324
|
+
resolveReplayPath(workspace.targetSkillPath),
|
|
325
|
+
...workspace.competingSkillPaths.map(resolveReplayPath),
|
|
326
|
+
]);
|
|
327
|
+
const targetSkillName = fixture.target_skill_name.trim();
|
|
328
|
+
const targetInvoked = observation.invokedSkillNames.includes(targetSkillName);
|
|
329
|
+
const competingInvoked = observation.invokedSkillNames.find((skillName) =>
|
|
330
|
+
fixture.competing_skill_paths.some(
|
|
331
|
+
(skillPath) => basename(dirname(skillPath)).trim() === skillName.trim(),
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
const unrelatedInvoked = observation.invokedSkillNames.find(
|
|
335
|
+
(skillName) => skillName.trim() !== targetSkillName && skillName.trim() !== competingInvoked,
|
|
336
|
+
);
|
|
337
|
+
const unrelatedReadPaths = [...normalizedReadPaths].filter((path) => !allowedReadPaths.has(path));
|
|
338
|
+
const targetRead = normalizedReadPaths.has(resolveReplayPath(workspace.targetSkillPath));
|
|
339
|
+
const competingRead = workspace.competingSkillPaths.find((skillPath) =>
|
|
340
|
+
normalizedReadPaths.has(resolveReplayPath(skillPath)),
|
|
341
|
+
);
|
|
342
|
+
const sessionPrefix = observation.sessionId
|
|
343
|
+
? `runtime replay session ${observation.sessionId}`
|
|
344
|
+
: "runtime replay";
|
|
345
|
+
if (observation.invokedSkillNames.length > 1) {
|
|
346
|
+
return {
|
|
347
|
+
query: entry.query,
|
|
348
|
+
should_trigger: entry.should_trigger,
|
|
349
|
+
triggered: false,
|
|
350
|
+
passed: false,
|
|
351
|
+
evidence: `${sessionPrefix} invoked multiple skills: ${observation.invokedSkillNames.join(", ")}`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (targetInvoked) {
|
|
356
|
+
return {
|
|
357
|
+
query: entry.query,
|
|
358
|
+
should_trigger: entry.should_trigger,
|
|
359
|
+
triggered: true,
|
|
360
|
+
passed: entry.should_trigger,
|
|
361
|
+
evidence: `${sessionPrefix} invoked target skill: ${targetSkillName}`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (competingInvoked) {
|
|
366
|
+
return {
|
|
367
|
+
query: entry.query,
|
|
368
|
+
should_trigger: entry.should_trigger,
|
|
369
|
+
triggered: false,
|
|
370
|
+
passed: !entry.should_trigger,
|
|
371
|
+
evidence: `${sessionPrefix} invoked competing skill: ${competingInvoked}`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (unrelatedInvoked) {
|
|
376
|
+
return {
|
|
377
|
+
query: entry.query,
|
|
378
|
+
should_trigger: entry.should_trigger,
|
|
379
|
+
triggered: false,
|
|
380
|
+
passed: false,
|
|
381
|
+
evidence: `${sessionPrefix} invoked unrelated skill: ${unrelatedInvoked}`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (unrelatedReadPaths.length > 0) {
|
|
386
|
+
return {
|
|
387
|
+
query: entry.query,
|
|
388
|
+
should_trigger: entry.should_trigger,
|
|
389
|
+
triggered: false,
|
|
390
|
+
passed: false,
|
|
391
|
+
evidence: `${sessionPrefix} read files outside staged skill set: ${unrelatedReadPaths.join(", ")}`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (targetRead) {
|
|
396
|
+
return {
|
|
397
|
+
query: entry.query,
|
|
398
|
+
should_trigger: entry.should_trigger,
|
|
399
|
+
triggered: false,
|
|
400
|
+
passed: !entry.should_trigger,
|
|
401
|
+
evidence: `${sessionPrefix} only read the target skill without invoking it`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (competingRead) {
|
|
406
|
+
return {
|
|
407
|
+
query: entry.query,
|
|
408
|
+
should_trigger: entry.should_trigger,
|
|
409
|
+
triggered: false,
|
|
410
|
+
passed: !entry.should_trigger,
|
|
411
|
+
evidence: `${sessionPrefix} only read a competing skill without invoking it`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (observation.runtimeError) {
|
|
416
|
+
throw new Error(`${sessionPrefix} did not reach a skill decision: ${observation.runtimeError}`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
query: entry.query,
|
|
421
|
+
should_trigger: entry.should_trigger,
|
|
422
|
+
triggered: false,
|
|
423
|
+
passed: !entry.should_trigger,
|
|
424
|
+
evidence: `${sessionPrefix} did not invoke any local project skill`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
85
428
|
function loadReplaySkillSurface(skillPath: string): ReplaySkillSurface {
|
|
86
429
|
const fallbackName = basename(dirname(skillPath)) || "unknown-skill";
|
|
87
430
|
try {
|
|
@@ -234,3 +577,48 @@ export function runHostReplayFixture(options: {
|
|
|
234
577
|
};
|
|
235
578
|
});
|
|
236
579
|
}
|
|
580
|
+
|
|
581
|
+
export async function runClaudeRuntimeReplayFixture(options: {
|
|
582
|
+
routing: string;
|
|
583
|
+
evalSet: EvalEntry[];
|
|
584
|
+
fixture: RoutingReplayFixture;
|
|
585
|
+
runtimeInvoker?: ClaudeRuntimeReplayInvoker;
|
|
586
|
+
}): Promise<RoutingReplayEntryResult[]> {
|
|
587
|
+
const fallbackReason = (reason: string) =>
|
|
588
|
+
`runtime replay unavailable; fell back to fixture simulation (${reason})`;
|
|
589
|
+
|
|
590
|
+
if (options.fixture.platform !== "claude_code") {
|
|
591
|
+
return prefixReplayEvidence(
|
|
592
|
+
runHostReplayFixture(options),
|
|
593
|
+
fallbackReason(`unsupported platform ${options.fixture.platform}`),
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const invokeRuntime = options.runtimeInvoker ?? invokeClaudeRuntimeReplay;
|
|
598
|
+
let workspace: ReplayWorkspace | undefined;
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
workspace = buildRuntimeReplayWorkspace(options.fixture, options.routing);
|
|
602
|
+
const results: RoutingReplayEntryResult[] = [];
|
|
603
|
+
|
|
604
|
+
for (const entry of options.evalSet) {
|
|
605
|
+
const observation = await invokeRuntime({
|
|
606
|
+
query: entry.query,
|
|
607
|
+
workspaceRoot: workspace.rootDir,
|
|
608
|
+
targetSkillName: options.fixture.target_skill_name,
|
|
609
|
+
targetSkillPath: workspace.targetSkillPath,
|
|
610
|
+
competingSkillPaths: workspace.competingSkillPaths,
|
|
611
|
+
});
|
|
612
|
+
results.push(
|
|
613
|
+
evaluateRuntimeReplayObservation(entry, options.fixture, observation, workspace),
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return results;
|
|
618
|
+
} catch (error) {
|
|
619
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
620
|
+
return prefixReplayEvidence(runHostReplayFixture(options), fallbackReason(message));
|
|
621
|
+
} finally {
|
|
622
|
+
if (workspace) cleanupRuntimeReplayWorkspace(workspace);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
@@ -147,6 +147,37 @@ export function evaluateRules(
|
|
|
147
147
|
return suggestions;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Reusable auto-activate orchestration
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Evaluate activation rules for a session and return suggestion strings.
|
|
156
|
+
* Checks PAI coexistence and session state dedup internally.
|
|
157
|
+
* Returns an empty array when PAI is active or no rules fire.
|
|
158
|
+
*/
|
|
159
|
+
export async function processAutoActivate(
|
|
160
|
+
sessionId: string,
|
|
161
|
+
settingsPath?: string,
|
|
162
|
+
): Promise<string[]> {
|
|
163
|
+
// Only check PAI coexistence when a settings path is provided (platform-specific)
|
|
164
|
+
if (settingsPath && checkPaiCoexistence(settingsPath)) return [];
|
|
165
|
+
|
|
166
|
+
const { DEFAULT_RULES } = await import("../activation-rules.js");
|
|
167
|
+
|
|
168
|
+
const ctx: ActivationContext = {
|
|
169
|
+
session_id: sessionId,
|
|
170
|
+
query_log_path: QUERY_LOG,
|
|
171
|
+
telemetry_log_path: TELEMETRY_LOG,
|
|
172
|
+
evolution_audit_log_path: EVOLUTION_AUDIT_LOG,
|
|
173
|
+
selftune_dir: SELFTUNE_CONFIG_DIR,
|
|
174
|
+
settings_path: settingsPath ?? "",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const statePath = sessionStatePath(sessionId);
|
|
178
|
+
return evaluateRules(DEFAULT_RULES, ctx, statePath);
|
|
179
|
+
}
|
|
180
|
+
|
|
150
181
|
// ---------------------------------------------------------------------------
|
|
151
182
|
// stdin main (only when executed directly, not when imported)
|
|
152
183
|
// ---------------------------------------------------------------------------
|
|
@@ -155,43 +186,18 @@ if (import.meta.main) {
|
|
|
155
186
|
try {
|
|
156
187
|
const payload: PromptSubmitPayload = JSON.parse(await Bun.stdin.text());
|
|
157
188
|
const sessionId = payload.session_id ?? "unknown";
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
telemetry_log_path: TELEMETRY_LOG,
|
|
171
|
-
evolution_audit_log_path: EVOLUTION_AUDIT_LOG,
|
|
172
|
-
selftune_dir: SELFTUNE_CONFIG_DIR,
|
|
173
|
-
settings_path: CLAUDE_SETTINGS_PATH,
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
// Check PAI coexistence — if PAI is active, skip selftune suggestions
|
|
177
|
-
// (PAI handles skill-level activation; selftune handles observability)
|
|
178
|
-
if (!checkPaiCoexistence(CLAUDE_SETTINGS_PATH)) {
|
|
179
|
-
const statePath = sessionStatePath(sessionId);
|
|
180
|
-
const suggestions = evaluateRules(DEFAULT_RULES, ctx, statePath);
|
|
181
|
-
|
|
182
|
-
if (suggestions.length > 0) {
|
|
183
|
-
// Output as JSON with additionalContext — Claude Code adds this to
|
|
184
|
-
// Claude's context on UserPromptSubmit (more reliable than stderr)
|
|
185
|
-
const context = suggestions.map((s) => `[selftune] Suggestion: ${s}`).join("\n");
|
|
186
|
-
process.stdout.write(
|
|
187
|
-
JSON.stringify({
|
|
188
|
-
hookSpecificOutput: {
|
|
189
|
-
hookEventName: "UserPromptSubmit",
|
|
190
|
-
additionalContext: context,
|
|
191
|
-
},
|
|
192
|
-
}),
|
|
193
|
-
);
|
|
194
|
-
}
|
|
189
|
+
const suggestions = await processAutoActivate(sessionId, CLAUDE_SETTINGS_PATH);
|
|
190
|
+
|
|
191
|
+
if (suggestions.length > 0) {
|
|
192
|
+
const context = suggestions.map((s) => `[selftune] Suggestion: ${s}`).join("\n");
|
|
193
|
+
process.stdout.write(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
hookSpecificOutput: {
|
|
196
|
+
hookEventName: "UserPromptSubmit",
|
|
197
|
+
additionalContext: context,
|
|
198
|
+
},
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
195
201
|
}
|
|
196
202
|
} catch {
|
|
197
203
|
// silent — hooks must never block Claude
|
|
@@ -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
|
+
}
|