@forwardimpact/libharness 3.0.0 → 3.0.1

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 (69) hide show
  1. package/README.md +60 -57
  2. package/package.json +2 -2
  3. package/src/advisor.js +47 -41
  4. package/src/agent-runner.js +57 -47
  5. package/src/benchmark/apm-installer.js +28 -28
  6. package/src/benchmark/env-loader.js +24 -16
  7. package/src/benchmark/grade.js +44 -41
  8. package/src/benchmark/hidden-tests.js +25 -24
  9. package/src/benchmark/hook-env.js +11 -9
  10. package/src/benchmark/invariants.js +20 -17
  11. package/src/benchmark/judge.js +29 -28
  12. package/src/benchmark/npm-installer.js +9 -8
  13. package/src/benchmark/report.js +53 -50
  14. package/src/benchmark/result.js +24 -23
  15. package/src/benchmark/runner.js +75 -69
  16. package/src/benchmark/scheduler.js +17 -16
  17. package/src/benchmark/task-family.js +28 -26
  18. package/src/benchmark/trace-split.js +8 -7
  19. package/src/benchmark/workdir.js +27 -25
  20. package/src/claude-code-executable.js +11 -11
  21. package/src/commands/advisor-flags.js +8 -7
  22. package/src/commands/assert.js +16 -15
  23. package/src/commands/benchmark-definition.js +11 -11
  24. package/src/commands/benchmark-grade.js +12 -11
  25. package/src/commands/benchmark-report.js +5 -5
  26. package/src/commands/benchmark-run.js +31 -28
  27. package/src/commands/by-discussion.js +10 -10
  28. package/src/commands/callback.js +11 -11
  29. package/src/commands/discuss.js +8 -7
  30. package/src/commands/facilitate.js +15 -13
  31. package/src/commands/output.js +3 -2
  32. package/src/commands/run.js +14 -14
  33. package/src/commands/scan-logs.js +21 -19
  34. package/src/commands/selfedit.js +14 -14
  35. package/src/commands/supervise.js +11 -9
  36. package/src/commands/task-input.js +9 -9
  37. package/src/commands/tee.js +10 -9
  38. package/src/commands/trace.js +55 -42
  39. package/src/commands/work-tracker.js +4 -3
  40. package/src/cost.js +17 -17
  41. package/src/discuss-tools.js +16 -16
  42. package/src/discusser.js +39 -38
  43. package/src/events/github.js +54 -37
  44. package/src/facilitator.js +21 -21
  45. package/src/inbox-poller.js +4 -4
  46. package/src/judge.js +32 -30
  47. package/src/message-bus.js +12 -11
  48. package/src/orchestration-loop.js +35 -36
  49. package/src/orchestration-toolkit.js +58 -53
  50. package/src/orchestrator-helpers.js +2 -2
  51. package/src/profile-prompt.js +54 -53
  52. package/src/redaction.js +63 -57
  53. package/src/render/line-renderer.js +5 -5
  54. package/src/render/orchestrator-filter.js +3 -3
  55. package/src/render/palette.js +11 -9
  56. package/src/render/tool-hints.js +18 -15
  57. package/src/render/turn-renderer.js +4 -4
  58. package/src/reply-emitter.js +2 -2
  59. package/src/sequence-counter.js +4 -3
  60. package/src/signature-filter.js +7 -6
  61. package/src/supervisor.js +19 -18
  62. package/src/tee-writer.js +25 -25
  63. package/src/trace-collector.js +53 -48
  64. package/src/trace-github.js +53 -44
  65. package/src/trace-multi.js +15 -13
  66. package/src/trace-query.js +61 -52
  67. package/src/trace-render.js +18 -18
  68. package/src/trace-usage.js +31 -28
  69. package/src/transcript-recorder.js +24 -20
@@ -1,8 +1,8 @@
1
1
  /**
2
- * System prompt composition for agent runners.
2
+ * Compose system prompts for agent runners.
3
3
  *
4
4
  * libharness assembles every agent system prompt from up to two parallel,
5
- * sibling-tagged sections (see COALIGNED.md § L0):
5
+ * sibling-tagged sections (see JIDOKA.md § L0):
6
6
  *
7
7
  * <agent_profile>
8
8
  * …persona body…
@@ -12,39 +12,40 @@
12
12
  * …orchestration mechanics, then any amendment…
13
13
  * </session_protocol>
14
14
  *
15
- * The two tags are siblings joined by a blank line — neither nests inside
15
+ * The two tags are siblings. A blank line joins them. Neither nests inside
16
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
17
+ * convention lives entirely here. Profile `.md` files and trailer constants
18
18
  * carry no tags.
19
19
  *
20
- * The `<session_protocol>` body is assembled from up to three fragments, in
21
- * order of decreasing generality:
20
+ * The composer assembles the `<session_protocol>` body from up to three
21
+ * fragments, in order of decreasing generality:
22
22
  *
23
23
  * 1. the role-invariant orchestration trailer (libharness-owned);
24
24
  * 2. the profile's own hoisted `## Session Protocol` section, if present;
25
25
  * 3. a run-specific amendment, if supplied.
26
26
  *
27
- * Fragment 2 is the convention-based hoist: a profile may carry a level-2
27
+ * Fragment 2 is the convention-based hoist. A profile may carry a level-2
28
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>`).
29
+ * routine. When the heading is present, the composer lifts that section out
30
+ * of `<agent_profile>`. It folds the section into `<session_protocol>` next
31
+ * to the orchestration mechanics. The harness comms protocol and the role's
32
+ * work routine then read as one coherent block. The composer drops the
33
+ * heading line itself, because the tag already names the section. A profile
34
+ * with no such heading is unaffected. Its entire body stays in
35
+ * `<agent_profile>`.
35
36
  *
36
37
  * Helpers:
37
38
  *
38
39
  * - `composeProfilePrompt(name, opts)` — profile + `claude_code` preset.
39
- * Used by agent participants that need the full Claude Code tool surface.
40
+ * Agent participants that need the full Claude Code tool surface use it.
40
41
  *
41
- * - `composeLeadPrompt(opts)` — plain string, no preset. Used by lead
42
- * roles (supervisor, facilitator, discuss lead) that should only see
42
+ * - `composeLeadPrompt(opts)` — plain string, no preset. Lead roles
43
+ * (supervisor, facilitator, discuss lead) use it. They should only see
43
44
  * the orchestration instructions and optionally a profile body.
44
45
  *
45
46
  * - `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`.
47
+ * the protocol section as the run-specific fragment. Then delegates to one
48
+ * of the above by `opts.role`.
48
49
  */
49
50
 
50
51
  import { join } from "node:path";
@@ -55,13 +56,13 @@ const SESSION_PROTOCOL_TAG = "session_protocol";
55
56
 
56
57
  /**
57
58
  * 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.
59
+ * section. The match ignores case. It tolerates trailing whitespace. The
60
+ * level is fixed at two `#`, so a `### Session Protocol` subsection does
61
+ * not trip the hoist.
61
62
  */
62
63
  const SESSION_PROTOCOL_HEADING = /^##[ \t]+session protocol[ \t]*$/i;
63
64
 
64
- /** A level-1 or level-2 heading — the boundary that ends a hoisted section. */
65
+ /** A level-1 or level-2 heading. This boundary ends a hoisted section. */
65
66
  const SECTION_BOUNDARY = /^#{1,2}[ \t]+\S/;
66
67
 
67
68
  /** Wrap content in a semantic section tag, each on its own line. */
@@ -71,11 +72,11 @@ function wrapSection(tag, content) {
71
72
 
72
73
  /**
73
74
  * 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.
75
+ * This function emits the profile section only when `body` is non-empty. It
76
+ * builds the protocol section from the fragments in the order given. It
77
+ * joins them with a blank-line separator and drops any empty fragment. It
78
+ * emits the protocol section only when at least one fragment survives. The
79
+ * two tags are siblings. A blank line joins them. They never nest.
79
80
  *
80
81
  * @param {object} parts
81
82
  * @param {string} [parts.body] - Profile body, frontmatter-stripped and with
@@ -95,10 +96,10 @@ function assembleSections({ body, protocolParts = [] }) {
95
96
  /**
96
97
  * Split a frontmatter-stripped profile body into its persona and an optional
97
98
  * 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`.
99
+ * the next level-1/level-2 heading (or end of body). This function drops the
100
+ * heading line. It rejoins anything before and after the section into
101
+ * `persona`. When the body carries no `## Session Protocol` heading, the
102
+ * whole body returns as `persona` and `protocol` is `undefined`.
102
103
  *
103
104
  * @param {string} body - Frontmatter-stripped, trimmed profile body.
104
105
  * @returns {{ persona: string, protocol: string | undefined }}
@@ -127,14 +128,14 @@ function splitSessionProtocol(body) {
127
128
  }
128
129
 
129
130
  /**
130
- * Read a profile `.md`, strip its frontmatter, and split off any hoisted
131
+ * Read a profile `.md`. Strip its frontmatter. Split off any hoisted
131
132
  * `## Session Protocol` section. Reads synchronously off the injected
132
- * `runtime.fsSync` surface — this composer runs inside the synchronous
133
+ * `runtime.fsSync` surface. This composer runs inside the synchronous
133
134
  * SDK-option builders of the supervisor / facilitator / discusser / judge
134
- * factories, so it cannot go async without an unbounded cascade.
135
+ * factories. So it cannot go async without an unbounded cascade.
135
136
  *
136
137
  * @param {string} name - Profile basename (no `.md` suffix)
137
- * @param {string} profilesDir - Directory containing `<name>.md`
138
+ * @param {string} profilesDir - Directory that contains `<name>.md`
138
139
  * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
139
140
  * @returns {{ persona: string, protocol: string | undefined }}
140
141
  */
@@ -145,19 +146,19 @@ function readProfileSections(name, profilesDir, runtime) {
145
146
  }
146
147
 
147
148
  /**
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>`.
149
+ * Compose a `claude_code`-preset system prompt from a profile file. This
150
+ * function wraps the persona in `<agent_profile>`. It joins the protocol
151
+ * trailer, the profile's hoisted `## Session Protocol` section, and any
152
+ * amendment into a sibling `<session_protocol>`, in that order.
152
153
  *
153
154
  * @param {string} name - Profile basename (no `.md` suffix)
154
155
  * @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.
156
+ * @param {string} opts.profilesDir - Directory that contains `<name>.md`
157
+ * @param {string} [opts.trailer] - Orchestration mechanics for the session
158
+ * protocol, the first fragment of the `<session_protocol>` section.
158
159
  * @param {string} [opts.amend] - Run-specific amendment, the last fragment of
159
160
  * the `<session_protocol>` section.
160
- * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
161
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators. Uses `fsSync.readFileSync`.
161
162
  * @returns {{type: "preset", preset: "claude_code", append: string}}
162
163
  */
163
164
  export function composeProfilePrompt(
@@ -177,18 +178,18 @@ export function composeProfilePrompt(
177
178
 
178
179
  /**
179
180
  * 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.
181
+ * preset). This function joins the protocol trailer, an optional profile's
182
+ * hoisted `## Session Protocol` section, and any amendment into
183
+ * `<session_protocol>`. It wraps an optional persona in a sibling
184
+ * `<agent_profile>` before that section.
184
185
  *
185
186
  * @param {object} opts
186
187
  * @param {string} [opts.profile] - Profile basename (no `.md` suffix)
187
- * @param {string} [opts.profilesDir] - Directory containing profile files
188
+ * @param {string} [opts.profilesDir] - Directory that contains profile files
188
189
  * @param {string} opts.trailer - Session protocol (orchestration instructions)
189
190
  * @param {string} [opts.amend] - Run-specific amendment, the last fragment of
190
191
  * the `<session_protocol>` section.
191
- * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
192
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators. Uses `fsSync.readFileSync`.
192
193
  * @returns {string}
193
194
  */
194
195
  export function composeLeadPrompt({
@@ -209,20 +210,20 @@ export function composeLeadPrompt({
209
210
  }
210
211
 
211
212
  /**
212
- * Unified entry point for composing system prompts. Threads an optional
213
+ * Unified entry point that composes a system prompt. Threads an optional
213
214
  * amendment through as the run-specific fragment of `<session_protocol>`
214
- * (after the trailer and any hoisted profile section), then delegates by
215
+ * (after the trailer and any hoisted profile section). Then delegates by
215
216
  * role.
216
217
  *
217
218
  * @param {object} opts
218
- * @param {"lead"|"agent"} opts.role - `"lead"` produces a plain string;
219
+ * @param {"lead"|"agent"} opts.role - `"lead"` produces a plain string.
219
220
  * `"agent"` produces a `claude_code` preset object.
220
221
  * @param {string} [opts.profile] - Profile basename
221
222
  * @param {string} [opts.profilesDir]
222
223
  * @param {string} opts.trailer - Session protocol (orchestration instructions)
223
224
  * @param {string} [opts.amend] - Caller-supplied amendment, the last fragment
224
225
  * inside `<session_protocol>`, joined with a blank-line separator.
225
- * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
226
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators. Uses `fsSync.readFileSync`.
226
227
  * @returns {string | {type: "preset", preset: "claude_code", append: string}}
227
228
  */
228
229
  export function composeSystemPrompt({
package/src/redaction.js CHANGED
@@ -1,19 +1,20 @@
1
1
  /**
2
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.
3
+ * the trace artifact. It composes two layers: an env-var value allowlist and
4
+ * a set of credential-shape regexes. Both run on every primitive string.
5
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.
6
+ * Coverage includes encoded credential forms as well as raw bytes. The env
7
+ * layer matches each allowlisted secret raw. It also matches the secret in
8
+ * its **standard base64** form at any byte offset within the encoded
9
+ * plaintext. The pattern layer covers the git `extraheader` basic-auth
10
+ * wrapper. Two limits apply. The redactor covers **standard base64 only**,
11
+ * so it does not cover URL-safe base64, hex, or percent-encoding. It also
12
+ * covers the **trace-write sink only**. Content an agent authors into a wiki
13
+ * commit never passes through this redactor.
13
14
  *
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.
15
+ * The redactor is stateless after construction. It captures `env` once, so
16
+ * in-process `process.env` writes (e.g. agent-runner.js LIBHARNESS_SKILL,
17
+ * commands/run.js LIBHARNESS_AGENT_PROFILE) cannot smuggle a value past it.
17
18
  */
18
19
 
19
20
  export const DEFAULT_ENV_ALLOWLIST = Object.freeze([
@@ -36,7 +37,7 @@ export const DEFAULT_ENV_ALLOWLIST = Object.freeze([
36
37
 
37
38
  // Anchored prefixes per
38
39
  // 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
+ // The Anthropic prefix is heuristic. The env-allowlist layer is the primary
40
41
  // defence for Anthropic keys.
41
42
  export const DEFAULT_PATTERNS = Object.freeze([
42
43
  { kind: "anthropic", regex: /sk-ant-[A-Za-z0-9_-]{80,}/g },
@@ -45,11 +46,11 @@ export const DEFAULT_PATTERNS = Object.freeze([
45
46
  { kind: "gh-oauth", regex: /\bgho_[A-Za-z0-9]{36}\b/g },
46
47
  { kind: "gh-fine-grained", regex: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
47
48
  // 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.
49
+ // `http.<url>.extraheader` as `AUTHORIZATION: basic <b64>`. There the
50
+ // plaintext is `x-access-token:<token>` (actions/checkout form). The
51
+ // raw-byte layers above cannot see that shape. The plaintext prefix is 15
52
+ // bytes, which is five whole base64 triplets. So every encoded form starts
53
+ // with the same 20 chars, whatever token follows.
53
54
  {
54
55
  kind: "gh-b64-basic-credential",
55
56
  regex: /\beC1hY2Nlc3MtdG9rZW46[A-Za-z0-9+/]{8,}={0,2}/g,
@@ -60,27 +61,29 @@ const ENV_PLACEHOLDER = (name) => `[REDACTED:env:${name}]`;
60
61
  const PATTERN_PLACEHOLDER = (kind) => `[REDACTED:pattern:${kind}]`;
61
62
 
62
63
  /**
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.
64
+ * The minimum byte length a secret needs before the redactor matches its
65
+ * encoded form. At 9 bytes the shortest offset core is exactly 8 chars.
66
+ * Below 9 bytes it drops under 8 chars. That is too short for a sound needle
67
+ * against ordinary base64 trace content (margin of safety, false positives).
68
+ * Every DEFAULT_ENV_ALLOWLIST value (token, key, password) far exceeds it.
68
69
  */
69
70
  const MIN_ENCODED_SECRET_BYTES = 9;
70
71
 
71
- // Leading base64 chars contaminated by the k filler bytes, per alignment.
72
+ // The k filler bytes contaminate this many base64 chars at the start, per
73
+ // alignment.
72
74
  const ENCODED_LEAD_STRIP = [0, 2, 3];
73
75
 
74
76
  /**
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.
77
+ * Return the three standard-base64 core substrings of `secret`, one per byte
78
+ * alignment (k = 0/1/2). Each core is offset-invariant. base64 maps disjoint
79
+ * 3-byte groups to 4 chars independently. So the chars that cover a secret's
80
+ * interior groups depend only on the secret's bytes. They never depend on the
81
+ * bytes around it. Only the partial groups at each edge depend on the
82
+ * neighbours. This function strips those groups. The core that remains
83
+ * appears in the base64 of any plaintext that puts `secret` at that
84
+ * alignment. Padding lives only in the final partial group, and this function
85
+ * strips that group. So each core is padding-free. One needle matches padded
86
+ * and unpadded haystack content. Returns [] below MIN_ENCODED_SECRET_BYTES.
84
87
  * @param {string} secret
85
88
  * @returns {string[]}
86
89
  */
@@ -98,9 +101,10 @@ function encodedNeedles(secret) {
98
101
 
99
102
  /**
100
103
  * 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
+ * vars. This function skips empty strings. A leaked empty env var would
105
+ * otherwise make the redactor replace every empty string in the trace.
106
+ * `needles` are the precomputed standard-base64 cores (empty for sub-floor
107
+ * secrets).
104
108
  */
105
109
  function snapshotEnv(env, allowlist) {
106
110
  const snap = {};
@@ -129,8 +133,8 @@ function walk(value, redactString) {
129
133
  export class Redactor {
130
134
  /**
131
135
  * @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]`.
136
+ * @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`.
137
+ * @param {ReadonlyArray<{kind: string, regex: RegExp}>} deps.patterns - Credential-shape regexes. Each match becomes `[REDACTED:pattern:KIND]`.
134
138
  * @param {boolean} deps.enabled - When false, `redactValue` returns its input by reference.
135
139
  */
136
140
  constructor({ envSnapshot, patterns, enabled }) {
@@ -140,8 +144,9 @@ export class Redactor {
140
144
  }
141
145
 
142
146
  /**
143
- * Redact any JSON-serialisable value by deep-walking and replacing secrets
144
- * in every primitive string. Identity on the input when disabled.
147
+ * Redact any JSON-serialisable value. This method deep-walks the value and
148
+ * replaces secrets in every primitive string. When disabled, it returns its
149
+ * input by reference.
145
150
  * @param {unknown} value
146
151
  * @returns {unknown}
147
152
  */
@@ -163,10 +168,11 @@ export class Redactor {
163
168
  if (out.includes(secret)) {
164
169
  out = out.split(secret).join(ENV_PLACEHOLDER(name));
165
170
  }
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.
171
+ // Standard-base64 form at any byte offset. The order among the three
172
+ // needles does not matter. The placeholder shares no base64 run with
173
+ // any needle. Once a replacement puts the placeholder over a region,
174
+ // those bytes are gone, so a later needle cannot re-match them. The
175
+ // floor keeps every needle ≥ 8 chars.
170
176
  for (const needle of needles) {
171
177
  if (out.includes(needle)) {
172
178
  out = out.split(needle).join(ENV_PLACEHOLDER(name));
@@ -181,20 +187,20 @@ export class Redactor {
181
187
  }
182
188
 
183
189
  /**
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.
190
+ * Build a redactor. It reads `LIBHARNESS_REDACTION_DISABLED` and
191
+ * `LIBHARNESS_REDACTION_ENV_VARS` from the supplied env. An injected
192
+ * `runtime` supplies the env and the stderr sink (`runtime.proc.env` /
193
+ * `runtime.proc.stderr`). When a caller supplies no runtime, the function
194
+ * constructs a default one so current callers keep working. An explicit
195
+ * `opts.env` override still wins for the snapshot. The function fires a
196
+ * one-shot stderr warning when a caller constructs it disabled. Use
197
+ * `createNoopRedactor()` for silent fixtures to bypass that warning.
192
198
  * @param {object} [opts]
193
- * @param {import("@forwardimpact/libutil/runtime").Runtime} [opts.runtime] - Ambient collaborators; `proc.env`/`proc.stderr` are used.
199
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} [opts.runtime] - Ambient collaborators. The factory uses `proc.env` and `proc.stderr`.
194
200
  * @param {Record<string, string|undefined>} [opts.env] - Environment to snapshot. Defaults to `runtime.proc.env`.
195
201
  * @param {string[]} [opts.allowlist] - Override the env-var name list. Defaults to `DEFAULT_ENV_ALLOWLIST` or the parsed `LIBHARNESS_REDACTION_ENV_VARS` value.
196
202
  * @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`.
203
+ * @param {boolean} [opts.enabled] - Force enabled or disabled. It bypasses `LIBHARNESS_REDACTION_DISABLED`.
198
204
  * @returns {Redactor}
199
205
  */
200
206
  export function createRedactor({
@@ -215,7 +221,7 @@ export function createRedactor({
215
221
  : Object.freeze({});
216
222
  if (!resolvedEnabled) {
217
223
  proc.stderr.write(
218
- "libharness: trace redaction DISABLED via LIBHARNESS_REDACTION_DISABLED — secrets may appear in trace artifact\n",
224
+ "libharness: trace redaction DISABLED through LIBHARNESS_REDACTION_DISABLED. Secrets may appear in the trace artifact\n",
219
225
  );
220
226
  }
221
227
  return new Redactor({ envSnapshot, patterns, enabled: resolvedEnabled });
@@ -240,8 +246,8 @@ function resolveAllowlistFromEnv(env) {
240
246
 
241
247
  /**
242
248
  * 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.
249
+ * Use this form in test fixtures. It bypasses `createRedactor`, so no stderr
250
+ * warning fires whatever the env state.
245
251
  * @returns {Redactor}
246
252
  */
247
253
  export function createNoopRedactor() {
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Line renderer — composes prefix + color + body + reset into a single
3
- * terminal line. Pure; no side effects.
3
+ * terminal line. Pure, with no side effects.
4
4
  *
5
5
  * Every renderer returns a `\n`-terminated string:
6
6
  * <source>: <ESC><color><body><RESET>\n
7
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.
8
+ * The `<source>: ` prefix lives outside the color escape, so grep and
9
+ * terminals that strip color preserve the participant tag. Colons separate
10
+ * the source label and the kind label (`Bash:`, `Result:`, `Error:`). The
11
+ * line then stays tight on narrow viewports and keeps its structure.
12
12
  */
13
13
 
14
14
  import { colorForSource, ERROR_COLOR, RESET } from "./palette.js";
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Orchestrator filter — predicate for the orchestrator lifecycle events that
3
- * should be suppressed from the human-readable log.
2
+ * Orchestrator filter — predicate that names the orchestrator lifecycle
3
+ * events to suppress from the human-readable log.
4
4
  *
5
- * NDJSON artifacts still carry every orchestrator event; this module only
5
+ * NDJSON artifacts still carry every orchestrator event. This module only
6
6
  * controls what the live `textStream` and offline `toText()` show.
7
7
  */
8
8
 
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Palette — pure profile-name → ANSI SGR foreground color function.
3
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.
4
+ * The module assigns a color from a FNV-1a hash of the source name modulo
5
+ * the palette size. So the same name maps to the same color in every
6
+ * process. The module reserves red for tool-result errors. The palette never
7
+ * contains red.
7
8
  *
8
9
  * Colors use the 24-bit truecolor SGR escape (`ESC[38;2;R;G;Bm`) rather than
9
10
  * 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
+ * render truecolor as the exact hex requested. This avoids the washed-out
11
12
  * 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
+ * largest concurrent cast in any current workflow (five domain agents plus
13
14
  * the facilitator) with headroom.
14
15
  */
15
16
 
@@ -33,9 +34,10 @@ export const RESET = "\u001b[0m";
33
34
  /**
34
35
  * Map a source name to a stable ANSI foreground color.
35
36
  *
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.
37
+ * This function is pure. A FNV-1a 32-bit hash of the name decides the color.
38
+ * The same name gives the same color on every call, in every process.
39
+ * Returns `RESET` for absent or empty names so callers never emit a stray
40
+ * escape.
39
41
  *
40
42
  * @param {string|null|undefined} name
41
43
  * @returns {string} ANSI SGR escape, never equal to `ERROR_COLOR`
@@ -47,7 +49,7 @@ export function colorForSource(name) {
47
49
  h ^= name.charCodeAt(i);
48
50
  h = Math.imul(h, 0x01000193) >>> 0;
49
51
  }
50
- // Length mixer: reduces FNV's intrinsic birthday collisions on short
52
+ // Length mixer. It reduces FNV's intrinsic birthday collisions on short
51
53
  // names with shared affixes (e.g. `staff-engineer`/`facilitator`).
52
54
  h ^= name.length;
53
55
  h = Math.imul(h, 0x01000193) >>> 0;
@@ -3,24 +3,25 @@
3
3
  * tool-result previews.
4
4
  *
5
5
  * `hintForCall(name, input)` renders the human-meaningful field for each
6
- * tool (file path, command, pattern, …) sanitized to strip JSON punctuation
7
- * (`{`, `}`, `"`) and collapsed to a single line ≤ 80 chars.
6
+ * tool (file path, command, pattern, …). It strips JSON punctuation
7
+ * (`{`, `}`, `"`) from that field. It collapses the field to a single line
8
+ * ≤ 80 chars.
8
9
  *
9
- * MCP-prefixed tools (`mcp__*`) are an intentional carve-out: their hint is
10
+ * MCP-prefixed tools (`mcp__*`) are an intentional carve-out. Their hint is
10
11
  * the full input rendered as compact single-line JSON, so `{` and `"` do
11
12
  * appear on those lines. Readers of GitHub workflow logs need the full MCP
12
- * payload to know what was actually sent across the protocol.
13
+ * payload to know what the caller actually sent across the protocol.
13
14
  *
14
15
  * `previewForResult(content, isError)` collapses a tool result to a single
15
- * line ≤ 80 chars and flags errors so the renderer can apply the reserved
16
- * error color and the `Error:` label.
16
+ * line ≤ 80 chars. It also flags errors. The flag lets the renderer apply
17
+ * the reserved error color and the `Error:` label.
17
18
  */
18
19
 
19
20
  const MAX_HINT_CHARS = 80;
20
21
 
21
22
  /**
22
23
  * Strip `{`, `}`, `"`, collapse whitespace, and truncate to MAX_HINT_CHARS.
23
- * First line only — anything past a newline is dropped. Always returns a
24
+ * Use the first line only. Drop anything past a newline. Always returns a
24
25
  * string, never null/undefined.
25
26
  * @param {unknown} raw
26
27
  * @returns {string}
@@ -36,9 +37,10 @@ function sanitize(raw) {
36
37
  }
37
38
 
38
39
  /**
39
- * Truncate an already-sanitized string to MAX_HINT_CHARS with a trailing
40
- * ellipsis when it overflows. Shared by the few handlers that concatenate
41
- * multiple sanitized pieces before deciding on truncation.
40
+ * Truncate an already-sanitized string to MAX_HINT_CHARS, with an ellipsis
41
+ * at the end when it overflows. The few handlers that concatenate multiple
42
+ * sanitized pieces share this helper. They concatenate first, then decide
43
+ * whether to truncate.
42
44
  * @param {string} str
43
45
  * @returns {string}
44
46
  */
@@ -50,8 +52,9 @@ function truncate(str) {
50
52
 
51
53
  /**
52
54
  * Per-tool hint handlers. Each entry takes the sanitized input object
53
- * (never null) and returns the hint string. Kept as a flat table so adding
54
- * a new tool is one entry, not a new branch in a growing switch.
55
+ * (never null) and returns the hint string. This table stays flat. A new
56
+ * tool then needs one entry. It does not need a new branch in a switch that
57
+ * grows.
55
58
  */
56
59
  const HINT_HANDLERS = {
57
60
  Bash: (i) => sanitize(i.command),
@@ -104,7 +107,7 @@ export function simplifyToolName(name) {
104
107
  * `{` / `"` from the input (built-in tool hints stay free of JSON
105
108
  * punctuation so readers see clean one-liners).
106
109
  * - An MCP-prefixed tool (`mcp__*`) → full input rendered as compact
107
- * single-line JSON; `{` and `"` intentionally appear so readers see
110
+ * single-line JSON. `{` and `"` intentionally appear so readers see
108
111
  * the actual MCP payload.
109
112
  * - Anything else → "" (the caller still shows the bare tool name).
110
113
  *
@@ -126,8 +129,8 @@ export function hintForCall(name, input) {
126
129
 
127
130
  /**
128
131
  * Render a tool result as a single preview line plus an `isError` flag.
129
- * The flag lets the line-renderer pick the reserved error color without
130
- * re-inspecting the content.
132
+ * The flag lets the line-renderer pick the reserved error color. The
133
+ * line-renderer does not re-inspect the content.
131
134
  *
132
135
  * @param {string|object|null|undefined} content - Tool result content
133
136
  * @param {boolean} isError - Whether the tool call failed
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Turn renderer — maps a structured turn into formatted text lines.
3
3
  *
4
- * Shared by `TeeWriter.flushTurns()` (live stream) and
5
- * `TraceCollector.toText()` (offline replay) so both emit identical output.
4
+ * `TeeWriter.flushTurns()` (live stream) and `TraceCollector.toText()`
5
+ * (offline replay) share it, so both emit identical output.
6
6
  */
7
7
 
8
8
  import {
@@ -51,8 +51,8 @@ function renderAssistantTurn(turn, withPrefix) {
51
51
 
52
52
  /** @param {object} turn @param {boolean} withPrefix @returns {string[]} */
53
53
  function renderToolResultTurn(turn, withPrefix) {
54
- // Successful tool results emit no preview line — the trace document keeps
55
- // the structured turn, but readers of the streamed log only see errors.
54
+ // Successful tool results emit no preview line. The trace document keeps
55
+ // the structured turn. Readers of the streamed log see errors only.
56
56
  if (!turn.isError) return [];
57
57
  return [
58
58
  renderToolResultLine({
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ReplyEmitter — POST reply/ack events to the callback URL as they
3
- * happen. Each emission is fire-and-forget so the message bus is never
4
- * blocked on network I/O.
3
+ * happen. Each emission is fire-and-forget, so network I/O never blocks
4
+ * the message bus.
5
5
  */
6
6
  export class ReplyEmitter {
7
7
  #callbackUrl;