@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,141 @@
1
+ /**
2
+ * Invariants — runs `<task.paths.hooks>/invariants.sh` from the template path
3
+ * against the post-run agent CWD. The exit code is authoritative for the
4
+ * verdict; structured per-check rows arrive on fd 3 (`$RESULTS_FD=3`) as NDJSON.
5
+ *
6
+ * Subprocess access flows through `runtime.subprocess.spawn`; the fd-3 backing
7
+ * store and the stderr log use the sync filesystem surface (`runtime.fsSync`) —
8
+ * the only surface this module touches, per design Decision 7.
9
+ */
10
+
11
+ import { join } from "node:path";
12
+
13
+ import { buildHookEnv } from "./hook-env.js";
14
+
15
+ /**
16
+ * @typedef {object} InvariantsResult
17
+ * @property {"pass" | "fail"} verdict
18
+ * @property {Array<object>} details
19
+ * @property {number} exitCode
20
+ * @property {string} [stderr] - Trimmed script stderr, present only when the
21
+ * script wrote to stderr. Surfaces hook failures (e.g. a missing tool) that
22
+ * leave `details` empty, so they read distinctly from a real invariant miss.
23
+ */
24
+
25
+ /**
26
+ * Run the task's invariants script.
27
+ * @param {import("./task-family.js").Task} task
28
+ * @param {{cwd: string, port: number, runDir: string, familyDir?: string|null}} ctx
29
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
30
+ * @returns {Promise<InvariantsResult>}
31
+ */
32
+ export async function runInvariants(task, ctx, runtime) {
33
+ if (!runtime) throw new Error("runtime is required");
34
+ if (!task.paths.invariants) {
35
+ return { verdict: "pass", details: [], exitCode: 0 };
36
+ }
37
+ const fsSync = runtime.fsSync;
38
+ const script = task.paths.invariants;
39
+ const stderrLogPath = join(ctx.runDir, "invariants.stderr.log");
40
+
41
+ // Bun's child_process pipe setup for fd >= 3 is racy under load (it
42
+ // creates a unix socket pair and the connect() can return ENOENT). Use
43
+ // a temp file as the fd-3 backing store instead — the script still
44
+ // writes via `$RESULTS_FD`, but we hand it a real file descriptor.
45
+ const fd3Path = join(ctx.runDir, "invariants.fd3.ndjson");
46
+ const fd3File = fsSync.openSync(fd3Path, "w+");
47
+
48
+ let child;
49
+ try {
50
+ child = runtime.subprocess.spawn(script, [], {
51
+ env: {
52
+ ...buildHookEnv(runtime.proc.env, {
53
+ cwd: ctx.cwd,
54
+ port: ctx.port,
55
+ taskId: task.id,
56
+ taskDir: task.paths.taskDir,
57
+ hooksDir: task.paths.hooks,
58
+ familyDir: ctx.familyDir,
59
+ }),
60
+ RESULTS_FD: "3",
61
+ },
62
+ stdio: ["inherit", "pipe", "pipe", fd3File],
63
+ });
64
+ } catch (e) {
65
+ tryClose(fsSync, fd3File);
66
+ throw e;
67
+ }
68
+
69
+ // Drain stdout (do not require consumers to read it); capture stderr to log.
70
+ const drainStdout = (async () => {
71
+ for await (const _chunk of child.stdout) {
72
+ // discard
73
+ }
74
+ })();
75
+ let stderr = "";
76
+ for await (const chunk of child.stderr) stderr += chunk.toString();
77
+ await drainStdout;
78
+ const code = await child.exitCode;
79
+
80
+ fsSync.writeFileSync(stderrLogPath, stderr);
81
+ tryClose(fsSync, fd3File);
82
+
83
+ const raw = readAndUnlink(fsSync, fd3Path);
84
+ const details = [];
85
+ parseFd3Buffer(raw, details);
86
+ const exitCode = typeof code === "number" ? code : -1;
87
+ const result = {
88
+ verdict: exitCode === 0 ? "pass" : "fail",
89
+ details,
90
+ exitCode,
91
+ };
92
+ const trimmedStderr = stderr.trim();
93
+ if (trimmedStderr) result.stderr = trimmedStderr;
94
+ return result;
95
+ }
96
+
97
+ function pushRow(line, details) {
98
+ const trimmed = line.trim();
99
+ if (!trimmed) return;
100
+ try {
101
+ details.push(JSON.parse(trimmed));
102
+ } catch {
103
+ details.push({ raw: trimmed, parseError: true });
104
+ }
105
+ }
106
+
107
+ function tryClose(fsSync, fd) {
108
+ try {
109
+ fsSync.closeSync(fd);
110
+ } catch {
111
+ // already closed
112
+ }
113
+ }
114
+
115
+ function readAndUnlink(fsSync, path) {
116
+ let raw = "";
117
+ try {
118
+ raw = fsSync.readFileSync(path, "utf8");
119
+ } catch {
120
+ // empty
121
+ }
122
+ try {
123
+ fsSync.unlinkSync(path);
124
+ } catch {
125
+ // best-effort cleanup
126
+ }
127
+ return raw;
128
+ }
129
+
130
+ /**
131
+ * Parse the fd-3 buffer (read from the temp-file backing) into one NDJSON
132
+ * row per detail entry.
133
+ */
134
+ function parseFd3Buffer(buf, details) {
135
+ if (!buf) return;
136
+ const parts = buf.split("\n");
137
+ for (let i = 0; i < parts.length - 1; i++) pushRow(parts[i], details);
138
+ if (parts[parts.length - 1].trim()) {
139
+ pushRow(parts[parts.length - 1], details);
140
+ }
141
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Benchmark adapter for the libharness `Judge`. Templates the family's
3
+ * `judge.task.md` with structured context variables, runs the judge against
4
+ * the post-run agent CWD, and returns the verdict in the benchmark's
5
+ * `pass`/`fail` vocabulary (mapped from libharness's `success`/`failure`).
6
+ *
7
+ * Template variables available in `judge.task.md`:
8
+ *
9
+ * {{AGENT_INSTRUCTIONS}} — contents of agent.task.md
10
+ * {{AGENT_PROFILE}} — agent profile body (empty string if none)
11
+ * {{AGENT_TRACE_PATH}} — path to agent.ndjson
12
+ * {{INVARIANTS_RESULT}} — JSON invariants object
13
+ * {{SKILL_SET_HASH}} — SHA-256 from apm.lock.yaml
14
+ * {{TASK_ID}} — task name (directory under tasks/)
15
+ * {{TASK_DIR}} — agent working directory path
16
+ *
17
+ * The judge verdict is captured from the orchestration context's
18
+ * `concluded` flag directly — no trace parsing on the happy path.
19
+ * `parseConcludeFromTrace` is preserved for offline analysis and as a
20
+ * fallback when the runtime ctx isn't available (e.g. re-grading a
21
+ * historical run from its judge.ndjson file).
22
+ */
23
+
24
+ import { createJudge } from "../judge.js";
25
+ import { createRedactor } from "../redaction.js";
26
+ import { sumTraceCost } from "../cost.js";
27
+
28
+ /**
29
+ * @typedef {object} JudgeVerdict
30
+ * @property {"pass" | "fail"} verdict
31
+ * @property {string} summary
32
+ * @property {number} costUsd - Cost of the judge's own SDK session.
33
+ */
34
+
35
+ /**
36
+ * @typedef {object} JudgeContext
37
+ * @property {string} agentInstructions - Contents of agent.task.md.
38
+ * @property {string} agentProfile - Agent profile body (empty string if none).
39
+ * @property {string} skillSetHash - SHA-256 fingerprint from apm.lock.yaml.
40
+ */
41
+
42
+ /**
43
+ * Run the judge over a completed task run.
44
+ * @param {import("./task-family.js").Task} task
45
+ * @param {import("./workdir.js").Workdir} workdir
46
+ * @param {import("./invariants.js").InvariantsResult} invariants
47
+ * @param {{query: Function, model: string, judgeProfile?: string, profilesDir?: string, runtime: import("@forwardimpact/libutil/runtime").Runtime}} deps
48
+ * @param {JudgeContext} [context]
49
+ * @returns {Promise<JudgeVerdict>}
50
+ */
51
+ export async function runJudge(task, workdir, invariants, deps, context) {
52
+ const runtime = deps.runtime;
53
+ if (!runtime) throw new Error("runtime is required");
54
+ const fs = runtime.fs;
55
+ const template = await fs.readFile(task.paths.judge, "utf8");
56
+ const invariantsJson = JSON.stringify(invariants, null, 2);
57
+ const taskText = template
58
+ .replaceAll("{{INVARIANTS_RESULT}}", invariantsJson)
59
+ .replaceAll("{{AGENT_TRACE_PATH}}", workdir.agentTracePath)
60
+ .replaceAll("{{AGENT_INSTRUCTIONS}}", context?.agentInstructions ?? "")
61
+ .replaceAll("{{AGENT_PROFILE}}", context?.agentProfile ?? "")
62
+ .replaceAll("{{SKILL_SET_HASH}}", context?.skillSetHash ?? "")
63
+ .replaceAll("{{TASK_ID}}", task.id)
64
+ .replaceAll("{{TASK_DIR}}", workdir.cwd);
65
+
66
+ const output = fs.createWriteStream(workdir.judgeTracePath);
67
+ const judge = createJudge({
68
+ cwd: workdir.cwd,
69
+ query: deps.query,
70
+ output,
71
+ model: deps.model,
72
+ judgeProfile: deps.judgeProfile,
73
+ profilesDir: deps.profilesDir,
74
+ maxTurns: 25,
75
+ redactor: createRedactor({ runtime }),
76
+ runtime,
77
+ });
78
+
79
+ let outcome;
80
+ try {
81
+ outcome = await judge.run(taskText);
82
+ } finally {
83
+ await new Promise((r) => output.end(r));
84
+ }
85
+
86
+ // The judge is its own SDK session; its spend lands in the judge trace we
87
+ // just wrote, not in the supervisor's combined trace. Read it back so the
88
+ // benchmark record's cost includes the judge.
89
+ const judgeTrace = await fs
90
+ .readFile(workdir.judgeTracePath, "utf8")
91
+ .catch(() => "");
92
+ const { totalCostUsd } = sumTraceCost(judgeTrace.split("\n"));
93
+
94
+ if (outcome.verdict === null) {
95
+ return {
96
+ verdict: "fail",
97
+ summary: "judge did not conclude",
98
+ costUsd: totalCostUsd,
99
+ };
100
+ }
101
+ return {
102
+ verdict: outcome.verdict === "success" ? "pass" : "fail",
103
+ summary: outcome.summary ?? "",
104
+ costUsd: totalCostUsd,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Parse the last judge-source (or supervisor-source, for backward compat
110
+ * with pre-Judge-class traces) `Conclude` tool call from an NDJSON trace
111
+ * and map the verdict (`success → pass`, `failure → fail`). Preserved for
112
+ * offline analysis; not used on the runtime happy path.
113
+ * @param {string} tracePath
114
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
115
+ * @returns {Promise<JudgeVerdict | null>}
116
+ */
117
+ export async function parseConcludeFromTrace(tracePath, runtime) {
118
+ if (!runtime) throw new Error("runtime is required");
119
+ const content = await runtime.fs.readFile(tracePath, "utf8");
120
+ let last = null;
121
+ for (const line of content.split("\n")) {
122
+ const candidate = extractConcludeInput(line);
123
+ if (candidate) last = candidate;
124
+ }
125
+ if (!last) return null;
126
+ return {
127
+ verdict: last.verdict === "success" ? "pass" : "fail",
128
+ summary: last.summary ?? "",
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Return the `Conclude` tool input if the line carries a judge-source or
134
+ * supervisor-source assistant message ending in a `Conclude` tool_use
135
+ * block; null otherwise.
136
+ * @param {string} line
137
+ * @returns {{verdict: string, summary?: string} | null}
138
+ */
139
+ function extractConcludeInput(line) {
140
+ const trimmed = line.trim();
141
+ if (!trimmed) return null;
142
+ let event;
143
+ try {
144
+ event = JSON.parse(trimmed);
145
+ } catch {
146
+ return null;
147
+ }
148
+ const wrapped =
149
+ event.event && typeof event.source === "string"
150
+ ? { source: event.source, inner: event.event }
151
+ : { source: null, inner: event };
152
+ if (
153
+ wrapped.source !== null &&
154
+ wrapped.source !== "judge" &&
155
+ wrapped.source !== "supervisor"
156
+ ) {
157
+ return null;
158
+ }
159
+ if (wrapped.inner.type !== "assistant") return null;
160
+ const content = wrapped.inner.message?.content ?? wrapped.inner.content;
161
+ if (!Array.isArray(content)) return null;
162
+ let found = null;
163
+ for (const block of content) {
164
+ if (
165
+ block.type === "tool_use" &&
166
+ isConcludeToolName(block.name) &&
167
+ block.input
168
+ ) {
169
+ found = block.input;
170
+ }
171
+ }
172
+ return found;
173
+ }
174
+
175
+ /**
176
+ * The Claude Agent SDK reports MCP tool names as
177
+ * `mcp__<server>__<tool>` when the model invokes them — the orchestration
178
+ * `Conclude` arrives as `mcp__orchestration__Conclude`. Pre-baked
179
+ * supervisor traces (and the libharness-internal envelopes) sometimes carry
180
+ * the bare `Conclude` name. Accept both forms so the parser is robust to
181
+ * trace source.
182
+ */
183
+ function isConcludeToolName(name) {
184
+ if (typeof name !== "string") return false;
185
+ if (name === "Conclude") return true;
186
+ return name.endsWith("__Conclude");
187
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * NpmInstaller — runs `bun install` in the family root when a package.json
3
+ * is present, then copies the resulting `node_modules/` into the staging
4
+ * directory so WorkdirManager can seed each per-task CWD.
5
+ *
6
+ * Symmetric to ApmInstaller: the subprocess and filesystem flow through the
7
+ * injected `runtime` bag (`runtime.subprocess.spawn` + `runtime.fs`).
8
+ */
9
+
10
+ import { join } from "node:path";
11
+
12
+ /** Run `bun install` in the family root and stage node_modules/ for per-task CWDs. */
13
+ export class NpmInstaller {
14
+ /**
15
+ * @param {object} deps
16
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} deps.runtime -
17
+ * Ambient collaborators; uses `subprocess.spawn` and `fs`.
18
+ */
19
+ constructor({ runtime }) {
20
+ if (!runtime) throw new Error("runtime is required");
21
+ this.runtime = runtime;
22
+ }
23
+
24
+ /**
25
+ * @param {import("./task-family.js").TaskFamily} family
26
+ * @param {string} stagingDir - The staging directory (created by ApmInstaller).
27
+ * @returns {Promise<void>}
28
+ */
29
+ async install(family, stagingDir) {
30
+ const fs = this.runtime.fs;
31
+ const pkgJson = join(family.rootPath, "package.json");
32
+ const hasPkg = await fs
33
+ .access(pkgJson)
34
+ .then(() => true)
35
+ .catch(() => false);
36
+ if (!hasPkg) return;
37
+
38
+ await this.#runBunInstall(family.rootPath);
39
+
40
+ const sourceModules = join(family.rootPath, "node_modules");
41
+ try {
42
+ await fs.access(sourceModules);
43
+ } catch {
44
+ throw new Error(
45
+ `bun install did not produce node_modules/ at ${sourceModules}; check the family's package.json`,
46
+ );
47
+ }
48
+
49
+ await fs.cp(sourceModules, join(stagingDir, "node_modules"), {
50
+ recursive: true,
51
+ });
52
+ }
53
+
54
+ async #runBunInstall(cwd) {
55
+ const child = this.runtime.subprocess.spawn("bun", ["install"], {
56
+ cwd,
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ });
59
+ let stderr = "";
60
+ const drainStdout = (async () => {
61
+ for await (const _chunk of child.stdout) {
62
+ // discard
63
+ }
64
+ })();
65
+ for await (const chunk of child.stderr) stderr += chunk.toString();
66
+ await drainStdout;
67
+ const code = await child.exitCode;
68
+ if (code !== 0) {
69
+ throw new Error(`bun install exited ${code}: ${stderr}`);
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Factory function — wires real dependencies. */
75
+ export function createNpmInstaller(deps) {
76
+ return new NpmInstaller(deps);
77
+ }
78
+
79
+ /**
80
+ * Free-function shorthand for callers that thread a runtime bag.
81
+ * @param {import("./task-family.js").TaskFamily} family
82
+ * @param {string} stagingDir
83
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
84
+ */
85
+ export function installNpm(family, stagingDir, runtime) {
86
+ return new NpmInstaller({ runtime }).install(family, stagingDir);
87
+ }