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