@forwardimpact/libharness 2.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 (73) hide show
  1. package/README.md +68 -65
  2. package/package.json +15 -13
  3. package/src/advisor.js +47 -41
  4. package/src/agent-runner.js +58 -48
  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 +29 -27
  18. package/src/benchmark/trace-split.js +9 -8
  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 +20 -20
  24. package/src/commands/benchmark-grade.js +13 -12
  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 +11 -11
  28. package/src/commands/callback.js +11 -11
  29. package/src/commands/discuss.js +8 -7
  30. package/src/commands/facilitate.js +16 -14
  31. package/src/commands/output.js +4 -3
  32. package/src/commands/run.js +15 -15
  33. package/src/commands/scan-logs.js +22 -20
  34. package/src/commands/selfedit.js +124 -0
  35. package/src/commands/supervise.js +13 -11
  36. package/src/commands/task-input.js +9 -9
  37. package/src/commands/tee.js +11 -10
  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 +16 -14
  66. package/src/trace-query.js +61 -52
  67. package/src/trace-render.js +19 -19
  68. package/src/trace-usage.js +31 -28
  69. package/src/transcript-recorder.js +24 -20
  70. package/bin/fit-benchmark.js +0 -44
  71. package/bin/fit-harness.js +0 -412
  72. package/bin/fit-selfedit.js +0 -165
  73. package/bin/fit-trace.js +0 -520
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OrchestrationToolkit — tool schemas, per-role tool sets, and handler
3
3
  * factories for orchestration between leads (facilitator, supervisor,
4
- * discuss-lead) and their participating agents.
4
+ * discuss-lead) and the agents that take part.
5
5
  *
6
6
  * **Tool surface, by role:**
7
7
  *
@@ -15,16 +15,16 @@
15
15
  * | Discuss agt | ✓ | ✓ | ✓ | ✓ | | RFC |
16
16
  * | Judge | | | | | ✓ | |
17
17
  *
18
- * **Ask is async.** Ask returns `{askIds:[…]}` immediately and posts the
18
+ * **Ask is async.** Ask returns `{askIds:[…]}` immediately. It posts the
19
19
  * question to the addressee's bus queue. The reply arrives on the asker's
20
- * next turn as `[answer#N] <participant>: <text>`. Pending state keys by
21
- * `askId` (visible in `[ask#N]` tags), so duplicate Asks to the same
22
- * addressee coexist without overwriting.
20
+ * next turn as `[answer#N] <participant>: <text>`. The toolkit keys pending
21
+ * state by `askId` (visible in `[ask#N]` tags). Duplicate Asks to the same
22
+ * addressee then coexist and never overwrite each other.
23
23
  *
24
24
  * **Answer's `askId` is optional.** With a matching askId, the reply
25
- * routes to that specific asker. Without, the handler auto-picks if
26
- * exactly one ask is owed to the caller, otherwise routes the message
27
- * as an Announce so it still reaches everyone.
25
+ * routes to that specific asker. Without one, the handler auto-picks when
26
+ * exactly one ask is owed to the caller. Otherwise the handler routes the
27
+ * message as an Announce so it still reaches everyone.
28
28
  */
29
29
 
30
30
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
@@ -48,9 +48,9 @@ export function createOrchestrationContext() {
48
48
 
49
49
  /**
50
50
  * Guard for terminal tools (`Conclude`, `Adjourn`, `Recess`). Returns an
51
- * error result when the caller still has Asks in flight, telling them to
52
- * end the turn and wait for the auto-resume. Returns `null` when no Asks
53
- * are pending and the terminal tool is free to run.
51
+ * error result when the caller still has Asks in flight. That result tells
52
+ * the caller to end the turn and wait for the auto-resume. Returns `null`
53
+ * when no Asks are pending and the terminal tool is free to run.
54
54
  */
55
55
  export function requireNoPendingAsks(ctx) {
56
56
  if (ctx.pendingAsks.size === 0) return null;
@@ -62,18 +62,18 @@ export function requireNoPendingAsks(ctx) {
62
62
  /**
63
63
  * Guard for terminal tools in discuss mode (`Adjourn`, `Recess`). Returns
64
64
  * an error result when the lead's inbox has unprocessed messages from the
65
- * human, telling them to end the turn and wait for the auto-resume.
66
- * Returns `null` when no inbox messages are pending and the terminal tool
67
- * is free to run.
65
+ * human. That result tells the lead to end the turn and wait for the
66
+ * auto-resume. Returns `null` when no inbox messages are pending and the
67
+ * terminal tool is free to run.
68
68
  */
69
69
  export function requireNoUnprocessedInbox(ctx) {
70
70
  if (!ctx.messageBus?.hasPending?.("lead")) return null;
71
71
  return errorResult(
72
- "New messages from the human are waiting. End your turn. You will be resumed to process them.",
72
+ "New messages from the human are in your inbox. End your turn. You will be resumed to process them.",
73
73
  );
74
74
  }
75
75
 
76
- /** Mark the session as concluded; cancel any open Asks so askers see the synthetic null on their next turn. */
76
+ /** Mark the session as concluded. Cancel any open Asks so askers see the synthetic null on their next turn. */
77
77
  export function createConcludeHandler(ctx) {
78
78
  return async ({ verdict, summary }) => {
79
79
  const guard = requireNoPendingAsks(ctx);
@@ -84,11 +84,11 @@ export function createConcludeHandler(ctx) {
84
84
  }
85
85
 
86
86
  /**
87
- * Shared terminal-tool helper. Conclude / Adjourn / Recess all set the
88
- * same three context fields (`concluded`, `verdict`, `summary`) and
89
- * cancel any in-flight Asks for the same reason: nobody will ever
90
- * answer them now. Mode-specific handlers (Adjourn, Recess) layer
91
- * extra state on top before calling this.
87
+ * Shared terminal-tool helper. Conclude, Adjourn, and Recess all set the
88
+ * same three context fields (`concluded`, `verdict`, `summary`). All three
89
+ * also cancel any in-flight Asks for the same reason. Nobody will ever
90
+ * answer them now. Mode-specific handlers (Adjourn, Recess) layer extra
91
+ * state on top before they call this.
92
92
  */
93
93
  export function concludeSession(ctx, { verdict, summary, reason }) {
94
94
  ctx.concluded = true;
@@ -124,21 +124,24 @@ function registerPendingAsk(ctx, { from, addressee, question }) {
124
124
  }
125
125
 
126
126
  /**
127
- * Create an Ask handler. Registers a pending entry per addressee, posts
128
- * the ask on the bus, returns `{askIds:[…]}` immediately. The LLM uses
129
- * those ids to match the `[answer#N]` it sees on a later turn.
127
+ * Create an Ask handler. The handler registers a pending entry for each
128
+ * addressee. It posts the ask on the bus. It returns `{askIds:[…]}`
129
+ * immediately. The LLM uses those ids to match the `[answer#N]` it sees on
130
+ * a later turn.
130
131
  *
131
132
  * @param {object} ctx
132
133
  * @param {object} opts
133
134
  * @param {string} opts.from
134
135
  * @param {string|undefined} opts.defaultTo - `undefined` means "broadcast
135
- * to everyone else"; a participant name means "target that one when
136
+ * to everyone else". A participant name means "target that one when
136
137
  * `to` is omitted."
137
138
  */
138
139
  export function createAskHandler(ctx, { from, defaultTo }) {
139
140
  return async ({ question, to }) => {
140
141
  if (ctx.concluded) {
141
- return errorResult("Session is concluded; Ask was not delivered.");
142
+ return errorResult(
143
+ "The session is concluded. The handler did not deliver your Ask.",
144
+ );
142
145
  }
143
146
  const addressees = resolveAddressees(ctx, { from, to, defaultTo });
144
147
  if (addressees.length === 0) {
@@ -157,7 +160,8 @@ export function createAskHandler(ctx, { from, defaultTo }) {
157
160
  * - askId provided + matches a pending entry whose addressee is the caller →
158
161
  * route the reply to the asker's queue and clear the pending entry.
159
162
  * - askId provided but unknown or wrong addressee → `isError`. The caller
160
- * tried to specify; we tell them why it didn't match.
163
+ * tried to name an askId. The handler tells the caller why it did not
164
+ * match.
161
165
  * - askId omitted + exactly one ask owed by the caller → auto-pick it.
162
166
  * - askId omitted + 0 or many pending → broadcast as Announce so the
163
167
  * message still reaches every other participant.
@@ -180,9 +184,9 @@ export function createAnswerHandler(ctx, { from }) {
180
184
  ctx.messageBus.announce(from, message);
181
185
  const reason =
182
186
  owed.length === 0
183
- ? "no pending ask for you"
184
- : `${owed.length} pending asks (askId omitted is ambiguous)`;
185
- return textResult(`Answer routed as Announce — ${reason}.`);
187
+ ? "You have no pending ask."
188
+ : `You have ${owed.length} pending asks. An omitted askId is ambiguous.`;
189
+ return textResult(`Answer routed as Announce. ${reason}`);
186
190
  };
187
191
  }
188
192
 
@@ -191,7 +195,7 @@ function routeAnswerByAskId(ctx, { from, askId, message }) {
191
195
  if (!entry) return errorResult(`No pending ask with askId=${askId}.`);
192
196
  if (entry.addresseeName !== from) {
193
197
  return errorResult(
194
- `Ask #${askId} is addressed to ${entry.addresseeName}, not ${from}.`,
198
+ `Ask #${askId} is addressed to ${entry.addresseeName}. You are ${from}.`,
195
199
  );
196
200
  }
197
201
  ctx.pendingAsks.delete(askId);
@@ -208,9 +212,9 @@ export function createAnnounceHandler(ctx, { from }) {
208
212
  }
209
213
 
210
214
  /**
211
- * Cancel pending Asks and route a synthetic `[no answer: <reason>]` to
212
- * each asker's queue so callers never deadlock on a participant ignoring
213
- * its inbox.
215
+ * Cancel pending Asks. Route a synthetic `[no answer: <reason>]` to each
216
+ * asker's queue, so callers never deadlock when a participant ignores its
217
+ * inbox.
214
218
  *
215
219
  * @param {object} ctx
216
220
  * @param {string} reason - Surfaced inside `[no answer: <reason>]`.
@@ -234,8 +238,8 @@ export function pendingAsksOwedBy(ctx, addressee) {
234
238
  }
235
239
 
236
240
  /**
237
- * Inject a synthetic reminder onto the addressee's bus queue and mark
238
- * each owed ask as reminded. Returns true when a reminder fired.
241
+ * Inject a synthetic reminder onto the addressee's bus queue. Mark each
242
+ * owed ask as reminded. Returns true when a reminder fired.
239
243
  */
240
244
  export function remindOwedAsks(ctx, addressee) {
241
245
  const owed = pendingAsksOwedBy(ctx, addressee).filter((e) => !e.reminded);
@@ -252,13 +256,13 @@ export function remindOwedAsks(ctx, addressee) {
252
256
  // --- Tool descriptions (shared across roles) ---
253
257
 
254
258
  const ASK_DESC_BROADCAST =
255
- "Send a question to one named participant, or omit 'to' to broadcast to every other participant. Returns {askIds:[…]} immediately; the reply arrives on a later turn as `[answer#N] <from>: <text>` in your inbox.";
259
+ "Send a question to one named participant. Omit 'to' to broadcast to every other participant. Returns {askIds:[…]} immediately. The reply arrives on a later turn as `[answer#N] <from>: <text>` in your inbox.";
256
260
 
257
261
  const ASK_DESC_TARGETED = (target) =>
258
- `Send a question to ${target}. Returns {askIds:[N]} immediately; the reply arrives on a later turn as \`[answer#N] ${target}: <text>\` in your inbox.`;
262
+ `Send a question to ${target}. Returns {askIds:[N]} immediately. The reply arrives on a later turn as \`[answer#N] ${target}: <text>\` in your inbox.`;
259
263
 
260
264
  const ANSWER_DESC =
261
- "Reply to an ask addressed to you. Quote askId from the [ask#N] tag on the question; omit it and the handler auto-picks the only pending ask, or routes your message as an Announce when 0 or many are pending.";
265
+ "Reply to an ask addressed to you. Quote askId from the [ask#N] tag on the question. Omit askId and the handler auto-picks the only pending ask. When 0 or many asks are pending, the handler routes your message as an Announce.";
262
266
 
263
267
  const ANNOUNCE_DESC = "Broadcast a message with no reply expected.";
264
268
 
@@ -275,7 +279,7 @@ const ADJOURN_DESC =
275
279
  "End the discussion. Provide a verdict ('adjourned' or 'failed') and a summary. Cancels any unanswered Asks.";
276
280
 
277
281
  const RECESS_DESC =
278
- "End the run and schedule an out-of-session re-dispatch. Cancels any unanswered Asks. Use only when waiting on an external reply or duration. Do not use to wait on in-flight Asks.";
282
+ "End the run. Schedule an out-of-session re-dispatch. Cancels any unanswered Asks. Use only when you wait on an external reply or duration. Do not use to wait on in-flight Asks.";
279
283
 
280
284
  // --- Tool builders ---
281
285
 
@@ -283,7 +287,7 @@ const RECESS_DESC =
283
287
  function textResult(text) {
284
288
  return { content: [{ type: "text", text }] };
285
289
  }
286
- /** Build an MCP tool error result wrapping a single text message. */
290
+ /** Build an MCP tool error result that wraps a single text message. */
287
291
  function errorResult(text) {
288
292
  return { content: [{ type: "text", text }], isError: true };
289
293
  }
@@ -298,10 +302,10 @@ function jsonResult(obj) {
298
302
  * @param {object} ctx
299
303
  * @param {object} opts
300
304
  * @param {string} opts.from - Caller's canonical name.
301
- * @param {string|undefined} opts.defaultTo - Default Ask target; `undefined`
305
+ * @param {string|undefined} opts.defaultTo - Default Ask target. `undefined`
302
306
  * means "broadcast across everyone else when `to` is omitted."
303
307
  * @param {boolean} opts.broadcast - Whether Ask accepts a `to` field at all.
304
- * Leads with multiple participants set this true; supervise's
308
+ * Leads with multiple participants set this true. Supervise's
305
309
  * single-participant roles set it false.
306
310
  */
307
311
  function baseTools(ctx, { from, defaultTo, broadcast }) {
@@ -338,19 +342,20 @@ function concludeTool(ctx) {
338
342
  }
339
343
 
340
344
  const ADVISOR_DESC =
341
- "Consult a stronger model on one focused question. Your full session context (system prompt, prompts, transcript so far) is forwarded automatically — you cannot restrict it. The advice returns in the tool result. The consult budget is shared session-wide across all participants.";
345
+ "Consult a stronger model on one focused question. The tool forwards your full session context (system prompt, prompts, transcript so far) automatically. You cannot restrict it. The advice returns in the tool result. All participants share one session-wide consult budget.";
342
346
 
343
347
  /**
344
- * Build the `Advisor` consult tool for one caller. Mode-agnostic: loop
345
- * modes pass it into the agent tool-server factories via `extraTools`;
346
- * run mode gives it a dedicated server. No orchestration-context
347
- * dependency — the budget object and emit callback are injected.
348
+ * Build the `Advisor` consult tool for one caller. The tool is
349
+ * mode-agnostic. Loop modes pass it into the agent tool-server factories
350
+ * through `extraTools`. Run mode gives it a dedicated server. The tool has
351
+ * no orchestration-context dependency. The budget object and the emit
352
+ * callback arrive as injected dependencies.
348
353
  *
349
354
  * @param {object} deps
350
355
  * @param {string} deps.from - Caller's canonical name (event attribution).
351
356
  * @param {(question: string) => Promise<{advice?: string, unavailable?: boolean, reason?: string, durationMs: number}>} deps.consult
352
357
  * @param {(event: object) => void} deps.emit - Orchestrator-event emitter for the `advisor_consult` event.
353
- * @param {{maxUses: number, used: number}} deps.budget - Session-wide budget shared by every caller's handler.
358
+ * @param {{maxUses: number, used: number}} deps.budget - Session-wide budget that every caller's handler shares.
354
359
  * @param {string} deps.model - Advisor model id, carried on the consult event.
355
360
  */
356
361
  export function advisorTool({ from, consult, emit, budget, model }) {
@@ -378,7 +383,7 @@ export function advisorTool({ from, consult, emit, budget, model }) {
378
383
  remaining,
379
384
  });
380
385
  if (r.unavailable) {
381
- // Not isError: fail-open, the caller continues normally.
386
+ // Not isError. This fails open, so the caller continues normally.
382
387
  return textResult(
383
388
  `The advisor is unavailable (${r.reason}) — proceed with your best judgment.`,
384
389
  );
@@ -477,7 +482,7 @@ export function createRequestForCommentHandler(ctx) {
477
482
  function requestForCommentTool(ctx) {
478
483
  return tool(
479
484
  "RequestForComment",
480
- "Open a new Discussion thread for long-horizon coordination on an open question. The bridge creates the thread; replies arrive asynchronously on future runs.",
485
+ "Open a new Discussion thread for long-horizon coordination on an open question. The bridge creates the thread. Replies arrive asynchronously on future runs.",
481
486
  {
482
487
  channel: z.string(),
483
488
  body: z.string(),
@@ -487,8 +492,8 @@ function requestForCommentTool(ctx) {
487
492
  );
488
493
  }
489
494
 
490
- // Re-export the building blocks discuss-tools.js needs to assemble its
491
- // own lead tool surface (it has two extra terminal tools).
495
+ // Re-export the parts discuss-tools.js needs to assemble its own lead tool
496
+ // surface (it has two extra terminal tools).
492
497
  export {
493
498
  ADJOURN_DESC,
494
499
  baseTools,
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Render a drained batch of bus messages as tagged text lines so the
3
3
  * LLM can read its inbox at a glance. Asks and answers include the
4
- * `askId` in the tag (`[ask#42] facilitator: …`, `[answer#42] agent: …`)
5
- * so the addressee can quote it back via Answer's `askId` field.
4
+ * `askId` in the tag (`[ask#42] facilitator: …`, `[answer#42] agent: …`).
5
+ * The addressee can then quote it back in Answer's `askId` field.
6
6
  *
7
7
  * @param {Array<{from: string, text: string, kind?: string, askId?: number}>} messages
8
8
  * @returns {string}
@@ -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({