@forwardimpact/libharness 0.1.22 → 1.1.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.
- package/LICENSE +21 -201
- package/README.md +196 -80
- package/bin/fit-benchmark.js +44 -0
- package/bin/fit-harness.js +358 -0
- package/bin/fit-selfedit.js +165 -0
- package/bin/fit-trace.js +510 -0
- package/package.json +41 -11
- package/src/agent-runner.js +256 -0
- package/src/benchmark/apm-installer.js +207 -0
- package/src/benchmark/env-loader.js +158 -0
- package/src/benchmark/hook-env.js +40 -0
- package/src/benchmark/invariants.js +141 -0
- package/src/benchmark/judge.js +187 -0
- package/src/benchmark/npm-installer.js +87 -0
- package/src/benchmark/report.js +604 -0
- package/src/benchmark/result.js +127 -0
- package/src/benchmark/runner.js +688 -0
- package/src/benchmark/scheduler.js +78 -0
- package/src/benchmark/task-family.js +260 -0
- package/src/benchmark/workdir.js +344 -0
- package/src/commands/assert.js +153 -0
- package/src/commands/benchmark-definition.js +175 -0
- package/src/commands/benchmark-invariants.js +73 -0
- package/src/commands/benchmark-report.js +51 -0
- package/src/commands/benchmark-run.js +175 -0
- package/src/commands/by-discussion.js +94 -0
- package/src/commands/callback.js +119 -0
- package/src/commands/discuss.js +132 -0
- package/src/commands/facilitate.js +123 -0
- package/src/commands/output.js +36 -0
- package/src/commands/run.js +152 -0
- package/src/commands/supervise.js +136 -0
- package/src/commands/task-input.js +54 -0
- package/src/commands/tee.js +53 -0
- package/src/commands/trace.js +630 -0
- package/src/commands/work-tracker.js +35 -0
- package/src/cost.js +79 -0
- package/src/discuss-tools.js +173 -0
- package/src/discusser.js +394 -0
- package/src/events/github.js +161 -0
- package/src/facilitator.js +205 -0
- package/src/inbox-poller.js +81 -0
- package/src/index.js +72 -2
- package/src/judge.js +210 -0
- package/src/message-bus.js +118 -0
- package/src/orchestration-loop.js +330 -0
- package/src/orchestration-toolkit.js +441 -0
- package/src/orchestrator-helpers.js +23 -0
- package/src/profile-prompt.js +266 -0
- package/src/redaction.js +253 -0
- package/src/render/line-renderer.js +54 -0
- package/src/render/orchestrator-filter.js +19 -0
- package/src/render/palette.js +63 -0
- package/src/render/tool-hints.js +154 -0
- package/src/render/turn-renderer.js +96 -0
- package/src/reply-emitter.js +47 -0
- package/src/sequence-counter.js +21 -0
- package/src/signature-filter.js +27 -0
- package/src/supervisor.js +236 -0
- package/src/tee-writer.js +150 -0
- package/src/trace-collector.js +444 -0
- package/src/trace-github.js +473 -0
- package/src/trace-multi.js +101 -0
- package/src/trace-query.js +748 -0
- package/src/trace-render.js +211 -0
- package/src/trace-usage.js +249 -0
- package/src/fixture/assertions.js +0 -42
- package/src/fixture/cache.js +0 -50
- package/src/fixture/eval.js +0 -146
- package/src/fixture/index.js +0 -9
- package/src/fixture/pathway.js +0 -451
- package/src/fixture/services.js +0 -56
- package/src/mock/clients.js +0 -135
- package/src/mock/config.js +0 -45
- package/src/mock/data.js +0 -46
- package/src/mock/fs.js +0 -111
- package/src/mock/grpc.js +0 -94
- package/src/mock/http.js +0 -60
- package/src/mock/index.js +0 -36
- package/src/mock/infra.js +0 -219
- package/src/mock/logger.js +0 -42
- package/src/mock/observer.js +0 -74
- package/src/mock/resource-index.js +0 -95
- package/src/mock/service-callbacks.js +0 -39
- package/src/mock/services.js +0 -79
- package/src/mock/spy.js +0 -44
- package/src/mock/storage.js +0 -118
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TeeWriter — a Writable stream that writes raw NDJSON to a file while
|
|
3
|
+
* simultaneously streaming human-readable text to a separate stream (e.g.
|
|
4
|
+
* process.stdout).
|
|
5
|
+
*
|
|
6
|
+
* All modes emit the same { source, seq, event } envelope. The `mode`
|
|
7
|
+
* parameter controls display formatting: multi-participant modes show
|
|
8
|
+
* source labels on content lines.
|
|
9
|
+
*
|
|
10
|
+
* Human text rendering is delegated to the pure modules under `./render/`
|
|
11
|
+
* so the live stream and the offline `TraceCollector.toText()` replay share
|
|
12
|
+
* one formatting path. The NDJSON going to `fileStream` is
|
|
13
|
+
* untouched — only what reaches `textStream` changes.
|
|
14
|
+
*
|
|
15
|
+
* Follows OO+DI: constructor injection, factory function, tests bypass factory.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Writable } from "node:stream";
|
|
19
|
+
import { TraceCollector } from "./trace-collector.js";
|
|
20
|
+
import { renderTurnLines } from "./render/turn-renderer.js";
|
|
21
|
+
import { isSuppressedOrchestratorEvent } from "./render/orchestrator-filter.js";
|
|
22
|
+
|
|
23
|
+
/** Writable stream that saves raw NDJSON to a file while streaming human-readable text to a display stream. */
|
|
24
|
+
export class TeeWriter extends Writable {
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} deps
|
|
27
|
+
* @param {import("stream").Writable} deps.fileStream - Stream to write raw NDJSON to
|
|
28
|
+
* @param {import("stream").Writable} deps.textStream - Stream to write human-readable text to
|
|
29
|
+
* @param {"raw"|"supervised"} [deps.mode] - Display mode: "raw" (no source labels) or "supervised" (source labels) (default: "raw")
|
|
30
|
+
* @param {function} [deps.now] - Injected ISO-timestamp source threaded into
|
|
31
|
+
* the internal `TraceCollector` (`() => isoTimestamp(runtime.clock.now())`).
|
|
32
|
+
*/
|
|
33
|
+
constructor({ fileStream, textStream, mode, now }) {
|
|
34
|
+
super();
|
|
35
|
+
if (!fileStream) throw new Error("fileStream is required");
|
|
36
|
+
if (!textStream) throw new Error("textStream is required");
|
|
37
|
+
this.fileStream = fileStream;
|
|
38
|
+
this.textStream = textStream;
|
|
39
|
+
this.mode = mode ?? "raw";
|
|
40
|
+
this.collector = new TraceCollector({ now });
|
|
41
|
+
this.turnsEmitted = 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {Buffer|string} chunk
|
|
46
|
+
* @param {string} encoding
|
|
47
|
+
* @param {function} callback
|
|
48
|
+
*/
|
|
49
|
+
_write(chunk, encoding, callback) {
|
|
50
|
+
const str = (this.partial ?? "") + chunk.toString();
|
|
51
|
+
const lines = str.split("\n");
|
|
52
|
+
this.partial = lines.pop() ?? "";
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (!line.trim()) continue;
|
|
56
|
+
this.fileStream.write(line + "\n");
|
|
57
|
+
this.processLine(line);
|
|
58
|
+
}
|
|
59
|
+
callback();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {function} callback
|
|
64
|
+
*/
|
|
65
|
+
_final(callback) {
|
|
66
|
+
if (this.partial && this.partial.trim()) {
|
|
67
|
+
this.fileStream.write(this.partial + "\n");
|
|
68
|
+
this.processLine(this.partial);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Emit the trailing `--- Result: ... ---` footer — the one summary line
|
|
72
|
+
// humans want. This is the same tail TraceCollector.toText()
|
|
73
|
+
// appends, so the live stream and the offline replay stay in sync.
|
|
74
|
+
// The superseded `--- Evaluation ... ---` footer is gone in every mode.
|
|
75
|
+
if (this.collector.result) {
|
|
76
|
+
const text = this.collector.toText();
|
|
77
|
+
const idx = text.lastIndexOf("\n---");
|
|
78
|
+
if (idx !== -1) {
|
|
79
|
+
// Slice past the leading `\n` — the previously-streamed body
|
|
80
|
+
// already ended with its own newline, so re-emitting `\n---` here
|
|
81
|
+
// would produce a blank line before the footer and desync from
|
|
82
|
+
// the offline replay.
|
|
83
|
+
this.textStream.write(text.slice(idx + 1) + "\n");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
callback();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Process a single NDJSON line — unified envelope handling for all modes.
|
|
92
|
+
* @param {string} line
|
|
93
|
+
*/
|
|
94
|
+
processLine(line) {
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(line);
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Universal envelope: { source, seq, event }
|
|
103
|
+
if (parsed.event) {
|
|
104
|
+
// Always forward to the collector so it can capture orchestrator
|
|
105
|
+
// metadata (e.g. the summary verdict for the result footer); the
|
|
106
|
+
// collector adds no turn for suppressed events, so flushTurns stays
|
|
107
|
+
// a no-op when we skip it below.
|
|
108
|
+
this.collector.addLine(line);
|
|
109
|
+
|
|
110
|
+
// Orchestrator lifecycle events are suppressed from the text stream
|
|
111
|
+
// entirely — humans only want agent-visible content. They still
|
|
112
|
+
// reached fileStream above.
|
|
113
|
+
if (
|
|
114
|
+
parsed.source === "orchestrator" &&
|
|
115
|
+
isSuppressedOrchestratorEvent(parsed.event)
|
|
116
|
+
) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.flushTurns();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Bare event (unwrapped run mode line or direct feed)
|
|
124
|
+
this.collector.addLine(line);
|
|
125
|
+
this.flushTurns();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Emit text for any new turns accumulated by the collector.
|
|
130
|
+
*/
|
|
131
|
+
flushTurns() {
|
|
132
|
+
const turns = this.collector.turns;
|
|
133
|
+
const withPrefix = this.mode !== "raw";
|
|
134
|
+
while (this.turnsEmitted < turns.length) {
|
|
135
|
+
const turn = turns[this.turnsEmitted++];
|
|
136
|
+
for (const line of renderTurnLines(turn, withPrefix)) {
|
|
137
|
+
this.textStream.write(line);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Factory function — wires a TeeWriter with the given streams.
|
|
145
|
+
* @param {object} deps - Same as TeeWriter constructor
|
|
146
|
+
* @returns {TeeWriter}
|
|
147
|
+
*/
|
|
148
|
+
export function createTeeWriter(deps) {
|
|
149
|
+
return new TeeWriter(deps);
|
|
150
|
+
}
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collects Claude Code stream-json NDJSON events into structured traces.
|
|
3
|
+
*
|
|
4
|
+
* Accepts one NDJSON line at a time via addLine(), then produces either a
|
|
5
|
+
* structured JSON trace (toJSON) or human-readable text (toText).
|
|
6
|
+
*
|
|
7
|
+
* Human text rendering is delegated to the pure modules under `./render/`
|
|
8
|
+
* so the live `TeeWriter` stream and the offline `toText()` replay share
|
|
9
|
+
* one formatting path.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isoTimestamp } from "@forwardimpact/libutil";
|
|
13
|
+
|
|
14
|
+
import { renderTurnLines } from "./render/turn-renderer.js";
|
|
15
|
+
import { isSuppressedOrchestratorEvent } from "./render/orchestrator-filter.js";
|
|
16
|
+
|
|
17
|
+
/** Accumulate Claude Code NDJSON stream events into structured traces for analysis or text replay. */
|
|
18
|
+
export class TraceCollector {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} [deps]
|
|
21
|
+
* @param {function} [deps.now] - Returns an ISO timestamp string. Injected
|
|
22
|
+
* so the collector never reads the wall clock directly; construct it as
|
|
23
|
+
* `() => isoTimestamp(runtime.clock.now())`. When omitted (pure
|
|
24
|
+
* structural/replay use where every event already carries a `timestamp`),
|
|
25
|
+
* the fallback formats the epoch — a deterministic sentinel, not a clock
|
|
26
|
+
* read.
|
|
27
|
+
*/
|
|
28
|
+
constructor(deps = {}) {
|
|
29
|
+
/** @type {function} */
|
|
30
|
+
this.now = deps.now ?? (() => isoTimestamp(0));
|
|
31
|
+
/** @type {object|null} */
|
|
32
|
+
this.metadata = null;
|
|
33
|
+
/** @type {Array<object>} */
|
|
34
|
+
this.turns = [];
|
|
35
|
+
/** @type {object|null} */
|
|
36
|
+
this.result = null;
|
|
37
|
+
/** @type {{verdict?: string, summary?: string, turns?: number}|null} */
|
|
38
|
+
this.orchestratorSummary = null;
|
|
39
|
+
/** @type {number} */
|
|
40
|
+
this.turnIndex = 0;
|
|
41
|
+
/** @type {object|null} */
|
|
42
|
+
this.initEvent = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse one NDJSON line and accumulate state.
|
|
47
|
+
* Malformed lines are silently skipped.
|
|
48
|
+
* @param {string} line - A single JSON line from stream-json output
|
|
49
|
+
*/
|
|
50
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: NDJSON envelope unwrap + orchestrator/system/assistant/user dispatch
|
|
51
|
+
addLine(line) {
|
|
52
|
+
const trimmed = line.trim();
|
|
53
|
+
if (!trimmed) return;
|
|
54
|
+
|
|
55
|
+
let event;
|
|
56
|
+
try {
|
|
57
|
+
event = JSON.parse(trimmed);
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Unwrap combined supervised trace format {source, seq, event}. The
|
|
63
|
+
// Supervisor / Facilitator emits this wrapper; when replayed through
|
|
64
|
+
// addLine the inner event is the one we care about. Carry the envelope
|
|
65
|
+
// `source` onto each new turn so the renderer can color it correctly.
|
|
66
|
+
let source = null;
|
|
67
|
+
if (event.event && !event.type && typeof event.source === "string") {
|
|
68
|
+
source = event.source;
|
|
69
|
+
event = event.event;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Orchestrator lifecycle events carry no content and are suppressed
|
|
73
|
+
// from turns entirely — the NDJSON artifact keeps them separately.
|
|
74
|
+
if (source === "orchestrator" && isSuppressedOrchestratorEvent(event)) {
|
|
75
|
+
// The summary event carries the supervisor/facilitator verdict —
|
|
76
|
+
// capture it before dropping the event, so the result footer can
|
|
77
|
+
// surface verdict="failure" instead of the SDK's per-runner status.
|
|
78
|
+
if (event.type === "summary") {
|
|
79
|
+
this.orchestratorSummary = {
|
|
80
|
+
...(event.verdict && { verdict: event.verdict }),
|
|
81
|
+
...(typeof event.summary === "string" && { summary: event.summary }),
|
|
82
|
+
...(typeof event.turns === "number" && { turns: event.turns }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (event.type === "meta" && typeof event.discussion_id === "string") {
|
|
86
|
+
this.discussionId = event.discussion_id;
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
switch (event.type) {
|
|
92
|
+
case "system":
|
|
93
|
+
this.handleSystem(event, source);
|
|
94
|
+
break;
|
|
95
|
+
case "assistant":
|
|
96
|
+
this.handleAssistant(event, source);
|
|
97
|
+
break;
|
|
98
|
+
case "user":
|
|
99
|
+
this.handleUser(event, source);
|
|
100
|
+
break;
|
|
101
|
+
case "result":
|
|
102
|
+
this.handleResult(event);
|
|
103
|
+
break;
|
|
104
|
+
default:
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {object} event
|
|
111
|
+
* @param {string|null} source
|
|
112
|
+
*/
|
|
113
|
+
handleSystem(event, source) {
|
|
114
|
+
const { type: _type, ...payload } = event;
|
|
115
|
+
|
|
116
|
+
if (event.subtype === "init") {
|
|
117
|
+
this.metadata = {
|
|
118
|
+
timestamp: event.timestamp ?? this.now(),
|
|
119
|
+
sessionId: event.session_id ?? null,
|
|
120
|
+
model: event.model ?? null,
|
|
121
|
+
claudeCodeVersion: event.claude_code_version ?? null,
|
|
122
|
+
tools: event.tools ?? [],
|
|
123
|
+
permissionMode: event.permissionMode ?? null,
|
|
124
|
+
};
|
|
125
|
+
this.initEvent = payload;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.turns.push({
|
|
129
|
+
index: this.turnIndex++,
|
|
130
|
+
role: "system",
|
|
131
|
+
source,
|
|
132
|
+
subtype: event.subtype ?? null,
|
|
133
|
+
data: payload,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {object} event
|
|
139
|
+
* @param {string|null} source
|
|
140
|
+
*/
|
|
141
|
+
handleAssistant(event, source) {
|
|
142
|
+
const message = event.message;
|
|
143
|
+
if (!message) return;
|
|
144
|
+
|
|
145
|
+
const content = (message.content ?? []).map((block) => {
|
|
146
|
+
if (block.type === "text") {
|
|
147
|
+
return { type: "text", text: block.text };
|
|
148
|
+
}
|
|
149
|
+
if (block.type === "tool_use") {
|
|
150
|
+
return {
|
|
151
|
+
type: "tool_use",
|
|
152
|
+
toolUseId: block.id ?? null,
|
|
153
|
+
name: block.name,
|
|
154
|
+
input: block.input,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return block;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const usage = message.usage
|
|
161
|
+
? {
|
|
162
|
+
inputTokens: message.usage.input_tokens ?? 0,
|
|
163
|
+
outputTokens: message.usage.output_tokens ?? 0,
|
|
164
|
+
cacheReadInputTokens: message.usage.cache_read_input_tokens ?? 0,
|
|
165
|
+
cacheCreationInputTokens:
|
|
166
|
+
message.usage.cache_creation_input_tokens ?? 0,
|
|
167
|
+
}
|
|
168
|
+
: null;
|
|
169
|
+
|
|
170
|
+
this.turns.push({
|
|
171
|
+
index: this.turnIndex++,
|
|
172
|
+
role: "assistant",
|
|
173
|
+
source,
|
|
174
|
+
messageId: message.id ?? null,
|
|
175
|
+
content,
|
|
176
|
+
usage,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {object} event
|
|
182
|
+
* @param {string|null} source
|
|
183
|
+
*/
|
|
184
|
+
handleUser(event, source) {
|
|
185
|
+
const message = event.message;
|
|
186
|
+
if (!message) return;
|
|
187
|
+
|
|
188
|
+
const contentItems = message.content;
|
|
189
|
+
if (!Array.isArray(contentItems)) return;
|
|
190
|
+
|
|
191
|
+
const textBlocks = contentItems
|
|
192
|
+
.filter((item) => item.type === "text")
|
|
193
|
+
.map((item) => ({ type: "text", text: item.text }));
|
|
194
|
+
|
|
195
|
+
if (textBlocks.length > 0) {
|
|
196
|
+
this.turns.push({
|
|
197
|
+
index: this.turnIndex++,
|
|
198
|
+
role: "user",
|
|
199
|
+
source,
|
|
200
|
+
content: textBlocks,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const item of contentItems) {
|
|
205
|
+
if (item.type === "tool_result") {
|
|
206
|
+
this.turns.push({
|
|
207
|
+
index: this.turnIndex++,
|
|
208
|
+
role: "tool_result",
|
|
209
|
+
source,
|
|
210
|
+
toolUseId: item.tool_use_id ?? null,
|
|
211
|
+
content:
|
|
212
|
+
typeof item.content === "string"
|
|
213
|
+
? item.content
|
|
214
|
+
: JSON.stringify(item.content),
|
|
215
|
+
isError: item.is_error ?? false,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Accumulate a result event into the running summary. Facilitated and
|
|
223
|
+
* supervised sessions emit one result event per runner invocation, so a
|
|
224
|
+
* single trace can carry several — cost, duration, turn, and token
|
|
225
|
+
* figures sum across all of them. `result` reflects the latest event;
|
|
226
|
+
* `isError` is true once any event errored.
|
|
227
|
+
* @param {object} event
|
|
228
|
+
*/
|
|
229
|
+
handleResult(event) {
|
|
230
|
+
const prev = this.result ?? EMPTY_RESULT;
|
|
231
|
+
|
|
232
|
+
this.result = {
|
|
233
|
+
result: event.subtype ?? "unknown",
|
|
234
|
+
isError: prev.isError || (event.is_error ?? false),
|
|
235
|
+
totalCostUsd: prev.totalCostUsd + (event.total_cost_usd ?? 0),
|
|
236
|
+
durationMs: prev.durationMs + (event.duration_ms ?? 0),
|
|
237
|
+
numTurns: prev.numTurns + (event.num_turns ?? 0),
|
|
238
|
+
tokenUsage: sumTokenUsage(prev.tokenUsage, normalizeUsage(event.usage)),
|
|
239
|
+
modelUsage: mergeModelUsage(prev.modelUsage, event.modelUsage),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Return a structured trace object for offline analysis.
|
|
245
|
+
* @returns {object} Structured trace document
|
|
246
|
+
*/
|
|
247
|
+
toJSON() {
|
|
248
|
+
return {
|
|
249
|
+
version: "1.2.0",
|
|
250
|
+
metadata: this.metadata ?? {
|
|
251
|
+
timestamp: this.now(),
|
|
252
|
+
sessionId: null,
|
|
253
|
+
model: null,
|
|
254
|
+
claudeCodeVersion: null,
|
|
255
|
+
tools: [],
|
|
256
|
+
permissionMode: null,
|
|
257
|
+
},
|
|
258
|
+
initEvent: this.initEvent ?? null,
|
|
259
|
+
turns: this.turns,
|
|
260
|
+
summary: this.result ?? {
|
|
261
|
+
result: "unknown",
|
|
262
|
+
isError: false,
|
|
263
|
+
totalCostUsd: 0,
|
|
264
|
+
durationMs: 0,
|
|
265
|
+
numTurns: 0,
|
|
266
|
+
tokenUsage: null,
|
|
267
|
+
modelUsage: null,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Render the accumulated turns as human-readable text — the same path the
|
|
274
|
+
* live `TeeWriter` stream uses, so `fit-harness output --format=text` over a
|
|
275
|
+
* captured trace reproduces what the live workflow log showed.
|
|
276
|
+
*
|
|
277
|
+
* Source prefixes are emitted whenever at least one turn has a non-null
|
|
278
|
+
* source (supervised / facilitated traces). A pure `run` trace has no
|
|
279
|
+
* envelope, all turn sources are null, and the renderer drops the prefix.
|
|
280
|
+
*
|
|
281
|
+
* @returns {string} Formatted text output including ANSI escapes
|
|
282
|
+
*/
|
|
283
|
+
toText() {
|
|
284
|
+
const withPrefix = this.turns.some((t) => t.source);
|
|
285
|
+
const out = [];
|
|
286
|
+
|
|
287
|
+
for (const turn of this.turns) {
|
|
288
|
+
out.push(...renderTurnLines(turn, withPrefix));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const tail = this.#formatResultTail();
|
|
292
|
+
|
|
293
|
+
// Each rendered line already ends with `\n`; concatenate, drop the
|
|
294
|
+
// trailing newline, then append the tail so the output shape stays
|
|
295
|
+
// compatible with existing consumers (no double-blank line before
|
|
296
|
+
// the result footer when there are turns, no leading blank when there
|
|
297
|
+
// are not).
|
|
298
|
+
const body = out.join("").replace(/\n$/, "");
|
|
299
|
+
return body + tail;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Format the trailing result summary line. When an orchestrator
|
|
304
|
+
* summary is present (supervised / facilitated mode), the headline word is
|
|
305
|
+
* the supervisor's verdict ("success" / "failure") rather than the SDK's
|
|
306
|
+
* per-runner subtype, so the footer aligns with the CI exit code. Turn,
|
|
307
|
+
* cost, and duration figures are the accumulated totals across every
|
|
308
|
+
* result event in the trace, not the last event's.
|
|
309
|
+
* @returns {string}
|
|
310
|
+
*/
|
|
311
|
+
#formatResultTail() {
|
|
312
|
+
if (!this.result) return "";
|
|
313
|
+
const duration = formatDuration(this.result.durationMs);
|
|
314
|
+
const cost = Number(this.result.totalCostUsd).toFixed(4);
|
|
315
|
+
const headline = this.orchestratorSummary?.verdict ?? this.result.result;
|
|
316
|
+
return (
|
|
317
|
+
"\n" +
|
|
318
|
+
`--- Result: ${headline} | Turns: ${this.result.numTurns} | Cost: $${cost} | Duration: ${duration} ---`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Identity element for result-event accumulation in handleResult. */
|
|
324
|
+
const EMPTY_RESULT = {
|
|
325
|
+
isError: false,
|
|
326
|
+
totalCostUsd: 0,
|
|
327
|
+
durationMs: 0,
|
|
328
|
+
numTurns: 0,
|
|
329
|
+
tokenUsage: null,
|
|
330
|
+
modelUsage: null,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Normalize an SDK snake_case usage block to camelCase token fields.
|
|
335
|
+
* @param {object|null|undefined} usage
|
|
336
|
+
* @returns {object|null}
|
|
337
|
+
*/
|
|
338
|
+
function normalizeUsage(usage) {
|
|
339
|
+
if (!usage) return null;
|
|
340
|
+
return {
|
|
341
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
342
|
+
outputTokens: usage.output_tokens ?? 0,
|
|
343
|
+
cacheReadInputTokens: usage.cache_read_input_tokens ?? 0,
|
|
344
|
+
cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Sum two token-usage records field-by-field. Either side may be null
|
|
350
|
+
* (a result event without usage); the sum is null only when both are.
|
|
351
|
+
* @param {object|null} a
|
|
352
|
+
* @param {object|null} b
|
|
353
|
+
* @returns {object|null}
|
|
354
|
+
*/
|
|
355
|
+
function sumTokenUsage(a, b) {
|
|
356
|
+
if (!a) return b;
|
|
357
|
+
if (!b) return a;
|
|
358
|
+
return {
|
|
359
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
360
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
361
|
+
cacheReadInputTokens: a.cacheReadInputTokens + b.cacheReadInputTokens,
|
|
362
|
+
cacheCreationInputTokens:
|
|
363
|
+
a.cacheCreationInputTokens + b.cacheCreationInputTokens,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Per-model fields that sum additively across result events — token counts,
|
|
369
|
+
* per-model cost, and request counters. Every other per-model field (e.g. a
|
|
370
|
+
* context-window size) is carried first-seen, never summed.
|
|
371
|
+
*/
|
|
372
|
+
const ADDITIVE_MODEL_FIELDS = [
|
|
373
|
+
"inputTokens",
|
|
374
|
+
"outputTokens",
|
|
375
|
+
"cacheReadInputTokens",
|
|
376
|
+
"cacheCreationInputTokens",
|
|
377
|
+
"costUSD",
|
|
378
|
+
"webSearchRequests",
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Merge two per-model usage maps across result events. Additive fields
|
|
383
|
+
* (token counts, cost, request counters) sum; non-additive fields are carried
|
|
384
|
+
* from the first event that set them (prev wins). Either side may be null.
|
|
385
|
+
* @param {object|null} prevMU
|
|
386
|
+
* @param {object|null} nextMU
|
|
387
|
+
* @returns {object|null}
|
|
388
|
+
*/
|
|
389
|
+
function mergeModelUsage(prevMU, nextMU) {
|
|
390
|
+
if (!prevMU) return nextMU ?? null;
|
|
391
|
+
if (!nextMU) return prevMU;
|
|
392
|
+
|
|
393
|
+
const merged = {};
|
|
394
|
+
for (const model of new Set([
|
|
395
|
+
...Object.keys(prevMU),
|
|
396
|
+
...Object.keys(nextMU),
|
|
397
|
+
])) {
|
|
398
|
+
merged[model] = mergeOneModel(prevMU[model] ?? {}, nextMU[model] ?? {});
|
|
399
|
+
}
|
|
400
|
+
return merged;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Merge one model's usage: additive fields sum, others carry first-seen (a).
|
|
405
|
+
* @param {object} a - First-seen (prev) per-model usage.
|
|
406
|
+
* @param {object} b - Next per-model usage.
|
|
407
|
+
* @returns {object}
|
|
408
|
+
*/
|
|
409
|
+
function mergeOneModel(a, b) {
|
|
410
|
+
const entry = { ...a, ...b };
|
|
411
|
+
for (const field of ADDITIVE_MODEL_FIELDS) {
|
|
412
|
+
if (field in a || field in b) {
|
|
413
|
+
entry[field] = (a[field] ?? 0) + (b[field] ?? 0);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
for (const field of Object.keys(a)) {
|
|
417
|
+
if (!ADDITIVE_MODEL_FIELDS.includes(field)) entry[field] = a[field];
|
|
418
|
+
}
|
|
419
|
+
return entry;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Format milliseconds into a human-readable duration.
|
|
424
|
+
* @param {number} ms - Duration in milliseconds
|
|
425
|
+
* @returns {string} Formatted duration
|
|
426
|
+
*/
|
|
427
|
+
function formatDuration(ms) {
|
|
428
|
+
if (ms < 1000) return `${ms}ms`;
|
|
429
|
+
const seconds = Math.round(ms / 1000);
|
|
430
|
+
if (seconds < 60) return `${seconds}s`;
|
|
431
|
+
const minutes = Math.floor(seconds / 60);
|
|
432
|
+
const remainingSeconds = seconds % 60;
|
|
433
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Factory function for TraceCollector.
|
|
438
|
+
* @param {object} [deps]
|
|
439
|
+
* @param {function} [deps.now] - Returns ISO timestamp string
|
|
440
|
+
* @returns {TraceCollector}
|
|
441
|
+
*/
|
|
442
|
+
export function createTraceCollector(deps) {
|
|
443
|
+
return new TraceCollector(deps);
|
|
444
|
+
}
|