@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.
- package/README.md +68 -65
- package/package.json +15 -13
- package/src/advisor.js +47 -41
- package/src/agent-runner.js +58 -48
- package/src/benchmark/apm-installer.js +28 -28
- package/src/benchmark/env-loader.js +24 -16
- package/src/benchmark/grade.js +44 -41
- package/src/benchmark/hidden-tests.js +25 -24
- package/src/benchmark/hook-env.js +11 -9
- package/src/benchmark/invariants.js +20 -17
- package/src/benchmark/judge.js +29 -28
- package/src/benchmark/npm-installer.js +9 -8
- package/src/benchmark/report.js +53 -50
- package/src/benchmark/result.js +24 -23
- package/src/benchmark/runner.js +75 -69
- package/src/benchmark/scheduler.js +17 -16
- package/src/benchmark/task-family.js +29 -27
- package/src/benchmark/trace-split.js +9 -8
- package/src/benchmark/workdir.js +27 -25
- package/src/claude-code-executable.js +11 -11
- package/src/commands/advisor-flags.js +8 -7
- package/src/commands/assert.js +16 -15
- package/src/commands/benchmark-definition.js +20 -20
- package/src/commands/benchmark-grade.js +13 -12
- package/src/commands/benchmark-report.js +5 -5
- package/src/commands/benchmark-run.js +31 -28
- package/src/commands/by-discussion.js +11 -11
- package/src/commands/callback.js +11 -11
- package/src/commands/discuss.js +8 -7
- package/src/commands/facilitate.js +16 -14
- package/src/commands/output.js +4 -3
- package/src/commands/run.js +15 -15
- package/src/commands/scan-logs.js +22 -20
- package/src/commands/selfedit.js +124 -0
- package/src/commands/supervise.js +13 -11
- package/src/commands/task-input.js +9 -9
- package/src/commands/tee.js +11 -10
- package/src/commands/trace.js +55 -42
- package/src/commands/work-tracker.js +4 -3
- package/src/cost.js +17 -17
- package/src/discuss-tools.js +16 -16
- package/src/discusser.js +39 -38
- package/src/events/github.js +54 -37
- package/src/facilitator.js +21 -21
- package/src/inbox-poller.js +4 -4
- package/src/judge.js +32 -30
- package/src/message-bus.js +12 -11
- package/src/orchestration-loop.js +35 -36
- package/src/orchestration-toolkit.js +58 -53
- package/src/orchestrator-helpers.js +2 -2
- package/src/profile-prompt.js +54 -53
- package/src/redaction.js +63 -57
- package/src/render/line-renderer.js +5 -5
- package/src/render/orchestrator-filter.js +3 -3
- package/src/render/palette.js +11 -9
- package/src/render/tool-hints.js +18 -15
- package/src/render/turn-renderer.js +4 -4
- package/src/reply-emitter.js +2 -2
- package/src/sequence-counter.js +4 -3
- package/src/signature-filter.js +7 -6
- package/src/supervisor.js +19 -18
- package/src/tee-writer.js +25 -25
- package/src/trace-collector.js +53 -48
- package/src/trace-github.js +53 -44
- package/src/trace-multi.js +16 -14
- package/src/trace-query.js +61 -52
- package/src/trace-render.js +19 -19
- package/src/trace-usage.js +31 -28
- package/src/transcript-recorder.js +24 -20
- package/bin/fit-benchmark.js +0 -44
- package/bin/fit-harness.js +0 -412
- package/bin/fit-selfedit.js +0 -165
- 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
|
|
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
|
|
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>`.
|
|
21
|
-
* `askId` (visible in `[ask#N]` tags)
|
|
22
|
-
* addressee coexist
|
|
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
|
|
26
|
-
* exactly one ask is owed to the caller
|
|
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
|
|
52
|
-
* end the turn and wait for the auto-resume. Returns `null`
|
|
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
|
|
66
|
-
* Returns `null` when no inbox messages are pending and the
|
|
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
|
|
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
|
|
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
|
|
88
|
-
* same three context fields (`concluded`, `verdict`, `summary`)
|
|
89
|
-
* cancel any in-flight Asks for the same reason
|
|
90
|
-
* answer them now. Mode-specific handlers (Adjourn, Recess) layer
|
|
91
|
-
*
|
|
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.
|
|
128
|
-
* the ask on the bus
|
|
129
|
-
* those ids to match the `[answer#N]` it sees on
|
|
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"
|
|
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(
|
|
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
|
|
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
|
|
184
|
-
:
|
|
185
|
-
return textResult(`Answer routed as Announce
|
|
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}
|
|
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
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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
|
|
238
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
345
|
-
* modes pass it into the agent tool-server factories
|
|
346
|
-
*
|
|
347
|
-
* dependency
|
|
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
|
|
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
|
|
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
|
|
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
|
|
491
|
-
//
|
|
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
|
-
*
|
|
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}
|
package/src/profile-prompt.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
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
|
|
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
|
|
17
|
+
* convention lives entirely here. Profile `.md` files and trailer constants
|
|
18
18
|
* carry no tags.
|
|
19
19
|
*
|
|
20
|
-
* The `<session_protocol>` body
|
|
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
|
|
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
|
|
30
|
-
*
|
|
31
|
-
* the harness comms protocol and the role's
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* stays in
|
|
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
|
-
*
|
|
40
|
+
* Agent participants that need the full Claude Code tool surface use it.
|
|
40
41
|
*
|
|
41
|
-
* - `composeLeadPrompt(opts)` — plain string, no preset.
|
|
42
|
-
*
|
|
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
|
|
47
|
-
* of the above
|
|
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.
|
|
59
|
-
* is fixed at two
|
|
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
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* blank-line separator
|
|
77
|
-
* when at least one fragment survives. The
|
|
78
|
-
* blank line
|
|
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)
|
|
99
|
-
*
|
|
100
|
-
* When the body carries no `## Session Protocol` heading, the
|
|
101
|
-
*
|
|
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
|
|
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
|
|
133
|
+
* `runtime.fsSync` surface. This composer runs inside the synchronous
|
|
133
134
|
* SDK-option builders of the supervisor / facilitator / discusser / judge
|
|
134
|
-
* factories
|
|
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
|
|
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.
|
|
149
|
-
*
|
|
150
|
-
* profile's hoisted `## Session Protocol` section, and any
|
|
151
|
-
*
|
|
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
|
|
156
|
-
* @param {string} [opts.trailer] -
|
|
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
|
|
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).
|
|
181
|
-
* `## Session Protocol` section, and any amendment
|
|
182
|
-
* `<session_protocol
|
|
183
|
-
* `<agent_profile>` before
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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({
|