+
+
+ Elicitation harness — dev app
+
+
+
+
+
+
diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json
new file mode 100644
index 00000000000..fc199cec26f
--- /dev/null
+++ b/apps/brunch-agent/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@apps/brunch-agent",
+ "version": "0.0.0-private",
+ "private": true,
+ "description": "Remote Brunch server, local development loop, target gallery, and diagnostic probe surface.",
+ "license": "AGPL-3.0",
+ "type": "module",
+ "scripts": {
+ "build": "vite build && vite build --config vite.client.config.ts",
+ "dev": "vite dev",
+ "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .",
+ "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .",
+ "lint:tsc": "tsgo --noEmit",
+ "petrinaut:dev": "vite dev --config petrinaut-local.vite.config.ts",
+ "test:unit": "vitest run --config vitest.config.ts"
+ },
+ "dependencies": {
+ "@flue/react": "2.0.3",
+ "@flue/runtime": "2.0.3",
+ "@flue/sdk": "2.0.3",
+ "@hashintel/brunch-agent": "workspace:*",
+ "@hashintel/brunch-agent-binding-flue": "workspace:*",
+ "@hashintel/brunch-agent-plugin-gherkin": "workspace:*",
+ "@hashintel/brunch-agent-transport-aisdk": "workspace:*",
+ "hono": "4.13.2",
+ "react": "19.2.6",
+ "react-dom": "19.2.6",
+ "valibot": "1.4.2"
+ },
+ "devDependencies": {
+ "@earendil-works/pi-ai": "0.83.0",
+ "@flue/vite": "2.0.3",
+ "@types/node": "22.18.13",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
+ "@typescript/native-preview": "7.0.0-dev.20260511.1",
+ "oxlint": "1.63.0",
+ "oxlint-tsgolint": "0.22.1",
+ "vite": "8.1.0",
+ "vitest": "4.1.10"
+ }
+}
diff --git a/apps/brunch-agent/petrinaut-local.vite.config.ts b/apps/brunch-agent/petrinaut-local.vite.config.ts
new file mode 100644
index 00000000000..249686c8498
--- /dev/null
+++ b/apps/brunch-agent/petrinaut-local.vite.config.ts
@@ -0,0 +1,72 @@
+/**
+ * Local FE-1436 panel launcher.
+ *
+ * Loads the real hash Petrinaut website config, removes only its incumbent
+ * `/api/chat` dev handler, and sends that same-origin route to brunch's
+ * committed application server. The real panel, wrappers, and editor stay untouched;
+ * hash's tracked checkout stays clean.
+ */
+
+import { join, resolve } from "node:path";
+
+import {
+ defineConfig,
+ loadConfigFromFile,
+ mergeConfig,
+ type PluginOption,
+} from "vite";
+
+import {
+ defaultChatOrigin,
+ petrinautLocalServer,
+} from "./src/local-dev-origins.ts";
+
+const withoutIncumbentChatHandler = (
+ plugins: readonly PluginOption[],
+): PluginOption[] =>
+ plugins.filter((plugin) => {
+ if (
+ plugin === false ||
+ plugin === null ||
+ plugin === undefined ||
+ Array.isArray(plugin) ||
+ typeof plugin !== "object" ||
+ !("name" in plugin)
+ ) {
+ return true;
+ }
+ return plugin.name !== "petrinaut-api-dev";
+ });
+
+export default defineConfig(async (environment) => {
+ const websiteRoot = process.env.PETRINAUT_WEBSITE_ROOT;
+ if (!websiteRoot) {
+ throw new Error(
+ "PETRINAUT_WEBSITE_ROOT must point at hash/apps/petrinaut-website for the real-panel run.",
+ );
+ }
+ const root = resolve(websiteRoot);
+ // Babel resolves the React compiler plugin from the launched project's cwd,
+ // not from the imported config file. Match a native hash launch before the
+ // plugin begins transforming the real panel source.
+ process.chdir(root);
+ const loaded = await loadConfigFromFile(
+ environment,
+ join(root, "vite.config.ts"),
+ root,
+ );
+ if (!loaded)
+ throw new Error(`Could not load Petrinaut's Vite config from ${root}.`);
+
+ const chatOrigin = process.env.BRUNCH_CHAT_ORIGIN ?? defaultChatOrigin;
+ return mergeConfig(
+ {
+ ...loaded.config,
+ plugins: withoutIncumbentChatHandler(loaded.config.plugins ?? []),
+ },
+ {
+ root,
+ server: petrinautLocalServer(chatOrigin),
+ },
+ );
+});
diff --git a/apps/brunch-agent/src/agents/gherkin-elicitor.ts b/apps/brunch-agent/src/agents/gherkin-elicitor.ts
new file mode 100644
index 00000000000..a6bbc6d387d
--- /dev/null
+++ b/apps/brunch-agent/src/agents/gherkin-elicitor.ts
@@ -0,0 +1,73 @@
+"use agent";
+/**
+ * The gherkin elicitor (spec §12.5: one agent per target).
+ *
+ * Named as a noun — the thing, not the act — and read target-first, so the
+ * family sorts together as targets multiply: `gherkin-elicitor`,
+ * `assurance-elicitor`.
+ *
+ * The product is the harness library in a thin host-authored agent — Flue's
+ * build-time scan makes the alternative structurally unavailable, since a
+ * library cannot ship a pre-registered agent (spec §12.1). So this module is
+ * deliberately thin: it mounts harness capability and holds no elicitation
+ * semantics of its own.
+ *
+ * Three recorded Flue constraints are honoured here by construction (spec §10):
+ * the `'use agent'` directive is the file's first statement; `agentName` is a
+ * pinned string literal, because conversation storage keys on it; and the tool
+ * set is static, because prompt-cache economics forbid per-question tool
+ * swapping.
+ */
+
+import { useInitialData, useModel, type AgentProps } from "@flue/runtime";
+import * as v from "valibot";
+
+import { useElicitation } from "@hashintel/brunch-agent-binding-flue";
+import { gherkin } from "@hashintel/brunch-agent-plugin-gherkin";
+
+import { createGherkinElicitationSession } from "../elicitation-session.ts";
+
+/**
+ * One definition for the agent and the faux provider alike: the two must name
+ * the same model id, and drift fails at resolution only if both sides resolve
+ * the same string (Flue patterns audit, 2026-08-17).
+ */
+export const GHERKIN_MODEL_ID = "claude-haiku-4-5";
+
+export function GherkinElicitor(props: AgentProps) {
+ useModel(`anthropic/${GHERKIN_MODEL_ID}`);
+ const initialData = useInitialData<{ targetDocumentId: string }>();
+ return useElicitation(
+ gherkin,
+ createGherkinElicitationSession(props.id, initialData.targetDocumentId),
+ );
+}
+
+/**
+ * Pinned, and never to be edited: conversation storage keys on this literal,
+ * so changing it orphans every existing conversation. Flue requires a string
+ * literal here because build targets derive durable identifiers from it before
+ * any user code runs.
+ *
+ * Product-prefixed on purpose, and this is the one place the prefix is not
+ * cosmetic. Agent identities are global per application, and the September
+ * demo shell is chartered to mount this library alongside the Petrinaut
+ * libraries — a bare `gherkin-elicitor` could collide with another library's
+ * agent, and the collision would land on durable conversation storage.
+ *
+ * The exported symbol stays the shorter `GherkinElicitor` because it reads
+ * better at the mount site; `agentName` exists precisely to let durable
+ * identity and source-level name differ.
+ */
+GherkinElicitor.agentName = "brunch-gherkin-elicitor";
+
+/**
+ * Session→document binding (spec §9.1, adjudication L4): a new session's
+ * `initialData` carries the target-document id, validated once at creation and
+ * immutable thereafter — Flue's own lane for a target descriptor. Dispatching
+ * to an existing conversation id resumes that session against the current state
+ * of its target-document.
+ */
+GherkinElicitor.initialData = v.object({
+ targetDocumentId: v.pipe(v.string(), v.nonEmpty()),
+});
diff --git a/apps/brunch-agent/src/app.ts b/apps/brunch-agent/src/app.ts
new file mode 100644
index 00000000000..6ce50876433
--- /dev/null
+++ b/apps/brunch-agent/src/app.ts
@@ -0,0 +1,57 @@
+/**
+ * The dev app's route map — the "mount" half of the thin host (spec §12.1).
+ *
+ * The dev app is chartered with three roles, none of them "the product"
+ * (spec §12.5): the local dev loop against every plugin, the colleague-facing
+ * target-gallery demo, and the diagnostic probe surface. Milestone one keeps
+ * affordance renderers here rather than in a ui package.
+ */
+
+import { readFile } from "node:fs/promises";
+
+import { createAgentRouter } from "@flue/runtime/routing";
+import { Hono } from "hono";
+
+import { GherkinElicitor } from "./agents/gherkin-elicitor.ts";
+import { assetHandler } from "./assets.ts";
+import { petrinautChatHandler } from "./petrinaut-chat.ts";
+import { GHERKIN_AGENT_ROUTE, PETRINAUT_CHAT_ROUTE } from "./routes.ts";
+
+const app = new Hono();
+
+// One route per target agent. The gallery grows an entry per plugin; gherkin
+// is the tracer that wires end-to-end first (spec §13). The browser and mount
+// share the route constant; Flue still keys storage on the agent's independent,
+// pinned identity.
+app.route(`/agents/${GHERKIN_AGENT_ROUTE}`, createAgentRouter(GherkinElicitor));
+
+// The application owns the HTTP mount; transport-aisdk owns only request validation
+// and AI SDK stream encoding. No parallel conversation renderer is introduced.
+app.on(["POST", "OPTIONS"], PETRINAUT_CHAT_ROUTE, (c) =>
+ petrinautChatHandler(c.req.raw),
+);
+
+// The flue dev controller owns the whole request space — no fall-through to
+// vite's html serving — so the ui is app-served, in dev and in production
+// alike (spec §10, recorded facts).
+//
+// Two different files, because two different builds produce them: in dev, the
+// source `index.html` whose script tag vite resolves live; in production, the
+// client build's emitted `index.html`, whose script tag points at a real
+// bundled asset. `@flue/vite` emits the server environment only, so that
+// client build is a second, plain vite build — without it the ui tree would
+// have no build coverage at all.
+const uiRoot = new URL(
+ import.meta.env?.DEV === false ? "./client/" : "../",
+ import.meta.url,
+);
+
+app.get("/", async (c) =>
+ c.html(await readFile(new URL("index.html", uiRoot), "utf8")),
+);
+
+// Production only: in dev, vite serves the module graph under /src. A
+// wildcard, not `:file` — bundlers may emit nested asset paths.
+app.get("/assets/*", assetHandler(uiRoot));
+
+export default app;
diff --git a/apps/brunch-agent/src/assets.ts b/apps/brunch-agent/src/assets.ts
new file mode 100644
index 00000000000..044eb140c32
--- /dev/null
+++ b/apps/brunch-agent/src/assets.ts
@@ -0,0 +1,106 @@
+/**
+ * Production asset serving — everything the client build emits, not an
+ * extension allowlist and not a filename grammar.
+ *
+ * The FE-1361 review verified the failure the old inline route carried: it
+ * allowlisted `js|css|map` and read hits as UTF-8, so the first font or image
+ * the client build emitted would 404 in production while vite dev served it
+ * fine — and widening the allowlist without dropping the UTF-8 read would
+ * have corrupted binary bytes instead. So: any safe file name serves, read as
+ * bytes; the content-type comes from hono's own MIME table and fails open to
+ * `application/octet-stream` rather than failing the asset.
+ *
+ * What counts as safe is a property of the path, not a pattern the emitted
+ * names are assumed to follow. `[name]` in a bundler's `assetFileNames` is the
+ * source basename, so spaces, parentheses, accents, `+`, `,`, `'`, `#` and `?`
+ * are all legal output, and a grammar narrower than the producer's is a
+ * production-only 404 waiting for the first asset that uses one.
+ *
+ * Mounted as a wildcard (`/assets/*`) rather than `:file`, because a `:file`
+ * param cannot match the nested paths bundlers emit (`assets/fonts/x.woff2`).
+ */
+
+import { readFile } from "node:fs/promises";
+
+import { getMimeType } from "hono/utils/mime";
+
+import type { Context } from "hono";
+
+/**
+ * Read failures that mean "no file at this path, and none can be": absent,
+ * reached through a file, a directory rather than a file, or too long to name
+ * one. Every other failure — a permission problem, a symlink cycle, an I/O
+ * error — is a fault to surface, because a 404 would report a broken build tree
+ * as an asset that was simply never emitted.
+ */
+const ABSENT_PATH_CODES = new Set([
+ "ENOENT",
+ "ENOTDIR",
+ "EISDIR",
+ "ENAMETOOLONG",
+]);
+
+const isAbsentPath = (error: unknown): boolean =>
+ error instanceof Error &&
+ "code" in error &&
+ ABSENT_PATH_CODES.has((error as NodeJS.ErrnoException).code ?? "");
+
+/**
+ * Whether a decoded path is safe to look up under the asset root: at least one
+ * segment, none empty, none dot-led — which is what keeps `..` traversal and
+ * hidden files out — no nul byte, which `readFile` rejects with a `TypeError`
+ * rather than an errno, and an extension on the last segment.
+ */
+const isSafeAssetPath = (file: string): boolean => {
+ if (file.includes("\0")) return false;
+ const segments = file.split("/");
+ return (
+ segments.every(
+ (segment) => segment.length > 0 && !segment.startsWith("."),
+ ) && /\.[^./]+$/.test(segments.at(-1) ?? "")
+ );
+};
+
+/** Serve `assets/*` from the client build rooted at `uiRoot`. */
+export function assetHandler(uiRoot: URL): (c: Context) => Promise {
+ return async (c) => {
+ // The raw wildcard remainder, decoded here because hono decodes params
+ // but not the path — an undecodable escape is a 404, not a crash.
+ let file: string;
+ try {
+ file = decodeURIComponent(c.req.path.replace(/^\/assets\//, ""));
+ } catch (error) {
+ if (error instanceof URIError) return c.notFound();
+ throw error;
+ }
+ if (!isSafeAssetPath(file)) return c.notFound();
+ // Re-encoded segment by segment on the way into the URL: to the URL parser
+ // `#` opens a fragment and `?` a query, and under the file: scheme a
+ // backslash is a path separator, so a name carrying any of them addresses
+ // some other file — `..\` addresses one outside the asset root entirely.
+ const path = new URL(
+ `assets/${file
+ .split("/")
+ .map((segment) => encodeURIComponent(segment))
+ .join("/")}`,
+ uiRoot,
+ );
+ // Served as read, with no intermediate copy. The cast is a type gap, not a
+ // conversion: `readFile` is typed to admit a SharedArrayBuffer-backed view,
+ // which hono's body type excludes, while the value it returns always owns
+ // its own ArrayBuffer.
+ let bytes: Uint8Array;
+ try {
+ bytes = (await readFile(path)) as Uint8Array;
+ } catch (error) {
+ if (isAbsentPath(error)) return c.notFound();
+ throw error;
+ }
+ // Fail open on the content-type, never on the bytes: an extension the
+ // table does not know degrades to a pickier content-type, not to a
+ // production-only 404.
+ return c.body(bytes, 200, {
+ "content-type": getMimeType(file) ?? "application/octet-stream",
+ });
+ };
+}
diff --git a/apps/brunch-agent/src/db-path.ts b/apps/brunch-agent/src/db-path.ts
new file mode 100644
index 00000000000..faaec3fcec7
--- /dev/null
+++ b/apps/brunch-agent/src/db-path.ts
@@ -0,0 +1,27 @@
+/**
+ * Where the conversation store lives, resolved before the adapter opens it.
+ *
+ * Anchored to this module's location — the way `app.ts` anchors `uiRoot` —
+ * never to the launch directory: a cwd-relative default silently creates a
+ * fresh empty database when the app is launched from anywhere else, which is
+ * the exact restart-durability failure `db.ts` exists to prevent. From `src/`
+ * and from the emitted `dist/` bundle alike, `../.data-wipe-me/` resolves to
+ * the package directory.
+ *
+ * Kept apart from `db.ts` so path policy stays importable without loading the
+ * Flue Node runtime and SQLite adapter.
+ */
+
+import { fileURLToPath } from "node:url";
+
+export function conversationDbPath(): string {
+ // Truthiness, not nullish, on purpose: a set-but-empty override would pass
+ // '' through to sqlite(), which opens an anonymous temporary database
+ // deleted on close — silently non-durable again.
+ const override = process.env.BRUNCH_DEV_DB_PATH;
+ return override
+ ? override
+ : fileURLToPath(
+ new URL("../.data-wipe-me/conversations.db", import.meta.url),
+ );
+}
diff --git a/apps/brunch-agent/src/db.ts b/apps/brunch-agent/src/db.ts
new file mode 100644
index 00000000000..a63b9680437
--- /dev/null
+++ b/apps/brunch-agent/src/db.ts
@@ -0,0 +1,19 @@
+/**
+ * The substrate's conversation storage — host-authored because Flue requires
+ * it of the consuming app (spec §9.6, adjudication C1).
+ *
+ * Not to be confused with the capture store: that is the harness's storage
+ * port, harness-defined and implemented in `@hashintel/brunch-agent-binding-flue`, and plugins are
+ * blind to both. This file holds only the live transport copy of conversations.
+ * The provenance record is the target-document's own session-log archive.
+ *
+ * Without this file conversations are process-memory and a restart loses them
+ * (recorded Flue fact, spec §10). Restart durability of the full stack is an
+ * open verification item (spec §14.5) that this file exists to make testable.
+ */
+
+import { sqlite } from "@flue/runtime/node";
+
+import { conversationDbPath } from "./db-path.ts";
+
+export default sqlite(conversationDbPath());
diff --git a/apps/brunch-agent/src/elicitation-session.ts b/apps/brunch-agent/src/elicitation-session.ts
new file mode 100644
index 00000000000..ae7a9753668
--- /dev/null
+++ b/apps/brunch-agent/src/elicitation-session.ts
@@ -0,0 +1,34 @@
+/** Host-owned wiring for the local Flue binding's history transport and store. */
+
+import {
+ createFlueHistoryReader,
+ createLocalCaptureStore,
+ type ElicitationSession,
+} from "@hashintel/brunch-agent-binding-flue";
+
+import { GHERKIN_AGENT_ROUTE } from "./routes.ts";
+import { targetDocumentPath } from "./target-document-path.ts";
+
+const appTransport = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const { default: app } = await import("./app.ts");
+ return app.fetch(input instanceof Request ? input : new Request(input, init));
+}) as typeof fetch;
+
+export const createGherkinElicitationSession = (
+ sessionId: string,
+ targetDocumentId: string,
+): ElicitationSession => {
+ const captureStore = createLocalCaptureStore(
+ targetDocumentPath(targetDocumentId),
+ );
+ return {
+ sessionId,
+ captureStore,
+ historyReader: createFlueHistoryReader({
+ resolveConversationUrl: (id) =>
+ `http://brunch.local/agents/${GHERKIN_AGENT_ROUTE}/${id}`,
+ transport: appTransport,
+ archive: captureStore,
+ }),
+ };
+};
diff --git a/apps/brunch-agent/src/local-dev-origins.ts b/apps/brunch-agent/src/local-dev-origins.ts
new file mode 100644
index 00000000000..797fc52b346
--- /dev/null
+++ b/apps/brunch-agent/src/local-dev-origins.ts
@@ -0,0 +1,30 @@
+/** Listen addresses the local Brunch↔Petrinaut pair must emit and assume. */
+
+export const localChatListen = {
+ host: "127.0.0.1",
+ port: 4321,
+ strictPort: true,
+} as const;
+
+export const localPanelListen = {
+ host: "127.0.0.1",
+ port: 4915,
+ strictPort: true,
+} as const;
+
+export const defaultChatOrigin = `http://${localChatListen.host}:${localChatListen.port}`;
+
+export const defaultPanelOrigins = [
+ `http://${localPanelListen.host}:${localPanelListen.port}`,
+ `http://localhost:${localPanelListen.port}`,
+] as const;
+
+export const petrinautLocalServer = (chatOrigin: string) => ({
+ ...localPanelListen,
+ proxy: {
+ "/api/chat": {
+ target: chatOrigin,
+ changeOrigin: true,
+ },
+ },
+});
diff --git a/apps/brunch-agent/src/petrinaut-chat.ts b/apps/brunch-agent/src/petrinaut-chat.ts
new file mode 100644
index 00000000000..9480653f56d
--- /dev/null
+++ b/apps/brunch-agent/src/petrinaut-chat.ts
@@ -0,0 +1,94 @@
+/** Application composition for Petrinaut's stock AI SDK chat transport. */
+
+import { init } from "@flue/runtime";
+
+import {
+ decideAskReplyAdmission,
+ pendingAskAffordanceId,
+} from "@hashintel/brunch-agent";
+import {
+ createFlueReplyProjector,
+ projectFlueHistoryForSweep,
+} from "@hashintel/brunch-agent-binding-flue";
+import {
+ createAiSdkChatHandler,
+ type HarnessReplyEvent,
+ type TransportInspectionEvent,
+} from "@hashintel/brunch-agent-transport-aisdk";
+
+import { GherkinElicitor } from "./agents/gherkin-elicitor.ts";
+import { createGherkinElicitationSession } from "./elicitation-session.ts";
+import { defaultPanelOrigins } from "./local-dev-origins.ts";
+
+const inspect =
+ process.env.BRUNCH_TRANSPORT_AISDK_INSPECT === "1"
+ ? (event: TransportInspectionEvent): void => {
+ // This is an opt-in shell diagnostic stream. It is never dispatched
+ // into Flue and therefore cannot become elicitation evidence.
+ console.log(`TRANSPORT_AISDK ${JSON.stringify(event)}`);
+ }
+ : undefined;
+
+// FE-1439 replaces this local one-conversation/one-document identity
+// with principal-owned private session lookup. Keep it opaque here.
+const targetDocumentIdFor = (conversationId: string): string =>
+ `petrinaut-local:${conversationId}`;
+
+const streamElicitorTurn = async (
+ conversationId: string,
+ dispatch: { readonly message: string; readonly idempotencyKey: string },
+ emit: (event: HarnessReplyEvent) => void,
+): Promise => {
+ const agent = init(GherkinElicitor, { id: conversationId });
+ const receipt = await agent.dispatch({
+ ...dispatch,
+ initialData: { targetDocumentId: targetDocumentIdFor(conversationId) },
+ });
+ const projector = createFlueReplyProjector({
+ submissionId: receipt.submissionId,
+ emit,
+ });
+ await agent.read(receipt, { onEvent: (chunk) => projector.accept(chunk) });
+};
+
+export const petrinautChatHandler = createAiSdkChatHandler({
+ allowedOrigins: (
+ process.env.BRUNCH_PETRINAUT_ORIGINS ?? defaultPanelOrigins.join(",")
+ )
+ .split(",")
+ .map((origin) => origin.trim())
+ .filter((origin) => origin.length > 0),
+ inspect,
+ runTurn: (input, emit) =>
+ streamElicitorTurn(
+ input.conversationId,
+ { message: input.userMessage.text, idempotencyKey: input.idempotencyKey },
+ emit,
+ ),
+ askReply: {
+ // Admission consults durable Flue history, not request-shaped claims: the
+ // submission resumes the conversation only when its tool-call id
+ // correlates with the one ask still awaiting a reply.
+ async admit(input) {
+ const session = createGherkinElicitationSession(
+ input.conversationId,
+ targetDocumentIdFor(input.conversationId),
+ );
+ const entries = projectFlueHistoryForSweep(
+ await session.historyReader.peek(input.conversationId),
+ );
+ return decideAskReplyAdmission(
+ pendingAskAffordanceId(entries),
+ input.ask.toolCallId,
+ );
+ },
+ // The admitted answer is a fresh user dispatch (spec §7.4); the binding
+ // binds it to the pending affordance, making it the user-affordance reply.
+ run: (input, emit) =>
+ streamElicitorTurn(
+ input.conversationId,
+ { message: input.ask.answer, idempotencyKey: input.idempotencyKey },
+ emit,
+ ),
+ },
+});
diff --git a/apps/brunch-agent/src/routes.ts b/apps/brunch-agent/src/routes.ts
new file mode 100644
index 00000000000..739c6323023
--- /dev/null
+++ b/apps/brunch-agent/src/routes.ts
@@ -0,0 +1,5 @@
+/** Browser-facing route segment; conversation identity remains the agent's pinned `agentName`. */
+export const GHERKIN_AGENT_ROUTE = "gherkin";
+
+/** Stock `DefaultChatTransport` endpoint used by Petrinaut's local panel. */
+export const PETRINAUT_CHAT_ROUTE = "/api/chat";
diff --git a/apps/brunch-agent/src/target-document-path.ts b/apps/brunch-agent/src/target-document-path.ts
new file mode 100644
index 00000000000..a2939f7ee7b
--- /dev/null
+++ b/apps/brunch-agent/src/target-document-path.ts
@@ -0,0 +1,17 @@
+/** Resolve one target-document's local binding store without trusting its id as a path. */
+
+import { createHash } from "node:crypto";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const defaultDirectory = (): string =>
+ fileURLToPath(new URL("../.data-wipe-me/target-documents/", import.meta.url));
+
+export function targetDocumentPath(targetDocumentId: string): string {
+ if (targetDocumentId.length === 0)
+ throw new TypeError("A target-document id cannot be empty.");
+ const directory =
+ process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR || defaultDirectory();
+ const identity = createHash("sha256").update(targetDocumentId).digest("hex");
+ return join(directory, `${identity}.json`);
+}
diff --git a/apps/brunch-agent/src/ui/chat.tsx b/apps/brunch-agent/src/ui/chat.tsx
new file mode 100644
index 00000000000..ea9d6dd2f59
--- /dev/null
+++ b/apps/brunch-agent/src/ui/chat.tsx
@@ -0,0 +1,143 @@
+import { useFlueAgent } from "@flue/react";
+import { createFlueClient, type FlueConversationMessage } from "@flue/sdk";
+import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
+import * as v from "valibot";
+
+import { FreeTextAffordance } from "@hashintel/brunch-agent";
+
+import { GHERKIN_AGENT_ROUTE } from "../routes.ts";
+
+const conversationId = crypto.randomUUID();
+
+function VisibleMessage({ message }: { message: FlueConversationMessage }) {
+ if (
+ message.display !== "visible" ||
+ (message.purpose !== "user" && message.purpose !== "assistant")
+ ) {
+ return null;
+ }
+
+ return (
+
+
: null}
+
+
+
+
+ );
+}
diff --git a/apps/brunch-agent/src/ui/main.tsx b/apps/brunch-agent/src/ui/main.tsx
new file mode 100644
index 00000000000..53d51ea6552
--- /dev/null
+++ b/apps/brunch-agent/src/ui/main.tsx
@@ -0,0 +1,22 @@
+/**
+ * The dev app's ui entry.
+ *
+ * The ui shell renders parts and transports replies; it owns no elicitation
+ * semantics (spec §4). A dedicated ui-affordance package is named and deferred
+ * — milestone one keeps renderers here (spec §12.5).
+ *
+ * The chat surface itself lands with the walking skeleton. Note what does and
+ * does not cover this file: `@flue/vite` builds the server environment only,
+ * so `vite build` never transforms it — `tsc` and `vite dev` are its whole
+ * safety net until a client build exists.
+ */
+
+import { createRoot } from "react-dom/client";
+
+import { Chat } from "./chat.tsx";
+import "./styles.css";
+
+const container = document.getElementById("root");
+if (!container) throw new Error("index.html is missing its #root container");
+
+createRoot(container).render();
diff --git a/apps/brunch-agent/src/ui/styles.css b/apps/brunch-agent/src/ui/styles.css
new file mode 100644
index 00000000000..8de4856c387
--- /dev/null
+++ b/apps/brunch-agent/src/ui/styles.css
@@ -0,0 +1,251 @@
+:root {
+ color: #29271f;
+ background: #eee9dc;
+ font-family: Georgia, "Times New Roman", serif;
+ font-synthesis: none;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ background:
+ linear-gradient(rgb(77 70 49 / 7%) 1px, transparent 1px) 0 7.5rem / 100%
+ 1.75rem,
+ #eee9dc;
+}
+
+button,
+textarea {
+ font: inherit;
+}
+
+.shell {
+ width: min(52rem, calc(100% - 2rem));
+ min-height: 100vh;
+ margin: 0 auto;
+ padding: 3.5rem 0 2rem;
+}
+
+.masthead {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 2rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 2px solid #29271f;
+}
+
+.eyebrow,
+.message__role,
+.question__index,
+.question__hint,
+.status,
+.composer label {
+ font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
+ font-size: 0.72rem;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.eyebrow {
+ margin: 0 0 0.65rem;
+ color: #766e56;
+}
+
+h1 {
+ max-width: 11ch;
+ margin: 0;
+ font-size: clamp(2.5rem, 7vw, 5rem);
+ font-weight: 400;
+ line-height: 0.93;
+ letter-spacing: -0.045em;
+}
+
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-top: 0.35rem;
+ color: #766e56;
+}
+
+.status::before {
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 50%;
+ background: #ba5a36;
+ content: "";
+}
+
+.status--streaming::before,
+.status--submitted::before {
+ animation: pulse 1.2s ease-in-out infinite;
+}
+
+.status--idle::before {
+ background: #607557;
+}
+
+.transcript {
+ display: grid;
+ gap: 1.25rem;
+ padding: 2rem 0 12rem;
+}
+
+.message {
+ max-width: 86%;
+}
+
+.message--user {
+ justify-self: end;
+ padding-left: 2rem;
+ text-align: right;
+}
+
+.message__role {
+ margin: 0 0 0.4rem;
+ color: #766e56;
+}
+
+.message__text {
+ margin: 0;
+ font-size: 1.05rem;
+ line-height: 1.55;
+ white-space: pre-wrap;
+}
+
+.question {
+ position: relative;
+ padding: 1.4rem 1.5rem 1.2rem;
+ border: 1px solid #29271f;
+ background: #f7f3e9;
+ box-shadow: 0.45rem 0.45rem 0 #c8bfa7;
+}
+
+.question__index {
+ color: #ba5a36;
+}
+
+.question__markdown {
+ margin: 1rem 0 1.4rem;
+ font-size: clamp(1.5rem, 4vw, 2.25rem);
+ line-height: 1.08;
+ white-space: pre-wrap;
+}
+
+.question__hint {
+ color: #766e56;
+ text-transform: none;
+}
+
+.opening,
+.error {
+ color: #766e56;
+ font-style: italic;
+}
+
+.error {
+ color: #9c3f28;
+}
+
+.composer {
+ position: fixed;
+ bottom: 0;
+ left: 50%;
+ z-index: 1;
+ width: min(54rem, 100%);
+ padding: 1rem;
+ border-top: 1px solid #29271f;
+ background: rgb(238 233 220 / 96%);
+ transform: translateX(-50%);
+ backdrop-filter: blur(8px);
+}
+
+.composer label {
+ display: block;
+ margin-bottom: 0.5rem;
+}
+
+.composer__row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 0.75rem;
+}
+
+textarea {
+ width: 100%;
+ resize: vertical;
+ padding: 0.85rem 1rem;
+ border: 1px solid #29271f;
+ border-radius: 0;
+ outline: none;
+ color: inherit;
+ background: #f7f3e9;
+ line-height: 1.4;
+}
+
+textarea:focus {
+ box-shadow: 0 0 0 3px #ba5a36;
+}
+
+button {
+ align-self: stretch;
+ min-width: 6rem;
+ border: 1px solid #29271f;
+ color: #f7f3e9;
+ background: #29271f;
+ cursor: pointer;
+}
+
+button:hover:not(:disabled) {
+ background: #ba5a36;
+}
+
+button:focus-visible {
+ outline: 3px solid #ba5a36;
+ outline-offset: 3px;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+@keyframes pulse {
+ 50% {
+ opacity: 0.25;
+ }
+}
+
+@media (max-width: 36rem) {
+ .shell {
+ padding-top: 2rem;
+ }
+
+ .masthead {
+ display: grid;
+ }
+
+ .message {
+ max-width: 100%;
+ }
+
+ .composer__row {
+ grid-template-columns: 1fr;
+ }
+
+ button {
+ min-height: 2.75rem;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .status::before {
+ animation: none;
+ }
+}
diff --git a/apps/brunch-agent/test/assets.test.ts b/apps/brunch-agent/test/assets.test.ts
new file mode 100644
index 00000000000..33b92ccedfe
--- /dev/null
+++ b/apps/brunch-agent/test/assets.test.ts
@@ -0,0 +1,204 @@
+/**
+ * The production asset route, driven as a real Hono route over a real
+ * directory. In dev, vite serves the module graph and this route is never
+ * hit — so nothing else exercises it, and a gap here is production-only
+ * by construction (the FE-1361 review's verified finding: the old
+ * js|css|map allowlist 404'd the first font or image the client build
+ * emitted, and its UTF-8 read would have corrupted the bytes had the
+ * allowlist merely grown).
+ *
+ * Tested against the handler module rather than the emitted server bundle so
+ * this contract remains isolated from the Flue Node runtime and SQLite.
+ */
+
+import {
+ mkdirSync,
+ mkdtempSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { pathToFileURL } from "node:url";
+
+import { Hono } from "hono";
+import { afterAll, describe, expect, test } from "vitest";
+
+import { assetHandler } from "../src/assets";
+
+const uiRoot = mkdtempSync(join(tmpdir(), "brunch-assets-"));
+const BINARY_BYTES = Uint8Array.from({ length: 256 }, (_, i) => i);
+
+/**
+ * Names a bundler can legally emit, because `[name]` in `assetFileNames` is the
+ * source basename and neither vite nor rollup reduces it to `[\w.-]`. Each file
+ * holds its own name as its body, so serving the wrong file is a failure rather
+ * than a coincidental pass.
+ */
+const PRODUCER_PUNCTUATION = [
+ "logo (1).png",
+ "café.woff2",
+ "a+b,c'd.js",
+ "x#y.js",
+ "q?z.js",
+ "@scope~thing.js",
+] as const;
+
+mkdirSync(join(uiRoot, "assets/fonts"), { recursive: true });
+// A directory whose name looks like an asset: reading it is EISDIR, which is an
+// expected absence rather than a fault.
+mkdirSync(join(uiRoot, "assets/legacy.js"), { recursive: true });
+writeFileSync(join(uiRoot, "assets/index.js"), "export {};\n");
+writeFileSync(join(uiRoot, "assets/index.css"), "body {}\n");
+writeFileSync(join(uiRoot, "assets/brand.woff2"), BINARY_BYTES);
+writeFileSync(join(uiRoot, "assets/Logo.PNG"), BINARY_BYTES);
+writeFileSync(join(uiRoot, "assets/blob.dat"), BINARY_BYTES);
+writeFileSync(join(uiRoot, "assets/fonts/nested.woff2"), BINARY_BYTES);
+for (const name of PRODUCER_PUNCTUATION)
+ writeFileSync(join(uiRoot, `assets/${name}`), name);
+// Outside `assets/`, so a traversal that escapes has something to find: a 404
+// on a path that leads nowhere proves nothing about refusal.
+writeFileSync(join(uiRoot, "secret.js"), "SECRET\n");
+// A symlink to itself: reading it is ELOOP, a broken tree rather than a missing
+// file, and nothing about it should read as "this asset was never emitted".
+symlinkSync("loop.js", join(uiRoot, "assets/loop.js"));
+
+afterAll(() => rmSync(uiRoot, { recursive: true, force: true }));
+
+const app = new Hono();
+app.get("/assets/*", assetHandler(pathToFileURL(`${uiRoot}/`)));
+
+describe("the production asset route", () => {
+ test("serves the build outputs with their types", async () => {
+ for (const [file, type] of [
+ ["index.js", "text/javascript; charset=utf-8"],
+ ["index.css", "text/css; charset=utf-8"],
+ ] as const) {
+ const response = await app.request(`/assets/${file}`);
+ expect({
+ file,
+ status: response.status,
+ type: response.headers.get("content-type"),
+ }).toEqual({ file, status: 200, type });
+ }
+ });
+
+ test("serves a nested asset path", async () => {
+ // Bundlers emit nested paths (assets/fonts/…); the old `:file` param
+ // could never match one, so nesting was a production-only 404.
+ const response = await app.request("/assets/fonts/nested.woff2");
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe("font/woff2");
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(BINARY_BYTES);
+ });
+
+ test("serves a binary asset byte-for-byte", async () => {
+ // Bytes 0..255 include invalid UTF-8 sequences on purpose: a decode-then-
+ // reencode anywhere on the path corrupts them, and this catches it.
+ const response = await app.request("/assets/brand.woff2");
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe("font/woff2");
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(BINARY_BYTES);
+ });
+
+ test("extension case does not decide servability", async () => {
+ // Bundlers preserve source-file case in emitted names, so `Logo.PNG` is a
+ // real production shape, not a hypothetical.
+ const response = await app.request("/assets/Logo.PNG");
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe("image/png");
+ });
+
+ test("an asset type nobody anticipated still serves, as octet-stream", async () => {
+ // Fail open on the content-type, never on the bytes: an unknown extension
+ // must not reintroduce the 404-in-production-only failure.
+ const response = await app.request("/assets/blob.dat");
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe(
+ "application/octet-stream",
+ );
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(BINARY_BYTES);
+ });
+
+ test("every name the producer can legally emit serves, punctuation and all", async () => {
+ // The old grammar accepted only `[\w.-]`, so each of these 404'd in
+ // production while vite dev served it — and `#` and `?` additionally break
+ // the URL the handler builds, silently addressing a different file.
+ for (const name of PRODUCER_PUNCTUATION) {
+ const response = await app.request(`/assets/${encodeURIComponent(name)}`);
+ expect({
+ name,
+ status: response.status,
+ body: await response.text(),
+ }).toEqual({
+ name,
+ status: 200,
+ body: name,
+ });
+ }
+ });
+
+ test("refuses traversal, hidden files, extensionless names, and nul bytes", async () => {
+ for (const path of [
+ "/assets/..%2Fsecret.js",
+ "/assets/%2e%2e%2fsecret.js",
+ "/assets/../secret.js",
+ "/assets/fonts/../../secret.js",
+ // A backslash is a path separator to the URL parser under the file:
+ // scheme, so this escapes `assets/` if the path reaches the URL raw.
+ "/assets/..%5Csecret.js",
+ "/assets/..%5C..%5Csecret.js",
+ "/assets/.env",
+ "/assets/fonts/.hidden.js",
+ "/assets/noextension",
+ // Refused before the filesystem sees it: a nul byte is a TypeError from
+ // readFile, not an errno, so translating it would mean catching broadly.
+ "/assets/a%00b.js",
+ ]) {
+ const response = await app.request(path);
+ expect({
+ path,
+ status: response.status,
+ leaked: (await response.text()).includes("SECRET"),
+ }).toEqual({ path, status: 404, leaked: false });
+ }
+ });
+
+ test("every way a path can be absent is a 404, not a crash", async () => {
+ for (const [path, reason] of [
+ ["/assets/never-emitted.js", "ENOENT — no such file"],
+ ["/assets/index.js/nested.js", "ENOTDIR — a file used as a directory"],
+ ["/assets/legacy.js", "EISDIR — a directory shaped like an asset"],
+ [
+ `/assets/${"x".repeat(400)}.js`,
+ "ENAMETOOLONG — no file can carry this name",
+ ],
+ ] as const) {
+ const response = await app.request(path);
+ expect({ reason, status: response.status }).toEqual({
+ reason,
+ status: 404,
+ });
+ }
+ });
+
+ test("an unexpected filesystem failure propagates instead of reading as absence", async () => {
+ // The broad catch this replaces turned every read failure into `notFound`,
+ // so a broken build tree served a clean 404 and looked like an asset the
+ // build had simply never emitted.
+ let propagated: unknown;
+ const guarded = new Hono();
+ guarded.onError((error, c) => {
+ propagated = error;
+ return c.text("propagated", 500);
+ });
+ guarded.get("/assets/*", assetHandler(pathToFileURL(`${uiRoot}/`)));
+
+ const response = await guarded.request("/assets/loop.js");
+ expect({
+ status: response.status,
+ code: (propagated as NodeJS.ErrnoException | undefined)?.code,
+ }).toEqual({ status: 500, code: "ELOOP" });
+ });
+});
diff --git a/apps/brunch-agent/test/build-artifact.test.ts b/apps/brunch-agent/test/build-artifact.test.ts
new file mode 100644
index 00000000000..ad9f51dd136
--- /dev/null
+++ b/apps/brunch-agent/test/build-artifact.test.ts
@@ -0,0 +1,135 @@
+/**
+ * What the build actually emitted — checked against the artifact, not the source.
+ *
+ * `test/boundaries.test.ts` catches a misplaced `'use agent'` directive by
+ * reading the source. This checks the same property from the other end: that
+ * the agent really is registered in the emitted bundle. The distinction earns
+ * its keep because the failure mode here is silent — `@flue/vite` drops a
+ * module that stops looking like an agent module and the build stays green, so
+ * "it compiled" says nothing about whether the app has any agents in it.
+ *
+ * Any future change that quietly stops an agent, its route, or the conversation
+ * store from reaching the bundle fails here, whatever the cause: a directive
+ * moved, a config path changed, an entry dropped from the scan glob.
+ */
+
+import { existsSync, readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { beforeAll, describe, expect, test } from "vitest";
+
+const DEV_APP = fileURLToPath(new URL("..", import.meta.url)).replace(
+ /[/\\]$/u,
+ "",
+);
+const DIST = join(DEV_APP, "dist");
+const CLIENT = join(DIST, "client");
+
+/** Everything the server build emitted, concatenated. */
+let bundle = "";
+
+beforeAll(() => {
+ // `test:unit` depends on the app's build in `turbo.json`; the test inspects
+ // that graph-owned artifact rather than hiding a nested build invocation.
+ bundle = readdirSync(DIST)
+ .filter((entry) => entry.endsWith(".mjs"))
+ .map((entry) => readFileSync(join(DIST, entry), "utf8"))
+ .join("\n");
+});
+
+/** The pinned identity of every agent module in the app, read from source. */
+function declaredAgentIdentities(): string[] {
+ const agentSource = readFileSync(
+ join(DEV_APP, "src/agents/gherkin-elicitor.ts"),
+ "utf8",
+ );
+ return [
+ ...agentSource.matchAll(/\w+\.agentName\s*=\s*(["'])([^"']+)\1/gu),
+ ].map((match) => match[2]!);
+}
+
+describe("the emitted server bundle", () => {
+ test("exists", () => {
+ expect(existsSync(DIST)).toBe(true);
+ expect(bundle.length).toBeGreaterThan(0);
+ });
+
+ test("registers every declared agent under its pinned identity", () => {
+ // The check that matters. A `'use agent'` directive that is not the first
+ // statement builds green and simply never registers — the app boots with no
+ // agents and nothing says so until a conversation fails to start.
+ //
+ // Asserted against the emitted `__flueBindAgentModule(Fn, { identity })`
+ // call rather than the bare string, because the string survives that
+ // failure: the `agentName` assignment is still in the bundle as ordinary
+ // dead code once the module stops being scanned as an agent.
+ const bound = new Set(
+ [
+ ...bundle.matchAll(
+ /__flueBindAgentModule\([^)]*identity:\s*["']([^"']+)["']/g,
+ ),
+ ].map((match) => match[1]!),
+ );
+ const identities = declaredAgentIdentities();
+ expect(identities.length).toBeGreaterThan(0);
+ for (const identity of identities) {
+ expect({ identity, bound: bound.has(identity) }).toEqual({
+ identity,
+ bound: true,
+ });
+ }
+ });
+
+ test("mounts the agent router and wires the conversation store", () => {
+ // Without db.ts reaching the bundle, conversations are process-memory and a
+ // restart loses them — a difference invisible until something restarts.
+ //
+ // Witnessed by strings that exist only in the app's own modules. The
+ // obvious witnesses are vacuous: `createAgentRouter` survives in a
+ // bootstrap JSDoc comment and `sqlite` in the bootstrap's unconditional
+ // default-adapter fallback, so both match even when the mount or db.ts
+ // never reach the bundle. (Bare `/agents/` is no better — a bundler
+ // region comment for `src/agents/` carries it.)
+ expect(bundle).toContain("route(`/agents/"); // app.ts's mount call
+ expect(bundle).toContain("BRUNCH_DEV_DB_PATH"); // db.ts's env override
+ expect(bundle).toContain(".data-wipe-me"); // db.ts's default store path
+ });
+
+ test("carries no model key", () => {
+ const modelKey = new RegExp(
+ `${"ANTHROPIC"}_${"API"}_${"KEY"}\\s*[:=]\\s*['"][^'"]+['"]`,
+ "u",
+ );
+ expect(bundle).not.toMatch(modelKey);
+ });
+});
+
+describe("the emitted client bundle", () => {
+ // `@flue/vite` emits the server environment only, so the ui tree is built by
+ // a second plain vite config. Without these, a client-side break would be
+ // invisible to CI — the Flue build would go green having never transformed a
+ // line of it.
+ test("emits html and a bundled entry", () => {
+ expect(existsSync(join(CLIENT, "index.html"))).toBe(true);
+ expect(existsSync(join(CLIENT, "assets/index.js"))).toBe(true);
+ });
+
+ test("the emitted html points at the built asset, not at source", () => {
+ // The failure this catches: shipping the source index.html, whose script
+ // tag names a .tsx module nothing serves in production.
+ const html = readFileSync(join(CLIENT, "index.html"), "utf8");
+ expect(html).toContain("/assets/index.js");
+ expect(html).not.toContain(".tsx");
+ });
+
+ test("the entry really bundled its dependencies", () => {
+ // A near-empty chunk would mean the entry resolved to nothing.
+ const entry = readFileSync(join(CLIENT, "assets/index.js"), "utf8");
+ expect(entry.length).toBeGreaterThan(10_000);
+ });
+});
+
+// The production asset route is tested in `apps/brunch-agent/test/assets.test.ts`,
+// against the handler module directly: the emitted server bundle targets
+// Node (`node:sqlite`), so the handler test isolates the asset policy.
diff --git a/apps/brunch-agent/test/db-path.test.ts b/apps/brunch-agent/test/db-path.test.ts
new file mode 100644
index 00000000000..a9452c15c17
--- /dev/null
+++ b/apps/brunch-agent/test/db-path.test.ts
@@ -0,0 +1,82 @@
+/**
+ * The conversation store's default path must not depend on the launch
+ * directory. The failure this pins down: a cwd-relative default meant
+ * launching the app from anywhere but the package directory silently created
+ * a fresh empty database — the restart-durability failure the store exists
+ * to prevent, invisible until something restarted.
+ *
+ * Tested against `db-path.ts` rather than `db.ts` so path policy remains
+ * isolated from the Flue Node runtime and SQLite (the same seam as
+ * `assets.test.ts`).
+ */
+
+import { tmpdir } from "node:os";
+import { dirname, isAbsolute, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { afterEach, describe, expect, test } from "vitest";
+
+import { conversationDbPath } from "../src/db-path";
+import { targetDocumentPath } from "../src/target-document-path";
+
+const appDir = fileURLToPath(new URL("..", import.meta.url));
+
+describe("the conversation store path", () => {
+ const originalCwd = process.cwd();
+ const originalOverride = process.env.BRUNCH_DEV_DB_PATH;
+
+ afterEach(() => {
+ process.chdir(originalCwd);
+ if (originalOverride === undefined) delete process.env.BRUNCH_DEV_DB_PATH;
+ else process.env.BRUNCH_DEV_DB_PATH = originalOverride;
+ });
+
+ test("is anchored to the package, wherever the process was launched from", () => {
+ delete process.env.BRUNCH_DEV_DB_PATH;
+ const fromRepo = conversationDbPath();
+ process.chdir(tmpdir());
+ const fromElsewhere = conversationDbPath();
+
+ expect(isAbsolute(fromRepo)).toBe(true);
+ expect(fromElsewhere).toBe(fromRepo);
+ expect(fromRepo).toBe(join(appDir, ".data-wipe-me", "conversations.db"));
+ });
+
+ test("the env override wins untouched", () => {
+ process.env.BRUNCH_DEV_DB_PATH = "./relative/on-purpose.db";
+ expect(conversationDbPath()).toBe("./relative/on-purpose.db");
+ });
+
+ test("a set-but-empty override falls back to the anchored default", () => {
+ // sqlite('') would open an anonymous temporary database deleted on close
+ // — non-durable with no error, which is this module's one job to prevent.
+ process.env.BRUNCH_DEV_DB_PATH = "";
+ expect(conversationDbPath()).toBe(
+ join(appDir, ".data-wipe-me", "conversations.db"),
+ );
+ });
+});
+
+describe("the target-document store path", () => {
+ const originalOverride = process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR;
+
+ afterEach(() => {
+ if (originalOverride === undefined)
+ delete process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR;
+ else process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR = originalOverride;
+ });
+
+ test("uses a stable opaque filename below the host-selected directory", () => {
+ process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR =
+ "/tmp/brunch-target-documents-test";
+ const first = targetDocumentPath("../shared-target");
+
+ expect(first).toBe(targetDocumentPath("../shared-target"));
+ expect(dirname(first)).toBe("/tmp/brunch-target-documents-test");
+ expect(first).not.toContain("shared-target");
+ });
+
+ test("refuses an empty target-document identity", () => {
+ expect(() => targetDocumentPath("")).toThrow("cannot be empty");
+ });
+});
diff --git a/apps/brunch-agent/test/local-dev-origins.test.ts b/apps/brunch-agent/test/local-dev-origins.test.ts
new file mode 100644
index 00000000000..26eec099d35
--- /dev/null
+++ b/apps/brunch-agent/test/local-dev-origins.test.ts
@@ -0,0 +1,49 @@
+import { readFileSync } from "node:fs";
+
+import { expect, test } from "vitest";
+
+import {
+ defaultChatOrigin,
+ defaultPanelOrigins,
+ localChatListen,
+ localPanelListen,
+ petrinautLocalServer,
+} from "../src/local-dev-origins.ts";
+
+const readAppFile = (relativePath: string): string =>
+ readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8");
+
+test("dev listens on the chat origin the panel proxy already assumes", () => {
+ expect(defaultChatOrigin).toBe("http://127.0.0.1:4321");
+ expect(localChatListen).toEqual({
+ host: "127.0.0.1",
+ port: 4321,
+ strictPort: true,
+ });
+ expect(readAppFile("vite.config.ts")).toContain("localChatListen");
+});
+
+test("petrinaut:dev listens on the panel origin chat CORS already assumes", () => {
+ expect(defaultPanelOrigins).toEqual([
+ "http://127.0.0.1:4915",
+ "http://localhost:4915",
+ ]);
+ expect(localPanelListen).toEqual({
+ host: "127.0.0.1",
+ port: 4915,
+ strictPort: true,
+ });
+ expect(petrinautLocalServer(defaultChatOrigin)).toEqual({
+ ...localPanelListen,
+ proxy: {
+ "/api/chat": {
+ target: defaultChatOrigin,
+ changeOrigin: true,
+ },
+ },
+ });
+ expect(readAppFile("petrinaut-local.vite.config.ts")).toContain(
+ "petrinautLocalServer",
+ );
+ expect(readAppFile("src/petrinaut-chat.ts")).toContain("defaultPanelOrigins");
+});
diff --git a/apps/brunch-agent/test/petrinaut-ask.integration.ts b/apps/brunch-agent/test/petrinaut-ask.integration.ts
new file mode 100644
index 00000000000..558e280b624
--- /dev/null
+++ b/apps/brunch-agent/test/petrinaut-ask.integration.ts
@@ -0,0 +1,152 @@
+/**
+ * FE-1449 end-to-end proof over the committed application route: the actual
+ * elicitor invokes `brunch_ask`, the wire holds the ask open as an awaiting
+ * client tool, the correlated return POST resumes the same Flue conversation
+ * and produces the next visible turn — and a duplicate of that submission is
+ * refused before any dispatch.
+ */
+
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import {
+ fauxAssistantMessage,
+ fauxProvider,
+ fauxText,
+ fauxThinking,
+ fauxToolCall,
+} from "@earendil-works/pi-ai";
+import { start } from "@flue/runtime/node";
+
+import {
+ GHERKIN_MODEL_ID,
+ GherkinElicitor,
+} from "../src/agents/gherkin-elicitor.ts";
+
+const targetDirectory = await mkdtemp(join(tmpdir(), "brunch-petrinaut-ask-"));
+process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR = targetDirectory;
+process.env.BRUNCH_TRANSPORT_AISDK_INSPECT = "1";
+
+const faux = fauxProvider({
+ provider: "anthropic",
+ models: [{ id: GHERKIN_MODEL_ID, reasoning: true }],
+});
+faux.setResponses([
+ fauxAssistantMessage(
+ [
+ fauxThinking("One question at a time; suspend for the answer."),
+ fauxToolCall(
+ "brunch_ask",
+ { question: "What outcome should this process reliably produce?" },
+ { id: "toolu_fe1449_ask" },
+ ),
+ ],
+ { stopReason: "toolUse" },
+ ),
+ fauxAssistantMessage([
+ fauxThinking("The reply is mechanically bound to the pending affordance."),
+ fauxText("Payment settled — who initiates the checkout?"),
+ ]),
+ // The settlement check nudges one more turn after the answered ask.
+ fauxAssistantMessage([
+ fauxText("The settled prefix is captured; nothing further to sweep."),
+ ]),
+]);
+
+const flue = await start({
+ agents: [GherkinElicitor],
+ providers: [faux.provider],
+});
+
+type StreamChunk = Record & { readonly type: string };
+
+const chunksFrom = (body: string): StreamChunk[] =>
+ body
+ .trim()
+ .split("\n\n")
+ .slice(0, -1)
+ .map((frame) => JSON.parse(frame.slice("data: ".length)) as StreamChunk);
+
+try {
+ const { default: app } = await import("../src/app.ts");
+ const postChat = async (
+ requestId: string,
+ body: unknown,
+ ): Promise =>
+ app.fetch(
+ new Request("http://brunch.test/api/chat", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-request-id": requestId,
+ },
+ body: JSON.stringify(body),
+ }),
+ );
+
+ const conversationId = "conversation-fe1449-ask";
+ const initial = await postChat("request-fe1449-initial", {
+ id: conversationId,
+ trigger: "submit-message",
+ messages: [
+ {
+ id: "user-fe1449-1",
+ role: "user",
+ parts: [{ type: "text", text: "Help me model checkout." }],
+ },
+ ],
+ });
+ const initialChunks = chunksFrom(await initial.text());
+ const askCall = initialChunks.find(
+ (chunk) => chunk.type === "tool-input-available",
+ );
+
+ const returnBody = {
+ id: conversationId,
+ trigger: "submit-message",
+ messageId: "assistant-fe1449-panel",
+ messages: [
+ {
+ id: "assistant-fe1449-panel",
+ role: "assistant",
+ parts: [
+ {
+ type: "dynamic-tool",
+ toolName: "brunch_ask",
+ toolCallId: askCall?.toolCallId,
+ state: "output-available",
+ input: askCall?.input,
+ output: { answer: "A confirmed order with payment settled." },
+ },
+ ],
+ },
+ ],
+ };
+ const resumed = await postChat("request-fe1449-return", returnBody);
+ const resumedChunks = chunksFrom(await resumed.text());
+
+ const duplicate = await postChat("request-fe1449-duplicate", returnBody);
+
+ console.log(
+ `PETRINAUT_ASK_RESULT ${JSON.stringify({
+ initialStatus: initial.status,
+ askCall,
+ initialToolOutputs: initialChunks.filter(
+ (chunk) => chunk.type === "tool-output-available",
+ ),
+ initialFinish: initialChunks.at(-1),
+ resumedStatus: resumed.status,
+ resumedText: resumedChunks
+ .filter((chunk) => chunk.type === "text-delta")
+ .map((chunk) => chunk.delta)
+ .join(""),
+ resumedFinish: resumedChunks.at(-1),
+ duplicateStatus: duplicate.status,
+ duplicateBody: await duplicate.json(),
+ })}`,
+ );
+} finally {
+ await flue.stop();
+ await rm(targetDirectory, { recursive: true, force: true });
+}
diff --git a/apps/brunch-agent/test/petrinaut-ask.test.ts b/apps/brunch-agent/test/petrinaut-ask.test.ts
new file mode 100644
index 00000000000..3d987b46e79
--- /dev/null
+++ b/apps/brunch-agent/test/petrinaut-ask.test.ts
@@ -0,0 +1,89 @@
+import { join } from "node:path";
+
+import { expect, test } from "vitest";
+
+import { runNodeScript } from "./run-node-script";
+
+type StreamChunk = Record & { readonly type: string };
+const testDirectory = import.meta.dirname;
+
+test("a structured ask suspends over the wire and its correlated submission resumes the conversation", async () => {
+ const { exitCode, stdout, stderr } = await runNodeScript(
+ join(testDirectory, "petrinaut-ask.integration.ts"),
+ join(testDirectory, "../../.."),
+ );
+
+ expect(exitCode, stderr || stdout).toBe(0);
+ const resultLine = stdout
+ .split("\n")
+ .find((line) => line.startsWith("PETRINAUT_ASK_RESULT "));
+ expect(resultLine, stdout).toBeDefined();
+ const result = JSON.parse(
+ resultLine!.slice("PETRINAUT_ASK_RESULT ".length),
+ ) as {
+ initialStatus: number;
+ askCall: StreamChunk | undefined;
+ initialToolOutputs: StreamChunk[];
+ initialFinish: StreamChunk;
+ resumedStatus: number;
+ resumedText: string;
+ resumedFinish: StreamChunk;
+ duplicateStatus: number;
+ duplicateBody: unknown;
+ };
+
+ // Suspension: the ask leaves the server as an awaiting client tool with a
+ // stable call id; the harness's minted affordance never reaches the wire.
+ expect(result.initialStatus).toBe(200);
+ expect(result.askCall).toMatchObject({
+ type: "tool-input-available",
+ toolName: "brunch_ask",
+ input: { question: "What outcome should this process reliably produce?" },
+ });
+ expect(typeof result.askCall?.toolCallId).toBe("string");
+ expect(result.initialToolOutputs).toEqual([]);
+ expect(result.initialFinish).toMatchObject({ type: "finish" });
+
+ // Resumption: the correlated submission becomes the user-affordance reply
+ // and the same conversation produces the next visible turn.
+ expect(result.resumedStatus).toBe(200);
+ // The response carries the answer turn and then the settlement-check turn,
+ // exactly as the FE-1436 application golden streams its second step.
+ expect(
+ result.resumedText.startsWith(
+ "Payment settled — who initiates the checkout?",
+ ),
+ ).toBe(true);
+ expect(result.resumedFinish).toEqual({
+ type: "finish",
+ finishReason: "stop",
+ });
+
+ // Provenance: replaying the same submission finds no pending ask and is
+ // refused at the wire boundary, before any dispatch.
+ expect(result.duplicateStatus).toBe(409);
+ expect(result.duplicateBody).toEqual({ error: "ask_not_pending" });
+
+ const inspections = stdout
+ .split("\n")
+ .filter((line) => line.startsWith("TRANSPORT_AISDK "))
+ .map(
+ (line) =>
+ JSON.parse(line.slice("TRANSPORT_AISDK ".length)) as StreamChunk,
+ );
+ expect(inspections.some((event) => event.type === "ask-await")).toBe(true);
+ expect(inspections.some((event) => event.type === "ask-reply-admitted")).toBe(
+ true,
+ );
+ expect(
+ inspections.filter((event) => event.type === "ask-reply-refused"),
+ ).toEqual([
+ {
+ type: "ask-reply-refused",
+ requestId: "request-fe1449-duplicate",
+ conversationId: "conversation-fe1449-ask",
+ toolCallId: result.askCall?.toolCallId,
+ reason: "no-pending-ask",
+ },
+ ]);
+});
diff --git a/apps/brunch-agent/test/petrinaut-chat.integration.ts b/apps/brunch-agent/test/petrinaut-chat.integration.ts
new file mode 100644
index 00000000000..cf04e8a757b
--- /dev/null
+++ b/apps/brunch-agent/test/petrinaut-chat.integration.ts
@@ -0,0 +1,102 @@
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ fauxAssistantMessage,
+ fauxProvider,
+ fauxText,
+ fauxThinking,
+} from "@earendil-works/pi-ai";
+import { start } from "@flue/runtime/node";
+
+import {
+ GHERKIN_MODEL_ID,
+ GherkinElicitor,
+} from "../src/agents/gherkin-elicitor.ts";
+
+const targetDirectory = await mkdtemp(join(tmpdir(), "brunch-petrinaut-chat-"));
+process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR = targetDirectory;
+process.env.BRUNCH_TRANSPORT_AISDK_INSPECT = "1";
+
+const faux = fauxProvider({
+ provider: "anthropic",
+ models: [{ id: GHERKIN_MODEL_ID, reasoning: true }],
+});
+faux.setResponses([
+ fauxAssistantMessage([
+ fauxThinking(
+ "I should establish the process outcome before proposing structure.",
+ ),
+ fauxText("What outcome should this process reliably produce?"),
+ ]),
+ fauxAssistantMessage([
+ fauxThinking("The settlement check does not add user evidence."),
+ fauxText(
+ "We can start with the outcome and then work backward through the process.",
+ ),
+ ]),
+]);
+
+const flue = await start({
+ agents: [GherkinElicitor],
+ providers: [faux.provider],
+});
+
+try {
+ const { default: app } = await import("../src/app.ts");
+ const fixturePath = fileURLToPath(
+ new URL(
+ "../../../libs/@hashintel/brunch-agent/packages/transport-aisdk/test/fixtures/panel-initial.post.json",
+ import.meta.url,
+ ),
+ );
+ const response = await app.fetch(
+ new Request("http://brunch.test/api/chat", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-request-id": "request-fe1436-application",
+ },
+ body: await readFile(fixturePath, "utf8"),
+ }),
+ );
+ const body = await response.text();
+ const chunks = body
+ .trim()
+ .split("\n\n")
+ .slice(0, -1)
+ .map(
+ (frame) =>
+ JSON.parse(frame.slice("data: ".length)) as Record,
+ );
+ const startChunk = chunks.find((chunk) => chunk.type === "start");
+ const partIds = chunks
+ .filter(
+ (chunk) =>
+ chunk.type === "reasoning-start" || chunk.type === "text-start",
+ )
+ .map((chunk) => chunk.id);
+
+ console.log(
+ `PETRINAUT_CHAT_RESULT ${JSON.stringify({
+ status: response.status,
+ messageId: startChunk?.messageId,
+ partIds,
+ reasoning: chunks
+ .filter((chunk) => chunk.type === "reasoning-delta")
+ .map((chunk) => chunk.delta)
+ .join(""),
+ text: chunks
+ .filter((chunk) => chunk.type === "text-delta")
+ .map((chunk) => chunk.delta)
+ .join(""),
+ finish: chunks.at(-1),
+ chunks,
+ })}`,
+ );
+} finally {
+ await flue.stop();
+ await rm(targetDirectory, { recursive: true, force: true });
+}
diff --git a/apps/brunch-agent/test/petrinaut-chat.test.ts b/apps/brunch-agent/test/petrinaut-chat.test.ts
new file mode 100644
index 00000000000..5d6be446944
--- /dev/null
+++ b/apps/brunch-agent/test/petrinaut-chat.test.ts
@@ -0,0 +1,104 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { expect, test } from "vitest";
+
+import { runNodeScript } from "./run-node-script";
+
+type StreamChunk = Record & { readonly type: string };
+const testDirectory = import.meta.dirname;
+
+const normalizedChunk = (
+ chunk: StreamChunk,
+ messageId: string,
+): StreamChunk => {
+ const normalized = { ...chunk };
+ if (normalized.messageId === messageId) normalized.messageId = "$message";
+ if (typeof normalized.id === "string")
+ normalized.id = normalized.id.replace(messageId, "$message");
+ return normalized;
+};
+
+const normalizedChunks = (
+ chunks: readonly StreamChunk[],
+ messageId: string,
+): StreamChunk[] =>
+ chunks.reduce((normalized, chunk) => {
+ const current = normalizedChunk(chunk, messageId);
+ const previous = normalized.at(-1);
+ if (
+ current.type.endsWith("-delta") &&
+ previous?.type === current.type &&
+ previous.id === current.id &&
+ typeof previous.delta === "string" &&
+ typeof current.delta === "string"
+ ) {
+ previous.delta += current.delta;
+ return normalized;
+ }
+ normalized.push(current);
+ return normalized;
+ }, []);
+
+test("the committed application route drives the actual elicitor for reasoning and text", async () => {
+ const { exitCode, stdout, stderr } = await runNodeScript(
+ join(testDirectory, "petrinaut-chat.integration.ts"),
+ join(testDirectory, "../../.."),
+ );
+
+ expect(exitCode, stderr || stdout).toBe(0);
+ const inspectionLines = stdout
+ .split("\n")
+ .filter((line) => line.startsWith("TRANSPORT_AISDK "))
+ .map(
+ (line) =>
+ JSON.parse(line.slice("TRANSPORT_AISDK ".length)) as Record<
+ string,
+ unknown
+ >,
+ );
+ const resultLine = stdout
+ .split("\n")
+ .find((line) => line.startsWith("PETRINAUT_CHAT_RESULT "));
+ expect(resultLine, stdout).toBeDefined();
+ const result = JSON.parse(
+ resultLine!.slice("PETRINAUT_CHAT_RESULT ".length),
+ ) as {
+ status: number;
+ messageId: string;
+ partIds: string[];
+ reasoning: string;
+ text: string;
+ finish: unknown;
+ chunks: StreamChunk[];
+ };
+
+ expect(result.status).toBe(200);
+ expect(result.messageId.length).toBeGreaterThan(0);
+ expect(
+ result.partIds.every((partId) => partId.startsWith(`${result.messageId}:`)),
+ ).toBe(true);
+ expect(result.reasoning).toContain("establish the process outcome");
+ expect(result.text).toContain(
+ "What outcome should this process reliably produce?",
+ );
+ expect(result.finish).toEqual({ type: "finish", finishReason: "stop" });
+ const golden = JSON.parse(
+ readFileSync(
+ join(
+ testDirectory,
+ "../../../libs/@hashintel/brunch-agent/packages/transport-aisdk/test/fixtures/elicitor-initial.normalized.json",
+ ),
+ "utf8",
+ ),
+ ) as StreamChunk[];
+ expect(normalizedChunks(result.chunks, result.messageId)).toEqual(golden);
+ expect(inspectionLines[0]).toMatchObject({
+ type: "request-start",
+ requestId: "request-fe1436-application",
+ });
+ expect(inspectionLines.at(-1)).toMatchObject({
+ type: "request-finish",
+ terminalState: "completed",
+ });
+});
diff --git a/apps/brunch-agent/test/run-node-script.ts b/apps/brunch-agent/test/run-node-script.ts
new file mode 100644
index 00000000000..4ef2e623d99
--- /dev/null
+++ b/apps/brunch-agent/test/run-node-script.ts
@@ -0,0 +1,35 @@
+import { spawn } from "node:child_process";
+
+interface NodeScriptResult {
+ readonly exitCode: number | null;
+ readonly stderr: string;
+ readonly stdout: string;
+}
+
+export const runNodeScript = async (
+ scriptPath: string,
+ cwd: string,
+): Promise =>
+ new Promise((resolve, reject) => {
+ const child = spawn(
+ process.execPath,
+ ["--experimental-strip-types", scriptPath],
+ {
+ cwd,
+ stdio: ["ignore", "pipe", "pipe"],
+ },
+ );
+
+ let stderr = "";
+ let stdout = "";
+ child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
+ stderr += chunk;
+ });
+ child.stdout.setEncoding("utf8").on("data", (chunk: string) => {
+ stdout += chunk;
+ });
+ child.once("error", reject);
+ child.once("close", (exitCode) => {
+ resolve({ exitCode, stderr, stdout });
+ });
+ });
diff --git a/apps/brunch-agent/test/transport-aisdk-server.test.ts b/apps/brunch-agent/test/transport-aisdk-server.test.ts
new file mode 100644
index 00000000000..1c1523b7d3e
--- /dev/null
+++ b/apps/brunch-agent/test/transport-aisdk-server.test.ts
@@ -0,0 +1,433 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, test } from "vitest";
+
+import { createFlueReplyProjector } from "@hashintel/brunch-agent-binding-flue";
+import {
+ createAiSdkChatHandler,
+ type HarnessReplyEvent,
+ type TransportInspectionEvent,
+} from "@hashintel/brunch-agent-transport-aisdk";
+
+type GoldenChunk = Record & { readonly type: string };
+
+const FIXTURES = join(
+ import.meta.dirname,
+ "../../../libs/@hashintel/brunch-agent/packages/transport-aisdk/test/fixtures",
+);
+
+const fixture = (name: string): string =>
+ readFileSync(join(FIXTURES, name), "utf8");
+
+const responseChunks = async (
+ response: Response,
+): Promise =>
+ (await response.text())
+ .trim()
+ .split("\n\n")
+ .slice(0, -1)
+ .map((frame) => JSON.parse(frame.slice("data: ".length)) as GoldenChunk);
+
+const panelInitialHarnessEvents: readonly HarnessReplyEvent[] = [
+ { type: "response-start", messageId: "assistant-fe1435-1" },
+ { type: "turn-start", turnId: "turn-fe1435-1" },
+ { type: "part-start", kind: "reasoning", partId: "reasoning-fe1435-1" },
+ {
+ type: "part-delta",
+ kind: "reasoning",
+ partId: "reasoning-fe1435-1",
+ delta:
+ "**Checking the wire seam**\n\nThe harness stream is translating reasoning parts.",
+ },
+ {
+ type: "part-delta",
+ kind: "reasoning",
+ partId: "reasoning-fe1435-1",
+ delta: " It will now drive both server and browser tools.",
+ },
+ { type: "part-end", kind: "reasoning", partId: "reasoning-fe1435-1" },
+ { type: "part-start", kind: "text", partId: "text-fe1435-1" },
+ {
+ type: "part-delta",
+ kind: "text",
+ partId: "text-fe1435-1",
+ delta: "Harness-streamed text reached Petrinaut before tool execution.",
+ },
+ { type: "part-end", kind: "text", partId: "text-fe1435-1" },
+ {
+ type: "tool-input",
+ toolCallId: "tool-server-fe1435",
+ toolName: "serverProbe",
+ input: { scope: "real-panel" },
+ execution: "server",
+ },
+ {
+ type: "tool-output",
+ toolCallId: "tool-server-fe1435",
+ output: { ok: true, source: "fake-harness-loop" },
+ execution: "server",
+ },
+ {
+ type: "tool-input",
+ toolCallId: "tool-place-fe1435",
+ toolName: "addPlace",
+ input: {
+ id: "place__fe1435_buffer",
+ name: "SpikeBuffer",
+ colorId: null,
+ dynamicsEnabled: false,
+ differentialEquationId: null,
+ showAsInitialState: true,
+ x: 180,
+ y: 140,
+ },
+ execution: "client",
+ },
+ {
+ type: "tool-input",
+ toolCallId: "tool-transition-fe1435",
+ toolName: "addTransition",
+ input: {
+ id: "transition__fe1435_dispatch",
+ name: "Spike dispatch",
+ inputArcs: [],
+ outputArcs: [],
+ lambdaType: "predicate",
+ lambdaCode: "export const Lambda = () => true;",
+ transitionKernelCode: "export const TransitionKernel = () => ({});",
+ x: 420,
+ y: 140,
+ },
+ execution: "client",
+ },
+ { type: "turn-finish", turnId: "turn-fe1435-1" },
+ {
+ type: "response-finish",
+ finishReason: "tool-calls",
+ terminalState: "completed",
+ },
+];
+
+describe("FE-1436 Petrinaut wire server", () => {
+ test("refuses malformed JSON values at the transport boundary", async () => {
+ let dispatched = false;
+ const handler = createAiSdkChatHandler({
+ async runTurn() {
+ dispatched = true;
+ },
+ });
+
+ for (const body of [
+ null,
+ [],
+ "not-an-object",
+ 1436,
+ { messages: [null] },
+ ]) {
+ const response = await handler(
+ new Request("http://brunch.test/api/petrinaut/chat", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ );
+
+ expect({
+ body,
+ status: response.status,
+ refusal: await response.json(),
+ }).toEqual({
+ body,
+ status: 400,
+ refusal: { error: "invalid_chat_request" },
+ });
+ }
+ expect(dispatched).toBe(false);
+ });
+
+ test("emits one truthful terminal sequence for failed and aborted settlements", async () => {
+ for (const terminalState of ["failed", "aborted"] as const) {
+ const inspections: TransportInspectionEvent[] = [];
+ const handler = createAiSdkChatHandler({
+ inspect: (event) => inspections.push(event),
+ async runTurn(_input, emit) {
+ const submissionId = `submission-${terminalState}`;
+ const projector = createFlueReplyProjector({ submissionId, emit });
+ projector.accept({
+ type: "message-started",
+ conversationId: `conversation-${terminalState}`,
+ submissionId,
+ messageId: `message-${terminalState}`,
+ turnId: `turn-${terminalState}`,
+ position: { batch: 1, index: 0 },
+ });
+ projector.accept({
+ type: "submission-settled",
+ conversationId: `conversation-${terminalState}`,
+ submissionId,
+ outcome: terminalState,
+ position: { batch: 1, index: 1 },
+ });
+ // Flue's read API rejects after delivering a failed/aborted settlement.
+ throw new Error(`${terminalState} settlement`);
+ },
+ });
+
+ const response = await handler(
+ new Request("http://brunch.test/api/petrinaut/chat", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ id: `conversation-${terminalState}`,
+ trigger: "submit-message",
+ messages: [
+ {
+ id: `user-${terminalState}`,
+ role: "user",
+ parts: [{ type: "text", text: "Go" }],
+ },
+ ],
+ }),
+ }),
+ );
+ const chunks = await responseChunks(response);
+ const terminalChunkTypes = chunks
+ .map((chunk) => chunk.type)
+ .filter(
+ (type) => type === "finish" || type === "abort" || type === "error",
+ );
+
+ expect({ terminalState, terminalChunkTypes }).toEqual({
+ terminalState,
+ terminalChunkTypes: [terminalState === "failed" ? "error" : "abort"],
+ });
+ expect(
+ inspections.filter((event) => event.type === "request-finish"),
+ ).toEqual([
+ {
+ type: "request-finish",
+ requestId: expect.any(String),
+ terminalState,
+ finishReason: "error",
+ },
+ ]);
+ expect(inspections.find((event) => event.type === "turn-finish")).toEqual(
+ {
+ type: "turn-finish",
+ requestId: expect.any(String),
+ turnId: `turn-${terminalState}`,
+ },
+ );
+ }
+ });
+
+ test("streams a failed server tool outcome before settling the failed turn", async () => {
+ const handler = createAiSdkChatHandler({
+ async runTurn(_input, emit) {
+ emit({ type: "response-start", messageId: "message-tool-failed" });
+ emit({ type: "turn-start", turnId: "turn-tool-failed" });
+ emit({
+ type: "tool-input",
+ toolCallId: "tool-failed",
+ toolName: "bl_sweep",
+ input: {},
+ execution: "server",
+ });
+ emit({
+ type: "tool-output-error",
+ toolCallId: "tool-failed",
+ errorText: "Sweep persistence failed.",
+ execution: "server",
+ });
+ emit({ type: "turn-finish", turnId: "turn-tool-failed" });
+ emit({
+ type: "response-finish",
+ terminalState: "failed",
+ finishReason: "error",
+ });
+ },
+ });
+
+ const response = await handler(
+ new Request("http://brunch.test/api/petrinaut/chat", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ id: "conversation-tool-failed",
+ trigger: "submit-message",
+ messages: [
+ {
+ id: "user-tool-failed",
+ role: "user",
+ parts: [{ type: "text", text: "Go" }],
+ },
+ ],
+ }),
+ }),
+ );
+
+ expect(await responseChunks(response)).toEqual([
+ { type: "start", messageId: "message-tool-failed" },
+ { type: "start-step" },
+ {
+ type: "tool-input-available",
+ toolCallId: "tool-failed",
+ toolName: "bl_sweep",
+ input: {},
+ providerExecuted: true,
+ },
+ {
+ type: "tool-output-error",
+ toolCallId: "tool-failed",
+ errorText: "Sweep persistence failed.",
+ providerExecuted: true,
+ },
+ { type: "finish-step" },
+ { type: "error", errorText: "The elicitor turn failed." },
+ ]);
+ });
+
+ test("answers an allowlisted panel preflight without opening every origin", async () => {
+ const handler = createAiSdkChatHandler({
+ allowedOrigins: ["http://127.0.0.1:4915"],
+ async runTurn() {},
+ });
+
+ const allowed = await handler(
+ new Request("http://brunch.test/api/petrinaut/chat", {
+ method: "OPTIONS",
+ headers: { origin: "http://127.0.0.1:4915" },
+ }),
+ );
+ expect(allowed.status).toBe(204);
+ expect(allowed.headers.get("access-control-allow-origin")).toBe(
+ "http://127.0.0.1:4915",
+ );
+ expect(allowed.headers.get("access-control-allow-methods")).toBe(
+ "POST, OPTIONS",
+ );
+
+ const refused = await handler(
+ new Request("http://brunch.test/api/petrinaut/chat", {
+ method: "OPTIONS",
+ headers: { origin: "https://untrusted.example" },
+ }),
+ );
+ expect(refused.status).toBe(403);
+ expect(refused.headers.get("access-control-allow-origin")).toBeNull();
+ });
+
+ test("encodes fixed harness events as the real-panel golden SSE", async () => {
+ const inspections: TransportInspectionEvent[] = [];
+ const handler = createAiSdkChatHandler({
+ inspect: (event) => inspections.push(event),
+ async runTurn(input, emit) {
+ expect(input).toEqual({
+ conversationId: "m5z0GU9KJPzhOTlx",
+ idempotencyKey: "m5z0GU9KJPzhOTlx:6ddgGkjhSxGjOtiv",
+ userMessage: {
+ id: "6ddgGkjhSxGjOtiv",
+ text: "Run the FE-1435 transport probe.",
+ },
+ });
+ for (const event of panelInitialHarnessEvents) emit(event);
+ },
+ });
+
+ const response = await handler(
+ new Request("http://brunch.test/api/petrinaut/chat", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-request-id": "request-fe1436-contract",
+ },
+ body: fixture("panel-initial.post.json"),
+ }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("x-vercel-ai-ui-message-stream")).toBe("v1");
+ expect((await response.text()).trimEnd()).toBe(
+ fixture("panel-initial.sse").trimEnd(),
+ );
+ expect(inspections[0]).toEqual({
+ type: "request-start",
+ requestId: "request-fe1436-contract",
+ conversationId: "m5z0GU9KJPzhOTlx",
+ userMessageId: "6ddgGkjhSxGjOtiv",
+ });
+ expect(
+ inspections
+ .filter((event) => event.type === "part-emitted")
+ .map((event) => ({
+ kind: event.kind,
+ partId: event.partId,
+ toolCallId: event.toolCallId,
+ })),
+ ).toEqual([
+ {
+ kind: "reasoning",
+ partId: "reasoning-fe1435-1",
+ toolCallId: undefined,
+ },
+ { kind: "text", partId: "text-fe1435-1", toolCallId: undefined },
+ {
+ kind: "tool-input",
+ partId: undefined,
+ toolCallId: "tool-server-fe1435",
+ },
+ {
+ kind: "tool-output",
+ partId: undefined,
+ toolCallId: "tool-server-fe1435",
+ },
+ {
+ kind: "tool-input",
+ partId: undefined,
+ toolCallId: "tool-place-fe1435",
+ },
+ {
+ kind: "tool-input",
+ partId: undefined,
+ toolCallId: "tool-transition-fe1435",
+ },
+ ]);
+ expect(inspections.at(-1)).toEqual({
+ type: "request-finish",
+ requestId: "request-fe1436-contract",
+ terminalState: "completed",
+ finishReason: "tool-calls",
+ });
+ });
+
+ test("refuses the frozen tool-result follow-up without dispatching diagnostics as user evidence", async () => {
+ let dispatched = false;
+ const handler = createAiSdkChatHandler({
+ allowedOrigins: ["http://127.0.0.1:4915"],
+ async runTurn() {
+ dispatched = true;
+ },
+ });
+
+ const response = await handler(
+ new Request("http://brunch.test/api/petrinaut/chat", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ origin: "http://127.0.0.1:4915",
+ },
+ body: fixture("panel-tool-results.post.json"),
+ }),
+ );
+
+ expect(response.status).toBe(422);
+ expect(response.headers.get("access-control-allow-origin")).toBe(
+ "http://127.0.0.1:4915",
+ );
+ expect(await response.json()).toEqual({
+ error: "tool_result_follow_up_not_supported",
+ });
+ expect(dispatched).toBe(false);
+ });
+});
diff --git a/apps/brunch-agent/test/walking-skeleton.integration.ts b/apps/brunch-agent/test/walking-skeleton.integration.ts
new file mode 100644
index 00000000000..66f85667a46
--- /dev/null
+++ b/apps/brunch-agent/test/walking-skeleton.integration.ts
@@ -0,0 +1,334 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+// One of the substrate integration entry points reviewed in
+// test/boundaries.test.ts, which is where the permission lives — this comment
+// does not grant it.
+import {
+ fauxAssistantMessage,
+ fauxProvider,
+ fauxToolCall,
+ type Context,
+} from "@earendil-works/pi-ai";
+import { start } from "@flue/runtime/node";
+import { createFlueClient } from "@flue/sdk";
+
+import { toolName } from "@hashintel/brunch-agent";
+import {
+ createFlueHistoryReader,
+ createLocalCaptureStore,
+} from "@hashintel/brunch-agent-binding-flue";
+
+import {
+ GHERKIN_MODEL_ID,
+ GherkinElicitor,
+} from "../src/agents/gherkin-elicitor.ts";
+import app from "../src/app.ts";
+import { GHERKIN_AGENT_ROUTE } from "../src/routes.ts";
+import { targetDocumentPath } from "../src/target-document-path.ts";
+
+const ask = toolName("ask");
+const sweep = toolName("sweep");
+const omittedQuote = "A shopper completes checkout.";
+const newlyCapturedQuote = "Payment is authorized before fulfillment.";
+const repairedQuote = "Refunds require approval.";
+const missingQuote = "This quote is not in the conversation.";
+const statementNoted = (quote: string) => ({
+ evidence: [{ excerpt: quote }],
+ epistemicStatus: "explicit" as const,
+ confidence: "firm" as const,
+ content: {
+ value: {
+ type: "statement-noted" as const,
+ interior: { verbatim: quote },
+ },
+ },
+});
+const faux = fauxProvider({
+ provider: "anthropic",
+ models: [{ id: GHERKIN_MODEL_ID }],
+});
+
+let replyContext: Context | undefined;
+faux.setResponses([
+ fauxAssistantMessage(
+ [
+ fauxToolCall(ask, {
+ question: "What outcome should the scenario describe?",
+ }),
+ ],
+ { stopReason: "toolUse" },
+ ),
+ (context) => {
+ replyContext = context;
+ return fauxAssistantMessage(
+ [fauxToolCall(ask, { question: "Who initiates that outcome?" })],
+ {
+ stopReason: "toolUse",
+ },
+ );
+ },
+ fauxAssistantMessage(
+ [
+ fauxToolCall(ask, { question: "What happens first?" }),
+ fauxToolCall(ask, { question: "What happens second?" }),
+ ],
+ { stopReason: "toolUse" },
+ ),
+ fauxAssistantMessage("Waiting for the accepted question to be answered."),
+ fauxAssistantMessage("That closes the payment topic."),
+ fauxAssistantMessage([fauxToolCall(sweep, {})], { stopReason: "toolUse" }),
+ fauxAssistantMessage(
+ [
+ fauxToolCall("finish", {
+ proposals: [statementNoted(newlyCapturedQuote)],
+ }),
+ ],
+ { stopReason: "toolUse" },
+ ),
+ fauxAssistantMessage([fauxToolCall(sweep, {})], { stopReason: "toolUse" }),
+ fauxAssistantMessage(
+ [
+ fauxToolCall("finish", {
+ proposals: [
+ statementNoted(newlyCapturedQuote),
+ statementNoted(omittedQuote),
+ ],
+ }),
+ ],
+ { stopReason: "toolUse" },
+ ),
+ fauxAssistantMessage("The settled statements are captured."),
+ fauxAssistantMessage("That closes the refund topic."),
+ fauxAssistantMessage([fauxToolCall(sweep, {})], { stopReason: "toolUse" }),
+ fauxAssistantMessage(
+ [fauxToolCall("finish", { proposals: [statementNoted(missingQuote)] })],
+ {
+ stopReason: "toolUse",
+ },
+ ),
+ fauxAssistantMessage("The refused sweep needs repair."),
+ fauxAssistantMessage("I am stopping on the repair continuation."),
+ fauxAssistantMessage([fauxToolCall(sweep, {})], { stopReason: "toolUse" }),
+ fauxAssistantMessage(
+ [
+ fauxToolCall("finish", {
+ proposals: [
+ statementNoted(newlyCapturedQuote),
+ statementNoted(omittedQuote),
+ statementNoted(repairedQuote),
+ ],
+ }),
+ ],
+ { stopReason: "toolUse" },
+ ),
+ fauxAssistantMessage("The repaired sweep is captured."),
+]);
+
+const flue = await start({
+ agents: [GherkinElicitor],
+ providers: [faux.provider],
+});
+const targetDirectory = await mkdtemp(
+ join(tmpdir(), "brunch-walking-skeleton-"),
+);
+
+try {
+ process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR = targetDirectory;
+ const fetchApp = ((input: RequestInfo | URL, init?: RequestInit) =>
+ Promise.resolve(
+ app.fetch(input instanceof Request ? input : new Request(input, init)),
+ )) as typeof fetch;
+ const conversationId = `walking-skeleton-${crypto.randomUUID()}`;
+ const targetDocumentId = "walking-skeleton-test";
+ const captureStore = createLocalCaptureStore(
+ targetDocumentPath(targetDocumentId),
+ );
+ const historyReader = createFlueHistoryReader({
+ resolveConversationUrl: (sessionId) =>
+ `http://brunch.test/agents/${GHERKIN_AGENT_ROUTE}/${sessionId}`,
+ transport: fetchApp,
+ archive: captureStore,
+ });
+ const client = createFlueClient({
+ url: `http://brunch.test/agents/${GHERKIN_AGENT_ROUTE}/${conversationId}`,
+ fetch: fetchApp,
+ });
+
+ const kickoff = await client.send({
+ message: { kind: "user", body: "Begin the interview." },
+ initialData: { targetDocumentId },
+ });
+ await client.wait(kickoff);
+
+ const firstHistory = await historyReader.read(conversationId);
+ const previousArchive = await captureStore.readArchivedEntries({
+ sessionId: conversationId,
+ entryStart: 1,
+ entryEnd: firstHistory.messages.length,
+ });
+ const quoteAbsentFromPreviousArchive =
+ !JSON.stringify(previousArchive).includes(newlyCapturedQuote);
+ const firstParts = firstHistory.messages.flatMap((message) => message.parts);
+ const firstAsk = firstParts.find(
+ (part) =>
+ part.type === "dynamic-tool" &&
+ part.toolName === ask &&
+ part.state === "output-available",
+ );
+ const firstAskOutput =
+ firstAsk?.type === "dynamic-tool" && firstAsk.state === "output-available"
+ ? firstAsk.output
+ : undefined;
+
+ const answer = await client.send({
+ message: { kind: "user", body: omittedQuote },
+ });
+ await client.wait(answer);
+
+ const secondAnswer = await client.send({
+ message: { kind: "user", body: "The shopper initiates it." },
+ });
+ await client.wait(secondAnswer);
+
+ const thirdAnswer = await client.send({
+ message: { kind: "user", body: newlyCapturedQuote },
+ });
+ await client.wait(thirdAnswer);
+
+ const fourthAnswer = await client.send({
+ message: { kind: "user", body: repairedQuote },
+ });
+ await client.wait(fourthAnswer);
+
+ const history = await historyReader.peek(conversationId);
+ const repeatedAskAssistant = [...history.messages]
+ .reverse()
+ .find(
+ (message) =>
+ message.role === "assistant" &&
+ message.parts.filter(
+ (part) => part.type === "dynamic-tool" && part.toolName === ask,
+ ).length >= 2,
+ );
+ const finalAskParts =
+ repeatedAskAssistant?.parts.filter(
+ (part) => part.type === "dynamic-tool" && part.toolName === ask,
+ ) ?? [];
+ const captured = await captureStore.read();
+ let archivePointerResolved = false;
+ let affordanceReplyClassified = false;
+ const capture = captured.captures.find(
+ (candidate) =>
+ "evidence" in candidate &&
+ candidate.evidence.some(
+ (evidence) => evidence.excerpt === newlyCapturedQuote,
+ ),
+ );
+ if (capture && "evidence" in capture) {
+ affordanceReplyClassified =
+ capture.evidence[0]?.source === "user-affordance-payload";
+ const [archived] = await captureStore.readArchivedEntries(
+ capture.evidence[0]!.pointer,
+ );
+ archivePointerResolved =
+ archived?.versions.at(-1)?.text === newlyCapturedQuote;
+ }
+ const settlementChecks = history.messages.filter(
+ (message) => message.signal?.tagName === "settlement-check",
+ );
+ const repairSignals = history.messages.filter(
+ (message) => message.signal?.tagName === "sweep-repair",
+ );
+ const sweepOutputs = history.messages
+ .flatMap((message) => message.parts)
+ .flatMap((part) =>
+ part.type === "dynamic-tool" &&
+ part.toolName === sweep &&
+ part.state === "output-available" &&
+ typeof part.output === "object" &&
+ part.output !== null
+ ? [part.output]
+ : [],
+ );
+ const appliedSweepOutputs = sweepOutputs.filter(
+ (output): output is Record =>
+ "status" in output && output.status === "applied",
+ );
+ const replayOutput = appliedSweepOutputs[1];
+ const capturesStayAtVerbatimFloor = captured.captures.every((candidate) => {
+ if (!("evidence" in candidate) || !("value" in candidate.content))
+ return false;
+ const serializedValue = JSON.stringify(candidate.content.value);
+ return candidate.evidence.some(
+ (evidence) =>
+ serializedValue ===
+ JSON.stringify({
+ type: "statement-noted",
+ interior: { verbatim: evidence.excerpt },
+ }),
+ );
+ });
+ const serializedReplyContext =
+ replyContext === undefined ? undefined : JSON.stringify(replyContext);
+
+ console.log(
+ `WALKING_SKELETON_RESULT ${JSON.stringify({
+ affordanceReplyClassified,
+ archivePointerResolved,
+ captureStoredThroughSweep: captured.captures.length === 3,
+ capturesStayAtVerbatimFloor,
+ boundReplyReachedModel:
+ serializedReplyContext?.includes("A shopper completes checkout.") ===
+ true && serializedReplyContext.includes("affordance-reply-bound"),
+ durableOutput:
+ JSON.stringify(firstAskOutput).includes(
+ "What outcome should the scenario describe?",
+ ) && JSON.stringify(firstAskOutput).includes('"form":"free-text"'),
+ markdownFloor: firstParts.some(
+ (part) =>
+ part.type === "data-affordance" &&
+ JSON.stringify(part.data).includes(
+ "What outcome should the scenario describe?",
+ ),
+ ),
+ noInstructionWake: !JSON.stringify(history.messages)
+ .toLowerCase()
+ .includes("instructions updated"),
+ pendingAskSuppressedSettlement: !firstHistory.messages.some(
+ (message) => message.signal?.tagName === "settlement-check",
+ ),
+ quoteAbsentFromPreviousArchive,
+ refusalStopReopenedRange:
+ sweepOutputs.some(
+ (output) => "status" in output && output.status === "refused",
+ ) &&
+ repairSignals.length === 1 &&
+ settlementChecks.length === 3,
+ replayRepairedOmission:
+ replayOutput !== undefined &&
+ Array.isArray(replayOutput.appliedCaptureIds) &&
+ replayOutput.appliedCaptureIds.length === 1 &&
+ Array.isArray(replayOutput.skippedDedupKeys) &&
+ replayOutput.skippedDedupKeys.length === 1,
+ secondAskRejected:
+ finalAskParts.filter(
+ (part) =>
+ part.type === "dynamic-tool" && part.state === "output-available",
+ ).length === 1 &&
+ finalAskParts.filter(
+ (part) =>
+ part.type === "dynamic-tool" && part.state === "output-error",
+ ).length === 1,
+ settlementNudgedAtEachFrontier: settlementChecks.length >= 2,
+ unaccountedAskAdvisory: appliedSweepOutputs.some((output) =>
+ JSON.stringify(output.advisories).includes("unaccounted-ask"),
+ ),
+ })}`,
+ );
+} finally {
+ delete process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR;
+ await flue.stop();
+ await rm(targetDirectory, { recursive: true });
+}
diff --git a/apps/brunch-agent/test/walking-skeleton.test.ts b/apps/brunch-agent/test/walking-skeleton.test.ts
new file mode 100644
index 00000000000..c40650f227c
--- /dev/null
+++ b/apps/brunch-agent/test/walking-skeleton.test.ts
@@ -0,0 +1,40 @@
+import { join } from "node:path";
+
+import { expect, test } from "vitest";
+
+import { runNodeScript } from "./run-node-script";
+
+test("the dev app suspends for free-text replies without instruction wakes", async () => {
+ // Spec §7.4 and §14.5's wake-wart item: the one instruction-state write path
+ // that exists must not re-trigger an advisory wake.
+ const testDirectory = import.meta.dirname;
+ const { exitCode, stdout, stderr } = await runNodeScript(
+ join(testDirectory, "walking-skeleton.integration.ts"),
+ join(testDirectory, "../../.."),
+ );
+
+ expect(exitCode, stderr || stdout).toBe(0);
+ const resultLine = stdout
+ .split("\n")
+ .find((line) => line.startsWith("WALKING_SKELETON_RESULT "));
+ expect(resultLine, stdout).toBeDefined();
+ expect(
+ JSON.parse(resultLine!.slice("WALKING_SKELETON_RESULT ".length)),
+ ).toEqual({
+ affordanceReplyClassified: true,
+ archivePointerResolved: true,
+ boundReplyReachedModel: true,
+ captureStoredThroughSweep: true,
+ capturesStayAtVerbatimFloor: true,
+ durableOutput: true,
+ markdownFloor: true,
+ noInstructionWake: true,
+ pendingAskSuppressedSettlement: true,
+ quoteAbsentFromPreviousArchive: true,
+ refusalStopReopenedRange: true,
+ replayRepairedOmission: true,
+ secondAskRejected: true,
+ settlementNudgedAtEachFrontier: true,
+ unaccountedAskAdvisory: true,
+ });
+});
diff --git a/apps/brunch-agent/tsconfig.json b/apps/brunch-agent/tsconfig.json
new file mode 100644
index 00000000000..1f44fa66022
--- /dev/null
+++ b/apps/brunch-agent/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "target": "es2024",
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "types": ["node", "vite/client"],
+ "module": "preserve",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "strict": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "isolatedModules": true
+ },
+ "include": ["src", "test", "*.config.ts"]
+}
diff --git a/apps/brunch-agent/turbo.json b/apps/brunch-agent/turbo.json
new file mode 100644
index 00000000000..5587a8d0535
--- /dev/null
+++ b/apps/brunch-agent/turbo.json
@@ -0,0 +1,32 @@
+{
+ "extends": ["//"],
+ "tasks": {
+ "build": {
+ "dependsOn": ["^build"],
+ "outputs": ["dist/**"],
+ "cache": false
+ },
+ "dev": {
+ "dependsOn": ["^build"],
+ "cache": false,
+ "persistent": true,
+ "passThroughEnv": [
+ "ANTHROPIC_API_KEY",
+ "BRUNCH_DEV_DB_PATH",
+ "BRUNCH_DEV_TARGET_DOCUMENT_DIR",
+ "BRUNCH_PETRINAUT_ORIGINS",
+ "BRUNCH_TRANSPORT_AISDK_INSPECT"
+ ]
+ },
+ "petrinaut:dev": {
+ "dependsOn": ["^build"],
+ "cache": false,
+ "persistent": true,
+ "passThroughEnv": ["BRUNCH_CHAT_ORIGIN", "PETRINAUT_WEBSITE_ROOT"]
+ },
+ "test:unit": {
+ "dependsOn": ["build", "codegen", "^build"],
+ "env": ["TEST_COVERAGE"]
+ }
+ }
+}
diff --git a/apps/brunch-agent/vite.client.config.ts b/apps/brunch-agent/vite.client.config.ts
new file mode 100644
index 00000000000..24e399214ab
--- /dev/null
+++ b/apps/brunch-agent/vite.client.config.ts
@@ -0,0 +1,30 @@
+import { defineConfig } from "vite";
+
+/**
+ * The client build — a second, plain Vite build alongside the Flue one.
+ *
+ * `@flue/vite` forces `build.ssr` with its own fixed inputs, so the main build
+ * emits the server and nothing else: `index.html` and everything it pulls in
+ * are never transformed by it. Without this config the ui tree has no build
+ * coverage at all, and a client-side break stays invisible until someone opens
+ * the page in `vite dev`.
+ *
+ * Deliberately not the flue plugin: in dev the flue controller owns the whole
+ * request space and serves the ui itself, so this config exists only to
+ * produce — and thereby typecheck-and-bundle — the production client.
+ */
+export default defineConfig({
+ build: {
+ outDir: "dist/client",
+ emptyOutDir: true,
+ // Referenced by app.ts when serving the built ui; a hashed filename would
+ // have to be looked up through the manifest for no gain at this size.
+ rollupOptions: {
+ output: {
+ entryFileNames: "assets/[name].js",
+ chunkFileNames: "assets/[name].js",
+ assetFileNames: "assets/[name][extname]",
+ },
+ },
+ },
+});
diff --git a/apps/brunch-agent/vite.config.ts b/apps/brunch-agent/vite.config.ts
new file mode 100644
index 00000000000..e5d3a94ef1b
--- /dev/null
+++ b/apps/brunch-agent/vite.config.ts
@@ -0,0 +1,14 @@
+import { flue } from "@flue/vite";
+import { defineConfig } from "vite";
+
+import { localChatListen } from "./src/local-dev-origins.ts";
+
+// No @vitejs/plugin-react: the flue plugin's dev controller owns the whole
+// request space and hands every request to app.ts, with no fall-through to
+// vite's html middleware — so index.html is app-served and react-refresh's
+// preamble injection would never run (recorded Flue fact, spec §10). Vite's
+// core esbuild transform still compiles the .tsx modules.
+export default defineConfig({
+ plugins: [flue()],
+ server: localChatListen,
+});
diff --git a/apps/brunch-agent/vitest.config.ts b/apps/brunch-agent/vitest.config.ts
new file mode 100644
index 00000000000..8b5840acac7
--- /dev/null
+++ b/apps/brunch-agent/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["test/**/*.test.ts"],
+ },
+});
diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json
index 1f2f0ff519a..46e48eeb96f 100644
--- a/apps/petrinaut-website/package.json
+++ b/apps/petrinaut-website/package.json
@@ -17,6 +17,7 @@
},
"dependencies": {
"@ai-sdk/openai": "3.0.63",
+ "@hashintel/brunch-agent-transport-aisdk": "workspace:*",
"@hashintel/ds-components": "workspace:*",
"@hashintel/ds-helpers": "workspace:*",
"@hashintel/petrinaut": "workspace:*",
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.tsx
new file mode 100644
index 00000000000..f5bee1aa485
--- /dev/null
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-ask-interactive-tool.tsx
@@ -0,0 +1,149 @@
+import { type FormEvent, useId, useState } from "react";
+
+import {
+ ASK_TOOL_NAME,
+ type BrunchAskInput,
+ type BrunchAskOutput,
+ parseBrunchAskInput,
+ parseBrunchAskOutput,
+} from "@hashintel/brunch-agent-transport-aisdk/client-tools";
+import { css } from "@hashintel/ds-helpers/css";
+import {
+ definePetrinautAiInteractiveTool,
+ type InteractiveToolWidgetProps,
+} from "@hashintel/petrinaut/ui";
+
+const containerStyle = css({
+ display: "flex",
+ flexDirection: "column",
+ gap: "2",
+ padding: "3",
+ borderWidth: "thin",
+ borderStyle: "solid",
+ borderColor: "blue.a30",
+ borderRadius: "lg",
+ backgroundColor: "blue.a10",
+});
+
+const questionStyle = css({
+ color: "neutral.s100",
+ fontSize: "sm",
+ fontWeight: "medium",
+ lineHeight: "relaxed",
+});
+
+const formStyle = css({
+ display: "flex",
+ flexDirection: "column",
+ gap: "2",
+});
+
+const labelStyle = css({
+ color: "neutral.s90",
+ fontSize: "xs",
+ fontWeight: "medium",
+});
+
+const textareaStyle = css({
+ width: "full",
+ minHeight: "20",
+ padding: "2",
+ borderWidth: "thin",
+ borderStyle: "solid",
+ borderColor: "neutral.a30",
+ borderRadius: "md",
+ backgroundColor: "neutral.s00",
+ color: "neutral.s100",
+ fontSize: "sm",
+ resize: "vertical",
+ _focusVisible: {
+ borderColor: "blue.a70",
+ outline: "2px solid",
+ outlineColor: "blue.a30",
+ outlineOffset: "[1px]",
+ },
+});
+
+const submitButtonStyle = css({
+ alignSelf: "flex-end",
+ paddingX: "3",
+ paddingY: "2",
+ borderRadius: "md",
+ backgroundColor: "blue.a85",
+ color: "white",
+ cursor: "pointer",
+ fontSize: "sm",
+ fontWeight: "medium",
+ _hover: {
+ backgroundColor: "blue.a100",
+ },
+ _disabled: {
+ cursor: "not-allowed",
+ opacity: 0.45,
+ },
+});
+
+const answerStyle = css({
+ padding: "2",
+ borderRadius: "md",
+ backgroundColor: "neutral.s00",
+ color: "neutral.s90",
+ fontSize: "sm",
+});
+
+const BrunchAskWidget = ({
+ input,
+ state,
+ submit,
+ submittedOutput,
+}: InteractiveToolWidgetProps) => {
+ const answerId = useId();
+ const [answer, setAnswer] = useState("");
+
+ const onSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ const trimmedAnswer = answer.trim();
+ if (!trimmedAnswer) {
+ return;
+ }
+ submit({ answer: trimmedAnswer });
+ };
+
+ return (
+
+
{input.question}
+ {state === "submitted" ? (
+
{submittedOutput?.answer}
+ ) : (
+
+ )}
+
+ );
+};
+
+export const brunchAskInteractiveTool = definePetrinautAiInteractiveTool({
+ toolName: ASK_TOOL_NAME,
+ shouldHandle: () => true,
+ parseInput: parseBrunchAskInput,
+ parseOutput: parseBrunchAskOutput,
+ Widget: BrunchAskWidget,
+});
diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx
index 87ceda4feb8..6f770fc9602 100644
--- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx
+++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx
@@ -17,6 +17,7 @@ import {
} from "@hashintel/petrinaut/ui";
import { useSentryFeedbackAction } from "../sentry-feedback-button";
+import { brunchAskInteractiveTool } from "./brunch-ask-interactive-tool";
import { useLocalStorageAiMessages } from "./use-local-storage-ai-messages";
import {
type SDCPNInLocalStorage,
@@ -256,6 +257,7 @@ export const LocalStorageDemoApp = () => {
const aiAssistant = useMemo(
() => ({
+ interactiveTools: [brunchAskInteractiveTool],
transport: petrinautAiChatTransport,
messages: currentNetId ? aiMessagesByNetId[currentNetId] : undefined,
onMessages: (messages: PetrinautAiMessage[]) => {
diff --git a/libs/@hashintel/brunch-agent/.agents/skills/arc-close/SKILL.md b/libs/@hashintel/brunch-agent/.agents/skills/arc-close/SKILL.md
new file mode 100644
index 00000000000..d8e12c92901
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/.agents/skills/arc-close/SKILL.md
@@ -0,0 +1,8 @@
+---
+name: arc-close
+description: Runs the mandatory Brunch arc-close control pass before a branch closes a work arc.
+---
+
+Read `docs/agents/arc-close.md` from the Brunch context root and execute its five steps in order.
+This wrapper carries no duplicate procedure. Report the required checks and whether each
+conditional control surface changed; never persist a no-op evaluation.
diff --git a/libs/@hashintel/brunch-agent/.claude/skills/arc-close/SKILL.md b/libs/@hashintel/brunch-agent/.claude/skills/arc-close/SKILL.md
new file mode 100644
index 00000000000..d8e12c92901
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/.claude/skills/arc-close/SKILL.md
@@ -0,0 +1,8 @@
+---
+name: arc-close
+description: Runs the mandatory Brunch arc-close control pass before a branch closes a work arc.
+---
+
+Read `docs/agents/arc-close.md` from the Brunch context root and execute its five steps in order.
+This wrapper carries no duplicate procedure. Report the required checks and whether each
+conditional control surface changed; never persist a no-op evaluation.
diff --git a/libs/@hashintel/brunch-agent/.gitignore b/libs/@hashintel/brunch-agent/.gitignore
new file mode 100644
index 00000000000..200a359c9f1
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/.gitignore
@@ -0,0 +1 @@
+**/drafts/
diff --git a/libs/@hashintel/brunch-agent/AGENTS.md b/libs/@hashintel/brunch-agent/AGENTS.md
new file mode 100644
index 00000000000..f38e3481753
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/AGENTS.md
@@ -0,0 +1,48 @@
+# Brunch agent
+
+This directory is the Brunch context and agent-session root inside `hashintel/hash`. HASH root
+guidance always wins where it conflicts with this file.
+
+## Scope
+
+- `packages/core`: substrate- and renderer-independent harness and plugin SDK.
+- `packages/binding-*`: substrate bindings; each depends inward on the harness.
+- `packages/transport-*`: wire transports; none may depend on a binding.
+- `packages/plugin-*`: target plugins; each depends only on the harness.
+- `../../../apps/brunch-agent`: remote server, application composition, and local diagnostics.
+
+The context root is not a package-manager root. Do not add a `package.json`, lockfile, nested
+workspace configuration, or standalone CI here. Run package tasks through HASH's root Yarn/Turbo
+workspace.
+
+## Before changing Brunch
+
+1. Read `CONTEXT.md` and the relevant decision under `docs/adr/`.
+2. Read the protocol under `docs/agents/` that corresponds to the operation.
+3. Preserve the executable package-direction, Flue entrypoint, bundle, and hermetic-runtime gates.
+
+## Working methods
+
+- Use Graphite (`gt`) for stack operations; do not use `gh stack` in HASH.
+- Issues live in Linear team `FE`, project `brunch-agent`.
+- Keep the human-owned issue contract separate from collapsed `🏗️ Agent notes`; see
+ `docs/agents/issue-writing.md`.
+- Maintain the glossary in `CONTEXT.md` and context decisions in `docs/adr/`; see
+ `docs/agents/domain.md`.
+- Keep `docs/INDEX.md` complete and follow `docs/agents/documentation.md`.
+- Before closing a Brunch work arc, run the context-local `arc-close` skill and its canonical
+ procedure in `docs/agents/arc-close.md`.
+- At design moments involving Flue, follow `docs/agents/flue-routing.md`.
+
+The complete protocol set is:
+
+- `docs/agents/arc-close.md`
+- `docs/agents/documentation.md`
+- `docs/agents/domain.md`
+- `docs/agents/flue-routing.md`
+- `docs/agents/git-workflow.md`
+- `docs/agents/issue-tracker.md`
+- `docs/agents/issue-writing.md`
+- `docs/agents/legibility.md`
+- `docs/agents/posture.md`
+- `docs/agents/triage-labels.md`
diff --git a/libs/@hashintel/brunch-agent/CLAUDE.md b/libs/@hashintel/brunch-agent/CLAUDE.md
new file mode 120000
index 00000000000..47dc3e3d863
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/libs/@hashintel/brunch-agent/CONTEXT.md b/libs/@hashintel/brunch-agent/CONTEXT.md
new file mode 100644
index 00000000000..2dfdb637fde
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/CONTEXT.md
@@ -0,0 +1,156 @@
+# Brunch — domain language
+
+Vocabulary for the brunch elicitation system: an architecture generalizing agentic interviewing against pluggable elicitation targets. Hardened during the elicitation-kernel effort (whose spec settled 2026-08-10) and now governing this repo's implementation and the live process-model-elicitation effort alike. (The old title "Elicitation Kernel" fell to the glossary's own rule: "kernel" is a retired shell name.)
+
+## Language
+
+### Shells
+
+**Substrate**:
+The agent framework the system is built on — the Pi family, Flue — including the embedding environment's concerns: deploy target, storage-port implementation, artifact delivery, model/provider. (The retired term "host" silently bundled these with interface concerns; they split into substrate and UI. The charter non-goal "harness-agnostic core" predates this glossary and reads "substrate-agnostic".)
+_Avoid_: harness (for Pi/Flue), platform, host (for the embedding environment)
+
+**UI**:
+The interface shell: whatever affords user interaction — rendering, input, reply transport. Not bound to GUI or TUI; a chat channel qualifies.
+_Avoid_: host, host-interface, frontend, client
+
+**Harness**:
+The middle shell and the essence of the effort: the generic capability layer of the elicitation system — mechanism and orchestration (the conversation loop, the `ask` API, capture envelope, issue queue, sweep bookkeeping). Injected into plugins as a narrow context; never owned by them.
+_Avoid_: kernel, core, elicitor (as a shell name — "elicitor" may name the whole system). Exempt compound: **kernel card** (below). Exempt name: the package `packages/core` (spec §12.2) — the avoidance applies to "core" as a prose shell name, not to the package path. "Kernel invariants" renamed **harness invariants** (spec §14.1).
+
+**Plugin**:
+The innermost shell: target-defining policy. Declares packs, forms, and validators; composes at authoring time; receives harness capabilities by injection. Mostly policy — mechanism stays in the harness.
+_Avoid_: extension, pack (a pack is a unit _within_ a plugin)
+
+**Binding**:
+The substrate-facing adapter between harness and substrate: implements the harness's named substrate-capability list (tool registration, instruction assembly, persistent state, affordance emission, suspend-for-reply, private model call) in one substrate's dialect. One per substrate; the harness imports no substrate, a binding imports both. Bindings vary in size — each absorbs what its substrate lacks or forbids.
+_Avoid_: adapter (generic), integration, wrapper
+
+### Sessions & durability
+
+**Target-domain**:
+The artifact family being elicited — what a plugin defines (gherkin scenarios, assurance arguments, BPMN). The family half of the former bare "target".
+_Avoid_: target-paradigm; bare "target" where family/instance is ambiguous
+
+**Target-document**:
+The durable unit sessions attach to: one target-domain, its capture store, and its session history. Named by its purpose — its authoritative state is the capture store plus session logs, never the rendered artifact (renders are derived, cacheable, disposable). Endures independently of any session; never locks — completion is a derived status, not a write gate.
+_Avoid_: spec (as the unit name), workpiece, case, target-output
+
+**Session**:
+One substrate conversation — the full log of entries (user, agent, tool calls, injected state messages), matching Pi's session model. Per-session state is exactly: the evidence log, the swept high-water mark, the pending-affordance slot. Sessions go quiet rather than close; any session is resumable against the current state of its target-document.
+_Avoid_: sitting, conversation (as a distinct concept)
+
+**Capture store**:
+The durable, session-independent truth of a target-document: captures, issues, events. Written only by atomic sweep application (serialized); statuses and projections derive from it at read time.
+
+**Re-entry briefing**:
+The state message the harness injects when a session resumes after the world moved: computed facts only — unswept tail, world-moved delta, open issues, pending affordance. Authored on behalf of the user in the transcript (Pi's custom-entry convention) but distinguished from true user entries in the data model, and never citable as capture evidence.
+_Avoid_: sync message, forced re-sweep
+
+### Interaction
+
+**Affordance**:
+A structured interactive element (question form, choice strip, questionnaire) emitted into the conversation stream as a rendered enhancement. Not a state machine — the conversation stays primary, and an affordance's payload is evidence in the session like any other entry.
+_Avoid_: exchange, exchange pair, terminal (brunch's retired turn-by-turn ontology)
+
+**Capture**:
+Extraction of structured evidence — envelope plus plugin-typed payload — from session entries. Produced by sweeps, never written directly during conversation.
+_Avoid_: extraction, harvest
+
+**Sweep**:
+An idempotent pass over a settled range of session entries that produces captures. Re-sweeping a range never double-captures. Disambiguation: the capture store's `apply-sweep` command names only the storage half — atomically applying a sweep's proposals; the sweep proper is the capture-producing pass, which does not exist yet (FE-1392).
+
+**Settlement**:
+The agent-judged event marking a range of conversation (a vein closing) ready to sweep. Always range-level, never per-question.
+_Avoid_: exchange completion
+
+**Interpretation render**:
+The harness-owned affordance form showing current captured state — the harness frames envelope semantics; the plugin's renderer definition (typed against its own payload shapes) supplies the content view when provided, with a harness default (plain JSON view) otherwise.
+_Avoid_: digest (brunch's form)
+
+### Envelope & packs
+
+**Intermediate representation (IR)**:
+The elicited conceptual model a target-document accumulates — the middle of three registers (ADR-0003): typed **assertions** (active captures) are folded by a pure, plugin-declared fold into the **model** (the IR proper — node instances with slot states), which **projections** consume without rereading the transcript. Not a second store — the model is a derivation, recomputable from active captures at any time, never a persistence surface; the rendered artifact is one projection of the model, never the model itself. Defining a plugin's IR means writing its contract — model schema, proposal catalog, fold table, demand table (`plugin-contract-spec.md`, provisional). An earlier definition read the capture set itself as the IR; ADR-0003 amends it.
+_Avoid_: knowledge store, domain model (as a stored unit), staging area
+
+**Capture envelope**:
+The harness-defined, domain-free wrapper around an opaque plugin payload: harness-minted id, evidence spans, epistemic status, confidence, value-xor-absence, alternatives grouping, one `supersedes` link. The hourglass waist. No stored status — envelope status (`active | superseded | retracted`) derives at read time from links and events.
+
+**Evidence span**:
+A capture's provenance link: a **quoted excerpt** (primary, the model-facing citation currency) plus a **pointer** (session id + entry range, harness-derived — entry identity is harness-side vocabulary only). Anchors only on true user and user-affordance-payload entries.
+
+**Epistemic status**:
+`explicit | inferred | tentative | defaulted | external-lookup` — how a capture's content relates to what the user actually said. Distinct from confidence; excluded from capture identity. One status per capture, coupled structurally to the provenance shape (see Basis) — per-field status is unrepresentable by design (FE-1390; FE-1405's central input, consumed without amendment: the structure that wanted per-field status lives below the status, in proposal interiors).
+
+**Grade**:
+How narrow a slot value's interpretation space is — "fewer readings remain." Per-slot orderings read by the fold, promotion, and demands ("this anchor demands the `range` rung"). Never claim strength: that is confidence (`firm | hedged | speculative`, envelope-side, orthogonal by design). Two sources: **form grades** from the standard-interiors library's ladders (e.g. verbal < point < range < quantiles), **composition grades** plugin-declared (e.g. Gherkin's given-only < given-when < full-gwt). Coined by the FE-1405 arc (`plugin-contract-spec.md`, provisional).
+_Avoid_: confidence (for narrowing), precision (unqualified)
+
+**Basis**:
+The provenance carrier for non-user-grounded captures: `declared-default` or `documented-transformation`, required exactly when epistemic status is `defaulted` / `external-lookup` and structurally exclusive with evidence spans (FE-1390 coined the field for what spec §5/C5 states in prose).
+_Avoid_: evidence (for these two statuses — evidence spans cite the user)
+
+**Absence state**:
+A first-class capture value where an answer would be: `unknown-to-user | not-yet-decided | not-applicable | explicitly-absent | declined | deferred` (`not-mentioned` is a computed fact, not a sweepable capture). Never collapses to null.
+_Avoid_: null, missing (as the stored representation)
+
+**Supersession**:
+The explicit correction mechanism, single-hop over active heads only. Two channels: the creation-time `supersedes` link (sweep-time correction) and the resolution record (issue-time adjudication). Superseded captures stay visible — corrections don't erase history.
+
+**Resolution record**:
+The explicit capture-store event that alone closes a `conflicting` issue (and, with no successor capture, expresses retraction). Must cite the true user's utterance as evidence.
+
+**Issue**:
+Typed, stored backpressure to the elicitation controller: `missing / ambiguous / conflicting / invalid / unsupported / unmapped / low-confidence`, with factual attributes. Two producers, namespaced: plugin ops (payload level) and the harness itself (envelope level). Closes only explicitly.
+_Avoid_: advisory (a different thing, below)
+
+**Advisory**:
+A computed, ephemeral, non-blocking fact the harness surfaces to the agent (unaccounted ask, unswept tail, world-moved delta). Never stored in the capture store; never gates anything.
+
+**Pack**:
+A unit within a plugin: **ElicitationPack** (kernel cards, completion contract, clarification hints) or **ProjectionPack** (`project` + `validate`, optional `reconcile`, annotated shapes, typed loss reports). Packs are shapes-to-fill plus behavioral guidance, per Principle v2.
+
+**Kernel card**:
+The pack-content unit of elicitation guidance: Detects / Goal / contrastive Questions / Artifacts (brunch `BEHAVIORAL_KERNELS.md` lineage — "kernel" here names a small unit of behavioral guidance, not a shell; the compound is the glossary's one sanctioned "kernel" use). Splits by ownership: domain cards are plugin pack content; a harness-shipped **generic strategy quiver** (cards over envelope vocabulary — conflict, ambiguity, weak evidence) is named in spec §11.5, not designed.
+
+**PluginContext**:
+The narrow injected context through which a plugin receives harness capabilities (the ask API, envelope, issue queue, sweep bookkeeping). The plugin's entire world at runtime; the four operations remain pure (snapshot-in/deltas-out) regardless.
+
+**Storage port**:
+The harness-defined contract for the capture store (atomic sweep application, envelope invariants as store-level refusals), implemented by the binding for its deploy target. Plugins are storage-blind. In code the port's type is `CaptureStore` (`packages/core/src/capture-store.ts`) — grep for that, not for "storage port". Scope includes the **session-log archive** (archive-on-read; spec §9.6): session logs live with the target-document, retained indefinitely — the substrate's conversation store is the live transport copy, never the provenance record.
+
+### September demo
+
+**Demo shell**:
+A retired proposal for a one-off September application (FE-1362). ADR-0004 replaced it with two
+application-owned surfaces in `hashintel/hash`: `apps/brunch-agent` runs the remote Brunch server,
+while `apps/petrinaut-website` owns the user-facing integration. Reusable Brunch and Petrinaut
+libraries remain mutually unaware.
+_Avoid_: using "demo shell" for the accepted topology
+
+**Artifact boundary**:
+The inter-library contract retained by ADR-0004: the elicitor emits a versioned net file plus
+scenario; Petrinaut consumes it through its published parser and import-with-autolayout path.
+Applications may compose both libraries, but neither reusable library consumes the other.
+_Avoid_: file handoff (undersells it), integration (generic)
+
+**Revision story**:
+The working-hypothesis demo spine (FE-1363; recommended to PM, not ratified): a sped-up recorded elicitation (conversation, interpretation surface, and growing net visible together) plus a bounded live segment in which a few turns elicit a fact forcing a structural revision of the net, run before/after in Petrinaut.
+_Avoid_: live demo (unqualified — the live part is one bounded segment, not the format)
+
+### Simulation & evaluation
+
+**Situation pack**:
+The interviewee-side bundle defining a user-to-be-simulated: situation, scenario, and persona — knowledge and motivations, some facts deliberately coloured by the persona's perspective. Private to the agent (or human) playing the user. Invariant: never authored from, or shaped to mirror, the IR — the elicitor's job is to excavate across that wall.
+_Avoid_: fact pack (undersells the persona; collides with the answer key), persona pack (too narrow)
+
+**Answer key**:
+The modeller-side list of facts the reference net needs, derived from the reference model — the evaluation rubric for what an elicitation should have excavated from a situation pack. Satisfies PRO-99's "written list of all facts necessary to make the net". Sits on the elicitor-team side of the wall; never part of the situation pack.
+_Avoid_: fact list (ambiguous with situation-pack content)
+
+**Walking skeleton**:
+A build that proves a transport or integration end-to-end on the real substrate (e.g. a real Flue agent + web UI) with stubbed internals. The term names the proof shape, not a disposal policy: the FE-1389 skeleton is retained as a durable CI gate, and its integration test pins runtime semantics nothing else does (do-not-weaken; see the Flue patterns audit).
+
+**Logic-prototype**:
+A prototype that locks down mechanism semantics (e.g. capture sweeps, settlement) in isolation, without the full host substrate.
diff --git a/libs/@hashintel/brunch-agent/README.md b/libs/@hashintel/brunch-agent/README.md
new file mode 100644
index 00000000000..2e6bbc3eb89
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/README.md
@@ -0,0 +1,19 @@
+# Brunch agent
+
+Brunch is a stateful elicitation harness and package family inside the HASH monorepo.
+
+This directory is its context and agent-session root, not a package workspace:
+
+- [`CONTEXT.md`](./CONTEXT.md) defines the domain language.
+- [`docs/adr/`](./docs/adr/) records governing decisions.
+- [`docs/spec.md`](./docs/spec.md) defines the harness contract.
+- [`docs/INDEX.md`](./docs/INDEX.md) indexes Brunch documentation.
+- [`packages/core/`](./packages/core/) is `@hashintel/brunch-agent`.
+- [`packages/binding-flue/`](./packages/binding-flue/) is the Flue binding.
+- [`packages/transport-aisdk/`](./packages/transport-aisdk/) is the AI SDK transport.
+- [`packages/plugin-gherkin/`](./packages/plugin-gherkin/) is the Gherkin target plugin.
+- [`../../../apps/brunch-agent/`](../../../apps/brunch-agent/) is the remote server and diagnostic
+ application.
+
+HASH's repository root owns package discovery, dependency policy, the lockfile, and the Turbo task
+graph.
diff --git a/libs/@hashintel/brunch-agent/docs/INDEX.md b/libs/@hashintel/brunch-agent/docs/INDEX.md
new file mode 100644
index 00000000000..ca6ea309c02
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/INDEX.md
@@ -0,0 +1,115 @@
+# Document index
+
+One line per document: what it is, where it lives, where it's used. Protocol:
+[`docs/agents/documentation.md`](agents/documentation.md). Statuses: `inbox` (awaiting
+settlement) · `active` (artifact of a live effort) · `settled` (permanent home) · `superseded`
+(retained history replaced by newer canon) · `accepted` (ratified ADR) · `external` (canonical
+copy lives outside the repo).
+
+## Inbox (awaiting settlement)
+
+_(empty — items settle out via the arc-close inbox sweep)_
+
+## Reference (settled sources)
+
+| Document | Status | Date | Digest | Used by |
+| ----------------------------------------------------------------------------------------------------------- | ------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| [agentic-elicitation-challenges](reference/agentic-elicitation-challenges-2026-08-06T10-02-41Z.md) | settled | 2026-08-06 | Turn 1 of the founding analysis: four contracts, packs, IR; source of the "capture meaning before representation" principle | elicitation-kernel spec §1 |
+| [agentic-elicitation-criteria](reference/agentic-elicitation-criteria-2026-08-06T14-11-18Z.md) | settled | 2026-08-06 | Turn 2: hourglass, five proof obligations, ten invariants, smells, test matrix | elicitation-kernel spec §14 |
+| [SDCPN Library - Ideas](reference/SDCPN%20Library%20-%20Ideas.md) | settled | 2026-08-11 | Eight CPS use-case sketches (physical/cyber/events/continuous-state/emergence anatomy), ChatGPT-drafted | FE-1357 map; FE-1363 candidates |
+| [hash-sails-public-report.pdf](reference/hash-sails-public-report.pdf) | settled | 2026-08-11 (pub. 2026-01) | SAILS/ARIA public report: Safeguarded AI gatekeeper (world model + safety spec + verifier), biopharma supply-chain research, tacit knowledge as adoption barrier | FE-1357 map (the "why"); FE-1363 cold-chain anchor |
+| [voice-implementation-recommendation-pplx](reference/voice-implementation-recommendation-pplx.md) | settled | 2026-08-11 | Perplexity research: voice-adapter options (ElevenLabs/OpenAI/Gemini/xAI) | FE-1359 (superseded in part by its findings) |
+| [yannis-dora-lu-transcript](reference/yannis-dora-lu-transcript-2026-08-11.md) | settled | 2026-08-11 | Meeting transcript: no in-house interviewing practice; SDCPN-as-hypothesis aired; baseline-control and priming ideas | expert-meeting-findings note; FE-1360, FE-1361 |
+| [amp-analysis-flue-vs-tilde](reference/amp-analysis-flue-vs-tilde.md) | settled | 2026-08-14 | Amp thread export: comparative assessment of the Flue and tilde agent frameworks (development and deployment stories) and its import for this project; verdict: keep Flue, Tilde is a control plane not a runtime | reconciled into flue-architecture-cheatsheet (2026-08-17); source of the pre-remote-exposure gates |
+| [2026-08 SDCPNs for cyber-physical systems](reference/2026-08%20SDCPNs%20for%20cyber-physical%20systems.md) | settled | 2026-08 (settled 2026-08-18) | Unattributed draft blog post (image placeholders, typos): five-level SDCPN explainer applied to gas supply, truck fleet, semiconductor fab; arrived during the FE-1405 arc. Read skeptically: good pedagogy, promotional register — concedes its formal guarantees don't apply once continuous/stochastic features are used (open research problem), models carry heavy kernel/guard logic that strains the "formal and inspectable" claim, and Petrinaut's integrator limitation is admitted | Register-3 background (projection-target expressivity) only; not elicitation design input; no consumer yet |
+
+## history/planning/elicitation-kernel (effort complete 2026-08-10; settled 2026-08-12)
+
+| Document | Status | Linear | Digest |
+| -------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| [spec.md](spec.md) | settled | linked from FE-1366 (context-canonical) | The elicitation-kernel spec: 14 sections + adjudications; FE-1437 import amendment records the native HASH package family, context root, and remote-server application charter |
+| [product-description.md](history/planning/elicitation-kernel/product-description.md) | settled | none | STE-style product description |
+| [product-description-plain.md](history/planning/elicitation-kernel/product-description-plain.md) | settled | none | Plain-prose rendering of the product description |
+| [map.md](history/planning/elicitation-kernel/map.md) | settled | **mirrored in full**: FE-1366 | Completed wayfinder map |
+| [issues/](history/planning/elicitation-kernel/issues/) 01–13 | settled | **mirrored in full**: FE-1367–FE-1379 (relations preserved) | 13 resolved tickets |
+| [notes/consistency-prepass](history/planning/elicitation-kernel/notes/consistency-prepass-2026-08-10.md) | settled | none | Pre-assembly contradiction audit (7 contradictions, adjudicated in spec Appendix A) |
+
+## planning/process-model-elicitation (effort active — FE-1357)
+
+| Document | Status | Linear | Digest |
+| -------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [recommendation-demo-vehicle](planning/process-model-elicitation/recommendation-demo-vehicle.md) | superseded | linked from FE-1362/1328/1329/1331/1333 | Demo-vehicle recommendation: demo shell + artifact boundary; superseded by ADR-0004 (18 Aug meeting chose in-Petrinaut staging) |
+| [petrinaut-integration-spec](planning/process-model-elicitation/petrinaut-integration-spec.md) | active | FE-1433 | Integration spec: elicitor as remote server behind the `aiAssistant` transport; suspension-borne client tools; `transport-aisdk`; principal + owner key; two gating spikes |
+| [FE-1434 suspension verdict](planning/process-model-elicitation/spikes/fe-1434-suspension-verdict-2026-08-19.md) | active | FE-1434 | Flue 2.0.3 carries a terminating client-tool batch through one durable pending slot and one non-user result signal; 3- and 100-result cases preserve ids in two dispatches |
+| [FE-1434 suspension evidence](planning/process-model-elicitation/spikes/fe-1434-suspension-evidence-2026-08-19.json) | active | FE-1434 | Deterministic transcript from the faux-provider runtime probe: native tool-result admission refused, signal resume succeeds, returned text is non-user and uncitable |
+| [adapter-panel-spike-2026-08-19](planning/process-model-elicitation/adapter-panel-spike-2026-08-19.md) | settled | FE-1435 | Real-panel spike verdict: AI SDK v6 SSE drives Petrinaut text/reasoning, default server-tool summaries, two live-editor client tools in one batched follow-up, and the diagnostics decorator; full POST/SSE transcript frozen as golden fixtures |
+| [transport-aisdk-implementation-2026-08-19](planning/process-model-elicitation/transport-aisdk-implementation-2026-08-19.md) | settled | FE-1436 | Durable real-panel transport: application `/api/chat` endpoint, substrate-neutral harness reply events, AI SDK v6 encoding, boundary gates, opt-in protocol inspection, and a clean-checkout local Petrinaut launcher |
+| [ask-return-implementation-2026-08-19](planning/process-model-elicitation/ask-return-implementation-2026-08-19.md) | active | FE-1449 | Ask suspend/return over the wire: the ask leaves as an awaiting client tool, the correlated `{ answer }` submission is admitted against durable history and resumes the conversation; stale/forged/duplicate/non-ask outputs refused before dispatch |
+| [notes/grilling-inputs-2026-08-12](planning/process-model-elicitation/notes/grilling-inputs-2026-08-12.md) | active | referenced from map | Session carryover: destination trend, facets/motions, Dora-checklist validate table |
+| [notes/expert-meeting-prep](planning/process-model-elicitation/notes/expert-meeting-prep-2026-08-11.md) | active | referenced from map | Prep brief for the Yannis meeting |
+| [notes/expert-meeting-findings](planning/process-model-elicitation/notes/expert-meeting-findings-2026-08-11.md) | active | referenced from map | Meeting findings: 5 facts, 7 idea seeds, 2 commitments |
+| [notes/open-questions-elicitation-design](planning/process-model-elicitation/notes/open-questions-elicitation-design-2026-08-11.md) | superseded | — | Draft; canonical copy is the [Notion page](https://www.notion.so/hashintel/3b93c81fe024801b89b3cf63a9a6ff20) (`external`) |
+| [research/petrinaut-survey](planning/process-model-elicitation/research/petrinaut-survey.md) | active | gisted in FE-1358 resolution | Petrinaut architecture/assistant/format survey + coupling audit |
+| [research/voice-feasibility](planning/process-model-elicitation/research/voice-feasibility.md) | active | gisted in FE-1359 resolution | Voice verdict: bolt-on with constraints; T0–T3 tiers |
+| [research/elicitation-strategy-literature](planning/process-model-elicitation/research/elicitation-strategy-literature.md) | active | gisted in FE-1360 resolution | Literature synthesis, 9 sections, verification-labeled |
+| [research/re-interviewing-literature-worker-report](planning/process-model-elicitation/research/re-interviewing-literature-worker-report.md) | active | noted on FE-1361 | Verbatim instruments: 34-mistake taxonomy, question typologies, LLM-interviewer results |
+| [baseline/](planning/process-model-elicitation/baseline/) | active | gisted in FE-1361 resolution | Baseline-control experiment: protocol, situation pack, v0 prompt, runner, both transcripts, scored read-out |
+| [ir-design](planning/process-model-elicitation/ir-design.md) | active | gisted in FE-1364 resolution | The IR design: Layer A (ratified on worked examples, FE-1397; definition sentence amended by ADR-0003) + the CPS plugin's ten-kind payload (Layer B) |
+| [ir-worked-examples](planning/process-model-elicitation/ir-worked-examples.md) | active | gisted in FE-1397 | Layer-A validation across Gherkin/CPS/BPMN + assurance: property verdicts, amendments, sublimation findings |
+| [ir-design-plain](planning/process-model-elicitation/ir-design-plain.md) | active | strain findings on FE-1401 | Plain-prose rendering of the IR design; the rendering pass doubled as review (7 strain findings, one load-bearing) |
+| [notes/research-patterns-audit](planning/process-model-elicitation/notes/research-patterns-audit.md) | active | FE-1401 / card inputs on FE-1403 | Plain-language audit of ~30 research imports in 7 families, evidence-graded, with an 8-point strain appendix |
+| [notes/penciled-directions-2026-08-14](planning/process-model-elicitation/notes/penciled-directions-2026-08-14.md) | active | FE-1401 | Penciled directions from the legibility session: 8 items with firming actions + editorial reflections |
+| [capture-store-plain](planning/process-model-elicitation/capture-store-plain.md) | active | strain findings on FE-1401 | STE-leaning rendering of the capture-store semantics (FE-1390/FE-1389) with a load-bearing not-guaranteed section; 8-point strain report incl. two command-reachable unclosable-conflict paths (confirms FE-1419 commits 7/8) and the FE-1405 status-arity answer |
+| [notes/deep-read-fe-1389](planning/process-model-elicitation/notes/deep-read-fe-1389.md) | active | FE-1401 / findings in FE-1420 | Deep-read of the walking skeleton: builder's account, spec-discharge table (issues 10/13 capabilities discharged; markdown floor contradicted in the UI), 12 findings; source of PR #10's backfilled record |
+| [notes/deep-read-fe-1390](planning/process-model-elicitation/notes/deep-read-fe-1390.md) | active | FE-1401 / probes on FE-1419 | Deep-read of the capture store: spec-discharge table, write-time tiering assessment (penciled item 7), the FE-1405 status-arity answer, and live-probed confirmation of FE-1419's capture-store claims plus one new aliasing hole; source of PR #11's backfilled record |
+| [plugin-contract-spec](planning/process-model-elicitation/plugin-contract-spec.md) | active | FE-1431 (spec issue); decided on FE-1405 | Provisional spec: a plugin is two schemas and two tables (model schema, proposal catalog, fold table, demand table) over the three-register IR (ADR-0003) — harness-machinery typology, standard-interiors library, grade-as-narrowing, derived fold rules; strains 4–7 and envelope pressure #2 held open with owners |
+
+## planning/\_shared (cross-effort control documents)
+
+| Document | Status | Linear | Digest |
+| -------------------------------------------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [COORDINATION](planning/_shared/COORDINATION.md) | active | cross-project; maintained by arc-close | Current sequencing recommendation, soft cross-map edges, unresolved seams, and exceptional roots; hard blockers, state, and hierarchy remain in Linear |
+| [hash-monorepo-import-plan](planning/_shared/hash-monorepo-import-plan.md) | active until FE-1437 lands | FE-1437 | Native HASH assimilation plan: preserved history and child package workspaces under one Brunch context root, explicit authority cutover, exhaustive repository-material disposition, toolchain port, boundary gates, and verification |
+| [SPEC-LEDGER](planning/_shared/SPEC-LEDGER.md) | active until milestone-one closure | FE-1383 | Obligation-level status and evidence ledger for the elicitation-kernel specification; settles when the milestone closes |
+| [flue-architecture-cheatsheet](planning/_shared/flue-architecture-cheatsheet.md) | active | commented on FE-1383; feeds docs/agents/flue-routing.md | Architect's consolidation of all 21 Flue guide pages: direct structured generation uses `harness.prompt`; model-delegated work uses `useSubagent`; three-lane boundary summary and ranked divergence risks; reconciled against installed Flue 2.0.3 source |
+| [topology](planning/_shared/topology.md) | active | ratified → ADR-0002; N1 discharged by FE-1422 + FE-1392; local N5 implemented by FE-1391; N3 amended by FE-1437 | Pseudo-style verification of the package/app tree against the three-lane model and spec §12.2: portable ask/sweep protocols, Flue binding wiring, package boundaries, and application-only Brunch–Petrinaut composition |
+
+## planning/legibility-sweep (FE-1401 arc records)
+
+| Document | Status | Linear | Digest |
+| ------------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [refactor-queue-2026-08-14](planning/legibility-sweep/refactor-queue-2026-08-14.md) | active | FE-1419 | Nine-commit refactor queue from an inductive review of open PR comments: capture-store contract closure + verification-oracle integrity; second-order review of the FE-1400 sweep's own countermeasures |
+| [flue-patterns-audit-2026-08-17](planning/legibility-sweep/flue-patterns-audit-2026-08-17.md) | active | commented on FE-1383 | Audit of Flue usage against the official docs: substantially canonical; two fragile spots since fixed; its two "undocumented semantics" strain items were later resolved by the cheatsheet's agent-hooks read (documented after all — the pins stay) |
+| [flue-entry-projection-source-read-2026-08-18](planning/legibility-sweep/flue-entry-projection-source-read-2026-08-18.md) | active | FE-1391 source gate + FE-1392 refresh oracle; reshapes FE-1386 | Installed Flue 2.0.3 evidence followed through to the public reader/archive and a causal refresh-before-apply oracle; FE-1386 remains one behavioral pin |
+| [remediation-plan-2026-08-17](planning/legibility-sweep/remediation-plan-2026-08-17.md) | active | A1 discharged by FE-1422; A3/B1 by FE-1391; B2/B3 by source read + FE-1392 | Two ledgers from the consolidated sweep. FE-1392 resolves the private-model-call seam with direct `harness.prompt`, while keeping free-text/abandoned accounting in FE-1420 and the compaction pin in FE-1386 |
+| [review-remediation-2026-08-18](planning/legibility-sweep/review-remediation-2026-08-18.md) | settled | FE-1432 | Executed queue from the cross-stack review: six lens-backed findings fixed, all 15 residual threads adjudicated and resolved, below-gate findings owned or refused, and three graduation proposals routed to FE-1401's tooling lane |
+| [issue-pr-migration-2026-08-20/](planning/legibility-sweep/issue-pr-migration-2026-08-20/) | settled | FE-1451 | Completed legibility migration: byte-exact snapshots of 73 Linear issues and 25 GitHub PRs, reviewed proposals, canonical stored-target hashes, rollback data, a drift-gated validator, and an append-only apply log |
+
+## Decision records (`docs/adr/`)
+
+Decisions taken _after_ the elicitation-kernel spec settled. The original spec text remains the
+record of what was decided in August; later changes live in ADRs and, when an accepted execution
+contract requires the spec to carry the new operating truth, in explicitly dated amendments.
+
+| Document | Status | Linear | Digest |
+| ------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [0001-brunch-is-the-product-name](adr/0001-brunch-is-the-product-name.md) | accepted | FE-1388; package naming amended by FE-1437 | `brunch` remains the product and durable-agent identity: `brunch_*` tools and `brunch-gherkin-elicitor`; FE-1437 replaces the standalone `@brunch/*` scope with HASH's `@hashintel/brunch-agent*` package family |
+| [0002-topology-and-placement-rules](adr/0002-topology-and-placement-rules.md) | accepted | FE-1401; FE-1422 is its one code change | The three-lane topology and placement rules N1–N6 ratified; N3 now places remote Brunch and Petrinaut composition only in applications; N2/N5 become boundary gates |
+| [0003-three-register-ir](adr/0003-three-register-ir.md) | accepted | FE-1405 | The IR is the elicited conceptual model, derived by a pure fold — three registers (assertions / model / projections); write-time-only semantics; promotion never refusal; amends ir-design.md Layer A's definition sentence; full FE-1397-style pass is a stated condition |
+| [0004-in-petrinaut-staging-and-the-monorepo-import](adr/0004-in-petrinaut-staging-and-the-monorepo-import.md) | accepted | FE-1433; amended by FE-1437 | September demo stages inside demo.petrinaut.org; the private package family lives under one Brunch context root; `apps/brunch-agent` is the Petrinaut-independent remote server; `apps/petrinaut-website` is the compile-time Brunch–Petrinaut meeting point |
+
+## External canonical documents
+
+| Document | Where | Digest |
+| --------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [Brunch — September Plan](https://www.notion.so/hashintel/Brunch-September-Plan-3b33c81fe02480a5af6bf3089c3ee640) | Notion | Product leadership's September demo vision |
+| [Petri Net business use cases (DB)](https://www.notion.so/hashintel/3893c81fe0248064baa9c13fed48e016) | Notion | Use-case database incl. the spec'd Production Scheduling exemplar |
+| [Eliciting process models: open questions](https://www.notion.so/hashintel/3b93c81fe024801b89b3cf63a9a6ff20) | Notion | Team-facing known-unknowns doc, awaiting comments |
+| [Cyber-physical process elicitation (Dora, PRO-98)](https://www.notion.so/hashintel/3b93c81fe0248019a7beda9bb31df2c8) | Notion | 14-category elicitation ontology + strategy outline + UX ideas; input claims for FE-1362/63/64 grilling — several categories map to net constructs, several "live only in the intermediate representation" |
+| Wayfinder maps FE-1357 (live), FE-1366 (archive) | Linear | Issue-tracker records; see `docs/agents/issue-tracker.md` |
+
+## Path migration note (2026-08-12)
+
+`.scratch/` is retired. Both efforts moved wholesale to `docs/planning//` (structure
+preserved; relative links fixed). Linear references to `.scratch/...` paths predating this date
+map 1:1 to `docs/planning/...`.
diff --git a/libs/@hashintel/brunch-agent/docs/adr/0001-brunch-is-the-product-name.md b/libs/@hashintel/brunch-agent/docs/adr/0001-brunch-is-the-product-name.md
new file mode 100644
index 00000000000..a12644b40a3
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/adr/0001-brunch-is-the-product-name.md
@@ -0,0 +1,80 @@
+# ADR-0001: `brunch` is the product name, and it may appear in structure
+
+Date: 2026-08-13
+Status: accepted
+Amended: 2026-08-20 by ADR-0004 / FE-1437 (HASH package namespace)
+Supersedes: spec [§12.3](../spec.md#123-naming--tool-namespacing) in part
+Decided on: FE-1388
+
+## Context
+
+The spec was written while the product name was open. Its preamble calls
+"elicitation kernel" and "brunch-lite" working labels, and §12.3 rules that
+_nothing bakes "elicit" or "brunch" into structure_, with a provisional tool
+prefix of `bl_*`.
+
+That rule bundled two different concerns:
+
+1. **Don't name a thing after what it does.** `elicit_*` fixes the product's
+ purpose in every model-facing string it owns, and the purpose is exactly
+ what a pluggable-target architecture expects to generalize.
+2. **Don't bake in a label you expect to discard.** `brunch` was assumed
+ temporary, so committing to it would have meant a rename later across
+ package names, the npm scope, tool names, and durable agent identities.
+
+The second assumption no longer holds: `brunch` is expected to stick.
+
+Carrying `bl_*` in the meantime had a cost the spec did not anticipate. `bl` is
+brunch-lite's initials, so it never actually satisfied §12.3 — it only obscured
+the label. And an unresolved name is worst where it is most expensive to
+change: Flue's `agentName` keys durable conversation storage, so every day the
+name stayed provisional was a day the eventual rename got dearer.
+
+## Decision
+
+Adopt `brunch` as the product name and let it appear in structure.
+
+- **Product name**: `PRODUCT_NAME = 'brunch'` in `@hashintel/brunch-agent`, the single
+ source every model-facing string derives from.
+- **Tool prefix**: `brunch_*` — so `brunch_ask`, computed via `toolName()`,
+ never written as a literal.
+- **Package names**: the standalone prototype uses `@brunch/*`. FE-1437 moves the private package
+ family into HASH's organizational scope as `@hashintel/brunch-agent` and role-suffixed companions
+ (`@hashintel/brunch-agent-binding-flue`, `@hashintel/brunch-agent-transport-aisdk`,
+ `@hashintel/brunch-agent-plugin-gherkin`). This amends package placement only; `brunch` remains
+ the product identity.
+- **Agent identity**: a **noun compound, target first**, product-prefixed:
+ `brunch-gherkin-elicitor`. The next target reads `brunch-assurance-elicitor`,
+ so the family sorts together.
+
+**Concern 1 above survives intact.** The prefix names _identity, not function_:
+`elicit_*` remains forbidden, and `packages/elicit-*` with it. A test in
+`packages/core/test/naming.test.ts` enforces this; the corresponding ban on
+`brunch` is deliberately removed there rather than left to rot.
+
+## Why the agent identity carries the product prefix
+
+This is the one place the prefix is load-bearing rather than cosmetic.
+
+Flue agent identities are **global per application** and key durable
+conversation storage. The demo shell (see `CONTEXT.md`) is chartered to consume
+this library _and_ the Petrinaut libraries in one application. A bare
+`gherkin-elicitor` could collide with an agent from another library, and the
+collision would land on durable storage rather than failing at boot.
+
+The exported symbol stays the shorter `GherkinElicitor`, because it reads
+better at the mount site. Flue's `agentName` static exists precisely so durable
+identity and source-level name can differ.
+
+## Consequences
+
+- Spec §12.3's `bl_*` provisional and its "nothing bakes in `brunch`" clause
+ are **superseded by this ADR**. The original spec text remains the settled August record; dated
+ amendments carry later operating truth when an accepted execution contract requires it.
+- FE-1437 supersedes this ADR's standalone `@brunch/*` scope and role-prefixed basename rule with
+ the HASH package names above. The tool prefix and durable agent identity are unchanged.
+- A future rename is still one edit for everything model-facing, because the
+ derivation stayed. It is **not** one edit for `agentName` — that string is
+ durable-storage-keyed, and changing it orphans existing conversations. Treat
+ it as permanent.
+- `plugin-assurance`, when authored, takes `brunch-assurance-elicitor`.
diff --git a/libs/@hashintel/brunch-agent/docs/adr/0002-topology-and-placement-rules.md b/libs/@hashintel/brunch-agent/docs/adr/0002-topology-and-placement-rules.md
new file mode 100644
index 00000000000..b8a56713755
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/adr/0002-topology-and-placement-rules.md
@@ -0,0 +1,54 @@
+# ADR-0002: The three-lane topology and placement rules N1–N6
+
+Date: 2026-08-17
+Status: accepted
+Amended: 2026-08-20 by ADR-0004 / FE-1437 (N3 application placement)
+Refines: spec [§12.2](../spec.md) (package topology) with placement
+rules the spec did not state
+Decided on: FE-1401 (remediation sweep); ratified by Lu, 2026-08-17
+
+## Context
+
+The Flue architecture cheatsheet's full read of the framework's affordance surface
+([flue-architecture-cheatsheet](../planning/_shared/flue-architecture-cheatsheet.md)) sorted this
+system into three lanes: shell-facing affordances to consume directly, agent-loop capabilities
+to translate in the binding, and elicitation semantics plus the capture store to own outright.
+The topology verification ([topology.md](../planning/_shared/topology.md)) then walked the actual tree
+against that model and spec §12.2 and found one violation — the ask/suspension protocol's
+portable mechanism living in the Flue binding (the §14.2 second-binding test failing in
+spirit) — and no rule governing where upcoming work lands, which is how the violation happened.
+
+## Decision
+
+The three-lane model and placement rules N1–N6, as specified in
+[topology.md](../planning/_shared/topology.md), are ratified. In brief:
+
+- **N1**: the ask protocol extracts to `core/src/ask-protocol.ts` (FE-1422, before FE-1392);
+ sweep mechanism starts in `core/src/sweep-protocol.ts` from day one. Bindings contain hook
+ wiring only.
+- **N2**: plugin-owned content (packs, cards) ships as plugin-package exports registered by
+ hosts — never per-agent `skills/` directories holding plugin content inside an app. Quiver
+ content is harness-shipped under the same rule.
+- **N3**: there is no dedicated demo shell. Applications are the only composition boundary:
+ imported `apps/brunch-agent` owns the remote Brunch server, target gallery, and diagnostics;
+ `apps/petrinaut-website` owns the September user-facing integration. Reusable Brunch and
+ Petrinaut libraries remain mutually unaware.
+- **N4**: experiment runners live beside their planning docs, JS-API pattern, `observe()`
+ accounting — never in `packages/`.
+- **N5**: storage-port implementations live one per (binding × deploy target), always in the
+ binding package, implementing core's `CaptureStore` with parse-on-read.
+- **N6**: `plugin-assurance`, when chartered, mirrors `plugin-gherkin`'s shape.
+
+`topology.md` remains the living reference (tree, per-node rules, tolerated-untils); this ADR
+records that its rules are decided, not proposed.
+
+## Consequences
+
+- FE-1422 is the one code change the ratification demands; it blocks FE-1392 by design.
+- The enforceable rules become boundary gates: N2 (no plugin content in app skills
+ directories) and N5 (port implementations only in bindings) get mechanical checks in
+ `packages/core/test/architecture/boundaries.test.ts` — designed with red-proofs, not filename
+ heuristics, per the
+ FE-1419 queue's discipline.
+- The two tolerated-untils stand: `chat.tsx` until FE-1385 adopts `@flue/react`; the flat
+ agent file until a second agent forces per-agent folders.
diff --git a/libs/@hashintel/brunch-agent/docs/adr/0003-three-register-ir.md b/libs/@hashintel/brunch-agent/docs/adr/0003-three-register-ir.md
new file mode 100644
index 00000000000..41c926cc4b4
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/adr/0003-three-register-ir.md
@@ -0,0 +1,75 @@
+# ADR-0003: The IR is the elicited model, derived — three registers, not one
+
+Date: 2026-08-18
+Status: accepted
+Amends: [ir-design.md](../planning/process-model-elicitation/ir-design.md) Layer A (the
+"Definition" paragraph), ratified FE-1364/FE-1397
+Decided on: FE-1405 (payload-interiors session); ratified by Lu, 2026-08-18
+
+## Context
+
+Layer A defined the intermediate representation as "the set of active captures, read through
+the plugin's declared payload type system," with every consolidated view a read-time
+projection. Working FE-1405's payload interiors against the baseline transcripts exposed the
+problem with that sentence: a bag of typed assertions is not a model of the plant. Mapping
+captures to a catalog of semantic types is a first small step that quietly delegates the
+actual assembly of a domain model to something several degrees removed — and unowned. The
+questions the system exists to answer ("is this model complete enough to answer the expert's
+objective?") are questions about a _model_, not about captures.
+
+The obvious fix — assemble the model at read time — founders on a real chasm: bridging "half
+a shift" or "every week or two" to model slots takes semantic interpretation, i.e. LLM
+inference, and interpretation hidden inside a read path is unauditable and unreproducible.
+
+## Decision
+
+Three registers, each with a designed schema:
+
+1. **Assertions** (captures) — answer-shaped typed semantic proposals, envelope-wrapped:
+ verbatim forms, hedges, absences, provenance. Every write-time semantic act — unit
+ parses, identity links, compositions, formalizations — is deposited here as a capture
+ (`inferred`, supersedable, contestable).
+2. **The elicited model** — the IR proper: domain-shaped, expert vocabulary, node kinds with
+ slots. **Derived, never stored**: assembly is a pure fold over active captures, forbidden
+ to interpret. Gaps surface as typed issues, never as silent inference. Every model part
+ answers "which captures made you."
+3. **Projections** — the net, the loss report, the completion table — derived from
+ register 2.
+
+Binding rules that make the split honest:
+
+- **Write-time-only semantics.** No semantic act at read time; every bridge is a capture.
+ The model is a pure function of the store, and the store contains every semantic judgment
+ the assembly needs, because the assembly is forbidden to make any.
+- **The acceptance oracle.** A second projection must be able to consume the conceptual
+ model without rereading the transcript or semantically interpreting generic capture
+ fields. If it can't, we have a capture ledger, not an IR.
+- **Promotion, never refusal.** Low-grade statements ("about 3 hours") are captured
+ honestly; the forcing function is that they never promote to a demanded grade without a
+ higher-grade capture superseding them.
+
+"No second store" survives intact — register 2 is a derivation, not a persistence surface.
+Layer A's five MUST properties and its recommended patterns are unamended; what changes is
+the definition sentence: the IR is register 2, and "active captures read through the payload
+type system" describes register 1.
+
+## Condition
+
+FE-1397's ratification bar applies to this amendment as it did to the original: worked
+designs across at least three plugin targets. Discharged so far by thumbnails only (Gherkin
+thin, formal-verification mid, CPS in full — recorded in the FE-1405 shapes work); a full
+FE-1397-style property-by-property pass is a stated condition on this record. Until the
+September build exercises a real fold, everything here is desk-validated, like the canon it
+amends.
+
+## Consequences
+
+- The FE-1405 output is a plugin _contract_, not just payload shapes: a model schema and a
+ proposal catalog (the two registers' declarations) joined by a fold table and a demand
+ table. Spec forthcoming from the session's working material.
+- Completion (FE-1402) computes over register-2 slot states, not over capture counts.
+- The sweep (FE-1392) becomes the single point of semantic failure by design — mitigations
+ travel with the FE-1405 handoff notes.
+- The capture envelope is untouched. The one pressure this work confirmed (absence captures
+ carry no locator, so a field-specific absence cannot name its slot) stays recorded at the
+ seam, pending adjudication — not forked around.
diff --git a/libs/@hashintel/brunch-agent/docs/adr/0004-in-petrinaut-staging-and-the-monorepo-import.md b/libs/@hashintel/brunch-agent/docs/adr/0004-in-petrinaut-staging-and-the-monorepo-import.md
new file mode 100644
index 00000000000..990ded32c95
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/adr/0004-in-petrinaut-staging-and-the-monorepo-import.md
@@ -0,0 +1,83 @@
+# ADR-0004: In-Petrinaut demo staging and the brunch-agent monorepo import
+
+Date: 2026-08-18
+Status: accepted
+Amended: 2026-08-20 by FE-1437 (package family and imported application charter);
+2026-08-21 by FE-1437 (Brunch context root)
+Supersedes: the demo-shell recommendation in
+[recommendation-demo-vehicle](../planning/process-model-elicitation/recommendation-demo-vehicle.md)
+(FE-1362's resolution); amends ADR-0002's rule N3
+Decided on: the 2026-08-18 integration meeting (Dei, Chris, Lu); recorded on FE-1433
+
+## Context
+
+The demo-vehicle recommendation (FE-1362, the September demo staging decision) proposed a
+one-off demo shell consuming the elicitation and Petrinaut libraries, meeting at the artifact
+boundary. The 2026-08-18 integration meeting decided otherwise: the desired staging is
+**integration into demo.petrinaut.org** (`apps/petrinaut-website` in `hashintel/hash`) — the
+product view being that the existing implementation surface should be extended rather than
+paralleled.
+
+A re-assessment against the Petrinaut survey (FE-1358, the read-only architecture audit)
+found the topology viable without changes to the Petrinaut chat panel: the panel's
+`aiAssistant` prop takes a host-supplied `ChatTransport`, wraps it with its own decorators, and
+executes tool calls client-side against the live editor. What Petrinaut lacks — a server-side
+home for a stateful agent loop with durable persistence — is exactly what the elicitor server
+is. The meeting also settled the library's name and destination: `@hashintel/brunch-agent`,
+imported with git history into the `hashintel/hash` monorepo as a native workspace alongside
+`@hashintel/petrinaut`. The review and spike gates for that import are now satisfied.
+
+## Decision
+
+1. **Staging**: the September demo runs inside demo.petrinaut.org. The elicitor is a **remote
+ server** the site's chat panel addresses through the `aiAssistant` transport; the site's
+ stock `/api/chat` proxy and `petrinautAiPrompt` are bypassed entirely for brunch sessions.
+2. **Identity of the package family**: `@hashintel/brunch-agent` is the harness package, imported
+ into `hashintel/hash` with git history preserved and accompanied by the independently installable
+ `@hashintel/brunch-agent-binding-flue`, `@hashintel/brunch-agent-transport-aisdk`, and
+ `@hashintel/brunch-agent-plugin-gherkin` packages. All four are private through the import and
+ live under `libs/@hashintel/brunch-agent/packages/`, with the harness implementation in
+ `packages/core`; the harness does not re-export its extensions.
+3. **Boundary discipline** (the rule the monorepo makes easy to erode): `@hashintel/petrinaut`
+ stays elicitor-agnostic; `@hashintel/brunch-agent` stays renderer-agnostic;
+ **`apps/petrinaut-website` is their compile-time meeting point**. The `apps/brunch-agent`
+ server remains Petrinaut-independent and meets the website only through the AI SDK/HTTP
+ transport. Any Petrinaut-library change brunch needs is made as a generic host extension (e.g.
+ host-supplied client-tool handlers on the `aiAssistant` prop), never as brunch-specific
+ code in the library.
+4. **Session identity**: the principal lives in the ui shell, per spec §4 — a
+ localStorage-stored random UID sent on the transport for the demo site; HASH's Ory identity
+ later. The elicitor server resolves principal → session set; the harness stays
+ principal-free (keyed by session id), with an opaque owner key at the storage port for
+ store-level refusal of cross-principal access.
+5. **N3 amended**: ADR-0002's "the demo shell is `apps/demo`" is retired — there is no demo
+ shell. The imported `apps/brunch-agent` re-charters `apps/dev` as the remote Brunch server,
+ carrying forward its target-gallery and diagnostics charter as internal operational roles. The
+ September user-facing application is `apps/petrinaut-website`.
+6. **Context locality**: `libs/@hashintel/brunch-agent/` is the Brunch context and agent-session
+ root. It owns the domain glossary, decisions, guidance, planning records, and the four child
+ package workspaces. It is not a package-manager root and carries no package manifest, lockfile,
+ or competing toolchain. `apps/brunch-agent` remains at HASH's application root and points back
+ to this context authority.
+
+The artifact boundary (versioned net file + scenario through `parseSDCPNFile`) remains the
+inter-library contract and the "it's just a file" demo beat; what this ADR changes is where the
+elicitor is staged, not what it emits.
+
+## Consequences
+
+- FE-1362 re-resolves to this decision; FE-1333 (the integration-definition ticket) closes on
+ this ADR; FE-1331 (start elicitation from create-new-net) is un-deferred — in-Petrinaut
+ initiation is now the September topology, not the post-September one.
+- The integration build was specified on FE-1433
+ ([petrinaut-integration-spec](../planning/process-model-elicitation/petrinaut-integration-spec.md)),
+ and both gating spikes reported: Flue turn suspension carries client-tool round-trips, and the
+ Pi-to-AI-SDK stream adapter drives Petrinaut's panel.
+- The review stack and spikes have landed. FE-1437 records the final standalone SHA and imports
+ that history; from that point unfinished work continues only in `hashintel/hash`.
+- Grouping the package family under one context root preserves one glossary and one agent operating
+ surface without recreating the standalone Bun workspace.
+- The Flue server's deployment home is an open question owned with infra (Postgres exists;
+ deployment is unblocked by the import decision — the code will live in `hashintel/hash`).
+- FE-1433 applied the transport and suspension amendments. FE-1437 explicitly amends §12.2 and
+ §12.5 for the imported package family and application charter.
diff --git a/libs/@hashintel/brunch-agent/docs/agents/arc-close.md b/libs/@hashintel/brunch-agent/docs/agents/arc-close.md
new file mode 100644
index 00000000000..5f24239ee15
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/agents/arc-close.md
@@ -0,0 +1,81 @@
+# Arc close
+
+Arc close is the required final control pass before submitting a branch that closes a work arc.
+Load the `arc-close` skill and run this protocol; do not rely on remembering its constituent
+checks. It complements, rather than replaces, the legibility protocol's close-out rendering for
+arcs with significant agent-generated output.
+
+An arc is closing when a bounded branch or session lands implementation, settles a planning or
+design decision, closes or materially changes a Linear issue, or changes a project control
+surface. Exploratory work that leaves no durable truth does not require arc close.
+
+If a conditional pass changes nothing, do not append a dated evaluation or no-op record. Git is
+the history of these control surfaces.
+
+## Required sequence
+
+### 1. Settle the inbox and reconcile the index
+
+Always inspect `docs/inbox/` and `docs/INDEX.md` together.
+
+- Move settled inbox material to its canonical home under `docs/reference/`,
+ `docs/planning//`, or `docs/planning/_shared/`.
+- Delete source material only when its durable information has been transferred and its
+ consumption is named.
+- Ensure every non-archive Markdown document is indexed and every index entry still resolves.
+- Update status, ownership, and digest text when this arc changed their truth.
+
+### 2. Audit the Linear registry and touched references
+
+Always run `turbo run linear:graph --filter '@hashintel/brunch-agent'` and inspect every open project
+issue with no parent.
+
+- Every non-root issue must have a parent.
+- Every intentional root must be a recognized map or sweep root under the registry rule, or be
+ named under **Exceptional roots** in `docs/planning/_shared/COORDINATION.md`.
+- Repair missing parentage in Linear when the intended owner is unambiguous; otherwise record the
+ unresolved root in `COORDINATION.md`.
+- Re-read every issue the arc closes or materially changes. Repair stale branch, dependency,
+ evidence, and document references before closing it.
+- Follow `issue-writing.md`: preserve a root issue's human-owned contract and put agent-maintained
+ detail inside `🏗️ Agent notes`.
+
+### 3. Reconcile the spec ledger when affected
+
+Update `docs/planning/_shared/SPEC-LEDGER.md` in the same change when the arc builds, disproves,
+supersedes, or changes evidence for a milestone-one specification obligation. Change the smallest
+affected set of rows; do not add an evaluation narrative. When milestone one closes, settle the
+ledger as a terminal record rather than keeping it artificially live.
+
+### 4. Reassess project coordination when affected
+
+Reassess `docs/planning/_shared/COORDINATION.md` when the arc changes:
+
+- a hard blocker, issue parent, project membership, or exceptional root;
+- a soft `coord`, `input`, or `state-gate` edge;
+- an unresolved cross-map seam; or
+- issue semantics that could change the current project-wide sequencing recommendation.
+
+Use `turbo run linear:graph --filter '@hashintel/brunch-agent'` for deterministic facts, then read
+the relevant issue bodies and infer the recommendation. Linear remains canonical for hard blockers,
+state, and hierarchy. Keep one compact pseudo-style map of the current recommendation; do not paste
+the full generated graph or maintain alternative or historical orderings. If the judgment did not
+change, leave the document untouched.
+
+### 5. Repair tense and report
+
+Read the arc's planning prose in the state that will exist after landing. Remove stale future
+tense, provisional labels, temporary pointers, and inaccurate status language. Report which
+control surfaces changed and which conditional passes were not applicable; do not persist the
+no-op report in those surfaces.
+
+## Definition of done
+
+Arc close is complete when:
+
+1. inbox and index agree with the tree;
+2. the Linear orphan audit has no unexplained roots;
+3. touched issue references are current;
+4. affected spec-ledger rows are current;
+5. affected coordination judgment is current; and
+6. changed planning prose reads correctly after landing.
diff --git a/libs/@hashintel/brunch-agent/docs/agents/documentation.md b/libs/@hashintel/brunch-agent/docs/agents/documentation.md
new file mode 100644
index 00000000000..daa7b5a05ff
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/agents/documentation.md
@@ -0,0 +1,71 @@
+# Documentation protocol: ingest, settle, index
+
+How documents enter the Brunch context, where they end up, and how we prove nothing is lost. Companion
+to `issue-tracker.md` (which governs issues; this file governs documents).
+
+## Zones
+
+| Zone | Role | Lifetime |
+| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
+| `docs/inbox/` | Untriaged arrivals: PDFs, exports, transcripts, pasted research. Timestamped filenames where the arrival date matters. | Temporary — everything here is awaiting settlement |
+| `docs/planning//` | All artifacts of an effort, active or complete: notes, research findings, maps, specs — nested by effort name. | Permanent (dispositioned at effort close) |
+| `docs/planning/_shared/` | Cross-effort control documents: current coordination, obligation ledgers, topology, and consolidations that outlive any one effort. | Permanent; lifecycle declared by each document |
+| `docs/reference/` | Settled documents of lasting value: external reports, transcripts, digested research. | Permanent |
+| `docs/INDEX.md` | The TOC: one line per settled or in-flight document — title, date, one-line digest, provenance, where used. | Permanent, always current |
+| `CONTEXT.md` | Glossary only (see domain-modeling discipline). | Permanent |
+
+**Ephemera** live outside all zones: any directory named `drafts/` is git-ignored (root
+`.gitignore`), for documents whose delivered form is the record — outbound comment and message
+drafts, one-off prep. Place one directly inside the effort it serves
+(`docs/planning//drafts/` — no deeper nesting). Never register drafts in `INDEX.md`, and never link a
+`drafts/` path from Linear or a committed document — once delivered, link the destination
+(the posted comment, the sent message) instead.
+
+**Control surface vs record.** A _record_ is arc-scoped, carries its date in the filename, and
+eventually stops changing — it lives inside the effort directory that produced it. A
+cross-effort _control surface_ is never dated in the filename and lives in
+`docs/planning/_shared/`. Most remain current; a bounded ledger may declare its own terminal
+condition and settle when that condition is met. The planning top level contains only
+directories; this rule and `INDEX.md` coverage (every file has a row, every row's path resolves)
+are enforced by `packages/core/test/architecture/docs-index.test.ts`. Freshness — whether statuses and digests are still
+_true_ — cannot be mechanized without building a dead gate; it belongs to the arc-close sweep.
+
+External stores (Linear, Notion) hold _pointers and mirrors_, never the only copy: Linear issue
+descriptions gist and link; Notion pages that originate content (e.g. team-facing question docs)
+are listed in `INDEX.md` with their URL.
+
+## The ingest protocol
+
+1. **Arrive**: new material lands in `docs/inbox/` (timestamped name if the date matters).
+2. **Register**: the first time a document is _used_ (read into an effort, cited by a ticket),
+ add its line to `docs/INDEX.md` with status `inbox`.
+3. **Settle**: move it to its permanent home (`docs/reference/` for external/source material;
+ `docs/planning//` if it is a working artifact of that effort), update its `INDEX.md`
+ line (new path, status `settled`), and fix any links that pointed at the inbox path.
+4. **Sweep**: at every effort boundary (map charted, map closed), empty the inbox — everything
+ either settles or is deleted _with its INDEX line recording the deletion and reason_. This
+ runs as step 1 of the arc-close sweep (`arc-close.md`).
+
+## Referencing ephemera (issues, external state)
+
+Long-lived documents outlive the trackers they cite. Three rules:
+
+- **Gloss at first mention**: an issue ID in a living document is introduced with its gist —
+ "FE-1423 (the pre-remote gates)" — so the document degrades gracefully for a reader who
+ cannot resolve Linear.
+- **Load-bearing only in the tracking layer**: coordination and obligation/remediation ledgers
+ may depend on issue resolution — tracking is their job. Everywhere else an issue ID is a
+ parenthetical citation the sentence must survive without.
+- **Tense repair at arc close**: prophecy becomes history when the issue lands ("will
+ extract" → "extracted (FE-1422)") — step 5 of `arc-close.md`.
+
+## Effort completion
+
+When an effort closes, its `docs/planning//` directory is reviewed file-by-file and
+the review recorded in `INDEX.md`: tracker records (map, tickets) are mirrored to Linear if
+not already there; everything else stays in place with status `settled`. (`.scratch/` was
+retired 2026-08-12 — efforts live under `docs/planning/` from birth.)
+
+**Nothing is deleted until its `INDEX.md` line records the disposition.** Linear mirrors gist
+and link; the repo copy is canonical, so a repo path referenced from Linear must never be
+deleted without updating the Linear reference.
diff --git a/libs/@hashintel/brunch-agent/docs/agents/domain.md b/libs/@hashintel/brunch-agent/docs/agents/domain.md
new file mode 100644
index 00000000000..a0a98c58290
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/agents/domain.md
@@ -0,0 +1,45 @@
+# Domain Docs
+
+How engineering skills consume Brunch domain documentation inside the HASH monorepo.
+
+## Before exploring, read these
+
+- **`CONTEXT.md`** at the Brunch context root
+- **`docs/adr/`** — read ADRs that touch the area you're about to work in
+
+If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `ds-domain-modeling` skill (also reached from the grilling and codebase-deepening flows in the same skill set) creates them lazily when terms or decisions actually get resolved.
+
+## File structure
+
+Brunch is one context inside the multi-context HASH repository:
+
+```text
+libs/@hashintel/brunch-agent/
+├── CONTEXT.md
+├── docs/adr/
+│ ├── 0001-example-decision.md
+│ └── 0002-another-decision.md
+└── packages/
+ ├── core/
+ ├── binding-flue/
+ ├── transport-aisdk/
+ └── plugin-gherkin/
+
+apps/brunch-agent/ # host module governed by this context
+```
+
+Package seams do not imply separate domain contexts. Add another Brunch `CONTEXT.md` only if the
+domain language itself diverges enough to require an explicit context map; do not create one merely
+because another binding, transport, plugin, or host appears.
+
+## Use the glossary's vocabulary
+
+When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
+
+If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `ds-domain-modeling`).
+
+## Flag ADR conflicts
+
+If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
+
+> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
diff --git a/libs/@hashintel/brunch-agent/docs/agents/flue-routing.md b/libs/@hashintel/brunch-agent/docs/agents/flue-routing.md
new file mode 100644
index 00000000000..62938e2db1e
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/agents/flue-routing.md
@@ -0,0 +1,43 @@
+# Flue routing: what to rely on, by symptom
+
+Consult this at design moments — when you notice yourself about to add state, a layer, a
+loop, a route, or a test harness — _before_ writing the new thing. Each row routes an
+indication to the affordance to rely on, the divergence it exists to prevent, and the point
+where canon stops and a human or an owning ticket decides. Every row is grounded in the
+[architecture cheatsheet](../planning/_shared/flue-architecture-cheatsheet.md) (§ refs), the
+[patterns audit](../planning/legibility-sweep/flue-patterns-audit-2026-08-17.md), or the
+[flue-vs-tilde analysis](../reference/amp-analysis-flue-vs-tilde.md); details live there.
+
+**Which lane am I in?** Flue's surface sorts our system into three lanes (cheatsheet,
+boundary summary). _Shell-facing_ (UI transport, observability, evals, schedules, deploy):
+consume Flue directly, never wrap. _Agent-loop_ (tools, state, suspension, subagents,
+projection reads): translate in the binding — the eight-capability list is the line.
+_Elicitation semantics + capture store_: ours outright; canon itself says "your application
+should manage its own data store separately". If your change doesn't fit its lane, that is
+the finding — stop and check the boundary summary before proceeding.
+
+## Routing table
+
+| Indication | Rely on | Never | Escalate when |
+| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| You're about to persist **per-conversation** state | `usePersistentState` — atomic with the unit of work; updater form sees the latest write, documented ([agent-hooks](https://flueframework.com/docs/guide/agent-hooks/); §2) | A side file or table keyed by conversation id — a parallel copy of Flue's own record | The state is really per-_target-document_ → next row |
+| You're persisting **cross-conversation** state (captures, issues, target-documents) | The storage port / capture store — lane 3, ours ([database](https://flueframework.com/docs/guide/database/); §5) | `db.ts` or DO SQLite for captures — Flue's stores are conversation-scoped, and the analysis confirms the capture store stays application-owned under every future | Schema changes → the archive slot's `migrate()`-style versioned provisioning (FE-1391) |
+| You need the **model to see a harness fact** | `ctx.append` signal entries (same-response) or tool results; instructions stay render-invariant (§2; spec §7.4) | Interpolating state into instructions — that is the wake-wart, killed at its cause in FE-1389 | The fact must also be user-visible → §9.3 insertion notice, FE-1396 |
+| You're adding a **second place that renders conversation parts** | `useFlueAgent()` — parts-based messages; the affordance arrives as a `dynamic-tool` part whose `.output` is the validated payload, text parts as floor ([react](https://flueframework.com/docs/guide/react/); §4) | Growing `chat.tsx` feature-by-feature into a hand-rolled client — divergence risk 1, and it's how the markdown floor broke | Adoption timing is FE-1385 / demo-shell; the floor fix is FE-1420 — don't build it twice |
+| You're touching the **kickoff or injected entries** | `useInitialData` (recorded once, structurally non-user) or a dispatched `signal` (§2, §4) | Machine-authored `kind: 'user'` entries — anchorable non-utterances that launder system words into the person's mouth (trace §9.4) | The re-entry briefing's insertion notice — FE-1396 |
+| You need a **private model call** | For deterministic structured work inside a harness tool, `harness.prompt(..., { result })` ([tools](https://flueframework.com/docs/guide/tools/); §3). For model-chosen specialist delegation with its own frame/model, declare `useSubagent`; the parent invokes it only through the model-visible `task` tool ([subagents](https://flueframework.com/docs/guide/subagents/); §2) | A second provider client hand-rolled inside the binding, or pretending `useSubagent` returns a callable delegate | The deterministic call needs a distinct model/frame → accept model-driven `task` delegation or raise a substrate-capability decision; do not blur the two surfaces |
+| You want **work to survive a crash** | `durable: true` tools + `step.do` — exactly-once-recorded; hooks run at-least-once, so guard side effects with persistent state ([tools](https://flueframework.com/docs/guide/tools/); §3, §5) | Assuming an external effect ran once — the at-least-once floor is universal (tilde analysis); content-keyed dedup exists _because_ of it | Orchestration itself must survive interruption → external engine seam (§6), a human call |
+| You're adding an **HTTP surface** | Explicit mounts in `app.ts`; `createAgentRouter`; auth as app middleware ([routing](https://flueframework.com/docs/guide/routing/); §1, §4) | Side-channel servers or file-based routing — removed in 2.0; `app.ts` owns every mount | Anything exposed beyond localhost → pre-remote row below |
+| You're writing a **loop that drives an agent through turns** (experiment, runner, cron) | The JS-API workflow pattern: `start()` + `init()` + `dispatch()`/`read()`; schedules resolve on durable admission ([workflows](https://flueframework.com/docs/guide/workflows/), [schedules](https://flueframework.com/docs/guide/schedules/); §6) | A bespoke runner daemon — the baseline runner already converged on the documented pattern independently | FE-1404 owns the armed rerun's design |
+| You're writing a **test that boots the runtime** | In-process `start()` + `init()` (no id → fresh conversation) + `read()`; faux provider; the reviewed substrate inventory in `test/boundaries.test.ts` ([evals](https://flueframework.com/docs/guide/evals/); §7) | Substrate imports outside the inventory; substring oracles over stringified history when `onEvent` captures tool calls directly | The code under test rides the Vite graph (skills) — plain runners can't load it; eval config or fixture-grade copies (reconciliation §) |
+| You're **counting tokens or costs** | `observe()` `turn` events (`totalTokens`, `cost`, cache splits); `useResponseFinish` in-agent ([observability](https://flueframework.com/docs/guide/observability/); §7) | Hand-counting from transcripts — divergence risk 5 | Cross-process aggregation — events are live-only; export via OTel instead |
+| You're **scoring elicitation quality** | `vitest-evals` judges (`createJudge`, `FactualityJudge`) asserting behavioral contracts (§7) | String assertions on real-model output | FE-1407's failure catalogue is the rubric source |
+| You're **loading guidance content into the prompt** | Skills: name+description in prompt, `activate_skill` for full instructions — progressive disclosure _is_ the card economy of penciled item 4; `defineSkill` for programmatic packs; `useInstruction()` for always-on content ([skills](https://flueframework.com/docs/guide/skills/); §3) | A bespoke card loader in the harness — divergence risk 3 | Card-to-skill compilation is FE-1403/FE-1406 design; keep card content assertable outside the Vite graph |
+| You're about to **wrap a Flue API in a binding layer** | The three-lane test (boundary summary): shell-facing → consume directly; agent-loop → it should already be on the eight-capability list | Wrapping lane-1 affordances — a parallel SDK, lens-2 debt at the API level | A genuinely new capability → extend `capabilities.ts` and apply the second-binding test (spec §14.2) |
+| You're **archiving or reading conversation history** | `createFlueClient({ url, fetch? }).history()` — one unpaged public materialized-message snapshot. The host injects the full conversation URL because Flue cannot discover its mount; custom `fetch` plus the router's `.fetch` is the candidate in-process composition (source-read record; §4, §5) | Shadow-recording entries inside hooks, consuming private canonical record types, or inventing offset arithmetic — all create a drifting second protocol (divergence risk 4) | FE-1391 must pin the in-lifecycle transport, archive pointer identity (public IDs are not canonical ranges), and identity-keyed merge/version semantics for repeated snapshots; retention authority vs. transport copy remains adjudicated (spec §9.6) |
+| You're about to **expose the demo remotely** | The four gates, all before exposure: auth + per-conversation authorization (the mounted route is public), runtime telemetry, persisted-state versioning/backup, restart durability. All four are ticketed as **FE-1423** (FE-1396 blocks it, covering durability); they are requirements, not recommendations (ratified 2026-08-17) | Exposing the mounted route while any FE-1423 gate is open | A deploy-target choice → N5 applies (new storage-port impl in the binding, never a leaked path assumption) |
+| You're **deploying the demo shell** | `dist/server.mjs` + a real `db.ts` adapter; one live owner per conversation; env read at startup only (§1) | Active-active replicas behind a shared database — the one-owner rule is not relaxed by sharing storage | Cloudflare is not a casual choice: per-object SQLite replaces `db.ts` and the capture store needs a separate cross-conversation design (§8) |
+| You're **upgrading Flue** | Re-verify the walking-skeleton pins (`boundReplyReachedModel`, `secondAskRejected`, `noInstructionWake`) and the FE-1386 compaction/history/state pin — they protect documented-but-load-bearing or source-settled semantics (audit; source-read record; §2/§5) | Treating minor bumps as safe or docs' future tense as shipped — 2.0.0 rewrote the architecture days before 2.0.3, and beta stores were rejected with no migration path (reconciliation §) | Any pin flips → stop; re-read agent-hooks, streaming protocol, and durability before adapting the binding |
+
+Rows route to tickets by design: if your situation's "Escalate when" names an issue, the
+decision belongs there — record it there, not in the code comment.
diff --git a/libs/@hashintel/brunch-agent/docs/agents/git-workflow.md b/libs/@hashintel/brunch-agent/docs/agents/git-workflow.md
new file mode 100644
index 00000000000..90600c48944
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/agents/git-workflow.md
@@ -0,0 +1,47 @@
+# Git workflow: Graphite stacks
+
+Branches are managed with **Graphite** (`gt`), matching HASH's repository-wide use of Graphite
+(its CI runs the Graphite optimizer). The standalone repository's late `gh stack` convention does
+not carry over. The unit of branching is the **Linear issue**: one stacked branch per issue tackled,
+created when work on that issue starts. Work discovered while
+resolving the issue (slices, refinements, side-fixes it requires) stays on its branch; only a
+different issue gets a new branch. Branches predating this convention (and the trunk) may mix
+multiple issues.
+
+## git vs gt boundary
+
+Use **git** for local operations that don't touch the stack: `status` / `diff` / `log`,
+`add` / `commit`, `stash`. Use **`gt`** for stack-aware operations: `gt create`, `gt submit`,
+`gt restack`, `gt sync`, and `gt checkout`. Raw branch creation or rebasing bypasses Graphite's
+stack parentage metadata; commits and reads are safe as plain git (run `gt restack` afterwards
+if upstack branches exist). Do not use `gh stack` in `hashintel/hash`.
+
+## Naming
+
+- **Branch**: `{prefix}/{issue-id}-{keywords}` (e.g. `ln/fe-1362-demo-vehicle`).
+- **PR title**: `{ISSUE-ID}: {Linear issue title in sentence case}`
+ (e.g. `FE-1362: Decide the September demo vehicle`).
+- PR descriptions are written when tying off a branch, not during active development. They fill
+ the repository template (`.github/pull_request_template.md`) with the visible-summary /
+ `🏗️ Agent notes` split applied inside its sections, per `issue-writing.md`.
+
+## Deposit rule
+
+Work deposits its own description at authoring time, whatever tool authored it: a commit
+carries a body that explains outcome and mechanism (the FE-1400 sweep's messages are the
+register), and a branch is not tied off until its PR body says what it establishes. A
+semantically heavy branch with an empty message is a defect, not a style choice — prose
+backfill is remediation, not workflow (see `legibility.md`).
+
+## Lifecycle
+
+```text
+gt create {prefix}/fe-XXXX-keywords # new branch stacked on the current one
+# ... work ...
+git add && git commit # plain git for commits
+gt submit # push + create/update the PR when ready
+gt sync # after merges: pull trunk, restack, prune
+```
+
+Trunk is `main`. Link the PR from the Linear issue (or let Linear's GitHub integration attach
+it) so the issue records where its work landed.
diff --git a/libs/@hashintel/brunch-agent/docs/agents/issue-tracker.md b/libs/@hashintel/brunch-agent/docs/agents/issue-tracker.md
new file mode 100644
index 00000000000..60d329f0c94
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/agents/issue-tracker.md
@@ -0,0 +1,128 @@
+# Issue tracker: Linear
+
+Issues for this repo live in **Linear**, worked through the `linear` CLI (see the
+`tool-linear-cli` skill). Long-form artifacts — specs, research write-ups, notes — stay in this
+repo under `docs/planning//` (see `documentation.md`); Linear issues link to them by repo path (and to Notion/Google
+docs by URL). The issue is the tracker record; the repo file is the document.
+
+How issue titles and bodies are written — the contract/execution-record split, outcome-shaped
+titles, plain-prose context — is covered in `issue-writing.md`; it applies to every issue an
+agent authors from this repo.
+
+## Conventions
+
+- **Default team**: `FE`. **Project**: `brunch-agent`. Create issues with
+ `linear issue create --team FE --project brunch-agent`. The project is the
+ ownership boundary for this codebase — see the registry rule below.
+- Related work also lives on teams `PRO` (product) and `H` (HASH) — read/reference those freely;
+ create there only when asked. The legacy `brunch` project holds the old brunch product's
+ history and is not this codebase's tracker.
+- Use `--description-file` / `--body-file` for any multi-line markdown (shell-escaping otherwise
+ mangles it).
+- Triage state is expressed through Linear workflow states and labels (see `triage-labels.md`).
+- Comments and conversation history go on the issue as Linear comments, shaped per
+ `issue-writing.md`'s comment rules: the decision or change in one or two sentences, with
+ detail in `🏗️ Agent notes`.
+
+## When a skill says "publish to the issue tracker"
+
+Create a Linear issue on team `FE`, project `brunch-agent`. If the content is long-form, commit
+it under `docs/planning//` and link the repo path from the issue description.
+
+## When a skill says "fetch the relevant ticket"
+
+`linear issue view ` (e.g. `FE-1333`). The user will normally pass the identifier directly.
+
+## Editing issue bodies from the CLI
+
+Bodies carry collapsed `🏗️ Agent notes` sections (`issue-writing.md`), and agents maintain
+them by fetching, editing, and pushing the raw description. Fetch immediately before every
+write, preserve the human-owned summary, and apply the smallest material change; never push a
+stale local copy. Three more facts keep that process safe:
+
+- **Read the raw description via GraphQL**, never via `issue view` — view prepends a
+ title/state header that would be pushed back into the body:
+
+ ```bash
+ linear api --variable id=FE-XXXX <<'GRAPHQL' | jq -r '.data.issue.description'
+ query($id: String!) { issue(id: $id) { description } }
+ GRAPHQL
+ ```
+
+- **Linear normalizes markdown on save** (collapsed-section spacing, `-` → `*` bullets). A diff
+ against your draft is not drift; don't "fix" it.
+- **Issue references in Linear bodies and comments are full URLs**
+ (`https://linear.app/hash/issue/FE-XXXX`), which Linear renders as issue chips — a bare ID
+ stays dead text. In repo documents the opposite holds: bare IDs with a gloss, per
+ `documentation.md`.
+
+## Project updates
+
+Linear project updates (`linear project-update`) reach a wider audience than issue comments.
+They give a plain-prose summary of decisions, confidence changes, risks, and opportunities
+since the last update, using the native health field. They contain no working detail, and empty
+sections are omitted. Draft one from the changes recorded in issue comments since the last
+update. Publish it to the `brunch-agent` project.
+
+## Wayfinding operations
+
+Used by the `ds-wayfind` skill. The **map** is a Linear issue with one **child** sub-issue per
+ticket.
+
+- **Map**: an FE issue in project `brunch-agent`, labeled `wayfinder → map` (label group `wayfinder`,
+ children `map` / `research` / `prototype` / `grilling` / `manual-task` — created 2026-08-11;
+ `manual-task` stands in for the skills' `task` type because the workspace already has an
+ unrelated "Task" label). The issue description holds the map body: Destination / Notes /
+ Decisions so far / Not yet specified / Out of scope.
+- **Child ticket**: a sub-issue of the map (native parent relation), same team and project,
+ carrying its `wayfinder → ` label. The description holds the question.
+- **Blocking**: Linear's native **blocks / blocked-by** relations. A ticket is unblocked when
+ every issue blocking it is closed (Done or Canceled).
+- **Frontier**: open, unblocked, **unassigned** sub-issues of the map — lowest issue number
+ first.
+- **Claim**: assign the issue to yourself (the dev driving the map) before any work.
+- **Resolve**: post the answer as a comment on the issue, set state **Done**, then append a
+ one-line gist + link to the map issue's _Decisions so far_ section (edit the map description).
+- **Out of scope**: set state **Canceled** and record the gist + reason in the map's
+ _Out of scope_ section.
+- Pre-existing product issues (the PM's stubs) are **referenced** from map tickets via _related_
+ relations — never duplicated as wayfinder tickets and never closed by the map. A wayfinder
+ ticket that validates a product issue links to it and records its verdict in the resolution
+ comment.
+
+## The registry rule
+
+**Project membership is the ownership boundary**: an issue belongs to this codebase iff it is
+in the `brunch-agent` project. Nothing else — no label, no team, no root-walking — decides
+belonging. Within the project, every issue must additionally be **reachable from a root**: either
+it is a sub-issue (directly or transitively) of a root map — currently FE-1383 (build) and
+FE-1357 (demo + plugin spec) — or a sub-issue of a named sweep ticket (FE-1401-style), or it
+_is_ a root and `docs/planning/_shared/COORDINATION.md` names it under **Exceptional roots**. An
+issue in the project but reachable from no root is captured-then-orphaned, the failure mode this
+rule exists to stop. Set the parent at creation (`--parent FE-XXXX`), not in a later sweep.
+
+Audit (run at arc close, alongside the legibility protocol's consolidation step):
+
+```shell
+turbo run linear:graph --filter '@hashintel/brunch-agent'
+```
+
+Check each open row without a `p:` parent against the roots above. An orphan gets a parent or an
+explicit root listing in `COORDINATION.md` — silence is not an option it has. The projection is
+project-wide rather than assignment-scoped: assignment is fallible, and more contributors means
+unassigned work is normal. A shared custom view — "brunch-agent: open without parent" — surfaces
+candidate orphans continuously; its only legitimate rows are the roots themselves.
+
+## Historical note
+
+Before 2026-08-11 this repo tracked issues as local markdown under `.scratch//`
+(map at `map.md`, tickets at `issues/NN-.md`; live trees moved under `docs/planning/`,
+completed ones under `docs/history/planning/`). The completed `elicitation-kernel` effort
+remains in that form as the canonical archive (now at `docs/history/planning/elicitation-kernel/`), and is **mirrored in Linear for team
+visibility** as FE-1366 (map) with sub-issues FE-1367–FE-1379, all Done, blocking relations
+preserved. New efforts go to Linear directly.
+
+Between 2026-08-11 and 2026-08-20 lite work was identified by a `lite` label plus the shared
+`brunch` project — filters, not ownership, which is how six pre-registry stubs (FE-1328–FE-1334)
+ended up orphaned. On 2026-08-20 the `brunch-agent` project was created, all 73 lite issues
+moved into it, and the `lite` label was deleted; the project itself is now the boundary.
diff --git a/libs/@hashintel/brunch-agent/docs/agents/issue-writing.md b/libs/@hashintel/brunch-agent/docs/agents/issue-writing.md
new file mode 100644
index 00000000000..8f24631c13c
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/agents/issue-writing.md
@@ -0,0 +1,218 @@
+# Writing issues and pull requests
+
+How to write Linear issues and GitHub pull requests for this repo. Adapted from the dogsled
+`ds-writing-issues` skill (the "issue contract"); motivated by team feedback that agent-authored
+records read as jargon and are not readable by non-engineers. Apply whenever creating or editing
+an issue title or body or a pull-request title or description.
+
+This file governs **structure and house style**. Write the visible layer as **plain technical
+prose** for an informed colleague who does not know the implementation. The style is Google
+Developer Documentation plus GOV.UK plain English, with INCOSE precision but not INCOSE
+formality: complete sentences, short paragraphs, active voice, concrete nouns and direct verbs.
+State causal, temporal, and conditional relationships explicitly. Keep sentence structure
+simple without simplifying the idea; prefer two clear sentences over one compressed sentence.
+
+Use shared product and system vocabulary, but avoid implementation-specific names unless they
+are necessary to explain the behavior. Do not write in telegraphic, checklist, ticket,
+changelog, or runbook style. Avoid fragments and labeled fields. Use a list only when readers
+need to compare or enumerate parallel items; use prose when the relationship between facts is
+the information.
+
+The org technical-writing skill drafted in
+[internal-agents#20](https://github.com/hashintel/internal-agents/pull/20) (GEN-449) supplies the
+plain-word and banned-words checks. Search all prose against that list before publishing,
+regardless of the PR's merge state; exact machine text is exempt. The list transcribes what
+colleagues asked for.
+
+Every issue serves two audiences: the team, who scan for direction and progress, and the agent,
+which needs precise state to continue the work. Don't make one body serve both equally. Give
+the issue a **contract** — the human-readable summary — above, and an **execution record** —
+the working state — inside a collapsed `🏗️ Agent notes` section below. The human driving the
+work owns the summary; agents may draft or update it on that person's behalf. The rhetorical
+mode changes by layer: **task language for scanning, explanatory language for understanding,
+specification language for execution.**
+
+## Who carries the contract
+
+The test is authorship, not parentage. Every issue an agent authors from this repo on behalf of
+the human driving the work carries the contract, including every sub-issue. A teammate-authored
+issue outside this workflow keeps its author's structure; comment, relate, and record verdicts
+without rewriting its title or body unless that author delegates the change.
+
+Before changing an issue that carries the contract, fetch its current raw body and read the
+human-owned summary.
+Preserve edits made since the agent last saw it. Change that summary only when acting on behalf
+of its owner and only for a material change. Never regenerate it from a stale local draft.
+
+## Title — the scan layer
+
+Start with an active verb and name the concrete task, problem, or decision in the fewest words
+that remain clear. Examples include "Keep issues easy to scan," "Stop duplicate
+notifications," "Decide how agents retrieve planning decisions," and "Map the September demo."
+Research, planning, documents, and maps are tasks too; use verbs such as "decide," "test,"
+"write," "map," or "plan."
+
+Match the title to the issue's current commitment. If the approach is undecided, name the
+problem or decision rather than a favored implementation. Once an approach is agreed and
+implementing it is the task, name it directly. Domain terms the wider team already uses
+("elicitation," "Petrinaut," "net") belong in a title; internal class, package, framework, and
+algorithm names belong only when changing that named mechanism is itself the task.
+
+Title a bug by its observable symptom, not the hypothesized root cause. Internal work names its
+real engineering task rather than inventing an end-user story.
+
+## Context — the prose layer
+
+One or two short paragraphs of plain prose at the top of the body, mandatory on every issue that
+carries the contract; two to four sentences suffice for a small task. The reader should be able
+to recover:
+**current state → consequence → intended change → material status or uncertainty.**
+
+The central rule: **use a list when the list itself is the information; use prose when the
+relationship between the facts is the information.** Cause, impact, direction, status, and
+uncertainty are relationships — prose. The failure mode is the property-bag (`Problem: … /
+Impact: … / Solution: …`); write the explanation instead. Update the context only on a
+_material_ change — outcome, scope, status, risk, timing — never on routine progress.
+
+When an issue comes from user, stakeholder, or teammate feedback, quote their words directly
+and link the original conversation when its audience is allowed to read it. A summary can lose
+the pain or qualification that made the feedback useful; prefer the source when it is
+available.
+
+## Wayfinder maps
+
+A map is an aggregating issue, so it carries both layers: a **plain-prose preamble**
+(the context layer, written so a non-engineer understands what the effort is, why, and where it
+stands), then, inside `🏗️ Agent notes`, the wayfinder working sections (Destination / Notes /
+Decisions so far / Not yet specified / Out of scope) as the execution record. The map's
+list-shaped sections are earned — enumerating many children's state is the
+information; `Not yet specified` is the map's one home for known-unknowns. When resolving a
+ticket updates the map, refresh the preamble's status sentence in the same edit.
+
+## The execution record
+
+The working layer lives inside a `🏗️ Agent notes` section that Linear and GitHub render closed
+by default:
+
+```
++++ 🏗️ Agent notes
+
+…working detail…
+
++++
+```
+
+on Linear; `🏗️ Agent notes…` on GitHub PR descriptions
+and long comments. Linear requires the space after `+++` and a blank line on both sides of the
+working detail; its API inserts that whitespace when it is absent. The label is one canonical
+string: **copy the complete wrapper from here, never retype it**. The emoji carries a variation
+selector, so visually identical labels can have different bytes. Write bare domains as explicit
+Markdown links when raw-body fidelity matters; Linear otherwise expands them into link syntax.
+The section is **agent-maintained**: agents update it by fetching the raw body, editing, and
+pushing back (see `issue-tracker.md` for the safe process), so a human edit inside it can be
+overwritten. The human-owned summary outside this section must be preserved unless its owner
+has delegated the change.
+
+The content is optional and schema-free: hold whatever the workflow needs (constraints,
+assumption tables, acceptance criteria, asset links). Present when there's something to hold;
+never mandatory boilerplate. Technical detail is additive — never deleted merely to simplify
+the issue, only moved into `🏗️ Agent notes`.
+
+## Pull requests
+
+A pull request uses the same two layers, expressed through the repository's pull-request
+template. Its title is `{ISSUE-ID}: {current Linear issue title}` as required by
+`git-workflow.md`. Its body **fills `.github/pull_request_template.md`** rather than replacing
+it; the layer discipline applies inside the template's sections:
+
+- **`## 🌟 What is the purpose of this PR?`** is the scan layer: one or two sentences of plain
+ technical prose that stand alone for a reader arriving from the repository feed — the larger
+ change this work belongs to, why it is happening, and what this branch establishes within it.
+ Spell out project-local shorthand on first use; a phrase such as "import gates" is not context
+ until the body says what is being imported and what the gates protect.
+- **`## 🔍 What does this change?`** carries the remaining layers in order: first a
+ plain-language description of the change, then implementation detail, verification, and stack
+ mechanics inside the GitHub `🏗️ Agent notes` wrapper shown above.
+- Fill the template's other sections (related links, blockers, checklists, known issues, tests)
+ per their own inline comments; answer the checklists honestly and keep the sections the
+ template says not to delete.
+
+The prose rules govern throughout: within each section, write sentences, not `What` / `Why` /
+`Testing` label-fragments beyond the headings the template itself provides.
+
+When reshaping an existing pull request, preserve its detailed record byte-for-byte inside the
+wrapper. A title or outer summary may change only to improve the scan layer without changing the
+recorded claim or status.
+
+## Comments
+
+A comment lands in inboxes and feeds. Write it as a notification you chose to send the team.
+Its visible content says what was decided or what changed in confidence, risk, or scope, in one
+or two short sentences. Implementation detail about that change goes into a collapsed
+`🏗️ Agent notes` section in the comment or the issue body. Two boundaries:
+
+- **Progress narration is never a comment.** "Tried X, now attempting Y" is working state — it
+ belongs in the issue body's `🏗️ Agent notes`, edited in place. If nothing was decided and no
+ belief changed, there is no comment to write.
+- **State goes in the body; events go in comments.** Current truth (status, plan,
+ findings-so-far) is edited into the body, where the next reader looks. Comments are the
+ chronological record of what happened.
+
+The resolution comment keeps its length exemption: verdict paragraph first, detail in
+`🏗️ Agent notes`. Linear **project updates** reach a wider audience than comments — see
+`issue-tracker.md`.
+
+## One kind of entity
+
+Investigative work — research, a prototype, a spike — becomes a **sub-issue** holding both the
+query and the result. Never a comment thread used as a workspace; never a body checklist as
+decomposition. The single exception is the immutable **resolution comment** posted when an
+issue closes — a closing act, not a workspace.
+
+## Ownership direction
+
+State a fact once; everywhere else links. Within an issue, a plan links to a decision, never
+restates it. Across issues, a sub-issue never re-explains a fact its parent's context already
+states. Long-form artifacts live in the repo (`docs/planning//`) and are linked by
+path, per `issue-tracker.md`.
+
+## Voice and authority
+
+Match the writing's authority to the author's actual remit. Decisions inside the dev remit —
+how something will be built, in what order, on what architecture — are stated plainly in first
+person ("I'm rebuilding the core greenfield because…"). Claims owned by someone else — product
+shape, a PM's checklist, another team's area — get **recommendation voice**: "I strongly
+recommend against X, because…", "the practical considerations don't point this way for the
+demo, IMO; I'd rather we…" — never "we've decided X" about a thing that is theirs to decide.
+Analytic verdict vocabulary ("contradicted", "redefined", "superseded") belongs in internal
+planning records (maps, decision docs, resolution comments on our own tickets); at the
+boundary — comments on others' issues, messages to colleagues — it becomes a recommendation
+with its reasons.
+
+## Vocabulary — three tiers
+
+Apply the technical-writing rules first. Then use these three tiers, judged by whether the
+reader resolves the word without a lookup:
+
+1. **Industry-standard terms** (thread, pipeline, module, interface): free everywhere.
+2. **Terms from technical literature**: use in `🏗️ Agent notes` only when no plainer exact phrase
+ fits. A visible summary may use one only after glossing it at first mention and adding it to
+ `CONTEXT.md`.
+3. **Locally coined terms**: do not coin one for an issue. When an issue inherits one from a
+ linked source, use it only in `🏗️ Agent notes`.
+
+Check banned constructions and filler with a string search. Judge other nouns by whether the
+reader can resolve them without a lookup.
+
+## Before publishing
+
+- **Scan test** — does the title start with an active verb and name the task compactly?
+- **Commitment test** — does the title avoid committing to an approach that is still undecided?
+- **Prose test** — does the context explain causality, or list fragments?
+- **Containment test** — are code-level details inside `🏗️ Agent notes`?
+- **Standalone-context test** — can a reader arriving from a feed recover the larger change, its
+ purpose, and this work's place in it without opening the issue?
+- **Word test** — has all prose been searched against the technical-writing banned list, and
+ does every term of art in the visible summary pass the glossary test?
+- **List test** — does every list hold parallel or ordered items?
+- **Uncertainty test** — are open questions presented as uncertainty, not fact?
diff --git a/libs/@hashintel/brunch-agent/docs/agents/legibility.md b/libs/@hashintel/brunch-agent/docs/agents/legibility.md
new file mode 100644
index 00000000000..20d5a462492
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/agents/legibility.md
@@ -0,0 +1,71 @@
+# Legibility protocol: render, read the strain, reconcile
+
+How work arcs with significant agent-generated output close: produce legibility artifacts that
+aid review and re-establish shared understanding between the humans and the agents carrying the
+work. Companion to `documentation.md` (which governs where documents live; this file governs
+how understanding is checked and kept).
+
+The protocol serves one thesis, the same one the CI gates and the capture store serve in code:
+**no claim without a way for it to fail.** A document read in its own register can nod along
+with itself; re-rendered into a different register, every claim must survive translation, and
+the places where it doesn't are findings.
+
+## The move: render and read the strain
+
+At the close of an arc, re-render its central artifact into another register and instruct the
+renderer to report **every place the source resisted plain rendering** — a dangling referent, a
+term defined nowhere, a prohibition citing nothing, a causal claim whose causality had to be
+inferred. The strain report _is_ the review yield; the rendering itself is a byproduct (often a
+useful one — a teammate-readable account).
+
+Run renderings as fork subagents carrying the strain-report instruction, so the main thread
+reviews the findings instead of doing the translation. Instrumenting the collection raises the
+yield: the ir-design plain rendering returned seven strain points where an uninstrumented
+round-2 read of the FE-1374 spec renderings had found four by accident (each of which fed a
+real spec change — the practice predates its name).
+
+## The register dial
+
+The register is a dial, not a single target. One practice, several grades — pick the cheapest
+grade that can still fail:
+
+- **Plain prose** (Google/GOV.UK style): the default. Catches undefined terms, uncited rules,
+ compressed allusions.
+- **STE grade** (controlled vocabulary, one instruction per sentence): for sources whose claims
+ are dense or load-bearing enough that plain prose can still paper over them. Costs more;
+ earns it when the source will govern implementation.
+- **Worked examples** (FE-1397's form): re-render a _definition_ into concrete instances and
+ check what breaks. The strongest grade for type systems and contracts — a definition that
+ survives three worked designs at different thicknesses has been tested, not admired.
+
+## Filings are render-and-read material too
+
+A sweep's own capture — its tickets, its accrual comments, its penciled directions — is itself
+a rendering of the session's understanding, and gets the same treatment: expect a challenge
+pass over the filings before the arc closes. The FE-1405/FE-1406 round came from re-reading the
+first round's own text ("shapes-to-fill" quoted back); the gaps were real and had been deepened
+by the filings meant to close them.
+
+## Consolidation: capture-as-we-go, reconcile-before-landing
+
+Capture channels (accrual comments, pencil lists, strain appendices, handoffs) guard against
+evaporation, not fragmentation. Two rules keep the yield coherent:
+
+- **Every capture channel names its consolidation target** — accruals reconcile into the
+ owning control surface, pencils graduate to issues or planning documents, and strain reports
+ become document fixes. A channel with no named target is a leak with a delay.
+- **An arc is not closed until consolidation runs.** The closing step reconciles what the
+ captures established into the durable artifacts (coordination, ledger, docs, issues) — a
+ handoff note alone is a deferral, not a deposit.
+
+## Deposit: work describes itself at authoring time
+
+Prose backfill is remediation, not workflow. A branch's commit message and PR body carry its
+semantics when it lands — the record must not abstain exactly where description is most needed
+(FE-1390 landed 1,392 lines with an empty body; the deep-read that repaired it cost more than
+writing it at authoring time would have). The same rule for tooling: a skill output written
+into `docs/` passes through the documentation protocol — an `INDEX.md` row or an `AGENTS.md`
+pointer — like any other document.
+
+Reflections belong in work products, marked as `> **Reflection:**` blockquotes, distinct from
+the captured facts — insight left only in chat evaporates with the context that produced it.
diff --git a/libs/@hashintel/brunch-agent/docs/agents/posture.md b/libs/@hashintel/brunch-agent/docs/agents/posture.md
new file mode 100644
index 00000000000..b2d08e23220
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/agents/posture.md
@@ -0,0 +1,4 @@
+posture: prototype
+
+stakes: high # Persisted capture data and merge gates must fail loudly rather than admit silent corruption.
+horizon: current-milestone
diff --git a/libs/@hashintel/brunch-agent/docs/agents/triage-labels.md b/libs/@hashintel/brunch-agent/docs/agents/triage-labels.md
new file mode 100644
index 00000000000..e78b86b996a
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/agents/triage-labels.md
@@ -0,0 +1,17 @@
+# Triage Labels
+
+The skills speak in terms of five canonical triage roles. This file maps those roles onto
+Linear (team `FE`) — using workflow **states** where a state is the natural fit, and labels
+only where no state expresses the role.
+
+| Label in mattpocock/skills | In our tracker (Linear FE) | Meaning |
+| -------------------------- | --------------------------------------------- | ---------------------------------------- |
+| `needs-triage` | state **Triage** | Maintainer needs to evaluate this issue |
+| `needs-info` | label `needs-info` (create on first use) | Waiting on reporter for more information |
+| `ready-for-agent` | label `ready-for-agent` (create on first use) | Fully specified, ready for an AFK agent |
+| `ready-for-human` | label `ready-for-human` (create on first use) | Requires human implementation |
+| `wontfix` | state **Canceled** | Will not be actioned |
+
+When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding
+state or label from this table. Wayfinder ticket-type labels are separate — see the
+`wayfinder` label group in `issue-tracker.md`.
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/01-flue-architecture-deep-read.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/01-flue-architecture-deep-read.md
new file mode 100644
index 00000000000..d25fb2370be
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/01-flue-architecture-deep-read.md
@@ -0,0 +1,127 @@
+# Flue architecture deep-read
+
+Type: research
+Status: resolved
+Resolved: 2026-08-06
+
+## Question
+
+What does Flue's architecture offer — and constrain — for embedding an elicitation kernel, such that the shipping-shape and contract-decomposition tickets can decide on facts rather than the marketing page?
+
+Specifically:
+
+- The agent programming model (React-like hooks API): what is "an agent" as a unit of code, state, and deployment?
+- How skills, tools, subagents, persistent state, sandboxes, and channels are defined and composed
+- How Pi is exposed through Flue — can an embedded library reach Pi primitives directly, or only through Flue's abstractions?
+- Local vs. remote parity: what changes between a Node local run and a Cloudflare/CI deploy? What state survives where?
+- Is a "kernel library embedded in a thin Flue agent" a natural pattern, or does Flue push toward the agent _being_ the program?
+- Channels (Slack/Teams/Discord/GitHub) as host input pathways — what does a host surface look like in Flue terms?
+
+Sources: https://flueframework.com/docs/guide/ (pages are fetchable as markdown at `/docs/guide//index.md`).
+
+## Answer
+
+> Resolved by `/research` subagent, 2026-08-06.
+
+# Flue Architecture — Findings for an Embedded "Elicitation Kernel"
+
+Version context: `@flue/runtime` **2.0.3** (npm, published 2026-08-05; package created 2026-05-14, 48 versions). Siblings `@flue/cli`, `@flue/sdk`, `@flue/react`, `@flue/vite` all at 2.0.3. Docs pages carry "Last updated Jul 21–23, 2026". Repo: `github.com/withastro/flue`.
+
+## 1. Agent as a unit
+
+**Code.** An agent is a plain exported, capitalized JS function that returns its system-prompt string. Capabilities come from `use*` hooks called in its body. A module marked `'use agent'` is scanned at build time by the Vite plugin (`flue()` from `@flue/vite`); every exported capitalized function in it becomes a registered agent. One file may export several.
+
+**Lifecycle.** The function **re-renders before every model call** and rebuilds instructions and its declared resource set from scratch. Unlike React, resource hooks _may_ be called conditionally — this is the framework's central design bet ("an agent is a program to write, not an object to configure"). Between renders the runtime diffs the declared set against a "last-narrated snapshot" and appends a framework-authored `resources` signal at the next turn boundary ("New tool available: …"). Skill/subagent catalogs are frozen on a durable baseline so flips don't bust the prompt cache; custom-tool changes rewrite the native tools array and _do_ invalidate cache (documented exception: a tool unlocked by a completed tool call, on non-Haiku Anthropic models).
+
+**State.** Durable identity is the function name (or the `Fn.agentName` static) — it keys conversation storage, so renaming without pinning `agentName` is a DB migration. Each conversation is addressed by a caller-chosen `id`. `Fn.initialData` (a Valibot schema static) validates creation-time data exactly once.
+
+**Deployment.** Registration ≠ mounting. `createAgentRouter(agent)` returns a Hono sub-app you mount yourself in `src/app.ts`; agents reached only via `dispatch(...)` need no mount at all. Entry points: `flue run ` (CLI, no server), HTTP `POST /:id` (202 fire-and-forget), `dispatch(agent, {id, message, initialData, uid})`, and `start()` + `init()` for standalone Node processes.
+
+## 2. Primitive inventory (what a library could register/own)
+
+- **Tools.** `defineTool({name, description, input?, output?, harness?, durable?, run})` — Valibot schemas, frozen at module load, importable from a light `@flue/runtime/tool` entry "for tool-only modules". Mounted per render via `useTool(def)`. `run` receives `{data, signal, log, toolCallId}`; `harness: true` adds `harness`, `durable: true` adds `step`. Reserved names: `task`, `activate_skill`, `read_skill_resource`, plus sandbox built-ins (`read/write/edit/bash/grep/glob`). **This is the primary registration surface an embedded kernel would own.**
+- **Harness tools** (worth calling out separately). `harness.sandbox` (direct file/exec verbs, _never recorded in the conversation_) and `harness.prompt(text, {result: Schema, tools, model, thinkingLevel, images})` — runs a model operation in a private scratch conversation invisible to clients, with Valibot-validated structured output enforced via a framework-injected `finish` tool. Repeated calls continue that scratch conversation. **This is the closest thing Flue has to a sub-LLM call primitive, and it is a strong fit for a kernel's internal extraction/normalization steps.** Also `harness.compact()`.
+- **Skills.** Open Agent Skills format (`SKILL.md` + supporting files), or `defineSkill({name, description, instructions, files})` for generated/assembled content. Progressive disclosure: one catalog line always present; full instructions arrive as an `activate_skill` tool _result_, so activation never mutates the system prompt and the cached prefix survives. Supporting files are served read-only from the app bundle at virtual paths — **not** copied into the sandbox. Also auto-discovered from `/.agents/skills/` when a sandbox exists.
+- **Subagents.** `defineSubagent({name, description, agent})` + `useSubagent()`. Model-driven via the always-present `task` tool. Child inherits _environment_ (sandbox, workspace context, parent model) but **nothing conversational** — no history, instructions, tools, skills, persistent state, or initialData. Only the final message returns. Explicitly _not_ a second addressable agent: "no conversation id, no persistent state, and no address." `GeneralSubagent` ships as a blank delegate under `flue-general`.
+- **Persistent state.** `usePersistentState(name, initial)` — React-shaped, JSON-serializable, keyed by name, durable for the life of the conversation. Writes commit **atomically with the unit of work that made them** (tool batch, or event-hook seam checkpoint), which is what makes it the correct guard for at-least-once callbacks. Prefer updater functions; the render value is a snapshot.
+- **Sandboxes.** `useSandbox(factory, {cwd})`. `local()` from `@flue/runtime/node`, or adapters (Daytona, E2B, Modal, Cloudflare Sandbox/Computer) built against the Sandbox Adapter API. Attaching one adds the six built-in file/shell tools; an adapter may replace that set entirely. Sandbox filesystems are **ephemeral by default** and independent of conversation durability.
+- **Channels.** Inbound-only verified HTTP ingress (Slack, Discord, Teams, GitHub, Stripe, …), shipped as **blueprints** (`flue add channel slack`) that generate project source rather than as opaque packages. A channel is "an object with declarative routes"; `createChannelRouter(routes)` builds one by hand. Handlers call `dispatch(...)` themselves. **Outbound is explicitly not Flue's job** — no send-message abstraction; you use the provider SDK and expose narrow tools with the destination bound in trusted code.
+- **Event hooks / data writers.** `useAgentStart` (async — the load-data seam), `useAgentFinish`, `useResponseStart/Finish` (return values merge onto response _metadata_). `useDataWriter(name, {schema})` streams typed structured data parts to clients (`{type: 'data-orderCard', data}`), strictly one-way out of the agent — **the model never sees data parts**, and a write never re-renders the agent.
+- **Custom hooks.** Plain `use*` functions composing the built-ins, returning instruction fragments to the caller. Docs explicitly frame this as the reuse/packaging unit.
+
+## 3. Pi exposure
+
+Partial and deliberate, concentrated at the **model-provider layer**. Documented facts:
+
+- `@flue/runtime`'s npm dependencies include `@earendil-works/pi-agent-core` and `@earendil-works/pi-ai` directly.
+- Models guide: "Providers are Pi's own objects, and Flue accepts them directly: build one with Pi's `createProvider()` … and hand it to `setProvider()` at module top level in `app.ts`." Examples import from `@earendil-works/pi-ai`, `.../api/anthropic-messages.lazy`, `.../providers/anthropic`, `.../api/openai-completions.lazy`.
+- `start({providers})` registers "the Pi providers this runtime registers, replacing the default set."
+- `PromptImage` "re-exports pi-ai's `ImageContent`."
+- Why-Flue: "Flue builds on Pi, the open agent harness behind OpenClaw, and integrates it deeply into every agent you build."
+
+Everything else — conversation loop, tool dispatch, skills, subagents, durability — sits behind Flue's own abstractions. **Inference:** there is no documented path to Pi's agent loop, message list, or Pi extension/package system from inside a Flue agent. A kernel that currently assumes Pi-level tool registration, transcript control, or `renderResult`-style TUI hooks would have to be re-expressed entirely in Flue's `defineTool` + render model. Flue also uses Durable Streams (`@durable-streams/client`) as its client transport protocol.
+
+## 4. Local vs. remote parity
+
+Durable _records and recovery decisions are identical_ across targets; ownership and wake mechanics differ.
+
+| | Node | Cloudflare |
+| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Unit | one server process, coordinator with **lease-based** ownership | one **Durable Object per agent conversation**, own SQLite; ownership structural |
+| Recovery | startup reconciliation + periodic lease scans | wake-on-start + self-renewing durable wake schedule, bounded supervision pass |
+| Storage | `db.ts` adapter required (sqlite, Postgres, MySQL, Mongo, Redis, libSQL, Turso, Supabase, Valkey); **without one, conversations are process-local memory and a restart loses them** | built in |
+| Constraint | must route each conversation to exactly one owner; active-active / round-robin for the same conversation is unsafe | every deployed agent needs an append-only DO migration tag in your hand-authored `wrangler.jsonc`; renames/deletes are storage migrations (`renamed_classes` / `deleted_classes`) |
+
+Core contract: "Every accepted submission reaches exactly one durable terminal outcome — completed, failed, or aborted — no matter how many crashes happen in between." Submissions are recorded durably _before_ any model work; that is what the 202 and the `DispatchReceipt` attest to. There is a retry budget plus a wall-clock timeout, enforced preemptively via the attempt's abort signal.
+
+**Deliberately not durable:** sandbox files (rebuilt fresh on each initialization unless the adapter keys a provider workspace on the instance id — "a durable database does not make a sandbox durable"); in-flight local promises (persist the `DispatchReceipt` and `read(receipt)` re-attaches from any process); and **code outside the agent** — Flue explicitly does not checkpoint arbitrary TypeScript. For that, docs point to Cloudflare Workflows / Inngest / Temporal calling Flue "like any other service." External side effects are never recorded, only that a tool ran and what it returned.
+
+Also: there is **no GitHub Actions or GitLab _target_**. CI is just `flue run` in a shell step (`--new` + deterministic `--id` gives exactly-once conversation creation). GHA/GitLab appear only in the ecosystem catalog. Builds are Vite (`vite build` → `dist/server.mjs` + `dist/app.mjs`, or the Cloudflare plugin's output). Node deps are externalized, not bundled; the built server does not load `.env`.
+
+## 5. Library-in-agent vs. agent-as-product
+
+**Embedding is a natural pattern, and the docs endorse it explicitly.** Evidence:
+
+- `defineTool` / `defineSkill` / `defineSubagent` all exist specifically as _exportable, frozen, module-load-validated_ units — described as "the natural shape for tools shared across agents" and "the exportable unit — define a delegate once, mount it from any agent."
+- **Custom hooks are the documented composition unit:** "a `useGitHub()` hook that bundles the right tools, skills, and instructions can be written once and dropped into every agent that works with GitHub." That is precisely a kernel-in-a-hook.
+- `@flue/runtime/tool` is a lighter entry point for tool-only modules — a library can depend on Flue without pulling in the server runtime.
+- Source-dir resolution order puts **`.flue/` first**, described as "a self-contained Flue source area inside a larger application," with authored modules free to "import ordinary supporting code from elsewhere in the project."
+- Per-mount overrides "spread cleanly": `useSubagent({ ...issueClassifier, model: 'anthropic/claude-haiku-4-5' })`.
+
+Counter-pressure: Flue insists the _agent itself_ be a program, not config, and the `'use agent'` build-time scan means a library **cannot ship a pre-registered agent** — the agent module must be authored in the consuming project.
+
+**Recommendation (inference):** ship the kernel as a published custom hook — `useElicitationKernel(targetPlugin)` — that internally calls `useTool` / `useSkill` / `usePersistentState` / `useDataWriter` and returns instruction fragments, plus raw `defineTool` exports for hosts that want selective mounting. The host owns a ~10-line `'use agent'` module, the `app.ts` mount, and `db.ts`. This is library-in-a-thin-agent, and Flue's grain supports it. One real constraint: tool names are globally unique per render and collide with reserved names, so the kernel needs a namespacing convention.
+
+## 6. Channels as host surfaces
+
+A "host input pathway" in Flue is: verified ingress → `dispatch(agent, {id, message, initialData})`. Three distinct payload lanes:
+
+- **`initialData`** — recorded once at conversation creation, validated against the agent's schema static, read with `useInitialData()`, immutable thereafter. This is where an elicitation _target descriptor_ belongs.
+- **`kind: 'signal'` messages** — `{type, body, attributes}` where `attributes` is a string→string map of facts _trusted code_ attached. Read with `useDelivery()`. Docs push this hard as the authorization pattern: "the model may choose an order ID to look up, but it cannot choose the customer." For a kernel, this is the channel for host-verified respondent identity. Channel deliveries are signals rather than `user` messages precisely because a Slack thread is multi-participant.
+- **`kind: 'user'`** — direct human turns.
+
+**Interviewing-UX rendering surfaces, ranked by fit:**
+
+1. **`useDataWriter` + `@flue/react`.** Named, schema-validated structured data parts arriving alongside text on the same message; a tool can write several times mid-run to drive live progress. `useFlueAgent({url})` gives `messages`, `parts`, `status`, `historyReady`, `sendMessage()`, `refresh()`. Message parts are `text | reasoning | dynamic-tool | file`, and **validated structured tool output is preserved on the `dynamic-tool` part's `output`** — the React docs say this exists "so applications can render custom tool interfaces without a separate data-event channel." Direct fit for question cards, choice sets, and review panes.
+2. **Chat channels** (Slack/Teams/Discord/GitHub) for text-shaped interviewing only. There is **no outbound abstraction** — every reply is a tool you write against the provider SDK with the destination bound in trusted code (the `replyInThread(data)` pattern). Rich Slack Block Kit interviewing is entirely your code.
+
+**Gap worth flagging loudly: Flue documents no first-class human-in-the-loop / elicitation / interrupt primitive.** There is no "ask the user and suspend" hook. `terminate: true` on a tool result ends the turn once the current batch settles; the documented pattern for waiting on a human is state-gated tools (`record_approval` unlocks `publish_release`) plus a new inbound submission. **Inference:** the kernel must implement its own turn-suspension protocol — a `terminate: true` tool that writes the pending question into `usePersistentState` and emits a data part, with the host's answer arriving as a fresh `dispatch`. That is exactly the kind of thing a kernel _should_ own, but Flue provides no scaffolding for it, so it is net-new work either way.
+
+## 7. Constraints & risks
+
+- **Maturity.** 2.0.3, with 2.0 a full API rewrite around hooks announced this cycle; a Migration Guide exists. `@flue/vite` first published 2026-07-10 (19 versions). Fast-moving — expect churn.
+- **Build-time magic is load-bearing.** `'use agent'` scanning, `SKILL.md` module imports, and agent-identity stamping all happen in the Vite plugin. **This effectively requires Vite.** A kernel published as a library must not depend on that transform internally — keep to `defineTool`/`defineSkill` (plain runtime calls) and let the host own the scanned module.
+- **Durable-identity coupling.** Conversation storage is keyed by agent function name. Always pin `agentName`.
+- **Cloudflare migration ceremony.** Adding an agent is always agent + mount + new DO migration tag. If the design ever wants _dynamic_ agent creation per elicitation target, Cloudflare forbids it — agents are a build-time set. Use one agent + many conversation ids instead.
+- **Prompt-cache economics constrain dynamism.** "Gate tools on state that changes rarely." An interviewing kernel that swaps tools per question would thrash the cache. Prefer one stable tool set + state-driven instructions, or skill activation (cache-safe by design).
+- **Valibot lock-in** at every schema boundary (`input`, `output`, `initialData`, `useDataWriter`, `harness.prompt({result})`). A kernel with Zod-based plugin contracts needs a conversion layer or a dual-schema strategy.
+- **Node multi-replica.** No active-active per conversation; you must own routing.
+- **Subagents are conversationally sterile** — no id, no state, no address. If elicitation-target plugins need their own durable memory, they cannot be subagents; they must be state + tools inside the parent, or separately registered agents.
+- **Petrinaut / non-React web-UI host risk:** the rich path (`useFlueAgent`) is React-specific. `@flue/sdk` (`createFlueClient` → `send/read/wait/observe/history`, built on `@durable-streams/client`) is framework-neutral and is what `@flue/react` sits on, so a non-React host is viable — but it must reimplement the materialized-snapshot / reconnect / canonical-reset layer that `useFlueAgent` provides. **Inference:** budget for that, or wrap `createFlueClient` directly and accept a thinner UI contract.
+- **`skills` frontmatter `allowed-tools` is accepted but not enforced** — Flue does not restrict the session toolset from a skill. Any kernel-level tool gating must be done with conditional `useTool`.
+
+## 8. Unreached sources
+
+**HTTP 404 (do not exist at those paths):** `/docs/guide/deployment/`, `/docs/guide/state/`, `/docs/guide/cloudflare/`, `/docs/guide/cli/`, `/docs/guide/github-actions-target/`, `/docs/reference/index.md`, `/llms.txt`. (Real equivalents: `/docs/guide/deploy/`, state is a section of `agent-hooks`, `/docs/guide/cloudflare-target/`.)
+
+**Exist but not fetched** (nav-confirmed; would sharpen specific answers): `/docs/guide/database/`, `/docs/guide/schedules/`, `/docs/guide/evals/`, `/docs/guide/observability/`, `/docs/guide/configuration/`, `/docs/guide/migration/`, `/docs/guide/agent-behavior/`, `/docs/reference/sandbox-api/`, `/docs/reference/provider-api/`, the full `/docs/reference/agent-hooks-api/` (indexed, read only via search), the CLI and Agent SDK reference sections, all `/docs/ecosystem/*` sub-pages, the `@flue/react` package README, and `examples/react-chat`. The GitHub repo (`withastro/flue`) source and Pi's own docs (`pi.dev/docs/latest`) were not read.
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/02-zil-lean-survey.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/02-zil-lean-survey.md
new file mode 100644
index 00000000000..4607c055928
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/02-zil-lean-survey.md
@@ -0,0 +1,123 @@
+# zil-lean survey
+
+Type: research
+Status: resolved
+Resolved: 2026-08-06
+
+> **Rename note (2026-08-10, spec assembly):** occurrences of `elicit-proof-obligations` below are the historical name; the second target is the **assurance argument**, package `plugin-assurance` (per the [Formal-verification canon survey](09-formal-verification-canon-survey.md)'s category-error verdict). This ticket's ElicitationPack sketch is superseded by ticket 09's `Statement` contract (pre-pass S10); the surviving contributions are the existence proof, the assurance lattice with prohibited promotions, derivation provenance, and the derived-status idea.
+
+## Question
+
+What elicitation-for-formal-verification insights does https://github.com/jagg-ix/zil-lean hold, and is a lean/formal-flavored elicitation target _dev-sized_ — small enough to develop the kernel against without wading into a massively complex target?
+
+Specifically:
+
+- What is the repo: purpose, structure, how it relates natural-language intent to Lean formalization
+- What would an **ElicitationPack** for a lean-flavored target need: concept contract (what counts as a proposition/invariant/assumption), observation lenses, completion criteria
+- What would its **ProjectionPack** need: output contract/shape, validators (does Lean itself act as the deterministic validator?), lossiness
+- Verdict: dev-sized or too heavy? If too heavy, what is the smallest formal-flavored slice that still exercises both pack axes differently from elicit-gherkin? (Fallback second target is BPMN/process-mining.)
+
+## Answer
+
+> Resolved by `/research` subagent, 2026-08-06. Local clone for follow-up: scratchpad `zil-lean/` (session-temporary).
+
+# zil-lean survey
+
+## 1. What it is
+
+**Documented facts.**
+
+- `jagg-ix/zil-lean` — "ZIL: a relational knowledge language implemented in Lean 4 with a Clojure runtime and toolchain." Primary language Clojure; no license file; 31 stars, 1 fork, 0 subscribers, 1 open issue, no topics.
+- **Provenance/activity**: repo created 2026-07-27T06:10, last push 2026-07-29T02:26. Entire history is **27 commits over ~2 days**. First commit is "Initial public snapshot" by `github-actions[bot]`; all six PRs are branches named `agent/modularize-*`, `agent/complete-engine-governance-modules`. There is also a `PUBLIC-CONTENT-POLICY.md` and commits titled "Isolate validated source publication." **Inference (high confidence)**: this is an agent-authored code dump published from a private working repo — ~140k tracked lines landed in one snapshot, then mechanically split into modules. Not an organically evolved project, and not battle-tested.
+- **Size/structure**: 532 files. `Zil/` (106 files, 14.2k lines Lean 4 — the native library), `src/zil/` (75 files, 17.5k lines Clojure — runtime/CLI/bridges), `spec/` (44 markdown specs, 5.3k lines), `examples/` (133 files, 85k lines — dominated by generated data), `lib/` + `libsets/` (`.zc` macro libraries), `test/` (57 files, 6.1k). Lean pinned to `leanprover/lean4:v4.31.0`.
+- **What "ZIL" is**: a **relation-tuple + Horn-rule (Datalog) knowledge language**, explicitly modeled on Google's Zanzibar `object#relation@user` tuple syntax, extended from authorization into general project knowledge: `declaration ─implements→ requirement`, `theorem ─validates→ component`, `claim ─supportedBy→ document`. Four primitives: nodes, relations, rules, queries. Two surface syntaxes (`.zc` tuple text; native Lean macros `zil_fact` / `zil_theorem_rule`) plus a canonical IR, snapshot format `ZILX/1`, delta format `ZILD/1`, revision log `ZILR/1`, and exporters to Soufflé and Prolog.
+- **Relation to Lean**: Lean is (a) the implementation host of the engine and (b) a **certification backend for a narrow slice**. `Zil.Trust` has three levels — `asserted` (registered fact), `graphDerived` (rule-inferred), `certified` (a rule paired with a Lean proposition + proof term, kernel-checked). Everything else about proofs is _bookkeeping_: `spec/proof-obligation-governance-v1.md` governs declared obligations across `z3 | tlaps | lean4 | acl2 | manual` and validates _presence of evidence references_, not their content ("Lean kernel validation remain[s] the responsibility of [its] producing system").
+
+**It is not**: a benchmark, a proof-automation system, an autoformalization pipeline, or anything with an LLM in it. `grep -ril 'openai|anthropic|llm|prompt|natural.language|gpt-'` across the repo hits **two files, both false positives** (a Terraform API schema, a config macro lib). "assistant" appears only as node names in examples (`assistant.formalization`, `assistant.codegen`) — agents are _modeled as nodes in the graph_, never as interviewers. **There is no elicitation, no question-asking, and no natural-language front-end anywhere in this repo.**
+
+## 2. Elicitation-relevant insights
+
+The bookmark intuition is wrong about the surface but partly right about the substrate. zil-lean contains **no informal→formal capture**, but it is an unusually well-worked-out example of **the layer the kernel calls the claim graph** — and it is worth reading precisely for that.
+
+Transferable, documented:
+
+- **Evidence-graded claim graph as a first-class artifact.** `spec/assurance-levels-v1.md` defines five orthogonal labels — `exploratory`, `validated`, `kernel-backed`, `externally-attested`, `byte-attested` — with an explicit **prohibited-promotion lattice** (`validated ↛ kernel-backed`, `externally-attested ↛ kernel-backed`, `byte-attested ↛ validated`). Crucially: "Assurance labels state what checked a result… must not be inferred from a successful exit code alone." Directly reusable as the kernel's IR annotation on captured claims, and the sharpest thing in the repo.
+- **Full derivation provenance.** `spec/derivation-provenance-v1.md`: every fact node carries `id / fact / origin / stratum`, where origin is `base` or `rule(ruleName, premiseFactIds, negativeChecks, binding)`. Query answers emit witnesses with premise fact IDs and bindings. Reports are deterministic and **timestamp-free** so they hash stably. This is the evidence-preserving IR the kernel wants, worked out concretely — including the honest caveat: "The trace is graph-derived evidence. It is not a Lean kernel proof term."
+- **A concept vocabulary for formal-flavored capture that already distinguishes the kernel's hard cases.** `examples/formalization-arc-demo.zc` + `lib/theorem-dsl-macros.zc` separate **assumption** (with class + `ASSUME_HOLDS(source)` / `ASSUME_BROKEN(source, reason)`), **lemma**, **theorem**, **guarantee** (`THEOREM_ENSURES`), **evidence** (engine + token), **component**, **signal**, and **incident** (which can _break_ an assumption and propagate). Derived statuses: `PROVED` (deps satisfied + witness), `CONDITIONAL` (witness but assumption under review), `WEAK` (no witness), `BROKEN` (required assumption broken). That is a completion-criteria ladder, expressed as Datalog, that the kernel could adopt nearly verbatim.
+- **Two forms of "enough."** `spec/formalization-plan-v1.md` schedules `FORMALIZATION_TARGET` declarations (`module, file, declaration, status, priority, dependencies?`) over a validated acyclic dependency graph, defining _readiness_ as `status ∈ {ready, in_progress} ∧ ∀dep. dep.status ∈ {verified, reviewed, proved}`, with structured blocking reasons (`status:`, `missing:`, `dependency::`). `spec/agent-context-v1.md` defines a **context bundle** for handing a task to an agent — changed nodes → reverse impact → relevant facts → originating rules → auto-selected queries and targets — with explicit incompleteness issues (`unknown-changed-node`, `missing-query`, `missing-formalization-target`) and `context_bundle_id = sha256(report bytes)`.
+- **Drift detection on formal statements.** `spec/theorem-statement-locks-v1.md` locks `(token_id, declaration, module, kind, type_fingerprint)` and reports ordered check states (`fingerprint_changed`, `declaration_changed`, `missing_token`, `current_unresolved`, `unexpected_token`…). Answers "did the formalization silently stop meaning what the user said?" — a re-elicitation trigger.
+- **A request-elicitation schema that already exists.** `spec/request-form-core-v0.1.md` (draft) formalizes "a requester asks for ``" as `Data | Action | Compound | Recursive`, with `mode ∈ {dry_run, apply}`, explicit `Effect` entities, and `Criterion` acceptance predicates, lowered to canonical tuples with derived judgments (`has_side_effect`, `is_recursive`, `execution_contract plan_only`). **Inference**: this is the closest thing in the repo to an elicitation target schema, and it is _not_ Lean-flavored at all — it is intent-flavored. It may be more useful to the kernel than the theorem DSL.
+
+What zil-lean does **not** show, and the kernel must supply: how anyone _arrives_ at these declarations. Every `.zc` file is hand-authored. There is no question generation, no ambiguity detection, no candidate extraction from prose, no clarification loop. The Lean checker is used as an oracle over _already-formal_ artifacts, never as a feedback signal into a conversation.
+
+## 3. ElicitationPack sketch (elicit-lean/formal)
+
+Grounded in the vocabulary zil-lean shows is actually load-bearing.
+
+**Concept contract** — five kinds, distinguished by their _evidential obligations_, not their grammar:
+
+| Kind | Test | Obligation on capture |
+| --------------------- | ---------------------------------------------------------- | --------------------------------------------- |
+| `assumption` | Taken as given; not to be discharged here | must name a holder/source and a review status |
+| `invariant` | Must hold at all times over a named state/scope | must name scope + the state it constrains |
+| `proposition/theorem` | A claim asserted to follow from others | must name required assumptions + lemmas |
+| `guarantee` (ensures) | An outcome promised to a consumer | must attach to a producing component |
+| `constraint` | A restriction on inputs/config, not a claim about behavior | must name what it restricts |
+
+Non-negotiable per-item fields (from `THM_*` + proof-obligation governance): `id`, `criticality`, `depends_on: [assumption|lemma]`, `evidence?: (engine, token)`, `status`, and — added by the kernel, absent in zil-lean — `source_span` (the utterance it came from) and `paraphrase_confirmed: bool`.
+
+**Observation lenses** (what to notice in conversation):
+
+1. **Modal/quantifier lens** — "always", "never", "must", "for every", "at most one" → invariant candidate.
+2. **Hedge lens** — "assuming", "as long as", "we can take for granted", "in practice X is well-formed" → assumption candidate, and a _required_ follow-up on who guarantees it. `a_input_well_formed` in the demo is exactly this shape.
+3. **Consequence lens** — "so then", "which means", "that guarantees" → proposition with an implicit dependency edge to name.
+4. **Break lens** — "except when", "unless", "this fell over once when…" → either a missing precondition on an existing item, or an incident that breaks an assumption (`INCIDENT_BREAK_ASSUMPTION`).
+5. **Undefined-term lens** — a noun used in a claim that has no node yet → must be introduced before the claim can be normalized.
+
+**Completion criteria** (ladder taken from the demo's status computation, plus dependency-closure from the plan spec):
+
+- _Structurally complete_: every claim's `depends_on` targets exist; dependency graph acyclic; every term referenced has a node. (Directly = the plan spec's set-validation rules.)
+- _Epistemically complete_: no claim is `WEAK` without the user having explicitly deferred it; every `CONDITIONAL` names the assumption under review and its owner; every `critical` item has either evidence or an explicit, reasoned waiver (governance spec forbids waiving critical items).
+- _Faithfulness_: every captured item has a user-confirmed paraphrase. This is the one criterion zil-lean cannot inform — there is no user in it.
+
+## 4. ProjectionPack sketch
+
+**Output contract**: the claim graph projects to a `.zc`-shaped tuple set plus derived-status queries — the `formalization-arc-demo.zc` shape is a working, concrete target. Textual, diffable, deterministic, machine-checkable _without Lean_.
+
+**Deterministic validators, in ascending cost** — and this is the important structural finding: **zil-lean demonstrates that Lean is the wrong first validator.** Three cheaper tiers exist and catch most errors:
+
+1. **Schema/graph validation** (free): unique IDs, nonnegative priorities, existing dependency targets, acyclicity, stratification safety, relation declared in some base fact or rule head (`unknown-relation` verdict).
+2. **Datalog closure** (cheap, deterministic, bounded — default fuel 64/stratum): derives `PROVED / CONDITIONAL / WEAK / BROKEN`, impact sets, break roots. The real workhorse oracle, and it runs on the _informal_ graph.
+3. **Obligation governance** (cheap): checks that declared statuses are supported by evidence references — `proved-status-requires-evidence`, `obligation-not-discharged`, `waiver-reason-missing`, `critical-obligation-cannot-be-waived`.
+4. **Lean elaboration** (expensive, narrow): only for items actually written as Lean declarations. `spec/lean-verification-report-v1.md` runs `lake env lean ` per module + SHA-256 manifest match; `Zil.Trust.CertifiedRule` kernel-checks a proposition/proof pair.
+
+So: **Lean serves as the deterministic validator only for the `kernel-backed` tier, and zil-lean's own architecture says you must not let success at tiers 1–3 masquerade as tier 4.** The prohibited-promotion table is the lossiness policy, pre-written.
+
+**What's lossy**: the natural-language statement itself (`statement` is a free-text field in the obligation schema, unvalidated); the _reason_ an assumption is believed (only `source` is kept); alternative derivations (v1 provenance retains only the first witness); and — the deep one — the gap between "Lean accepted this declaration" and "this declaration means what the user said." `theorem-statement-locks` spells it out: "The lock does not claim that unchanged type fingerprints imply unchanged proof terms, source text, or external scientific meaning." The kernel must keep the utterance→claim→declaration chain because nothing downstream can reconstruct it.
+
+## 5. VERDICT — dev-sized?
+
+**A full elicit-lean/formal target is not dev-sized. A slice of it is, and it's a good one — but it is not the slice with Lean in it.**
+
+Reasoning:
+
+- **The heavy part is real.** Getting from a user's claim to a Lean _statement_ (not proof) requires committing to a type-theoretic encoding of the domain — the step autoformalization research finds hardest. That is deep-Lean-expertise work, and the contract is not definable without it. zil-lean quietly concedes this: it never writes theorem statements from intent. It stores `proof:Normalize.idempotent` as an opaque **token naming a declaration a human already wrote**. The demo file says so outright: "Proof status here is bookkeeping only. The proof assistant remains the sole proof authority; proof tokens name checked declarations but do not assert them."
+- **The light part is genuinely there, and zil-lean is a working existence proof of it.** Eliciting an **assumption/lemma/theorem dependency graph with criticality and evidence pointers** — no Lean statements, no proofs — is weeks-scale. Every validator you need is graph-level and already specified in this repo.
+
+**Smallest formal-flavored slice: "elicit-proof-obligations" / the verification arc.** Interview a user about a system they believe is correct; capture assumptions (with owners and review status), lemmas, theorems, guarantees, and the dependency edges among them; attach evidence references where they exist; project to a `.zc`-style graph; validate by acyclicity + stratified Datalog closure yielding `PROVED/CONDITIONAL/WEAK/BROKEN` + break-root and impact queries.
+
+**Does it differ from elicit-gherkin on both pack axes? Yes, cleanly:**
+
+- _ElicitationPack_: Gherkin's concept contract is **scenario-shaped and example-driven** (Given/When/Then, concrete instances, no cross-item structure); completion is per-scenario coverage. This target's contract is **claim-shaped and dependency-structured** — the unit is a proposition with edges to other propositions, the hedge lens is central (Gherkin has no notion of an assumption), and completion is _graph closure plus evidence adequacy_, not enumeration. Different lenses, genuinely different "enough."
+- _ProjectionPack_: Gherkin projects to a flat, independently-executable list; validation is parse + step-binding. This projects to a **DAG with derived statuses**, validated by fixpoint computation and an evidence-promotion lattice. The lossiness policy is substantive (assurance levels, first-witness-only) rather than near-absent.
+
+**Fallback comparison.** Take this slice **over** elicit-BPMN/process-mining as second target. Both differ from Gherkin, but the proof-obligation slice stresses the kernel harder on the axes the design cares about: it forces the claim-graph IR to carry _evidence grades and derivation provenance_ (BPMN mostly forces sequencing and gateway structure, which Gherkin partly covers), and it gives you a deterministic non-trivial validator — a Datalog fixpoint — without any external tooling or domain SME. Keep BPMN as third; it's the better _breadth_ target once the IR is stable, and it has an easier user-recruitment story. **Inference**, based on pack-axis distance, not on any BPMN sources reviewed here.
+
+One caveat worth carrying: nothing in this repo has been validated by use. Treat the specs as well-reasoned design documents by an agent-assisted author, not as field-tested contracts — the `assurance-levels` lattice and the `THM_*` status ladder are worth stealing on their merits, not on their track record.
+
+## 6. Unreached sources
+
+- Did not build or run anything (`lake build`, `lake exe zilLeanTests`, `clojure -M:test`) — no Lean toolchain here; **all correctness claims are from specs and source, not execution.**
+- Did not read: the 129 Lean files in `Zil/` beyond filenames; `spec/zil-formal-core-v0.1.md` (427 lines, the core semantics); `spec/canonical-relational-ir-v0.1.md`; `spec/evidence-envelope-v1.md`; `spec/query-governance-v1.md`; `spec/recovery-audit-v1.md`; `spec/dmetavm-core-v0.1.md`; `formal/` (TLA+/SMT); the 17 `libsets/` domain packs; the 133 example files beyond three.
+- Repo wiki is enabled (`has_wiki: true`) — not checked. 1 open issue — not read. No discussions, no releases checked.
+- Author `jagg-ix` (Jorge A. Garcia) — no external profile or publication search performed, so no independent read on provenance beyond commit metadata.
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/03-brunch-exchange-schema-audit.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/03-brunch-exchange-schema-audit.md
new file mode 100644
index 00000000000..ec7c8624322
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/03-brunch-exchange-schema-audit.md
@@ -0,0 +1,138 @@
+# Brunch exchange-schema audit
+
+Type: research
+Status: resolved
+Resolved: 2026-08-06
+
+## Question
+
+Catalog brunch's structured-exchange vocabulary and classify each element as **generic to any elicitation** vs. **brunch-specific** (coupled to the spec-graph/product), so the questioning-UX and contract-decomposition tickets inherit a map of what to keep, generalize, or drop.
+
+Specifically, in `../brunch` (read-only):
+
+- `src/exchanges/` — schemas (`ask`, `present_candidates`, `present_digest`, `present_review_set`, `request_choice`/`request_review`, capture/editor envelopes), projections, recovery
+- `src/agents/runtime/elicitor/` — prompt/context assembly, active-tool policy, the control-ownership table (spec posture / elicitation style / asking agenda)
+- `src/agents/skills/` — the activity-move routing table (analyze/elicit/ingest/propose/project/map/review) as candidate generic elicitation routines
+- Relevant `TOPOLOGY.md` files for boundary intent
+
+For each element: what interaction problem it solves, where it hard-codes brunch's graph/spec shape, and any right-sizedness doubts visible in the code or docs (the abstractions "were also just a guess" — look for strain marks: legacy kept-for-reads projections, migration notes, loud failure modes).
+
+## Answer
+
+> Resolved by `/research` subagent, 2026-08-06. Facts cited to file:line-range in `../brunch`; judgments marked **[J]**.
+
+# Brunch Structured-Exchange & Elicitor Audit
+
+## 1. Exchange catalog
+
+**`ask` (the only registered interactive terminal).** Solves: getting exactly one typed response from a human, in one of four shapes, without a separate collect tool per shape. Params (`src/exchanges/schemas/params.ts:~180-215`) carry markdown `body`, optional `options[]`, `multiple`, `allowOther`, `allowNone`, `commentPrompt`, `topLabel`/`bottomLabel`; no options ⇒ free text, options ⇒ single choice, options + `multiple` ⇒ multi-choice (`src/.pi/extensions/exchanges/TOPOLOGY.md`, "Answer sources"). Details (`src/exchanges/schemas/request.ts:~390-460`) echo the question _and_ the answer in one result: `question` echo (body, options, `commentPrompt`, `otherPrompt`) plus exactly one of `answered | cancelled | unavailable` — a **property-presence union** rather than a status enum (`src/exchanges/schemas/TOPOLOGY.md:293-300`). Continuation: `ask({ continues: exchange_id })` reads the declared continuation off a prior present; model-authored payload fields on a continuing ask are rejected at the params boundary (`params.ts` `zAskParams.superRefine`, message `"continuing ask payload is declared by the referenced offer"`).
+
+**`present_candidates`.** Solves: fan-out comparison — let the user _recognize_ a direction among alternatives instead of authoring one. Details (`src/exchanges/schemas/present.ts:185-235`): `display{heading, body?}` plus `candidates[]` of `{id, title, user_rubric, meta_rubric, graph_refs[]}`. `user_rubric` is six required nonblank markdown fields — `core_bet, best_fit, cost_complexity, covers_well, main_risks, lock_in_constraints` (+ optional `recommendation`); `meta_rubric` is four optional fields — `legibility_cost_of_knowing, failure_modes, coverage_range, commitment` (the D31-L four-axis meta-rubric, `memory/SPEC.md:262`). Continuation: `continuation: zOptionRequiredAskContinuationDeclaration` → `{tool: "ask", params: {body, options, ...}}`; the answer emits a `request_choice` detail discriminant with `tool_meta.prev = present_candidates`, and may lead to `capture_candidate`.
+
+**`present_digest`.** Solves: putting a large ingested source in front of the user as prose. Details (`present.ts:237-262`): `digest{abstract (required, nonblank), analysis?, recommendation?}`. Explicitly _not_ a graph carrier — graph payload fields are rejected (`schemas/TOPOLOGY.md:289`). Continuation is the **free-text** variant (`zFreeTextAskContinuationDeclaration`, which `z.never()`s options/multiple/allowOther/allowNone/commentPrompt/labels — `shared.ts`), collecting conversational feedback only. Acceptance is deliberately _not_ a review decision: a later standalone `ask({acceptsDigest, questions})` questionnaire or a `confirm|revise` single-select mints the accepted carrier, copying `accepted_abstract` from the runtime-resolved digest — caller-authored abstracts are rejected (`src/exchanges/TOPOLOGY.md:27`; `projections/ask.ts` `projectDigestQuestionnaire` / `projectDigestConfirmation`).
+
+**`present_review_set`.** Solves: batch approval of a structurally valid graph-mutation proposal. Details (`present.ts:60-180`): `review_set{nodes[], edges[]}` where each node is `{draft_id, proposed_code, settlement: advisory|settled, plane: intent|oracle|design|plan, kind, title, body?, detail?}` and each edge is a role-named discriminated union over nine categories (`dependency, witness, rationale, realization, refinement, exclusion, composition, cross_reference, supersession`) with `{draft_id}|{existing_code}` endpoint refs. Continuation is option-required. Approval is the commit: an approved continuation invokes shared settlement _before_ `ask.execute` returns, so only a committed, `receipt`-bearing result is appended (`.pi/extensions/exchanges/TOPOLOGY.md`; `ask/continuation.ts:527` throws `'Review-set approval must route through shared settlement'`). `request_changes` requires a comment; `request_changes`/`reject` are terminal-only, no graph effect.
+
+**Legacy `present_question`.** A merged question/offer anchor (`response_kind: answer|choice|choices` + options inline). Unregistered; its Pi adapter is deleted; the projection survives only for old persisted reads (`schemas/TOPOLOGY.md:132-134`; `projections/present-question.ts`).
+
+**`request_choice` / `request_review` / `request_answer` / `request_choices`.** No longer registered tools — they are the _preserved wire vocabulary_ for terminal details, so capture/sweep readers keep reading what they always read (`.pi/extensions/exchanges/TOPOLOGY.md:81-84`). `request_review` projection callers must pass a present-tool discriminator because review-set and digest close through the same detail kind but capture reads them differently (`projections/request-response/review.ts` `ReviewPresentTool`).
+
+**Capture envelopes.** `capture_answer|choice|choices|review|candidate` (`schemas/capture.ts`), each a `zCaptureDetailsHeader` + `tool_meta{prev, curr}`. Graph payloads intentionally undesigned in this pass (`schemas/TOPOLOGY.md:386-390`); `capture_candidate` consumes only the selected id.
+
+**Editor envelope.** `editor.ts` is a _wire_ envelope, not transcript detail: a JSON blob prefilled into `ctx.ui.editor` for the one payload Pi built-ins can't carry over RPC (multi-choice). Its `status: 'answered'|'cancelled'` string never enters details, which carry outcome as key presence (`schemas/TOPOLOGY.md:36-38`).
+
+**Questionnaires.** `schemas/questionnaire.ts`: `{id, kind: free-text|single-select|multi-select, prompt, options[]}` questions and kind-matched answers, replayed on persisted completion so IDs/kinds/option-membership/completeness can't diverge.
+
+## 2. Classification
+
+| Element | Class | Change needed / coupling |
+| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `ask` single-tool-four-shapes | **Generic** | The strongest abstraction here. **[J]** One terminal covering free-text/single/multi + a declared-continuation mode is the right primitive. |
+| Property-presence terminal union (`answered`/`cancelled`/`unavailable`) | **Generic** | Distinguishes "user declined" from "no UI available" — both are non-answers, and conflating them is the classic bug. |
+| `comment` (user-authored) vs `message` (system-authored) split | **Generic** | `schemas/TOPOLOGY.md:108-112`. Cheap, high-value provenance discipline. |
+| Question-echo-in-result (self-contained terminal) | **Generic** | Makes the transcript replayable without joining back to the present. |
+| Declared continuation (`present` names its own terminal; collector rejects model re-authoring) | **Generic** | **[J]** The single most transferable idea: the offer owns the answer vocabulary, so the model cannot drift the options between showing and collecting. |
+| `exchange_id` + `tool_meta{prev,curr,next}` chain | **Generalize-with-changes** | Currently a hand-maintained discriminated union of ~15 literal prev/curr/next triples (`shared.ts`). Replace with a generic `{exchangeId, parentId?}` link + one `form` tag; the pairwise enumeration is combinatorial and buys little. |
+| Recovery scan / pending-present resumption | **Generic** | `recovery.ts:78-81`: only an _answered_ terminal closes an exchange; cancelled/unavailable stay resumable. Any interviewer needs this. |
+| Present-then-ask two-step (offer ≠ collection) | **Generalize-with-changes** | Generic as "render surface separate from input surface", but brunch pins the offer set at three named tools. A kernel wants one `present(form, payload)` with pluggable payload validators. |
+| `present_candidates` as an _interaction_ | **Generic** | Fan-out/compare/recognize is plane-invariant (A31-L, `memory/SPEC.md:111`). |
+| Candidate `user_rubric` (six required fields) | **Brunch-specific** | Hard-codes brunch's product judgment about what makes specs comparable (`core_bet`, `lock_in_constraints`). A kernel should take a caller-declared rubric schema. |
+| Candidate `meta_rubric` (D31-L four axes) | **Generalize-with-changes** | SPEC calls it "a soft heuristic… not architecturally enforced" (`memory/SPEC.md:262`) yet it is a required schema key. Demote to optional caller-supplied axes. |
+| `graph_refs[]` on candidates | **Brunch-specific** | Node-id coupling to the spec graph. |
+| `present_digest` as an interaction | **Generic** | "Here is my reading of your source; react to it" is universal to ingestion-shaped interviewing. |
+| Digest accept-via-later-carrier (feedback ≠ acceptance) | **Generalize-with-changes** | **[J]** The _separation_ is a real lesson; the specific `acceptsDigest`/`accepted_abstract` field names and the "runtime resolves the final eligible digest" recognizer (`recovery.ts:18`) are brunch plumbing. |
+| `present_review_set` as an interaction | **Generalize-with-changes** | "Approve/request-changes/reject a proposed batch of writes, atomically" is generic. Brunch's version hardwires the target. |
+| Review-set node/edge draft schema | **Brunch-specific** | `plane: intent\|oracle\|design\|plan`, nine graph edge categories, `settlement: advisory\|settled` (D27-L), `proposed_code`. Params import `zReviewSetProposalPayloadForBoundary` from `graph/review-set.ts` — the only inbound coupling `exchanges/` allows (`exchanges/TOPOLOGY.md`, dependency direction). |
+| "Boundary-teaching" schema (advertise nested shape so the model sees it before the deep validator runs) | **Generic** | `schemas/TOPOLOGY.md:47-52`. **[J]** A genuinely good pattern: shallow schema for model legibility, deep validator for correctness. |
+| Approval-commits-atomically + receipt | **Generalize-with-changes** | Generic as "the approve branch runs the target's commit and the terminal carries the target's receipt"; `MutateGraphSuccess` (`request.ts:1`) is the coupling. Kernel needs an opaque target-supplied receipt type. |
+| Capture layer | **Brunch-specific** | Exists as envelope only, graph payloads undesigned; `capture_candidate` semantics are graph-shaped. |
+| Editor envelope (`editor.ts`) | **Generalize-with-changes** | Real problem (host UI can't carry a payload over RPC), brunch-shaped solution. Kernel: name it a "degraded-transport fallback", don't bind it to multi-choice. |
+| Option-id regex `^[^>\r\n]+$` | **Generalize-with-changes** | The comment says ids round-trip through an HTML-comment marker recovered by a regex stopping at `>` (`params.ts`). **[J]** A rendering leak in a validation schema — a kernel should encode ids opaquely instead. |
+| `active-tools.ts` allowlist | **Generalize-with-changes** | Generic pattern (fixed tool policy per role), brunch-specific contents (`read_graph`, `mutate_graph`, `read_elicitation_scratchpad`). |
+| Elicitation scratchpad (private, non-authoritative obligations) | **Generic** | D101-L: session-local, last-snapshot-wins, "durable truth stays in the graph; low-confidence noticings land here." Every interviewer needs a place for "noticed, not yet asked." |
+| `elicitation_style: interrogate\|disambiguate\|propose` | **Generic** | `session/elicitation-style.ts`. Style ≠ authority ≠ capability. |
+| `warrant-before-commit` directive (hash-pinned, ablatable) | **Generic** | `compose-live-prompt.ts` validates exactly one directive block, hashes it, exposes it as `providerVisibleText`, and supports dev-only ablation. **[J]** Treating one prompt paragraph as a versioned, A/B-testable artifact is a pattern worth stealing outright. |
+| Skill moves `analyze / elicit / ingest / propose / project / review` | **Generic** | These six are the actual elicitation routine vocabulary. |
+| Skill move `map` | **Brunch-specific** | Graph vocabulary/routing/persistence. |
+| Skill move `tutorial` | **Brunch-specific** | Product walkthrough of Brunch itself. |
+| `elicit` topology-driven question ranking table | **Generalize-with-changes** | The _shape_ — structural signal in the target ⇒ question shape — is generic; every row names graph kinds (`assumption`, `witness` path, `criterion`, `exclusion`). Kernel: targets supply their own signal→question-shape table. |
+| Readiness bands as "concentric concern envelopes, not workflow stages" | **Generalize-with-changes** | **[J]** Excellent dialogue policy ("absence matters _later_, capture is never illegal earlier"); the band vocabulary is brunch's. |
+| Prompt-injected skill manifest (`` with absolute paths, "do not infer additional skills") | **Generic** | `skills/registry.ts` `renderBrunchSkills`. |
+| `intent -> design -> verification -> scope -> build` handoff sequence, frontier/scope edge directions, `PROJECT_EXECUTION_HARNESS_TITLE` verify-recipe block | **Brunch-specific** | `prompts/elicitor.md`; `compose-live-prompt.ts` `renderProjectExecutionHarnessGuidance`. |
+
+## 3. Strain marks
+
+**Legacy-kept-for-reads.** `present-question.ts` and `request-response.ts` survive as projections with no registered tool (`exchanges/TOPOLOGY.md:22`; `.pi/.../TOPOLOGY.md:81-84`). `shared.ts` literally names the residue: `STRUCTURED_EXCHANGE_TERMINAL_NAMES = { current: 'ask', legacyRequestPrefix: 'request_' }`. The tool topology collapsed from `present_question → request_response` to `ask`, but the _detail_ vocabulary could not follow because capture/sweep readers were written against it.
+
+**Same detail kind, two meanings.** `request_review` closes both review-set and digest, so `projectRequestReview` demands a `ReviewPresentTool` discriminator from callers (`projections/request-response/review.ts`). Receipts are required for review-set approval and _strictly rejected_ on every digest branch (`schemas/TOPOLOGY.md:~325`). **[J]** Two different transactions wearing one name — a merge that shouldn't have happened.
+
+**A retracted merge.** Digest acceptance _was_ a review decision and was pulled back out: "digest acceptance is no longer a review decision" (`exchanges/TOPOLOGY.md:27`), with `recovery.ts:31-36` retaining a `legacyReview` parse path for digests approved under the old model.
+
+**Doc/code drift on edge categories.** `schemas/TOPOLOGY.md:194` documents `dependency | proof | support | realization | boundary | composition | association | supersession`; the Zod in `present.ts:89-160` implements `dependency | witness | rationale | realization | refinement | exclusion | composition | cross_reference | supersession`. Four categories were renamed and the schema doc was not updated.
+
+**Retired axes.** D98-L retired strategy/lens/method as runtime or manifest state; A35-L records the retreat: the axis model "may still be useful as prompt-resource organization, but it is no longer trusted as user-changeable or transcript-backed runtime state" (`memory/SPEC.md:115`). The migration note in `skills/TOPOLOGY.md` maps seven old constructs onto activity homes and ends with a hard rule: _"if a skill is live, it appears in the first-level registry; if guidance is not there, it is not switchable product state."_ Retired axes also left dead vocabulary behind — `lens` is still advertised in the review-set boundary schema (`schemas/TOPOLOGY.md:50`) and the elicitor prompt still says "plan-lens review set".
+
+**Loud failure modes.** Only two `throw`s in the whole exchange surface, both about authority: `'Review-set approval must route through shared settlement'` (`ask/continuation.ts:527`) and `'parsed ask parameters do not describe a runtime variant'` (`ask.ts:748`). Everything user-facing degrades to an `unavailable` terminal with a message instead (`continuation.ts:222` — a missing continuation declaration is an error string, not a crash). Params failures return bounded `TOOL_INPUT_INVALID` tool results with no human-visible transcript line.
+
+**Where validation lives (revised once).** D105-L: validate at trust boundaries — LLM params, RPC input, editor replies, transcript read-back — _not_ inside constructors, which "do not parse objects [they] just built." D108-L consolidated the whole contract out of two prior homes (`src/.pi/extensions/exchanges/schemas/` and `src/projections/exchanges/`) into `src/exchanges/`.
+
+**Lessons [J].** (a) Wire vocabulary outlives tool names — version the _detail_ schema independently of the tool registry from day one, or every tool rename leaves a fossil. (b) Do not merge two interaction forms because their outcomes look alike; brunch merged digest into review and had to un-merge it while keeping a legacy read path. (c) An enumerated `prev/curr/next` chain across N forms is O(N²) maintenance for a link that could be one parent pointer. (d) The `settlement: advisory|settled` retrofit (D27-L, FE-1187) shows that _strength of assertion_ is a first-class field, not a later annotation.
+
+## 4. Elicitor control model
+
+Three controls, three owners, three lifetimes (`runtime/elicitor/TOPOLOGY.md`, "Control ownership"):
+
+- **Spec posture** (`kind`/`origin`/`relatesToSpecId`) — owned by the persisted product row, spec lifetime. Session establishment decides _whether to ask_, not what the fact is; the live context renderer "cannot establish or overwrite it." Enforced in code: `context.ts:14-19` comments that `workspace.posture` is a _workspace_ stub, "not D118-L spec posture."
+- **Elicitation style** (`interrogate|disambiguate|propose`) — last valid `brunch.elicitation_style` entry on the active branch, session lifetime across kicks. The prompt adapter "projects it into the live elicitor control block without changing capability or authority."
+- **Asking agenda** — _has no state field at all_. Turn lifetime, "reconsidered from current conversation and on-demand reads." Origination supplies neutral graph facts once; the prompt directs `establish orientation` then `focus a vein`.
+
+The closing rule is the sharp part: **"Formatting is not authority: shared text helpers may render facts, but spec posture cannot satisfy style, style cannot persist or gate an agenda, and the prompt cannot establish product posture."**
+
+The conduct itself lives in `prompts/elicitor.md`: a new session "starts from graph facts and an empty or inherited elicitation scratchpad, **never a scored or ranked agenda**"; establish orientation, then "pick one concrete thread worth pursuing this session and let the scratchpad track obligations you notice along the way, rather than trying to cover every absence at once." Scratchpad obligations are private working state — "do not disclose even a summary… unless the user explicitly asks."
+
+**Judgment [J].** _Generic dialogue policy:_ the three-way separation of persisted-fact / session-style / turn-agenda; the refusal to persist an agenda (D101-L retired the persisted spec-scoped register in favour of a session scratchpad — `memory/SPEC.md:180`); "one vein, not full coverage"; private-by-default working state; the three style values; "formatting is not authority." _Product policy:_ that spec posture is the persisted axis at all; the specific graph facts constituting orientation; the `intent→design→verification→scope→build` sequence and execution-harness verify-recipe block; the `mutate_graph`/`read_graph` tool allowlist. **[J]** The most under-appreciated design move is the _negative_ one — an agenda deliberately has no storage, so it cannot go stale, cannot be gamed, and cannot become a scoring engine the prompt defers to instead of reading the conversation.
+
+## 5. Design lessons for a greenfield questioning-UX contract
+
+**Inherit**
+
+1. **Declared continuations.** The offer names its own terminal in its result; the collector fills body/options from that declaration and rejects model-authored payload on a continuing call. This is the mechanism that makes offer→answer non-forgeable.
+2. **Property-presence outcome union with three arms** — answered / cancelled / unavailable — plus the recovery rule that _only answered closes an exchange_ (`recovery.ts:78-81`). Cancel must leave the offer resumable, and the user-facing hint must stay honest after a cancel.
+3. **One terminal tool, several shapes.** Not one collect-tool per question type. Params-level cross-field refinement decides the shape; the runtime asserts the parsed params "describe a runtime variant" and throws if not.
+4. **Self-contained terminals.** Echo the question, the options, and the sub-prompts into the answer detail so a transcript reader never needs the present.
+5. **`comment` vs `message`** — never let user text and system text share a field.
+6. **Boundary-teaching schemas.** Advertise the nested shape shallowly for model legibility; keep the deep requiredness contract in one validator the target owns.
+7. **Agenda as derived state, not stored state**, with a private, non-authoritative scratchpad for "noticed but not asked."
+8. **Version and hash load-bearing prompt paragraphs.** `LIVE_ELICITOR_DIRECTIVES` pins one directive by sha256, validates its stable opening sentence, and supports dev-only ablation — prompt text as a testable artifact.
+9. **Separate "react to this" from "accept this."** Digest feedback and digest acceptance are different exchanges for a reason brunch learned by reverting the merge.
+10. **Approval-commits, with the target's receipt in the terminal.** Never leave "approved" and "committed" as two states the model can straddle — the settlement runs before the terminal is appended.
+
+**Avoid**
+
+1. **Enumerated `prev/curr/next` tool-meta unions.** Use one parent link and one form tag.
+2. **Product judgment baked into required schema fields** — the six-field `user_rubric` and the four-axis `meta_rubric` (documented as "a soft heuristic… not architecturally enforced" yet schema-required). Rubrics belong to the target, declared per call.
+3. **Rendering constraints leaking into validation** — the option-id regex exists because ids round-trip through an HTML comment. Encode ids opaquely.
+4. **One detail kind for two transactions.** `request_review` forces every caller to hand-carry a discriminator and forks receipt rules downstream.
+5. **Tool names as the versioning unit.** Brunch's wire vocabulary froze independently of its tool registry; plan for that from the start rather than maintaining `legacyRequestPrefix` and a parallel unregistered projection tree.
+
+**Key paths:** `../brunch/src/exchanges/{TOPOLOGY.md,recovery.ts,editor-envelope.ts,text.ts}`, `../brunch/src/exchanges/schemas/{TOPOLOGY.md,shared.ts,present.ts,request.ts,params.ts,questionnaire.ts,capture.ts,editor.ts}`, `../brunch/src/exchanges/projections/`, `../brunch/src/.pi/extensions/exchanges/{TOPOLOGY.md,ask.ts,ask/continuation.ts,index.ts}`, `../brunch/src/agents/runtime/elicitor/{TOPOLOGY.md,active-tools.ts,compose-live-prompt.ts,context.ts}`, `../brunch/src/agents/prompts/elicitor.md`, `../brunch/src/agents/skills/{TOPOLOGY.md,registry.ts,elicit/SKILL.md,propose/SKILL.md}`, `../brunch/src/session/elicitation-style.ts`, `../brunch/memory/SPEC.md`.
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/04-contract-decomposition.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/04-contract-decomposition.md
new file mode 100644
index 00000000000..66002ac067e
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/04-contract-decomposition.md
@@ -0,0 +1,88 @@
+# Contract decomposition: kernel / host / plugin / pack boundary
+
+Type: grilling
+Status: resolved
+Resolved: 2026-08-06
+Blocked by: 01, 03 (both resolved)
+
+## Question
+
+What exactly does the kernel own (mechanism + orchestration), what does the host supply (input shapes and pathways, deploy target), and what do plugins define (target policy) — and does the four-contract + pack decomposition (ElicitationPack: concept/observation/completion; ProjectionPack: projection) survive contact with the Flue facts and the brunch audit?
+
+Sub-questions this grilling must close:
+
+- Are the A-axis (semantic target) and B-axis (representation target) genuinely separately swappable in our first milestone, or bundled per plugin?
+- Where does the evidence-preserving IR / claim graph live, and how thin is its common core?
+- Persistence: does the plugin-owned-persistence hypothesis hold, or does the host (deploy target) own it with plugins declaring shape? What state must the kernel externalize (session, transcript refs, artifact-in-progress, episteme ledger)?
+- Where is control inverted: typed issues as backpressure (projector → elicitation controller) — is that the only inversion, or do observation lenses invert too?
+- The policy-vs-mechanism rule: enumerate what would otherwise become the central `switch` and check each is on the plugin side.
+
+Primary input: docs/reference/agentic-elicitation-challenges-2026-08-06T10-02-41Z.md
+
+Named input from the portfolio decision (issue 07): **behavioral over procedural** — agents do better with behavioral guidance than procedural scripts, and with clear shapes/patterns to fill rather than schemas that require extensive parsing to build a model of the output shape. Brunch's unsolved problem — specifying how an elicitation process should work plus skill material to guide an agent through it, without over-proceduralizing — is a core stress test for the pack contract. The decomposition must say what a pack _feels like_ to the agent consuming it, not only what it validates.
+
+Concrete test cases for every boundary claim (from issue 07): how would `elicit-gherkin` do this vs. `elicit-proof-obligations`? The spec mandates both packs are authored before the pack interface freezes.
+
+## Answer
+
+> Resolved by HITL grilling, 2026-08-06 (four rounds, including an evidence pass over `~/Clones/mattpocock/skills` + `../brunch/docs/design/BEHAVIORAL_KERNELS.md`, and integration of [agentic-elicitation-criteria](../../../../reference/agentic-elicitation-criteria-2026-08-06T14-11-18Z.md), the second inbox doc).
+
+### Ownership table
+
+| | Owns |
+| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Kernel** _(mechanism + orchestration)_ | The conversation loop, agent-forward (agent judgment at the helm) · the questioning-UX contract (issue 05's subject) · the **capture envelope** (below) around opaque plugin payloads · the **typed issue queue** (vocabulary, storage, factual attributes; the only stored agenda-like state; also where conflict/equivalence live) · the private scratchpad · the **turn-suspension protocol** (Flue has no ask-primitive; the kernel owns one) · operation _signatures_ (`observe/reconcile/project/validate`) with snapshot-in/deltas-out calling convention, validation, and application of returned deltas · completion evaluation (running plugin-declared criteria) · pack loading, progressive disclosure, kernel-card activation · capture-id minting · the **storage port** definition |
+| **Host** _(embedding + affordances)_ | Input surfaces and pathways (TUI / web / chat channel / Petrinaut later) · verified respondent identity · deploy target · **storage port implementation** · artifact delivery (repo write, API, post) · model/provider via the Pi family |
+| **Plugin** _(target policy)_ | Its **own IR payload structure** — graph, flat list, whatever fits; shared between its packs, never universal; namespaced concepts · **ElicitationPack**: kernel cards (Detects / Goal / contrastive Questions / Artifacts), completion contract, clarification hints · **ProjectionPack(s)**: `project` + `validate` (required), `reconcile` (optional), output contract, annotated shapes, **typed loss reports**, lossiness policy · artifact persistence _shape_ · domain vocabulary |
+
+### The capture envelope (the hourglass waist)
+
+Kernel-defined, domain-free — semantically rich, structurally minimal:
+
+- `id` (kernel-minted), evidence spans (utterance provenance, phrase-level where possible)
+- **Epistemic status** enum, distinct from confidence: `explicit | inferred | tentative | defaulted | external-lookup`
+- Confidence (qualitative), status: `active | superseded | retracted`, one `supersedes` link
+- **Absence states** as first-class capture values: `not-mentioned | unknown-to-user | not-yet-decided | not-applicable | explicitly-absent | declined | deferred`
+- **Alternatives** grouping: >1 live interpretation of the same evidence may coexist until resolved
+- Opaque, plugin-typed payload. **No kernel edges, no graph, no kind taxonomy** — structure is payload business. Conflict (`conflicting`) and equivalence (`possibly-equivalent`) are **typed issues referencing capture ids**, not edges; resolution must be an explicit event (supersession or recorded decision) — "no silent conflict resolution."
+
+### Operations
+
+- **Required**: `project` (captures → draft artifact **+ typed loss report**: `mapped-exactly / normalized / approximate / collapsed / omitted / defaulted / unrepresentable`) and `validate` (→ typed issues).
+- **Optional**: `reconcile` (dedup/merge over the plugin's own structure); kernel calls it when present.
+- **Agent-native**: `observe` — noticing is the agent's work guided by pack kernel cards; code-level extractors are an optimization, never the required path.
+- **Calling convention**: plugin ops receive an **immutable state snapshot**, return observations/issues/deltas; the kernel validates and applies. Buys atomic plugin failure, semantically idempotent retries (a retry never counts as a second user assertion), and tracing.
+- **Backpressure**: validators and projectors never address the user; they return typed issues the agent consumes.
+
+### Dialogue policy
+
+Behavioral guidance + factual issue queue. **Facts computed, weights judged**: the kernel computes issue facts (blocks-required-criterion, origin semantic|representational, can*default); the agent weighs them qualitatively. The inbox doc's priority formula is adopted as \_prose the agent thinks with*, never as a computed score — computed-priority dimensions are judgments wearing metric costumes, and a stored ranking becomes an authority the agent defers to instead of reading the conversation (brunch's no-stored-agenda lesson).
+
+### Cross-cutting decisions
+
+1. **No universal IR** — the kernel's slice is the envelope; structure is plugin-unique. Typed-entity-graph maximalism explicitly not adopted; a graph remains any plugin's private choice (including a future brunch-target plugin).
+2. **Smallest-honest-plugin test** — a flat record list + one validator must suffice; every kernel-contract addition is checked against the bar it raises. Empirical form: the black-box authoring test.
+3. **Axes separated in contract, bundled in shipping** — one ElicitationPack + N ProjectionPacks per plugin sharing the plugin's IR; swappability proven by reprojection.
+4. **Principle v2 (ratified, replaces "behavioral over procedural")**: _procedure for mechanism, anchors for judgment, shapes for output_. Evidence: the mattpocock skills are full of procedure that works because it is short, carried by leading words (pretrained concepts recruited deliberately), ends on checkable completion criteria, and rides on shapes/templates with progressive disclosure. Failure modes to design against: sprawl, negation-steering, no-ops, judgment-encoded-as-procedure — not procedure itself. `writing-for-agents` is the cited pack-authoring standard.
+5. **Kernel cards** (from BEHAVIORAL_KERNELS.md) are the unit of ElicitationPack content: Detects (signal-phrase activation) / Goal / contrastive Question patterns / Artifacts (typed claims emitted) / validator hooks. The fifteen-kernel ontology itself stays brunch prior art; packs declare their own kernels. Contrastive classification over open-ended essays.
+6. **Pack physical form**: kernel cards + annotated shapes + deterministic validators + small wire schemas (boundary-teaching: shallow for model legibility, deep requiredness in validators) + completion contract as checkable bounds.
+
+### Acceptance material adopted into the spec (from the criteria doc)
+
+- The **five proof obligations** as contract acceptance criteria: independent variability, semantic conservation, explicit transformation, controlled elicitation, local implementation.
+- The **ten kernel invariants** (§9) as kernel-enforced test properties (no unsupported value without provenance; no silent conflict resolution; no silent projection loss; corrections don't erase history; retries idempotent; target issues namespaced; plugin failures atomic; equivalent state → equivalent projection; unknown ≠ false; explicit ≠ inferred/defaulted).
+- Gating tests: **reprojection/projector substitution**, **minimal pairs** ("the budget is / might be €20,000"), **black-box authoring test** (count concepts, boilerplate, escape hatches).
+- Named smells as review vocabulary: opaque payload waist, giant context bag, schema-shaped questioning, null collapse, silent coercion/loss, correction-as-duplication, hidden target leakage.
+
+### Deferred to fog (post-milestone)
+
+Simultaneous multi-plugin composition · plugin removal · full replay · capability negotiation · version/migration machinery (the spec names the five version axes — API contract / plugin impl / concept-schema / target-schema / persisted state — implements nothing).
+
+### Routed onward
+
+- §4 SDK-machinery list (evidence anchoring, issue construction, fixtures, **local simulation harness** — "debugging should not require reading an entire agent transcript") → issue 06 (Shipping shape).
+- Envelope + absence states + turn-suspension → issue 05 (Questioning-UX contract).
+
+## Comments
+
+**2026-08-07 (vocabulary clarification from issue 05's resolution).** The ownership table's "Host (embedding + affordances)" bundled two concerns that later split: the **ui** shell (interface: rendering, input, reply transport, identity) and the **substrate** (the embedding environment: deploy target, storage-port implementation, artifact delivery, model/provider). Under the hardened lexicon (`CONTEXT.md`), this table's "Host" row reads as substrate concerns plus ui concerns; "kernel" reads as **harness**. No substantive change to the decomposition.
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/05-questioning-ux-contract.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/05-questioning-ux-contract.md
new file mode 100644
index 00000000000..d35d21c1696
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/05-questioning-ux-contract.md
@@ -0,0 +1,58 @@
+# Questioning-UX contract
+
+Type: grilling
+Status: resolved
+Resolved: 2026-08-07
+Blocked by: 03
+
+## Question
+
+What is the kernel's generic questioning-UX contract — the successor to brunch's `ask` / `present_*` / `request_*` exchange family — critiqued rather than copied?
+
+Sub-questions:
+
+- Which of brunch's exchange forms earn a place in the generic contract, which generalize with changes, which are brunch-specific and stay behind?
+- What does the "one high-value question over several low-value questions" dialogue policy need from the UX contract (question budgets, visible current interpretation, distinguish not-mentioned/no/unknown/N-A)?
+- How do typed issues (missing/ambiguous/conflicting/invalid/unsupported/unmapped/low-confidence) render as user-facing exchanges?
+- What must the contract leave to the host surface (TUI vs. web vs. chat channel) vs. fix in the kernel?
+
+Input from Contract decomposition (issue 04): the exchange contract must carry the envelope's conversation-level semantics — **absence states** (`unknown-to-user | declined | deferred | not-applicable`… as answer outcomes, not null), **alternatives** (letting more than one interpretation stay live through an exchange), and conflict-resolution exchanges (an explicit resolution event for a `conflicting` issue). Dialogue policy is behavioral guidance + factual issue queue — the UX contract renders issues to the agent, never scores them.
+
+Note from the Flue deep-read (issue 01): Flue has **no first-class ask-the-user primitive** — the kernel must own a turn-suspension protocol (`terminate: true` tool + pending question in persistent state + structured data part; the answer arrives as a fresh dispatch). The UX contract should be designed with that as the remote rendering path (`useDataWriter` / `dynamic-tool` output parts), alongside richer local surfaces.
+
+## Answer
+
+> Resolved by HITL grilling, 2026-08-06 (five rounds), with a live fact-finding pass over Flue's docs mid-session. All shape-level commitments are **working hypotheses** per the built-artifacts-as-proofs preference (ratified into the map's Notes during this session); delegated proof obligations live in tickets 10 (walking skeleton) and 11 (logic-prototype). Vocabulary hardened via `/domain-modeling` into repo `CONTEXT.md`.
+
+### The load-bearing reframe: no exchange-pair ontology
+
+Brunch's exchange machinery (offer→terminal pairs, pending-exchange state, recovery scans) was baggage from its older agent-initiated turn-by-turn model and is **not inherited**. The free-flowing conversation is primary. When the agent poses a structured question it emits an **affordance** — a rendered enhancement in the stream, not a state machine the harness maintains. There is no "pending exchange" concept, hence no cardinality rule, no recovery scan, no terminal union.
+
+- Ask invocations **do commit** structured question payloads to the session (design clue: Claude Code's `AskUserQuestion` — question set as `tool_use`, selections/interruption as adjacent `tool_result`; self-contained, replayable, no separate exchange store). The answer may follow as a structured response, or the ask may be cancelled/redirected — all of it is **session evidence**.
+- **Capture is decoupled from asking**: a **range-sweep** over session entries, run on **settlement** (agent-judged, range-level — a vein closing — never per-question, which would resurrect exchange-pairs through the back door). Harness owns sweep bookkeeping (high-water mark, idempotence); agent owns settlement judgment. → ticket 11.
+- Audit lessons demoted by the reframe: _declared continuations / non-forgeability_ (the failure mode shifts from forgery to misinterpretation, which the envelope's `epistemic_status: inferred` already covers; whether a widget reply needs an echo token is empirical → ticket 10); _only-answered-closes recovery_ and _self-contained terminals_ (patterns at most, not structure).
+
+### Shells, vocabulary, and control (hardened)
+
+**substrate** (Pi family, Flue) → **ui** (the user-interface shell: whatever affords interaction — rendering, input, reply transport; not bound to GUI/TUI) → **harness** (the generic capability layer — mechanism + orchestration; the effort's essence is _harness-engineering_; replaces "kernel" as shell name) → **plugin** (target policy). Control is IoC per the Hollywood principle: the plugin declares and registers; the harness discovers, orders, invokes; harness capabilities (the ask API, capture envelope, issue queue, sweep bookkeeping) reach the plugin as a **narrow injected context** — the questioning-UX contract is literally part of the PluginContext surface. Composition is the plugin's at authoring time; control flow is the harness's at runtime. Schema ownership follows capability ownership: _the shell that defines a capability owns its affordance schemas._
+
+### What the harness fixes (the generic contract)
+
+1. **Baseline question forms**: free-text / single-choice / multi-choice, plus **questionnaire chaining** as first-class baseline (beyond brunch's `ask`). The harness owns the standard ask API and payload shapes; plugins add custom presentation/collection forms through the plugin API.
+2. **Absence**: interpret by default, afford when structured. Agent-interpreted absence from free conversation carries `epistemic_status: inferred`; structured affordances carry a one-tap absence strip (don't-know / not-applicable / decide-later — generalizing `allowNone`), keeping `unknown-to-user` vs `declined` vs `not-applicable` explicit. Transport outcome and epistemic absence never conflate.
+3. **One harness data channel** multiplexing all affordance forms (`form` tag + plugin-typed body + markdown baseline), because Flue channel names are static structural identity in a flat collision-prone namespace. Plugin widgets are progressive enhancement keyed on the form tag; hosts that know only the envelope render everything via markdown. → ticket 10.
+4. **Interpretation render** ("visible current interpretation"): the one affordance form that must be harness-owned, since it renders the harness's own envelope vocabulary — captures with epistemic status, absence states, live alternatives. The **plugin may supply a renderer/projector definition with typed arguments** (typed against its own payload shapes), which the harness uses to produce the ui-level view; **when it doesn't, the harness falls back to a default renderer (plain JSON view of payloads)** — keeping the renderer optional, consistent with the smallest-honest-plugin test. React vs. accept are two _capture semantics_, not exchange steps. Renderer-seam exercised once real packs exist (ticket 07's portfolio).
+5. **Issues → exchanges**: only `conflicting` / `possibly-equivalent` get forced treatment — a `conflicting` issue closes **only via an explicit resolution record** (capture-layer event citing the user's utterance as evidence); the guarantee moved from wire to store. Every other issue type renders however the agent judges best, guided by kernel cards — prescribing seven mappings would be judgment-as-procedure. → ticket 11.
+6. **No question-budget machinery**: economical interviewing is implemented through strategy and judgment guidance in pack kernel cards; with asks committed to session, anything countable is derivable — no stored counters, no ask-to-issue attribution model, no stored number for the agent to defer to.
+
+### What the ui owns
+
+Rendering (zero built-in widgets in Flue — the embedding app branches on part types), reply transport, identity. Flue facts recorded for the spec: outbound is rich (Valibot-validated `data-*` parts, dynamic-tool outputs), **inbound is string-only** (`sendMessage(text, images)`; SDK signals are string-body too) — so answer typing/validation happens entirely harness-side on read-back; unknown part types are silently dropped (hence the markdown-baseline floor); one documented contradiction (data-part update-in-place vs append) needs the ticket-10 runtime check; turn suspension confirmed (`terminate: true`, answer as fresh dispatch; multi-tool batches terminate only when every result terminates).
+
+### Brunch disposition (sub-question 1)
+
+**Earns a place**: the three answer shapes as interaction vocabulary; questionnaire (promoted); absence affordances (generalized from `allowNone`); react≠accept (as capture semantics); no-stored-agenda (extended: no stored counters either). **Generalizes with changes**: named present tools → plugin-declared forms over one channel; approval-commits-atomically → the resolution-record store guarantee. **Stays behind**: the exchange-pair ontology and everything predicated on it (terminal unions, recovery scans, prev/curr/next chains, declared continuations as wire mechanism), plus everything the audit already classed brunch-specific (rubric schemas, review-set node/edge machinery, graph_refs). Audit lessons not touched by the reframe (comment-vs-message provenance, boundary-teaching schemas, hash-pinned prompt directives, private scratchpad) stand as pattern guidance for the spec.
+
+### Process decision (map-level)
+
+**Built artifacts as proofs** ratified into the map Notes: grilling tickets resolve decisions provisionally and name the proof obligations they delegate. Two prototype tickets created from this session: [Walking skeleton: Flue question round-trip](10-walking-skeleton-flue-roundtrip.md) and [Logic-prototype: capture sweep & settlement](11-logic-prototype-capture-sweep.md). Plugin lifecycle, fault containment, and contract versioning routed to [Shipping shape](06-shipping-shape.md).
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/06-shipping-shape.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/06-shipping-shape.md
new file mode 100644
index 00000000000..7ad3eb48837
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/06-shipping-shape.md
@@ -0,0 +1,74 @@
+# Shipping shape: kernel library vs. Flue agent
+
+Type: grilling
+Status: resolved
+Resolved: 2026-08-07
+Blocked by: 01, 04
+
+## Question
+
+What does the carve-out physically ship as — a kernel library that a thin Flue agent (and later Petrinaut/web/brunch hosts) embeds, or a Flue agent as the product itself — and what is the viable/ideal package structure?
+
+Sub-questions:
+
+- Given the Flue deep-read: is library-embedded-in-agent natural in Flue, or fighting the framework?
+- What does each option cost the Petrinaut-UI and web-UI futures?
+- Package topology: one package or kernel + packs as separate packages? Where do dev targets (elicit-gherkin, elicit-lean) live?
+- What is the local dev loop (run against both targets) vs. the remote deploy story?
+
+Input from Contract decomposition (issue 04): the plugin **SDK surface** is part of the shipping shape — standard machinery for evidence anchoring, claim identity, issue construction, schema validation, retries, idempotency, state-delta application, tracing, test fixtures, and a **local simulation harness** (fixture-driven pack testing: conversation in → expected claims/issues/projections out; "debugging should not require reading an entire agent transcript"). The black-box authoring test and change-surface metric are the acceptance bar.
+
+## Answer
+
+> Resolved by HITL grilling, 2026-08-07 (two rounds + a testing-strategy revision pass grounded in `expert-property-based-testing`).
+
+### Root: harness library in a thin host-authored agent — ratified, with the second-binding test
+
+The product is the **harness library** (core + packs/plugins); every host authors its own thin `'use agent'` module, `app.ts` mount, and storage adapter, and calls a hook shaped like `useElicitation(plugin)`. The Flue facts make the alternative structurally unavailable anyway (build-time `'use agent'` scan lives in the consuming project; a library cannot ship a pre-registered agent). A runnable reference app ships alongside as a dev/demo vehicle, not as the product.
+
+**Amendment — portability as a named pressure test, not a build target.** The deliverable decomposes into (1) pure runtime mechanism, (2) a tool surface, (3) prompt/skill material — and (3) is already portable for free (Open Agent Skills format is shared by Flue, Claude Code, and Pi-family harnesses). The substrate-agnostic-core non-goal stands as written (no ports for hypothetical consumers, no second maintained binding), but the decomposition must keep a second binding _demonstrably small_:
+
+- The spec **enumerates the substrate-facing surface as a short named capability list** the binding must supply: register a tool · contribute instructions · persist state · emit an affordance payload · suspend-for-reply · private model call. Porting = reimplementing that list; if it grows exotic Flue-shaped entries, that is an early smell signal.
+- **Second-binding test** (sibling to smallest-honest-plugin, adopted into spec acceptance material): every time mechanism wants to land in the binding rather than the core, ask "is this genuinely substrate-specific, or is mechanism leaking into Flue's dialect?"
+- Binding-size asymmetry is expected, not a failure: Flue's binding carries the turn-suspension compensation (ticket 10); a terminal binding gets ask-the-user nearly free but may only afford the questioning-UX markdown floor (which issue 05 already fixed as universal). Claude Code/Codex would be the awkward cousins (out-of-process: MCP server + skills dir). The core stays identical; each binding absorbs what its substrate lacks or forbids.
+- **Binding** entered the glossary (`CONTEXT.md`): the harness defines the capability list; a binding imports both harness and substrate; the harness imports no substrate.
+
+### Package topology
+
+- **Core and Flue binding as two workspace packages from day one.** The package boundary is the enforcement mechanism for the portability property; extracting a subpath later is visible churn. Acknowledged as mild ceremony now — accepted because the cost is low if kept clean.
+- **Monorepo in this repo** (brunch-lite becomes the workspace; rename is cheap once the real name resolves). Bun workspaces: `packages/core`, `packages/flue` (binding), `packages/plugin-gherkin`, `packages/plugin-proof-obligations`, `apps/dev`. The dev app owns the `'use agent'` module, `app.ts`, `db.ts`, and the Vite build Flue requires. Spec records the layout as intended structure; nothing is scaffolded during this map.
+- **Plugin packages are `plugin-*`, not `elicit-*`** — the prefix names what they are architecturally; "elicit" is the function, not the identity.
+- **Plugin SDK is core's public export surface** (authoring types + machinery; test/fixture machinery on a `core/testing` subpath so prod bundles stay clean). A separate SDK package would re-export core with no seam-value.
+- **Dependency rule, stated as a spec invariant: plugins depend on `core` only** — never on the binding, never on Flue. Every plugin is substrate-portable by construction, and the black-box authoring test stays honest (a plugin author's world is one package's exports).
+- **Envisioned horizon** (named, not built): per-substrate binding packages (`flue-`, `pi-`, `codex-`), same harness inside each. The payoff _if the second-binding test keeps passing_, not a commitment.
+
+### Cross-cutting choices
+
+- **Valibot throughout** — Flue locks it at every boundary; Standard-Schema-at-the-waist would buy plugin-author comfort at the cost of a conversion seam that can silently drop constraints (named smell: silent coercion/loss).
+- **Tool namespacing: prefix derived from the product name**, provisionally `bl_*` — never `elicit_*` (function vs. identity again). Core names ops abstractly; the binding renders them as substrate tool names. All model-facing tools are harness-owned (plugins expose ops, not tools).
+- **Naming principle** (recurring, carried forward): architectural strings name _identity_ (product, role), not _function_; the name-fog eventually resolves every one of these strings, so nothing bakes "elicit" or "brunch" into structure.
+- **Publishing posture: workspace-internal** — no npm publishing until the real name resolves and an external consumer exists. The spec describes the publishable shape; publishing waits.
+
+### Testing strategy: generation-first fixtures, deterministic replay
+
+The scripted deterministic driver (ticket 11's headless-driver pattern) is the **execution/replay layer**: fixtures are data, replay is pure, everything runs in plain `bun test` — no model, no substrate. But hand-written fixtures demote to **seeds**; the corpus is generated, answering the two untruthfulness modes:
+
+1. **Circularity** (fixtures tailored to the plugin-as-written): properties come from the **kernel contract** — the ten kernel invariants (ticket 04) are literally properties (re-sweep idempotence, equivalent-state → equivalent-projection, corrections don't erase history, retries idempotent, …) — and generators come from the **plugin's declarations**, never its implementation. The SDK ships `arbitraryFromSchema` (Valibot → fast-check arbitraries) for generated capture populations, plus negative-space properties for plugin code (validators total: never throw, always typed issues; `project` never emits an undeclared loss category).
+2. **Unrealistic conversational dynamics**: (i) most invariants hold over capture/state space directly — no conversation needed; (ii) where dynamics are the subject (sweep/settlement, supersession, absences), **model-based command-sequence testing** (`fc.commands`) over a small command alphabet — utter · settle-range · sweep · correct · contradict · reply-with-absence · redirect — derived from the envelope vocabulary; the fuzzer explores interleavings no hand-scripted conversation contains; (iii) language realism via a **model as offline generator, never CI oracle**: a model plays the respondent against the plugin's own kernel cards (Detects/Questions = targeting spec), varied by persona/curveball, plus a **mutation library** generalizing the minimal-pairs test (epistemic-status flips, absence injections, supersession injections). Outputs freeze as replayable fixture files; **regenerate when declarations change** (the anti-drift mechanism).
+
+Bonus adopted: shrunk counterexamples from broken invariants _are_ minimal pathological conversations — pinned as regressions and read first as type-design feedback on envelope/payload types. SDK surface therefore includes: schema-driven arbitraries, the command alphabet, mutation operators, fixture freeze/replay format — alongside ticket 04's list (evidence anchoring, capture identity, issue construction, retries, tracing).
+
+### Dev loop, demo, deploy
+
+- **One agent per target** in one dev app (`ElicitGherkin`, `ElicitProofObligations`): static per-agent tool sets (Flue cache economics), and the shape Cloudflare forces later anyway (build-time agent set; plugin choice is conversation-lifetime-immutable via `initialData` regardless).
+- **The dev app is chartered with three roles, spec'd as roles not features**: (1) local dev loop against both plugins; (2) the colleague-facing **target-gallery demo** — parallel tabbed sessions: start a BDD-spec elicitation, open another tab for a proof obligation, another for a process model; (3) the diagnostic/probe surface — provisional affordance renderers now (the deferred UI package's exploratory material), exploded-view instrumented readout when it graduates. Demo-polish and probe-internals pull opposite ways, so they are different views/routes of one app.
+- **UI affordance package deferred**: spec names it as intended (React renderers + reply transport over `@flue/react`; non-React hosts build on `@flue/sdk`), milestone one keeps renderers in the dev app.
+- **Remote deploy: milestone one is local-only; the spec pins the remote-parity constraints** (one-agent-many-conversations, pinned `agentName`, host-owned storage port, no dynamic agent creation) so nothing local-only creeps in. CI smoke = `vite build` + the simulation suite (no model key, no flake); an optional secret-gated real-model `flue run` smoke once a provider key exists. Actual deploy-target choice waits on an infra conversation (user, 2026-08-07) and blocks nothing on this map.
+
+## Comments
+
+**2026-08-07 (context pointer, from issue 11's cleanup).** The prototype branches are the implementation seeds for this topology, kept as primary sources out of main: `prototype/11-capture-sweep` holds the pure capture-layer reducer (no DOM, envelope semantics only) whose behavior `packages/core`'s capture module lifts _as semantics, not code-by-copy_, plus a headless driver whose 32 checks seed the invariant-property suite described under Testing strategy (several are already kernel-invariant instances: re-sweep idempotence, atomic sweep application, corrections-don't-erase-history, single-hop supersession). Likewise `prototype/10-flue-roundtrip` is the sketch `packages/flue` starts from. Working-tree copies were deleted after HITL review; retrieve any file with e.g. `git show prototype/11-capture-sweep:.scratch/elicitation-kernel/prototypes/11-capture-sweep.html`.
+
+**2026-08-10 (amendment, from [Multi-session elicitation & durable target state](12-multi-session-durable-target.md)).** The charter's persistence hypothesis resolved: **flipped** — the storage port is harness-defined and binding-implemented, plugin-blind; plugin-addressable storage exists only as harness-defined methods passed through the injected PluginContext. This sharpens two things already in this ticket: the capability-list entry "persist state" and the remote-parity constraint "host-owned storage port". Additionally, evidence spans carry pointer + quoted excerpt, so capture provenance is self-contained regardless of how a deploy target exposes substrate session logs.
+
+**2026-08-10 (amendment, second-target rename).** Per the [Formal-verification canon survey](09-formal-verification-canon-survey.md)'s category-error verdict, the second target is now the **assurance argument**; the topology's `packages/plugin-proof-obligations` reads `packages/plugin-assurance`.
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/07-dev-target-portfolio.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/07-dev-target-portfolio.md
new file mode 100644
index 00000000000..f2631b7ffa5
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/07-dev-target-portfolio.md
@@ -0,0 +1,34 @@
+# Dev-target portfolio confirmation
+
+Type: grilling
+Status: resolved
+Resolved: 2026-08-06
+Blocked by: 02
+
+> **Rename note (2026-08-10, spec assembly):** occurrences of `elicit-proof-obligations` below are the historical name; the second target is the **assurance argument**, package `plugin-assurance` (per the [Formal-verification canon survey](09-formal-verification-canon-survey.md)'s category-error verdict). Also superseded here: the four-rung status ladder is demoted to a derived UI label with the assumption ledger as headline (pre-pass S8), and the Geolog/ARIA adjacency guess is refuted (pre-pass S9).
+
+## Question
+
+Confirm the first milestone's two live dev targets. The zil-lean survey resolved the shape of the second: full elicit-lean is **not** dev-sized (writing Lean statements from intent is the deep-expertise step), but the **elicit-proof-obligations** slice is — capture an assumption/lemma/theorem/guarantee dependency graph with criticality and evidence refs, validated by acyclicity + Datalog closure (no Lean statements, no proofs), per the ElicitationPack/ProjectionPack sketches in the survey answer. The survey judges it a _better_ second target than BPMN on both pack axes; BPMN stays third.
+
+Proposed portfolio to confirm: **elicit-gherkin** (tracer) + **elicit-proof-obligations** (second, forces the pack swap and the evidence-graded IR).
+
+## Answer
+
+> Resolved by HITL grilling, 2026-08-06.
+
+**Portfolio confirmed**: `elicit-gherkin` + `elicit-proof-obligations` are the first milestone's two live dev targets; `elicit-BPMN/process-mining` is named third; full elicit-lean is deferred (writing Lean statements from intent is the deep-expertise step zil-lean itself never attempts).
+
+**Order — hybrid, addressing the real risk at the design layer**: the spec mandates that **both packs are authored before the pack interface freezes** (design against both simultaneously, on paper — the "two targets on each axis from the beginning" rule), while **elicit-gherkin wires end-to-end first** as the cheap mechanism proof, with elicit-proof-obligations immediately after. Rationale: the user's identified risk — brunch failed to find a scalable, modular way to specify an elicitation process plus guiding skill material without over-proceduralizing — lives in interface design, not wiring order. The trivial target must not freeze the pack contract before the hard target has stressed it.
+
+**Design principle surfaced (routed to Contract decomposition as a named input)**: agents do better with _behavioral_ guidance than procedural, and with _clear shapes/patterns to fill_ rather than schemas that require extensive parsing to build a model of the output shape. Packs are shapes-to-fill plus behavioral guidance — not procedural scripts, not parse-heavy schemas.
+
+**Proof-obligations output format**: steal the ideas, own the format. Adopt zil-lean's load-bearing vocabulary (evidence-graded assurance lattice with prohibited promotions; PROVED/CONDITIONAL/WEAK/BROKEN status ladder; acyclicity + Datalog-closure validation) in our own claim-DAG serialization, **hewing to whatever existing canon fits** — Dafny's `requires/ensures/invariant` contract vocabulary is the leading candidate; Geolog (ARIA program, axioms addressed via Datalog-like queries) is plausibly adjacent. Grounding this is the new **Formal-verification canon survey** ticket (09), which blocks Assemble-the-spec so the milestone lands canon-grounded. `.zc` export is someday-maybe; no dependency on the unproven zil-lean repo.
+
+**Gherkin validator depth (milestone one)**: parse validity + optional **pack-declared step-lexicon** binding check. The apparent codebase coupling dissolves: a step lexicon carried as pack policy needs no external project; only live-codebase step binding defers, named as the target's growth path.
+
+Sub-questions:
+
+- Do the two chosen targets differ materially on _both_ pack axes (semantic + representation)?
+- What is each target's smallest honest output contract for milestone one?
+- Which target is the tracer (built first) and which trails to force the second-pack swap?
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/08-assemble-the-spec.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/08-assemble-the-spec.md
new file mode 100644
index 00000000000..4c062fd66ba
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/08-assemble-the-spec.md
@@ -0,0 +1,40 @@
+# Assemble the spec
+
+Type: task
+Status: resolved
+Resolved: 2026-08-10
+Blocked by: 04, 05, 06, 07, 09, 10, 11, 12, 13
+
+## Question
+
+Assemble the destination spec from the resolved decisions: architecture + contract decomposition + questioning-UX contract + shipping shape + first milestone against the confirmed dev-target portfolio. Non-goals (harness-agnosticism) and the elicitor→executor seam named explicitly. This is the map's terminal deliverable; resolving it ends the effort.
+
+## Answer
+
+> Resolved by spec assembly, 2026-08-10. Full read of the ~54K-token working set (map, glossary, tickets 01–13 in the pre-pass's amendment order, both inbox docs), one session, no digest subagents — as sized.
+
+**The spec is assembled: [spec.md](../../../../spec.md)** — the map's terminal deliverable. Fourteen sections + an adjudications appendix: purpose · non-goals & the elicitor→executor seam · vocabulary · four shells + binding · the capture envelope (derived-status principle, three strata) · operations & validation strata · questioning-UX contract · capture mechanics · sessions/durability/storage port · the ten-item substrate-capability list · plugins & packs · shipping shape · dev targets & milestone one (gherkin + assurance argument, `Statement` contract) · acceptance material (five proof obligations, ten harness invariants restated in envelope vocabulary, gating tests, testing strategy, open verification items).
+
+**All seven pre-pass contradictions adjudicated** (spec Appendix A): C1 storage port binding-implemented, with the `db.ts`-vs-capture-store reconciliation stated; C2 the four operations stay pure — ticket 12's PluginContext-storage clause scoped to non-op code, milestone one defines no such methods; C3 status derived, never stored — retraction specified as an explicit user-cited event with no successor; C4 tap-ness made a transport fact via a harness-defined reserved reply encoding (else absences are inferred); C5 invariant 1 reconciled with the provenance rule — user-derived captures cite user entries, `defaulted`/`external-lookup` cite declared defaults/documented transformations; C6 the channel is a per-message current-affordance surface, durable identity on tool output parts, reject-second-interactive-affordance as mechanism; C7 picked together — no instruction interpolation (kills the wake wart's cause), pending question on the ask tool result + pending-affordance slot, reply binding harness-mechanical via the single-pending invariant, no echo token.
+
+**Notable assembler adjudications beyond the seven** (each flagged inline in the spec): retraction semantics; `not-mentioned` demoted to computed fact; absence-strip label mapping and the `not-yet-decided`/`deferred` distinction; advisories as computed-ephemeral vs. stored issues; issue namespacing; domain labels computed via `project` at read time; transport outcomes `answered | redirected | unanswered` (`unavailable` retired); session→target-document binding via `initialData`; milestone-one store format constrained by whole-sweep atomicity; **kernel card** kept as a term of art, **"kernel invariants" renamed harness invariants**.
+
+**Fold-ins completed alongside**: `CONTEXT.md` gained the envelope-vocabulary section (capture envelope, evidence span, epistemic status, absence state, supersession, resolution record, issue, advisory, pack, kernel card, PluginContext, storage port — pre-pass L11) and the kernel-compound ruling (L12); the `plugin-assurance` rename propagated as header notes on tickets 02 and 07; the 42-item obligation checklist verified covered (§ mapping held during drafting); the ten kernel invariants restated in current vocabulary (S14) so only one vocabulary exists in the acceptance criteria.
+
+Resolving this ticket ends the effort: the map's frontier is empty. Remaining fog (dev-app probe view, remote deploy target, storage format, concurrent-session coordination, plugin-ecosystem machinery, the real name) is post-spec by construction and graduates with the build effort the spec now enables.
+
+## Comments
+
+**2026-08-10 (pre-assembly prep, HITL).** Three prep items completed ahead of this ticket:
+
+- **Second-target rename decided**: the target is the **assurance argument**, package `plugin-assurance` (recorded on the [Formal-verification canon survey](09-formal-verification-canon-survey.md); ticket 09's category-error verdict discharged). Propagate over the historical `elicit-proof-obligations` occurrences in tickets 02 and 07.
+- **Cross-ticket consistency pre-pass**: [notes/consistency-prepass-2026-08-10.md](../notes/consistency-prepass-2026-08-10.md) — the assembler's working checklist. Contents: **seven contradictions** the spec must adjudicate, each with an authoritative-side recommendation (C1 storage-port implementer; C2 op purity vs PluginContext storage methods; C3 derived capture status changes 04's envelope schema; C4 `deferred (explicit)` lacks a transport mechanism; C5 provenance rule vs kernel invariant 1's declared defaults; C6 one-live-affordance slot vs multiplexed forms; C7 wake-wart remedy and reply binding must be picked together); **fourteen stale statements** (S1–S14) so nobody re-imports superseded material — notably 03's partly-overturned classification table and the requirement to restate the ten kernel invariants in current vocabulary; the **42-item "the spec must…" checklist** (§3b) collected from every ticket; and the **amendment-order reading guide** (§4) — read tickets in that order, treating 01/02/03 as lookup sources.
+- **Sizing corrected**: ~54K tokens total — map + glossary + tickets (~42K) **plus the two inbox docs** (`agentic-elicitation-challenges`, `agentic-elicitation-criteria`, ~13K), which ticket 04 adopts by reference and by count, so the spec cannot be assembled without reading them. Fits one session raw; no digest subagents needed.
+
+**New blocker added**: [Walking skeleton: sweep seam on Flue](13-walking-skeleton-sweep-seam.md) — the pre-pass found the sweep seam unproven on the committed substrate (no settlement-trigger lifecycle event exercised, no proven harness path to read a session entry range; items L1–L3). Decision (HITL, 2026-08-10): prove it before assembly. Its resolution also completes the substrate-capability list this spec must enumerate.
+
+Also fold in when drafting: the glossary needs the envelope vocabulary added (pre-pass L11–L12) — capture envelope, evidence span, epistemic status, absence state, resolution record, supersession, pack, issue, kernel card, PluginContext, storage port — and a ruling on the "kernel card" / "kernel invariants" compounds vs the glossary's "avoid: kernel".
+
+**2026-08-10 (HITL review round 1, via tuicr).** Seven comments on the draft; five spec amendments: (1) **executor/handoff language removed entirely** — brunch adoption leakage; the spec now names _no privileged downstream consumer_ (partially supersedes obligation 17's "elicitor→executor seam" clause); (2) absence-state enum marked a working set with expected extension pressure (the assumption vs. known-unknown lesson; naming-for-behavioral-activation as kernel-card-grade work); (3) structured taps pinned as optional ui capability, not a requirement; (4) `project` kept as canon op name with a stated prose preference for the noun (and the §9.4 "signals project" verb collision fixed); (5) **new §9.7 compaction vs. durable log** — compaction may shrink what the model re-reads, never what the store can resolve; durable-projection independence stated as a storage-contract constraint, binding absorbs otherwise; verification item added to §14.5; (6) Principle v2 ratified by acclaim, no change; (7) **binding packages take the role prefix**: `packages/binding-flue`, horizon `binding-*` (product name lives in the npm scope, not the basename).
+
+**2026-08-11 (HITL review round 2, from the plain-prose read-through).** Four observations, all folded in: (1) evidence pointers confirmed already stored — §5 sharpened so quote→entry resolution is stated as write-time-once, every later reader navigates by pointer, never text search; (2) `declined` ≠ `deferred` pinned in §5.1 — a decline is a boundary (closes only via explicit act), a deferral an invitation (completion evaluation chases it); (3) **conversations are documents too** — storage-port scope extended to capture store **plus session-log archive**, archive-on-read mechanism, session logs retained indefinitely with the target-document (§9.1/§9.6/§9.7 amended; the archive also becomes the compaction defense); (4) **generic strategy quiver written in as §11.5**, named-not-designed — guidance ownership follows vocabulary ownership; harness-shipped strategy cards over envelope vocabulary, plugin-composed; reference shapes `ln-grill`/`ln-disambiguate` and brunch's style trichotomy; the assurance technique decomposes into generic strategy + domain cards. `CONTEXT.md` (storage port, kernel card) and both companion docs updated to match. Note: round 2's changes were narrated in-session on 2026-08-10 but committed 2026-08-11 — the earlier narration preceded the actual edits.
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/09-formal-verification-canon-survey.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/09-formal-verification-canon-survey.md
new file mode 100644
index 00000000000..25039df9bb0
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/09-formal-verification-canon-survey.md
@@ -0,0 +1,137 @@
+# Formal-verification canon survey
+
+Type: research
+Status: resolved
+Resolved: 2026-08-06
+
+## Question
+
+> **Rename decided 2026-08-10** (HITL, during spec-assembly prep): the second target is the **assurance argument** — GSN's own noun, per this ticket's verdict that "proof obligations" reads as a category error. Package: `plugin-assurance` (was `plugin-proof-obligations` in the Shipping-shape topology). References to `elicit-proof-obligations` below are the historical name.
+
+What existing canon should the `elicit-proof-obligations` output contract hew to, and — written didactically, for a reader new to formal verification — what does a verification workflow _actually do_: what are its artifacts, what is one trying to produce, where does the human effort go?
+
+Specifically:
+
+- **Dafny's contract vocabulary** (`requires` / `ensures` / `invariant` / `decreases`, pre/postconditions, loop invariants): how practitioners actually express obligations, and how much of that vocabulary transfers to a language-agnostic claim DAG
+- **Proof-obligation workflow 101**: in Dafny/Lean/TLA+-style work, what is the day-to-day loop (state → obligation → discharge/failure → refine)? What does "an obligation" look like as an artifact? What roles do assumptions/axioms play?
+- **Geolog / ARIA relevance**: what is Geolog (ARIA program context — axiom sets ostensibly addressed via Datalog-like queries), and does its shape align with our acyclicity + Datalog-closure validator design?
+- **Recommendation**: what our claim-DAG format should align to — which canon's vocabulary, which parts to adopt vs. leave, and what the smallest canonical-feeling output contract for milestone one looks like
+
+Context: the portfolio decision (issue 07) adopted zil-lean's _ideas_ (assurance lattice, PROVED/CONDITIONAL/WEAK/BROKEN ladder, Datalog-closure validation) but rejected its format as unproven; the output contract should feel native to people who do this work.
+
+## Answer
+
+> Resolved by `/research` subagent, 2026-08-06.
+
+# Formal-Verification Canon Survey — for the `elicit-proof-obligations` target
+
+## 1. Proof-obligation workflow 101
+
+**The daily loop (Dafny-style, most concrete of the three).** You write code _and_ annotations in the same file. You hit save. The verifier translates your program into a pile of logical formulas called **verification conditions (VCs)** and asks an automated prover whether each is valid. Dafny specifically "verif[ies] that the program meets its specifications, by translating the program to verification conditions and checking those with Boogie and an SMT solver, typically Z3" ([Dafny Reference Manual §13.1](https://dafny.org/latest/DafnyRef/DafnyRef)). Green = discharged. Red = an assertion the solver could not prove. You then edit _the annotations, not usually the code_, and re-run. Loop time is seconds-to-minutes; Midspiral reports "proofs often take more than 10 minutes to run" on real domains ([midspiral.com](https://midspiral.com/blog/from-intent-to-proof-dafny-verification-for-web-apps/)).
+
+**What a proof obligation _is_.** It is machine-generated, not human-authored. The human writes _contracts_; the tool derives obligations from them. Worked micro-example:
+
+```dafny
+method Decrement(n: int) returns (m: int)
+ requires n > 0 // precondition — caller must establish
+ ensures m == n - 1 // postcondition — callee must establish
+ ensures m >= 0
+{ m := n - 1; }
+```
+
+From those three lines the verifier generates roughly: (a) _assuming_ `n > 0` and the body's effect, prove `m == n-1`; (b) same, prove `m >= 0`; (c) at every call site of `Decrement`, prove `n > 0` holds there. Add a loop and you get more: the invariant holds on entry, is preserved by one iteration, and (with the negated guard) implies what follows — "The `invariant` clause is effectively a precondition and it along with the negation of the loop test condition provides the postcondition. The `decreases` clause is used to prove termination" (Dafny RM §7.6). Termination needs the `decreases` expression to both _decrease_ and be _bounded below_ ([Dafny tutorial, Termination](https://dafny.org/dafny/OnlineTutorial/guide)).
+
+**"Discharging"** = the prover established that VC. Nobody hand-writes it. **On failure there are exactly two diagnoses** and telling them apart is the actual skill: "there are two main causes for Dafny verification errors: specifications that are inconsistent with the code, and situations where it is not 'clever' enough to prove the required properties" (Dafny tutorial). Failure gives you an error at a source location, optionally a counterexample — which Dafny explicitly downgrades to a hint: "Dafny cannot guarantee that the counterexample it reports provably violates the assertion... should be inspected manually and treated as a hint" (RM §13.7.1).
+
+**Where human effort goes.** Not into proofs — into _specs, invariants, and hints_. Concretely: strengthening a loop invariant; adding a `lemma` ("a lemma states a logical fact, summarizing an inference that the verifier cannot do on its own," RM §6.3.3); hiding irrelevant facts so the solver focuses ("sometimes less information is better for the solver," RM §8.20.2). Midspiral's numbers make the shape vivid: same kernel, "counter domain (~50 lines of proofs) and the Kanban domain (~1,400 lines of proofs)."
+
+**Durable vs. ephemeral artifacts.** Durable: the specification (contracts, invariants), the lemma corpus, and — critically — **the ledger of things assumed rather than proved**. Ephemeral: SMT queries, counterexamples, timings, proof-search traces. And the canon is explicit that the durable spec is the weak point: "Proofs guarantee that the implementation satisfies the specification. They don't guarantee that the specification is what you actually wanted... The human still owns the spec" (Midspiral, _Methodology Limitations_). That sentence is the entire justification for your product.
+
+## 2. Dafny's contract vocabulary — what transfers
+
+| Keyword | Meaning | Transfers to a claim DAG about an arbitrary system? |
+| -------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| `requires` | precondition, obligation on the _caller_ | **Yes** — the canonical name for "this claim's premise / what must hold for my guarantee to mean anything" |
+| `ensures` | postcondition, obligation on the _callee_ | **Yes** — canonical for "guarantee" |
+| `invariant` | property preserved across steps | **Yes** — reads naturally as a system-level always-true claim |
+| `decreases` | termination measure | **Partial** — as a _well-foundedness witness_ it's the honest canonical way to license a cycle; otherwise program-bound |
+| `modifies` / `reads` | frame conditions | **No** — "framing only applies to the heap, or memory accessed through references" (RM §7.1.4). Inherently program text. |
+| `assert` | prove this here | **Yes** — an obligation you accept |
+| `assume` | take this on faith | **Yes** — this _is_ your "assumption with review status" |
+| ghost state | spec-only variables, erased at compile | **No** — an artifact of having a compiler |
+| `lemma` | named reusable inference step | **Yes** — maps directly to your lemma record |
+
+The single most transferable thing in the Dafny ecosystem is not a keyword: it is **`dafny audit`**, which "reports issues in the Dafny code that might limit the soundness claims of verification" and flags declarations marked `{:axiom}`, `{:verify false}`, `{:extern}` with contracts, any `assume` in a body, and `decreases *` — because "the key purpose of the `audit` command is to ensure that all assumptions are intentional and acknowledged" (RM §13.6.1.8). It emits a **Markdown table**. That is, near-verbatim, the output artifact you are building. Adopt its framing.
+
+## 3. Adjacent canons
+
+**TLA+.** Obligation-like artifact: an _invariant_ or _temporal property_ checked against a state machine. Two tools, two epistemics. TLC does bounded exhaustive search: it "builds a finite state model... performs a breadth-first search... If TLC discovers a state which violates a system invariant, it halts and provides a state trace path" ([Wikipedia](https://en.wikipedia.org/wiki/TLA%2B)). TLAPS does real proof: proofs are "transformed into individual obligations which are sent to back-end provers" (Isabelle, Zenon, Z3), and are "hierarchically structured, easing refactoring and enabling non-linear development: work can begin on later steps before all prior steps are verified." **Fit: strong on structure** — hierarchical, obligation-per-step, partial completion is normal — but the vocabulary (`Init`, `Next`, `[]`, fairness) presumes a state machine you don't have.
+
+**Lean / Isabelle.** Artifacts: `definition` / `lemma` / `theorem`, organized in namespaces, with `axiom` a first-class declaration kind ([Lean Language Reference §8](https://lean-lang.org/doc/reference/latest/)). The culture-critical mechanism is **`sorry`-tracking**: a proof left incomplete still typechecks but taints the result, and `#print axioms` reveals the taint. **Fit: excellent for your lemma/theorem/assumption trichotomy and for the CONDITIONAL rung** — "proved, but modulo these named holes" is native theorem-prover thinking.
+
+**Alloy.** Vocabulary: `sig` (signatures define vocabulary), `fact` (always-true constraints), `pred`, `fun`, `assert` — checked by a SAT-based model finder within a bounded scope ([Wikipedia]()). "Lightweight formal methods": finds counterexamples, never proves. **Fit: weaker on vocabulary, but philosophically closest to milestone one** — you too are doing a cheap, bounded, always-terminating check that surfaces defects rather than certifying correctness. Borrow the _stance_, not the nouns.
+
+**GSN (assurance cases).** Six core element types: **Goal** (a claim), **Strategy** (the nature of the inference from a goal to its sub-goals), **Solution** (a reference to evidence), **Context**, **Assumption**, **Justification** (rationale). Two link types: **SupportedBy** (inferential or evidential) and **InContextOf** (relating Context/Assumption/Justification to Goals and Strategies). Goals and Strategies may be marked **Undeveloped** — "a line of argument has not been developed yet." Large arguments modularize via **away goals** ([GSN Community Standard v1, FAA-hosted PDF](https://www.faa.gov/about/office_org/headquarters_offices/ang/redac/redac-sas-201503-gsn-community-standard-v1.pdf); [SCSC GSN](https://scsc.uk/gsn); GSN liaises with OMG's [SACM](https://www.omg.org/spec/SACM/)). **Fit: best of the four for interviewed claims about an arbitrary system.** It was designed for exactly your situation — a human argues that a system is adequate, with heterogeneous evidence, in a graph, where "not yet argued" is a legitimate node state.
+
+## 4. Geolog / ARIA — negative result, stated plainly
+
+**No ARIA / Safeguarded AI / davidad artifact named "Geolog" could be found.** Searches across `geolog + davidad`, `geolog + Safeguarded AI`, `geolog + Datalog + verification kernel`, and GitHub returned nothing. The ARIA [Programme Thesis v2](https://aria.org.uk/media/ikrkutfk/safeguarded-ai-programme-thesis-v2.pdf) is an image-heavy PDF whose text could not be extracted; the [funded projects page](https://aria.org.uk/opportunity-spaces/mathematics-for-safe-ai/safeguarded-ai/funded-projects) and the [TA1.1 Theory call](https://aria.org.uk/media/tfkjkjxy/aria-safeguarded-ai-ta11-theory-call-for-proposals.pdf) describe "computationally practicable mathematical representations and formal semantics" without naming a logic. **Do not build on a claim that ARIA ships something called Geolog.**
+
+**What "Geolog" actually names in the literature** (documented): a logic-programming language for **coherent logic**, the language whose queries Skolem machines compute (Fisher & Bezem, _Skolem Machines_; Bezem & Coquand, _Automating Coherent Logic_). Coherent logic is "a restriction of first-order logic due to Skolem that is proof-theoretically tractable"; geometric logic is its infinitary generalisation, with axioms written as sequents built from `⊤, ∧, ⊥, ⋁, ∃, =`, and models "preserved and reflected by geometric morphisms" ([Wikipedia: Geometric logic](https://en.wikipedia.org/wiki/Geometric_logic); [nLab: geometric theory](https://ncatlab.org/nlab/show/geometric+logic)). There is a separate, unrelated _Geolog_ for GIS/spatial Prolog ([arXiv:2109.08295](https://arxiv.org/abs/2109.08295)).
+
+**Does the shape align with acyclicity + Datalog closure?** Yes, and non-trivially. Coherent-logic provers are **forward-chaining fixpoint engines** — "the first automated theorem prover based on coherent logic, Euclid, was developed in Prolog and its inference system relied on a forward-chaining mechanism," computing "the fixpoint for a geometric configuration" ([Automating Coherent Logic, Springer](https://link.springer.com/chapter/10.1007/11591191_18); [A Deductive Database Approach to Automated Geometry Theorem Proving](https://link.springer.com/article/10.1023/A:1006171315513)). Datalog is precisely the ∃-free, ⋁-free fragment of that. **(Inference):** the validator is a Datalog restriction of a coherent-logic saturation engine, which is a genuinely canonical lineage you can cite — Geolog is the _right ancestor_ to name, just not an ARIA one. Honest caveat: coherent logic in general is undecidable; Datalog is not. The restriction is what buys determinism.
+
+_(Adjacent, real, and possibly what was half-remembered: ARIA-adjacent work on **Kolm**, "an early-stage decentralized proof database designed to interoperate with Lean" — mentioned in [a davidad interview](https://www.cognitiverevolution.ai/alignment-with-awakening-davidad-on-moral-realism-ai-wisdom-why-his-p-doom-is-down-to-5/), with usable tools projected end of 2027. Single-source; treat as unconfirmed.)_
+
+## 5. RECOMMENDATION
+
+**Align to a GSN skeleton with Dafny nouns on the claim fields and Lean/Dafny-audit semantics on the status ladder.** GSN because it is the only canon designed for _argued_ claims about a system by humans with mixed evidence; Dafny because `requires`/`ensures`/`invariant`/`lemma` are the words verification people reach for first and cost nothing to adopt; `dafny audit` because it is literally the deliverable.
+
+**Adopt:** GSN's Goal / Strategy / Solution / Assumption / Justification vocabulary and its two link types; Dafny's `requires`/`ensures`/`invariant`/`lemma`/`assumption`; Lean's `sorry`-taint semantics; `dafny audit`'s "list of intentional, acknowledged assumptions" as the primary output.
+**Leave:** `modifies`/`reads` (heap-bound), ghost state, TLA+'s temporal operators, Alloy's `sig`/scope machinery.
+
+### Smallest canonical-feeling milestone-one contract
+
+**One record type, `Statement`, with a `kind` discriminant** (avoids five near-identical schemas):
+
+- `id`, `kind` ∈ {`goal`, `strategy`, `assumption`, `lemma`, `theorem`, `guarantee`, `constraint`, `evidence`, `justification`, `context`}
+- `statement` — one natural-language sentence, indicative mood
+- `owner`, `review_status` ∈ {`unreviewed`, `accepted`, `disputed`, `retired`} _(assumptions only; from `dafny audit`)_
+- `criticality` ∈ {`catastrophic`, `major`, `minor`} — **note: this comes from safety engineering (DAL/SIL/ASIL), not from Dafny/Lean, which have no notion of it.** Source it there and say so.
+- `evidence_refs[]`, `provenance` (transcript span), `developed: bool` (GSN Undeveloped)
+
+**Four edge kinds:**
+
+1. `supports` (GSN SupportedBy — inferential; child → parent)
+2. `evidenced_by` (GSN SupportedBy — evidential; claim → evidence)
+3. `requires` (Dafny precondition; claim → premise it needs)
+4. `in_context_of` (GSN InContextOf; claim → assumption/context/justification)
+
+Only `supports`, `evidenced_by`, `requires` are load-bearing for status. `in_context_of` is scoping.
+
+**Derived status, stratified:**
+
+- **S0** `refuted(X)` if evidence marked contradicting; `open(X)` if `kind=assumption ∧ review_status ∈ {unreviewed, disputed}`
+- **S1** `BROKEN(X)` if `refuted(X)` ∨ ∃ load-bearing child `BROKEN` _(pure positive recursion — closes first)_
+- **S2** `WEAK(X)` if ¬BROKEN ∧ (`¬developed` ∨ (no `evidenced_by` ∧ no `supports`))
+- **S3** `CONDITIONAL(X)` if ¬BROKEN ∧ ¬WEAK ∧ ∃ transitively-reachable `open` assumption
+- **S4** `PROVED(X)` if ¬BROKEN ∧ ¬WEAK ∧ ¬CONDITIONAL
+
+Negation only ever looks at a lower stratum — that is the stratification the validator must enforce, alongside acyclicity of the three load-bearing edge kinds.
+
+### Where canon conflicts with already-made decisions
+
+1. **The four-rung ladder is not canon; the audit list is.** Dafny and Lean are binary (verified / not) _plus an enumerated list of soundness caveats_. **Canon should win on emphasis:** make the per-claim status a derived UI label, and make the **assumption ledger** — every `open` assumption with its owner, review status, and which guarantees it taints — the headline artifact. Ship it as a Markdown table, like `dafny audit`.
+2. **Acyclicity is stricter than canon.** Lean and Dafny permit mutual recursion licensed by a `decreases` measure. **The acyclicity decision should win for milestone one** — it makes validation trivially decidable and the failure message legible — but record it as a deliberate restriction and name `decreases` as the future escape hatch. That framing reads as informed rather than naive to a verification reader.
+3. **GSN is deliberately _not_ computed.** A GSN goal structure is a human argument; nobody derives a verdict from it mechanically. The Datalog closure must therefore be sold as a **well-formedness and taint-propagation check, not an assurance verdict.** Never let the UI say a claim is "proved" unqualified. Borrow Alloy's honest stance: this finds defects; it does not certify.
+4. **"Proof obligation" is a term of art for a machine-generated VC.** We are eliciting _contracts and claims_, from which obligations would later be generated. Calling the output "proof obligations" will read as a category error to a Dafny user. **Prefer "obligation ledger," "claim structure," or "assurance argument."**
+
+## 6. Unreached sources
+
+- ARIA **Safeguarded AI Programme Thesis v1.2 / v2** — PDFs are image-based; no text extraction. Likely the single highest-value unread source for Q4.
+- **GSN Community Standard v3 (SCSC-141C)** — download endpoint returns a redirect stub, not the PDF. GSN element definitions come from the FAA-hosted v1 plus secondary literature; v3 may have refined them.
+- **Bezem & Coquand, _Automating Coherent Logic_** (ResearchGate HTTP 403) and **Fisher & Bezem, _Skolem Machines_** (Semantic Scholar, empty body) — the primary Geolog sources. Read via institutional access before citing Geolog's concrete syntax.
+- **LessWrong: _Davidad's Provably Safe AI Architecture_** — HTTP 429 rate-limited. Worth a retry for Q4.
+- **Alloy online tutorial** (HTML parser crash) and **alloytools day-course slides** (PDF) — Alloy detail rests on Wikipedia plus its citation of Jackson's _Software Abstractions_.
+- **TLAPS project site** (`lamport.azurewebsites.net/tla/tlaps.html`, 404); TLAPS facts are from Wikipedia and the INRIA mirror.
+- **Isabelle documentation** (fetch error) — Isabelle is covered only by analogy to Lean here.
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/10-walking-skeleton-flue-roundtrip.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/10-walking-skeleton-flue-roundtrip.md
new file mode 100644
index 00000000000..9efa6328e82
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/10-walking-skeleton-flue-roundtrip.md
@@ -0,0 +1,43 @@
+# Walking skeleton: Flue question round-trip
+
+Type: prototype
+Status: resolved
+Resolved: 2026-08-07
+Blocked by: 05
+
+## Question
+
+Does the one-channel questioning transport hold up in a real Flue agent + web UI? Build a walking skeleton — real Flue agent, minimal elicitor stub (no plugin), one question round-trip: structured ask affordance emitted via a single kernel-owned data channel (`form` tag + markdown-baseline payload), answer returned as string dispatch, interpretation recorded to the session.
+
+Proves or refutes (proof obligations delegated from the Questioning-UX contract, issue 05):
+
+- The **one-channel multiplex** working hypothesis: one fixed `data-exchange` channel, forms discriminated inside the payload, plugin widgets as progressive enhancement.
+- The **data-part update-in-place vs. append** contradiction in Flue's docs (hooks reference says in-place; streaming protocol + `AgentReply.data` say append) — runtime check.
+- Whether a reply needs an **echo token** binding it to the question asked, or whether transcript adjacency + agent interpretation suffice.
+- What the **turn-suspension protocol** actually needs to persist (`terminate: true` tool + fresh-dispatch answer) — and how a cancelled/redirected question reads back from the session.
+- UI-side **rendering ergonomics**: branching on the form tag, markdown fallback for unknown forms, `purpose`/`display` filtering of non-user-facing traffic.
+
+## Answer
+
+> Resolved by walking skeleton, 2026-08-07. Real Flue agent (`@flue/runtime` 2.0.3, vite 8, Node target, no db) + React UI (`useFlueAgent`), driven live over multiple conversations: full round-trips through single-choice, free-text, an unknown form, absence-strip taps, and a redirect. Prototype captured on branch **`prototype/10-flue-roundtrip`** (`prototypes/flue-roundtrip/` — README documents the probes; `probe-stream.mjs` is the SSE-level evidence).
+
+**Overall: the transport holds — with one load-bearing amendment.** The one-channel questioning transport survives contact with the real runtime, but the fixed data channel is a _one-live-affordance slot per message_, not an accumulating log, so affordance **identity** must ride the ask tool's output part, not the channel.
+
+### Verdict per proof obligation
+
+1. **One-channel multiplex: PROVEN, amended.** One `data-exchange` channel carried three forms (single-choice, free-text, and the deliberately unknown `rating-stars`); the UI branched on the `form` tag, rendered widgets for known forms and the markdown floor for the unknown one, and reply transport stayed string-only throughout. **The amendment:** writes to one channel name materialize _last-write-wins per assistant message_ — forced two `ask_user` calls in one turn, and the first question's affordance was silently clobbered from the durable record (both `dynamic-tool` parts survived, with inputs and validated outputs). So: the channel is a "current affordance" surface; per-ask identity and payload belong on the ask tool's `output` (Flue's React docs bless exactly this — tool output parts exist "so applications can render custom tool interfaces"). A kernel invariant follows: **the ask tool must reject a second ask in the same batch** (mechanism, not instruction — the stub's instruction-level "one question at a time" held until deliberately overridden, but the guarantee belongs in the tool).
+2. **Update-in-place vs append: SETTLED — update-in-place, at every layer.** The hooks reference wins; the streaming protocol's "append" describes delta chunks, not part materialization. Evidence: two same-channel writes (`draft` → `open`) produced two SSE deltas but every snapshot held exactly one part; durable history holds one part (final value); even `readSubmissionReply`'s "emit order" array returns one entry. Clients DO see intermediate values live (progress rendering works); only the final value persists.
+3. **Echo token: NOT NEEDED for the tested shapes — adjacency + persistent pending state suffice.** All replies were bare strings (typed text, choice-button labels, absence taps, a mid-stream redirect); the agent bound every one to the correct `exchangeId` because the pending question (with its id) is interpolated into the instructions from `usePersistentState`. The binding evidence is the `record_interpretation` tool part citing the exchangeId — session evidence, exactly as issue 05 hypothesized. Untested residual: simultaneous multiple open questions (ruled out by the one-ask invariant above) and long-delay/interleaved answers.
+4. **Turn suspension: WORKS, with a wake wart.** `terminate: true` + pending question in `usePersistentState` + answer-as-fresh-dispatch is sufficient; nothing else needed persisting. A cancelled/redirected question reads back cleanly: the affordance part stays in the transcript, and the `record_interpretation` part (`outcome: redirected`, `epistemicStatus: stated`) is the resolution evidence — the store-level guarantee issue 05 wanted. **The wart:** writing pending-state that's interpolated into instructions triggers a "System instructions updated" advisory _after_ the terminating batch, which wakes the model for an extra turn that emits redundant "I'm still waiting…" text — one wasted model call per ask, plus transcript noise. Spec options: don't interpolate the pending question into instructions (keep it in state only, or narrate it inside the ask tool's result), or accept and UI-filter. Related fact: a mixed batch (non-terminating `record_interpretation` + terminating `ask_user`) still suspended correctly.
+5. **Rendering ergonomics: PROVEN.** Form-tag branching, markdown fallback, and read-back of a whole past conversation from durable history (including resolved-question dimming derived purely from `record_interpretation` parts in the transcript — no side store) all worked first try. Messages carry `purpose` and `display` fields (`display: "diagnostic"` on the advisory noise) — the UI must filter on them; the skeleton didn't at first, and the advisories rendered as visible cards.
+
+### Incidental facts worth the spec's attention
+
+- **`@flue/vite` hard-requires vite ^8** (its `parseAstAsync` TS support); on vite 6 the `'use agent'` scan dies with a bare parse error. The directive must also be the file's first statement.
+- **The Flue dev controller gives `app.ts` the entire request space** — no fall-through to vite's HTML serving — so a co-located browser UI is served by the Hono app itself (vite still transforms module requests, but react-refresh/HMR is off the table without a second server). A real deployment would face the same: the ui shell is a separate app or app-served static assets.
+- Data-channel writes are Valibot-validated per write; `body: v.any()` works fine as the opaque plugin-payload slot with typed envelope fields around it.
+- Restart durability untested by design (no `db.ts` → process-memory conversations; a restart wipes them — consistent with the deploy-target-owns-persistence hypothesis).
+
+## Comments
+
+**2026-08-07 (user, post-resolution):** Ratified reading of the questionnaire dimension against the clobbering finding — the whole questionnaire is a **single affordance with multiple steps**, not per-question affordances. The **one-ask-per-batch invariant stands** even though one ask may carry multiple questions passed in the same call. **Progression is UI-driven**: the payload carries all N questions, the ui walks them locally, answers return as evidence (individually or batched), and the agent interprets on settlement — zero intermediate model turns, sidestepping the wake-wart. Interpretation-on-settlement hands off to the capture-sweep semantics in ticket 11.
diff --git a/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/11-logic-prototype-capture-sweep.md b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/11-logic-prototype-capture-sweep.md
new file mode 100644
index 00000000000..d4cdf0916c2
--- /dev/null
+++ b/libs/@hashintel/brunch-agent/docs/history/planning/elicitation-kernel/issues/11-logic-prototype-capture-sweep.md
@@ -0,0 +1,67 @@
+# Logic-prototype: capture sweep & settlement
+
+Type: prototype
+Status: resolved
+Resolved: 2026-08-07
+Blocked by: 05
+
+## Question
+
+Do the session-as-evidence capture mechanics hold up when implemented in isolation (no substrate — mechanism semantics only)? Build a logic-prototype of the harness's sweep machinery over a synthetic session log.
+
+Working hypotheses to prove or refute (delegated from the Questioning-UX contract, issue 05):
+
+- **Range-level settlement**: settlement is declared over ranges of conversation (a vein closing), never per-question — the agent judges _when_ a range has settled; the harness provides the bookkeeping (swept high-water mark). Per-question "settled" events are the rejected alternative (exchange-pair machinery through the back door).
+- **Sweep idempotence**: re-sweeping a range never double-captures (the retries-idempotent kernel invariant, exercised at the sweep level).
+- **Cancelled/redirected questions at sweep time**: an ask affordance committed to session but answered by cancellation, silence, or topic-change must read back honestly — as absence evidence, not as an answer.
+- **Supersession across sweeps**: a later range's captures superseding an earlier range's, via the envelope's one `supersedes` link and explicit events only.
+- **Conflict-resolution record**: a `conflicting` issue closes only via an explicit resolution record — a capture-layer event citing the user's utterance as evidence; the harness refuses to close the issue without it (the "no silent conflict resolution" invariant moved from wire to store).
+
+## Answer
+
+> Resolved by logic-prototype + HITL reaction, 2026-08-07. Prototype on throwaway branch
+> `prototype/11-capture-sweep` (commit d235258): single-file demo
+> (`11-capture-sweep.html`, an untracked historical artifact available from that branch) + headless
+> driver (32 checks passing) + browser walkthrough smoke (refusals land exactly on the
+> deliberately-illegal steps, no console errors). The pure reducer module — the liftable part — is
+> the demo's first script block.
+
+### Verdict: all five hypotheses hold, each sharpened
+
+1. **Range-level settlement — holds.** Zero per-ask state exists anywhere in the model; the swept high-water mark is the only sweep bookkeeping. Sharpened by the reaction: settlement decomposes into **trigger** (when to invoke the judgment — substrate lifecycle events such as turn-end/agent-settled; wiring proof delegated to ticket 10) and **judgment** (the agent names `upTo`). The race with concurrent user input is benign by construction: a sweep asserts only "read up to N," never "conversation paused" — new entries land above the high-water mark, in the next range.
+2. **Sweep idempotence — holds, split in two.** The harness guarantee is **mechanical** idempotence via evidence-anchored capture identity (dedup key = evidence spans + payload/absence) — aligned with where retries actually occur, since a retried tool call re-executes byte-identical proposals. **Semantic** re-interpretation (a fresh judgment re-phrasing the same fact) is deliberately not a harness concern: that is plugin `reconcile` + `possibly-equivalent` issues. Identity is **content-based, not range-based**: a re-sweep never double-captures but _can repair omissions_ (exercised in the dodged-question walkthrough). Epistemic status is excluded from identity — revising the epistemic reading of unchanged evidence requires explicit supersession, never a silent update.
+3. **Honest absence — holds.** A dodge reads back as `declined (inferred)`, a strip tap as `deferred (explicit)`; neither is an answer, and transport outcome never conflates with epistemic absence. Absences are **evidence, not agenda**: the re-ask path runs through plugin `validate` → typed issues, with completion evaluation as the backstop for unresolved deferrals on required concepts. Adopted mechanism: the **unaccounted-ask advisory** — a swept range containing an ask with no reply and no capture citing it makes the harness report the fact and block nothing.
+4. **Explicit supersession — holds.** Supersession is single-hop over **active heads only**: superseding an already-superseded capture is refused, which is the lost-update guard (a corrector must confront the current head, so history stays a chain, never a silently forking tree). Sweeps validate and apply atomically; superseded captures remain visible — corrections don't erase history.
+5. **No silent conflict resolution — holds, with the wrinkle.** A bare close is refused; a resolution citing the _agent's_ words is refused; only a record citing the user's utterance closes a `conflicting` issue. Wrinkle: the envelope's one creation-time `supersedes` link cannot adjudicate between two _already-existing_ alternatives — there are **two supersession channels**: the link (sweep-time correction) and the resolution record (issue-time adjudication). Related: the winning capture keeps its original epistemic status while authority sits in the record, suggesting per-capture status be **derived at read time** (echoes the derived-label approach from the formal-verification canon survey, issue 09).
+
+### Amendments to prior decisions (from the HITL reaction)
+
+- **Two validation strata**, amending the operation tiering in Contract decomposition (04): **envelope-level, harness-owned** — hard invariants enforced as refusals (provenance required, value-xor-absence, single-hop supersession) plus computed facts raised as advisories or generic `possibly-equivalent` issues (same-evidence duplicate actives, near-identical payload text — the harness can compare payloads as strings without understanding them) — versus **payload-level, plugin-owned** (`validate`/`reconcile` as already decided). Strengthens the smallest-honest-plugin test: a flat-record plugin gets generic duplicate detection for free.
+- **Op cadence is orchestration policy, not correctness.** Nothing had pinned when `project`/`validate`/`reconcile` run; snapshot-in/deltas-out purity means the harness may run them at any time without changing outcomes. Sweep-completion is the default trigger; the spec states cadence as explicit harness policy.
+- **Resume-time sweep reconciliation.** A session ending between settlement judgment and sweep leaves an unswept tail — a computable fact (entries above the high-water mark). On resume the harness surfaces it as an advisory and the agent judges whether to sweep before proceeding. Facts computed, weights judged.
+
+### Graduated
+
+The multi-session question raised in the reaction (target state durable beyond sessions; interleaved sessions against one target; re-entry semantics) graduates the map's "Spec permanence / sessions-roam-across-specs" fog → [Multi-session elicitation & durable target state](12-multi-session-durable-target.md). The prototype's decomposition carries it: durable capture store / per-session evidence logs / sweep as sole bridge, with the single-hop refusal doubling as the stale-session guard.
+
+## Comments
+
+**2026-08-07 (prototype built — awaiting your reaction; HITL).** The logic-prototype is done and the mechanics were exercised end-to-end. **Provisional verdict: all five hypotheses hold**, with two design wrinkles surfaced for the spec.
+
+**Assets** (throwaway branch `prototype/11-capture-sweep`, commit d235258):
+
+- `11-capture-sweep.html` — single-file shareable demo retained only on the historical branch. Five
+ guided walkthroughs (one per hypothesis, including every deliberately-illegal move) plus a
+ free-play console with evidence-ticking and a sweep-proposal builder. The pure reducer module (no
+ DOM) is the first `