@forwardimpact/libharness 0.1.22 → 1.0.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.
Files changed (86) hide show
  1. package/LICENSE +21 -201
  2. package/README.md +196 -80
  3. package/bin/fit-benchmark.js +44 -0
  4. package/bin/fit-harness.js +358 -0
  5. package/bin/fit-selfedit.js +165 -0
  6. package/bin/fit-trace.js +510 -0
  7. package/package.json +41 -11
  8. package/src/agent-runner.js +256 -0
  9. package/src/benchmark/apm-installer.js +207 -0
  10. package/src/benchmark/env-loader.js +158 -0
  11. package/src/benchmark/hook-env.js +40 -0
  12. package/src/benchmark/invariants.js +141 -0
  13. package/src/benchmark/judge.js +187 -0
  14. package/src/benchmark/npm-installer.js +87 -0
  15. package/src/benchmark/report.js +522 -0
  16. package/src/benchmark/result.js +127 -0
  17. package/src/benchmark/runner.js +583 -0
  18. package/src/benchmark/task-family.js +260 -0
  19. package/src/benchmark/workdir.js +298 -0
  20. package/src/commands/assert.js +153 -0
  21. package/src/commands/benchmark-definition.js +165 -0
  22. package/src/commands/benchmark-invariants.js +73 -0
  23. package/src/commands/benchmark-report.js +51 -0
  24. package/src/commands/benchmark-run.js +111 -0
  25. package/src/commands/by-discussion.js +94 -0
  26. package/src/commands/callback.js +119 -0
  27. package/src/commands/discuss.js +132 -0
  28. package/src/commands/facilitate.js +123 -0
  29. package/src/commands/output.js +36 -0
  30. package/src/commands/run.js +152 -0
  31. package/src/commands/supervise.js +136 -0
  32. package/src/commands/task-input.js +54 -0
  33. package/src/commands/tee.js +53 -0
  34. package/src/commands/trace.js +630 -0
  35. package/src/commands/work-tracker.js +35 -0
  36. package/src/cost.js +79 -0
  37. package/src/discuss-tools.js +173 -0
  38. package/src/discusser.js +394 -0
  39. package/src/events/github.js +161 -0
  40. package/src/facilitator.js +205 -0
  41. package/src/inbox-poller.js +81 -0
  42. package/src/index.js +72 -2
  43. package/src/judge.js +210 -0
  44. package/src/message-bus.js +118 -0
  45. package/src/orchestration-loop.js +330 -0
  46. package/src/orchestration-toolkit.js +441 -0
  47. package/src/orchestrator-helpers.js +23 -0
  48. package/src/profile-prompt.js +266 -0
  49. package/src/redaction.js +253 -0
  50. package/src/render/line-renderer.js +54 -0
  51. package/src/render/orchestrator-filter.js +19 -0
  52. package/src/render/palette.js +63 -0
  53. package/src/render/tool-hints.js +154 -0
  54. package/src/render/turn-renderer.js +96 -0
  55. package/src/reply-emitter.js +47 -0
  56. package/src/sequence-counter.js +21 -0
  57. package/src/signature-filter.js +27 -0
  58. package/src/supervisor.js +236 -0
  59. package/src/tee-writer.js +150 -0
  60. package/src/trace-collector.js +444 -0
  61. package/src/trace-github.js +473 -0
  62. package/src/trace-multi.js +101 -0
  63. package/src/trace-query.js +748 -0
  64. package/src/trace-render.js +211 -0
  65. package/src/trace-usage.js +249 -0
  66. package/src/fixture/assertions.js +0 -42
  67. package/src/fixture/cache.js +0 -50
  68. package/src/fixture/eval.js +0 -146
  69. package/src/fixture/index.js +0 -9
  70. package/src/fixture/pathway.js +0 -451
  71. package/src/fixture/services.js +0 -56
  72. package/src/mock/clients.js +0 -135
  73. package/src/mock/config.js +0 -45
  74. package/src/mock/data.js +0 -46
  75. package/src/mock/fs.js +0 -111
  76. package/src/mock/grpc.js +0 -94
  77. package/src/mock/http.js +0 -60
  78. package/src/mock/index.js +0 -36
  79. package/src/mock/infra.js +0 -219
  80. package/src/mock/logger.js +0 -42
  81. package/src/mock/observer.js +0 -74
  82. package/src/mock/resource-index.js +0 -95
  83. package/src/mock/service-callbacks.js +0 -39
  84. package/src/mock/services.js +0 -79
  85. package/src/mock/spy.js +0 -44
  86. package/src/mock/storage.js +0 -118
@@ -0,0 +1,473 @@
1
+ import path from "node:path";
2
+ import { pipeline } from "node:stream/promises";
3
+ import { Readable } from "node:stream";
4
+
5
+ import { isoTimestamp } from "@forwardimpact/libutil";
6
+
7
+ const API = "https://api.github.com";
8
+
9
+ /**
10
+ * GitHub API client for trace-related operations: listing workflow runs
11
+ * and downloading trace artifacts.
12
+ */
13
+ export class TraceGitHub {
14
+ /**
15
+ * @param {object} deps
16
+ * @param {string} deps.token - GitHub token
17
+ * @param {string} deps.owner - Repository owner
18
+ * @param {string} deps.repo - Repository name
19
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} deps.runtime -
20
+ * Ambient collaborators; uses `fs`, `subprocess`, `clock`.
21
+ */
22
+ constructor({ token, owner, repo, runtime }) {
23
+ if (!runtime) throw new Error("runtime is required");
24
+ this.token = token;
25
+ this.owner = owner;
26
+ this.repo = repo;
27
+ this.runtime = runtime;
28
+ }
29
+
30
+ /**
31
+ * List recent workflow runs, optionally filtered by name pattern and by the
32
+ * participant whose trace lane a run carries.
33
+ *
34
+ * Without `participant`, behaviour is unchanged: the workflow-name pattern is
35
+ * the only filter. With `participant`, each name-matched run is resolved
36
+ * against its trace lane (see {@link runMatchesParticipant}) and annotated
37
+ * with a `match` field:
38
+ * - `"confirmed"` — the participant's lane is present in the run's
39
+ * artifacts (matrix artifact name, or a member filename in the shared
40
+ * dispatch artifact).
41
+ * - `"unconfirmed-pending-artifacts"` — the run's workflow mints trace
42
+ * artifacts but none exist yet (still running, or completed-but-not-yet
43
+ * uploaded); reported as a candidate, never silently dropped.
44
+ * Runs that have artifacts but no matching lane are omitted. Participant
45
+ * identity is read from artifact/file *names* only, never from trace content.
46
+ *
47
+ * @param {object} [opts]
48
+ * @param {string} [opts.pattern] - Case-insensitive regex to match workflow name (default: "kata|agent" — covers `Kata: Shift`, `Kata: Dispatch`, and any `agent`-named workflow)
49
+ * @param {number} [opts.limit=50] - Max runs to return from GitHub API
50
+ * @param {string} [opts.lookback="7d"] - How far back to search (e.g. "7d", "24h", "2w")
51
+ * @param {string} [opts.participant] - Participant name; when set, filter/annotate runs by trace lane
52
+ * @returns {Promise<object[]>} Array of {workflow, runId, status, conclusion, createdAt, branch, url[, match]}
53
+ */
54
+ async listRuns(opts = {}) {
55
+ const { pattern = "kata|agent", limit = 50, lookback = "7d" } = opts;
56
+ const cutoff = parseLookback(lookback, this.runtime.clock.now());
57
+
58
+ const params = new URLSearchParams({
59
+ per_page: String(Math.min(limit, 100)),
60
+ });
61
+ if (cutoff) {
62
+ params.set("created", `>=${cutoff}`);
63
+ }
64
+
65
+ const url = `${API}/repos/${this.owner}/${this.repo}/actions/runs?${params}`;
66
+ const data = await this.#get(url);
67
+ const runs = data.workflow_runs ?? [];
68
+
69
+ const re = new RegExp(pattern, "i");
70
+ const matched = runs
71
+ .filter((r) => re.test(r.name))
72
+ .map((r) => ({
73
+ workflow: r.name,
74
+ runId: r.id,
75
+ status: r.status,
76
+ conclusion: r.conclusion,
77
+ createdAt: r.created_at,
78
+ branch: r.head_branch,
79
+ url: r.html_url,
80
+ }));
81
+
82
+ if (!opts.participant) return matched;
83
+
84
+ const out = [];
85
+ for (const run of matched) {
86
+ const verdict = await this.runMatchesParticipant(
87
+ run.runId,
88
+ opts.participant,
89
+ );
90
+ if (verdict === "omit") continue;
91
+ out.push({ ...run, match: verdict });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Decide whether a run carries a participant's trace lane.
98
+ *
99
+ * Matrix hosts name the participant in an artifact name
100
+ * (`trace--<participant>`); dispatch hosts name it in a member filename
101
+ * (`trace--<case>--<participant>.<role>.ndjson`) inside one shared `trace--*`
102
+ * artifact. The GitHub artifacts API exposes only artifact-level metadata, so
103
+ * a matrix lane confirms from the inventory alone, while a dispatch lane
104
+ * requires downloading the shared artifact and listing its extracted member
105
+ * filenames — names only, never trace content.
106
+ *
107
+ * A run whose trace artifacts are absent (still running, or
108
+ * completed-but-not-yet-uploaded) is a candidate, not a drop.
109
+ *
110
+ * @param {number|string} runId
111
+ * @param {string} participant
112
+ * @returns {Promise<"confirmed"|"unconfirmed-pending-artifacts"|"omit">}
113
+ */
114
+ async runMatchesParticipant(runId, participant) {
115
+ const url = `${API}/repos/${this.owner}/${this.repo}/actions/runs/${runId}/artifacts`;
116
+ const data = await this.#get(url);
117
+ const artifacts = data.artifacts ?? [];
118
+ const traceArtifacts = artifacts.filter((a) =>
119
+ a.name.startsWith("trace--"),
120
+ );
121
+
122
+ // No trace artifacts yet: a candidate the matcher must report, not drop —
123
+ // the lane may upload when the host completes.
124
+ if (traceArtifacts.length === 0) return "unconfirmed-pending-artifacts";
125
+
126
+ // Matrix host: the participant is an artifact name. No download.
127
+ if (
128
+ participantInNames(
129
+ traceArtifacts.map((a) => a.name),
130
+ participant,
131
+ )
132
+ ) {
133
+ return "confirmed";
134
+ }
135
+
136
+ // Dispatch host: one shared artifact whose members name the participant.
137
+ // Download and list member filenames (names only).
138
+ for (const artifact of traceArtifacts) {
139
+ const { files } = await this.downloadTrace(runId, {
140
+ name: artifact.name,
141
+ });
142
+ if (participantInNames(files, participant)) return "confirmed";
143
+ }
144
+ return "omit";
145
+ }
146
+
147
+ /**
148
+ * Resolve a participant's lane trace path for a known run in one keyed
149
+ * lookup — no run enumeration, no trace-content inspection.
150
+ *
151
+ * Matrix host: the artifact name carries the participant (no download).
152
+ * Dispatch host: download the shared `trace--*` artifact and return the
153
+ * extracted member file whose name carries the participant.
154
+ *
155
+ * @param {number|string} runId
156
+ * @param {string} participant
157
+ * @param {object} [opts]
158
+ * @param {string} [opts.dir] - Output directory for a downloaded dispatch artifact
159
+ * @returns {Promise<{runId: (number|string), participant: string, host: "matrix"|"dispatch", artifact: string, path: string}>}
160
+ * @throws {Error} when the run has no trace artifacts, or none carries the participant's lane.
161
+ */
162
+ async findByKey(runId, participant, opts = {}) {
163
+ const url = `${API}/repos/${this.owner}/${this.repo}/actions/runs/${runId}/artifacts`;
164
+ const data = await this.#get(url);
165
+ const artifacts = data.artifacts ?? [];
166
+ const traceArtifacts = artifacts.filter((a) =>
167
+ a.name.startsWith("trace--"),
168
+ );
169
+ if (traceArtifacts.length === 0) {
170
+ throw new Error(`No trace artifacts for run ${runId}`);
171
+ }
172
+
173
+ // Matrix host: the artifact name carries the participant. No download.
174
+ const matrix = traceArtifacts.find((a) =>
175
+ participantInNames([a.name], participant),
176
+ );
177
+ if (matrix) {
178
+ return {
179
+ runId,
180
+ participant,
181
+ host: "matrix",
182
+ artifact: matrix.name,
183
+ path: matrix.name,
184
+ };
185
+ }
186
+
187
+ // Dispatch host: download the shared artifact and match a member filename.
188
+ for (const artifact of traceArtifacts) {
189
+ const { dir, files } = await this.downloadTrace(runId, {
190
+ name: artifact.name,
191
+ dir: opts.dir,
192
+ });
193
+ const member = files.find((f) => participantInNames([f], participant));
194
+ if (member) {
195
+ return {
196
+ runId,
197
+ participant,
198
+ host: "dispatch",
199
+ artifact: artifact.name,
200
+ path: path.join(dir, member),
201
+ };
202
+ }
203
+ }
204
+
205
+ throw new Error(
206
+ `No trace lane for participant "${participant}" in run ${runId}`,
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Download a trace artifact from a workflow run and extract it.
212
+ *
213
+ * When `opts.name` is set, looks up that exact artifact. Otherwise picks
214
+ * the single `trace--*` artifact if exactly one exists, or throws with a
215
+ * disambiguation list when matrix workflows emit multiple per-participant
216
+ * artifacts (see {@link pickTraceArtifact}).
217
+ *
218
+ * @param {number|string} runId
219
+ * @param {object} [opts]
220
+ * @param {string} [opts.dir] - Output directory (default: /tmp/trace-<runId>)
221
+ * @param {string} [opts.name] - Specific artifact name to download
222
+ * @returns {Promise<{dir: string, artifact: string, files: string[]}>}
223
+ */
224
+ async downloadTrace(runId, opts = {}) {
225
+ const fs = this.runtime.fs;
226
+ const dir = opts.dir ?? `/tmp/trace-${runId}`;
227
+ await fs.mkdir(dir, { recursive: true });
228
+
229
+ // List artifacts for this run.
230
+ const url = `${API}/repos/${this.owner}/${this.repo}/actions/runs/${runId}/artifacts`;
231
+ const data = await this.#get(url);
232
+ const artifacts = data.artifacts ?? [];
233
+ const artifact = pickTraceArtifact(artifacts, opts.name, runId);
234
+
235
+ // Download the zip.
236
+ const zipPath = path.join(dir, `${artifact.name}.zip`);
237
+ const downloadUrl = `${API}/repos/${this.owner}/${this.repo}/actions/artifacts/${artifact.id}/zip`;
238
+ const response = await fetch(downloadUrl, {
239
+ headers: this.#headers(),
240
+ redirect: "follow",
241
+ });
242
+ if (!response.ok) {
243
+ throw new Error(
244
+ `Failed to download artifact: ${response.status} ${response.statusText}`,
245
+ );
246
+ }
247
+
248
+ // Stream to disk then extract.
249
+ await pipeline(
250
+ Readable.fromWeb(response.body),
251
+ fs.createWriteStream(zipPath),
252
+ );
253
+
254
+ const unzip = await this.runtime.subprocess.run("unzip", [
255
+ "-o",
256
+ "-q",
257
+ zipPath,
258
+ "-d",
259
+ dir,
260
+ ]);
261
+ if (unzip.exitCode !== 0) {
262
+ throw new Error(
263
+ `unzip failed (${unzip.exitCode}): ${unzip.stderr || unzip.stdout}`,
264
+ );
265
+ }
266
+
267
+ // List extracted files.
268
+ const entries = await fs.readdir(dir);
269
+ const files = entries.filter((f) => !f.endsWith(".zip"));
270
+
271
+ return { dir, artifact: artifact.name, files };
272
+ }
273
+
274
+ /**
275
+ * @param {string} url
276
+ * @returns {Promise<object>}
277
+ */
278
+ async #get(url) {
279
+ const response = await fetch(url, { headers: this.#headers() });
280
+ if (!response.ok) {
281
+ throw new Error(`GitHub API: ${response.status} ${response.statusText}`);
282
+ }
283
+ return response.json();
284
+ }
285
+
286
+ /** @returns {Record<string, string>} */
287
+ #headers() {
288
+ return {
289
+ Authorization: `Bearer ${this.token}`,
290
+ Accept: "application/vnd.github+json",
291
+ "X-GitHub-Api-Version": "2022-11-28",
292
+ };
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Test whether a participant's trace lane is present in a list of names.
298
+ *
299
+ * Matches the two trace-naming shapes by *name* only (never by content):
300
+ * - matrix artifact name: `trace--<participant>`
301
+ * - dispatch member filename: `trace--<case>--<participant>.<role>.ndjson`
302
+ *
303
+ * The participant segment is delimited by `--` and ends at the next `--`, `.`,
304
+ * or end-of-string, so a substring like `release` does not match
305
+ * `release-engineer` and vice versa.
306
+ *
307
+ * @param {string[]} names - Artifact names or extracted member filenames.
308
+ * @param {string} participant - Participant name to look for.
309
+ * @returns {boolean}
310
+ */
311
+ export function participantInNames(names, participant) {
312
+ return names.some((name) => {
313
+ if (!name.startsWith("trace--")) return false;
314
+ const rest = name.slice("trace--".length);
315
+ // Matrix: `<participant>` is the whole remainder (artifact name).
316
+ if (rest === participant) return true;
317
+ // Dispatch: `<case>--<participant>.<role>.ndjson`.
318
+ const sep = rest.indexOf("--");
319
+ if (sep === -1) return false;
320
+ const afterCase = rest.slice(sep + 2);
321
+ const participantSegment = afterCase.split(".")[0];
322
+ return participantSegment === participant;
323
+ });
324
+ }
325
+
326
+ /**
327
+ * Pick the trace artifact to download from a workflow run's artifact list.
328
+ *
329
+ * When `name` is given, returns the exact match or throws with the available
330
+ * names. When `name` is omitted, returns the only `trace--*` artifact if
331
+ * there is exactly one; if there are multiple (matrix workflows like
332
+ * `kata-shift.yml` emit one `trace--<participant>` per cell), throws and
333
+ * lists them so the caller can pass `--name` to disambiguate.
334
+ *
335
+ * @param {Array<{name: string}>} artifacts - Artifact list from the GitHub API.
336
+ * @param {string} [name] - Exact artifact name to match.
337
+ * @param {number|string} [runId] - Run id for error messages.
338
+ * @returns {{name: string}} The selected artifact.
339
+ */
340
+ export function pickTraceArtifact(artifacts, name, runId) {
341
+ const runRef = runId == null ? "" : ` for run ${runId}`;
342
+ if (name) {
343
+ const found = artifacts.find((a) => a.name === name);
344
+ if (found) return found;
345
+ const available = artifacts.map((a) => a.name).join(", ");
346
+ throw new Error(
347
+ `No artifact named "${name}"${runRef}. Available: ${available || "none"}`,
348
+ );
349
+ }
350
+
351
+ const traceArtifacts = artifacts.filter((a) => a.name.startsWith("trace--"));
352
+ if (traceArtifacts.length === 1) return traceArtifacts[0];
353
+ if (traceArtifacts.length === 0) {
354
+ const available = artifacts.map((a) => a.name).join(", ");
355
+ throw new Error(
356
+ `No trace artifact found${runRef}. Available: ${available || "none"}`,
357
+ );
358
+ }
359
+ const names = traceArtifacts.map((a) => a.name).join(", ");
360
+ throw new Error(
361
+ `Multiple trace artifacts found${runRef}: ${names}. Pass --name to choose one.`,
362
+ );
363
+ }
364
+
365
+ /**
366
+ * Parse a lookback duration string into an ISO date string.
367
+ * Supports: Nd (days), Nh (hours), Nw (weeks).
368
+ * @param {string} lookback
369
+ * @param {number} nowMs - Current time in ms (`runtime.clock.now()`).
370
+ * @returns {string|null} ISO date string or null if unparseable
371
+ */
372
+ function parseLookback(lookback, nowMs) {
373
+ const match = lookback.match(/^(\d+)([dhw])$/);
374
+ if (!match) return null;
375
+ const [, val, unit] = match;
376
+ const ms = { d: 86400000, h: 3600000, w: 604800000 }[unit];
377
+ return isoTimestamp(nowMs - parseInt(val, 10) * ms);
378
+ }
379
+
380
+ /**
381
+ * Parse a GitHub repository URL or "owner/repo" string.
382
+ * @param {string} remote - Git remote URL or owner/repo string
383
+ * @returns {{owner: string, repo: string}}
384
+ */
385
+ export function parseGitRemote(remote) {
386
+ // SSH: git@github.com:owner/repo.git
387
+ const ssh = remote.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
388
+ if (ssh) return { owner: ssh[1], repo: ssh[2] };
389
+
390
+ // HTTPS: https://github.com/owner/repo
391
+ const https = remote.match(/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
392
+ if (https) return { owner: https[1], repo: https[2] };
393
+
394
+ // Plain owner/repo format (no github.com prefix).
395
+ const simple = remote.match(/^([^/:@]+)\/([^/]+)$/);
396
+ if (simple) return { owner: simple[1], repo: simple[2] };
397
+
398
+ // Generic URL fallback: any remote whose path ends in /owner/repo(.git)?
399
+ // Covers GitHub Enterprise, proxied git URLs, and mirrors.
400
+ const generic = remote.match(/[/:]([^/:@?#]+)\/([^/:@?#]+?)(?:\.git)?\/?$/);
401
+ if (generic) return { owner: generic[1], repo: generic[2] };
402
+
403
+ throw new Error(`Cannot parse GitHub remote: ${remote}`);
404
+ }
405
+
406
+ /**
407
+ * Detect the current GitHub repository slug as `{owner, repo}`.
408
+ *
409
+ * Resolution order:
410
+ * 1. `GITHUB_REPOSITORY` env var (set automatically by GitHub Actions).
411
+ * 2. `git remote get-url origin` in the current working directory.
412
+ *
413
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
414
+ * @returns {Promise<{owner: string, repo: string}>}
415
+ * @throws {Error} with a clear message if neither source yields a parseable slug.
416
+ */
417
+ export async function detectRepoSlug(runtime) {
418
+ const env = runtime.proc.env.GITHUB_REPOSITORY;
419
+ if (env && env.trim()) {
420
+ return parseGitRemote(env.trim());
421
+ }
422
+
423
+ const result = await runtime.subprocess.run("git", [
424
+ "remote",
425
+ "get-url",
426
+ "origin",
427
+ ]);
428
+ const remote = result.exitCode === 0 ? result.stdout.trim() : "";
429
+ if (result.exitCode !== 0) {
430
+ throw new Error(
431
+ "Cannot detect repository: set --repo <owner/repo>, export GITHUB_REPOSITORY, or run inside a git checkout with an 'origin' remote.",
432
+ );
433
+ }
434
+
435
+ if (!remote) {
436
+ throw new Error(
437
+ "Cannot detect repository: 'git remote get-url origin' returned an empty value. Pass --repo <owner/repo> or set GITHUB_REPOSITORY.",
438
+ );
439
+ }
440
+
441
+ return parseGitRemote(remote);
442
+ }
443
+
444
+ /**
445
+ * Create a TraceGitHub instance. The caller is responsible for resolving
446
+ * the GitHub token — typically via `Config.ghToken()` — so credential
447
+ * loading stays at the CLI entry point.
448
+ *
449
+ * Breaking change from the prior signature: `token` is now a required
450
+ * caller input. Construct a `Config` via `@forwardimpact/libconfig` and
451
+ * pass `config.ghToken()`.
452
+ *
453
+ * @param {object} opts
454
+ * @param {string} opts.token - GitHub token (e.g. from `Config.ghToken()`)
455
+ * @param {string} [opts.repo] - "owner/repo" override (default: detect from git remote)
456
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators.
457
+ * @returns {Promise<TraceGitHub>}
458
+ */
459
+ export async function createTraceGitHub(opts = {}) {
460
+ const { token, repo: repoOverride, runtime } = opts;
461
+ if (!runtime) throw new Error("createTraceGitHub: runtime is required");
462
+ if (!token) {
463
+ throw new Error(
464
+ "createTraceGitHub: token is required (pass Config.ghToken())",
465
+ );
466
+ }
467
+
468
+ const { owner, repo } = repoOverride
469
+ ? parseGitRemote(repoOverride)
470
+ : await detectRepoSlug(runtime);
471
+
472
+ return new TraceGitHub({ token, owner, repo, runtime });
473
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Multi-file orchestrator for cross-trace `fit-trace` verbs.
3
+ *
4
+ * Two functions centralise the load-tag-concat (`runOver`) and
5
+ * aggregate-and-sort (`aggregate`) policies so every cross-trace verb shares
6
+ * one source-attribution rule. `compareTwo` derives per-side identity from
7
+ * each input's basename and threads it into `TraceQuery.compare()`.
8
+ *
9
+ * `load` is injected (the exported `loadTrace` from `commands/trace.js`) so
10
+ * this module stays IO-policy-free and unit-testable with a stub.
11
+ */
12
+ import { basename } from "node:path";
13
+
14
+ /**
15
+ * Load each file → `TraceQuery`, run `query(tq)`, tag each emitted record with
16
+ * `source: <basename>` only when more than one file is supplied. Records are
17
+ * concatenated in file-then-record order.
18
+ * @param {string[]} files
19
+ * @param {(tq: object) => object[]} query
20
+ * @param {(file: string) => object} load
21
+ * @returns {object[]}
22
+ */
23
+ export function runOver(files, query, load) {
24
+ const multi = files.length > 1;
25
+ const out = [];
26
+ for (const file of files) {
27
+ const source = basename(file);
28
+ const records = query(load(file));
29
+ for (const record of records) {
30
+ out.push(multi ? { ...record, source } : record);
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * Merge per-file record arrays by `key(record)`, summing each record's
38
+ * existing `count` field (not occurrence count), and frequency-sort by
39
+ * `count desc`. Merged records carry `sources: string[]` only when more than
40
+ * one file is supplied.
41
+ * @param {string[]} files
42
+ * @param {(tq: object) => Array<{count: number}>} query
43
+ * @param {(record: object) => string} key
44
+ * @param {(file: string) => object} load
45
+ * @returns {object[]}
46
+ */
47
+ export function aggregate(files, query, key, load) {
48
+ const multi = files.length > 1;
49
+ const merged = new Map();
50
+ for (const file of files) {
51
+ const source = basename(file);
52
+ for (const record of query(load(file))) {
53
+ const k = key(record);
54
+ if (!merged.has(k)) {
55
+ merged.set(k, { record: { ...record }, sources: new Set() });
56
+ } else {
57
+ merged.get(k).record.count += record.count;
58
+ }
59
+ merged.get(k).sources.add(source);
60
+ }
61
+ }
62
+ return [...merged.values()]
63
+ .map(({ record, sources }) =>
64
+ multi ? { ...record, sources: [...sources].sort() } : record,
65
+ )
66
+ .sort((a, b) => b.count - a.count);
67
+ }
68
+
69
+ /**
70
+ * Load two files, derive each side's `{caseName, participant}` from its
71
+ * basename via the `split` convention, and thread them into
72
+ * `a.compare(b, {aIdentity, bIdentity})`.
73
+ * @param {string} a
74
+ * @param {string} b
75
+ * @param {(file: string) => object} load
76
+ * @returns {object}
77
+ */
78
+ export function compareTwo(a, b, load) {
79
+ const qa = load(a);
80
+ const qb = load(b);
81
+ return qa.compare(qb, {
82
+ aIdentity: parseIdentity(a),
83
+ bIdentity: parseIdentity(b),
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Parse `trace--<case>--<participant>.<role>.ndjson` into `{caseName,
89
+ * participant}`. On no match, `caseName` is the basename minus its final
90
+ * `.ndjson` extension only and `participant` is null.
91
+ * @param {string} file
92
+ * @returns {{caseName: string, participant: string|null}}
93
+ */
94
+ export function parseIdentity(file) {
95
+ const name = basename(file);
96
+ const match = name.match(/^trace--(.+?)--(.+?)\.[^.]+\.ndjson$/);
97
+ if (match) {
98
+ return { caseName: match[1], participant: match[2] };
99
+ }
100
+ return { caseName: name.replace(/\.ndjson$/, ""), participant: null };
101
+ }