@skill-harness/core 0.6.0 → 0.7.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.
@@ -0,0 +1,193 @@
1
+ import type { CaptureCaseV1, CaptureClassification, CaptureTarget, SanitizedArgs } from "./capture-trace-types.js";
2
+ /**
3
+ * Projection and sanitization for `/skill-harness capture` — turning a live pi
4
+ * conversation into a reviewable regression case.
5
+ *
6
+ * Everything here is pure: entries in, plain data out. The pi extension owns the
7
+ * UI and the filesystem; this module owns the rules that decide what a capture
8
+ * is allowed to contain. That split is deliberate — the privacy rules are the
9
+ * part that must be unit-testable without a running agent.
10
+ *
11
+ * Nothing in here calls a model. Checklist drafting is offline by default; any
12
+ * LLM assistance is the extension's business and requires its own confirmation.
13
+ */
14
+ export interface SessionContentBlock {
15
+ type: string;
16
+ text?: string;
17
+ thinking?: string;
18
+ name?: string;
19
+ arguments?: unknown;
20
+ [k: string]: unknown;
21
+ }
22
+ export interface SessionMessage {
23
+ role: string;
24
+ content?: SessionContentBlock[];
25
+ toolName?: string;
26
+ isError?: boolean;
27
+ [k: string]: unknown;
28
+ }
29
+ /**
30
+ * One entry from a pi session file.
31
+ *
32
+ * Entries form a linked list through `parentId`, NOT a flat log: forking and
33
+ * rewinding leave sibling chains in the same file. Anything that reads a session
34
+ * without resolving the branch first will happily mix two alternate histories
35
+ * into one "conversation".
36
+ */
37
+ export interface SessionEntry {
38
+ type: string;
39
+ id?: string;
40
+ parentId?: string | null;
41
+ timestamp?: string;
42
+ message?: SessionMessage;
43
+ [k: string]: unknown;
44
+ }
45
+ /**
46
+ * The chain of entries ending at `leafId`, in root→leaf order.
47
+ *
48
+ * With no `leafId`, the leaf is the last entry in file order — which is what
49
+ * "the conversation I am currently in" means. Entries with no `id` (the
50
+ * `session` header) are not part of the chain and are dropped.
51
+ *
52
+ * Cycles cannot occur in a well-formed file, but a corrupted one must not hang
53
+ * the agent, so the walk is bounded by the entry count.
54
+ */
55
+ export declare function activeBranch(entries: SessionEntry[], leafId?: string): SessionEntry[];
56
+ /** A tool call as it is summarized for review — never the result body. */
57
+ export interface ToolCallSummary {
58
+ name: string;
59
+ args: SanitizedArgs;
60
+ isError: boolean;
61
+ /**
62
+ * pi's `toolCallId`, when the entry carried one.
63
+ *
64
+ * Correlation key for the matching result. Parallel tool calls complete out of
65
+ * issue order (measured on pi 0.83.0), so pairing by position is wrong the
66
+ * moment a conversation uses parallelism — which is exactly the kind of
67
+ * conversation worth capturing.
68
+ */
69
+ id?: string;
70
+ /** Byte length of the result content, when the result was present. */
71
+ resultBytes?: number;
72
+ /** SHA-256 of the result content, so identity is checkable without the body. */
73
+ resultSha256?: string;
74
+ }
75
+ /**
76
+ * One user message and everything the agent did in reply, up to the next user
77
+ * message.
78
+ *
79
+ * This is the selection unit for capture. pi exposes no public API for arbitrary
80
+ * mouse-highlighted transcript text, so turn-level contiguous selection is the
81
+ * supported contract — not a limitation we can quietly widen later.
82
+ */
83
+ export interface LogicalTurn {
84
+ index: number;
85
+ user: string;
86
+ /** Assistant text with thinking removed. Evidence for the human, never an oracle. */
87
+ assistantText: string;
88
+ toolCalls: ToolCallSummary[];
89
+ /** Entry ids covered by this turn. Local-only — never written to a committed capture. */
90
+ entryIds: string[];
91
+ }
92
+ /** Extract visible text from content blocks. Thinking is dropped unconditionally. */
93
+ export declare function visibleText(blocks: SessionContentBlock[] | undefined): string;
94
+ /**
95
+ * Group an already-branch-resolved entry list into logical turns.
96
+ *
97
+ * Entries that are not messages (`model_change`, `thinking_level_change`,
98
+ * compaction markers, custom extension entries) are skipped rather than
99
+ * guessed at: an unknown entry type is not evidence about the conversation.
100
+ * Anything before the first user message is dropped — it belongs to no turn.
101
+ */
102
+ export declare function projectTurns(entries: SessionEntry[], homeDir?: string): LogicalTurn[];
103
+ /** Values longer than this are truncated rather than persisted whole. */
104
+ export declare const MAX_VALUE_CHARS = 2000;
105
+ /**
106
+ * Redact secrets and machine paths from a free-text string.
107
+ *
108
+ * Home directories are replaced because a capture is meant to be committed, and
109
+ * `/home/<name>/…` identifies a person as surely as a token identifies an
110
+ * account. `homeDir` is a parameter rather than a read of `process.env.HOME` so
111
+ * the behavior is testable and does not differ between a developer's machine
112
+ * and CI.
113
+ */
114
+ export declare function redactText(input: string, homeDir?: string): string;
115
+ /** Truncate with an explicit marker — silent truncation reads as complete evidence. */
116
+ export declare function truncate(input: string, max?: number): string;
117
+ /**
118
+ * Sanitize tool-call arguments: drop secret-named values, redact secret-shaped
119
+ * ones, truncate the oversized, and refuse to recurse without bound.
120
+ */
121
+ export declare function redactArgs(args: unknown, homeDir?: string, depth?: number): SanitizedArgs;
122
+ /** Stable, sortable, collision-resistant enough for a per-skill directory. */
123
+ export declare function captureId(seed: string, existing?: readonly string[]): string;
124
+ export interface BuildCaptureOptions {
125
+ turns: LogicalTurn[];
126
+ /** Inclusive selected range over `turns`. */
127
+ range: {
128
+ start: number;
129
+ end: number;
130
+ };
131
+ classification: CaptureClassification;
132
+ expectedBehavior: string;
133
+ checklist: string[];
134
+ target: CaptureTarget;
135
+ sessionPath: string;
136
+ created: string;
137
+ subject?: {
138
+ provider: string;
139
+ model: string;
140
+ };
141
+ gitCommit?: string;
142
+ gitDirty?: boolean;
143
+ homeDir?: string;
144
+ existingIds?: readonly string[];
145
+ }
146
+ /**
147
+ * Assemble a reviewed capture case.
148
+ *
149
+ * Only the USER turns are carried into the case: they are the stimulus, and they
150
+ * are the only part that can be replayed. The assistant's historical prose is
151
+ * evidence for the human writing the expectation, never an exact-output oracle —
152
+ * so it lives in the git-ignored local sidecar, not here.
153
+ *
154
+ * The session path is hashed rather than stored: an absolute path names a
155
+ * machine and a user, and a hash is enough to recognize the same session again.
156
+ */
157
+ export declare function buildCaptureCase(opts: BuildCaptureOptions): CaptureCaseV1;
158
+ /**
159
+ * Project a reviewed capture into a scenario object for `appendScenario`.
160
+ *
161
+ * Deliberately minimal: id, title, turns, checklist. No `critical`, no gates, no
162
+ * fixture. Promotion is the moment a human takes responsibility for a test, and
163
+ * a capture cannot know whether the behavior it saw is ship-blocking — guessing
164
+ * `critical: true` here would let a captured one-off silently gate a release.
165
+ */
166
+ export declare function captureToScenario(capture: CaptureCaseV1, scenarioId: string, title: string): Record<string, unknown>;
167
+ /**
168
+ * Draft an orchestration assertion from subagent calls seen in the captured range.
169
+ *
170
+ * Returns null when the range contains none, so the capture UI can skip the
171
+ * question entirely rather than offering an empty form.
172
+ *
173
+ * Deliberately proposes only `agent` and `count` — never `task_contains`. The
174
+ * task text that happened to be sent is not the task text that is *required*;
175
+ * turning one observed handoff into a required substring would manufacture a
176
+ * brittle assertion the author never reasoned about. Required context is a
177
+ * judgement, so the UI asks.
178
+ */
179
+ export declare function draftSubagentAssertion(turns: LogicalTurn[], toolNames?: readonly string[]): {
180
+ tool: string;
181
+ agent: string;
182
+ count: {
183
+ min: number;
184
+ };
185
+ } | null;
186
+ /**
187
+ * Offline first-draft checklist from the human's expectation.
188
+ *
189
+ * Splits on sentence and bullet boundaries. This is a typing shortcut for the
190
+ * editor step, not an understanding of the text — the UI always opens the result
191
+ * for correction, and the plan makes LLM drafting a separate, paid, opt-in path.
192
+ */
193
+ export declare function draftChecklist(expectedBehavior: string): string[];
@@ -0,0 +1,344 @@
1
+ import { createHash } from "node:crypto";
2
+ import { sep } from "node:path";
3
+ import { CAPTURE_SCHEMA_VERSION } from "./capture-trace-types.js";
4
+ /**
5
+ * The chain of entries ending at `leafId`, in root→leaf order.
6
+ *
7
+ * With no `leafId`, the leaf is the last entry in file order — which is what
8
+ * "the conversation I am currently in" means. Entries with no `id` (the
9
+ * `session` header) are not part of the chain and are dropped.
10
+ *
11
+ * Cycles cannot occur in a well-formed file, but a corrupted one must not hang
12
+ * the agent, so the walk is bounded by the entry count.
13
+ */
14
+ export function activeBranch(entries, leafId) {
15
+ const byId = new Map();
16
+ for (const e of entries)
17
+ if (typeof e.id === "string")
18
+ byId.set(e.id, e);
19
+ let cursor = leafId;
20
+ if (cursor === undefined) {
21
+ for (let i = entries.length - 1; i >= 0; i--) {
22
+ if (typeof entries[i].id === "string") {
23
+ cursor = entries[i].id;
24
+ break;
25
+ }
26
+ }
27
+ }
28
+ const chain = [];
29
+ const seen = new Set();
30
+ while (cursor !== undefined && cursor !== null && byId.has(cursor) && chain.length <= entries.length) {
31
+ if (seen.has(cursor))
32
+ break;
33
+ seen.add(cursor);
34
+ const entry = byId.get(cursor);
35
+ chain.push(entry);
36
+ cursor = entry.parentId ?? undefined;
37
+ }
38
+ return chain.reverse();
39
+ }
40
+ /** Extract visible text from content blocks. Thinking is dropped unconditionally. */
41
+ export function visibleText(blocks) {
42
+ if (!blocks)
43
+ return "";
44
+ const parts = [];
45
+ for (const b of blocks) {
46
+ if (b.type === "thinking")
47
+ continue; // never, at any call site
48
+ if (b.type === "text" && typeof b.text === "string")
49
+ parts.push(b.text);
50
+ else if (b.type === "image")
51
+ parts.push("[image omitted]");
52
+ }
53
+ return parts.join("\n").trim();
54
+ }
55
+ /**
56
+ * Group an already-branch-resolved entry list into logical turns.
57
+ *
58
+ * Entries that are not messages (`model_change`, `thinking_level_change`,
59
+ * compaction markers, custom extension entries) are skipped rather than
60
+ * guessed at: an unknown entry type is not evidence about the conversation.
61
+ * Anything before the first user message is dropped — it belongs to no turn.
62
+ */
63
+ export function projectTurns(entries, homeDir) {
64
+ const turns = [];
65
+ let current = null;
66
+ for (const entry of entries) {
67
+ if (entry.type !== "message" || !entry.message)
68
+ continue;
69
+ const msg = entry.message;
70
+ const id = typeof entry.id === "string" ? entry.id : "";
71
+ if (msg.role === "user") {
72
+ current = {
73
+ index: turns.length,
74
+ user: visibleText(msg.content),
75
+ assistantText: "",
76
+ toolCalls: [],
77
+ entryIds: id ? [id] : [],
78
+ };
79
+ turns.push(current);
80
+ continue;
81
+ }
82
+ if (!current)
83
+ continue; // pre-conversation noise belongs to no turn
84
+ if (id)
85
+ current.entryIds.push(id);
86
+ if (msg.role === "assistant") {
87
+ const text = visibleText(msg.content);
88
+ if (text)
89
+ current.assistantText = current.assistantText ? `${current.assistantText}\n${text}` : text;
90
+ for (const b of msg.content ?? []) {
91
+ if (b.type !== "toolCall")
92
+ continue;
93
+ current.toolCalls.push({
94
+ name: typeof b.name === "string" ? b.name : "(unknown)",
95
+ // Without `homeDir` this scrubbed secrets but left absolute home paths
96
+ // intact — and these args are what the evidence sidecar records.
97
+ args: redactArgs(b.arguments, homeDir),
98
+ isError: false,
99
+ ...(typeof b.id === "string" ? { id: b.id } : {}),
100
+ });
101
+ }
102
+ continue;
103
+ }
104
+ if (msg.role === "toolResult") {
105
+ // Attach the outcome to the matching call. Result BODIES are never kept —
106
+ // they routinely carry file contents, command output and absolute paths.
107
+ const body = JSON.stringify(msg.content ?? []);
108
+ const callId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
109
+ // By id when pi gave us one; otherwise the FIRST still-unmatched call of
110
+ // that name, since results arrive in order for a sequential conversation.
111
+ const target = callId
112
+ ? current.toolCalls.find((c) => c.id === callId)
113
+ : current.toolCalls.find((c) => c.name === msg.toolName && c.resultBytes === undefined);
114
+ if (target) {
115
+ target.isError = msg.isError === true;
116
+ target.resultBytes = Buffer.byteLength(body, "utf8");
117
+ target.resultSha256 = sha256(body);
118
+ }
119
+ }
120
+ }
121
+ return turns;
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Redaction
125
+ // ---------------------------------------------------------------------------
126
+ /** Values longer than this are truncated rather than persisted whole. */
127
+ export const MAX_VALUE_CHARS = 2000;
128
+ const REDACTED = "[redacted]";
129
+ /** Argument/field names whose VALUE is always dropped, whatever it looks like. */
130
+ const SECRET_KEY = /^(.*[-_])?(password|passwd|secret|token|api[-_]?key|apikey|auth|authorization|credential|private[-_]?key|access[-_]?key|session[-_]?key)([-_].*)?$/i;
131
+ /** Value-shaped secrets, caught even under an innocuous key name. */
132
+ const SECRET_VALUE = [
133
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
134
+ /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/g,
135
+ /\bsk-[A-Za-z0-9]{16,}\b/g,
136
+ /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
137
+ /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g,
138
+ /\bAKIA[0-9A-Z]{16}\b/g,
139
+ /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, // JWT
140
+ // Credentials embedded in a URL — `postgres://user:pass@host/db`. Caught by
141
+ // shape rather than by key name: the key is usually something like `DB_URL`,
142
+ // which no list of secret-sounding names will ever match.
143
+ /\b([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s@]+@/gi,
144
+ ];
145
+ /**
146
+ * Redact secrets and machine paths from a free-text string.
147
+ *
148
+ * Home directories are replaced because a capture is meant to be committed, and
149
+ * `/home/<name>/…` identifies a person as surely as a token identifies an
150
+ * account. `homeDir` is a parameter rather than a read of `process.env.HOME` so
151
+ * the behavior is testable and does not differ between a developer's machine
152
+ * and CI.
153
+ */
154
+ export function redactText(input, homeDir) {
155
+ let out = input;
156
+ // The URL pattern keeps its scheme so the value stays recognisable as a URL;
157
+ // every other pattern replaces the whole match.
158
+ out = out.replace(SECRET_VALUE[SECRET_VALUE.length - 1], `$1${REDACTED}@`);
159
+ for (const re of SECRET_VALUE.slice(0, -1))
160
+ out = out.replace(re, REDACTED);
161
+ if (homeDir && homeDir.length > 1) {
162
+ out = out.split(homeDir).join("~");
163
+ }
164
+ return out;
165
+ }
166
+ /** Truncate with an explicit marker — silent truncation reads as complete evidence. */
167
+ export function truncate(input, max = MAX_VALUE_CHARS) {
168
+ if (input.length <= max)
169
+ return input;
170
+ return `${input.slice(0, max)}… [truncated ${input.length - max} chars]`;
171
+ }
172
+ /**
173
+ * Sanitize tool-call arguments: drop secret-named values, redact secret-shaped
174
+ * ones, truncate the oversized, and refuse to recurse without bound.
175
+ */
176
+ export function redactArgs(args, homeDir, depth = 0) {
177
+ if (args === null || typeof args !== "object" || Array.isArray(args))
178
+ return {};
179
+ const out = {};
180
+ for (const [key, value] of Object.entries(args)) {
181
+ if (SECRET_KEY.test(key)) {
182
+ out[key] = REDACTED;
183
+ continue;
184
+ }
185
+ out[key] = redactValue(value, homeDir, depth);
186
+ }
187
+ return out;
188
+ }
189
+ function redactValue(value, homeDir, depth) {
190
+ if (typeof value === "string")
191
+ return truncate(redactText(value, homeDir));
192
+ if (typeof value === "number" || typeof value === "boolean" || value === null)
193
+ return value;
194
+ if (depth >= 3)
195
+ return "[nested]"; // bounded: a deep object is not review evidence
196
+ if (Array.isArray(value))
197
+ return value.slice(0, 20).map((v) => redactValue(v, homeDir, depth + 1));
198
+ if (typeof value === "object")
199
+ return redactArgs(value, homeDir, depth + 1);
200
+ return String(value);
201
+ }
202
+ function sha256(text) {
203
+ return createHash("sha256").update(text, "utf8").digest("hex");
204
+ }
205
+ // ---------------------------------------------------------------------------
206
+ // Building a capture case
207
+ // ---------------------------------------------------------------------------
208
+ /** Stable, sortable, collision-resistant enough for a per-skill directory. */
209
+ export function captureId(seed, existing = []) {
210
+ const taken = new Set(existing);
211
+ const base = `CAP-${sha256(seed).slice(0, 6).toUpperCase()}`;
212
+ if (!taken.has(base))
213
+ return base;
214
+ for (let n = 2;; n++) {
215
+ const candidate = `${base}-${n}`;
216
+ if (!taken.has(candidate))
217
+ return candidate;
218
+ }
219
+ }
220
+ /**
221
+ * Assemble a reviewed capture case.
222
+ *
223
+ * Only the USER turns are carried into the case: they are the stimulus, and they
224
+ * are the only part that can be replayed. The assistant's historical prose is
225
+ * evidence for the human writing the expectation, never an exact-output oracle —
226
+ * so it lives in the git-ignored local sidecar, not here.
227
+ *
228
+ * The session path is hashed rather than stored: an absolute path names a
229
+ * machine and a user, and a hash is enough to recognize the same session again.
230
+ */
231
+ export function buildCaptureCase(opts) {
232
+ const { start, end } = opts.range;
233
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= opts.turns.length) {
234
+ throw new Error(`invalid capture range ${start}..${end} over ${opts.turns.length} turn(s)`);
235
+ }
236
+ if (opts.expectedBehavior.trim() === "") {
237
+ throw new Error("a capture needs a written expected behavior — it is what makes the case reviewable");
238
+ }
239
+ const checklist = opts.checklist.map((c) => c.trim()).filter(Boolean);
240
+ if (checklist.length === 0) {
241
+ throw new Error("a capture needs at least one checklist item");
242
+ }
243
+ const selected = opts.turns.slice(start, end + 1);
244
+ const turns = selected.map((t) => truncate(redactText(t.user, opts.homeDir)));
245
+ const id = captureId(`${opts.sessionPath}:${start}:${end}:${opts.created}`, opts.existingIds ?? []);
246
+ return {
247
+ capture_schema: CAPTURE_SCHEMA_VERSION,
248
+ id,
249
+ created: opts.created,
250
+ classification: opts.classification,
251
+ turns,
252
+ expected_behavior: truncate(redactText(opts.expectedBehavior, opts.homeDir)),
253
+ checklist: checklist.map((c) => truncate(redactText(c, opts.homeDir), 300)),
254
+ target: opts.target,
255
+ // `covers` refs resolve against the SPEC dir, and `target.path` is relative
256
+ // to the skill root — one level up. A subagent path (`.pi/agents/x.md`) is
257
+ // carried the same way; both are instruction files a section walk can read.
258
+ covers: [`../${opts.target.path.split(sep).join("/")}`],
259
+ provenance: {
260
+ session_sha256: sha256(opts.sessionPath),
261
+ turn_range: { start, end },
262
+ ...(opts.subject ? { subject: opts.subject } : {}),
263
+ ...(opts.gitCommit ? { git_commit: opts.gitCommit } : {}),
264
+ ...(opts.gitDirty === undefined ? {} : { git_dirty: opts.gitDirty }),
265
+ },
266
+ status: "pending",
267
+ };
268
+ }
269
+ /**
270
+ * Project a reviewed capture into a scenario object for `appendScenario`.
271
+ *
272
+ * Deliberately minimal: id, title, turns, checklist. No `critical`, no gates, no
273
+ * fixture. Promotion is the moment a human takes responsibility for a test, and
274
+ * a capture cannot know whether the behavior it saw is ship-blocking — guessing
275
+ * `critical: true` here would let a captured one-off silently gate a release.
276
+ */
277
+ export function captureToScenario(capture, scenarioId, title) {
278
+ return {
279
+ id: scenarioId,
280
+ title,
281
+ turns: capture.turns,
282
+ checklist: capture.checklist,
283
+ };
284
+ }
285
+ /**
286
+ * Draft an orchestration assertion from subagent calls seen in the captured range.
287
+ *
288
+ * Returns null when the range contains none, so the capture UI can skip the
289
+ * question entirely rather than offering an empty form.
290
+ *
291
+ * Deliberately proposes only `agent` and `count` — never `task_contains`. The
292
+ * task text that happened to be sent is not the task text that is *required*;
293
+ * turning one observed handoff into a required substring would manufacture a
294
+ * brittle assertion the author never reasoned about. Required context is a
295
+ * judgement, so the UI asks.
296
+ */
297
+ export function draftSubagentAssertion(turns, toolNames = ["Agent", "subagent", "task"]) {
298
+ const names = new Set(toolNames.map((n) => n.toLowerCase()));
299
+ for (const turn of turns) {
300
+ for (const call of turn.toolCalls) {
301
+ if (!names.has(call.name.toLowerCase()))
302
+ continue;
303
+ const invocations = subagentInvocationsOf(call.args);
304
+ if (invocations.length === 0)
305
+ continue;
306
+ return { tool: call.name, agent: invocations[0], count: { min: 1 } };
307
+ }
308
+ }
309
+ return null;
310
+ }
311
+ /** Agent names visible in a call's arguments, across the known shapes. */
312
+ function subagentInvocationsOf(args) {
313
+ const nameOf = (v) => {
314
+ if (v === null || typeof v !== "object" || Array.isArray(v))
315
+ return null;
316
+ const o = v;
317
+ if (typeof o.agent === "string")
318
+ return o.agent;
319
+ if (typeof o.name === "string")
320
+ return o.name;
321
+ return null;
322
+ };
323
+ for (const key of ["tasks", "chain"]) {
324
+ const list = args[key];
325
+ if (Array.isArray(list))
326
+ return list.map(nameOf).filter((x) => x !== null);
327
+ }
328
+ const single = nameOf(args);
329
+ return single ? [single] : [];
330
+ }
331
+ /**
332
+ * Offline first-draft checklist from the human's expectation.
333
+ *
334
+ * Splits on sentence and bullet boundaries. This is a typing shortcut for the
335
+ * editor step, not an understanding of the text — the UI always opens the result
336
+ * for correction, and the plan makes LLM drafting a separate, paid, opt-in path.
337
+ */
338
+ export function draftChecklist(expectedBehavior) {
339
+ return expectedBehavior
340
+ .split(/\n\s*[-*]\s+|\n{2,}|(?<=[.!?])\s+(?=[A-Z])/)
341
+ .map((s) => s.replace(/^[-*]\s+/, "").replace(/\s+/g, " ").trim().replace(/[.]$/, ""))
342
+ .filter((s) => s.length > 3);
343
+ }
344
+ //# sourceMappingURL=capture.js.map
@@ -0,0 +1,61 @@
1
+ import type { ExecutionTraceV1 } from "./capture-trace-types.js";
2
+ import type { ModelRef, RunMode } from "./adapters/types.js";
3
+ export interface TraceMeta {
4
+ piVersion: string | null;
5
+ subject: ModelRef;
6
+ scenarioId: string;
7
+ mode: RunMode;
8
+ rep: number;
9
+ turn: number;
10
+ /**
11
+ * Workspace paths observed to have changed; supplied by the runner, not the
12
+ * stream. Omitted means NOT OBSERVED, which the trace records as `null`.
13
+ */
14
+ changedPaths?: string[];
15
+ /** Home dir to scrub from arguments. */
16
+ homeDir?: string;
17
+ }
18
+ /**
19
+ * Build a trace from pi JSON lines.
20
+ *
21
+ * Malformed lines are counted, not thrown on: a single truncated line at the end
22
+ * of a killed process must not discard an otherwise complete trace. But a stream
23
+ * with NO terminal events at all is not a trace — `isComplete` says so, and the
24
+ * caller turns that into ERROR rather than a passing gate.
25
+ */
26
+ export declare function parseTrace(lines: Iterable<string>, meta: TraceMeta): {
27
+ trace: ExecutionTraceV1;
28
+ isComplete: boolean;
29
+ malformedLines: number;
30
+ };
31
+ /**
32
+ * Deterministic hash over the trace, excluding the hash field itself.
33
+ *
34
+ * Keys are emitted in a fixed order rather than whatever insertion produced, so
35
+ * the same execution always hashes the same — a digest that depends on key order
36
+ * would make `regate` report spurious drift.
37
+ */
38
+ export declare function traceSha256(trace: ExecutionTraceV1): string;
39
+ /** Serialize a trace as the JSONL artifact saved beside a transcript. */
40
+ export declare function serializeTrace(trace: ExecutionTraceV1): string;
41
+ /** Read a saved trace artifact back, for `regate`. Returns null when unusable. */
42
+ export declare function deserializeTrace(text: string): ExecutionTraceV1 | null;
43
+ /**
44
+ * Collapse a scenario's per-turn traces into one view for gate evaluation.
45
+ *
46
+ * Assertions are written about the scenario ("it delegated to `plan` at least
47
+ * once"), not about turn 3 — a multi-turn scenario would otherwise need the
48
+ * author to know which turn a tool call landed in, which is a property of the
49
+ * model's choices, not of the test.
50
+ *
51
+ * Indices are renumbered across the whole scenario so `issueIndex` stays a total
52
+ * order. `completionIndex` is renumbered within the concatenation too: turns are
53
+ * strictly sequential (each is a separate `pi` invocation), so no completion in
54
+ * turn 2 can precede one in turn 1.
55
+ *
56
+ * Returns null for an empty list — "no turns produced evidence" must not look
57
+ * like "a run in which nothing happened".
58
+ */
59
+ export declare function mergeTraces(traces: ExecutionTraceV1[]): ExecutionTraceV1 | null;
60
+ /** Split a raw stdout blob into lines. Prefer streaming; this is for saved blobs. */
61
+ export declare function lines(text: string): Generator<string>;