@forwardimpact/libharness 3.0.2 → 3.2.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.
@@ -3,6 +3,7 @@ import { isoTimestamp } from "@forwardimpact/libutil";
3
3
  import { createTraceCollector, sumTraceCost } from "@forwardimpact/libharness";
4
4
  import { createTraceQuery } from "../trace-query.js";
5
5
  import { createTraceGitHub } from "../trace-github.js";
6
+ import { splitTrace } from "../trace-split.js";
6
7
  import { stripSignatures } from "../signature-filter.js";
7
8
  import { runOver, aggregate, compareTwo } from "../trace-multi.js";
8
9
  import {
@@ -100,7 +101,9 @@ export async function runRunsCommand(ctx) {
100
101
  }
101
102
 
102
103
  /**
103
- * Resolve a participant's lane trace for a known run id in one keyed lookup.
104
+ * Resolve a trace lane for a known run id in one keyed lookup. The key may
105
+ * be an exact member filename, a case id, or a participant name; ambiguous
106
+ * keys error with the matching candidates.
104
107
  * @param {import("@forwardimpact/libcli").InvocationContext} ctx
105
108
  */
106
109
  export async function runFindCommand(ctx) {
@@ -110,7 +113,7 @@ export async function runFindCommand(ctx) {
110
113
  repo: ctx.options.repo,
111
114
  runtime,
112
115
  });
113
- const result = await gh.findByKey(ctx.args["run-id"], ctx.args.participant, {
116
+ const result = await gh.findByKey(ctx.args["run-id"], ctx.args.key, {
114
117
  dir: ctx.options.dir,
115
118
  });
116
119
  writeJSON(runtime, result, ctx.options);
@@ -118,7 +121,22 @@ export async function runFindCommand(ctx) {
118
121
  }
119
122
 
120
123
  /**
121
- * Download a trace artifact and auto-convert to structured JSON.
124
+ * The single `.ndjson` member to auto-convert to structured JSON, or null
125
+ * when the artifact carries zero or several. Multi-member bundles (kata
126
+ * dispatch, harness matrix, eval shards) get no `structured.json` — the
127
+ * prior first-member conversion picked an arbitrary lane, which was actively
128
+ * misleading; the analysis verbs read the `.ndjson` members directly.
129
+ * @param {string[]} files - Extracted member paths, relative to the artifact dir.
130
+ * @returns {string|null}
131
+ */
132
+ export function structuredConvertTarget(files) {
133
+ const ndjson = files.filter((f) => f.endsWith(".ndjson"));
134
+ return ndjson.length === 1 ? ndjson[0] : null;
135
+ }
136
+
137
+ /**
138
+ * Download a trace artifact; auto-convert to structured JSON only when the
139
+ * artifact carries exactly one `.ndjson` member.
122
140
  * @param {import("@forwardimpact/libcli").InvocationContext} ctx
123
141
  */
124
142
  export async function runDownloadCommand(ctx) {
@@ -133,7 +151,7 @@ export async function runDownloadCommand(ctx) {
133
151
  name: ctx.options.artifact,
134
152
  });
135
153
 
136
- const ndjsonFile = result.files.find((f) => f.endsWith(".ndjson"));
154
+ const ndjsonFile = structuredConvertTarget(result.files);
137
155
  if (ndjsonFile) {
138
156
  const ndjsonPath = join(result.dir, ndjsonFile);
139
157
  const collector = createTraceCollector({
@@ -496,27 +514,13 @@ export async function runCompareCommand(ctx) {
496
514
 
497
515
  // --- Split command ---
498
516
 
499
- /**
500
- * A valid source name starts with a lowercase letter. The rest uses lowercase
501
- * alphanumeric characters or hyphens.
502
- */
503
- const VALID_SOURCE_NAME = /^[a-z][a-z0-9-]*$/;
504
-
505
- /**
506
- * Sources whose name is itself a structural role. The splitter classifies
507
- * each one into the role it represents.
508
- */
509
- const STRUCTURAL_ROLES = new Set(["agent", "supervisor", "facilitator"]);
510
-
511
517
  /**
512
518
  * Split a combined NDJSON trace into per-source files. The output names
513
519
  * follow the `trace--<case>--<participant>.<role>.ndjson` convention.
514
520
  *
515
- * Each valid envelope source becomes one output file. Structural sources
516
- * (`agent`, `supervisor`, `facilitator`) classify into the matching role.
517
- * They use their own name as participant. Profile-named sources (e.g.
518
- * `staff-engineer`) classify as agents with the profile in the participant
519
- * slot. The command drops orchestrator events and invalid source names.
521
+ * The command owns the CLI concerns only: input and `--mode` validation,
522
+ * defaults, and output-dir creation. The shared `splitTrace` implementation
523
+ * owns the split itself, including source-to-role classification.
520
524
  *
521
525
  * @param {import("@forwardimpact/libcli").InvocationContext} ctx
522
526
  */
@@ -525,10 +529,11 @@ export async function runSplitCommand(ctx) {
525
529
  const file = ctx.args.file;
526
530
  if (!file) return { ok: false, code: 1, error: "split: missing input file" };
527
531
 
528
- // `discuss` has the same lead + N-participants shape as `facilitate`. The
529
- // splitter buckets purely by envelope `source`, which is mode-independent.
530
- // So the CLI accepts `discuss` alongside the structural modes. The CLI owns
531
- // this rule. Callers do not.
532
+ // `discuss` has the same lead + N-participants shape as `facilitate`, and the
533
+ // splitter buckets purely by envelope `source` (mode-independent), so it is
534
+ // accepted alongside the structural modes. The CLI owns this, not callers.
535
+ // `--mode` stays required-but-inert: the harness action passes it and that
536
+ // surface is out of scope for the shared-split extraction.
532
537
  const mode = ctx.options.mode;
533
538
  if (!mode) return { ok: false, code: 1, error: "split: --mode is required" };
534
539
  if (!["run", "supervise", "facilitate", "discuss"].includes(mode)) {
@@ -539,52 +544,10 @@ export async function runSplitCommand(ctx) {
539
544
  const outputDir = ctx.options["output-dir"] || dirname(file);
540
545
  runtime.fsSync.mkdirSync(outputDir, { recursive: true });
541
546
 
542
- const buckets = parseBuckets(runtime.fsSync.readFileSync(file, "utf8"));
543
-
544
- for (const [source, lines] of buckets.entries()) {
545
- if (!VALID_SOURCE_NAME.test(source)) continue;
546
- const role = STRUCTURAL_ROLES.has(source) ? source : "agent";
547
- const outPath = join(
548
- outputDir,
549
- `trace--${caseId}--${source}.${role}.ndjson`,
550
- );
551
- runtime.fsSync.writeFileSync(outPath, lines.join("\n") + "\n");
552
- }
547
+ await splitTrace(runtime, file, { caseId, outputDir });
553
548
  return { ok: true };
554
549
  }
555
550
 
556
- /**
557
- * Parse NDJSON content into per-source buckets of unwrapped event lines.
558
- * Skips empty lines, malformed JSON, non-envelope lines, and orchestrator events.
559
- * @param {string} content - Raw NDJSON file content
560
- * @returns {Map<string, string[]>} source name -> array of unwrapped JSON lines
561
- */
562
- function parseBuckets(content) {
563
- const buckets = new Map();
564
-
565
- for (const raw of content.split("\n")) {
566
- const trimmed = raw.trim();
567
- if (!trimmed) continue;
568
-
569
- let envelope;
570
- try {
571
- envelope = JSON.parse(trimmed);
572
- } catch {
573
- continue;
574
- }
575
-
576
- if (!envelope.event || typeof envelope.source !== "string") continue;
577
- if (envelope.source === "orchestrator") continue;
578
-
579
- if (!buckets.has(envelope.source)) {
580
- buckets.set(envelope.source, []);
581
- }
582
- buckets.get(envelope.source).push(JSON.stringify(envelope.event));
583
- }
584
-
585
- return buckets;
586
- }
587
-
588
551
  // --- Shared helpers ---
589
552
 
590
553
  /**
@@ -1,6 +1,5 @@
1
1
  /**
2
- * GitHub event → task-prompt composition. Replaces ~70 lines of shell in
3
- * kata-dispatch.yml's `Compose task text` step. Each branch in the dispatch
2
+ * GitHub event → task-prompt composition. Each branch in the dispatch
4
3
  * function corresponds to one (event_name, action) the agent workflows react
5
4
  * to.
6
5
  *
package/src/index.js CHANGED
@@ -7,9 +7,9 @@ export {
7
7
  createTraceGitHub,
8
8
  detectRepoSlug,
9
9
  parseGitRemote,
10
- participantInNames,
11
10
  pickTraceArtifact,
12
11
  } from "./trace-github.js";
12
+ export { participantInNames } from "./trace-identity.js";
13
13
  export { AgentRunner, createAgentRunner } from "./agent-runner.js";
14
14
  export { resolveClaudeCodeExecutable } from "./claude-code-executable.js";
15
15
  export {
@@ -4,6 +4,8 @@ import { Readable } from "node:stream";
4
4
 
5
5
  import { isoTimestamp } from "@forwardimpact/libutil";
6
6
 
7
+ import { nameMatchesKey, participantInNames } from "./trace-identity.js";
8
+
7
9
  const API = "https://api.github.com";
8
10
 
9
11
  /**
@@ -48,14 +50,18 @@ export class TraceGitHub {
48
50
  * trace content.
49
51
  *
50
52
  * @param {object} [opts]
51
- * @param {string} [opts.pattern] - Case-insensitive regex to match workflow name (default: "kata|agent", which covers `Kata: Shift`, `Kata: Dispatch`, and any `agent`-named workflow)
53
+ * @param {string} [opts.pattern] - Case-insensitive regex to match workflow name (default: "kata|agent|eval|benchmark" — covers `Kata: Shift`, `Kata: Dispatch`, benchmark-driven eval workflows, and any `agent`-named workflow)
52
54
  * @param {number} [opts.limit=50] - Max runs to return from GitHub API
53
55
  * @param {string} [opts.lookback="7d"] - How far back to search (e.g. "7d", "24h", "2w")
54
56
  * @param {string} [opts.participant] - Participant name. When set, the method filters and annotates runs by trace lane
55
57
  * @returns {Promise<object[]>} Array of {workflow, runId, status, conclusion, createdAt, branch, url[, match]}
56
58
  */
57
59
  async listRuns(opts = {}) {
58
- const { pattern = "kata|agent", limit = 50, lookback = "7d" } = opts;
60
+ const {
61
+ pattern = "kata|agent|eval|benchmark",
62
+ limit = 50,
63
+ lookback = "7d",
64
+ } = opts;
59
65
  const cutoff = parseLookback(lookback, this.runtime.clock.now());
60
66
 
61
67
  const params = new URLSearchParams({
@@ -140,33 +146,41 @@ export class TraceGitHub {
140
146
  }
141
147
 
142
148
  // Dispatch host: one shared artifact whose members name the participant.
143
- // Download and list member filenames (names only).
149
+ // Download and list member filenames (names only). Members are nested
150
+ // relative paths (`runs/<taskId>/<idx>/trace--*` on eval artifacts), so
151
+ // match on basenames — the `trace--` prefix check never matches a nested
152
+ // path directly.
144
153
  for (const artifact of traceArtifacts) {
145
154
  const { files } = await this.downloadTrace(runId, {
146
155
  name: artifact.name,
147
156
  });
148
- if (participantInNames(files, participant)) return "confirmed";
157
+ const basenames = files.map((f) => path.basename(f));
158
+ if (participantInNames(basenames, participant)) return "confirmed";
149
159
  }
150
160
  return "omit";
151
161
  }
152
162
 
153
163
  /**
154
- * Resolve a participant's lane trace path for a known run in one keyed
155
- * lookup. The method does not enumerate runs. It does not inspect trace
156
- * content.
164
+ * Resolve a trace lane path for a known run in one keyed lookup. The method
165
+ * does not enumerate runs. It does not inspect trace content. The key is an
166
+ * exact member filename, a case id, or a participant name.
157
167
  *
158
- * Matrix host: the artifact name carries the participant (no download).
159
- * Dispatch host: download the shared `trace--*` artifact. Return the
160
- * extracted member file whose name carries the participant.
168
+ * Matrix host: the artifact name carries the key, so no download happens.
169
+ * Dispatch host: download every `trace--*` artifact and match member
170
+ * basenames against the key. Exactly one match resolves. Several matches
171
+ * throw an error that lists the candidates, so the caller narrows the key.
172
+ * This replaces a silent first-match, which returned an arbitrary cell's
173
+ * lane on eval runs, because every cell emits the same participants.
161
174
  *
162
175
  * @param {number|string} runId
163
- * @param {string} participant
176
+ * @param {string} key - Exact member filename, case id, or participant name.
164
177
  * @param {object} [opts]
165
178
  * @param {string} [opts.dir] - Output directory for a downloaded dispatch artifact
166
- * @returns {Promise<{runId: (number|string), participant: string, host: "matrix"|"dispatch", artifact: string, path: string}>}
167
- * @throws {Error} when the run has no trace artifacts, or none carries the participant's lane.
179
+ * @returns {Promise<{runId: (number|string), key: string, host: "matrix"|"dispatch", artifact: string, path: string}>}
180
+ * @throws {Error} when the run has no trace artifacts, no member matches
181
+ * the key, or several members match.
168
182
  */
169
- async findByKey(runId, participant, opts = {}) {
183
+ async findByKey(runId, key, opts = {}) {
170
184
  const url = `${API}/repos/${this.owner}/${this.repo}/actions/runs/${runId}/artifacts`;
171
185
  const data = await this.#get(url);
172
186
  const artifacts = data.artifacts ?? [];
@@ -177,41 +191,57 @@ export class TraceGitHub {
177
191
  throw new Error(`No trace artifacts for run ${runId}`);
178
192
  }
179
193
 
180
- // Matrix host: the artifact name carries the participant. No download.
194
+ // Matrix host: the artifact name carries the key. No download.
181
195
  const matrix = traceArtifacts.find((a) =>
182
- participantInNames([a.name], participant),
196
+ participantInNames([a.name], key),
183
197
  );
184
198
  if (matrix) {
185
199
  return {
186
200
  runId,
187
- participant,
201
+ key,
188
202
  host: "matrix",
189
203
  artifact: matrix.name,
190
204
  path: matrix.name,
191
205
  };
192
206
  }
193
207
 
194
- // Dispatch host: download the shared artifact and match a member filename.
208
+ // Dispatch host: download every shared artifact and collect the members
209
+ // whose basename matches the key (members are nested relative paths).
210
+ // Each artifact extracts into its own subdirectory — a shared extract
211
+ // dir would re-list earlier artifacts' members on every iteration, so a
212
+ // uniquely-matching key on a multi-artifact (sharded) run would throw a
213
+ // spurious ambiguity error.
214
+ const baseDir = opts.dir ?? `/tmp/trace-${runId}`;
215
+ const matches = [];
195
216
  for (const artifact of traceArtifacts) {
196
217
  const { dir, files } = await this.downloadTrace(runId, {
197
218
  name: artifact.name,
198
- dir: opts.dir,
219
+ dir: path.join(baseDir, artifact.name),
199
220
  });
200
- const member = files.find((f) => participantInNames([f], participant));
201
- if (member) {
202
- return {
203
- runId,
204
- participant,
205
- host: "dispatch",
206
- artifact: artifact.name,
207
- path: path.join(dir, member),
208
- };
221
+ for (const member of files) {
222
+ if (nameMatchesKey(path.basename(member), key)) {
223
+ matches.push({ artifact: artifact.name, dir, member });
224
+ }
209
225
  }
210
226
  }
211
227
 
212
- throw new Error(
213
- `No trace lane for participant "${participant}" in run ${runId}`,
214
- );
228
+ if (matches.length === 1) {
229
+ const m = matches[0];
230
+ return {
231
+ runId,
232
+ key,
233
+ host: "dispatch",
234
+ artifact: m.artifact,
235
+ path: path.join(m.dir, m.member),
236
+ };
237
+ }
238
+ if (matches.length > 1) {
239
+ const names = matches.map((m) => m.member).join(", ");
240
+ throw new Error(
241
+ `Ambiguous key "${key}" for run ${runId}: matches ${names}. Narrow the key to a case id or exact filename.`,
242
+ );
243
+ }
244
+ throw new Error(`No trace lane for key "${key}" in run ${runId}`);
215
245
  }
216
246
 
217
247
  /**
@@ -271,9 +301,9 @@ export class TraceGitHub {
271
301
  );
272
302
  }
273
303
 
274
- // List extracted files.
275
- const entries = await fs.readdir(dir);
276
- const files = entries.filter((f) => !f.endsWith(".zip"));
304
+ // List extracted files — recursively, since eval artifacts carry nested
305
+ // members (`runs/<taskId>/<idx>/trace--*`).
306
+ const files = await listExtractedFiles(this.runtime, dir);
277
307
 
278
308
  return { dir, artifact: artifact.name, files };
279
309
  }
@@ -301,34 +331,22 @@ export class TraceGitHub {
301
331
  }
302
332
 
303
333
  /**
304
- * Test whether a participant's trace lane is present in a list of names.
305
- *
306
- * This function matches the two trace-name shapes by *name* only (never by
307
- * content):
308
- * - matrix artifact name: `trace--<participant>`
309
- * - dispatch member filename: `trace--<case>--<participant>.<role>.ndjson`
310
- *
311
- * The `--` separator delimits the participant segment. The segment ends at
312
- * the next `--`, at a `.`, or at the end of the string. So a substring like
313
- * `release` does not match `release-engineer` and vice versa.
314
- *
315
- * @param {string[]} names - Artifact names or extracted member filenames.
316
- * @param {string} participant - Participant name to look for.
317
- * @returns {boolean}
334
+ * List every regular file under `dir` recursively, as paths relative to
335
+ * `dir`, excluding `*.zip` (the downloaded archive itself). Sorted for a
336
+ * deterministic member order.
337
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
338
+ * @param {string} dir
339
+ * @returns {Promise<string[]>}
318
340
  */
319
- export function participantInNames(names, participant) {
320
- return names.some((name) => {
321
- if (!name.startsWith("trace--")) return false;
322
- const rest = name.slice("trace--".length);
323
- // Matrix: `<participant>` is the whole remainder (artifact name).
324
- if (rest === participant) return true;
325
- // Dispatch: `<case>--<participant>.<role>.ndjson`.
326
- const sep = rest.indexOf("--");
327
- if (sep === -1) return false;
328
- const afterCase = rest.slice(sep + 2);
329
- const participantSegment = afterCase.split(".")[0];
330
- return participantSegment === participant;
341
+ export async function listExtractedFiles(runtime, dir) {
342
+ const entries = await runtime.fs.readdir(dir, {
343
+ recursive: true,
344
+ withFileTypes: true,
331
345
  });
346
+ return entries
347
+ .filter((e) => e.isFile() && !e.name.endsWith(".zip"))
348
+ .map((e) => path.relative(dir, path.join(e.parentPath ?? e.path, e.name)))
349
+ .sort();
332
350
  }
333
351
 
334
352
  /**
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Trace identity grammar — the single owner of case ids and lane filenames.
3
+ *
4
+ * Builds case ids (`<taskId>-r<runIndex>`), builds raw/lane filenames under
5
+ * the shared `trace--` convention, validates task ids, and parses names back
6
+ * into identity. Workdir allocation, task-family loading, the shared split
7
+ * module, and GitHub discovery all invoke this module — files agreeing by
8
+ * convention is the drifted-copies pattern this module retires.
9
+ */
10
+
11
+ import { basename } from "node:path";
12
+
13
+ /**
14
+ * Task ids must not contain "--" or start/end with "-": the "--" delimiter
15
+ * and the terminal "-r<digits>" suffix then parse unambiguously.
16
+ * @param {string} id
17
+ * @returns {boolean}
18
+ */
19
+ export function isValidTaskId(id) {
20
+ if (typeof id !== "string" || id.length === 0) return false;
21
+ if (id.includes("--")) return false;
22
+ if (id.startsWith("-") || id.endsWith("-")) return false;
23
+ return true;
24
+ }
25
+
26
+ /**
27
+ * Build the grid-unique case id `<taskId>-r<runIndex>`. Shards partition one
28
+ * grid, so (task, runIndex) is already grid- and shard-unique.
29
+ * @param {string} taskId
30
+ * @param {number} runIndex
31
+ * @returns {string}
32
+ * @throws {Error} when `isValidTaskId(taskId)` is false or `runIndex` is not
33
+ * a non-negative integer.
34
+ */
35
+ export function buildCaseId(taskId, runIndex) {
36
+ if (!isValidTaskId(taskId)) {
37
+ throw new Error(
38
+ `invalid task id '${taskId}': task ids must not contain "--" or start/end with "-"`,
39
+ );
40
+ }
41
+ if (!Number.isInteger(runIndex) || runIndex < 0) {
42
+ throw new Error(`invalid run index '${runIndex}': must be an integer ≥ 0`);
43
+ }
44
+ return `${taskId}-r${runIndex}`;
45
+ }
46
+
47
+ /**
48
+ * Filename of the combined raw envelope trace: `trace--<caseId>.raw.ndjson`.
49
+ * @param {string} caseId
50
+ * @returns {string}
51
+ */
52
+ export function rawTraceFilename(caseId) {
53
+ return `trace--${caseId}.raw.ndjson`;
54
+ }
55
+
56
+ /**
57
+ * Filename of a per-participant lane:
58
+ * `trace--<caseId>--<participant>.<role>.ndjson`.
59
+ * @param {string} caseId
60
+ * @param {string} participant
61
+ * @param {string} role
62
+ * @returns {string}
63
+ */
64
+ export function laneFilename(caseId, participant, role) {
65
+ return `trace--${caseId}--${participant}.${role}.ndjson`;
66
+ }
67
+
68
+ /**
69
+ * Parse `trace--<case>--<participant>.<role>.ndjson` into `{caseName,
70
+ * participant}`. On no match, `caseName` is the basename minus its final
71
+ * `.ndjson` extension only and `participant` is null.
72
+ * @param {string} file
73
+ * @returns {{caseName: string, participant: string|null}}
74
+ */
75
+ export function parseIdentity(file) {
76
+ const name = basename(file);
77
+ const match = name.match(/^trace--(.+?)--(.+?)\.[^.]+\.ndjson$/);
78
+ if (match) {
79
+ return { caseName: match[1], participant: match[2] };
80
+ }
81
+ return { caseName: name.replace(/\.ndjson$/, ""), participant: null };
82
+ }
83
+
84
+ /**
85
+ * Test whether a participant's trace lane is present in a list of names.
86
+ *
87
+ * Matches the two trace-naming shapes by *name* only (never by content):
88
+ * - matrix artifact name: `trace--<participant>`
89
+ * - dispatch member filename: `trace--<case>--<participant>.<role>.ndjson`
90
+ *
91
+ * The participant segment is delimited by `--` and ends at the next `--`, `.`,
92
+ * or end-of-string, so a substring like `release` does not match
93
+ * `release-engineer` and vice versa.
94
+ *
95
+ * Kept as a distinct shape from {@link parseIdentity} deliberately: this
96
+ * matcher also accepts bare artifact names with no extension, which the
97
+ * filename regex cannot.
98
+ *
99
+ * @param {string[]} names - Artifact names or extracted member filenames.
100
+ * @param {string} participant - Participant name to look for.
101
+ * @returns {boolean}
102
+ */
103
+ export function participantInNames(names, participant) {
104
+ return names.some((name) => {
105
+ if (!name.startsWith("trace--")) return false;
106
+ const rest = name.slice("trace--".length);
107
+ // Matrix: `<participant>` is the whole remainder (artifact name).
108
+ if (rest === participant) return true;
109
+ // Dispatch: `<case>--<participant>.<role>.ndjson`.
110
+ const sep = rest.indexOf("--");
111
+ if (sep === -1) return false;
112
+ const afterCase = rest.slice(sep + 2);
113
+ const participantSegment = afterCase.split(".")[0];
114
+ return participantSegment === participant;
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Keyed-lookup rule for one name: true when `key` equals the exact basename,
120
+ * the parsed case segment, or the parsed participant segment. Derives case
121
+ * and participant via {@link parseIdentity} and reuses the
122
+ * {@link participantInNames} single-name check — no second grammar.
123
+ * @param {string} name - A member basename or artifact name.
124
+ * @param {string} key - Exact filename, case id, or participant name.
125
+ * @returns {boolean}
126
+ */
127
+ export function nameMatchesKey(name, key) {
128
+ if (name === key) return true;
129
+ const identity = parseIdentity(name);
130
+ if (identity.participant !== null && identity.caseName === key) return true;
131
+ return participantInNames([name], key);
132
+ }
@@ -12,6 +12,8 @@
12
12
  */
13
13
  import { basename } from "node:path";
14
14
 
15
+ import { parseIdentity } from "./trace-identity.js";
16
+
15
17
  /**
16
18
  * Load each file → `TraceQuery`. Run `query(tq)`. Tag each emitted record
17
19
  * with `source: <basename>` only when the caller supplies more than one file.
@@ -84,20 +86,3 @@ export function compareTwo(a, b, load) {
84
86
  bIdentity: parseIdentity(b),
85
87
  });
86
88
  }
87
-
88
- /**
89
- * Parse `trace--<case>--<participant>.<role>.ndjson` into `{caseName,
90
- * participant}`. On no match, `caseName` is the basename without its final
91
- * `.ndjson` extension. The function removes that one extension only.
92
- * `participant` is then null.
93
- * @param {string} file
94
- * @returns {{caseName: string, participant: string|null}}
95
- */
96
- export function parseIdentity(file) {
97
- const name = basename(file);
98
- const match = name.match(/^trace--(.+?)--(.+?)\.[^.]+\.ndjson$/);
99
- if (match) {
100
- return { caseName: match[1], participant: match[2] };
101
- }
102
- return { caseName: name.replace(/\.ndjson$/, ""), participant: null };
103
- }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Shared trace-split implementation — the single owner of source-to-role
3
+ * classification. Both the `gemba-trace split` command and the benchmark
4
+ * runner drive this module, so exactly one split policy exists (the same
5
+ * treatment the one-cost-path rule gives `sumTraceCost`).
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { createInterface } from "node:readline";
10
+
11
+ import { laneFilename } from "./trace-identity.js";
12
+
13
+ /** Valid source name pattern: lowercase letter, then lowercase alphanumeric or hyphen. */
14
+ const VALID_SOURCE_NAME = /^[a-z][a-z0-9-]*$/;
15
+
16
+ /**
17
+ * Sources whose name is itself a structural role; classified into the role
18
+ * they represent. `judge` is structural so the judge lane classifies under
19
+ * one rule — no current producer feeds judge-source envelopes through split
20
+ * (the judge is its own session), so kata split output is unchanged.
21
+ */
22
+ const STRUCTURAL_ROLES = new Set([
23
+ "agent",
24
+ "supervisor",
25
+ "facilitator",
26
+ "judge",
27
+ ]);
28
+
29
+ /**
30
+ * Split a combined `{source, seq, event}` NDJSON trace into per-source lane
31
+ * files named by `laneFilename(caseId, source, role)`.
32
+ *
33
+ * Classification: sources in the structural-role set ("agent", "supervisor",
34
+ * "facilitator", "judge") take their own name as role; any other valid source
35
+ * name classifies as role "agent" with the source as participant. Skips
36
+ * empty/malformed/non-envelope lines and orchestrator events; drops sources
37
+ * failing `/^[a-z][a-z0-9-]*$/`. Lane files carry unwrapped event JSON, one
38
+ * per line.
39
+ *
40
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime -
41
+ * Ambient collaborators; streams via `runtime.fs`.
42
+ * @param {string} inputPath - Combined NDJSON trace to split.
43
+ * @param {object} opts
44
+ * @param {string} opts.caseId - Case identity embedded in lane filenames.
45
+ * @param {string} opts.outputDir - Directory the lane files are written to.
46
+ * @returns {Promise<string[]>} Paths written, resolved against `outputDir`
47
+ * (absolute iff `outputDir` is absolute).
48
+ */
49
+ export async function splitTrace(runtime, inputPath, { caseId, outputDir }) {
50
+ const fs = runtime.fs;
51
+ const rl = createInterface({
52
+ input: fs.createReadStream(inputPath),
53
+ crlfDelay: Infinity,
54
+ });
55
+ const streams = new Map();
56
+ const paths = [];
57
+ for await (const line of rl) {
58
+ const envelope = parseEnvelopeLine(line);
59
+ if (!envelope) continue;
60
+ if (envelope.source === "orchestrator") continue;
61
+ if (!VALID_SOURCE_NAME.test(envelope.source)) continue;
62
+
63
+ let stream = streams.get(envelope.source);
64
+ if (!stream) {
65
+ const role = STRUCTURAL_ROLES.has(envelope.source)
66
+ ? envelope.source
67
+ : "agent";
68
+ const outPath = join(
69
+ outputDir,
70
+ laneFilename(caseId, envelope.source, role),
71
+ );
72
+ stream = fs.createWriteStream(outPath);
73
+ streams.set(envelope.source, stream);
74
+ paths.push(outPath);
75
+ }
76
+ stream.write(JSON.stringify(envelope.event) + "\n");
77
+ }
78
+ await Promise.all(
79
+ [...streams.values()].map((s) => new Promise((r) => s.end(r))),
80
+ );
81
+ return paths;
82
+ }
83
+
84
+ /**
85
+ * Parse one NDJSON line into a `{source, seq, event}` envelope, or null when
86
+ * the line is blank, malformed, or not an envelope. Shared with the
87
+ * raw-summary reader so exactly one envelope-line parser exists; callers add
88
+ * their own source filters.
89
+ * @param {string} line
90
+ * @returns {{source: string, event: object}|null}
91
+ */
92
+ export function parseEnvelopeLine(line) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed) return null;
95
+ let envelope;
96
+ try {
97
+ envelope = JSON.parse(trimmed);
98
+ } catch {
99
+ return null;
100
+ }
101
+ if (!envelope.event || typeof envelope.source !== "string") return null;
102
+ return envelope;
103
+ }