Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ report/
cli/event-copy.js
cli/message-formatter-utils.js
cli/json-export.js
cli/trace-export.js
cli/semantic-export.js
cli/agent-provider-boundary.js
cli/export-stream.js
cli/semantic-contract.js
cli/semantic-canonical.js
cli/semantic-events.js
cli/semantic-evidence.js
cli/semantic-json.js
cli/semantic-line-scanner.js
cli/semantic-parser.js
cli/semantic-provider-line.js
cli/trace-evidence.js
cli/trace-output.js
cli/trace-output-record.js
cli/message-formatters-normal.js
cli/message-formatters-watch.js
test-metadata-manual.sh
Expand Down
25 changes: 24 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,9 @@ zeroshot stop <id> # Graceful stop
zeroshot kill <id> # Force kill

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

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

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

</details>

<details>
Expand Down
79 changes: 79 additions & 0 deletions cli/agent-provider-boundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
interface TextEvent {
readonly type: 'text' | 'thinking';
readonly text: string;
}

interface ToolCallEvent {
readonly type: 'tool_call';
readonly toolName: string | null | undefined;
readonly toolId: string | null | undefined;
readonly input: unknown;
}

interface ToolResultEvent {
readonly type: 'tool_result';
readonly toolId: string | null | undefined;
readonly content: unknown;
readonly isError: unknown;
}

interface ResultEvent {
readonly type: 'result';
readonly success: boolean;
readonly result?: unknown;
readonly error?: unknown;
readonly cost?: unknown;
readonly duration?: unknown;
readonly inputTokens?: number;
readonly outputTokens?: number;
readonly cacheReadInputTokens?: number;
readonly cacheCreationInputTokens?: number;
readonly modelUsage?: unknown;
readonly requests?: number;
readonly usageSource?: unknown;
readonly usageCompleteness?: unknown;
readonly invocation?: unknown;
readonly ompSdk?: unknown;
}

export type OutputEvent = TextEvent | ToolCallEvent | ToolResultEvent | ResultEvent;
export type ProviderParseResult = OutputEvent | readonly OutputEvent[] | null;

export interface ProviderAdapter {
readonly id: string;
readonly adapterVersion: string;
createParserState(): object;
parseEvent(line: string, state: object): ProviderParseResult;
finishParsing?(state: object): ProviderParseResult;
}

interface AdapterRuntime {
getProviderAdapter(provider: string): ProviderAdapter;
}

interface PrefixRuntime {
stripTimestampPrefix(line: string): string;
}

function isRuntime(value: unknown, method: string): boolean {
return (
value !== null && typeof value === 'object' && typeof Reflect.get(value, method) === 'function'
);
}

function isAdapterRuntime(value: unknown): value is AdapterRuntime {
return isRuntime(value, 'getProviderAdapter');
}

function isPrefixRuntime(value: unknown): value is PrefixRuntime {
return isRuntime(value, 'stripTimestampPrefix');
}

const adapters: unknown = require('../lib/agent-cli-provider/adapters');
const prefixes: unknown = require('../lib/agent-cli-provider/log-prefix');
if (!isAdapterRuntime(adapters) || !isPrefixRuntime(prefixes)) {
throw new Error('Built agent-provider runtime is unavailable');
}

export const getProviderAdapter = adapters.getProviderAdapter;
export const stripTimestampPrefix = prefixes.stripTimestampPrefix;
118 changes: 118 additions & 0 deletions cli/export-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import crypto = require('crypto');
import fs = require('fs');

export interface ExportStream {
readonly fd?: number;
write(value: string): unknown;
}

export interface Destination {
close(): void;
write(value: string): void;
}

export interface RecordWriter {
readonly records: number;
finish(record: Record<string, unknown>): void;
write(record: Record<string, unknown>): void;
}

export function nullableString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}

export function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}

export function compareText(left: string, right: string): number {
if (left < right) return -1;
return left > right ? 1 : 0;
}

export function sameFileSnapshot(before: fs.BigIntStats, after: fs.BigIntStats): boolean {
return (
before.dev === after.dev &&
before.ino === after.ino &&
before.size === after.size &&
before.mtimeNs === after.mtimeNs &&
before.ctimeNs === after.ctimeNs
);
}

function writeAll(fd: number, value: string, label: string): void {
const bytes = Buffer.from(value);
let offset = 0;
while (offset < bytes.length) {
const written = fs.writeSync(fd, bytes, offset, bytes.length - offset);
if (!Number.isInteger(written) || written <= 0) {
throw new Error(`${label} export destination stopped accepting bytes`);
}
offset += written;
}
}

function streamDestination(stdout: ExportStream, label: string): Destination {
if (typeof stdout.fd === 'number' && Number.isInteger(stdout.fd)) {
const fd = stdout.fd;
return { close(): void {}, write: (value) => writeAll(fd, value, label) };
}
return {
close(): void {},
write(value): void {
stdout.write(value);
},
};
}

export function createExclusiveDestination(
outputPath: string | null | undefined,
stdout: ExportStream,
label: string
): Destination {
if (!outputPath) return streamDestination(stdout, label);
const flags =
fs.constants.O_WRONLY |
fs.constants.O_CREAT |
fs.constants.O_EXCL |
(fs.constants.O_NOFOLLOW || 0);
const fd = fs.openSync(outputPath, flags, 0o600);
try {
fs.fchmodSync(fd, 0o600);
} catch (error) {
fs.closeSync(fd);
throw error;
}
return { close: () => fs.closeSync(fd), write: (value) => writeAll(fd, value, label) };
}

export function createReplacingDestination(
outputPath: string | null | undefined,
stdout: ExportStream
): Destination {
if (!outputPath) return streamDestination(stdout, 'JSON');
const fd = fs.openSync(outputPath, 'w');
return { close: () => fs.closeSync(fd), write: (value) => writeAll(fd, value, 'JSON') };
}

export function createRecordWriter(destination: Destination): RecordWriter {
const digest = crypto.createHash('sha256');
let records = 0;
const encode = (record: Record<string, unknown>): string => `${JSON.stringify(record)}\n`;
return {
get records(): number {
return records;
},
write(record): void {
const line = encode(record);
digest.update(line);
destination.write(line);
records += 1;
},
finish(record): void {
destination.write(
encode({ ...record, preceding_records: records, records_sha256: digest.digest('hex') })
);
},
};
}
Loading
Loading