@forwardimpact/libeval 0.1.51 → 0.1.52

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.
@@ -14,20 +14,25 @@
14
14
  * of the above based on `opts.role`.
15
15
  */
16
16
 
17
- import { readFileSync } from "node:fs";
18
17
  import { join } from "node:path";
19
18
 
20
19
  /**
21
- * Compose a `claude_code`-preset system prompt from a profile file.
20
+ * Compose a `claude_code`-preset system prompt from a profile file. The
21
+ * profile is read synchronously off the injected `runtime.fsSync` surface —
22
+ * this composer runs inside the synchronous SDK-option builders of the
23
+ * supervisor / facilitator / discusser / judge factories, so it cannot go
24
+ * async without an unbounded cascade.
25
+ *
22
26
  * @param {string} name - Profile basename (no `.md` suffix)
23
27
  * @param {object} opts
24
28
  * @param {string} opts.profilesDir - Directory containing `<name>.md`
25
29
  * @param {string} [opts.trailer] - Mode-specific trailer appended after a blank line
30
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
26
31
  * @returns {{type: "preset", preset: "claude_code", append: string}}
27
32
  */
28
- export function composeProfilePrompt(name, { profilesDir, trailer }) {
33
+ export function composeProfilePrompt(name, { profilesDir, trailer, runtime }) {
29
34
  const path = join(profilesDir, `${name}.md`);
30
- const raw = readFileSync(path, "utf8");
35
+ const raw = runtime.fsSync.readFileSync(path, "utf8");
31
36
  const body = stripFrontmatter(raw).trim();
32
37
  const append = trailer && trailer.length > 0 ? `${body}\n\n${trailer}` : body;
33
38
  return { type: "preset", preset: "claude_code", append };
@@ -39,13 +44,14 @@ export function composeProfilePrompt(name, { profilesDir, trailer }) {
39
44
  * @param {string} [opts.profile] - Profile basename (no `.md` suffix)
40
45
  * @param {string} [opts.profilesDir] - Directory containing profile files
41
46
  * @param {string} opts.trailer - Mode-specific orchestration instructions
47
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
42
48
  * @returns {string}
43
49
  */
44
- export function composeLeadPrompt({ profile, profilesDir, trailer }) {
50
+ export function composeLeadPrompt({ profile, profilesDir, trailer, runtime }) {
45
51
  if (!trailer) throw new Error("trailer is required");
46
52
  if (!profile) return trailer;
47
53
  const path = join(profilesDir, `${profile}.md`);
48
- const raw = readFileSync(path, "utf8");
54
+ const raw = runtime.fsSync.readFileSync(path, "utf8");
49
55
  const body = stripFrontmatter(raw).trim();
50
56
  return `${body}\n\n${trailer}`;
51
57
  }
@@ -59,15 +65,22 @@ export function composeLeadPrompt({ profile, profilesDir, trailer }) {
59
65
  * @param {string} [opts.profile] - Profile basename
60
66
  * @param {string} [opts.profilesDir]
61
67
  * @param {string} opts.trailer - Mode-specific instructions
68
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
62
69
  * @returns {string | {type: "preset", preset: "claude_code", append: string}}
63
70
  */
64
- export function composeSystemPrompt({ role, profile, profilesDir, trailer }) {
71
+ export function composeSystemPrompt({
72
+ role,
73
+ profile,
74
+ profilesDir,
75
+ trailer,
76
+ runtime,
77
+ }) {
65
78
  if (!trailer) throw new Error("trailer is required");
66
79
  if (role === "lead") {
67
- return composeLeadPrompt({ profile, profilesDir, trailer });
80
+ return composeLeadPrompt({ profile, profilesDir, trailer, runtime });
68
81
  }
69
82
  if (profile) {
70
- return composeProfilePrompt(profile, { profilesDir, trailer });
83
+ return composeProfilePrompt(profile, { profilesDir, trailer, runtime });
71
84
  }
72
85
  return { type: "preset", preset: "claude_code", append: trailer };
73
86
  }
package/src/supervisor.js CHANGED
@@ -145,8 +145,10 @@ export function createSupervisor({
145
145
  taskAmend,
146
146
  agentMcpServers,
147
147
  redactor,
148
+ runtime,
148
149
  }) {
149
150
  if (!redactor) throw new Error("redactor is required");
151
+ if (!runtime) throw new Error("runtime is required");
150
152
  const resolvedProfilesDir =
151
153
  profilesDir ?? resolve(supervisorCwd, ".claude/agents");
152
154
 
@@ -180,6 +182,7 @@ export function createSupervisor({
180
182
  profile: agentProfile,
181
183
  profilesDir: resolvedProfilesDir,
182
184
  trailer: AGENT_SYSTEM_PROMPT,
185
+ runtime,
183
186
  }),
184
187
  mcpServers: { orchestration: agentServer, ...agentMcpServers },
185
188
  redactor,
@@ -213,6 +216,7 @@ export function createSupervisor({
213
216
  profile: supervisorProfile,
214
217
  profilesDir: resolvedProfilesDir,
215
218
  trailer: SUPERVISOR_SYSTEM_PROMPT,
219
+ runtime,
216
220
  }),
217
221
  mcpServers: { orchestration: supervisorServer },
218
222
  redactor,
package/src/tee-writer.js CHANGED
@@ -27,15 +27,17 @@ export class TeeWriter extends Writable {
27
27
  * @param {import("stream").Writable} deps.fileStream - Stream to write raw NDJSON to
28
28
  * @param {import("stream").Writable} deps.textStream - Stream to write human-readable text to
29
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())`).
30
32
  */
31
- constructor({ fileStream, textStream, mode }) {
33
+ constructor({ fileStream, textStream, mode, now }) {
32
34
  super();
33
35
  if (!fileStream) throw new Error("fileStream is required");
34
36
  if (!textStream) throw new Error("textStream is required");
35
37
  this.fileStream = fileStream;
36
38
  this.textStream = textStream;
37
39
  this.mode = mode ?? "raw";
38
- this.collector = new TraceCollector();
40
+ this.collector = new TraceCollector({ now });
39
41
  this.turnsEmitted = 0;
40
42
  }
41
43
 
@@ -9,6 +9,8 @@
9
9
  * one formatting path.
10
10
  */
11
11
 
12
+ import { isoTimestamp } from "@forwardimpact/libutil";
13
+
12
14
  import { renderTurnLines } from "./render/turn-renderer.js";
13
15
  import { isSuppressedOrchestratorEvent } from "./render/orchestrator-filter.js";
14
16
 
@@ -16,11 +18,16 @@ import { isSuppressedOrchestratorEvent } from "./render/orchestrator-filter.js";
16
18
  export class TraceCollector {
17
19
  /**
18
20
  * @param {object} [deps]
19
- * @param {function} [deps.now] - Returns ISO timestamp string. Defaults to () => new Date().toISOString()
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.
20
27
  */
21
28
  constructor(deps = {}) {
22
29
  /** @type {function} */
23
- this.now = deps.now ?? (() => new Date().toISOString());
30
+ this.now = deps.now ?? (() => isoTimestamp(0));
24
31
  /** @type {object|null} */
25
32
  this.metadata = null;
26
33
  /** @type {Array<object>} */
@@ -1,10 +1,9 @@
1
- import { execSync } from "node:child_process";
2
- import { createWriteStream } from "node:fs";
3
- import { mkdir } from "node:fs/promises";
4
1
  import path from "node:path";
5
2
  import { pipeline } from "node:stream/promises";
6
3
  import { Readable } from "node:stream";
7
4
 
5
+ import { isoTimestamp } from "@forwardimpact/libutil";
6
+
8
7
  const API = "https://api.github.com";
9
8
 
10
9
  /**
@@ -17,11 +16,15 @@ export class TraceGitHub {
17
16
  * @param {string} deps.token - GitHub token
18
17
  * @param {string} deps.owner - Repository owner
19
18
  * @param {string} deps.repo - Repository name
19
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} deps.runtime -
20
+ * Ambient collaborators; uses `fs`, `subprocess`, `clock`.
20
21
  */
21
- constructor({ token, owner, repo }) {
22
+ constructor({ token, owner, repo, runtime }) {
23
+ if (!runtime) throw new Error("runtime is required");
22
24
  this.token = token;
23
25
  this.owner = owner;
24
26
  this.repo = repo;
27
+ this.runtime = runtime;
25
28
  }
26
29
 
27
30
  /**
@@ -35,7 +38,7 @@ export class TraceGitHub {
35
38
  */
36
39
  async listRuns(opts = {}) {
37
40
  const { pattern = "agent", limit = 50, lookback = "7d" } = opts;
38
- const cutoff = parseLookback(lookback);
41
+ const cutoff = parseLookback(lookback, this.runtime.clock.now());
39
42
 
40
43
  const params = new URLSearchParams({
41
44
  per_page: String(Math.min(limit, 100)),
@@ -77,8 +80,9 @@ export class TraceGitHub {
77
80
  * @returns {Promise<{dir: string, artifact: string, files: string[]}>}
78
81
  */
79
82
  async downloadTrace(runId, opts = {}) {
83
+ const fs = this.runtime.fs;
80
84
  const dir = opts.dir ?? `/tmp/trace-${runId}`;
81
- await mkdir(dir, { recursive: true });
85
+ await fs.mkdir(dir, { recursive: true });
82
86
 
83
87
  // List artifacts for this run.
84
88
  const url = `${API}/repos/${this.owner}/${this.repo}/actions/runs/${runId}/artifacts`;
@@ -121,15 +125,27 @@ export class TraceGitHub {
121
125
  }
122
126
 
123
127
  // Stream to disk then extract.
124
- await pipeline(Readable.fromWeb(response.body), createWriteStream(zipPath));
125
-
126
- execSync(
127
- `unzip -o -q ${JSON.stringify(zipPath)} -d ${JSON.stringify(dir)}`,
128
+ await pipeline(
129
+ Readable.fromWeb(response.body),
130
+ fs.createWriteStream(zipPath),
128
131
  );
129
132
 
133
+ const unzip = await this.runtime.subprocess.run("unzip", [
134
+ "-o",
135
+ "-q",
136
+ zipPath,
137
+ "-d",
138
+ dir,
139
+ ]);
140
+ if (unzip.exitCode !== 0) {
141
+ throw new Error(
142
+ `unzip failed (${unzip.exitCode}): ${unzip.stderr || unzip.stdout}`,
143
+ );
144
+ }
145
+
130
146
  // List extracted files.
131
- const { readdirSync } = await import("node:fs");
132
- const files = readdirSync(dir).filter((f) => !f.endsWith(".zip"));
147
+ const entries = await fs.readdir(dir);
148
+ const files = entries.filter((f) => !f.endsWith(".zip"));
133
149
 
134
150
  return { dir, artifact: artifact.name, files };
135
151
  }
@@ -160,14 +176,15 @@ export class TraceGitHub {
160
176
  * Parse a lookback duration string into an ISO date string.
161
177
  * Supports: Nd (days), Nh (hours), Nw (weeks).
162
178
  * @param {string} lookback
179
+ * @param {number} nowMs - Current time in ms (`runtime.clock.now()`).
163
180
  * @returns {string|null} ISO date string or null if unparseable
164
181
  */
165
- function parseLookback(lookback) {
182
+ function parseLookback(lookback, nowMs) {
166
183
  const match = lookback.match(/^(\d+)([dhw])$/);
167
184
  if (!match) return null;
168
185
  const [, val, unit] = match;
169
186
  const ms = { d: 86400000, h: 3600000, w: 604800000 }[unit];
170
- return new Date(Date.now() - parseInt(val, 10) * ms).toISOString();
187
+ return isoTimestamp(nowMs - parseInt(val, 10) * ms);
171
188
  }
172
189
 
173
190
  /**
@@ -203,22 +220,23 @@ export function parseGitRemote(remote) {
203
220
  * 1. `GITHUB_REPOSITORY` env var (set automatically by GitHub Actions).
204
221
  * 2. `git remote get-url origin` in the current working directory.
205
222
  *
206
- * @returns {{owner: string, repo: string}}
223
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
224
+ * @returns {Promise<{owner: string, repo: string}>}
207
225
  * @throws {Error} with a clear message if neither source yields a parseable slug.
208
226
  */
209
- export function detectRepoSlug() {
210
- const env = process.env.GITHUB_REPOSITORY;
227
+ export async function detectRepoSlug(runtime) {
228
+ const env = runtime.proc.env.GITHUB_REPOSITORY;
211
229
  if (env && env.trim()) {
212
230
  return parseGitRemote(env.trim());
213
231
  }
214
232
 
215
- let remote;
216
- try {
217
- remote = execSync("git remote get-url origin", {
218
- encoding: "utf8",
219
- stdio: ["ignore", "pipe", "ignore"],
220
- }).trim();
221
- } catch {
233
+ const result = await runtime.subprocess.run("git", [
234
+ "remote",
235
+ "get-url",
236
+ "origin",
237
+ ]);
238
+ const remote = result.exitCode === 0 ? result.stdout.trim() : "";
239
+ if (result.exitCode !== 0) {
222
240
  throw new Error(
223
241
  "Cannot detect repository: set --repo <owner/repo>, export GITHUB_REPOSITORY, or run inside a git checkout with an 'origin' remote.",
224
242
  );
@@ -245,10 +263,12 @@ export function detectRepoSlug() {
245
263
  * @param {object} opts
246
264
  * @param {string} opts.token - GitHub token (e.g. from `Config.ghToken()`)
247
265
  * @param {string} [opts.repo] - "owner/repo" override (default: detect from git remote)
266
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators.
248
267
  * @returns {Promise<TraceGitHub>}
249
268
  */
250
269
  export async function createTraceGitHub(opts = {}) {
251
- const { token, repo: repoOverride } = opts;
270
+ const { token, repo: repoOverride, runtime } = opts;
271
+ if (!runtime) throw new Error("createTraceGitHub: runtime is required");
252
272
  if (!token) {
253
273
  throw new Error(
254
274
  "createTraceGitHub: token is required (pass Config.ghToken())",
@@ -257,7 +277,7 @@ export async function createTraceGitHub(opts = {}) {
257
277
 
258
278
  const { owner, repo } = repoOverride
259
279
  ? parseGitRemote(repoOverride)
260
- : detectRepoSlug();
280
+ : await detectRepoSlug(runtime);
261
281
 
262
- return new TraceGitHub({ token, owner, repo });
282
+ return new TraceGitHub({ token, owner, repo, runtime });
263
283
  }