@bjoernrochel/sevro 0.1.0-rc.19 → 0.1.0-rc.20

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.
@@ -23,10 +23,10 @@ Local installation records the selected version in the project lockfile.
23
23
 
24
24
  ## Current release
25
25
 
26
- The current release is `0.1.0-rc.19`. Install this exact version:
26
+ The current release is `0.1.0-rc.20`. Install this exact version:
27
27
 
28
28
  ```sh
29
- bun add --exact @bjoernrochel/sevro@0.1.0-rc.19
29
+ bun add --exact @bjoernrochel/sevro@0.1.0-rc.20
30
30
  ```
31
31
 
32
32
  <!-- sevro-current-release:end -->
@@ -90,6 +90,73 @@ Newly created or changed skill bodies are outside the prepared snapshot. A
90
90
  complete empty observation is limited to this scope and the supported local
91
91
  tool hook paths.
92
92
 
93
+ Codex observations include `incompleteReasons`: an empty array for a complete
94
+ collection, or all applicable reason codes for a partial collection. Older
95
+ observations without this field have unknown reasons; their missing details
96
+ cannot be reconstructed from balanced native call/return counts.
97
+
98
+ | Reason | Meaning |
99
+ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
100
+ | `host_incomplete` | Host execution did not complete. |
101
+ | `catalog_incomplete` | The prepared catalog could not be collected completely. |
102
+ | `receipts_unavailable` | The receipt file is missing or unreadable. |
103
+ | `invalid_receipts` | A malformed receipt stopped processing; earlier valid receipts remain available. |
104
+ | `receipt_limit` | The receipt file exceeds the collection byte limit. |
105
+ | `observer_failure` | The hook recorded a failure or writer limit, including input, nesting or matching-budget failures. |
106
+ | `session_identity_missing` | No root session identity was available. |
107
+ | `session_start_missing` / `session_stop_missing` | The root session lacks the corresponding lifecycle hook. |
108
+ | `pending_tool_calls` | Pre-tool hooks remain without matching post-tool hooks. |
109
+ | `unmatched_post_tool_use` | Post-tool hooks were received without pending pre-tool hooks. |
110
+ | `duplicate_tool_calls` | A pre-tool identity was already pending or finished. |
111
+ | `partial_hook_events` | A hook receipt is partial independently of body ambiguity. |
112
+ | `ambiguous_skill_bodies` | Complete body exposure could not distinguish skill names. |
113
+ | `partial_skill_bodies` | Recognized fragments remain without complete body exposure. |
114
+ | `calls_truncated` | Relevant call evidence exceeded its public count or byte budget. |
115
+
116
+ The `collection` summary reports `eventCount`, `preToolUseCount`,
117
+ `postToolUseCount`, `pendingToolCallCount`, `unmatchedPostToolUseCount`,
118
+ `duplicateToolCallCount` and `partialEventCount`, plus `sessionIdentified`,
119
+ `sessionStarted` and `sessionStopped` for the root session. Counts cover all
120
+ successfully parsed receipts, including receipts omitted from `calls`; an
121
+ unreadable or invalid tail remains unknown. Tool pairing spans the observed
122
+ sessions and uses session and tool-use identities. A later balanced pair is
123
+ still counted after an earlier anomaly.
124
+
125
+ `calls` is a bounded selection of complete matches, fragment-only matches,
126
+ ambiguous or partial receipts, and receipts associated with pending, orphaned
127
+ or duplicate tool identities. Each entry's `fragmentSkills` names recognizable
128
+ fragments without exposing their body offsets or digests. A fragment entry can
129
+ remain in `calls` after later complete exposure resolves `partialSkills`.
130
+ Neither `partial: false` on an individual receipt nor an empty `partialSkills`
131
+ array proves aggregate completeness. `truncated` describes this selected call
132
+ list, which is never the complete observer event ledger. A pending tool hook
133
+ can follow a rejected launch or a missing hook; it does not establish either
134
+ cause, and a tool error with a balanced hook pair does not itself imply an
135
+ observer gap. These diagnostics preserve unknown activation verdicts and do
136
+ not recover activation from native transcripts.
137
+
138
+ Codex also reports `primarySkill` and `primarySkillCompleteness` separately
139
+ from whole-collection completeness. A complete collection establishes its first
140
+ observed skill when the first matching receipt credits exactly one skill, or
141
+ `null` when none was observed. Multiple bodies credited by the first receipt
142
+ leave primary selection unknown even with complete collection; their catalog
143
+ order does not prove selection order. A partial collection can still
144
+ establish the primary skill when its only gap is pending tool calls, exactly one
145
+ skill was credited by the first matching receipt, and every call started through
146
+ that receipt has a matching post hook. A later pending call cannot change that
147
+ established first selection. An earlier or overlapping pending call leaves the
148
+ primary unknown (`null` with `primarySkillCompleteness: partial`). Other
149
+ collection gaps remain conservative. Consumers may use the established primary
150
+ for first-selection assertions while retaining the partial collection status;
151
+ this claim does not establish exclusions or a complete supporting sequence.
152
+ When a partial collection establishes the primary, the same collector emits
153
+ `sevro.host.skill-activation-claims` as complete evidence with scope
154
+ `primary_and_positive_presence`, the qualified `primarySkill`, and `presentSkills`.
155
+ It retains `collectionCompleteness: partial`. Consumers cite this receipt for
156
+ those specific claims, preserving the protocol's requirement that passing
157
+ results cite complete evidence. It cannot establish an exclusion or an
158
+ unobserved supporting read and is absent when the primary is unknown.
159
+
93
160
  Claude's `skill_tool_result` method observes its existing structured `Skill`
94
161
  stream without enabling additional command hooks. It pairs each call with a
95
162
  successful result under the same session, parent actor and tool-use ID.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjoernrochel/sevro",
3
- "version": "0.1.0-rc.19",
3
+ "version": "0.1.0-rc.20",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "A standalone evaluation runner for agentic coding capabilities",
6
6
  "type": "module",
@@ -44,6 +44,7 @@ import { codexSkillCatalogRoots } from "./skill-hook-catalog";
44
44
  import {
45
45
  prepareSkillTracking,
46
46
  skillTrackingObservation,
47
+ skillActivationClaims,
47
48
  type SkillTracking,
48
49
  } from "./skill-hooks";
49
50
  import {
@@ -139,6 +140,7 @@ function codexCapabilities(
139
140
  "sevro.host.native-transcripts",
140
141
  "sevro.host.hooks",
141
142
  "sevro.host.skill-activations",
143
+ "sevro.host.skill-activation-claims",
142
144
  ...(entrypoint === "app-server" ? ["sevro.host.native-goal"] : []),
143
145
  "sevro.codex.plugin-marketplace",
144
146
  "sevro.codex.explicit-invocation",
@@ -732,6 +734,11 @@ function appServerObservation(result: AppServerResult | null): Observation[] {
732
734
  : [];
733
735
  }
734
736
  async function codexObservations(context: ExecutionContext, turns: TurnResult) {
737
+ const tracking = await skillTrackingObservation(
738
+ context.skillTracking,
739
+ turns.summary.threadId,
740
+ turns.summary.complete,
741
+ );
735
742
  const native = await codexNativeCallObservation(
736
743
  context.state.codexHome,
737
744
  turns.summary.threadId,
@@ -752,11 +759,8 @@ async function codexObservations(context: ExecutionContext, turns: TurnResult) {
752
759
  codexNativeControls(native),
753
760
  ...(turns.continuation ? [turns.continuation] : []),
754
761
  ...(explicit ? [explicit] : []),
755
- await skillTrackingObservation(
756
- context.skillTracking,
757
- turns.summary.threadId,
758
- turns.summary.complete,
759
- ),
762
+ tracking,
763
+ ...skillActivationClaims(tracking),
760
764
  ];
761
765
  }
762
766
 
@@ -142,69 +142,107 @@ function receiptLabels(value: Record<string, unknown>): boolean {
142
142
 
143
143
  async function receipts(tracking: SkillTracking) {
144
144
  const root = tracking.configuration.receipts;
145
- const result = { events: [] as SkillHookReceipt[], partial: false };
145
+ const result = { events: [] as SkillHookReceipt[], reasons: [] as string[] };
146
+ if (await Bun.file(join(root, "partial")).exists())
147
+ result.reasons.push("observer_failure");
146
148
  try {
147
149
  const path = join(root, "events.jsonl");
148
150
  if ((await stat(path)).size > MAX_EVENTS_BYTES)
149
- throw new Error("oversized skill hook receipts");
150
- result.partial = await Bun.file(join(root, "partial")).exists();
151
- for (const line of (await readFile(path, "utf8")).trimEnd().split("\n")) {
152
- const event: unknown = JSON.parse(line);
153
- if (!isReceipt(event, tracking.configuration.skills))
154
- throw new Error("invalid skill hook receipt");
155
- result.events.push(event);
156
- }
157
- } catch {
158
- result.partial = true;
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
+ );
159
163
  }
160
164
  return result;
161
165
  }
162
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
+
163
180
  function hookKey(event: SkillHookReceipt): string {
164
181
  return JSON.stringify([event.sessionId, event.toolUseId]);
165
182
  }
166
183
 
167
- function collectionComplete(
184
+ function collectionSummary(
168
185
  events: SkillHookReceipt[],
169
186
  sessionId: string | null,
170
- ): boolean {
171
- if (!lifecycleAvailable(events, sessionId)) return false;
187
+ ) {
172
188
  const ledger = {
173
189
  pending: new Set<string>(),
174
190
  finished: new Set<string>(),
175
- partial: false,
191
+ diagnosticKeys: new Set<string>(),
192
+ duplicateToolCallCount: 0,
193
+ unmatchedPostToolUseCount: 0,
176
194
  };
177
195
  for (const event of events) updatePending(event, ledger);
178
- return (
179
- ledger.pending.size === 0 &&
180
- !ledger.partial &&
181
- events.every((event) => !event.partial)
182
- );
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
+ };
183
209
  }
184
210
 
185
- function lifecycleAvailable(
211
+ function lifecycleSummary(
186
212
  events: SkillHookReceipt[],
187
213
  sessionId: string | null,
188
- ): boolean {
214
+ ) {
189
215
  const selected = events.filter((event) => event.sessionId === sessionId);
190
- return (
191
- sessionId !== null &&
192
- selected.some((event) => event.event === "SessionStart") &&
193
- selected.some((event) => event.event === "Stop")
194
- );
216
+ return {
217
+ sessionIdentified: sessionId !== null,
218
+ sessionStarted: selected.some((event) => event.event === "SessionStart"),
219
+ sessionStopped: selected.some((event) => event.event === "Stop"),
220
+ };
195
221
  }
196
222
 
197
223
  function updatePending(
198
224
  event: SkillHookReceipt,
199
- ledger: { pending: Set<string>; finished: Set<string>; partial: boolean },
225
+ ledger: {
226
+ pending: Set<string>;
227
+ finished: Set<string>;
228
+ diagnosticKeys: Set<string>;
229
+ duplicateToolCallCount: number;
230
+ unmatchedPostToolUseCount: number;
231
+ },
200
232
  ) {
201
233
  const key = hookKey(event);
202
234
  if (event.event === "PreToolUse") {
203
- ledger.partial ||= duplicateCall(key, ledger);
235
+ if (duplicateCall(key, ledger)) {
236
+ ledger.duplicateToolCallCount++;
237
+ ledger.diagnosticKeys.add(key);
238
+ }
204
239
  ledger.pending.add(key);
205
240
  }
206
241
  if (event.event === "PostToolUse") {
207
- ledger.partial ||= !ledger.pending.delete(key);
242
+ if (!ledger.pending.delete(key)) {
243
+ ledger.unmatchedPostToolUseCount++;
244
+ ledger.diagnosticKeys.add(key);
245
+ }
208
246
  ledger.finished.add(key);
209
247
  }
210
248
  }
@@ -299,7 +337,12 @@ function pagedEvents(events: SkillHookReceipt[], skills: HookSkill[]) {
299
337
  function publicReceipt(event: SkillHookReceipt) {
300
338
  const receipt = { ...event };
301
339
  delete receipt.bodyRanges;
302
- return receipt;
340
+ return {
341
+ ...receipt,
342
+ fragmentSkills: [
343
+ ...new Set((event.bodyRanges ?? []).map((page) => page.invocation)),
344
+ ],
345
+ };
303
346
  }
304
347
 
305
348
  function incompleteSkills(events: SkillHookReceipt[]) {
@@ -317,10 +360,15 @@ function incompleteSkills(events: SkillHookReceipt[]) {
317
360
  return [...fragments].filter((invocation) => !settled.has(invocation));
318
361
  }
319
362
 
320
- function retainedCalls(events: SkillHookReceipt[]) {
321
- const calls: SkillHookReceipt[] = [];
363
+ function retainedCalls(
364
+ events: SkillHookReceipt[],
365
+ diagnosticKeys: Set<string>,
366
+ ) {
367
+ const calls: ReturnType<typeof publicReceipt>[] = [];
322
368
  let truncated = false;
323
- for (const event of events.filter((event) => event.invocations.length > 0)) {
369
+ for (const event of events.filter((event) =>
370
+ relevantCall(event, diagnosticKeys),
371
+ )) {
324
372
  calls.push(publicReceipt(event));
325
373
  if (
326
374
  calls.length > MAX_RETAINED_CALLS ||
@@ -334,24 +382,113 @@ function retainedCalls(events: SkillHookReceipt[]) {
334
382
  return { calls, truncated };
335
383
  }
336
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
+
337
452
  export async function skillTrackingObservation(
338
453
  tracking: SkillTracking,
339
454
  sessionId: string | null,
340
455
  completed: boolean,
341
456
  ) {
342
457
  const collected = await receipts(tracking);
343
- const { partial } = collected;
344
458
  const events = pagedEvents(collected.events, tracking.configuration.skills);
345
459
  const partialSkills = incompleteSkills(events);
346
- const { calls, truncated } = retainedCalls(events);
347
- const complete = [
348
- completed,
349
- !partial,
350
- !truncated,
351
- partialSkills.length === 0,
352
- tracking.configuration.catalogComplete,
353
- collectionComplete(events, sessionId),
354
- ].every(Boolean);
460
+ const { diagnosticKeys, ...collection } = collectionSummary(
461
+ events,
462
+ sessionId,
463
+ );
464
+ const { calls, truncated } = retainedCalls(events, diagnosticKeys);
465
+ const ambiguousSkills = [
466
+ ...new Set(events.flatMap((event) => event.ambiguousInvocations)),
467
+ ];
468
+ const conditions: [string, boolean][] = [
469
+ ["host_incomplete", !completed],
470
+ ["catalog_incomplete", !tracking.configuration.catalogComplete],
471
+ ["session_identity_missing", !collection.sessionIdentified],
472
+ ["session_start_missing", !collection.sessionStarted],
473
+ ["session_stop_missing", !collection.sessionStopped],
474
+ ["pending_tool_calls", collection.pendingToolCallCount > 0],
475
+ ["unmatched_post_tool_use", collection.unmatchedPostToolUseCount > 0],
476
+ ["duplicate_tool_calls", collection.duplicateToolCallCount > 0],
477
+ [
478
+ "partial_hook_events",
479
+ events.some(
480
+ (event) => event.partial && event.ambiguousInvocations.length === 0,
481
+ ),
482
+ ],
483
+ ["ambiguous_skill_bodies", ambiguousSkills.length > 0],
484
+ ["partial_skill_bodies", partialSkills.length > 0],
485
+ ["calls_truncated", truncated],
486
+ ];
487
+ const incompleteReasons = [
488
+ ...collected.reasons,
489
+ ...conditions.filter(([, applies]) => applies).map(([reason]) => reason),
490
+ ];
491
+ const complete = incompleteReasons.length === 0;
355
492
  return {
356
493
  id: "sevro.host.skill-activations",
357
494
  completeness: complete ? ("complete" as const) : ("partial" as const),
@@ -361,12 +498,13 @@ export async function skillTrackingObservation(
361
498
  scope: "prepared_skill_bodies",
362
499
  catalogComplete: tracking.configuration.catalogComplete,
363
500
  observedSkills: [...new Set(calls.flatMap((call) => call.invocations))],
501
+ ...primaryEvidence(events, incompleteReasons),
364
502
  partialSkills,
365
- ambiguousSkills: [
366
- ...new Set(events.flatMap((event) => event.ambiguousInvocations)),
367
- ],
503
+ ambiguousSkills,
368
504
  calls,
369
505
  truncated,
506
+ incompleteReasons,
507
+ collection,
370
508
  },
371
509
  };
372
510
  }