selftune 0.2.21 → 0.2.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -8
- package/apps/local-dashboard/dist/assets/index-CwOtTrUS.css +1 -0
- package/apps/local-dashboard/dist/assets/index-f1HQpbeH.js +59 -0
- package/apps/local-dashboard/dist/assets/vendor-ui-jVSaIZey.js +12 -0
- package/apps/local-dashboard/dist/index.html +3 -3
- package/cli/selftune/adapters/cline/hook.ts +167 -0
- package/cli/selftune/adapters/cline/install.ts +197 -0
- package/cli/selftune/adapters/codex/hook.ts +296 -0
- package/cli/selftune/adapters/codex/install.ts +289 -0
- package/cli/selftune/adapters/opencode/hook.ts +222 -0
- package/cli/selftune/adapters/opencode/install.ts +543 -0
- package/cli/selftune/adapters/pi/hook.ts +273 -0
- package/cli/selftune/adapters/pi/install.ts +207 -0
- package/cli/selftune/constants.ts +10 -1
- package/cli/selftune/dashboard-contract.ts +14 -0
- package/cli/selftune/evolution/engines/judge-engine.ts +96 -0
- package/cli/selftune/evolution/engines/replay-engine.ts +158 -0
- package/cli/selftune/evolution/evidence.ts +2 -6
- package/cli/selftune/evolution/evolve-body.ts +73 -20
- package/cli/selftune/evolution/validate-body.ts +78 -42
- package/cli/selftune/evolution/validate-routing.ts +45 -104
- package/cli/selftune/hooks/auto-activate.ts +43 -37
- package/cli/selftune/hooks/skill-eval.ts +2 -1
- package/cli/selftune/hooks-shared/git-metadata.ts +149 -0
- package/cli/selftune/hooks-shared/hook-output.ts +105 -0
- package/cli/selftune/hooks-shared/normalize.ts +196 -0
- package/cli/selftune/hooks-shared/session-state.ts +76 -0
- package/cli/selftune/hooks-shared/skill-paths.ts +50 -0
- package/cli/selftune/hooks-shared/stdin-dispatch.ts +59 -0
- package/cli/selftune/hooks-shared/types.ts +91 -0
- package/cli/selftune/index.ts +76 -6
- package/cli/selftune/ingestors/pi-ingest.ts +726 -0
- package/cli/selftune/init.ts +11 -1
- package/cli/selftune/localdb/direct-write.ts +85 -0
- package/cli/selftune/localdb/materialize.ts +6 -7
- package/cli/selftune/localdb/queries.ts +126 -0
- package/cli/selftune/localdb/schema.ts +38 -0
- package/cli/selftune/observability.ts +8 -1
- package/cli/selftune/orchestrate.ts +43 -0
- package/cli/selftune/registry/client.ts +74 -0
- package/cli/selftune/registry/history.ts +54 -0
- package/cli/selftune/registry/index.ts +90 -0
- package/cli/selftune/registry/install.ts +141 -0
- package/cli/selftune/registry/list.ts +44 -0
- package/cli/selftune/registry/push.ts +171 -0
- package/cli/selftune/registry/rollback.ts +49 -0
- package/cli/selftune/registry/status.ts +62 -0
- package/cli/selftune/registry/sync.ts +125 -0
- package/cli/selftune/repair/skill-usage.ts +4 -1
- package/cli/selftune/status.ts +31 -0
- package/cli/selftune/sync.ts +127 -23
- package/cli/selftune/types.ts +2 -1
- package/cli/selftune/utils/jsonl.ts +1 -30
- package/cli/selftune/utils/llm-call.ts +99 -34
- package/cli/selftune/utils/skill-discovery.ts +22 -0
- package/node_modules/@selftune/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
- package/node_modules/@selftune/telemetry-contract/fixtures/golden.test.ts +0 -1
- package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
- package/node_modules/@selftune/telemetry-contract/package.json +1 -1
- package/node_modules/@selftune/telemetry-contract/src/index.ts +1 -0
- package/node_modules/@selftune/telemetry-contract/src/schemas.ts +22 -4
- package/node_modules/@selftune/telemetry-contract/src/types.ts +1 -12
- package/node_modules/@selftune/telemetry-contract/tests/compatibility.test.ts +0 -1
- package/package.json +1 -1
- package/packages/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
- package/packages/telemetry-contract/fixtures/golden.test.ts +0 -1
- package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
- package/packages/telemetry-contract/package.json +1 -1
- package/packages/telemetry-contract/src/index.ts +1 -0
- package/packages/telemetry-contract/src/schemas.ts +22 -4
- package/packages/telemetry-contract/src/types.ts +1 -12
- package/packages/telemetry-contract/tests/compatibility.test.ts +0 -1
- package/packages/ui/AGENTS.md +16 -0
- package/packages/ui/README.md +1 -1
- package/packages/ui/package.json +1 -1
- package/packages/ui/src/components/ActivityTimeline.tsx +152 -168
- package/packages/ui/src/components/AnalyticsCharts.tsx +344 -0
- package/packages/ui/src/components/EvidenceViewer.tsx +153 -443
- package/packages/ui/src/components/EvolutionTimeline.tsx +34 -87
- package/packages/ui/src/components/InfoTip.tsx +1 -2
- package/packages/ui/src/components/InvocationsPanel.tsx +413 -0
- package/packages/ui/src/components/JobHistoryTimeline.tsx +156 -0
- package/packages/ui/src/components/OrchestrateRunsPanel.tsx +18 -36
- package/packages/ui/src/components/OverviewPanels.tsx +652 -0
- package/packages/ui/src/components/PipelineStatusBar.tsx +65 -0
- package/packages/ui/src/components/SkillReportGuide.tsx +215 -0
- package/packages/ui/src/components/SkillReportPanels.tsx +919 -0
- package/packages/ui/src/components/SkillsLibrary.tsx +437 -0
- package/packages/ui/src/components/index.ts +56 -1
- package/packages/ui/src/components/section-cards.tsx +18 -35
- package/packages/ui/src/components/skill-health-grid.tsx +47 -37
- package/packages/ui/src/lib/constants.tsx +0 -1
- package/packages/ui/src/primitives/card.tsx +1 -1
- package/packages/ui/src/primitives/checkbox.tsx +1 -1
- package/packages/ui/src/primitives/dropdown-menu.tsx +2 -2
- package/packages/ui/src/primitives/select.tsx +2 -2
- package/packages/ui/src/types.ts +172 -4
- package/skill/SKILL.md +26 -2
- package/skill/Workflows/Ingest.md +60 -2
- package/skill/Workflows/Initialize.md +54 -9
- package/skill/Workflows/PlatformHooks.md +109 -0
- package/skill/Workflows/Registry.md +99 -0
- package/skill/Workflows/Sync.md +3 -1
- package/apps/local-dashboard/dist/assets/index-D8O-RG1I.js +0 -60
- package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +0 -1
- package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +0 -12
- package/cli/selftune/utils/html.ts +0 -27
- package/packages/ui/src/components/RecentActivityFeed.tsx +0 -117
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Validates a routing table evolution proposal by checking structural validity
|
|
5
5
|
* and running trigger accuracy checks against an eval set.
|
|
6
|
+
*
|
|
7
|
+
* Delegates replay-based and judge-based validation to dedicated engines
|
|
8
|
+
* (engines/replay-engine.ts and engines/judge-engine.ts).
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import type {
|
|
@@ -10,28 +13,20 @@ import type {
|
|
|
10
13
|
BodyValidationResult,
|
|
11
14
|
EvalEntry,
|
|
12
15
|
RoutingReplayEntryResult,
|
|
13
|
-
RoutingReplayFixture,
|
|
14
16
|
ValidationMode,
|
|
15
17
|
} from "../types.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type
|
|
28
|
-
input: RoutingReplayRunnerInput,
|
|
29
|
-
) => Promise<RoutingReplayEntryResult[]>;
|
|
30
|
-
|
|
31
|
-
export interface RoutingValidationOptions {
|
|
32
|
-
replayFixture?: RoutingReplayFixture;
|
|
33
|
-
replayRunner?: RoutingReplayRunner;
|
|
34
|
-
}
|
|
18
|
+
import { runJudgeValidation } from "./engines/judge-engine.js";
|
|
19
|
+
import {
|
|
20
|
+
runReplayValidation,
|
|
21
|
+
type ReplayRunner,
|
|
22
|
+
type ReplayRunnerInput,
|
|
23
|
+
type ReplayValidationOptions,
|
|
24
|
+
} from "./engines/replay-engine.js";
|
|
25
|
+
|
|
26
|
+
// Re-export engine types for backward compatibility
|
|
27
|
+
export type { ReplayRunnerInput as RoutingReplayRunnerInput };
|
|
28
|
+
export type { ReplayRunner as RoutingReplayRunner };
|
|
29
|
+
export type { ReplayValidationOptions as RoutingValidationOptions };
|
|
35
30
|
|
|
36
31
|
export interface RoutingTriggerAccuracyResult {
|
|
37
32
|
before_pass_rate: number;
|
|
@@ -41,6 +36,7 @@ export interface RoutingTriggerAccuracyResult {
|
|
|
41
36
|
validation_agent: string;
|
|
42
37
|
validation_fixture_id?: string;
|
|
43
38
|
per_entry_results?: RoutingReplayEntryResult[];
|
|
39
|
+
before_entry_results?: RoutingReplayEntryResult[];
|
|
44
40
|
}
|
|
45
41
|
|
|
46
42
|
// ---------------------------------------------------------------------------
|
|
@@ -104,6 +100,9 @@ export function validateRoutingStructure(routing: string): { valid: boolean; rea
|
|
|
104
100
|
/**
|
|
105
101
|
* Run before/after trigger checks on the eval set using the routing content.
|
|
106
102
|
* Returns pass rates for comparison.
|
|
103
|
+
*
|
|
104
|
+
* Prefers replay-backed validation when a fixture is available,
|
|
105
|
+
* falls back to LLM judge otherwise.
|
|
107
106
|
*/
|
|
108
107
|
export async function validateRoutingTriggerAccuracy(
|
|
109
108
|
originalRouting: string,
|
|
@@ -111,7 +110,7 @@ export async function validateRoutingTriggerAccuracy(
|
|
|
111
110
|
evalSet: EvalEntry[],
|
|
112
111
|
agent: string,
|
|
113
112
|
modelFlag?: string,
|
|
114
|
-
options:
|
|
113
|
+
options: ReplayValidationOptions = {},
|
|
115
114
|
): Promise<RoutingTriggerAccuracyResult> {
|
|
116
115
|
if (evalSet.length === 0) {
|
|
117
116
|
return {
|
|
@@ -123,93 +122,34 @@ export async function validateRoutingTriggerAccuracy(
|
|
|
123
122
|
};
|
|
124
123
|
}
|
|
125
124
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
routing: proposedRouting,
|
|
135
|
-
evalSet,
|
|
136
|
-
agent,
|
|
137
|
-
fixture: options.replayFixture,
|
|
138
|
-
});
|
|
139
|
-
const beforePassed = beforeResults.filter((result) => result.passed).length;
|
|
140
|
-
const afterPassed = afterResults.filter((result) => result.passed).length;
|
|
141
|
-
const total = evalSet.length;
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
before_pass_rate: beforePassed / total,
|
|
145
|
-
after_pass_rate: afterPassed / total,
|
|
146
|
-
improved: afterPassed > beforePassed,
|
|
147
|
-
validation_mode: "host_replay",
|
|
148
|
-
validation_agent: agent,
|
|
149
|
-
validation_fixture_id: options.replayFixture.fixture_id,
|
|
150
|
-
per_entry_results: afterResults,
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (options.replayFixture) {
|
|
155
|
-
const beforeResults = runHostReplayFixture({
|
|
156
|
-
routing: originalRouting,
|
|
157
|
-
evalSet,
|
|
158
|
-
fixture: options.replayFixture,
|
|
159
|
-
});
|
|
160
|
-
const afterResults = runHostReplayFixture({
|
|
161
|
-
routing: proposedRouting,
|
|
162
|
-
evalSet,
|
|
163
|
-
fixture: options.replayFixture,
|
|
164
|
-
});
|
|
165
|
-
const beforePassed = beforeResults.filter((result) => result.passed).length;
|
|
166
|
-
const afterPassed = afterResults.filter((result) => result.passed).length;
|
|
167
|
-
const total = evalSet.length;
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
before_pass_rate: beforePassed / total,
|
|
171
|
-
after_pass_rate: afterPassed / total,
|
|
172
|
-
improved: afterPassed > beforePassed,
|
|
173
|
-
validation_mode: "host_replay",
|
|
174
|
-
validation_agent: agent,
|
|
175
|
-
validation_fixture_id: options.replayFixture.fixture_id,
|
|
176
|
-
per_entry_results: afterResults,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const systemPrompt = "You are an evaluation assistant. Answer only YES or NO.";
|
|
181
|
-
let beforePassed = 0;
|
|
182
|
-
let afterPassed = 0;
|
|
183
|
-
|
|
184
|
-
for (const entry of evalSet) {
|
|
185
|
-
// Check with original routing
|
|
186
|
-
const beforePrompt = buildTriggerCheckPrompt(originalRouting, entry.query);
|
|
187
|
-
const beforeRaw = await callLlm(systemPrompt, beforePrompt, agent, modelFlag);
|
|
188
|
-
const beforeTriggered = parseTriggerResponse(beforeRaw);
|
|
189
|
-
const beforePass =
|
|
190
|
-
(entry.should_trigger && beforeTriggered) || (!entry.should_trigger && !beforeTriggered);
|
|
191
|
-
|
|
192
|
-
// Check with proposed routing
|
|
193
|
-
const afterPrompt = buildTriggerCheckPrompt(proposedRouting, entry.query);
|
|
194
|
-
const afterRaw = await callLlm(systemPrompt, afterPrompt, agent, modelFlag);
|
|
195
|
-
const afterTriggered = parseTriggerResponse(afterRaw);
|
|
196
|
-
const afterPass =
|
|
197
|
-
(entry.should_trigger && afterTriggered) || (!entry.should_trigger && !afterTriggered);
|
|
125
|
+
// Try replay-backed validation first
|
|
126
|
+
const replayResult = await runReplayValidation(
|
|
127
|
+
originalRouting,
|
|
128
|
+
proposedRouting,
|
|
129
|
+
evalSet,
|
|
130
|
+
agent,
|
|
131
|
+
options,
|
|
132
|
+
);
|
|
198
133
|
|
|
199
|
-
|
|
200
|
-
|
|
134
|
+
if (replayResult) {
|
|
135
|
+
return replayResult;
|
|
201
136
|
}
|
|
202
137
|
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
138
|
+
// Fall back to LLM judge
|
|
139
|
+
const judgeResult = await runJudgeValidation(
|
|
140
|
+
originalRouting,
|
|
141
|
+
proposedRouting,
|
|
142
|
+
evalSet,
|
|
143
|
+
agent,
|
|
144
|
+
modelFlag,
|
|
145
|
+
);
|
|
206
146
|
|
|
207
147
|
return {
|
|
208
|
-
before_pass_rate:
|
|
209
|
-
after_pass_rate:
|
|
210
|
-
improved:
|
|
211
|
-
validation_mode:
|
|
212
|
-
validation_agent:
|
|
148
|
+
before_pass_rate: judgeResult.before_pass_rate,
|
|
149
|
+
after_pass_rate: judgeResult.after_pass_rate,
|
|
150
|
+
improved: judgeResult.improved,
|
|
151
|
+
validation_mode: judgeResult.validation_mode,
|
|
152
|
+
validation_agent: judgeResult.validation_agent,
|
|
213
153
|
};
|
|
214
154
|
}
|
|
215
155
|
|
|
@@ -223,7 +163,7 @@ export async function validateRoutingProposal(
|
|
|
223
163
|
evalSet: EvalEntry[],
|
|
224
164
|
agent: string,
|
|
225
165
|
modelFlag?: string,
|
|
226
|
-
options:
|
|
166
|
+
options: ReplayValidationOptions = {},
|
|
227
167
|
): Promise<BodyValidationResult> {
|
|
228
168
|
const gateResults: Array<{ gate: string; passed: boolean; reason: string }> = [];
|
|
229
169
|
|
|
@@ -280,5 +220,6 @@ export async function validateRoutingProposal(
|
|
|
280
220
|
before_pass_rate: accuracy.before_pass_rate,
|
|
281
221
|
after_pass_rate: accuracy.after_pass_rate,
|
|
282
222
|
per_entry_results: accuracy.per_entry_results,
|
|
223
|
+
before_entry_results: accuracy.before_entry_results,
|
|
283
224
|
};
|
|
284
225
|
}
|
|
@@ -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
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
getLatestPromptIdentity,
|
|
26
26
|
} from "../normalization.js";
|
|
27
27
|
import type { PostToolUsePayload, SkillUsageRecord } from "../types.js";
|
|
28
|
-
import { classifySkillPath } from "../utils/skill-discovery.js";
|
|
28
|
+
import { classifySkillPath, isTestFixturePath } from "../utils/skill-discovery.js";
|
|
29
29
|
import { getLastUserMessage } from "../utils/transcript.js";
|
|
30
30
|
|
|
31
31
|
/**
|
|
@@ -122,6 +122,7 @@ export async function processToolUse(
|
|
|
122
122
|
const skillName = extractSkillName(filePath);
|
|
123
123
|
|
|
124
124
|
if (skillName === null) return null;
|
|
125
|
+
if (isTestFixturePath(filePath)) return null;
|
|
125
126
|
|
|
126
127
|
const transcriptPath = payload.transcript_path ?? "";
|
|
127
128
|
const sessionId = payload.session_id ?? "unknown";
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared git metadata extraction for hooks.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from duplicated logic in session-stop.ts (branch/remote extraction)
|
|
5
|
+
* and commit-track.ts (commit detection, remote scrubbing, branch fallback).
|
|
6
|
+
*
|
|
7
|
+
* All functions are fail-open: git errors return undefined/null, never throw.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Types
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Git metadata extracted from a working directory. */
|
|
17
|
+
export interface GitMetadata {
|
|
18
|
+
branch?: string;
|
|
19
|
+
repoRemote?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parsed commit information from git output. */
|
|
23
|
+
export interface ParsedCommit {
|
|
24
|
+
sha?: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
branch?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Pre-compiled regex patterns
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Matches git commands that produce commits: commit, merge, cherry-pick, revert. */
|
|
34
|
+
const GIT_COMMIT_CMD_RE = /\bgit\s+(commit|merge|cherry-pick|revert)\b/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Matches standard git commit output: [branch SHA] title
|
|
38
|
+
* Supports optional parenthetical like (root-commit).
|
|
39
|
+
* Branch names can contain word chars, slashes, dots, hyphens, plus signs.
|
|
40
|
+
*/
|
|
41
|
+
const COMMIT_OUTPUT_RE = /\[([\w/.+-]+)(?:\s+\([^)]+\))?\s+([a-f0-9]{7,40})\]\s+(.+)/;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Git metadata extraction
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract git branch and remote URL from a working directory.
|
|
49
|
+
*
|
|
50
|
+
* Uses short-timeout execSync calls. Returns partial results if one
|
|
51
|
+
* command fails (e.g., branch succeeds but remote is not configured).
|
|
52
|
+
* Returns empty object if cwd is not a git repo.
|
|
53
|
+
*
|
|
54
|
+
* @param cwd Working directory to inspect
|
|
55
|
+
*/
|
|
56
|
+
export function extractGitMetadata(cwd: string): GitMetadata {
|
|
57
|
+
if (!cwd) return {};
|
|
58
|
+
|
|
59
|
+
const result: GitMetadata = {};
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
result.branch =
|
|
63
|
+
execSync("git rev-parse --abbrev-ref HEAD", {
|
|
64
|
+
cwd,
|
|
65
|
+
timeout: 3000,
|
|
66
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
67
|
+
})
|
|
68
|
+
.toString()
|
|
69
|
+
.trim() || undefined;
|
|
70
|
+
} catch {
|
|
71
|
+
/* not a git repo or git not available */
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const rawRemote =
|
|
76
|
+
execSync("git remote get-url origin", {
|
|
77
|
+
cwd,
|
|
78
|
+
timeout: 3000,
|
|
79
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
80
|
+
})
|
|
81
|
+
.toString()
|
|
82
|
+
.trim() || undefined;
|
|
83
|
+
if (rawRemote) {
|
|
84
|
+
result.repoRemote = scrubRemoteUrl(rawRemote);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
/* no remote configured */
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// URL scrubbing
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Scrub credentials from a git remote URL.
|
|
99
|
+
*
|
|
100
|
+
* HTTP(S) URLs have username/password stripped. SSH URLs and other formats
|
|
101
|
+
* are returned as-is (they don't embed credentials in the URL structure).
|
|
102
|
+
*
|
|
103
|
+
* @param rawUrl Raw remote URL from `git remote get-url`
|
|
104
|
+
* @returns Scrubbed URL, or undefined for empty input
|
|
105
|
+
*/
|
|
106
|
+
export function scrubRemoteUrl(rawUrl: string): string | undefined {
|
|
107
|
+
if (!rawUrl) return undefined;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = new URL(rawUrl);
|
|
110
|
+
parsed.username = "";
|
|
111
|
+
parsed.password = "";
|
|
112
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
113
|
+
} catch {
|
|
114
|
+
// SSH or non-URL format -- safe as-is
|
|
115
|
+
return rawUrl;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Commit detection
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if a command string contains a git commit-producing operation.
|
|
125
|
+
* Detects: git commit, git merge, git cherry-pick, git revert.
|
|
126
|
+
*/
|
|
127
|
+
export function containsGitCommitCommand(command: string): boolean {
|
|
128
|
+
return GIT_COMMIT_CMD_RE.test(command);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parse commit metadata from git's standard output format.
|
|
133
|
+
*
|
|
134
|
+
* Expects output like: `[main abc1234] Fix the bug`
|
|
135
|
+
* or with root-commit: `[main (root-commit) abc1234] Initial commit`
|
|
136
|
+
*
|
|
137
|
+
* @param stdout The stdout from a git commit/merge/cherry-pick/revert command
|
|
138
|
+
* @returns Parsed commit info, or null if output doesn't match
|
|
139
|
+
*/
|
|
140
|
+
export function parseCommitFromOutput(stdout: string): ParsedCommit | null {
|
|
141
|
+
const match = stdout.match(COMMIT_OUTPUT_RE);
|
|
142
|
+
if (!match) return null;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
branch: match[1],
|
|
146
|
+
sha: match[2],
|
|
147
|
+
title: match[3].trim(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared output helpers for platform-agnostic hook responses.
|
|
3
|
+
*
|
|
4
|
+
* Hooks communicate with their host agent through stdout (context injection),
|
|
5
|
+
* stderr (advisory messages), and exit codes (allow/block decisions). The
|
|
6
|
+
* exact format varies by platform — this module abstracts those differences.
|
|
7
|
+
*
|
|
8
|
+
* Claude Code conventions (the primary platform):
|
|
9
|
+
* - stdout JSON with hookSpecificOutput.additionalContext for context injection
|
|
10
|
+
* - stderr for advisory suggestions
|
|
11
|
+
* - exit 0 = allow, exit 2 = block with message
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { HookPlatform, HookResponse } from "./types.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format a HookResponse for a specific platform's expected output format.
|
|
18
|
+
*
|
|
19
|
+
* For Claude Code: produces JSON with hookSpecificOutput wrapper.
|
|
20
|
+
* For other platforms: produces a simplified JSON response.
|
|
21
|
+
*
|
|
22
|
+
* @param response The platform-agnostic hook response
|
|
23
|
+
* @param platform The target platform
|
|
24
|
+
* @returns Formatted string ready to write to stdout
|
|
25
|
+
*/
|
|
26
|
+
export function formatResponseForPlatform(response: HookResponse, platform: HookPlatform): string {
|
|
27
|
+
if (platform === "claude-code" || platform === "codex") {
|
|
28
|
+
// Claude Code / Codex use hookSpecificOutput wrapper
|
|
29
|
+
const output: Record<string, unknown> = {};
|
|
30
|
+
|
|
31
|
+
if (response.context) {
|
|
32
|
+
output.hookSpecificOutput = {
|
|
33
|
+
additionalContext: response.context,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (response.updated_input) {
|
|
38
|
+
output.updatedInput = response.updated_input;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (response.decision) {
|
|
42
|
+
output.decision = response.decision;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return JSON.stringify(output);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Generic JSON format for other platforms
|
|
49
|
+
return JSON.stringify({
|
|
50
|
+
modified: response.modified,
|
|
51
|
+
decision: response.decision,
|
|
52
|
+
message: response.message,
|
|
53
|
+
context: response.context,
|
|
54
|
+
updated_input: response.updated_input,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Write an advisory suggestion to stderr.
|
|
60
|
+
*
|
|
61
|
+
* Stderr messages appear as system messages to the host agent in Claude Code.
|
|
62
|
+
* Other platforms may handle stderr differently, but writing to stderr is
|
|
63
|
+
* universally safe (it never affects the hook's exit code or stdout response).
|
|
64
|
+
*
|
|
65
|
+
* @param message The suggestion text to display
|
|
66
|
+
*/
|
|
67
|
+
export function writeSuggestion(message: string): void {
|
|
68
|
+
process.stderr.write(`${message}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write context injection to stdout.
|
|
73
|
+
*
|
|
74
|
+
* For Claude Code, this injects content into Claude's context via the
|
|
75
|
+
* hookSpecificOutput.additionalContext mechanism. The output is JSON-formatted
|
|
76
|
+
* to match Claude Code's expected hook output schema.
|
|
77
|
+
*
|
|
78
|
+
* @param context The context string to inject
|
|
79
|
+
*/
|
|
80
|
+
export function writeContext(context: string): void {
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
JSON.stringify({
|
|
83
|
+
hookSpecificOutput: {
|
|
84
|
+
additionalContext: context,
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Exit with a platform-appropriate exit code for allow/block decisions.
|
|
92
|
+
*
|
|
93
|
+
* Claude Code convention:
|
|
94
|
+
* - exit 0 = allow the tool call
|
|
95
|
+
* - exit 2 = block the tool call (with stderr message)
|
|
96
|
+
*
|
|
97
|
+
* Other platforms use the same convention unless they specify otherwise.
|
|
98
|
+
*
|
|
99
|
+
* @param decision "allow" or "block"
|
|
100
|
+
* @param _platform The target platform (reserved for future per-platform codes)
|
|
101
|
+
*/
|
|
102
|
+
export function exitWithDecision(decision: "allow" | "block", _platform: HookPlatform): never {
|
|
103
|
+
const code = decision === "block" ? 2 : 0;
|
|
104
|
+
process.exit(code);
|
|
105
|
+
}
|