@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,260 @@
1
+ /**
2
+ * Task-family loader. A task family is a directory under
3
+ * <root>/
4
+ * apm.lock.yaml
5
+ * .claude/ # pre-staged skills + agents
6
+ * tasks/<task_name>/
7
+ * agent.task.md
8
+ * supervisor.task.md # optional; appended to the task as supervisor context
9
+ * judge.task.md
10
+ * hooks/ # harness-only; never copied to agent CWD
11
+ * preflight.sh
12
+ * invariants.sh
13
+ * specs/ # copied into agent CWD
14
+ * workdir/ # copied into agent CWD
15
+ *
16
+ * Local paths or git URLs are both accepted; git URLs are shallow-cloned into
17
+ * a temp dir and `familyRevision` becomes `git:<sha>` of HEAD at clone time.
18
+ * Local paths use the canonical-tree algorithm from design § Family revision
19
+ * algorithm so the result is stable across operating systems.
20
+ *
21
+ * Filesystem and subprocess access route through the injected `runtime` bag
22
+ * (`runtime.fs` async, `runtime.subprocess.run` one-shot, `tmpdir` derived
23
+ * from `runtime.proc.env`).
24
+ */
25
+
26
+ import { createHash } from "node:crypto";
27
+ import { join, posix, relative, resolve, sep } from "node:path";
28
+
29
+ const GIT_URL_RE = /^(git@|https?:\/\/|ssh:\/\/|git:\/\/)/;
30
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
31
+ // POSIX `X_OK` (execute permission); node's fs honours the numeric mode, so we
32
+ // avoid importing `node:fs`'s `constants` (which would light the fs smell).
33
+ const X_OK = 1;
34
+
35
+ /**
36
+ * Derive the system temp dir from the env (node's `os.tmpdir()` is itself an
37
+ * env-respecting wrapper). The runtime bag has no `os` slot by design.
38
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
39
+ * @returns {string}
40
+ */
41
+ function tmpdir(runtime) {
42
+ return runtime.proc.env.TMPDIR ?? "/tmp";
43
+ }
44
+
45
+ /**
46
+ * Load a task family from a local path or git URL.
47
+ * @param {string} rootPathOrGitUrl
48
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
49
+ * @returns {Promise<TaskFamily>}
50
+ */
51
+ export async function loadTaskFamily(rootPathOrGitUrl, runtime) {
52
+ if (!runtime) throw new Error("runtime is required");
53
+ const isGit = GIT_URL_RE.test(rootPathOrGitUrl);
54
+ let rootPath;
55
+ let familyRevision;
56
+ if (isGit) {
57
+ const dir = await runtime.fs.mkdtemp(
58
+ join(tmpdir(runtime), "fit-benchmark-family-"),
59
+ );
60
+ await gitClone(runtime, rootPathOrGitUrl, dir);
61
+ rootPath = dir;
62
+ familyRevision = "git:" + (await gitHead(runtime, dir));
63
+ } else {
64
+ rootPath = resolve(rootPathOrGitUrl);
65
+ familyRevision = "sha256:" + (await canonicalTreeHash(runtime, rootPath));
66
+ }
67
+
68
+ const tasks = await discoverTasks(runtime, rootPath);
69
+
70
+ return {
71
+ rootPath,
72
+ familyRevision,
73
+ tasks() {
74
+ return tasks;
75
+ },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Assert that `<judgeProfilesDir>/<judgeProfile>.md` exists. Called from
81
+ * `BenchmarkRunner.run()` so a missing judge profile fails the family
82
+ * install before any agent session starts.
83
+ * @param {TaskFamily} _family
84
+ * @param {string} judgeProfilesDir
85
+ * @param {string} judgeProfile
86
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
87
+ * @returns {Promise<void>}
88
+ */
89
+ export async function assertJudgeProfileStaged(
90
+ _family,
91
+ judgeProfilesDir,
92
+ judgeProfile,
93
+ runtime,
94
+ ) {
95
+ const candidate = join(judgeProfilesDir, `${judgeProfile}.md`);
96
+ try {
97
+ await runtime.fs.access(candidate);
98
+ } catch {
99
+ throw new Error(`judge profile not staged: ${candidate}`);
100
+ }
101
+ }
102
+
103
+ async function discoverTasks(runtime, rootPath) {
104
+ const fs = runtime.fs;
105
+ const tasksRoot = join(rootPath, "tasks");
106
+ const tasks = [];
107
+ let entries;
108
+ try {
109
+ entries = await fs.readdir(tasksRoot, { withFileTypes: true });
110
+ } catch (e) {
111
+ if (e.code === "ENOENT") return tasks;
112
+ throw e;
113
+ }
114
+ for (const entry of entries) {
115
+ if (!entry.isDirectory()) continue;
116
+ const taskDir = join(tasksRoot, entry.name);
117
+ const supervisorPath = join(taskDir, "supervisor.task.md");
118
+ const judgePath = join(taskDir, "judge.task.md");
119
+ const preflightPath = join(taskDir, "hooks", "preflight.sh");
120
+ const invariantsPath = join(taskDir, "hooks", "invariants.sh");
121
+ tasks.push({
122
+ id: entry.name,
123
+ paths: {
124
+ taskDir,
125
+ instructions: join(taskDir, "agent.task.md"),
126
+ supervisor: (await fileExists(fs, supervisorPath))
127
+ ? supervisorPath
128
+ : null,
129
+ judge: (await fileExists(fs, judgePath)) ? judgePath : null,
130
+ hooks: join(taskDir, "hooks"),
131
+ preflight: (await fileExecutable(fs, preflightPath))
132
+ ? preflightPath
133
+ : null,
134
+ invariants: (await fileExecutable(fs, invariantsPath))
135
+ ? invariantsPath
136
+ : null,
137
+ specs: join(taskDir, "specs"),
138
+ workdir: join(taskDir, "workdir"),
139
+ },
140
+ });
141
+ }
142
+ tasks.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
143
+ return tasks;
144
+ }
145
+
146
+ async function fileExists(fs, path) {
147
+ try {
148
+ await fs.access(path);
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ async function fileExecutable(fs, path) {
156
+ try {
157
+ await fs.access(path, X_OK);
158
+ return true;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Canonical-tree hash per design § Family revision algorithm:
166
+ * list regular files (excluding .git/, node_modules/)
167
+ * resolve symlinks before reading
168
+ * sort by NFC-normalised POSIX-style root-relative path
169
+ * row = <rel-path>\0<hex-sha256>\n
170
+ * sha256(concat(rows))
171
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
172
+ * @param {string} rootPath
173
+ * @returns {Promise<string>} hex digest
174
+ */
175
+ async function canonicalTreeHash(runtime, rootPath) {
176
+ const fs = runtime.fs;
177
+ const real = await fs.realpath(rootPath);
178
+ const rows = [];
179
+ for await (const filePath of walkFiles(fs, real)) {
180
+ const rel = toPosix(relative(real, filePath)).normalize("NFC");
181
+ const target = await fs.realpath(filePath);
182
+ const bytes = await fs.readFile(target);
183
+ const hex = createHash("sha256").update(bytes).digest("hex");
184
+ rows.push({ rel, hex });
185
+ }
186
+ rows.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
187
+ const acc = createHash("sha256");
188
+ for (const r of rows) acc.update(`${r.rel}\0${r.hex}\n`, "utf8");
189
+ return acc.digest("hex");
190
+ }
191
+
192
+ async function* walkFiles(fs, dir) {
193
+ const entries = await fs.readdir(dir, { withFileTypes: true });
194
+ for (const entry of entries) {
195
+ const full = join(dir, entry.name);
196
+ if (entry.isDirectory()) {
197
+ if (SKIP_DIRS.has(entry.name)) continue;
198
+ yield* walkFiles(fs, full);
199
+ } else if (entry.isSymbolicLink()) {
200
+ const resolvedFile = await resolveSymlinkToFile(fs, full);
201
+ if (resolvedFile) yield full;
202
+ } else if (entry.isFile()) {
203
+ yield full;
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Return the resolved path if `linkPath` is a symlink to a regular file.
210
+ * Returns null for dangling symlinks or links to non-file targets.
211
+ */
212
+ async function resolveSymlinkToFile(fs, linkPath) {
213
+ const st = await fs.lstat(linkPath);
214
+ if (!st.isSymbolicLink()) return null;
215
+ try {
216
+ const resolved = await fs.realpath(linkPath);
217
+ const tstat = await fs.lstat(resolved);
218
+ return tstat.isFile() ? resolved : null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ function toPosix(p) {
225
+ if (sep === posix.sep) return p;
226
+ return p.split(sep).join(posix.sep);
227
+ }
228
+
229
+ async function gitClone(runtime, url, dir) {
230
+ await git(runtime, ["clone", "--depth", "1", url, dir]);
231
+ }
232
+
233
+ async function gitHead(runtime, dir) {
234
+ const out = await git(runtime, ["-C", dir, "rev-parse", "HEAD"]);
235
+ return out.trim();
236
+ }
237
+
238
+ async function git(runtime, args) {
239
+ const { stdout, stderr, exitCode } = await runtime.subprocess.run(
240
+ "git",
241
+ args,
242
+ );
243
+ if (exitCode !== 0) {
244
+ throw new Error(`git ${args.join(" ")} exited ${exitCode}: ${stderr}`);
245
+ }
246
+ return stdout;
247
+ }
248
+
249
+ /**
250
+ * @typedef {object} Task
251
+ * @property {string} id - Task name (directory name under tasks/)
252
+ * @property {{taskDir: string, instructions: string, supervisor: string|null, judge: string|null, hooks: string, preflight: string|null, invariants: string|null, specs: string, workdir: string}} paths
253
+ */
254
+
255
+ /**
256
+ * @typedef {object} TaskFamily
257
+ * @property {string} rootPath
258
+ * @property {string} familyRevision - `git:<sha>` or `sha256:<hex>`
259
+ * @property {() => Task[]} tasks
260
+ */
@@ -0,0 +1,298 @@
1
+ /**
2
+ * WorkdirManager — per-task lifecycle: create the agent CWD, seed it from the
3
+ * task's workdir + specs + staged .claude/, allocate a free TCP port, run
4
+ * the pre-flight smoke probe, and tear down the process group at end of run.
5
+ *
6
+ * The Workdir handle threads `cwd`, `port`, `pgid`, and trace paths through
7
+ * runAgent → invariants → judge → teardown.
8
+ *
9
+ * Filesystem, subprocess, clock, and process-signal access all route through
10
+ * the injected `runtime` bag. Only raw TCP plumbing (`node:net`) stays direct —
11
+ * it is not an ambient-dependency smell and the runtime bag models no socket
12
+ * surface.
13
+ */
14
+
15
+ import { createServer } from "node:net";
16
+ import { connect } from "node:net";
17
+ import { join } from "node:path";
18
+
19
+ import { loadEnv } from "./env-loader.js";
20
+ import { buildHookEnv } from "./hook-env.js";
21
+
22
+ const DEFAULT_TERM_GRACE_MS = 5_000;
23
+
24
+ /**
25
+ * @typedef {object} Workdir
26
+ * @property {string} cwd - Agent CWD (per-task copy).
27
+ * @property {string} runDir - Parent of `cwd`; holds trace/log siblings.
28
+ * @property {number} port - Allocated TCP port for the agent.
29
+ * @property {number} pgid - Process-group id captured from the preflight child.
30
+ * @property {*} scaffold - Reserved per design § Components; v1 sets null.
31
+ * @property {string} agentTracePath
32
+ * @property {string} supervisorTracePath
33
+ * @property {string} judgeTracePath
34
+ * @property {string[]} [envNames] - Env var names loaded from .env files.
35
+ * @property {{phase: string, message: string, exitCode: number}} [preflightError]
36
+ */
37
+
38
+ /** Per-task workdir lifecycle: seed → preflight → teardown. */
39
+ export class WorkdirManager {
40
+ /**
41
+ * @param {object} deps
42
+ * @param {string} deps.stagingDir - Output of `installApm(...)`.
43
+ * @param {string} deps.runOutputDir - Root run-output directory (parent of `runs/`).
44
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} deps.runtime -
45
+ * Ambient collaborators; uses `fs`, `subprocess`, `clock`, `proc`.
46
+ */
47
+ constructor({
48
+ stagingDir,
49
+ runOutputDir,
50
+ termGraceMs,
51
+ familyRootPath,
52
+ runtime,
53
+ }) {
54
+ if (!stagingDir) throw new Error("stagingDir is required");
55
+ if (!runOutputDir) throw new Error("runOutputDir is required");
56
+ if (!runtime) throw new Error("runtime is required");
57
+ this.stagingDir = stagingDir;
58
+ this.runOutputDir = runOutputDir;
59
+ this.termGraceMs = termGraceMs ?? DEFAULT_TERM_GRACE_MS;
60
+ this.familyRootPath = familyRootPath ?? null;
61
+ this.runtime = runtime;
62
+ }
63
+
64
+ /**
65
+ * Create the per-task working directory and run the pre-flight probe.
66
+ * @param {import("./task-family.js").Task} task
67
+ * @param {number} runIndex
68
+ * @returns {Promise<Workdir>}
69
+ */
70
+ async start(task, runIndex) {
71
+ const fs = this.runtime.fs;
72
+ const slug = task.id.replace("/", "__");
73
+ const runDir = join(this.runOutputDir, "runs", slug, String(runIndex));
74
+ const cwd = join(runDir, "cwd");
75
+ await fs.mkdir(cwd, { recursive: true });
76
+
77
+ // Family-level shared fixtures: convention-over-configuration, copied if
78
+ // present. They form the shared base; the per-task workdir/specs below
79
+ // overlay on top (fs.cp defaults to force:true, so a per-task file wins).
80
+ if (this.familyRootPath) {
81
+ await fs
82
+ .cp(join(this.familyRootPath, "workdir"), cwd, { recursive: true })
83
+ .catch((e) => {
84
+ if (e.code !== "ENOENT") throw e;
85
+ });
86
+ await fs
87
+ .cp(join(this.familyRootPath, "specs"), join(cwd, "specs"), {
88
+ recursive: true,
89
+ })
90
+ .catch((e) => {
91
+ if (e.code !== "ENOENT") throw e;
92
+ });
93
+ }
94
+
95
+ await fs.cp(task.paths.workdir, cwd, { recursive: true }).catch((e) => {
96
+ if (e.code !== "ENOENT") throw e;
97
+ });
98
+ await fs
99
+ .cp(task.paths.specs, join(cwd, "specs"), {
100
+ recursive: true,
101
+ })
102
+ .catch((e) => {
103
+ if (e.code !== "ENOENT") throw e;
104
+ });
105
+ await fs.cp(join(this.stagingDir, ".claude"), join(cwd, ".claude"), {
106
+ recursive: true,
107
+ });
108
+ await fs
109
+ .cp(join(this.stagingDir, "node_modules"), join(cwd, "node_modules"), {
110
+ recursive: true,
111
+ })
112
+ .catch((e) => {
113
+ if (e.code !== "ENOENT") throw e;
114
+ });
115
+
116
+ const envDirs = [
117
+ ...(this.familyRootPath ? [this.familyRootPath] : []),
118
+ ...(task.paths.taskDir ? [task.paths.taskDir] : []),
119
+ ];
120
+ const envNames =
121
+ envDirs.length > 0 ? await loadEnv(envDirs, cwd, this.runtime) : [];
122
+
123
+ const port = await allocatePort();
124
+ const agentTracePath = join(runDir, "agent.ndjson");
125
+ const supervisorTracePath = join(runDir, "supervisor.ndjson");
126
+ const judgeTracePath = join(runDir, "judge.ndjson");
127
+
128
+ const preflight = task.paths.preflight
129
+ ? await runPreflight(this.runtime, task.paths.preflight, cwd, port, {
130
+ taskId: task.id,
131
+ taskDir: task.paths.taskDir,
132
+ hooksDir: task.paths.hooks,
133
+ familyDir: this.familyRootPath,
134
+ })
135
+ : { pgid: 0 };
136
+
137
+ return {
138
+ cwd,
139
+ runDir,
140
+ port,
141
+ pgid: preflight.pgid,
142
+ scaffold: null,
143
+ agentTracePath,
144
+ supervisorTracePath,
145
+ judgeTracePath,
146
+ envNames,
147
+ ...(preflight.error && { preflightError: preflight.error }),
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Tear down the per-task process group: SIGTERM, wait, SIGKILL, then probe.
153
+ * @param {Workdir} workdir
154
+ * @returns {Promise<{portFree: boolean, descendants: number}>}
155
+ */
156
+ async teardown(workdir) {
157
+ const { proc, clock } = this.runtime;
158
+ if (workdir.pgid && workdir.pgid > 0) {
159
+ try {
160
+ proc.kill(-workdir.pgid, "SIGTERM");
161
+ } catch {
162
+ // Process group already gone — fine.
163
+ }
164
+ await clock.sleep(this.termGraceMs);
165
+ try {
166
+ proc.kill(-workdir.pgid, "SIGKILL");
167
+ } catch {
168
+ // Already exited.
169
+ }
170
+ // Poll briefly until the process group is empty — SIGKILL returns
171
+ // before the kernel finishes reaping descendants.
172
+ await waitFor(
173
+ this.runtime,
174
+ async () => (await countDescendants(this.runtime, workdir.pgid)) === 0,
175
+ 2_000,
176
+ );
177
+ }
178
+ const portFree = await isPortFree(workdir.port);
179
+ const descendants = await countDescendants(this.runtime, workdir.pgid);
180
+ return { portFree, descendants };
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Spawn preflight. Stays detached so we can SIGTERM the whole process group.
186
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
187
+ * @param {string} script
188
+ * @param {string} cwd - Agent CWD passed via $AGENT_CWD.
189
+ * @param {number} port - Free TCP port passed via $PORT.
190
+ * @param {{taskId: string, taskDir: string, hooksDir: string, familyDir: string|null}} vars - Extra hook env vars.
191
+ * @returns {Promise<{pgid: number, error?: {phase: string, message: string, exitCode: number}}>}
192
+ */
193
+ async function runPreflight(runtime, script, cwd, port, vars) {
194
+ const child = runtime.subprocess.spawn(script, [], {
195
+ cwd,
196
+ env: buildHookEnv(runtime.proc.env, { cwd, port, ...vars }),
197
+ detached: true,
198
+ stdio: ["ignore", "pipe", "pipe"],
199
+ });
200
+ if (child.pid === undefined) {
201
+ throw new Error(`failed to spawn preflight: ${script}`);
202
+ }
203
+ const pgid = child.pid;
204
+ let stderr = "";
205
+ const drainStdout = (async () => {
206
+ for await (const _chunk of child.stdout) {
207
+ // discard
208
+ }
209
+ })();
210
+ for await (const chunk of child.stderr) stderr += chunk.toString();
211
+ await drainStdout;
212
+ const code = await child.exitCode;
213
+ if (code === 0) return { pgid };
214
+ const message = stderr.trim() || `preflight exited with code ${code}`;
215
+ return {
216
+ pgid,
217
+ error: {
218
+ phase: "preflight",
219
+ message,
220
+ exitCode: typeof code === "number" ? code : -1,
221
+ },
222
+ };
223
+ }
224
+
225
+ function allocatePort() {
226
+ return new Promise((res, rej) => {
227
+ const server = createServer();
228
+ server.unref();
229
+ server.on("error", rej);
230
+ server.listen(0, "127.0.0.1", () => {
231
+ const addr = server.address();
232
+ if (!addr || typeof addr === "string") {
233
+ server.close();
234
+ rej(new Error("failed to allocate port"));
235
+ return;
236
+ }
237
+ const port = addr.port;
238
+ server.close(() => res(port));
239
+ });
240
+ });
241
+ }
242
+
243
+ function isPortFree(port) {
244
+ if (!port) return Promise.resolve(true);
245
+ return new Promise((res) => {
246
+ const socket = connect({ port, host: "127.0.0.1" }, () => {
247
+ socket.destroy();
248
+ res(false);
249
+ });
250
+ socket.on("error", () => res(true));
251
+ socket.setTimeout(500, () => {
252
+ socket.destroy();
253
+ res(true);
254
+ });
255
+ });
256
+ }
257
+
258
+ async function countDescendants(runtime, pgid) {
259
+ if (!pgid || pgid <= 0) return 0;
260
+ const child = runtime.subprocess.spawn(
261
+ "ps",
262
+ ["-o", "pid=", "-g", String(pgid)],
263
+ {
264
+ stdio: ["ignore", "pipe", "ignore"],
265
+ },
266
+ );
267
+ let out = "";
268
+ try {
269
+ for await (const chunk of child.stdout) out += chunk.toString();
270
+ await child.exitCode;
271
+ } catch {
272
+ return 0;
273
+ }
274
+ const pids = out
275
+ .split("\n")
276
+ .map((s) => s.trim())
277
+ .filter(Boolean)
278
+ .filter((s) => Number(s) !== runtime.proc.pid);
279
+ return pids.length;
280
+ }
281
+
282
+ async function waitFor(runtime, predicate, timeoutMs) {
283
+ const deadline = runtime.clock.now() + timeoutMs;
284
+ while (runtime.clock.now() < deadline) {
285
+ if (await predicate()) return true;
286
+ await runtime.clock.sleep(50);
287
+ }
288
+ return false;
289
+ }
290
+
291
+ /**
292
+ * Factory function — wires real dependencies.
293
+ * @param {ConstructorParameters<typeof WorkdirManager>[0]} deps
294
+ * @returns {WorkdirManager}
295
+ */
296
+ export function createWorkdirManager(deps) {
297
+ return new WorkdirManager(deps);
298
+ }
@@ -0,0 +1,153 @@
1
+ import { basename } from "node:path";
2
+ import jmespath from "jmespath";
3
+
4
+ /**
5
+ * Evaluate an assertion and return the structured result.
6
+ * @param {object} values - { grep?: string, query?: string, exists?: boolean, not?: boolean, message?: string }
7
+ * @param {string[]} args - [testName, file]
8
+ * @param {object} fsSync - Sync filesystem surface (`runtime.fsSync`): `existsSync`, `readFileSync`.
9
+ * @returns {{ test: string, pass: boolean, message?: string }}
10
+ */
11
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: assertion dispatch by type
12
+ export function evaluateAssertion(values, args, fsSync) {
13
+ const testName = args[0];
14
+ if (!testName) throw new Error("assert: missing test name");
15
+
16
+ const file = args[1];
17
+ const modes = [
18
+ values.grep,
19
+ values.query,
20
+ values.exists,
21
+ values["cites-job"],
22
+ ].filter((v) => v !== undefined && v !== false);
23
+ if (modes.length === 0) {
24
+ throw new Error(
25
+ "assert: specify one of --grep, --query, --exists, or --cites-job",
26
+ );
27
+ }
28
+ if (modes.length > 1) {
29
+ throw new Error(
30
+ "assert: specify only one of --grep, --query, --exists, or --cites-job",
31
+ );
32
+ }
33
+
34
+ let result;
35
+ if (values.exists) {
36
+ if (!file) throw new Error("assert: missing file argument");
37
+ result = assertExists(file, fsSync);
38
+ } else if (values.grep) {
39
+ if (!file) throw new Error("assert: missing file argument for --grep");
40
+ result = assertGrep(values.grep, file, fsSync);
41
+ } else if (values["cites-job"]) {
42
+ if (!file) throw new Error("assert: missing file argument for --cites-job");
43
+ result = assertCitesJob(values["cites-job"], file, fsSync);
44
+ } else {
45
+ if (!file) throw new Error("assert: missing file argument for --query");
46
+ result = assertQuery(values.query, file, fsSync);
47
+ }
48
+
49
+ if (values.not) {
50
+ result.pass = !result.pass;
51
+ if (result.pass) {
52
+ delete result.message;
53
+ } else {
54
+ result.message =
55
+ result.message ?? `inverted assertion failed for ${basename(file)}`;
56
+ }
57
+ }
58
+
59
+ if (!result.pass && values.message) {
60
+ result.message = values.message;
61
+ }
62
+
63
+ const output = { test: testName, pass: result.pass };
64
+ if (result.message) output.message = result.message;
65
+ return output;
66
+ }
67
+
68
+ /**
69
+ * Run an assertion, write JSON to stdout, and return a failure envelope when
70
+ * the assertion does not pass.
71
+ * @param {import("@forwardimpact/libcli").InvocationContext} ctx
72
+ * @returns {Promise<{ok: true} | {ok: false, code: number, error: string}>}
73
+ */
74
+ export async function runAssertCommand(ctx) {
75
+ const runtime = ctx.deps.runtime;
76
+ const args = [ctx.args["test-name"], ctx.args.file];
77
+ let result;
78
+ try {
79
+ result = evaluateAssertion(ctx.options, args, runtime.fsSync);
80
+ } catch (err) {
81
+ return { ok: false, code: 1, error: err.message };
82
+ }
83
+ runtime.proc.stdout.write(JSON.stringify(result) + "\n");
84
+ return result.pass ? { ok: true } : { ok: false, code: 1, error: "" };
85
+ }
86
+
87
+ function assertExists(file, fsSync) {
88
+ if (fsSync.existsSync(file)) return { pass: true };
89
+ return { pass: false, message: `${file} not found` };
90
+ }
91
+
92
+ function assertGrep(pattern, file, fsSync) {
93
+ const content = fsSync.readFileSync(file, "utf8");
94
+ const re = new RegExp(pattern, "im");
95
+ if (re.test(content)) return { pass: true };
96
+ return {
97
+ pass: false,
98
+ message: `pattern "${pattern}" not found in ${basename(file)}`,
99
+ };
100
+ }
101
+
102
+ function assertQuery(expression, file, fsSync) {
103
+ const content = fsSync.readFileSync(file, "utf8");
104
+ const data = parseJsonOrNdjson(content);
105
+ const result = jmespath.search(data, expression);
106
+ const truthy =
107
+ result !== null &&
108
+ result !== undefined &&
109
+ result !== false &&
110
+ (Array.isArray(result) ? result.length > 0 : true);
111
+ if (truthy) return { pass: true };
112
+ return {
113
+ pass: false,
114
+ message: `query returned ${JSON.stringify(result)}`,
115
+ };
116
+ }
117
+
118
+ const JOB_TAG_RE = /<job\s+user="([^"]*)"\s+goal="([^"]*)">/;
119
+
120
+ function assertCitesJob(jobFile, file, fsSync) {
121
+ const jobContent = fsSync.readFileSync(jobFile, "utf8");
122
+ const match = JOB_TAG_RE.exec(jobContent);
123
+ if (!match) {
124
+ return {
125
+ pass: false,
126
+ message: `no <job> tag found in ${basename(jobFile)}`,
127
+ };
128
+ }
129
+ const citation = `${match[1]}: ${match[2]}`;
130
+ const content = fsSync.readFileSync(file, "utf8");
131
+ if (content.includes(citation)) return { pass: true };
132
+ return { pass: false, message: `missing "${citation}"` };
133
+ }
134
+
135
+ function parseJsonOrNdjson(content) {
136
+ try {
137
+ return JSON.parse(content);
138
+ } catch {
139
+ // Fall through to NDJSON
140
+ }
141
+ const lines = [];
142
+ for (const raw of content.split("\n")) {
143
+ const trimmed = raw.trim();
144
+ if (!trimmed) continue;
145
+ try {
146
+ lines.push(JSON.parse(trimmed));
147
+ } catch {
148
+ // skip unparseable lines
149
+ }
150
+ }
151
+ if (lines.length === 0) throw new Error("assert: no valid JSON in file");
152
+ return lines;
153
+ }