Skip to content

Commit 2af8721

Browse files
committed
feat: export provider-neutral research traces
1 parent c47285c commit 2af8721

50 files changed

Lines changed: 3603 additions & 312 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ report/
6161
cli/event-copy.js
6262
cli/message-formatter-utils.js
6363
cli/json-export.js
64+
cli/trace-export.js
65+
cli/semantic-export.js
66+
cli/agent-provider-boundary.js
67+
cli/export-stream.js
68+
cli/semantic-contract.js
69+
cli/semantic-canonical.js
70+
cli/semantic-events.js
71+
cli/semantic-evidence.js
72+
cli/semantic-json.js
73+
cli/semantic-line-scanner.js
74+
cli/semantic-parser.js
75+
cli/semantic-provider-line.js
76+
cli/trace-evidence.js
77+
cli/trace-output.js
78+
cli/trace-output-record.js
6479
cli/message-formatters-normal.js
6580
cli/message-formatters-watch.js
6681
test-metadata-manual.sh

AGENTS.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,9 @@ zeroshot stop <id> # Graceful stop
603603
zeroshot kill <id> # Force kill
604604

605605
# Utilities
606-
zeroshot export <id> # Export conversation
606+
zeroshot export <id> # Export rendered conversation
607+
zeroshot export <id> -f trace -o run.trace.jsonl # Export native research evidence
608+
zeroshot export <id> -f semantic -o run.semantic.jsonl # Project provider-neutral events
607609
zeroshot agents list # Available agents
608610
zeroshot settings # View/modify settings
609611
zeroshot providers # Provider status and defaults
@@ -965,6 +967,27 @@ bounded repair attempts. A persisted high-water allocator assigns explicit messa
965967
deleting compacted output can never reuse a sequence already returned to a caller; later readonly
966968
exports and cursors remain bounded and monotonic. JSON export must iterate ledger rows directly to
967969
its destination instead of materializing the whole ledger or pretty-printed document in memory.
970+
The `zeroshot.trace.v1` JSONL export is the provider-neutral native evidence boundary for research.
971+
It streams the ordered ledger, exact selected task prompts, and fixed-size base64 chunks of exact
972+
task-log bytes into one deterministic source bundle. Task IDs must come from causal ledger fields,
973+
limited to `AGENT_OUTPUT` and explicit task lifecycle records, never arbitrary topics or log-directory
974+
timestamps; host and isolated `AGENT_OUTPUT` records both carry that task ID. References inside the
975+
bundle are logical `zeroshot-trace://` identifiers only, never host paths. File destinations are
976+
create-only and never follow or replace an existing path. Missing or changing task evidence is an
977+
explicit sorted issue and makes the footer incomplete. A nonterminal task may be captured as a
978+
snapshot, but its output and bundle must remain incomplete.
979+
The separate `zeroshot.semantic.v1` JSONL export may project those same task logs only through the
980+
registered provider's existing stateful adapter lifecycle. It preserves the provider-neutral
981+
`OutputEvent` union, references native prompt/output identities and the raw-output digest, and keeps
982+
source completeness distinct from semantic completeness. Structurally identified Zeroshot wrapper
983+
footers and stderr records remain in native evidence but never enter a provider stdout parser.
984+
New watcher logs start with the exact `channel-framed-v2` format marker and frame provider stdout
985+
and stderr explicitly; `src/task-log-line.js` is the sole framing encoder/decoder for regular and
986+
OMP SDK writers and every consumer. OMP SDK errors become one failed terminal `result` event, and
987+
its diagnostics use the stderr frame. Consumers unwrap only the outer stdout frame. Legacy v1
988+
evidence remains readable without heuristic channel classification.
989+
Parser diagnostics never affect execution, scoring, or `zeroshot.trace.v1`. ATIF and viewer-specific
990+
projection still belongs downstream.
968991
POSIX providers run in a dedicated process group; Windows providers use the exact root PID with
969992
`taskkill /T`. Recovery must terminate that recorded boundary before retrying work. Command cleanup
970993
ownership is persisted with the task and may run only after that boundary is confirmed terminal.

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ zeroshot logs <id> -f # stream logs
146146
zeroshot resume <id> [prompt] # resume a stopped or failed run
147147
zeroshot stop <id> # graceful stop
148148
zeroshot kill <id> # force stop
149+
zeroshot export <id> --format trace --output run.trace.jsonl
150+
zeroshot export <id> --format semantic --output run.semantic.jsonl
149151

150152
zeroshot providers # provider availability and defaults
151153
zeroshot settings # effective settings
@@ -158,6 +160,16 @@ template supports `{{issue_number}}`, `{{issue_title}}`, and `{{issue_reference}
158160
expand to empty text for tasks without an issue, so manual runs never emit `Closes #unknown`.
159161
The unrendered template is retained for detached and resumed runs.
160162

163+
The `trace` export is a deterministic, provider-neutral research bundle. It preserves the ordered
164+
cluster ledger, exact selected prompts, and exact raw task-log bytes without interpreting a Claude,
165+
Codex, Pi, or other provider protocol. Missing evidence is recorded explicitly in its footer. File
166+
exports are create-only: choose a new output path rather than replacing an existing bundle. Live
167+
tasks are exported only as explicitly incomplete snapshots.
168+
The separate `semantic` export runs those task bytes through Zeroshot's existing stateful provider
169+
adapters and emits bounded `text`, `thinking`, `tool_call`, `tool_result`, and `result` events.
170+
Zeroshot-owned wrapper and stderr records remain native-only. Parser diagnostics affect only
171+
semantic completeness; they do not alter the native trace or run.
172+
161173
</details>
162174

163175
<details>

cli/agent-provider-boundary.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
interface TextEvent {
2+
readonly type: 'text' | 'thinking';
3+
readonly text: string;
4+
}
5+
6+
interface ToolCallEvent {
7+
readonly type: 'tool_call';
8+
readonly toolName: string | null | undefined;
9+
readonly toolId: string | null | undefined;
10+
readonly input: unknown;
11+
}
12+
13+
interface ToolResultEvent {
14+
readonly type: 'tool_result';
15+
readonly toolId: string | null | undefined;
16+
readonly content: unknown;
17+
readonly isError: unknown;
18+
}
19+
20+
interface ResultEvent {
21+
readonly type: 'result';
22+
readonly success: boolean;
23+
readonly result?: unknown;
24+
readonly error?: unknown;
25+
readonly cost?: unknown;
26+
readonly duration?: unknown;
27+
readonly inputTokens?: number;
28+
readonly outputTokens?: number;
29+
readonly cacheReadInputTokens?: number;
30+
readonly cacheCreationInputTokens?: number;
31+
readonly modelUsage?: unknown;
32+
readonly requests?: number;
33+
readonly usageSource?: unknown;
34+
readonly usageCompleteness?: unknown;
35+
readonly invocation?: unknown;
36+
readonly ompSdk?: unknown;
37+
}
38+
39+
export type OutputEvent = TextEvent | ToolCallEvent | ToolResultEvent | ResultEvent;
40+
export type ProviderParseResult = OutputEvent | readonly OutputEvent[] | null;
41+
42+
export interface ProviderAdapter {
43+
readonly id: string;
44+
readonly adapterVersion: string;
45+
createParserState(): object;
46+
parseEvent(line: string, state: object): ProviderParseResult;
47+
finishParsing?(state: object): ProviderParseResult;
48+
}
49+
50+
interface AdapterRuntime {
51+
getProviderAdapter(provider: string): ProviderAdapter;
52+
}
53+
54+
interface PrefixRuntime {
55+
stripTimestampPrefix(line: string): string;
56+
}
57+
58+
function isRuntime(value: unknown, method: string): boolean {
59+
return (
60+
value !== null && typeof value === 'object' && typeof Reflect.get(value, method) === 'function'
61+
);
62+
}
63+
64+
function isAdapterRuntime(value: unknown): value is AdapterRuntime {
65+
return isRuntime(value, 'getProviderAdapter');
66+
}
67+
68+
function isPrefixRuntime(value: unknown): value is PrefixRuntime {
69+
return isRuntime(value, 'stripTimestampPrefix');
70+
}
71+
72+
const adapters: unknown = require('../lib/agent-cli-provider/adapters');
73+
const prefixes: unknown = require('../lib/agent-cli-provider/log-prefix');
74+
if (!isAdapterRuntime(adapters) || !isPrefixRuntime(prefixes)) {
75+
throw new Error('Built agent-provider runtime is unavailable');
76+
}
77+
78+
export const getProviderAdapter = adapters.getProviderAdapter;
79+
export const stripTimestampPrefix = prefixes.stripTimestampPrefix;

cli/export-stream.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import crypto = require('crypto');
2+
import fs = require('fs');
3+
4+
export interface ExportStream {
5+
readonly fd?: number;
6+
write(value: string): unknown;
7+
}
8+
9+
export interface Destination {
10+
close(): void;
11+
write(value: string): void;
12+
}
13+
14+
export interface RecordWriter {
15+
readonly records: number;
16+
finish(record: Record<string, unknown>): void;
17+
write(record: Record<string, unknown>): void;
18+
}
19+
20+
export function nullableString(value: unknown): string | null {
21+
return typeof value === 'string' ? value : null;
22+
}
23+
24+
export function isRecord(value: unknown): value is Record<string, unknown> {
25+
return value !== null && typeof value === 'object' && !Array.isArray(value);
26+
}
27+
28+
export function compareText(left: string, right: string): number {
29+
if (left < right) return -1;
30+
return left > right ? 1 : 0;
31+
}
32+
33+
export function sameFileSnapshot(before: fs.BigIntStats, after: fs.BigIntStats): boolean {
34+
return (
35+
before.dev === after.dev &&
36+
before.ino === after.ino &&
37+
before.size === after.size &&
38+
before.mtimeNs === after.mtimeNs &&
39+
before.ctimeNs === after.ctimeNs
40+
);
41+
}
42+
43+
function writeAll(fd: number, value: string, label: string): void {
44+
const bytes = Buffer.from(value);
45+
let offset = 0;
46+
while (offset < bytes.length) {
47+
const written = fs.writeSync(fd, bytes, offset, bytes.length - offset);
48+
if (!Number.isInteger(written) || written <= 0) {
49+
throw new Error(`${label} export destination stopped accepting bytes`);
50+
}
51+
offset += written;
52+
}
53+
}
54+
55+
function streamDestination(stdout: ExportStream, label: string): Destination {
56+
if (typeof stdout.fd === 'number' && Number.isInteger(stdout.fd)) {
57+
const fd = stdout.fd;
58+
return { close(): void {}, write: (value) => writeAll(fd, value, label) };
59+
}
60+
return {
61+
close(): void {},
62+
write(value): void {
63+
stdout.write(value);
64+
},
65+
};
66+
}
67+
68+
export function createExclusiveDestination(
69+
outputPath: string | null | undefined,
70+
stdout: ExportStream,
71+
label: string
72+
): Destination {
73+
if (!outputPath) return streamDestination(stdout, label);
74+
const flags =
75+
fs.constants.O_WRONLY |
76+
fs.constants.O_CREAT |
77+
fs.constants.O_EXCL |
78+
(fs.constants.O_NOFOLLOW || 0);
79+
const fd = fs.openSync(outputPath, flags, 0o600);
80+
try {
81+
fs.fchmodSync(fd, 0o600);
82+
} catch (error) {
83+
fs.closeSync(fd);
84+
throw error;
85+
}
86+
return { close: () => fs.closeSync(fd), write: (value) => writeAll(fd, value, label) };
87+
}
88+
89+
export function createReplacingDestination(
90+
outputPath: string | null | undefined,
91+
stdout: ExportStream
92+
): Destination {
93+
if (!outputPath) return streamDestination(stdout, 'JSON');
94+
const fd = fs.openSync(outputPath, 'w');
95+
return { close: () => fs.closeSync(fd), write: (value) => writeAll(fd, value, 'JSON') };
96+
}
97+
98+
export function createRecordWriter(destination: Destination): RecordWriter {
99+
const digest = crypto.createHash('sha256');
100+
let records = 0;
101+
const encode = (record: Record<string, unknown>): string => `${JSON.stringify(record)}\n`;
102+
return {
103+
get records(): number {
104+
return records;
105+
},
106+
write(record): void {
107+
const line = encode(record);
108+
digest.update(line);
109+
destination.write(line);
110+
records += 1;
111+
},
112+
finish(record): void {
113+
destination.write(
114+
encode({ ...record, preceding_records: records, records_sha256: digest.digest('hex') })
115+
);
116+
},
117+
};
118+
}

0 commit comments

Comments
 (0)