diff --git a/.config/oxlint/brunch/base.json b/.config/oxlint/brunch/base.json new file mode 100644 index 00000000000..fdda879f279 --- /dev/null +++ b/.config/oxlint/brunch/base.json @@ -0,0 +1,102 @@ +{ + "$schema": "../../../node_modules/oxlint/configuration_schema.json", + "plugins": [ + "eslint", + "typescript", + "unicorn", + "oxc", + "import", + "jsdoc", + "node", + "promise", + "vitest" + ], + "rules": { + "array-callback-return": ["error", { "allowImplicit": true }], + "default-case-last": "error", + "default-param-last": "error", + "eqeqeq": ["error", "always", { "null": "ignore" }], + "guard-for-in": "error", + "no-alert": "error", + "no-bitwise": "error", + "no-cond-assign": ["error", "always"], + "no-console": "error", + "no-extend-native": "error", + "no-loop-func": "error", + "no-multi-assign": "error", + "no-new": "error", + "no-new-func": "error", + "no-param-reassign": [ + "error", + { + "props": true, + "ignorePropertyModificationsForRegex": ["^existing", "draft"] + } + ], + "no-return-assign": ["error", "always"], + "no-self-compare": "error", + "no-sequences": "error", + "no-template-curly-in-string": "error", + "no-unsafe-optional-chaining": [ + "error", + { "disallowArithmeticOperators": true } + ], + "no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_+", + "varsIgnorePattern": "^_+" + } + ], + "no-void": ["error", { "allowAsStatement": true }], + "func-names": "error", + "new-cap": "error", + "import/no-cycle": "error", + "import/no-duplicates": "error", + "import/no-mutable-exports": "error", + "import/no-named-as-default": "error", + "import/no-named-as-default-member": "error", + "import/no-named-default": "error", + "import/no-self-import": "error", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/ban-ts-comment": [ + "error", + { + "ts-expect-error": "allow-with-description", + "minimumDescriptionLength": 10 + } + ], + "@typescript-eslint/no-empty-object-type": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-implied-eval": "error", + "@typescript-eslint/no-misused-promises": "error", + "@typescript-eslint/no-require-imports": "error", + "@typescript-eslint/no-unnecessary-condition": "error", + "@typescript-eslint/no-unnecessary-type-constraint": "error", + "@typescript-eslint/no-unsafe-argument": "error", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-call": "error", + "@typescript-eslint/no-unsafe-function-type": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + "@typescript-eslint/no-unsafe-return": "error", + "unicorn/filename-case": "error", + "unicorn/no-new-array": "off", + "vitest/valid-expect": "off", + "constructor-super": "off", + "no-class-assign": "off", + "no-const-assign": "off", + "no-constant-condition": "off", + "no-dupe-keys": "off", + "no-func-assign": "off", + "no-import-assign": "off", + "no-obj-calls": "off", + "no-redeclare": "off", + "no-setter-return": "off", + "no-this-before-super": "off", + "no-throw-literal": "off", + "no-unsafe-negation": "off", + "prefer-promise-reject-errors": "off" + } +} diff --git a/.config/oxlint/brunch/react.json b/.config/oxlint/brunch/react.json new file mode 100644 index 00000000000..99c985729d8 --- /dev/null +++ b/.config/oxlint/brunch/react.json @@ -0,0 +1,49 @@ +{ + "$schema": "../../../node_modules/oxlint/configuration_schema.json", + "extends": ["./base.json"], + "plugins": [ + "eslint", + "typescript", + "unicorn", + "oxc", + "import", + "jsdoc", + "node", + "promise", + "vitest", + "react", + "react-perf", + "jsx-a11y" + ], + "rules": { + "react/button-has-type": [ + "error", + { "button": true, "submit": true, "reset": false } + ], + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-target-blank": ["error", { "enforceDynamicLinks": "always" }], + "react/jsx-pascal-case": ["error", { "allowAllCaps": true }], + "react/no-array-index-key": "error", + "react/no-danger": "error", + "jsx-a11y/aria-role": ["error", { "ignoreNonDOM": false }], + "jsx-a11y/label-has-associated-control": "error", + "jsx-a11y/no-noninteractive-tabindex": [ + "error", + { "tags": [], "roles": ["tabpanel"] } + ], + "jsx-a11y/no-static-element-interactions": [ + "error", + { + "handlers": [ + "onClick", + "onMouseDown", + "onMouseUp", + "onKeyPress", + "onKeyDown", + "onKeyUp" + ] + } + ], + "jsx-a11y/prefer-tag-over-role": "off" + } +} diff --git a/.github/actions/prune-repository/prune.py b/.github/actions/prune-repository/prune.py index bfb5a60564d..891da3fdfe2 100644 --- a/.github/actions/prune-repository/prune.py +++ b/.github/actions/prune-repository/prune.py @@ -54,6 +54,7 @@ # architecture tests in packages/core read its docs, scripts, and agent # contract files "@hashintel/brunch-agent": [ + ".config/oxlint/brunch", "libs/@hashintel/brunch-agent/AGENTS.md", "libs/@hashintel/brunch-agent/CONTEXT.md", "libs/@hashintel/brunch-agent/docs", diff --git a/apps/brunch-agent/.oxlintrc.json b/apps/brunch-agent/.oxlintrc.json new file mode 100644 index 00000000000..b83c386eb3b --- /dev/null +++ b/apps/brunch-agent/.oxlintrc.json @@ -0,0 +1,61 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.config/oxlint/brunch/react.json"], + "categories": { + "correctness": "error", + "perf": "warn" + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "env": { + "builtin": true, + "es2026": true, + "node": true + }, + "settings": { + "react": { + "version": "19.2" + } + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@hashintel/petrinaut", + "message": "The Brunch server must remain independent of Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "The Brunch application must not depend on unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "The Brunch server must remain independent of Petrinaut implementations." + } + ] + } + ] + }, + "overrides": [ + { + "files": ["src/ui/**/*.{ts,tsx}"], + "env": { + "browser": true + } + } + ], + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index fc199cec26f..2768015dbfa 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -8,8 +8,8 @@ "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 .", + "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --type-check --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" @@ -34,6 +34,7 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260511.1", + "ai": "6.0.182", "oxlint": "1.63.0", "oxlint-tsgolint": "0.22.1", "vite": "8.1.0", diff --git a/apps/brunch-agent/src/agents/gherkin-elicitor.ts b/apps/brunch-agent/src/agents/gherkin-elicitor.ts index a6bbc6d387d..9aaf8b1e493 100644 --- a/apps/brunch-agent/src/agents/gherkin-elicitor.ts +++ b/apps/brunch-agent/src/agents/gherkin-elicitor.ts @@ -34,9 +34,14 @@ import { createGherkinElicitationSession } from "../elicitation-session.ts"; */ export const GHERKIN_MODEL_ID = "claude-haiku-4-5"; +const gherkinElicitorInitialData = v.object({ + targetDocumentId: v.pipe(v.string(), v.nonEmpty()), +}); + export function GherkinElicitor(props: AgentProps) { useModel(`anthropic/${GHERKIN_MODEL_ID}`); - const initialData = useInitialData<{ targetDocumentId: string }>(); + const initialData = + useInitialData>(); return useElicitation( gherkin, createGherkinElicitationSession(props.id, initialData.targetDocumentId), @@ -68,6 +73,4 @@ GherkinElicitor.agentName = "brunch-gherkin-elicitor"; * 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()), -}); +GherkinElicitor.initialData = gherkinElicitorInitialData; diff --git a/apps/brunch-agent/src/app.ts b/apps/brunch-agent/src/app.ts index 6ce50876433..65915d05f8d 100644 --- a/apps/brunch-agent/src/app.ts +++ b/apps/brunch-agent/src/app.ts @@ -42,6 +42,7 @@ app.on(["POST", "OPTIONS"], PETRINAUT_CHAT_ROUTE, (c) => // client build is a second, plain vite build — without it the ui tree would // have no build coverage at all. const uiRoot = new URL( + // oxlint-disable-next-line typescript/no-unnecessary-condition -- import.meta.env is absent when Node executes this module directly. import.meta.env?.DEV === false ? "./client/" : "../", import.meta.url, ); diff --git a/apps/brunch-agent/src/elicitation-session.ts b/apps/brunch-agent/src/elicitation-session.ts index ae7a9753668..827a91833fd 100644 --- a/apps/brunch-agent/src/elicitation-session.ts +++ b/apps/brunch-agent/src/elicitation-session.ts @@ -4,15 +4,19 @@ import { createFlueHistoryReader, createLocalCaptureStore, type ElicitationSession, + type FlueHistoryReaderOptions, } 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 appTransport: FlueHistoryReaderOptions["transport"] = async ( + input, + init, +) => { 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, diff --git a/apps/brunch-agent/src/petrinaut-chat.ts b/apps/brunch-agent/src/petrinaut-chat.ts index 9480653f56d..280fed99ad8 100644 --- a/apps/brunch-agent/src/petrinaut-chat.ts +++ b/apps/brunch-agent/src/petrinaut-chat.ts @@ -25,7 +25,7 @@ const inspect = ? (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)}`); + process.stdout.write(`TRANSPORT_AISDK ${JSON.stringify(event)}\n`); } : undefined; diff --git a/apps/brunch-agent/src/ui/chat.tsx b/apps/brunch-agent/src/ui/chat.tsx index ea9d6dd2f59..3756e7b11e5 100644 --- a/apps/brunch-agent/src/ui/chat.tsx +++ b/apps/brunch-agent/src/ui/chat.tsx @@ -25,6 +25,7 @@ function VisibleMessage({ message }: { message: FlueConversationMessage }) { {message.parts.map((part, index) => { if (part.type === "text") { return ( + // oxlint-disable-next-line react/no-array-index-key -- Flue text parts expose no stable identifier.

{part.text}

diff --git a/apps/brunch-agent/test/petrinaut-ask-result.ts b/apps/brunch-agent/test/petrinaut-ask-result.ts new file mode 100644 index 00000000000..f0175bfea88 --- /dev/null +++ b/apps/brunch-agent/test/petrinaut-ask-result.ts @@ -0,0 +1,19 @@ +import type { UIMessageChunk } from "ai"; + +type ToolInputChunk = Extract; +type ToolOutputChunk = Extract< + UIMessageChunk, + { type: "tool-output-available" } +>; + +export interface PetrinautAskResult { + readonly initialStatus: number; + readonly askCall: ToolInputChunk | undefined; + readonly initialToolOutputs: readonly ToolOutputChunk[]; + readonly initialFinish: UIMessageChunk | undefined; + readonly resumedStatus: number; + readonly resumedText: string; + readonly resumedFinish: UIMessageChunk | undefined; + readonly duplicateStatus: number; + readonly duplicateBody: unknown; +} diff --git a/apps/brunch-agent/test/petrinaut-ask.integration.ts b/apps/brunch-agent/test/petrinaut-ask.integration.ts index 558e280b624..ae6033c43ec 100644 --- a/apps/brunch-agent/test/petrinaut-ask.integration.ts +++ b/apps/brunch-agent/test/petrinaut-ask.integration.ts @@ -24,6 +24,9 @@ import { GherkinElicitor, } from "../src/agents/gherkin-elicitor.ts"; +import type { PetrinautAskResult } from "./petrinaut-ask-result"; +import type { UIMessageChunk } from "ai"; + const targetDirectory = await mkdtemp(join(tmpdir(), "brunch-petrinaut-ask-")); process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR = targetDirectory; process.env.BRUNCH_TRANSPORT_AISDK_INSPECT = "1"; @@ -59,14 +62,12 @@ const flue = await start({ providers: [faux.provider], }); -type StreamChunk = Record & { readonly type: string }; - -const chunksFrom = (body: string): StreamChunk[] => +const chunksFrom = (body: string): UIMessageChunk[] => body .trim() .split("\n\n") .slice(0, -1) - .map((frame) => JSON.parse(frame.slice("data: ".length)) as StreamChunk); + .map((frame) => JSON.parse(frame.slice("data: ".length)) as UIMessageChunk); try { const { default: app } = await import("../src/app.ts"); @@ -128,24 +129,23 @@ try { 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(), - })}`, - ); + const result: PetrinautAskResult = { + 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()) as unknown, + }; + process.stdout.write(`PETRINAUT_ASK_RESULT ${JSON.stringify(result)}\n`); } 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 index 3d987b46e79..f5ad1e90664 100644 --- a/apps/brunch-agent/test/petrinaut-ask.test.ts +++ b/apps/brunch-agent/test/petrinaut-ask.test.ts @@ -4,7 +4,9 @@ import { expect, test } from "vitest"; import { runNodeScript } from "./run-node-script"; -type StreamChunk = Record & { readonly type: string }; +import type { PetrinautAskResult } from "./petrinaut-ask-result"; +import type { TransportInspectionEvent } from "@hashintel/brunch-agent-transport-aisdk"; + const testDirectory = import.meta.dirname; test("a structured ask suspends over the wire and its correlated submission resumes the conversation", async () => { @@ -20,17 +22,7 @@ test("a structured ask suspends over the wire and its correlated submission resu 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; - }; + ) as PetrinautAskResult; // 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. @@ -69,7 +61,9 @@ test("a structured ask suspends over the wire and its correlated submission resu .filter((line) => line.startsWith("TRANSPORT_AISDK ")) .map( (line) => - JSON.parse(line.slice("TRANSPORT_AISDK ".length)) as StreamChunk, + JSON.parse( + line.slice("TRANSPORT_AISDK ".length), + ) as TransportInspectionEvent, ); expect(inspections.some((event) => event.type === "ask-await")).toBe(true); expect(inspections.some((event) => event.type === "ask-reply-admitted")).toBe( diff --git a/apps/brunch-agent/test/petrinaut-chat-result.ts b/apps/brunch-agent/test/petrinaut-chat-result.ts new file mode 100644 index 00000000000..0a8fe2cbe92 --- /dev/null +++ b/apps/brunch-agent/test/petrinaut-chat-result.ts @@ -0,0 +1,11 @@ +import type { UIMessageChunk } from "ai"; + +export interface PetrinautChatResult { + readonly status: number; + readonly messageId: string | undefined; + readonly partIds: readonly string[]; + readonly reasoning: string; + readonly text: string; + readonly finish: UIMessageChunk | undefined; + readonly chunks: readonly UIMessageChunk[]; +} diff --git a/apps/brunch-agent/test/petrinaut-chat.integration.ts b/apps/brunch-agent/test/petrinaut-chat.integration.ts index cf04e8a757b..3325a33e151 100644 --- a/apps/brunch-agent/test/petrinaut-chat.integration.ts +++ b/apps/brunch-agent/test/petrinaut-chat.integration.ts @@ -16,6 +16,9 @@ import { GherkinElicitor, } from "../src/agents/gherkin-elicitor.ts"; +import type { PetrinautChatResult } from "./petrinaut-chat-result"; +import type { UIMessageChunk } from "ai"; + const targetDirectory = await mkdtemp(join(tmpdir(), "brunch-petrinaut-chat-")); process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR = targetDirectory; process.env.BRUNCH_TRANSPORT_AISDK_INSPECT = "1"; @@ -67,10 +70,7 @@ try { .trim() .split("\n\n") .slice(0, -1) - .map( - (frame) => - JSON.parse(frame.slice("data: ".length)) as Record, - ); + .map((frame) => JSON.parse(frame.slice("data: ".length)) as UIMessageChunk); const startChunk = chunks.find((chunk) => chunk.type === "start"); const partIds = chunks .filter( @@ -79,23 +79,22 @@ try { ) .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, - })}`, - ); + const result: PetrinautChatResult = { + 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, + }; + process.stdout.write(`PETRINAUT_CHAT_RESULT ${JSON.stringify(result)}\n`); } 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 index 5d6be446944..72e8b8b643d 100644 --- a/apps/brunch-agent/test/petrinaut-chat.test.ts +++ b/apps/brunch-agent/test/petrinaut-chat.test.ts @@ -5,33 +5,49 @@ import { expect, test } from "vitest"; import { runNodeScript } from "./run-node-script"; -type StreamChunk = Record & { readonly type: string }; +import type { PetrinautChatResult } from "./petrinaut-chat-result"; +import type { TransportInspectionEvent } from "@hashintel/brunch-agent-transport-aisdk"; +import type { UIMessageChunk } from "ai"; + const testDirectory = import.meta.dirname; const normalizedChunk = ( - chunk: StreamChunk, + chunk: UIMessageChunk, messageId: string, -): StreamChunk => { - const normalized = { ...chunk }; - if (normalized.messageId === messageId) normalized.messageId = "$message"; - if (typeof normalized.id === "string") +): UIMessageChunk => { + const normalized = structuredClone(chunk); + if ("messageId" in normalized && normalized.messageId === messageId) + normalized.messageId = "$message"; + if ("id" in normalized && typeof normalized.id === "string") normalized.id = normalized.id.replace(messageId, "$message"); return normalized; }; +type DeltaChunk = Extract< + UIMessageChunk, + { type: `${string}-delta`; id: string; delta: string } +>; + +const isDeltaChunk = (chunk: UIMessageChunk): chunk is DeltaChunk => + chunk.type.endsWith("-delta") && + "id" in chunk && + typeof chunk.id === "string" && + "delta" in chunk && + typeof chunk.delta === "string"; + const normalizedChunks = ( - chunks: readonly StreamChunk[], + chunks: readonly UIMessageChunk[], messageId: string, -): StreamChunk[] => - chunks.reduce((normalized, chunk) => { +): UIMessageChunk[] => + 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" + isDeltaChunk(current) && + previous !== undefined && + isDeltaChunk(previous) && + previous.type === current.type && + previous.id === current.id ) { previous.delta += current.delta; return normalized; @@ -52,10 +68,9 @@ test("the committed application route drives the actual elicitor for reasoning a .filter((line) => line.startsWith("TRANSPORT_AISDK ")) .map( (line) => - JSON.parse(line.slice("TRANSPORT_AISDK ".length)) as Record< - string, - unknown - >, + JSON.parse( + line.slice("TRANSPORT_AISDK ".length), + ) as TransportInspectionEvent, ); const resultLine = stdout .split("\n") @@ -63,17 +78,11 @@ test("the committed application route drives the actual elicitor for reasoning a 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[]; - }; + ) as PetrinautChatResult; expect(result.status).toBe(200); + expect(result.messageId).toBeDefined(); + if (result.messageId === undefined) throw new Error("missing message id"); expect(result.messageId.length).toBeGreaterThan(0); expect( result.partIds.every((partId) => partId.startsWith(`${result.messageId}:`)), @@ -91,7 +100,7 @@ test("the committed application route drives the actual elicitor for reasoning a ), "utf8", ), - ) as StreamChunk[]; + ) as UIMessageChunk[]; expect(normalizedChunks(result.chunks, result.messageId)).toEqual(golden); expect(inspectionLines[0]).toMatchObject({ type: "request-start", diff --git a/apps/brunch-agent/test/transport-aisdk-server.test.ts b/apps/brunch-agent/test/transport-aisdk-server.test.ts index 1c1523b7d3e..cecfb9042e9 100644 --- a/apps/brunch-agent/test/transport-aisdk-server.test.ts +++ b/apps/brunch-agent/test/transport-aisdk-server.test.ts @@ -10,7 +10,7 @@ import { type TransportInspectionEvent, } from "@hashintel/brunch-agent-transport-aisdk"; -type GoldenChunk = Record & { readonly type: string }; +import type { UIMessageChunk } from "ai"; const FIXTURES = join( import.meta.dirname, @@ -22,12 +22,12 @@ const fixture = (name: string): string => const responseChunks = async ( response: Response, -): Promise => +): Promise => (await response.text()) .trim() .split("\n\n") .slice(0, -1) - .map((frame) => JSON.parse(frame.slice("data: ".length)) as GoldenChunk); + .map((frame) => JSON.parse(frame.slice("data: ".length)) as UIMessageChunk); const panelInitialHarnessEvents: readonly HarnessReplyEvent[] = [ { type: "response-start", messageId: "assistant-fe1435-1" }, @@ -136,7 +136,7 @@ describe("FE-1436 Petrinaut wire server", () => { expect({ body, status: response.status, - refusal: await response.json(), + refusal: (await response.json()) as unknown, }).toEqual({ body, status: 400, @@ -207,6 +207,7 @@ describe("FE-1436 Petrinaut wire server", () => { ).toEqual([ { type: "request-finish", + // oxlint-disable-next-line typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. requestId: expect.any(String), terminalState, finishReason: "error", @@ -215,6 +216,7 @@ describe("FE-1436 Petrinaut wire server", () => { expect(inspections.find((event) => event.type === "turn-finish")).toEqual( { type: "turn-finish", + // oxlint-disable-next-line typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. requestId: expect.any(String), turnId: `turn-${terminalState}`, }, diff --git a/apps/brunch-agent/test/walking-skeleton.integration.ts b/apps/brunch-agent/test/walking-skeleton.integration.ts index 66f85667a46..607ca8872dc 100644 --- a/apps/brunch-agent/test/walking-skeleton.integration.ts +++ b/apps/brunch-agent/test/walking-skeleton.integration.ts @@ -18,6 +18,7 @@ import { toolName } from "@hashintel/brunch-agent"; import { createFlueHistoryReader, createLocalCaptureStore, + type FlueHistoryReaderOptions, } from "@hashintel/brunch-agent-binding-flue"; import { @@ -28,19 +29,21 @@ import app from "../src/app.ts"; import { GHERKIN_AGENT_ROUTE } from "../src/routes.ts"; import { targetDocumentPath } from "../src/target-document-path.ts"; +import type { StatementNotedProposalInput } from "@hashintel/brunch-agent-plugin-gherkin"; + 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) => ({ +const statementNoted = (quote: string): StatementNotedProposalInput => ({ evidence: [{ excerpt: quote }], - epistemicStatus: "explicit" as const, - confidence: "firm" as const, + epistemicStatus: "explicit", + confidence: "firm", content: { value: { - type: "statement-noted" as const, + type: "statement-noted", interior: { verbatim: quote }, }, }, @@ -136,10 +139,10 @@ const targetDirectory = await mkdtemp( try { process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR = targetDirectory; - const fetchApp = ((input: RequestInfo | URL, init?: RequestInit) => + const fetchApp: FlueHistoryReaderOptions["transport"] = (input, init) => 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( @@ -273,7 +276,7 @@ try { const serializedReplyContext = replyContext === undefined ? undefined : JSON.stringify(replyContext); - console.log( + process.stdout.write( `WALKING_SKELETON_RESULT ${JSON.stringify({ affordanceReplyClassified, archivePointerResolved, @@ -325,7 +328,7 @@ try { unaccountedAskAdvisory: appliedSweepOutputs.some((output) => JSON.stringify(output.advisories).includes("unaccounted-ask"), ), - })}`, + })}\n`, ); } finally { delete process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR; diff --git a/libs/@hashintel/brunch-agent/AGENTS.md b/libs/@hashintel/brunch-agent/AGENTS.md index 9e6a4226a8c..129149986e5 100644 --- a/libs/@hashintel/brunch-agent/AGENTS.md +++ b/libs/@hashintel/brunch-agent/AGENTS.md @@ -12,6 +12,18 @@ guidance always wins where it conflicts with this file. - `../../../apps/brunch-agent`: remote server, application composition, and local diagnostics. - `evaluations`: cases, protocols, and oracles; see `evaluations/AGENTS.md` before changing them. +## Stack + +- Format TypeScript and JSON with HASH-root `oxfmt` (double quotes, 80 columns), not Biome or + Prettier. Brunch Markdown remains excluded. +- `lint:eslint` runs Oxlint with multi-file import analysis, type-aware rules, and compiler + diagnostics. Package `.oxlintrc.json` files extend Brunch presets under + `.config/oxlint/brunch/`. +- `lint:tsc` remains the independent `tsgo --noEmit` type-check gate. +- `test:unit` runs Vitest through `vitest run`; architecture tests remain the topology, Flue + placement, and hermetic-runtime gates. +- Vite 8 builds the libraries and application. + 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. diff --git a/libs/@hashintel/brunch-agent/CONTEXT.md b/libs/@hashintel/brunch-agent/CONTEXT.md index 45c61d4386f..830f8fad92e 100644 --- a/libs/@hashintel/brunch-agent/CONTEXT.md +++ b/libs/@hashintel/brunch-agent/CONTEXT.md @@ -130,11 +130,23 @@ 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. +The inter-library contract retained by ADR-0004 and amended by ADR-0005: the plugin projects a +versioned net scaffold plus scenario, code obligations, and loss report; the application realizes +the obligations through Petrinaut's client tools and compiler. Applications may compose both +libraries, but neither reusable library consumes the other. _Avoid_: file handoff (undersells it), integration (generic) +**Code obligation**: +A field-addressed requirement emitted with a projection scaffold for TypeScript that cannot be +derived deterministically. It names the semantic intent, available net symbols, supporting capture +ids, and acceptance checks. The sidecar obligation is authoritative; a matching comment in the +draft code field is human- and agent-facing context, not the machine contract. + +**Artifact realization**: +The model-assisted application step that fulfills code obligations through Petrinaut client tools, +repairs against compiler diagnostics, and stops only at deterministic compilation and simulation +gates. It is downstream authoring over a projection, not a fourth IR register or a plugin operation. + **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) diff --git a/libs/@hashintel/brunch-agent/docs/INDEX.md b/libs/@hashintel/brunch-agent/docs/INDEX.md index aca4b6325c5..4647f5e32ba 100644 --- a/libs/@hashintel/brunch-agent/docs/INDEX.md +++ b/libs/@hashintel/brunch-agent/docs/INDEX.md @@ -33,7 +33,7 @@ control loop is [`docs/agents/steering.md`](agents/steering.md). | Document | Status | Linear | Digest | | -------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [spec.md](specs/elicitation-kernel.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 | +| [spec.md](specs/elicitation-kernel.md) | settled | linked from FE-1366 (context-canonical) | The elicitation-kernel spec: 14 sections + adjudications; FE-1437 records the HASH package/application charter and ADR-0005 separates pure projection scaffolds from model-assisted executable realization | | [product-description.md](archive/elicitation-kernel/product-description.md) | settled | none | STE-style product description | | [product-description-plain.md](archive/elicitation-kernel/product-description-plain.md) | settled | none | Plain-prose rendering of the product description | | [map.md](archive/elicitation-kernel/map.md) | settled | **mirrored in full**: FE-1366 | Completed wayfinder map | @@ -60,15 +60,15 @@ control loop is [`docs/agents/steering.md`](agents/steering.md). | [research/elicitation-strategy-literature](reference/research/elicitation/elicitation-strategy-literature.md) | active | gisted in FE-1360 resolution | Literature synthesis, 9 sections, verification-labeled | | [research/re-interviewing-literature-worker-report](reference/research/elicitation/interviewing-literature-source-catalog.md) | active | noted on FE-1361 | Verbatim instruments: 34-mistake taxonomy, question typologies, LLM-interviewer results | | [baseline evaluation evidence](evidence/evaluations/process-model-elicitation/baseline/) | settled | gisted in FE-1361 resolution | Immutable baseline-control evidence: both transcripts, raw snapshots, delivered models, and scored read-out; executable cases and protocol live under `evaluations/` | -| [ir-design](specs/intermediate-representation.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-design](specs/intermediate-representation.md) | active | gisted in FE-1364 resolution; amended by FE-1480 | The IR design: Layer A (ratified on worked examples, FE-1397; definition sentence amended by ADR-0003) + the CPS plugin's ten-kind payload, deterministic scaffold and obligation contract (Layer B); executable code is realized downstream under ADR-0005 | | [ir-worked-examples](evidence/proofs/design/intermediate-representation-worked-examples.md) | active | gisted in FE-1397 | Layer-A validation across Gherkin/CPS/BPMN + assurance: property verdicts, amendments, sublimation findings | -| [ir-design-plain](specs/intermediate-representation-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) | +| [ir-design-plain](specs/intermediate-representation-plain.md) | active | strain findings on FE-1401; amended by FE-1480 | Plain-prose rendering of the IR design, including ADR-0005's split between deterministic scaffolding and model-assisted executable realization | | [notes/research-patterns-audit](evidence/proofs/audits/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](archive/planning-inputs/penciled-directions-2026-08-14.md) | settled | FE-1401 | Penciled directions from the legibility session: 8 items with firming actions + editorial reflections | | [capture-store-plain](reference/architecture/capture-store.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](evidence/proofs/audits/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](evidence/proofs/audits/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](specs/plugin-contract.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 | +| [plugin-contract-spec](specs/plugin-contract.md) | active | FE-1431 (spec issue); decided on FE-1405; amended by FE-1480 | Provisional spec: a plugin is two schemas and two tables over the three-register IR; code-bearing projections add a deterministic scaffold, typed obligation sidecar, and loss report before downstream realization; strains 4–7 and envelope pressure #2 remain open | ## Control, architecture reference, and migration archive @@ -102,8 +102,9 @@ contract requires the spec to carry the new operating truth, in explicitly dated | ------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [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 | +| [0003-three-register-ir](adr/0003-three-register-ir.md) | accepted | FE-1405; amended by FE-1480 | The IR is the elicited conceptual model, derived by a pure fold — three registers (assertions / model / projections); ADR-0005 narrows pure executable projection to deterministic scaffolds and obligation plans before downstream realization | +| [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; extended by FE-1480 | September demo stages inside demo.petrinaut.org; reusable libraries remain mutually unaware; ADR-0005 extends the application-owned artifact path from projection scaffold and obligations to an executable file through Petrinaut client tools | +| [0005-model-assisted-sdcpn-realization](adr/0005-model-assisted-sdcpn-realization.md) | accepted | FE-1480 | A pure plugin projection emits an SDCPN scaffold, typed code-obligation sidecar, and loss report; the Brunch agent realizes executable TypeScript through Petrinaut client tools and deterministic compile/simulation gates | ## External canonical documents 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 index cb2dff3d678..2138b765ac4 100644 --- a/libs/@hashintel/brunch-agent/docs/adr/0003-three-register-ir.md +++ b/libs/@hashintel/brunch-agent/docs/adr/0003-three-register-ir.md @@ -4,6 +4,8 @@ Date: 2026-08-18 Status: accepted Amends: [ir-design.md](../specs/intermediate-representation.md) Layer A (the "Definition" paragraph), ratified FE-1364/FE-1397 +Amended by: [ADR-0005](0005-model-assisted-sdcpn-realization.md) — projections remain pure through +the scaffold and obligation plan; executable code is realized downstream. Decided on: FE-1405 (payload-interiors session); ratified by Lu, 2026-08-18 ## Context 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 index 8472f336349..fbf1302c1a0 100644 --- 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 @@ -4,6 +4,8 @@ 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) +Extended: 2026-08-24 by [ADR-0005](0005-model-assisted-sdcpn-realization.md) (artifact contract; +no topology change) Supersedes: the demo-shell recommendation in [recommendation-demo-vehicle](../archive/decisions/superseded/recommendation-demo-vehicle.md) (FE-1362's resolution); amends ADR-0002's rule N3 @@ -60,9 +62,11 @@ imported with git history into the `hashintel/hash` monorepo as a native workspa 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. +The artifact boundary remains file-based through `parseSDCPNFile`, and the "it's just a file" demo +beat remains. ADR-0005 refines the production path without changing this topology: the projection +plugin gives the application a versioned scaffold plus scenario, code obligations, and loss report; +the application realizes the obligations through Petrinaut's client tools before accepting the +final executable file. ## Consequences diff --git a/libs/@hashintel/brunch-agent/docs/adr/0005-model-assisted-sdcpn-realization.md b/libs/@hashintel/brunch-agent/docs/adr/0005-model-assisted-sdcpn-realization.md new file mode 100644 index 00000000000..6e81da6daca --- /dev/null +++ b/libs/@hashintel/brunch-agent/docs/adr/0005-model-assisted-sdcpn-realization.md @@ -0,0 +1,50 @@ +# ADR-0005: Realize executable SDCPNs from deterministic projection scaffolds + +Date: 2026-08-24 +Status: accepted +Amends: [ADR-0003](0003-three-register-ir.md), register 3 +Extends: [ADR-0004](0004-in-petrinaut-staging-and-the-monorepo-import.md), artifact contract only; +the application/library topology is unchanged +Decided on: FE-1480 + +## Context + +The seven incoming Petrinaut nets all contain TypeScript code surfaces. The richer nets require +substantial stochastic lambdas, transition kernels, differential equations, scenario setup, and +metrics. Petrinaut can parse those fields as strings, but its compiler requires valid code in an +analyzable TypeScript subset before the affected behavior can simulate. A semantic CPS model can +determine what the code must express, but cannot determine the implementation mechanically. + +Generating a complete net on the Brunch server would either bypass Petrinaut's compiler or couple +the server to Petrinaut internals. Storing generated code as captures would instead persist derived, +topology-dependent text as if it were elicited evidence. Both alternatives make corrections harder +to localize and audit. + +## Decision + +The plugin's pure projection produces three outputs from the elicited model: + +1. a versioned SDCPN scaffold containing deterministic structure and field-local comments for code + still to be written; +2. a sidecar of typed code obligations keyed by net element and field, carrying semantic intent, + available places, token fields and parameters, supporting capture ids, and acceptance checks; +3. the typed loss report. + +The sidecar is the machine contract; comments are a readable projection of it. Comment-only code is +an intentionally incomplete draft, not a runnable artifact. + +Artifact realization is downstream agent work at the application meeting point established by +[ADR-0004](0004-in-petrinaut-staging-and-the-monorepo-import.md). The Brunch agent fulfills each +obligation through Petrinaut's client-executed tools, receives field-addressed compiler diagnostics, +and repairs the code until compilation succeeds. Completion additionally requires one scenario to +simulate without a runtime error. Realized TypeScript is derived artifact state, not a capture, IR +slot, fourth register, or plugin operation. + +## Consequences + +- A deterministic scaffold can be built and golden-tested without a model call. +- FE-1438 blocks FE-1480's executable production proof, but not scaffold or obligation design. +- Stable obligation identities make localized correction possible without whole-net resynthesis; + failure of that property reopens this decision. +- Reusable Brunch and Petrinaut libraries remain mutually unaware; no ADR-0004 topology change is + required. diff --git a/libs/@hashintel/brunch-agent/docs/agents/legibility.md b/libs/@hashintel/brunch-agent/docs/agents/legibility.md index c46aae550e4..3f73b046c90 100644 --- a/libs/@hashintel/brunch-agent/docs/agents/legibility.md +++ b/libs/@hashintel/brunch-agent/docs/agents/legibility.md @@ -24,6 +24,31 @@ yield: the ir-design plain rendering returned seven strain points where an unins 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). +## Review before commit in an orchestrated frontier + +When a thread contributes one step of an ordered proof frontier, its producer stops before commit +with a fixed review packet: the base commit, the exact issue contract, the uncommitted diff, +applicable verification commands and results, and a second-register rendering whose grade the +orchestrator chose for the claim, plus the proof bundle and its evidence references. A reviewer +other than the producer checks every packet component, records each correctness or legibility +finding and its disposition in the thread's review record, and judges the claim validated, +rejected, or narrowed with its consequences for successor work. + +The orchestrator owns the review gate and adjudicates disagreements. A confirmed issue-contract +violation blocks commit until it is fixed or the contract changes in its owning authority and the +packet is reviewed again; it cannot be relabelled as uncertainty. Other findings must be fixed, +refused with evidence, or carried into residual uncertainty with the claim and downstream +consequences narrowed accordingly. Remediation that changes the diff or makes the rendering stale +requires an updated packet, rerun verification, and reviewer confirmation before authorization. +Consolidate the review record into the branch's commit and PR description or the indexed proof +artifact, as appropriate. The commit is the deposit of reviewed understanding, not the checkpoint +at which review begins. + +The orchestrator then deposits the reviewed result and confidence changes in their owning +authorities and derives the next dispatch brief from those deposits before dispatching a successor. +An issue moving state or producing a plausible artifact is not evidence that its claim survived +review. + ## The register dial The register is a dial, not a single target. One practice, several grades — pick the cheapest diff --git a/libs/@hashintel/brunch-agent/docs/agents/steering.md b/libs/@hashintel/brunch-agent/docs/agents/steering.md index cf3faf4d19a..20fad964150 100644 --- a/libs/@hashintel/brunch-agent/docs/agents/steering.md +++ b/libs/@hashintel/brunch-agent/docs/agents/steering.md @@ -41,6 +41,15 @@ Select one proof frontier, or a deliberate pair whose join is named. Record: - **issue/gap projection** — existing issues and uncovered work needed to execute it; and - **stop/replan trigger** — the observation that ends or redirects the attempt. +An ordered frontier is an epistemic strategy, not a queue of tasks. Before dispatch, each step +names the evidence it consumes, the claim it can establish, and the later decision or proof it +informs; a terminal step records `none`. Do not dispatch a successor merely because the preceding +issue moved: first pass the independent review gate in [legibility](legibility.md), reconcile what +the result changed in current truth and confidence, and prepare the successor's dispatch brief from +that result. Deposit any durable change in its owning authority before launch; a dispatch brief is +not a substitute authority. Parallelize steps only after recording why neither step's scope or +interpretation depends on the other's findings and defining the claim and inputs at their join. + ## Execute Exercise real production entrypoints and wiring. A fixture may supply domain inputs, but it must @@ -69,7 +78,12 @@ inputs to the interviewee or elicitor under evaluation. - witness record, or explicit inapplicability; - observed failures and residual uncertainty; - validated, rejected, or narrowed claim; -- oracle candidate and promotion decision. +- oracle candidate and promotion decision; and +- successor decisions or dispatch briefs changed by the result, or `none`. + +At selection time, initialize the prospective fields. After independent review, finalize the +result-dependent fields and deposit changed truth in the owning authorities before dispatching a +successor. ## Reconcile diff --git a/libs/@hashintel/brunch-agent/docs/control/SPEC-LEDGER.md b/libs/@hashintel/brunch-agent/docs/control/SPEC-LEDGER.md index fc814343344..cf41809f068 100644 --- a/libs/@hashintel/brunch-agent/docs/control/SPEC-LEDGER.md +++ b/libs/@hashintel/brunch-agent/docs/control/SPEC-LEDGER.md @@ -44,15 +44,15 @@ states. ## Operations & validation (§6) -| Obligation | Spec | Status | Evidence | -| ---------------------------------------------------------------------------- | ---- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `project` + typed loss report; `validate`; optional `reconcile`; purity (C2) | §6.1 | **pending** | FE-1392 adds only the plugin-declared `statement-noted` verbatim proposal floor; operations remain FE-1393 | -| Envelope-level refusals (provenance, XOR, single-hop supersession) | §6.2 | **discharged** | FE-1390 command surface | -| Citations resolve to true user entries | §6.2 | **discharged** | FE-1391 resolves quote-only inputs against archived public messages and refuses injected non-user matches. FE-1392's mounted oracle starts with a quote absent from the archive, permits only non-writing peeks, then proves the refresh adjacent to apply resolves and stores it | -| Duplicate detection free for flat-record plugins | §6.2 | **partial** | near-identical advisory fires for string payloads only; a flat record gets none | -| Issues typed + namespaced to producer (invariant 6) | §6.3 | **partial** | all seven types, origin variants present; producer self-declared, unauthenticated | -| Advisories computed, ephemeral, never stored | §6.3 | **discharged** | returned in results, never in snapshot (FE-1390) | -| Cadence as policy (§6.4) | §6.4 | **partial** | FE-1392 makes successful sweep the cadence boundary and keeps projection/validation read-time-only, leaving sweep outcome unchanged. Concrete operations remain absent until FE-1393 | +| Obligation | Spec | Status | Evidence | +| ----------------------------------------------------------------------------------------------- | ---- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `project` + typed loss report/code obligations; `validate`; optional `reconcile`; purity (C2) | §6.1 | **pending** | ADR-0005 settles the pure boundary at the deterministic scaffold and obligation plan, with executable realization downstream. FE-1392 adds only the plugin-declared `statement-noted` verbatim proposal floor; projection operations and realization remain unimplemented. | +| Envelope-level refusals (provenance, XOR, single-hop supersession) | §6.2 | **discharged** | FE-1390 command surface | +| Citations resolve to true user entries | §6.2 | **discharged** | FE-1391 resolves quote-only inputs against archived public messages and refuses injected non-user matches. FE-1392's mounted oracle starts with a quote absent from the archive, permits only non-writing peeks, then proves the refresh adjacent to apply resolves and stores it. | +| Duplicate detection free for flat-record plugins | §6.2 | **partial** | near-identical advisory fires for string payloads only; a flat record gets none | +| Issues typed + namespaced to producer (invariant 6) | §6.3 | **partial** | all seven types, origin variants present; producer self-declared, unauthenticated | +| Advisories computed, ephemeral, never stored | §6.3 | **discharged** | returned in results, never in snapshot (FE-1390) | +| Cadence as policy (§6.4) | §6.4 | **partial** | FE-1392 makes successful sweep the cadence boundary and keeps projection/validation read-time-only, leaving sweep outcome unchanged. Concrete operations remain absent until FE-1393. | ## Questioning UX (§7) diff --git a/libs/@hashintel/brunch-agent/docs/control/STEERING.md b/libs/@hashintel/brunch-agent/docs/control/STEERING.md index 41d6f9c3cff..729a033d159 100644 --- a/libs/@hashintel/brunch-agent/docs/control/STEERING.md +++ b/libs/@hashintel/brunch-agent/docs/control/STEERING.md @@ -11,38 +11,75 @@ utterance, corrects it in three to five turns, and sees a provenance-preserving the optimisation flow. This is the current proof, not a permanent product-scope decision. Acceptance is one screen-recordable deployed run, surviving reload, through the real HTTP handler, -session binding, sweep, fold, pure projection, client-tool application, and optimisation handoff. -The changed element traces to a sweep-produced superseding capture while an unrelated region stays -stable. Preserve runnable and legibility evidence under [proof evidence](../evidence/proofs/). +session binding, sweep, fold, deterministic projection scaffold, model-assisted client-tool +realization, compilation, and optimisation handoff. The changed element traces to a sweep-produced +superseding capture while an unrelated region stays stable. Preserve runnable and legibility +evidence under [proof evidence](../evidence/proofs/). Governing strategic decisions: [S-001](STRATEGY-LOG.md#s-001), -[S-002](STRATEGY-LOG.md#s-002), and [S-003](STRATEGY-LOG.md#s-003). +[S-004](STRATEGY-LOG.md#s-004), [S-005](STRATEGY-LOG.md#s-005), and +[S-006](STRATEGY-LOG.md#s-006). -## Execution tree +## Selected frontier: design convergence + +**Claim:** the existing baseline evidence, elicitation research, and worked CPS payload are +sufficient to settle completion versus session stopping, targeted interview guidance, and reusable +strategy without new human or domain-expert input. Those results can then narrow FE-1431 to a +build-ready plugin-authoring contract before runtime implementation resumes. + +The selected single-agent work order is below. Arrows express strategic order. Linear is canonical +for hard blockers and now encodes the three genuine prerequisite joins: FE-1407 blocks FE-1404, +FE-1404 blocks FE-1406, and FE-1406 blocks FE-1431. The other arrows remain soft ordering. ```text -now -├─ semantic: FE-1482 CPS slice + FE-1480 authority decision + FE-1478 provenance -│ gap: worked capture -> model -> SDCPN transformation and production model/issues read path -└─ reviewer: FE-1438 client-tool return + FE-1439 existing-target session - gap: narrow review-and-revise controller/runbook on the real Petrinaut route -join -└─ FE-1479 targeted correction: reviewer utterance -> sweep -> supersession -> stable scoped delta -next -└─ FE-1477/FE-1440 provider routing -> FE-1423 exposure gates -> FE-1441 deployment - gap: clean-browser rehearsal and optimisation handoff +design resolution +FE-1407 failure catalogue -> FE-1402 completion/stopping contract +-> FE-1403 CPS guidance -> FE-1404 condition-3 run +-> FE-1406 strategy quiver -> FE-1431 plugin-authoring/absence-locator closure + +reviewer-path implementation +FE-1420 retry/abandonment safety -> FE-1438 client-tool return -> FE-1439 durable session + +semantic implementation +FE-1393 exercised plugin SDK -> FE-1482 CPS plugin -> FE-1478 provenance read +-> FE-1480 scaffold/realization -> FE-1479 targeted correction join ``` +### Proof bundle for the selected frontier + +- **Bounded scenario:** replay the two existing truck-fleet baseline transcripts, then run condition + 3 with the drafted completion contract, surviving cards, and corrected stopping instrument. +- **Inputs:** the indexed baseline transcripts/readout, FE-1405 worked CPS payload, and indexed + elicitation literature. No live interview or new use-case decision is an input. +- **Procedure and result:** FE-1407 produces the typed failure catalogue; FE-1402 and FE-1403 each + perform their issue-specified desk replay; FE-1404 runs the existing baseline protocol and scores + condition 3 against conditions 1 and 2; FE-1406 keeps only strategies supported by those results. +- **Durable outputs:** reference catalogue, immutable evaluation transcript/raw log/readout, and + amendments to the completion, card, strategy, and plugin-authoring contracts. Index each output + when it lands. +- **Runtime and witness boundary:** this frontier validates design discrimination, not production + behavior or UX. A human witness is therefore inapplicable; the later reviewer-path proof still + requires the deployed production entrypoint and witness. +- **Oracle candidates:** completion/stalling decisions over transcript prefixes, per-card firing + verdicts, and condition-3 regression measures. Promote only categorical claims that survive the + run. + ### Active soft edges -- FE-1480 inputs FE-1482/FE-1478; semantic and reviewer lanes join only at FE-1479. +- FE-1402 and FE-1403 remain deliberately soft-ordered after FE-1407 even though they do not depend + on it; all three inputs join at FE-1404 through Linear's hard blockers. +- FE-1431 now defines design closure as a build-ready handoff separately from its later three-target + ratification condition; the unresolved absence locator remains part of that design seam. +- FE-1420's idempotency and abandonment semantics precede FE-1438's external-tool protocol; FE-1439 + then proves the reviewer path survives reload without crossing principals. +- FE-1393 exercises the smallest honest plugin before CPS pressures the still-unstable SDK in + FE-1482. FE-1478 supplies supporting-capture reads before FE-1480 realization. +- FE-1480's executable proof remains blocked by FE-1438, and FE-1479 follows the semantic/reviewer + join. - FE-1477 and FE-1440 share one routing implementation. -- FE-1402/FE-1403/FE-1406/FE-1431 input FE-1482 only when its CPS slice consumes them. - FE-1395's structured-tap transport fact inputs the capture-store evidence rule. - FE-1385/FE-1404/FE-1423 share telemetry vocabulary before FE-1423's exposure gate. - The living-prototype charter waits on the infrastructure conversation. -- The unresolved absence locator is a fold-table coordinate owned here; its cases and obligation - remain in [the plugin contract](../specs/plugin-contract.md#epistemic-anatomy). The read-only Linear graph supplies mechanical availability, never priority. @@ -50,16 +87,18 @@ The read-only Linear graph supplies mechanical availability, never priority. | Gate | Owner / source | Watch trigger | Last checked | Consequence | | --- | --- | --- | --- | --- | -| FE-1480 register authority unresolved | FE-1480; [ADR-0003](../adr/0003-three-register-ir.md) | Worked transformation assigns every judgment. | 2026-08-24 | Do not freeze projector/contract; record semantics earlier or revisit ADR. | +| FE-1480 executable realization unavailable | FE-1438; [ADR-0005](../adr/0005-model-assisted-sdcpn-realization.md) | Client tools return code diagnostics to the elicitor. | 2026-08-24 | Scaffold work may proceed; no runnable FE-1480 proof until the gate opens. | +| Plugin authoring not build-ready | FE-1431; [plugin contract](../specs/plugin-contract.md#open-strains-first-class-with-owners) | FE-1404/FE-1406 results land and the absence locator has one owner and representation. | 2026-08-24 | Do not freeze or generalize the SDK; FE-1393 may start only from the narrowed handoff. | | Final use case outstanding | Dora; FE-1476 / September Plan | Dora confirms or changes it. | 2026-08-24 | If creation is required, activate cold-start and reconcile ADR-0004/proof. | -| Truck-fleet dossier unavailable | Unknown; FE-1357 names its ref/path. | Ref appears or replacement selected. | 2026-08-24 | Claim no provenance; use reviewed replacement. | +| Truck-fleet dossier missing from the repository | FE-1382 is Done but its promised `docs/reference/research/` artifact is absent. | Artifact path/branch is supplied or a reviewed replacement is selected. | 2026-08-24 | Existing baseline evidence may support design replay; claim no dossier-backed domain provenance. | ## Decision-relevant beliefs and unknowns | Belief or unknown | Confidence / evidence | Cheapest probe | | --- | --- | --- | -| CPS establishes the minimum plugin contract. | Medium-high; Gherkin under-stresses it. | Implement one FE-1480 transformation. | -| Register 2 supports pure projection. | Low-medium; ADR-0003 says so, FE-1480 disputes it. | Assign each judgment; expose residue. | +| The selected design queue can run without HITL. | Medium-high; every issue has bounded existing inputs and a desk/run oracle. | FE-1407 classifies both baseline transcripts without requesting new product choices. | +| CPS establishes the minimum plugin contract. | Medium-high; Gherkin under-stresses it, while the current SDK is only identity plus one proposal. | Complete the FE-1431 handoff, then exercise Gherkin and CPS in that order. | +| Field-local code obligations support localized realization and repair. | Low-medium; the corpus and Petrinaut diagnostics are field-addressed, but no Brunch run exists. | Realize one stochastic transition without rewriting an unrelated field. | | Five turns yield a scoped correction. | Low; unrehearsed. | Run two bounded rehearsals. | | Ask carries durable client-tool results. | Medium-low; machine results refused today. | Run one correlated FE-1438 round trip. | | Structured export explains provenance/delta. | Medium; FE-1481 permits it. | Witness one rehearsal. | @@ -67,15 +106,22 @@ The read-only Linear graph supplies mechanical availability, never priority. ## Sequencing cuts - Cold-start does not gate review-and-revise ([S-001](STRATEGY-LOG.md#s-001)). -- CPS precedes FE-1387's generic freeze; Gherkin completion does not gate it - ([S-002](STRATEGY-LOG.md#s-002)). +- FE-1393's smallest-honest Gherkin/SDK exercise precedes CPS without freezing the interface; + FE-1482 then pressures it before FE-1387's generic freeze ([S-005](STRATEGY-LOG.md#s-005)). +- Finish the selected design queue before runtime feature work; then build the under-built reviewer + path before returning to FE-1480 realization ([S-005](STRATEGY-LOG.md#s-005)). +- During design convergence, do not implement SDK surface, client tools, projection, provider + routing, or deployment. - Defer broad UI/ontology/gallery/affordances/voice/scenarios/telemetry until the loop closes. - Fixtures supply domain state, never product wiring; provenance and the real entrypoint are gates. ## Stop or replan - Dora requires cold-start creation. -- FE-1480 exposes unavoidable read-time semantic inference. +- A selected design issue requires an unrecorded product preference or new domain testimony. +- Condition 3 cannot distinguish or improve the failures FE-1402/FE-1403 claim to address. +- FE-1431 cannot isolate a build-ready design handoff from its later empirical ratification. +- A code obligation cannot localize realization without whole-net resynthesis. - Two rehearsals fail the five-turn correction. - FE-1438 loses correlation, durability, or evidence semantics. - Production remains undeployable after FE-1479; seek a demo-surface decision, not test wiring. diff --git a/libs/@hashintel/brunch-agent/docs/control/STRATEGY-LOG.md b/libs/@hashintel/brunch-agent/docs/control/STRATEGY-LOG.md index 606a31e9d20..9be216f411f 100644 --- a/libs/@hashintel/brunch-agent/docs/control/STRATEGY-LOG.md +++ b/libs/@hashintel/brunch-agent/docs/control/STRATEGY-LOG.md @@ -69,3 +69,89 @@ shared prerequisite. **Supersedes:** none **Evidence links:** [STEERING execution tree](STEERING.md#execution-tree), FE-1479 + +### S-004 + +**Date:** 2026-08-24 + +**Trigger/evidence:** All seven incoming SDCPNs contain TypeScript code surfaces, and the richer +examples require substantial stochastic lambdas, transition kernels, dynamics, scenario code, and +metrics. FE-1480 therefore fired the replan trigger: an executable net cannot be projected +deterministically from the elicited model. + +**Decision:** Produce a deterministic SDCPN scaffold, typed code obligations, and loss report from +the elicited model; realize the obligations with model inference through Petrinaut client tools; +then gate the result with deterministic compilation and simulation. + +**Consequences/cuts:** Scaffold work remains independent, but FE-1438 blocks FE-1480's executable +production proof. The semantic and elicitor lanes first join at FE-1480 realization, then join the +review-and-revise path at FE-1479. This authority decision does not select FE-1480 implementation as +the next investment. + +**Revisit when:** Field-local obligations cannot support localized repair without whole-net +resynthesis, or Petrinaut diagnostics cannot provide the deterministic gate. + +**Supersedes:** S-003 + +**Evidence links:** [ADR-0005](../adr/0005-model-assisted-sdcpn-realization.md), +[incoming SDCPNs](../inbox/SDCPNs/), FE-1480, FE-1438 + +### S-005 + +**Date:** 2026-08-24 + +**Trigger/evidence:** FE-1480's authority boundary is settled, but the implementation floor remains +thin: the plugin SDK exposes identity plus one verbatim proposal, the client-tool surface exposes +only ask contracts, and FE-1482 has no build-ready contract. In contrast, FE-1407, FE-1402, +FE-1403, FE-1404, and FE-1406 each have bounded existing evidence and explicit non-HITL oracles. + +**Decision:** Run a design-convergence queue before more runtime feature work: FE-1407 failure +catalogue, FE-1402 completion/stopping contract, FE-1403 CPS guidance, FE-1404 condition-3 run, and +FE-1406 reusable strategy quiver. Use those results to narrow FE-1431 to a build-ready +plugin-authoring handoff, including the absence locator. Then build the under-developed reviewer +path before returning to semantic realization. + +**Consequences/cuts:** One agent can execute the design queue from existing inputs without waiting +for domain experts or the final use-case decision. No SDK, client-tool, projection, provider, or +deployment implementation belongs inside that frontier. After design convergence, the selected +order is FE-1420 → FE-1438 → FE-1439, then FE-1393 → FE-1482 → FE-1478 → FE-1480, joining at +FE-1479. These are strategic sequencing edges; Linear hard dependencies remain unchanged until a +separately approved tracker reconciliation. This replaces S-002's claim that Gherkin completion +does not gate CPS: FE-1393 now provides the smallest-honest, explicitly non-freezing SDK exercise; +FE-1482 still pressures that interface before FE-1387's generic freeze. + +**Revisit when:** A design issue requires an unrecorded product preference or new domain testimony, +condition 3 fails to discriminate the claimed improvements, or FE-1431 cannot separate a build-ready +contract from later three-target ratification. + +**Supersedes:** S-002 + +**Evidence links:** [STEERING selected frontier](STEERING.md#selected-frontier-design-convergence), +[plugin contract](../specs/plugin-contract.md), FE-1407, FE-1402, FE-1403, FE-1404, FE-1406, +FE-1431 + +### S-006 + +**Date:** 2026-08-24 + +**Trigger/evidence:** The design-convergence queue in S-005 was selected, but Linear did not encode +three genuine prerequisites, and FE-1431 still conflated a build-ready design handoff with later +three-target ratification. + +**Decision:** Encode FE-1407 blocking FE-1404, FE-1404 blocking FE-1406, and FE-1406 blocking +FE-1431 in Linear. Define FE-1431 as complete when its plugin-authoring contract is build-ready, +while retaining three-target ratification as a later condition for removing the contract's +provisional marker. Keep every other sequencing edge in S-005 soft. + +**Consequences/cuts:** Mechanical issue availability now protects the three actual joins without +pretending that the whole strategic order is a dependency graph. FE-1402 and FE-1403 can still run +independently, and SDK implementation or empirical ratification cannot hold FE-1431's design +closure open. + +**Revisit when:** A recorded prerequisite proves unnecessary, or implementation exposes a product +decision that the FE-1431 handoff failed to settle. + +**Supersedes:** none + +**Evidence links:** [STEERING selected frontier](STEERING.md#selected-frontier-design-convergence), +FE-1407, FE-1404, FE-1406, FE-1431 diff --git a/libs/@hashintel/brunch-agent/docs/specs/elicitation-kernel.md b/libs/@hashintel/brunch-agent/docs/specs/elicitation-kernel.md index 1fad7129adb..bf5274dc17c 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/elicitation-kernel.md +++ b/libs/@hashintel/brunch-agent/docs/specs/elicitation-kernel.md @@ -7,6 +7,9 @@ Assembled: 2026-08-10, from the resolved [criteria](../reference/agentic-elicitation-criteria-2026-08-06T14-11-18Z.md)), and the [2026-08-10 consistency pre-pass](../archive/elicitation-kernel/notes/consistency-prepass-2026-08-10.md). Contradiction adjudications are collected in [Appendix A](#appendix-a--adjudications). +Amended 2026-08-24 by +[ADR-0005](../adr/0005-model-assisted-sdcpn-realization.md): code-bearing projections emit +deterministic scaffolds and obligations; executable realization is downstream agent work. "Elicitation kernel" and "brunch-lite" are working labels; the real product name is unresolved fog. No architectural string bakes in either label (see [Naming](#123-naming--tool-namespacing)). @@ -180,8 +183,9 @@ transport fact to earn `explicit`; nothing else may claim it. ### 6.1 Plugin operations -- **Required**: `project` (captures → draft artifact + **typed loss report**: `mapped-exactly / -normalized / approximate / collapsed / omitted / defaulted / unrepresentable`) and `validate` +- **Required**: `project` (elicited model → draft artifact + **typed loss report**: + `mapped-exactly / normalized / approximate / collapsed / omitted / defaulted / + unrepresentable`, plus typed code obligations when the target contains programs) and `validate` (→ typed issues). `project` also computes the plugin's domain labels (§13.3) — read-time derivation is projection. (The operation keeps the canon name `project`; in running prose this spec prefers the noun — "produce a projection" — because the verb collides with everyday @@ -190,6 +194,9 @@ normalized / approximate / collapsed / omitted / defaulted / unrepresentable`) a 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. +- **Agent-native**: artifact realization — an agent fulfills code obligations through the target + application's authoring tools and repairs against deterministic compiler/runtime feedback. It + is downstream of `project`, never a plugin operation or capture-store write. - **Calling convention — pure, snapshot-in/deltas-out** (adjudicated, C2): every operation receives an **immutable state snapshot** and returns observations/issues/deltas; the harness validates and applies. Operations never address storage, the user, or the model. This purity is @@ -536,9 +543,10 @@ Target policy only: its **own payload structure** (graph, flat list — never un 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; its declared payload/output _shape_ (never persistence itself, §9.6); -domain vocabulary. One ElicitationPack + N ProjectionPacks per plugin sharing the plugin's payload -structure — axes separated in contract, bundled in shipping; swappability proven by reprojection. +reports, code-obligation shapes for program-bearing targets, lossiness policy; its declared +payload/output _shape_ (never persistence itself, §9.6); domain vocabulary. One ElicitationPack + N +ProjectionPacks per plugin sharing the plugin's payload structure — axes separated in contract, +bundled in shipping; swappability proven by reprojection. ### 11.2 Pack form and Principle v2 diff --git a/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation-plain.md b/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation-plain.md index f099ad726d9..65a52792bcf 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation-plain.md +++ b/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation-plain.md @@ -53,7 +53,7 @@ Kinds 1 to 6 bear on the net: they project into Petri-net structure. Kinds 7 to Three things attach across kinds and are deliberately not kinds themselves. Quantities — durations, rates, probabilities, capacities — can attach to any kind; they are elicited as quantiles, never as a minimum, mode, and maximum; and the shared or tunable ones project to Petrinaut parameters. A rationale can attach to every kind, never only to objectives. And source-regime marks every capture as prescribed or practiced: the process as the manuals state it versus the process as it actually runs. There is one model, not two parallel ones. A divergence between the regimes surfaces as an ordinary typed conflicting issue, and such a divergence is elicitation gold — the rules nobody wrote down — not an error state. -**The granularity rule.** Dora's claim that steps become transitions and the states between them become places survives, with a correction: it is a projection rule, not a storage rule. The IR stores activities at the granularity the expert stated them, durations included. Petrinaut has no timing field of any kind, so a timed step cannot become a single transition. The projection function therefore owns the factoring — for example into a start transition, an in-progress place, and an end transition, or into rate code. If the IR stored net-granularity elements instead, every change to the factoring would masquerade as a change to what the expert said, and the interviewer would be doing net modelling in the middle of the conversation. +**The granularity rule.** Dora's claim that steps become transitions and the states between them become places survives, with a correction: it is a projection rule, not a storage rule. The IR stores activities at the granularity the expert stated them, durations included. Petrinaut has no timing field of any kind, so a timed step cannot become a single transition. The projection function therefore owns the factoring — for example into a start transition, an in-progress place, and an end transition, or into a rate-code obligation. If the IR stored net-granularity elements instead, every change to the factoring would masquerade as a change to what the expert said, and the interviewer would be doing net modelling in the middle of the conversation. **Motifs.** The motif quiver — small parameterised process patterns with variant selectors — lives in the ElicitationPack as question guidance only: motifs may scaffold the interviewer's questions, but they never generate model structure. This follows the literature verdict. The September payload carries no motif vocabulary; if a projection ever demonstrably needs a motif hint, Layer A's escape hatch exists for that. Per-object-type templates appear nowhere in the design. @@ -61,13 +61,15 @@ Three things attach across kinds and are deliberately not kinds themselves. Quan ### Projection to Petrinaut -The projection emits all four surfaces of the Petrinaut file: the net structure (places, transitions, colours, differential equations, arcs), the scenario, the metrics, and the parameters. The scenario is mandatory, because a bare net loads with an empty marking and does nothing when simulated. The Optuna optimization file format is excluded for September: its ontology is itself still moving — Yannis is working on it, and his design is a candidate future input — so penalty weights stay IR-only and appear in the loss report. +The deterministic projection scaffold declares all four surfaces of the Petrinaut file: the net structure (places, transitions, colours, differential equations, arcs), the scenario, the metrics, and the parameters. The scenario is mandatory, because a bare net loads with an empty marking and does nothing when simulated. Declarative structure is populated directly. TypeScript fields that require authored behavior carry readable comments and field-local code obligations; they become executable only through the downstream realization step defined by ADR-0005. The Optuna optimization file format is excluded for September: its ontology is itself still moving — Yannis is working on it, and his design is a candidate future input — so penalty weights stay IR-only and appear in the loss report. -The loss report is typed and per-capture. Every active capture lands in exactly one of seven categories: mapped exactly, normalized, approximate, collapsed, omitted, defaulted, or unrepresentable. The kind catalog implies the first cut. Entity types and dynamics map exactly, with names normalized. Boundary conditions map to scenario content. Activity structure is normalized, and durations are approximate because they become rate code. Orderings map exactly. Policies land as approximate or collapsed, and their rationale is unrepresentable. Objectives normalize to metrics where a scalar over simulation state can express them; their penalty weights and rationale are unrepresentable. Constraints collapse partially, with regulatory references unrepresentable. Data bindings and validation criteria are wholly unrepresentable. This assignment is a first cut and illustrative only: the plugin spec owns the binding table, while the mechanism itself — per capture, seven categories — is settled. +The loss report is typed and per-capture. Every active capture lands in exactly one of seven categories: mapped exactly, normalized, approximate, collapsed, omitted, defaulted, or unrepresentable. Those categories describe the semantic fidelity of the scaffold and obligation plan, not whether TypeScript realization has finished. The kind catalog implies the first cut. Entity types map exactly, with names normalized. Dynamics and other authored behavior map to field-local obligations and are exact, normalized, or approximate according to the specificity of the capture. Boundary conditions map to declarative scenario content or scenario-code obligations. Activity structure is normalized, and durations are approximate. Orderings map exactly. Policies land as approximate or collapsed, and their rationale is unrepresentable. Objectives normalize to metric obligations where a scalar over simulation state can express them; their penalty weights and rationale are unrepresentable. Constraints collapse partially, with regulatory references unrepresentable. Data bindings and validation criteria are wholly unrepresentable. This assignment is a first cut and illustrative only: the plugin spec owns the binding table, while the mechanism itself — per capture, seven categories — is settled. -Two further rules govern what the projection prefers and how it names things. The net projects the practiced process: where prescribed and practiced diverge unresolved, practiced wins, and the prescribed reading lands in the loss report as omitted. And the IR keeps the expert's names verbatim, because payloads are evidence-faithful, while the ProjectionPack owns a deterministic scheme that turns those names into PascalCase identifiers. The scheme is necessary because place names function as identifiers inside every code surface of the file — guards, kernels, differential equations, metrics — and import does not validate them. The ProjectionPack emits the resulting name map as projection metadata for the demo shell to display, and records any collision renames as normalized. +Two further rules govern what the projection prefers and how it names things. The net projects the practiced process: where prescribed and practiced diverge unresolved, practiced wins, and the prescribed reading lands in the loss report as omitted. And the IR keeps the expert's names verbatim, because payloads are evidence-faithful, while the ProjectionPack owns a deterministic scheme that turns those names into PascalCase identifiers. The scheme is necessary because place names function as identifiers inside every code surface of the file — guards, kernels, differential equations, metrics — and import does not validate them. The ProjectionPack emits the resulting name map as projection metadata for the demo shell to display, exposes the identifiers to code obligations as available symbols, and records any collision renames as normalized. -Provenance stays outside the file. The Petrinaut format has no fields for provenance, rationale, confidence, or draft status anywhere, and it strips unknown keys on import, so inline annotation cannot round-trip. Everything IR-only is therefore honestly unrepresentable in the artifact, and displaying provenance is the demo shell's job — never something smuggled into the file. +Provenance stays outside the file. The Petrinaut format has no fields for provenance, rationale, confidence, or draft status anywhere, and it strips unknown keys on import, so inline annotation cannot round-trip. The obligation sidecar may reference supporting capture ids, but comments in code fields are readable context rather than authority. Everything IR-only is therefore honestly unrepresentable in the artifact, and displaying provenance is the demo shell's job — never something smuggled into the file. + +The application realizes code obligations through Petrinaut's client tools. Model inference writes and repairs field-local TypeScript against returned compiler diagnostics; no generated code is promoted into the capture store or elicited model. The completed artifact is accepted only when all obligations are fulfilled, Petrinaut reports no compile failures, and at least one scenario runs without a runtime error. ### The September minimum diff --git a/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation.md b/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation.md index 6bf802cc848..f13e93487a4 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation.md +++ b/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation.md @@ -130,7 +130,7 @@ simulation/experiment layer — i.e. metrics.) not a storage rule. The IR stores activities at the expert's statement granularity, durations included. Petrinaut has no timing field of any kind — a timed step cannot be one transition — so `project` owns the factoring (e.g. start-transition → in-progress place → end-transition, -or rate code). If the IR stored net-granularity elements, every factoring change would +or a rate-code obligation). If the IR stored net-granularity elements, every factoring change would masquerade as a knowledge change and the interviewer would be doing net modelling mid-conversation. @@ -156,37 +156,51 @@ objectives demand rather than from the ontology's own layout. ### Projection to Petrinaut -- **Emission surfaces**: all four in-file surfaces — net structure (places, transitions, - colours, ODEs, arcs), **scenario** (mandatory: a bare net loads with an empty marking and - does nothing when simulated), **metrics**, **parameters**. The Optuna/optimization file - format (`petrinaut-optimization`) is **excluded for September**: its ontology is itself in - flight (Yannis is working on it; his design is a candidate future input). Penalty weights - stay IR-only and appear in the loss report. +- **Emission surfaces**: the deterministic scaffold declares all four in-file surfaces — net + structure (places, transitions, colours, ODEs, arcs), **scenario** (mandatory: a bare net loads + with an empty marking and does nothing when simulated), **metrics**, **parameters**. Declarative + structure is populated directly. TypeScript fields that require authored behavior carry readable + comments and field-local code obligations; they become executable only through the downstream + realization step defined by ADR-0005. The Optuna/optimization file format + (`petrinaut-optimization`) is **excluded for September**: its ontology is itself in flight + (Yannis is working on it; his design is a candidate future input). Penalty weights stay IR-only + and appear in the loss report. - **Typed loss report**: per-capture; every active capture lands in exactly one of `mapped-exactly / normalized / approximate / collapsed / omitted / defaulted / -unrepresentable`. The table above implies the first cut: entity-types and dynamics map - exactly (names normalized); boundary-conditions map to scenario content; activity structure - is normalized with durations approximate (rate code); orderings map exactly; policies are - approximate/collapsed with rationale unrepresentable; objectives normalize to metrics where - scalar-expressible, with penalty weights and rationale unrepresentable; constraints collapse - partially with regulatory references unrepresentable; data-bindings and validation-criteria - are unrepresentable. **First cut, illustrative** — the binding table is owned by the plugin - spec; the mechanism (per-capture, seven categories) is resolution-grade. +unrepresentable`. These categories describe the semantic fidelity of the scaffold-plus-obligation + product, not whether its TypeScript has already been realized. The table above implies the first + cut: entity-types map exactly (names normalized); dynamics and other authored behavior map to + field-local obligations and are exact, normalized, or approximate according to the specificity + of the capture; boundary-conditions map to declarative scenario content or scenario-code + obligations; activity structure is normalized with durations approximate; orderings map exactly; + policies are approximate/collapsed with rationale unrepresentable; objectives normalize to + metric obligations where scalar-expressible, with penalty weights and rationale unrepresentable; + constraints collapse partially with regulatory references unrepresentable; data-bindings and + validation-criteria are unrepresentable. **First cut, illustrative** — the binding table is owned + by the plugin spec; the mechanism (per-capture, seven categories) is resolution-grade. - **Regime rule**: the net projects the **practiced** process. Where prescribed and practiced diverge unresolved, practiced wins and the prescribed reading lands as `omitted` in the report. - **Naming discipline**: IR payloads keep expert-language names verbatim (evidence-faithful). The ProjectionPack owns a deterministic name→PascalCase identifier scheme, emits the name-map as projection metadata for the demo shell to display, and records collision renames as - `normalized`. The failure mode this prevents: place names are identifiers inside every code - surface (guards, kernels, ODEs, metrics) and import does not validate them, so an - inconsistent rename leaves code referencing identifiers that no longer resolve — nothing - catches it at import time; it surfaces only when simulation misbehaves. + `normalized`. Code obligations expose those generated identifiers as available symbols. The + failure mode this prevents: place names are identifiers inside every code surface (guards, + kernels, ODEs, metrics) and import does not validate them, so an inconsistent rename leaves code + referencing identifiers that no longer resolve — nothing catches it at import time; it surfaces + only when simulation misbehaves. - **Provenance stays outside the file.** The Petrinaut format has no provenance, rationale, confidence, or draft-ness fields anywhere, and unknown keys are stripped on import (inline - annotation is explicitly not round-trippable). Everything IR-only is honestly - `unrepresentable` in the artifact; provenance display is the demo shell's job, never - smuggled into the file. + annotation is explicitly not round-trippable). The obligation sidecar may reference supporting + capture ids, but comments in code fields are readable context rather than authority. Everything + IR-only is honestly `unrepresentable` in the artifact; provenance display is the demo shell's + job, never smuggled into the file. + +The application realizes code obligations through Petrinaut's client tools. Model inference writes +and repairs field-local TypeScript against returned compiler diagnostics; no generated code is +promoted into the capture store or elicited model. The completed artifact is accepted only when all +obligations are fulfilled, Petrinaut reports no compile failures, and at least one scenario runs +without a runtime error. ### September minimum (the open-questions doc's §7.2, answered) diff --git a/libs/@hashintel/brunch-agent/docs/specs/plugin-contract.md b/libs/@hashintel/brunch-agent/docs/specs/plugin-contract.md index 47b587737ca..db6cea0b736 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/plugin-contract.md +++ b/libs/@hashintel/brunch-agent/docs/specs/plugin-contract.md @@ -6,6 +6,8 @@ FE-1397-style worked pass across at least three plugin targets. Until the Septem exercises a real fold, everything here is design, not demonstrated behavior. Decided on: FE-1405 (the payload-interiors session, 2026-08-18); inputs were that session's working draft and its pseudo-YAML structural rendering, which collapse into this document. +Amended: 2026-08-24 by [ADR-0005](../adr/0005-model-assisted-sdcpn-realization.md), which +separates deterministic projection scaffolds from model-assisted executable-artifact realization. ## Problem Statement @@ -109,14 +111,17 @@ able to write a third plugin by analogy in a sitting. audit, contest, and supersede any interpretation. 20. As a reviewer, I want the fold forbidden to interpret, so that re-running it on the same store always yields the same model. -21. As a projection author (net renderer, loss report, completion table), I want to consume +21. As a projection author (net scaffold, loss report, completion table), I want to consume the elicited model without rereading the transcript or interpreting generic capture - fields, so that my projection is a pure function of register 2. + fields, so that the scaffold and its code obligations are pure functions of register 2. 22. As a harness developer (FE-1393, the plugin SDK and fold engine), I want every harness mechanism to be a pure function classified by which plugin declaration it reads, so that the harness/plugin boundary is mechanically checkable. 23. As a team reader, I want example plugins that read as declarations of their domain, so that I can evaluate the product's generality without reading harness internals. +24. As the artifact-realizing agent, I want each code obligation to name one target field, its + semantic intent, available net symbols, supporting captures, and acceptance checks, so that I + can write and repair TypeScript without resynthesizing unrelated regions. ## Implementation Decisions @@ -126,11 +131,39 @@ artifact); they encode the decisions more precisely than prose. ### The three registers (ADR-0003, binding) Assertions (envelope-wrapped typed proposals) → the elicited model (derived by a pure fold, -never stored) → projections. Write-time-only semantics: no semantic act at read time; every -bridge is a capture. The acceptance oracle: a second projection must consume the model without -rereading the transcript or semantically interpreting generic capture fields. Promotion, never -refusal: low-grade statements are captured honestly and never promote to a demanded grade -without a higher-grade capture superseding them. +never stored) → projections. Write-time-only semantics still governs model assembly: no semantic +act hides inside the fold, and every bridge from user language into the model is a capture. The +acceptance oracle: a second projection must consume the model without rereading the transcript or +semantically interpreting generic capture fields. Promotion, never refusal: low-grade statements +are captured honestly and never promote to a demanded grade without a higher-grade capture +superseding them. + +### Executable-artifact seam (ADR-0005) + +Some projection targets contain authored programs rather than declarative fields. For SDCPN, the +deterministic register-3 output is therefore a scaffold, a typed loss report, and a sidecar of code +obligations. Artifact realization is a downstream, model-assisted application step; it is not part +of `project`, the fold, or the persisted IR. + +```yaml +ProjectionResult: + draftArtifact: unknown + lossReport: LossEntry[] + codeObligations: CodeObligation[] + +CodeObligation: + id: string + target: { elementId: string, field: string } + semanticIntent: string + availableSymbols: { places: string[], tokenFields: string[], parameters: string[] } + supportingCaptureIds: string[] + acceptanceChecks: string[] +``` + +The sidecar is authoritative. A projector may mirror `semanticIntent` into the target code field as +a comment so the incomplete work is visible in the editor, but realization never reconstructs the +contract by parsing comments. The final gate is deterministic: every obligation is fulfilled, all +code compiles through Petrinaut, and at least one scenario simulates without a runtime error. ### Harness machinery: pure functions classified by what they read @@ -325,8 +358,10 @@ activeCaptures) → model` is pure by construction, so the whole contract is gol the sweep-accuracy rubric material for FE-1407 (the evaluation/gold-set effort). 2. **The acceptance oracle, executable**: a second projection consumes the folded model with no transcript access and no generic-field interpretation — enforced structurally by the - projection's input type being register 2 only. If the projection can't be written that - way, the failure is the finding. + projection's input type being register 2 only. For a code-bearing target, this proves the + scaffold and obligation plan; separate realization gates prove that the resulting artifact + compiles and runs. If the scaffold or obligations cannot be written from register 2 alone, + the failure is the finding. 3. **Contract validation**: plugin contract documents validate against the harness meta-schema; the fold-rule derivation is tested as a table-driven pure function over its finite product space (cardinality × gradedness + the two specials). @@ -377,6 +412,7 @@ decidedness. - Authoring the sweep skill, technique cards, and prompt-mechanism manifest keys (FE-1392/FE-1403/FE-1406 territory — this spec fixes only the `firesWhen` and `technique` hook points). +- Implementing artifact realization or the Petrinaut client-tool round trip (FE-1480/FE-1438). - The full FE-1397-style ratification pass — it is this spec's _condition_, owed before the provisional marker comes off, not part of its build scope. - Loss-category content and projection implementations beyond the oracle projection. diff --git a/libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/run.ts b/libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/run.ts index 4ec4b0bae3e..b75d45614e2 100644 --- a/libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/run.ts +++ b/libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/run.ts @@ -39,25 +39,24 @@ const FORCED_WRAP_MESSAGE = const CONTINUE_MESSAGE = "You were cut off mid-document. Continue exactly from where you stopped — no preamble, no repetition."; -interface ChatMessage { - role: "user" | "assistant"; - content: string; +type ChatMessage = Omit & { + content: Extract; // Present only when the API ended this model-generated message at its token limit. // Older checkpoints and human-authored messages legitimately omit it. truncated?: true; -} +}; -interface Usage { - input_tokens: number; - output_tokens: number; - // Absent in checkpoints written before the SDK migration; treated as 0. - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; -} +type Usage = Pick & + Partial< + Pick< + Anthropic.Usage, + "cache_creation_input_tokens" | "cache_read_input_tokens" + > + >; interface CallRecord { agent: "interviewer" | "expert" | "classifier"; - model: string; + model: Anthropic.Model; usage: Usage; } @@ -68,7 +67,7 @@ interface CallResult { interface RawCheckpoint { startedAt: string; - condition: string; + condition: "1" | "2"; stopReason: string; calls: CallRecord[]; interviewerMessages: ChatMessage[]; @@ -112,8 +111,9 @@ function usage(): never { const conditionArg = process.argv[2]; const mode = process.argv[3] ?? "fresh"; if (conditionArg !== "1" && conditionArg !== "2") usage(); -if (!["fresh", "--resume", "--continue-final"].includes(mode)) usage(); -const condition: "1" | "2" = conditionArg; +if (mode !== "fresh" && mode !== "--resume" && mode !== "--continue-final") + usage(); +const condition = conditionArg; const baseDir = fileURLToPath(new URL(".", import.meta.url)); const caseDir = fileURLToPath( @@ -135,7 +135,7 @@ const calls: CallRecord[] = []; async function callClaude( agent: CallRecord["agent"], - model: string, + model: CallRecord["model"], system: string | undefined, messages: ChatMessage[], maxTokens: number, diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/.oxlintrc.json b/libs/@hashintel/brunch-agent/packages/binding-flue/.oxlintrc.json new file mode 100644 index 00000000000..d7cfb823d33 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/.oxlintrc.json @@ -0,0 +1,52 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../../../../.config/oxlint/brunch/base.json"], + "categories": { + "correctness": "error", + "perf": "warn" + }, + "env": { + "builtin": true, + "es2026": true, + "node": true + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@hashintel/petrinaut", + "message": "Brunch libraries must not depend on Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "Brunch libraries must remain independent of unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "Brunch libraries must not depend on Petrinaut implementations." + }, + { + "group": ["@hashintel/brunch-agent-*"], + "message": "A binding may depend inward on the harness, not on Brunch extensions." + } + ] + } + ] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/package.json b/libs/@hashintel/brunch-agent/packages/binding-flue/package.json index 44cdc5a27c5..d7a4463edbb 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/package.json +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/package.json @@ -13,8 +13,8 @@ }, "scripts": { "build": "vite build", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", "test:unit": "vitest run" }, diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/capture-accounting.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/capture-accounting.ts index a12e6400489..dbdaf658b6d 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/capture-accounting.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/capture-accounting.ts @@ -14,14 +14,17 @@ export const capturedUserEntryIdsForSession = async ( sessionId: string, ): Promise> => { const entryIds = new Set(); - for (const capture of snapshot.captures) { - if (!("evidence" in capture)) continue; - for (const evidence of capture.evidence) { - if (evidence.pointer.sessionId !== sessionId) continue; - for (const entry of await store.readArchivedEntries(evidence.pointer)) { - if (entry.versions.at(-1)?.kind === "user-affordance-payload") { - entryIds.add(entry.substrateEntryId); - } + const archiveReads = snapshot.captures.flatMap((capture) => + "evidence" in capture + ? capture.evidence + .filter((evidence) => evidence.pointer.sessionId === sessionId) + .map((evidence) => store.readArchivedEntries(evidence.pointer)) + : [], + ); + for (const archivedEntries of await Promise.all(archiveReads)) { + for (const entry of archivedEntries) { + if (entry.versions.at(-1)?.kind === "user-affordance-payload") { + entryIds.add(entry.substrateEntryId); } } } diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts index 715684db65b..f16f4ecdb1d 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts @@ -26,6 +26,7 @@ import { ASK_TOOL_DESCRIPTION, AskInput, FreeTextAffordance, + SWEEP_RESULT_STATUSES, advanceSweepHighWater, askProtocolInstructionFragments, buildSettlementCheckSignal, @@ -57,7 +58,7 @@ import { } from "./history-reader"; const SweepToolOutput = v.looseObject({ - status: v.picklist(["no-settled-range", "refused", "applied"]), + status: v.picklist(SWEEP_RESULT_STATUSES), }); export { CAPABILITIES, type Capability, type Provision } from "./capabilities"; diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/reply-projector.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/reply-projector.ts index ed0322ae76e..0caba201aaf 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/reply-projector.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/reply-projector.ts @@ -13,10 +13,10 @@ export interface FlueReplyProjector { accept(chunk: ConversationStreamChunk): void; } -type StreamingPart = { - readonly kind: "text" | "reasoning"; - readonly partId: string; -}; +type StreamingPart = Omit< + Extract, + "type" +>; export const createFlueReplyProjector = ( options: FlueReplyProjectorOptions, @@ -40,7 +40,7 @@ export const createFlueReplyProjector = ( turnId = undefined; }; - const startPart = (kind: "text" | "reasoning"): StreamingPart => { + const startPart = (kind: StreamingPart["kind"]): StreamingPart => { finishPart(); partOrdinal += 1; const part = { diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts index 534eb6b1205..8e7ac59bafe 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts @@ -281,6 +281,8 @@ describe("local capture store", () => { }), ); - await expect(createLocalCaptureStoreAdapter(path).read()).rejects.toThrow(); + await expect(createLocalCaptureStoreAdapter(path).read()).rejects.toThrow( + Error, + ); }); }); diff --git a/libs/@hashintel/brunch-agent/packages/core/.oxlintrc.json b/libs/@hashintel/brunch-agent/packages/core/.oxlintrc.json new file mode 100644 index 00000000000..871350799af --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/.oxlintrc.json @@ -0,0 +1,56 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../../../../.config/oxlint/brunch/base.json"], + "categories": { + "correctness": "error", + "perf": "warn" + }, + "env": { + "builtin": true, + "es2026": true, + "node": true + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@hashintel/petrinaut", + "message": "Brunch libraries must not depend on Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "Brunch libraries must remain independent of unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "Brunch libraries must not depend on Petrinaut implementations." + }, + { + "group": ["@flue/*", "@earendil-works/*"], + "message": "The Brunch harness must remain substrate-independent." + }, + { + "group": ["@hashintel/brunch-agent-*"], + "message": "The Brunch harness must not depend on bindings, plugins, or transports." + } + ] + } + ] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/libs/@hashintel/brunch-agent/packages/core/package.json b/libs/@hashintel/brunch-agent/packages/core/package.json index 76258ade034..10899eaaf31 100644 --- a/libs/@hashintel/brunch-agent/packages/core/package.json +++ b/libs/@hashintel/brunch-agent/packages/core/package.json @@ -26,9 +26,9 @@ "scripts": { "baseline:run": "node --experimental-strip-types ../../evaluations/protocols/process-model-elicitation/baseline/run.ts", "build": "vite build", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", + "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", "linear:graph": "node --experimental-strip-types ../../scripts/linear-project-graph.ts", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", "test:unit": "vitest run" }, diff --git a/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts b/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts index 54356282519..ec9206b8d5f 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts @@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto"; import * as v from "valibot"; +import { JsonValueSchema } from "./json-value"; import { + EvidenceQuoteSchema, resolveEvidenceQuotes, type EvidenceQuote, type EvidenceResolutionRefusal, @@ -11,6 +13,11 @@ import { type SessionLogArchive, } from "./session-log"; +import type { JsonValue } from "./json-value"; +import type { ReadonlyDeep } from "./readonly-deep"; + +export type { JsonValue } from "./json-value"; + export const ABSENCE_STATES = [ "unknown-to-user", "not-yet-decided", @@ -43,95 +50,29 @@ export type EpistemicStatus = (typeof EPISTEMIC_STATUSES)[number]; export type IssueType = (typeof ISSUE_TYPES)[number]; export type CaptureStatus = "active" | "superseded" | "retracted"; export type IssueStatus = "open" | "closed"; -export type JsonValue = - | null - | boolean - | number - | string - | JsonValue[] - | { readonly [key: string]: JsonValue }; - -export interface EvidenceSpan { - readonly excerpt: string; - readonly pointer: { - readonly sessionId: string; - readonly entryStart: number; - readonly entryEnd: number; - }; - readonly source: "user" | "user-affordance-payload"; -} - -export type CaptureContent = - | { readonly value: JsonValue } - | { readonly absence: AbsenceState }; - -interface CaptureProposalCommon { - readonly confidence: string; - readonly content: CaptureContent; - readonly alternativeGroup?: string; - readonly supersedes?: string; -} - -export type CaptureInputProposal = CaptureProposalCommon & - ( - | { - readonly evidence: readonly EvidenceQuote[]; - readonly epistemicStatus: "explicit" | "inferred" | "tentative"; - } - | { - readonly basis: { - readonly type: "declared-default"; - readonly description: string; - }; - readonly epistemicStatus: "defaulted"; - } - | { - readonly basis: { - readonly type: "documented-transformation"; - readonly description: string; - }; - readonly epistemicStatus: "external-lookup"; - } - ); - -export type CaptureProposal = CaptureProposalCommon & - ( - | { - readonly evidence: readonly EvidenceSpan[]; - readonly epistemicStatus: "explicit" | "inferred" | "tentative"; - } - | { - readonly basis: { - readonly type: "declared-default"; - readonly description: string; - }; - readonly epistemicStatus: "defaulted"; - } - | { - readonly basis: { - readonly type: "documented-transformation"; - readonly description: string; - }; - readonly epistemicStatus: "external-lookup"; - } - ); - -export type CaptureEnvelope = CaptureProposal & { - readonly id: string; - readonly dedupKey: string; -}; - -export type IssueOrigin = - | { readonly type: "harness" } - | { readonly type: "plugin"; readonly namespace: string }; - -export interface CaptureIssue { - readonly id: string; - readonly type: IssueType; - readonly origin: IssueOrigin; - readonly references: readonly string[]; - readonly canDefault: boolean; -} +export type EvidenceSpan = ReadonlyDeep< + v.InferOutput +>; +export type CaptureContent = ReadonlyDeep>; +type ParsedCaptureInputProposal = ReadonlyDeep< + v.InferOutput +>; +export type CaptureInputProposal = + ParsedCaptureInputProposal extends infer Proposal + ? Proposal extends { readonly evidence: readonly unknown[] } + ? Omit & { + readonly evidence: readonly EvidenceQuote[]; + } + : Proposal + : never; +export type CaptureProposal = ReadonlyDeep< + v.InferOutput +>; +export type CaptureEnvelope = ReadonlyDeep< + v.InferOutput +>; +export type CaptureIssue = ReadonlyDeep>; +export type IssueOrigin = CaptureIssue["origin"]; export type CaptureAdvisory = | { @@ -141,39 +82,21 @@ export type CaptureAdvisory = } | MultipleEvidenceMatchesAdvisory; -export interface ResolutionRecord { - readonly type: "resolution"; - readonly id: string; - readonly issueId: string; - readonly decision: string; - readonly evidence: readonly EvidenceSpan[]; - readonly winnerCaptureId: string; - readonly loserCaptureIds: readonly string[]; -} - -export interface RetractionEvent { - readonly type: "retraction"; - readonly id: string; - readonly captureId: string; - readonly evidence: readonly EvidenceSpan[]; -} - -export interface IssueClosedEvent { - readonly type: "issue-closed"; - readonly id: string; - readonly issueId: string; -} - -export type CaptureStoreEvent = - | ResolutionRecord - | RetractionEvent - | IssueClosedEvent; - -export interface CaptureStoreSnapshot { - readonly captures: readonly CaptureEnvelope[]; - readonly issues: readonly CaptureIssue[]; - readonly events: readonly CaptureStoreEvent[]; -} +export type ResolutionRecord = ReadonlyDeep< + v.InferOutput +>; +export type RetractionEvent = ReadonlyDeep< + v.InferOutput +>; +export type IssueClosedEvent = ReadonlyDeep< + v.InferOutput +>; +export type CaptureStoreEvent = ReadonlyDeep< + v.InferOutput +>; +export type CaptureStoreSnapshot = ReadonlyDeep< + v.InferOutput +>; export interface CaptureStore { read(): Promise; @@ -310,9 +233,8 @@ const evidenceSpanSchema = v.strictObject({ ), source: v.picklist(["user", "user-affordance-payload"]), }); -const evidenceQuoteSchema = v.strictObject({ excerpt: nonEmptyString }); const contentSchema = v.union([ - v.strictObject({ value: v.unknown() }), + v.strictObject({ value: JsonValueSchema }), v.strictObject({ absence: v.picklist(ABSENCE_STATES) }), ]); const captureCommonFields = { @@ -350,7 +272,7 @@ const captureProposalSchema = v.union([ export const CaptureInputProposalSchema = v.union([ v.strictObject({ ...captureCommonFields, - evidence: v.pipe(v.array(evidenceQuoteSchema), v.minLength(1)), + evidence: v.pipe(v.array(EvidenceQuoteSchema), v.minLength(1)), epistemicStatus: v.picklist(["explicit", "inferred", "tentative"]), }), v.strictObject(defaultedCaptureFields), @@ -414,12 +336,15 @@ const issueClosedSchema = v.strictObject({ id: nonEmptyString, issueId: nonEmptyString, }); +const captureStoreEventSchema = v.variant("type", [ + resolutionSchema, + retractionSchema, + issueClosedSchema, +]); const snapshotSchema = v.strictObject({ captures: v.array(captureEnvelopeSchema), issues: v.array(issueSchema), - events: v.array( - v.variant("type", [resolutionSchema, retractionSchema, issueClosedSchema]), - ), + events: v.array(captureStoreEventSchema), }); /** @@ -450,7 +375,7 @@ export const createEmptyCaptureStoreSnapshot = (): CaptureStoreSnapshot => ({ export const parseCaptureStoreSnapshot = ( input: unknown, ): CaptureStoreSnapshot => { - const snapshot = v.parse(snapshotSchema, input) as CaptureStoreSnapshot; + const snapshot = v.parse(snapshotSchema, input); for (const records of [snapshot.captures, snapshot.issues, snapshot.events]) { if (new Set(records.map((record) => record.id)).size !== records.length) { throw new TypeError( @@ -459,11 +384,6 @@ export const parseCaptureStoreSnapshot = ( } } for (const capture of snapshot.captures) { - if ("value" in capture.content && !isJsonValue(capture.content.value)) { - throw new TypeError( - `Capture ${capture.id} contains a value that cannot be stored as JSON`, - ); - } if (capture.dedupKey !== captureDedupKey(capture)) { throw new TypeError( `Capture ${capture.id} has a stale content dedup key.`, @@ -606,22 +526,6 @@ export const parseCaptureStoreSnapshot = ( return snapshot; }; -const isJsonValue = (value: unknown): value is JsonValue => { - if (value === null || typeof value === "string" || typeof value === "boolean") - return true; - // Negative zero is refused alongside the non-finite numbers: JSON.stringify - // writes it as "0", so the read path could never reproduce what was accepted. - if (typeof value === "number") - return Number.isFinite(value) && !Object.is(value, -0); - if (Array.isArray(value)) return value.every(isJsonValue); - if (typeof value !== "object") return false; - const prototype = Object.getPrototypeOf(value); - return ( - (prototype === Object.prototype || prototype === null) && - Object.values(value as Record).every(isJsonValue) - ); -}; - const canonicalize = (value: JsonValue): JsonValue => { if (Array.isArray(value)) return value.map(canonicalize); if (value !== null && typeof value === "object") { @@ -747,16 +651,7 @@ const validateProposal = ( // well formed and declare a source, which is not the same as provenance // having been resolved against an entry projection. message: - "A capture must carry the provenance shape its epistemic status names, exactly one of value or absence, and evidence ranges that do not end before they start.", - }; - } - if ( - "value" in parsed.output.content && - !isJsonValue(parsed.output.content.value) - ) { - return { - code: "invalid-envelope", - message: "A capture value must be JSON-compatible.", + "A capture must carry the provenance shape its epistemic status names, exactly one JSON-compatible value or absence, and evidence ranges that do not end before they start.", }; } return undefined; @@ -770,16 +665,7 @@ const validateInputProposal = ( return { code: "invalid-envelope", message: - "A capture must carry the provenance shape its epistemic status names, exactly one of value or absence, and non-empty verbatim evidence quotes.", - }; - } - if ( - "value" in parsed.output.content && - !isJsonValue(parsed.output.content.value) - ) { - return { - code: "invalid-envelope", - message: "A capture value must be JSON-compatible.", + "A capture must carry the provenance shape its epistemic status names, exactly one JSON-compatible value or absence, and non-empty verbatim evidence quotes.", }; } return undefined; @@ -1218,7 +1104,7 @@ export const applyCaptureStoreCommand = ( } if ( !v.safeParse( - v.pipe(v.array(evidenceQuoteSchema), v.minLength(1)), + v.pipe(v.array(EvidenceQuoteSchema), v.minLength(1)), command.evidence, ).success ) { @@ -1301,7 +1187,7 @@ export const applyCaptureStoreCommand = ( } if ( !v.safeParse( - v.pipe(v.array(evidenceQuoteSchema), v.minLength(1)), + v.pipe(v.array(EvidenceQuoteSchema), v.minLength(1)), command.evidence, ).success ) { diff --git a/libs/@hashintel/brunch-agent/packages/core/src/index.ts b/libs/@hashintel/brunch-agent/packages/core/src/index.ts index 40a29668ac5..76ee8da9f85 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/index.ts @@ -36,7 +36,11 @@ export { toolPrefix, type Operation, } from "./naming"; -export { type HarnessReplyEvent } from "./reply-protocol"; +export { + type HarnessReplyEvent, + type ReplyPartKind, + type ToolExecution, +} from "./reply-protocol"; export { definePlugin, PluginDescriptor, @@ -78,6 +82,8 @@ export { type JsonValue, } from "./capture-store"; export { + EvidenceQuoteSchema, + SESSION_ENTRY_KINDS, type ArchivedSessionEntry, type ArchivedSessionEntryVersion, type EvidenceQuote, @@ -87,6 +93,7 @@ export { type SessionEntryKind, } from "./session-log"; export { + SWEEP_RESULT_STATUSES, advanceSweepHighWater, buildSettlementCheckSignal, buildSweepExtractionPrompt, diff --git a/libs/@hashintel/brunch-agent/packages/core/src/json-value.ts b/libs/@hashintel/brunch-agent/packages/core/src/json-value.ts new file mode 100644 index 00000000000..b360d869070 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/src/json-value.ts @@ -0,0 +1,30 @@ +import * as v from "valibot"; + +export type JsonValue = + | null + | boolean + | number + | string + | readonly JsonValue[] + | { readonly [key: string]: JsonValue }; + +export const isJsonValue = (value: unknown): value is JsonValue => { + if (value === null || typeof value === "string" || typeof value === "boolean") + return true; + // JSON.stringify would silently rewrite non-finite numbers and negative zero, + // so the persisted value could not be reproduced on read. + if (typeof value === "number") + return Number.isFinite(value) && !Object.is(value, -0); + if (Array.isArray(value)) return value.every(isJsonValue); + if (typeof value !== "object") return false; + const prototype: unknown = Object.getPrototypeOf(value); + return ( + (prototype === Object.prototype || prototype === null) && + Object.values(value as Record).every(isJsonValue) + ); +}; + +export const JsonValueSchema = v.custom( + isJsonValue, + "Expected a JSON-compatible value.", +); diff --git a/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts b/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts index 34c8403d420..4861710b030 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts @@ -43,6 +43,7 @@ export type Plugin = v.InferOutput & { export function definePlugin(descriptor: Plugin): Plugin { const identity = v.parse(PluginDescriptor, descriptor); const [proposal, ...extraProposals] = descriptor.proposalCatalog; + // oxlint-disable-next-line typescript/no-unnecessary-condition -- Public JavaScript callers still require the runtime cardinality guard. if (!proposal || extraProposals.length > 0) { throw new TypeError( "This slice requires exactly one declared proposal type.", diff --git a/libs/@hashintel/brunch-agent/packages/core/src/readonly-deep.ts b/libs/@hashintel/brunch-agent/packages/core/src/readonly-deep.ts new file mode 100644 index 00000000000..b3ea862d88d --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/src/readonly-deep.ts @@ -0,0 +1,21 @@ +/** Recursively expose a parsed data contract as immutable. */ +export type ReadonlyDeep< + Value, + Depth extends readonly unknown[] = [], +> = Depth["length"] extends 8 + ? Value + : Value extends readonly unknown[] + ? { + readonly [Index in keyof Value]: ReadonlyDeep< + Value[Index], + readonly [unknown, ...Depth] + >; + } + : Value extends object + ? { + readonly [Key in keyof Value]: ReadonlyDeep< + Value[Key], + readonly [unknown, ...Depth] + >; + } + : Value; diff --git a/libs/@hashintel/brunch-agent/packages/core/src/reply-protocol.ts b/libs/@hashintel/brunch-agent/packages/core/src/reply-protocol.ts index 8e5334564c0..6de31a5e247 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/reply-protocol.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/reply-protocol.ts @@ -5,23 +5,26 @@ * crosses the boundary. Message, part, turn, and tool-call identities are * supplied by the binding and preserved by transports. */ +export type ReplyPartKind = "text" | "reasoning"; +export type ToolExecution = "client" | "server"; + export type HarnessReplyEvent = | { readonly type: "response-start"; readonly messageId: string } | { readonly type: "turn-start"; readonly turnId: string } | { readonly type: "part-start"; - readonly kind: "text" | "reasoning"; + readonly kind: ReplyPartKind; readonly partId: string; } | { readonly type: "part-delta"; - readonly kind: "text" | "reasoning"; + readonly kind: ReplyPartKind; readonly partId: string; readonly delta: string; } | { readonly type: "part-end"; - readonly kind: "text" | "reasoning"; + readonly kind: ReplyPartKind; readonly partId: string; } | { @@ -29,19 +32,19 @@ export type HarnessReplyEvent = readonly toolCallId: string; readonly toolName: string; readonly input: unknown; - readonly execution: "client" | "server"; + readonly execution: ToolExecution; } | { readonly type: "tool-output"; readonly toolCallId: string; readonly output: unknown; - readonly execution: "client" | "server"; + readonly execution: ToolExecution; } | { readonly type: "tool-output-error"; readonly toolCallId: string; readonly errorText: string; - readonly execution: "client" | "server"; + readonly execution: ToolExecution; } | { readonly type: "turn-finish"; readonly turnId: string } | { diff --git a/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts b/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts index 328c1fa6db6..434841d9e2a 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts @@ -1,12 +1,19 @@ import * as v from "valibot"; -import type { EvidenceSpan, JsonValue } from "./capture-store"; +import { JsonValueSchema, isJsonValue } from "./json-value"; -export type SessionEntryKind = - | "user" - | "user-affordance-payload" - | "assistant" - | "non-user"; +import type { EvidenceSpan } from "./capture-store"; +import type { JsonValue } from "./json-value"; +import type { ReadonlyDeep } from "./readonly-deep"; + +export const SESSION_ENTRY_KINDS = [ + "user", + "user-affordance-payload", + "assistant", + "non-user", +] as const; + +export type SessionEntryKind = (typeof SESSION_ENTRY_KINDS)[number]; export interface SessionLogEntrySnapshot { /** Stable identity supplied by the substrate's public projection. */ @@ -30,50 +37,30 @@ export interface SessionLogRead { readonly settlements: readonly JsonValue[]; } -export interface ArchivedSessionEntryVersion { - readonly version: number; - readonly observedAtOffset: string; - readonly kind: SessionEntryKind; - readonly text: string; - readonly materialized: JsonValue; -} +export type ArchivedSessionEntryVersion = ReadonlyDeep< + v.InferOutput +>; +export type ArchivedSessionEntry = ReadonlyDeep< + v.InferOutput +>; +export type ArchivedSessionRead = ReadonlyDeep< + v.InferOutput +>; +export type ArchivedSessionLog = ReadonlyDeep< + v.InferOutput +>; +export type SessionLogArchive = ReadonlyDeep< + v.InferOutput +>; -export interface ArchivedSessionEntry { - /** Harness-owned, one-based entry identity used by evidence pointers. */ - readonly ordinal: number; - readonly substrateEntryId: string; - readonly substrateIncarnation?: string; - readonly versions: readonly ArchivedSessionEntryVersion[]; -} - -export interface ArchivedSessionRead { - readonly offset: string; - readonly substrateConversationId?: string; - readonly incarnation?: string; - readonly entries: readonly { - readonly ordinal: number; - readonly version: number; - }[]; - readonly settlements: readonly JsonValue[]; -} - -export interface ArchivedSessionLog { - readonly sessionId: string; - readonly entries: readonly ArchivedSessionEntry[]; - readonly reads: readonly ArchivedSessionRead[]; -} - -export interface SessionLogArchive { - readonly sessions: readonly ArchivedSessionLog[]; -} - -export interface EvidenceQuote { - readonly excerpt: string; +export type EvidenceQuote = ReadonlyDeep< + v.InferOutput +> & { /** Persisted pointer fields are deliberately unassignable to caller input. */ readonly pointer?: never; /** Provenance is derived from the archive, never asserted by the caller. */ readonly source?: never; -} +}; export interface MultipleEvidenceMatchesAdvisory { readonly type: "multiple-evidence-matches"; @@ -104,18 +91,14 @@ export type EvidenceResolutionResult = const nonEmptyString = v.pipe(v.string(), v.nonEmpty()); const positiveInteger = v.pipe(v.number(), v.integer(), v.minValue(1)); -const kindSchema = v.picklist([ - "user", - "user-affordance-payload", - "assistant", - "non-user", -]); +const kindSchema = v.picklist(SESSION_ENTRY_KINDS); +export const EvidenceQuoteSchema = v.strictObject({ excerpt: nonEmptyString }); const versionSchema = v.strictObject({ version: positiveInteger, observedAtOffset: nonEmptyString, kind: kindSchema, text: v.string(), - materialized: v.unknown(), + materialized: JsonValueSchema, }); const entrySchema = v.strictObject({ ordinal: positiveInteger, @@ -133,7 +116,7 @@ const readSchema = v.strictObject({ version: positiveInteger, }), ), - settlements: v.array(v.unknown()), + settlements: v.array(JsonValueSchema), }); const sessionSchema = v.strictObject({ sessionId: nonEmptyString, @@ -142,20 +125,6 @@ const sessionSchema = v.strictObject({ }); const archiveSchema = v.strictObject({ sessions: v.array(sessionSchema) }); -const isJsonValue = (value: unknown): value is JsonValue => { - if (value === null || typeof value === "string" || typeof value === "boolean") - return true; - if (typeof value === "number") - return Number.isFinite(value) && !Object.is(value, -0); - if (Array.isArray(value)) return value.every(isJsonValue); - if (typeof value !== "object") return false; - const prototype = Object.getPrototypeOf(value); - return ( - (prototype === Object.prototype || prototype === null) && - Object.values(value as Record).every(isJsonValue) - ); -}; - const canonicalize = (value: JsonValue): JsonValue => { if (Array.isArray(value)) return value.map(canonicalize); if (value !== null && typeof value === "object") { @@ -176,7 +145,7 @@ export const createEmptySessionLogArchive = (): SessionLogArchive => ({ }); export const parseSessionLogArchive = (input: unknown): SessionLogArchive => { - const archive = v.parse(archiveSchema, input) as SessionLogArchive; + const archive = v.parse(archiveSchema, input); const sessionIds = new Set(); for (const session of archive.sessions) { if (sessionIds.has(session.sessionId)) { @@ -206,20 +175,8 @@ export const parseSessionLogArchive = (input: unknown): SessionLogArchive => { `Archived entry ${entry.ordinal} has non-contiguous versions.`, ); } - for (const version of entry.versions) { - if (!isJsonValue(version.materialized)) { - throw new TypeError( - `Archived entry ${entry.ordinal} is not JSON-compatible.`, - ); - } - } } for (const read of session.reads) { - if (!read.settlements.every(isJsonValue)) { - throw new TypeError( - `Session log ${session.sessionId} has a non-JSON settlement.`, - ); - } for (const reference of read.entries) { const archived = session.entries[reference.ordinal - 1]; if (!archived || !archived.versions[reference.version - 1]) { diff --git a/libs/@hashintel/brunch-agent/packages/core/src/sweep-protocol.ts b/libs/@hashintel/brunch-agent/packages/core/src/sweep-protocol.ts index 4672fa7361f..2abb1d9a866 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/sweep-protocol.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/sweep-protocol.ts @@ -2,11 +2,10 @@ import * as v from "valibot"; import { toolName } from "./naming"; -import type { - CaptureInputProposal, - CaptureStoreRefusal, -} from "./capture-store"; +import type { FreeTextAffordance } from "./affordance"; +import type { CaptureInputProposal } from "./capture-store"; import type { Plugin } from "./plugin"; +import type { ReadonlyDeep } from "./readonly-deep"; import type { SessionEntryKind } from "./session-log"; const nonEmptyString = v.pipe(v.string(), v.nonEmpty()); @@ -22,18 +21,22 @@ export const createSweepExtractionResultSchema = ( proposals: v.array(plugin.proposalCatalog[0].schema), }); -export interface SweepAffordance { - readonly id: string; - readonly markdown: string; -} +export type SweepAffordance = Pick; export interface SweepRefusalFact { + /** Durable history may contain refusal codes from a different harness version. */ readonly code: string; readonly message: string; } +export const SWEEP_RESULT_STATUSES = [ + "no-settled-range", + "refused", + "applied", +] as const; + export interface SweepResultFact { - readonly status: "no-settled-range" | "refused" | "applied"; + readonly status: (typeof SWEEP_RESULT_STATUSES)[number]; readonly refusal?: SweepRefusalFact; } @@ -48,15 +51,12 @@ export interface SweepSessionEntry { readonly sweepRepairSignal?: true; } -export interface SweepState { - /** Latest true-user entry included in a successfully applied sweep. */ - readonly sweptThroughUserEntryId: string | null; - /** Loop guard: latest true-user entry offered for settlement judgment. */ - readonly lastCheckedUserEntryId: string | null; -} +export type SweepState = ReadonlyDeep>; const sweepStateSchema = v.strictObject({ + /** Latest true-user entry included in a successfully applied sweep. */ sweptThroughUserEntryId: v.nullable(nonEmptyString), + /** Loop guard: latest true-user entry offered for settlement judgment. */ lastCheckedUserEntryId: v.nullable(nonEmptyString), }); @@ -237,7 +237,7 @@ export interface SweepRepairSignal { } export const buildSweepRepairSignal = ( - refusal: Pick | SweepRefusalFact, + refusal: SweepRefusalFact, ): SweepRepairSignal => ({ type: "sweep-repair", tagName: "sweep-repair", diff --git a/libs/@hashintel/brunch-agent/packages/core/test/anchoring.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/anchoring.test.ts index 224c7aca753..cb27a9da9f9 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/anchoring.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/anchoring.test.ts @@ -9,11 +9,12 @@ import { import { archiveSessionLogRead, createEmptySessionLogArchive, + type EvidenceQuote, } from "../src/session-log"; type UserCaptureInput = Extract< CaptureInputProposal, - { readonly evidence: readonly { readonly excerpt: string }[] } + { readonly evidence: readonly EvidenceQuote[] } >; const archive = archiveSessionLogRead(createEmptySessionLogArchive(), { diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts index 01fb800398f..4a25c4ea06e 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts @@ -6,10 +6,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import * as v from "valibot"; import { afterEach, describe, expect, test } from "vitest"; import { CONTEXT_ROOT, contextRootPresent } from "./workspace"; +import type { StubReply } from "./fixtures/baseline-anthropic-stub"; + const BASELINE_PROTOCOL_DIR = join( CONTEXT_ROOT, "evaluations/protocols/process-model-elicitation/baseline", @@ -23,17 +26,28 @@ const STUB_MODULE = pathToFileURL( ).href; const temporaryDirectories: string[] = []; -interface StubReply { - text: string; - truncated?: boolean; -} - interface BaselineCopy { outputDirectory: string; protocolDirectory: string; testDirectory: string; } +const BaselineCheckpoint = v.object({ + stopReason: v.string(), + calls: v.array(v.unknown()), + interviewerMessages: v.array( + v.object({ + role: v.picklist(["user", "assistant"]), + content: v.string(), + truncated: v.optional(v.boolean()), + }), + ), +}); + +const BaselineRequest = v.object({ + messages: v.array(v.record(v.string(), v.unknown())), +}); + async function createBaselineCopy(): Promise { const testDirectory = await mkdtemp(join(tmpdir(), "baseline-runner-test-")); temporaryDirectories.push(testDirectory); @@ -61,17 +75,9 @@ async function runBaseline( replies: StubReply[], mode?: "--resume" | "--continue-final", ): Promise<{ - checkpoint: { - stopReason: string; - calls: unknown[]; - interviewerMessages: Array<{ - role: "user" | "assistant"; - content: string; - truncated?: boolean; - }>; - }; + checkpoint: v.InferOutput; stderr: string; - requests: Array<{ messages: Array> }>; + requests: Array>; }> { const requestsPath = join(baselineCopy.testDirectory, "requests.jsonl"); const subprocess = spawn( @@ -105,16 +111,19 @@ async function runBaseline( }); expect(exitCode).toBe(0); - const checkpoint = JSON.parse( - await readFile( - join(baselineCopy.outputDirectory, "condition-1.raw.json"), - "utf8", - ), + const checkpoint = v.parse( + BaselineCheckpoint, + JSON.parse( + await readFile( + join(baselineCopy.outputDirectory, "condition-1.raw.json"), + "utf8", + ), + ) as unknown, ); const requests = (await readFile(requestsPath, "utf8")) .trim() .split("\n") - .map((line) => JSON.parse(line)); + .map((line) => v.parse(BaselineRequest, JSON.parse(line) as unknown)); return { checkpoint, stderr, requests }; } diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts index 5d15cdfcd9b..f35b536ff89 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts @@ -155,7 +155,9 @@ describe("dependency direction (spec §4, §12.2)", () => { for (const specifier of importedPackages(file)) { const pkg = packageOf(specifier); expect(isSubstrate(pkg)).toBe(false); - if (pkg.startsWith("@hashintel/brunch-agent")) expect(pkg).toBe(CORE); + expect(pkg.startsWith("@hashintel/brunch-agent") ? pkg : CORE).toBe( + CORE, + ); expect(specifier).not.toBe(`${CORE}/storage`); } } @@ -207,17 +209,23 @@ describe("the direction is enforced under HASH's linker", () => { for (const pkg of PACKAGES) { const declared = runtimeDependencies(pkg); for (const file of sourceFiles(pkg)) { - for (const imported of importedPackages(file).map(packageOf)) { - if ( - imported === CORE || - imported.startsWith("@hashintel/brunch-agent-") - ) { - expect({ file: file.relPath, imported, declared }).toEqual({ - file: file.relPath, - imported, - declared: expect.arrayContaining([imported]), - }); - } + const importedWorkspaces = importedPackages(file) + .map(packageOf) + .filter( + (imported) => + imported === CORE || + imported.startsWith("@hashintel/brunch-agent-"), + ); + for (const imported of importedWorkspaces) { + expect({ + file: file.relPath, + imported, + declared: declared.includes(imported), + }).toEqual({ + file: file.relPath, + imported, + declared: true, + }); } } } @@ -426,11 +434,9 @@ describe("core auxiliary subpaths stay in their assigned lanes (spec §12.2)", ( describe("the HASH smoke is runnable without a model key or a network (spec §12.5)", () => { test("every Brunch workspace exposes HASH lint, typecheck, and unit-test tasks", () => { for (const pkg of PACKAGES) { - expect(pkg.manifest.scripts).toMatchObject({ - "lint:eslint": expect.any(String), - "lint:tsc": expect.any(String), - "test:unit": expect.stringContaining("vitest run"), - }); + expect(typeof pkg.manifest.scripts?.["lint:eslint"]).toBe("string"); + expect(typeof pkg.manifest.scripts?.["lint:tsc"]).toBe("string"); + expect(pkg.manifest.scripts?.["test:unit"]).toContain("vitest run"); } }); diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/control-surfaces.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/control-surfaces.test.ts index 9e27a187488..6224ab98cf0 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/control-surfaces.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/control-surfaces.test.ts @@ -70,14 +70,32 @@ describe.skipIf(!contextRootPresent)("strategic control surfaces", () => { test("supersedes targets resolve backward without cycles", () => { const seen = new Set(); + const invalidTargets: Array<{ + entry: string; + target: string; + reason: "malformed" | "not-earlier"; + }> = []; for (const entry of entries) { const supersedes = entry.fields.get("Supersedes")!; if (supersedes !== "none") { - expect(supersedes).toMatch(/^S-\d{3}$/); - expect(seen.has(supersedes)).toBe(true); + if (!/^S-\d{3}$/.test(supersedes)) { + invalidTargets.push({ + entry: entry.id, + target: supersedes, + reason: "malformed", + }); + } + if (!seen.has(supersedes)) { + invalidTargets.push({ + entry: entry.id, + target: supersedes, + reason: "not-earlier", + }); + } } seen.add(entry.id); } + expect(invalidTargets).toEqual([]); }); test("every strategy ID in steering resolves and is unsuperseded", () => { diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/docs-index.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/docs-index.test.ts index db2d443be41..070dcfcf8d2 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/docs-index.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/docs-index.test.ts @@ -25,22 +25,22 @@ const DOCS_ROOT = join(REPO_ROOT, "docs"); const INDEX_RELPATH = "INDEX.md"; /** Gitignored ephemera — in the tree but not of it, so never indexed. */ -const SKIP_DIRECTORIES = ["drafts"]; +const SKIP_DIRECTORIES = new Set(["drafts"]); /** Filesystem and placeholder artefacts: not documents. */ -const SKIP_FILES = [".DS_Store", ".gitkeep"]; +const SKIP_FILES = new Set([".DS_Store", ".gitkeep"]); /** * `docs/agents/` is deliberately outside the INDEX's remit: those files are * pointed at from `AGENTS.md`, which is the pointer an agent actually reads, and * the third rule below governs them there. Listing them twice would let the two * registries disagree about what the protocol set is. */ -const INDEX_EXEMPT = ["agents", INDEX_RELPATH]; +const INDEX_EXEMPT = new Set(["agents", INDEX_RELPATH]); /** * Preserved external analysis containing links into its source checkout and * embedded Markdown examples. Those links are evidence, not context-local * navigation. */ -const LINK_CHECK_EXEMPT = ["reference/amp-analysis-flue-vs-tilde.md"]; +const LINK_CHECK_EXEMPT = new Set(["reference/amp-analysis-flue-vs-tilde.md"]); /** Immutable migration snapshots whose old paths are part of the evidence. */ const LINK_CHECK_EXEMPT_PREFIXES = [ "archive/migrations/hash-monorepo-import-plan.md", @@ -63,11 +63,10 @@ function documentationFiles(): string[] { const found: string[] = []; const walk = (dir: string): void => { for (const entry of readdirSync(dir).sort()) { - if (SKIP_DIRECTORIES.includes(entry) || SKIP_FILES.includes(entry)) - continue; + if (SKIP_DIRECTORIES.has(entry) || SKIP_FILES.has(entry)) continue; const path = join(dir, entry); const rel = relPath(path); - if (INDEX_EXEMPT.includes(rel)) continue; + if (INDEX_EXEMPT.has(rel)) continue; if (statSync(path).isDirectory()) walk(path); else found.push(rel); } @@ -180,7 +179,7 @@ test("relative links in context documentation point at existing files", () => { for (const file of FILES) { if ( !file.endsWith(".md") || - LINK_CHECK_EXEMPT.includes(file) || + LINK_CHECK_EXEMPT.has(file) || LINK_CHECK_EXEMPT_PREFIXES.some((prefix) => file.startsWith(prefix)) ) continue; diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/fixtures/baseline-anthropic-stub.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/fixtures/baseline-anthropic-stub.ts index 160d8f8858d..cac95247f9e 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/fixtures/baseline-anthropic-stub.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/fixtures/baseline-anthropic-stub.ts @@ -1,6 +1,8 @@ import { appendFile } from "node:fs/promises"; -interface StubReply { +import type Anthropic from "@anthropic-ai/sdk"; + +export interface StubReply { text: string; truncated?: boolean; } @@ -13,23 +15,31 @@ let requestCount = 0; export default { messages: { - create: async (request: unknown) => { + create: async (request: Anthropic.MessageCreateParamsNonStreaming) => { if (requestsPath) { await appendFile(requestsPath, `${JSON.stringify(request)}\n`); } const reply = replies[requestCount++]; if (!reply) throw new Error(`unexpected model call ${requestCount}`); return { + id: `test-message-${requestCount}`, + type: "message", + role: "assistant", model: "test-model", - content: [{ type: "text" as const, text: reply.text }], + content: [{ type: "text", text: reply.text, citations: null }], stop_reason: reply.truncated ? "max_tokens" : "end_turn", + stop_sequence: null, usage: { + cache_creation: null, input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, + inference_geo: null, + server_tool_use: null, + service_tier: null, }, - }; + } satisfies Anthropic.Message; }, }, }; diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts index 3daa437caea..b4717bf4040 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts @@ -14,7 +14,7 @@ import { OPEN_GAPS } from "./open-gaps"; // than filed somewhere they would have to think to look. Silent when the ledger // is empty, because that is the goal state and not a warning. if (OPEN_GAPS.length > 0) { - console.warn( + process.stderr.write( [ "", `⚠ ${OPEN_GAPS.length} verification gaps are open (spec §14.5 and friends):`, diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/workspace.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/workspace.ts index 26d6acf3180..0a2952dd77c 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/workspace.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/workspace.ts @@ -118,7 +118,7 @@ export interface SourceFile { const SOURCE_EXTENSIONS = /\.(ts|tsx|mts|mjs|js|jsx)$/; /** Never scanned: not authored here, or build output. */ const SKIP_DIRECTORIES = ["node_modules", "dist", ".flue", ".git", ".turbo"]; -const TEST_DIRECTORIES = ["test", "tests", "__tests__"]; +const TEST_DIRECTORIES = new Set(["test", "tests", "__tests__"]); /** Every source file under a directory, recursively. A missing directory yields none. */ export function filesIn( @@ -163,7 +163,7 @@ function partitionedFiles(pkg: WorkspacePackage): { const test: SourceFile[] = []; for (const file of filesIn(pkg.path)) { const segments = relative(pkg.path, file.path).split(/[/\\]/); - (segments.some((segment) => TEST_DIRECTORIES.includes(segment)) + (segments.some((segment) => TEST_DIRECTORIES.has(segment)) ? test : source ).push(file); diff --git a/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts index fb59595d102..5d7dafea9be 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from "vitest"; import { + ABSENCE_STATES, applyCaptureStoreCommand as applyCaptureStoreCommandWithArchive, createEmptyCaptureStoreSnapshot, deriveCaptureStatus, @@ -213,15 +214,7 @@ describe("capture-store contract", () => { }); test("harness-invariant: 9 — all six absence values remain first-class capture content", () => { - const absences = [ - "unknown-to-user", - "not-yet-decided", - "not-applicable", - "explicitly-absent", - "declined", - "deferred", - ] as const; - const proposals: CaptureInputProposal[] = absences.map( + const proposals: CaptureInputProposal[] = ABSENCE_STATES.map( (absence, index) => ({ evidence: [userEvidence(absence, index + 1)], epistemicStatus: "inferred", @@ -236,7 +229,7 @@ describe("capture-store contract", () => { }); expect(result.snapshot.captures.map((capture) => capture.content)).toEqual( - absences.map((absence) => ({ absence })), + ABSENCE_STATES.map((absence) => ({ absence })), ); expect( result.snapshot.captures.every( @@ -682,6 +675,7 @@ describe("capture-store contract", () => { reason, refused: true, code: "invalid-envelope", + // oxlint-disable-next-line typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. message: expect.stringMatching(expectedMessage), }); } @@ -770,6 +764,7 @@ describe("capture-store contract", () => { reason, refused: true, code: "invalid-envelope", + // oxlint-disable-next-line typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. message: expect.stringMatching(/open conflict.*share/i), }); } @@ -1157,7 +1152,10 @@ describe("capture-store contract", () => { // Bent from a snapshot the store itself produced, so the reversed range is // the only thing wrong with what the parser is handed. - type EvidenceBearing = { evidence: { pointer: Record }[] }; + type Mutable = { -readonly [Key in keyof Value]: Value[Key] }; + type EvidenceBearing = { + evidence: Array<{ pointer: Mutable }>; + }; const withReversedRange = (family: "captures" | "events"): unknown => { const clone = structuredClone(retracted) as unknown as Record< string, diff --git a/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts index 049383c556a..2e0f008bbba 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts @@ -14,6 +14,7 @@ import { settlementProtocolInstructionFragments, sweepableRange, unsweptTail, + type SweepRefusalFact, type SweepSessionEntry, } from "../src/sweep-protocol"; @@ -45,7 +46,9 @@ describe("settlement and sweep protocol", () => { lastCheckedUserEntryId: null, }); expect(parseSweepState(initial)).toEqual(initial); - expect(() => parseSweepState({ ...initial, invented: true })).toThrow(); + expect(() => parseSweepState({ ...initial, invented: true })).toThrow( + Error, + ); }); test("computes the unswept range through the latest true-user entry only", () => { @@ -178,7 +181,7 @@ describe("settlement and sweep protocol", () => { const refusal = { code: "evidence-quote-not-found", message: "Use an exact quote.", - }; + } satisfies SweepRefusalFact; expect(buildSweepRepairSignal(refusal)).toEqual({ type: "sweep-repair", tagName: "sweep-repair", diff --git a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/.oxlintrc.json b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/.oxlintrc.json new file mode 100644 index 00000000000..52c387bca8c --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/.oxlintrc.json @@ -0,0 +1,60 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../../../../.config/oxlint/brunch/base.json"], + "categories": { + "correctness": "error", + "perf": "warn" + }, + "env": { + "builtin": true, + "es2026": true, + "node": true + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@hashintel/brunch-agent/storage", + "message": "Plugins receive harness capabilities and must remain storage-blind." + }, + { + "name": "@hashintel/petrinaut", + "message": "Brunch libraries must not depend on Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "Brunch libraries must remain independent of unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "Brunch libraries must not depend on Petrinaut implementations." + }, + { + "group": ["@flue/*", "@earendil-works/*"], + "message": "Brunch plugins must remain substrate-independent." + }, + { + "group": ["@hashintel/brunch-agent-*"], + "message": "A plugin may depend inward on the harness, not on Brunch extensions." + } + ] + } + ] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/package.json b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/package.json index 1808988f8d0..5fcb5411090 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/package.json +++ b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/package.json @@ -13,8 +13,8 @@ }, "scripts": { "build": "vite build", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", "test:unit": "vitest run" }, diff --git a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts index 2d517da3ca2..64d9b52375a 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts @@ -18,7 +18,7 @@ import { definePlugin } from "@hashintel/brunch-agent"; const nonEmptyString = v.pipe(v.string(), v.nonEmpty()); const evidenceQuote = v.strictObject({ excerpt: nonEmptyString }); -const StatementNotedProposal = v.pipe( +export const StatementNotedProposal = v.pipe( v.strictObject({ evidence: v.pipe(v.array(evidenceQuote), v.minLength(1)), epistemicStatus: v.literal("explicit"), @@ -40,6 +40,10 @@ const StatementNotedProposal = v.pipe( ), ); +export type StatementNotedProposalInput = v.InferInput< + typeof StatementNotedProposal +>; + export const gherkin = definePlugin({ name: "plugin-gherkin", targetDomain: "gherkin", diff --git a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/test/statement-noted.test.ts b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/test/statement-noted.test.ts index 04a01a4748c..548b646d061 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/test/statement-noted.test.ts +++ b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/test/statement-noted.test.ts @@ -61,7 +61,7 @@ describe("the Gherkin verbatim-grade proposal floor", () => { }, ], }), - ).toThrow(); + ).toThrow(v.ValiError); expect(() => v.parse(schema, { proposals: [ @@ -76,6 +76,6 @@ describe("the Gherkin verbatim-grade proposal floor", () => { }, ], }), - ).toThrow(); + ).toThrow(v.ValiError); }); }); diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/.oxlintrc.json b/libs/@hashintel/brunch-agent/packages/transport-aisdk/.oxlintrc.json new file mode 100644 index 00000000000..8cf02a4f042 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/.oxlintrc.json @@ -0,0 +1,56 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../../../../.config/oxlint/brunch/base.json"], + "categories": { + "correctness": "error", + "perf": "warn" + }, + "env": { + "builtin": true, + "es2026": true, + "node": true + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@hashintel/petrinaut", + "message": "Brunch libraries must not depend on Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "Brunch libraries must remain independent of unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "Brunch libraries must not depend on Petrinaut implementations." + }, + { + "group": ["@flue/*", "@earendil-works/*"], + "message": "Brunch transports must remain substrate-independent." + }, + { + "group": ["@hashintel/brunch-agent-*"], + "message": "A transport may depend inward on the harness, not on Brunch extensions." + } + ] + } + ] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json b/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json index 6095c8f6a15..1c0f37880ff 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json @@ -17,8 +17,8 @@ }, "scripts": { "build": "vite build", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", "test:unit": "vitest run" }, diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts index 503793f2bea..2fda5f55683 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts @@ -18,7 +18,7 @@ import { type HarnessReplyEvent, } from "@hashintel/brunch-agent"; -import { ASK_TOOL_NAME } from "./client-tools"; +import { ASK_TOOL_NAME, type BrunchAskOutput } from "./client-tools"; export { type AskReplyAdmission, @@ -46,14 +46,27 @@ export type HarnessTurnRunner = ( emit: (event: HarnessReplyEvent) => void, ) => Promise; +type HarnessPartEvent = Extract< + HarnessReplyEvent, + { type: "part-start" | "part-delta" | "part-end" } +>; +type HarnessToolEvent = Extract< + HarnessReplyEvent, + { type: "tool-input" | "tool-output" | "tool-output-error" } +>; +type HarnessResponseFinishEvent = Extract< + HarnessReplyEvent, + { type: "response-finish" } +>; +type AskReplyRefusal = Extract; + export interface HarnessAskReplyInput { readonly conversationId: string; /** Existing assistant UI message whose pending tool call this continues. */ readonly assistantMessageId: string; readonly idempotencyKey: string; - readonly ask: { + readonly ask: BrunchAskOutput & { readonly toolCallId: string; - readonly answer: string; }; } @@ -91,12 +104,7 @@ export type TransportInspectionEvent = | { readonly type: "part-emitted"; readonly requestId: string; - readonly kind: - | "text" - | "reasoning" - | "tool-input" - | "tool-output" - | "tool-output-error"; + readonly kind: HarnessPartEvent["kind"] | HarnessToolEvent["type"]; readonly partId?: string; readonly toolCallId?: string; } @@ -108,8 +116,8 @@ export type TransportInspectionEvent = | { readonly type: "request-finish"; readonly requestId: string; - readonly terminalState: "completed" | "failed" | "aborted"; - readonly finishReason: "stop" | "tool-calls" | "error"; + readonly terminalState: HarnessResponseFinishEvent["terminalState"]; + readonly finishReason: HarnessResponseFinishEvent["finishReason"]; } | { readonly type: "ask-await"; @@ -127,7 +135,7 @@ export type TransportInspectionEvent = readonly requestId: string; readonly conversationId: string; readonly toolCallId: string; - readonly reason: "no-pending-ask" | "different-ask-pending"; + readonly reason: AskReplyRefusal["reason"]; }; export interface AiSdkChatHandlerOptions { @@ -169,23 +177,6 @@ const panelPostBodySchema = v.looseObject({ type PanelMessage = v.InferOutput; type PanelPostBody = v.InferOutput; -type TransportRequestRefusal = - | { - readonly reason: "invalid-chat-request"; - readonly status: 400; - readonly error: "invalid_chat_request"; - } - | { - readonly reason: "tool-result-follow-up-not-supported"; - readonly status: 422; - readonly error: "tool_result_follow_up_not_supported"; - } - | { - readonly reason: "invalid-ask-submission"; - readonly status: 400; - readonly error: "invalid_ask_submission"; - }; - const transportRequestRefusals = { invalidChatRequest: { reason: "invalid-chat-request", @@ -202,7 +193,10 @@ const transportRequestRefusals = { status: 400, error: "invalid_ask_submission", }, -} as const satisfies Record; +} as const; + +type TransportRequestRefusal = + (typeof transportRequestRefusals)[keyof typeof transportRequestRefusals]; const askReplyRefusalErrors = { "no-pending-ask": "ask_not_pending", @@ -234,7 +228,6 @@ const userTextFrom = (message: PanelMessage): string | undefined => { .filter( (part): part is { readonly type: "text"; readonly text: string } => typeof part === "object" && - part !== null && "type" in part && part.type === "text" && "text" in part && diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts index a12578e635e..e66c0674b51 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts @@ -20,7 +20,7 @@ import { type TransportInspectionEvent, } from "../src/index"; -type StreamChunk = Record & { readonly type: string }; +import type { UIMessageChunk } from "ai"; const FIXTURES = join(import.meta.dirname, "fixtures"); @@ -30,12 +30,12 @@ test("keeps the client ask tool name aligned with the Brunch product name", () = const responseChunks = async ( response: Response, -): Promise => +): Promise => (await response.text()) .trim() .split("\n\n") .slice(0, -1) - .map((frame) => JSON.parse(frame.slice("data: ".length)) as StreamChunk); + .map((frame) => JSON.parse(frame.slice("data: ".length)) as UIMessageChunk); const post = (body: unknown): Request => new Request("http://brunch.test/api/petrinaut/chat", { @@ -265,7 +265,7 @@ describe("FE-1449 ask return POST", () => { expect({ reason, status: response.status, - body: await response.json(), + body: (await response.json()) as unknown, }).toEqual({ reason, status: 409, diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/golden.test.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/golden.test.ts index 1e1cc5fa867..6ea3afada8e 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/golden.test.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/golden.test.ts @@ -3,49 +3,54 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; -type MessagePart = { - readonly input?: unknown; - readonly output?: { readonly applied?: boolean }; - readonly providerExecuted?: boolean; - readonly state?: string; - readonly text?: string; - readonly toolCallId?: string; - readonly toolName?: string; - readonly type: string; -}; +import type { ChatTransport, UIMessage, UIMessageChunk } from "ai"; -type PanelMessage = { - readonly id: string; - readonly parts: MessagePart[]; - readonly role: string; -}; +type SendMessagesOptions = Parameters< + ChatTransport["sendMessages"] +>[0]; +type ToolMessagePart = Extract< + UIMessage["parts"][number], + { type: `tool-${string}` } +>; +type ToolInputChunk = Extract; type PanelPostBody = { - readonly id: string; - readonly messageId?: string; - readonly messages: PanelMessage[]; - readonly trigger: string; -}; - -type StreamChunk = MessagePart & { - readonly delta?: string; - readonly finishReason?: string; - readonly messageId?: string; + readonly id: SendMessagesOptions["chatId"]; + readonly messageId?: SendMessagesOptions["messageId"]; + readonly messages: SendMessagesOptions["messages"]; + readonly trigger: SendMessagesOptions["trigger"]; }; const FIXTURES = join(import.meta.dirname, "fixtures"); +const isToolMessagePart = ( + part: UIMessage["parts"][number], +): part is ToolMessagePart => part.type.startsWith("tool-"); + +const isClientToolOutput = ( + part: UIMessage["parts"][number], +): part is ToolMessagePart => + isToolMessagePart(part) && + (part.type === "tool-addPlace" || part.type === "tool-addTransition"); + +const appliedFrom = (part: ToolMessagePart): unknown => + typeof part.output === "object" && + part.output !== null && + "applied" in part.output + ? part.output.applied + : undefined; + const readPostBody = (name: string): PanelPostBody => JSON.parse(readFileSync(join(FIXTURES, name), "utf8")) as PanelPostBody; -const readSseChunks = (name: string): StreamChunk[] => { +const readSseChunks = (name: string): UIMessageChunk[] => { const frames = readFileSync(join(FIXTURES, name), "utf8") .trim() .split("\n\n"); expect(frames.at(-1)).toBe("data: [DONE]"); return frames.slice(0, -1).map((frame) => { expect(frame.startsWith("data: ")).toBe(true); - return JSON.parse(frame.slice("data: ".length)) as StreamChunk; + return JSON.parse(frame.slice("data: ".length)) as UIMessageChunk; }); }; @@ -66,19 +71,13 @@ describe("FE-1435 real-panel wire transcript", () => { expect(body.messages).toHaveLength(3); const assistant = body.messages[1]!; - const clientToolOutputs = assistant.parts.filter( - (part) => - part.type === "tool-addPlace" || part.type === "tool-addTransition", - ); + const clientToolOutputs = assistant.parts.filter(isClientToolOutput); expect(clientToolOutputs).toHaveLength(2); expect(clientToolOutputs.map((part) => part.state)).toEqual([ "output-available", "output-available", ]); - expect(clientToolOutputs.map((part) => part.output?.applied)).toEqual([ - true, - true, - ]); + expect(clientToolOutputs.map(appliedFrom)).toEqual([true, true]); const serverTool = assistant.parts.find( (part) => part.type === "tool-serverProbe", @@ -91,8 +90,11 @@ describe("FE-1435 real-panel wire transcript", () => { const diagnostics = body.messages[2]!; expect(diagnostics.id).toBe("petrinaut-diagnostics-context"); + const diagnosticsText = diagnostics.parts.find( + (part) => part.type === "text", + ); expect( - diagnostics.parts[0]?.text?.startsWith( + diagnosticsText?.text.startsWith( "Petrinaut diagnostics context only; this is not a user request.", ), ).toBe(true); @@ -113,7 +115,7 @@ describe("FE-1435 real-panel wire transcript", () => { expect( chunks .filter( - (chunk) => + (chunk): chunk is ToolInputChunk => chunk.type === "tool-input-available" && chunk.providerExecuted !== true, ) diff --git a/yarn.lock b/yarn.lock index ae29103804d..106f4dba50c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -447,6 +447,7 @@ __metadata: "@types/react": "npm:19.2.14" "@types/react-dom": "npm:19.2.3" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" + ai: "npm:6.0.182" hono: "npm:4.13.2" oxlint: "npm:1.63.0" oxlint-tsgolint: "npm:0.22.1"