From b99a7e595287a37e5ca78d4266ab89dfd7437ebc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 19:38:40 -0800 Subject: [PATCH 1/5] fix(artifacts): scope coordinator and writer write_artifact schemas to their kinds --- apps/hub/src/artifacts/tools.test.ts | 177 +++++++++++++++++++++++++++ apps/hub/src/artifacts/tools.ts | 54 +++++--- apps/hub/src/projects/errors.test.ts | 4 +- packages/schemas/src/artifact.ts | 22 +++- workflows/coordinator/package.json | 2 +- workflows/writer/package.json | 2 +- 6 files changed, 239 insertions(+), 22 deletions(-) create mode 100644 apps/hub/src/artifacts/tools.test.ts diff --git a/apps/hub/src/artifacts/tools.test.ts b/apps/hub/src/artifacts/tools.test.ts new file mode 100644 index 00000000..7ef6f5d2 --- /dev/null +++ b/apps/hub/src/artifacts/tools.test.ts @@ -0,0 +1,177 @@ +import { expect, test } from "bun:test"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows"; +import { createArtifactTools } from "./tools.ts"; + +const deployment = { + source: { + kind: "asset" as const, + assetId: "asset", + package: { format: "tarball" as const }, + }, + entry: "entry", + pin: "@corbits-builder/writer-native@1.0.0", + sourceOfferingIds: ["offering"], + defaultSourceOfferingId: "offering", +}; + +const sampleApproaches = { + approaches: [ + { + id: "simple", + name: "Simple", + summary: "Client-side forecast view.", + bestFor: "Fast launch", + pros: ["Fast to build"], + cons: ["No server cache"], + effort: "low" as const, + risks: ["Rate limits"], + }, + { + id: "cached", + name: "Cached", + summary: "Server-cached forecast view.", + bestFor: "Higher traffic", + pros: ["Fewer upstream calls"], + cons: ["More moving parts"], + effort: "medium" as const, + risks: ["Stale cache"], + }, + ], + recommended: "simple", +}; + +function harness(role: WorkflowRole) { + const written: unknown[] = []; + const fetch = async (path: string, init: RequestInit) => { + if (path === "/api/workflow-project-scope") + return Response.json({ + tenantId: "tenant", + projectId: "sb-project1", + domain: "example.invalid", + role, + workflows: { + writer: deployment, + reviewer: deployment, + coder: deployment, + designer: deployment, + }, + openNetwork: false, + build: { + assetDirectory: "/tmp", + browserCli: "/tmp/browser", + childPath: "/tmp/child.ts", + dependencyRoots: ["/tmp"], + ripgrepPath: "/tmp/rg", + runtimeVersion: "1.0.0", + }, + }); + if (path.endsWith("/artifacts") && init.method === "POST") { + const body = JSON.parse(String(init.body)) as { kind: string }; + written.push(body); + return Response.json({ + artifact: { + artifactId: "art-" + body.kind, + kind: body.kind, + version: written.length, + sha256: "a".repeat(64), + }, + }); + } + return Response.json({ code: "unexpected_route" }, { status: 404 }); + }; + const capabilities = { + resolve: () => ({ resolve: async () => ({ kind: "http", fetch }) }), + } as unknown as RuntimeCapabilities; + const instance = createArtifactTools(role)({ capabilities } as never); + return { + definitions: instance.definitions, + written, + write: (args: Record) => + instance.run( + { id: "call", name: "write_artifact", arguments: args }, + new AbortController().signal, + ), + }; +} + +test("coordinator write_artifact exposes only problem_brief without approaches", async () => { + const coordinator = harness("coordinator"); + const definition = coordinator.definitions.find( + (item) => item.name === "write_artifact", + ); + expect(definition).toBeDefined(); + expect(definition?.description).not.toContain("approaches"); + const properties = ( + definition?.inputSchema as + | { properties?: Record } + | undefined + )?.properties; + expect(properties?.["kind"]).toEqual({ const: "problem_brief" }); + expect(properties?.["approaches"]).toBeUndefined(); + + const withApproaches = await coordinator.write({ + kind: "problem_brief", + title: "Problem Brief", + content: "# Problem Brief\n\nWeather at a glance.", + approaches: sampleApproaches, + }); + expect(withApproaches.isError).toBe(true); + expect(withApproaches.content).toContain("approaches"); + + const wrongKind = await coordinator.write({ + kind: "constraints", + title: "Constraints", + content: "# Constraints", + }); + expect(wrongKind.isError).toBe(true); + expect(wrongKind.content).toBe( + "brief_only: you write the problem brief; deploy_writer has a writer write the constraints", + ); + + const valid = await coordinator.write({ + kind: "problem_brief", + title: "Problem Brief", + content: "# Problem Brief\n\nWeather at a glance.", + }); + expect(valid.isError).toBeUndefined(); + expect(coordinator.written).toHaveLength(1); +}); + +test("writer write_artifact exposes only writer kinds and validates approaches", async () => { + const writer = harness("writer"); + const definition = writer.definitions.find( + (item) => item.name === "write_artifact", + ); + const properties = ( + definition?.inputSchema as + | { + properties?: { kind?: { enum?: string[] }; approaches?: unknown }; + } + | undefined + )?.properties; + expect(properties?.kind?.enum).toEqual([ + "approaches", + "build_plan", + "constraints", + "product_requirements", + ]); + expect(properties?.approaches).toBeDefined(); + + const briefAttempt = await writer.write({ + kind: "problem_brief", + title: "Problem Brief", + content: "# Problem Brief", + }); + expect(briefAttempt.isError).toBe(true); + expect(briefAttempt.content).toStartWith("invalid_arguments:"); + + const validApproaches = await writer.write({ + kind: "approaches", + title: "Approaches", + content: "# Approaches", + approaches: sampleApproaches, + }); + expect(validApproaches.isError).toBeUndefined(); + expect(writer.written).toHaveLength(1); +}); diff --git a/apps/hub/src/artifacts/tools.ts b/apps/hub/src/artifacts/tools.ts index 11579584..a9046763 100644 --- a/apps/hub/src/artifacts/tools.ts +++ b/apps/hub/src/artifacts/tools.ts @@ -8,10 +8,11 @@ import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; import { ArtifactContent, - DocumentWrite, + BriefWrite, ArtifactList, ArtifactRead, ArtifactReceipt, + WriterDocumentWrite, } from "@corbits-builder/schemas/artifact"; import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows"; import { unpackDesign } from "./design-tools.ts"; @@ -45,11 +46,17 @@ const readDefinition = { "See which project documents exist, or read one.\nWith no arguments it lists: each document's artifactId, kind, title, current version, sha256, approvedVersion, the version a person approved, and openQuestionCount, the questions its current version leaves open for the person; inFlight, the writer, reviewer, coder and designer runs still open, each with its status, deployed until its first assignment arrives and running after, and each working (mid-turn) or waiting (parked for your next mail), with progress once it has acted: its turns so far, its latest tool calls as tool and target, the files it has written, and lastActivityAt; failed, the latest runs that stopped on an error, with when and why, which deliver nothing more; awaitingApproval, the kinds of filed documents whose current version waits on the person's approval; and preferenceGuidance, how the person has said they want to work.\nWith a kind or an artifactId it returns that document's content at its current version, or at the given version, with the openQuestions that version leaves for the person; a long document comes in numbered parts, and part reads the next one.", inputSchema: { ...ToolRead.toJsonSchema() }, }; -const writeDefinition = { +const briefWriteDefinition = { + name: "write_artifact", + description: + "Write the next version of the problem brief. The hub assigns its id, version and sha256 and returns them. Writing a version is not approval.\nopenQuestions lists each question in the document's Open questions section that the person must answer: a short id, the question as they would read it, whyItBlocks, and suggestedAnswers, your recommendation first. A version written without openQuestions leaves none open.", + inputSchema: { ...BriefWrite.toJsonSchema() }, +}; +const writerWriteDefinition = { name: "write_artifact", description: "Write the next version of the project document of one kind. The hub assigns its id, version and sha256 and returns them. Writing a version is not approval.\nopenQuestions lists each question in the document's Open questions section that the person must answer: a short id, the question as they would read it, whyItBlocks, and suggestedAnswers, your recommendation first. A version written without openQuestions leaves none open.\nOn the approaches document, approaches carries the approaches it compares, for the person to see side by side: each with a short id, its name, a one-line summary, bestFor, its pros, cons and risks as short phrases, and its effort; recommended names the id of the one you recommend.", - inputSchema: { ...DocumentWrite.toJsonSchema() }, + inputSchema: { ...WriterDocumentWrite.toJsonSchema() }, }; export function failure(callId: string, error: unknown) { @@ -67,17 +74,23 @@ export function failure(callId: string, error: unknown) { return projectScopeFailure(callId, error); } +const DEFINITIONS_BY_ROLE: Record< + WorkflowRole, + readonly (typeof readDefinition)[] +> = { + coordinator: [readDefinition, briefWriteDefinition], + writer: [writerWriteDefinition], + reviewer: [readDefinition], + coder: [readDefinition], + designer: [readDefinition], +}; + export function createArtifactTools( role: WorkflowRole, ): AnnotatedToolFactory { // A writer's or reviewer's task carries every document it needs in full; a read tool sends // writers looking for documents that do not exist. - const definitions = - role === "coordinator" - ? [readDefinition, writeDefinition] - : role === "writer" - ? [writeDefinition] - : [readDefinition]; + const definitions = DEFINITIONS_BY_ROLE[role]; return defineTool({ id: "@corbits-builder/artifacts/tools", credentials: [{ handle: HUB_CREDENTIAL_HANDLE }], @@ -120,19 +133,28 @@ export function createArtifactTools( signal, }); if (call.name === "write_artifact") { - const input = DocumentWrite(call.arguments); - if (input instanceof type.errors) + // The coordinator's writers own every kind but the brief, so the documents the person + // decides on come from them. + const rawKind = (call.arguments as { kind?: unknown } | undefined) + ?.kind; + if ( + role === "coordinator" && + typeof rawKind === "string" && + rawKind !== "problem_brief" + ) return { callId: call.id, - content: "invalid_arguments: " + input.summary, + content: `brief_only: you write the problem brief; deploy_writer has a writer write the ${rawKind}`, isError: true, }; - // The coordinator's writers own every kind but the brief, so the documents the person - // decides on come from them. - if (role === "coordinator" && input.kind !== "problem_brief") + const input = + role === "coordinator" + ? BriefWrite(call.arguments) + : WriterDocumentWrite(call.arguments); + if (input instanceof type.errors) return { callId: call.id, - content: `brief_only: you write the problem brief; deploy_writer has a writer write the ${input.kind}`, + content: "invalid_arguments: " + input.summary, isError: true, }; const body = JSON.stringify(input); diff --git a/apps/hub/src/projects/errors.test.ts b/apps/hub/src/projects/errors.test.ts index 0d9ba326..688a0fd5 100644 --- a/apps/hub/src/projects/errors.test.ts +++ b/apps/hub/src/projects/errors.test.ts @@ -105,7 +105,7 @@ test("a rejected write_artifact call reaches the agent with {path, message} issu expect( await write({ - kind: "problem_brief", + kind: "constraints", approaches: { approaches: [approach("a"), approach("b")] }, }), ).toEqual({ @@ -115,7 +115,7 @@ test("a rejected write_artifact call reaches the agent with {path, message} issu { path: "approaches", message: - "goes on the approaches document; write this problem_brief without it", + "goes on the approaches document; write this constraints without it", }, ], }); diff --git a/packages/schemas/src/artifact.ts b/packages/schemas/src/artifact.ts index 337bda9d..f7fbd55b 100644 --- a/packages/schemas/src/artifact.ts +++ b/packages/schemas/src/artifact.ts @@ -10,9 +10,10 @@ const sha256 = /^[a-f0-9]{64}$/; // Documents a model writes as text. A design concept's option is filed from the designer's // workspace by file_design_option, the design by the person's choice among the options, and the // app from the coder's workspace each time check-app passes. -const DocumentKind = type( - "'problem_brief' | 'constraints' | 'approaches' | 'product_requirements' | 'build_plan'", +const WriterDocumentKind = type( + "'constraints' | 'approaches' | 'product_requirements' | 'build_plan'", ); +const DocumentKind = WriterDocumentKind.or("'problem_brief'"); const WritableArtifactKind = DocumentKind.or( "'design' | 'design_option' | 'app'", ); @@ -32,6 +33,23 @@ export const ArtifactKindWrite = type({ "approaches?": ApproachesMeta, }); export type ArtifactKindWrite = typeof ArtifactKindWrite.infer; +export const BriefWrite = type({ + "+": "reject", + kind: "'problem_brief'", + title: text, + content, + "openQuestions?": OpenQuestions, +}); +export type BriefWrite = typeof BriefWrite.infer; +export const WriterDocumentWrite = type({ + "+": "reject", + kind: WriterDocumentKind, + title: text, + content, + "openQuestions?": OpenQuestions, + "approaches?": ApproachesMeta, +}); +export type WriterDocumentWrite = typeof WriterDocumentWrite.infer; export const DocumentWrite = type({ "+": "reject", kind: DocumentKind, diff --git a/workflows/coordinator/package.json b/workflows/coordinator/package.json index f0225362..09faaa98 100644 --- a/workflows/coordinator/package.json +++ b/workflows/coordinator/package.json @@ -1,6 +1,6 @@ { "name": "@corbits-builder/coordinator", - "version": "0.35.0", + "version": "0.35.1", "private": true, "license": "SEE LICENSE IN ../../LICENSE.md", "author": "Sawyer Cutler ", diff --git a/workflows/writer/package.json b/workflows/writer/package.json index ccc576c3..ad940587 100644 --- a/workflows/writer/package.json +++ b/workflows/writer/package.json @@ -1,6 +1,6 @@ { "name": "@corbits-builder/writer", - "version": "0.7.0", + "version": "0.7.1", "private": true, "license": "SEE LICENSE IN ../../LICENSE.md", "author": "Sawyer Cutler ", From b15172b0f5d2870c94bdb25c700a2921f7ea41e8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 20:16:11 -0800 Subject: [PATCH 2/5] fix(artifacts): omit approaches unless the writer is filing approaches WriterDocumentWrite is a union so approaches is a field only when kind is approaches. The writer tool rejects the key on the other kinds before POST. --- apps/hub/src/artifacts/tools.test.ts | 54 ++++++++++++++++++++++------ apps/hub/src/projects/errors.test.ts | 16 --------- packages/schemas/src/artifact.ts | 8 ++++- 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/apps/hub/src/artifacts/tools.test.ts b/apps/hub/src/artifacts/tools.test.ts index 7ef6f5d2..b7a3a8e2 100644 --- a/apps/hub/src/artifacts/tools.test.ts +++ b/apps/hub/src/artifacts/tools.test.ts @@ -143,20 +143,32 @@ test("writer write_artifact exposes only writer kinds and validates approaches", const definition = writer.definitions.find( (item) => item.name === "write_artifact", ); - const properties = ( + const branches = ( definition?.inputSchema as | { - properties?: { kind?: { enum?: string[] }; approaches?: unknown }; + anyOf?: { + additionalProperties?: boolean; + properties?: { + kind?: { const?: string; enum?: string[] }; + approaches?: unknown; + }; + }[]; } | undefined - )?.properties; - expect(properties?.kind?.enum).toEqual([ - "approaches", - "build_plan", - "constraints", - "product_requirements", - ]); - expect(properties?.approaches).toBeDefined(); + )?.anyOf; + const branchFor = (kind: string) => + branches?.find( + (branch) => + branch.properties?.kind?.const === kind || + branch.properties?.kind?.enum?.includes(kind), + ); + expect(branchFor("approaches")?.properties?.approaches).toBeDefined(); + for (const kind of ["constraints", "product_requirements", "build_plan"]) { + const branch = branchFor(kind); + expect(branch?.additionalProperties).toBe(false); + expect(branch?.properties?.approaches).toBeUndefined(); + } + expect(branchFor("problem_brief")).toBeUndefined(); const briefAttempt = await writer.write({ kind: "problem_brief", @@ -166,6 +178,19 @@ test("writer write_artifact exposes only writer kinds and validates approaches", expect(briefAttempt.isError).toBe(true); expect(briefAttempt.content).toStartWith("invalid_arguments:"); + for (const kind of ["constraints", "product_requirements", "build_plan"]) { + const rejected = await writer.write({ + kind, + title: "Doc", + content: "# Doc", + approaches: sampleApproaches, + }); + expect(rejected.isError).toBe(true); + expect(rejected.content).toStartWith("invalid_arguments:"); + expect(rejected.content).toContain("approaches"); + } + expect(writer.written).toHaveLength(0); + const validApproaches = await writer.write({ kind: "approaches", title: "Approaches", @@ -173,5 +198,12 @@ test("writer write_artifact exposes only writer kinds and validates approaches", approaches: sampleApproaches, }); expect(validApproaches.isError).toBeUndefined(); - expect(writer.written).toHaveLength(1); + expect(writer.written).toEqual([ + { + kind: "approaches", + title: "Approaches", + content: "# Approaches", + approaches: sampleApproaches, + }, + ]); }); diff --git a/apps/hub/src/projects/errors.test.ts b/apps/hub/src/projects/errors.test.ts index 688a0fd5..d6f7119c 100644 --- a/apps/hub/src/projects/errors.test.ts +++ b/apps/hub/src/projects/errors.test.ts @@ -103,22 +103,6 @@ test("a rejected write_artifact call reaches the agent with {path, message} issu return JSON.parse(String(result.content)); }; - expect( - await write({ - kind: "constraints", - approaches: { approaches: [approach("a"), approach("b")] }, - }), - ).toEqual({ - code: "invalid_artifact_write", - status: 400, - issues: [ - { - path: "approaches", - message: - "goes on the approaches document; write this constraints without it", - }, - ], - }); expect( await write({ kind: "approaches", diff --git a/packages/schemas/src/artifact.ts b/packages/schemas/src/artifact.ts index f7fbd55b..7652ab2c 100644 --- a/packages/schemas/src/artifact.ts +++ b/packages/schemas/src/artifact.ts @@ -43,11 +43,17 @@ export const BriefWrite = type({ export type BriefWrite = typeof BriefWrite.infer; export const WriterDocumentWrite = type({ "+": "reject", - kind: WriterDocumentKind, + kind: "'approaches'", title: text, content, "openQuestions?": OpenQuestions, "approaches?": ApproachesMeta, +}).or({ + "+": "reject", + kind: "'constraints' | 'product_requirements' | 'build_plan'", + title: text, + content, + "openQuestions?": OpenQuestions, }); export type WriterDocumentWrite = typeof WriterDocumentWrite.infer; export const DocumentWrite = type({ From 409acd5bad91ac9c0efb3511651b3113b186c472 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 20:17:44 -0800 Subject: [PATCH 3/5] fix(artifacts): give each writer kind its own write_artifact arm constraints, product_requirements, and build_plan each accept only kind, title, content, and openQuestions. approaches is the only arm that also accepts approaches. --- apps/hub/src/artifacts/tools.test.ts | 26 +++++++++++++++------- packages/schemas/src/artifact.ts | 33 ++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/apps/hub/src/artifacts/tools.test.ts b/apps/hub/src/artifacts/tools.test.ts index b7a3a8e2..fb796c21 100644 --- a/apps/hub/src/artifacts/tools.test.ts +++ b/apps/hub/src/artifacts/tools.test.ts @@ -149,7 +149,7 @@ test("writer write_artifact exposes only writer kinds and validates approaches", anyOf?: { additionalProperties?: boolean; properties?: { - kind?: { const?: string; enum?: string[] }; + kind?: { const?: string }; approaches?: unknown; }; }[]; @@ -157,16 +157,26 @@ test("writer write_artifact exposes only writer kinds and validates approaches", | undefined )?.anyOf; const branchFor = (kind: string) => - branches?.find( - (branch) => - branch.properties?.kind?.const === kind || - branch.properties?.kind?.enum?.includes(kind), - ); - expect(branchFor("approaches")?.properties?.approaches).toBeDefined(); + branches?.find((branch) => branch.properties?.kind?.const === kind); + expect(branches).toHaveLength(4); + const approaches = branchFor("approaches"); + expect(approaches?.additionalProperties).toBe(false); + expect(Object.keys(approaches?.properties ?? {}).sort()).toEqual([ + "approaches", + "content", + "kind", + "openQuestions", + "title", + ]); for (const kind of ["constraints", "product_requirements", "build_plan"]) { const branch = branchFor(kind); expect(branch?.additionalProperties).toBe(false); - expect(branch?.properties?.approaches).toBeUndefined(); + expect(Object.keys(branch?.properties ?? {}).sort()).toEqual([ + "content", + "kind", + "openQuestions", + "title", + ]); } expect(branchFor("problem_brief")).toBeUndefined(); diff --git a/packages/schemas/src/artifact.ts b/packages/schemas/src/artifact.ts index 7652ab2c..9c9f96d0 100644 --- a/packages/schemas/src/artifact.ts +++ b/packages/schemas/src/artifact.ts @@ -43,18 +43,33 @@ export const BriefWrite = type({ export type BriefWrite = typeof BriefWrite.infer; export const WriterDocumentWrite = type({ "+": "reject", - kind: "'approaches'", + kind: "'constraints'", title: text, content, "openQuestions?": OpenQuestions, - "approaches?": ApproachesMeta, -}).or({ - "+": "reject", - kind: "'constraints' | 'product_requirements' | 'build_plan'", - title: text, - content, - "openQuestions?": OpenQuestions, -}); +}) + .or({ + "+": "reject", + kind: "'product_requirements'", + title: text, + content, + "openQuestions?": OpenQuestions, + }) + .or({ + "+": "reject", + kind: "'build_plan'", + title: text, + content, + "openQuestions?": OpenQuestions, + }) + .or({ + "+": "reject", + kind: "'approaches'", + title: text, + content, + "openQuestions?": OpenQuestions, + "approaches?": ApproachesMeta, + }); export type WriterDocumentWrite = typeof WriterDocumentWrite.infer; export const DocumentWrite = type({ "+": "reject", From 5b4c80bcaf9c40ccff5567e680392b2a31675fac Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 21:50:00 -0800 Subject: [PATCH 4/5] fix(artifacts): keep writer write_artifact inputSchema top-level type object --- apps/hub/src/artifacts/tools.test.ts | 69 ++++++++++++++++------------ apps/hub/src/artifacts/tools.ts | 6 ++- packages/schemas/src/artifact.ts | 32 ++++--------- 3 files changed, 52 insertions(+), 55 deletions(-) diff --git a/apps/hub/src/artifacts/tools.test.ts b/apps/hub/src/artifacts/tools.test.ts index fb796c21..c2789109 100644 --- a/apps/hub/src/artifacts/tools.test.ts +++ b/apps/hub/src/artifacts/tools.test.ts @@ -138,47 +138,56 @@ test("coordinator write_artifact exposes only problem_brief without approaches", expect(coordinator.written).toHaveLength(1); }); +test("every role's artifact tool definitions declare top-level type: object", () => { + const roles: WorkflowRole[] = [ + "coordinator", + "writer", + "reviewer", + "coder", + "designer", + ]; + for (const role of roles) { + for (const definition of harness(role).definitions) { + expect( + (definition.inputSchema as { type?: unknown } | undefined)?.type, + ).toBe("object"); + } + } +}); + test("writer write_artifact exposes only writer kinds and validates approaches", async () => { const writer = harness("writer"); const definition = writer.definitions.find( (item) => item.name === "write_artifact", ); - const branches = ( - definition?.inputSchema as - | { - anyOf?: { - additionalProperties?: boolean; - properties?: { - kind?: { const?: string }; - approaches?: unknown; - }; - }[]; - } - | undefined - )?.anyOf; - const branchFor = (kind: string) => - branches?.find((branch) => branch.properties?.kind?.const === kind); - expect(branches).toHaveLength(4); - const approaches = branchFor("approaches"); - expect(approaches?.additionalProperties).toBe(false); - expect(Object.keys(approaches?.properties ?? {}).sort()).toEqual([ + const schema = definition?.inputSchema as + | { + type?: unknown; + additionalProperties?: boolean; + properties?: { + kind?: { enum?: string[] }; + approaches?: unknown; + content?: unknown; + openQuestions?: unknown; + title?: unknown; + }; + } + | undefined; + expect(schema?.type).toBe("object"); + expect(schema?.additionalProperties).toBe(false); + expect(schema?.properties?.kind?.enum).toEqual([ + "approaches", + "build_plan", + "constraints", + "product_requirements", + ]); + expect(Object.keys(schema?.properties ?? {}).sort()).toEqual([ "approaches", "content", "kind", "openQuestions", "title", ]); - for (const kind of ["constraints", "product_requirements", "build_plan"]) { - const branch = branchFor(kind); - expect(branch?.additionalProperties).toBe(false); - expect(Object.keys(branch?.properties ?? {}).sort()).toEqual([ - "content", - "kind", - "openQuestions", - "title", - ]); - } - expect(branchFor("problem_brief")).toBeUndefined(); const briefAttempt = await writer.write({ kind: "problem_brief", diff --git a/apps/hub/src/artifacts/tools.ts b/apps/hub/src/artifacts/tools.ts index a9046763..c305b9a7 100644 --- a/apps/hub/src/artifacts/tools.ts +++ b/apps/hub/src/artifacts/tools.ts @@ -56,7 +56,11 @@ const writerWriteDefinition = { name: "write_artifact", description: "Write the next version of the project document of one kind. The hub assigns its id, version and sha256 and returns them. Writing a version is not approval.\nopenQuestions lists each question in the document's Open questions section that the person must answer: a short id, the question as they would read it, whyItBlocks, and suggestedAnswers, your recommendation first. A version written without openQuestions leaves none open.\nOn the approaches document, approaches carries the approaches it compares, for the person to see side by side: each with a short id, its name, a one-line summary, bestFor, its pros, cons and risks as short phrases, and its effort; recommended names the id of the one you recommend.", - inputSchema: { ...WriterDocumentWrite.toJsonSchema() }, + inputSchema: { + ...WriterDocumentWrite.toJsonSchema({ + fallback: { predicate: (ctx) => ctx.base }, + }), + }, }; export function failure(callId: string, error: unknown) { diff --git a/packages/schemas/src/artifact.ts b/packages/schemas/src/artifact.ts index 9c9f96d0..31efc788 100644 --- a/packages/schemas/src/artifact.ts +++ b/packages/schemas/src/artifact.ts @@ -43,33 +43,17 @@ export const BriefWrite = type({ export type BriefWrite = typeof BriefWrite.infer; export const WriterDocumentWrite = type({ "+": "reject", - kind: "'constraints'", + kind: WriterDocumentKind, title: text, content, "openQuestions?": OpenQuestions, -}) - .or({ - "+": "reject", - kind: "'product_requirements'", - title: text, - content, - "openQuestions?": OpenQuestions, - }) - .or({ - "+": "reject", - kind: "'build_plan'", - title: text, - content, - "openQuestions?": OpenQuestions, - }) - .or({ - "+": "reject", - kind: "'approaches'", - title: text, - content, - "openQuestions?": OpenQuestions, - "approaches?": ApproachesMeta, - }); + "approaches?": ApproachesMeta, +}).narrow( + (doc, ctx) => + doc.kind === "approaches" || + doc.approaches === undefined || + ctx.mustBe("without approaches unless kind is approaches"), +); export type WriterDocumentWrite = typeof WriterDocumentWrite.infer; export const DocumentWrite = type({ "+": "reject", From 5a035ec5c25a0142c6535caf810caf0ce07ed813 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 23:07:00 -0800 Subject: [PATCH 5/5] fix(artifacts): keep top-level type object on per-kind writer write_artifact union --- apps/hub/src/artifacts/tools.test.ts | 55 +++++++++++++++++----------- apps/hub/src/artifacts/tools.ts | 5 +-- packages/schemas/src/artifact.ts | 32 ++++++++++++---- 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/apps/hub/src/artifacts/tools.test.ts b/apps/hub/src/artifacts/tools.test.ts index c2789109..36c752e3 100644 --- a/apps/hub/src/artifacts/tools.test.ts +++ b/apps/hub/src/artifacts/tools.test.ts @@ -160,34 +160,45 @@ test("writer write_artifact exposes only writer kinds and validates approaches", const definition = writer.definitions.find( (item) => item.name === "write_artifact", ); - const schema = definition?.inputSchema as - | { - type?: unknown; - additionalProperties?: boolean; - properties?: { - kind?: { enum?: string[] }; - approaches?: unknown; - content?: unknown; - openQuestions?: unknown; - title?: unknown; - }; - } - | undefined; - expect(schema?.type).toBe("object"); - expect(schema?.additionalProperties).toBe(false); - expect(schema?.properties?.kind?.enum).toEqual([ - "approaches", - "build_plan", - "constraints", - "product_requirements", - ]); - expect(Object.keys(schema?.properties ?? {}).sort()).toEqual([ + expect( + (definition?.inputSchema as { type?: unknown } | undefined)?.type, + ).toBe("object"); + const branches = ( + definition?.inputSchema as + | { + anyOf?: { + additionalProperties?: boolean; + properties?: { + kind?: { const?: string }; + approaches?: unknown; + }; + }[]; + } + | undefined + )?.anyOf; + const branchFor = (kind: string) => + branches?.find((branch) => branch.properties?.kind?.const === kind); + expect(branches).toHaveLength(4); + const approaches = branchFor("approaches"); + expect(approaches?.additionalProperties).toBe(false); + expect(Object.keys(approaches?.properties ?? {}).sort()).toEqual([ "approaches", "content", "kind", "openQuestions", "title", ]); + for (const kind of ["constraints", "product_requirements", "build_plan"]) { + const branch = branchFor(kind); + expect(branch?.additionalProperties).toBe(false); + expect(Object.keys(branch?.properties ?? {}).sort()).toEqual([ + "content", + "kind", + "openQuestions", + "title", + ]); + } + expect(branchFor("problem_brief")).toBeUndefined(); const briefAttempt = await writer.write({ kind: "problem_brief", diff --git a/apps/hub/src/artifacts/tools.ts b/apps/hub/src/artifacts/tools.ts index c305b9a7..28d5dd46 100644 --- a/apps/hub/src/artifacts/tools.ts +++ b/apps/hub/src/artifacts/tools.ts @@ -57,9 +57,8 @@ const writerWriteDefinition = { description: "Write the next version of the project document of one kind. The hub assigns its id, version and sha256 and returns them. Writing a version is not approval.\nopenQuestions lists each question in the document's Open questions section that the person must answer: a short id, the question as they would read it, whyItBlocks, and suggestedAnswers, your recommendation first. A version written without openQuestions leaves none open.\nOn the approaches document, approaches carries the approaches it compares, for the person to see side by side: each with a short id, its name, a one-line summary, bestFor, its pros, cons and risks as short phrases, and its effort; recommended names the id of the one you recommend.", inputSchema: { - ...WriterDocumentWrite.toJsonSchema({ - fallback: { predicate: (ctx) => ctx.base }, - }), + type: "object" as const, + ...WriterDocumentWrite.toJsonSchema(), }, }; diff --git a/packages/schemas/src/artifact.ts b/packages/schemas/src/artifact.ts index 31efc788..9c9f96d0 100644 --- a/packages/schemas/src/artifact.ts +++ b/packages/schemas/src/artifact.ts @@ -43,17 +43,33 @@ export const BriefWrite = type({ export type BriefWrite = typeof BriefWrite.infer; export const WriterDocumentWrite = type({ "+": "reject", - kind: WriterDocumentKind, + kind: "'constraints'", title: text, content, "openQuestions?": OpenQuestions, - "approaches?": ApproachesMeta, -}).narrow( - (doc, ctx) => - doc.kind === "approaches" || - doc.approaches === undefined || - ctx.mustBe("without approaches unless kind is approaches"), -); +}) + .or({ + "+": "reject", + kind: "'product_requirements'", + title: text, + content, + "openQuestions?": OpenQuestions, + }) + .or({ + "+": "reject", + kind: "'build_plan'", + title: text, + content, + "openQuestions?": OpenQuestions, + }) + .or({ + "+": "reject", + kind: "'approaches'", + title: text, + content, + "openQuestions?": OpenQuestions, + "approaches?": ApproachesMeta, + }); export type WriterDocumentWrite = typeof WriterDocumentWrite.infer; export const DocumentWrite = type({ "+": "reject",