Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
73a9ae0
Materialise the plugin keys, the repertoire, and both plugin definitions
lunelson Aug 25, 2026
059da57
Add baseline condition 4: the teaching layer as prompt only
lunelson Aug 25, 2026
daaf4ee
Record the cycle-1 pressure review and fix any-kind patterns
lunelson Aug 25, 2026
e756889
Score baseline condition 4: the teaching layer as prompt only
lunelson Aug 25, 2026
48d9cac
Put the harness in the baseline loop as condition 5
lunelson Aug 25, 2026
2f76827
Record the first condition-5 run
lunelson Aug 25, 2026
df2393f
Close the condition-5 arc: steering, ledger, and the turn-latency ass…
lunelson Aug 26, 2026
6c5fee9
Delete the retired condition-3 instrument and fix the condition-5 tes…
lunelson Aug 26, 2026
63571e2
Record Lu's decisions on the next arc in STEERING
lunelson Aug 26, 2026
73a1202
Reconcile Brunch steering with dogsled
lunelson Aug 26, 2026
2a0cb2f
Select the black triangle as the frontier (S-011)
lunelson Aug 26, 2026
3f65c2d
Distinguish moves, streams, and sequences in the control vocabulary
lunelson Aug 26, 2026
9b8018a
Add the parallel partition to Brunch steering
lunelson Aug 26, 2026
9bfe6d2
Define partition as a strategic-control term
lunelson Aug 26, 2026
74715a2
Repair the control docs a cold read found stale
lunelson Aug 26, 2026
b2228fc
Add a protocol for cutting worktrees from a recorded partition.
lunelson Aug 26, 2026
cfbda9e
Anchor the control-surface test on the governing-concerns section
lunelson Aug 26, 2026
eefae3c
driver fixes for W1 overreach
lunelson Aug 26, 2026
0173281
steering churn
lunelson Aug 26, 2026
ffcad51
more steering churn
lunelson Aug 26, 2026
e1878be
Record the completed pre-Proof-0 braid in steering
lunelson Aug 26, 2026
ce302d3
Keep the Brunch evaluation runner in pruned app jobs
lunelson Aug 27, 2026
20a32e7
Render completion relative to the declared anchor kind
lunelson Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/actions/prune-repository/prune.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
"libs/@hashintel/brunch-agent/evaluations",
"libs/@hashintel/brunch-agent/scripts",
],
# The app's condition-5 test executes the evaluation runner as a child
# process; the context root is not a workspace and must be copied explicitly.
"@apps/brunch-agent": ["libs/@hashintel/brunch-agent/evaluations"],
}

TURBO_QUERY = """
Expand Down
8 changes: 8 additions & 0 deletions .github/actions/prune-repository/prune_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,21 @@ def test_core_job_adds_the_app_and_context_paths(self) -> None:
self.assertEqual(
extra_paths_for_requested({CORE}),
[
".config/oxlint/brunch",
"libs/@hashintel/brunch-agent/AGENTS.md",
"libs/@hashintel/brunch-agent/CONTEXT.md",
"libs/@hashintel/brunch-agent/docs",
"libs/@hashintel/brunch-agent/evaluations",
"libs/@hashintel/brunch-agent/scripts",
],
)

def test_app_job_adds_the_baseline_evaluation_paths(self) -> None:
self.assertEqual(
extra_paths_for_requested({APP}),
["libs/@hashintel/brunch-agent/evaluations"],
)

def test_sibling_or_website_job_does_not_add_brunch_extras(self) -> None:
self.assertEqual(extras_for_requested({TRANSPORT}), frozenset())
self.assertEqual(extras_for_requested({WEBSITE}), frozenset())
Expand Down
1 change: 1 addition & 0 deletions apps/brunch-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"license": "AGPL-3.0",
"type": "module",
"scripts": {
"baseline:harness": "node --experimental-strip-types ../../libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/harness-run.ts",
"build": "vite build && vite build --config vite.client.config.ts",
"dev": "vite dev",
"fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .",
Expand Down
15 changes: 11 additions & 4 deletions apps/brunch-agent/src/agents/sdcpn-elicitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* The SDCPN elicitor (spec Β§12.5: one agent per target).
*
* The second entry in the target gallery, and the first whose plugin is a
* file: `@hashintel/brunch-agent-plugin-sdcpn` loads `plugin.md` and the
* harness reads its three tables (ADR-0006). This module is as thin as the
* file: `@hashintel/brunch-agent-plugin-sdcpn` loads `plugin.yaml` and the
* harness reads its cells (ADR-0006, ADR-0007). This module is as thin as the
* gherkin one β€” it mounts harness capability and holds no elicitation
* semantics of its own; what the interviewer asks, demands, and treats as
* complete all comes from the plugin file through the binding.
Expand All @@ -22,8 +22,15 @@ import { sdcpn } from "@hashintel/brunch-agent-plugin-sdcpn";

import { createSdcpnElicitationSession } from "../elicitation-session.ts";

/** One definition for the agent and any faux provider alike (see the gherkin elicitor). */
export const SDCPN_MODEL_ID = "claude-haiku-4-5";
/**
* One definition for the agent and any faux provider alike (see the gherkin
* elicitor). `BRUNCH_SDCPN_MODEL` overrides the default so an evaluation
* runner can drive this same agent with a stronger model without a second
* agent definition; the override is read once, at module load, like the rest
* of the agent's static configuration.
*/
export const SDCPN_MODEL_ID =
process.env["BRUNCH_SDCPN_MODEL"] || "claude-haiku-4-5";

const sdcpnElicitorInitialData = v.object({
targetDocumentId: v.pipe(v.string(), v.nonEmpty()),
Expand Down
128 changes: 128 additions & 0 deletions apps/brunch-agent/test/baseline-harness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, expect, test } from "vitest";

import {
CLOSING_TEXT,
EXPERT_OBJECTIVE_QUOTE,
FIRST_QUESTION,
SECOND_QUESTION,
} from "./fixtures/baseline-harness-interviewer.ts";
import { runNodeScript } from "./run-node-script";

import type { HarnessRunRecord } from "../../../libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/harness-run.ts";

const testDirectory = import.meta.dirname;
const contextRoot = join(
testDirectory,
"../../../libs/@hashintel/brunch-agent",
);
const runner = join(
contextRoot,
"evaluations/protocols/process-model-elicitation/baseline/harness-run.ts",
);
const expertStub = join(
contextRoot,
"packages/core/test/architecture/fixtures/baseline-anthropic-stub.ts",
);
const interviewer = join(
testDirectory,
"fixtures/baseline-harness-interviewer.ts",
);

const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});

test("condition 5 drives the shipped elicitor through the binding and reads the harness's facts back", async () => {
const outputDirectory = await mkdtemp(
join(tmpdir(), "brunch-baseline-c5-test-"),
);
temporaryDirectories.push(outputDirectory);
const expertReplies = [
{ text: EXPERT_OBJECTIVE_QUOTE },
{ text: "Better is fewer late promises, then fewer changeovers." },
{ text: "Alright. Anything else?" },
{ text: "Then I'll get back to the floor." },
{ text: "Cheers." },
];
// The shared expert stub reads its scripted replies from a file, never inline.
const expertRepliesPath = join(outputDirectory, "expert-replies.json");
await writeFile(expertRepliesPath, JSON.stringify(expertReplies));

const { exitCode, stderr } = await runNodeScript(
runner,
join(testDirectory, "../../.."),
{
BRUNCH_BASELINE_TEST_OUTPUT_DIR: outputDirectory,
BRUNCH_BASELINE_ANTHROPIC_MODULE: expertStub,
BRUNCH_BASELINE_INTERVIEWER_PROVIDER_MODULE: interviewer,
BASELINE_STUB_REPLIES_PATH: expertRepliesPath,
BRUNCH_SDCPN_MODEL: "claude-haiku-4-5",
},
);
expect(exitCode, stderr).toBe(0);

const run = JSON.parse(
await readFile(join(outputDirectory, "condition-5.raw.json"), "utf8"),
) as HarnessRunRecord;

// Turn 1 asks; the expert's reply is bound to that ask on the next dispatch.
expect(run.turns[0]?.pendingQuestion).toBe(FIRST_QUESTION);
expect(run.turns[0]?.expert?.content).toBe(EXPERT_OBJECTIVE_QUOTE);
expect(
run.turns[1]?.signals.some(
(signal) => signal.tagName === "affordance-reply-bound",
),
).toBe(true);

// Turn 2 sweeps the settled range: one capture applied, completion reported
// by the harness, and the second question left pending.
const sweep = run.turns[1]?.sweeps[0];
expect(sweep?.status).toBe("applied");
expect(sweep?.appliedCaptureIds).toHaveLength(1);
expect(sweep?.completion).toMatchObject({ complete: false });
expect(run.turns[1]?.pendingQuestion).toBe(SECOND_QUESTION);
expect(run.turns[1]?.completion).toMatchObject({
captures: 1,
complete: false,
});

// Then the interviewer closes without asking; the runner counts three such
// turns before the wrap and declares the interview stalled β€” no classifier.
expect(run.turns.slice(2).map((turn) => turn.text)).toEqual([
expect.arrayContaining([CLOSING_TEXT]),
expect.arrayContaining([CLOSING_TEXT]),
expect.arrayContaining([CLOSING_TEXT]),
]);
expect(
run.turns.slice(2).every((turn) => turn.pendingQuestion === undefined),
).toBe(true);
expect(run.stopReason).toBe("stalled");
expect(run.turns).toHaveLength(5);
expect(run.usage.interviewer.calls).toBeGreaterThanOrEqual(5);
expect(run.usage.expert.calls).toBe(4);

// The store is the deliverable: the one capture, quoting the expert verbatim.
expect(run.store.captures).toHaveLength(1);
const [captures, model, transcript, system] = await Promise.all([
readFile(join(outputDirectory, "condition-5-captures.json"), "utf8"),
readFile(join(outputDirectory, "condition-5-model.md"), "utf8"),
readFile(join(outputDirectory, "condition-5.md"), "utf8"),
readFile(join(outputDirectory, "condition-5-system.md"), "utf8"),
]);
expect(captures).toContain(EXPERT_OBJECTIVE_QUOTE);
expect(model).toContain("### objective (1)");
expect(model).toContain("Complete: **no**");
expect(transcript).toContain("Stop reason: stalled");
expect(transcript).toContain("> harness β€” sweep applied; applied 1");
expect(transcript).toContain(FIRST_QUESTION);
expect(system).toContain("brunch_ask");
});
105 changes: 105 additions & 0 deletions apps/brunch-agent/test/fixtures/baseline-harness-interviewer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* A scripted interviewer for the condition-5 harness runner's hermetic test.
*
* Loaded by `harness-run.ts` through `BRUNCH_BASELINE_INTERVIEWER_PROVIDER_MODULE`
* as the runtime's only provider. The responses are decided from what the
* model can see, not from a fixed sequence, so the fixture stays correct
* however many model calls the binding's settlement nudge adds to a turn:
*
* 1. no ask yet β†’ ask the objective question
* 2. an answer, no sweep β†’ sweep; the extraction call gets one proposal
* 3. sweep applied β†’ ask a second question
* 4. anything after that β†’ close with text and no question, until the
* runner declares the interview stalled
*/

import {
fauxAssistantMessage,
fauxProvider,
fauxToolCall,
type Context,
} from "@earendil-works/pi-ai";

import { toolName } from "@hashintel/brunch-agent";

import { SDCPN_MODEL_ID } from "../../src/agents/sdcpn-elicitor.ts";

import type { SlotAssertedProposalInput } from "@hashintel/brunch-agent-plugin-sdcpn";

export const FIRST_QUESTION =
"What decision are you trying to get right, in your own words?";
export const SECOND_QUESTION =
"When two orders compete for the same line, what does 'better' mean to you?";
export const CLOSING_TEXT =
"Thank you β€” I have what I need for now and no further questions.";
/** The expert stub's first reply; the extraction quotes it verbatim. */
export const EXPERT_OBJECTIVE_QUOTE =
"Which job should each line run next so the week's promises hold.";

const ask = toolName("ask");
const sweep = toolName("sweep");

const objectiveProposal: SlotAssertedProposalInput = {
evidence: [{ excerpt: EXPERT_OBJECTIVE_QUOTE }],
epistemicStatus: "explicit",
confidence: "firm",
content: {
value: {
type: "slot-asserted",
kind: "objective",
node: "which job next",
slot: "the question, in the expert's words",
precision: "spelled out",
assertion: { value: EXPERT_OBJECTIVE_QUOTE },
},
},
};

const countToolCalls = (context: Context, name: string): number => {
let count = 0;
for (const message of context.messages) {
if (message.role !== "assistant") continue;
for (const block of message.content) {
if (block.type === "toolCall" && block.name === name) count += 1;
}
}
return count;
};

const faux = fauxProvider({
provider: "anthropic",
models: [{ id: SDCPN_MODEL_ID }],
});

faux.setResponses(
Array.from({ length: 64 }, () => (context: Context) => {
if (context.tools?.some((tool) => tool.name === "finish")) {
return fauxAssistantMessage(
[fauxToolCall("finish", { proposals: [objectiveProposal] })],
{ stopReason: "toolUse" },
);
}
const asks = countToolCalls(context, ask);
const sweeps = countToolCalls(context, sweep);
if (asks === 0) {
return fauxAssistantMessage(
[fauxToolCall(ask, { question: FIRST_QUESTION })],
{ stopReason: "toolUse" },
);
}
if (sweeps === 0) {
return fauxAssistantMessage([fauxToolCall(sweep, {})], {
stopReason: "toolUse",
});
}
if (asks === 1) {
return fauxAssistantMessage(
[fauxToolCall(ask, { question: SECOND_QUESTION })],
{ stopReason: "toolUse" },
);
}
return fauxAssistantMessage(CLOSING_TEXT);
}),
);

export default faux.provider;
2 changes: 2 additions & 0 deletions apps/brunch-agent/test/run-node-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ interface NodeScriptResult {
export const runNodeScript = async (
scriptPath: string,
cwd: string,
env: Readonly<Record<string, string>> = {},
): Promise<NodeScriptResult> =>
new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
["--experimental-strip-types", scriptPath],
{
cwd,
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
},
);
Expand Down
11 changes: 11 additions & 0 deletions apps/brunch-agent/turbo.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
{
"extends": ["//"],
"tasks": {
"baseline:harness": {
"dependsOn": ["^build"],
"cache": false,
"passThroughEnv": [
"ANTHROPIC_API_KEY",
"BRUNCH_SDCPN_MODEL",
"BRUNCH_BASELINE_ANTHROPIC_MODULE",
"BRUNCH_BASELINE_INTERVIEWER_PROVIDER_MODULE",
"BRUNCH_BASELINE_TEST_OUTPUT_DIR"
]
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
Expand Down
4 changes: 3 additions & 1 deletion libs/@hashintel/brunch-agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ workspace.
Route by trigger; load only the applicable compact protocol:

- Start or resume without a proof target, or when objectives, pressure, proof, authority, external
gates, frontier value, or arc-close findings change: `docs/agents/steering.md`.
gates, frontier value, or arc-close findings change: invoke `/ds-steer`, which consults the Brunch
supplement at `docs/agents/steering.md`.
- Create, mutate, triage, or structure issues: `docs/agents/issue-tracker.md`,
`docs/agents/issue-writing.md`, and `docs/agents/triage-labels.md`.
- Add, move, settle, or index documents: `docs/agents/documentation.md`.
Expand All @@ -52,5 +53,6 @@ Route by trigger; load only the applicable compact protocol:
- Produce a significant agent-authored artifact or proof: `docs/agents/legibility.md`.
- Make an architecture-sensitive move: `docs/agents/posture.md`.
- Operate on branches, stacks, commits, or PRs: `docs/agents/git-workflow.md`.
- Create or refresh worktrees for a recorded partition: `docs/agents/partition-worktrees.md`.
- Close a work arc: run the context-local `arc-close` skill and
`docs/agents/arc-close.md`.
Loading
Loading