@bjoernrochel/sevro 0.1.0-rc.8 → 1.0.0
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/CONTRIBUTING.md +22 -6
- package/README.md +5 -1
- package/docs/README.md +19 -15
- package/docs/architecture.md +138 -17
- package/docs/assets/README.md +5 -4
- package/docs/builtin-graders-v1.md +9 -2
- package/docs/codex-host.md +155 -57
- package/docs/contracts.md +2 -1
- package/docs/creating-cases.md +9 -2
- package/docs/development-cli.md +187 -19
- package/docs/documentation-quality.md +12 -2
- package/docs/evaluation-workflow.md +110 -0
- package/docs/extension-protocol-v1.md +26 -3
- package/docs/getting-started.md +23 -2
- package/docs/guide-evaluation.md +33 -1
- package/docs/identity-v1.md +25 -2
- package/docs/installing.md +11 -11
- package/docs/native-controls.md +76 -5
- package/docs/native-hosts.md +196 -7
- package/docs/reading-results.md +102 -0
- package/docs/recipes.md +489 -0
- package/docs/releases.md +31 -12
- package/docs/report-v1.md +29 -1
- package/docs/results-v1.md +122 -4
- package/docs/run-settings-v1.md +147 -0
- package/docs/runtime-v1.md +255 -22
- package/docs/troubleshooting.md +12 -12
- package/docs/typescript-quality.md +89 -26
- package/examples/recipes/candidate.ts +26 -0
- package/examples/recipes/cases/coding-change.json +21 -0
- package/examples/recipes/cases/host-tools.json +21 -0
- package/examples/recipes/cases/mise.json +21 -0
- package/examples/recipes/cases/node.json +21 -0
- package/examples/recipes/cases/python-uv.json +21 -0
- package/examples/recipes/cases/semantic.json +17 -0
- package/examples/recipes/coding-change/check.ts +23 -0
- package/examples/recipes/extension-artifacts.ts +65 -0
- package/examples/recipes/extension-methods.ts +174 -0
- package/examples/recipes/extension.ts +37 -0
- package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
- package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
- package/examples/recipes/repository-skill/README.md +3 -0
- package/examples/recipes/run-inputs.ts +188 -0
- package/examples/recipes/run-options.ts +131 -0
- package/examples/recipes/run.ts +63 -0
- package/examples/recipes/runtime/empty.json +1 -0
- package/examples/recipes/runtime/goals.json +1 -0
- package/examples/recipes/runtime/mise.json +6 -0
- package/examples/recipes/runtime/path.json +1 -0
- package/examples/recipes/runtime/plugins.json +4 -0
- package/examples/recipes/runtime/python-uv.json +10 -0
- package/examples/recipes/semantic-judge.ts +29 -0
- package/examples/templates/coding/adapter.ts +17 -0
- package/examples/templates/coding/case.json +15 -0
- package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
- package/examples/templates/repository-skill/extension-data.ts +64 -0
- package/examples/templates/repository-skill/extension.ts +31 -0
- package/examples/templates/response/adapter.ts +12 -0
- package/examples/templates/response/case.json +13 -0
- package/package.json +11 -5
- package/schemas/report-v1.schema.json +9 -0
- package/schemas/run-evidence-v1.schema.json +2 -1
- package/schemas/runtime-v1.schema.json +43 -7
- package/src/advisory.ts +2 -2
- package/src/candidate-artifacts.ts +42 -11
- package/src/candidate-codex-state.ts +185 -0
- package/src/case-validation.ts +73 -50
- package/src/cli/cancellation.ts +20 -0
- package/src/cli/command.ts +87 -0
- package/src/cli/completion.ts +15 -0
- package/src/cli/demo.ts +60 -0
- package/src/cli/evaluation.ts +55 -0
- package/src/cli/extension.ts +79 -0
- package/src/cli/help.ts +198 -0
- package/src/cli/hosts.ts +55 -0
- package/src/cli/init-settings.ts +30 -0
- package/src/cli/init.ts +111 -0
- package/src/{cli-invocation.ts → cli/invocation.ts} +214 -218
- package/src/cli/loading.ts +151 -0
- package/src/cli/preparation.ts +154 -0
- package/src/cli/presentation.ts +93 -0
- package/src/cli/progress.ts +6 -0
- package/src/cli/run-manifest.ts +128 -0
- package/src/cli/run-options.ts +403 -0
- package/src/cli/run-overrides.ts +69 -0
- package/src/cli/settings.ts +154 -0
- package/src/cli/summary.ts +43 -0
- package/src/cli.ts +13 -591
- package/src/contract-types.ts +94 -0
- package/src/engine.ts +9 -5
- package/src/evaluation/advisory.ts +150 -0
- package/src/{evaluation-check-plan.ts → evaluation/check-plan.ts} +103 -67
- package/src/evaluation/collection.ts +88 -0
- package/src/evaluation/context.ts +10 -0
- package/src/{evaluation-grading-host.ts → evaluation/grading-host.ts} +5 -5
- package/src/evaluation/grading.ts +235 -0
- package/src/{evaluation-invocation-plan.ts → evaluation/invocation-plan.ts} +147 -58
- package/src/{evaluation-plan.ts → evaluation/plan.ts} +208 -141
- package/src/evaluation/progress.ts +19 -0
- package/src/evaluation/resources.ts +241 -0
- package/src/evaluation/run.ts +206 -0
- package/src/{evaluation-semantic.ts → evaluation/semantic.ts} +77 -87
- package/src/evaluation/trial.ts +800 -0
- package/src/{evaluation-types.ts → evaluation/types.ts} +79 -29
- package/src/evidence/host-results.ts +85 -0
- package/src/evidence/retained-artifacts.ts +18 -0
- package/src/evidence/trial-writer.ts +203 -0
- package/src/evidence/usage.ts +17 -0
- package/src/extension-client.ts +34 -96
- package/src/extension-session.ts +24 -48
- package/src/{advisory-fixture.ts → fixtures/advisory.ts} +12 -72
- package/src/{fixture-bin.ts → fixtures/bin.ts} +1 -1
- package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
- package/src/fixtures/create.ts +183 -0
- package/src/{generated-fixture.ts → fixtures/generated.ts} +10 -53
- package/src/{git-hooks.ts → fixtures/git-hooks.ts} +1 -1
- package/src/{preparation.ts → fixtures/preparation.ts} +6 -16
- package/src/{repository-fixture.ts → fixtures/repository.ts} +30 -64
- package/src/{fixture-setup.ts → fixtures/setup.ts} +33 -51
- package/src/{fixture-tools.ts → fixtures/tools.ts} +1 -1
- package/src/{workspace-state.ts → fixtures/workspace-state.ts} +5 -5
- package/src/generated/cli-result.d.cts +2 -1
- package/src/generated/extension.d.cts +2 -1
- package/src/generated/report.cjs +1 -1
- package/src/generated/report.d.cts +2 -1
- package/src/generated/run-evidence.cjs +1 -1
- package/src/generated/run-evidence.d.cts +2 -1
- package/src/generated/runtime.cjs +1 -1
- package/src/generated/runtime.d.cts +2 -1
- package/src/git.ts +93 -0
- package/src/graders/git-head.ts +29 -84
- package/src/graders/shell.ts +50 -68
- package/src/host-diagnostic-redaction.ts +52 -0
- package/src/host-diagnostics.ts +331 -0
- package/src/hosts/claude-continuation.ts +57 -40
- package/src/hosts/claude-credential.ts +160 -63
- package/src/hosts/claude-events.ts +34 -18
- package/src/hosts/claude-keychain.ts +78 -21
- package/src/hosts/claude-login-recovery.ts +85 -0
- package/src/hosts/claude-login-refresh.ts +178 -0
- package/src/hosts/claude-login-store.ts +162 -0
- package/src/hosts/claude-login.ts +107 -0
- package/src/hosts/claude-native-goal.ts +15 -12
- package/src/hosts/claude-process.ts +10 -50
- package/src/hosts/claude-refresh-lock.ts +81 -0
- package/src/hosts/claude-repository-invocation.ts +9 -34
- package/src/hosts/claude-settings.ts +46 -9
- package/src/hosts/claude-skill-activations.ts +169 -0
- package/src/hosts/claude-state.ts +28 -15
- package/src/hosts/claude-tool-calls.ts +13 -10
- package/src/hosts/claude.ts +121 -81
- package/src/hosts/codex-app-server.ts +78 -52
- package/src/hosts/codex-configuration.ts +5 -10
- package/src/hosts/codex-exec-controls.ts +516 -0
- package/src/hosts/codex-marketplace.ts +12 -10
- package/src/hosts/codex-native-calls.ts +15 -122
- package/src/hosts/codex-process.ts +10 -183
- package/src/hosts/codex-profile.ts +36 -21
- package/src/hosts/codex-session-files.ts +1 -9
- package/src/hosts/codex-state.ts +134 -18
- package/src/hosts/codex.ts +210 -183
- package/src/hosts/isolation-common.ts +8 -11
- package/src/hosts/isolation-roots.ts +2 -2
- package/src/hosts/linux-sandbox.ts +15 -2
- package/src/hosts/mac-sandbox.ts +87 -18
- package/src/hosts/native-controls.ts +72 -22
- package/src/hosts/native-request.ts +55 -0
- package/src/hosts/process-lifecycle.ts +216 -48
- package/src/hosts/protected-worktrees.ts +1 -1
- package/src/hosts/skill-hook-catalog.ts +131 -0
- package/src/hosts/skill-hook-handler.ts +374 -0
- package/src/hosts/skill-hooks.ts +577 -0
- package/src/instrumentation.ts +7 -8
- package/src/native-transcript-state.ts +22 -3
- package/src/native-transcript-view.ts +52 -13
- package/src/native-transcripts.ts +26 -11
- package/src/paths.ts +22 -0
- package/src/process-lifecycle.ts +150 -0
- package/src/provenance.ts +27 -7
- package/src/prune.ts +300 -0
- package/src/report.ts +63 -36
- package/src/resolved-case.ts +1 -1
- package/src/results-lock.ts +42 -0
- package/src/results.ts +14 -18
- package/src/{runtime-config.ts → runtime/config.ts} +160 -46
- package/src/{runtime-discovery-apple.ts → runtime/discovery-apple.ts} +3 -3
- package/src/{runtime-discovery-providers.ts → runtime/discovery-providers.ts} +3 -9
- package/src/{runtime-discovery.ts → runtime/discovery.ts} +4 -4
- package/src/{runtime-hook-processes.ts → runtime/hook-processes.ts} +1 -1
- package/src/{runtime-hooks.ts → runtime/hooks.ts} +32 -22
- package/src/{runtime-paths.ts → runtime/paths.ts} +5 -19
- package/src/{runtime-query.ts → runtime/query.ts} +5 -5
- package/src/{runtime-seeds.ts → runtime/seeds.ts} +3 -3
- package/src/runtime/shared-cache.ts +130 -0
- package/src/{runtime-state.ts → runtime/state.ts} +28 -11
- package/src/schema-types.ts +73 -161
- package/src/shell.ts +4 -0
- package/src/transcript-retention.ts +146 -0
- package/src/value-guards.ts +22 -0
- package/docs/assets/sevro-logo.png +0 -0
- package/docs/assets/sevro-symbol.svg +0 -1
- package/docs/assets/sevro-wordmark.svg +0 -1
- package/docs/cache-ownership-validation.md +0 -49
- package/docs/claude-continuation-validation.md +0 -47
- package/docs/concurrency-validation.md +0 -86
- package/docs/configuration-root-validation.md +0 -70
- package/docs/engine-slice.md +0 -114
- package/docs/evidence/runtime-startup-30/README.md +0 -185
- package/docs/evidence/runtime-startup-30/after.json +0 -319
- package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +0 -2
- package/docs/evidence/runtime-startup-30/before.json +0 -403
- package/docs/evidence/runtime-startup-30/consumer-smoke.json +0 -63
- package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +0 -3
- package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +0 -3
- package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +0 -20
- package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +0 -2
- package/docs/evidence.md +0 -17
- package/docs/fixture-cleanup-validation.md +0 -73
- package/docs/guide-verification.json +0 -239
- package/src/evaluation-context.ts +0 -259
- package/src/evaluation-fixture.ts +0 -302
- package/src/evaluation-run.ts +0 -200
- package/src/evaluation-trial.ts +0 -1016
- package/src/hosts/claude-nested-skills.ts +0 -272
- package/src/hosts/codex-command-output.ts +0 -510
- package/src/hosts/codex-executor-command.ts +0 -291
- package/src/hosts/codex-skill-reads.ts +0 -783
- /package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +0 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { shellQuote as quote } from "../shell";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { isRecord, isUnknownArray } from "../value-guards";
|
|
6
|
+
import { skillHookCatalog, type SkillCatalogRoot } from "./skill-hook-catalog";
|
|
7
|
+
import type {
|
|
8
|
+
HookSkill,
|
|
9
|
+
SkillBodyRanges,
|
|
10
|
+
SkillHookConfiguration,
|
|
11
|
+
SkillHookReceipt,
|
|
12
|
+
} from "./skill-hook-handler";
|
|
13
|
+
|
|
14
|
+
const MAX_EVENTS_BYTES = 8 * 1024 * 1024;
|
|
15
|
+
const MAX_RETAINED_CALLS = 128;
|
|
16
|
+
export interface SkillTracking {
|
|
17
|
+
configuration: SkillHookConfiguration;
|
|
18
|
+
hooks: Record<string, unknown>;
|
|
19
|
+
command: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Install the runner's silent observer separately from authorized plugin hooks. */
|
|
23
|
+
export async function prepareSkillTracking(options: {
|
|
24
|
+
privateRoot: string;
|
|
25
|
+
host: "codex";
|
|
26
|
+
roots?: SkillCatalogRoot[];
|
|
27
|
+
}): Promise<SkillTracking> {
|
|
28
|
+
const root = join(options.privateRoot, "skill-tracking");
|
|
29
|
+
const receipts = join(root, "receipts");
|
|
30
|
+
await mkdir(receipts, { recursive: true, mode: 0o700 });
|
|
31
|
+
const configuration: SkillHookConfiguration = {
|
|
32
|
+
host: options.host,
|
|
33
|
+
receipts,
|
|
34
|
+
...(await skillHookCatalog(options.roots ?? [])),
|
|
35
|
+
};
|
|
36
|
+
const configurationPath = join(root, "configuration.json");
|
|
37
|
+
await writeFile(configurationPath, JSON.stringify(configuration), {
|
|
38
|
+
flag: "wx",
|
|
39
|
+
mode: 0o600,
|
|
40
|
+
});
|
|
41
|
+
const command = [
|
|
42
|
+
"/usr/bin/env",
|
|
43
|
+
"-i",
|
|
44
|
+
"PATH=/usr/bin:/bin",
|
|
45
|
+
process.execPath,
|
|
46
|
+
fileURLToPath(new URL("./skill-hook-handler.ts", import.meta.url)),
|
|
47
|
+
configurationPath,
|
|
48
|
+
]
|
|
49
|
+
.map(quote)
|
|
50
|
+
.join(" ");
|
|
51
|
+
return { configuration, command, hooks: hookDefinitions(command) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hookDefinitions(command: string) {
|
|
55
|
+
return Object.fromEntries(
|
|
56
|
+
["SessionStart", "PreToolUse", "PostToolUse", "Stop"].map((event) => {
|
|
57
|
+
return [event, [{ hooks: [{ type: "command", command, timeout: 5 }] }]];
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isReceipt(
|
|
63
|
+
value: unknown,
|
|
64
|
+
skills: HookSkill[],
|
|
65
|
+
): value is SkillHookReceipt {
|
|
66
|
+
if (!isRecord(value)) return false;
|
|
67
|
+
return (
|
|
68
|
+
receiptLabels(value) &&
|
|
69
|
+
stringList(value.invocations) &&
|
|
70
|
+
stringList(value.ambiguousInvocations) &&
|
|
71
|
+
receiptPageFields(value, skills)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function receiptPageFields(
|
|
76
|
+
value: Record<string, unknown>,
|
|
77
|
+
skills: HookSkill[],
|
|
78
|
+
) {
|
|
79
|
+
return (
|
|
80
|
+
typeof value.partial === "boolean" &&
|
|
81
|
+
validBodyRanges(value.bodyRanges, skills)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validOffset(value: unknown): value is number {
|
|
86
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validRange(value: unknown, length: number): boolean {
|
|
90
|
+
if (!isOffsetRange(value)) return false;
|
|
91
|
+
const [start, end] = value;
|
|
92
|
+
return start < end && end <= length;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isOffsetRange(value: unknown): value is [number, number] {
|
|
96
|
+
return (
|
|
97
|
+
isUnknownArray(value) && value.length === 2 && value.every(validOffset)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validPage(value: unknown, skills: HookSkill[]): boolean {
|
|
102
|
+
if (!isRecord(value) || !Array.isArray(value.ranges)) return false;
|
|
103
|
+
const skill = skills.find(
|
|
104
|
+
(item) =>
|
|
105
|
+
item.invocation === value.invocation && item.sha256 === value.sha256,
|
|
106
|
+
);
|
|
107
|
+
if (!skill) return false;
|
|
108
|
+
return value.ranges.every((range: unknown) =>
|
|
109
|
+
validRange(range, skill.body.length),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validBodyRanges(value: unknown, skills: HookSkill[]): boolean {
|
|
114
|
+
if (value === undefined) return true;
|
|
115
|
+
return (
|
|
116
|
+
Array.isArray(value) &&
|
|
117
|
+
value.every((page: unknown) => validPage(page, skills))
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function stringList(value: unknown): value is string[] {
|
|
122
|
+
return (
|
|
123
|
+
Array.isArray(value) &&
|
|
124
|
+
value.every((item: unknown) => typeof item === "string")
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function receiptLabels(value: Record<string, unknown>): boolean {
|
|
129
|
+
if (
|
|
130
|
+
!["SessionStart", "PreToolUse", "PostToolUse", "Stop"].includes(
|
|
131
|
+
String(value.event),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return false;
|
|
135
|
+
return [value.sessionId, value.turnId, value.toolUseId, value.toolName].every(
|
|
136
|
+
(field) => field === null || typeof field === "string",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function receipts(tracking: SkillTracking) {
|
|
141
|
+
const root = tracking.configuration.receipts;
|
|
142
|
+
const result = { events: [] as SkillHookReceipt[], reasons: [] as string[] };
|
|
143
|
+
if (await Bun.file(join(root, "partial")).exists())
|
|
144
|
+
result.reasons.push("observer_failure");
|
|
145
|
+
try {
|
|
146
|
+
const path = join(root, "events.jsonl");
|
|
147
|
+
if ((await stat(path)).size > MAX_EVENTS_BYTES)
|
|
148
|
+
return { ...result, reasons: [...result.reasons, "receipt_limit"] };
|
|
149
|
+
parseReceipts(
|
|
150
|
+
await readFile(path, "utf8"),
|
|
151
|
+
tracking.configuration.skills,
|
|
152
|
+
result.events,
|
|
153
|
+
);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
result.reasons.push(
|
|
156
|
+
error instanceof SyntaxError
|
|
157
|
+
? "invalid_receipts"
|
|
158
|
+
: "receipts_unavailable",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseReceipts(
|
|
165
|
+
text: string,
|
|
166
|
+
skills: HookSkill[],
|
|
167
|
+
events: SkillHookReceipt[],
|
|
168
|
+
) {
|
|
169
|
+
for (const line of text.trimEnd().split("\n")) {
|
|
170
|
+
const event: unknown = JSON.parse(line);
|
|
171
|
+
if (!isReceipt(event, skills))
|
|
172
|
+
throw new SyntaxError("invalid skill hook receipt");
|
|
173
|
+
events.push(event);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function hookKey(event: SkillHookReceipt): string {
|
|
178
|
+
return JSON.stringify([event.sessionId, event.toolUseId]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function collectionSummary(
|
|
182
|
+
events: SkillHookReceipt[],
|
|
183
|
+
sessionId: string | null,
|
|
184
|
+
) {
|
|
185
|
+
const ledger = {
|
|
186
|
+
pending: new Set<string>(),
|
|
187
|
+
finished: new Set<string>(),
|
|
188
|
+
diagnosticKeys: new Set<string>(),
|
|
189
|
+
duplicateToolCallCount: 0,
|
|
190
|
+
unmatchedPostToolUseCount: 0,
|
|
191
|
+
};
|
|
192
|
+
for (const event of events) updatePending(event, ledger);
|
|
193
|
+
return {
|
|
194
|
+
diagnosticKeys: new Set([...ledger.diagnosticKeys, ...ledger.pending]),
|
|
195
|
+
eventCount: events.length,
|
|
196
|
+
preToolUseCount: events.filter((event) => event.event === "PreToolUse")
|
|
197
|
+
.length,
|
|
198
|
+
postToolUseCount: events.filter((event) => event.event === "PostToolUse")
|
|
199
|
+
.length,
|
|
200
|
+
pendingToolCallCount: ledger.pending.size,
|
|
201
|
+
duplicateToolCallCount: ledger.duplicateToolCallCount,
|
|
202
|
+
unmatchedPostToolUseCount: ledger.unmatchedPostToolUseCount,
|
|
203
|
+
partialEventCount: events.filter((event) => event.partial).length,
|
|
204
|
+
...lifecycleSummary(events, sessionId),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function lifecycleSummary(
|
|
209
|
+
events: SkillHookReceipt[],
|
|
210
|
+
sessionId: string | null,
|
|
211
|
+
) {
|
|
212
|
+
const selected = events.filter((event) => event.sessionId === sessionId);
|
|
213
|
+
return {
|
|
214
|
+
sessionIdentified: sessionId !== null,
|
|
215
|
+
sessionStarted: selected.some((event) => event.event === "SessionStart"),
|
|
216
|
+
sessionStopped: selected.some((event) => event.event === "Stop"),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function updatePending(
|
|
221
|
+
event: SkillHookReceipt,
|
|
222
|
+
ledger: {
|
|
223
|
+
pending: Set<string>;
|
|
224
|
+
finished: Set<string>;
|
|
225
|
+
diagnosticKeys: Set<string>;
|
|
226
|
+
duplicateToolCallCount: number;
|
|
227
|
+
unmatchedPostToolUseCount: number;
|
|
228
|
+
},
|
|
229
|
+
) {
|
|
230
|
+
const key = hookKey(event);
|
|
231
|
+
if (event.event === "PreToolUse") {
|
|
232
|
+
if (duplicateCall(key, ledger)) {
|
|
233
|
+
ledger.duplicateToolCallCount++;
|
|
234
|
+
ledger.diagnosticKeys.add(key);
|
|
235
|
+
}
|
|
236
|
+
ledger.pending.add(key);
|
|
237
|
+
}
|
|
238
|
+
if (event.event === "PostToolUse") {
|
|
239
|
+
if (!ledger.pending.delete(key)) {
|
|
240
|
+
ledger.unmatchedPostToolUseCount++;
|
|
241
|
+
ledger.diagnosticKeys.add(key);
|
|
242
|
+
}
|
|
243
|
+
ledger.finished.add(key);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function duplicateCall(
|
|
248
|
+
key: string,
|
|
249
|
+
ledger: { pending: Set<string>; finished: Set<string> },
|
|
250
|
+
): boolean {
|
|
251
|
+
return ledger.pending.has(key) || ledger.finished.has(key);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function mergedRanges(ranges: [number, number][]): [number, number][] {
|
|
255
|
+
const merged: [number, number][] = [];
|
|
256
|
+
for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) {
|
|
257
|
+
const previous = merged.at(-1);
|
|
258
|
+
if (previous && start <= previous[1])
|
|
259
|
+
previous[1] = Math.max(previous[1], end);
|
|
260
|
+
else merged.push([start, end]);
|
|
261
|
+
}
|
|
262
|
+
return merged;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function wholeBody(ranges: [number, number][], skill: HookSkill) {
|
|
266
|
+
const range = ranges[0];
|
|
267
|
+
return (
|
|
268
|
+
ranges.length === 1 &&
|
|
269
|
+
range !== undefined &&
|
|
270
|
+
range[0] === 0 &&
|
|
271
|
+
range[1] === skill.body.length
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function completedPage(
|
|
276
|
+
receipt: SkillHookReceipt,
|
|
277
|
+
page: SkillBodyRanges,
|
|
278
|
+
skills: HookSkill[],
|
|
279
|
+
) {
|
|
280
|
+
const aliases = skills.filter((skill) => skill.sha256 === page.sha256);
|
|
281
|
+
if (aliases.some((skill) => skill.invocation !== page.invocation)) {
|
|
282
|
+
receipt.ambiguousInvocations = [
|
|
283
|
+
...new Set([
|
|
284
|
+
...receipt.ambiguousInvocations,
|
|
285
|
+
...aliases.map((skill) => skill.invocation),
|
|
286
|
+
]),
|
|
287
|
+
];
|
|
288
|
+
receipt.partial = true;
|
|
289
|
+
} else
|
|
290
|
+
receipt.invocations = [
|
|
291
|
+
...new Set([...receipt.invocations, page.invocation]),
|
|
292
|
+
];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function accumulatePage(
|
|
296
|
+
receipt: SkillHookReceipt,
|
|
297
|
+
page: SkillBodyRanges,
|
|
298
|
+
skills: HookSkill[],
|
|
299
|
+
coverage: Map<string, [number, number][]>,
|
|
300
|
+
) {
|
|
301
|
+
const skill = skills.find(
|
|
302
|
+
(item) =>
|
|
303
|
+
item.invocation === page.invocation && item.sha256 === page.sha256,
|
|
304
|
+
);
|
|
305
|
+
if (!skill) {
|
|
306
|
+
receipt.partial = true;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const key = JSON.stringify([
|
|
310
|
+
receipt.sessionId,
|
|
311
|
+
receipt.turnId,
|
|
312
|
+
page.invocation,
|
|
313
|
+
page.sha256,
|
|
314
|
+
]);
|
|
315
|
+
const ranges = mergedRanges([...(coverage.get(key) ?? []), ...page.ranges]);
|
|
316
|
+
coverage.set(key, ranges);
|
|
317
|
+
if (wholeBody(ranges, skill)) completedPage(receipt, page, skills);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function pagedEvents(events: SkillHookReceipt[], skills: HookSkill[]) {
|
|
321
|
+
const coverage = new Map<string, [number, number][]>();
|
|
322
|
+
const settled = new Set<string>();
|
|
323
|
+
return events.map((event) => {
|
|
324
|
+
const receipt = { ...event };
|
|
325
|
+
for (const page of event.bodyRanges ?? []) {
|
|
326
|
+
if (!settled.has(page.invocation))
|
|
327
|
+
accumulatePage(receipt, page, skills, coverage);
|
|
328
|
+
}
|
|
329
|
+
for (const invocation of receipt.invocations) settled.add(invocation);
|
|
330
|
+
return receipt;
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function publicReceipt(event: SkillHookReceipt) {
|
|
335
|
+
const receipt = { ...event };
|
|
336
|
+
delete receipt.bodyRanges;
|
|
337
|
+
return {
|
|
338
|
+
...receipt,
|
|
339
|
+
fragmentSkills: [
|
|
340
|
+
...new Set((event.bodyRanges ?? []).map((page) => page.invocation)),
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function incompleteSkills(events: SkillHookReceipt[]) {
|
|
346
|
+
const settled = new Set(
|
|
347
|
+
events.flatMap((event) => [
|
|
348
|
+
...event.invocations,
|
|
349
|
+
...event.ambiguousInvocations,
|
|
350
|
+
]),
|
|
351
|
+
);
|
|
352
|
+
const fragments = new Set(
|
|
353
|
+
events.flatMap((event) =>
|
|
354
|
+
(event.bodyRanges ?? []).map((page) => page.invocation),
|
|
355
|
+
),
|
|
356
|
+
);
|
|
357
|
+
return [...fragments].filter((invocation) => !settled.has(invocation));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function retainedCalls(
|
|
361
|
+
events: SkillHookReceipt[],
|
|
362
|
+
diagnosticKeys: Set<string>,
|
|
363
|
+
) {
|
|
364
|
+
const calls: ReturnType<typeof publicReceipt>[] = [];
|
|
365
|
+
let truncated = false;
|
|
366
|
+
for (const event of events.filter((event) =>
|
|
367
|
+
relevantCall(event, diagnosticKeys),
|
|
368
|
+
)) {
|
|
369
|
+
calls.push(publicReceipt(event));
|
|
370
|
+
if (
|
|
371
|
+
calls.length > MAX_RETAINED_CALLS ||
|
|
372
|
+
Buffer.byteLength(JSON.stringify(calls)) > 24 * 1024
|
|
373
|
+
) {
|
|
374
|
+
calls.pop();
|
|
375
|
+
truncated = true;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return { calls, truncated };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function relevantCall(
|
|
383
|
+
event: SkillHookReceipt,
|
|
384
|
+
diagnosticKeys: Set<string>,
|
|
385
|
+
): boolean {
|
|
386
|
+
return [
|
|
387
|
+
event.invocations.length > 0,
|
|
388
|
+
event.ambiguousInvocations.length > 0,
|
|
389
|
+
(event.bodyRanges ?? []).length > 0,
|
|
390
|
+
event.partial,
|
|
391
|
+
diagnosticKeys.has(hookKey(event)),
|
|
392
|
+
].some(Boolean);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function primaryEvidence(events: SkillHookReceipt[], reasons: string[]) {
|
|
396
|
+
const index = events.findIndex((event) => event.invocations.length > 0);
|
|
397
|
+
const first = events[index];
|
|
398
|
+
const complete = primarySelectionComplete(events, index, reasons);
|
|
399
|
+
if (!complete)
|
|
400
|
+
return { primarySkill: null, primarySkillCompleteness: "partial" as const };
|
|
401
|
+
return {
|
|
402
|
+
primarySkill: first?.invocations[0] ?? null,
|
|
403
|
+
primarySkillCompleteness: "complete" as const,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function primarySelectionComplete(
|
|
408
|
+
events: SkillHookReceipt[],
|
|
409
|
+
index: number,
|
|
410
|
+
reasons: string[],
|
|
411
|
+
) {
|
|
412
|
+
const matches = events[index]?.invocations.length ?? 0;
|
|
413
|
+
if (matches > 1) return false;
|
|
414
|
+
if (reasons.length === 0) return true;
|
|
415
|
+
return primaryBeforeGap(events, index, reasons);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function primaryBeforeGap(
|
|
419
|
+
events: SkillHookReceipt[],
|
|
420
|
+
index: number,
|
|
421
|
+
reasons: string[],
|
|
422
|
+
) {
|
|
423
|
+
if (reasons.join(",") !== "pending_tool_calls") return false;
|
|
424
|
+
if (events[index]?.invocations.length !== 1) return false;
|
|
425
|
+
const prefix = collectionSummary(events.slice(0, index + 1), null);
|
|
426
|
+
return prefix.pendingToolCallCount === 0;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function skillActivationClaims(
|
|
430
|
+
observation: Awaited<ReturnType<typeof skillTrackingObservation>>,
|
|
431
|
+
) {
|
|
432
|
+
if (observation.completeness !== "partial") return [];
|
|
433
|
+
if (observation.data.primarySkillCompleteness !== "complete") return [];
|
|
434
|
+
return [
|
|
435
|
+
{
|
|
436
|
+
id: "sevro.host.skill-activation-claims",
|
|
437
|
+
completeness: "complete" as const,
|
|
438
|
+
data: {
|
|
439
|
+
method: "tool_result_body_match",
|
|
440
|
+
scope: "primary_and_positive_presence",
|
|
441
|
+
collectionCompleteness: "partial",
|
|
442
|
+
primarySkill: observation.data.primarySkill,
|
|
443
|
+
presentSkills: observation.data.observedSkills,
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function skillExposureClaims(
|
|
450
|
+
observation: Awaited<ReturnType<typeof skillTrackingObservation>>,
|
|
451
|
+
) {
|
|
452
|
+
const skillExposures = observation.data.skillExposures.filter(
|
|
453
|
+
(exposure) => exposure.status === "full" || exposure.status === "partial",
|
|
454
|
+
);
|
|
455
|
+
if (skillExposures.length === 0) return [];
|
|
456
|
+
return [
|
|
457
|
+
{
|
|
458
|
+
id: "sevro.host.skill-exposure-claims",
|
|
459
|
+
completeness: "complete" as const,
|
|
460
|
+
data: {
|
|
461
|
+
method: "tool_result_body_match",
|
|
462
|
+
scope: "positive_exposure",
|
|
463
|
+
collectionStatus: observation.data.collectionStatus,
|
|
464
|
+
skillExposures,
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function exposureSummary(
|
|
471
|
+
skills: HookSkill[],
|
|
472
|
+
events: SkillHookReceipt[],
|
|
473
|
+
collectionStatus: string,
|
|
474
|
+
) {
|
|
475
|
+
const exposure = {
|
|
476
|
+
full: new Set(events.flatMap((event) => event.invocations)),
|
|
477
|
+
partial: new Set(incompleteSkills(events)),
|
|
478
|
+
ambiguous: new Set(events.flatMap((event) => event.ambiguousInvocations)),
|
|
479
|
+
};
|
|
480
|
+
return skills.map(({ invocation: skill }) => ({
|
|
481
|
+
skill,
|
|
482
|
+
status: skillExposureStatus(skill, exposure, collectionStatus),
|
|
483
|
+
}));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function skillExposureStatus(
|
|
487
|
+
skill: string,
|
|
488
|
+
exposure: { full: Set<string>; partial: Set<string>; ambiguous: Set<string> },
|
|
489
|
+
collectionStatus: string,
|
|
490
|
+
) {
|
|
491
|
+
if (exposure.full.has(skill)) return "full" as const;
|
|
492
|
+
if (exposure.ambiguous.has(skill)) return "ambiguous" as const;
|
|
493
|
+
if (exposure.partial.has(skill)) return "partial" as const;
|
|
494
|
+
return collectionStatus === "complete"
|
|
495
|
+
? ("none" as const)
|
|
496
|
+
: ("unknown" as const);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function exposureCollectionStatus(reasons: string[], eventCount: number) {
|
|
500
|
+
if (eventCount === 0) return "unavailable" as const;
|
|
501
|
+
return reasons.some(
|
|
502
|
+
(reason) =>
|
|
503
|
+
!["partial_skill_bodies", "ambiguous_skill_bodies"].includes(reason),
|
|
504
|
+
)
|
|
505
|
+
? ("partial" as const)
|
|
506
|
+
: ("complete" as const);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export async function skillTrackingObservation(
|
|
510
|
+
tracking: SkillTracking,
|
|
511
|
+
sessionId: string | null,
|
|
512
|
+
completed: boolean,
|
|
513
|
+
) {
|
|
514
|
+
const collected = await receipts(tracking);
|
|
515
|
+
const events = pagedEvents(collected.events, tracking.configuration.skills);
|
|
516
|
+
const partialSkills = incompleteSkills(events);
|
|
517
|
+
const { diagnosticKeys, ...collection } = collectionSummary(
|
|
518
|
+
events,
|
|
519
|
+
sessionId,
|
|
520
|
+
);
|
|
521
|
+
const { calls, truncated } = retainedCalls(events, diagnosticKeys);
|
|
522
|
+
const ambiguousSkills = [
|
|
523
|
+
...new Set(events.flatMap((event) => event.ambiguousInvocations)),
|
|
524
|
+
];
|
|
525
|
+
const conditions: [string, boolean][] = [
|
|
526
|
+
["host_incomplete", !completed],
|
|
527
|
+
["catalog_incomplete", !tracking.configuration.catalogComplete],
|
|
528
|
+
["session_identity_missing", !collection.sessionIdentified],
|
|
529
|
+
["session_start_missing", !collection.sessionStarted],
|
|
530
|
+
["session_stop_missing", !collection.sessionStopped],
|
|
531
|
+
["pending_tool_calls", collection.pendingToolCallCount > 0],
|
|
532
|
+
["unmatched_post_tool_use", collection.unmatchedPostToolUseCount > 0],
|
|
533
|
+
["duplicate_tool_calls", collection.duplicateToolCallCount > 0],
|
|
534
|
+
[
|
|
535
|
+
"partial_hook_events",
|
|
536
|
+
events.some(
|
|
537
|
+
(event) => event.partial && event.ambiguousInvocations.length === 0,
|
|
538
|
+
),
|
|
539
|
+
],
|
|
540
|
+
["ambiguous_skill_bodies", ambiguousSkills.length > 0],
|
|
541
|
+
["partial_skill_bodies", partialSkills.length > 0],
|
|
542
|
+
["calls_truncated", truncated],
|
|
543
|
+
];
|
|
544
|
+
const incompleteReasons = [
|
|
545
|
+
...collected.reasons,
|
|
546
|
+
...conditions.filter(([, applies]) => applies).map(([reason]) => reason),
|
|
547
|
+
];
|
|
548
|
+
const complete = incompleteReasons.length === 0;
|
|
549
|
+
const collectionStatus = exposureCollectionStatus(
|
|
550
|
+
incompleteReasons,
|
|
551
|
+
events.length,
|
|
552
|
+
);
|
|
553
|
+
return {
|
|
554
|
+
id: "sevro.host.skill-activations",
|
|
555
|
+
completeness: complete ? ("complete" as const) : ("partial" as const),
|
|
556
|
+
data: {
|
|
557
|
+
method: "tool_result_body_match",
|
|
558
|
+
visibility: "tool_result",
|
|
559
|
+
scope: "prepared_skill_bodies",
|
|
560
|
+
catalogComplete: tracking.configuration.catalogComplete,
|
|
561
|
+
collectionStatus,
|
|
562
|
+
skillExposures: exposureSummary(
|
|
563
|
+
tracking.configuration.skills,
|
|
564
|
+
events,
|
|
565
|
+
collectionStatus,
|
|
566
|
+
),
|
|
567
|
+
observedSkills: [...new Set(calls.flatMap((call) => call.invocations))],
|
|
568
|
+
...primaryEvidence(events, incompleteReasons),
|
|
569
|
+
partialSkills,
|
|
570
|
+
ambiguousSkills,
|
|
571
|
+
calls,
|
|
572
|
+
truncated,
|
|
573
|
+
incompleteReasons,
|
|
574
|
+
collection,
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
package/src/instrumentation.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isRecord,
|
|
3
|
+
isUnknownArray,
|
|
4
|
+
validQualifiedId as validInstrumentationId,
|
|
5
|
+
} from "./value-guards";
|
|
2
6
|
import { canonicalJson } from "./identity";
|
|
3
7
|
|
|
4
8
|
export interface InstrumentationRequest {
|
|
@@ -18,12 +22,6 @@ export class InstrumentationEvidenceError extends Error {
|
|
|
18
22
|
}
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
const ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/;
|
|
22
|
-
|
|
23
|
-
function validInstrumentationId(value: unknown): value is string {
|
|
24
|
-
return typeof value === "string" && ID.test(value);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
25
|
function validRequest(value: unknown): value is InstrumentationRequest {
|
|
28
26
|
return (
|
|
29
27
|
isRecord(value) &&
|
|
@@ -39,7 +37,8 @@ function validateSupportedInstrumentation(
|
|
|
39
37
|
if (
|
|
40
38
|
supported.some(
|
|
41
39
|
(item) =>
|
|
42
|
-
!
|
|
40
|
+
!validInstrumentationId(item.id) ||
|
|
41
|
+
typeof item.executionChanging !== "boolean",
|
|
43
42
|
) ||
|
|
44
43
|
ids.size !== supported.length
|
|
45
44
|
)
|
|
@@ -84,20 +84,39 @@ export async function requireNativeTranscriptViews(
|
|
|
84
84
|
/** Curated hook copies are a separate runner-owned exception, never policy roots. */
|
|
85
85
|
export async function requireNativeHookRoots(
|
|
86
86
|
roots: string[] | undefined,
|
|
87
|
+
privateRoot: string,
|
|
87
88
|
): Promise<void> {
|
|
88
|
-
for (const root of roots ?? [])
|
|
89
|
+
for (const root of roots ?? [])
|
|
90
|
+
await requireNativeHookRoot(root, privateRoot);
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
async function requireNativeHookRoot(
|
|
93
|
+
async function requireNativeHookRoot(
|
|
94
|
+
root: string,
|
|
95
|
+
privateRoot: string,
|
|
96
|
+
): Promise<void> {
|
|
92
97
|
const parent = await nativeStateParent();
|
|
93
98
|
const state = dirname(dirname(root));
|
|
94
99
|
if (!nativeHookLayout(root, state, parent) || (await realpath(root)) !== root)
|
|
95
100
|
throw new Error("native hook read root must be a canonical curated mount");
|
|
101
|
+
await requireNativeHookOwner(state, privateRoot);
|
|
96
102
|
const info = await lstat(root);
|
|
97
103
|
if (!info.isDirectory() || info.uid !== userInfo().uid)
|
|
98
104
|
throw new Error("native hook read root must be owned");
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
async function requireNativeHookOwner(
|
|
108
|
+
state: string,
|
|
109
|
+
privateRoot: string,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
if (
|
|
112
|
+
dirname(privateRoot) !== state ||
|
|
113
|
+
(await realpath(privateRoot)) !== privateRoot
|
|
114
|
+
)
|
|
115
|
+
throw new Error("native hook read root must belong to the current request");
|
|
116
|
+
requirePrivateNamespace(await lstat(state));
|
|
117
|
+
requirePrivateNamespace(await lstat(privateRoot));
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
function nativeHookLayout(
|
|
102
121
|
root: string,
|
|
103
122
|
state: string,
|
|
@@ -105,7 +124,7 @@ function nativeHookLayout(
|
|
|
105
124
|
): boolean {
|
|
106
125
|
return (
|
|
107
126
|
dirname(state) === parent &&
|
|
108
|
-
basename(state)
|
|
127
|
+
/^(?:claude|codex)-/.test(basename(state)) &&
|
|
109
128
|
basename(dirname(root)) === "runtime-plugins" &&
|
|
110
129
|
/^plugin-[0-9]+$/.test(basename(root))
|
|
111
130
|
);
|