@forwardimpact/libharness 0.1.20 → 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 +42 -12
  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,266 @@
1
+ /**
2
+ * System prompt composition for agent runners.
3
+ *
4
+ * libharness assembles every agent system prompt from up to two parallel,
5
+ * sibling-tagged sections (see COALIGNED.md § L0):
6
+ *
7
+ * <agent_profile>
8
+ * …persona body…
9
+ * </agent_profile>
10
+ *
11
+ * <session_protocol>
12
+ * …orchestration mechanics, then any amendment…
13
+ * </session_protocol>
14
+ *
15
+ * The two tags are siblings joined by a blank line — neither nests inside
16
+ * the other. A section appears only when its content is present. The tag
17
+ * convention lives entirely here: profile `.md` files and trailer constants
18
+ * carry no tags.
19
+ *
20
+ * The `<session_protocol>` body is assembled from up to three fragments, in
21
+ * order of decreasing generality:
22
+ *
23
+ * 1. the role-invariant orchestration trailer (libharness-owned);
24
+ * 2. the profile's own hoisted `## Session Protocol` section, if present;
25
+ * 3. a run-specific amendment, if supplied.
26
+ *
27
+ * Fragment 2 is the convention-based hoist: a profile may carry a level-2
28
+ * `## Session Protocol` markdown heading whose body is the role's work
29
+ * routine. When present, that section is lifted out of `<agent_profile>` and
30
+ * folded into `<session_protocol>` next to the orchestration mechanics, so
31
+ * the harness comms protocol and the role's work routine read as one
32
+ * coherent block. The heading line itself is dropped — the tag already names
33
+ * the section. Profiles with no such heading are unaffected (the entire body
34
+ * stays in `<agent_profile>`).
35
+ *
36
+ * Helpers:
37
+ *
38
+ * - `composeProfilePrompt(name, opts)` — profile + `claude_code` preset.
39
+ * Used by agent participants that need the full Claude Code tool surface.
40
+ *
41
+ * - `composeLeadPrompt(opts)` — plain string, no preset. Used by lead
42
+ * roles (supervisor, facilitator, discuss lead) that should only see
43
+ * the orchestration instructions and optionally a profile body.
44
+ *
45
+ * - `composeSystemPrompt(opts)` — unified entry point. Threads `amend` into
46
+ * the protocol section as the run-specific fragment, then delegates to one
47
+ * of the above based on `opts.role`.
48
+ */
49
+
50
+ import { join } from "node:path";
51
+
52
+ /** Sibling section tags. Neither nests inside the other. */
53
+ const AGENT_PROFILE_TAG = "agent_profile";
54
+ const SESSION_PROTOCOL_TAG = "session_protocol";
55
+
56
+ /**
57
+ * A level-2 heading that names the profile's hoisted session-protocol
58
+ * section. Case-insensitive, tolerant of trailing whitespace, but the level
59
+ * is fixed at two `#` so a `### Session Protocol` subsection does not trip
60
+ * the hoist.
61
+ */
62
+ const SESSION_PROTOCOL_HEADING = /^##[ \t]+session protocol[ \t]*$/i;
63
+
64
+ /** A level-1 or level-2 heading — the boundary that ends a hoisted section. */
65
+ const SECTION_BOUNDARY = /^#{1,2}[ \t]+\S/;
66
+
67
+ /** Wrap content in a semantic section tag, each on its own line. */
68
+ function wrapSection(tag, content) {
69
+ return `<${tag}>\n${content}\n</${tag}>`;
70
+ }
71
+
72
+ /**
73
+ * Assemble the parallel `<agent_profile>` / `<session_protocol>` sections.
74
+ * The profile section is emitted only when `body` is non-empty. The protocol
75
+ * section is built by joining its fragments (in the order given) with a
76
+ * blank-line separator, dropping any that are empty, and is emitted only
77
+ * when at least one fragment survives. The two tags are siblings joined by a
78
+ * blank line and never nest.
79
+ *
80
+ * @param {object} parts
81
+ * @param {string} [parts.body] - Profile body, frontmatter-stripped and with
82
+ * any `## Session Protocol` section already hoisted out.
83
+ * @param {Array<string | undefined>} [parts.protocolParts] - Ordered session
84
+ * protocol fragments: trailer, hoisted profile section, run amendment.
85
+ * @returns {string}
86
+ */
87
+ function assembleSections({ body, protocolParts = [] }) {
88
+ const sections = [];
89
+ if (body) sections.push(wrapSection(AGENT_PROFILE_TAG, body));
90
+ const protocol = protocolParts.filter(Boolean).join("\n\n");
91
+ if (protocol) sections.push(wrapSection(SESSION_PROTOCOL_TAG, protocol));
92
+ return sections.join("\n\n");
93
+ }
94
+
95
+ /**
96
+ * Split a frontmatter-stripped profile body into its persona and an optional
97
+ * hoisted `## Session Protocol` section. The section runs from its heading to
98
+ * the next level-1/level-2 heading (or end of body); the heading line is
99
+ * dropped. Anything before and after the section is rejoined into `persona`.
100
+ * When the body carries no `## Session Protocol` heading, the whole body is
101
+ * returned as `persona` and `protocol` is `undefined`.
102
+ *
103
+ * @param {string} body - Frontmatter-stripped, trimmed profile body.
104
+ * @returns {{ persona: string, protocol: string | undefined }}
105
+ */
106
+ function splitSessionProtocol(body) {
107
+ const lines = body.split("\n");
108
+ const start = lines.findIndex((line) => SESSION_PROTOCOL_HEADING.test(line));
109
+ if (start === -1) return { persona: body, protocol: undefined };
110
+
111
+ let end = lines.length;
112
+ for (let i = start + 1; i < lines.length; i++) {
113
+ if (SECTION_BOUNDARY.test(lines[i])) {
114
+ end = i;
115
+ break;
116
+ }
117
+ }
118
+
119
+ const protocol = lines
120
+ .slice(start + 1, end)
121
+ .join("\n")
122
+ .trim();
123
+ const before = lines.slice(0, start).join("\n").trim();
124
+ const after = lines.slice(end).join("\n").trim();
125
+ const persona = [before, after].filter(Boolean).join("\n\n");
126
+ return { persona, protocol: protocol || undefined };
127
+ }
128
+
129
+ /**
130
+ * Read a profile `.md`, strip its frontmatter, and split off any hoisted
131
+ * `## Session Protocol` section. Reads synchronously off the injected
132
+ * `runtime.fsSync` surface — this composer runs inside the synchronous
133
+ * SDK-option builders of the supervisor / facilitator / discusser / judge
134
+ * factories, so it cannot go async without an unbounded cascade.
135
+ *
136
+ * @param {string} name - Profile basename (no `.md` suffix)
137
+ * @param {string} profilesDir - Directory containing `<name>.md`
138
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
139
+ * @returns {{ persona: string, protocol: string | undefined }}
140
+ */
141
+ function readProfileSections(name, profilesDir, runtime) {
142
+ const path = join(profilesDir, `${name}.md`);
143
+ const raw = runtime.fsSync.readFileSync(path, "utf8");
144
+ return splitSessionProtocol(stripFrontmatter(raw).trim());
145
+ }
146
+
147
+ /**
148
+ * Compose a `claude_code`-preset system prompt from a profile file. The
149
+ * persona is wrapped in `<agent_profile>`; the protocol trailer, the
150
+ * profile's hoisted `## Session Protocol` section, and any amendment are
151
+ * joined (in that order) into a sibling `<session_protocol>`.
152
+ *
153
+ * @param {string} name - Profile basename (no `.md` suffix)
154
+ * @param {object} opts
155
+ * @param {string} opts.profilesDir - Directory containing `<name>.md`
156
+ * @param {string} [opts.trailer] - Session protocol orchestration mechanics,
157
+ * the first fragment of the `<session_protocol>` section.
158
+ * @param {string} [opts.amend] - Run-specific amendment, the last fragment of
159
+ * the `<session_protocol>` section.
160
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
161
+ * @returns {{type: "preset", preset: "claude_code", append: string}}
162
+ */
163
+ export function composeProfilePrompt(
164
+ name,
165
+ { profilesDir, trailer, amend, runtime },
166
+ ) {
167
+ const { persona, protocol } = readProfileSections(name, profilesDir, runtime);
168
+ return {
169
+ type: "preset",
170
+ preset: "claude_code",
171
+ append: assembleSections({
172
+ body: persona,
173
+ protocolParts: [trailer, protocol, amend],
174
+ }),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Compose a plain-string system prompt for a lead role (no Claude Code
180
+ * preset). The protocol trailer, an optional profile's hoisted
181
+ * `## Session Protocol` section, and any amendment are joined into
182
+ * `<session_protocol>`; an optional persona is wrapped in a sibling
183
+ * `<agent_profile>` before it.
184
+ *
185
+ * @param {object} opts
186
+ * @param {string} [opts.profile] - Profile basename (no `.md` suffix)
187
+ * @param {string} [opts.profilesDir] - Directory containing profile files
188
+ * @param {string} opts.trailer - Session protocol (orchestration instructions)
189
+ * @param {string} [opts.amend] - Run-specific amendment, the last fragment of
190
+ * the `<session_protocol>` section.
191
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
192
+ * @returns {string}
193
+ */
194
+ export function composeLeadPrompt({
195
+ profile,
196
+ profilesDir,
197
+ trailer,
198
+ amend,
199
+ runtime,
200
+ }) {
201
+ if (!trailer) throw new Error("trailer is required");
202
+ const { persona, protocol } = profile
203
+ ? readProfileSections(profile, profilesDir, runtime)
204
+ : { persona: undefined, protocol: undefined };
205
+ return assembleSections({
206
+ body: persona,
207
+ protocolParts: [trailer, protocol, amend],
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Unified entry point for composing system prompts. Threads an optional
213
+ * amendment through as the run-specific fragment of `<session_protocol>`
214
+ * (after the trailer and any hoisted profile section), then delegates by
215
+ * role.
216
+ *
217
+ * @param {object} opts
218
+ * @param {"lead"|"agent"} opts.role - `"lead"` produces a plain string;
219
+ * `"agent"` produces a `claude_code` preset object.
220
+ * @param {string} [opts.profile] - Profile basename
221
+ * @param {string} [opts.profilesDir]
222
+ * @param {string} opts.trailer - Session protocol (orchestration instructions)
223
+ * @param {string} [opts.amend] - Caller-supplied amendment, the last fragment
224
+ * inside `<session_protocol>`, joined with a blank-line separator.
225
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
226
+ * @returns {string | {type: "preset", preset: "claude_code", append: string}}
227
+ */
228
+ export function composeSystemPrompt({
229
+ role,
230
+ profile,
231
+ profilesDir,
232
+ trailer,
233
+ amend,
234
+ runtime,
235
+ }) {
236
+ if (!trailer) throw new Error("trailer is required");
237
+ if (role === "lead") {
238
+ return composeLeadPrompt({ profile, profilesDir, trailer, amend, runtime });
239
+ }
240
+ if (profile) {
241
+ return composeProfilePrompt(profile, {
242
+ profilesDir,
243
+ trailer,
244
+ amend,
245
+ runtime,
246
+ });
247
+ }
248
+ return {
249
+ type: "preset",
250
+ preset: "claude_code",
251
+ append: assembleSections({ protocolParts: [trailer, amend] }),
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Strip a leading YAML frontmatter fence (`---\n…\n---\n`) from a markdown
257
+ * string. Returns the input unchanged when no frontmatter is present.
258
+ * @param {string} raw
259
+ * @returns {string}
260
+ */
261
+ function stripFrontmatter(raw) {
262
+ if (!raw.startsWith("---\n")) return raw;
263
+ const end = raw.indexOf("\n---\n", 4);
264
+ if (end === -1) return raw;
265
+ return raw.slice(end + 5);
266
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Redactor — replaces secrets in JSON-serialisable values before they reach
3
+ * the trace artifact. Composes two layers: an env-var value allowlist and a
4
+ * set of credential-shape regexes. Both run on every primitive string.
5
+ *
6
+ * Coverage includes encoded credential forms, not only raw bytes: the env
7
+ * layer matches each allowlisted secret both raw and in its **standard
8
+ * base64** form at any byte offset within the encoded plaintext, and the
9
+ * pattern layer covers the git `extraheader` basic-auth wrapper. Boundary:
10
+ * **standard base64 only** — URL-safe base64, hex, and percent-encoding are
11
+ * not covered — and the **trace-write sink only**; content an agent authors
12
+ * into a wiki commit is never passed through this redactor.
13
+ *
14
+ * Stateless after construction: `env` is captured once so in-process
15
+ * `process.env` writes (e.g. agent-runner.js LIBHARNESS_SKILL, commands/run.js
16
+ * LIBHARNESS_AGENT_PROFILE) cannot smuggle a value past the redactor.
17
+ */
18
+
19
+ export const DEFAULT_ENV_ALLOWLIST = Object.freeze([
20
+ "ANTHROPIC_API_KEY",
21
+ "AWS_ACCESS_KEY_ID",
22
+ "AWS_SECRET_ACCESS_KEY",
23
+ "DATABASE_PASSWORD",
24
+ "GH_TOKEN",
25
+ "GITHUB_TOKEN",
26
+ "JWT_SECRET",
27
+ "MCP_TOKEN",
28
+ "MICROSOFT_APP_ID",
29
+ "MICROSOFT_APP_PASSWORD",
30
+ "MICROSOFT_APP_TENANT_ID",
31
+ "PRODUCT_LANDMARK_TOKEN",
32
+ "SERVICE_SECRET",
33
+ "SUPABASE_ANON_KEY",
34
+ "SUPABASE_SERVICE_ROLE_KEY",
35
+ ]);
36
+
37
+ // Anchored prefixes per
38
+ // https://github.blog/security/application-security/behind-githubs-new-authentication-token-formats/
39
+ // Anthropic prefix is heuristic — the env-allowlist layer is the primary
40
+ // defence for Anthropic keys.
41
+ export const DEFAULT_PATTERNS = Object.freeze([
42
+ { kind: "anthropic", regex: /sk-ant-[A-Za-z0-9_-]{80,}/g },
43
+ { kind: "gh-pat", regex: /\bghp_[A-Za-z0-9]{36}\b/g },
44
+ { kind: "gh-installation", regex: /\bghs_[A-Za-z0-9]{36}\b/g },
45
+ { kind: "gh-oauth", regex: /\bgho_[A-Za-z0-9]{36}\b/g },
46
+ { kind: "gh-fine-grained", regex: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
47
+ // git persists HTTP basic-auth credentials base64-encoded in
48
+ // `http.<url>.extraheader` as `AUTHORIZATION: basic <b64>` where the
49
+ // plaintext is `x-access-token:<token>` (actions/checkout form) — a shape
50
+ // the raw-byte layers above cannot see. The plaintext prefix is 15 bytes
51
+ // — five whole base64 triplets — so every encoding starts with the same
52
+ // 20 chars no matter which token follows.
53
+ {
54
+ kind: "gh-b64-basic-credential",
55
+ regex: /\beC1hY2Nlc3MtdG9rZW46[A-Za-z0-9+/]{8,}={0,2}/g,
56
+ },
57
+ ]);
58
+
59
+ const ENV_PLACEHOLDER = (name) => `[REDACTED:env:${name}]`;
60
+ const PATTERN_PLACEHOLDER = (kind) => `[REDACTED:pattern:${kind}]`;
61
+
62
+ /**
63
+ * Minimum secret byte length for encoded-form matching. At 9 bytes the
64
+ * shortest offset core is exactly 8 chars; below 9 it drops under 8 — too
65
+ * short to be a sound needle against ordinary base64 trace content (margin of
66
+ * safety, false positives). Every DEFAULT_ENV_ALLOWLIST value (token, key,
67
+ * password) far exceeds it.
68
+ */
69
+ const MIN_ENCODED_SECRET_BYTES = 9;
70
+
71
+ // Leading base64 chars contaminated by the k filler bytes, per alignment.
72
+ const ENCODED_LEAD_STRIP = [0, 2, 3];
73
+
74
+ /**
75
+ * The three offset-invariant standard-base64 core substrings of `secret`, one
76
+ * per byte alignment (k = 0/1/2). base64 maps disjoint 3-byte groups to 4 chars
77
+ * independently, so the chars covering a secret's interior groups depend only
78
+ * on the secret's bytes — never on the bytes surrounding it. Only the partial
79
+ * groups at each edge are neighbour-dependent; stripping them leaves a core
80
+ * that appears in the base64 of any plaintext placing `secret` at that
81
+ * alignment. Padding lives only in the final partial group, which is stripped,
82
+ * so each core is padding-free and one needle matches padded and unpadded
83
+ * haystack content. Returns [] below MIN_ENCODED_SECRET_BYTES.
84
+ * @param {string} secret
85
+ * @returns {string[]}
86
+ */
87
+ function encodedNeedles(secret) {
88
+ if (Buffer.byteLength(secret, "utf8") < MIN_ENCODED_SECRET_BYTES) return [];
89
+ const needles = [];
90
+ for (let k = 0; k < 3; k++) {
91
+ const enc = Buffer.from("\0".repeat(k) + secret, "utf8")
92
+ .toString("base64")
93
+ .replace(/=+$/, "");
94
+ needles.push(enc.slice(ENCODED_LEAD_STRIP[k], enc.length - 4));
95
+ }
96
+ return needles;
97
+ }
98
+
99
+ /**
100
+ * Build a frozen { name → { secret, needles } } snapshot of the requested env
101
+ * vars. Empty strings are skipped — a leaked empty env var would otherwise
102
+ * cause every empty string in the trace to be replaced. `needles` are the
103
+ * precomputed standard-base64 cores (empty for sub-floor secrets).
104
+ */
105
+ function snapshotEnv(env, allowlist) {
106
+ const snap = {};
107
+ for (const name of allowlist) {
108
+ const v = env[name];
109
+ if (typeof v === "string" && v.length > 0) {
110
+ snap[name] = { secret: v, needles: encodedNeedles(v) };
111
+ }
112
+ }
113
+ return Object.freeze(snap);
114
+ }
115
+
116
+ /** Recursively walk and redact a JSON-serialisable value in place-free style. */
117
+ function walk(value, redactString) {
118
+ if (typeof value === "string") return redactString(value);
119
+ if (Array.isArray(value)) return value.map((v) => walk(v, redactString));
120
+ if (value && typeof value === "object") {
121
+ const out = {};
122
+ for (const k of Object.keys(value)) out[k] = walk(value[k], redactString);
123
+ return out;
124
+ }
125
+ return value;
126
+ }
127
+
128
+ /** Stateless secret redactor — composes env-allowlist and pattern layers. */
129
+ export class Redactor {
130
+ /**
131
+ * @param {object} deps
132
+ * @param {Readonly<Record<string, {secret: string, needles: string[]}>>} deps.envSnapshot - Frozen { name → { secret, needles } } map captured at construction time; `needles` are the precomputed standard-base64 cores of `secret`.
133
+ * @param {ReadonlyArray<{kind: string, regex: RegExp}>} deps.patterns - Credential-shape regexes; each match becomes `[REDACTED:pattern:KIND]`.
134
+ * @param {boolean} deps.enabled - When false, `redactValue` returns its input by reference.
135
+ */
136
+ constructor({ envSnapshot, patterns, enabled }) {
137
+ this.envSnapshot = envSnapshot;
138
+ this.patterns = patterns;
139
+ this.enabled = enabled;
140
+ }
141
+
142
+ /**
143
+ * Redact any JSON-serialisable value by deep-walking and replacing secrets
144
+ * in every primitive string. Identity on the input when disabled.
145
+ * @param {unknown} value
146
+ * @returns {unknown}
147
+ */
148
+ redactValue(value) {
149
+ if (!this.enabled) return value;
150
+ return walk(value, (s) => this.#redactString(s));
151
+ }
152
+
153
+ /**
154
+ * Apply the env-allowlist and pattern layers to a single string.
155
+ * @param {string} s
156
+ * @returns {string}
157
+ */
158
+ #redactString(s) {
159
+ let out = s;
160
+ for (const [name, { secret, needles }] of Object.entries(
161
+ this.envSnapshot,
162
+ )) {
163
+ if (out.includes(secret)) {
164
+ out = out.split(secret).join(ENV_PLACEHOLDER(name));
165
+ }
166
+ // Standard-base64 form at any byte offset. Order among the three needles
167
+ // is irrelevant: once a region is replaced by the placeholder (which
168
+ // shares no base64 run with any needle) those bytes are gone, so a later
169
+ // needle cannot re-match them. The floor keeps every needle ≥ 8 chars.
170
+ for (const needle of needles) {
171
+ if (out.includes(needle)) {
172
+ out = out.split(needle).join(ENV_PLACEHOLDER(name));
173
+ }
174
+ }
175
+ }
176
+ for (const { kind, regex } of this.patterns) {
177
+ out = out.replace(regex, PATTERN_PLACEHOLDER(kind));
178
+ }
179
+ return out;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Build a redactor. Reads `LIBHARNESS_REDACTION_DISABLED` and
185
+ * `LIBHARNESS_REDACTION_ENV_VARS` from the supplied env. The env and the stderr
186
+ * sink are sourced from an injected `runtime` (`runtime.proc.env` /
187
+ * `runtime.proc.stderr`); when no runtime is supplied a default one is
188
+ * constructed so existing callers keep working. An explicit `opts.env`
189
+ * override still wins for the snapshot. Fires a one-shot stderr warning when
190
+ * constructed disabled — bypass via `createNoopRedactor()` for silent
191
+ * fixtures.
192
+ * @param {object} [opts]
193
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} [opts.runtime] - Ambient collaborators; `proc.env`/`proc.stderr` are used.
194
+ * @param {Record<string, string|undefined>} [opts.env] - Environment to snapshot. Defaults to `runtime.proc.env`.
195
+ * @param {string[]} [opts.allowlist] - Override the env-var name list. Defaults to `DEFAULT_ENV_ALLOWLIST` or the parsed `LIBHARNESS_REDACTION_ENV_VARS` value.
196
+ * @param {ReadonlyArray<{kind: string, regex: RegExp}>} [opts.patterns] - Credential-shape regexes. Defaults to `DEFAULT_PATTERNS`.
197
+ * @param {boolean} [opts.enabled] - Force enabled/disabled; bypasses `LIBHARNESS_REDACTION_DISABLED`.
198
+ * @returns {Redactor}
199
+ */
200
+ export function createRedactor({
201
+ runtime,
202
+ env,
203
+ allowlist,
204
+ patterns = DEFAULT_PATTERNS,
205
+ enabled,
206
+ } = {}) {
207
+ if (!runtime) throw new Error("runtime is required");
208
+ const proc = runtime.proc;
209
+ const resolvedEnv = env ?? proc.env;
210
+ const envDisabled = resolvedEnv.LIBHARNESS_REDACTION_DISABLED === "1";
211
+ const resolvedEnabled = enabled ?? !envDisabled;
212
+ const resolvedAllowlist = allowlist ?? resolveAllowlistFromEnv(resolvedEnv);
213
+ const envSnapshot = resolvedEnabled
214
+ ? snapshotEnv(resolvedEnv, resolvedAllowlist)
215
+ : Object.freeze({});
216
+ if (!resolvedEnabled) {
217
+ proc.stderr.write(
218
+ "libharness: trace redaction DISABLED via LIBHARNESS_REDACTION_DISABLED — secrets may appear in trace artifact\n",
219
+ );
220
+ }
221
+ return new Redactor({ envSnapshot, patterns, enabled: resolvedEnabled });
222
+ }
223
+
224
+ /**
225
+ * Parse `LIBHARNESS_REDACTION_ENV_VARS` into a trimmed, non-empty name list.
226
+ * Falls back to `DEFAULT_ENV_ALLOWLIST` when unset or empty.
227
+ * @param {Record<string, string|undefined>} env
228
+ * @returns {string[]}
229
+ */
230
+ function resolveAllowlistFromEnv(env) {
231
+ const override = env.LIBHARNESS_REDACTION_ENV_VARS;
232
+ if (typeof override !== "string" || override.length === 0) {
233
+ return DEFAULT_ENV_ALLOWLIST;
234
+ }
235
+ return override
236
+ .split(",")
237
+ .map((s) => s.trim())
238
+ .filter(Boolean);
239
+ }
240
+
241
+ /**
242
+ * Build a disabled redactor whose `redactValue` is the identity function.
243
+ * Test-fixture form — bypasses `createRedactor` so no stderr warning
244
+ * fires regardless of env state.
245
+ * @returns {Redactor}
246
+ */
247
+ export function createNoopRedactor() {
248
+ return new Redactor({
249
+ envSnapshot: Object.freeze({}),
250
+ patterns: [],
251
+ enabled: false,
252
+ });
253
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Line renderer — composes prefix + color + body + reset into a single
3
+ * terminal line. Pure; no side effects.
4
+ *
5
+ * Every renderer returns a `\n`-terminated string:
6
+ * <source>: <ESC><color><body><RESET>\n
7
+ *
8
+ * The `<source>: ` prefix lives outside the color escape so grep and
9
+ * color-stripping terminals preserve the participant tag. Colons separate
10
+ * the source label and the kind label (`Bash:`, `Result:`, `Error:`) for a
11
+ * tighter line on narrow viewports without losing structure.
12
+ */
13
+
14
+ import { colorForSource, ERROR_COLOR, RESET } from "./palette.js";
15
+
16
+ /**
17
+ * @param {string|null} source
18
+ * @param {boolean} withPrefix
19
+ * @returns {string}
20
+ */
21
+ function prefix(source, withPrefix) {
22
+ if (!withPrefix || !source) return "";
23
+ return `${source}: `;
24
+ }
25
+
26
+ /**
27
+ * @param {{source: string|null, text: string, withPrefix: boolean}} args
28
+ * @returns {string}
29
+ */
30
+ export function renderTextLine({ source, text, withPrefix }) {
31
+ const color = colorForSource(source);
32
+ return `${prefix(source, withPrefix)}${color}${text}${RESET}\n`;
33
+ }
34
+
35
+ /**
36
+ * @param {{source: string|null, toolName: string, hint: string, withPrefix: boolean}} args
37
+ * @returns {string}
38
+ */
39
+ export function renderToolCallLine({ source, toolName, hint, withPrefix }) {
40
+ const color = colorForSource(source);
41
+ const body = hint ? `${toolName}: ${hint}` : `${toolName}`;
42
+ return `${prefix(source, withPrefix)}${color}${body}${RESET}\n`;
43
+ }
44
+
45
+ /**
46
+ * @param {{source: string|null, preview: {text: string, isError: boolean}, withPrefix: boolean}} args
47
+ * @returns {string}
48
+ */
49
+ export function renderToolResultLine({ source, preview, withPrefix }) {
50
+ const color = preview.isError ? ERROR_COLOR : colorForSource(source);
51
+ const label = preview.isError ? "Error" : "Result";
52
+ const body = `${label}: ${preview.text}`;
53
+ return `${prefix(source, withPrefix)}${color}${body}${RESET}\n`;
54
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Orchestrator filter — predicate for the orchestrator lifecycle events that
3
+ * should be suppressed from the human-readable log.
4
+ *
5
+ * NDJSON artifacts still carry every orchestrator event; this module only
6
+ * controls what the live `textStream` and offline `toText()` show.
7
+ */
8
+
9
+ const SUPPRESSED = new Set(["session_start", "agent_start", "summary", "meta"]);
10
+
11
+ /**
12
+ * @param {{type?: string}|null|undefined} event
13
+ * @returns {boolean} true when the event's type is one we hide from text output
14
+ */
15
+ export function isSuppressedOrchestratorEvent(event) {
16
+ return Boolean(
17
+ event && typeof event === "object" && SUPPRESSED.has(event.type),
18
+ );
19
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Palette — pure profile-name → ANSI SGR foreground color function.
3
+ *
4
+ * Assignment is a FNV-1a hash of the source name modulo the palette size, so
5
+ * the same name maps to the same color in every process. Red is reserved for
6
+ * tool-result errors and is never in the palette.
7
+ *
8
+ * Colors use the 24-bit truecolor SGR escape (`ESC[38;2;R;G;Bm`) rather than
9
+ * the 16-color table. GitHub Actions' log viewer and most modern terminals
10
+ * render truecolor as the exact hex requested, avoiding the washed-out
11
+ * mustard/olive tones GHA applies to `ESC[93m` etc. Eight slots cover the
12
+ * largest concurrent cast in any existing workflow (five domain agents plus
13
+ * the facilitator) with headroom.
14
+ */
15
+
16
+ const PALETTE = [
17
+ "\u001b[38;2;79;195;247m", // sky blue #4FC3F7
18
+ "\u001b[38;2;129;199;132m", // bright green #81C784
19
+ "\u001b[38;2;255;202;40m", // amber #FFCA28
20
+ "\u001b[38;2;236;64;122m", // magenta #EC407A
21
+ "\u001b[38;2;38;198;218m", // cyan #26C6DA
22
+ "\u001b[38;2;186;104;200m", // lavender #BA68C8
23
+ "\u001b[38;2;255;167;38m", // orange #FFA726
24
+ "\u001b[38;2;66;165;245m", // blue #42A5F5
25
+ ];
26
+
27
+ /** 24-bit SGR foreground code reserved for tool-result errors (#F14C4C). */
28
+ export const ERROR_COLOR = "\u001b[38;2;241;76;76m";
29
+
30
+ /** ANSI SGR reset sequence. */
31
+ export const RESET = "\u001b[0m";
32
+
33
+ /**
34
+ * Map a source name to a stable ANSI foreground color.
35
+ *
36
+ * The mapping is a pure function of the name via FNV-1a 32-bit hash — same
37
+ * name, same color, every call, in every process. Returns `RESET` for
38
+ * missing/empty names so callers never emit a stray escape.
39
+ *
40
+ * @param {string|null|undefined} name
41
+ * @returns {string} ANSI SGR escape, never equal to `ERROR_COLOR`
42
+ */
43
+ export function colorForSource(name) {
44
+ if (!name) return RESET;
45
+ let h = 0x811c9dc5;
46
+ for (let i = 0; i < name.length; i++) {
47
+ h ^= name.charCodeAt(i);
48
+ h = Math.imul(h, 0x01000193) >>> 0;
49
+ }
50
+ // Length mixer: reduces FNV's intrinsic birthday collisions on short
51
+ // names with shared affixes (e.g. `staff-engineer`/`facilitator`).
52
+ h ^= name.length;
53
+ h = Math.imul(h, 0x01000193) >>> 0;
54
+ return PALETTE[h % PALETTE.length];
55
+ }
56
+
57
+ /**
58
+ * Expose the palette size so tests can assert distinctness on the full set.
59
+ * @returns {number}
60
+ */
61
+ export function paletteSize() {
62
+ return PALETTE.length;
63
+ }